Skip to main content

data_beans/figure/
term.rs

1//! Figures drawn as images inside the full-screen views, in terminals that
2//! speak an image protocol (kitty, iTerm2, WezTerm, Ghostty, sixel). The
3//! image is the same SVG a save writes, rasterised to the area's pixel
4//! size; elsewhere the views keep their text plots, which beat half-block
5//! images. From faba's `src/figure/term.rs`.
6
7use image::DynamicImage;
8use legume_tui::graphics::Fallback;
9use ratatui::layout::{Rect, Size};
10use ratatui::Frame;
11use ratatui_image::picker::{Picker, ProtocolType};
12use ratatui_image::protocol::Protocol;
13use ratatui_image::{Image, Resize};
14
15/// Figure points per terminal cell height, so 9-point text reads at about
16/// three quarters of a text line.
17const POINTS_PER_CELL: f32 = 12.0;
18
19/// Whether the environment names a terminal with an image protocol. The
20/// query is only sent then, so a terminal without one never waits for its
21/// answer.
22fn likely_images(var: impl Fn(&str) -> Option<String>) -> bool {
23    let has = |k: &str| var(k).is_some_and(|v| !v.is_empty());
24    let term = var("TERM").unwrap_or_default();
25    let program = var("TERM_PROGRAM").unwrap_or_default();
26    has("KITTY_WINDOW_ID")
27        || has("WEZTERM_EXECUTABLE")
28        || matches!(
29            program.as_str(),
30            "iTerm.app" | "WezTerm" | "ghostty" | "vscode"
31        )
32        || var("LC_TERMINAL").is_some_and(|v| v == "iTerm2")
33        || ["kitty", "ghostty", "wezterm", "foot"]
34            .iter()
35            .any(|t| term.contains(t))
36}
37
38/// Whether to ask the terminal for its image protocol, by
39/// `DATA_BEANS_TUI_IMAGES` (`images`) and the terminal's environment
40/// (`var`): never with `0`, always with `1`, else where the environment
41/// names a terminal that has one.
42fn asks(images: Option<&str>, var: impl Fn(&str) -> Option<String>) -> bool {
43    match images {
44        Some("0") => false,
45        Some("1") => true,
46        _ => likely_images(var),
47    }
48}
49
50/// The image support of the terminal `session` is attached to now, asked
51/// of it (`legume_tui::graphics`, with a timeout). `None` without an image
52/// protocol, in a terminal not known to have one (`DATA_BEANS_TUI_IMAGES=1`
53/// asks anyway), or with `DATA_BEANS_TUI_IMAGES=0`. Call it with the screen
54/// up, and again after every attach.
55pub fn picker(session: &legume_tui::Session) -> Option<Picker> {
56    let images = std::env::var("DATA_BEANS_TUI_IMAGES").ok();
57    if !asks(images.as_deref(), |k| session.terminal_env(k)) {
58        return None;
59    }
60    let (p, _) = legume_tui::graphics::pick_protocol(session, None, Fallback::Probe);
61    (p.protocol_type() != ProtocolType::Halfblocks).then_some(p)
62}
63
64/// One plot area drawn as an image: rebuilt only when marked stale, when
65/// the area changes size, or after a terminal attached (its picker may be
66/// another, and kitty's images were deleted by the attach).
67#[derive(Default)]
68pub struct PlotImage {
69    stale: bool,
70    size: Option<(u16, u16)>,
71    protocol: Option<Protocol>,
72    made: Option<(u64, (ProtocolType, u16, u16))>,
73}
74
75impl PlotImage {
76    /// The view changed: rebuild on the next frame.
77    pub fn invalidate(&mut self) {
78        self.stale = true;
79    }
80
81    /// Draw into `area`. `draw(width, height)` returns the SVG, in figure
82    /// points, for an area that size. False when no image could be made,
83    /// so the caller can fall back to text.
84    pub fn render(
85        &mut self,
86        frame: &mut Frame,
87        area: Rect,
88        picker: &Picker,
89        draw: impl FnOnce(f64, f64) -> String,
90    ) -> bool {
91        if area.width == 0 || area.height == 0 {
92            return true;
93        }
94        let font = picker.font_size();
95        let made = (
96            legume_tui::attaches(),
97            (picker.protocol_type(), font.width, font.height),
98        );
99        if self.made != Some(made) {
100            self.invalidate();
101            self.made = Some(made);
102        }
103        let size = (area.width, area.height);
104        if self.stale || self.size != Some(size) || self.protocol.is_none() {
105            let scale = font.height.max(1) as f32 / POINTS_PER_CELL;
106            let (px, py) = (
107                area.width as f32 * font.width as f32,
108                area.height as f32 * font.height as f32,
109            );
110            let svg = draw((px / scale) as f64, (py / scale) as f64);
111            self.protocol = super::image(&svg, scale).ok().and_then(|img| {
112                picker
113                    .new_protocol(
114                        DynamicImage::ImageRgba8(img),
115                        Size::new(area.width, area.height),
116                        Resize::Fit(None),
117                    )
118                    .ok()
119            });
120            self.size = Some(size);
121            self.stale = false;
122        }
123        match &self.protocol {
124            Some(p) => {
125                frame.render_widget(Image::new(p), area);
126                true
127            }
128            None => false,
129        }
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn images_are_asked_for_where_the_terminal_has_them() {
139        let env = |pairs: &'static [(&'static str, &'static str)]| {
140            move |k: &str| {
141                pairs
142                    .iter()
143                    .find(|(n, _)| *n == k)
144                    .map(|(_, v)| v.to_string())
145            }
146        };
147        assert!(asks(None, env(&[("TERM", "xterm-kitty")])));
148        assert!(asks(None, env(&[("TERM_PROGRAM", "ghostty")])));
149        assert!(!asks(None, env(&[("TERM", "xterm-256color")])));
150        assert!(asks(Some("1"), env(&[("TERM", "xterm-256color")])));
151        assert!(!asks(Some("0"), env(&[("TERM", "xterm-kitty")])));
152    }
153}