use std::path::{Path, PathBuf};
use dais_document::cache::PageCache;
use dais_document::page::RenderSize;
use dais_document::source::DocumentSource;
use dais_sidecar::format::SidecarFormat;
use dais_sidecar::types::{PresentationMetadata, SlideGroupMeta};
use crate::widgets::SlideThumbnail;
pub struct GroupingEditor {
doc: Box<dyn DocumentSource>,
cache: PageCache,
pdf_path: PathBuf,
sidecar_format: String,
metadata: PresentationMetadata,
boundaries: Vec<usize>,
thumbnails: Vec<SlideThumbnail>,
status_message: Option<(String, std::time::Instant)>,
}
const THUMB_HEIGHT: f32 = 120.0;
const RENDER_SCALE: f32 = 3.0;
const PAGE_CELL_GAP: f32 = 10.0;
const PAGE_ROW_GAP: f32 = 12.0;
const STATUS_DURATION_SECS: f64 = 3.0;
const GROUP_BG_A: egui::Color32 = egui::Color32::from_rgb(44, 51, 63);
const GROUP_BG_B: egui::Color32 = egui::Color32::from_rgb(52, 60, 74);
const PANEL_BG: egui::Color32 = egui::Color32::from_rgb(20, 24, 31);
const TOP_BAR_BG: egui::Color32 = egui::Color32::from_rgb(28, 33, 42);
const TEXT_PRIMARY: egui::Color32 = egui::Color32::from_rgb(241, 245, 249);
const TEXT_SECONDARY: egui::Color32 = egui::Color32::from_rgb(224, 232, 240);
const ACTION_COLOR: egui::Color32 = egui::Color32::from_rgb(124, 178, 255);
const BUTTON_FILL: egui::Color32 = egui::Color32::from_rgb(58, 72, 92);
const BUTTON_FILL_HOVER: egui::Color32 = egui::Color32::from_rgb(71, 88, 112);
const BUTTON_FILL_ACTIVE: egui::Color32 = egui::Color32::from_rgb(84, 104, 132);
fn flat_button(text: impl Into<egui::WidgetText>) -> egui::Button<'static> {
egui::Button::new(text)
.fill(BUTTON_FILL)
.stroke(egui::Stroke::new(1.0, ACTION_COLOR.gamma_multiply(0.65)))
.corner_radius(4.0)
}
fn small_flat_button(text: impl Into<egui::WidgetText>) -> egui::Button<'static> {
flat_button(text).small()
}
impl GroupingEditor {
pub fn new(
doc: Box<dyn DocumentSource>,
pdf_path: &Path,
metadata: PresentationMetadata,
sidecar_format: &str,
) -> Self {
let page_count = doc.page_count();
let thumbnails = (0..page_count).map(|_| SlideThumbnail::new()).collect();
let boundaries = groups_to_boundaries(&metadata.groups, page_count);
Self {
doc,
cache: PageCache::new(128),
pdf_path: pdf_path.to_path_buf(),
sidecar_format: sidecar_format.to_string(),
metadata,
boundaries,
thumbnails,
status_message: None,
}
}
fn compute_groups(&self) -> Vec<Vec<usize>> {
let page_count = self.doc.page_count();
if page_count == 0 {
return Vec::new();
}
let mut all_boundaries: Vec<usize> =
std::iter::once(0).chain(self.boundaries.iter().copied()).collect();
all_boundaries.sort_unstable();
all_boundaries.dedup();
let mut groups = Vec::new();
for i in 0..all_boundaries.len() {
let start = all_boundaries[i];
let end = if i + 1 < all_boundaries.len() { all_boundaries[i + 1] } else { page_count };
let pages: Vec<usize> = (start..end).collect();
if !pages.is_empty() {
groups.push(pages);
}
}
groups
}
fn toggle_boundary(&mut self, page: usize) {
if page == 0 {
return; }
if let Some(pos) = self.boundaries.iter().position(|&b| b == page) {
self.boundaries.remove(pos);
} else {
self.boundaries.push(page);
self.boundaries.sort_unstable();
}
}
fn save_sidecar(&mut self) {
let groups = self.compute_groups();
let group_metas: Vec<SlideGroupMeta> = groups
.iter()
.map(|g| SlideGroupMeta {
start_page: *g.first().unwrap_or(&0),
end_page: *g.last().unwrap_or(&0),
})
.collect();
let mut meta = self.metadata.clone();
meta.groups = group_metas;
let (sidecar_path, format): (PathBuf, Box<dyn SidecarFormat>) = if self.sidecar_format
== "dais"
{
(self.pdf_path.with_extension("dais"), Box::new(dais_sidecar::dais_format::DaisFormat))
} else {
(self.pdf_path.with_extension("pdfpc"), Box::new(dais_sidecar::pdfpc::PdfpcFormat))
};
match format.write(&sidecar_path, &meta) {
Ok(()) => {
tracing::info!("Saved grouping to {}", sidecar_path.display());
self.status_message = Some((
format!("Saved to {}", sidecar_path.display()),
std::time::Instant::now(),
));
self.metadata = meta;
}
Err(e) => {
tracing::error!("Failed to save sidecar: {e}");
self.status_message = Some((format!("Error: {e}"), std::time::Instant::now()));
}
}
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn ensure_thumbnail(&mut self, ctx: &egui::Context, page_index: usize) {
let render_height = (THUMB_HEIGHT * RENDER_SCALE) as u32;
let render_width = (THUMB_HEIGHT * RENDER_SCALE * 16.0 / 9.0) as u32;
let render_size = RenderSize { width: render_width, height: render_height };
if self.cache.get(page_index, render_size).is_none()
&& let Ok(rendered) = self.doc.render_page(page_index, render_size)
{
self.cache.insert(page_index, render_size, rendered);
}
if let Some(page) = self.cache.get(page_index, render_size) {
let page = page.clone();
self.thumbnails[page_index].update(ctx, &page, page_index);
}
}
fn show_top_bar(&mut self, ctx: &egui::Context, page_count: usize, group_count: usize) {
egui::TopBottomPanel::top("grouping_top")
.frame(egui::Frame::new().fill(TOP_BAR_BG).inner_margin(8.0))
.show(ctx, |ui| {
ui.horizontal(|ui| {
ui.heading(egui::RichText::new("Grouping Editor").color(TEXT_PRIMARY));
ui.separator();
ui.label(
egui::RichText::new(format!("{page_count} pages → {group_count} slides"))
.color(TEXT_SECONDARY),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(flat_button(egui::RichText::new("Close").color(TEXT_PRIMARY)))
.clicked()
{
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
if ui
.add(
flat_button(
egui::RichText::new("Save").color(TEXT_PRIMARY).strong(),
)
.fill(ACTION_COLOR.gamma_multiply(0.25)),
)
.clicked()
{
self.save_sidecar();
}
if let Some((ref msg, when)) = self.status_message
&& when.elapsed().as_secs_f64() < STATUS_DURATION_SECS
{
ui.label(egui::RichText::new(msg).color(TEXT_PRIMARY).size(13.0));
}
});
});
});
}
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn show_group(
thumbnails: &[SlideThumbnail],
ui: &mut egui::Ui,
group: &[usize],
group_idx: usize,
) -> Vec<usize> {
let card_width = ui.available_width();
let thumb_width = THUMB_HEIGHT * 16.0 / 9.0;
let thumb_size = egui::vec2(thumb_width, THUMB_HEIGHT);
let page_cell_width = thumb_width;
let frame_inner_margin = 24.0;
let usable_width = (card_width - frame_inner_margin).max(page_cell_width);
let pages_per_row = ((usable_width + PAGE_CELL_GAP) / (page_cell_width + PAGE_CELL_GAP))
.floor()
.max(1.0) as usize;
let bg_color = if group_idx.is_multiple_of(2) { GROUP_BG_A } else { GROUP_BG_B };
let mut toggles = Vec::new();
ui.allocate_ui(egui::vec2(card_width, 0.0), |ui| {
egui::Frame::group(ui.style())
.fill(bg_color)
.stroke(egui::Stroke::new(1.0, ACTION_COLOR.gamma_multiply(0.2)))
.corner_radius(8.0)
.inner_margin(12.0)
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format!("Slide {}", group_idx + 1))
.strong()
.size(16.0)
.color(TEXT_PRIMARY),
);
ui.separator();
ui.label(
egui::RichText::new(format!(
"{} page{}",
group.len(),
if group.len() == 1 { "" } else { "s" }
))
.color(TEXT_SECONDARY),
);
if let (Some(first), Some(last)) = (group.first(), group.last()) {
ui.separator();
ui.label(
egui::RichText::new(format!("pages {}-{}", first + 1, last + 1))
.color(TEXT_SECONDARY),
);
}
});
ui.add_space(6.0);
let row_count = group.len().div_ceil(pages_per_row);
for (row_idx, row) in group.chunks(pages_per_row).enumerate() {
ui.horizontal(|ui| {
for (col_idx, &page_idx) in row.iter().enumerate() {
let page_position = row_idx * pages_per_row + col_idx;
ui.vertical(|ui| {
thumbnails[page_idx].show(ui, thumb_size);
ui.label(
egui::RichText::new(format!("Page {}", page_idx + 1))
.size(12.0)
.color(TEXT_PRIMARY),
);
if page_position + 1 < group.len() {
let next_page = group[page_position + 1];
let response = ui
.add(small_flat_button(
egui::RichText::new("Split after")
.color(TEXT_PRIMARY),
))
.on_hover_text(format!(
"Start a new slide at page {}",
next_page + 1
));
if response.clicked() {
toggles.push(next_page);
}
} else {
ui.add_space(ui.spacing().interact_size.y);
}
});
if col_idx + 1 < row.len() {
ui.add_space(PAGE_CELL_GAP);
}
}
});
if row_idx + 1 < row_count {
ui.add_space(PAGE_ROW_GAP);
}
}
});
});
toggles
}
}
impl eframe::App for GroupingEditor {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let page_count = self.doc.page_count();
for i in 0..page_count {
self.ensure_thumbnail(ctx, i);
}
let groups = self.compute_groups();
self.show_top_bar(ctx, page_count, groups.len());
let mut boundary_toggles = Vec::new();
egui::CentralPanel::default()
.frame(egui::Frame::new().fill(PANEL_BG))
.show(ctx, |ui| {
let visuals = &mut ui.style_mut().visuals;
visuals.widgets.inactive.bg_fill = BUTTON_FILL;
visuals.widgets.inactive.weak_bg_fill = BUTTON_FILL;
visuals.widgets.hovered.bg_fill = BUTTON_FILL_HOVER;
visuals.widgets.hovered.weak_bg_fill = BUTTON_FILL_HOVER;
visuals.widgets.active.bg_fill = BUTTON_FILL_ACTIVE;
visuals.widgets.active.weak_bg_fill = BUTTON_FILL_ACTIVE;
visuals.widgets.noninteractive.bg_fill = PANEL_BG;
visuals.widgets.inactive.fg_stroke.color = TEXT_PRIMARY;
visuals.widgets.hovered.fg_stroke.color = TEXT_PRIMARY;
visuals.widgets.active.fg_stroke.color = TEXT_PRIMARY;
visuals.override_text_color = Some(TEXT_PRIMARY);
egui::ScrollArea::vertical().show(ui, |ui| {
for (group_idx, group) in groups.iter().enumerate() {
boundary_toggles
.extend(Self::show_group(&self.thumbnails, ui, group, group_idx));
if group_idx + 1 < groups.len() {
let next_group_first = groups[group_idx + 1][0];
ui.add_space(6.0);
ui.horizontal(|ui| {
if ui
.add(flat_button(
egui::RichText::new("Merge with above")
.color(TEXT_PRIMARY),
))
.on_hover_text(format!(
"Merge this slide group into the one above by removing the boundary before page {}",
next_group_first + 1
))
.clicked()
{
boundary_toggles.push(next_group_first);
}
});
ui.add_space(8.0);
}
}
});
});
for page in boundary_toggles {
self.toggle_boundary(page);
}
}
}
fn groups_to_boundaries(groups: &[SlideGroupMeta], page_count: usize) -> Vec<usize> {
if groups.is_empty() {
return Vec::new();
}
let mut boundaries: Vec<usize> =
groups.iter().map(|g| g.start_page).filter(|&p| p > 0 && p < page_count).collect();
boundaries.sort_unstable();
boundaries.dedup();
boundaries
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_groups_produce_no_boundaries() {
assert!(groups_to_boundaries(&[], 10).is_empty());
}
#[test]
fn single_group_no_boundaries() {
let groups = vec![SlideGroupMeta { start_page: 0, end_page: 9 }];
assert!(groups_to_boundaries(&groups, 10).is_empty());
}
#[test]
fn multiple_groups_produce_boundaries() {
let groups = vec![
SlideGroupMeta { start_page: 0, end_page: 2 },
SlideGroupMeta { start_page: 3, end_page: 5 },
SlideGroupMeta { start_page: 6, end_page: 9 },
];
let boundaries = groups_to_boundaries(&groups, 10);
assert_eq!(boundaries, vec![3, 6]);
}
#[test]
fn out_of_range_boundaries_filtered() {
let groups = vec![
SlideGroupMeta { start_page: 0, end_page: 4 },
SlideGroupMeta { start_page: 20, end_page: 25 },
];
let boundaries = groups_to_boundaries(&groups, 10);
assert!(boundaries.is_empty());
}
}