Skip to main content

ratatui_image/
picker.rs

1//! Helper module to build a protocol, and swap protocols at runtime
2
3use std::{
4    env,
5    io::{self, Read, Write},
6    sync::mpsc::Sender,
7};
8
9use crate::{
10    FontSize, ImageSource, Resize, Result,
11    errors::Errors,
12    protocol::{
13        Protocol, StatefulProtocol, StatefulProtocolType,
14        halfblocks::Halfblocks,
15        iterm2::Iterm2,
16        kitty::{Kitty, StatefulKitty},
17        sixel::Sixel,
18    },
19};
20use cap_parser::{Parser, QueryStdioOptions, Response};
21use image::{DynamicImage, Rgba};
22use rand::random;
23use ratatui::layout::Rect;
24#[cfg(feature = "serde")]
25use serde::{Deserialize, Serialize};
26
27pub mod cap_parser;
28
29#[derive(Debug, PartialEq, Clone)]
30pub enum Capability {
31    /// Reports supporting kitty graphics protocol.
32    Kitty,
33    /// Reports supporting sixel graphics protocol.
34    Sixel,
35    /// Reports supporting rectangular ops.
36    RectangularOps,
37    /// Reports font size in pixels.
38    CellSize(Option<(u16, u16)>),
39    /// Reports supporting text sizing protocol.
40    TextSizingProtocol,
41}
42
43const DEFAULT_BACKGROUND: Rgba<u8> = Rgba([0, 0, 0, 0]);
44const STDIN_READ_TIMEOUT_MILLIS: u64 = 2000;
45
46#[derive(Clone, Debug)]
47pub struct Picker {
48    font_size: FontSize,
49    protocol_type: ProtocolType,
50    background_color: Rgba<u8>,
51    is_tmux: bool,
52    capabilities: Vec<Capability>,
53}
54
55/// Serde-friendly protocol-type enum for [Picker].
56#[derive(PartialEq, Clone, Debug, Copy)]
57#[cfg_attr(
58    feature = "serde",
59    derive(Deserialize, Serialize),
60    serde(rename_all = "lowercase")
61)]
62pub enum ProtocolType {
63    Halfblocks,
64    Sixel,
65    Kitty,
66    Iterm2,
67}
68
69impl ProtocolType {
70    pub fn next(&self) -> ProtocolType {
71        match self {
72            ProtocolType::Halfblocks => ProtocolType::Sixel,
73            ProtocolType::Sixel => ProtocolType::Kitty,
74            ProtocolType::Kitty => ProtocolType::Iterm2,
75            ProtocolType::Iterm2 => ProtocolType::Halfblocks,
76        }
77    }
78}
79
80/// Helper for building widgets
81impl Picker {
82    /// Query terminal stdio for graphics capabilities and font-size with some escape sequences.
83    ///
84    /// This writes and reads from stdio momentarily. WARNING: this method should be called after
85    /// entering alternate screen but before reading terminal events.
86    ///
87    /// # Example
88    /// ```rust
89    /// use ratatui_image::picker::Picker;
90    /// let mut picker = Picker::from_query_stdio();
91    /// ```
92    ///
93    pub fn from_query_stdio() -> Result<Self> {
94        Picker::from_query_stdio_with_options(QueryStdioOptions::default())
95    }
96
97    /// This should ONLY be used if [Capability::TextSizingProtocol] is needed for some external
98    /// reason.
99    ///
100    /// Query for additional capabilities, currently supports querying for [Text Sizing Protocol].
101    ///
102    /// The result can be checked by searching for [Capability::TextSizingProtocol] in [Picker::capabilities].
103    ///
104    /// [Text Sizing Protocol] <https://sw.kovidgoyal.net/kitty/text-sizing-protocol//>
105    pub fn from_query_stdio_with_options(options: QueryStdioOptions) -> Result<Self> {
106        // Detect tmux, and only if positive then take some risky guess for iTerm2 support.
107        let (is_tmux, tmux_proto) = detect_tmux_and_outer_protocol_from_env();
108
109        static DEFAULT_PICKER: Picker = Picker {
110            // This is completely arbitrary. For halfblocks, it doesn't have to be precise
111            // since we're not rendering pixels. It should be roughly 1:2 ratio, and some
112            // reasonable size.
113            font_size: (10, 20),
114            background_color: DEFAULT_BACKGROUND,
115            protocol_type: ProtocolType::Halfblocks,
116            is_tmux: false,
117            capabilities: Vec::new(),
118        };
119
120        // Write and read to stdin to query protocol capabilities and font-size.
121        match query_with_timeout(is_tmux, options) {
122            Ok((capability_proto, font_size, caps)) => {
123                let iterm2_proto = iterm2_from_env();
124
125                // Wezterm reports kitty support but its implementation is incomplete.
126                // Suppress kitty and default to iterm2 (which wezterm fully supports).
127                let is_wezterm = env::var("WEZTERM_EXECUTABLE").is_ok_and(|s| !s.is_empty());
128
129                // IO-based detection is authoritative; env-based hints are fallbacks
130                // (env vars like KITTY_WINDOW_ID can be stale in tmux sessions).
131                let protocol_type = if is_wezterm {
132                    capability_proto
133                        .filter(|p| *p != ProtocolType::Kitty)
134                        .unwrap_or(ProtocolType::Iterm2)
135                } else {
136                    capability_proto
137                        .or(tmux_proto)
138                        .or(iterm2_proto)
139                        .unwrap_or(ProtocolType::Halfblocks)
140                };
141
142                if let Some(font_size) = font_size {
143                    Ok(Self {
144                        font_size,
145                        background_color: DEFAULT_BACKGROUND,
146                        protocol_type,
147                        is_tmux,
148                        capabilities: caps,
149                    })
150                } else {
151                    let mut p = DEFAULT_PICKER.clone();
152                    p.is_tmux = is_tmux;
153                    Ok(p)
154                }
155            }
156            Err(Errors::NoCap | Errors::NoStdinResponse | Errors::NoFontSize) => {
157                let mut p = DEFAULT_PICKER.clone();
158                p.is_tmux = is_tmux;
159                Ok(p)
160            }
161            Err(err) => Err(err),
162        }
163    }
164
165    /// Create a picker that is guaranteed to only work with Halfblocks.
166    ///
167    /// # Example
168    /// ```rust
169    /// use ratatui_image::picker::Picker;
170    ///
171    /// let mut picker = Picker::halfblocks();
172    /// ```
173    pub fn halfblocks() -> Self {
174        // Detect tmux, ignore iTerm2 as we don't have font-size.
175        let (is_tmux, _tmux_proto) = detect_tmux_and_outer_protocol_from_env();
176
177        Self {
178            font_size: (10, 20),
179            background_color: DEFAULT_BACKGROUND,
180            protocol_type: ProtocolType::Halfblocks,
181            is_tmux,
182            capabilities: Vec::new(),
183        }
184    }
185
186    /// Create a picker from a given terminal [FontSize].
187    #[deprecated(
188        since = "9.0.0",
189        note = "use `from_query_stdio` or `halfblocks` instead"
190    )]
191    pub fn from_fontsize(font_size: FontSize) -> Self {
192        // Detect tmux, and if positive then take some risky guess for iTerm2 support.
193        let (is_tmux, tmux_proto) = detect_tmux_and_outer_protocol_from_env();
194
195        // Disregard protocol-from-capabilities if some env var says that we could try iTerm2.
196        let iterm2_proto = iterm2_from_env();
197
198        let protocol_type = tmux_proto
199            .or(iterm2_proto)
200            .unwrap_or(ProtocolType::Halfblocks);
201
202        Self {
203            font_size,
204            background_color: DEFAULT_BACKGROUND,
205            protocol_type,
206            is_tmux,
207            capabilities: Vec::new(),
208        }
209    }
210
211    /// Returns the current protocol type.
212    pub fn protocol_type(&self) -> ProtocolType {
213        self.protocol_type
214    }
215
216    /// Force a protocol type.
217    pub fn set_protocol_type(&mut self, protocol_type: ProtocolType) {
218        self.protocol_type = protocol_type;
219    }
220
221    /// Returns the [FontSize] detected by [Picker::from_query_stdio].
222    pub fn font_size(&self) -> FontSize {
223        self.font_size
224    }
225
226    /// Change the default background color (transparent black).
227    pub fn set_background_color<T: Into<Rgba<u8>>>(&mut self, background_color: T) {
228        self.background_color = background_color.into();
229    }
230
231    /// Returns the capabilities detected by [Picker::from_query_stdio].
232    pub fn capabilities(&self) -> &Vec<Capability> {
233        &self.capabilities
234    }
235
236    /// Returns a new protocol for [`crate::Image`] widgets that fits into the given size.
237    pub fn new_protocol(
238        &self,
239        image: DynamicImage,
240        size: Rect,
241        resize: Resize,
242    ) -> Result<Protocol> {
243        let source = ImageSource::new(image, self.font_size, self.background_color);
244
245        let (image, area) =
246            match resize.needs_resize(&source, self.font_size, source.desired, size, false) {
247                Some(area) => {
248                    let image = resize.resize(&source, self.font_size, area, self.background_color);
249                    (image, area)
250                }
251                None => (source.image, source.desired),
252            };
253
254        match self.protocol_type {
255            ProtocolType::Halfblocks => Ok(Protocol::Halfblocks(Halfblocks::new(image, area)?)),
256            ProtocolType::Sixel => Ok(Protocol::Sixel(Sixel::new(image, area, self.is_tmux)?)),
257            ProtocolType::Kitty => Ok(Protocol::Kitty(Kitty::new(
258                image,
259                area,
260                rand::random(),
261                self.is_tmux,
262            )?)),
263            ProtocolType::Iterm2 => Ok(Protocol::ITerm2(Iterm2::new(image, area, self.is_tmux)?)),
264        }
265    }
266
267    /// Returns a new *stateful* protocol for [`crate::StatefulImage`] widgets.
268    pub fn new_resize_protocol(&self, image: DynamicImage) -> StatefulProtocol {
269        let source = ImageSource::new(image, self.font_size, self.background_color);
270        let protocol_type = match self.protocol_type {
271            ProtocolType::Halfblocks => StatefulProtocolType::Halfblocks(Halfblocks::default()),
272            ProtocolType::Sixel => StatefulProtocolType::Sixel(Sixel {
273                is_tmux: self.is_tmux,
274                ..Sixel::default()
275            }),
276            ProtocolType::Kitty => {
277                StatefulProtocolType::Kitty(StatefulKitty::new(random(), self.is_tmux))
278            }
279            ProtocolType::Iterm2 => StatefulProtocolType::ITerm2(Iterm2 {
280                is_tmux: self.is_tmux,
281                ..Iterm2::default()
282            }),
283        };
284        StatefulProtocol::new(source, self.font_size, protocol_type)
285    }
286}
287
288fn detect_tmux_and_outer_protocol_from_env() -> (bool, Option<ProtocolType>) {
289    // Check if we're inside tmux.
290    if !env::var("TERM").is_ok_and(|term| term.starts_with("tmux"))
291        && !env::var("TERM_PROGRAM").is_ok_and(|term_program| term_program == "tmux")
292    {
293        return (false, None);
294    }
295
296    let _ = std::process::Command::new("tmux")
297        .args(["set", "-p", "allow-passthrough", "on"])
298        .stdin(std::process::Stdio::null())
299        .stdout(std::process::Stdio::null())
300        .stderr(std::process::Stdio::null())
301        .spawn()
302        .and_then(|mut child| child.wait()); // wait(), for check_device_attrs.
303
304    // Crude guess based on the *existence* of some magic program specific env vars.
305    // Note: kitty is detected via io query (which works through tmux passthrough),
306    // not env vars, since KITTY_WINDOW_ID is often stale in tmux sessions.
307    const OUTER_TERM_HINTS: [(&str, ProtocolType); 2] = [
308        ("ITERM_SESSION_ID", ProtocolType::Iterm2),
309        ("WEZTERM_EXECUTABLE", ProtocolType::Iterm2),
310    ];
311    for (hint, proto) in OUTER_TERM_HINTS {
312        if env::var(hint).is_ok_and(|s| !s.is_empty()) {
313            return (true, Some(proto));
314        }
315    }
316    (true, None)
317}
318
319fn iterm2_from_env() -> Option<ProtocolType> {
320    if env::var("TERM_PROGRAM").is_ok_and(|term_program| {
321        term_program.contains("iTerm")
322            || term_program.contains("WezTerm")
323            || term_program.contains("mintty")
324            || term_program.contains("vscode")
325            || term_program.contains("Tabby")
326            || term_program.contains("Hyper")
327            || term_program.contains("rio")
328            || term_program.contains("Bobcat")
329            || term_program.contains("WarpTerminal")
330    }) {
331        return Some(ProtocolType::Iterm2);
332    }
333    if env::var("LC_TERMINAL").is_ok_and(|lc_term| lc_term.contains("iTerm")) {
334        return Some(ProtocolType::Iterm2);
335    }
336    None
337}
338
339#[cfg(not(windows))]
340fn enable_raw_mode() -> Result<impl FnOnce() -> Result<()>> {
341    use rustix::termios::{self, LocalModes, OptionalActions};
342
343    let stdin = io::stdin();
344    let mut termios = termios::tcgetattr(&stdin)?;
345    let termios_original = termios.clone();
346
347    // Disable canonical mode to read without waiting for Enter, disable echoing.
348    termios.local_modes &= !LocalModes::ICANON;
349    termios.local_modes &= !LocalModes::ECHO;
350    termios::tcsetattr(&stdin, OptionalActions::Drain, &termios)?;
351
352    Ok(move || {
353        Ok(termios::tcsetattr(
354            io::stdin(),
355            OptionalActions::Now,
356            &termios_original,
357        )?)
358    })
359}
360
361#[cfg(windows)]
362fn enable_raw_mode() -> Result<impl FnOnce() -> Result<()>> {
363    use windows::{
364        Win32::{
365            Foundation::{GENERIC_READ, GENERIC_WRITE, HANDLE},
366            Storage::FileSystem::{
367                self, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
368            },
369            System::Console::{
370                self, CONSOLE_MODE, ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT,
371            },
372        },
373        core::PCWSTR,
374    };
375
376    let utf16: Vec<u16> = "CONIN$\0".encode_utf16().collect();
377    let utf16_ptr: *const u16 = utf16.as_ptr();
378
379    let in_handle = unsafe {
380        FileSystem::CreateFileW(
381            PCWSTR(utf16_ptr),
382            (GENERIC_READ | GENERIC_WRITE).0,
383            FILE_SHARE_READ | FILE_SHARE_WRITE,
384            None,
385            OPEN_EXISTING,
386            FILE_FLAGS_AND_ATTRIBUTES(0),
387            HANDLE::default(),
388        )
389    }?;
390
391    let mut original_in_mode = CONSOLE_MODE::default();
392    unsafe { Console::GetConsoleMode(in_handle, &mut original_in_mode) }?;
393
394    let requested_in_modes = !ENABLE_ECHO_INPUT & !ENABLE_LINE_INPUT & !ENABLE_PROCESSED_INPUT;
395    let in_mode = original_in_mode & requested_in_modes;
396    unsafe { Console::SetConsoleMode(in_handle, in_mode) }?;
397
398    Ok(move || {
399        unsafe { Console::SetConsoleMode(in_handle, original_in_mode) }?;
400        Ok(())
401    })
402}
403
404#[cfg(not(windows))]
405fn font_size_fallback() -> Option<FontSize> {
406    use rustix::termios::{self, Winsize};
407
408    let winsize = termios::tcgetwinsize(io::stdout()).ok()?;
409    let Winsize {
410        ws_xpixel: x,
411        ws_ypixel: y,
412        ws_col: cols,
413        ws_row: rows,
414    } = winsize;
415    if x == 0 || y == 0 || cols == 0 || rows == 0 {
416        return None;
417    }
418
419    Some((x / cols, y / rows))
420}
421
422#[cfg(windows)]
423fn font_size_fallback() -> Option<FontSize> {
424    None
425}
426
427/// Query the terminal, by writing and reading to stdin and stdout.
428/// The terminal must be in "raw mode" and should probably be reset to "cooked mode" when this
429/// operation has completed.
430///
431/// The returned [ProtocolType] and [FontSize] may be included in the list of [Capability]s,
432/// but the burden of picking out the right one or a font-size fallback is already resolved here.
433fn query_stdio_capabilities(
434    is_tmux: bool,
435    options: QueryStdioOptions,
436    tx: &Sender<QueryResult>,
437) -> Result<()> {
438    // Send several control sequences at once:
439    // `_Gi=...`: Kitty graphics support.
440    // `[c`: Capabilities including sixels.
441    // `[16t`: Cell-size (perhaps we should also do `[14t`).
442    // `[1337n`: iTerm2 (some terminals implement the protocol but sadly not this custom CSI)
443    // `[5n`: Device Status Report, implemented by all terminals, ensure that there is some
444    // response and we don't hang reading forever.
445    let query = Parser::query(is_tmux, options);
446    io::stdout().write_all(query.as_bytes())?;
447    io::stdout().flush()?;
448
449    let mut parser = Parser::new();
450    let mut responses = vec![];
451    'out: loop {
452        let mut charbuf: [u8; 50] = [0; 50];
453
454        let read = io::stdin().read(&mut charbuf)?;
455        // A read blocks a bit, keep receiver busy now.
456        tx.send(QueryResult::Busy)
457            .map_err(|_senderr| Errors::NoStdinResponse)?;
458
459        for ch in charbuf.iter().take(read) {
460            let mut more_caps = parser.push(char::from(*ch));
461            match more_caps[..] {
462                [Response::Status] => {
463                    break 'out;
464                }
465                _ => responses.append(&mut more_caps),
466            }
467        }
468    }
469
470    let result = interpret_parser_responses(responses)?;
471    tx.send(QueryResult::Done(result))
472        .map_err(|_senderr| Errors::NoStdinResponse)?;
473    Ok(())
474}
475
476fn interpret_parser_responses(
477    responses: Vec<Response>,
478) -> Result<(Option<ProtocolType>, Option<FontSize>, Vec<Capability>)> {
479    if responses.is_empty() {
480        return Err(Errors::NoCap);
481    }
482
483    let mut capabilities = Vec::new();
484
485    let mut proto = None;
486    let mut font_size = None;
487
488    let mut cursor_position_reports = vec![];
489    for response in &responses {
490        if let Some(capability) = match response {
491            Response::Kitty => {
492                proto = Some(ProtocolType::Kitty);
493                Some(Capability::Kitty)
494            }
495            Response::Sixel => {
496                if proto.is_none() {
497                    // Only if kitty is not supported.
498                    proto = Some(ProtocolType::Sixel);
499                }
500                Some(Capability::Sixel)
501            }
502            Response::RectangularOps => Some(Capability::RectangularOps),
503            Response::CellSize(cell_size) => {
504                if let Some((w, h)) = cell_size {
505                    font_size = Some((*w, *h));
506                }
507                Some(Capability::CellSize(*cell_size))
508            }
509            Response::CursorPositionReport(x, y) => {
510                cursor_position_reports.push((x, y));
511                None
512            }
513            Response::Status => None,
514        } {
515            capabilities.push(capability);
516        }
517    }
518
519    // In case some terminal didn't support the cell-size query.
520    font_size = font_size.or_else(font_size_fallback);
521
522    if let [(x1, _y1), (x2, _y2), (x3, _y3)] = cursor_position_reports[..] {
523        // Test if the cursor advanced exactly two columns (instead of one) on both the width and
524        // scaling queries of the protocol.
525        // The documentation is a bit ambiguous, as it only says the cursor positions "need to be
526        // different from each other".
527        // However from my testing on Kitty and other terminals that do not support the feature,
528        // the cursor always advances at least one column since it is printing a space, so the CPRs
529        // will always be different from each other (unless we would move the cursor to a known
530        // position or something like that - and this also begs the question of needing to do this
531        // anyway, for the edge case of the cursor being at the very end of a line).
532        // My interpretation is that the cursor should advance 2 columns, instead of one, with both
533        // queries, and only then can we interpret it as supported.
534        // The Foot terminal notably reports a 2 column movement but fortunately only for the `w=2`
535        // query.
536        //
537        // The row part can be ignored.
538        if *x2 == x1 + 2 && *x3 == x2 + 2 {
539            capabilities.push(Capability::TextSizingProtocol);
540        }
541    }
542
543    Ok((proto, font_size, capabilities))
544}
545
546enum QueryResult {
547    Done((Option<ProtocolType>, Option<FontSize>, Vec<Capability>)),
548    Err(Errors),
549    Busy,
550}
551fn query_with_timeout(
552    is_tmux: bool,
553    options: QueryStdioOptions,
554) -> Result<(Option<ProtocolType>, Option<FontSize>, Vec<Capability>)> {
555    use std::{sync::mpsc, thread};
556    let (tx, rx) = mpsc::channel();
557
558    let timeout = options.timeout;
559    thread::spawn(move || {
560        if let Err(err) = tx
561            .send(QueryResult::Busy)
562            .map_err(|_senderr| Errors::NoStdinResponse)
563            .and_then(|_| enable_raw_mode())
564            .and_then(|disable_raw_mode| {
565                tx.send(QueryResult::Busy)
566                    .map_err(|_senderr| Errors::NoStdinResponse)?;
567                let result = query_stdio_capabilities(is_tmux, options, &tx);
568                disable_raw_mode()?;
569                result
570            })
571        {
572            // Last chance, fire and forget now.
573            let _ = tx.send(QueryResult::Err(err));
574        }
575    });
576
577    loop {
578        match rx.recv_timeout(timeout) {
579            Ok(qresult) => match qresult {
580                QueryResult::Done(result) => return Ok(result),
581                QueryResult::Err(err) => return Err(err),
582                QueryResult::Busy => continue, // restarts the timeout
583            },
584            Err(_recverr) => {
585                return Err(Errors::NoStdinResponse);
586            }
587        }
588    }
589}
590
591#[cfg(test)]
592mod tests {
593    use std::assert_eq;
594
595    use crate::picker::{Capability, Picker, ProtocolType};
596
597    use super::{cap_parser::Response, interpret_parser_responses};
598
599    #[test]
600    fn test_cycle_protocol() {
601        let mut proto = ProtocolType::Halfblocks;
602        proto = proto.next();
603        assert_eq!(proto, ProtocolType::Sixel);
604        proto = proto.next();
605        assert_eq!(proto, ProtocolType::Kitty);
606        proto = proto.next();
607        assert_eq!(proto, ProtocolType::Iterm2);
608        proto = proto.next();
609        assert_eq!(proto, ProtocolType::Halfblocks);
610    }
611
612    #[test]
613    fn test_from_query_stdio_no_hang() {
614        let _ = Picker::from_query_stdio();
615    }
616
617    #[test]
618    fn test_interpret_parser_responses_text_sizing_protocol() {
619        let (_, _, caps) = interpret_parser_responses(vec![
620            // Example response from Kitty.
621            Response::CursorPositionReport(1, 1),
622            Response::CursorPositionReport(3, 1),
623            Response::CursorPositionReport(5, 1),
624        ])
625        .unwrap();
626        assert!(caps.contains(&Capability::TextSizingProtocol));
627    }
628
629    #[test]
630    fn test_interpret_parser_responses_text_sizing_protocol_incomplete() {
631        let (_, _, caps) = interpret_parser_responses(vec![
632            // Example response from Foot, notably moves 2 columns only on `w=2` query, but not
633            // `s=2`.
634            Response::CursorPositionReport(1, 22),
635            Response::CursorPositionReport(3, 22),
636            Response::CursorPositionReport(4, 22),
637        ])
638        .unwrap();
639        assert!(!caps.contains(&Capability::TextSizingProtocol));
640    }
641}