From 8f4f9731b0dda1793bae1c6eda01a6c2d213b180 Mon Sep 17 00:00:00 2001 From: pcarter Date: Wed, 8 Apr 2026 14:00:06 -0700 Subject: [PATCH] scrolling for copilot --- src/main.rs | 37 ++++++++++++++-- src/terminal.rs | 111 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index 71cbc53..36cb260 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1599,7 +1599,11 @@ impl ApplicationHandler for App { WindowEvent::MouseWheel { delta, .. } => { if let Some(tw) = self.windows.get_mut(&window_id) { let state = &tw.panes[tw.active_pane].terminal.state; - let app_scroll = state.is_alt_screen() || state.cursor_keys_app_mode; + // Forward scroll as arrow keys only in alt-screen mode (vim, htop, etc.), + // unless mouse tracking is active — then the app receives raw mouse events. + let mouse_tracking = state.mouse_tracking; + let mouse_sgr = state.mouse_sgr; + let app_scroll = state.is_alt_screen() && !mouse_tracking; let lines = match delta { MouseScrollDelta::LineDelta(_, y) => { tw.scroll_frac = 0.0; @@ -1614,8 +1618,35 @@ impl ApplicationHandler for App { } }; if lines != 0 { - if app_scroll { - // Forward scroll to the PTY as arrow key presses + if mouse_tracking { + // Send mouse scroll events to the PTY so the app can handle them + // in context (e.g. scroll a specific pane / list, not the history). + // Button 64 = scroll up, 65 = scroll down (X10/SGR encoding). + let (mx, my) = tw.cursor_pos; + if let Some((_pi, row, col)) = tw.pixel_to_pane_cell(mx, my) { + let count = lines.unsigned_abs(); + // Positive lines = scroll up (content moves up = wheel up). + let button = if lines > 0 { 64u32 } else { 65u32 }; + let col1 = col + 1; + let row1 = row + 1; + if mouse_sgr { + let seq = format!("\x1b[<{button};{col1};{row1}M"); + for _ in 0..count { + tw.panes[tw.active_pane].write(seq.as_bytes()); + } + } else { + // X10 format: ESC [ M Cb Cx Cy (capped at char 255) + let cb = (32 + button).min(255) as u8; + let cx = (32 + col1).min(255) as u8; + let cy = (32 + row1).min(255) as u8; + let seq = [0x1b, b'[', b'M', cb, cx, cy]; + for _ in 0..count { + tw.panes[tw.active_pane].write(&seq); + } + } + } + } else if app_scroll { + // Alt screen without mouse tracking: forward as arrow keys (vim, htop). let seq = if lines > 0 { b"\x1bOA".as_ref() } else { b"\x1bOB".as_ref() }; for _ in 0..lines.unsigned_abs() { tw.panes[tw.active_pane].write(seq); diff --git a/src/terminal.rs b/src/terminal.rs index a5ef059..3a6b88a 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -115,6 +115,14 @@ pub struct TerminalState { pub cursor_shape: u8, /// Whether focus-in/out events are enabled (?1004h). pub focus_tracking: bool, + /// Whether any mouse tracking mode is active (?1000h/?1002h/?1003h). + /// When true, scroll wheel events are sent as mouse escape sequences instead + /// of being handled locally by the terminal. + pub mouse_tracking: bool, + /// Whether SGR mouse encoding is active (?1006h). + /// SGR format: `\x1b[223 cols/rows. + /// When false, the legacy X10 format is used instead. + pub mouse_sgr: bool, /// Whether synchronized-output mode is active (?2026h). When true, /// redraws are suppressed until the mode is cleared. pub sync_output: bool, @@ -177,6 +185,8 @@ impl TerminalState { cursor_keys_app_mode: false, cursor_shape: 0, focus_tracking: false, + mouse_tracking: false, + mouse_sgr: false, sync_output: false, links: Vec::new(), current_link_id: 0, @@ -786,7 +796,9 @@ impl Perform for TerminalState { for ¶m in &p { match param { 1 => self.cursor_keys_app_mode = true, + 1000 | 1002 | 1003 => self.mouse_tracking = true, 1004 => self.focus_tracking = true, + 1006 => self.mouse_sgr = true, 47 | 1047 => self.enter_alt_screen(false), 1049 => self.enter_alt_screen(true), 2004 => self.bracketed_paste = true, @@ -799,7 +811,9 @@ impl Perform for TerminalState { for ¶m in &p { match param { 1 => self.cursor_keys_app_mode = false, + 1000 | 1002 | 1003 => self.mouse_tracking = false, 1004 => self.focus_tracking = false, + 1006 => self.mouse_sgr = false, 47 | 1047 => self.leave_alt_screen(false), 1049 => self.leave_alt_screen(true), 2004 => self.bracketed_paste = false, @@ -931,6 +945,8 @@ impl TerminalState { self.scroll_bottom = self.rows.saturating_sub(1); self.cursor_keys_app_mode = false; self.cursor_shape = 0; + self.mouse_tracking = false; + self.mouse_sgr = false; self.wrap_next = false; } } @@ -3150,6 +3166,101 @@ mod tests { assert_eq!(s.current_link_id, 0); assert!(s.links.is_empty()); } + + // ── Mouse tracking ──────────────────────────────────────────────────────── + + #[test] + fn mouse_tracking_off_by_default() { + let s = TerminalState::new(80, 24); + assert!(!s.mouse_tracking); + assert!(!s.mouse_sgr); + } + + #[test] + fn mouse_tracking_enabled_by_1000h() { + let mut t = Terminal::new(80, 24); + t.process(b"\x1b[?1000h"); + assert!(t.state.mouse_tracking); + } + + #[test] + fn mouse_tracking_enabled_by_1002h() { + let mut t = Terminal::new(80, 24); + t.process(b"\x1b[?1002h"); + assert!(t.state.mouse_tracking); + } + + #[test] + fn mouse_tracking_enabled_by_1003h() { + let mut t = Terminal::new(80, 24); + t.process(b"\x1b[?1003h"); + assert!(t.state.mouse_tracking); + } + + #[test] + fn mouse_tracking_disabled_by_1000l() { + let mut t = Terminal::new(80, 24); + t.process(b"\x1b[?1000h"); + assert!(t.state.mouse_tracking); + t.process(b"\x1b[?1000l"); + assert!(!t.state.mouse_tracking); + } + + #[test] + fn mouse_sgr_enabled_by_1006h() { + let mut t = Terminal::new(80, 24); + t.process(b"\x1b[?1006h"); + assert!(t.state.mouse_sgr); + } + + #[test] + fn mouse_sgr_disabled_by_1006l() { + let mut t = Terminal::new(80, 24); + t.process(b"\x1b[?1006h"); + t.process(b"\x1b[?1006l"); + assert!(!t.state.mouse_sgr); + } + + #[test] + fn mouse_tracking_and_sgr_enabled_together() { + let mut t = Terminal::new(80, 24); + t.process(b"\x1b[?1000h\x1b[?1006h"); + assert!(t.state.mouse_tracking); + assert!(t.state.mouse_sgr); + } + + #[test] + fn mouse_tracking_does_not_affect_cursor_keys() { + // ?1000h must not change cursor_keys_app_mode + let mut t = Terminal::new(80, 24); + assert!(!t.state.cursor_keys_app_mode); + t.process(b"\x1b[?1000h"); + assert!(!t.state.cursor_keys_app_mode); + } + + #[test] + fn mouse_tracking_reset_by_soft_reset() { + let mut t = Terminal::new(80, 24); + t.process(b"\x1b[?1000h\x1b[?1006h"); + t.state.reset_soft(); + assert!(!t.state.mouse_tracking); + assert!(!t.state.mouse_sgr); + } + + #[test] + fn mouse_tracking_double_enable_is_idempotent() { + let mut t = Terminal::new(80, 24); + t.process(b"\x1b[?1000h"); + t.process(b"\x1b[?1000h"); + assert!(t.state.mouse_tracking); + } + + #[test] + fn mouse_tracking_disable_without_enable_is_safe() { + let mut t = Terminal::new(80, 24); + t.process(b"\x1b[?1000l"); + assert!(!t.state.mouse_tracking); + } } pub struct Terminal { -- 2.51.2