Skip to content

Forward mouse events to the PTY when the app asks for them - #182

Open
phil-kremidas-unitedmasters wants to merge 1 commit into
masterfrom
forward-mouse-events
Open

Forward mouse events to the PTY when the app asks for them#182
phil-kremidas-unitedmasters wants to merge 1 commit into
masterfrom
forward-mouse-events

Conversation

@phil-kremidas-unitedmasters

Copy link
Copy Markdown
Collaborator

mandelbot never sent mouse reports — grep -rn 'MOUSE\|SGR' src/ came back empty. The wheel and clicks were dead inside tmux, vim, less, htop and anything else that enables mouse tracking. Worse, inside the alternate screen our own grid keeps no scrollback (history_size() == 0), so Message::Scroll was a no-op and the wheel had nowhere to go at all.

What's here

A pure encoder in src/widget/mouse_report.rs — grid coordinates and modifiers in, PTY bytes out, no widget state — covering:

  • SGR (CSI < Cb ; Cx ; Cy M/m) when SGR_MOUSE is set.
  • Legacy X10/normal (CSI M Cb Cx Cy, +32 offset, 1-based coordinate clamped at 223) otherwise, with UTF-8 extended coordinates when UTF8_MOUSE is set.
  • Button bits (left 0, middle 1, right 2, no-button 3, wheel 64/65), the +32 motion bit, and modifier bits (shift 4, meta 8, control 16).
  • ALTERNATE_SCROLL: on the alt screen with mode 1007 and no mouse reporting, the wheel becomes cursor-key presses (ESC O A/ESC [ A per line, honoring APP_CURSOR). This is what makes less scroll.

Wired into the existing WheelScrolled, ButtonPressed, ButtonReleased and CursorMoved arms of the terminal widget.

Precedence

Highest first:

  1. Our scrollbar — chrome, never application input.
  2. Shift held — the conventional escape hatch, so click-drag selection and copy/paste still work inside a mouse-aware app. Without it users lose copy/paste in tmux entirely.
  3. Application reporting, when TermMode::MOUSE_MODE is set.
  4. Local scrollback and selection — unchanged when reporting is off.

Two details worth flagging:

  • Reports use the visible screen row, not the scrollback-relative line pixel_to_grid returns. pixel_to_screen_cell is a separate helper for exactly that reason.
  • Motion only fires when the cursor crosses into a new cell, and a release is only sent for a button whose press we forwarded, so the application never sees an unbalanced press. Wheel reports per event are capped so a big trackpad delta can't flood the PTY.

Verification

14 unit tests on the encoder (SGR press/release/drag/wheel/no-button, X10 +32 offset and the 223 clamp, X10 release reporting button 3, modifier bits individually and combined, UTF-8 wide coordinates, SGR winning over UTF-8, alternate scroll arrows and APP_CURSOR). cargo build and cargo test pass — 92 tests, up from 78.

Also verified live in a real mandelbot window by capturing the exact bytes reaching the PTY:

  • SGR probe (modes 1000+1002+1006): press ESC [ < 0 ; 69 ; 5 M, drag ESC [ < 32 ; ... M emitted once per cell crossed with no duplicates, release ESC [ < 0 ; 52 ; 13 m — correct button identity, lowercase final byte. Wheel gave 64 up / 65 down. Double- and triple-click forwarded as three press/release pairs rather than becoming local selection. Shift-held drag produced no reports at all (no report anywhere in the capture carried the +4 shift bit).
  • Alternate-scroll probe (alt screen, no mouse reporting): wheel up emitted ESC [ A per line, wheel down ESC [ B.
  • Real apps: vim with :set mouse=a — clicks move the cursor, wheel scrolls. less — wheel scrolls via the alternate-scroll path. Confirmed by the repo owner clicking in the window; I could not synthesize input myself (this environment denies Accessibility and Screen Recording), so the app-level confirmation is observational rather than captured.

tmux specifically was not exercised — it isn't installed on this machine. Its path is the SGR one that the probe covers byte-for-byte.

Merge note

Touches the WheelScrolled arm, where a sibling change is adding wheel delta accumulation. The diff there is deliberately tight — one shell.publish(Message::Scroll(lines)) becomes a match on self.wheel_report(...) — but whoever merges second will need to rebase that arm. The accumulated lines value should feed wheel_report unchanged.

Out of scope

Mouse forwarding only. Not touched: the alt-screen scrollbar being hidden when history_size == 0 (correct as-is once the wheel reaches the app), focus reporting (mode 1004), and pixel-precision mouse mode (1016).

🤖 Generated with Claude Code

@astex
astex force-pushed the forward-mouse-events branch from a829201 to de7149a Compare August 3, 2026 18:32
mandelbot never sent mouse reports, so the wheel and clicks were dead
inside tmux, vim, less, htop and anything else that enables mouse
tracking. Worse, inside the alternate screen our own grid keeps no
scrollback, so the wheel had nowhere to go at all.

Add a pure encoder in src/widget/mouse_report.rs covering SGR (1006),
legacy X10 and UTF-8 extended coordinates, plus the alternate-scroll
arrow-key translation, and wire it into the existing mouse arms of the
terminal widget.

Precedence, highest first: our own scrollbar (it is chrome, never app
input), then shift-held events (the conventional escape hatch, so text
selection and copy/paste still work inside a mouse-aware app), then
application reporting, then local scrollback and selection. Behavior
with reporting off is unchanged.

Motion reports fire only when the cursor crosses into a new cell, and a
release is only sent for a button whose press we forwarded, so the
application never sees an unbalanced press.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@astex
astex force-pushed the forward-mouse-events branch from de7149a to 88dc65e Compare August 13, 2026 16:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants