Skip to content

Mouse input ​

Mouse support is opt-in and split 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, tabs switch, things hover, and things drag. Where keyboard routes by the focus path, the mouse routes through the geometry from the last successful Ratcn::render.

See Rendering and event routing for the shared retained surface and bubbling contract, and Host integration for loop ownership.

Turning it on ​

A terminal reports mouse events once the host asks it to. A host on ratcn's terminal session opens it with SessionOptions::new().mouse(). With the crossterm feature, InputModes is the RAII helper that asks:

rust
let _input_modes = ratcn::crossterm::InputModes::new()
    .mouse()
    .enable()?;

The host decides when to enable the mode and keeps the guard for the event loop's lifetime. Dropping the last guard that requested a mode restores it, including on ? or panic unwinding. A host with its own terminal lifecycle abstraction can drive the same modes through that.

Bind it to a name

let _input_modes = … keeps the guard alive for the scope, and mouse capture stays on for as long as it lives. The same retention rule applies to bracketed paste and host-owned raw mode or alternate-screen guards.

Synthesizing clicks and drags ​

Backends deliver Down, Up, and Moved (crossterm may also deliver Drag). A click (a press and release on the same component) and a drag (a press, then a move with the button held) are higher-level, and the runtime synthesizes them for you. Ratcn tracks each button's gesture, so you feed the raw mouse events straight to handle_event, the same call you already use for keys:

rust
use ratcn::runtime::EventResult;

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

Before routing, handle_event turns the raw events into Click, Drag, and DragEnd: a press-then-release on one component reaches it as a Click, a held-button move as a Drag, and the release that ends a drag as a DragEnd. One raw event still produces at most one message, the same one-event-one-message flow as keyboard.

A click needs the same component under the pointer at the press and at the release. The pointer may move in between: drifting a column while pressing a button still clicks it, the way a real mouse behaves. Leaving the component ends the click, and returning to it before releasing revives it. If a redraw moved or replaced things in between, a newly exposed component at the same cell does not inherit the click.

Movement only cancels the click when a component claimed the gesture with capture_pointer on the Down. Claiming is what declares the movement a drag, and that release arrives as DragEnd instead. A claimed press that never moved is still a click, so one component can both drag and be clicked.

Hit-testing uses the geometry from the last successful render. When targets overlap, the one declared later wins; paint-only widgets are never hit targets. The event lands on the component under the pointer first and bubbles to its ancestors from there, the same way keyboard events bubble from the focused component; see Rendering and event routing.

What components do with it ​

A component can receive these MouseKinds: Down, Up, Click, Drag, DragEnd, Moved, and Scroll. Components opt into the ones they need:

  • Primary click activates by default: a button presses and a list row is chosen. Right and middle clicks do not activate or focus the standard built-ins. A primary click also moves focus, so the component is keyboard-ready immediately after. For a List item cursor or manual Tabs cursor, the click's selection message should update that app-owned cursor along with the committed value; those cursors are separate from runtime component focus.
  • Primary down focus is a fallback. After the target and its ancestors return Ignored, the runtime focuses the innermost eligible target. Capturing the pointer does not consume the event, so capture plus Ignored still gets this fallback. Consumed vetoes it, while Emit returns the component's message instead. A press inside a scope that already holds focus (its empty space, or a child that takes none) leaves focus where it is.
  • Hover is the runtime's own path, separate from focus and from your state, so there is nothing to bind. PaintCtx::hovered and contains_hover let a component highlight under the pointer without stealing focus, so keyboard use of the focused component keeps working while the mouse drifts over a button. DeclareCtx::pointer_within() answers the same question while declaring, for structure that depends on it. Every pointer event, not only Moved, records where the pointer is, and each committed frame resolves hover from that position against the surface it just declared.
  • Motion is never Ignored once a surface exists. It comes back as at least Consumed, whether or not any component handled it, because paint may read the pointer position itself through PaintCtx::hover_position (a tabs row highlights the tab under the pointer that way). That result is the redraw signal for a host that redraws on anything but Ignored.
  • A gesture freezes hover. While a button is held, hover stays on whatever the gesture started on, so the geometry a drag moves does not chase the pointer dragging it. The release hands it back. See Dragging for the details.
  • Focus-follows-mouse is the opt-in exception. By default hover never moves focus. Ratcn::hover_focus() applies at the implicit root; a nested scope uses ScopeOptions::hover_focus(). The first move onto another direct focusable child focuses that child's first focusable leaf. It is off everywhere it is not explicitly set, so hover and focus remain independent there.
  • Scroll moves a stored, app-owned offset (lists, scroll areas). The wheel scrolls the view, independent of the cursor.
  • Drag moves an app-owned position, and DragEnd commits it. Captured gestures return DragEnd to their source, and its release position lets that source apply its own drop-target hit test. EventCtx::drag handles capture, button matching, and cleanup; see Dragging for the full pattern.

