Skip to main content

dais_ui/presenter/
overview.rs

1//! Slide overview grid.
2//!
3//! Modal overlay showing a grid of slide thumbnails with keyboard navigation.
4
5use dais_core::bus::CommandSender;
6use dais_core::commands::Command;
7use dais_core::state::PresentationState;
8use dais_document::cache::PageCache;
9use dais_document::render_pipeline::FALLBACK_RENDER_SIZE;
10
11use crate::widgets::SlideThumbnail;
12
13/// Slide overview grid overlay.
14pub struct OverviewGrid {
15    thumbnails: Vec<SlideThumbnail>,
16    selected: usize,
17    columns: usize,
18}
19
20/// Target thumbnail size for overview grid items.
21const THUMB_WIDTH: f32 = 200.0;
22const THUMB_HEIGHT: f32 = 150.0;
23const THUMB_PADDING: f32 = 8.0;
24
25impl OverviewGrid {
26    pub fn new() -> Self {
27        Self { thumbnails: Vec::new(), selected: 0, columns: 4 }
28    }
29
30    /// Show the overview grid as a full-window overlay.
31    pub fn show(
32        &mut self,
33        ctx: &egui::Context,
34        ui: &mut egui::Ui,
35        state: &PresentationState,
36        cache: &mut PageCache,
37        sender: &CommandSender,
38    ) {
39        if !state.overview_visible {
40            // Reset selection when overview is closed so it's fresh on next open
41            self.selected = state.current_logical_slide;
42            return;
43        }
44
45        while self.thumbnails.len() < state.total_logical_slides {
46            self.thumbnails.push(SlideThumbnail::new());
47        }
48
49        let available = ui.available_rect_before_wrap();
50        ui.painter().rect_filled(
51            available,
52            0.0,
53            egui::Color32::from_rgba_unmultiplied(0, 0, 0, 220),
54        );
55
56        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
57        {
58            self.columns =
59                ((available.width() / (THUMB_WIDTH + THUMB_PADDING * 2.0)) as usize).max(1);
60        }
61
62        self.handle_navigation(ctx, state, sender);
63        self.render_grid(ctx, ui, state, cache, sender);
64    }
65
66    fn handle_navigation(
67        &mut self,
68        ctx: &egui::Context,
69        state: &PresentationState,
70        sender: &CommandSender,
71    ) {
72        let navigate_cmd = ctx.input(|i| {
73            if i.key_pressed(egui::Key::ArrowRight) {
74                Some(NavigateDir::Right)
75            } else if i.key_pressed(egui::Key::ArrowLeft) {
76                Some(NavigateDir::Left)
77            } else if i.key_pressed(egui::Key::ArrowDown) {
78                Some(NavigateDir::Down)
79            } else if i.key_pressed(egui::Key::ArrowUp) {
80                Some(NavigateDir::Up)
81            } else if i.key_pressed(egui::Key::Enter) {
82                Some(NavigateDir::Select)
83            } else if i.key_pressed(egui::Key::Escape) {
84                Some(NavigateDir::Close)
85            } else {
86                None
87            }
88        });
89
90        if let Some(dir) = navigate_cmd {
91            match dir {
92                NavigateDir::Right if self.selected + 1 < state.total_logical_slides => {
93                    self.selected += 1;
94                }
95                NavigateDir::Left if self.selected > 0 => {
96                    self.selected -= 1;
97                }
98                NavigateDir::Down if self.selected + self.columns < state.total_logical_slides => {
99                    self.selected += self.columns;
100                }
101                NavigateDir::Up if self.selected >= self.columns => {
102                    self.selected -= self.columns;
103                }
104                NavigateDir::Select => {
105                    let _ = sender.send(Command::GoToSlide(self.selected));
106                    let _ = sender.send(Command::ToggleSlideOverview);
107                }
108                NavigateDir::Close => {
109                    let _ = sender.send(Command::ToggleSlideOverview);
110                }
111                _ => {}
112            }
113        }
114    }
115
116    fn render_grid(
117        &mut self,
118        ctx: &egui::Context,
119        ui: &mut egui::Ui,
120        state: &PresentationState,
121        cache: &mut PageCache,
122        sender: &CommandSender,
123    ) {
124        let render_size = FALLBACK_RENDER_SIZE;
125
126        egui::ScrollArea::vertical().show(ui, |ui| {
127            ui.horizontal_wrapped(|ui| {
128                ui.spacing_mut().item_spacing = egui::vec2(THUMB_PADDING, THUMB_PADDING);
129
130                for i in 0..state.total_logical_slides {
131                    self.render_thumbnail(ctx, ui, i, state, cache, sender, render_size);
132                }
133            });
134        });
135    }
136
137    #[allow(clippy::too_many_arguments)]
138    fn render_thumbnail(
139        &mut self,
140        ctx: &egui::Context,
141        ui: &mut egui::Ui,
142        index: usize,
143        state: &PresentationState,
144        cache: &mut PageCache,
145        sender: &CommandSender,
146        render_size: dais_document::page::RenderSize,
147    ) {
148        let first_page =
149            state.slide_groups.get(index).and_then(|g| g.pages.first().copied()).unwrap_or(index);
150
151        // Just read from cache — the pipeline will populate it
152        if let Some(page) = cache.get(first_page, render_size) {
153            self.thumbnails[index].update(ctx, page, first_page);
154        }
155
156        let desired = egui::vec2(THUMB_WIDTH, THUMB_HEIGHT + 20.0);
157        let (rect, response) = ui.allocate_exact_size(desired, egui::Sense::click());
158
159        let thumb_rect = egui::Rect::from_min_size(rect.min, egui::vec2(THUMB_WIDTH, THUMB_HEIGHT));
160        let mut thumb_ui = ui.new_child(egui::UiBuilder::new().max_rect(thumb_rect));
161        self.thumbnails[index].show(&mut thumb_ui, egui::vec2(THUMB_WIDTH, THUMB_HEIGHT));
162
163        if index == self.selected {
164            ui.painter().rect_stroke(
165                thumb_rect,
166                2.0,
167                egui::Stroke::new(3.0, egui::Color32::LIGHT_BLUE),
168                egui::StrokeKind::Outside,
169            );
170        }
171
172        let label_rect = egui::Rect::from_min_size(
173            rect.min + egui::vec2(0.0, THUMB_HEIGHT),
174            egui::vec2(THUMB_WIDTH, 20.0),
175        );
176        ui.painter().text(
177            label_rect.center(),
178            egui::Align2::CENTER_CENTER,
179            format!("{}", index + 1),
180            egui::FontId::proportional(12.0),
181            egui::Color32::LIGHT_GRAY,
182        );
183
184        if response.clicked() {
185            let _ = sender.send(Command::GoToSlide(index));
186            let _ = sender.send(Command::ToggleSlideOverview);
187        }
188    }
189}
190
191impl Default for OverviewGrid {
192    fn default() -> Self {
193        Self::new()
194    }
195}
196
197#[derive(Debug, Clone, Copy)]
198enum NavigateDir {
199    Left,
200    Right,
201    Up,
202    Down,
203    Select,
204    Close,
205}