Skip to main content

mdcat/terminal/
probe.rs

1// Copyright 2025 mdcat contributors
2
3// This Source Code Form is subject to the terms of the Mozilla Public
4// License, v. 2.0. If a copy of the MPL was not distributed with this
5// file, You can obtain one at http://mozilla.org/MPL/2.0/.
6
7//! Active terminal capability probing.
8//!
9//! When env-var based detection falls back to `TerminalProgram::Ansi`
10//! we can still learn something about the terminal by asking it directly.
11//! [`probe_da1`] sends the Primary Device Attributes query (`ESC [ c`) and
12//! parses the response, in particular looking for the `;4;` parameter that
13//! signals Sixel support.
14//!
15//! Probing is Unix-only. On Windows and when `/dev/tty` is unavailable the
16//! functions return `None` so callers can fall back to env-var detection.
17
18#[cfg(unix)]
19use tracing::{event, Level};
20
21/// Capabilities the terminal self-reported in response to a DA1 query.
22#[derive(Debug, Default, Copy, Clone, Eq, PartialEq)]
23pub struct DeviceAttributes {
24    /// `;4;` appeared in the DA1 response, meaning the terminal advertises
25    /// Sixel graphics.
26    pub sixel: bool,
27}
28
29/// Probe the terminal for DA1 capabilities, or return `None` if probing is
30/// not possible (not a TTY, `/dev/tty` unavailable, Windows, etc.).
31///
32/// `timeout` caps how long we block reading the response. 200–500 ms is a
33/// sensible range — long enough for a terminal to answer, short enough that
34/// unresponsive terminals don't stall startup noticeably.
35#[cfg(unix)]
36pub fn probe_da1(timeout: std::time::Duration) -> Option<DeviceAttributes> {
37    use std::fs::OpenOptions;
38    use std::os::fd::AsFd;
39
40    use rustix::termios::{tcgetattr, tcsetattr, LocalModes, OptionalActions, SpecialCodeIndex};
41
42    let mut tty = OpenOptions::new()
43        .read(true)
44        .write(true)
45        .open("/dev/tty")
46        .ok()?;
47
48    let original = tcgetattr(tty.as_fd()).ok()?;
49    let mut raw = original.clone();
50
51    // Disable canonical mode + echo so the terminal's response reaches us
52    // byte-by-byte without being interpreted by the line discipline.
53    raw.local_modes
54        .remove(LocalModes::ICANON | LocalModes::ECHO | LocalModes::ECHONL);
55
56    // VMIN=0 + VTIME = deciseconds → read() returns after up to VTIME
57    // 10ths of a second, even if no byte arrives.
58    let deciseconds = timeout.as_millis().div_ceil(100).min(255) as u8;
59    raw.special_codes[SpecialCodeIndex::VMIN] = 0;
60    raw.special_codes[SpecialCodeIndex::VTIME] = deciseconds;
61
62    tcsetattr(tty.as_fd(), OptionalActions::Now, &raw).ok()?;
63
64    // Always restore termios on the way out, even if the DA1 exchange fails.
65    let result = perform_da1_exchange(&mut tty);
66    let _ = tcsetattr(tty.as_fd(), OptionalActions::Now, &original);
67
68    let response = result?;
69    event!(Level::TRACE, ?response, "DA1 response");
70    Some(parse_da1(&response))
71}
72
73#[cfg(unix)]
74fn perform_da1_exchange(tty: &mut std::fs::File) -> Option<Vec<u8>> {
75    use std::io::{Read, Write};
76    tty.write_all(b"\x1b[c").ok()?;
77    tty.flush().ok()?;
78
79    let mut buffer = Vec::with_capacity(64);
80    let mut chunk = [0u8; 32];
81    // DA1 response ends with `c`. Keep reading chunks until we see one,
82    // or the VTIME timeout kicks in and `read` returns 0 bytes.
83    loop {
84        match tty.read(&mut chunk) {
85            Ok(0) => break,
86            Ok(n) => {
87                buffer.extend_from_slice(&chunk[..n]);
88                if buffer.contains(&b'c') {
89                    break;
90                }
91            }
92            Err(_) => break,
93        }
94    }
95
96    if buffer.is_empty() {
97        None
98    } else {
99        Some(buffer)
100    }
101}
102
103/// Windows stub — active probing over `/dev/tty` isn't available. Always
104/// returns `None` so callers fall back to env-var detection.
105#[cfg(not(unix))]
106pub fn probe_da1(_timeout: std::time::Duration) -> Option<DeviceAttributes> {
107    None
108}
109
110#[cfg(unix)]
111fn parse_da1(response: &[u8]) -> DeviceAttributes {
112    // A DA1 response is `ESC [ ? <params> c` where <params> is a
113    // semicolon-separated list of parameters. Parameter 4 means Sixel.
114    let text = std::str::from_utf8(response).unwrap_or("");
115    let Some(start) = text.find("\x1b[?") else {
116        return DeviceAttributes::default();
117    };
118    let remainder = &text[start + 3..];
119    let end = remainder.find('c').unwrap_or(remainder.len());
120    let params = &remainder[..end];
121    let sixel = params.split(';').any(|p| p == "4");
122    DeviceAttributes { sixel }
123}
124
125#[cfg(all(test, unix))]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn parses_sixel_capable_response() {
131        let resp = b"\x1b[?63;1;2;4;6;9;15c";
132        assert!(parse_da1(resp).sixel);
133    }
134
135    #[test]
136    fn parses_non_sixel_response() {
137        let resp = b"\x1b[?61;6;22c";
138        assert!(!parse_da1(resp).sixel);
139    }
140
141    #[test]
142    fn handles_garbage() {
143        assert_eq!(parse_da1(b""), DeviceAttributes::default());
144        assert_eq!(
145            parse_da1(b"not a DA1 response"),
146            DeviceAttributes::default()
147        );
148    }
149
150    #[test]
151    fn param_4_inside_other_numbers_is_not_a_match() {
152        // `;14;` must NOT be treated as sixel. Split-on-`;` prevents that.
153        let resp = b"\x1b[?14;22c";
154        assert!(!parse_da1(resp).sixel);
155    }
156}