use crossbeam_channel::{Receiver, Sender, bounded};
use ratatui::layout::Rect;
use std::{collections::VecDeque, path::PathBuf, sync::Arc, thread::JoinHandle};
pub(super) const PREVIEW_ROWS: u16 = 20;
pub(super) const PREVIEW_PADDING: u16 = 1;
const PREVIEW_COLUMNS: u16 = 120;
const PREVIEW_MAX_PIXELS: u32 = 1280;
const CACHE_ENTRIES: usize = 16;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ImageRow {
pub path: String,
pub row: u16,
}
#[derive(Debug)]
pub(super) struct PreparedImage {
pub width: u32,
pub height: u32,
pub encoded: String,
}
#[derive(Debug, Clone)]
pub(super) struct ImagePlacement {
pub image: Arc<PreparedImage>,
pub area: Rect,
pub source_y: u32,
pub source_height: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ImagePolicy {
cwd: PathBuf,
absolute: bool,
max_bytes: u64,
session: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ImageLayout {
columns: u16,
cell_pixels: (u16, u16),
}
#[derive(Debug)]
struct ImageRequest {
path: String,
policy: ImagePolicy,
generation: u64,
layout: ImageLayout,
}
type ImageResult = (ImageRequest, Option<Arc<PreparedImage>>);
#[derive(Debug)]
struct ImageWorker {
requests: Sender<ImageRequest>,
results: Receiver<ImageResult>,
handle: Option<JoinHandle<()>>,
}
#[derive(Debug, Default)]
pub(crate) struct TranscriptImages {
worker: Option<ImageWorker>,
policy: Option<ImagePolicy>,
generation: u64,
cache: VecDeque<(String, Option<Arc<PreparedImage>>)>,
pending: bool,
visible_paths: Vec<String>,
pub(super) placements: Vec<ImagePlacement>,
pub(super) cell_pixels: (u16, u16),
layout: Option<ImageLayout>,
}
impl TranscriptImages {
pub(super) fn start(cell_pixels: (u16, u16)) -> Self {
let (requests, receive) = bounded::<ImageRequest>(1);
let (send, results) = bounded(1);
let handle = std::thread::Builder::new()
.name("transcript-images".into())
.spawn(move || {
while let Ok(request) = receive.recv() {
let prepared = prepare_image(&request).ok().map(Arc::new);
if send.send((request, prepared)).is_err() {
break;
}
}
});
match handle {
Ok(handle) => Self {
worker: Some(ImageWorker {
requests,
results,
handle: Some(handle),
}),
cell_pixels,
..Self::default()
},
Err(_) => Self::default(),
}
}
pub(super) fn enabled(&self) -> bool {
self.worker.is_some()
}
pub(super) fn is_pending(&self) -> bool {
self.pending
}
pub(super) fn clear_frame(&mut self) {
self.placements.clear();
self.visible_paths.clear();
}
pub(super) fn start_next_request(&mut self) {
if self.pending {
return;
}
let (Some(worker), Some(policy)) = (&self.worker, &self.policy) else {
return;
};
let Some(layout) = self.layout else {
return;
};
let Some(path) = self
.visible_paths
.iter()
.find(|path| !self.cache.iter().any(|(cached, _)| cached == *path))
else {
return;
};
let request = ImageRequest {
path: path.clone(),
policy: policy.clone(),
generation: self.generation,
layout,
};
self.pending = worker.requests.try_send(request).is_ok();
}
pub(super) fn sync_policy(
&mut self,
cwd: &std::path::Path,
absolute: bool,
max_bytes: u64,
session: Option<&str>,
) -> bool {
if self.policy.as_ref().is_some_and(|p| {
p.cwd == cwd
&& p.absolute == absolute
&& p.max_bytes == max_bytes
&& p.session.as_deref() == session
}) {
return false;
}
self.policy = Some(ImagePolicy {
cwd: cwd.to_path_buf(),
absolute,
max_bytes,
session: session.map(str::to_owned),
});
self.generation = self.generation.wrapping_add(1);
self.cache.clear();
self.clear_frame();
true
}
pub(super) fn poll(&mut self) -> bool {
let Some(worker) = &self.worker else {
return false;
};
match worker.results.try_recv() {
Ok((request, image)) => {
self.pending = false;
if request.generation == self.generation {
self.cache.push_back((request.path, image));
while self.cache.len() > CACHE_ENTRIES {
self.cache.pop_front();
}
}
true
}
Err(crossbeam_channel::TryRecvError::Disconnected) => std::mem::take(&mut self.pending),
Err(crossbeam_channel::TryRecvError::Empty) => false,
}
}
pub(super) fn place_row(&mut self, row: &ImageRow, area: Rect) {
if self.placements.len() >= 64 || area.is_empty() {
return;
}
let layout = ImageLayout {
columns: area
.width
.saturating_sub(2 * PREVIEW_PADDING)
.min(PREVIEW_COLUMNS),
cell_pixels: self.cell_pixels,
};
if self.layout.is_some_and(|previous| previous != layout) {
self.generation = self.generation.wrapping_add(1);
self.cache.clear();
self.clear_frame();
}
self.layout = Some(layout);
if layout.columns == 0 {
return;
}
if !self.visible_paths.contains(&row.path) {
if self.visible_paths.len() >= CACHE_ENTRIES {
return;
}
self.visible_paths.push(row.path.clone());
}
let Some((_, Some(image))) = self.cache.iter().find(|(path, _)| path == &row.path) else {
return;
};
if let Some(placement) = image_row_placement(image, row.row, area, self.cell_pixels) {
self.placements.push(placement);
}
}
}
fn prepare_image(request: &ImageRequest) -> anyhow::Result<PreparedImage> {
let image = crate::tools::read_local_preview_image(
&request.policy.cwd,
&request.path,
request.policy.absolute,
request.policy.max_bytes,
)?;
prepare_pixels(image, request.layout)
}
fn prepare_pixels(
image: image::DynamicImage,
layout: ImageLayout,
) -> anyhow::Result<PreparedImage> {
use base64::Engine;
let (cell_width, cell_height) = layout.cell_pixels;
let (cell_width, cell_height) = (u32::from(cell_width), u32::from(cell_height));
anyhow::ensure!(
(1..=640).contains(&cell_width) && (1..=320).contains(&cell_height),
"unsupported terminal cell dimensions"
);
let max_width = u32::from(layout.columns).min(PREVIEW_MAX_PIXELS / cell_width) * cell_width;
let max_height = u32::from(PREVIEW_ROWS).min(PREVIEW_MAX_PIXELS / cell_height) * cell_height;
anyhow::ensure!(max_width > 0, "empty image viewport");
let resized = image
.thumbnail(image.width().min(max_width), image.height().min(max_height))
.to_rgba8();
let mut pixels = image::RgbaImage::new(
resized.width().div_ceil(cell_width) * cell_width,
resized.height().div_ceil(cell_height) * cell_height,
);
image::imageops::replace(&mut pixels, &resized, 0, 0);
Ok(PreparedImage {
width: pixels.width(),
height: pixels.height(),
encoded: base64::engine::general_purpose::STANDARD.encode(pixels.as_raw()),
})
}
fn image_row_placement(
image: &Arc<PreparedImage>,
row: u16,
area: Rect,
cell_pixels: (u16, u16),
) -> Option<ImagePlacement> {
if area.is_empty()
|| image.width == 0
|| image.height == 0
|| cell_pixels.0 == 0
|| cell_pixels.1 == 0
{
return None;
}
let (cell_width, cell_height) = (u32::from(cell_pixels.0), u32::from(cell_pixels.1));
if !image.width.is_multiple_of(cell_width) || !image.height.is_multiple_of(cell_height) {
return None;
}
let columns = image.width / cell_width;
let rows = image.height / cell_height;
if columns
> u32::from(
area.width
.saturating_sub(2 * PREVIEW_PADDING)
.min(PREVIEW_COLUMNS),
)
|| rows > u32::from(PREVIEW_ROWS)
|| u32::from(row) >= rows
{
return None;
}
let source_y = u32::from(row) * cell_height;
Some(ImagePlacement {
image: Arc::clone(image),
area: Rect::new(area.x + PREVIEW_PADDING, area.y, columns as u16, 1),
source_y,
source_height: cell_height,
})
}
impl Drop for ImageWorker {
fn drop(&mut self) {
if self.handle.as_ref().is_some_and(JoinHandle::is_finished)
&& let Some(handle) = self.handle.take()
{
let _ = handle.join();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wide_preview_reserves_one_column_on_both_sides() {
let mut images = TranscriptImages {
cell_pixels: (10, 20),
..TranscriptImages::default()
};
let row = ImageRow {
path: "wide.png".into(),
row: 0,
};
let area = Rect::new(4, 5, 12, 1);
images.place_row(&row, area);
let image = prepare_pixels(
image::DynamicImage::new_rgba8(1000, 100),
images.layout.unwrap(),
)
.unwrap();
images
.cache
.push_back((row.path.clone(), Some(Arc::new(image))));
images.place_row(&row, area);
assert_eq!(images.placements.len(), 1);
let placement = &images.placements[0];
assert_eq!(placement.area, Rect::new(5, 5, 10, 1));
assert_eq!(placement.area.right(), area.right() - 1);
}
#[test]
fn worker_prepares_real_pixels_and_keeps_only_one_request_in_flight() {
let directory = tempfile::tempdir().unwrap();
image::RgbaImage::from_pixel(10, 10, image::Rgba([255, 0, 0, 255]))
.save(directory.path().join("image.png"))
.unwrap();
let mut images = TranscriptImages::start((10, 20));
images.sync_policy(directory.path(), false, 1024, Some("session"));
let row = ImageRow {
path: "image.png".into(),
row: 0,
};
let area = Rect::new(0, 0, 60, 1);
images.place_row(&row, area);
images.start_next_request();
assert!(images.is_pending());
for _ in 0..100 {
images.place_row(&row, area);
images.start_next_request();
}
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
while !images.poll() {
assert!(
std::time::Instant::now() < deadline,
"image worker did not finish"
);
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(images.cache.len(), 1);
assert!(!images.is_pending());
images.place_row(&row, area);
use base64::Engine;
let pixels = base64::engine::general_purpose::STANDARD
.decode(&images.placements[0].image.encoded)
.unwrap();
assert_eq!(&pixels[..400], [255, 0, 0, 255].repeat(100));
assert_eq!(&pixels[400..], [0; 400]);
}
#[test]
fn large_preview_uses_twenty_rows_and_keeps_aspect_ratio() {
let image = prepare_pixels(
image::DynamicImage::new_rgba8(1600, 2000),
ImageLayout {
columns: 120,
cell_pixels: (10, 20),
},
)
.unwrap();
assert_eq!((image.width, image.height), (320, 400));
let image = Arc::new(image);
let last_row = image_row_placement(&image, 19, Rect::new(0, 19, 120, 1), (10, 20)).unwrap();
assert_eq!(last_row.area, Rect::new(1, 19, 32, 1));
assert_eq!(last_row.source_y + last_row.source_height, image.height);
assert!(image_row_placement(&image, 20, Rect::new(0, 20, 120, 1), (10, 20)).is_none());
}
#[test]
fn non_cell_aligned_square_keeps_square_pixels_across_cropped_rows() {
use base64::Engine;
let image = Arc::new(
prepare_pixels(
image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
32,
32,
image::Rgba([255, 0, 0, 255]),
)),
ImageLayout {
columns: 60,
cell_pixels: (10, 20),
},
)
.unwrap(),
);
assert_eq!((image.width, image.height), (40, 40));
let pixels = base64::engine::general_purpose::STANDARD
.decode(&image.encoded)
.unwrap();
let mut visible_pixels = 0;
for row in 0..2 {
let crop =
image_row_placement(&image, row, Rect::new(2, 5 + row, 60, 1), (10, 20)).unwrap();
assert_eq!(crop.area.width, 4);
assert_eq!(
(crop.source_y, crop.source_height),
(u32::from(row) * 20, 20)
);
for y in crop.source_y..crop.source_y + crop.source_height {
for x in 0..image.width {
let offset = ((y * image.width + x) * 4) as usize;
let expected = if x < 32 && y < 32 {
visible_pixels += 1;
[255, 0, 0, 255]
} else {
[0, 0, 0, 0]
};
assert_eq!(&pixels[offset..offset + 4], expected);
}
}
}
assert_eq!(visible_pixels, 32 * 32);
assert!(image_row_placement(&image, 2, Rect::new(2, 7, 60, 1), (10, 20)).is_none());
}
#[test]
fn geometry_change_discards_pixels_and_stale_worker_results() {
let mut images = TranscriptImages::default();
let (send, results) = bounded(1);
let (requests, _) = bounded(1);
images.worker = Some(ImageWorker {
requests,
results,
handle: None,
});
images.cell_pixels = (10, 20);
images.sync_policy(std::path::Path::new("/repo"), false, 1024, None);
let row = ImageRow {
path: "image.png".into(),
row: 0,
};
images.place_row(&row, Rect::new(0, 0, 60, 1));
let old_request = ImageRequest {
path: row.path.clone(),
policy: images.policy.clone().unwrap(),
generation: images.generation,
layout: images.layout.unwrap(),
};
images.cache.push_back((row.path.clone(), None));
images.place_row(&row, Rect::new(0, 0, 1, 1));
assert!(images.cache.is_empty());
send.send((old_request, None)).unwrap();
assert!(images.poll());
assert!(images.cache.is_empty());
images.cache.push_back((row.path.clone(), None));
images.cell_pixels = (12, 24);
images.place_row(&row, Rect::new(0, 0, 1, 1));
assert!(images.cache.is_empty());
}
#[test]
fn scrolling_crops_source_rows_without_rescaling_visible_fragment() {
let image = Arc::new(PreparedImage {
width: 160,
height: 160,
encoded: String::new(),
});
let crop = image_row_placement(&image, 3, Rect::new(2, 5, 60, 1), (10, 20)).unwrap();
assert_eq!(crop.area, Rect::new(3, 5, 16, 1));
assert_eq!((crop.source_y, crop.source_height), (60, 20));
assert!(image_row_placement(&image, 8, Rect::new(2, 5, 60, 1), (10, 20)).is_none());
assert!(image_row_placement(&image, 0, Rect::default(), (10, 20)).is_none());
}
#[test]
fn small_viewport_preserves_crop_bounds() {
let image = Arc::new(
prepare_pixels(
image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
640,
320,
image::Rgba([255, 0, 0, 255]),
)),
ImageLayout {
columns: 1,
cell_pixels: (10, 20),
},
)
.unwrap(),
);
let crop = image_row_placement(&image, 0, Rect::new(0, 0, 3, 1), (10, 20)).unwrap();
assert_eq!(crop.area.width, 1);
assert_eq!(crop.area.x, 1);
for width in 0..=2 {
assert!(image_row_placement(&image, 0, Rect::new(0, 0, width, 1), (10, 20)).is_none());
}
assert_eq!((crop.source_y, crop.source_height), (0, 20));
assert_eq!((image.width, image.height), (10, 20));
assert!(image_row_placement(&image, 1, Rect::new(0, 0, 3, 1), (10, 20)).is_none());
}
#[test]
fn changed_policy_discards_prior_session_pixels() {
let mut images = TranscriptImages::default();
images.sync_policy(std::path::Path::new("/repo"), true, 1024, Some("a"));
images.cache.push_back(("a.png".into(), None));
let generation = images.generation;
images.sync_policy(std::path::Path::new("/repo"), false, 1024, Some("a"));
assert!(images.cache.is_empty());
assert_ne!(generation, images.generation);
}
}
#[cfg(test)]
mod transcript_tests;