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    time::Duration,
7};
8
9use crate::{
10    errors::Errors,
11    protocol::{
12        halfblocks::Halfblocks,
13        iterm2::Iterm2,
14        kitty::{Kitty, StatefulKitty},
15        sixel::Sixel,
16        Protocol, StatefulProtocol, StatefulProtocolType,
17    },
18    FontSize, ImageSource, Resize, Result,
19};
20use cap_parser::{Capability, Parser};
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
29const DEFAULT_BACKGROUND: Rgba<u8> = Rgba([0, 0, 0, 0]);
30
31#[derive(Clone, Copy, Debug)]
32pub struct Picker {
33    font_size: FontSize,
34    protocol_type: ProtocolType,
35    background_color: Rgba<u8>,
36    is_tmux: bool,
37}
38
39/// Serde-friendly protocol-type enum for [Picker].
40#[derive(PartialEq, Clone, Debug, Copy)]
41#[cfg_attr(
42    feature = "serde",
43    derive(Deserialize, Serialize),
44    serde(rename_all = "lowercase")
45)]
46pub enum ProtocolType {
47    Halfblocks,
48    Sixel,
49    Kitty,
50    Iterm2,
51}
52
53impl ProtocolType {
54    pub fn next(&self) -> ProtocolType {
55        match self {
56            ProtocolType::Halfblocks => ProtocolType::Sixel,
57            ProtocolType::Sixel => ProtocolType::Kitty,
58            ProtocolType::Kitty => ProtocolType::Iterm2,
59            ProtocolType::Iterm2 => ProtocolType::Halfblocks,
60        }
61    }
62}
63
64/// Helper for building widgets
65impl Picker {
66    /// Query terminal stdio for graphics capabilities and font-size with some escape sequences.
67    ///
68    /// This writes and reads from stdio momentarily. WARNING: this method should be called after
69    /// entering alternate screen but before reading terminal events.
70    ///
71    /// # Example
72    /// ```rust
73    /// use ratatui_image::picker::Picker;
74    /// let mut picker = Picker::from_query_stdio();
75    /// ```
76    ///
77    pub fn from_query_stdio() -> Result<Self> {
78        // Detect tmux, and only if positive then take some risky guess for iTerm2 support.
79        let (is_tmux, tmux_proto) = detect_tmux_and_outer_protocol_from_env();
80
81        // Write and read to stdin to query protocol capabilities and font-size.
82        match query_with_timeout(is_tmux, Duration::from_secs(1)) {
83            Ok((capability_proto, font_size)) => {
84                // If some env var says that we should try iTerm2, then disregard protocol-from-capabilities.
85                let iterm2_proto = iterm2_from_env();
86
87                let protocol_type = tmux_proto
88                    .or(iterm2_proto)
89                    .or(capability_proto)
90                    .unwrap_or(ProtocolType::Halfblocks);
91
92                if let Some(font_size) = font_size {
93                    Ok(Self {
94                        font_size,
95                        background_color: DEFAULT_BACKGROUND,
96                        protocol_type,
97                        is_tmux,
98                    })
99                } else {
100                    Err(Errors::NoFontSize)
101                }
102            }
103            Err(Errors::NoCap) => Ok(Self {
104                // This is completely arbitrary. For halfblocks, it doesn't have to be precise
105                // since we're not rendering pixels. It should be roughly 1:2 ratio, and some
106                // reasonable size.
107                font_size: (10, 20),
108                background_color: DEFAULT_BACKGROUND,
109                protocol_type: ProtocolType::Halfblocks,
110                is_tmux,
111            }),
112            Err(err) => Err(err),
113        }
114    }
115
116    /// Create a picker from a given terminal [FontSize].
117    /// This is the only way to create a picker on windows, for now.
118    ///
119    /// # Example
120    /// ```rust
121    /// use ratatui_image::picker::Picker;
122    ///
123    /// let user_fontsize = (7, 14);
124    ///
125    /// let mut picker = Picker::from_fontsize(user_fontsize);
126    /// ```
127    pub fn from_fontsize(font_size: FontSize) -> Self {
128        // Detect tmux, and if positive then take some risky guess for iTerm2 support.
129        let (is_tmux, tmux_proto) = detect_tmux_and_outer_protocol_from_env();
130
131        // Disregard protocol-from-capabilities if some env var says that we could try iTerm2.
132        let iterm2_proto = iterm2_from_env();
133
134        let protocol_type = tmux_proto
135            .or(iterm2_proto)
136            .unwrap_or(ProtocolType::Halfblocks);
137
138        Self {
139            font_size,
140            background_color: DEFAULT_BACKGROUND,
141            protocol_type,
142            is_tmux,
143        }
144    }
145
146    pub fn protocol_type(self) -> ProtocolType {
147        self.protocol_type
148    }
149
150    pub fn set_protocol_type(&mut self, protocol_type: ProtocolType) {
151        self.protocol_type = protocol_type;
152    }
153
154    pub fn font_size(self) -> FontSize {
155        self.font_size
156    }
157
158    // Change the default background color (transparent black).
159    pub fn set_background_color<T: Into<Rgba<u8>>>(&mut self, background_color: T) {
160        self.background_color = background_color.into();
161    }
162
163    /// Returns a new protocol for [`crate::Image`] widgets that fits into the given size.
164    pub fn new_protocol(
165        &self,
166        image: DynamicImage,
167        size: Rect,
168        resize: Resize,
169    ) -> Result<Protocol> {
170        let source = ImageSource::new(image, self.font_size, self.background_color);
171
172        let (image, area) =
173            match resize.needs_resize(&source, self.font_size, source.desired, size, false) {
174                Some(area) => {
175                    // Not exactly sure why this is necessary only for Protocol and not
176                    // StatefulProtocol, but the image proportion comes out wrong if we don't
177                    // divide height by half here.
178                    let font_size = if self.protocol_type == ProtocolType::Halfblocks {
179                        (self.font_size.0, self.font_size.1 / 2)
180                    } else {
181                        self.font_size
182                    };
183                    let image = resize.resize(&source, font_size, size, self.background_color);
184                    (image, area)
185                }
186                None => (source.image, source.desired),
187            };
188
189        match self.protocol_type {
190            ProtocolType::Halfblocks => Ok(Protocol::Halfblocks(Halfblocks::new(image, area)?)),
191            ProtocolType::Sixel => Ok(Protocol::Sixel(Sixel::new(image, area, self.is_tmux)?)),
192            ProtocolType::Kitty => Ok(Protocol::Kitty(Kitty::new(
193                image,
194                area,
195                rand::random(),
196                self.is_tmux,
197            )?)),
198            ProtocolType::Iterm2 => Ok(Protocol::ITerm2(Iterm2::new(image, area, self.is_tmux)?)),
199        }
200    }
201
202    /// Returns a new *stateful* protocol for [`crate::StatefulImage`] widgets.
203    pub fn new_resize_protocol(&self, image: DynamicImage) -> StatefulProtocol {
204        let source = ImageSource::new(image, self.font_size, self.background_color);
205        let protocol_type = match self.protocol_type {
206            ProtocolType::Halfblocks => StatefulProtocolType::Halfblocks(Halfblocks::default()),
207            ProtocolType::Sixel => StatefulProtocolType::Sixel(Sixel {
208                is_tmux: self.is_tmux,
209                ..Sixel::default()
210            }),
211            ProtocolType::Kitty => {
212                StatefulProtocolType::Kitty(StatefulKitty::new(random(), self.is_tmux))
213            }
214            ProtocolType::Iterm2 => StatefulProtocolType::ITerm2(Iterm2 {
215                is_tmux: self.is_tmux,
216                ..Iterm2::default()
217            }),
218        };
219        StatefulProtocol::new(source, self.font_size, protocol_type)
220    }
221}
222
223fn detect_tmux_and_outer_protocol_from_env() -> (bool, Option<ProtocolType>) {
224    // Check if we're inside tmux.
225    if !env::var("TERM").is_ok_and(|term| term.starts_with("tmux"))
226        && !env::var("TERM_PROGRAM").is_ok_and(|term_program| term_program == "tmux")
227    {
228        return (false, None);
229    }
230
231    let _ = std::process::Command::new("tmux")
232        .args(["set", "-p", "allow-passthrough", "on"])
233        .stdin(std::process::Stdio::null())
234        .stdout(std::process::Stdio::null())
235        .stderr(std::process::Stdio::null())
236        .spawn()
237        .and_then(|mut child| child.wait()); // wait(), for check_device_attrs.
238
239    // Crude guess based on the *existence* of some magic program specific env vars.
240    // Produces false positives, for example xterm started from kitty inherits KITTY_WINDOW_ID.
241    // Furthermore, tmux shares env vars from the first session, for example tmux started in xterm
242    // after a previous tmux session started in kitty, inherits KITTY_WINDOW_ID.
243    const OUTER_TERM_HINTS: [(&str, ProtocolType); 3] = [
244        ("KITTY_WINDOW_ID", ProtocolType::Kitty), // TODO: query should work inside tmux, remove?
245        ("ITERM_SESSION_ID", ProtocolType::Iterm2),
246        ("WEZTERM_EXECUTABLE", ProtocolType::Iterm2),
247    ];
248    for (hint, proto) in OUTER_TERM_HINTS {
249        if env::var(hint).is_ok_and(|s| !s.is_empty()) {
250            return (true, Some(proto));
251        }
252    }
253    (true, None)
254}
255
256fn iterm2_from_env() -> Option<ProtocolType> {
257    if env::var("TERM_PROGRAM").is_ok_and(|term_program| {
258        term_program.contains("iTerm")
259            || term_program.contains("WezTerm")
260            || term_program.contains("mintty")
261            || term_program.contains("vscode")
262            || term_program.contains("Tabby")
263            || term_program.contains("Hyper")
264            || term_program.contains("rio")
265    }) {
266        return Some(ProtocolType::Iterm2);
267    }
268    if env::var("LC_TERMINAL").is_ok_and(|lc_term| lc_term.contains("iTerm")) {
269        return Some(ProtocolType::Iterm2);
270    }
271    None
272}
273
274#[cfg(not(windows))]
275fn enable_raw_mode() -> Result<impl FnOnce() -> Result<()>> {
276    use rustix::termios::{self, LocalModes, OptionalActions};
277
278    let stdin = io::stdin();
279    let mut termios = termios::tcgetattr(&stdin)?;
280    let termios_original = termios.clone();
281
282    // Disable canonical mode to read without waiting for Enter, disable echoing.
283    termios.local_modes &= !LocalModes::ICANON;
284    termios.local_modes &= !LocalModes::ECHO;
285    termios::tcsetattr(&stdin, OptionalActions::Drain, &termios)?;
286
287    Ok(move || {
288        Ok(termios::tcsetattr(
289            io::stdin(),
290            OptionalActions::Now,
291            &termios_original,
292        )?)
293    })
294}
295
296#[cfg(windows)]
297fn enable_raw_mode() -> Result<impl FnOnce() -> Result<()>> {
298    use windows::{
299        core::PCWSTR,
300        Win32::{
301            Foundation::{GENERIC_READ, GENERIC_WRITE, HANDLE},
302            Storage::FileSystem::{
303                self, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
304            },
305            System::Console::{
306                self, CONSOLE_MODE, ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT,
307            },
308        },
309    };
310
311    let utf16: Vec<u16> = "CONIN$\0".encode_utf16().collect();
312    let utf16_ptr: *const u16 = utf16.as_ptr();
313
314    let in_handle = unsafe {
315        FileSystem::CreateFileW(
316            PCWSTR(utf16_ptr),
317            (GENERIC_READ | GENERIC_WRITE).0,
318            FILE_SHARE_READ | FILE_SHARE_WRITE,
319            None,
320            OPEN_EXISTING,
321            FILE_FLAGS_AND_ATTRIBUTES(0),
322            HANDLE::default(),
323        )
324    }?;
325
326    let mut original_in_mode = CONSOLE_MODE::default();
327    unsafe { Console::GetConsoleMode(in_handle, &mut original_in_mode) }?;
328
329    let requested_in_modes = !ENABLE_ECHO_INPUT & !ENABLE_LINE_INPUT & !ENABLE_PROCESSED_INPUT;
330    let in_mode = original_in_mode & requested_in_modes;
331    unsafe { Console::SetConsoleMode(in_handle, in_mode) }?;
332
333    Ok(move || {
334        unsafe { Console::SetConsoleMode(in_handle, original_in_mode) }?;
335        Ok(())
336    })
337}
338
339#[cfg(not(windows))]
340fn font_size_fallback() -> Option<FontSize> {
341    use rustix::termios::{self, Winsize};
342
343    let winsize = termios::tcgetwinsize(io::stdout()).ok()?;
344    let Winsize {
345        ws_xpixel: x,
346        ws_ypixel: y,
347        ws_col: cols,
348        ws_row: rows,
349    } = winsize;
350    if x == 0 || y == 0 || cols == 0 || rows == 0 {
351        return None;
352    }
353
354    Some((x / cols, y / rows))
355}
356
357#[cfg(windows)]
358fn font_size_fallback() -> Option<FontSize> {
359    None
360}
361
362fn query_stdio_capabilities(is_tmux: bool) -> Result<(Option<ProtocolType>, Option<FontSize>)> {
363    // Send several control sequences at once:
364    // `_Gi=...`: Kitty graphics support.
365    // `[c`: Capabilities including sixels.
366    // `[16t`: Cell-size (perhaps we should also do `[14t`).
367    // `[1337n`: iTerm2 (some terminals implement the protocol but sadly not this custom CSI)
368    // `[5n`: Device Status Report, implemented by all terminals, ensure that there is some
369    // response and we don't hang reading forever.
370    let query = Parser::query(is_tmux);
371    io::stdout().write_all(query.as_bytes())?;
372    io::stdout().flush()?;
373
374    let mut parser = Parser::new();
375    let mut capabilities = vec![];
376    'out: loop {
377        let mut charbuf: [u8; 50] = [0; 50];
378        let result = io::stdin().read(&mut charbuf);
379        match result {
380            Ok(read) => {
381                for ch in charbuf.iter().take(read) {
382                    let mut more_caps = parser.push(char::from(*ch));
383                    if more_caps[..] == [Capability::Status] {
384                        break 'out;
385                    } else {
386                        capabilities.append(&mut more_caps);
387                    }
388                }
389            }
390            Err(err) => {
391                return Err(err.into());
392            }
393        }
394    }
395
396    if capabilities.is_empty() {
397        return Err(Errors::NoCap);
398    }
399
400    let mut proto = None;
401    let mut font_size = None;
402    if capabilities.contains(&Capability::Kitty) {
403        proto = Some(ProtocolType::Kitty);
404    } else if capabilities.contains(&Capability::Sixel) {
405        proto = Some(ProtocolType::Sixel);
406    }
407
408    for cap in capabilities {
409        if let Capability::CellSize(Some((w, h))) = cap {
410            font_size = Some((w, h));
411        }
412    }
413    // In case some terminal didn't support the cell-size query.
414    font_size = font_size.or_else(font_size_fallback);
415
416    Ok((proto, font_size))
417}
418
419fn query_with_timeout(
420    is_tmux: bool,
421    timeout: Duration,
422) -> Result<(Option<ProtocolType>, Option<FontSize>)> {
423    use std::{sync::mpsc, thread};
424    let (tx, rx) = mpsc::channel();
425
426    thread::spawn(move || {
427        let _ = tx.send(
428            enable_raw_mode()
429                .map_err(Errors::into)
430                .and_then(|disable_raw_mode| {
431                    let result = query_stdio_capabilities(is_tmux);
432                    // Always try to return to raw_mode.
433                    disable_raw_mode()?;
434                    result
435                }),
436        );
437    });
438
439    match rx.recv_timeout(timeout) {
440        Ok(result) => Ok(result?),
441        Err(_recvtimeout) => Err(Errors::NoStdinResponse),
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use std::assert_eq;
448
449    use crate::picker::{Picker, ProtocolType};
450
451    #[test]
452    fn test_cycle_protocol() {
453        let mut proto = ProtocolType::Halfblocks;
454        proto = proto.next();
455        assert_eq!(proto, ProtocolType::Sixel);
456        proto = proto.next();
457        assert_eq!(proto, ProtocolType::Kitty);
458        proto = proto.next();
459        assert_eq!(proto, ProtocolType::Iterm2);
460        proto = proto.next();
461        assert_eq!(proto, ProtocolType::Halfblocks);
462    }
463
464    #[test]
465    fn test_from_query_stdio_no_hang() {
466        let _ = Picker::from_query_stdio();
467    }
468}