data_beans/figure/
term.rs1use 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
15const POINTS_PER_CELL: f32 = 12.0;
18
19fn 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
38fn 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
50pub 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#[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 pub fn invalidate(&mut self) {
79 self.stale = true;
80 }
81
82 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 plot.invalidate();
182 assert!(!frame(&mut plot, &mut drawn));
183 assert_eq!(drawn, 2);
184 }
185}