aboutsummaryrefslogtreecommitdiff
path: root/src/pane
diff options
context:
space:
mode:
Diffstat (limited to 'src/pane')
-rw-r--r--src/pane/input.rs7
-rw-r--r--src/pane/terminal.rs311
-rw-r--r--src/pane/terminal/migration_tests.rs382
-rw-r--r--src/pane/terminal/windows_recent_fallback.rs17
-rw-r--r--src/pane/xtgettcap.rs270
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\\"),
- }]
- );
- }
-}