lumina/src/ui/presentation_editor.rs
Chris Cochrun 29de582f84
Some checks failed
/ clippy (push) Failing after 6m38s
/ test (push) Failing after 7m31s
[fix]: Use os_str on mupdf::FilePath such for builds on windows
2026-04-29 10:20:35 -05:00

635 lines
23 KiB
Rust

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<Presentation>,
document: Option<Document>,
current_slide: Option<Handle>,
slides: Option<Vec<Handle>>,
page_count: Option<i32>,
current_slide_index: Option<i32>,
title: String,
editing: bool,
hovered_slide: Option<i32>,
context_menu_id: Option<i32>,
}
pub enum Action {
Task(Task<Message>),
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<Vec<Handle>>),
ChangeSlide(usize),
HoverSlide(Option<i32>),
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<Message> {
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<Element<Message>> = 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<Message> {
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<i32>,
presentation_path: impl AsRef<Path>,
) -> Option<Vec<Handle>> {
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<PathBuf, PresentationError> {
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),
}