diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-24 21:21:58 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-24 21:30:04 +0300 |
| commit | 420925d141c8805e610eb0d62d49d8b9b483d961 (patch) | |
| tree | bbd1f778a038bf4387c704a0df921fa53e7af615 /src/client/mod.rs | |
| parent | 43c6ca3a5dbdcefc6c6dc29be9ce81d990baf39e (diff) | |
fix: support scrolling direct agent attaches
Diffstat (limited to 'src/client/mod.rs')
| -rw-r--r-- | src/client/mod.rs | 221 |
1 files changed, 206 insertions, 15 deletions
diff --git a/src/client/mod.rs b/src/client/mod.rs index 1ac05201..5dd2652f 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -23,16 +23,17 @@ use std::time::Duration; use crossterm::event::{ DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, - EnableFocusChange, EnableMouseCapture, PopKeyboardEnhancementFlags, - PushKeyboardEnhancementFlags, + EnableFocusChange, EnableMouseCapture, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind, + PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }; use crossterm::execute; use tracing::{debug, info, warn}; use crate::protocol::render_ansi; use crate::protocol::{ - self, ClientKeybindings, ClientMessage, NotifyKind, RenderEncoding, ServerMessage, - MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, + self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientMessage, NotifyKind, + RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, + MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, }; use crate::server::socket_paths::client_socket_path; @@ -56,6 +57,8 @@ struct ClientState { kitty_graphics_enabled: bool, /// Direct attach prefix escape state. None for full-app clients. attach_escape: Option<AttachEscapeState>, + /// Rows scrolled for one direct-attach wheel notch. + mouse_scroll_lines: usize, } #[derive(Debug, Default)] @@ -66,12 +69,25 @@ struct AttachEscapeState { #[derive(Debug)] enum AttachInputAction { Forward(Vec<u8>), + Scroll { + source: AttachScrollSource, + direction: AttachScrollDirection, + lines: u16, + column: Option<u16>, + row: Option<u16>, + modifiers: u8, + }, Detach, None, } impl AttachEscapeState { - fn filter_input(&mut self, data: Vec<u8>) -> AttachInputAction { + fn filter_input( + &mut self, + data: Vec<u8>, + viewport_rows: u16, + mouse_scroll_lines: usize, + ) -> AttachInputAction { const PREFIX: u8 = 0x02; // Ctrl+B let mut output = Vec::with_capacity(data.len()); @@ -98,12 +114,73 @@ impl AttachEscapeState { if output.is_empty() { AttachInputAction::None + } else if let Some(action) = + attach_scroll_action(&output, viewport_rows, mouse_scroll_lines) + { + action } else { AttachInputAction::Forward(output) } } } +fn attach_scroll_action( + data: &[u8], + viewport_rows: u16, + mouse_scroll_lines: usize, +) -> Option<AttachInputAction> { + let mut events = crate::raw_input::parse_raw_input_bytes_sync(data); + if events.len() != 1 { + return None; + } + + match events.pop()? { + crate::raw_input::RawInputEvent::Mouse(mouse) => { + let direction = match mouse.kind { + MouseEventKind::ScrollUp => AttachScrollDirection::Up, + MouseEventKind::ScrollDown => AttachScrollDirection::Down, + _ => return Some(AttachInputAction::None), + }; + Some(AttachInputAction::Scroll { + source: AttachScrollSource::Wheel, + direction, + lines: mouse_scroll_lines.max(1).min(u16::MAX as usize) as u16, + column: Some(mouse.column), + row: Some(mouse.row), + modifiers: mouse.modifiers.bits(), + }) + } + crate::raw_input::RawInputEvent::Key(key) + if key.modifiers.is_empty() + && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => + { + let direction = match key.code { + KeyCode::PageUp => AttachScrollDirection::Up, + KeyCode::PageDown => AttachScrollDirection::Down, + _ => return None, + }; + Some(AttachInputAction::Scroll { + source: AttachScrollSource::PageKey { + input: data.to_vec(), + }, + direction, + lines: viewport_rows.saturating_sub(1).max(1), + column: None, + row: None, + modifiers: KeyModifiers::empty().bits(), + }) + } + crate::raw_input::RawInputEvent::Key(key) + if key.modifiers.is_empty() + && key.kind == KeyEventKind::Release + && matches!(key.code, KeyCode::PageUp | KeyCode::PageDown) => + { + Some(AttachInputAction::None) + } + _ => None, + } +} + impl ClientState { fn request_full_redraw(&mut self) { self.blit_encoder = render_ansi::BlitEncoder::new(); @@ -210,10 +287,11 @@ fn setup_terminal(mouse_capture: bool) -> io::Result<TerminalGuard> { /// Sets up a direct attach terminal. /// -/// Direct attach forwards stdin to the attached PTY, so it must not enable -/// outer-terminal protocols that generate local responses or mouse reports. +/// Direct attach forwards stdin to the attached PTY. It enables mouse capture +/// so wheel events can drive the attached viewport or be forwarded to child +/// programs that requested mouse input. fn setup_direct_attach_terminal() -> io::Result<TerminalGuard> { - setup_terminal_with_capabilities(false, false) + setup_terminal_with_capabilities(false, true) } fn setup_terminal_with_capabilities( @@ -234,6 +312,8 @@ fn setup_terminal_with_capabilities( EnableFocusChange, PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags()) )?; + } else if mouse_capture { + execute!(io::stdout(), EnableMouseCapture)?; } else { execute!(io::stdout(), DisableMouseCapture)?; } @@ -433,6 +513,7 @@ fn run_client_with_mode( init_logging(); let loaded_config = crate::config::Config::load(); + let mouse_scroll_lines = loaded_config.config.ui.mouse_scroll_lines(); let sound_config = loaded_config.config.ui.sound; let direct_attach_requested = attach_request.is_some(); let kitty_graphics_enabled = @@ -526,6 +607,7 @@ fn run_client_with_mode( rows, should_quit, sound_config, + mouse_scroll_lines, kitty_graphics_enabled, false, negotiated_encoding, @@ -572,6 +654,7 @@ async fn run_client_loop( rows: u16, should_quit: Arc<AtomicBool>, sound_config: crate::config::SoundConfig, + mouse_scroll_lines: usize, kitty_graphics_enabled: bool, mouse_capture_active: bool, negotiated_encoding: RenderEncoding, @@ -584,6 +667,7 @@ async fn run_client_loop( sound_config, kitty_graphics_enabled, attach_escape, + mouse_scroll_lines, }; debug!(?negotiated_encoding, "client render encoding active"); @@ -644,8 +728,33 @@ async fn run_client_loop( match event { ClientLoopEvent::StdinInput(data) => { let data = if let Some(attach_escape) = &mut state.attach_escape { - match attach_escape.filter_input(data) { + match attach_escape.filter_input( + data, + state.reported_size.1, + state.mouse_scroll_lines, + ) { AttachInputAction::Forward(data) => data, + AttachInputAction::Scroll { + source, + direction, + lines, + column, + row, + modifiers, + } => { + let msg = ClientMessage::AttachScroll { + source, + direction, + lines, + column, + row, + modifiers, + }; + if let Err(e) = write_to_server(&mut write_stream, &msg) { + return Err(ClientError::ConnectionLost(e)); + } + continue; + } AttachInputAction::Detach => { let _ = write_to_server(&mut write_stream, &ClientMessage::Detach); return Ok(()); @@ -1269,11 +1378,11 @@ mod tests { fn attach_escape_detaches_on_prefix_q() { let mut escape = AttachEscapeState::default(); assert!(matches!( - escape.filter_input(vec![0x02]), + escape.filter_input(vec![0x02], 24, 3), AttachInputAction::None )); assert!(matches!( - escape.filter_input(vec![b'q']), + escape.filter_input(vec![b'q'], 24, 3), AttachInputAction::Detach )); } @@ -1282,10 +1391,10 @@ mod tests { fn attach_escape_sends_literal_prefix_on_double_prefix() { let mut escape = AttachEscapeState::default(); assert!(matches!( - escape.filter_input(vec![0x02]), + escape.filter_input(vec![0x02], 24, 3), AttachInputAction::None )); - match escape.filter_input(vec![0x02]) { + match escape.filter_input(vec![0x02], 24, 3) { AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02]), other => panic!("expected forwarded prefix, got {other:?}"), } @@ -1295,16 +1404,98 @@ mod tests { fn attach_escape_forwards_prefix_before_non_escape_key() { let mut escape = AttachEscapeState::default(); assert!(matches!( - escape.filter_input(vec![b'a', 0x02]), + escape.filter_input(vec![b'a', 0x02], 24, 3), AttachInputAction::Forward(bytes) if bytes == b"a" )); - match escape.filter_input(vec![b'x']) { + match escape.filter_input(vec![b'x'], 24, 3) { AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02, b'x']), other => panic!("expected forwarded bytes, got {other:?}"), } } #[test] + fn attach_escape_turns_wheel_into_scroll_action() { + let mut escape = AttachEscapeState::default(); + match escape.filter_input(b"\x1b[<64;11;6M".to_vec(), 24, 7) { + AttachInputAction::Scroll { + source, + direction, + lines, + column, + row, + .. + } => { + assert_eq!(source, AttachScrollSource::Wheel); + assert_eq!(direction, AttachScrollDirection::Up); + assert_eq!(lines, 7); + assert_eq!(column, Some(10)); + assert_eq!(row, Some(5)); + } + other => panic!("expected scroll action, got {other:?}"), + } + } + + #[test] + fn attach_escape_swallows_non_wheel_mouse_reports() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(b"\x1b[<0;11;6M".to_vec(), 24, 7), + AttachInputAction::None + )); + } + + #[test] + fn attach_escape_turns_plain_page_keys_into_scroll_actions() { + let mut escape = AttachEscapeState::default(); + match escape.filter_input(b"\x1b[5~".to_vec(), 12, 3) { + AttachInputAction::Scroll { + source, + direction, + lines, + .. + } => { + assert_eq!( + source, + AttachScrollSource::PageKey { + input: b"\x1b[5~".to_vec() + } + ); + assert_eq!(direction, AttachScrollDirection::Up); + assert_eq!(lines, 11); + } + other => panic!("expected page-up scroll action, got {other:?}"), + } + + match escape.filter_input(b"\x1b[6~".to_vec(), 12, 3) { + AttachInputAction::Scroll { + source, + direction, + lines, + .. + } => { + assert_eq!( + source, + AttachScrollSource::PageKey { + input: b"\x1b[6~".to_vec() + } + ); + assert_eq!(direction, AttachScrollDirection::Down); + assert_eq!(lines, 11); + } + other => panic!("expected page-down scroll action, got {other:?}"), + } + } + + #[test] + fn attach_escape_forwards_modified_page_key() { + let mut escape = AttachEscapeState::default(); + match escape.filter_input(b"\x1b[5;5~".to_vec(), 12, 3) { + AttachInputAction::Forward(bytes) => assert_eq!(bytes, b"\x1b[5;5~"), + other => panic!("expected modified page key to forward, got {other:?}"), + } + } + + #[test] fn client_error_display_connection_failed() { let err = ClientError::ConnectionFailed(io::Error::new( io::ErrorKind::ConnectionRefused, |
