use std::collections::{HashMap, VecDeque};
use std::sync::{mpsc, Arc};
use image::DynamicImage;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::StatefulWidget;
use ratatui_image::picker::{Picker, ProtocolType};
use ratatui_image::thread::{ResizeRequest, ResizeResponse, ThreadProtocol};
use ratatui_image::{Resize, StatefulImage};
pub fn render_halfblocks_scratch(picker: &Picker, image: DynamicImage, rect: Rect) -> Buffer {
let mut picker = picker.clone();
picker.set_protocol_type(ProtocolType::Halfblocks);
let mut protocol = picker.new_resize_protocol(image);
let mut buf = Buffer::empty(rect);
StatefulImage::default()
.resize(Resize::Fit(None))
.render(rect, &mut buf, &mut protocol);
buf
}
pub fn aspect_rows_of(
image: &DynamicImage,
max_width_cells: u16,
max_height_cells: u16,
font_size: (u16, u16),
) -> usize {
let (fw, fh) = (u32::from(font_size.0.max(1)), u32::from(font_size.1.max(1)));
let box_w_px = u32::from(max_width_cells).saturating_mul(fw);
let box_h_px = u32::from(max_height_cells).saturating_mul(fh);
let iw = image.width().max(1);
let ih = image.height().max(1);
if box_w_px == 0 || box_h_px == 0 {
return 0;
}
let h_if_width_binds = (u64::from(ih) * u64::from(box_w_px)) / u64::from(iw);
let fitted_h_px = h_if_width_binds.min(u64::from(box_h_px)).min(u64::from(ih));
let rows = fitted_h_px.div_ceil(u64::from(fh));
(rows.clamp(1, u64::from(max_height_cells)) as usize).max(1)
}
pub enum DecodeStatus {
Pending,
Ready(Arc<DynamicImage>),
Failed(#[allow(dead_code)] String),
}
struct PendingResize {
url: String,
width: u16,
height: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NativePaint {
pub rect: Rect,
pub generation: u64,
pub frame: u64,
}
pub struct ProtocolPair {
pub native: Option<ThreadProtocol>,
pub native_ready: bool,
pub native_generation: u64,
pub last_native_paint: Option<NativePaint>,
pub halfblocks_scratch: Option<Buffer>,
}
#[derive(Default)]
pub struct ImageCache {
decoded: HashMap<String, DecodeStatus>,
protocols: HashMap<(String, u16, u16), ProtocolPair>,
prebuilt_scratches: HashMap<(String, u16, u16), Buffer>,
pending: VecDeque<PendingResize>,
resize_tx: Option<mpsc::Sender<ResizeRequest>>,
frame_seq: u64,
}
impl ImageCache {
pub fn new() -> Self {
Self::default()
}
pub fn attach_resize_sender(&mut self, tx: mpsc::Sender<ResizeRequest>) {
self.resize_tx = Some(tx);
self.protocols.clear();
self.pending.clear();
self.prebuilt_scratches.clear();
}
pub fn has_resize_sender(&self) -> bool {
self.resize_tx.is_some()
}
pub fn begin_frame(&mut self) {
self.frame_seq = self.frame_seq.wrapping_add(1);
}
pub fn frame_seq(&self) -> u64 {
self.frame_seq
}
pub fn invalidate_native_paints(&mut self) {
for pair in self.protocols.values_mut() {
pair.last_native_paint = None;
}
}
pub fn request(&mut self, url: &str) -> bool {
if self.decoded.contains_key(url) {
return false;
}
self.decoded.insert(url.to_owned(), DecodeStatus::Pending);
true
}
#[allow(dead_code)]
pub fn set_decoded(&mut self, url: &str, image: DynamicImage) {
self.set_decoded_with_prebuilt(url, image, None);
}
pub fn set_decoded_with_prebuilt(
&mut self,
url: &str,
image: DynamicImage,
prebuilt_scratch: Option<(Rect, Buffer)>,
) {
self.decoded
.insert(url.to_owned(), DecodeStatus::Ready(Arc::new(image)));
self.protocols.retain(|(u, _, _), _| u != url);
self.prebuilt_scratches.retain(|(u, _, _), _| u != url);
if let Some((rect, buf)) = prebuilt_scratch {
self.prebuilt_scratches
.insert((url.to_owned(), rect.width, rect.height), buf);
}
}
pub fn set_failed(&mut self, url: &str, message: String) {
self.decoded
.insert(url.to_owned(), DecodeStatus::Failed(message));
self.protocols.retain(|(u, _, _), _| u != url);
self.prebuilt_scratches.retain(|(u, _, _), _| u != url);
}
#[allow(dead_code)]
pub fn status(&self, url: &str) -> Option<&DecodeStatus> {
self.decoded.get(url)
}
pub fn get_protocol_pair(
&mut self,
url: &str,
width: u16,
height: u16,
native_picker: Option<&Picker>,
halfblocks_picker: Option<&Picker>,
) -> Option<&mut ProtocolPair> {
let native_picker = native_picker?;
let resize_tx = self.resize_tx.as_ref()?.clone();
if !matches!(self.decoded.get(url), Some(DecodeStatus::Ready(_))) {
return None;
}
let key = (url.to_owned(), width, height);
if !self.protocols.contains_key(&key) {
let image_arc = match self.decoded.get(url) {
Some(DecodeStatus::Ready(img)) => Arc::clone(img),
_ => return None,
};
let full_rect = Rect::new(0, 0, width, height);
let is_halfblocks_native = native_picker.protocol_type() == ProtocolType::Halfblocks;
let halfblocks_scratch = if let Some(buf) = self.prebuilt_scratches.remove(&key) {
Some(buf)
} else if is_halfblocks_native {
Some(render_halfblocks_scratch(
native_picker,
(*image_arc).clone(),
full_rect,
))
} else {
halfblocks_picker
.map(|p| render_halfblocks_scratch(p, (*image_arc).clone(), full_rect))
};
let native = if is_halfblocks_native {
None
} else {
let native_inner = native_picker.new_resize_protocol((*image_arc).clone());
Some(ThreadProtocol::new(resize_tx, Some(native_inner)))
};
self.protocols.insert(
key.clone(),
ProtocolPair {
native,
native_ready: false,
native_generation: 0,
last_native_paint: None,
halfblocks_scratch,
},
);
}
self.protocols.get_mut(&key)
}
pub fn protocol_pair_mut(
&mut self,
url: &str,
width: u16,
height: u16,
) -> Option<&mut ProtocolPair> {
self.protocols.get_mut(&(url.to_owned(), width, height))
}
pub fn track_pending_resize(&mut self, url: &str, width: u16, height: u16) {
self.pending.push_back(PendingResize {
url: url.to_owned(),
width,
height,
});
}
pub fn drop_pending_front(&mut self) {
self.pending.pop_front();
}
pub fn apply_resize_response(&mut self, resp: ResizeResponse) {
let Some(pending) = self.pending.pop_front() else {
return;
};
let key = (pending.url, pending.width, pending.height);
if let Some(pair) = self.protocols.get_mut(&key) {
if let Some(native) = pair.native.as_mut() {
if native.update_resized_protocol(resp) {
pair.native_ready = true;
pair.native_generation = pair.native_generation.wrapping_add(1);
pair.last_native_paint = None;
}
}
}
}
#[allow(dead_code)]
pub fn invalidate_protocols(&mut self) {
self.protocols.clear();
self.prebuilt_scratches.clear();
}
#[allow(dead_code)]
pub fn aspect_rows(
&self,
url: &str,
max_width_cells: u16,
max_height_cells: u16,
font_size: (u16, u16),
) -> Option<usize> {
let Some(DecodeStatus::Ready(img)) = self.decoded.get(url) else {
return None;
};
Some(aspect_rows_of(
img,
max_width_cells,
max_height_cells,
font_size,
))
}
pub fn reserved_rows(
&self,
url: &str,
max_width_cells: u16,
max_height_cells: u16,
font_size: (u16, u16),
) -> Option<usize> {
match self.decoded.get(url) {
Some(DecodeStatus::Ready(img)) => Some(aspect_rows_of(
img,
max_width_cells,
max_height_cells,
font_size,
)),
Some(DecodeStatus::Failed(_)) => Some(1),
Some(DecodeStatus::Pending) | None => None,
}
}
pub fn clear_failures_for_remote_reopening(&mut self) {
self.decoded
.retain(|_, status| !matches!(status, DecodeStatus::Failed(_)));
}
pub fn forget(&mut self, url: &str) {
self.decoded.remove(url);
self.protocols.retain(|(u, _, _), _| u != url);
self.prebuilt_scratches.retain(|(u, _, _), _| u != url);
}
pub fn evict_remote(&mut self) {
self.decoded
.retain(|url, _| !crate::image::loader::is_remote(url));
self.protocols
.retain(|(url, _, _), _| !crate::image::loader::is_remote(url));
self.prebuilt_scratches
.retain(|(url, _, _), _| !crate::image::loader::is_remote(url));
}
pub fn gc(&mut self, live: &std::collections::HashSet<String>) {
self.decoded.retain(|url, _| live.contains(url));
self.protocols.retain(|(url, _, _), _| live.contains(url));
self.prebuilt_scratches
.retain(|(url, _, _), _| live.contains(url));
}
#[cfg(test)]
pub fn protocol_count(&self) -> usize {
self.protocols.len()
}
#[cfg(test)]
pub fn pending_count(&self) -> usize {
self.pending.len()
}
#[cfg(test)]
pub fn prebuilt_scratch_count(&self) -> usize {
self.prebuilt_scratches.len()
}
}
#[cfg(test)]
#[allow(deprecated)]
mod tests {
use super::*;
fn halfblocks_picker() -> Picker {
let mut picker = Picker::from_fontsize((1, 2).into());
picker.set_protocol_type(ProtocolType::Halfblocks);
picker
}
fn native_picker() -> Picker {
let mut picker = Picker::from_fontsize((1, 2).into());
picker.set_protocol_type(ProtocolType::Iterm2);
picker
}
#[test]
fn small_image_reserves_natural_height_not_width_filled() {
let img = DynamicImage::new_rgba8(190, 65);
let rows = aspect_rows_of(&img, 80, 40, (8, 16));
assert_eq!(rows, 65_u32.div_ceil(16) as usize);
}
#[test]
fn wide_image_still_binds_on_width() {
let img = DynamicImage::new_rgba8(1920, 1080);
let rows = aspect_rows_of(&img, 80, 40, (8, 16));
assert_eq!(rows, 23);
}
#[test]
fn forget_drops_one_url_and_allows_a_re_request() {
let mut cache = ImageCache::new();
cache.set_decoded("a.png", DynamicImage::new_rgba8(1, 1));
cache.set_decoded("b.png", DynamicImage::new_rgba8(1, 1));
cache.forget("a.png");
assert!(cache.status("a.png").is_none());
assert!(cache.status("b.png").is_some(), "other entries untouched");
assert!(cache.request("a.png"), "forgotten URL can be re-requested");
}
#[test]
fn evict_remote_drops_remote_entries_and_keeps_local() {
let mut cache = ImageCache::new();
cache.set_decoded("https://example.com/a.png", DynamicImage::new_rgba8(1, 1));
cache.set_failed("http://example.com/b.png", "blocked".into());
cache.set_decoded("local/c.png", DynamicImage::new_rgba8(1, 1));
cache.evict_remote();
assert!(cache.status("https://example.com/a.png").is_none());
assert!(cache.status("http://example.com/b.png").is_none());
assert!(matches!(
cache.status("local/c.png"),
Some(DecodeStatus::Ready(_))
));
assert!(cache.request("https://example.com/a.png"));
}
#[test]
fn request_returns_true_first_time_only() {
let mut cache = ImageCache::new();
assert!(cache.request("a.png"));
assert!(!cache.request("a.png"));
assert!(matches!(cache.status("a.png"), Some(DecodeStatus::Pending)));
}
#[test]
fn set_decoded_transitions_from_pending_to_ready() {
let mut cache = ImageCache::new();
cache.request("a.png");
let img = DynamicImage::new_rgba8(1, 1);
cache.set_decoded("a.png", img);
assert!(matches!(
cache.status("a.png"),
Some(DecodeStatus::Ready(_))
));
assert!(!cache.request("a.png"));
}
#[test]
fn set_failed_transitions_and_blocks_retry() {
let mut cache = ImageCache::new();
cache.request("a.png");
cache.set_failed("a.png", "io error".to_owned());
assert!(matches!(
cache.status("a.png"),
Some(DecodeStatus::Failed(_))
));
assert!(!cache.request("a.png"));
}
fn cache_with_sender() -> ImageCache {
let (tx, rx) = mpsc::channel::<ResizeRequest>();
let mut cache = ImageCache::new();
cache.attach_resize_sender(tx);
std::mem::forget(rx);
cache
}
#[test]
fn set_decoded_clears_stale_protocol_entries() {
let mut cache = cache_with_sender();
cache.request("a.png");
cache.set_decoded("a.png", DynamicImage::new_rgba8(1, 1));
let picker = halfblocks_picker();
assert!(cache
.get_protocol_pair("a.png", 10, 10, Some(&picker), Some(&picker))
.is_some());
assert_eq!(cache.protocol_count(), 1);
cache.set_decoded("a.png", DynamicImage::new_rgba8(2, 2));
assert_eq!(cache.protocol_count(), 0);
}
#[test]
fn set_decoded_with_prebuilt_stashes_scratch_for_matching_dims() {
let mut cache = cache_with_sender();
cache.request("a.png");
let rect = Rect::new(0, 0, 8, 4);
let prebuilt = Buffer::empty(rect);
cache.set_decoded_with_prebuilt(
"a.png",
DynamicImage::new_rgba8(8, 4),
Some((rect, prebuilt)),
);
assert_eq!(cache.prebuilt_scratch_count(), 1);
let picker = halfblocks_picker();
let pair = cache
.get_protocol_pair("a.png", 8, 4, Some(&picker), Some(&picker))
.expect("pair for ready image");
assert!(pair.halfblocks_scratch.is_some());
assert_eq!(cache.prebuilt_scratch_count(), 0);
}
#[test]
fn set_decoded_with_prebuilt_falls_back_to_sync_on_mismatched_dims() {
let mut cache = cache_with_sender();
cache.request("a.png");
let prebuilt_rect = Rect::new(0, 0, 8, 4);
let prebuilt = Buffer::empty(prebuilt_rect);
cache.set_decoded_with_prebuilt(
"a.png",
DynamicImage::new_rgba8(8, 4),
Some((prebuilt_rect, prebuilt)),
);
let picker = halfblocks_picker();
let pair = cache
.get_protocol_pair("a.png", 16, 4, Some(&picker), Some(&picker))
.expect("pair for ready image");
assert!(pair.halfblocks_scratch.is_some());
assert_eq!(cache.prebuilt_scratch_count(), 1);
}
#[test]
fn invalidate_protocols_also_clears_prebuilt_scratches() {
let mut cache = cache_with_sender();
cache.request("a.png");
let rect = Rect::new(0, 0, 8, 4);
cache.set_decoded_with_prebuilt(
"a.png",
DynamicImage::new_rgba8(8, 4),
Some((rect, Buffer::empty(rect))),
);
assert_eq!(cache.prebuilt_scratch_count(), 1);
cache.invalidate_protocols();
assert_eq!(cache.prebuilt_scratch_count(), 0);
}
#[test]
fn invalidate_protocols_clears_all_protocol_entries_only() {
let mut cache = cache_with_sender();
cache.request("a.png");
cache.set_decoded("a.png", DynamicImage::new_rgba8(1, 1));
let picker = halfblocks_picker();
cache
.get_protocol_pair("a.png", 1, 1, Some(&picker), Some(&picker))
.expect("pair for ready image");
cache.invalidate_protocols();
assert_eq!(cache.protocol_count(), 0);
assert!(matches!(
cache.status("a.png"),
Some(DecodeStatus::Ready(_))
));
}
#[test]
fn protocol_pair_from_halfblocks_native_skips_native_thread_protocol() {
let mut cache = cache_with_sender();
cache.request("a.png");
cache.set_decoded("a.png", DynamicImage::new_rgba8(4, 4));
let picker = halfblocks_picker();
let pair = cache
.get_protocol_pair("a.png", 8, 4, Some(&picker), Some(&picker))
.expect("pair for ready image");
assert!(pair.native.is_none());
assert!(pair.halfblocks_scratch.is_some());
}
#[test]
fn protocol_pair_with_non_halfblocks_native_builds_both() {
let mut cache = cache_with_sender();
cache.request("b.png");
cache.set_decoded("b.png", DynamicImage::new_rgba8(4, 4));
let pair = cache
.get_protocol_pair(
"b.png",
8,
4,
Some(&native_picker()),
Some(&halfblocks_picker()),
)
.expect("pair for ready image");
assert!(pair.native.is_some(), "native encode shipped off-thread");
assert!(pair.halfblocks_scratch.is_some(), "fallback scratch built");
}
#[test]
fn scratch_holds_halfblock_cells_even_from_a_native_picker() {
let rect = Rect::new(0, 0, 8, 4);
let img = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
16,
16,
image::Rgba([40, 80, 120, 255]),
));
let buf = render_halfblocks_scratch(&native_picker(), img, rect);
let expected = ratatui::style::Color::Rgb(40, 80, 120);
for y in 0..rect.height {
for x in 0..rect.width {
let cell = buf.cell((x, y)).expect("cell in rect");
assert!(
!cell.symbol().contains('\u{1b}'),
"cell ({x},{y}) carries an escape sequence, not a halfblock"
);
assert_eq!(cell.fg, expected, "cell ({x},{y}) fg");
assert_eq!(cell.bg, expected, "cell ({x},{y}) bg");
}
}
}
#[test]
fn get_protocol_pair_returns_none_without_native_picker() {
let mut cache = cache_with_sender();
cache.request("a.png");
cache.set_decoded("a.png", DynamicImage::new_rgba8(1, 1));
assert!(cache.get_protocol_pair("a.png", 8, 4, None, None).is_none());
}
#[test]
fn get_protocol_pair_returns_none_without_resize_sender() {
let mut cache = ImageCache::new();
cache.request("a.png");
cache.set_decoded("a.png", DynamicImage::new_rgba8(1, 1));
let picker = halfblocks_picker();
assert!(cache
.get_protocol_pair("a.png", 8, 4, Some(&picker), Some(&picker))
.is_none());
}
#[test]
fn get_protocol_pair_returns_none_for_pending() {
let mut cache = cache_with_sender();
cache.request("a.png");
let picker = halfblocks_picker();
assert!(cache
.get_protocol_pair("a.png", 8, 4, Some(&picker), Some(&picker))
.is_none());
}
#[test]
fn apply_resize_response_pops_pending_fifo_even_when_target_gone() {
let mut cache = cache_with_sender();
cache.request("a.png");
cache.set_decoded("a.png", DynamicImage::new_rgba8(4, 4));
let picker = halfblocks_picker();
cache
.get_protocol_pair("a.png", 8, 4, Some(&picker), Some(&picker))
.expect("pair built");
cache.track_pending_resize("a.png", 8, 4);
assert_eq!(cache.pending.len(), 1);
cache.invalidate_protocols();
assert_eq!(cache.pending_count(), 1);
}
#[test]
fn aspect_rows_returns_none_before_decode() {
let mut cache = ImageCache::new();
cache.request("a.png");
assert!(cache.aspect_rows("a.png", 80, 24, (10, 20)).is_none());
}
#[test]
fn aspect_rows_wide_image_returns_fewer_rows_than_max() {
let mut cache = ImageCache::new();
cache.request("wide.png");
cache.set_decoded("wide.png", DynamicImage::new_rgba8(1600, 400));
assert_eq!(cache.aspect_rows("wide.png", 80, 24, (10, 20)), Some(10));
}
#[test]
fn aspect_rows_tall_image_clamps_to_max_height() {
let mut cache = ImageCache::new();
cache.request("tall.png");
cache.set_decoded("tall.png", DynamicImage::new_rgba8(400, 1600));
assert_eq!(cache.aspect_rows("tall.png", 80, 24, (10, 20)), Some(24));
}
#[test]
fn aspect_rows_square_image_kept_at_natural_height() {
let mut cache = ImageCache::new();
cache.request("sq.png");
cache.set_decoded("sq.png", DynamicImage::new_rgba8(400, 400));
assert_eq!(cache.aspect_rows("sq.png", 80, 24, (10, 20)), Some(20));
}
#[test]
fn aspect_rows_small_image_reserves_natural_height() {
let mut cache = ImageCache::new();
cache.request("thin.png");
cache.set_decoded("thin.png", DynamicImage::new_rgba8(10, 2));
assert_eq!(cache.aspect_rows("thin.png", 80, 24, (10, 20)), Some(1));
}
#[test]
fn reserved_rows_collapses_failed_to_one() {
let mut cache = ImageCache::new();
cache.request("broken.png");
cache.set_failed("broken.png", "RemoteBlocked".to_owned());
assert_eq!(cache.reserved_rows("broken.png", 80, 24, (10, 20)), Some(1));
}
#[test]
fn reserved_rows_pending_returns_none() {
let mut cache = ImageCache::new();
cache.request("in_flight.png");
assert!(cache
.reserved_rows("in_flight.png", 80, 24, (10, 20))
.is_none());
}
#[test]
fn reserved_rows_ready_returns_aspect_rows() {
let mut cache = ImageCache::new();
cache.request("wide.png");
cache.set_decoded("wide.png", DynamicImage::new_rgba8(1600, 400));
assert_eq!(cache.reserved_rows("wide.png", 80, 24, (10, 20)), Some(10));
}
}