use std::sync::Arc;
use pdfboss_render::Pixmap;
use ratatui::style::Color;
pub const SPINNER: [char; 4] = ['|', '/', '-', '\\'];
pub const RESIZE_DEBOUNCE_TICKS: u8 = 2;
#[derive(Debug)]
pub struct PreviewFrame {
pub file_bytes: Arc<Vec<u8>>,
pub pixmap: Pixmap,
}
pub struct PreviewState {
pub active: bool,
pub page: Option<usize>,
pub pixmap: Option<Pixmap>,
pub rendering: bool,
pub spinner_frame: usize,
pub generation: u64,
pub file_bytes: Option<Arc<Vec<u8>>>,
pub debounce: Option<u8>,
pub error: Option<String>,
}
impl PreviewState {
pub fn new() -> PreviewState {
PreviewState {
active: false,
page: None,
pixmap: None,
rendering: false,
spinner_frame: 0,
generation: 0,
file_bytes: None,
debounce: None,
error: None,
}
}
pub fn start_render(&mut self, page: usize) -> u64 {
self.generation += 1;
self.page = Some(page);
self.rendering = true;
self.error = None;
self.debounce = None;
self.generation
}
pub fn apply_ready(&mut self, generation: u64, result: Result<PreviewFrame, String>) -> bool {
if let Ok(frame) = &result {
self.file_bytes = Some(Arc::clone(&frame.file_bytes));
}
if generation != self.generation {
return false;
}
self.rendering = false;
match result {
Ok(frame) => {
self.pixmap = Some(frame.pixmap);
self.error = None;
}
Err(message) => self.error = Some(message),
}
true
}
pub fn tick(&mut self) -> bool {
if self.rendering {
self.spinner_frame = (self.spinner_frame + 1) % SPINNER.len();
}
match self.debounce {
Some(0) | None => {
self.debounce = None;
false
}
Some(1) => {
self.debounce = None;
self.active
}
Some(remaining) => {
self.debounce = Some(remaining - 1);
false
}
}
}
}
impl Default for PreviewState {
fn default() -> PreviewState {
PreviewState::new()
}
}
pub fn fit_scale(page_w: f32, page_h: f32, max_w: u32, max_h: u32) -> f32 {
if !(page_w.is_finite() && page_h.is_finite()) || page_w <= 0.0 || page_h <= 0.0 {
return 1.0;
}
let horizontal = max_w as f32 / page_w;
let vertical = max_h as f32 / page_h;
horizontal.min(vertical).max(0.001)
}
fn blend_over_white(rgba: [u8; 4]) -> Color {
let alpha = rgba[3] as u32;
let channel = |value: u8| -> u8 { ((value as u32 * alpha + 255 * (255 - alpha)) / 255) as u8 };
Color::Rgb(channel(rgba[0]), channel(rgba[1]), channel(rgba[2]))
}
fn pixel(pix: &Pixmap, x: u32, y: u32) -> [u8; 4] {
if x >= pix.width || y >= pix.height {
return [255, 255, 255, 255];
}
let index = ((y * pix.width + x) * 4) as usize;
[
pix.data[index],
pix.data[index + 1],
pix.data[index + 2],
pix.data[index + 3],
]
}
pub fn cell_colors(pix: &Pixmap, x: u32, row: u32) -> (Color, Color) {
(
blend_over_white(pixel(pix, x, row * 2)),
blend_over_white(pixel(pix, x, row * 2 + 1)),
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
fn two_by_two() -> Pixmap {
Pixmap {
width: 2,
height: 2,
data: vec![
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 0, 0, 0, 0,
],
}
}
#[test]
fn fit_scale_fits_both_axes() {
assert_eq!(fit_scale(100.0, 100.0, 200, 50), 0.5);
assert_eq!(fit_scale(100.0, 100.0, 50, 200), 0.5);
assert_eq!(fit_scale(612.0, 792.0, 612, 792), 1.0);
assert_eq!(fit_scale(0.0, 100.0, 50, 50), 1.0, "degenerate page");
assert!(
fit_scale(1_000_000.0, 1.0, 10, 10) >= 0.001,
"clamped floor"
);
}
#[test]
fn cell_colors_pack_two_rows_per_cell() {
let pix = two_by_two();
assert_eq!(
cell_colors(&pix, 0, 0),
(Color::Rgb(255, 0, 0), Color::Rgb(0, 0, 255))
);
assert_eq!(
cell_colors(&pix, 1, 0),
(Color::Rgb(0, 255, 0), Color::Rgb(255, 255, 255))
);
assert_eq!(
cell_colors(&pix, 5, 9),
(Color::Rgb(255, 255, 255), Color::Rgb(255, 255, 255))
);
}
#[test]
fn start_render_bumps_generation_and_spins() {
let mut preview = PreviewState::new();
let first = preview.start_render(0);
let second = preview.start_render(0);
assert!(second > first);
assert!(preview.rendering);
let before = preview.spinner_frame;
assert!(!preview.tick());
assert_ne!(
preview.spinner_frame, before,
"spinner advances while rendering"
);
}
#[test]
fn apply_ready_ignores_stale_generations() {
let mut preview = PreviewState::new();
let stale = preview.start_render(0);
let current = preview.start_render(0);
let frame = PreviewFrame {
file_bytes: Arc::new(vec![1, 2, 3]),
pixmap: two_by_two(),
};
assert!(!preview.apply_ready(stale, Ok(frame)));
assert!(preview.rendering, "stale result leaves the spinner on");
let frame = PreviewFrame {
file_bytes: Arc::new(vec![1, 2, 3]),
pixmap: two_by_two(),
};
assert!(preview.apply_ready(current, Ok(frame)));
assert!(!preview.rendering);
assert!(preview.pixmap.is_some());
assert!(preview.file_bytes.is_some(), "bytes cached for re-renders");
assert!(preview.apply_ready(current, Err("boom".to_string())));
assert_eq!(preview.error.as_deref(), Some("boom"));
}
#[test]
fn stale_frame_still_caches_file_bytes() {
let mut preview = PreviewState::new();
let stale = preview.start_render(0);
let _current = preview.start_render(0);
let frame = PreviewFrame {
file_bytes: Arc::new(vec![9, 9, 9]),
pixmap: two_by_two(),
};
assert!(
!preview.apply_ready(stale, Ok(frame)),
"stale generation is still rejected"
);
assert!(
preview.pixmap.is_none(),
"stale pixmap must not be installed"
);
assert!(
preview.file_bytes.is_some(),
"whole-file bytes are generation-independent and must be cached \
even from a superseded render, so the next render skips re-fetching"
);
}
#[test]
fn debounce_counts_down_to_render_request() {
let mut preview = PreviewState::new();
preview.active = true;
preview.debounce = Some(RESIZE_DEBOUNCE_TICKS);
assert!(!preview.tick());
assert!(preview.tick(), "second tick fires the deferred render");
assert_eq!(preview.debounce, None);
assert!(!preview.tick(), "no further fires");
}
}