Skip to main content

rich_ext/
graphics.rs

1//! What a terminal can draw beyond text (#573): the graphics protocol, and
2//! the size of one cell in pixels.
3//!
4//! Core's `TargetCapabilities` stays as upstream has it (it knows only
5//! Sixel), so these facts travel beside it in a [`GraphicsEnvironment`] built
6//! from a capability [`Report`]. The cell size comes from the terminal's
7//! window size in pixels (`TIOCGWINSZ`'s pixel fields, divided by its rows
8//! and columns); where the terminal leaves those at zero,
9//! [`GraphicsEnvironment::system`] asks with a `CSI 16 t` query, and only
10//! when both stdin and stdout are a terminal, so a pipe is never written a
11//! query and never waits for a reply.
12//!
13//! The query shares the terminal with the user, so it stays out of the way:
14//! it is skipped unless the process is in the terminal's foreground process
15//! group (a background job that touched the terminal's modes would be
16//! stopped with `SIGTTOU`), and skipped when input is already waiting
17//! (typeahead belongs to whatever reads next). A device-attributes request
18//! (`CSI c`) follows it, so the reader stops as soon as the terminal has
19//! answered both. A byte read during the exchange that is not part of a
20//! reply is dropped, never pushed back on the terminal's input queue:
21//! `TIOCSTI` would replay whatever arrived (another program's terminal
22//! reply included) to the shell as typed input. Only a key pressed in the
23//! milliseconds the terminal takes to answer can be lost that way.
24
25use std::time::Duration;
26
27use crate::capabilities::{
28    Capabilities, ColorDepth, Environment, Field, Graphics, Origin, Report, SystemEnvironment,
29};
30
31/// The size of one terminal cell in pixels.
32#[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    /// A cell size, or `None` when either side is zero.
41    pub fn new(width: u16, height: u16) -> Option<CellPixels> {
42        (width > 0 && height > 0).then_some(CellPixels { width, height })
43    }
44
45    /// The cell size a window of `pixels` across `cells` implies.
46    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    /// Parse `WIDTHxHEIGHT`, as `8x16`.
54    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
66/// The cell size in a `CSI 16 t` reply (`ESC [ 6 ; height ; width t`), if
67/// `bytes` holds one.
68pub 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
77/// Ask the controlling terminal for its cell size with `CSI 16 t`, waiting
78/// at most `timeout` for the reply. The terminal is put in raw mode for the
79/// exchange and restored after. `None` without a controlling terminal, on
80/// Windows, when the process is not in the terminal's foreground process
81/// group, when input is already waiting to be read, or when the terminal
82/// does not answer in time.
83///
84/// Only call it when the program is interactive: it writes to and reads
85/// from `/dev/tty`.
86pub 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    // A background job may not change the terminal's modes or read from it:
110    // the kernel would stop it (SIGTTOU, SIGTTIN) until `fg`. Ask only from
111    // the foreground, with both signals blocked so a job sent to the
112    // background in between is not stopped (its read fails with EIO).
113    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    // In raw mode a partly typed line is readable too: anything waiting is
125    // the user's, for whatever reads next, so there is no query.
126    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/// Whether this process is in `tty`'s foreground process group.
136#[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/// Whether `tty` has input waiting, without waiting.
142#[cfg(unix)]
143fn input_waiting(tty: &rustix::fd::OwnedFd) -> bool {
144    // An error counts as waiting: when in doubt, do not read.
145    readable(tty, Duration::ZERO) != Some(false)
146}
147
148/// Whether `tty` has input to read within `timeout`; `None` on an error.
149/// This is `select`, not `poll`: macOS's `poll` does not support terminal
150/// devices (it answers `POLLNVAL` for `/dev/tty`).
151#[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    // SAFETY: `set` is initialised by FD_ZERO before use, `fd` is open and
164    // below FD_SETSIZE, and select only reads and writes `set` and `wait`.
165    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/// `SIGTTOU` and `SIGTTIN` blocked on this thread until dropped.
185#[cfg(unix)]
186struct JobSignals(libc::sigset_t);
187
188// rustix has no safe wrapper for the signal mask.
189#[cfg(unix)]
190#[allow(unsafe_code)]
191impl JobSignals {
192    fn block() -> Option<JobSignals> {
193        // SAFETY: the sets are initialised by sigemptyset before use, and
194        // pthread_sigmask only reads `set` and writes `old`.
195        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        // SAFETY: restores the mask `block` saved.
211        unsafe {
212            libc::pthread_sigmask(libc::SIG_SETMASK, &self.0, std::ptr::null_mut());
213        }
214    }
215}
216
217/// What one escape sequence read during the exchange turned out to be.
218#[derive(Debug, PartialEq, Eq)]
219#[cfg_attr(not(unix), allow(dead_code))]
220enum Reply {
221    /// Not finished yet.
222    Partial,
223    /// The `CSI 16 t` reply, when it names a cell size.
224    CellSize(Option<CellPixels>),
225    /// The device-attributes reply (`ESC [ ? … c`): the terminal has
226    /// answered everything asked.
227    Attributes,
228    /// Not a reply to either question: the user's input.
229    Other,
230}
231
232/// Classify `sequence`, which starts with ESC.
233#[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/// Write the query and a device-attributes request, and read the replies
265/// a byte at a time until the second arrives or `timeout` passes. Returns
266/// the cell size and the bytes read that were not part of a reply.
267#[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
308/// The window size stdout's terminal reports, as `(columns, rows)` and
309/// `(x pixels, y pixels)`, when it is a terminal that says.
310pub(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/// What the terminal can draw beyond text, with where each answer came
323/// from: the ext-side environment micro-asset renderers read (core's
324/// `TargetCapabilities` is left as upstream has it).
325#[derive(Clone, Debug, PartialEq, Eq)]
326#[cfg_attr(feature = "serde", derive(serde::Serialize))]
327pub struct GraphicsEnvironment {
328    /// The graphics protocol found (or overridden).
329    pub graphics: Field<Graphics>,
330    /// Whether Sixel was found, separately (WezTerm speaks both).
331    pub sixel: Field<bool>,
332    /// The size of one cell in pixels, when the terminal said.
333    pub cell_pixels: Field<Option<CellPixels>>,
334    /// stdout is a terminal.
335    pub interactive: bool,
336    /// Colour depth, for block renderings.
337    pub color: ColorDepth,
338    /// Whether emoji and block characters can be shown.
339    pub unicode: bool,
340    /// Whether animation may run (off with reduced motion or
341    /// `RICH_ANIMATION=0`).
342    pub animation: Field<bool>,
343}
344
345impl GraphicsEnvironment {
346    /// From a capability report, reading the cell size from `env`: the
347    /// `RICH_CELL_PIXELS=WxH` override, then the terminal's window size in
348    /// pixels. Never queries the terminal.
349    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    /// Detect from `env`, never querying the terminal.
380    pub fn detect(env: &dyn Environment) -> GraphicsEnvironment {
381        GraphicsEnvironment::from_report(&Capabilities::detect(env), env)
382    }
383
384    /// Detect from the process. When the cell size is still unknown and both
385    /// stdin and stdout are a terminal, ask the terminal (`CSI 16 t`,
386    /// waiting at most 500 ms) — from the foreground only, and only when no
387    /// input is waiting ([`query_cell_pixels`]). Every terminal answers the
388    /// device-attributes request sent with it, which ends the wait at once,
389    /// so the timeout only bounds a terminal that answers nothing; a shorter
390    /// one let a reply over a slow link arrive late, as typed input.
391    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        // Keys the user pressed: not replies, so dropped.
447        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}