Skip to main content

dais_ui/widgets/
slide_thumbnail.rs

1//! Slide thumbnail rendering widget.
2//!
3//! Renders a PDF page as an egui texture with correct aspect ratio.
4
5use dais_document::page::RenderedPage;
6use egui::{Response, Sense, TextureHandle, Ui, Vec2};
7
8/// A reusable widget that displays a rendered PDF page as an egui texture.
9pub struct SlideThumbnail {
10    texture: Option<TextureHandle>,
11    page_index: usize,
12    width: u32,
13    height: u32,
14}
15
16impl SlideThumbnail {
17    pub fn new() -> Self {
18        Self { texture: None, page_index: usize::MAX, width: 0, height: 0 }
19    }
20
21    /// Upload new page data to the GPU texture, only if the page changed.
22    pub fn update(&mut self, ctx: &egui::Context, page: &RenderedPage, page_index: usize) {
23        if self.page_index == page_index && self.width == page.width && self.height == page.height {
24            return;
25        }
26
27        let color_image = egui::ColorImage::from_rgba_premultiplied(
28            [page.width as usize, page.height as usize],
29            &page.data,
30        );
31        let name = format!("slide_{page_index}_{}", page.width);
32        self.texture = Some(ctx.load_texture(name, color_image, egui::TextureOptions::LINEAR));
33        self.page_index = page_index;
34        self.width = page.width;
35        self.height = page.height;
36    }
37
38    /// Display the thumbnail in the UI, fitting within `desired_size` while
39    /// preserving aspect ratio. Returns the response for the image area.
40    #[allow(clippy::cast_precision_loss)]
41    pub fn show(&self, ui: &mut Ui, desired_size: Vec2) -> Response {
42        let Some(tex) = &self.texture else {
43            let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::hover());
44            ui.painter().rect_filled(rect, 0.0, egui::Color32::from_gray(40));
45            return response;
46        };
47
48        let tex_aspect = self.width as f32 / self.height.max(1) as f32;
49        let box_aspect = desired_size.x / desired_size.y.max(1.0);
50
51        let display_size = if tex_aspect > box_aspect {
52            Vec2::new(desired_size.x, desired_size.x / tex_aspect)
53        } else {
54            Vec2::new(desired_size.y * tex_aspect, desired_size.y)
55        };
56
57        let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::hover());
58
59        let offset = (desired_size - display_size) / 2.0;
60        let image_rect = egui::Rect::from_min_size(rect.min + offset, display_size);
61
62        ui.painter().rect_filled(rect, 0.0, egui::Color32::BLACK);
63
64        ui.painter().image(
65            tex.id(),
66            image_rect,
67            egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
68            egui::Color32::WHITE,
69        );
70
71        response
72    }
73
74    /// Like `show`, but makes the thumbnail clickable and returns both the
75    /// response and the image rect (for coordinate normalization).
76    #[allow(clippy::cast_precision_loss)]
77    pub fn show_interactive(&self, ui: &mut Ui, desired_size: Vec2) -> (Response, egui::Rect) {
78        self.show_with_sense(ui, desired_size, egui::Sense::click_and_drag())
79    }
80
81    /// Display the thumbnail with a caller-provided interaction sense.
82    #[allow(clippy::cast_precision_loss)]
83    pub fn show_with_sense(
84        &self,
85        ui: &mut Ui,
86        desired_size: Vec2,
87        sense: Sense,
88    ) -> (Response, egui::Rect) {
89        let Some(tex) = &self.texture else {
90            let (rect, response) = ui.allocate_exact_size(desired_size, sense);
91            ui.painter().rect_filled(rect, 0.0, egui::Color32::from_gray(40));
92            return (response, rect);
93        };
94
95        let tex_aspect = self.width as f32 / self.height.max(1) as f32;
96        let box_aspect = desired_size.x / desired_size.y.max(1.0);
97
98        let display_size = if tex_aspect > box_aspect {
99            Vec2::new(desired_size.x, desired_size.x / tex_aspect)
100        } else {
101            Vec2::new(desired_size.y * tex_aspect, desired_size.y)
102        };
103
104        let (rect, response) = ui.allocate_exact_size(desired_size, sense);
105
106        let offset = (desired_size - display_size) / 2.0;
107        let image_rect = egui::Rect::from_min_size(rect.min + offset, display_size);
108
109        ui.painter().rect_filled(rect, 0.0, egui::Color32::BLACK);
110
111        ui.painter().image(
112            tex.id(),
113            image_rect,
114            egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
115            egui::Color32::WHITE,
116        );
117
118        (response, image_rect)
119    }
120
121    pub fn has_texture(&self) -> bool {
122        self.texture.is_some()
123    }
124
125    /// Display the thumbnail zoomed into a specific region.
126    /// `center` is normalized (0..1, 0..1), `factor` is the zoom multiplier.
127    #[allow(clippy::cast_precision_loss)]
128    pub fn show_zoomed(
129        &self,
130        ui: &mut Ui,
131        desired_size: Vec2,
132        center: (f32, f32),
133        factor: f32,
134    ) -> Response {
135        let Some(tex) = &self.texture else {
136            let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::hover());
137            ui.painter().rect_filled(rect, 0.0, egui::Color32::from_gray(40));
138            return response;
139        };
140
141        let tex_aspect = self.width as f32 / self.height.max(1) as f32;
142        let box_aspect = desired_size.x / desired_size.y.max(1.0);
143
144        let display_size = if tex_aspect > box_aspect {
145            Vec2::new(desired_size.x, desired_size.x / tex_aspect)
146        } else {
147            Vec2::new(desired_size.y * tex_aspect, desired_size.y)
148        };
149
150        let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::hover());
151
152        let offset = (desired_size - display_size) / 2.0;
153        let image_rect = egui::Rect::from_min_size(rect.min + offset, display_size);
154
155        ui.painter().rect_filled(rect, 0.0, egui::Color32::BLACK);
156
157        // Compute UV sub-rect for the zoomed region
158        let half_u = 1.0 / (factor * 2.0);
159        let half_v = 1.0 / (factor * 2.0);
160        let u_center = center.0.clamp(half_u, 1.0 - half_u);
161        let v_center = center.1.clamp(half_v, 1.0 - half_v);
162        let uv = egui::Rect::from_min_max(
163            egui::pos2(u_center - half_u, v_center - half_v),
164            egui::pos2(u_center + half_u, v_center + half_v),
165        );
166
167        ui.painter().image(tex.id(), image_rect, uv, egui::Color32::WHITE);
168
169        response
170    }
171}
172
173impl Default for SlideThumbnail {
174    fn default() -> Self {
175        Self::new()
176    }
177}