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        self.show_with_image_rect(ui, desired_size).0
43    }
44
45    /// Display the thumbnail and return both the allocated response and the
46    /// actual screen-space rect where the page image was painted.
47    #[allow(clippy::cast_precision_loss)]
48    pub fn show_with_image_rect(&self, ui: &mut Ui, desired_size: Vec2) -> (Response, egui::Rect) {
49        let Some(tex) = &self.texture else {
50            let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::hover());
51            ui.painter().rect_filled(rect, 0.0, egui::Color32::from_gray(40));
52            return (response, rect);
53        };
54
55        let tex_aspect = self.width as f32 / self.height.max(1) as f32;
56        let box_aspect = desired_size.x / desired_size.y.max(1.0);
57
58        let display_size = if tex_aspect > box_aspect {
59            Vec2::new(desired_size.x, desired_size.x / tex_aspect)
60        } else {
61            Vec2::new(desired_size.y * tex_aspect, desired_size.y)
62        };
63
64        let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::hover());
65
66        let offset = (desired_size - display_size) / 2.0;
67        let image_rect = egui::Rect::from_min_size(rect.min + offset, display_size);
68
69        ui.painter().rect_filled(rect, 0.0, egui::Color32::BLACK);
70
71        ui.painter().image(
72            tex.id(),
73            image_rect,
74            egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
75            egui::Color32::WHITE,
76        );
77
78        (response, image_rect)
79    }
80
81    /// Like `show`, but makes the thumbnail clickable and returns both the
82    /// response and the image rect (for coordinate normalization).
83    #[allow(clippy::cast_precision_loss)]
84    pub fn show_interactive(&self, ui: &mut Ui, desired_size: Vec2) -> (Response, egui::Rect) {
85        self.show_with_sense(ui, desired_size, egui::Sense::click_and_drag())
86    }
87
88    /// Display the thumbnail with a caller-provided interaction sense.
89    #[allow(clippy::cast_precision_loss)]
90    pub fn show_with_sense(
91        &self,
92        ui: &mut Ui,
93        desired_size: Vec2,
94        sense: Sense,
95    ) -> (Response, egui::Rect) {
96        let Some(tex) = &self.texture else {
97            let (rect, response) = ui.allocate_exact_size(desired_size, sense);
98            ui.painter().rect_filled(rect, 0.0, egui::Color32::from_gray(40));
99            return (response, rect);
100        };
101
102        let tex_aspect = self.width as f32 / self.height.max(1) as f32;
103        let box_aspect = desired_size.x / desired_size.y.max(1.0);
104
105        let display_size = if tex_aspect > box_aspect {
106            Vec2::new(desired_size.x, desired_size.x / tex_aspect)
107        } else {
108            Vec2::new(desired_size.y * tex_aspect, desired_size.y)
109        };
110
111        let (rect, response) = ui.allocate_exact_size(desired_size, sense);
112
113        let offset = (desired_size - display_size) / 2.0;
114        let image_rect = egui::Rect::from_min_size(rect.min + offset, display_size);
115
116        ui.painter().rect_filled(rect, 0.0, egui::Color32::BLACK);
117
118        ui.painter().image(
119            tex.id(),
120            image_rect,
121            egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
122            egui::Color32::WHITE,
123        );
124
125        (response, image_rect)
126    }
127
128    pub fn has_texture(&self) -> bool {
129        self.texture.is_some()
130    }
131
132    /// Display the thumbnail zoomed into a specific region.
133    /// `center` is normalized (0..1, 0..1), `factor` is the zoom multiplier.
134    #[allow(clippy::cast_precision_loss)]
135    pub fn show_zoomed(
136        &self,
137        ui: &mut Ui,
138        desired_size: Vec2,
139        center: (f32, f32),
140        factor: f32,
141    ) -> Response {
142        self.show_zoomed_with_image_rect(ui, desired_size, center, factor).0
143    }
144
145    /// Display a zoomed thumbnail and return the actual painted page rect.
146    #[allow(clippy::cast_precision_loss)]
147    pub fn show_zoomed_with_image_rect(
148        &self,
149        ui: &mut Ui,
150        desired_size: Vec2,
151        center: (f32, f32),
152        factor: f32,
153    ) -> (Response, egui::Rect) {
154        let Some(tex) = &self.texture else {
155            let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::hover());
156            ui.painter().rect_filled(rect, 0.0, egui::Color32::from_gray(40));
157            return (response, rect);
158        };
159
160        let tex_aspect = self.width as f32 / self.height.max(1) as f32;
161        let box_aspect = desired_size.x / desired_size.y.max(1.0);
162
163        let display_size = if tex_aspect > box_aspect {
164            Vec2::new(desired_size.x, desired_size.x / tex_aspect)
165        } else {
166            Vec2::new(desired_size.y * tex_aspect, desired_size.y)
167        };
168
169        let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::hover());
170
171        let offset = (desired_size - display_size) / 2.0;
172        let image_rect = egui::Rect::from_min_size(rect.min + offset, display_size);
173
174        ui.painter().rect_filled(rect, 0.0, egui::Color32::BLACK);
175
176        // Compute UV sub-rect for the zoomed region
177        let half_u = 1.0 / (factor * 2.0);
178        let half_v = 1.0 / (factor * 2.0);
179        let u_center = center.0.clamp(half_u, 1.0 - half_u);
180        let v_center = center.1.clamp(half_v, 1.0 - half_v);
181        let uv = egui::Rect::from_min_max(
182            egui::pos2(u_center - half_u, v_center - half_v),
183            egui::pos2(u_center + half_u, v_center + half_v),
184        );
185
186        ui.painter().image(tex.id(), image_rect, uv, egui::Color32::WHITE);
187
188        (response, image_rect)
189    }
190}
191
192impl Default for SlideThumbnail {
193    fn default() -> Self {
194        Self::new()
195    }
196}