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)]
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 pub fn invalidate(&mut self) {
78 self.stale = true;
79 }
80
81 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}