From e08d7543f4c3e82257af86e8d3a56bfefe8c5ff3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Ram=C3=ADrez=20Norambuena?= Date: Sun, 16 Aug 2026 11:40:06 -0400 Subject: [PATCH] feat(input): bypass mouse reporting while Shift is held Applications that enable mouse reporting (?1000/1002/1006h) received every click, so text selection and link opening were unreachable while they ran. Holding Shift on press now keeps the whole press-drag-release local, the xterm convention other terminals follow. --- CHANGELOG.md | 1 + README.md | 2 +- src/app_event.rs | 22 ++++++++++++++++++++-- src/app_event_test.rs | 27 +++++++++++++++++++++++++++ src/app_state.rs | 5 +++++ 5 files changed, 54 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53fb074..f6a8419 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - add `window.opacity` for a transparent background (compositor-dependent) +- hold Shift while clicking or dragging to bypass an application's mouse reporting — selection and link opening now work inside full-screen apps (vim, tmux, Claude Code) ### Fixed - honour SGR colon subparameters: `4:0` now turns underline off (was leaving the whole screen underlined) and `38:2:r:g:b` / `48:5:n` colours are applied diff --git a/README.md b/README.md index b998685..1fd7977 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Renders entirely via a CPU pixel buffer — no GPU, no OpenGL, no Vulkan. - **Multi-tab** — independent pane trees and font metrics per tab - **Scrollback search** — live match highlighting across 10 000-line buffer; history navigable with ↑/↓, persisted to `~/.config/mmterm/search_history` - **Themes** — 11 built-in themes (including `ereader`, a warm parchment/sepia theme for long sessions, and `totoro`, a Studio Ghibli–inspired forest-night theme); custom themes via `~/.config/mmterm/themes/`; each named scope remembers its own theme independently of the global config -- **OSC 8 hyperlinks** — clickable URLs rendered in the terminal; hovering a link shows its full URL in the status bar +- **OSC 8 hyperlinks** — clickable URLs rendered in the terminal; hovering a link shows its full URL in the status bar; inside apps that grab the mouse (vim, tmux, Claude Code) hold `Shift` to click links and select text - **OSC 52 clipboard sync** — copy/paste over SSH without extra tools - **Focus reporting** — `?1004h/l` sends `\e[I`/`\e[O` on window, tab, and pane focus changes; neovim `autoread` and tmux work correctly - **DEC line drawing** — box-drawing characters for ncurses TUI apps (`dialog`, `nmtui`, `mutt`) diff --git a/src/app_event.rs b/src/app_event.rs index 3e1ba9e..dfb3c6f 100644 --- a/src/app_event.rs +++ b/src/app_event.rs @@ -26,6 +26,14 @@ fn palette_move_selection(selected: usize, filtered_len: usize, up: bool) -> usi } } +/// Whether a mouse click is reported to the application instead of being +/// handled locally. `bypass` is set when the press started with Shift held — +/// the xterm convention for reaching the terminal's own selection and links +/// while a full-screen application has mouse reporting enabled. +fn forward_click_to_pty(mouse_mode: u16, btn_code: u8, bypass: bool) -> bool { + mouse_mode >= 1000 && btn_code < 3 && !bypass +} + fn cursor_icon_for_hover(hover_sep: Option<&SeparatorHandle>, has_url: bool) -> CursorIcon { if let Some(h) = hover_sep { match h.dir { @@ -579,7 +587,7 @@ impl App { self.request_redraw(); } let (mouse_mode, mouse_sgr) = self.active_mouse_mode(); - if mouse_mode >= 1002 { + if mouse_mode >= 1002 && !self.state.mouse_bypass_reporting { self.report_pty_mouse_move(px, py, mouse_mode, mouse_sgr); } else if self.state.mouse_selecting { self.update_mouse_selection(px, py); @@ -663,8 +671,18 @@ impl App { MouseButton::Right => 2u8, _ => 3u8, }; + // Shift on press bypasses mouse reporting for the whole press-release + // pair, so selection and link clicks stay usable inside full-screen + // applications that grab the mouse. + if state == ElementState::Pressed && self.modifiers.state().shift_key() { + self.state.mouse_bypass_reporting = true; + } + let bypass = self.state.mouse_bypass_reporting; + if state == ElementState::Released { + self.state.mouse_bypass_reporting = false; + } let (mouse_mode, mouse_sgr) = self.active_mouse_mode(); - if mouse_mode >= 1000 && btn_code < 3 { + if forward_click_to_pty(mouse_mode, btn_code, bypass) { self.send_pty_mouse_click(btn_code, state, button, mouse_sgr); return; } diff --git a/src/app_event_test.rs b/src/app_event_test.rs index bdf74f9..9e396cd 100644 --- a/src/app_event_test.rs +++ b/src/app_event_test.rs @@ -57,3 +57,30 @@ fn push_search_history_clears_pending_before_buffer() { "committing a search must clear the saved in-progress query" ); } + +#[test] +fn click_is_forwarded_when_application_enabled_mouse_reporting() { + assert!(super::forward_click_to_pty(1000, 0, false)); + assert!(super::forward_click_to_pty(1002, 1, false)); + assert!(super::forward_click_to_pty(1006, 2, false)); +} + +#[test] +fn click_is_handled_locally_without_mouse_reporting() { + assert!(!super::forward_click_to_pty(0, 0, false)); +} + +#[test] +fn shift_click_bypasses_mouse_reporting() { + // Shift must reach the terminal's own selection / link handling even while + // a full-screen application (Claude Code, vim, tmux) grabs the mouse. + assert!(!super::forward_click_to_pty(1000, 0, true)); + assert!(!super::forward_click_to_pty(1002, 1, true)); + assert!(!super::forward_click_to_pty(1006, 2, true)); +} + +#[test] +fn buttons_beyond_right_are_never_forwarded() { + assert!(!super::forward_click_to_pty(1000, 3, false)); + assert!(!super::forward_click_to_pty(1000, 3, true)); +} diff --git a/src/app_state.rs b/src/app_state.rs index 9872f18..f57cc4e 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -90,6 +90,10 @@ pub struct AppState { pub clipboard: Option, pub mouse_pos: Option<(f64, f64)>, pub mouse_selecting: bool, + /// Set while a left-button press started with Shift held: mouse reporting is + /// bypassed for the whole press-drag-release so the click selects text or + /// opens a link locally instead of reaching the application. + pub mouse_bypass_reporting: bool, /// Time, pixel position, and running click count of the last left-button /// press, for multi-click detection (double-click word / triple-click line). pub last_click: Option<(Instant, f64, f64, u8)>, @@ -149,6 +153,7 @@ impl AppState { clipboard: Clipboard::new().ok(), mouse_pos: None, mouse_selecting: false, + mouse_bypass_reporting: false, last_click: None, search_matches: Vec::new(), search_current: 0,