diff options
Diffstat (limited to 'src/pane')
| -rw-r--r-- | src/pane/input.rs | 7 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 311 | ||||
| -rw-r--r-- | src/pane/terminal/migration_tests.rs | 382 | ||||
| -rw-r--r-- | src/pane/terminal/windows_recent_fallback.rs | 17 | ||||
| -rw-r--r-- | src/pane/xtgettcap.rs | 270 |
5 files changed, 694 insertions, 293 deletions
diff --git a/src/pane/input.rs b/src/pane/input.rs index 8de216e8..10900c94 100644 --- a/src/pane/input.rs +++ b/src/pane/input.rs @@ -218,7 +218,7 @@ fn ghostty_unshifted_codepoint(key: &crate::input::TerminalKey) -> Option<u32> { fn ghostty_key_from_crossterm_key_code( code: crossterm::event::KeyCode, shifted_codepoint: Option<u32>, -) -> Option<u32> { +) -> Option<crate::ghostty::ffi::GhosttyKey> { use crate::ghostty::ffi; use crossterm::event::KeyCode; @@ -257,7 +257,10 @@ fn ghostty_key_from_crossterm_key_code( } } -fn ghostty_key_from_char(c: char, shifted_codepoint: Option<u32>) -> Option<u32> { +fn ghostty_key_from_char( + c: char, + shifted_codepoint: Option<u32>, +) -> Option<crate::ghostty::ffi::GhosttyKey> { use crate::ghostty::ffi; let base = if let Some(shifted) = shifted_codepoint.and_then(char::from_u32) { diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 1dc5a88d..071bb20a 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -16,6 +16,8 @@ use unicode_width::UnicodeWidthStr; use crate::layout::PaneId; use crate::protocol::CellData; +#[cfg(test)] +mod migration_tests; #[cfg(windows)] mod windows_recent_fallback; @@ -35,7 +37,7 @@ use super::{ AgentOscStateTracker, DefaultColorEvent, DefaultColorEventTracker, DefaultColorOscTracker, DefaultColorQuery, DefaultColorTrackedEvent, OscDebugTracker, }, - xtgettcap::{XtgettcapQueryTracker, XtgettcapResponse}, + xtgettcap::{C1XtgettcapQueryTracker, C1XtgettcapResponse}, }; const DEFAULT_DETECTION_ROWS: usize = 24; @@ -201,11 +203,11 @@ pub(crate) struct GhosttyPaneCore { pub transient_default_color_owner_pgid: Option<u32>, pub default_color_tracker: DefaultColorOscTracker, pub default_color_event_tracker: DefaultColorEventTracker, + c1_xtgettcap_tracker: C1XtgettcapQueryTracker, pub child_default_foreground_changed: bool, pub child_default_background_changed: bool, pub osc_debug_tracker: OscDebugTracker, pub agent_osc_state: AgentOscStateTracker, - pub xtgettcap_query_tracker: XtgettcapQueryTracker, decscusr_tracker: DecscusrTracker, cursor_settle_state: CursorPositionSettleState, windows_powershell_prompt_cwd_reporting: bool, @@ -1154,11 +1156,11 @@ impl GhosttyPaneTerminal { transient_default_color_owner_pgid: None, default_color_tracker: DefaultColorOscTracker::default(), default_color_event_tracker: DefaultColorEventTracker::default(), + c1_xtgettcap_tracker: C1XtgettcapQueryTracker::default(), child_default_foreground_changed: false, child_default_background_changed: false, osc_debug_tracker: OscDebugTracker::default(), agent_osc_state: AgentOscStateTracker::default(), - xtgettcap_query_tracker: XtgettcapQueryTracker::default(), decscusr_tracker: DecscusrTracker::default(), cursor_settle_state: CursorPositionSettleState::default(), windows_powershell_prompt_cwd_reporting: false, @@ -1384,19 +1386,18 @@ impl GhosttyPaneTerminal { let mut terminal_responses = Vec::new(); core.default_color_event_tracker .observe(filtered_bytes.as_ref()); - core.xtgettcap_query_tracker - .observe(filtered_bytes.as_ref()); + core.c1_xtgettcap_tracker.observe(filtered_bytes.as_ref()); + let c1_xtgettcap_responses = core.c1_xtgettcap_tracker.drain_pending(); core.decscusr_tracker.observe(filtered_bytes.as_ref()); let in_progress_default_color_event = core.default_color_event_tracker.in_progress_event(); let default_color_events = core.default_color_event_tracker.drain_pending(); - let xtgettcap_responses = core.xtgettcap_query_tracker.drain_pending(); let write_started = crate::render_prof::timer(); self.write_pty_bytes_with_ordered_responses( &mut core, filtered_bytes.as_ref(), default_color_events, in_progress_default_color_event, - xtgettcap_responses, + c1_xtgettcap_responses, &mut terminal_responses, ); let terminal_bells = core.terminal.take_bell_count(); @@ -1470,25 +1471,32 @@ impl GhosttyPaneTerminal { bytes: &[u8], default_color_events: Vec<DefaultColorTrackedEvent>, in_progress_default_color_event: Option<DefaultColorEvent>, - xtgettcap_responses: Vec<XtgettcapResponse>, + c1_xtgettcap_responses: Vec<C1XtgettcapResponse>, terminal_responses: &mut Vec<Bytes>, ) { - let mut events = Vec::with_capacity(default_color_events.len() + xtgettcap_responses.len()); - events.extend( - default_color_events - .into_iter() - .map(OrderedPtyResponseEvent::DefaultColor), - ); - events.extend( - xtgettcap_responses - .into_iter() - .map(OrderedPtyResponseEvent::Xtgettcap), - ); - events.sort_by_key(OrderedPtyResponseEvent::end_offset); - + // Only legacy C1 replies need merging; ordinary XTGETTCAP remains native. + let mut events: Vec<_> = default_color_events + .into_iter() + .map(OrderedColorOrC1Event::Color) + .chain( + c1_xtgettcap_responses + .into_iter() + .map(OrderedColorOrC1Event::C1), + ) + .collect(); + events.sort_by_key(OrderedColorOrC1Event::end_offset); let mut written = 0; for event in events { let end_offset = event.end_offset().min(bytes.len()); + if matches!(&event, OrderedColorOrC1Event::C1(response) if response.suppress_native) { + // Suppress only the unhook byte's replies, not earlier native queries. + let prefix_end = end_offset.saturating_sub(1).max(written); + if prefix_end > written { + core.terminal.write(&bytes[written..prefix_end]); + terminal_responses.extend(self.drain_pending_pty_responses()); + written = prefix_end; + } + } let mut libghostty_responses = Vec::new(); if end_offset > written { core.terminal.write(&bytes[written..end_offset]); @@ -1496,7 +1504,7 @@ impl GhosttyPaneTerminal { written = end_offset; } match event { - OrderedPtyResponseEvent::DefaultColor(event) => { + OrderedColorOrC1Event::Color(event) => { let replacement = respond_to_default_color_event(core, event.event); if replacement.is_some() { remove_last_matching_libghostty_color_reply( @@ -1507,9 +1515,16 @@ impl GhosttyPaneTerminal { terminal_responses.extend(libghostty_responses); terminal_responses.extend(replacement); } - OrderedPtyResponseEvent::Xtgettcap(response) => { + OrderedColorOrC1Event::C1(response) => { + if response.suppress_native { + libghostty_responses.retain(|reply| { + !reply.starts_with(b"\x1bP1+r") && !reply.starts_with(b"\x1bP0+r") + }); + } terminal_responses.extend(libghostty_responses); - terminal_responses.push(response.bytes); + if !response.suppress_native { + terminal_responses.push(response.bytes); + } } } } @@ -1648,6 +1663,8 @@ impl GhosttyPaneTerminal { cell_height_px: u32, ) -> Vec<Bytes> { if let Ok(mut core) = self.core.lock() { + #[cfg(windows)] + windows_recent_fallback::refresh_if_needed(&mut core); let offset_from_bottom = core .terminal .scrollbar() @@ -1675,6 +1692,12 @@ impl GhosttyPaneTerminal { None }; + #[cfg(windows)] + if core.recent_fallback.usable { + // Track the old viewport's start without pinning trailing blank rows. + core.terminal.track_row(0); + } + let _ = core .terminal .resize(cols, rows, cell_width_px, cell_height_px); @@ -2409,21 +2432,17 @@ fn cursor_state_from_render_state( render_state: &mut crate::ghostty::RenderState, decscusr_tracker: &DecscusrTracker, ) -> Option<TerminalCursorState> { - let cursor = render_state.cursor_viewport().ok()??; + let cursor = render_state.cursor().ok()?; + let viewport = cursor.viewport?; let shape = if decscusr_tracker.cursor_shape_overridden() { - render_state - .cursor_visual_style() - .ok() - .zip(render_state.cursor_blinking().ok()) - .map(|(style, blinking)| decscusr_cursor_shape(style, blinking)) - .unwrap_or(0) + decscusr_cursor_shape(cursor.visual_style, cursor.blinking) } else { 0 }; Some(TerminalCursorState { - x: cursor.x, - y: cursor.y, - visible: render_state.cursor_visible().ok()?, + x: viewport.x, + y: viewport.y, + visible: cursor.visible, shape, }) } @@ -2431,6 +2450,9 @@ fn cursor_state_from_render_state( type VisibleHyperlinks = Vec<((u16, u16), String, String)>; fn ghostty_clear_render_dirty(render_state: &mut crate::ghostty::RenderState, area_height: u16) { + if render_state.rows().is_ok_and(|rows| area_height >= rows) && render_state.clean().is_ok() { + return; + } let Ok(mut row_iterator) = crate::ghostty::RowIterator::new() else { return; }; @@ -2492,16 +2514,11 @@ fn ghostty_collect_dirty_patch( if render_state.update(terminal).is_err() { fallback!("render_state_update_error"); } - let collect_all_rows = match render_state.dirty() { + match render_state.dirty() { Ok(crate::ghostty::Dirty::Clean) => finish!(TerminalDirtyPatchOutcome::Clean), - Ok(crate::ghostty::Dirty::Partial) => false, - // A full dirty state means that every visible row may have changed. It - // is still safe to send this as a bounded patch: the client replaces - // only this pane's viewport, rather than falling back to the whole - // shell surface. - Ok(crate::ghostty::Dirty::Full) => true, + Ok(crate::ghostty::Dirty::Partial | crate::ghostty::Dirty::Full) => {} Err(_) => fallback!("dirty_read_error"), - }; + } let colors = render_state.colors().ok(); let default_bg = colors @@ -2527,77 +2544,73 @@ fn ghostty_collect_dirty_patch( let mut grapheme_bytes = Vec::new(); let mut symbol_scratch = String::new(); let mut patch_rows = Vec::new(); - let mut y = 0u16; - while y < area_height && rows.next() { - let Ok(dirty) = rows.dirty() else { - fallback!("row_dirty_read_error"); + while let Some(y) = rows.next_dirty() { + if y >= area_height { + break; + } + match rows.selection() { + Ok(None) => {} + Ok(Some(_)) => fallback!("row_selection_present"), + Err(_) => fallback!("row_selection_error"), + } + let Ok(mut cells) = rows.populate_cells(&mut row_cells) else { + fallback!("populate_cells_error"); }; - if collect_all_rows || dirty { - match rows.selection() { - Ok(None) => {} - Ok(Some(_)) => fallback!("row_selection_present"), - Err(_) => fallback!("row_selection_error"), - } - let Ok(mut cells) = rows.populate_cells(&mut row_cells) else { - fallback!("populate_cells_error"); + let mut patch_cells = Vec::with_capacity(usize::from(area_width)); + let mut x = 0u16; + while x < area_width && cells.next() { + let Ok(basic) = cells.basic_data() else { + fallback!("basic_data_error"); }; - let mut patch_cells = Vec::with_capacity(usize::from(area_width)); - let mut x = 0u16; - while x < area_width && cells.next() { - let Ok(basic) = cells.basic_data() else { - fallback!("basic_data_error"); - }; - if basic.has_hyperlink { - fallback!("hyperlink_present"); - } - let style = ghostty_cell_style( - &cells, - &basic, - default_fg, - default_bg, - resolved_fg, - resolved_bg, - palette_overrides.as_ref(), - ); - let symbol = match ghostty_buffer_symbol_into( - &cells, - basic.wide, - hide_kitty_placeholders, - &mut grapheme_bytes, - &mut symbol_scratch, - ) { - Ok(symbol) => symbol.to_owned(), - Err(_) => ghostty_blank_symbol_for_width(basic.wide).to_owned(), - }; - patch_cells.push(cell_data_from_style(symbol, style)); - x += 1; - } - while x < area_width { - patch_cells.push(blank_cell_data(default_fg, default_bg)); - x += 1; + if basic.has_hyperlink { + fallback!("hyperlink_present"); } - patch_rows.push((y, patch_cells)); + let style = ghostty_cell_style( + &cells, + &basic, + default_fg, + default_bg, + resolved_fg, + resolved_bg, + palette_overrides.as_ref(), + ); + let symbol = match ghostty_buffer_symbol_into( + &cells, + basic.wide, + hide_kitty_placeholders, + &mut grapheme_bytes, + &mut symbol_scratch, + ) { + Ok(symbol) => symbol.to_owned(), + Err(_) => ghostty_blank_symbol_for_width(basic.wide).to_owned(), + }; + patch_cells.push(cell_data_from_style(symbol, style)); + x += 1; } - y += 1; + while x < area_width { + patch_cells.push(blank_cell_data(default_fg, default_bg)); + x += 1; + } + patch_rows.push((y, patch_cells)); } // Nothing above mutates dirty state. Only clear it after every row has // been collected successfully, so a safety fallback leaves the next // collection with the same information. - let dirty_ys: std::collections::HashSet<u16> = patch_rows.iter().map(|(row, _)| *row).collect(); - if !dirty_ys.is_empty() { + if !patch_rows.is_empty() { let Ok(mut clear_row_iterator) = crate::ghostty::RowIterator::new() else { fallback!("clear_row_iterator_new_error"); }; let Ok(mut clear_rows) = render_state.populate_row_iterator(&mut clear_row_iterator) else { fallback!("clear_populate_rows_error"); }; - let mut clear_y = 0u16; - while clear_y < area_height && clear_rows.next() { - if dirty_ys.contains(&clear_y) && clear_rows.clear_dirty().is_err() { + while let Some(y) = clear_rows.next_dirty() { + if y >= area_height { + break; + } + if clear_rows.clear_dirty().is_err() { fallback!("clear_dirty_error"); } - clear_y += 1; } } if render_state @@ -3182,17 +3195,16 @@ fn ghostty_cell_style( style.add_modifier(modifiers) } -#[derive(Debug)] -enum OrderedPtyResponseEvent { - DefaultColor(DefaultColorTrackedEvent), - Xtgettcap(XtgettcapResponse), +enum OrderedColorOrC1Event { + Color(DefaultColorTrackedEvent), + C1(C1XtgettcapResponse), } -impl OrderedPtyResponseEvent { +impl OrderedColorOrC1Event { fn end_offset(&self) -> usize { match self { - Self::DefaultColor(event) => event.end_offset, - Self::Xtgettcap(response) => response.end_offset, + Self::Color(event) => event.end_offset, + Self::C1(response) => response.end_offset, } } } @@ -5921,6 +5933,55 @@ mod tests { } #[test] + fn process_pty_bytes_returns_fragmented_c1_xtgettcap_once_in_order() { + for query in [ + b"\x90+q5463;524742\x9c".as_slice(), + b"\x1bP+q5463;524742\x9c".as_slice(), + b"\x90+q5463;524742\x1b\\".as_slice(), + ] { + for fragmented in [false, true] { + let (tx, mut rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + pane.apply_host_terminal_theme(crate::terminal_theme::TerminalTheme { + background: Some(crate::terminal_theme::RgbColor { + r: 0, + g: 0x2b, + b: 0x36, + }), + ..Default::default() + }); + let mut replies = pane + .process_pty_bytes(pane_id, 0, b"\x1b]11;?\x07", &tx) + .terminal_responses; + for chunk in query.chunks(if fragmented { 1 } else { query.len() }) { + replies.extend( + pane.process_pty_bytes(pane_id, 0, chunk, &tx) + .terminal_responses, + ); + } + replies.extend( + pane.process_pty_bytes(pane_id, 0, b"\x1b]11;?\x1b\\\x1bP+q5375\x1b\\", &tx) + .terminal_responses, + ); + assert_eq!( + replies, + vec![ + Bytes::from_static(b"\x1b]11;rgb:0000/2b2b/3636\x1b\\"), + expected_xtgettcap_response("5463", None), + expected_xtgettcap_response("524742", Some(b"8")), + Bytes::from_static(b"\x1b]11;rgb:0000/2b2b/3636\x1b\\"), + expected_xtgettcap_response("5375", None), + ], + "query={query:?}, fragmented={fragmented}" + ); + assert!(rx.try_recv().is_err()); + } + } + } + + #[test] fn process_pty_bytes_returns_split_xtgettcap_query_response() { let (tx, mut rx) = mpsc::channel(4); let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); @@ -5930,10 +5991,12 @@ mod tests { let result = pane.process_pty_bytes(pane_id, 0, b"\x1bP+q4", &tx); assert!(result.terminal_responses.is_empty()); assert!(rx.try_recv().is_err()); - let result = pane.process_pty_bytes(pane_id, 0, b"D73\x1b", &tx); + let result = pane.process_pty_bytes(pane_id, 0, b"d73", &tx); assert!(result.terminal_responses.is_empty()); assert!(rx.try_recv().is_err()); - let result = pane.process_pty_bytes(pane_id, 0, b"\\", &tx); + // libghostty unhooks DCS on ESC, before the final ST backslash. + // Splitting ST must not lose the reply or emit it again on completion. + let result = pane.process_pty_bytes(pane_id, 0, b"\x1b", &tx); assert_eq!( result.terminal_responses, @@ -5943,6 +6006,9 @@ mod tests { )] ); assert!(rx.try_recv().is_err()); + let result = pane.process_pty_bytes(pane_id, 0, b"\\", &tx); + assert!(result.terminal_responses.is_empty()); + assert!(rx.try_recv().is_err()); } #[test] @@ -6082,6 +6148,41 @@ mod tests { } #[test] + fn process_pty_bytes_orders_default_color_reset_reply_before_xtgettcap() { + let (tx, mut rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + pane.apply_host_terminal_theme(crate::terminal_theme::TerminalTheme { + background: Some(crate::terminal_theme::RgbColor { + r: 0x00, + g: 0x2b, + b: 0x36, + }), + ..Default::default() + }); + + let result = pane.process_pty_bytes( + pane_id, + 0, + b"\x1b]11;#112233\x07\x1b]111\x07\x1b]11;?\x1b", + &tx, + ); + assert!(result.terminal_responses.is_empty()); + let result = pane.process_pty_bytes(pane_id, 0, b"\\\x1bP+q436f\x1b\\", &tx); + + assert_eq!( + result.terminal_responses, + vec![ + Bytes::from_static(b"\x1b]11;rgb:0000/2b2b/3636\x1b\\"), + // Co is provided by the native map, beyond our old eight keys. + expected_xtgettcap_response("436F", Some(b"256")), + ] + ); + assert!(rx.try_recv().is_err()); + } + + #[test] fn process_pty_bytes_ignores_unknown_and_unsupported_xtgettcap_queries() { let (tx, mut rx) = mpsc::channel(4); let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); diff --git a/src/pane/terminal/migration_tests.rs b/src/pane/terminal/migration_tests.rs new file mode 100644 index 00000000..94d78a77 --- /dev/null +++ b/src/pane/terminal/migration_tests.rs @@ -0,0 +1,382 @@ +//! Bounded semantic migration gates. No opaque IDs, timing, or render scheduling +//! decisions enter this oracle. Keep the same runner for old/candidate captures. +use super::*; + +struct Harness { + pane: PaneTerminal, + tx: mpsc::Sender<Bytes>, + width: u16, + height: u16, + effects: Effects, +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct Effects { + replies: Vec<u8>, + clipboard: Vec<Vec<u8>>, + bells: u32, + cwd: Vec<std::path::PathBuf>, +} + +#[derive(Debug, PartialEq, Eq)] +struct Observation { + geometry: (u16, u16), + cells: Vec<CellData>, + text_rows: Vec<crate::ghostty::ScreenTextRow>, + links: Vec<((u16, u16), String, String)>, + cursor: TerminalCursorState, + input: InputState, + visible: String, + recent: TerminalReadSnapshot, + detection: String, + title: Option<String>, +} + +impl Harness { + fn new(width: u16, height: u16) -> Self { + let (tx, _rx) = mpsc::channel(16); + let terminal = crate::ghostty::Terminal::new(width, height, 256).unwrap(); + Self { + pane: PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap()), + tx, + width, + height, + effects: Effects::default(), + } + } + + fn write(&mut self, bytes: &[u8]) { + let result = self + .pane + .process_pty_bytes(PaneId::from_raw(1), 0, bytes, &self.tx); + for reply in result.terminal_responses { + self.effects.replies.extend_from_slice(&reply); + } + self.effects.clipboard.extend(result.clipboard_writes); + self.effects.bells += u32::from(result.terminal_bells); + self.effects.cwd.extend(result.reported_cwd); + } + + fn resize(&mut self, width: u16, height: u16) { + for reply in self.pane.resize(height, width, 8, 16) { + self.effects.replies.extend_from_slice(&reply); + } + self.width = width; + self.height = height; + } + + fn full_cells(&self) -> Vec<CellData> { + // Read the frame buffer, not TestBackend's diff output (which may skip + // wide spacer cells). This is the independent full-render oracle. + let backend = ratatui::backend::TestBackend::new(self.width, self.height); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + let mut cells = Vec::new(); + terminal + .draw(|frame| { + self.pane + .render(frame, Rect::new(0, 0, self.width, self.height), false); + cells = frame + .buffer_mut() + .content + .iter() + .map(CellData::from_ratatui_cell) + .collect(); + }) + .unwrap(); + cells + } + + fn cursor(&self) -> Option<TerminalCursorState> { + // Compare terminal semantics, not Windows' time-based cursor settling policy. + current_cursor_state(&mut self.pane.ghostty.core.lock().unwrap()) + } + + fn observe(&self) -> Observation { + let (_, cols, text_rows) = self.pane.screen_text_snapshot().unwrap(); + assert_eq!(cols, self.width); + Observation { + geometry: (self.width, self.height), + cells: self.full_cells(), + text_rows, + links: self + .pane + .visible_hyperlinks(Rect::new(0, 0, self.width, self.height)), + cursor: self.cursor().unwrap(), + input: self.pane.input_state().unwrap(), + visible: self.pane.visible_text(), + recent: self.pane.recent_text_snapshot(32), + detection: self.pane.detection_text(), + title: self.pane.terminal_title(), + } + } +} + +#[test] +fn short_streams_are_invariant_at_every_byte_boundary() { + let fixtures: &[&[u8]] = &[ + "a界e\u{301}🇯🇵!".as_bytes(), + b"a\x1b[31;1mB\x1b[0m\x1b[2;3HZ\x1b[6n\x1b[?2004h", + b"\x1b]8;;https://example.test/a\x1b\\link\x1b]8;;\x1b\\!", + b"\x1b]52;c;aGk=\x07\x1b]2;migration\x1b\\\x07", + b"\x1bP+q5463\x1b\\\x1bP+q6E6F7065\x9c\x1b[6n", + ]; + for bytes in fixtures { + let mut whole = Harness::new(16, 4); + whole.write(bytes); + let expected = whole.observe(); + for split in 0..=bytes.len() { + let mut fragmented = Harness::new(16, 4); + fragmented.write(&bytes[..split]); + fragmented.write(&bytes[split..]); + assert_eq!( + fragmented.observe(), + expected, + "split {split}, bytes {bytes:?}" + ); + assert_eq!( + fragmented.effects, whole.effects, + "effects at split {split}" + ); + } + let mut bytewise = Harness::new(16, 4); + for byte in bytes.iter() { + bytewise.write(std::slice::from_ref(byte)); + } + assert_eq!(bytewise.observe(), expected); + assert_eq!(bytewise.effects, whole.effects); + } +} + +const MIXED: &str = "ab\x1b[1;38;2;12;34;56m界e\u{301}\x1b[0m\x1b]8;;https://example.test/reflow\x1b\\🇯🇵xyz\x1b]8;;\x1b\\\r\nnext カタカナ end"; + +#[test] +fn mixed_reflow_reads_are_stable_and_chunk_independent() { + let mut whole = Harness::new(12, 5); + let mut fragmented = Harness::new(12, 5); + whole.write(MIXED.as_bytes()); + for chunk in MIXED.as_bytes().chunks(3) { + fragmented.write(chunk); + } + for (width, height) in [(12, 5), (8, 4), (17, 6), (9, 5), (12, 5)] { + whole.resize(width, height); + fragmented.resize(width, height); + let expected = whole.observe(); + assert_eq!(whole.observe(), expected, "reads must not mutate semantics"); + assert_eq!(fragmented.observe(), expected); + assert!( + !expected.links.is_empty(), + "fixture must retain a visible link" + ); + } +} + +#[test] +fn incremental_rows_reconstruct_full_render() { + let mut incremental = Harness::new(12, 5); + let mut full = Harness::new(12, 5); + let mut retained = vec![CellData::from_ratatui_cell(&ratatui::buffer::Cell::default()); 60]; + // Independent terminals: full render must not consume incremental dirty state. + for bytes in [ + b"".as_slice(), + "ab\x1b[1;31m界e\u{301}\x1b[0m\r\nnext".as_bytes(), + b"\x1b[2;2HZ", + b"\x1b[1;1HA\x1b[5;8HB", + b"\x1b]4;1;rgb:12/34/56\x07", + b"\x1b[3;4H", + b"\x1b[?1049hALT", + b"\x1b[?1049l", + ] { + incremental.write(bytes); + full.write(bytes); + match incremental.pane.collect_dirty_patch(12, 5) { + TerminalDirtyPatchOutcome::Clean => {} + TerminalDirtyPatchOutcome::Patch(patch) => { + for (row, cells) in patch.rows { + assert_eq!(cells.len(), 12); + let start = usize::from(row) * 12; + retained[start..start + 12].clone_from_slice(&cells); + } + } + TerminalDirtyPatchOutcome::Fallback => { + panic!("bounded text fixture unexpectedly fell back") + } + } + assert_eq!(retained, full.full_cells(), "after {bytes:?}"); + assert_eq!(incremental.cursor(), full.cursor()); + } +} + +#[test] +fn sparse_dirty_patches_preserve_coordinates_and_clipped_rows() { + for height in [3, 6] { + let mut terminal = Harness::new(8, 6); + terminal.write(b"\x1b[2;3H"); + terminal.pane.collect_dirty_patch(8, 6); + assert!(matches!( + terminal.pane.collect_dirty_patch(8, 6), + TerminalDirtyPatchOutcome::Clean + )); + terminal.write(b"\x1b[2;3HX\x1b[5;4HY"); + let TerminalDirtyPatchOutcome::Patch(patch) = terminal.pane.collect_dirty_patch(8, height) + else { + panic!("expected sparse patch"); + }; + let expected_rows = if height == 3 { vec![1] } else { vec![1, 4] }; + assert_eq!( + patch.rows.iter().map(|(y, _)| *y).collect::<Vec<_>>(), + expected_rows + ); + assert!(patch.rows.iter().all(|(_, cells)| cells.len() == 8)); + + let core = terminal.pane.ghostty.core.lock().unwrap(); + let mut iterator = crate::ghostty::RowIterator::new().unwrap(); + let mut rows = core + .render_state + .populate_row_iterator(&mut iterator) + .unwrap(); + let mut y = 0; + while rows.next() { + assert_eq!(rows.dirty().unwrap(), height == 3 && y == 4); + y += 1; + } + assert_eq!(y, 6); + } +} + +#[test] +fn dirty_patch_fallback_keeps_previously_collected_rows_dirty() { + let mut terminal = Harness::new(8, 6); + terminal.write(b"\x1b[2;3H"); + terminal.pane.collect_dirty_patch(8, 6); + terminal.write(b"\x1b[2;3HX\x1b[5;4H\x1b]8;;https://example.test\x1b\\Y\x1b]8;;\x1b\\"); + assert!(matches!( + terminal.pane.collect_dirty_patch(8, 6), + TerminalDirtyPatchOutcome::Fallback + )); + let core = terminal.pane.ghostty.core.lock().unwrap(); + let mut iterator = crate::ghostty::RowIterator::new().unwrap(); + let mut rows = core + .render_state + .populate_row_iterator(&mut iterator) + .unwrap(); + let mut dirty = Vec::new(); + let mut y = 0; + while rows.next() { + if rows.dirty().unwrap() { + dirty.push(y); + } + y += 1; + } + assert_eq!(dirty, vec![1, 4]); +} + +#[test] +fn complete_history_replay_supports_plain_append() { + // ANSI history is text restoration, not a parser/cursor snapshot. End at a + // non-wrapping printable cell with SGR and OSC8 closed; no pending tab/CSI. + let mut source = Harness::new(24, 4); + source.write(b"\x1b[31mred\x1b[0m\r\nplain"); + let ansi = source.pane.recent_unwrapped_ansi(32); + let mut restored = Harness::new(24, 4); + restored.pane.ghostty.seed_history_ansi(&ansi); + assert_eq!( + restored.pane.recent_text_snapshot(32), + source.pane.recent_text_snapshot(32) + ); + // Establish the documented live-output boundary explicitly instead of + // requiring history formatting to restore arbitrary cursor/SGR state. + for terminal in [&mut source, &mut restored] { + terminal.write(b"\x1b[0m\r\nappended"); + } + assert_eq!( + restored.pane.recent_text_snapshot(32), + source.pane.recent_text_snapshot(32) + ); + assert!(restored.effects.clipboard.is_empty()); + assert!(restored.effects.replies.is_empty()); +} + +#[test] +#[ignore = "non-gating release render scaling profile"] +fn render_scale_profile_sparse_dirty_patches() { + for count in [1, 15] { + let mut panes = (0..count).map(|_| Harness::new(80, 24)).collect::<Vec<_>>(); + for pane in &mut panes { + for line in 0..40 { + pane.write(format!("{line:04} populated 界 terminal row\r\n").as_bytes()); + } + pane.write(b"\x1b[12;40H"); + pane.pane.collect_dirty_patch(80, 24); + } + for sparse in [false, true] { + let mut samples = Vec::new(); + for sample in 0..35 { + let mut elapsed = std::time::Duration::ZERO; + for iteration in 0..20 { + if sparse { + let bytes: &[u8] = if iteration % 2 == 0 { + b"\x1b[12;40HX" + } else { + b"\x1b[12;40HY" + }; + for pane in &panes { + pane.pane.ghostty.core.lock().unwrap().terminal.write(bytes); + } + } + let start = std::time::Instant::now(); + for pane in &panes { + let outcome = pane.pane.collect_dirty_patch(80, 24); + if sparse { + let TerminalDirtyPatchOutcome::Patch(ref patch) = outcome else { + panic!("expected sparse patch"); + }; + assert_eq!(patch.rows.len(), 1); + assert_eq!(patch.rows[0].0, 11); + } else { + assert!(matches!(outcome, TerminalDirtyPatchOutcome::Clean)); + } + std::hint::black_box(outcome); + } + elapsed += start.elapsed(); + } + if sample >= 5 { + samples.push(elapsed.as_nanos() / 20); + } + } + samples.sort_unstable(); + eprintln!( + "dirty_patch_scale panes={count} sparse={sparse} median_ns={} p95_ns={}", + samples[samples.len() / 2], + samples[samples.len() * 95 / 100] + ); + } + } +} + +#[test] +fn capture_bounded_migration_observations() { + let mut terminal = Harness::new(12, 5); + let mut observations = Vec::new(); + terminal.write(MIXED.as_bytes()); + observations.push(terminal.observe()); + for (width, height) in [(8, 4), (17, 6), (12, 5)] { + terminal.resize(width, height); + observations.push(terminal.observe()); + } + terminal.write(b"\x1b[6n\x1b[?2004h\x1b]52;c;aGk=\x07\x07"); + observations.push(terminal.observe()); + terminal.pane.scroll_up(2); + observations.push(terminal.observe()); + terminal.pane.scroll_reset(); + observations.push(terminal.observe()); + // An explicit path prevents fixtures from silently being regenerated. + // Compare old/new with diff; explain each difference, never bulk-bless it. + if let Some(path) = std::env::var_os("HERDR_MIGRATION_OBSERVATIONS") { + std::fs::write( + path, + format!("{observations:#?}\n{:#?}\n", terminal.effects), + ) + .unwrap(); + } + assert_eq!(observations.last().unwrap(), &terminal.observe()); +} diff --git a/src/pane/terminal/windows_recent_fallback.rs b/src/pane/terminal/windows_recent_fallback.rs index efb58b49..7f632c08 100644 --- a/src/pane/terminal/windows_recent_fallback.rs +++ b/src/pane/terminal/windows_recent_fallback.rs @@ -346,6 +346,20 @@ mod tests { #[test] fn deferred_repaint_survives_real_scrollback_pruning() { + assert_deferred_repaint_survives_real_scrollback_pruning(None); + } + + #[test] + fn deferred_repaint_survives_real_scrollback_pruning_resize_rows() { + assert_deferred_repaint_survives_real_scrollback_pruning(Some((2, 40))); + } + + #[test] + fn deferred_repaint_survives_real_scrollback_pruning_resize_both_axes() { + assert_deferred_repaint_survives_real_scrollback_pruning(Some((2, 20))); + } + + fn assert_deferred_repaint_survives_real_scrollback_pruning(resize: Option<(u16, u16)>) { let (tx, _rx) = mpsc::channel(4); let terminal = crate::ghostty::Terminal::new(40, 1, 1).unwrap(); let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); @@ -387,6 +401,9 @@ mod tests { .collect::<String>(); pane.process_pty_bytes(pane_id, 0, batch.as_bytes(), &tx); assert_eq!(state(), (before, true)); + if let Some((rows, cols)) = resize { + pane.resize(rows, cols, 8, 16); + } let mut core = pane.core.lock().unwrap(); let fallback = super::super::finish_recent_snapshot(&mut core, String::new(), CACHE_LINES, false); diff --git a/src/pane/xtgettcap.rs b/src/pane/xtgettcap.rs index 7d2dfc5d..4d8fa572 100644 --- a/src/pane/xtgettcap.rs +++ b/src/pane/xtgettcap.rs @@ -1,20 +1,26 @@ +// Compatibility for legacy raw-C1 XTGETTCAP only. Ordinary ESC P ... ESC \ +// requests are answered exclusively by libghostty. Keep bytes untouched: C1 +// normalization across the stream could corrupt UTF-8 or binary string payloads. use bytes::Bytes; #[derive(Debug, Default)] -pub(super) struct XtgettcapQueryTracker { - state: XtgettcapTrackerState, +pub(super) struct C1XtgettcapQueryTracker { + state: C1XtgettcapTrackerState, + raw_c1_intro: bool, + native_dcs_pending: bool, body: Vec<u8>, - pending: Vec<XtgettcapResponse>, + pending: Vec<C1XtgettcapResponse>, } #[derive(Debug, Clone, PartialEq, Eq)] -pub(super) struct XtgettcapResponse { +pub(super) struct C1XtgettcapResponse { pub(super) end_offset: usize, pub(super) bytes: Bytes, + pub(super) suppress_native: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -enum XtgettcapTrackerState { +enum C1XtgettcapTrackerState { #[default] Ground, Escape, @@ -30,114 +36,133 @@ enum XtgettcapTrackerState { OversizedDcsEscape, } -impl XtgettcapQueryTracker { +impl C1XtgettcapQueryTracker { pub(super) fn observe(&mut self, bytes: &[u8]) { for (index, &byte) in bytes.iter().enumerate() { + // With a 7-bit intro and raw ST, the native parser still holds the + // DCS open. At its eventual unhook it can answer earlier keys in a + // multi-key request again. Discard only that dispatch's XTGETTCAP + // replies, not subsequent native requests or other response types. + if self.native_dcs_pending && matches!(byte, 0x1b | 0x18 | 0x1a) { + self.pending.push(C1XtgettcapResponse { + end_offset: index + 1, + bytes: Bytes::new(), + suppress_native: true, + }); + self.native_dcs_pending = false; + } match self.state { - XtgettcapTrackerState::Ground => { + C1XtgettcapTrackerState::Ground => { if byte == 0x1b { - self.state = XtgettcapTrackerState::Escape; + self.state = C1XtgettcapTrackerState::Escape; } else if byte == 0x90 { self.body.clear(); - self.state = XtgettcapTrackerState::DcsIntro; + self.raw_c1_intro = true; + self.state = C1XtgettcapTrackerState::DcsIntro; } else if byte == 0x9d { - self.state = XtgettcapTrackerState::IgnoreOsc; + self.state = C1XtgettcapTrackerState::IgnoreOsc; } else if matches!(byte, 0x98 | 0x9e | 0x9f) { - self.state = XtgettcapTrackerState::IgnoreString; + self.state = C1XtgettcapTrackerState::IgnoreString; } } - XtgettcapTrackerState::Escape => match byte { + C1XtgettcapTrackerState::Escape => match byte { b'P' => { + self.raw_c1_intro = false; self.body.clear(); - self.state = XtgettcapTrackerState::DcsIntro; + self.state = C1XtgettcapTrackerState::DcsIntro; } b']' => { self.body.clear(); - self.state = XtgettcapTrackerState::IgnoreOsc; + self.state = C1XtgettcapTrackerState::IgnoreOsc; } b'_' | b'^' | b'X' => { self.body.clear(); - self.state = XtgettcapTrackerState::IgnoreString; + self.state = C1XtgettcapTrackerState::IgnoreString; } - 0x1b => self.state = XtgettcapTrackerState::Escape, - _ => self.state = XtgettcapTrackerState::Ground, + 0x1b => self.state = C1XtgettcapTrackerState::Escape, + _ => self.state = C1XtgettcapTrackerState::Ground, }, - XtgettcapTrackerState::DcsIntro => match byte { - b'+' => self.state = XtgettcapTrackerState::DcsIntroPlus, - 0x1b => self.state = XtgettcapTrackerState::IgnoreStringEscape, - 0x9c => self.state = XtgettcapTrackerState::Ground, - _ => self.state = XtgettcapTrackerState::IgnoreString, + C1XtgettcapTrackerState::DcsIntro => match byte { + b'+' => self.state = C1XtgettcapTrackerState::DcsIntroPlus, + 0x1b => self.state = C1XtgettcapTrackerState::IgnoreStringEscape, + 0x9c => self.state = C1XtgettcapTrackerState::Ground, + _ => self.state = C1XtgettcapTrackerState::IgnoreString, }, - XtgettcapTrackerState::DcsIntroPlus => match byte { - b'q' => self.state = XtgettcapTrackerState::DcsBody, - 0x1b => self.state = XtgettcapTrackerState::IgnoreStringEscape, - 0x9c => self.state = XtgettcapTrackerState::Ground, - _ => self.state = XtgettcapTrackerState::IgnoreString, + C1XtgettcapTrackerState::DcsIntroPlus => match byte { + b'q' => self.state = C1XtgettcapTrackerState::DcsBody, + 0x1b => self.state = C1XtgettcapTrackerState::IgnoreStringEscape, + 0x9c => self.state = C1XtgettcapTrackerState::Ground, + _ => self.state = C1XtgettcapTrackerState::IgnoreString, }, - XtgettcapTrackerState::DcsBody => match byte { - 0x1b => self.state = XtgettcapTrackerState::DcsEscape, + C1XtgettcapTrackerState::DcsBody => match byte { + 0x1b => self.state = C1XtgettcapTrackerState::DcsEscape, 0x9c => { + self.native_dcs_pending |= !self.raw_c1_intro; self.finalize(index + 1); - self.state = XtgettcapTrackerState::Ground; + self.state = C1XtgettcapTrackerState::Ground; } _ => self.body.push(byte), }, - XtgettcapTrackerState::DcsEscape => { + C1XtgettcapTrackerState::DcsEscape => { if byte == b'\\' { - self.finalize(index + 1); - self.state = XtgettcapTrackerState::Ground; + if self.raw_c1_intro { + self.finalize(index + 1); + } else { + self.body.clear(); + } + self.state = C1XtgettcapTrackerState::Ground; } else if byte != 0x1b { self.body.clear(); - self.state = XtgettcapTrackerState::IgnoreString; + self.state = C1XtgettcapTrackerState::IgnoreString; } } - XtgettcapTrackerState::IgnoreOsc => { + C1XtgettcapTrackerState::IgnoreOsc => { if byte == 0x1b { - self.state = XtgettcapTrackerState::IgnoreOscEscape; + self.state = C1XtgettcapTrackerState::IgnoreOscEscape; } else if matches!(byte, 0x07 | 0x9c) { - self.state = XtgettcapTrackerState::Ground; + self.state = C1XtgettcapTrackerState::Ground; } } - XtgettcapTrackerState::IgnoreOscEscape => { + C1XtgettcapTrackerState::IgnoreOscEscape => { if byte == b'\\' { - self.state = XtgettcapTrackerState::Ground; + self.state = C1XtgettcapTrackerState::Ground; } else if byte != 0x1b { - self.state = XtgettcapTrackerState::IgnoreOsc; + self.state = C1XtgettcapTrackerState::IgnoreOsc; } } - XtgettcapTrackerState::IgnoreString => { + C1XtgettcapTrackerState::IgnoreString => { if byte == 0x1b { - self.state = XtgettcapTrackerState::IgnoreStringEscape; + self.state = C1XtgettcapTrackerState::IgnoreStringEscape; } else if byte == 0x9c { - self.state = XtgettcapTrackerState::Ground; + self.state = C1XtgettcapTrackerState::Ground; } } - XtgettcapTrackerState::IgnoreStringEscape => { + C1XtgettcapTrackerState::IgnoreStringEscape => { if byte == b'\\' { - self.state = XtgettcapTrackerState::Ground; + self.state = C1XtgettcapTrackerState::Ground; } else if byte != 0x1b { - self.state = XtgettcapTrackerState::IgnoreString; + self.state = C1XtgettcapTrackerState::IgnoreString; } } - XtgettcapTrackerState::OversizedDcs => { + C1XtgettcapTrackerState::OversizedDcs => { if byte == 0x1b { - self.state = XtgettcapTrackerState::OversizedDcsEscape; + self.state = C1XtgettcapTrackerState::OversizedDcsEscape; } else if byte == 0x9c { - self.state = XtgettcapTrackerState::Ground; + self.state = C1XtgettcapTrackerState::Ground; } } - XtgettcapTrackerState::OversizedDcsEscape => { + C1XtgettcapTrackerState::OversizedDcsEscape => { if byte == b'\\' { - self.state = XtgettcapTrackerState::Ground; + self.state = C1XtgettcapTrackerState::Ground; } else if byte != 0x1b { - self.state = XtgettcapTrackerState::OversizedDcs; + self.state = C1XtgettcapTrackerState::OversizedDcs; } } } if self.body.len() > 1024 { self.body.clear(); - self.state = XtgettcapTrackerState::OversizedDcs; + self.state = C1XtgettcapTrackerState::OversizedDcs; } } } @@ -145,13 +170,17 @@ impl XtgettcapQueryTracker { fn finalize(&mut self, end_offset: usize) { for cap_hex in self.body.split(|byte| *byte == b';') { if let Some(bytes) = xtgettcap_response(cap_hex) { - self.pending.push(XtgettcapResponse { end_offset, bytes }); + self.pending.push(C1XtgettcapResponse { + end_offset, + bytes, + suppress_native: false, + }); } } self.body.clear(); } - pub(super) fn drain_pending(&mut self) -> Vec<XtgettcapResponse> { + pub(super) fn drain_pending(&mut self) -> Vec<C1XtgettcapResponse> { std::mem::take(&mut self.pending) } } @@ -210,134 +239,3 @@ fn append_upper_hex(bytes: &[u8], output: &mut Vec<u8>) { output.push(HEX[usize::from(byte & 0x0f)]); } } - -#[cfg(test)] -mod tests { - use super::*; - - fn response_bytes(responses: Vec<XtgettcapResponse>) -> Vec<Bytes> { - responses - .into_iter() - .map(|response| response.bytes) - .collect() - } - - #[test] - fn tracker_returns_multiple_capabilities_in_order() { - let mut tracker = XtgettcapQueryTracker::default(); - - tracker.observe(b"\x1bP+q5463;524742\x1b\\"); - - assert_eq!( - response_bytes(tracker.drain_pending()), - vec![ - Bytes::from_static(b"\x1bP1+r5463\x1b\\"), - Bytes::from_static(b"\x1bP1+r524742=38\x1b\\"), - ] - ); - } - - #[test] - fn tracker_normalizes_mixed_case_query_keys() { - let mut tracker = XtgettcapQueryTracker::default(); - - tracker.observe(b"\x1bP+q4d73\x1b\\"); - - assert_eq!( - response_bytes(tracker.drain_pending()), - vec![Bytes::from_static( - b"\x1bP1+r4D73=5C455D35323B25703125733B25703225735C303037\x1b\\" - )] - ); - } - - #[test] - fn tracker_ignores_unsupported_capabilities() { - let mut tracker = XtgettcapQueryTracker::default(); - - tracker.observe(b"\x1bP+q6E6F7065\x1b\\"); - - assert!(response_bytes(tracker.drain_pending()).is_empty()); - } - - #[test] - fn tracker_returns_underline_style_capability() { - let mut tracker = XtgettcapQueryTracker::default(); - - tracker.observe(b"\x1bP+q536D756C78\x1b\\"); - - assert_eq!( - response_bytes(tracker.drain_pending()), - vec![Bytes::from_static( - b"\x1bP1+r536D756C78=5C455B343A25703125646D\x1b\\" - )] - ); - } - - #[test] - fn tracker_keeps_split_query_until_string_terminator() { - let mut tracker = XtgettcapQueryTracker::default(); - - tracker.observe(b"\x1bP+q537"); - assert!(response_bytes(tracker.drain_pending()).is_empty()); - tracker.observe(b"5\x1b"); - assert!(response_bytes(tracker.drain_pending()).is_empty()); - tracker.observe(b"\\"); - - assert_eq!( - response_bytes(tracker.drain_pending()), - vec![Bytes::from_static(b"\x1bP1+r5375\x1b\\")] - ); - } - - #[test] - fn tracker_resumes_after_ignored_osc_bel_terminator() { - let mut tracker = XtgettcapQueryTracker::default(); - - tracker.observe(b"\x1b]0;title\x07\x1bP+q5463\x1b\\"); - - assert_eq!( - response_bytes(tracker.drain_pending()), - vec![Bytes::from_static(b"\x1bP1+r5463\x1b\\")] - ); - } - - #[test] - fn tracker_accepts_eight_bit_dcs_and_string_terminator() { - let mut tracker = XtgettcapQueryTracker::default(); - - tracker.observe(b"\x90+q5463\x9c"); - - assert_eq!( - response_bytes(tracker.drain_pending()), - vec![Bytes::from_static(b"\x1bP1+r5463\x1b\\")] - ); - } - - #[test] - fn tracker_ignores_xtgettcap_bytes_inside_eight_bit_osc() { - let mut tracker = XtgettcapQueryTracker::default(); - - tracker.observe(b"\x9dtitle\x1bP+q5463\x9c\x1bP+q5463\x1b\\"); - - assert_eq!( - response_bytes(tracker.drain_pending()), - vec![Bytes::from_static(b"\x1bP1+r5463\x1b\\")] - ); - } - - #[test] - fn tracker_reports_response_end_offsets() { - let mut tracker = XtgettcapQueryTracker::default(); - - tracker.observe(b"before\x1bP+q5463\x1b\\after"); - - assert_eq!( - tracker.drain_pending(), - vec![XtgettcapResponse { - end_offset: 16, - bytes: Bytes::from_static(b"\x1bP1+r5463\x1b\\"), - }] - ); - } -} |
