1use 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
16pub struct GroupingEditor {
18 doc: Box<dyn DocumentSource>,
19 cache: PageCache,
20 pdf_path: PathBuf,
21 sidecar_format: String,
22 metadata: PresentationMetadata,
24 boundaries: Vec<usize>,
27 thumbnails: Vec<SlideThumbnail>,
29 status_message: Option<(String, std::time::Instant)>,
31}
32
33const THUMB_HEIGHT: f32 = 120.0;
35const RENDER_SCALE: f32 = 3.0;
37const PAGE_CELL_GAP: f32 = 10.0;
39const PAGE_ROW_GAP: f32 = 12.0;
41const STATUS_DURATION_SECS: f64 = 3.0;
43const 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);
46const PANEL_BG: egui::Color32 = egui::Color32::from_rgb(20, 24, 31);
48const TOP_BAR_BG: egui::Color32 = egui::Color32::from_rgb(28, 33, 42);
49const TEXT_PRIMARY: egui::Color32 = egui::Color32::from_rgb(241, 245, 249);
51const TEXT_SECONDARY: egui::Color32 = egui::Color32::from_rgb(224, 232, 240);
52const ACTION_COLOR: egui::Color32 = egui::Color32::from_rgb(124, 178, 255);
54const 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 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 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 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 fn toggle_boundary(&mut self, page: usize) {
122 if page == 0 {
123 return; }
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 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 #[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 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 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 #[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 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 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 for page in boundary_toggles {
401 self.toggle_boundary(page);
402 }
403 }
404}
405
406fn 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}