Skip to main content

dais_ui/
grouping_editor.rs

1//! Manual slide grouping editor mode.
2//!
3//! A standalone `eframe::App` for visually editing slide overlay groups.
4//! Launched via `dais --edit <file.pdf>`.
5
6use std::path::{Path, PathBuf};
7
8use dais_document::cache::PageCache;
9use dais_document::page::RenderSize;
10use dais_document::source::DocumentSource;
11use dais_sidecar::format::SidecarFormat;
12use dais_sidecar::types::{PresentationMetadata, SlideGroupMeta};
13
14use crate::widgets::SlideThumbnail;
15
16/// The grouping editor application.
17pub struct GroupingEditor {
18    doc: Box<dyn DocumentSource>,
19    cache: PageCache,
20    pdf_path: PathBuf,
21    sidecar_format: String,
22    /// Existing metadata loaded from sidecar (if any).
23    metadata: PresentationMetadata,
24    /// Group boundaries: a sorted list of page indices where a new group starts.
25    /// Page 0 always starts a group (implicit). This stores *additional* boundaries.
26    boundaries: Vec<usize>,
27    /// Pre-allocated thumbnails (one per page).
28    thumbnails: Vec<SlideThumbnail>,
29    /// Status message shown briefly after save.
30    status_message: Option<(String, std::time::Instant)>,
31}
32
33/// Thumbnail display height in logical pixels.
34const THUMB_HEIGHT: f32 = 120.0;
35/// Render resolution multiplier for higher quality thumbnails.
36const RENDER_SCALE: f32 = 3.0;
37/// Horizontal spacing between page cells within a group.
38const PAGE_CELL_GAP: f32 = 10.0;
39/// Vertical spacing between wrapped page rows within a group.
40const PAGE_ROW_GAP: f32 = 12.0;
41/// Status message display duration.
42const STATUS_DURATION_SECS: f64 = 3.0;
43/// Alternating group background colors.
44const GROUP_BG_A: egui::Color32 = egui::Color32::from_rgb(44, 51, 63);
45const GROUP_BG_B: egui::Color32 = egui::Color32::from_rgb(52, 60, 74);
46/// Editor background colors.
47const PANEL_BG: egui::Color32 = egui::Color32::from_rgb(20, 24, 31);
48const TOP_BAR_BG: egui::Color32 = egui::Color32::from_rgb(28, 33, 42);
49/// Text colors.
50const TEXT_PRIMARY: egui::Color32 = egui::Color32::from_rgb(241, 245, 249);
51const TEXT_SECONDARY: egui::Color32 = egui::Color32::from_rgb(224, 232, 240);
52/// Accent color for group editing controls.
53const ACTION_COLOR: egui::Color32 = egui::Color32::from_rgb(124, 178, 255);
54/// Flatter button fills.
55const BUTTON_FILL: egui::Color32 = egui::Color32::from_rgb(58, 72, 92);
56const BUTTON_FILL_HOVER: egui::Color32 = egui::Color32::from_rgb(71, 88, 112);
57const BUTTON_FILL_ACTIVE: egui::Color32 = egui::Color32::from_rgb(84, 104, 132);
58
59fn flat_button(text: impl Into<egui::WidgetText>) -> egui::Button<'static> {
60    egui::Button::new(text)
61        .fill(BUTTON_FILL)
62        .stroke(egui::Stroke::new(1.0, ACTION_COLOR.gamma_multiply(0.65)))
63        .corner_radius(4.0)
64}
65
66fn small_flat_button(text: impl Into<egui::WidgetText>) -> egui::Button<'static> {
67    flat_button(text).small()
68}
69
70impl GroupingEditor {
71    /// Create a new grouping editor for the given document.
72    pub fn new(
73        doc: Box<dyn DocumentSource>,
74        pdf_path: &Path,
75        metadata: PresentationMetadata,
76        sidecar_format: &str,
77    ) -> Self {
78        let page_count = doc.page_count();
79        let thumbnails = (0..page_count).map(|_| SlideThumbnail::new()).collect();
80
81        // Convert existing group metadata into boundary set
82        let boundaries = groups_to_boundaries(&metadata.groups, page_count);
83
84        Self {
85            doc,
86            cache: PageCache::new(128),
87            pdf_path: pdf_path.to_path_buf(),
88            sidecar_format: sidecar_format.to_string(),
89            metadata,
90            boundaries,
91            thumbnails,
92            status_message: None,
93        }
94    }
95
96    /// Compute current groups from the boundary set.
97    fn compute_groups(&self) -> Vec<Vec<usize>> {
98        let page_count = self.doc.page_count();
99        if page_count == 0 {
100            return Vec::new();
101        }
102
103        let mut all_boundaries: Vec<usize> =
104            std::iter::once(0).chain(self.boundaries.iter().copied()).collect();
105        all_boundaries.sort_unstable();
106        all_boundaries.dedup();
107
108        let mut groups = Vec::new();
109        for i in 0..all_boundaries.len() {
110            let start = all_boundaries[i];
111            let end = if i + 1 < all_boundaries.len() { all_boundaries[i + 1] } else { page_count };
112            let pages: Vec<usize> = (start..end).collect();
113            if !pages.is_empty() {
114                groups.push(pages);
115            }
116        }
117        groups
118    }
119
120    /// Toggle a boundary at the given page index.
121    fn toggle_boundary(&mut self, page: usize) {
122        if page == 0 {
123            return; // page 0 is always a boundary
124        }
125        if let Some(pos) = self.boundaries.iter().position(|&b| b == page) {
126            self.boundaries.remove(pos);
127        } else {
128            self.boundaries.push(page);
129            self.boundaries.sort_unstable();
130        }
131    }
132
133    /// Save groups to the configured sidecar file format.
134    fn save_sidecar(&mut self) {
135        let groups = self.compute_groups();
136        let group_metas: Vec<SlideGroupMeta> = groups
137            .iter()
138            .map(|g| SlideGroupMeta {
139                start_page: *g.first().unwrap_or(&0),
140                end_page: *g.last().unwrap_or(&0),
141            })
142            .collect();
143
144        let mut meta = self.metadata.clone();
145        meta.groups = group_metas;
146
147        let (sidecar_path, format): (PathBuf, Box<dyn SidecarFormat>) = if self.sidecar_format
148            == "dais"
149        {
150            (self.pdf_path.with_extension("dais"), Box::new(dais_sidecar::dais_format::DaisFormat))
151        } else {
152            (self.pdf_path.with_extension("pdfpc"), Box::new(dais_sidecar::pdfpc::PdfpcFormat))
153        };
154
155        match format.write(&sidecar_path, &meta) {
156            Ok(()) => {
157                tracing::info!("Saved grouping to {}", sidecar_path.display());
158                self.status_message = Some((
159                    format!("Saved to {}", sidecar_path.display()),
160                    std::time::Instant::now(),
161                ));
162                self.metadata = meta;
163            }
164            Err(e) => {
165                tracing::error!("Failed to save sidecar: {e}");
166                self.status_message = Some((format!("Error: {e}"), std::time::Instant::now()));
167            }
168        }
169    }
170
171    /// Ensure a page thumbnail is rendered and uploaded.
172    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
173    fn ensure_thumbnail(&mut self, ctx: &egui::Context, page_index: usize) {
174        let render_height = (THUMB_HEIGHT * RENDER_SCALE) as u32;
175        let render_width = (THUMB_HEIGHT * RENDER_SCALE * 16.0 / 9.0) as u32;
176        let render_size = RenderSize { width: render_width, height: render_height };
177
178        if self.cache.get(page_index, render_size).is_none()
179            && let Ok(rendered) = self.doc.render_page(page_index, render_size)
180        {
181            self.cache.insert(page_index, render_size, rendered);
182        }
183
184        if let Some(page) = self.cache.get(page_index, render_size) {
185            let page = page.clone();
186            self.thumbnails[page_index].update(ctx, &page, page_index);
187        }
188    }
189
190    /// Render the top header bar.
191    fn show_top_bar(&mut self, ctx: &egui::Context, page_count: usize, group_count: usize) {
192        egui::TopBottomPanel::top("grouping_top")
193            .frame(egui::Frame::new().fill(TOP_BAR_BG).inner_margin(8.0))
194            .show(ctx, |ui| {
195                ui.horizontal(|ui| {
196                    ui.heading(egui::RichText::new("Grouping Editor").color(TEXT_PRIMARY));
197                    ui.separator();
198                    ui.label(
199                        egui::RichText::new(format!("{page_count} pages → {group_count} slides"))
200                            .color(TEXT_SECONDARY),
201                    );
202
203                    ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
204                        if ui
205                            .add(flat_button(egui::RichText::new("Close").color(TEXT_PRIMARY)))
206                            .clicked()
207                        {
208                            ctx.send_viewport_cmd(egui::ViewportCommand::Close);
209                        }
210                        if ui
211                            .add(
212                                flat_button(
213                                    egui::RichText::new("Save").color(TEXT_PRIMARY).strong(),
214                                )
215                                .fill(ACTION_COLOR.gamma_multiply(0.25)),
216                            )
217                            .clicked()
218                        {
219                            self.save_sidecar();
220                        }
221
222                        // Status message
223                        if let Some((ref msg, when)) = self.status_message
224                            && when.elapsed().as_secs_f64() < STATUS_DURATION_SECS
225                        {
226                            ui.label(egui::RichText::new(msg).color(TEXT_PRIMARY).size(13.0));
227                        }
228                    });
229                });
230            });
231    }
232
233    /// Render a single group card, returning any boundary toggle requests.
234    #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation, clippy::cast_sign_loss)]
235    fn show_group(
236        thumbnails: &[SlideThumbnail],
237        ui: &mut egui::Ui,
238        group: &[usize],
239        group_idx: usize,
240    ) -> Vec<usize> {
241        let card_width = ui.available_width();
242        let thumb_width = THUMB_HEIGHT * 16.0 / 9.0;
243        let thumb_size = egui::vec2(thumb_width, THUMB_HEIGHT);
244        let page_cell_width = thumb_width;
245        let frame_inner_margin = 24.0;
246        let usable_width = (card_width - frame_inner_margin).max(page_cell_width);
247        let pages_per_row = ((usable_width + PAGE_CELL_GAP) / (page_cell_width + PAGE_CELL_GAP))
248            .floor()
249            .max(1.0) as usize;
250        let bg_color = if group_idx.is_multiple_of(2) { GROUP_BG_A } else { GROUP_BG_B };
251        let mut toggles = Vec::new();
252
253        ui.allocate_ui(egui::vec2(card_width, 0.0), |ui| {
254            egui::Frame::group(ui.style())
255                .fill(bg_color)
256                .stroke(egui::Stroke::new(1.0, ACTION_COLOR.gamma_multiply(0.2)))
257                .corner_radius(8.0)
258                .inner_margin(12.0)
259                .show(ui, |ui| {
260                    ui.set_width(ui.available_width());
261
262                    ui.horizontal(|ui| {
263                        ui.label(
264                            egui::RichText::new(format!("Slide {}", group_idx + 1))
265                                .strong()
266                                .size(16.0)
267                                .color(TEXT_PRIMARY),
268                        );
269                        ui.separator();
270                        ui.label(
271                            egui::RichText::new(format!(
272                                "{} page{}",
273                                group.len(),
274                                if group.len() == 1 { "" } else { "s" }
275                            ))
276                            .color(TEXT_SECONDARY),
277                        );
278                        if let (Some(first), Some(last)) = (group.first(), group.last()) {
279                            ui.separator();
280                            ui.label(
281                                egui::RichText::new(format!("pages {}-{}", first + 1, last + 1))
282                                    .color(TEXT_SECONDARY),
283                            );
284                        }
285                    });
286
287                    ui.add_space(6.0);
288
289                    let row_count = group.len().div_ceil(pages_per_row);
290                    for (row_idx, row) in group.chunks(pages_per_row).enumerate() {
291                        ui.horizontal(|ui| {
292                            for (col_idx, &page_idx) in row.iter().enumerate() {
293                                let page_position = row_idx * pages_per_row + col_idx;
294                                ui.vertical(|ui| {
295                                    thumbnails[page_idx].show(ui, thumb_size);
296                                    ui.label(
297                                        egui::RichText::new(format!("Page {}", page_idx + 1))
298                                            .size(12.0)
299                                            .color(TEXT_PRIMARY),
300                                    );
301
302                                    if page_position + 1 < group.len() {
303                                        let next_page = group[page_position + 1];
304                                        let response = ui
305                                            .add(small_flat_button(
306                                                egui::RichText::new("Split after")
307                                                    .color(TEXT_PRIMARY),
308                                            ))
309                                            .on_hover_text(format!(
310                                                "Start a new slide at page {}",
311                                                next_page + 1
312                                            ));
313                                        if response.clicked() {
314                                            toggles.push(next_page);
315                                        }
316                                    } else {
317                                        ui.add_space(ui.spacing().interact_size.y);
318                                    }
319                                });
320
321                                if col_idx + 1 < row.len() {
322                                    ui.add_space(PAGE_CELL_GAP);
323                                }
324                            }
325                        });
326
327                        if row_idx + 1 < row_count {
328                            ui.add_space(PAGE_ROW_GAP);
329                        }
330                    }
331                });
332        });
333
334        toggles
335    }
336}
337
338impl eframe::App for GroupingEditor {
339    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
340        let page_count = self.doc.page_count();
341
342        // Pre-render thumbnails
343        for i in 0..page_count {
344            self.ensure_thumbnail(ctx, i);
345        }
346
347        let groups = self.compute_groups();
348
349        self.show_top_bar(ctx, page_count, groups.len());
350
351        // Main scrollable area
352        let mut boundary_toggles = Vec::new();
353
354        egui::CentralPanel::default()
355            .frame(egui::Frame::new().fill(PANEL_BG))
356            .show(ctx, |ui| {
357                let visuals = &mut ui.style_mut().visuals;
358                visuals.widgets.inactive.bg_fill = BUTTON_FILL;
359                visuals.widgets.inactive.weak_bg_fill = BUTTON_FILL;
360                visuals.widgets.hovered.bg_fill = BUTTON_FILL_HOVER;
361                visuals.widgets.hovered.weak_bg_fill = BUTTON_FILL_HOVER;
362                visuals.widgets.active.bg_fill = BUTTON_FILL_ACTIVE;
363                visuals.widgets.active.weak_bg_fill = BUTTON_FILL_ACTIVE;
364                visuals.widgets.noninteractive.bg_fill = PANEL_BG;
365                visuals.widgets.inactive.fg_stroke.color = TEXT_PRIMARY;
366                visuals.widgets.hovered.fg_stroke.color = TEXT_PRIMARY;
367                visuals.widgets.active.fg_stroke.color = TEXT_PRIMARY;
368                visuals.override_text_color = Some(TEXT_PRIMARY);
369
370                egui::ScrollArea::vertical().show(ui, |ui| {
371                    for (group_idx, group) in groups.iter().enumerate() {
372                        boundary_toggles
373                            .extend(Self::show_group(&self.thumbnails, ui, group, group_idx));
374
375                        if group_idx + 1 < groups.len() {
376                            let next_group_first = groups[group_idx + 1][0];
377                            ui.add_space(6.0);
378                            ui.horizontal(|ui| {
379                                if ui
380                                    .add(flat_button(
381                                        egui::RichText::new("Merge with above")
382                                        .color(TEXT_PRIMARY),
383                                    ))
384                                    .on_hover_text(format!(
385                                        "Merge this slide group into the one above by removing the boundary before page {}",
386                                        next_group_first + 1
387                                    ))
388                                    .clicked()
389                                {
390                                    boundary_toggles.push(next_group_first);
391                                }
392                            });
393                            ui.add_space(8.0);
394                        }
395                    }
396                });
397            });
398
399        // Apply boundary toggles (deferred to avoid borrow conflicts)
400        for page in boundary_toggles {
401            self.toggle_boundary(page);
402        }
403    }
404}
405
406/// Convert `SlideGroupMeta` list into a set of boundary page indices.
407fn groups_to_boundaries(groups: &[SlideGroupMeta], page_count: usize) -> Vec<usize> {
408    if groups.is_empty() {
409        return Vec::new();
410    }
411
412    let mut boundaries: Vec<usize> =
413        groups.iter().map(|g| g.start_page).filter(|&p| p > 0 && p < page_count).collect();
414    boundaries.sort_unstable();
415    boundaries.dedup();
416    boundaries
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    #[test]
424    fn empty_groups_produce_no_boundaries() {
425        assert!(groups_to_boundaries(&[], 10).is_empty());
426    }
427
428    #[test]
429    fn single_group_no_boundaries() {
430        let groups = vec![SlideGroupMeta { start_page: 0, end_page: 9 }];
431        assert!(groups_to_boundaries(&groups, 10).is_empty());
432    }
433
434    #[test]
435    fn multiple_groups_produce_boundaries() {
436        let groups = vec![
437            SlideGroupMeta { start_page: 0, end_page: 2 },
438            SlideGroupMeta { start_page: 3, end_page: 5 },
439            SlideGroupMeta { start_page: 6, end_page: 9 },
440        ];
441        let boundaries = groups_to_boundaries(&groups, 10);
442        assert_eq!(boundaries, vec![3, 6]);
443    }
444
445    #[test]
446    fn out_of_range_boundaries_filtered() {
447        let groups = vec![
448            SlideGroupMeta { start_page: 0, end_page: 4 },
449            SlideGroupMeta { start_page: 20, end_page: 25 },
450        ];
451        let boundaries = groups_to_boundaries(&groups, 10);
452        assert!(boundaries.is_empty());
453    }
454}