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();
}
}
}