1#[cfg(unix)]
19use tracing::{event, Level};
20
21#[derive(Debug, Default, Copy, Clone, Eq, PartialEq)]
23pub struct DeviceAttributes {
24 pub sixel: bool,
27}
28
29#[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 raw.local_modes
54 .remove(LocalModes::ICANON | LocalModes::ECHO | LocalModes::ECHONL);
55
56 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 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 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#[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 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 let resp = b"\x1b[?14;22c";
154 assert!(!parse_da1(resp).sixel);
155 }
156}