diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/client/mod.rs | 221 | ||||
| -rw-r--r-- | src/protocol/wire.rs | 49 | ||||
| -rw-r--r-- | src/server/client_transport.rs | 31 | ||||
| -rw-r--r-- | src/server/headless.rs | 456 |
4 files changed, 705 insertions, 52 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, diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index ea81b36e..bd79c900 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- /// Current protocol version. Bumped when wire format changes incompatibly. -pub const PROTOCOL_VERSION: u32 = 10; +pub const PROTOCOL_VERSION: u32 = 11; /// Maximum allowed frame payload size (2 MB). Frames larger than this are /// rejected to prevent denial-of-service via oversized length prefixes. @@ -109,6 +109,37 @@ pub enum ClientMessage { /// Replace an existing writable attach owner for this terminal. takeover: bool, }, + + /// Scroll input handled by a direct terminal attach client. + AttachScroll { + /// Original input source for routing. + source: AttachScrollSource, + /// Scroll direction. + direction: AttachScrollDirection, + /// Number of terminal rows to move when using host scrollback. + lines: u16, + /// Mouse column relative to the attached terminal, when available. + column: Option<u16>, + /// Mouse row relative to the attached terminal, when available. + row: Option<u16>, + /// Crossterm-compatible modifier bits for forwarded mouse wheel events. + modifiers: u8, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AttachScrollDirection { + Up, + Down, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum AttachScrollSource { + Wheel, + PageKey { + /// Original key bytes to forward when the child application owns page keys. + input: Vec<u8>, + }, } // --------------------------------------------------------------------------- @@ -702,6 +733,22 @@ mod tests { assert_eq!(msg, decoded); } + #[test] + fn client_attach_scroll_roundtrip() { + let msg = ClientMessage::AttachScroll { + source: AttachScrollSource::Wheel, + direction: AttachScrollDirection::Up, + lines: 3, + column: Some(12), + row: Some(7), + modifiers: 4, + }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ClientMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + // ---- Round-trip: ServerMessage ---- #[test] diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index 5a5984aa..36334fda 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -14,8 +14,9 @@ use tokio::sync::mpsc; use tracing::{debug, warn}; use crate::protocol::{ - self, ClientKeybindings, ClientMessage, RenderEncoding, ServerMessage, - MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, + self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientMessage, + RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, + MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, }; /// Minimum accepted attached client size. @@ -72,6 +73,16 @@ pub(crate) enum ServerEvent { terminal_id: String, takeover: bool, }, + /// A direct terminal attach client requested scrollback movement. + ClientAttachScroll { + client_id: u64, + source: AttachScrollSource, + direction: AttachScrollDirection, + lines: u16, + column: Option<u16>, + row: Option<u16>, + modifiers: u8, + }, /// A client sent a resize message. ClientResize { client_id: u64, @@ -431,6 +442,22 @@ fn client_read_loop( terminal_id, takeover, }, + ClientMessage::AttachScroll { + source, + direction, + lines, + column, + row, + modifiers, + } => ServerEvent::ClientAttachScroll { + client_id, + source, + direction, + lines, + column, + row, + modifiers, + }, ClientMessage::Hello { .. } => { // Duplicate Hello — ignore. continue; diff --git a/src/server/headless.rs b/src/server/headless.rs index a6d3f6a5..d10aef04 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -23,6 +23,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; +use crossterm::event::{KeyModifiers, MouseEventKind}; use ratatui::layout::Rect; use tokio::sync::mpsc; use tracing::{debug, error, info, warn}; @@ -34,7 +35,10 @@ use crate::api; use crate::app; use crate::config; use crate::events::AppEvent; -use crate::protocol::{self, FrameData, ServerMessage, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE}; +use crate::protocol::{ + self, AttachScrollDirection, AttachScrollSource, FrameData, ServerMessage, MAX_FRAME_SIZE, + MAX_GRAPHICS_FRAME_SIZE, +}; use crate::server::client_accept::accept_pending_client_connections; use crate::server::client_transport::ServerEvent; use crate::server::clients::{ @@ -125,6 +129,79 @@ pub struct HeadlessServer { server_event_tx: mpsc::Sender<ServerEvent>, } +fn apply_terminal_attach_scroll( + runtime: &crate::terminal::TerminalRuntime, + source: AttachScrollSource, + direction: AttachScrollDirection, + lines: u16, + column: Option<u16>, + row: Option<u16>, + modifiers: u8, +) -> Result<(), String> { + let wheel_kind = match direction { + AttachScrollDirection::Up => MouseEventKind::ScrollUp, + AttachScrollDirection::Down => MouseEventKind::ScrollDown, + }; + if let AttachScrollSource::PageKey { input } = source { + let host_scroll = runtime.input_state().is_some_and(|input_state| { + !input_state.alternate_screen && !input_state.mouse_reporting_enabled() + }); + if host_scroll { + match direction { + AttachScrollDirection::Up => runtime.scroll_up(lines.max(1) as usize), + AttachScrollDirection::Down => runtime.scroll_down(lines.max(1) as usize), + } + return Ok(()); + } + return apply_terminal_attach_input(runtime, input); + } + + match runtime.wheel_routing() { + Some(crate::pane::WheelRouting::MouseReport) => { + runtime.scroll_reset(); + let column = column.unwrap_or(0); + let row = row.unwrap_or(0); + let Some(bytes) = runtime.encode_mouse_wheel( + wheel_kind, + column, + row, + KeyModifiers::from_bits_truncate(modifiers), + ) else { + return Err(format!( + "failed to encode terminal attach mouse wheel event: {wheel_kind:?}" + )); + }; + runtime + .try_send_bytes(Bytes::from(bytes)) + .map_err(|err| format!("terminal attach mouse wheel input failed: {err}"))?; + } + Some(crate::pane::WheelRouting::AlternateScroll) => { + runtime.scroll_reset(); + let Some(bytes) = runtime.encode_alternate_scroll(wheel_kind) else { + return Ok(()); + }; + runtime + .try_send_bytes(Bytes::from(bytes)) + .map_err(|err| format!("terminal attach alternate scroll input failed: {err}"))?; + } + Some(crate::pane::WheelRouting::HostScroll) | None => match direction { + AttachScrollDirection::Up => runtime.scroll_up(lines.max(1) as usize), + AttachScrollDirection::Down => runtime.scroll_down(lines.max(1) as usize), + }, + } + Ok(()) +} + +fn apply_terminal_attach_input( + runtime: &crate::terminal::TerminalRuntime, + data: Vec<u8>, +) -> Result<(), String> { + runtime.scroll_reset(); + runtime + .try_send_bytes(Bytes::from(data)) + .map_err(|err| format!("terminal attach input failed: {err}")) +} + impl HeadlessServer { /// Creates and starts the headless server. /// @@ -546,6 +623,24 @@ impl HeadlessServer { } } + fn client_removal_needs_shared_resize(&self, client_id: u64) -> bool { + if self.foreground_client_id == Some(client_id) { + return true; + } + matches!( + self.clients.get(&client_id).map(|client| &client.mode), + Some(ClientConnectionMode::TerminalAttach { .. }) + ) && self.foreground_client_id.is_some() + } + + fn remove_client_and_resize_if_needed(&mut self, client_id: u64) { + let needs_shared_resize = self.client_removal_needs_shared_resize(client_id); + let foreground_changed = self.remove_client(client_id); + if needs_shared_resize || foreground_changed { + self.resize_shared_runtime_to_effective_size(); + } + } + fn send_client_graphics_cleanup(&mut self, client_id: u64) { let (writer, bytes) = match self.clients.get_mut(&client_id) { Some(client) => { @@ -690,6 +785,35 @@ impl HeadlessServer { true } + fn handle_terminal_attach_scroll( + &mut self, + client_id: u64, + source: AttachScrollSource, + direction: AttachScrollDirection, + lines: u16, + column: Option<u16>, + row: Option<u16>, + modifiers: u8, + ) -> bool { + let Some(ClientConnection { + mode: ClientConnectionMode::TerminalAttach { terminal_id }, + .. + }) = self.clients.get(&client_id) + else { + return false; + }; + let Some(runtime) = self.runtime_for_terminal_id_string(terminal_id) else { + return false; + }; + + if let Err(err) = + apply_terminal_attach_scroll(runtime, source, direction, lines, column, row, modifiers) + { + warn!(client_id, terminal_id = %terminal_id, err = %err, "terminal attach scroll failed"); + } + true + } + fn pane_effective_state(&self, pane_id: crate::layout::PaneId) -> crate::detect::AgentState { self.app .state @@ -1058,10 +1182,7 @@ impl HeadlessServer { // Remove broken clients. for client_id in broken_clients { - let foreground_changed = self.remove_client(client_id); - if foreground_changed { - self.resize_shared_runtime_to_effective_size(); - } + self.remove_client_and_resize_if_needed(client_id); } } @@ -1091,10 +1212,7 @@ impl HeadlessServer { client_id, "client writer channel closed during targeted send" ); - let foreground_changed = self.remove_client(client_id); - if foreground_changed { - self.resize_shared_runtime_to_effective_size(); - } + self.remove_client_and_resize_if_needed(client_id); return false; } } @@ -1114,10 +1232,7 @@ impl HeadlessServer { reason: Some(reason.clone()), }, ); - let foreground_changed = self.remove_client(client_id); - if foreground_changed { - self.resize_shared_runtime_to_effective_size(); - } + self.remove_client_and_resize_if_needed(client_id); } } @@ -1136,7 +1251,7 @@ impl HeadlessServer { )), }, ); - self.remove_client(client_id); + self.remove_client_and_resize_if_needed(client_id); return false; }; @@ -1150,7 +1265,7 @@ impl HeadlessServer { )), }, ); - self.remove_client(client_id); + self.remove_client_and_resize_if_needed(client_id); return false; } if existing_owner != client_id { @@ -1160,7 +1275,7 @@ impl HeadlessServer { reason: Some("terminal attach taken over".to_owned()), }, ); - self.remove_client(existing_owner); + self.remove_client_and_resize_if_needed(existing_owner); } } @@ -1243,6 +1358,17 @@ impl HeadlessServer { terminal_id, takeover, } => self.attach_terminal_client(client_id, terminal_id, takeover), + ServerEvent::ClientAttachScroll { + client_id, + source, + direction, + lines, + column, + row, + modifiers, + } => self.handle_terminal_attach_scroll( + client_id, source, direction, lines, column, row, modifiers, + ), ServerEvent::ClientInput { client_id, data } => { debug!(client_id, len = data.len(), "client input received"); if let Some(ClientConnection { @@ -1251,8 +1377,8 @@ impl HeadlessServer { }) = self.clients.get(&client_id) { if let Some(runtime) = self.runtime_for_terminal_id_string(terminal_id) { - if let Err(err) = runtime.try_send_bytes(Bytes::from(data)) { - warn!(client_id, terminal_id = %terminal_id, err = %err, "terminal attach input failed"); + if let Err(err) = apply_terminal_attach_input(runtime, data) { + warn!(client_id, terminal_id = %terminal_id, err = %err); } } return true; @@ -1395,18 +1521,12 @@ impl HeadlessServer { } ServerEvent::ClientDetach { client_id } => { info!(client_id, "client detached"); - let foreground_changed = self.remove_client(client_id); - if foreground_changed { - self.resize_shared_runtime_to_effective_size(); - } + self.remove_client_and_resize_if_needed(client_id); true } ServerEvent::ClientDisconnected { client_id } => { info!(client_id, "client disconnected"); - let foreground_changed = self.remove_client(client_id); - if foreground_changed { - self.resize_shared_runtime_to_effective_size(); - } + self.remove_client_and_resize_if_needed(client_id); true } ServerEvent::ClientWriterDrained { client_id } => { @@ -1689,10 +1809,7 @@ impl HeadlessServer { } for client_id in broken_clients { - let foreground_changed = self.remove_client(client_id); - if foreground_changed { - self.resize_shared_runtime_to_effective_size(); - } + self.remove_client_and_resize_if_needed(client_id); } } @@ -1890,10 +2007,7 @@ impl HeadlessServer { if !broken_clients.is_empty() { for client_id in broken_clients { - let foreground_changed = self.remove_client(client_id); - if foreground_changed { - self.resize_shared_runtime_to_effective_size(); - } + self.remove_client_and_resize_if_needed(client_id); } } @@ -2646,6 +2760,181 @@ next_tab = "" } #[test] + fn terminal_attach_scroll_moves_attached_runtime_viewport() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut bytes = Vec::new(); + for line in 0..80 { + bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); + } + let runtime = + crate::terminal::TerminalRuntime::test_with_scrollback_bytes(20, 5, 4096, &bytes); + + apply_terminal_attach_scroll( + &runtime, + AttachScrollSource::Wheel, + AttachScrollDirection::Up, + 3, + None, + None, + 0, + ) + .expect("scroll up"); + let metrics = runtime.scroll_metrics().expect("scroll metrics"); + assert_eq!(metrics.offset_from_bottom, 3); + + apply_terminal_attach_scroll( + &runtime, + AttachScrollSource::Wheel, + AttachScrollDirection::Down, + 2, + None, + None, + 0, + ) + .expect("scroll down"); + let metrics = runtime.scroll_metrics().expect("scroll metrics"); + assert_eq!(metrics.offset_from_bottom, 1); + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); + } + + #[test] + fn terminal_attach_input_resets_scrolled_viewport() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut bytes = Vec::new(); + for line in 0..80 { + bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); + } + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 20, 5, 4096, &bytes, 4, + ); + + runtime.scroll_up(4); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 4 + ); + + apply_terminal_attach_input(&runtime, b"x".to_vec()).expect("attach input"); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + assert_eq!( + input_rx.try_recv().expect("forwarded input"), + Bytes::from("x") + ); + + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); + } + + #[test] + fn terminal_attach_page_key_host_scrolls_plain_terminal() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut bytes = Vec::new(); + for line in 0..80 { + bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); + } + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 20, 5, 4096, &bytes, 4, + ); + + apply_terminal_attach_scroll( + &runtime, + AttachScrollSource::PageKey { + input: b"\x1b[5~".to_vec(), + }, + AttachScrollDirection::Up, + 4, + None, + None, + 0, + ) + .expect("page key scroll"); + + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 4 + ); + assert!(input_rx.try_recv().is_err()); + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); + } + + #[test] + fn terminal_attach_page_key_forwards_when_mouse_reporting() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut bytes = b"\x1b[?1000h".to_vec(); + for line in 0..80 { + bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); + } + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 20, 5, 4096, &bytes, 4, + ); + runtime.scroll_up(3); + + apply_terminal_attach_scroll( + &runtime, + AttachScrollSource::PageKey { + input: b"\x1b[5~".to_vec(), + }, + AttachScrollDirection::Up, + 4, + None, + None, + 0, + ) + .expect("page key forward"); + + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + assert_eq!( + input_rx.try_recv().expect("forwarded page key"), + Bytes::from_static(b"\x1b[5~") + ); + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); + } + + #[test] fn virtual_render_produces_nonempty_buffer() { let mut state = AppState::test_new(); let area = Rect::new(0, 0, 80, 24); @@ -3264,6 +3553,105 @@ next_tab = "" } #[test] + fn terminal_attach_disconnect_restores_app_pane_size() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("test"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).expect("terminal id").clone(); + let terminal_id_string = terminal_id.to_string(); + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + server.app.terminal_runtimes.insert( + terminal_id.clone(), + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), + ); + server.clients.insert( + 1, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + None, + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + server.resize_shared_runtime_to_effective_size(); + let expected_app_size = server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("runtime") + .current_size(); + assert_ne!(expected_app_size, (24, 80)); + + let (writer, _control_rx, _render_rx) = test_client_writer(); + assert!(server.handle_server_event(ServerEvent::ClientConnected { + client_id: 2, + cols: 80, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + render_encoding: RenderEncoding::TerminalAnsi, + keybindings: None, + writer, + })); + assert!( + server.handle_server_event(ServerEvent::ClientAttachTerminal { + client_id: 2, + terminal_id: terminal_id_string.clone(), + takeover: false, + }) + ); + assert_eq!(server.foreground_client_id, Some(1)); + assert!(server + .app + .state + .direct_attach_resize_locks + .contains(&terminal_id)); + assert_eq!( + server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("runtime") + .current_size(), + (24, 80) + ); + + assert!(server.handle_server_event(ServerEvent::ClientDisconnected { client_id: 2 })); + + assert!(!server + .app + .state + .direct_attach_resize_locks + .contains(&terminal_id)); + assert_eq!( + server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("runtime") + .current_size(), + expected_app_size + ); + drop(server); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); + } + + #[test] fn render_and_stream_sends_terminal_frame_for_terminal_ansi_client() { let mut server = test_headless_server(); let (client_tx, _client_control_rx, client_rx) = test_client_writer(); |
