Skip to main content

qframe/
graphics.rs

1//! Which way the terminal can show a picture: the [`Graphics`] an [`Env`](crate::env::Env)
2//! reports, and the rules that decide it.
3//!
4//! The runtime asks the terminal once, as it starts (see
5//! [`Env::graphics`](crate::env::Env::graphics)); what the terminal answered is then weighed
6//! against what is known of the environment. This module holds the parts that need no terminal:
7//! reading the answers and applying the rules.
8
9/// The way a picture can be drawn in this terminal, sharpest first.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum Graphics {
12    /// The kitty graphics protocol: real pixels, sent once and placed again by number. Kitty,
13    /// WezTerm, Ghostty and Konsole answer to it.
14    Kitty,
15    /// DEC sixel: real pixels, written out whole each time. foot, WezTerm, mlterm, Windows
16    /// Terminal and a configured xterm answer to it.
17    Sixel,
18    /// Half blocks: each cell shows two pixels, the upper one as the colour of `▀`, the lower one
19    /// as the cell's ground. Every terminal with 256 or more colours shows it, over any link.
20    HalfBlock,
21    /// No picture at all: the terminal has 16 colours or draws in ASCII, where a picture cannot
22    /// be told apart from noise. A picture's place shows what it is instead.
23    None,
24}
25
26impl Graphics {
27    /// Every value, sharpest first.
28    pub const ALL: [Self; 4] = [Self::Kitty, Self::Sixel, Self::HalfBlock, Self::None];
29
30    /// The name the `QUVYTA_GRAPHICS` environment variable takes for this value: `kitty`,
31    /// `sixel`, `halfblock` or `none`.
32    #[must_use]
33    pub fn name(self) -> &'static str {
34        match self {
35            Self::Kitty => "kitty",
36            Self::Sixel => "sixel",
37            Self::HalfBlock => "halfblock",
38            Self::None => "none",
39        }
40    }
41
42    /// Whether a picture is drawn at all: true for everything but [`Graphics::None`].
43    ///
44    /// It is the question the `Image` widget asks itself before it draws, so an
45    /// application that would rather show nothing than the image's "cannot show" state, such as
46    /// a wallpaper, asks it first and gets the same answer. Read it from
47    /// [`Env::graphics`](crate::env::Env::graphics) in `view`, or from
48    /// [`App::graphics`](crate::runtime::App::graphics) before decoding.
49    #[must_use]
50    pub fn can_draw(self) -> bool {
51        self != Self::None
52    }
53
54    /// The value `name` stands for, as [`Graphics::name`] writes it; case and surrounding space
55    /// do not matter. `None` for any other name.
56    #[must_use]
57    pub fn from_name(name: &str) -> Option<Self> {
58        let name = name.trim();
59        Self::ALL.into_iter().find(|graphics| graphics.name().eq_ignore_ascii_case(name))
60    }
61}
62
63/// The environment variable that decides the graphics, whatever the terminal answered.
64pub(crate) const VARIABLE: &str = "QUVYTA_GRAPHICS";
65
66/// What the environment knows about pictures before the terminal is asked, and what it answered.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub(crate) struct GraphicsFacts {
69    /// What the terminal answered to the probe; [`Graphics::HalfBlock`] until it is asked, and
70    /// when it did not answer.
71    pub(crate) answer: Graphics,
72    /// What `QUVYTA_GRAPHICS` forces, if it is set to a known name.
73    pub(crate) forced: Option<Graphics>,
74    /// Whether the application runs inside tmux or GNU screen.
75    pub(crate) multiplexed: bool,
76}
77
78impl Default for GraphicsFacts {
79    fn default() -> Self {
80        Self { answer: Graphics::HalfBlock, forced: None, multiplexed: false }
81    }
82}
83
84impl GraphicsFacts {
85    /// Reads `QUVYTA_GRAPHICS`, `TMUX` and `STY` through `lookup`. An unknown name in
86    /// `QUVYTA_GRAPHICS` is returned as the second value, so the caller can report it.
87    pub(crate) fn detect(lookup: impl Fn(&str) -> Option<String>) -> (Self, Option<String>) {
88        let set = |name: &str| lookup(name).filter(|value| !value.trim().is_empty());
89        let multiplexed = set("TMUX").is_some() || set("STY").is_some();
90        let (forced, unknown) = match set(VARIABLE) {
91            Some(value) => match Graphics::from_name(&value) {
92                Some(graphics) => (Some(graphics), None),
93                None => (None, Some(value)),
94            },
95            None => (None, None),
96        };
97        (Self { answer: Graphics::HalfBlock, forced, multiplexed }, unknown)
98    }
99
100    /// The graphics in force for a terminal of `depth` drawing in `glyphs`.
101    ///
102    /// `QUVYTA_GRAPHICS` wins over everything, because the person who set it knows their
103    /// terminal. Otherwise 16 colours and ASCII glyphs show no picture, a multiplexer turns kitty
104    /// and sixel into half blocks because it does not pass them through, and what the terminal
105    /// answered decides the rest.
106    pub(crate) fn resolve(self, depth: crate::color::ColorDepth, glyphs: crate::icons::GlyphMode) -> Graphics {
107        if let Some(forced) = self.forced {
108            return forced;
109        }
110        if depth == crate::color::ColorDepth::Ansi16 || glyphs == crate::icons::GlyphMode::Ascii {
111            return Graphics::None;
112        }
113        match self.answer {
114            Graphics::Kitty | Graphics::Sixel if self.multiplexed => Graphics::HalfBlock,
115            answer => answer,
116        }
117    }
118
119    /// Whether asking the terminal could change the result: not when the variable decides, not
120    /// inside a multiplexer, and not at 16 colours, which a running application never leaves.
121    /// ASCII glyphs do not count, because a settings screen can switch them off while it runs.
122    pub(crate) fn worth_asking(self, depth: crate::color::ColorDepth) -> bool {
123        self.forced.is_none() && !self.multiplexed && depth != crate::color::ColorDepth::Ansi16
124    }
125}
126
127/// The question the runtime sends as it starts: a kitty graphics query for a one-pixel image
128/// (`a=q` only asks, nothing is stored or shown), then a primary device attributes request (DA1).
129/// Every terminal answers DA1 and answers in order, so its reply marks the end of the answers.
130pub(crate) const QUERY: &str = "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\\x1b[c";
131
132/// Reads the terminal's answers to [`QUERY`]: an `OK` to the kitty query means kitty, a DA1
133/// answer that lists attribute `4` means sixel, and anything else, no answer included, means half
134/// blocks. Kitty wins when both are there, being the sharper and the cheaper to redraw.
135pub(crate) fn classify(replies: &[u8]) -> Graphics {
136    if kitty_ok(replies) {
137        Graphics::Kitty
138    } else if primary_attributes(replies).is_some_and(|attributes| attributes.contains(&"4")) {
139        Graphics::Sixel
140    } else {
141        Graphics::HalfBlock
142    }
143}
144
145/// Whether `replies` hold the terminal's primary device attributes in full, which is the last
146/// answer to [`QUERY`].
147pub(crate) fn answered(replies: &[u8]) -> bool {
148    primary_attributes(replies).is_some()
149}
150
151/// Whether `replies` hold `ESC _ G i=31 … ; OK ESC \`, the kitty answer to the query's image 31.
152fn kitty_ok(replies: &[u8]) -> bool {
153    let mut rest = replies;
154    while let Some(start) = find(rest, b"\x1b_G") {
155        let after = &rest[start + 3..];
156        let Some(end) = find(after, b"\x1b\\") else {
157            return false;
158        };
159        let body = &after[..end];
160        if let Some(split) = body.iter().position(|&byte| byte == b';') {
161            let (keys, message) = (&body[..split], &body[split + 1..]);
162            if keys.split(|&byte| byte == b',').any(|key| key == b"i=31") && message == b"OK" {
163                return true;
164            }
165        }
166        rest = &after[end + 2..];
167    }
168    false
169}
170
171/// The attributes of a DA1 answer, `ESC [ ? 62 ; 4 ; 22 c`, when `replies` hold a whole one.
172fn primary_attributes(replies: &[u8]) -> Option<Vec<&str>> {
173    let mut rest = replies;
174    while let Some(start) = find(rest, b"\x1b[?") {
175        let body = &rest[start + 3..];
176        let length = body.iter().position(|&byte| !(byte.is_ascii_digit() || byte == b';'))?;
177        if body[length] == b'c' {
178            let text = std::str::from_utf8(&body[..length]).ok()?;
179            return Some(text.split(';').collect());
180        }
181        rest = &body[length..];
182    }
183    None
184}
185
186fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
187    haystack.windows(needle.len()).position(|window| window == needle)
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::color::ColorDepth;
194    use crate::icons::GlyphMode;
195
196    const KITTY_OK: &[u8] = b"\x1b_Gi=31;OK\x1b\\";
197    /// What kitty itself answers to DA1: a VT220 without sixel.
198    const DA1_PLAIN: &[u8] = b"\x1b[?62;c";
199    /// What foot answers: a VT220 with sixel (4) and ANSI colour (22).
200    const DA1_SIXEL: &[u8] = b"\x1b[?62;4;22c";
201
202    #[test]
203    fn a_kitty_ok_before_the_attributes_means_kitty() {
204        assert_eq!(classify(&[KITTY_OK, DA1_PLAIN].concat()), Graphics::Kitty);
205        assert_eq!(classify(&[KITTY_OK, DA1_SIXEL].concat()), Graphics::Kitty, "the sharper of the two wins");
206    }
207
208    #[test]
209    fn attributes_listing_4_mean_sixel() {
210        assert_eq!(classify(DA1_SIXEL), Graphics::Sixel);
211        assert_eq!(classify(b"\x1b[?4c"), Graphics::Sixel, "4 alone");
212        assert_eq!(classify(b"\x1b[?64;1;2;4;6;9;15;18;21;22c"), Graphics::Sixel, "xterm as a VT340");
213    }
214
215    #[test]
216    fn attributes_without_4_mean_half_blocks() {
217        assert_eq!(classify(DA1_PLAIN), Graphics::HalfBlock);
218        assert_eq!(classify(b"\x1b[?1;2c"), Graphics::HalfBlock, "a VT100 with advanced video");
219        assert_eq!(classify(b"\x1b[?64;14;22c"), Graphics::HalfBlock, "14 is not 4");
220    }
221
222    #[test]
223    fn a_kitty_error_or_another_image_is_not_kitty() {
224        let refused = [&b"\x1b_Gi=31;ENOTSUPPORTED:no\x1b\\"[..], DA1_PLAIN].concat();
225        assert_eq!(classify(&refused), Graphics::HalfBlock);
226        let other = [&b"\x1b_Gi=7;OK\x1b\\"[..], DA1_PLAIN].concat();
227        assert_eq!(classify(&other), Graphics::HalfBlock);
228    }
229
230    #[test]
231    fn garbage_and_silence_mean_half_blocks() {
232        assert_eq!(classify(b""), Graphics::HalfBlock);
233        assert_eq!(classify(b"hello \x1b[?62;4"), Graphics::HalfBlock, "an unfinished answer");
234        assert_eq!(classify(b"\x1b_Gi=31;OK"), Graphics::HalfBlock, "an unterminated kitty answer");
235        assert_eq!(classify(b"\x1b[?6x4c\x1b\x1b_G;"), Graphics::HalfBlock);
236        assert_eq!(classify(&[0xff, 0x1b, b'[', b'?', 0xfe]), Graphics::HalfBlock);
237    }
238
239    #[test]
240    fn the_attributes_mark_the_end_of_the_answers() {
241        assert!(!answered(b""));
242        assert!(!answered(KITTY_OK), "the kitty answer comes first; the attributes are still due");
243        assert!(!answered(b"\x1b[?62;4"));
244        assert!(answered(&[KITTY_OK, DA1_PLAIN].concat()));
245        assert!(answered(DA1_SIXEL));
246    }
247
248    #[test]
249    fn a_multiplexer_turns_kitty_and_sixel_into_half_blocks() {
250        for variable in ["TMUX", "STY"] {
251            let lookup = |name: &str| (name == variable).then(|| "/tmp/tmux-1000/default,1234,0".to_owned());
252            let (mut facts, unknown) = GraphicsFacts::detect(lookup);
253            assert!(facts.multiplexed && unknown.is_none(), "{variable}");
254            for answer in [Graphics::Kitty, Graphics::Sixel, Graphics::HalfBlock] {
255                facts.answer = answer;
256                let graphics = facts.resolve(ColorDepth::TrueColor, GlyphMode::Unicode);
257                assert_eq!(graphics, Graphics::HalfBlock, "{variable} with {answer:?}");
258            }
259            assert!(!facts.worth_asking(ColorDepth::TrueColor), "a multiplexer needs no question");
260        }
261        let empty = |name: &str| (name == "TMUX").then(String::new);
262        assert!(!GraphicsFacts::detect(empty).0.multiplexed, "an empty variable is unset");
263    }
264
265    #[test]
266    fn outside_a_multiplexer_the_answer_decides() {
267        let (mut facts, _) = GraphicsFacts::detect(|_| None);
268        for answer in [Graphics::Kitty, Graphics::Sixel, Graphics::HalfBlock] {
269            facts.answer = answer;
270            assert_eq!(facts.resolve(ColorDepth::TrueColor, GlyphMode::Unicode), answer);
271            assert_eq!(facts.resolve(ColorDepth::Ansi256, GlyphMode::Nerd), answer);
272        }
273        assert!(facts.worth_asking(ColorDepth::Ansi256));
274    }
275
276    #[test]
277    fn sixteen_colours_and_ascii_show_no_picture() {
278        let facts = GraphicsFacts { answer: Graphics::Kitty, ..GraphicsFacts::default() };
279        assert_eq!(facts.resolve(ColorDepth::Ansi16, GlyphMode::Unicode), Graphics::None);
280        assert_eq!(facts.resolve(ColorDepth::TrueColor, GlyphMode::Ascii), Graphics::None);
281        assert!(!facts.worth_asking(ColorDepth::Ansi16), "16 colours never change while it runs");
282        assert!(facts.worth_asking(ColorDepth::TrueColor), "ASCII glyphs can be switched off while it runs");
283    }
284
285    #[test]
286    fn the_variable_wins_over_everything() {
287        for forced in Graphics::ALL {
288            let lookup = |name: &str| match name {
289                VARIABLE => Some(format!(" {} ", forced.name().to_uppercase())),
290                "TMUX" => Some("/tmp/tmux".to_owned()),
291                _ => None,
292            };
293            let (mut facts, unknown) = GraphicsFacts::detect(lookup);
294            assert_eq!((facts.forced, unknown), (Some(forced), None));
295            facts.answer = Graphics::Sixel;
296            assert_eq!(facts.resolve(ColorDepth::Ansi16, GlyphMode::Ascii), forced, "{forced:?}");
297            assert_eq!(facts.resolve(ColorDepth::TrueColor, GlyphMode::Unicode), forced, "{forced:?}");
298            assert!(!facts.worth_asking(ColorDepth::TrueColor), "the variable already decided");
299        }
300    }
301
302    #[test]
303    fn an_unknown_name_is_reported_and_ignored() {
304        let lookup = |name: &str| (name == VARIABLE).then(|| "pixels".to_owned());
305        let (facts, unknown) = GraphicsFacts::detect(lookup);
306        assert_eq!(facts.forced, None);
307        assert_eq!(unknown.as_deref(), Some("pixels"));
308    }
309
310    #[test]
311    fn names_read_back() {
312        for graphics in Graphics::ALL {
313            assert_eq!(Graphics::from_name(graphics.name()), Some(graphics));
314        }
315        assert_eq!(Graphics::from_name("half-block"), None);
316    }
317}