Skip to main content

pdfboss_tui/
preview.rs

1//! Page preview: a rasterized page painted with `▀` half-blocks — the
2//! upper pixel of each terminal cell is the foreground color, the lower
3//! pixel the background color, two vertical pixels per cell. Rendering
4//! happens off the event loop; this module is pure state and math.
5
6use std::sync::Arc;
7
8use pdfboss_render::Pixmap;
9use ratatui::style::Color;
10
11/// Spinner frames shown while a render is in flight.
12pub const SPINNER: [char; 4] = ['|', '/', '-', '\\'];
13/// Resize debounce in 100 ms ticks (~200 ms).
14pub const RESIZE_DEBOUNCE_TICKS: u8 = 2;
15
16/// A finished render plus the file bytes fetched for it (cached so later
17/// renders skip the fetch).
18#[derive(Debug)]
19pub struct PreviewFrame {
20    pub file_bytes: Arc<Vec<u8>>,
21    pub pixmap: Pixmap,
22    /// One-line summary of anything the render had to drop, so a preview
23    /// that came out blank because pdfboss could not read the page says so
24    /// instead of looking like an empty page.
25    pub notice: Option<String>,
26}
27
28/// Preview pane model.
29pub struct PreviewState {
30    /// Whether the preview replaces the inspector (`p`).
31    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    /// Ticks until a resize-deferred re-render fires.
39    pub debounce: Option<u8>,
40    pub error: Option<String>,
41    /// What the last accepted render had to drop, if anything (see
42    /// [`PreviewFrame::notice`]).
43    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    /// Marks a render in flight for `page`; returns its generation.
63    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    /// Applies a finished render; stale generations are dropped. Returns
74    /// whether the result was accepted.
75    ///
76    /// The whole-file bytes are cached *before* the generation check: they
77    /// are generation-independent (the same file backs every render of
78    /// this document), so even a superseded render's bytes are worth
79    /// keeping — dropping them here would force the next render to
80    /// re-fetch the entire file. Only the pixmap/error handling stays
81    /// gated on the generation matching.
82    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    /// 100 ms heartbeat: advances the spinner and counts the resize
102    /// debounce down. Returns true when a deferred re-render should fire.
103    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
130/// The scale that fits a `page_w x page_h` point page inside a
131/// `max_w x max_h` pixel budget, preserving aspect ratio.
132pub 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
141/// RGBA (straight alpha) composited over the white page background.
142fn 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
161/// The `(foreground, background)` of terminal cell `(x, row)`: pixel rows
162/// `2*row` (upper, fg of `▀`) and `2*row + 1` (lower, bg).
163pub 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        // Row 0: red, green; row 1: blue, transparent.
177        Pixmap {
178            width: 2,
179            height: 2,
180            data: vec![
181                255, 0, 0, 255, 0, 255, 0, 255, //
182                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        // Transparent blends to white; out-of-range pixels are white.
207        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}