use image::DynamicImage;
use legume_tui::graphics::Fallback;
use ratatui::layout::{Rect, Size};
use ratatui::Frame;
use ratatui_image::picker::{Picker, ProtocolType};
use ratatui_image::protocol::Protocol;
use ratatui_image::{Image, Resize};
const POINTS_PER_CELL: f32 = 12.0;
fn likely_images(var: impl Fn(&str) -> Option<String>) -> bool {
let has = |k: &str| var(k).is_some_and(|v| !v.is_empty());
let term = var("TERM").unwrap_or_default();
let program = var("TERM_PROGRAM").unwrap_or_default();
has("KITTY_WINDOW_ID")
|| has("WEZTERM_EXECUTABLE")
|| matches!(
program.as_str(),
"iTerm.app" | "WezTerm" | "ghostty" | "vscode"
)
|| var("LC_TERMINAL").is_some_and(|v| v == "iTerm2")
|| ["kitty", "ghostty", "wezterm", "foot"]
.iter()
.any(|t| term.contains(t))
}
fn asks(images: Option<&str>, var: impl Fn(&str) -> Option<String>) -> bool {
match images {
Some("0") => false,
Some("1") => true,
_ => likely_images(var),
}
}
pub fn picker(session: &legume_tui::Session) -> Option<Picker> {
let images = std::env::var("DATA_BEANS_TUI_IMAGES").ok();
if !asks(images.as_deref(), |k| session.terminal_env(k)) {
return None;
}
let (p, _) = legume_tui::graphics::pick_protocol(session, None, Fallback::Probe);
(p.protocol_type() != ProtocolType::Halfblocks).then_some(p)
}
#[derive(Default)]
pub struct PlotImage {
stale: bool,
size: Option<(u16, u16)>,
protocol: Option<Protocol>,
made: Option<(u64, (ProtocolType, u16, u16))>,
}
impl PlotImage {
pub fn invalidate(&mut self) {
self.stale = true;
}
pub fn render(
&mut self,
frame: &mut Frame,
area: Rect,
picker: &Picker,
draw: impl FnOnce(f64, f64) -> String,
) -> bool {
if area.width == 0 || area.height == 0 {
return true;
}
let font = picker.font_size();
let made = (
legume_tui::attaches(),
(picker.protocol_type(), font.width, font.height),
);
if self.made != Some(made) {
self.invalidate();
self.made = Some(made);
}
let size = (area.width, area.height);
if self.stale || self.size != Some(size) {
let scale = font.height.max(1) as f32 / POINTS_PER_CELL;
let (px, py) = (
area.width as f32 * font.width as f32,
area.height as f32 * font.height as f32,
);
let svg = draw((px / scale) as f64, (py / scale) as f64);
let made = super::image(&svg, scale).and_then(|img| {
Ok(picker.new_protocol(
DynamicImage::ImageRgba8(img),
Size::new(area.width, area.height),
Resize::Fit(None),
)?)
});
if let Err(e) = &made {
log::debug!("no image for a {}x{} plot area: {e}", size.0, size.1);
}
self.protocol = made.ok();
self.size = Some(size);
self.stale = false;
}
match &self.protocol {
Some(p) => {
frame.render_widget(Image::new(p), area);
true
}
None => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn images_are_asked_for_where_the_terminal_has_them() {
let env = |pairs: &'static [(&'static str, &'static str)]| {
move |k: &str| {
pairs
.iter()
.find(|(n, _)| *n == k)
.map(|(_, v)| v.to_string())
}
};
assert!(asks(None, env(&[("TERM", "xterm-kitty")])));
assert!(asks(None, env(&[("TERM_PROGRAM", "ghostty")])));
assert!(!asks(None, env(&[("TERM", "xterm-256color")])));
assert!(asks(Some("1"), env(&[("TERM", "xterm-256color")])));
assert!(!asks(Some("0"), env(&[("TERM", "xterm-kitty")])));
}
#[test]
fn an_image_that_cannot_be_made_is_not_drawn_again_each_frame() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut terminal = Terminal::new(TestBackend::new(40, 12)).unwrap();
let picker = Picker::halfblocks();
let mut plot = PlotImage::default();
let mut drawn = 0;
let mut frame = |plot: &mut PlotImage, drawn: &mut usize| {
let mut shown = true;
terminal
.draw(|f| {
shown = plot.render(f, f.area(), &picker, |_, _| {
*drawn += 1;
"not a figure".to_string()
})
})
.unwrap();
shown
};
assert!(!frame(&mut plot, &mut drawn));
assert!(!frame(&mut plot, &mut drawn));
assert_eq!(drawn, 1);
plot.invalidate();
assert!(!frame(&mut plot, &mut drawn));
assert_eq!(drawn, 2);
}
}