1use std::sync::Arc;
7
8use pdfboss_render::Pixmap;
9use ratatui::style::Color;
10
11pub const SPINNER: [char; 4] = ['|', '/', '-', '\\'];
13pub const RESIZE_DEBOUNCE_TICKS: u8 = 2;
15
16#[derive(Debug)]
19pub struct PreviewFrame {
20 pub file_bytes: Arc<Vec<u8>>,
21 pub pixmap: Pixmap,
22 pub notice: Option<String>,
26}
27
28pub struct PreviewState {
30 pub active: bool,
32 pub page: Option<usize>,
33 pub pixmap: Option<Pixmap>,
34 pub rendering: bool,
35 pub spinner_frame: usize,
36 pub generation: u64,
37 pub file_bytes: Option<Arc<Vec<u8>>>,
38 pub debounce: Option<u8>,
40 pub error: Option<String>,
41 pub notice: Option<String>,
44}
45
46impl PreviewState {
47 pub fn new() -> PreviewState {
48 PreviewState {
49 active: false,
50 page: None,
51 pixmap: None,
52 rendering: false,
53 spinner_frame: 0,
54 generation: 0,
55 file_bytes: None,
56 debounce: None,
57 error: None,
58 notice: None,
59 }
60 }
61
62 pub fn start_render(&mut self, page: usize) -> u64 {
64 self.generation += 1;
65 self.page = Some(page);
66 self.rendering = true;
67 self.error = None;
68 self.notice = None;
69 self.debounce = None;
70 self.generation
71 }
72
73 pub fn apply_ready(&mut self, generation: u64, result: Result<PreviewFrame, String>) -> bool {
83 if let Ok(frame) = &result {
84 self.file_bytes = Some(Arc::clone(&frame.file_bytes));
85 }
86 if generation != self.generation {
87 return false;
88 }
89 self.rendering = false;
90 match result {
91 Ok(frame) => {
92 self.pixmap = Some(frame.pixmap);
93 self.error = None;
94 self.notice = frame.notice;
95 }
96 Err(message) => self.error = Some(message),
97 }
98 true
99 }
100
101 pub fn tick(&mut self) -> bool {
104 if self.rendering {
105 self.spinner_frame = (self.spinner_frame + 1) % SPINNER.len();
106 }
107 match self.debounce {
108 Some(0) | None => {
109 self.debounce = None;
110 false
111 }
112 Some(1) => {
113 self.debounce = None;
114 self.active
115 }
116 Some(remaining) => {
117 self.debounce = Some(remaining - 1);
118 false
119 }
120 }
121 }
122}
123
124impl Default for PreviewState {
125 fn default() -> PreviewState {
126 PreviewState::new()
127 }
128}
129
130pub fn fit_scale(page_w: f32, page_h: f32, max_w: u32, max_h: u32) -> f32 {
133 if !(page_w.is_finite() && page_h.is_finite()) || page_w <= 0.0 || page_h <= 0.0 {
134 return 1.0;
135 }
136 let horizontal = max_w as f32 / page_w;
137 let vertical = max_h as f32 / page_h;
138 horizontal.min(vertical).max(0.001)
139}
140
141fn blend_over_white(rgba: [u8; 4]) -> Color {
143 let alpha = rgba[3] as u32;
144 let channel = |value: u8| -> u8 { ((value as u32 * alpha + 255 * (255 - alpha)) / 255) as u8 };
145 Color::Rgb(channel(rgba[0]), channel(rgba[1]), channel(rgba[2]))
146}
147
148fn pixel(pix: &Pixmap, x: u32, y: u32) -> [u8; 4] {
149 if x >= pix.width || y >= pix.height {
150 return [255, 255, 255, 255];
151 }
152 let index = ((y * pix.width + x) * 4) as usize;
153 [
154 pix.data[index],
155 pix.data[index + 1],
156 pix.data[index + 2],
157 pix.data[index + 3],
158 ]
159}
160
161pub fn cell_colors(pix: &Pixmap, x: u32, row: u32) -> (Color, Color) {
164 (
165 blend_over_white(pixel(pix, x, row * 2)),
166 blend_over_white(pixel(pix, x, row * 2 + 1)),
167 )
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use std::sync::Arc;
174
175 fn two_by_two() -> Pixmap {
176 Pixmap {
178 width: 2,
179 height: 2,
180 data: vec![
181 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 0, 0, 0, 0,
183 ],
184 }
185 }
186
187 #[test]
188 fn fit_scale_fits_both_axes() {
189 assert_eq!(fit_scale(100.0, 100.0, 200, 50), 0.5);
190 assert_eq!(fit_scale(100.0, 100.0, 50, 200), 0.5);
191 assert_eq!(fit_scale(612.0, 792.0, 612, 792), 1.0);
192 assert_eq!(fit_scale(0.0, 100.0, 50, 50), 1.0, "degenerate page");
193 assert!(
194 fit_scale(1_000_000.0, 1.0, 10, 10) >= 0.001,
195 "clamped floor"
196 );
197 }
198
199 #[test]
200 fn cell_colors_pack_two_rows_per_cell() {
201 let pix = two_by_two();
202 assert_eq!(
203 cell_colors(&pix, 0, 0),
204 (Color::Rgb(255, 0, 0), Color::Rgb(0, 0, 255))
205 );
206 assert_eq!(
208 cell_colors(&pix, 1, 0),
209 (Color::Rgb(0, 255, 0), Color::Rgb(255, 255, 255))
210 );
211 assert_eq!(
212 cell_colors(&pix, 5, 9),
213 (Color::Rgb(255, 255, 255), Color::Rgb(255, 255, 255))
214 );
215 }
216
217 #[test]
218 fn start_render_bumps_generation_and_spins() {
219 let mut preview = PreviewState::new();
220 let first = preview.start_render(0);
221 let second = preview.start_render(0);
222 assert!(second > first);
223 assert!(preview.rendering);
224 let before = preview.spinner_frame;
225 assert!(!preview.tick());
226 assert_ne!(
227 preview.spinner_frame, before,
228 "spinner advances while rendering"
229 );
230 }
231
232 #[test]
233 fn apply_ready_ignores_stale_generations() {
234 let mut preview = PreviewState::new();
235 let stale = preview.start_render(0);
236 let current = preview.start_render(0);
237 let frame = PreviewFrame {
238 file_bytes: Arc::new(vec![1, 2, 3]),
239 pixmap: two_by_two(),
240 notice: None,
241 };
242 assert!(!preview.apply_ready(stale, Ok(frame)));
243 assert!(preview.rendering, "stale result leaves the spinner on");
244 let frame = PreviewFrame {
245 file_bytes: Arc::new(vec![1, 2, 3]),
246 pixmap: two_by_two(),
247 notice: None,
248 };
249 assert!(preview.apply_ready(current, Ok(frame)));
250 assert!(!preview.rendering);
251 assert!(preview.pixmap.is_some());
252 assert!(preview.file_bytes.is_some(), "bytes cached for re-renders");
253 assert!(preview.apply_ready(current, Err("boom".to_string())));
254 assert_eq!(preview.error.as_deref(), Some("boom"));
255 }
256
257 #[test]
258 fn stale_frame_still_caches_file_bytes() {
259 let mut preview = PreviewState::new();
260 let stale = preview.start_render(0);
261 let _current = preview.start_render(0);
262 let frame = PreviewFrame {
263 file_bytes: Arc::new(vec![9, 9, 9]),
264 pixmap: two_by_two(),
265 notice: None,
266 };
267 assert!(
268 !preview.apply_ready(stale, Ok(frame)),
269 "stale generation is still rejected"
270 );
271 assert!(
272 preview.pixmap.is_none(),
273 "stale pixmap must not be installed"
274 );
275 assert!(
276 preview.file_bytes.is_some(),
277 "whole-file bytes are generation-independent and must be cached \
278 even from a superseded render, so the next render skips re-fetching"
279 );
280 }
281
282 #[test]
283 fn debounce_counts_down_to_render_request() {
284 let mut preview = PreviewState::new();
285 preview.active = true;
286 preview.debounce = Some(RESIZE_DEBOUNCE_TICKS);
287 assert!(!preview.tick());
288 assert!(preview.tick(), "second tick fires the deferred render");
289 assert_eq!(preview.debounce, None);
290 assert!(!preview.tick(), "no further fires");
291 }
292}