use std::collections::HashMap; use std::io; use std::ops::RangeBounds; use std::path::{Path, PathBuf}; use crate::core::presentations::{PresKind, Presentation}; use crate::ui::widgets::loaded_image::loaded_image; use cosmic::dialog::file_chooser::FileFilter; use cosmic::dialog::file_chooser::open::Dialog; use cosmic::iced::alignment::Vertical; use cosmic::iced::widget::{column, row}; use cosmic::iced::{Background, ContentFit, Length}; use cosmic::widget::image::Handle; use cosmic::widget::space::{self, horizontal}; use cosmic::widget::{ self, Space, button, container, context_menu, icon, menu, mouse_area, scrollable, text, text_input, }; use cosmic::{Element, Task, theme}; use miette::{IntoDiagnostic, Result, miette}; use mupdf::{Colorspace, Document, Matrix}; use tracing::{debug, error, warn}; #[derive(Debug)] pub struct PresentationEditor { pub presentation: Option, document: Option, current_slide: Option, slides: Option>, page_count: Option, current_slide_index: Option, title: String, editing: bool, hovered_slide: Option, context_menu_id: Option, } pub enum Action { Task(Task), UpdatePresentation(Presentation), SplitAddPresentation((Presentation, Presentation)), None, } #[derive(Debug, Clone)] pub enum Message { ChangePresentation(Presentation), Update(Presentation), ChangeTitle(String), PickPresentation, Edit(bool), NextPage, PrevPage, None, ChangePresentationFile(Presentation), AddSlides(Option>), ChangeSlide(usize), HoverSlide(Option), ContextMenu(usize), SplitBefore, SplitAfter, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum MenuAction { SplitBefore, SplitAfter, } impl menu::Action for MenuAction { type Message = Message; fn message(&self) -> Self::Message { match self { Self::SplitBefore => Message::SplitBefore, Self::SplitAfter => Message::SplitAfter, } } } impl PresentationEditor { #[must_use] pub const fn new() -> Self { Self { presentation: None, document: None, title: String::new(), editing: false, current_slide: None, current_slide_index: None, page_count: None, slides: None, hovered_slide: None, context_menu_id: None, } } #[allow(clippy::too_many_lines)] pub fn update(&mut self, message: Message) -> Action { match message { Message::ChangePresentation(presentation) => { self.update_entire_presentation(&presentation); if let Some(presentation) = &self.presentation { let task; let path = presentation.path.clone(); if let PresKind::Pdf { starting_index, ending_index, } = presentation.kind { let range = starting_index..=ending_index; task = Task::perform( async move { get_pages(range, path) }, Message::AddSlides, ); } else { task = Task::perform( async move { get_pages(.., path) }, Message::AddSlides, ); } return Action::Task(task); } } Message::ChangeTitle(title) => { self.title.clone_from(&title); if let Some(presentation) = &self.presentation { let mut presentation = presentation.clone(); presentation.title = title; return self.update(Message::Update(presentation)); } } Message::Edit(edit) => { debug!(edit); self.editing = edit; } Message::Update(presentation) => { warn!(?presentation, "about to update"); self.presentation = Some(presentation.clone()); return Action::UpdatePresentation(presentation); } Message::PickPresentation => { let presentation_id = self.presentation.as_ref().map(|v| v.id).unwrap_or_default(); let task = Task::perform(pick_presentation(), move |presentation_result| { presentation_result.map_or(Message::None, |presentation| { let mut presentation = Presentation::from(presentation); presentation.id = presentation_id; Message::ChangePresentationFile(presentation) }) }); return Action::Task(task); } Message::ChangePresentationFile(presentation) => { self.update_entire_presentation(&presentation); if let Some(presentation) = &self.presentation { let mut task; let path = presentation.path.clone(); if let PresKind::Pdf { starting_index, ending_index, } = presentation.kind.clone() { let range = starting_index..=ending_index; task = Task::perform( async move { get_pages(range, path) }, Message::AddSlides, ); } else { task = Task::perform( async move { get_pages(.., path) }, Message::AddSlides, ); } task = task.chain(Task::done(Message::Update(presentation.clone()))); return Action::Task(task); } } Message::AddSlides(slides) => { self.slides = slides; } Message::None => (), Message::NextPage => { let next_index = self.current_slide_index.unwrap_or_default() + 1; let last_index = if let Some(presentation) = self.presentation.as_ref() && let PresKind::Pdf { ending_index, .. } = presentation.kind { ending_index } else { self.page_count.unwrap_or_default() }; if next_index > last_index { return Action::None; } self.current_slide = self.document.as_ref().and_then(|doc| { let page = doc.load_page(next_index).ok()?; let matrix = Matrix::IDENTITY; let colorspace = Colorspace::device_rgb(); let Ok(pixmap) = page .to_pixmap(&matrix, &colorspace, true, true) .into_diagnostic() else { error!("Can't turn this page into pixmap"); return None; }; debug!(?pixmap); Some(Handle::from_rgba( pixmap.width(), pixmap.height(), pixmap.samples().to_vec(), )) }); self.current_slide_index = Some(next_index); } Message::PrevPage => { let previous_index = self.current_slide_index.unwrap_or_default() - 1; let first_index = if let Some(presentation) = self.presentation.as_ref() && let PresKind::Pdf { starting_index, .. } = presentation.kind { starting_index } else { self.page_count.unwrap_or_default() }; if previous_index < first_index { return Action::None; } self.current_slide = self.document.as_ref().and_then(|doc| { let page = doc.load_page(previous_index).ok()?; let matrix = Matrix::IDENTITY; let colorspace = Colorspace::device_rgb(); let pixmap = page.to_pixmap(&matrix, &colorspace, true, true).ok()?; Some(Handle::from_rgba( pixmap.width(), pixmap.height(), pixmap.samples().to_vec(), )) }); self.current_slide_index = Some(previous_index); } Message::ChangeSlide(index) => { let starting_index = if let Some(presentation) = self.presentation.as_ref() && let PresKind::Pdf { starting_index, .. } = presentation.kind { starting_index } else { 0 }; self.current_slide = self.document.as_ref().and_then(|doc| { let page = doc .load_page(i32::try_from(index).ok()? + starting_index) .ok()?; let matrix = Matrix::IDENTITY; let colorspace = Colorspace::device_rgb(); let pixmap = page.to_pixmap(&matrix, &colorspace, true, true).ok()?; Some(Handle::from_rgba( pixmap.width(), pixmap.height(), pixmap.samples().to_vec(), )) }); self.current_slide_index = i32::try_from(index).ok(); } Message::HoverSlide(slide) => { self.hovered_slide = slide; } Message::ContextMenu(index) => { self.context_menu_id = i32::try_from(index).ok(); } Message::SplitBefore => { if let Ok((first, second)) = self.split_before() { debug!(?first, ?second); self.update_entire_presentation(&first); return Action::SplitAddPresentation((first, second)); } } Message::SplitAfter => { if let Ok((first, second)) = self.split_after() { debug!(?first, ?second); self.update_entire_presentation(&first); return Action::SplitAddPresentation((first, second)); } } } Action::None } pub fn view(&self) -> Element { let presentation = self.current_slide.as_ref().map_or_else( || container(Space::new()), |slide| { container(loaded_image( slide.clone(), widget::image(slide) .content_fit(ContentFit::ScaleDown) .into(), )) .style(|_| { container::background(Background::Color(cosmic::iced::Color::WHITE)) }) }, ); let pdf_pages: Vec> = self.slides.as_ref().map_or_else( || vec![horizontal().into()], |pages| { pages .iter() .enumerate() .map(|(index, page)| { let image = loaded_image( page.clone(), widget::image(page) .height(theme::spacing().space_xxxl * 3) .content_fit(ContentFit::ScaleDown) .into(), ); let slide = container(image).style(|_| { container::background(Background::Color( cosmic::iced::Color::WHITE, )) }); let clickable_slide = container( mouse_area(slide) .on_enter(Message::HoverSlide(i32::try_from(index).ok())) .on_exit(Message::HoverSlide(None)) .on_right_press(Message::ContextMenu(index)) .on_press(Message::ChangeSlide(index)), ) .padding(theme::spacing().space_m) .clip(true) .class(self.hovered_slide.map_or( theme::Container::Card, |hovered_index| { if i32::try_from(index) .is_ok_and(|index| index == hovered_index) { theme::Container::Primary } else { theme::Container::Card } }, )); clickable_slide.into() }) .collect() }, ); let pages_column = container( self.context_menu( scrollable( column(pdf_pages) .spacing(theme::active().cosmic().space_xs()) .padding(theme::spacing().space_xs), ) .into(), ), ) .class(theme::Container::Card); let main_row = row![ pages_column, container(presentation).center(Length::FillPortion(2)) ] .spacing(theme::spacing().space_xxl); let control_buttons = row![ button::standard("Previous Page").on_press(Message::PrevPage), space::horizontal(), button::standard("Next Page").on_press(Message::NextPage), ]; let column = column![self.toolbar(), main_row, control_buttons] .spacing(theme::active().cosmic().space_l()); column.into() } fn toolbar(&self) -> Element { let title_box = text_input( "Title...", self.presentation .as_ref() .map_or("", |presentation| &presentation.title), ) .on_input(Message::ChangeTitle); let presentation_selector = button::icon(icon::from_name("folder-presentations-symbolic").scale(2)) .label("Change Presentation") .tooltip("Select a presentation") .on_press(Message::PickPresentation) .padding(10); row![ text::body("Title:"), title_box, space::horizontal(), presentation_selector ] .align_y(Vertical::Center) .spacing(10) .into() } pub const fn editing(&self) -> bool { self.editing } fn context_menu<'b>(&self, items: Element<'b, Message>) -> Element<'b, Message> { if self.context_menu_id.is_some() { let before_icon = icon::from_path("./res/split-above.svg".into()).symbolic(true); let after_icon = icon::from_path("./res/split-below.svg".into()).symbolic(true); let menu_items = vec![ menu::Item::Button( "Spit Before", Some(before_icon), MenuAction::SplitBefore, ), menu::Item::Button( "Split After", Some(after_icon), MenuAction::SplitAfter, ), ]; let context_menu = context_menu( items, self.context_menu_id.map_or_else( || None, |_| Some(menu::items(&HashMap::new(), menu_items)), ), ); Element::from(context_menu) } else { items } } fn update_entire_presentation(&mut self, presentation: &Presentation) { self.presentation = Some(presentation.clone()); self.title.clone_from(&presentation.title); self.document = Document::open(&presentation.path.as_os_str()).ok(); self.page_count = self.document.as_ref().and_then(|doc| doc.page_count().ok()); warn!("changing presentation"); let pages = if let PresKind::Pdf { starting_index, ending_index, } = presentation.kind { self.current_slide = self.document.as_ref().and_then(|doc| { let page = doc.load_page(starting_index).ok()?; let matrix = Matrix::IDENTITY; let colorspace = Colorspace::device_rgb(); let pixmap = page.to_pixmap(&matrix, &colorspace, true, true).ok()?; Some(Handle::from_rgba( pixmap.width(), pixmap.height(), pixmap.samples().to_vec(), )) }); self.current_slide_index = Some(starting_index); get_pages(starting_index..=ending_index, presentation.path.clone()) } else { self.current_slide = self.document.as_ref().and_then(|doc| { let page = doc.load_page(0).ok()?; let matrix = Matrix::IDENTITY; let colorspace = Colorspace::device_rgb(); let pixmap = page.to_pixmap(&matrix, &colorspace, true, true).ok()?; Some(Handle::from_rgba( pixmap.width(), pixmap.height(), pixmap.samples().to_vec(), )) }); self.current_slide_index = Some(0); get_pages(.., presentation.path.clone()) }; self.slides = pages; } fn split_before(&self) -> Result<(Presentation, Presentation)> { if let Some(index) = self.context_menu_id { let Some(current_presentation) = self.presentation.as_ref() else { return Err(miette!("There is no current presentation")); }; let first_presentation = Presentation { id: current_presentation.id, title: current_presentation.title.clone(), path: current_presentation.path.clone(), kind: match current_presentation.kind { PresKind::Pdf { .. } => PresKind::Pdf { starting_index: 0, ending_index: index - 1, }, _ => current_presentation.kind.clone(), }, }; let second_presentation = Presentation { id: 0, title: format!("{} (2)", current_presentation.title.clone()), path: current_presentation.path.clone(), kind: match current_presentation.kind { PresKind::Pdf { ending_index, .. } => PresKind::Pdf { starting_index: index, ending_index, }, _ => current_presentation.kind.clone(), }, }; Ok((first_presentation, second_presentation)) } else { error!("split before no index"); Err(miette!( "No current index from context menu, has there been a right click on a presentation page" )) } } fn split_after(&self) -> Result<(Presentation, Presentation)> { if let Some(index) = self.context_menu_id { let Some(current_presentation) = self.presentation.as_ref() else { return Err(miette!("There is no current presentation")); }; let first_presentation = Presentation { id: current_presentation.id, title: current_presentation.title.clone(), path: current_presentation.path.clone(), kind: match current_presentation.kind { PresKind::Pdf { .. } => PresKind::Pdf { starting_index: 0, ending_index: index, }, _ => current_presentation.kind.clone(), }, }; let second_presentation = Presentation { id: 0, title: format!("{} (2)", current_presentation.title.clone()), path: current_presentation.path.clone(), kind: match current_presentation.kind { PresKind::Pdf { ending_index, .. } => PresKind::Pdf { starting_index: index + 1, ending_index, }, _ => current_presentation.kind.clone(), }, }; Ok((first_presentation, second_presentation)) } else { error!("split before no index"); Err(miette!( "No current index from context menu, has there been a right click on a presentation page" )) } } } impl Default for PresentationEditor { fn default() -> Self { Self::new() } } fn get_pages( range: impl RangeBounds, presentation_path: impl AsRef, ) -> Option> { let document = Document::open(presentation_path.as_ref().as_os_str()).ok()?; let pages = document.pages().ok()?; Some( pages .enumerate() .filter_map(|(index, page)| { if !range.contains( &i32::try_from(index) .expect("looking for a pdf index that is way too large"), ) { return None; } let page = page.ok()?; let matrix = Matrix::IDENTITY; let colorspace = Colorspace::device_rgb(); let pixmap = page.to_pixmap(&matrix, &colorspace, true, true).ok()?; Some(Handle::from_rgba( pixmap.width(), pixmap.height(), pixmap.samples().to_vec(), )) }) .collect(), ) } async fn pick_presentation() -> Result { let dialog = Dialog::new().title("Choose a presentation..."); let bg_filter = FileFilter::new("Presentations") .extension("pdf") .extension("html"); dialog .filter(bg_filter) .directory(dirs::home_dir().expect("oops")) .open_file() .await .map_err(|e| { error!(?e); PresentationError::DialogClosed }) .map(|file| file.url().to_file_path().expect("Should be a file here")) // rfd::AsyncFileDialog::new() // .set_title("Choose a background...") // .add_filter( // "Presentations and Presentations", // &["png", "jpeg", "mp4", "webm", "mkv", "jpg", "mpeg"], // ) // .set_directory(dirs::home_dir().unwrap()) // .pick_file() // .await // .ok_or(PresentationError::BackgroundDialogClosed) // .map(|file| file.path().to_owned()) } #[derive(Debug, Clone)] pub enum PresentationError { DialogClosed, IOError(io::ErrorKind), }