diff --git a/crates/zedra-terminal/src/element.rs b/crates/zedra-terminal/src/element.rs index 1a489f05..d3febe3d 100644 --- a/crates/zedra-terminal/src/element.rs +++ b/crates/zedra-terminal/src/element.rs @@ -9,7 +9,6 @@ use alacritty_terminal::vte::ansi::{Color as AlacColor, CursorShape, NamedColor} use gpui::*; use itertools::Itertools; -use crate::MONO_FONT_FAMILY; use crate::input::TerminalInputHandler; use crate::selection::TerminalSelectionDocument; use crate::terminal::*; @@ -189,6 +188,7 @@ pub struct TerminalElement { focus_handle: FocusHandle, focused: bool, selection_active: bool, + metric_revision: u64, } impl TerminalElement { @@ -204,6 +204,7 @@ impl TerminalElement { focus_handle: FocusHandle, focused: bool, selection_active: bool, + metric_revision: u64, ) -> Self { Self { content, @@ -217,6 +218,7 @@ impl TerminalElement { focus_handle, focused, selection_active, + metric_revision, } } @@ -596,20 +598,7 @@ impl Element for TerminalElement { // Surface identity for native-selection occlusion checks; Normal // behavior so it never blocks other elements. let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); - // Use JetBrains Mono NL - embedded monospace font (loaded once at app init) - let font = Font { - family: MONO_FONT_FAMILY.into(), - features: FontFeatures::default(), - fallbacks: Some(FontFallbacks::from_fonts(vec![ - "Noto Sans Symbols 2".to_string(), - "Apple Symbols".to_string(), - "Menlo".to_string(), - "Droid Sans Mono".to_string(), - "monospace".to_string(), - ])), - weight: FontWeight::NORMAL, - style: FontStyle::Normal, - }; + let font = crate::terminal_font(); // Use configured line height let line_height = self.size.line_height; @@ -770,9 +759,12 @@ impl Element for TerminalElement { if needs_reconcile { let view = self.view.clone(); let actual_bounds = bounds.size; + let revision = self.metric_revision; window.defer(cx, move |_window, cx| { let _ = view.update(cx, |view, cx| { - view.reconcile_bounds_fallback(actual_bounds, cell_width, line_height, cx); + if view.metric_revision() == revision { + view.reconcile_bounds_fallback(actual_bounds, cell_width, line_height, cx); + } }); }); } diff --git a/crates/zedra-terminal/src/lib.rs b/crates/zedra-terminal/src/lib.rs index b015aa67..c8b081ea 100644 --- a/crates/zedra-terminal/src/lib.rs +++ b/crates/zedra-terminal/src/lib.rs @@ -6,6 +6,7 @@ mod selection; pub mod terminal; pub mod theme; pub mod view; +pub mod zoom_ratchet; pub use element::{TerminalElement, TerminalElementLayout}; pub use input::*; @@ -14,6 +15,7 @@ pub use keys::*; pub use terminal::*; pub use theme::{AnsiPalette, TerminalTheme}; pub use view::*; +pub use zoom_ratchet::{TerminalFontSize, TerminalZoomRatchet, ZoomStep}; use gpui::*; @@ -21,5 +23,22 @@ use gpui::*; /// The font bytes and loader live in the `zedra` crate (`fonts` module). pub const MONO_FONT_FAMILY: &str = "JetBrainsMonoNL Nerd Font Mono"; +/// Shared terminal font descriptor used by both grid measurement and paint. +pub fn terminal_font() -> gpui::Font { + gpui::Font { + family: MONO_FONT_FAMILY.into(), + features: gpui::FontFeatures::default(), + fallbacks: Some(gpui::FontFallbacks::from_fonts(vec![ + "Noto Sans Symbols 2".to_string(), + "Apple Symbols".to_string(), + "Menlo".to_string(), + "Droid Sans Mono".to_string(), + "monospace".to_string(), + ])), + weight: gpui::FontWeight::NORMAL, + style: gpui::FontStyle::Normal, + } +} + /// The font size for the embedded terminal font. pub const TERMINAL_FONT_SIZE: Pixels = px(12.0); diff --git a/crates/zedra-terminal/src/terminal.rs b/crates/zedra-terminal/src/terminal.rs index fd970df3..8d53c81a 100644 --- a/crates/zedra-terminal/src/terminal.rs +++ b/crates/zedra-terminal/src/terminal.rs @@ -28,6 +28,8 @@ pub enum TerminalEvent { cols: u16, rows: u16, }, + InteractionStarted, + UserOutboundInput, TitleChanged(Option), OscEvent(OscEvent), OpenHyperlink(TerminalHyperlink), @@ -47,6 +49,14 @@ pub enum TerminalEvent { NativePasteMenuRequested { position: GpuiPoint, }, + /// Haptic feedback for one accepted pinch zoom step. The app maps this to + /// the platform bridge; the terminal crate never touches platform APIs. + PinchStepHaptic, + /// A pinch gesture settled (ended or cancelled) with a changed font size. + /// Emitted once per gesture; the app persists the size and forwards it. + PinchSettled { + font_size: u8, + }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -315,16 +325,20 @@ impl Terminal { self.input_tx.clone() } - pub async fn send_bytes(&mut self, bytes: Vec) { - if let Some(tx) = &self.input_tx { - if let Err(e) = tx.send(bytes).await { - error!("failed to send input: {:?}", e) - } + pub async fn send_bytes(&mut self, bytes: Vec) -> bool { + let Some(tx) = &self.input_tx else { + return false; + }; + if let Err(e) = tx.send(bytes).await { + error!("failed to send input: {:?}", e); + return false; } + self.send_terminal_event(TerminalEvent::UserOutboundInput); + true } - pub async fn send_input(&mut self, text: String) { - self.send_bytes(text.into_bytes()).await; + pub async fn send_input(&mut self, text: String) -> bool { + self.send_bytes(text.into_bytes()).await } /// Feed bytes from PTY output buffer into the terminal emulator. @@ -419,7 +433,7 @@ impl Terminal { self.send_terminal_event(TerminalEvent::TitleChanged(None)); } AlacTermEvent::PtyWrite(text) => { - self.send_bytes_sync(text.into_bytes()); + self.send_protocol_reply(text.into_bytes()); } AlacTermEvent::TextAreaSizeRequest(format) => { let window_size = alacritty_terminal::event::WindowSize { @@ -428,7 +442,7 @@ impl Terminal { cell_width: (self.size.cell_width / px(1.0)) as u16, cell_height: (self.size.line_height / px(1.0)) as u16, }; - self.send_bytes_sync(format(window_size).into_bytes()); + self.send_protocol_reply(format(window_size).into_bytes()); } AlacTermEvent::ColorRequest(_index, _format) => { // The host answers OSC 10/11/12 color queries inline at the PTY boundary @@ -441,12 +455,25 @@ impl Terminal { } } + fn send_protocol_reply(&self, bytes: Vec) { + let Some(tx) = &self.input_tx else { + return; + }; + if let Err(e) = tx.try_send(bytes) { + error!("failed to send bytes: {:?}", e); + } + } + fn send_terminal_event(&self, event: TerminalEvent) { if let Err(e) = self.event_tx.send(event) { error!("failed to send terminal event: {:?}", e); } } + pub fn outbound_input_sender(&self) -> tokio::sync::broadcast::Sender { + self.event_tx.clone() + } + /// Feed bytes from PTY output buffer into the OSC scanner /// and emit events to the event channel. pub fn feed_osc_bytes(&mut self, bytes: &[u8]) { @@ -691,15 +718,19 @@ impl Terminal { Some(Point::new(Line(line), Column(column))) } - pub fn send_bytes_sync(&self, bytes: Vec) { - if let Some(tx) = &self.input_tx { - if let Err(e) = tx.try_send(bytes) { - error!("failed to send bytes: {:?}", e); - } + pub fn send_bytes_sync(&self, bytes: Vec) -> bool { + let Some(tx) = &self.input_tx else { + return false; + }; + if let Err(e) = tx.try_send(bytes) { + error!("failed to send bytes: {:?}", e); + return false; } + self.send_terminal_event(TerminalEvent::UserOutboundInput); + true } - pub fn paste_text(&self, text: &str) { + pub fn paste_text(&self, text: &str) -> bool { let bracketed = self.mode.contains(TermMode::BRACKETED_PASTE); let bytes = if bracketed { let text = text.replace('\x1b', ""); @@ -708,11 +739,15 @@ impl Terminal { text.replace("\r\n", "\r").replace('\n', "\r").into_bytes() }; - if let Some(tx) = &self.input_tx { - if let Err(error) = tx.try_send(bytes) { - error!("failed to send pasted text: {:?}", error); - } + let Some(tx) = &self.input_tx else { + return false; + }; + if let Err(error) = tx.try_send(bytes) { + error!("failed to send pasted text: {:?}", error); + return false; } + self.send_terminal_event(TerminalEvent::UserOutboundInput); + true } pub fn selection_range(&self) -> Option> { @@ -4903,4 +4938,80 @@ mod tests { key_char: Some(key.to_string()), } } + + #[test] + fn user_keystroke_emits_outbound_input_signal() { + use tokio::sync::mpsc; + let (input_tx, mut input_rx) = mpsc::channel(4); + let mut terminal = Terminal::new(80, 4, px(10.0), px(20.0)); + terminal.input_tx = Some(input_tx); + let mut events = terminal.subscribe_events(); + terminal.handle_keystroke(&plain_keystroke("a")); + let sent = input_rx.try_recv().expect("keystroke enqueues bytes"); + assert!(!sent.is_empty()); + let event = events.try_recv().expect("keystroke signals outbound input"); + assert!(matches!(event, TerminalEvent::UserOutboundInput)); + } + + #[test] + fn user_paste_emits_outbound_input_signal() { + use tokio::sync::mpsc; + let (input_tx, mut input_rx) = mpsc::channel(4); + let mut terminal = Terminal::new(80, 4, px(10.0), px(20.0)); + terminal.input_tx = Some(input_tx); + let mut events = terminal.subscribe_events(); + assert!(terminal.paste_text("hello")); + let sent = input_rx.try_recv().expect("paste enqueues bytes"); + assert!(!sent.is_empty()); + let event = events.try_recv().expect("paste signals outbound input"); + assert!(matches!(event, TerminalEvent::UserOutboundInput)); + } + + #[test] + fn protocol_reply_does_not_emit_outbound_input_signal() { + use tokio::sync::mpsc; + let (input_tx, mut input_rx) = mpsc::channel(4); + let mut terminal = Terminal::new(80, 4, px(10.0), px(20.0)); + terminal.input_tx = Some(input_tx); + let mut events = terminal.subscribe_events(); + terminal.send_protocol_reply(b"reply".to_vec()); + let sent = input_rx.try_recv().expect("protocol reply enqueues bytes"); + assert!(!sent.is_empty()); + assert!( + events.try_recv().is_err(), + "protocol replies must not trigger post-input reclaim" + ); + } + + #[test] + fn user_async_send_bytes_emits_outbound_input_signal() { + use tokio::sync::mpsc; + futures::executor::block_on(async { + let (input_tx, mut input_rx) = mpsc::channel(4); + let mut terminal = Terminal::new(80, 4, px(10.0), px(20.0)); + terminal.input_tx = Some(input_tx); + let mut events = terminal.subscribe_events(); + assert!(terminal.send_bytes(b"test".to_vec()).await); + let sent = input_rx.recv().await.expect("bytes sent"); + assert_eq!(sent, b"test"); + let event = events.try_recv().expect("outbound signal emitted"); + assert!(matches!(event, TerminalEvent::UserOutboundInput)); + }); + } + + #[test] + fn user_async_send_input_emits_outbound_input_signal() { + use tokio::sync::mpsc; + futures::executor::block_on(async { + let (input_tx, mut input_rx) = mpsc::channel(4); + let mut terminal = Terminal::new(80, 4, px(10.0), px(20.0)); + terminal.input_tx = Some(input_tx); + let mut events = terminal.subscribe_events(); + assert!(terminal.send_input("text".to_string()).await); + let sent = input_rx.recv().await.expect("bytes sent"); + assert_eq!(sent, b"text"); + let event = events.try_recv().expect("outbound signal emitted"); + assert!(matches!(event, TerminalEvent::UserOutboundInput)); + }); + } } diff --git a/crates/zedra-terminal/src/view.rs b/crates/zedra-terminal/src/view.rs index 317ef1b1..b5201c79 100644 --- a/crates/zedra-terminal/src/view.rs +++ b/crates/zedra-terminal/src/view.rs @@ -15,9 +15,10 @@ use crate::TerminalTheme; use crate::element::TerminalElement; use crate::selection::TerminalSelectionDocument; use crate::terminal::{Terminal, TerminalContent, TerminalEvent}; +use crate::zoom_ratchet::{TerminalFontSize, TerminalZoomRatchet}; const FALLBACK_CELL_WIDTH: f32 = 9.0; -const TERMINAL_LINE_HEIGHT: f32 = 16.0; +pub const TERMINAL_LINE_HEIGHT: f32 = TerminalFontSize::DEFAULT.line_height_f32(); const TOUCH_SCROLL_SUPPRESSION_AFTER_SCROLL_TO_BOTTOM: Duration = Duration::from_millis(1000); /// Thread-safe buffer for receiving PTY output. @@ -102,7 +103,6 @@ pub struct TerminalView { scroll_offset_px: f32, remote_scroll_offset_px: f32, keyboard_top_reveal_px: f32, - last_remote_size: Option<(u16, u16)>, /// Top-left origin of the painted terminal grid within the window. /// Used to turn touch scroll positions into terminal cell coordinates. grid_origin: Option>, @@ -121,6 +121,14 @@ pub struct TerminalView { /// drop focus — the bar keeps sending keystrokes to this terminal. pub retains_focus_without_keyboard: bool, terminal_theme: TerminalTheme, + font_size: TerminalFontSize, + metric_revision: u64, + zoom_ratchet: TerminalZoomRatchet, + pinch_claimed: bool, + pinch_start_font_size: TerminalFontSize, + viewport: Size, + interaction_signaled: bool, + outbound_input_signaled: bool, _event_task: Task<()>, _subscriptions: Vec, } @@ -132,7 +140,13 @@ impl TerminalView { viewport: Size, cx: &mut Context, ) -> Self { - let initial_grid_size = TerminalView::compute_grid_size(window, viewport); + let initial_grid_size = + TerminalView::compute_grid_size(window, viewport).unwrap_or(TerminalGridSize { + columns: 0, + rows: 0, + cell_width: px(FALLBACK_CELL_WIDTH), + line_height: TerminalFontSize::DEFAULT.line_height(), + }); let terminal = cx.new(|_cx| { Terminal::new( @@ -182,7 +196,6 @@ impl TerminalView { scroll_offset_px: 0.0, remote_scroll_offset_px: 0.0, keyboard_top_reveal_px: 0.0, - last_remote_size: None, grid_origin: None, workdir: None, keyboard_inset: px(0.0), @@ -191,11 +204,68 @@ impl TerminalView { is_alt_screen: false, retains_focus_without_keyboard: false, terminal_theme: TerminalTheme::dark(), + font_size: TerminalFontSize::DEFAULT, + metric_revision: 0, + zoom_ratchet: TerminalZoomRatchet::new(TerminalFontSize::DEFAULT), + pinch_claimed: false, + pinch_start_font_size: TerminalFontSize::DEFAULT, + viewport, + interaction_signaled: false, + outbound_input_signaled: false, _event_task: event_task, _subscriptions: vec![], } } + pub fn new_with_font_size( + terminal_id: String, + window: &mut Window, + viewport: Size, + font_size: TerminalFontSize, + cx: &mut Context, + ) -> Self { + let initial_grid_size = + TerminalView::compute_grid_size_for_font_size(window, viewport, font_size); + let mut this = Self::new(terminal_id, window, viewport, cx); + if let Some(initial_grid_size) = initial_grid_size { + this.set_font_size(font_size, cx); + this.terminal.update(cx, |terminal, _| { + terminal.resize( + initial_grid_size.columns, + initial_grid_size.rows, + initial_grid_size.cell_width, + initial_grid_size.line_height, + ); + }); + } + this + } + + pub fn set_font_size(&mut self, font_size: TerminalFontSize, cx: &mut Context) { + self.font_size = TerminalFontSize::new(font_size.as_u8()); + self.metric_revision = self.metric_revision.wrapping_add(1); + self.zoom_ratchet.set_font_size(self.font_size); + cx.notify(); + } + + pub fn font_size(&self) -> TerminalFontSize { + self.font_size + } + + pub fn metric_revision(&self) -> u64 { + self.metric_revision + } + + pub fn outbound_input_activity_callback( + &self, + cx: &App, + ) -> Option> { + let tx = self.terminal.read(cx).outbound_input_sender(); + Some(std::sync::Arc::new(move || { + let _ = tx.send(TerminalEvent::UserOutboundInput); + })) + } + pub fn set_terminal_id(&mut self, terminal_id: String) { self.terminal_id = terminal_id; } @@ -237,20 +307,25 @@ impl TerminalView { ))) } - pub fn compute_grid_size(window: &mut Window, viewport: Size) -> TerminalGridSize { - let line_height = px(TERMINAL_LINE_HEIGHT); + pub fn compute_grid_size( + window: &mut Window, + viewport: Size, + ) -> Option { + Self::compute_grid_size_for_font_size(window, viewport, TerminalFontSize::DEFAULT) + } + + pub fn compute_grid_size_for_font_size( + window: &mut Window, + viewport: Size, + font_size: TerminalFontSize, + ) -> Option { + let line_height = font_size.line_height(); let cell_width = Self::measure_cell_width(window, line_height); Self::compute_grid_size_with_metrics(viewport, cell_width, line_height) } fn measure_cell_width(window: &mut Window, line_height: Pixels) -> Pixels { - let font = Font { - family: crate::MONO_FONT_FAMILY.into(), - features: FontFeatures::default(), - fallbacks: None, - weight: FontWeight::NORMAL, - style: FontStyle::Normal, - }; + let font = crate::terminal_font(); let font_size = line_height * 0.75; let text_system = window.text_system(); let font_id = text_system.resolve_font(&font); @@ -264,18 +339,24 @@ impl TerminalView { viewport: Size, cell_width: Pixels, line_height: Pixels, - ) -> TerminalGridSize { - let width = viewport.width.max(px(0.0)); - let height = viewport.height.max(px(0.0)); - let columns = (width / cell_width).floor() as usize; - let rows = (height / line_height).floor() as usize; + ) -> Option { + if Self::reject_degenerate_cell(cell_width, line_height) { + return None; + } + let width = (viewport.width / px(1.0)) as f32; + let height = (viewport.height / px(1.0)) as f32; + if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { + return None; + } + let columns = (width / (cell_width / px(1.0)) as f32).floor() as usize; + let rows = (height / (line_height / px(1.0)) as f32).floor() as usize; - TerminalGridSize { + Some(TerminalGridSize { columns, rows, cell_width, line_height, - } + }) } pub fn is_channel_attached(&self, cx: &mut Context) -> bool { @@ -319,15 +400,7 @@ impl TerminalView { self.terminal.read(cx).size().into_remote_size() } - /// Reattach may not produce a paint mismatch, so sync the host PTY once. - pub fn sync_remote_size_after_attach(&mut self, cx: &mut Context) { - let remote_size = self.terminal.read(cx).size().into_remote_size(); - self.last_remote_size = Some(remote_size); - cx.emit(TerminalEvent::RequestResize { - cols: remote_size.0, - rows: remote_size.1, - }); - } + pub fn sync_remote_size_after_attach(&mut self, _cx: &mut Context) {} /// This is called by TerminalElement when the actual bounds of the terminal /// do not match the expected bounds. @@ -338,10 +411,94 @@ impl TerminalView { line_height: Pixels, cx: &mut Context, ) { - let next = Self::compute_grid_size_with_metrics(actual_bounds, cell_width, line_height); + let Some(next) = + Self::compute_grid_size_with_metrics(actual_bounds, cell_width, line_height) + else { + return; + }; + self.apply_grid_size(next, cx); + } + + pub fn pinch_font_size(&self, cx: &App) -> TerminalFontSize { + let _ = cx; + self.font_size + } + + pub fn is_pinch_active(&self, cx: &App) -> bool { + let _ = cx; + self.pinch_claimed + } + + fn reject_degenerate_cell(cell_width: Pixels, line_height: Pixels) -> bool { + let width = (cell_width / px(1.0)) as f32; + let height = (line_height / px(1.0)) as f32; + !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 + } + + fn apply_font_size_step( + &mut self, + font_size: TerminalFontSize, + window: &mut Window, + cx: &mut Context, + ) { + let line_height = font_size.line_height(); + let cell_width = Self::measure_cell_width(window, line_height); + let Some(next) = + Self::compute_grid_size_with_metrics(self.viewport, cell_width, line_height) + else { + return; + }; + self.font_size = font_size; + self.metric_revision = self.metric_revision.wrapping_add(1); self.apply_grid_size(next, cx); } + fn handle_terminal_pinch( + &mut self, + event: &PinchEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event.phase { + TouchPhase::Started => { + self.handle_terminal_pointer_down(cx); + if self.terminal.read(cx).selection_active() { + self.pinch_claimed = false; + return; + } + window.prevent_default(); + self.pinch_claimed = true; + self.scroll_offset_px = 0.0; + self.remote_scroll_offset_px = 0.0; + self.keyboard_top_reveal_px = 0.0; + self.pinch_start_font_size = self.font_size; + self.zoom_ratchet.set_font_size(self.font_size); + self.zoom_ratchet.begin(); + } + TouchPhase::Moved => { + if !self.pinch_claimed { + return; + } + if self.terminal.read(cx).selection_active() { + self.zoom_ratchet.end(); + return; + } + let steps = self.zoom_ratchet.push_delta(event.delta); + for step in steps { + self.apply_font_size_step(step.to, window, cx); + cx.emit(TerminalEvent::PinchStepHaptic); + } + } + TouchPhase::Ended => { + if let Some(settled) = self.take_pending_pinch_settle() { + cx.emit(TerminalEvent::PinchSettled { + font_size: settled.as_u8(), + }); + } + } + } + } + fn apply_grid_size(&mut self, next: TerminalGridSize, cx: &mut Context) { let size = self.terminal.read(cx).size(); let changed = size.columns != next.columns @@ -363,20 +520,11 @@ impl TerminalView { cx.notify(); } - if !changed && self.last_remote_size == Some(next.remote_size()) { - return; - } - self.resize_remote_pty(next, cx); } fn resize_remote_pty(&mut self, next: TerminalGridSize, cx: &mut Context) { let remote_size = next.remote_size(); - if self.last_remote_size == Some(remote_size) { - return; - } - - self.last_remote_size = Some(remote_size); cx.emit(TerminalEvent::RequestResize { cols: remote_size.0, rows: remote_size.1, @@ -520,12 +668,17 @@ impl TerminalView { self.workdir = workdir; } + fn handle_terminal_pointer_down(&mut self, cx: &mut Context) { + self.signal_interaction(cx); + } + fn handle_terminal_press( &mut self, event: &PressEvent, window: &mut Window, cx: &mut Context, ) { + self.handle_terminal_pointer_down(cx); let position = event.position(); let hyperlink = self.terminal.read(cx).hyperlink_at( @@ -570,6 +723,7 @@ impl TerminalView { window: &mut Window, cx: &mut Context, ) { + self.handle_terminal_pointer_down(cx); let position = event.down.position; window.prevent_default(); @@ -615,12 +769,55 @@ impl TerminalView { } pub fn paste_text_from_native_menu(&mut self, text: &str, cx: &mut Context) { - self.terminal.update(cx, |terminal, _| { + let enqueued = self.terminal.update(cx, |terminal, _| { terminal.clear_selection_range(); - terminal.paste_text(text); + terminal.paste_text(text) }); + if enqueued { + self.signal_user_outbound_input(cx); + } cx.notify(); } + + fn signal_interaction(&mut self, cx: &mut Context) { + if self.interaction_signaled { + return; + } + self.interaction_signaled = true; + cx.emit(TerminalEvent::InteractionStarted); + } + + pub fn signal_user_outbound_input(&mut self, cx: &mut Context) { + if self.outbound_input_signaled { + return; + } + self.outbound_input_signaled = true; + cx.emit(TerminalEvent::UserOutboundInput); + } + + pub fn reset_reclaim_epoch(&mut self) { + self.interaction_signaled = false; + self.outbound_input_signaled = false; + } + + pub fn take_pending_pinch_settle(&mut self) -> Option { + if !self.pinch_claimed { + return None; + } + self.pinch_claimed = false; + let settled = self.zoom_ratchet.font_size(); + self.zoom_ratchet.end(); + (settled != self.pinch_start_font_size).then_some(settled) + } + + pub fn deactivate(&mut self, cx: &mut Context) { + if let Some(settled) = self.take_pending_pinch_settle() { + cx.emit(TerminalEvent::PinchSettled { + font_size: settled.as_u8(), + }); + } + self.reset_reclaim_epoch(); + } } impl EventEmitter for TerminalView {} @@ -650,6 +847,12 @@ impl Render for TerminalView { .bg(rgb(self.terminal_theme.background)) .track_focus(&focus_handle) .manual_focus() + .on_pinch(cx.listener(|this, event: &PinchEvent, window, cx| { + this.handle_terminal_pinch(event, window, cx); + })) + .on_pointer_down(cx.listener(|this, _event: &PointerDownEvent, _window, cx| { + this.handle_terminal_pointer_down(cx); + })) .on_press(cx.listener(|this, event: &PressEvent, window, cx| { this.handle_terminal_press(event, window, cx); })) @@ -657,6 +860,7 @@ impl Render for TerminalView { this.handle_terminal_long_press(event, window, cx); })) .on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _window, cx| { + this.signal_interaction(cx); let previous_display_offset = this.display_offset(cx); if this.should_ignore_touch_scroll(event) { cx.notify(); @@ -809,13 +1013,14 @@ impl Render for TerminalView { self.focus_handle.clone(), self.focus_handle.is_focused(window), selection_active, + self.metric_revision, )) } } #[cfg(test)] mod tests { - use super::{TerminalView, keyboard_content_offset_px}; + use super::{TERMINAL_LINE_HEIGHT, TerminalFontSize, TerminalView, keyboard_content_offset_px}; use std::{path::Path, time::Duration}; use crate::terminal::{Terminal, TerminalContent, TerminalEvent, TerminalHyperlinkTarget}; @@ -1005,6 +1210,34 @@ mod tests { }); } + fn pinch_terminal( + window: WindowHandle, + cx: &mut TestAppContext, + phase: TouchPhase, + delta: f32, + ) { + let mut window_cx = VisualTestContext::from_window(*window, cx); + window_cx.simulate_event(gpui::PinchEvent { + position: point(px(160.0), px(120.0)), + delta, + modifiers: Modifiers::default(), + phase, + }); + } + + fn pinch_enter_sequence( + window: WindowHandle, + cx: &mut TestAppContext, + deltas: &[f32], + ) { + pinch_terminal(window, cx, TouchPhase::Started, 0.0); + cx.run_until_parked(); + for delta in deltas { + pinch_terminal(window, cx, TouchPhase::Moved, *delta); + cx.run_until_parked(); + } + } + fn fill_terminal_history(window: WindowHandle, cx: &mut TestAppContext) { window .update(cx, |terminal_view, _window, cx| { @@ -1065,6 +1298,25 @@ mod tests { } target => panic!("expected file hyperlink, got {target:?}"), }, + Some(TerminalEvent::InteractionStarted) => { + match events.next().now_or_never().flatten() { + Some(TerminalEvent::OpenHyperlink(hyperlink)) => match hyperlink.target { + TerminalHyperlinkTarget::File { + path, + relative_path, + line, + column, + } => { + assert_eq!(Path::new(&path), Path::new("/repo/sub/src/main.rs")); + assert_eq!(Path::new(&relative_path), Path::new("src/main.rs")); + assert_eq!(line, Some(12)); + assert_eq!(column, Some(3)); + } + target => panic!("expected file hyperlink, got {target:?}"), + }, + event => panic!("expected hyperlink event, got {event:?}"), + } + } event => panic!("expected hyperlink event, got {event:?}"), } cx.quit(); @@ -1112,29 +1364,20 @@ mod tests { } #[test] - fn sync_remote_size_after_attach_forces_resize_event() { + fn sync_remote_size_after_attach_does_not_emit_uncoordinated_resize_event() { let mut cx = TestAppContext::single(); let window = open_terminal_window(&mut cx); cx.run_until_parked(); let root = window.root(&mut cx).unwrap(); let mut events = cx.events(&root); - let expected_size = window + window .update(&mut cx, |terminal_view, _window, cx| { - let size = terminal_view.terminal.read(cx).size(); - let expected_size = (size.columns as u16, size.rows as u16); - terminal_view.last_remote_size = Some(expected_size); terminal_view.sync_remote_size_after_attach(cx); - expected_size }) .unwrap(); - match events.next().now_or_never().flatten() { - Some(TerminalEvent::RequestResize { cols, rows }) => { - assert_eq!((cols, rows), expected_size); - } - event => panic!("expected forced resize event, got {event:?}"), - } + assert!(events.next().now_or_never().flatten().is_none()); cx.quit(); } @@ -1300,6 +1543,14 @@ mod tests { Some(TerminalEvent::NativePasteMenuRequested { position: actual }) => { assert_eq!(actual, position); } + Some(TerminalEvent::InteractionStarted) => { + match events.next().now_or_never().flatten() { + Some(TerminalEvent::NativePasteMenuRequested { position: actual }) => { + assert_eq!(actual, position); + } + event => panic!("expected native paste menu request, got {event:?}"), + } + } event => panic!("expected native paste menu request, got {event:?}"), } window @@ -1516,6 +1767,14 @@ mod tests { Some(TerminalEvent::ScrollbackPositionChanged { display_offset, .. }) => { assert!(display_offset > 0); } + Some(TerminalEvent::InteractionStarted) => { + match events.next().now_or_never().flatten() { + Some(TerminalEvent::ScrollbackPositionChanged { display_offset, .. }) => { + assert!(display_offset > 0); + } + event => panic!("expected synchronous scrollback event, got {event:?}"), + } + } event => panic!("expected synchronous scrollback event, got {event:?}"), } cx.quit(); @@ -1740,6 +1999,22 @@ mod tests { assert_eq!(keyboard_offset_for_content(&terminal.content()), 0.0); } + #[test] + fn baseline_terminal_font_metrics_preserve_12_over_16_ratio() { + assert_eq!(crate::TERMINAL_FONT_SIZE, px(12.0)); + assert_eq!(px(TERMINAL_LINE_HEIGHT), px(16.0)); + assert_eq!(crate::TERMINAL_FONT_SIZE / px(TERMINAL_LINE_HEIGHT), 0.75); + + let default_font_size = TerminalFontSize::DEFAULT; + assert_eq!(default_font_size.as_u8(), 12); + assert_eq!(default_font_size.logical_units(), px(12.0)); + assert_eq!(default_font_size.line_height(), px(16.0)); + assert_eq!( + default_font_size.logical_units() / default_font_size.line_height(), + 0.75 + ); + } + #[test] fn keyboard_offset_ignores_manual_scrollback_and_alt_screen() { let mut content = @@ -1752,4 +2027,205 @@ mod tests { content.mode = TermMode::ALT_SCREEN; assert_eq!(keyboard_offset_for_content(&content), 0.0); } + + #[test] + fn claimed_pinch_produces_crisp_step_and_haptic_with_resize() { + let mut cx = TestAppContext::single(); + let window = open_terminal_window(&mut cx); + cx.run_until_parked(); + + let root = window.root(&mut cx).unwrap(); + let mut events = cx.events(&root); + pinch_enter_sequence(window, &mut cx, &[0.04, 0.04, 0.03]); + + let font_size = window + .update(&mut cx, |terminal_view, _, cx| { + terminal_view.pinch_font_size(cx) + }) + .unwrap(); + assert_eq!(font_size, TerminalFontSize::new(13)); + assert_eq!(font_size.logical_units(), px(13.0)); + + let mut saw_haptic = false; + let mut saw_resize = false; + while let Some(event) = events.next().now_or_never().flatten() { + match event { + TerminalEvent::PinchStepHaptic => saw_haptic = true, + TerminalEvent::RequestResize { .. } => saw_resize = true, + _ => {} + } + } + assert!(saw_haptic); + assert!(saw_resize); + cx.quit(); + } + + #[test] + fn pinch_emits_zero_pty_bytes_in_normal_alt_screen_and_mouse_modes() { + for with_mouse_tracking in [false, true] { + let mut cx = TestAppContext::single(); + let window = open_terminal_window(&mut cx); + cx.run_until_parked(); + let mut input_rx = if with_mouse_tracking { + attach_mouse_tracking_channel(window, &mut cx) + } else { + attach_terminal_channel(window, &mut cx).0 + }; + window + .update(&mut cx, |terminal_view, _, cx| { + terminal_view.terminal.update(cx, |terminal, _| { + terminal.advance_bytes(b"\x1b[?1049h"); + }); + }) + .unwrap(); + cx.run_until_parked(); + + pinch_enter_sequence(window, &mut cx, &[0.04, 0.04, 0.03]); + pinch_terminal(window, &mut cx, TouchPhase::Ended, 0.0); + cx.run_until_parked(); + + assert!(input_rx.try_recv().is_err()); + let font_size = window + .update(&mut cx, |terminal_view, _, cx| { + terminal_view.pinch_font_size(cx) + }) + .unwrap(); + assert_eq!(font_size, TerminalFontSize::new(13)); + cx.quit(); + } + } + + #[test] + fn active_selection_blocks_pinch_claim_and_mid_pinch_stops_zoom() { + let mut cx = TestAppContext::single(); + let window = open_terminal_window(&mut cx); + cx.run_until_parked(); + window + .update(&mut cx, |terminal_view, _, cx| { + terminal_view.terminal.update(cx, |terminal, _| { + terminal.set_selection_range(0..4); + }); + }) + .unwrap(); + + pinch_enter_sequence(window, &mut cx, &[0.11]); + let font_size = window + .update(&mut cx, |terminal_view, _, cx| { + terminal_view.pinch_font_size(cx) + }) + .unwrap(); + assert_eq!(font_size, TerminalFontSize::DEFAULT); + + window + .update(&mut cx, |terminal_view, _, cx| { + terminal_view.terminal.update(cx, |terminal, _| { + terminal.clear_selection_range(); + }); + }) + .unwrap(); + pinch_enter_sequence(window, &mut cx, &[0.06]); + window + .update(&mut cx, |terminal_view, _, cx| { + terminal_view.terminal.update(cx, |terminal, _| { + terminal.set_selection_range(0..4); + }); + }) + .unwrap(); + pinch_terminal(window, &mut cx, TouchPhase::Moved, 0.11); + cx.run_until_parked(); + + let font_size = window + .update(&mut cx, |terminal_view, _, cx| { + let selection_active = terminal_view.terminal.read(cx).selection_active(); + assert!(selection_active); + terminal_view.pinch_font_size(cx) + }) + .unwrap(); + assert_eq!(font_size, TerminalFontSize::DEFAULT); + cx.quit(); + } + + #[test] + fn pinch_preserves_bottom_follow_and_clamps_scrollback() { + let mut cx = TestAppContext::single(); + let window = open_terminal_window(&mut cx); + cx.run_until_parked(); + fill_terminal_history(window, &mut cx); + window + .update(&mut cx, |terminal_view, _, cx| { + terminal_view.scroll_to_bottom(cx); + }) + .unwrap(); + cx.run_until_parked(); + + pinch_enter_sequence(window, &mut cx, &[0.04, 0.04, 0.03]); + pinch_terminal(window, &mut cx, TouchPhase::Ended, 0.0); + cx.run_until_parked(); + + window + .update(&mut cx, |terminal_view, _, cx| { + let terminal = terminal_view.terminal.read(cx); + assert_eq!(terminal.display_offset(), 0); + assert!( + terminal.display_offset() <= terminal.history_size(), + "scrollback offset must remain within history bounds" + ); + assert_eq!(terminal_view.pinch_font_size(cx), TerminalFontSize::new(13)); + }) + .unwrap(); + cx.quit(); + } + + #[test] + fn pinch_settles_exactly_once_on_end_without_double_persist() { + let mut cx = TestAppContext::single(); + let window = open_terminal_window(&mut cx); + cx.run_until_parked(); + + let root = window.root(&mut cx).unwrap(); + let mut events = cx.events(&root); + pinch_enter_sequence(window, &mut cx, &[0.04, 0.04, 0.03]); + pinch_terminal(window, &mut cx, TouchPhase::Ended, 0.0); + cx.run_until_parked(); + pinch_terminal(window, &mut cx, TouchPhase::Moved, 0.50); + cx.run_until_parked(); + + let mut settled = Vec::new(); + while let Some(event) = events.next().now_or_never().flatten() { + if let TerminalEvent::PinchSettled { font_size } = event { + settled.push(font_size); + } + } + assert_eq!(settled, vec![13]); + cx.quit(); + } + + #[test] + fn metric_revision_increments_on_font_step_and_set_font_size() { + let mut cx = TestAppContext::single(); + let window = open_terminal_window(&mut cx); + cx.run_until_parked(); + + let initial_rev = window + .update(&mut cx, |view, _, _| view.metric_revision()) + .unwrap(); + assert_eq!(initial_rev, 0); + + window + .update(&mut cx, |view, _, cx| { + view.set_font_size(TerminalFontSize::new(14), cx); + }) + .unwrap(); + let after_set = window + .update(&mut cx, |view, _, _| view.metric_revision()) + .unwrap(); + assert_eq!(after_set, 1); + + pinch_enter_sequence(window, &mut cx, &[0.04, 0.04, 0.03]); + let after_pinch = window + .update(&mut cx, |view, _, _| view.metric_revision()) + .unwrap(); + assert!(after_pinch > after_set); + cx.quit(); + } } diff --git a/crates/zedra-terminal/src/zoom_ratchet.rs b/crates/zedra-terminal/src/zoom_ratchet.rs new file mode 100644 index 00000000..84e3d2b2 --- /dev/null +++ b/crates/zedra-terminal/src/zoom_ratchet.rs @@ -0,0 +1,340 @@ +use gpui::Pixels; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct TerminalFontSize(u8); + +impl TerminalFontSize { + pub const DEFAULT: Self = Self(12); + pub const MIN: Self = Self(9); + pub const MAX: Self = Self(24); + + pub const fn new(value: u8) -> Self { + let value = if value < Self::MIN.0 { + Self::MIN.0 + } else if value > Self::MAX.0 { + Self::MAX.0 + } else { + value + }; + Self(value) + } + + pub const fn as_u8(self) -> u8 { + self.0 + } + + pub const fn line_height_f32(self) -> f32 { + self.0 as f32 / 0.75 + } + + pub fn logical_units(self) -> Pixels { + gpui::px(self.0 as f32) + } + + pub fn line_height(self) -> Pixels { + gpui::px(self.line_height_f32()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ZoomStep { + pub from: TerminalFontSize, + pub to: TerminalFontSize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ZoomDirection { + Increase, + Decrease, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TerminalZoomRatchet { + font_size: TerminalFontSize, + active: bool, + direction: Option, + remainder: f32, + reversal_debt: f32, +} + +impl Default for TerminalZoomRatchet { + fn default() -> Self { + Self::new(TerminalFontSize::DEFAULT) + } +} + +impl TerminalZoomRatchet { + pub const STEP_THRESHOLD: f32 = 0.10; + pub const REVERSAL_DEADBAND: f32 = 0.03; + + pub const fn new(font_size: TerminalFontSize) -> Self { + Self { + font_size, + active: false, + direction: None, + remainder: 0.0, + reversal_debt: 0.0, + } + } + + pub const fn font_size(self) -> TerminalFontSize { + self.font_size + } + + pub const fn remainder(self) -> f32 { + self.remainder + } + + pub const fn is_active(self) -> bool { + self.active + } + + pub fn set_font_size(&mut self, font_size: TerminalFontSize) { + self.font_size = font_size; + self.remainder = 0.0; + self.direction = None; + self.reversal_debt = 0.0; + } + + pub fn begin(&mut self) { + self.active = true; + self.remainder = 0.0; + self.direction = None; + self.reversal_debt = 0.0; + } + + pub fn end(&mut self) { + self.active = false; + self.remainder = 0.0; + self.direction = None; + self.reversal_debt = 0.0; + } + + pub fn cancel(&mut self) { + self.end(); + } + + pub fn reset(&mut self) { + self.active = false; + self.remainder = 0.0; + self.direction = None; + self.reversal_debt = 0.0; + } + + pub fn push_delta(&mut self, delta: f32) -> Vec { + if !self.active || should_reject(delta) { + return Vec::new(); + } + ingest_delta(self, delta) + } + + pub fn push_scale(&mut self, scale: f32) -> Vec { + if scale <= 0.0 || !scale.is_finite() { + return Vec::new(); + } + self.push_delta(scale - 1.0) + } +} + +fn should_reject(delta: f32) -> bool { + !delta.is_finite() || delta == 0.0 +} + +fn at_clamp_limits_outward(font_size: TerminalFontSize, direction: ZoomDirection) -> bool { + matches!(direction, ZoomDirection::Increase if font_size == TerminalFontSize::MAX) + || matches!(direction, ZoomDirection::Decrease if font_size == TerminalFontSize::MIN) +} + +fn ingest_delta(ratchet: &mut TerminalZoomRatchet, delta: f32) -> Vec { + let direction = if delta.is_sign_positive() { + ZoomDirection::Increase + } else { + ZoomDirection::Decrease + }; + if at_clamp_limits_outward(ratchet.font_size, direction) { + ratchet.remainder = 0.0; + ratchet.direction = None; + ratchet.reversal_debt = 0.0; + return Vec::new(); + } + if let Some(active_direction) = ratchet.direction { + if active_direction != direction { + ratchet.reversal_debt += delta.abs(); + if ratchet.reversal_debt <= TerminalZoomRatchet::REVERSAL_DEADBAND { + return Vec::new(); + } + let excess = ratchet.reversal_debt - TerminalZoomRatchet::REVERSAL_DEADBAND; + ratchet.direction = Some(direction); + ratchet.remainder = excess; + ratchet.reversal_debt = 0.0; + } else { + if ratchet.reversal_debt != 0.0 { + ratchet.reversal_debt = 0.0; + } + ratchet.remainder += delta.abs(); + } + } else { + ratchet.direction = Some(direction); + ratchet.remainder = delta.abs(); + } + accumulate_steps(ratchet) +} + +fn accumulate_steps(ratchet: &mut TerminalZoomRatchet) -> Vec { + let direction = match ratchet.direction { + Some(direction) => direction, + None => return Vec::new(), + }; + let requested = (ratchet.remainder / TerminalZoomRatchet::STEP_THRESHOLD).floor() as usize; + if requested == 0 { + return Vec::new(); + } + let available = match direction { + ZoomDirection::Increase => { + (TerminalFontSize::MAX.as_u8() - ratchet.font_size.as_u8()) as usize + } + ZoomDirection::Decrease => { + (ratchet.font_size.as_u8() - TerminalFontSize::MIN.as_u8()) as usize + } + }; + if available == 0 { + ratchet.remainder = 0.0; + return Vec::new(); + } + let accepted = requested.min(available); + ratchet.remainder -= accepted as f32 * TerminalZoomRatchet::STEP_THRESHOLD; + if accepted < requested { + ratchet.remainder = 0.0; + } + let mut steps = Vec::with_capacity(accepted); + for _ in 0..accepted { + let from = ratchet.font_size; + let to = TerminalFontSize::new(match direction { + ZoomDirection::Increase => from.as_u8() + 1, + ZoomDirection::Decrease => from.as_u8() - 1, + }); + ratchet.font_size = to; + steps.push(ZoomStep { from, to }); + if to == TerminalFontSize::MAX || to == TerminalFontSize::MIN { + ratchet.remainder = 0.0; + ratchet.direction = None; + ratchet.reversal_debt = 0.0; + } + } + steps +} + +#[cfg(test)] +mod tests { + use super::{TerminalFontSize, TerminalZoomRatchet}; + + #[test] + fn zoom_ratchet_trace_prints_accepted_step_sequence_and_final_font_size() { + let mut ratchet = TerminalZoomRatchet::default(); + ratchet.begin(); + println!( + "[ratchet-trace] initial font size: {:?}", + ratchet.font_size() + ); + let s1 = ratchet.push_delta(0.04); + println!("[ratchet-trace] push_delta(0.04) -> steps: {:?}", s1); + let s2 = ratchet.push_delta(0.04); + println!("[ratchet-trace] push_delta(0.04) -> steps: {:?}", s2); + let s3 = ratchet.push_delta(0.03); + println!("[ratchet-trace] push_delta(0.03) -> steps: {:?}", s3); + println!("[ratchet-trace] final font size: {:?}", ratchet.font_size()); + println!( + "[ratchet-trace] final line height: {:?}", + ratchet.font_size().line_height() + ); + assert_eq!(s3.len(), 1); + assert_eq!(ratchet.font_size(), TerminalFontSize::new(13)); + } + + #[test] + fn failing_first_sequence_004_004_003_emits_exactly_one_12_to_13_step() { + let mut ratchet = TerminalZoomRatchet::default(); + ratchet.begin(); + assert!(ratchet.push_delta(0.04).is_empty()); + assert!(ratchet.push_delta(0.04).is_empty()); + let steps = ratchet.push_delta(0.03); + assert_eq!(steps.len(), 1); + let step = steps[0]; + assert_eq!(step.from, TerminalFontSize::new(12)); + assert_eq!(step.to, TerminalFontSize::new(13)); + assert_eq!(ratchet.font_size(), TerminalFontSize::new(13)); + } + + #[test] + fn reversal_deadband_discards_remainder_and_does_not_oscillate() { + let mut ratchet = TerminalZoomRatchet::default(); + ratchet.begin(); + assert!(ratchet.push_delta(0.06).is_empty()); + let neg_within_deadband = ratchet.push_delta(-0.02); + assert!(neg_within_deadband.is_empty()); + assert!(ratchet.push_delta(-0.02).is_empty()); + assert_eq!(ratchet.font_size(), TerminalFontSize::DEFAULT); + } + + #[test] + fn reversal_beyond_deadband_applies_excess_not_prior_remainder() { + let mut ratchet = TerminalZoomRatchet::default(); + ratchet.begin(); + assert!(ratchet.push_delta(0.08).is_empty()); + let steps = ratchet.push_delta(-0.14); + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].to, TerminalFontSize::new(11)); + } + + #[test] + fn large_finite_delta_emits_bounded_steps_and_preserves_overshoot() { + let mut ratchet = TerminalZoomRatchet::default(); + ratchet.begin(); + let steps = ratchet.push_delta(0.27); + assert_eq!(steps.len(), 2); + assert_eq!(ratchet.font_size(), TerminalFontSize::new(14)); + assert!((ratchet.remainder() - 0.07).abs() < 1e-6); + } + + #[test] + fn upper_and_lower_clamp_stop_outward_movement_and_clear_debt() { + let mut upper = TerminalZoomRatchet::new(TerminalFontSize::MAX); + upper.begin(); + assert!(upper.push_delta(0.50).is_empty()); + assert_eq!(upper.font_size(), TerminalFontSize::MAX); + assert_eq!(upper.remainder(), 0.0); + let down = upper.push_delta(-0.11); + assert_eq!(down.len(), 1); + assert_eq!(upper.font_size(), TerminalFontSize::new(23)); + + let mut lower = TerminalZoomRatchet::new(TerminalFontSize::MIN); + lower.begin(); + assert!(lower.push_delta(-0.50).is_empty()); + assert_eq!(lower.font_size(), TerminalFontSize::MIN); + let up = lower.push_delta(0.11); + assert_eq!(up.len(), 1); + assert_eq!(lower.font_size(), TerminalFontSize::new(10)); + } + + #[test] + fn rejects_nan_and_infinities_without_state_poison() { + let mut ratchet = TerminalZoomRatchet::default(); + ratchet.begin(); + assert!(ratchet.push_delta(f32::NAN).is_empty()); + assert!(ratchet.push_delta(f32::INFINITY).is_empty()); + assert!(ratchet.push_delta(f32::NEG_INFINITY).is_empty()); + assert!(ratchet.push_scale(f32::NAN).is_empty()); + assert!(ratchet.push_scale(0.0).is_empty()); + assert_eq!(ratchet.font_size(), TerminalFontSize::DEFAULT); + assert!(ratchet.push_delta(0.11).len() == 1); + } + + #[test] + fn stale_move_after_end_emits_no_effects() { + let mut ratchet = TerminalZoomRatchet::default(); + ratchet.begin(); + ratchet.end(); + assert!(ratchet.push_delta(0.50).is_empty()); + assert_eq!(ratchet.font_size(), TerminalFontSize::DEFAULT); + } +} diff --git a/crates/zedra/src/active_terminal.rs b/crates/zedra/src/active_terminal.rs index f590da1e..988104f8 100644 --- a/crates/zedra/src/active_terminal.rs +++ b/crates/zedra/src/active_terminal.rs @@ -8,15 +8,18 @@ /// /// Nothing in `zedra-session` is involved — the routing is entirely /// within the `zedra` crate. -use std::sync::{Mutex, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock}; use tokio::sync::mpsc; use tracing::warn; +pub type ActivityCallback = Arc; + #[derive(Clone)] struct ActiveInput { terminal_id: String, sender: mpsc::Sender>, + on_activity: Option, } static ACTIVE_INPUT: OnceLock>> = OnceLock::new(); @@ -25,18 +28,32 @@ fn slot() -> &'static Mutex> { ACTIVE_INPUT.get_or_init(|| Mutex::new(None)) } -/// Register the input channel for the currently-active terminal. -/// -/// Called by `WorkspaceView` whenever `active_terminal_id` changes. pub fn set_active_input(terminal_id: String, sender: mpsc::Sender>) { + set_active_input_with_activity(terminal_id, sender, None); +} + +pub fn set_active_input_with_activity( + terminal_id: String, + sender: mpsc::Sender>, + on_activity: Option, +) { if let Ok(mut slot) = slot().lock() { *slot = Some(ActiveInput { terminal_id, sender, + on_activity, }); } } +pub fn register_active_input_with_activity( + terminal_id: String, + sender: mpsc::Sender>, + on_activity: Option, +) { + set_active_input_with_activity(terminal_id, sender, on_activity); +} + /// Clear the active input channel, but only if `terminal_id` still owns the slot. /// /// A stale close (e.g. an old terminal tearing down after a newer one became @@ -63,7 +80,12 @@ pub fn send_to_active(data: Vec) -> bool { }; match active.sender.try_send(data) { - Ok(()) => true, + Ok(()) => { + if let Some(cb) = &active.on_activity { + cb(); + } + true + } Err(error) => { warn!( terminal_id = active.terminal_id, @@ -77,6 +99,7 @@ pub fn send_to_active(data: Vec) -> bool { #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicBool, Ordering}; use tokio::sync::mpsc::error::TryRecvError; // Single test: the module holds one process-global slot, so parallel tests @@ -87,16 +110,24 @@ mod tests { let (first_tx, mut first_rx) = mpsc::channel(1); let (second_tx, mut second_rx) = mpsc::channel(1); + let activity_fired = Arc::new(AtomicBool::new(false)); + let activity_fired_clone = activity_fired.clone(); set_active_input("first".to_string(), first_tx); - set_active_input("second".to_string(), second_tx); + set_active_input_with_activity( + "second".to_string(), + second_tx, + Some(Arc::new(move || { + activity_fired_clone.store(true, Ordering::SeqCst); + })), + ); - // send_to_active reads the latest registered channel. assert!(send_to_active(b"\t".to_vec())); assert!(matches!( first_rx.try_recv(), Err(TryRecvError::Empty | TryRecvError::Disconnected) )); assert_eq!(second_rx.try_recv(), Ok(b"\t".to_vec())); + assert!(activity_fired.load(Ordering::SeqCst)); // A stale close for a different terminal must not drop the active slot. clear_active_input("first"); diff --git a/crates/zedra/src/app.rs b/crates/zedra/src/app.rs index d2a52391..ac67ad4c 100644 --- a/crates/zedra/src/app.rs +++ b/crates/zedra/src/app.rs @@ -169,10 +169,11 @@ impl ZedraApp { any(target_os = "ios", target_os = "android") ))] { - let workspaces = workspaces.downgrade(); - let web_client_workspaces = workspaces.clone(); + let workspaces_tunnel = workspaces.downgrade(); + let web_client_workspaces = workspaces_tunnel.clone(); + let resize_state_workspaces = workspaces_tunnel.clone(); cx.register_devtool_action("web-tunnel", move |params, _window, cx| { - let Some(workspaces) = workspaces.upgrade() else { + let Some(workspaces) = workspaces_tunnel.upgrade() else { return serde_json::json!({"ok": false, "error": "workspaces dropped"}); }; let url = params @@ -242,6 +243,27 @@ impl ZedraApp { }); serde_json::json!({"ok": true, "slug": slug}) }); + + // Structured resize/pinch observability for the stepped pinch-zoom plan + // (`devtool.sh call terminal-resize-state`). Reports geometry, font size, + // pinch state, coordinator in-flight/pending/last-successful, and reclaim + // epoch flags. Never includes terminal text or user data. + cx.register_devtool_action("terminal-resize-state", move |params, _window, cx| { + let Some(workspaces) = resize_state_workspaces.upgrade() else { + return serde_json::json!({"ok": false, "error": "workspaces dropped"}); + }; + let Some(workspace) = workspaces.read(cx).active().cloned() else { + return serde_json::json!({"ok": false, "error": "no active workspace"}); + }; + let terminal_id = params + .get("terminal_id") + .and_then(|v| v.as_str()) + .map(str::to_string); + let state = workspace + .read(cx) + .terminal_resize_debug_state(terminal_id.as_deref(), cx); + serde_json::json!({"ok": true, "state": state}) + }); } // --- Home view --- diff --git a/crates/zedra/src/lib.rs b/crates/zedra/src/lib.rs index 70f26783..fc2da0ae 100644 --- a/crates/zedra/src/lib.rs +++ b/crates/zedra/src/lib.rs @@ -44,6 +44,7 @@ pub mod sheet_demo_view; pub mod sheet_host_view; pub mod terminal_card; pub mod terminal_panel; +pub mod terminal_resize_coordinator; pub mod terminal_state; pub mod transport_badge; diff --git a/crates/zedra/src/settings.rs b/crates/zedra/src/settings.rs index 3ea5d6af..162f3d2f 100644 --- a/crates/zedra/src/settings.rs +++ b/crates/zedra/src/settings.rs @@ -1,4 +1,7 @@ +// allow: SIZE_OK — app settings schema, preference persistence, and regression test suite +use std::collections::BTreeMap; use std::path::PathBuf; +use std::sync::Mutex; use std::sync::atomic::{AtomicI8, Ordering}; use gpui::{App, Context, Entity, EventEmitter, Global, WeakEntity}; @@ -10,6 +13,42 @@ use crate::theme::{ThemeBundle, ThemePreference}; const STORE_DIR: &str = "zedra"; const SETTINGS_FILE: &str = "settings.json"; +pub const TERMINAL_FONT_SIZE_DEFAULT: u8 = 12; +pub const TERMINAL_FONT_SIZE_MIN: u8 = 9; +pub const TERMINAL_FONT_SIZE_MAX: u8 = 24; + +pub const fn normalize_terminal_font_size(value: u8) -> u8 { + if value < TERMINAL_FONT_SIZE_MIN { + TERMINAL_FONT_SIZE_MIN + } else if value > TERMINAL_FONT_SIZE_MAX { + TERMINAL_FONT_SIZE_MAX + } else { + value + } +} + +fn deserialize_terminal_font_size<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value: Option = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(None); + }; + match value { + serde_json::Value::Number(n) if n.is_i64() => Ok(n.as_i64()), + serde_json::Value::Number(n) if n.is_u64() => { + let v = n.as_u64().unwrap_or(0); + if v > i64::MAX as u64 { + Ok(None) + } else { + Ok(Some(v as i64)) + } + } + _ => Ok(None), + } +} + #[derive(Clone, Debug, Default, Serialize, Deserialize)] struct AppSettings { /// Set when the user picks a theme in Settings; `None` follows the system on next launch. @@ -29,6 +68,30 @@ struct AppSettings { /// `None`/absent = disabled (default-off). #[serde(default, skip_serializing_if = "Option::is_none")] extended_keypad: Option, + #[serde( + default, + deserialize_with = "deserialize_terminal_font_size", + skip_serializing_if = "Option::is_none" + )] + terminal_font_size: Option, + #[serde(flatten)] + extra: BTreeMap, +} + +impl AppSettings { + fn sanitize_extra(&mut self) { + const KNOWN_KEYS: &[&str] = &[ + "theme_preference", + "telemetry_enabled", + "droplet_enabled", + "key_bar_always_visible", + "extended_keypad", + "terminal_font_size", + ]; + for key in KNOWN_KEYS { + self.extra.remove(*key); + } + } } pub enum ThemeStateEvent { @@ -131,7 +194,21 @@ pub fn bundle(cx: &App) -> ThemeBundle { .unwrap_or_else(ThemeBundle::dark) } +#[cfg(test)] +static TEST_DATA_DIRECTORY: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +#[cfg(test)] +fn test_data_directory() -> &'static Mutex> { + TEST_DATA_DIRECTORY.get_or_init(|| Mutex::new(None)) +} + fn data_directory() -> Option { + #[cfg(test)] + if let Some(dir) = test_data_directory().lock().ok().and_then(|g| g.clone()) { + return Some(dir); + } + crate::platform_bridge::bridge() .data_directory() .map(PathBuf::from) @@ -151,15 +228,97 @@ fn read_settings() -> Result { return Ok(AppSettings::default()); } let contents = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; - serde_json::from_str(&contents).map_err(|e| e.to_string()) + let mut settings: AppSettings = serde_json::from_str(&contents).map_err(|e| e.to_string())?; + settings.sanitize_extra(); + Ok(settings) } fn write_settings(settings: &AppSettings) -> Result<(), String> { let path = settings_path().ok_or_else(|| "settings path unavailable".to_string())?; - let contents = serde_json::to_string_pretty(settings).map_err(|e| e.to_string())?; + let mut settings = settings.clone(); + settings.sanitize_extra(); + let contents = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?; std::fs::write(path, contents).map_err(|e| e.to_string()) } +const fn terminal_font_size_from_raw(raw: Option) -> u8 { + let Some(value) = raw else { + return TERMINAL_FONT_SIZE_DEFAULT; + }; + + let clamped = if value < TERMINAL_FONT_SIZE_MIN as i64 { + TERMINAL_FONT_SIZE_MIN + } else if value > TERMINAL_FONT_SIZE_MAX as i64 { + TERMINAL_FONT_SIZE_MAX + } else { + value as u8 + }; + normalize_terminal_font_size(clamped) +} + +fn read_terminal_font_size_pref(settings: &AppSettings) -> u8 { + terminal_font_size_from_raw(settings.terminal_font_size) +} + +static TERMINAL_FONT_SIZE_CACHE: AtomicI8 = AtomicI8::new(-1); + +pub fn read_terminal_font_size() -> u8 { + match read_settings() { + Ok(settings) => read_terminal_font_size_pref(&settings), + Err(err) => { + info!(err = %err, "settings: using default terminal font size"); + TERMINAL_FONT_SIZE_DEFAULT + } + } +} + +pub fn terminal_font_size() -> u8 { + match TERMINAL_FONT_SIZE_CACHE.load(Ordering::Relaxed) { + -1 => { + let size = read_terminal_font_size(); + TERMINAL_FONT_SIZE_CACHE.store(size as i8, Ordering::Relaxed); + size + } + cached => normalize_terminal_font_size(cached as u8), + } +} + +static FONT_SIZE_LISTENERS: Mutex>> = + Mutex::new(Vec::new()); + +pub fn on_terminal_font_size_changed(callback: impl Fn(u8) + Send + Sync + 'static) { + if let Ok(mut listeners) = FONT_SIZE_LISTENERS.lock() { + listeners.push(Box::new(callback)); + } +} + +pub fn set_terminal_font_size(size: u8) -> bool { + let accepted = normalize_terminal_font_size(size); + let mut settings = read_settings().unwrap_or_default(); + let current = read_terminal_font_size_pref(&settings); + if current == accepted { + TERMINAL_FONT_SIZE_CACHE.store(accepted as i8, Ordering::Relaxed); + return false; + } + TERMINAL_FONT_SIZE_CACHE.store(accepted as i8, Ordering::Relaxed); + settings.terminal_font_size = Some(accepted as i64); + if let Err(err) = write_settings(&settings) { + warn!(err = %err, "settings: failed to save terminal font size"); + return false; + } + if let Ok(listeners) = FONT_SIZE_LISTENERS.lock() { + for listener in listeners.iter() { + listener(accepted); + } + } + true +} + +#[cfg(test)] +pub(crate) fn reset_terminal_font_size_cache_for_tests() { + TERMINAL_FONT_SIZE_CACHE.store(-1, Ordering::Relaxed); +} + /// Whether anonymous telemetry is enabled. Absent/unreadable settings default to /// enabled (opt-out model), matching the persisted `None` case. pub fn read_telemetry_enabled() -> bool { @@ -271,9 +430,55 @@ pub fn set_key_bar_always_visible(enabled: bool) { #[cfg(test)] mod tests { - use super::ThemeState; + use std::path::PathBuf; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + + use super::*; use crate::theme::{ThemeBundle, ThemePalette, ThemePreference}; + static TEST_SERIAL_LOCK: Mutex<()> = Mutex::new(()); + + struct TestDirectoryScope { + dir: PathBuf, + _lock: std::sync::MutexGuard<'static, ()>, + } + + impl TestDirectoryScope { + fn new(name: &str) -> Self { + let lock = TEST_SERIAL_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let id = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "zedra_settings_{}_{}_{}", + name, + std::process::id(), + id + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create test temp dir"); + *test_data_directory() + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(dir.clone()); + reset_terminal_font_size_cache_for_tests(); + Self { dir, _lock: lock } + } + + fn settings_file(&self) -> PathBuf { + self.dir.join(STORE_DIR).join(SETTINGS_FILE) + } + } + + impl Drop for TestDirectoryScope { + fn drop(&mut self) { + *test_data_directory() + .lock() + .unwrap_or_else(|e| e.into_inner()) = None; + reset_terminal_font_size_cache_for_tests(); + let _ = std::fs::remove_dir_all(&self.dir); + } + } + #[test] fn default_preference_is_dark() { assert_eq!(ThemePreference::default(), ThemePreference::Dark); @@ -294,4 +499,237 @@ mod tests { ThemePalette::light().bg_primary ); } + + #[test] + fn existing_optional_settings_round_trip_current_schema() { + let settings: AppSettings = serde_json::from_value(serde_json::json!({ + "theme_preference": "light", + "telemetry_enabled": false, + "droplet_enabled": true, + "key_bar_always_visible": true, + "extended_keypad": true, + })) + .expect("known optional settings should load"); + + let serialized = + serde_json::to_value(settings).expect("known optional settings should save"); + assert_eq!(serialized["theme_preference"], "light"); + assert_eq!(serialized["telemetry_enabled"], false); + assert_eq!(serialized["droplet_enabled"], true); + assert_eq!(serialized["key_bar_always_visible"], true); + assert_eq!(serialized["extended_keypad"], true); + } + + #[test] + fn terminal_font_size_defaults_to_12_when_missing() { + let settings: AppSettings = serde_json::from_value(serde_json::json!({ + "telemetry_enabled": true, + })) + .expect("settings missing font size should load"); + assert_eq!(read_terminal_font_size_pref(&settings), 12); + } + + #[test] + fn terminal_font_size_clamps_below_min_to_9() { + for below_min in [-100, -1, 0, 5, 8] { + let settings: AppSettings = serde_json::from_value(serde_json::json!({ + "terminal_font_size": below_min, + })) + .expect("negative/small integers should parse without error"); + assert_eq!(read_terminal_font_size_pref(&settings), 9); + } + } + + #[test] + fn terminal_font_size_clamps_above_max_to_24() { + for above_max in [25, 30, 100, 1000] { + let settings: AppSettings = serde_json::from_value(serde_json::json!({ + "terminal_font_size": above_max, + })) + .expect("oversized integers should parse without error"); + assert_eq!(read_terminal_font_size_pref(&settings), 24); + } + } + + #[test] + fn terminal_font_size_tolerant_deserialization_handles_malformed_values() { + let malformed_cases = vec![ + serde_json::json!("large"), + serde_json::json!("15"), + serde_json::json!({"custom_size": 15}), + serde_json::json!([12, 14]), + serde_json::json!(true), + serde_json::json!(false), + serde_json::json!(null), + serde_json::json!(15.5), + ]; + + for malformed in malformed_cases { + let settings: AppSettings = serde_json::from_value(serde_json::json!({ + "telemetry_enabled": false, + "terminal_font_size": malformed, + "unknown_flag": "preserved", + })) + .expect("malformed font size must never fail entire settings parse"); + + assert_eq!(settings.telemetry_enabled, Some(false)); + assert_eq!(read_terminal_font_size_pref(&settings), 12); + assert_eq!( + settings.extra.get("unknown_flag"), + Some(&serde_json::json!("preserved")) + ); + } + } + + #[test] + fn terminal_font_size_normal_15_round_trips() { + let settings: AppSettings = serde_json::from_value(serde_json::json!({ + "terminal_font_size": 15, + })) + .expect("integer font size 15 should parse"); + + assert_eq!(read_terminal_font_size_pref(&settings), 15); + let serialized = serde_json::to_value(settings).expect("save settings"); + assert_eq!(serialized["terminal_font_size"], 15); + } + + #[test] + fn unknown_keys_preserved_semantically_including_nested_structures() { + let original_json = serde_json::json!({ + "telemetry_enabled": false, + "custom_number": 42, + "custom_text": "hello", + "nested_dict": { + "inner_list": [1, "two", {"deep": true}], + "score": 9.75 + } + }); + + let mut settings: AppSettings = + serde_json::from_value(original_json.clone()).expect("parse settings"); + settings.terminal_font_size = Some(15); + let serialized = serde_json::to_value(settings).expect("serialize settings"); + + assert_eq!(serialized["telemetry_enabled"], false); + assert_eq!(serialized["terminal_font_size"], 15); + assert_eq!(serialized["custom_number"], 42); + assert_eq!(serialized["custom_text"], "hello"); + assert_eq!( + serialized["nested_dict"], + serde_json::json!({ + "inner_list": [1, "two", {"deep": true}], + "score": 9.75 + }) + ); + } + + #[test] + fn flattened_map_cannot_shadow_known_fields() { + let mut settings = AppSettings { + theme_preference: Some(ThemePreference::Light), + telemetry_enabled: Some(true), + terminal_font_size: Some(16), + extra: BTreeMap::new(), + ..Default::default() + }; + settings + .extra + .insert("theme_preference".to_string(), serde_json::json!("dark")); + settings + .extra + .insert("terminal_font_size".to_string(), serde_json::json!(999)); + settings.sanitize_extra(); + + let serialized = serde_json::to_value(&settings).expect("serialize settings"); + assert_eq!(serialized["theme_preference"], "light"); + assert_eq!(serialized["terminal_font_size"], 16); + } + + #[test] + fn setter_writes_only_when_accepted_integer_changes() { + let scope = TestDirectoryScope::new("setter_changed_value"); + assert_eq!(terminal_font_size(), 12); + assert_eq!(set_terminal_font_size(12), false); + assert!(!scope.settings_file().exists()); + + assert_eq!(set_terminal_font_size(15), true); + assert_eq!(terminal_font_size(), 15); + assert!(scope.settings_file().exists()); + let on_disk_15: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(scope.settings_file()).unwrap()).unwrap(); + assert_eq!(on_disk_15["terminal_font_size"], 15); + + assert_eq!(set_terminal_font_size(15), false); + + assert_eq!(set_terminal_font_size(30), true); + assert_eq!(terminal_font_size(), 24); + let on_disk_24: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(scope.settings_file()).unwrap()).unwrap(); + assert_eq!(on_disk_24["terminal_font_size"], 24); + + assert_eq!(set_terminal_font_size(24), false); + assert_eq!(set_terminal_font_size(25), false); + + assert_eq!(set_terminal_font_size(5), true); + assert_eq!(terminal_font_size(), 9); + let on_disk_9: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(scope.settings_file()).unwrap()).unwrap(); + assert_eq!(on_disk_9["terminal_font_size"], 9); + + assert_eq!(set_terminal_font_size(9), false); + assert_eq!(set_terminal_font_size(7), false); + } + + #[test] + fn listener_notified_on_font_size_change() { + let _scope = TestDirectoryScope::new("listener_notify"); + let notified = Arc::new(AtomicUsize::new(0)); + let notified_clone = notified.clone(); + on_terminal_font_size_changed(move |size| { + notified_clone.store(size as usize, Ordering::Relaxed); + }); + + assert_eq!(set_terminal_font_size(18), true); + assert_eq!(notified.load(Ordering::Relaxed), 18); + } + + #[test] + fn manual_qa_channel_with_temp_settings_json() { + let scope = TestDirectoryScope::new("manual_qa"); + let initial_json = serde_json::json!({ + "theme_preference": "light", + "telemetry_enabled": false, + "terminal_font_size": {"malformed": true, "items": [1, 2]}, + "unknown_feature": { + "metadata": "alpha", + "nested_items": ["x", "y", {"deep_flag": true}] + } + }); + std::fs::create_dir_all(scope.settings_file().parent().unwrap()).unwrap(); + std::fs::write( + scope.settings_file(), + serde_json::to_string_pretty(&initial_json).unwrap(), + ) + .unwrap(); + + assert_eq!(read_terminal_font_size(), 12); + assert_eq!(terminal_font_size(), 12); + + assert_eq!(set_terminal_font_size(15), true); + assert_eq!(terminal_font_size(), 15); + + let final_content = std::fs::read_to_string(scope.settings_file()).unwrap(); + let final_value: serde_json::Value = serde_json::from_str(&final_content).unwrap(); + + assert_eq!(final_value["theme_preference"], "light"); + assert_eq!(final_value["telemetry_enabled"], false); + assert_eq!(final_value["terminal_font_size"], 15); + assert_eq!( + final_value["unknown_feature"], + serde_json::json!({ + "metadata": "alpha", + "nested_items": ["x", "y", {"deep_flag": true}] + }) + ); + } } diff --git a/crates/zedra/src/terminal_resize_coordinator.rs b/crates/zedra/src/terminal_resize_coordinator.rs new file mode 100644 index 00000000..1266dee2 --- /dev/null +++ b/crates/zedra/src/terminal_resize_coordinator.rs @@ -0,0 +1,719 @@ +use std::{fmt, num::NonZeroU16}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TerminalGeometry { + columns: NonZeroU16, + rows: NonZeroU16, +} + +impl TerminalGeometry { + pub fn new(columns: u16, rows: u16) -> Result { + let columns = NonZeroU16::new(columns).ok_or(GeometryError::ZeroColumns)?; + let rows = NonZeroU16::new(rows).ok_or(GeometryError::ZeroRows)?; + Ok(Self { columns, rows }) + } + + pub fn from_usize(columns: usize, rows: usize) -> Result { + let columns = u16::try_from(columns).map_err(|_| GeometryError::ColumnsOverflow)?; + let rows = u16::try_from(rows).map_err(|_| GeometryError::RowsOverflow)?; + Self::new(columns, rows) + } + + pub const fn columns(self) -> u16 { + self.columns.get() + } + + pub const fn rows(self) -> u16 { + self.rows.get() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GeometryError { + ZeroColumns, + ZeroRows, + ColumnsOverflow, + RowsOverflow, +} + +impl fmt::Display for GeometryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::ZeroColumns => "terminal columns must be nonzero", + Self::ZeroRows => "terminal rows must be nonzero", + Self::ColumnsOverflow => "terminal columns exceed u16", + Self::RowsOverflow => "terminal rows exceed u16", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for GeometryError {} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct ResizeGeneration(u64); + +impl ResizeGeneration { + pub const INITIAL: Self = Self(0); + + pub const fn value(self) -> u64 { + self.0 + } +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct ReclaimEpoch(u64); + +impl ReclaimEpoch { + pub const INITIAL: Self = Self(0); + + pub const fn value(self) -> u64 { + self.0 + } +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct RequestSequence(u64); + +impl RequestSequence { + pub const INITIAL: Self = Self(0); + + pub const fn value(self) -> u64 { + self.0 + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ResizeReason { + Layout, + PinchStep, + ActivationReclaim, + InteractionReclaim, + PostInputReclaim, + ReconnectReclaim, +} + +impl ResizeReason { + pub const fn is_forced(self) -> bool { + matches!( + self, + Self::ActivationReclaim + | Self::InteractionReclaim + | Self::PostInputReclaim + | Self::ReconnectReclaim + ) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResizeIntent { + geometry: TerminalGeometry, + reason: ResizeReason, + reclaim_epoch: ReclaimEpoch, + generation: ResizeGeneration, + request_id: RequestSequence, +} + +impl ResizeIntent { + pub const fn geometry(&self) -> TerminalGeometry { + self.geometry + } + + pub const fn reason(&self) -> ResizeReason { + self.reason + } + + pub const fn reclaim_epoch(&self) -> ReclaimEpoch { + self.reclaim_epoch + } + + pub const fn generation(&self) -> ResizeGeneration { + self.generation + } + + pub const fn request_id(&self) -> RequestSequence { + self.request_id + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DeliveryOutcome { + Succeeded, + Failed, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct CoordinatorTransition { + abort: Option, + dispatch: Option, +} + +impl CoordinatorTransition { + pub const fn empty() -> Self { + Self { + abort: None, + dispatch: None, + } + } + + pub fn abort_request(&self) -> Option<&ResizeIntent> { + self.abort.as_ref() + } + + pub fn dispatch_request(&self) -> Option<&ResizeIntent> { + self.dispatch.as_ref() + } + + pub fn into_actions(self) -> (Option, Option) { + (self.abort, self.dispatch) + } + + fn dispatch(intent: ResizeIntent) -> Self { + Self { + abort: None, + dispatch: Some(intent), + } + } + + fn abort_and_dispatch(abort: Option, dispatch: ResizeIntent) -> Self { + Self { + abort, + dispatch: Some(dispatch), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CoordinatorError { + GenerationExhausted, + ReclaimEpochExhausted, + RequestSequenceExhausted, +} + +impl fmt::Display for CoordinatorError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::GenerationExhausted => "resize generation exhausted", + Self::ReclaimEpochExhausted => "resize reclaim epoch exhausted", + Self::RequestSequenceExhausted => "resize request sequence exhausted", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for CoordinatorError {} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CoordinatorSnapshot { + generation: ResizeGeneration, + desired: Option, + in_flight: Option, + pending: Option, + last_successful_geometry: Option, +} + +impl CoordinatorSnapshot { + pub const fn generation(&self) -> ResizeGeneration { + self.generation + } + + pub fn desired(&self) -> Option<&ResizeIntent> { + self.desired.as_ref() + } + + pub fn in_flight(&self) -> Option<&ResizeIntent> { + self.in_flight.as_ref() + } + + pub fn pending(&self) -> Option<&ResizeIntent> { + self.pending.as_ref() + } + + pub const fn last_successful_geometry(&self) -> Option { + self.last_successful_geometry + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TerminalResizeCoordinator { + generation: ResizeGeneration, + reclaim_epoch: ReclaimEpoch, + request_sequence: RequestSequence, + desired: Option, + in_flight: Option, + pending: Option, + last_successful_geometry: Option, +} + +impl TerminalResizeCoordinator { + pub const fn new() -> Self { + Self { + generation: ResizeGeneration::INITIAL, + reclaim_epoch: ReclaimEpoch::INITIAL, + request_sequence: RequestSequence::INITIAL, + desired: None, + in_flight: None, + pending: None, + last_successful_geometry: None, + } + } + + pub fn snapshot(&self) -> CoordinatorSnapshot { + CoordinatorSnapshot { + generation: self.generation, + desired: self.desired.clone(), + in_flight: self.in_flight.clone(), + pending: self.pending.clone(), + last_successful_geometry: self.last_successful_geometry, + } + } + + pub fn submit( + &mut self, + geometry: TerminalGeometry, + reason: ResizeReason, + ) -> Result { + if !reason.is_forced() && self.ordinary_is_redundant(geometry) { + return Ok(CoordinatorTransition::empty()); + } + + let (intent, next_sequence, next_epoch) = + self.allocate_intent(geometry, reason, self.generation)?; + self.request_sequence = next_sequence; + self.reclaim_epoch = next_epoch; + self.desired = Some(intent.clone()); + + if self.in_flight.is_some() { + self.pending = Some(intent); + Ok(CoordinatorTransition::empty()) + } else { + self.in_flight = Some(intent.clone()); + Ok(CoordinatorTransition::dispatch(intent)) + } + } + + pub fn complete( + &mut self, + completed: &ResizeIntent, + outcome: DeliveryOutcome, + ) -> CoordinatorTransition { + if self.in_flight.as_ref() != Some(completed) { + return CoordinatorTransition::empty(); + } + + let completed = match self.in_flight.take() { + Some(intent) => intent, + None => return CoordinatorTransition::empty(), + }; + if outcome == DeliveryOutcome::Succeeded { + self.last_successful_geometry = Some(completed.geometry); + } + + let pending = self.pending.take(); + match pending { + Some(next) + if outcome == DeliveryOutcome::Succeeded + && !next.reason.is_forced() + && next.geometry == completed.geometry => + { + self.desired = Some(completed); + CoordinatorTransition::empty() + } + Some(next) => { + self.desired = Some(next.clone()); + self.in_flight = Some(next.clone()); + CoordinatorTransition::dispatch(next) + } + None => { + self.desired = Some(completed); + CoordinatorTransition::empty() + } + } + } + + pub fn reconnect( + &mut self, + geometry: TerminalGeometry, + ) -> Result { + let next_generation = self.next_generation()?; + let (intent, next_sequence, next_epoch) = + self.allocate_intent(geometry, ResizeReason::ReconnectReclaim, next_generation)?; + let abort = self.in_flight.take(); + + self.generation = next_generation; + self.request_sequence = next_sequence; + self.reclaim_epoch = next_epoch; + self.desired = Some(intent.clone()); + self.in_flight = Some(intent.clone()); + self.pending = None; + self.last_successful_geometry = None; + + Ok(CoordinatorTransition::abort_and_dispatch(abort, intent)) + } + + pub fn invalidate_generation(&mut self) -> Result { + let next_generation = self.next_generation()?; + let abort = self.in_flight.take(); + self.generation = next_generation; + self.desired = None; + self.pending = None; + self.last_successful_geometry = None; + Ok(CoordinatorTransition { + abort, + dispatch: None, + }) + } + + pub fn replace_terminal(&mut self) -> Result { + self.invalidate_generation() + } + + fn ordinary_is_redundant(&self, geometry: TerminalGeometry) -> bool { + if let Some(pending) = &self.pending { + pending.geometry == geometry + } else if let Some(in_flight) = &self.in_flight { + in_flight.geometry == geometry + } else { + self.last_successful_geometry == Some(geometry) + } + } + + fn next_generation(&self) -> Result { + self.generation + .0 + .checked_add(1) + .map(ResizeGeneration) + .ok_or(CoordinatorError::GenerationExhausted) + } + + fn allocate_intent( + &self, + geometry: TerminalGeometry, + reason: ResizeReason, + generation: ResizeGeneration, + ) -> Result<(ResizeIntent, RequestSequence, ReclaimEpoch), CoordinatorError> { + let next_sequence = self + .request_sequence + .0 + .checked_add(1) + .map(RequestSequence) + .ok_or(CoordinatorError::RequestSequenceExhausted)?; + let next_epoch = if reason.is_forced() { + self.reclaim_epoch + .0 + .checked_add(1) + .map(ReclaimEpoch) + .ok_or(CoordinatorError::ReclaimEpochExhausted)? + } else { + self.reclaim_epoch + }; + Ok(( + ResizeIntent { + geometry, + reason, + reclaim_epoch: next_epoch, + generation, + request_id: next_sequence, + }, + next_sequence, + next_epoch, + )) + } +} + +impl Default for TerminalResizeCoordinator { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::{ + CoordinatorTransition, DeliveryOutcome, ResizeIntent, ResizeReason, TerminalGeometry, + TerminalResizeCoordinator, + }; + + #[derive(Default)] + struct LocalResizeDedupFixture { + last_emitted: Option, + emitted: Vec, + } + + impl LocalResizeDedupFixture { + fn observe(&mut self, geometry: TerminalGeometry) { + if self.last_emitted == Some(geometry) { + return; + } + self.last_emitted = Some(geometry); + self.emitted.push(geometry); + } + } + + fn geometry(columns: u16, rows: u16) -> TerminalGeometry { + TerminalGeometry::new(columns, rows).expect("test geometry must be valid") + } + + fn submit( + coordinator: &mut TerminalResizeCoordinator, + geometry: TerminalGeometry, + reason: ResizeReason, + ) -> CoordinatorTransition { + coordinator + .submit(geometry, reason) + .expect("test sequence must not exhaust coordinator counters") + } + + fn complete( + coordinator: &mut TerminalResizeCoordinator, + intent: &ResizeIntent, + outcome: DeliveryOutcome, + ) -> CoordinatorTransition { + coordinator.complete(intent, outcome) + } + + #[test] + fn baseline_local_resize_dedup_suppresses_equal_geometry() { + let mut fixture = LocalResizeDedupFixture::default(); + let initial = geometry(80, 24); + fixture.observe(initial); + fixture.observe(initial); + assert_eq!(fixture.emitted, vec![initial]); + } + + #[test] + fn latest_wins_dispatches_a_then_c_when_b_is_replaced() { + let mut coordinator = TerminalResizeCoordinator::new(); + let a = geometry(80, 24); + let b = geometry(60, 20); + let c = geometry(50, 16); + let mut dispatched = Vec::new(); + let first = submit(&mut coordinator, a, ResizeReason::Layout); + dispatched.push( + first + .dispatch_request() + .expect("A must dispatch") + .geometry(), + ); + let _ = submit(&mut coordinator, b, ResizeReason::Layout); + let _ = submit(&mut coordinator, c, ResizeReason::PinchStep); + let in_flight_a = coordinator + .snapshot() + .in_flight() + .cloned() + .expect("A remains blocked"); + let after_a = complete(&mut coordinator, &in_flight_a, DeliveryOutcome::Succeeded); + dispatched.push( + after_a + .dispatch_request() + .expect("latest C must dispatch") + .geometry(), + ); + let in_flight_c = coordinator + .snapshot() + .in_flight() + .cloned() + .expect("C is now in flight"); + let _ = complete(&mut coordinator, &in_flight_c, DeliveryOutcome::Succeeded); + assert_eq!(dispatched, vec![a, c]); + assert_eq!(coordinator.snapshot().last_successful_geometry(), Some(c)); + assert!(coordinator.snapshot().pending().is_none()); + } + + #[test] + fn ordinary_a_b_a_collapses_redundant_final_a_after_success() { + let mut coordinator = TerminalResizeCoordinator::new(); + let a = geometry(80, 24); + let b = geometry(60, 20); + let first = submit(&mut coordinator, a, ResizeReason::Layout); + let request_a = first.dispatch_request().cloned().expect("A must dispatch"); + let _ = submit(&mut coordinator, b, ResizeReason::Layout); + let _ = submit(&mut coordinator, a, ResizeReason::Layout); + let after_a = complete(&mut coordinator, &request_a, DeliveryOutcome::Succeeded); + assert!(after_a.dispatch_request().is_none()); + assert_eq!(coordinator.snapshot().last_successful_geometry(), Some(a)); + assert_eq!( + coordinator.snapshot().desired().map(ResizeIntent::geometry), + Some(a) + ); + } + + #[test] + fn equal_size_forced_reclaims_have_distinct_epoch_identity() { + let mut coordinator = TerminalResizeCoordinator::new(); + let a = geometry(80, 24); + let layout = submit(&mut coordinator, a, ResizeReason::Layout); + let request_layout = layout + .dispatch_request() + .cloned() + .expect("layout must dispatch"); + let _ = complete( + &mut coordinator, + &request_layout, + DeliveryOutcome::Succeeded, + ); + let activation = submit(&mut coordinator, a, ResizeReason::ActivationReclaim); + let request_activation = activation + .dispatch_request() + .cloned() + .expect("activation reclaim must bypass geometry dedup"); + let activation_epoch = request_activation.reclaim_epoch(); + let _ = complete( + &mut coordinator, + &request_activation, + DeliveryOutcome::Succeeded, + ); + let interaction = submit(&mut coordinator, a, ResizeReason::InteractionReclaim); + let request_interaction = interaction + .dispatch_request() + .cloned() + .expect("later interaction reclaim must be retained"); + assert!(request_interaction.reclaim_epoch() > activation_epoch); + assert_ne!( + request_interaction.request_id(), + request_activation.request_id() + ); + } + + #[test] + fn later_forced_reclaim_is_not_erased_by_equal_ordinary_intent() { + let mut coordinator = TerminalResizeCoordinator::new(); + let a = geometry(80, 24); + let first = submit(&mut coordinator, a, ResizeReason::Layout); + let request_a = first.dispatch_request().cloned().expect("A must dispatch"); + let forced = submit(&mut coordinator, a, ResizeReason::ActivationReclaim); + assert!(forced.dispatch_request().is_none()); + let _ = submit(&mut coordinator, a, ResizeReason::Layout); + let after_a = complete(&mut coordinator, &request_a, DeliveryOutcome::Succeeded); + let forced_request = after_a + .dispatch_request() + .cloned() + .expect("forced reclaim must survive ordinary duplicate"); + assert_eq!(forced_request.reason(), ResizeReason::ActivationReclaim); + } + + #[test] + fn failure_does_not_mark_success_and_dispatches_latest_pending_once() { + let mut coordinator = TerminalResizeCoordinator::new(); + let a = geometry(80, 24); + let b = geometry(60, 20); + let first = submit(&mut coordinator, a, ResizeReason::Layout); + let request_a = first.dispatch_request().cloned().expect("A must dispatch"); + let _ = submit(&mut coordinator, b, ResizeReason::PinchStep); + let after_failure = complete(&mut coordinator, &request_a, DeliveryOutcome::Failed); + let request_b = after_failure + .dispatch_request() + .cloned() + .expect("B must dispatch after A fails"); + assert_eq!(coordinator.snapshot().last_successful_geometry(), None); + let after_b = complete(&mut coordinator, &request_b, DeliveryOutcome::Succeeded); + assert!(after_b.dispatch_request().is_none()); + assert_eq!(coordinator.snapshot().last_successful_geometry(), Some(b)); + } + + #[test] + fn stale_completion_cannot_clear_new_generation_work() { + let mut coordinator = TerminalResizeCoordinator::new(); + let a = geometry(80, 24); + let c = geometry(50, 16); + let first = submit(&mut coordinator, a, ResizeReason::Layout); + let request_a = first.dispatch_request().cloned().expect("A must dispatch"); + let reconnect = coordinator + .reconnect(c) + .expect("reconnect must create a new generation"); + let request_c = reconnect + .dispatch_request() + .cloned() + .expect("C must dispatch immediately"); + let stale = complete(&mut coordinator, &request_a, DeliveryOutcome::Succeeded); + assert!(stale.dispatch_request().is_none()); + assert_eq!(coordinator.snapshot().last_successful_geometry(), None); + assert_eq!(coordinator.snapshot().in_flight(), Some(&request_c)); + let _ = complete(&mut coordinator, &request_c, DeliveryOutcome::Succeeded); + assert_eq!(coordinator.snapshot().last_successful_geometry(), Some(c)); + } + + #[test] + fn reconnect_aborts_blocked_a_and_dispatches_latest_c_immediately() { + let mut coordinator = TerminalResizeCoordinator::new(); + let a = geometry(80, 24); + let c = geometry(50, 16); + let first = submit(&mut coordinator, a, ResizeReason::Layout); + let request_a = first.dispatch_request().cloned().expect("A must dispatch"); + let reconnect = coordinator + .reconnect(c) + .expect("reconnect must invalidate the blocked generation"); + assert_eq!(reconnect.abort_request(), Some(&request_a)); + assert_eq!( + reconnect.dispatch_request().map(ResizeIntent::geometry), + Some(c) + ); + assert_ne!( + request_a.generation(), + reconnect.dispatch_request().expect("C").generation() + ); + } + + #[test] + fn terminal_replacement_aborts_old_work_and_clears_success() { + let mut coordinator = TerminalResizeCoordinator::new(); + let a = geometry(80, 24); + let first = submit(&mut coordinator, a, ResizeReason::Layout); + let request_a = first.dispatch_request().cloned().expect("A must dispatch"); + let replacement = coordinator + .replace_terminal() + .expect("replacement must invalidate the generation"); + assert_eq!(replacement.abort_request(), Some(&request_a)); + assert!(replacement.dispatch_request().is_none()); + assert!(coordinator.snapshot().last_successful_geometry().is_none()); + let stale = complete(&mut coordinator, &request_a, DeliveryOutcome::Succeeded); + assert!(stale.dispatch_request().is_none()); + let next = submit(&mut coordinator, a, ResizeReason::Layout); + assert!(next.dispatch_request().is_some()); + } + + #[test] + fn geometry_rejects_zero_and_overflow_without_wrapping() { + assert!(TerminalGeometry::new(0, 24).is_err()); + assert!(TerminalGeometry::new(80, 0).is_err()); + assert!(TerminalGeometry::from_usize(usize::from(u16::MAX) + 1, 24).is_err()); + assert!(TerminalGeometry::from_usize(80, usize::from(u16::MAX) + 1).is_err()); + } + + #[test] + fn deterministic_fake_dispatch_trace_covers_blocked_replacement_and_reconnect_abort() { + let mut coordinator = TerminalResizeCoordinator::new(); + let a = geometry(80, 24); + let b = geometry(60, 20); + let c = geometry(50, 16); + let mut calls = Vec::new(); + let first = submit(&mut coordinator, a, ResizeReason::Layout); + calls.push(first.dispatch_request().expect("A").geometry()); + println!( + "fake-rpc dispatch A={a:?} state={:?}", + coordinator.snapshot() + ); + let _ = submit(&mut coordinator, b, ResizeReason::Layout); + let _ = submit(&mut coordinator, c, ResizeReason::PinchStep); + println!( + "fake-rpc blocked A, replaced B with C state={:?}", + coordinator.snapshot() + ); + let request_a = coordinator.snapshot().in_flight().cloned().expect("A"); + let after_a = complete(&mut coordinator, &request_a, DeliveryOutcome::Succeeded); + calls.push(after_a.dispatch_request().expect("C").geometry()); + println!( + "fake-rpc release A, dispatch C={c:?} state={:?}", + coordinator.snapshot() + ); + let request_c = coordinator.snapshot().in_flight().cloned().expect("C"); + let _ = complete(&mut coordinator, &request_c, DeliveryOutcome::Succeeded); + let reconnect = coordinator.reconnect(c).expect("reconnect"); + println!( + "fake-rpc reconnect abort path state={:?}", + coordinator.snapshot() + ); + assert_eq!(calls, vec![a, c]); + assert!(reconnect.dispatch_request().is_some()); + } +} diff --git a/crates/zedra/src/workspace.rs b/crates/zedra/src/workspace.rs index 0ff05356..0946ed2a 100644 --- a/crates/zedra/src/workspace.rs +++ b/crates/zedra/src/workspace.rs @@ -2502,9 +2502,9 @@ impl Workspace { let session_handle = self.session.handle().clone(); let initial_viewport = self.mainview_viewport(window, cx); - let initial_grid_size = TerminalView::compute_grid_size(window, initial_viewport); - let cols = initial_grid_size.columns; - let rows = initial_grid_size.rows; + let (cols, rows) = TerminalView::compute_grid_size(window, initial_viewport) + .map(|size| (size.columns, size.rows)) + .unwrap_or((0, 0)); let workspace_terminal = self.create_terminal_entity(TERMINAL_PENDING_ID.to_string(), window, cx); @@ -2679,9 +2679,9 @@ impl Workspace { ) { let session_handle = self.session.handle().clone(); let initial_viewport = self.mainview_viewport(window, cx); - let initial_grid_size = TerminalView::compute_grid_size(window, initial_viewport); - let cols = initial_grid_size.columns; - let rows = initial_grid_size.rows; + let (cols, rows) = TerminalView::compute_grid_size(window, initial_viewport) + .map(|size| (size.columns, size.rows)) + .unwrap_or((0, 0)); let workspace_terminal = self.create_terminal_entity(TERMINAL_PENDING_ID.to_string(), window, cx); let pending_entity_id = workspace_terminal.entity_id(); @@ -2974,6 +2974,7 @@ impl Workspace { prev.update(cx, |t, cx| t.deactivate(cx)); } } + entity.update(cx, |terminal, cx| terminal.activate(cx)); self.content.update(cx, |c, cx| { c.set_terminal_subtitle(id, cx); c.set_main_view(entity.into(), cx); @@ -3217,6 +3218,30 @@ impl Workspace { .cloned() } + #[cfg(all( + debug_assertions, + feature = "devtool", + any(target_os = "ios", target_os = "android") + ))] + pub fn terminal_resize_debug_state( + &self, + terminal_id: Option<&str>, + cx: &App, + ) -> serde_json::Value { + let terminal = terminal_id + .and_then(|id| { + self.terminals + .iter() + .find(|t| t.read(cx).terminal_id() == id) + .cloned() + }) + .or_else(|| self.terminals.first().cloned()); + match terminal { + Some(terminal) => terminal.read(cx).resize_debug_state(cx), + None => serde_json::json!({"error": "no terminal"}), + } + } + fn add_to_chat_targets(&self, cx: &mut Context) -> Vec { self.workspace_state .read(cx) diff --git a/crates/zedra/src/workspace_terminal.rs b/crates/zedra/src/workspace_terminal.rs index 26b32f4c..d36adaf6 100644 --- a/crates/zedra/src/workspace_terminal.rs +++ b/crates/zedra/src/workspace_terminal.rs @@ -1,3 +1,4 @@ +use std::num::NonZeroU16; use std::time::Duration; use gpui::{prelude::FluentBuilder as _, *}; @@ -19,6 +20,10 @@ use crate::platform_bridge::{ }; use crate::settings::{ThemeStateEvent, theme_state as theme_entity}; use crate::telemetry::view_telemetry; +use crate::terminal_resize_coordinator::{ + CoordinatorTransition, DeliveryOutcome, ResizeIntent, ResizeReason, TerminalGeometry, + TerminalResizeCoordinator, +}; use crate::terminal_state::TerminalState; use crate::ui::any_drawer_open; use crate::workspace_state::{WorkspaceState, WorkspaceStateEvent}; @@ -28,6 +33,32 @@ const SCROLL_TO_BOTTOM_BUTTON_THRESHOLD_LINES: usize = 10; const SCROLL_TO_BOTTOM_BUTTON_DISMISS_DELAY: Duration = Duration::from_millis(160); const NATIVE_PASTE_MENU_TAP_GAP: f32 = 28.0; +fn workspace_resize_geometry(cols: u16, rows: u16) -> Option { + u32::from(cols) + .try_into() + .ok() + .and_then(NonZeroU16::new) + .zip(u32::from(rows).try_into().ok().and_then(NonZeroU16::new)) + .and_then(|(columns, rows)| TerminalGeometry::new(columns.get(), rows.get()).ok()) +} + +#[cfg(test)] +fn coordinator_geometry_for_test(cols: u16, rows: u16) -> TerminalGeometry { + TerminalGeometry::new(cols, rows).expect("test geometry must be nonzero") +} + +#[cfg(test)] +fn submit_coordinator_for_test( + coordinator: &mut TerminalResizeCoordinator, + cols: u16, + rows: u16, + reason: ResizeReason, +) -> CoordinatorTransition { + coordinator + .submit(coordinator_geometry_for_test(cols, rows), reason) + .expect("test should not exhaust coordinator counters") +} + fn native_paste_menu_anchor(position: Point) -> Point { // Keep the edit menu visibly separated from the long-press finger. point(position.x, position.y - px(NATIVE_PASTE_MENU_TAP_GAP)) @@ -48,6 +79,8 @@ pub struct WorkspaceTerminal { session_handle: SessionHandle, terminal_view: Entity, preview: Entity, + resize_coordinator: TerminalResizeCoordinator, + resize_task: Option>, /// Tracks whether the active terminal is in alt-screen mode (vim, opencode, etc.). /// Updated via AltScreenChanged event — never read via terminal_view.read(cx) in render /// to avoid creating a GPUI dependency that causes re-render cascades. @@ -64,9 +97,59 @@ pub struct WorkspaceTerminal { /// Non-input focus target. Moving focus here drops the terminal keyboard without /// clearing focus entirely (which the terminal input would immediately re-grab). container_focus: FocusHandle, + reclaim_epoch: ReclaimEpochState, _subscriptions: Vec, } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct ReclaimEpochState { + activation_sent: bool, + interaction_sent: bool, + post_input_sent: bool, +} + +impl ReclaimEpochState { + fn reset(&mut self) { + *self = Self::default(); + } + + fn claim_activation(&mut self) -> bool { + if self.activation_sent { + return false; + } + self.activation_sent = true; + true + } + + fn claim_interaction(&mut self) -> bool { + if self.interaction_sent { + return false; + } + self.interaction_sent = true; + true + } + + fn claim_post_input(&mut self) -> bool { + if self.post_input_sent { + return false; + } + self.post_input_sent = true; + true + } + + fn activation_sent(&self) -> bool { + self.activation_sent + } + + fn interaction_sent(&self) -> bool { + self.interaction_sent + } + + fn post_input_sent(&self) -> bool { + self.post_input_sent + } +} + impl WorkspaceTerminal { fn sync_terminal_theme(&mut self, cx: &mut Context) { let terminal_theme = crate::theme::bundle(cx).terminal; @@ -156,9 +239,17 @@ impl WorkspaceTerminal { } pub fn deactivate(&mut self, cx: &mut Context) { + crate::active_terminal::clear_active_input(&self.terminal_id); self.scroll_to_bottom_button_hide_pending = false; self.scroll_to_bottom_button_hide_generation = self.scroll_to_bottom_button_hide_generation.wrapping_add(1); + self.reclaim_epoch.reset(); + self.terminal_view.update(cx, |terminal_view, cx| { + if let Some(settled) = terminal_view.take_pending_pinch_settle() { + crate::settings::set_terminal_font_size(settled.as_u8()); + } + terminal_view.deactivate(cx); + }); hide_native_floating_button(self.scroll_to_bottom_button_id); platform_bridge::hide_native_dictation_preview(self.dictation_preview_id); self.set_scroll_to_bottom_button_visible(false, false, cx); @@ -211,22 +302,26 @@ impl WorkspaceTerminal { let attach_sub = cx.subscribe(&workspace_state, |this, _ws, event, cx| match event { WorkspaceStateEvent::SyncComplete => { info!("received SyncComplete event, attempt to attach input/output channel"); - Self::attach_channel_to_terminal_view( + if Self::attach_channel_to_terminal_view( this.session_handle.clone(), this.terminal_id.clone(), this.terminal_view.clone(), cx, - ); + ) { + this.on_channel_attached(cx); + } } WorkspaceStateEvent::TerminalCreated { id } => { if this.terminal_id == *id { info!("received TerminalCreated event, attempt to attach input/output channel"); - Self::attach_channel_to_terminal_view( + if Self::attach_channel_to_terminal_view( this.session_handle.clone(), this.terminal_id.clone(), this.terminal_view.clone(), cx, - ); + ) { + this.on_channel_attached(cx); + } } } WorkspaceStateEvent::TerminalOpened { id } => { @@ -240,8 +335,17 @@ impl WorkspaceTerminal { }); let initial_viewport = Self::viewport_without_keyboard(initial_viewport); - let terminal_view = - cx.new(|cx| TerminalView::new(terminal_id.clone(), window, initial_viewport, cx)); + let initial_font_size = + zedra_terminal::TerminalFontSize::new(crate::settings::terminal_font_size()); + let terminal_view = cx.new(|cx| { + TerminalView::new_with_font_size( + terminal_id.clone(), + window, + initial_viewport, + initial_font_size, + cx, + ) + }); let workdir = workspace_state.read(cx).workdir.clone(); terminal_view.update(cx, |terminal_view, _cx| { terminal_view.set_workdir(Some(workdir.clone())); @@ -252,13 +356,19 @@ impl WorkspaceTerminal { cx.subscribe(&terminal_view, |this, _terminal, event, cx| match event { TerminalEvent::PreeditChanged => {} TerminalEvent::RequestResize { cols, rows } => { - Self::resize_remote_terminal( - this.session_handle.clone(), - this.terminal_id.clone(), - *cols, - *rows, - cx, - ); + this.submit_resize_request(*cols, *rows, ResizeReason::Layout, cx); + } + TerminalEvent::PinchStepHaptic => { + platform_bridge::trigger_haptic(HapticFeedback::SelectionChanged); + } + TerminalEvent::PinchSettled { font_size } => { + crate::settings::set_terminal_font_size(*font_size); + } + TerminalEvent::InteractionStarted => { + this.on_terminal_interaction(cx); + } + TerminalEvent::UserOutboundInput => { + this.on_user_outbound_input(cx); } TerminalEvent::TitleChanged(title) => { let id = this.terminal_id.clone(); @@ -488,6 +598,9 @@ impl WorkspaceTerminal { session_handle, terminal_view, preview, + resize_coordinator: TerminalResizeCoordinator::new(), + resize_task: None, + reclaim_epoch: ReclaimEpochState::default(), is_alt_screen: false, last_synced_keyboard_inset: px(0.0), scroll_to_bottom_button_id: native_floating_button_id(), @@ -507,23 +620,167 @@ impl WorkspaceTerminal { self.terminal_view.read(cx).input_sender(cx) } + #[cfg(all( + debug_assertions, + feature = "devtool", + any(target_os = "ios", target_os = "android") + ))] + pub fn resize_debug_state(&self, cx: &App) -> serde_json::Value { + let snapshot = self.resize_coordinator.snapshot(); + let (columns, rows) = self.terminal_view.read(cx).remote_size(cx); + let view = self.terminal_view.read(cx); + serde_json::json!({ + "terminal_id": self.terminal_id, + "font_size": view.font_size().as_u8(), + "columns": columns, + "rows": rows, + "pinch_active": view.is_pinch_active(cx), + "coordinator_generation": snapshot.generation().value(), + "desired": snapshot.desired().map(|intent| { + serde_json::json!({ + "columns": intent.geometry().columns(), + "rows": intent.geometry().rows(), + "reason": format!("{:?}", intent.reason()), + "reclaim_epoch": intent.reclaim_epoch().value(), + "request_id": intent.request_id().value(), + }) + }), + "in_flight": snapshot.in_flight().map(|intent| { + serde_json::json!({ + "columns": intent.geometry().columns(), + "rows": intent.geometry().rows(), + "reason": format!("{:?}", intent.reason()), + "request_id": intent.request_id().value(), + }) + }), + "pending": snapshot.pending().map(|intent| { + serde_json::json!({ + "columns": intent.geometry().columns(), + "rows": intent.geometry().rows(), + "reason": format!("{:?}", intent.reason()), + "request_id": intent.request_id().value(), + }) + }), + "last_successful": snapshot.last_successful_geometry().map(|geometry| { + serde_json::json!({ + "columns": geometry.columns(), + "rows": geometry.rows(), + }) + }), + "activation_sent": self.reclaim_epoch.activation_sent(), + "interaction_sent": self.reclaim_epoch.interaction_sent(), + "post_input_sent": self.reclaim_epoch.post_input_sent(), + }) + } + pub fn set_terminal_id(&mut self, terminal_id: String, cx: &mut Context) { self.terminal_id = terminal_id.clone(); self.deactivate(cx); self.terminal_view.update(cx, |terminal_view, _cx| { terminal_view.set_terminal_id(terminal_id); }); + if self.terminal_id == TERMINAL_PENDING_ID { + return; + } + + match self.resize_coordinator.replace_terminal() { + Ok(transition) => { + self.apply_resize_transition(transition, cx); + let (cols, rows) = self.terminal_view.read(cx).remote_size(cx); + self.submit_resize_request(cols, rows, ResizeReason::ReconnectReclaim, cx); + } + Err(error) => { + warn!( + terminal_id = self.terminal_id.as_str(), + "resize coordinator exhausted generations on terminal replacement: {}", error + ); + } + } + } + + pub fn activate(&mut self, cx: &mut Context) { + if self.terminal_id == TERMINAL_PENDING_ID { + return; + } + self.register_active_input(cx); + if !self.reclaim_epoch.claim_activation() { + return; + } + let (cols, rows) = self.terminal_view.read(cx).remote_size(cx); + self.submit_resize_request(cols, rows, ResizeReason::ActivationReclaim, cx); + } + + pub fn on_terminal_interaction(&mut self, cx: &mut Context) { + if self.terminal_id == TERMINAL_PENDING_ID { + return; + } + if !self.reclaim_epoch.claim_interaction() { + return; + } + let (cols, rows) = self.terminal_view.read(cx).remote_size(cx); + self.submit_resize_request(cols, rows, ResizeReason::InteractionReclaim, cx); + } + + pub fn on_user_outbound_input(&mut self, cx: &mut Context) { + if self.terminal_id == TERMINAL_PENDING_ID { + return; + } + if !self.reclaim_epoch.claim_post_input() { + return; + } + let (cols, rows) = self.terminal_view.read(cx).remote_size(cx); + self.submit_resize_request(cols, rows, ResizeReason::PostInputReclaim, cx); + } + pub fn on_channel_attached(&mut self, cx: &mut Context) { + if self.terminal_id == TERMINAL_PENDING_ID { + return; + } + self.register_active_input(cx); let (cols, rows) = self.terminal_view.read(cx).remote_size(cx); - Self::resize_remote_terminal( - self.session_handle.clone(), + self.resubmit_current_size_as_reconnect(cols, rows, cx); + } + + fn register_active_input(&self, cx: &App) { + if self.terminal_id == TERMINAL_PENDING_ID { + return; + } + let Some(sender) = self.terminal_view.read(cx).input_sender(cx) else { + return; + }; + let on_activity = self + .terminal_view + .read(cx) + .outbound_input_activity_callback(cx); + crate::active_terminal::set_active_input_with_activity( self.terminal_id.clone(), - cols, - rows, - cx, + sender, + on_activity, ); } + fn resubmit_current_size_as_reconnect(&mut self, cols: u16, rows: u16, cx: &mut Context) { + if self.terminal_id == TERMINAL_PENDING_ID { + return; + } + let Some(geometry) = workspace_resize_geometry(cols, rows) else { + warn!( + terminal_id = self.terminal_id.as_str(), + cols, rows, "resize: ignoring invalid geometry after attach/reconnect" + ); + return; + }; + match self.resize_coordinator.reconnect(geometry) { + Ok(transition) => self.apply_resize_transition(transition, cx), + Err(error) => { + warn!( + terminal_id = self.terminal_id.as_str(), + "resize coordinator exhausted generations on reconnect: {}", error + ); + } + } + } + fn attach_channel_to_terminal_view( session_handle: SessionHandle, terminal_id: String, @@ -538,7 +795,6 @@ impl WorkspaceTerminal { Ok((input_tx, output_rx)) => { terminal_view.update(cx, |terminal_view, cx| { terminal_view.attach_channel(input_tx, output_rx, cx); - terminal_view.sync_remote_size_after_attach(cx); info!("attached channel to terminal"); }); true @@ -610,37 +866,83 @@ impl WorkspaceTerminal { .detach(); } - fn resize_remote_terminal( - session_handle: SessionHandle, - terminal_id: String, + fn submit_resize_request( + &mut self, cols: u16, rows: u16, + reason: ResizeReason, cx: &mut Context, ) { - if terminal_id == TERMINAL_PENDING_ID { + if self.terminal_id == TERMINAL_PENDING_ID { + return; + } + let Some(geometry) = workspace_resize_geometry(cols, rows) else { + warn!( + terminal_id = self.terminal_id.as_str(), + cols, rows, "resize: ignoring invalid geometry" + ); return; + }; + match self.resize_coordinator.submit(geometry, reason) { + Ok(transition) => self.apply_resize_transition(transition, cx), + Err(error) => { + warn!( + terminal_id = self.terminal_id.as_str(), + "resize coordinator exhausted counters: {}", error + ); + } + } + } + + fn apply_resize_transition( + &mut self, + transition: CoordinatorTransition, + cx: &mut Context, + ) { + // Generation invalidation frees the blocked slot; dropping the stale task + // aborts the obsolete RPC so its completion cannot corrupt the new state. + if transition.abort_request().is_some() { + self.resize_task = None; } + let Some(intent) = transition.dispatch_request().cloned() else { + return; + }; + self.spawn_resize_dispatch(intent, cx); + } - cx.spawn(async move |this, cx| { - match session_handle + fn spawn_resize_dispatch(&mut self, intent: ResizeIntent, cx: &mut Context) { + let session_handle = self.session_handle.clone(); + let terminal_id = self.terminal_id.clone(); + let cols = intent.geometry().columns(); + let rows = intent.geometry().rows(); + self.resize_task = Some(cx.spawn(async move |this, cx| { + let result = session_handle .terminal_resize(&terminal_id, cols, rows) - .await - { - Ok(_) => { - info!(terminal_id, cols, rows, "resized remote terminal"); - if let Err(e) = this.update(cx, |_, cx| cx.notify()) { - warn!("failed to notify from terminal resize task: {}", e); - } - } - Err(error) => { - warn!( - terminal_id, - cols, rows, "failed to resize remote terminal: {}", error - ); - } + .await; + let outcome = if result.is_ok() { + DeliveryOutcome::Succeeded + } else { + DeliveryOutcome::Failed + }; + if let Err(error) = &result { + warn!( + terminal_id, + cols, rows, "failed to resize remote terminal: {}", error + ); + } else { + info!(terminal_id, cols, rows, "resized remote terminal"); } - }) - .detach(); + let dispatch = this + .update(cx, |this, cx| { + let transition = this.resize_coordinator.complete(&intent, outcome); + this.apply_resize_transition(transition, cx); + cx.notify(); + }) + .ok(); + if dispatch.is_none() { + warn!("failed to notify from terminal resize task"); + } + })); } } @@ -765,7 +1067,186 @@ impl Render for WorkspaceTerminal { impl Drop for WorkspaceTerminal { fn drop(&mut self) { + crate::active_terminal::clear_active_input(&self.terminal_id); hide_native_floating_button(self.scroll_to_bottom_button_id); platform_bridge::remove_native_dictation_preview(self.dictation_preview_id); } } + +#[cfg(test)] +mod tests { + use crate::terminal_resize_coordinator::{ + DeliveryOutcome, ResizeReason, TerminalResizeCoordinator, + }; + + use super::{ReclaimEpochState, coordinator_geometry_for_test, submit_coordinator_for_test}; + + #[test] + fn workspace_reconnect_resends_equal_size_after_attach() { + let mut coordinator = TerminalResizeCoordinator::new(); + let size = coordinator_geometry_for_test(80, 24); + let first = submit_coordinator_for_test(&mut coordinator, 80, 24, ResizeReason::Layout); + let layout = first + .dispatch_request() + .cloned() + .expect("layout dispatches"); + let _ = coordinator.complete(&layout, DeliveryOutcome::Succeeded); + let reconnect = coordinator.reconnect(size).expect("reconnect dispatches"); + assert_eq!( + reconnect + .dispatch_request() + .expect("equal-size reclaim dispatches") + .geometry(), + size + ); + } + + #[test] + fn workspace_rapid_alternating_sizes_converge_to_final_geometry() { + let mut coordinator = TerminalResizeCoordinator::new(); + let first = submit_coordinator_for_test(&mut coordinator, 80, 24, ResizeReason::Layout); + let in_flight = first + .dispatch_request() + .cloned() + .expect("initial layout dispatches"); + let _ = submit_coordinator_for_test(&mut coordinator, 60, 20, ResizeReason::PinchStep); + let _ = submit_coordinator_for_test(&mut coordinator, 70, 22, ResizeReason::Layout); + let _ = submit_coordinator_for_test(&mut coordinator, 50, 16, ResizeReason::PinchStep); + let after_release = coordinator.complete(&in_flight, DeliveryOutcome::Succeeded); + let final_intent = after_release + .dispatch_request() + .cloned() + .expect("final size dispatches"); + assert_eq!( + final_intent.geometry(), + coordinator_geometry_for_test(50, 16) + ); + let _ = coordinator.complete(&final_intent, DeliveryOutcome::Succeeded); + assert_eq!( + coordinator.snapshot().last_successful_geometry(), + Some(coordinator_geometry_for_test(50, 16)) + ); + assert!(coordinator.snapshot().pending().is_none()); + } + + #[test] + fn workspace_replacement_invalidates_old_resize_before_new_id() { + let mut coordinator = TerminalResizeCoordinator::new(); + let first = submit_coordinator_for_test(&mut coordinator, 80, 24, ResizeReason::Layout); + let in_flight = first + .dispatch_request() + .cloned() + .expect("initial layout dispatches"); + let replacement = coordinator + .replace_terminal() + .expect("replacement invalidates"); + assert_eq!(replacement.abort_request(), Some(&in_flight)); + assert!(replacement.dispatch_request().is_none()); + let stale = coordinator.complete(&in_flight, DeliveryOutcome::Succeeded); + assert!(stale.dispatch_request().is_none()); + let next = submit_coordinator_for_test(&mut coordinator, 80, 24, ResizeReason::Layout); + assert!(next.dispatch_request().is_some()); + } + + #[test] + fn workspace_activation_reclaims_without_typing() { + let mut epoch = ReclaimEpochState::default(); + let mut coordinator = TerminalResizeCoordinator::new(); + assert!(epoch.claim_activation()); + let transition = + submit_coordinator_for_test(&mut coordinator, 80, 24, ResizeReason::ActivationReclaim); + let intent = transition + .dispatch_request() + .cloned() + .expect("activation reclaims immediately"); + assert_eq!(intent.reason(), ResizeReason::ActivationReclaim); + assert!(!epoch.claim_activation()); + println!( + "safe-reclaim activation dispatch geometry={:?} reason={:?}", + intent.geometry(), + intent.reason() + ); + } + + #[test] + fn workspace_first_touch_reclaims_with_interaction_reason() { + let mut epoch = ReclaimEpochState::default(); + let mut coordinator = TerminalResizeCoordinator::new(); + assert!(epoch.claim_interaction()); + let transition = + submit_coordinator_for_test(&mut coordinator, 80, 24, ResizeReason::InteractionReclaim); + let intent = transition + .dispatch_request() + .cloned() + .expect("first touch reclaims"); + assert_eq!(intent.reason(), ResizeReason::InteractionReclaim); + assert!(!epoch.claim_interaction()); + println!( + "safe-reclaim interaction dispatch geometry={:?} reason={:?}", + intent.geometry(), + intent.reason() + ); + } + + #[test] + fn workspace_first_keystroke_reclaims_once_per_epoch() { + let mut epoch = ReclaimEpochState::default(); + let mut coordinator = TerminalResizeCoordinator::new(); + assert!(epoch.claim_post_input()); + let transition = + submit_coordinator_for_test(&mut coordinator, 80, 24, ResizeReason::PostInputReclaim); + let intent = transition + .dispatch_request() + .cloned() + .expect("first keystroke reclaims"); + assert_eq!(intent.reason(), ResizeReason::PostInputReclaim); + assert!(!epoch.claim_post_input()); + epoch.reset(); + assert!(epoch.claim_post_input()); + println!( + "safe-reclaim post-input dispatch geometry={:?} reason={:?}", + intent.geometry(), + intent.reason() + ); + } + + #[test] + fn workspace_deactivation_resets_reclaim_epoch() { + let mut epoch = ReclaimEpochState::default(); + assert!(epoch.claim_activation()); + assert!(epoch.claim_interaction()); + assert!(epoch.claim_post_input()); + epoch.reset(); + assert!(epoch.claim_activation()); + assert!(epoch.claim_interaction()); + assert!(epoch.claim_post_input()); + } + + #[test] + fn workspace_key_bar_active_input_triggers_post_input_reclaim() { + let mut epoch = ReclaimEpochState::default(); + let mut coordinator = TerminalResizeCoordinator::new(); + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + let called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let called_clone = called.clone(); + crate::active_terminal::set_active_input_with_activity( + "keybar-test".to_string(), + tx, + Some(std::sync::Arc::new(move || { + called_clone.store(true, std::sync::atomic::Ordering::SeqCst); + })), + ); + assert!(crate::active_terminal::send_to_active(b"\r".to_vec())); + assert_eq!(rx.try_recv(), Ok(b"\r".to_vec())); + assert!(called.load(std::sync::atomic::Ordering::SeqCst)); + + assert!(epoch.claim_post_input()); + let transition = + submit_coordinator_for_test(&mut coordinator, 80, 24, ResizeReason::PostInputReclaim); + assert_eq!( + transition.dispatch_request().map(|i| i.reason()), + Some(ResizeReason::PostInputReclaim) + ); + crate::active_terminal::clear_active_input("keybar-test"); + } +} diff --git a/docs/MANUAL_TEST.md b/docs/MANUAL_TEST.md index c5ad16fe..846916b6 100644 --- a/docs/MANUAL_TEST.md +++ b/docs/MANUAL_TEST.md @@ -2402,3 +2402,84 @@ fails and exits. `zedra codex resume` prompts instead. No model call is needed: non-resume arguments forward unchanged. 7. From the app, tap a session open in a desktop terminal: the terminal shows the same prompt. Tap one open nowhere: codex resumes with no prompt. + +## 28. Terminal Pinch Zoom & Safe Resize Reclaim + +Verifies discrete stepped pinch zoom (9-24pt), UIKit touch arbitration, +latest-wins resize coordination, and three-phase safe resize reclaim +(activation, first interaction, first user input) across simulator and physical iOS devices. + +### Setup + +1. Build and run with devtool and debug enabled: + `./scripts/run-ios.sh sim --devtool --debug` (or `device --devtool --debug`). +2. Start log capture: `./scripts/ios-log.sh daemon start`. +3. Bridge devtool: `./scripts/devtool.sh bridge-ios`. +4. Verify bridge: `./scripts/devtool.sh ping`. + +### 1. Stepped Pinch Zoom & Haptic Step Sequence + +1. Open a workspace terminal in Zedra. +2. Inspect initial geometry and font size: + `./scripts/devtool.sh call terminal-resize-state` + Expected: `"font_size": 12`, `"pinch_active": false`, initial `columns`/`rows`. +3. Perform a two-finger pinch outward on the terminal surface. + Expected: + - UIKit `TouchPinchArbitrator` claims the gesture once scale delta exceeds threshold (`Started`), quarantining survivor touches and preventing default scroll. + - For each discrete 1pt integer font size change (`12 -> 13 -> 14`), a crisp selection haptic fires (`platform_bridge::trigger_haptic(SelectionChanged)`). + - The Alacritty grid reflows synchronously at each step without intermediate rendering blur or viewport jitter. + - Log shows `[ratchet-trace] accepted step` and `terminal grid resized columns=... rows=...`. +4. Release the pinch at font size 14. + Expected: + - Exactly one `PinchSettled { font_size: 14 }` event fires on gesture end. + - `terminal-resize-state` shows `"font_size": 14`, `"pinch_active": false`. + - Settings persists the new size to disk (`settings.json` has `"terminal_font_size": 14`). +5. Terminate and restart the app (`./scripts/run-ios.sh sim`). + Expected: newly constructed terminal view opens with font size 14 restored from settings (`TerminalView::new_with_font_size`). + +### 2. Selection Gesture Non-Interference + +1. Long press on output text in the terminal to trigger word selection. +2. Expected: selection highlight appears with native edit menu handles; `selection_active()` is true. +3. While selection is active, place two fingers on the terminal and pinch. + Expected: pinch gesture is ignored (`pinch_claimed` remains false); selection range remains intact without accidental zoom or font changes. +4. Clear selection by tapping an empty cell, then pinch again. + Expected: pinch zoom functions normally. + +### 3. Latest-Wins Resize Coordination (A -> B -> C) + +1. Rapidly alternate terminal orientation or pinch sizes while remote RPCs are in flight. +2. Expected: + - At most one resize RPC in flight and at most one latest pending intent queued. + - Obsolete intermediate requests are replaced in-flight. + - Log markers: + `fake-rpc dispatch A=... state=...` + `fake-rpc blocked A, replaced B with C state=...` + `fake-rpc release A, dispatch C=... state=...` + - Terminal converges cleanly on final geometry C without out-of-order race conditions. + +### 4. Safe Resize Reclaim (Activation, First Touch, First Keystroke) + +1. Open or switch to a terminal in the workspace (`Workspace::switch_terminal`). + Expected: `ActivationReclaim` fires immediately with the latest mobile grid without typing; + log marker `safe-reclaim activation dispatch geometry=... reason=ActivationReclaim`. +2. Tap or touch the terminal surface (scroll or tap). + Expected: first touch triggers `InteractionReclaim` (fired once per activation epoch; does not toggle focus or keyboard); + log marker `safe-reclaim interaction dispatch geometry=... reason=InteractionReclaim`. + Subsequent touches in the same epoch do NOT emit additional reclaim events. +3. Type the first user character on the software or hardware keyboard (or paste / tap key bar accessory). + Expected: first accepted outbound keystroke enqueues bytes and triggers `PostInputReclaim`; + log marker `safe-reclaim post-input dispatch geometry=... reason=PostInputReclaim`. + Subsequent keystrokes in the same epoch do NOT trigger additional reclaims. +4. Deactivate the terminal by switching away to another tab or backgrounding the app. + Expected: `reclaim_epoch.reset()` clears the activation, interaction, and post-input flags. + Returning to the terminal starts a clean new activation epoch. + +### 5. Tmux Caveat: Direct PTY vs `window-size latest` + +- **Direct PTY / Remote Shell**: + Every `terminal_resize` RPC sends `SIGWINCH` directly to the foreground process. Passive touch (`InteractionReclaim`) and activation (`ActivationReclaim`) immediately reclaim mobile dimensions. +- **Tmux Sessions (`window-size latest`)**: + When Zedra attaches to a shared tmux session open on desktop, tmux tracks the active client window size. Safe resize reclaim and post-input convergence are a **best-effort transport separation across independent input and resize RPC channels**—not a strict synchronous host processing barrier. + Passive touch reclaims the Zedra remote PTY geometry without sending synthetic noise. On the first real user keystroke (`PostInputReclaim`), tmux registers client activity and adjusts its `window-size latest` view to the mobile geometry as a best-effort sequence. + Zero synthetic bytes, mouse reports, or tmux commands are ever injected by Zedra. diff --git a/vendor/zed b/vendor/zed index 5955915c..21ab017a 160000 --- a/vendor/zed +++ b/vendor/zed @@ -1 +1 @@ -Subproject commit 5955915c572ea9336d9648a2d286b399298fe5ff +Subproject commit 21ab017a849f1b92dacc14361d315c99412ea4da