Exited never reaches a component. A backend reports it when the pointer leaves the interactive grid, and the runtime clears every tracked gesture and empties hover until another pointer event arrives. A release outside the grid therefore cannot leave a stale drag or hover active when the pointer returns.

Only focus needs wiring at the root:

rust
let ratcn = Ratcn::new().focus(|state: &AppState| &state.focus, Msg::FocusChanged);

The path semantics, the split between app-owned focus and runtime-owned hover, and the hover_focus boundary rules are covered in Focus, hover, and identity.

Pointer shape ​

The mouse pointer shows what it is over: a hand over anything pressable, a text beam over a field. A component asks for a shape while painting, with PaintCtx::set_pointer_shape. Only the hovered declaration is heard, so the deepest component under the pointer decides, a modal hides the shapes beneath it, and a captured drag keeps its own. A frame where nothing asks shows PointerShape::Default.

ComponentShape
Button, Checkbox, Cycle, Select and its options, Tabs, List rowsPointer
Input, TextAreaText, or NotAllowed when disabled
ScrollArea's thumb, Dialog's draggable borderGrab, then Grabbing while dragged
A disabled controlDefault

Your own component, or a ctx.paint closure inside a named scope, asks the same way. PaintCtx::pointer_captured says whether it holds the pointer, for Grabbing; the kanban and drag demos do this for their cards. After rendering, read Ratcn::pointer_shape() and hand it to the host, which writes only when it changes:

  • Terminal: Session::set_pointer_shape writes OSC 22, and dropping the session hands the terminal its own pointer back on exit.
  • Browser: ratcn::runtime::set_browser_pointer_shape sets the CSS cursor of the app's element.

PointerShape::Default is the arrow (default) once a shape has been shown; in a terminal, before any shape, it leaves the terminal's own pointer alone. In the browser it is the arrow even over a DomBackend's text.

TerminalOSC 22
kittyFull support
footSupported, resets on exit
iTerm2Supported
GhosttySupported; 1.3.1 ignores the exit's reset (fixed later, ghostty#14495), but switching mouse reporting off on exit resets it anyway
WezTermNot yet: the support is in review
Alacritty, Windows Terminal, VTE (GNOME Terminal)Ignored
tmux (mainline), herdrNot forwarded to the outer terminal, so no shapes inside them

A host on ratcn::crossterm writes OSC 22 itself, with css_name() as the hook. Write only on change. Before the first shape, write a bare push (ESC ] 22 ; >, never a name after >, which Ghostty and foot reject). On exit, pop and then send the empty reset (not default) before leaving the alternate screen:

rust
use std::io::Write;

// Before the first frame: nothing has been shown, so nothing is written
// until a frame asks for a shape.
let mut last = PointerShape::Default;
let mut pushed = false;

// After each frame:
if shape != last {
    if !pushed {
        write!(out, "\x1b]22;>\x1b\\")?;
        pushed = true;
    }
    write!(out, "\x1b]22;{}\x1b\\", shape.css_name())?;
    out.flush()?;
    last = shape;
}

// On exit, when `pushed`:
write!(out, "\x1b]22;<\x1b\\\x1b]22;\x1b\\")?;

See Host integration for the loop.

In the browser ​

The browser backend (Ratzilla) reports mouse positions directly in terminal cell coordinates (col/row), the same space as crossterm, so no pixel conversion or app-side mapping is needed. With ratcn's ratzilla feature, pass the callback event directly to the runtime:

rust
terminal.on_mouse_event({
    let app = Rc::clone(&app);
    move |mouse_event| app.borrow_mut().handle_event(mouse_event)
}).map_err(|error| io::Error::other(error.to_string()))?;

The conversion accepts Ratzilla's raw ButtonDown/ButtonUp/Moved stream, plus Exited for the pointer leaving the grid. It ignores Ratzilla's SingleClick/DoubleClick, because the runtime synthesizes clicks and drags the same way for native and browser input. An app that writes the clipboard routes mouse events through the route function in Host integration instead.

Page focus, key capture, and browser default prevention stay with the host. The demos' shared host script shows one working policy (forwarding captured keys to Ratzilla's canvas and normalizing macOS Option chords from KeyboardEvent.code), and an application can supply its own.