1#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum Graphics {
12 Kitty,
15 Sixel,
18 HalfBlock,
21 None,
24}
25
26impl Graphics {
27 pub const ALL: [Self; 4] = [Self::Kitty, Self::Sixel, Self::HalfBlock, Self::None];
29
30 #[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 #[must_use]
50 pub fn can_draw(self) -> bool {
51 self != Self::None
52 }
53
54 #[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
63pub(crate) const VARIABLE: &str = "QUVYTA_GRAPHICS";
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub(crate) struct GraphicsFacts {
69 pub(crate) answer: Graphics,
72 pub(crate) forced: Option<Graphics>,
74 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 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 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 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
127pub(crate) const QUERY: &str = "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\\x1b[c";
131
132pub(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
145pub(crate) fn answered(replies: &[u8]) -> bool {
148 primary_attributes(replies).is_some()
149}
150
151fn 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
171fn 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 const DA1_PLAIN: &[u8] = b"\x1b[?62;c";
199 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}