Skip to content

Mouse input

Mouse support is opt-in and split cleanly between you and the library, the same way keyboard is. You turn mouse reporting on and feed raw events in; the library routes each event to the component under the pointer and turns it into a message — buttons press, lists select and scroll, inputs place their cursor, things hover, things drag. Where keyboard routes by the focus path, the mouse routes by geometry (which component's rect the event landed in).

Nothing here takes over your loop. The library still enters at exactly two points — render and handle_event — and everything below is ordinary app code around them.

Turning it on — and the cleanup that's easy to miss

A terminal does not report mouse events until you ask it to. In a crossterm app that is EnableMouseCapture:

rust
use ratatui::crossterm::{event::EnableMouseCapture, execute};

execute!(io::stdout(), EnableMouseCapture)?;

Here is the catch, and it bites: EnableMouseCapture is not local to your process. It writes escape sequences that switch the terminal emulator into mouse-reporting mode. If your program exits without sending the matching DisableMouseCapture — an error propagated with ?, a panic, an early return — that mode is left on in the terminal you launched from. The shell, and especially tmux, then receive encoded mouse events instead of clicks, so selecting and resizing panes silently stops working until a manual reset.

The fragile shape is the obvious one:

rust
execute!(io::stdout(), EnableMouseCapture)?;
loop {
    terminal.draw(|f| app.draw(f))?;   // any `?` here…
    // …
}
execute!(io::stdout(), DisableMouseCapture)?;   // …skips this

The fix is a small RAII guard: enable in the constructor, disable in Drop. Drop runs on every exit path — normal, ?, and panic unwinding — so the mode can't leak:

rust
use std::io;
use ratatui::crossterm::{
    event::{DisableMouseCapture, EnableMouseCapture},
    execute,
};

/// Enables mouse reporting for its lifetime; disables it on drop.
struct MouseCapture;

impl MouseCapture {
    fn enable() -> io::Result<Self> {
        execute!(io::stdout(), EnableMouseCapture)?;
        Ok(Self)
    }
}

impl Drop for MouseCapture {
    fn drop(&mut self) {
        // Ignore errors — there is nothing useful to do while tearing down.
        let _ = execute!(io::stdout(), DisableMouseCapture);
    }
}

Hold it for the lifetime of your loop:

rust
ratatui::run(|terminal| {
    let _mouse = MouseCapture::enable()?;   // restored when this scope ends
    loop {
        terminal.draw(|f| app.draw(f))?;
        // read and dispatch events…
    }
})

Bind it to a name

let _mouse = … keeps the guard alive for the scope. let _ = … drops it immediately, disabling mouse capture on the next line — a quiet way to get no mouse at all. The same pattern works for EnableBracketedPaste and for raw mode + the alternate screen; one guard each, or one guard that restores all of them.

This guard is deliberately not in the library. ratcn is backend-agnostic and never owns your loop or the terminal's lifecycle, so enabling and restoring terminal modes stays in app code — and it is short enough to own. (If you recover a wedged terminal, printf '\033[?1000l\033[?1002l\033[?1003l\033[?1006l\033[?1015l\033[?2004l' or reset clears the leaked modes.)

Synthesizing clicks and drags

Backends deliver Down, Up, and Moved. A click ("a press and release on the same spot") and a drag ("a press, then a move with the button held") are higher-level — and synthesized for you. The view tracks the press internally, so you feed the raw mouse events straight to handle_event, the same call you already use for keys — there is no separate tracker to own:

rust
// In your event loop — hand the backend event straight to the view, no
// conversion step (an unsupported event maps to `Ignored`):
let event = ratatui::crossterm::event::read()?;
if let view::EventResult::Emit(msg) = root.handle_event(event, &state) {
    update(&mut state, msg);
}

handle_event synthesizes Click/Drag/DragEnd from the raw Down/Up/Moved before routing: a press-then-release on one cell reaches a component as a Click, a held-button move as a Drag, and the release that ends a drag as a DragEnd (a dragged press never also clicks). One raw event still produces at most one message — the same one-event-one-message flow as keyboard.

What components do with it

The normalized MouseKind is Down, Up, Click, Drag, DragEnd, Moved, and Scroll. Components opt into the ones they need:

  • Click activates — a button presses, a list row is chosen, an input places its cursor at the clicked cell. A click also moves focus, so the component is keyboard-ready immediately after.
  • Hover (Moved) is its own state, separate from focus. Wired with View::hover — the twin of View::focus — it lets a component highlight under the pointer without stealing focus, so typing in a focused input keeps working while the mouse drifts over a button.
  • Focus-follows-mouse is the opt-in exception. By default hover never moves focus; calling View::hover_focus() on a scope reverses that for its children, so the pointer moving onto a child focuses it — web-style "hover to focus" for a grid of panes the pointer sweeps between. The first move onto an unfocused child focuses it; once focused, further motion descends to the child (so a focused list then tracks the hovered row). It is per scope, off unless asked for, so the hover ≠ focus rule still holds everywhere it is not set.
  • Scroll moves a stored, app-owned offset (lists, text areas) — the wheel scrolls the view, independent of the cursor.
  • Drag moves an app-owned position, and DragEnd commits it — the release carries the cell it landed on, so a target can hit-test the drop. See Dragging for the full pattern; it is the same three pieces (an offset in state, the Drag primitive, three mouse arms) any component can adopt.

You enable only what you wire: a keyboard-only app links none of this and pays nothing.

In the browser

The browser backend (ratzilla) delivers mouse events too. Recent ratzilla reports them already in terminal cell coordinates (col/row) — the same space as crossterm — so no pixel conversion is needed. The demos' shared helper just maps ratzilla's raw button stream (ButtonDown/ButtonUp/Moved) onto the view layer's MouseEvent, and the view synthesizes Click/Drag from it exactly as on the terminal. (ratzilla also emits its own SingleClick/ DoubleClick; the demos ignore those to keep one code path across both backends.)