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). An image that
67/// could not be made is not tried again until then either.
68#[derive(Default)]
69pub struct PlotImage {
70    stale: bool,
71    size: Option<(u16, u16)>,
72    protocol: Option<Protocol>,
73    made: Option<(u64, (ProtocolType, u16, u16))>,
74}
75
76impl PlotImage {
77    /// The view changed: rebuild on the next frame.
78    pub fn invalidate(&mut self) {
79        self.stale = true;
80    }
81
82    /// Draw into `area`. `draw(width, height)` returns the SVG, in figure
83    /// points, for an area that size. False when no image could be made,
84    /// so the caller can fall back to text.
85    pub fn render(
86        &mut self,
87        frame: &mut Frame,
88        area: Rect,
89        picker: &Picker,
90        draw: impl FnOnce(f64, f64) -> String,
91    ) -> bool {
92        if area.width == 0 || area.height == 0 {
93            return true;
94        }
95        let font = picker.font_size();
96        let made = (
97            legume_tui::attaches(),
98            (picker.protocol_type(), font.width, font.height),
99        );
100        if self.made != Some(made) {
101            self.invalidate();
102            self.made = Some(made);
103        }
104        let size = (area.width, area.height);
105        if self.stale || self.size != Some(size) {
106            let scale = font.height.max(1) as f32 / POINTS_PER_CELL;
107            let (px, py) = (
108                area.width as f32 * font.width as f32,
109                area.height as f32 * font.height as f32,
110            );
111            let svg = draw((px / scale) as f64, (py / scale) as f64);
112            let made = super::image(&svg, scale).and_then(|img| {
113                Ok(picker.new_protocol(
114                    DynamicImage::ImageRgba8(img),
115                    Size::new(area.width, area.height),
116                    Resize::Fit(None),
117                )?)
118            });
119            if let Err(e) = &made {
120                log::debug!("no image for a {}x{} plot area: {e}", size.0, size.1);
121            }
122            self.protocol = made.ok();
123            self.size = Some(size);
124            self.stale = false;
125        }
126        match &self.protocol {
127            Some(p) => {
128                frame.render_widget(Image::new(p), area);
129                true
130            }
131            None => false,
132        }
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn images_are_asked_for_where_the_terminal_has_them() {
142        let env = |pairs: &'static [(&'static str, &'static str)]| {
143            move |k: &str| {
144                pairs
145                    .iter()
146                    .find(|(n, _)| *n == k)
147                    .map(|(_, v)| v.to_string())
148            }
149        };
150        assert!(asks(None, env(&[("TERM", "xterm-kitty")])));
151        assert!(asks(None, env(&[("TERM_PROGRAM", "ghostty")])));
152        assert!(!asks(None, env(&[("TERM", "xterm-256color")])));
153        assert!(asks(Some("1"), env(&[("TERM", "xterm-256color")])));
154        assert!(!asks(Some("0"), env(&[("TERM", "xterm-kitty")])));
155    }
156
157    #[test]
158    fn an_image_that_cannot_be_made_is_not_drawn_again_each_frame() {
159        use ratatui::backend::TestBackend;
160        use ratatui::Terminal;
161        let mut terminal = Terminal::new(TestBackend::new(40, 12)).unwrap();
162        let picker = Picker::halfblocks();
163        let mut plot = PlotImage::default();
164        let mut drawn = 0;
165        let mut frame = |plot: &mut PlotImage, drawn: &mut usize| {
166            let mut shown = true;
167            terminal
168                .draw(|f| {
169                    shown = plot.render(f, f.area(), &picker, |_, _| {
170                        *drawn += 1;
171                        "not a figure".to_string()
172                    })
173                })
174                .unwrap();
175            shown
176        };
177        assert!(!frame(&mut plot, &mut drawn));
178        assert!(!frame(&mut plot, &mut drawn));
179        assert_eq!(drawn, 1);
180        // A change to the view tries again.
181        plot.invalidate();
182        assert!(!frame(&mut plot, &mut drawn));
183        assert_eq!(drawn, 2);
184    }
185}