diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/pane.rs | 1 | ||||
| -rw-r--r-- | src/pane/osc.rs | 37 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 314 | ||||
| -rw-r--r-- | src/pane/xtgettcap.rs | 329 |
4 files changed, 651 insertions, 30 deletions
diff --git a/src/pane.rs b/src/pane.rs index 8230cd2b..7ab069bc 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -27,6 +27,7 @@ mod kitty_keyboard; mod osc; mod state; mod terminal; +mod xtgettcap; use self::terminal::{GhosttyPaneTerminal, PaneTerminal}; pub(crate) use self::terminal::{TerminalDirtyPatch, TerminalDirtyPatchOutcome}; diff --git a/src/pane/osc.rs b/src/pane/osc.rs index 1512f336..37407ade 100644 --- a/src/pane/osc.rs +++ b/src/pane/osc.rs @@ -29,6 +29,12 @@ pub(super) enum DefaultColorEvent { PaletteQuery(u8), } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct DefaultColorTrackedEvent { + pub(super) end_offset: usize, + pub(super) event: DefaultColorEvent, +} + #[derive(Debug, Default)] pub(super) struct DefaultColorOscTracker { state: DefaultColorOscTrackerState, @@ -145,12 +151,12 @@ fn is_default_color_set_osc(body: &[u8]) -> bool { pub(super) struct DefaultColorEventTracker { state: DefaultColorOscTrackerState, body: Vec<u8>, - pending: Vec<DefaultColorEvent>, + pending: Vec<DefaultColorTrackedEvent>, } impl DefaultColorEventTracker { pub(super) fn observe(&mut self, bytes: &[u8]) { - for &byte in bytes { + for (index, &byte) in bytes.iter().enumerate() { match self.state { DefaultColorOscTrackerState::Ground => { if byte == 0x1b { @@ -172,7 +178,7 @@ impl DefaultColorEventTracker { } DefaultColorOscTrackerState::OscBody => match byte { 0x07 => { - self.finalize(); + self.finalize(index + 1); self.state = DefaultColorOscTrackerState::Ground; } 0x1b => self.state = DefaultColorOscTrackerState::OscEscape, @@ -180,7 +186,7 @@ impl DefaultColorEventTracker { }, DefaultColorOscTrackerState::OscEscape => { if byte == b'\\' { - self.finalize(); + self.finalize(index + 1); self.state = DefaultColorOscTrackerState::Ground; } else { self.body.push(0x1b); @@ -223,14 +229,15 @@ impl DefaultColorEventTracker { } } - fn finalize(&mut self) { + fn finalize(&mut self, end_offset: usize) { if let Some(event) = parse_default_color_event(&self.body) { - self.pending.push(event); + self.pending + .push(DefaultColorTrackedEvent { end_offset, event }); } self.body.clear(); } - pub(super) fn drain_pending(&mut self) -> Vec<DefaultColorEvent> { + pub(super) fn drain_pending(&mut self) -> Vec<DefaultColorTrackedEvent> { std::mem::take(&mut self.pending) } } @@ -571,6 +578,12 @@ mod tests { } } + fn tracked_default_color_events( + events: Vec<DefaultColorTrackedEvent>, + ) -> Vec<DefaultColorEvent> { + events.into_iter().map(|event| event.event).collect() + } + #[test] fn default_color_tracker_detects_split_osc_11_sequences() { let mut tracker = DefaultColorOscTracker::default(); @@ -596,7 +609,7 @@ mod tests { ); assert_eq!( - tracker.drain_pending(), + tracked_default_color_events(tracker.drain_pending()), vec![ DefaultColorEvent::Query(DefaultColorQuery::Foreground), DefaultColorEvent::Query(DefaultColorQuery::Background), @@ -618,7 +631,7 @@ mod tests { tracker.observe(b"\\"); assert_eq!( - tracker.drain_pending(), + tracked_default_color_events(tracker.drain_pending()), vec![DefaultColorEvent::Query(DefaultColorQuery::Background)] ); } @@ -634,7 +647,7 @@ mod tests { tracker.observe(b"\\"); assert_eq!( - tracker.drain_pending(), + tracked_default_color_events(tracker.drain_pending()), vec![DefaultColorEvent::PaletteQuery(255)] ); } @@ -651,7 +664,7 @@ mod tests { tracker.observe(b"\x1b]4;0;?\x07"); assert_eq!( - tracker.drain_pending(), + tracked_default_color_events(tracker.drain_pending()), vec![DefaultColorEvent::PaletteQuery(0)] ); } @@ -680,7 +693,7 @@ mod tests { tracker.observe(b"\x1b]11;?\x07"); assert_eq!( - tracker.drain_pending(), + tracked_default_color_events(tracker.drain_pending()), vec![DefaultColorEvent::Query(DefaultColorQuery::Background)] ); } diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 75b4a8a5..be1200e6 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -24,8 +24,9 @@ use super::{ contains_scrollback_clear_sequence, current_transient_default_color_owner, maybe_filter_primary_screen_scrollback_clear, restore_host_terminal_theme_if_needed, write_host_terminal_theme, DefaultColorEvent, DefaultColorEventTracker, - DefaultColorOscTracker, DefaultColorQuery, Osc52Forwarder, + DefaultColorOscTracker, DefaultColorQuery, DefaultColorTrackedEvent, Osc52Forwarder, }, + xtgettcap::{XtgettcapQueryTracker, XtgettcapResponse}, }; const DEFAULT_DETECTION_ROWS: usize = 24; @@ -122,6 +123,7 @@ pub(crate) struct GhosttyPaneCore { pub child_default_foreground_changed: bool, pub child_default_background_changed: bool, pub osc52_forwarder: Osc52Forwarder, + pub xtgettcap_query_tracker: XtgettcapQueryTracker, } pub(crate) struct PaneTerminal { @@ -333,6 +335,7 @@ impl GhosttyPaneTerminal { child_default_foreground_changed: false, child_default_background_changed: false, osc52_forwarder: Osc52Forwarder::default(), + xtgettcap_query_tracker: XtgettcapQueryTracker::default(), }), key_encoder: Mutex::new(key_encoder), pending_pty_responses, @@ -445,11 +448,19 @@ impl GhosttyPaneTerminal { let mut terminal_responses = Vec::new(); core.default_color_event_tracker .observe(filtered_bytes.as_ref()); - respond_to_default_color_events(&mut core, &mut terminal_responses); + core.xtgettcap_query_tracker + .observe(filtered_bytes.as_ref()); + 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(); - core.terminal.write(filtered_bytes.as_ref()); + self.write_pty_bytes_with_ordered_responses( + &mut core, + filtered_bytes.as_ref(), + default_color_events, + xtgettcap_responses, + &mut terminal_responses, + ); crate::render_prof::duration_since("pty.ghostty_write", write_started); - terminal_responses.extend(self.drain_pending_pty_responses()); let has_kitty_graphics_sequence = crate::kitty_graphics::is_enabled() && contains_kitty_graphics_sequence(filtered_bytes.as_ref()); @@ -483,6 +494,51 @@ impl GhosttyPaneTerminal { } } + fn write_pty_bytes_with_ordered_responses( + &self, + core: &mut GhosttyPaneCore, + bytes: &[u8], + default_color_events: Vec<DefaultColorTrackedEvent>, + xtgettcap_responses: Vec<XtgettcapResponse>, + 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); + + let mut written = 0; + for event in events { + let end_offset = event.end_offset().min(bytes.len()); + if end_offset > written { + core.terminal.write(&bytes[written..end_offset]); + terminal_responses.extend(self.drain_pending_pty_responses()); + written = end_offset; + } + match event { + OrderedPtyResponseEvent::DefaultColor(event) => { + respond_to_default_color_event(core, terminal_responses, event.event); + } + OrderedPtyResponseEvent::Xtgettcap(response) => { + terminal_responses.push(response.bytes); + } + } + } + + if written < bytes.len() { + core.terminal.write(&bytes[written..]); + terminal_responses.extend(self.drain_pending_pty_responses()); + } + } + fn drain_pending_pty_responses(&self) -> Vec<Bytes> { self.pending_pty_responses .lock() @@ -1577,7 +1633,10 @@ fn ghostty_cell_style( std::mem::swap(&mut fg, &mut bg); } - let style = ghostty_default_style(fg, bg); + let mut style = ghostty_default_style(fg, bg); + if let Some(underline_color) = basic.style.underline_color.map(ghostty_cell_color) { + style = style.underline_color(underline_color); + } let mut modifiers = Modifier::empty(); if basic.style.bold { modifiers |= Modifier::BOLD; @@ -1600,25 +1659,39 @@ fn ghostty_cell_style( style.add_modifier(modifiers) } -fn respond_to_default_color_events( +#[derive(Debug)] +enum OrderedPtyResponseEvent { + DefaultColor(DefaultColorTrackedEvent), + Xtgettcap(XtgettcapResponse), +} + +impl OrderedPtyResponseEvent { + fn end_offset(&self) -> usize { + match self { + Self::DefaultColor(event) => event.end_offset, + Self::Xtgettcap(response) => response.end_offset, + } + } +} + +fn respond_to_default_color_event( core: &mut GhosttyPaneCore, terminal_responses: &mut Vec<Bytes>, + event: DefaultColorEvent, ) { - for event in core.default_color_event_tracker.drain_pending() { - match event { - DefaultColorEvent::Query(query) => { - if let Some(response) = default_color_query_response(query, core) { - terminal_responses.push(response); - } + match event { + DefaultColorEvent::Query(query) => { + if let Some(response) = default_color_query_response(query, core) { + terminal_responses.push(response); } - DefaultColorEvent::PaletteQuery(index) => { - if let Some(response) = palette_color_query_response(index, core) { - terminal_responses.push(response); - } + } + DefaultColorEvent::PaletteQuery(index) => { + if let Some(response) = palette_color_query_response(index, core) { + terminal_responses.push(response); } - DefaultColorEvent::Set(query) => mark_child_default_color_changed(core, query, true), - DefaultColorEvent::Reset(query) => mark_child_default_color_changed(core, query, false), } + DefaultColorEvent::Set(query) => mark_child_default_color_changed(core, query, true), + DefaultColorEvent::Reset(query) => mark_child_default_color_changed(core, query, false), } } @@ -1808,6 +1881,24 @@ mod tests { Bytes::from(format!("\x1b]{command};rgb:{r:04x}/{g:04x}/{b:04x}\x1b\\")) } + fn expected_xtgettcap_response(cap_hex: &str, value: Option<&[u8]>) -> Bytes { + let mut response = format!("\x1bP1+r{cap_hex}").into_bytes(); + if let Some(value) = value { + response.push(b'='); + append_upper_hex(value, &mut response); + } + response.extend_from_slice(b"\x1b\\"); + Bytes::from(response) + } + + fn append_upper_hex(bytes: &[u8], output: &mut Vec<u8>) { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + for &byte in bytes { + output.push(HEX[usize::from(byte >> 4)]); + output.push(HEX[usize::from(byte & 0x0f)]); + } + } + #[test] fn decscusr_cursor_shape_preserves_blinking_variants() { assert_eq!( @@ -2692,6 +2783,193 @@ mod tests { } #[test] + fn process_pty_bytes_returns_xtgettcap_truecolor_query_responses_without_queuing_input() { + let (tx, mut rx) = mpsc::channel(8); + 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); + + let result = pane.process_pty_bytes( + pane_id, + 0, + b"\x1bP+q5463;524742;73657472676266;73657472676262\x1b\\", + &tx, + ); + + assert_eq!( + result.terminal_responses, + vec![ + expected_xtgettcap_response("5463", None), + expected_xtgettcap_response("524742", Some(b"8")), + expected_xtgettcap_response("73657472676266", Some(b"\\E[38:2:%p1%d:%p2%d:%p3%dm")), + expected_xtgettcap_response("73657472676262", Some(b"\\E[48:2:%p1%d:%p2%d:%p3%dm")), + ] + ); + 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(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + + 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); + assert!(result.terminal_responses.is_empty()); + assert!(rx.try_recv().is_err()); + let result = pane.process_pty_bytes(pane_id, 0, b"\\", &tx); + + assert_eq!( + result.terminal_responses, + vec![expected_xtgettcap_response( + "4D73", + Some(b"\\E]52;%p1%s;%p2%s\\007") + )] + ); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn process_pty_bytes_orders_device_attribute_reply_before_following_xtgettcap_reply() { + 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); + + let result = pane.process_pty_bytes(pane_id, 0, b"\x1b[c\x1bP+q5463\x1b\\", &tx); + + assert_eq!(result.terminal_responses.len(), 2); + assert!(String::from_utf8_lossy(&result.terminal_responses[0]).contains('c')); + assert_eq!( + result.terminal_responses[1], + expected_xtgettcap_response("5463", None) + ); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn process_pty_bytes_orders_xtgettcap_reply_before_following_device_attribute_reply() { + 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); + + let result = pane.process_pty_bytes(pane_id, 0, b"\x1bP+q5463\x1b\\\x1b[c", &tx); + + assert_eq!(result.terminal_responses.len(), 2); + assert_eq!( + result.terminal_responses[0], + expected_xtgettcap_response("5463", None) + ); + assert!(String::from_utf8_lossy(&result.terminal_responses[1]).contains('c')); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn process_pty_bytes_orders_xtgettcap_reply_before_following_default_color_reply() { + 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 { + foreground: None, + background: Some(crate::terminal_theme::RgbColor { + r: 0x00, + g: 0x2b, + b: 0x36, + }), + }); + + let result = pane.process_pty_bytes(pane_id, 0, b"\x1bP+q5463\x1b\\\x1b]11;?\x07", &tx); + + assert_eq!( + result.terminal_responses, + vec![ + expected_xtgettcap_response("5463", None), + Bytes::from_static(b"\x1b]11;rgb:0000/2b2b/3636\x1b\\"), + ] + ); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn process_pty_bytes_recovers_xtgettcap_after_osc_bel_terminator() { + 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); + + let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]0;title\x07\x1bP+q5463\x1b\\", &tx); + + assert_eq!( + result.terminal_responses, + vec![expected_xtgettcap_response("5463", None)] + ); + 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(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + + let result = + pane.process_pty_bytes(pane_id, 0, b"\x1bP+q6E6F7065;536D756C78;4D7\x1b\\", &tx); + + assert!(result.terminal_responses.is_empty()); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn process_pty_bytes_returns_underline_color_xtgettcap_query_responses() { + 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); + + let result = pane.process_pty_bytes(pane_id, 0, b"\x1bP+q5375;536574756C63\x1b\\", &tx); + + assert_eq!( + result.terminal_responses, + vec![ + expected_xtgettcap_response("5375", None), + expected_xtgettcap_response( + "536574756C63", + Some(b"\\E[58:2::%p1%{65536}%/%d:%p1%{256}%/%{255}%&%d:%p1%{255}%&%d%;m") + ), + ] + ); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn render_preserves_underline_color() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + { + let mut core = pane.core.lock().unwrap(); + core.terminal.write(b"\x1b[4m\x1b[58:2::17:34:51mU"); + } + + let backend = ratatui::backend::TestBackend::new(20, 5); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 5), false)) + .unwrap(); + + let style = terminal.backend().buffer()[(0, 0)].style(); + assert!(style.add_modifier.contains(Modifier::UNDERLINED)); + assert_eq!(style.underline_color, Some(Color::Rgb(17, 34, 51))); + } + + #[test] fn process_pty_bytes_orders_default_color_reply_before_following_device_attribute_reply() { let (tx, mut rx) = mpsc::channel(4); let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); diff --git a/src/pane/xtgettcap.rs b/src/pane/xtgettcap.rs new file mode 100644 index 00000000..f0a91df5 --- /dev/null +++ b/src/pane/xtgettcap.rs @@ -0,0 +1,329 @@ +use bytes::Bytes; + +#[derive(Debug, Default)] +pub(super) struct XtgettcapQueryTracker { + state: XtgettcapTrackerState, + body: Vec<u8>, + pending: Vec<XtgettcapResponse>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct XtgettcapResponse { + pub(super) end_offset: usize, + pub(super) bytes: Bytes, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum XtgettcapTrackerState { + #[default] + Ground, + Escape, + DcsIntro, + DcsIntroPlus, + DcsBody, + DcsEscape, + IgnoreOsc, + IgnoreOscEscape, + IgnoreString, + IgnoreStringEscape, + OversizedDcs, + OversizedDcsEscape, +} + +impl XtgettcapQueryTracker { + pub(super) fn observe(&mut self, bytes: &[u8]) { + for (index, &byte) in bytes.iter().enumerate() { + match self.state { + XtgettcapTrackerState::Ground => { + if byte == 0x1b { + self.state = XtgettcapTrackerState::Escape; + } else if byte == 0x90 { + self.body.clear(); + self.state = XtgettcapTrackerState::DcsIntro; + } else if byte == 0x9d { + self.state = XtgettcapTrackerState::IgnoreOsc; + } else if matches!(byte, 0x98 | 0x9e | 0x9f) { + self.state = XtgettcapTrackerState::IgnoreString; + } + } + XtgettcapTrackerState::Escape => match byte { + b'P' => { + self.body.clear(); + self.state = XtgettcapTrackerState::DcsIntro; + } + b']' => { + self.body.clear(); + self.state = XtgettcapTrackerState::IgnoreOsc; + } + b'_' | b'^' | b'X' => { + self.body.clear(); + self.state = XtgettcapTrackerState::IgnoreString; + } + 0x1b => self.state = XtgettcapTrackerState::Escape, + _ => self.state = XtgettcapTrackerState::Ground, + }, + XtgettcapTrackerState::DcsIntro => match byte { + b'+' => self.state = XtgettcapTrackerState::DcsIntroPlus, + 0x1b => self.state = XtgettcapTrackerState::IgnoreStringEscape, + 0x9c => self.state = XtgettcapTrackerState::Ground, + _ => self.state = XtgettcapTrackerState::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, + }, + XtgettcapTrackerState::DcsBody => match byte { + 0x1b => self.state = XtgettcapTrackerState::DcsEscape, + 0x9c => { + self.finalize(index + 1); + self.state = XtgettcapTrackerState::Ground; + } + _ => self.body.push(byte), + }, + XtgettcapTrackerState::DcsEscape => { + if byte == b'\\' { + self.finalize(index + 1); + self.state = XtgettcapTrackerState::Ground; + } else if byte != 0x1b { + self.body.clear(); + self.state = XtgettcapTrackerState::IgnoreString; + } + } + XtgettcapTrackerState::IgnoreOsc => { + if byte == 0x1b { + self.state = XtgettcapTrackerState::IgnoreOscEscape; + } else if matches!(byte, 0x07 | 0x9c) { + self.state = XtgettcapTrackerState::Ground; + } + } + XtgettcapTrackerState::IgnoreOscEscape => { + if byte == b'\\' { + self.state = XtgettcapTrackerState::Ground; + } else if byte != 0x1b { + self.state = XtgettcapTrackerState::IgnoreOsc; + } + } + XtgettcapTrackerState::IgnoreString => { + if byte == 0x1b { + self.state = XtgettcapTrackerState::IgnoreStringEscape; + } else if byte == 0x9c { + self.state = XtgettcapTrackerState::Ground; + } + } + XtgettcapTrackerState::IgnoreStringEscape => { + if byte == b'\\' { + self.state = XtgettcapTrackerState::Ground; + } else if byte != 0x1b { + self.state = XtgettcapTrackerState::IgnoreString; + } + } + XtgettcapTrackerState::OversizedDcs => { + if byte == 0x1b { + self.state = XtgettcapTrackerState::OversizedDcsEscape; + } else if byte == 0x9c { + self.state = XtgettcapTrackerState::Ground; + } + } + XtgettcapTrackerState::OversizedDcsEscape => { + if byte == b'\\' { + self.state = XtgettcapTrackerState::Ground; + } else if byte != 0x1b { + self.state = XtgettcapTrackerState::OversizedDcs; + } + } + } + + if self.body.len() > 1024 { + self.body.clear(); + self.state = XtgettcapTrackerState::OversizedDcs; + } + } + } + + 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.body.clear(); + } + + pub(super) fn drain_pending(&mut self) -> Vec<XtgettcapResponse> { + std::mem::take(&mut self.pending) + } +} + +fn xtgettcap_response(cap_hex: &[u8]) -> Option<Bytes> { + if cap_hex.is_empty() || !cap_hex.len().is_multiple_of(2) { + return None; + } + + let mut normalized_cap_hex = Vec::with_capacity(cap_hex.len()); + for &byte in cap_hex { + if !byte.is_ascii_hexdigit() { + return None; + } + normalized_cap_hex.push(byte.to_ascii_uppercase()); + } + + let value = xtgettcap_value(&normalized_cap_hex)?; + Some(build_xtgettcap_response(&normalized_cap_hex, value)) +} + +fn xtgettcap_value(cap_hex: &[u8]) -> Option<Option<&'static [u8]>> { + // Mirror only the Ghostty terminfo capabilities that this pane path can + // stand behind. Smulx is intentionally absent until underline shapes render. + match cap_hex { + b"5463" => Some(None), + b"524742" => Some(Some(b"8")), + b"73657472676266" => Some(Some(b"\\E[38:2:%p1%d:%p2%d:%p3%dm")), + b"73657472676262" => Some(Some(b"\\E[48:2:%p1%d:%p2%d:%p3%dm")), + b"4D73" => Some(Some(b"\\E]52;%p1%s;%p2%s\\007")), + b"5375" => Some(None), + b"536574756C63" => Some(Some( + b"\\E[58:2::%p1%{65536}%/%d:%p1%{256}%/%{255}%&%d:%p1%{255}%&%d%;m", + )), + _ => None, + } +} + +fn build_xtgettcap_response(cap_hex: &[u8], value: Option<&[u8]>) -> Bytes { + let mut response = + Vec::with_capacity(8 + cap_hex.len() + value.map_or(0, |bytes| bytes.len() * 2)); + response.extend_from_slice(b"\x1bP1+r"); + response.extend_from_slice(cap_hex); + if let Some(value) = value { + response.push(b'='); + append_upper_hex(value, &mut response); + } + response.extend_from_slice(b"\x1b\\"); + Bytes::from(response) +} + +fn append_upper_hex(bytes: &[u8], output: &mut Vec<u8>) { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + for &byte in bytes { + output.push(HEX[usize::from(byte >> 4)]); + 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+q536D756C78;6E6F7065\x1b\\"); + + assert!(response_bytes(tracker.drain_pending()).is_empty()); + } + + #[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\\"), + }] + ); + } +} |
