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