1use std::time::Duration;
26
27use crate::capabilities::{
28 Capabilities, ColorDepth, Environment, Field, Graphics, Origin, Report, SystemEnvironment,
29};
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
33#[cfg_attr(feature = "serde", derive(serde::Serialize))]
34pub struct CellPixels {
35 pub width: u16,
36 pub height: u16,
37}
38
39impl CellPixels {
40 pub fn new(width: u16, height: u16) -> Option<CellPixels> {
42 (width > 0 && height > 0).then_some(CellPixels { width, height })
43 }
44
45 pub fn from_window(pixels: (u16, u16), cells: (u16, u16)) -> Option<CellPixels> {
47 if cells.0 == 0 || cells.1 == 0 {
48 return None;
49 }
50 CellPixels::new(pixels.0 / cells.0, pixels.1 / cells.1)
51 }
52
53 pub fn parse(value: &str) -> Option<CellPixels> {
55 let (w, h) = value.trim().split_once(['x', 'X'])?;
56 CellPixels::new(w.trim().parse().ok()?, h.trim().parse().ok()?)
57 }
58}
59
60impl std::fmt::Display for CellPixels {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 write!(f, "{}x{}", self.width, self.height)
63 }
64}
65
66pub fn parse_cell_pixels_reply(bytes: &[u8]) -> Option<CellPixels> {
69 let text = std::str::from_utf8(bytes).ok()?;
70 let start = text.find("\x1b[6;")?;
71 let body = &text[start + 4..];
72 let end = body.find('t')?;
73 let (height, width) = body[..end].split_once(';')?;
74 CellPixels::new(width.parse().ok()?, height.parse().ok()?)
75}
76
77pub fn query_cell_pixels(timeout: Duration) -> Option<CellPixels> {
87 #[cfg(unix)]
88 {
89 query_unix(timeout)
90 }
91 #[cfg(not(unix))]
92 {
93 let _ = timeout;
94 None
95 }
96}
97
98#[cfg(unix)]
99fn query_unix(timeout: Duration) -> Option<CellPixels> {
100 use rustix::fs::{Mode, OFlags, open};
101 use rustix::termios::{OptionalActions, tcgetattr, tcsetattr};
102
103 let tty = open(
104 "/dev/tty",
105 OFlags::RDWR | OFlags::NOCTTY | OFlags::CLOEXEC,
106 Mode::empty(),
107 )
108 .ok()?;
109 if !in_foreground(&tty) {
114 return None;
115 }
116 let _blocked = JobSignals::block()?;
117 if !in_foreground(&tty) {
118 return None;
119 }
120 let saved = tcgetattr(&tty).ok()?;
121 let mut raw = saved.clone();
122 raw.make_raw();
123 tcsetattr(&tty, OptionalActions::Now, &raw).ok()?;
124 let cell = if input_waiting(&tty) {
127 None
128 } else {
129 exchange(&tty, timeout).0
130 };
131 let _ = tcsetattr(&tty, OptionalActions::Now, &saved);
132 cell
133}
134
135#[cfg(unix)]
137fn in_foreground(tty: &rustix::fd::OwnedFd) -> bool {
138 rustix::termios::tcgetpgrp(tty).is_ok_and(|group| group == rustix::process::getpgrp())
139}
140
141#[cfg(unix)]
143fn input_waiting(tty: &rustix::fd::OwnedFd) -> bool {
144 readable(tty, Duration::ZERO) != Some(false)
146}
147
148#[cfg(unix)]
152#[allow(unsafe_code)]
153fn readable(tty: &rustix::fd::OwnedFd, timeout: Duration) -> Option<bool> {
154 use rustix::fd::AsRawFd;
155 let fd = tty.as_raw_fd();
156 if !(0..libc::FD_SETSIZE as i32).contains(&fd) {
157 return None;
158 }
159 let mut wait = libc::timeval {
160 tv_sec: timeout.as_secs().try_into().unwrap_or(libc::time_t::MAX),
161 tv_usec: timeout.subsec_micros() as _,
162 };
163 let ready = unsafe {
166 let mut set: libc::fd_set = std::mem::zeroed();
167 libc::FD_ZERO(&mut set);
168 libc::FD_SET(fd, &mut set);
169 libc::select(
170 fd + 1,
171 &mut set,
172 std::ptr::null_mut(),
173 std::ptr::null_mut(),
174 &mut wait,
175 )
176 };
177 match ready {
178 0 => Some(false),
179 n if n > 0 => Some(true),
180 _ => None,
181 }
182}
183
184#[cfg(unix)]
186struct JobSignals(libc::sigset_t);
187
188#[cfg(unix)]
190#[allow(unsafe_code)]
191impl JobSignals {
192 fn block() -> Option<JobSignals> {
193 unsafe {
196 let mut set: libc::sigset_t = std::mem::zeroed();
197 libc::sigemptyset(&mut set);
198 libc::sigaddset(&mut set, libc::SIGTTOU);
199 libc::sigaddset(&mut set, libc::SIGTTIN);
200 let mut old: libc::sigset_t = std::mem::zeroed();
201 (libc::pthread_sigmask(libc::SIG_BLOCK, &set, &mut old) == 0).then_some(JobSignals(old))
202 }
203 }
204}
205
206#[cfg(unix)]
207#[allow(unsafe_code)]
208impl Drop for JobSignals {
209 fn drop(&mut self) {
210 unsafe {
212 libc::pthread_sigmask(libc::SIG_SETMASK, &self.0, std::ptr::null_mut());
213 }
214 }
215}
216
217#[derive(Debug, PartialEq, Eq)]
219#[cfg_attr(not(unix), allow(dead_code))]
220enum Reply {
221 Partial,
223 CellSize(Option<CellPixels>),
225 Attributes,
228 Other,
230}
231
232#[cfg_attr(not(unix), allow(dead_code))]
234fn classify(sequence: &[u8]) -> Reply {
235 let Some(rest) = sequence.strip_prefix(b"\x1b") else {
236 return Reply::Other;
237 };
238 let Some((&first, rest)) = rest.split_first() else {
239 return Reply::Partial;
240 };
241 if first != b'[' {
242 return Reply::Other;
243 }
244 let (attributes, body) = match rest.split_first() {
245 None => return Reply::Partial,
246 Some((b'?', body)) => (true, body),
247 Some(_) => (false, rest),
248 };
249 let Some((&last, params)) = body.split_last() else {
250 return Reply::Partial;
251 };
252 let in_params = |b: &u8| b.is_ascii_digit() || *b == b';';
253 if !params.iter().all(in_params) || sequence.len() > 32 {
254 return Reply::Other;
255 }
256 match (attributes, last) {
257 (_, b) if in_params(&b) => Reply::Partial,
258 (true, b'c') => Reply::Attributes,
259 (false, b't') => Reply::CellSize(parse_cell_pixels_reply(sequence)),
260 _ => Reply::Other,
261 }
262}
263
264#[cfg(unix)]
268fn exchange(tty: &rustix::fd::OwnedFd, timeout: Duration) -> (Option<CellPixels>, Vec<u8>) {
269 use std::time::Instant;
270
271 if rustix::io::write(tty, b"\x1b[16t\x1b[c").is_err() {
272 return (None, Vec::new());
273 }
274 let deadline = Instant::now() + timeout;
275 let mut cell = None;
276 let mut sequence: Vec<u8> = Vec::new();
277 let mut stray: Vec<u8> = Vec::new();
278 while let Some(left) = deadline.checked_duration_since(Instant::now()) {
279 if readable(tty, left) != Some(true) {
280 break;
281 }
282 let mut byte = [0u8; 1];
283 if !matches!(rustix::io::read(tty, &mut byte), Ok(1)) {
284 break;
285 }
286 if sequence.is_empty() && byte[0] != 0x1b {
287 stray.push(byte[0]);
288 continue;
289 }
290 sequence.push(byte[0]);
291 match classify(&sequence) {
292 Reply::Partial => {}
293 Reply::CellSize(found) => {
294 cell = cell.or(found);
295 sequence.clear();
296 }
297 Reply::Attributes => {
298 sequence.clear();
299 break;
300 }
301 Reply::Other => stray.append(&mut sequence),
302 }
303 }
304 stray.append(&mut sequence);
305 (cell, stray)
306}
307
308pub(crate) fn stdout_window() -> Option<((u16, u16), (u16, u16))> {
311 #[cfg(unix)]
312 {
313 let size = rustix::termios::tcgetwinsize(std::io::stdout()).ok()?;
314 Some(((size.ws_col, size.ws_row), (size.ws_xpixel, size.ws_ypixel)))
315 }
316 #[cfg(not(unix))]
317 {
318 None
319 }
320}
321
322#[derive(Clone, Debug, PartialEq, Eq)]
326#[cfg_attr(feature = "serde", derive(serde::Serialize))]
327pub struct GraphicsEnvironment {
328 pub graphics: Field<Graphics>,
330 pub sixel: Field<bool>,
332 pub cell_pixels: Field<Option<CellPixels>>,
334 pub interactive: bool,
336 pub color: ColorDepth,
338 pub unicode: bool,
340 pub animation: Field<bool>,
343}
344
345impl GraphicsEnvironment {
346 pub fn from_report(report: &Report, env: &dyn Environment) -> GraphicsEnvironment {
350 let cell_pixels = match env.var("RICH_CELL_PIXELS").filter(|v| !v.is_empty()) {
351 Some(value) => match CellPixels::parse(&value) {
352 Some(cell) => Field::new(
353 Some(cell),
354 Origin::Environment("RICH_CELL_PIXELS".into()),
355 format!("RICH_CELL_PIXELS={cell}"),
356 ),
357 None => Field::new(None, Origin::Default, "RICH_CELL_PIXELS is not WxH"),
358 },
359 None => match env.cell_pixels().and_then(|(w, h)| CellPixels::new(w, h)) {
360 Some(cell) if report.interactive.value => Field::new(
361 Some(cell),
362 Origin::Inferred,
363 "terminal window size in pixels",
364 ),
365 _ => Field::new(None, Origin::Default, "the terminal did not report pixels"),
366 },
367 };
368 GraphicsEnvironment {
369 graphics: report.graphics.clone(),
370 sixel: report.sixel.clone(),
371 cell_pixels,
372 interactive: report.interactive.value,
373 color: report.color.value,
374 unicode: report.unicode.value,
375 animation: report.animation.clone(),
376 }
377 }
378
379 pub fn detect(env: &dyn Environment) -> GraphicsEnvironment {
381 GraphicsEnvironment::from_report(&Capabilities::detect(env), env)
382 }
383
384 pub fn system() -> GraphicsEnvironment {
392 use std::io::IsTerminal;
393 let mut environment = GraphicsEnvironment::detect(&SystemEnvironment);
394 if environment.cell_pixels.value.is_none()
395 && environment.interactive
396 && std::io::stdin().is_terminal()
397 && environment.graphics.value != Graphics::None
398 && let Some(cell) = query_cell_pixels(Duration::from_millis(500))
399 {
400 environment.cell_pixels = Field::new(Some(cell), Origin::Inferred, "CSI 16 t reply");
401 }
402 environment
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use crate::capabilities::MapEnvironment;
410
411 #[test]
412 fn parses_cell_sizes() {
413 assert_eq!(CellPixels::parse("8x16"), CellPixels::new(8, 16));
414 assert_eq!(CellPixels::parse(" 10 X 20 "), CellPixels::new(10, 20));
415 assert_eq!(CellPixels::parse("0x16"), None);
416 assert_eq!(CellPixels::parse("8"), None);
417 assert_eq!(
418 CellPixels::from_window((800, 600), (100, 30)),
419 CellPixels::new(8, 20)
420 );
421 assert_eq!(CellPixels::from_window((0, 0), (100, 30)), None);
422 }
423
424 #[test]
425 fn parses_the_query_reply() {
426 assert_eq!(
427 parse_cell_pixels_reply(b"junk\x1b[6;18;9t"),
428 CellPixels::new(9, 18)
429 );
430 assert_eq!(parse_cell_pixels_reply(b"\x1b[6;18"), None);
431 assert_eq!(parse_cell_pixels_reply(b"\x1b[4;600;800t"), None);
432 }
433
434 #[test]
435 fn classifies_what_the_query_reads() {
436 assert_eq!(classify(b"\x1b"), Reply::Partial);
437 assert_eq!(classify(b"\x1b["), Reply::Partial);
438 assert_eq!(classify(b"\x1b[6;18"), Reply::Partial);
439 assert_eq!(classify(b"\x1b[?62;2"), Reply::Partial);
440 assert_eq!(
441 classify(b"\x1b[6;18;9t"),
442 Reply::CellSize(CellPixels::new(9, 18))
443 );
444 assert_eq!(classify(b"\x1b[4;600;800t"), Reply::CellSize(None));
445 assert_eq!(classify(b"\x1b[?62;22c"), Reply::Attributes);
446 assert_eq!(classify(b"\x1b[A"), Reply::Other);
448 assert_eq!(classify(b"\x1bOP"), Reply::Other);
449 assert_eq!(classify(b"\x1b\x1b"), Reply::Other);
450 assert_eq!(classify(b"\x1b[1;5D"), Reply::Other);
451 }
452
453 #[test]
454 fn cell_size_comes_from_the_window_on_a_terminal_only() {
455 let tty = MapEnvironment::tty()
456 .var("TERM", "xterm-kitty")
457 .cell_pixels(9, 18);
458 let environment = GraphicsEnvironment::detect(&tty);
459 assert_eq!(environment.graphics.value, Graphics::Kitty);
460 assert_eq!(environment.cell_pixels.value, CellPixels::new(9, 18));
461 let pipe = MapEnvironment::new().cell_pixels(9, 18);
462 assert_eq!(GraphicsEnvironment::detect(&pipe).cell_pixels.value, None);
463 let forced = MapEnvironment::new().var("RICH_CELL_PIXELS", "10x20");
464 let environment = GraphicsEnvironment::detect(&forced);
465 assert_eq!(environment.cell_pixels.value, CellPixels::new(10, 20));
466 assert_eq!(
467 environment.cell_pixels.origin,
468 Origin::Environment("RICH_CELL_PIXELS".into())
469 );
470 }
471}