Skip to main content

dais_document/
render_pipeline.rs

1//! Asynchronous render pipeline.
2//!
3//! Offloads PDF page rendering to a background thread so the UI thread
4//! never blocks waiting for hayro.  The UI submits render requests and
5//! polls for completed results each frame.
6
7use std::collections::HashSet;
8use std::sync::Arc;
9
10use crossbeam_channel::{Receiver, Sender, TrySendError};
11
12use crate::cache::PageCache;
13use crate::page::{RenderSize, RenderedPage};
14use crate::source::DocumentSource;
15
16/// A request for the background renderer.
17struct RenderRequest {
18    page_index: usize,
19    size: RenderSize,
20}
21
22/// A completed render from the background thread.
23struct RenderResult {
24    page_index: usize,
25    size: RenderSize,
26    page: RenderedPage,
27}
28
29/// Manages background rendering and result collection.
30pub struct RenderPipeline {
31    request_tx: Sender<RenderRequest>,
32    result_rx: Receiver<RenderResult>,
33    /// Pages currently being rendered (to avoid duplicate requests).
34    pending: HashSet<(usize, RenderSize)>,
35}
36
37/// Fallback render size used when a target display resolution is unavailable.
38/// This is intentionally high enough that the fullscreen audience view does
39/// not need to upscale from a small 720p source on a typical 1080p projector.
40pub const FALLBACK_RENDER_SIZE: RenderSize = RenderSize { width: 1920, height: 1080 };
41
42impl RenderPipeline {
43    /// Spawn the render pipeline with `num_workers` background threads.
44    #[allow(clippy::needless_pass_by_value)]
45    pub fn new(doc: Arc<dyn DocumentSource>, num_workers: usize) -> Self {
46        // Bounded request channel — don't queue hundreds of requests
47        let (request_tx, request_rx) = crossbeam_channel::bounded::<RenderRequest>(64);
48        let (result_tx, result_rx) = crossbeam_channel::unbounded::<RenderResult>();
49
50        for _ in 0..num_workers {
51            let rx = request_rx.clone();
52            let tx = result_tx.clone();
53            let doc = Arc::clone(&doc);
54            std::thread::Builder::new()
55                .name("dais-render".into())
56                .spawn(move || render_worker(doc, rx, tx))
57                .expect("failed to spawn render thread");
58        }
59
60        Self { request_tx, result_rx, pending: HashSet::new() }
61    }
62
63    /// Poll for completed renders and insert them into the cache.
64    /// Call once per frame at the start of `update()`.
65    pub fn poll_results(&mut self, cache: &mut PageCache) {
66        while let Ok(result) = self.result_rx.try_recv() {
67            let key = (result.page_index, result.size);
68            self.pending.remove(&key);
69            cache.insert(result.page_index, result.size, result.page);
70        }
71    }
72
73    /// Ensure a page is rendered (or being rendered). If not in cache and not
74    /// pending, submits a background render request.
75    pub fn ensure_rendered(&mut self, page_index: usize, size: RenderSize, cache: &mut PageCache) {
76        if cache.get(page_index, size).is_some() {
77            return;
78        }
79        let key = (page_index, size);
80        if self.pending.contains(&key) {
81            return;
82        }
83        match self.request_tx.try_send(RenderRequest { page_index, size }) {
84            Ok(()) => {
85                self.pending.insert(key);
86            }
87            Err(TrySendError::Full(_)) => {
88                // Queue full — will retry next frame
89            }
90            Err(TrySendError::Disconnected(_)) => {
91                tracing::error!("Render pipeline disconnected");
92            }
93        }
94    }
95
96    /// Request the current page and its neighbors for smooth navigation.
97    pub fn prefetch_neighborhood(
98        &mut self,
99        current_page: usize,
100        total_pages: usize,
101        size: RenderSize,
102        cache: &mut PageCache,
103    ) {
104        // Current page (highest priority — submitted first)
105        self.ensure_rendered(current_page, size, cache);
106
107        // Next page
108        if current_page + 1 < total_pages {
109            self.ensure_rendered(current_page + 1, size, cache);
110        }
111
112        // Previous page (for back-navigation)
113        if current_page > 0 {
114            self.ensure_rendered(current_page - 1, size, cache);
115        }
116
117        // Two pages ahead (look-ahead for fast clicking)
118        if current_page + 2 < total_pages {
119            self.ensure_rendered(current_page + 2, size, cache);
120        }
121    }
122}
123
124#[allow(clippy::needless_pass_by_value)]
125fn render_worker(
126    doc: Arc<dyn DocumentSource>,
127    rx: Receiver<RenderRequest>,
128    tx: Sender<RenderResult>,
129) {
130    while let Ok(req) = rx.recv() {
131        match doc.render_page(req.page_index, req.size) {
132            Ok(page) => {
133                let _ = tx.send(RenderResult { page_index: req.page_index, size: req.size, page });
134            }
135            Err(e) => {
136                tracing::warn!("Background render failed for page {}: {e}", req.page_index);
137            }
138        }
139    }
140}