aboutsummaryrefslogtreecommitdiff
path: root/src/pane/xtgettcap.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-02 03:30:31 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-02 03:32:40 +0300
commit6b61ab6a3a37216549d59a4c929c9d6abc68ac36 (patch)
treef37b4e2050c7d17c8554d26392c0817165043e44 /src/pane/xtgettcap.rs
parent1257c1e5cbb6487448ab10f2b6d153af7059e65e (diff)
fix: answer xtgettcap queries in panes
refs #393
Diffstat (limited to 'src/pane/xtgettcap.rs')
-rw-r--r--src/pane/xtgettcap.rs329
1 files changed, 329 insertions, 0 deletions
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\\"),
+ }]
+ );
+ }
+}