use image::GenericImageView;
use ratatui_image::picker::Picker;
use ratatui_image::protocol::StatefulProtocol;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
const MAX_ROWS: u16 = 20;
struct Cached {
protocol: StatefulProtocol,
natural: (u32, u32),
fit: (u32, u32),
rows: u16,
}
#[derive(Default)]
pub struct Images {
picker: Option<Picker>,
attachments: PathBuf,
cache: HashMap<PathBuf, Option<Cached>>,
}
impl Images {
pub fn new(attachments: PathBuf) -> Images {
Images {
attachments,
..Default::default()
}
}
pub fn set_attachments(&mut self, attachments: PathBuf) {
self.attachments = attachments;
self.cache.clear();
}
pub fn probe(&mut self) {
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() || std::env::var_os("TMUX").is_some() {
return;
}
self.picker = Picker::from_query_stdio().ok().filter(|p| {
!matches!(
p.protocol_type(),
ratatui_image::picker::ProtocolType::Halfblocks
)
});
}
pub fn resolve(&self, url: &str, note_dir: &Path) -> Option<PathBuf> {
resolve_in(url, note_dir, &self.attachments)
}
pub fn rows(&mut self, url: &str, note_dir: &Path, cols: u16) -> Option<u16> {
let picker = self.picker.as_ref()?;
let path = self.resolve(url, note_dir)?;
let (font_w, font_h) = picker.font_size();
if !self.cache.contains_key(&path) {
let decoded = image::ImageReader::open(&path)
.ok()
.and_then(|r| r.with_guessed_format().ok())
.and_then(|r| r.decode().ok());
let entry = decoded.map(|img| {
let natural = img.dimensions();
let (fit, rows) = fit_px(natural.0, natural.1, font_w, font_h, cols);
Cached {
protocol: picker.new_resize_protocol(scaled(&img, fit)),
natural,
fit,
rows,
}
});
self.cache.insert(path.clone(), entry);
}
let entry = self.cache.get_mut(&path)?.as_mut()?;
let (fit, rows) = fit_px(entry.natural.0, entry.natural.1, font_w, font_h, cols);
if fit != entry.fit {
let img = image::ImageReader::open(&path)
.ok()
.and_then(|r| r.with_guessed_format().ok())
.and_then(|r| r.decode().ok());
if let Some(img) = img {
entry.protocol = picker.new_resize_protocol(scaled(&img, fit));
entry.fit = fit;
entry.rows = rows;
}
}
Some(entry.rows)
}
pub fn protocol(&mut self, url: &str, note_dir: &Path) -> Option<&mut StatefulProtocol> {
let path = self.resolve(url, note_dir)?;
self.cache.get_mut(&path)?.as_mut().map(|c| &mut c.protocol)
}
}
fn scaled(img: &image::DynamicImage, (w, h): (u32, u32)) -> image::DynamicImage {
if img.dimensions() == (w, h) {
img.clone()
} else {
img.resize_exact(w.max(1), h.max(1), image::imageops::FilterType::Triangle)
}
}
fn fit_px(w: u32, h: u32, font_w: u16, font_h: u16, cols: u16) -> ((u32, u32), u16) {
let font_w = font_w.max(1) as f32;
let font_h = font_h.max(1) as f32;
let (wf, hf) = (w.max(1) as f32, h.max(1) as f32);
let mut scale = (cols.max(1) as f32 * font_w / wf).min(1.0);
let cap = MAX_ROWS as f32 * font_h;
if hf * scale > cap {
scale = cap / hf;
}
let rows = ((hf * scale / font_h).ceil() as u16).clamp(1, MAX_ROWS);
let px = (
(wf * scale).round().max(1.0) as u32,
(hf * scale).round().max(1.0) as u32,
);
(px, rows)
}
pub fn band_rows(natural: u16, viewport: u16) -> u16 {
natural.min(viewport.max(1)).max(1)
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub struct BandSlice {
pub offset: u16,
pub rows: u16,
pub clip_top: bool,
}
pub fn band_slice(start: usize, rows: u16, top: usize, height: u16) -> Option<BandSlice> {
if rows == 0 || height == 0 {
return None;
}
let end = start.checked_add(rows as usize)?;
if end <= top || start >= top + height as usize {
return None; }
if start >= top {
let offset = (start - top) as u16;
let visible = (height - offset).min(rows);
Some(BandSlice {
offset,
rows: visible,
clip_top: false,
})
} else {
let hidden = (top - start) as u16;
let visible = (rows - hidden).min(height);
Some(BandSlice {
offset: 0,
rows: visible,
clip_top: true,
})
}
}
fn resolve_in(url: &str, note_dir: &Path, attachments: &Path) -> Option<PathBuf> {
if url.contains("://") {
return None; }
let raw = PathBuf::from(shellexpand(url));
let candidates = [
raw.clone(),
note_dir.join(&raw),
note_dir.join("attachments").join(&raw),
attachments.join(raw.file_name().unwrap_or(raw.as_os_str())),
];
candidates.into_iter().find(|p| p.is_file())
}
fn shellexpand(url: &str) -> String {
match url.strip_prefix("~/") {
Some(rest) => dirs::home_dir()
.map(|h| h.join(rest).to_string_lossy().into_owned())
.unwrap_or_else(|| url.to_string()),
None => url.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_band_is_only_ever_clamped_by_the_viewport() {
assert_eq!(band_rows(8, 30), 8);
assert_eq!(band_rows(20, 6), 6);
assert_eq!(band_rows(20, 0), 1);
}
fn slice(offset: u16, rows: u16, clip_top: bool) -> Option<BandSlice> {
Some(BandSlice {
offset,
rows,
clip_top,
})
}
#[test]
fn a_wholly_visible_band_is_drawn_at_its_full_height() {
assert_eq!(band_slice(10, 8, 5, 20), slice(5, 8, false));
assert_eq!(band_slice(10, 8, 5, 13), slice(5, 8, false));
assert_eq!(band_slice(10, 8, 10, 8), slice(0, 8, false));
}
#[test]
fn a_band_running_off_the_bottom_draws_the_rows_that_fit() {
assert_eq!(band_slice(10, 8, 5, 12), slice(5, 7, false));
assert_eq!(band_slice(10, 8, 5, 6), slice(5, 1, false));
}
#[test]
fn a_band_scrolled_off_the_top_draws_its_bottom_slice() {
assert_eq!(band_slice(10, 8, 11, 20), slice(0, 7, true));
assert_eq!(band_slice(10, 8, 17, 20), slice(0, 1, true));
}
#[test]
fn a_band_wholly_off_screen_is_not_drawn() {
assert_eq!(band_slice(10, 8, 18, 20), None); assert_eq!(band_slice(30, 8, 5, 20), None); assert_eq!(band_slice(10, 0, 5, 20), None);
assert_eq!(band_slice(10, 8, 5, 0), None);
}
#[test]
fn the_fitted_pixel_size_matches_the_rows_reserved() {
assert_eq!(fit_px(1000, 500, 10, 20, 20), ((200, 100), 5));
assert_eq!(fit_px(100, 200, 10, 20, 20), ((100, 200), 10));
let ((w, h), rows) = fit_px(10, 10_000, 10, 20, 20);
assert_eq!(rows, MAX_ROWS);
assert!(h <= MAX_ROWS as u32 * 20, "{h} px in {MAX_ROWS} rows");
assert!(w >= 1);
}
#[test]
fn resolves_beside_the_note_and_in_attachments() {
let dir = std::env::temp_dir().join("tinynote-images-test");
let att = dir.join("attachments");
std::fs::create_dir_all(&att).unwrap();
std::fs::write(dir.join("a.png"), b"x").unwrap();
std::fs::write(att.join("b.png"), b"x").unwrap();
let images = Images::new(att.clone());
assert_eq!(images.resolve("a.png", &dir), Some(dir.join("a.png")));
assert_eq!(images.resolve("b.png", &dir), Some(att.join("b.png")));
assert_eq!(images.resolve("missing.png", &dir), None);
assert_eq!(images.resolve("https://x.y/z.png", &dir), None);
let elsewhere = dir.join("sub");
std::fs::create_dir_all(&elsewhere).unwrap();
assert_eq!(
images.resolve("attachments/b.png", &elsewhere),
Some(att.join("b.png"))
);
let _ = std::fs::remove_dir_all(&dir);
}
}