diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 0c3b11d9..8d91d121 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -18,6 +18,17 @@ use crate::SlideApp; use crate::icons::{self, Icon}; use crate::theme::{self, Tokens}; +mod files; +mod info; +mod print; + +pub use files::split_path; +use files::{home, new_page, open_page}; +use info::{PROPERTIES, account, commit_props, info, save_as}; +pub use info::{SAVE_TYPES, format_bytes}; +use print::{export, print}; +pub use print::{print_page_size, print_params, print_slides}; + /// Width of the side menu. pub const NAV_W: f32 = 160.0; /// Height of a side-menu entry (the back arrow's row is a little taller). @@ -215,28 +226,6 @@ impl Backstage { } } -/// Save As and Change File Type: (id for [`SlideApp::export_as`], label, detail). -pub const SAVE_TYPES: [(&str, &str, &str); 7] = [ - ("pptx", "PowerPoint Presentation (*.pptx)", "Opens in PowerPoint and other presentation apps"), - ("deckcraft", "DeckCraft Presentation (*.deckcraft)", "DeckCraft's own format: everything is kept"), - ("pdf", "PDF (*.pdf)", "A fixed copy that keeps its layout on any device"), - ("outline", "Outline (*.txt)", "The slide titles and bullet text as plain text"), - ("rtf", "Outline/RTF (*.rtf)", "The slide titles and bullet text for word processors"), - ("png", "PNG Picture (*.png)", "The current slide as a picture"), - ("jpeg", "JPEG Picture (*.jpg)", "The current slide as a compressed picture"), -]; - -/// Info page properties: (`file.properties` key, label, hint). -const PROPERTIES: [(&str, &str, &str); 7] = [ - ("title", "Title", "Add a title"), - ("subject", "Subject", "Specify the subject"), - ("keywords", "Tags", "Add a tag"), - ("category", "Categories", "Add a category"), - ("company", "Company", "Specify the company"), - ("comments", "Comments", "Add comments"), - ("author", "Author", "Add an author"), -]; - /// What a click asked for, applied after drawing. #[derive(Default)] struct Outcome { @@ -493,763 +482,6 @@ fn search(ui: &mut Ui, query: &mut String, hint: &str, width: f32) -> bool { ui.add(egui::TextEdit::singleline(query).hint_text(hint).desired_width(width)).changed() } -/// A new-presentation tile: a theme preview drawn in code (or a blank slide) over its name. -fn tile(ui: &mut Ui, label: &str, theme: Option<&deckcraft_model::Theme>, big: bool) -> egui::Response { - let t = Tokens::get(ui.ctx()); - let thumb_w = if big { 200.0 } else { 160.0 }; - let (rect, resp) = ui.allocate_exact_size(vec2(thumb_w + 16.0, thumb_w * 9.0 / 16.0 + 44.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); - let p = ui.painter(); - if resp.hovered() { - p.rect_filled(rect, CornerRadius::same(4), t.hover); - } - let thumb = Rect::from_min_size(rect.min + vec2(8.0, 10.0), vec2(thumb_w, thumb_w * 9.0 / 16.0)); - match theme { - Some(th) => crate::ribbon::paint_theme_tile(p, thumb, th), - None => { - p.rect_filled(thumb, CornerRadius::same(2), Color32::WHITE); - p.rect_stroke(thumb, CornerRadius::same(2), Stroke::new(1.0, Color32::from_gray(200)), StrokeKind::Inside); - } - } - if resp.hovered() { - p.rect_stroke(thumb, CornerRadius::same(2), Stroke::new(2.0, t.accent), StrokeKind::Outside); - } - let galley = p.layout(label.to_string(), theme::font(12.0), t.text, rect.width() - 8.0); - p.galley(pos2(rect.center().x - galley.size().x / 2.0, thumb.bottom() + 8.0), galley, t.text); - resp -} - -/// The built-in themes and the sample deck, filtered by `query` (after a blank presentation when -/// `blank`); at most `limit` theme tiles. False when nothing matched. -fn new_tiles(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize, blank: bool) -> bool { - let q = query.trim().to_lowercase(); - let themes: Vec = - deckcraft_model::theme::builtin_themes().into_iter().filter(|th| q.is_empty() || th.name.to_lowercase().contains(&q)).collect(); - let sample = q.is_empty() || "sample deck northern lights".contains(&q); - if themes.is_empty() && !sample { - return false; - } - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = vec2(10.0, 10.0); - if blank && tile(ui, "Blank Presentation", None, false).clicked() { - let _ = app.run("file.new", json!({})); - } - let mut shown = 0; - for th in themes { - if shown >= limit { - break; - } - if tile(ui, &th.name, Some(&th), false).clicked() { - let _ = app.run("file.new", json!({"theme": th.name})); - } - shown += 1; - } - if sample && shown < limit { - let th = deckcraft_model::theme::builtin_themes().into_iter().find(|t| t.name == "DeckCraft"); - if tile(ui, "Sample deck: Northern Lights", th.as_ref(), false).clicked() - && let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) - { - app.set_status(e.to_string()); - } - } - }); - true -} - -/// Starter decks whose name, summary, theme or a slide title matches `query`, at most `limit` tiles. -fn template_tiles(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) -> bool { - let list = deckcraft_engine::templates::matching(query); - if list.is_empty() { - return false; - } - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = vec2(10.0, 10.0); - for starter in list.into_iter().take(limit) { - let theme = deckcraft_model::theme::builtin_themes().into_iter().find(|th| th.name == starter.theme); - if tile(ui, starter.name, theme.as_ref(), false).on_hover_text(starter.summary).clicked() { - let _ = app.run("file.new", json!({"template": starter.name})); - } - } - }); - true -} - -/// File name and folder of a recent path, split as text (nothing on disk is read). -pub fn split_path(path: &str) -> (String, String) { - let p = std::path::Path::new(path); - let name = p.file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(path).to_string(); - let folder = p.parent().and_then(std::path::Path::to_str).unwrap_or("").to_string(); - (name, folder) -} - -/// Recent presentations whose name matches `query`, newest first, at most `limit`. Opening one is -/// the user's explicit choice. -fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) { - let t = Tokens::get(ui.ctx()); - if app.ui.recent.is_empty() { - dim(ui, "Presentations you open show up here. Use Open to browse for one."); - return; - } - let q = query.trim().to_lowercase(); - let list: Vec = - app.ui.recent.iter().filter(|p| q.is_empty() || split_path(p).0.to_lowercase().contains(&q)).take(limit).cloned().collect(); - if list.is_empty() { - dim(ui, &format!("No recent presentations match “{}”.", query.trim())); - return; - } - let width = ui.available_width().min(CONTENT_W); - for path in list { - let missing = !app.recent_exists(&path); - let (name, folder) = split_path(&path); - let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &name)); - let p = ui.painter(); - if resp.hovered() { - p.rect_filled(rect, CornerRadius::same(3), t.hover); - } - let ink = if missing { t.text_dim } else { t.text }; - // The row is as wide as the page's content, which a narrow window clips: keep the mark - // and the remove button inside the visible part of the row. - let visible_right = rect.right().min(ui.clip_rect().right() - 6.0); - { - let p = ui.painter(); - icons::paint(p, Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), vec2(22.0, 22.0)), Icon::Slide, ink, false); - p.text(pos2(rect.left() + 44.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::font(13.0), ink); - p.text(pos2(rect.left() + 44.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::font(11.0), t.text_dim); - // A missing file says so before it is clicked (#172). - if missing { - p.text(pos2(visible_right - 82.0, rect.center().y), Align2::RIGHT_CENTER, "(missing)", theme::font(11.0), t.text_dim); - } - } - let resp = if missing { resp.on_hover_text(format!("{path} — this file can't be found")) } else { resp.on_hover_text(&path) }; - // Remove from the list: a real labelled widget on top of the row, with a code-drawn icon - // (a text glyph would need a font that has it). - let x = ui.allocate_rect( - Rect::from_min_max(pos2(visible_right - 32.0, rect.top() + 5.0), pos2(visible_right, rect.bottom() - 5.0)), - Sense::click(), - ); - x.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Remove from Recent")); - if x.hovered() { - ui.painter().rect_filled(x.rect, CornerRadius::same(3), t.hover); - } - icons::paint(ui.painter(), Rect::from_center_size(x.rect.center(), vec2(14.0, 14.0)), Icon::Close, t.text_dim, false); - if x.on_hover_text("Remove from Recent").clicked() { - app.forget_recent(&path); - } - if resp.clicked() { - app.open_path_in_background(&path); - } - } -} - -/// A large square command button (Print, Create PDF, Check for Issues…). -fn square_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egui::Response { - let t = Tokens::get(ui.ctx()); - let (rect, resp) = ui.allocate_exact_size(vec2(88.0, 80.0), if enabled { Sense::click() } else { Sense::hover() }); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label)); - let p = ui.painter(); - p.rect_filled(rect, CornerRadius::same(3), if resp.hovered() && enabled { t.hover } else { t.card }); - p.rect_stroke(rect, CornerRadius::same(3), Stroke::new(1.0, if resp.hovered() && enabled { t.accent } else { t.border }), StrokeKind::Inside); - icons::paint(p, Rect::from_center_size(pos2(rect.center().x, rect.top() + 26.0), vec2(26.0, 26.0)), icon, t.text, !enabled); - let ink = if enabled { t.text } else { t.text_faint }; - let galley = p.layout(label.to_string(), theme::font(12.0), ink, rect.width() - 8.0); - p.galley(pos2(rect.center().x - galley.size().x / 2.0, rect.top() + 46.0), galley, ink); - resp -} - -/// A row of a page's own list on the left (Open's places, Export's tasks). -fn place_row(ui: &mut Ui, icon: Icon, label: &str, selected: bool, width: f32) -> egui::Response { - let t = Tokens::get(ui.ctx()); - let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, label)); - let p = ui.painter(); - if selected { - p.rect_filled(rect, CornerRadius::same(2), t.selected); - p.rect_stroke(rect, CornerRadius::same(2), Stroke::new(1.0, t.border), StrokeKind::Inside); - } else if resp.hovered() { - p.rect_filled(rect, CornerRadius::same(2), t.hover); - } - icons::paint(p, Rect::from_center_size(pos2(rect.left() + 22.0, rect.center().y), vec2(24.0, 24.0)), icon, t.text, false); - p.text(pos2(rect.left() + 46.0, rect.center().y), Align2::LEFT_CENTER, label, theme::font(13.5), t.text); - resp -} - -/// A square button with its title and description beside it (Info, Export). -fn task(ui: &mut Ui, icon: Icon, button: &str, title: &str, detail: &str, enabled: bool) -> bool { - let t = Tokens::get(ui.ctx()); - let mut clicked = false; - ui.horizontal_top(|ui| { - clicked = square_button(ui, icon, button, enabled).clicked(); - ui.add_space(12.0); - ui.vertical(|ui| { - ui.add_space(4.0); - ui.label(RichText::new(title).font(theme::font(15.0)).color(t.text)); - dim(ui, detail); - }); - }); - ui.add_space(14.0); - clicked -} - -// ---------------------------------------------------------------- pages - -/// Home: DeckCraft's mark, new presentations (the first themes, with a link to New) and recent -/// presentations (with a link to Open). The same page is the start screen. -fn home(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) { - let t = Tokens::get(ui.ctx()); - ui.horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::hover()); - crate::dialogs::paint_logo(ui.painter(), r); - ui.label(RichText::new("DeckCraft").font(theme::bold(24.0)).color(t.text)); - }); - ui.add_space(18.0); - ui.horizontal(|ui| { - ui.label(RichText::new("New").font(theme::bold(16.0)).color(t.text)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if arrow_link(ui, "More themes") { - out.page = Some(Page::New); - } - }); - }); - ui.add_space(6.0); - new_tiles(app, ui, "", 5, true); - ui.add_space(22.0); - ui.horizontal(|ui| { - ui.label(RichText::new("Templates").font(theme::bold(16.0)).color(t.text)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if arrow_link(ui, "More templates") { - out.page = Some(Page::New); - } - }); - }); - ui.add_space(6.0); - template_tiles(app, ui, "", 3); - ui.add_space(22.0); - ui.horizontal(|ui| { - ui.label(RichText::new("Recent").font(theme::bold(16.0)).color(t.text)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - search(ui, &mut bs.query, "Search for a file", 280.0); - }); - }); - ui.add_space(6.0); - recent_list(app, ui, &bs.query.clone(), 8); - if app.ui.recent.len() > 8 { - ui.add_space(6.0); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { - if arrow_link(ui, "More presentations") { - out.page = Some(Page::Open); - } - }); - } - ui.add_space(30.0); - ui.hyperlink_to("Join the ArtCraft community on Discord", deckcraft_engine::links::DISCORD); -} - -/// New: a blank presentation, then every theme and starter template, with a search box. -fn new_page(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { - let t = Tokens::get(ui.ctx()); - if tile(ui, "Blank Presentation", None, true).clicked() { - let _ = app.run("file.new", json!({})); - } - ui.add_space(8.0); - let w = ui.available_width(); - let (r, _) = ui.allocate_exact_size(vec2(w, 1.0), Sense::hover()); - ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, t.separator)); - ui.add_space(14.0); - search(ui, &mut bs.query, "Search for themes and templates", 380.0); - ui.add_space(14.0); - let q = bs.query.clone(); - let themes = new_tiles(app, ui, &q, usize::MAX, false); - let templates = if q.trim().is_empty() || !deckcraft_engine::templates::matching(&q).is_empty() { - ui.add_space(18.0); - heading(ui, "Templates"); - template_tiles(app, ui, &q, usize::MAX) - } else { - false - }; - if !themes && !templates { - dim(ui, &format!("No themes or templates match “{}”.", q.trim())); - } -} - -/// Open: Recent and Browse on the left; the recent presentations (with a search box) on the right. -fn open_page(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { - let t = Tokens::get(ui.ctx()); - ui.horizontal_top(|ui| { - ui.vertical(|ui| { - ui.set_width(230.0); - place_row(ui, Icon::Recent, "Recent", true, 220.0); - if place_row(ui, Icon::OpenFolder, "Browse", false, 220.0).on_hover_text("Open a presentation from a folder (Ctrl+O)").clicked() { - let _ = app.run("app.openDialog", json!({})); - } - if app.session.recovery_dir.is_some() { - ui.add_space(24.0); - if ui.button("Recover Unsaved Presentations").clicked() { - match app.run("file.recovery.open", json!({})) { - Ok(v) if v.get("opened").and_then(Value::as_array).is_some_and(|a| a.is_empty()) => { - app.set_status("There are no unsaved presentations to recover"); - } - _ => {} - } - } - } - }); - let h = ui.available_height().max(320.0); - let (r, _) = ui.allocate_exact_size(vec2(1.0, h), Sense::hover()); - ui.painter().line_segment([r.center_top(), r.center_bottom()], Stroke::new(1.0, t.separator)); - ui.add_space(24.0); - ui.vertical(|ui| { - search(ui, &mut bs.query, "Search for a file", 380.0); - ui.add_space(12.0); - heading(ui, "Presentations"); - let q = bs.query.clone(); - recent_list(app, ui, &q, usize::MAX); - }); - }); -} - -/// Info: the presentation's name and location, Inspect and Manage on the left, its properties -/// (editable, through `file.properties`) on the right. -fn info(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { - let t = Tokens::get(ui.ctx()); - let Ok(v) = app.session.execute("file.info", &json!({})) else { - dim(ui, "No presentation is open."); - return; - }; - let path = v.get("path").and_then(Value::as_str).map(str::to_string); - let title = v.get("title").and_then(Value::as_str).unwrap_or("").to_string(); - let dirty = v.get("dirty").and_then(Value::as_bool).unwrap_or(false); - ui.label(RichText::new(&title).font(theme::font(24.0)).color(t.accent)); - match &path { - Some(p) => dim(ui, p), - None => dim(ui, "Not saved yet"), - } - if let Some(p) = &path { - ui.add_space(8.0); - ui.horizontal(|ui| { - if ui.button("Copy path").clicked() { - ui.ctx().copy_text(p.clone()); - app.set_status("Copied the path"); - } - #[cfg(not(target_arch = "wasm32"))] - if let Some(dir) = std::path::Path::new(p).parent().and_then(std::path::Path::to_str) - && ui.button("Open file location").clicked() - { - app.open_url(&format!("file:///{}", dir.replace('\\', "/").trim_start_matches('/'))); - } - }); - } - ui.add_space(20.0); - ui.horizontal_top(|ui| { - ui.vertical(|ui| { - ui.set_width(440.0); - if task( - ui, - Icon::Accessibility, - "Check for Issues", - "Inspect Presentation", - "Before you share it, check the presentation for things people with disabilities might find hard to read.", - true, - ) { - let _ = app.run("app.dialog", json!({"id": "accessibility"})); - } - let (detail, can_revert) = match (dirty, path.is_some()) { - (false, _) => ("There are no unsaved changes.", false), - (true, true) => ("This presentation has unsaved changes. Revert goes back to the saved file.", true), - (true, false) => ("This presentation has never been saved.", false), - }; - if task(ui, Icon::ResetSlide, "Revert", "Manage Presentation", detail, can_revert) { - let _ = app.run("file.revert", json!({})); - } - }); - ui.add_space(30.0); - ui.vertical(|ui| { - heading(ui, "Properties"); - let props = v.get("properties").cloned().unwrap_or_default(); - let size = v.get("bytes").and_then(Value::as_u64).map(format_bytes); - egui::Grid::new("backstage_props").num_columns(2).spacing(vec2(18.0, 7.0)).show(ui, |ui| { - let row = |ui: &mut Ui, k: &str, val: String| { - ui.label(RichText::new(k).color(t.text_dim)); - ui.label(val); - ui.end_row(); - }; - if let Some(s) = size { - row(ui, "Size", s); - } - row(ui, "Slides", v.get("slides").and_then(Value::as_u64).unwrap_or(0).to_string()); - row(ui, "Hidden slides", v.get("hiddenSlides").and_then(Value::as_u64).unwrap_or(0).to_string()); - for (i, (key, label, hint)) in PROPERTIES.iter().enumerate() { - let saved = props.get(*key).and_then(Value::as_str).unwrap_or("").to_string(); - let Some(draft) = bs.props.get_mut(i) else { continue }; - let l = ui.label(RichText::new(*label).color(t.text_dim)); - let id = ui.make_persistent_id(("backstage_prop", *key)); - let resp = ui.add(egui::TextEdit::singleline(draft).id(id).hint_text(*hint).desired_width(300.0)).labelled_by(l.id); - if resp.lost_focus() && *draft != saved { - // Commit first: a click on another field takes the focus before this one is drawn. - let mut p = serde_json::Map::new(); - p.insert((*key).to_string(), json!(draft.clone())); - let _ = app.run("file.properties", Value::Object(p)); - } else if !resp.has_focus() && *draft != saved { - // Show the presentation's value unless the field is being edited (undo...). - draft.clone_from(&saved); - ui.ctx().request_repaint(); - } - ui.end_row(); - } - row(ui, "Location", path.clone().unwrap_or_else(|| "Not saved yet".into())); - }); - }); - }); -} - -/// Commits what the Info page's property fields hold and the presentation does not: a side-menu -/// action such as Save runs before the field that is being left is drawn again (#422). -fn commit_props(app: &mut SlideApp, bs: &Backstage) { - if bs.page != Page::Info { - return; - } - let Ok(v) = app.session.execute("file.info", &json!({})) else { return }; - let mut p = serde_json::Map::new(); - for ((key, _, _), draft) in PROPERTIES.iter().zip(&bs.props) { - if v["properties"].get(*key).and_then(Value::as_str).unwrap_or("") != *draft { - p.insert((*key).to_string(), json!(draft)); - } - } - if !p.is_empty() { - let _ = app.run("file.properties", Value::Object(p)); - } -} - -/// `1234567` → `1.2 MB`. -pub fn format_bytes(b: u64) -> String { - match b { - 0..1024 => format!("{b} bytes"), - 1024..1_048_576 => format!("{:.1} KB", b as f64 / 1024.0), - _ => format!("{:.1} MB", b as f64 / 1_048_576.0), - } -} - -/// Save As: the file type, then the system Save dialog (desktop) or a download (web). A -/// presentation saved as .pptx or .deckcraft takes the new file; the other types are copies. -fn save_as(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) { - let t = Tokens::get(ui.ctx()); - let (title, path) = app.session.active().map(|d| (d.title(), d.path.clone())).unwrap_or_default(); - ui.label(RichText::new(&title).font(theme::font(15.0)).color(t.text)); - dim(ui, path.as_deref().unwrap_or("Not saved yet")); - ui.add_space(14.0); - let current = SAVE_TYPES.iter().find(|s| s.0 == bs.file_type).map_or(SAVE_TYPES[0].1, |s| s.1); - ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("backstage_save_type") - .width(340.0) - .selected_text(current) - .show_ui(ui, |ui| { - for (id, label, _) in SAVE_TYPES { - if ui.selectable_label(bs.file_type == id, label).clicked() { - bs.file_type = id; - } - } - }) - .response - .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Save as type")); - let web = app.services.pick_save.is_none(); - let label = if web { "Download" } else { "Save" }; - let save = ui.add(egui::Button::new(RichText::new(label).color(t.accent_text)).fill(t.accent).min_size(vec2(84.0, 26.0))); - if save.on_hover_text(if web { "Download the file" } else { "Choose where to save" }).clicked() { - let fmt = bs.file_type; - let done = if matches!(fmt, "pptx" | "deckcraft") { - let before = app.session.active().map(|d| (d.path.clone(), d.is_dirty())); - app.save_as_dialog(fmt); - web || app.session.active().map(|d| (d.path.clone(), d.is_dirty())) != before - } else { - app.export_as(fmt, json!({})) - }; - if done { - out.close = true; - } - } - }); - ui.add_space(8.0); - if let Some((_, _, detail)) = SAVE_TYPES.iter().find(|s| s.0 == bs.file_type) { - dim(ui, detail); - } -} - -/// The slides the Print page prints, in order (0-based). -pub fn print_slides(doc: &deckcraft_model::Presentation, bs: &Backstage, current: usize) -> Result, String> { - let n = doc.slides.len(); - let mut v: Vec = match bs.print_scope { - PrintScope::All => (0..n).collect(), - PrintScope::Current => vec![current.min(n.saturating_sub(1))], - PrintScope::Custom => deckcraft_engine::cmd::file::parse_slide_range(&bs.print_range, n).map_err(|e| e.to_string())?, - }; - if !bs.print_hidden && bs.print_scope != PrintScope::Current { - v.retain(|i| doc.slides.get(*i).is_some_and(|s| !s.hidden)); - } - Ok(v) -} - -/// Handout slides per page for a `print_layout` id (`h3` → 3). -fn per_page(layout: &str) -> usize { - layout.strip_prefix('h').and_then(|n| n.parse::().ok()).filter(|n| matches!(n, 1 | 2 | 3 | 4 | 6 | 9)).unwrap_or(1) -} - -/// The `file.export` / `app.print` params for the Print page's settings. -pub fn print_params(bs: &Backstage, slides: &[usize]) -> Value { - let (layout, per) = match bs.print_layout { - "notes" => ("notes", 0), - l if l.starts_with('h') => ("handouts", per_page(l)), - _ => ("slides", 0), - }; - // The list is already filtered for hidden slides, so the PDF takes it as it is. - json!({ - "layout": layout, - "perPage": per, - "slides": slides, - "includeHidden": true, - "frame": bs.print_frame, - "paper": bs.print_paper, - "orientation": if bs.print_landscape { "landscape" } else { "portrait" }, - }) -} - -/// The printed page size in points: the slide size for full-page slides, else the chosen paper. -pub fn print_page_size(bs: &Backstage, doc: &deckcraft_model::Presentation) -> (f32, f32) { - if bs.print_layout == "slides" { - return (doc.slide_size.width as f32, doc.slide_size.height as f32); - } - let (w, h) = deckcraft_engine::cmd::file::paper_size(bs.print_paper, bs.print_landscape).unwrap_or((612.0, 792.0)); - (w as f32, h as f32) -} - -const LAYOUTS: [(&str, &str); 8] = [ - ("slides", "Full Page Slides"), - ("notes", "Notes Pages"), - ("h1", "Handouts (1 slide per page)"), - ("h2", "Handouts (2 slides per page)"), - ("h3", "Handouts (3 slides per page)"), - ("h4", "Handouts (4 slides per page)"), - ("h6", "Handouts (6 slides per page)"), - ("h9", "Handouts (9 slides per page)"), -]; - -/// Print: the Print button and settings on the left, a preview of each page on the right. -fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { - let t = Tokens::get(ui.ctx()); - let Some((doc, current)) = app.session.active().map(|d| (d.doc.clone(), d.selection.slide)) else { - dim(ui, "No presentation is open."); - return; - }; - let slides = print_slides(&doc, bs, current); - ui.horizontal_top(|ui| { - ui.vertical(|ui| { - ui.set_width(250.0); - let ok = slides.as_ref().is_ok_and(|s| !s.is_empty()); - if square_button(ui, Icon::Print, "Print", ok).clicked() - && let Ok(s) = &slides - { - app.print_pdf(print_params(bs, s)); - } - ui.add_space(6.0); - dim( - ui, - if app.services.pick_save.is_some() { - "DeckCraft prints through a PDF: it opens in your PDF viewer, where you choose the printer." - } else { - "DeckCraft prints through a PDF: it downloads, ready to print from your browser or PDF viewer." - }, - ); - ui.add_space(14.0); - heading(ui, "Settings"); - let scope = match bs.print_scope { - PrintScope::All => "Print All Slides", - PrintScope::Current => "Print Current Slide", - PrintScope::Custom => "Custom Range", - }; - let combo = egui::ComboBox::from_id_salt("backstage_print_scope").width(240.0).selected_text(scope).show_ui(ui, |ui| { - for (s, label) in - [(PrintScope::All, "Print All Slides"), (PrintScope::Current, "Print Current Slide"), (PrintScope::Custom, "Custom Range")] - { - if ui.selectable_label(bs.print_scope == s, label).clicked() { - bs.print_scope = s; - bs.preview_page = 0; - } - } - }); - combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Print range")); - ui.add_space(4.0); - ui.horizontal(|ui| { - let l = ui.label("Slides:"); - let r = ui.add(egui::TextEdit::singleline(&mut bs.print_range).hint_text("e.g. 1-3, 5").desired_width(176.0)).labelled_by(l.id); - if r.changed() { - // Typing a range picks Custom Range, as in PowerPoint. - bs.print_scope = if bs.print_range.trim().is_empty() { PrintScope::All } else { PrintScope::Custom }; - bs.preview_page = 0; - } - }); - ui.add_space(4.0); - let layout = LAYOUTS.iter().find(|l| l.0 == bs.print_layout).map_or("Full Page Slides", |l| l.1); - let combo = egui::ComboBox::from_id_salt("backstage_print_layout").width(240.0).selected_text(layout).show_ui(ui, |ui| { - for (id, label) in LAYOUTS { - if ui.selectable_label(bs.print_layout == id, label).clicked() { - bs.print_layout = id; - bs.preview_page = 0; - } - } - }); - combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Print layout")); - ui.add_space(4.0); - // PowerPoint prints full-page slides on slide-shaped pages; paper and orientation are - // for notes pages and handouts. - ui.add_enabled_ui(bs.print_layout != "slides", |ui| { - let paper = deckcraft_engine::cmd::file::PAPERS.iter().find(|p| p.0 == bs.print_paper).map_or("Letter", |p| p.1); - egui::ComboBox::from_id_salt("backstage_print_paper") - .width(240.0) - .selected_text(paper) - .show_ui(ui, |ui| { - for (id, label, _) in deckcraft_engine::cmd::file::PAPERS { - if ui.selectable_label(bs.print_paper == id, label).clicked() { - bs.print_paper = id; - } - } - }) - .response - .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Paper size")); - ui.add_space(4.0); - let orient = if bs.print_landscape { "Landscape Orientation" } else { "Portrait Orientation" }; - egui::ComboBox::from_id_salt("backstage_print_orientation") - .width(240.0) - .selected_text(orient) - .show_ui(ui, |ui| { - for (land, label) in [(false, "Portrait Orientation"), (true, "Landscape Orientation")] { - if ui.selectable_label(bs.print_landscape == land, label).clicked() { - bs.print_landscape = land; - } - } - }) - .response - .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Orientation")); - }); - ui.add_space(4.0); - if ui.checkbox(&mut bs.print_hidden, "Print Hidden Slides").changed() { - bs.preview_page = 0; - } - ui.checkbox(&mut bs.print_frame, "Frame Slides"); - ui.add_space(6.0); - if ui.link("Edit Header & Footer").clicked() { - let _ = app.run("app.dialog", json!({"id": "headerFooter"})); - } - }); - ui.add_space(20.0); - let (r, _) = ui.allocate_exact_size(vec2(1.0, 560.0), Sense::hover()); - ui.painter().line_segment([r.center_top(), r.center_bottom()], Stroke::new(1.0, t.separator)); - ui.add_space(20.0); - ui.vertical(|ui| match &slides { - Err(e) => dim(ui, e), - Ok(s) if s.is_empty() => dim(ui, "Nothing to print: every slide in the range is hidden."), - Ok(s) => preview(app, ui, bs, &doc, s), - }); - }); -} - -/// One printed page in miniature, with a pager under it. -fn preview(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, doc: &deckcraft_model::Presentation, slides: &[usize]) { - let t = Tokens::get(ui.ctx()); - let per = match bs.print_layout { - "slides" | "notes" => 1, - l => per_page(l), - }; - let pages: Vec<&[usize]> = slides.chunks(per.max(1)).collect(); - let last = pages.len().saturating_sub(1); - bs.preview_page = bs.preview_page.min(last); - let Some(on_page) = pages.get(bs.preview_page).copied() else { return }; - // Full-page slides print on slide-shaped pages; notes and handouts on the chosen paper. - let (pw, ph) = print_page_size(bs, doc); - let avail_w = ui.available_width().clamp(200.0, 760.0); - let scale = (avail_w / pw.max(1.0)).min(520.0 / ph.max(1.0)); - let (area, _) = ui.allocate_exact_size(vec2(avail_w, ph * scale + 24.0), Sense::hover()); - ui.painter().rect_filled(area, CornerRadius::same(2), t.canvas); - let page = Rect::from_center_size(area.center(), vec2(pw * scale, ph * scale)); - let p = ui.painter(); - p.rect_filled(page.translate(vec2(2.0, 3.0)), 0.0, t.shadow); - p.rect_filled(page, 0.0, Color32::WHITE); - let aspect = (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32; - let mut budget = 4u32; - let mut draw_slide = |ui: &mut Ui, i: usize, r: Rect| { - let px = (r.width() * ui.ctx().pixels_per_point()).round().clamp(16.0, 1600.0) as u32; - match app.textures.thumb(ui.ctx(), doc, i, px, &mut budget) { - Some(tex) => { - ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - } - None => ui.ctx().request_repaint(), - } - if bs.print_frame || bs.print_layout != "slides" { - ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_gray(120)), StrokeKind::Outside); - } - }; - match bs.print_layout { - "slides" => { - if let Some(i) = on_page.first() { - draw_slide(ui, *i, page); - } - } - "notes" => { - if let Some(i) = on_page.first() { - let w = page.width() * 0.75; - let r = Rect::from_min_size(pos2(page.center().x - w / 2.0, page.top() + page.height() * 0.08), vec2(w, w * aspect)); - draw_slide(ui, *i, r); - let notes = doc.slides.get(*i).map(|s| s.notes_text()).unwrap_or_default(); - let font = theme::font((11.0 * scale).clamp(5.0, 13.0)); - let text_rect = Rect::from_min_max( - pos2(page.left() + page.width() * 0.12, r.bottom() + 20.0 * scale), - page.max - vec2(page.width() * 0.12, 30.0 * scale), - ); - let galley = ui.painter().layout(notes, font, Color32::from_gray(30), text_rect.width()); - ui.painter().with_clip_rect(text_rect).galley(text_rect.min, galley, Color32::from_gray(30)); - } - } - l => { - let n = per_page(l); - let (cols, rows) = match n { - 1 => (1, 1), - 2 => (1, 2), - 3 => (1, 3), - 4 => (2, 2), - 6 => (2, 3), - _ => (3, 3), - }; - let inner = page.shrink2(vec2(page.width() * 0.08, page.height() * 0.07)); - // Three a page leaves lines for notes beside each slide, as PowerPoint's handouts do. - let slide_area = if n == 3 { Rect::from_min_size(inner.min, vec2(inner.width() * 0.5, inner.height())) } else { inner }; - let cell = vec2(slide_area.width() / cols as f32, slide_area.height() / rows as f32); - for (k, i) in on_page.iter().enumerate() { - let (c, r) = ((k % cols) as f32, (k / cols) as f32); - let cell_rect = Rect::from_min_size(slide_area.min + vec2(c * cell.x, r * cell.y), cell).shrink(cell.x.min(cell.y) * 0.08); - let w = cell_rect.width().min(cell_rect.height() / aspect.max(0.01)); - let sr = Rect::from_center_size(cell_rect.center(), vec2(w, w * aspect)); - draw_slide(ui, *i, sr); - if n == 3 { - for line in 1..=5 { - let y = sr.top() + sr.height() * line as f32 / 6.0; - ui.painter() - .line_segment([pos2(inner.center().x + 10.0, y), pos2(inner.right(), y)], Stroke::new(0.8, Color32::from_gray(150))); - } - } - } - } - } - ui.add_space(6.0); - ui.horizontal(|ui| { - let prev = icon_button(ui, Icon::ChevronLeft, "Previous Page", bs.preview_page > 0); - if prev { - bs.preview_page = bs.preview_page.saturating_sub(1); - } - ui.label(format!("{} of {}", bs.preview_page + 1, pages.len())); - if icon_button(ui, Icon::ChevronRight, "Next Page", bs.preview_page < last) { - bs.preview_page = (bs.preview_page + 1).min(last); - } - }); -} - /// Draw a labelled icon button and return whether it was clicked while enabled. fn icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> bool { let t = Tokens::get(ui.ctx()); @@ -1262,97 +494,6 @@ fn icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> bool { resp.on_hover_text(label).clicked() } -/// Export: tasks on the left (Create PDF Document, Create Handouts, Change File Type), the chosen -/// one's description and button on the right. PowerPoint's video, animated GIF and CD package -/// exports aren't offered. -fn export(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { - let t = Tokens::get(ui.ctx()); - ui.horizontal_top(|ui| { - ui.vertical(|ui| { - ui.set_width(250.0); - for (task, icon, label) in [ - (ExportTask::Pdf, Icon::File, "Create PDF Document"), - (ExportTask::Handouts, Icon::Notes, "Create Handouts"), - (ExportTask::FileType, Icon::Export, "Change File Type"), - ] { - if place_row(ui, icon, label, bs.export == task, 244.0).clicked() { - bs.export = task; - } - } - }); - let (r, _) = ui.allocate_exact_size(vec2(1.0, 420.0), Sense::hover()); - ui.painter().line_segment([r.center_top(), r.center_bottom()], Stroke::new(1.0, t.separator)); - ui.add_space(24.0); - ui.vertical(|ui| { - ui.set_max_width(560.0); - match bs.export { - ExportTask::Pdf => { - ui.label(RichText::new("Create a PDF Document").font(theme::font(16.0)).color(t.text)); - bullets( - ui, - &["Keeps the layout, fonts and pictures", "Opens on any device with a PDF viewer", "The presentation keeps its own file"], - ); - ui.checkbox(&mut bs.pdf_hidden, "Include hidden slides"); - ui.add_space(10.0); - if square_button(ui, Icon::File, "Create PDF", true).clicked() { - app.export_as("pdf", json!({"includeHidden": bs.pdf_hidden})); - } - } - ExportTask::Handouts => { - ui.label(RichText::new("Create Handouts").font(theme::font(16.0)).color(t.text)); - bullets(ui, &["Several slides on each page of a PDF", "Three a page leaves lines for notes", "Print them for your audience"]); - let combo = egui::ComboBox::from_id_salt("backstage_handouts") - .width(240.0) - .selected_text(format!("{} slides per page", bs.handouts_per_page)) - .show_ui(ui, |ui| { - for n in [1u8, 2, 3, 4, 6, 9] { - if ui.selectable_label(bs.handouts_per_page == n, format!("{n} slides per page")).clicked() { - bs.handouts_per_page = n; - } - } - }); - combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Slides per page")); - ui.add_space(10.0); - if square_button(ui, Icon::Notes, "Create Handouts", true).clicked() { - app.export_as("pdf", json!({"layout": "handouts", "perPage": bs.handouts_per_page})); - } - } - ExportTask::FileType => { - ui.label(RichText::new("Change File Type").font(theme::font(16.0)).color(t.text)); - ui.add_space(8.0); - for (id, label, detail) in SAVE_TYPES.iter().filter(|s| s.0 != "pdf") { - let selected = bs.file_type == *id; - let (rect, resp) = ui.allocate_exact_size(vec2(520.0, 46.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, *label)); - let p = ui.painter(); - if selected { - p.rect_filled(rect, CornerRadius::same(2), t.selected); - } else if resp.hovered() { - p.rect_filled(rect, CornerRadius::same(2), t.hover); - } - p.text(pos2(rect.left() + 12.0, rect.center().y - 8.0), Align2::LEFT_CENTER, *label, theme::font(13.0), t.text); - p.text(pos2(rect.left() + 12.0, rect.center().y + 9.0), Align2::LEFT_CENTER, *detail, theme::font(11.5), t.text_dim); - if resp.clicked() { - bs.file_type = id; - } - } - ui.add_space(12.0); - let png = bs.file_type == "png"; - if png { - ui.checkbox(&mut bs.png_transparent, "Transparent background") - .on_hover_text("Leave the slide background out: only what is on the slide is drawn"); - ui.add_space(8.0); - } - if square_button(ui, Icon::SaveAs, "Save As", true).clicked() { - let extra = if png && bs.png_transparent { json!({"transparentBackground": true}) } else { json!({}) }; - app.export_as(bs.file_type, extra); - } - } - } - }); - }); -} - fn bullets(ui: &mut Ui, items: &[&str]) { ui.add_space(4.0); for b in items { @@ -1364,57 +505,5 @@ fn bullets(ui: &mut Ui, items: &[&str]) { ui.add_space(10.0); } -/// Account: the interface's appearance and what DeckCraft is. DeckCraft has no accounts, so -/// there is nothing to sign in to. -fn account(app: &mut SlideApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - ui.horizontal_top(|ui| { - ui.vertical(|ui| { - ui.set_width(340.0); - heading(ui, "Appearance"); - let dark = app.ui.brightness == theme::Brightness::Dark; - egui::ComboBox::from_id_salt("backstage_appearance") - .width(260.0) - .selected_text(if dark { "Dark" } else { "Light" }) - .show_ui(ui, |ui| { - for (on, label) in [(false, "Light"), (true, "Dark")] { - if ui.selectable_label(dark == on, label).clicked() { - let _ = app.run("view.dark", json!({"on": on})); - } - } - }) - .response - .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Appearance")); - ui.add_space(16.0); - heading(ui, "User Information"); - dim(ui, "DeckCraft has no accounts: there is nothing to sign in to, and your presentations stay on your device."); - }); - ui.add_space(40.0); - ui.vertical(|ui| { - heading(ui, "Product Information"); - ui.horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::hover()); - crate::dialogs::paint_logo(ui.painter(), r); - ui.label(RichText::new("DeckCraft").font(theme::bold(24.0)).color(t.text)); - }); - dim(ui, &crate::dialogs::version_line()); - dim(ui, "Open source under MIT OR Apache-2.0. Not affiliated with Microsoft."); - ui.add_space(14.0); - if task( - ui, - Icon::Help, - "About DeckCraft", - "About DeckCraft", - "The version, the people and models who built it, and where to find us.", - true, - ) { - let _ = app.run("app.about", json!({})); - } - ui.hyperlink_to("Join our community on Discord", deckcraft_engine::links::DISCORD); - ui.hyperlink_to("Source on GitHub", deckcraft_engine::links::GITHUB); - }); - }); -} - #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/backstage/files.rs b/crates/ui-egui/src/backstage/files.rs new file mode 100644 index 00000000..58bdf94b --- /dev/null +++ b/crates/ui-egui/src/backstage/files.rs @@ -0,0 +1,316 @@ +use super::*; + +/// A new-presentation tile: a theme preview drawn in code (or a blank slide) over its name. +fn tile(ui: &mut Ui, label: &str, theme: Option<&deckcraft_model::Theme>, big: bool) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let thumb_w = if big { 200.0 } else { 160.0 }; + let (rect, resp) = ui.allocate_exact_size(vec2(thumb_w + 16.0, thumb_w * 9.0 / 16.0 + 44.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); + let p = ui.painter(); + if resp.hovered() { + p.rect_filled(rect, CornerRadius::same(4), t.hover); + } + let thumb = Rect::from_min_size(rect.min + vec2(8.0, 10.0), vec2(thumb_w, thumb_w * 9.0 / 16.0)); + match theme { + Some(th) => crate::ribbon::paint_theme_tile(p, thumb, th), + None => { + p.rect_filled(thumb, CornerRadius::same(2), Color32::WHITE); + p.rect_stroke(thumb, CornerRadius::same(2), Stroke::new(1.0, Color32::from_gray(200)), StrokeKind::Inside); + } + } + if resp.hovered() { + p.rect_stroke(thumb, CornerRadius::same(2), Stroke::new(2.0, t.accent), StrokeKind::Outside); + } + let galley = p.layout(label.to_string(), theme::font(12.0), t.text, rect.width() - 8.0); + p.galley(pos2(rect.center().x - galley.size().x / 2.0, thumb.bottom() + 8.0), galley, t.text); + resp +} + +/// The built-in themes and the sample deck, filtered by `query` (after a blank presentation when +/// `blank`); at most `limit` theme tiles. False when nothing matched. +fn new_tiles(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize, blank: bool) -> bool { + let q = query.trim().to_lowercase(); + let themes: Vec = + deckcraft_model::theme::builtin_themes().into_iter().filter(|th| q.is_empty() || th.name.to_lowercase().contains(&q)).collect(); + let sample = q.is_empty() || "sample deck northern lights".contains(&q); + if themes.is_empty() && !sample { + return false; + } + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(10.0, 10.0); + if blank && tile(ui, "Blank Presentation", None, false).clicked() { + let _ = app.run("file.new", json!({})); + } + let mut shown = 0; + for th in themes { + if shown >= limit { + break; + } + if tile(ui, &th.name, Some(&th), false).clicked() { + let _ = app.run("file.new", json!({"theme": th.name})); + } + shown += 1; + } + if sample && shown < limit { + let th = deckcraft_model::theme::builtin_themes().into_iter().find(|t| t.name == "DeckCraft"); + if tile(ui, "Sample deck: Northern Lights", th.as_ref(), false).clicked() + && let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) + { + app.set_status(e.to_string()); + } + } + }); + true +} + +/// Starter decks whose name, summary, theme or a slide title matches `query`, at most `limit` tiles. +fn template_tiles(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) -> bool { + let list = deckcraft_engine::templates::matching(query); + if list.is_empty() { + return false; + } + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(10.0, 10.0); + for starter in list.into_iter().take(limit) { + let theme = deckcraft_model::theme::builtin_themes().into_iter().find(|th| th.name == starter.theme); + if tile(ui, starter.name, theme.as_ref(), false).on_hover_text(starter.summary).clicked() { + let _ = app.run("file.new", json!({"template": starter.name})); + } + } + }); + true +} + +/// File name and folder of a recent path, split as text (nothing on disk is read). +pub fn split_path(path: &str) -> (String, String) { + let p = std::path::Path::new(path); + let name = p.file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(path).to_string(); + let folder = p.parent().and_then(std::path::Path::to_str).unwrap_or("").to_string(); + (name, folder) +} + +/// Recent presentations whose name matches `query`, newest first, at most `limit`. Opening one is +/// the user's explicit choice. +fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) { + let t = Tokens::get(ui.ctx()); + if app.ui.recent.is_empty() { + dim(ui, "Presentations you open show up here. Use Open to browse for one."); + return; + } + let q = query.trim().to_lowercase(); + let list: Vec = + app.ui.recent.iter().filter(|p| q.is_empty() || split_path(p).0.to_lowercase().contains(&q)).take(limit).cloned().collect(); + if list.is_empty() { + dim(ui, &format!("No recent presentations match “{}”.", query.trim())); + return; + } + let width = ui.available_width().min(CONTENT_W); + for path in list { + let missing = !app.recent_exists(&path); + let (name, folder) = split_path(&path); + let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &name)); + let p = ui.painter(); + if resp.hovered() { + p.rect_filled(rect, CornerRadius::same(3), t.hover); + } + let ink = if missing { t.text_dim } else { t.text }; + // The row is as wide as the page's content, which a narrow window clips: keep the mark + // and the remove button inside the visible part of the row. + let visible_right = rect.right().min(ui.clip_rect().right() - 6.0); + { + let p = ui.painter(); + icons::paint(p, Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), vec2(22.0, 22.0)), Icon::Slide, ink, false); + p.text(pos2(rect.left() + 44.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::font(13.0), ink); + p.text(pos2(rect.left() + 44.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::font(11.0), t.text_dim); + // A missing file says so before it is clicked (#172). + if missing { + p.text(pos2(visible_right - 82.0, rect.center().y), Align2::RIGHT_CENTER, "(missing)", theme::font(11.0), t.text_dim); + } + } + let resp = if missing { resp.on_hover_text(format!("{path} — this file can't be found")) } else { resp.on_hover_text(&path) }; + // Remove from the list: a real labelled widget on top of the row, with a code-drawn icon + // (a text glyph would need a font that has it). + let x = ui.allocate_rect( + Rect::from_min_max(pos2(visible_right - 32.0, rect.top() + 5.0), pos2(visible_right, rect.bottom() - 5.0)), + Sense::click(), + ); + x.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Remove from Recent")); + if x.hovered() { + ui.painter().rect_filled(x.rect, CornerRadius::same(3), t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(x.rect.center(), vec2(14.0, 14.0)), Icon::Close, t.text_dim, false); + if x.on_hover_text("Remove from Recent").clicked() { + app.forget_recent(&path); + } + if resp.clicked() { + app.open_path_in_background(&path); + } + } +} + +/// A large square command button (Print, Create PDF, Check for Issues…). +pub(super) fn square_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(88.0, 80.0), if enabled { Sense::click() } else { Sense::hover() }); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label)); + let p = ui.painter(); + p.rect_filled(rect, CornerRadius::same(3), if resp.hovered() && enabled { t.hover } else { t.card }); + p.rect_stroke(rect, CornerRadius::same(3), Stroke::new(1.0, if resp.hovered() && enabled { t.accent } else { t.border }), StrokeKind::Inside); + icons::paint(p, Rect::from_center_size(pos2(rect.center().x, rect.top() + 26.0), vec2(26.0, 26.0)), icon, t.text, !enabled); + let ink = if enabled { t.text } else { t.text_faint }; + let galley = p.layout(label.to_string(), theme::font(12.0), ink, rect.width() - 8.0); + p.galley(pos2(rect.center().x - galley.size().x / 2.0, rect.top() + 46.0), galley, ink); + resp +} + +/// A row of a page's own list on the left (Open's places, Export's tasks). +pub(super) fn place_row(ui: &mut Ui, icon: Icon, label: &str, selected: bool, width: f32) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, label)); + let p = ui.painter(); + if selected { + p.rect_filled(rect, CornerRadius::same(2), t.selected); + p.rect_stroke(rect, CornerRadius::same(2), Stroke::new(1.0, t.border), StrokeKind::Inside); + } else if resp.hovered() { + p.rect_filled(rect, CornerRadius::same(2), t.hover); + } + icons::paint(p, Rect::from_center_size(pos2(rect.left() + 22.0, rect.center().y), vec2(24.0, 24.0)), icon, t.text, false); + p.text(pos2(rect.left() + 46.0, rect.center().y), Align2::LEFT_CENTER, label, theme::font(13.5), t.text); + resp +} + +/// A square button with its title and description beside it (Info, Export). +pub(super) fn task(ui: &mut Ui, icon: Icon, button: &str, title: &str, detail: &str, enabled: bool) -> bool { + let t = Tokens::get(ui.ctx()); + let mut clicked = false; + ui.horizontal_top(|ui| { + clicked = square_button(ui, icon, button, enabled).clicked(); + ui.add_space(12.0); + ui.vertical(|ui| { + ui.add_space(4.0); + ui.label(RichText::new(title).font(theme::font(15.0)).color(t.text)); + dim(ui, detail); + }); + }); + ui.add_space(14.0); + clicked +} + +// ---------------------------------------------------------------- pages + +/// Home: DeckCraft's mark, new presentations (the first themes, with a link to New) and recent +/// presentations (with a link to Open). The same page is the start screen. +pub(super) fn home(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::hover()); + crate::dialogs::paint_logo(ui.painter(), r); + ui.label(RichText::new("DeckCraft").font(theme::bold(24.0)).color(t.text)); + }); + ui.add_space(18.0); + ui.horizontal(|ui| { + ui.label(RichText::new("New").font(theme::bold(16.0)).color(t.text)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if arrow_link(ui, "More themes") { + out.page = Some(Page::New); + } + }); + }); + ui.add_space(6.0); + new_tiles(app, ui, "", 5, true); + ui.add_space(22.0); + ui.horizontal(|ui| { + ui.label(RichText::new("Templates").font(theme::bold(16.0)).color(t.text)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if arrow_link(ui, "More templates") { + out.page = Some(Page::New); + } + }); + }); + ui.add_space(6.0); + template_tiles(app, ui, "", 3); + ui.add_space(22.0); + ui.horizontal(|ui| { + ui.label(RichText::new("Recent").font(theme::bold(16.0)).color(t.text)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + search(ui, &mut bs.query, "Search for a file", 280.0); + }); + }); + ui.add_space(6.0); + recent_list(app, ui, &bs.query.clone(), 8); + if app.ui.recent.len() > 8 { + ui.add_space(6.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + if arrow_link(ui, "More presentations") { + out.page = Some(Page::Open); + } + }); + } + ui.add_space(30.0); + ui.hyperlink_to("Join the ArtCraft community on Discord", deckcraft_engine::links::DISCORD); +} + +/// New: a blank presentation, then every theme and starter template, with a search box. +pub(super) fn new_page(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { + let t = Tokens::get(ui.ctx()); + if tile(ui, "Blank Presentation", None, true).clicked() { + let _ = app.run("file.new", json!({})); + } + ui.add_space(8.0); + let w = ui.available_width(); + let (r, _) = ui.allocate_exact_size(vec2(w, 1.0), Sense::hover()); + ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, t.separator)); + ui.add_space(14.0); + search(ui, &mut bs.query, "Search for themes and templates", 380.0); + ui.add_space(14.0); + let q = bs.query.clone(); + let themes = new_tiles(app, ui, &q, usize::MAX, false); + let templates = if q.trim().is_empty() || !deckcraft_engine::templates::matching(&q).is_empty() { + ui.add_space(18.0); + heading(ui, "Templates"); + template_tiles(app, ui, &q, usize::MAX) + } else { + false + }; + if !themes && !templates { + dim(ui, &format!("No themes or templates match “{}”.", q.trim())); + } +} + +/// Open: Recent and Browse on the left; the recent presentations (with a search box) on the right. +pub(super) fn open_page(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { + let t = Tokens::get(ui.ctx()); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(230.0); + place_row(ui, Icon::Recent, "Recent", true, 220.0); + if place_row(ui, Icon::OpenFolder, "Browse", false, 220.0).on_hover_text("Open a presentation from a folder (Ctrl+O)").clicked() { + let _ = app.run("app.openDialog", json!({})); + } + if app.session.recovery_dir.is_some() { + ui.add_space(24.0); + if ui.button("Recover Unsaved Presentations").clicked() { + match app.run("file.recovery.open", json!({})) { + Ok(v) if v.get("opened").and_then(Value::as_array).is_some_and(|a| a.is_empty()) => { + app.set_status("There are no unsaved presentations to recover"); + } + _ => {} + } + } + } + }); + let h = ui.available_height().max(320.0); + let (r, _) = ui.allocate_exact_size(vec2(1.0, h), Sense::hover()); + ui.painter().line_segment([r.center_top(), r.center_bottom()], Stroke::new(1.0, t.separator)); + ui.add_space(24.0); + ui.vertical(|ui| { + search(ui, &mut bs.query, "Search for a file", 380.0); + ui.add_space(12.0); + heading(ui, "Presentations"); + let q = bs.query.clone(); + recent_list(app, ui, &q, usize::MAX); + }); + }); +} diff --git a/crates/ui-egui/src/backstage/info.rs b/crates/ui-egui/src/backstage/info.rs new file mode 100644 index 00000000..e1e23027 --- /dev/null +++ b/crates/ui-egui/src/backstage/info.rs @@ -0,0 +1,242 @@ +use super::files::task; +use super::*; + +/// Save As and Change File Type: (id for [`SlideApp::export_as`], label, detail). +pub const SAVE_TYPES: [(&str, &str, &str); 7] = [ + ("pptx", "PowerPoint Presentation (*.pptx)", "Opens in PowerPoint and other presentation apps"), + ("deckcraft", "DeckCraft Presentation (*.deckcraft)", "DeckCraft's own format: everything is kept"), + ("pdf", "PDF (*.pdf)", "A fixed copy that keeps its layout on any device"), + ("outline", "Outline (*.txt)", "The slide titles and bullet text as plain text"), + ("rtf", "Outline/RTF (*.rtf)", "The slide titles and bullet text for word processors"), + ("png", "PNG Picture (*.png)", "The current slide as a picture"), + ("jpeg", "JPEG Picture (*.jpg)", "The current slide as a compressed picture"), +]; + +/// Info page properties: (`file.properties` key, label, hint). +pub(super) const PROPERTIES: [(&str, &str, &str); 7] = [ + ("title", "Title", "Add a title"), + ("subject", "Subject", "Specify the subject"), + ("keywords", "Tags", "Add a tag"), + ("category", "Categories", "Add a category"), + ("company", "Company", "Specify the company"), + ("comments", "Comments", "Add comments"), + ("author", "Author", "Add an author"), +]; + +/// Info: the presentation's name and location, Inspect and Manage on the left, its properties +/// (editable, through `file.properties`) on the right. +pub(super) fn info(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { + let t = Tokens::get(ui.ctx()); + let Ok(v) = app.session.execute("file.info", &json!({})) else { + dim(ui, "No presentation is open."); + return; + }; + let path = v.get("path").and_then(Value::as_str).map(str::to_string); + let title = v.get("title").and_then(Value::as_str).unwrap_or("").to_string(); + let dirty = v.get("dirty").and_then(Value::as_bool).unwrap_or(false); + ui.label(RichText::new(&title).font(theme::font(24.0)).color(t.accent)); + match &path { + Some(p) => dim(ui, p), + None => dim(ui, "Not saved yet"), + } + if let Some(p) = &path { + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Copy path").clicked() { + ui.ctx().copy_text(p.clone()); + app.set_status("Copied the path"); + } + #[cfg(not(target_arch = "wasm32"))] + if let Some(dir) = std::path::Path::new(p).parent().and_then(std::path::Path::to_str) + && ui.button("Open file location").clicked() + { + app.open_url(&format!("file:///{}", dir.replace('\\', "/").trim_start_matches('/'))); + } + }); + } + ui.add_space(20.0); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(440.0); + if task( + ui, + Icon::Accessibility, + "Check for Issues", + "Inspect Presentation", + "Before you share it, check the presentation for things people with disabilities might find hard to read.", + true, + ) { + let _ = app.run("app.dialog", json!({"id": "accessibility"})); + } + let (detail, can_revert) = match (dirty, path.is_some()) { + (false, _) => ("There are no unsaved changes.", false), + (true, true) => ("This presentation has unsaved changes. Revert goes back to the saved file.", true), + (true, false) => ("This presentation has never been saved.", false), + }; + if task(ui, Icon::ResetSlide, "Revert", "Manage Presentation", detail, can_revert) { + let _ = app.run("file.revert", json!({})); + } + }); + ui.add_space(30.0); + ui.vertical(|ui| { + heading(ui, "Properties"); + let props = v.get("properties").cloned().unwrap_or_default(); + let size = v.get("bytes").and_then(Value::as_u64).map(format_bytes); + egui::Grid::new("backstage_props").num_columns(2).spacing(vec2(18.0, 7.0)).show(ui, |ui| { + let row = |ui: &mut Ui, k: &str, val: String| { + ui.label(RichText::new(k).color(t.text_dim)); + ui.label(val); + ui.end_row(); + }; + if let Some(s) = size { + row(ui, "Size", s); + } + row(ui, "Slides", v.get("slides").and_then(Value::as_u64).unwrap_or(0).to_string()); + row(ui, "Hidden slides", v.get("hiddenSlides").and_then(Value::as_u64).unwrap_or(0).to_string()); + for (i, (key, label, hint)) in PROPERTIES.iter().enumerate() { + let saved = props.get(*key).and_then(Value::as_str).unwrap_or("").to_string(); + let Some(draft) = bs.props.get_mut(i) else { continue }; + let l = ui.label(RichText::new(*label).color(t.text_dim)); + let id = ui.make_persistent_id(("backstage_prop", *key)); + let resp = ui.add(egui::TextEdit::singleline(draft).id(id).hint_text(*hint).desired_width(300.0)).labelled_by(l.id); + if resp.lost_focus() && *draft != saved { + // Commit first: a click on another field takes the focus before this one is drawn. + let mut p = serde_json::Map::new(); + p.insert((*key).to_string(), json!(draft.clone())); + let _ = app.run("file.properties", Value::Object(p)); + } else if !resp.has_focus() && *draft != saved { + // Show the presentation's value unless the field is being edited (undo...). + draft.clone_from(&saved); + ui.ctx().request_repaint(); + } + ui.end_row(); + } + row(ui, "Location", path.clone().unwrap_or_else(|| "Not saved yet".into())); + }); + }); + }); +} + +/// Commits what the Info page's property fields hold and the presentation does not: a side-menu +/// action such as Save runs before the field that is being left is drawn again (#422). +pub(super) fn commit_props(app: &mut SlideApp, bs: &Backstage) { + if bs.page != Page::Info { + return; + } + let Ok(v) = app.session.execute("file.info", &json!({})) else { return }; + let mut p = serde_json::Map::new(); + for ((key, _, _), draft) in PROPERTIES.iter().zip(&bs.props) { + if v["properties"].get(*key).and_then(Value::as_str).unwrap_or("") != *draft { + p.insert((*key).to_string(), json!(draft)); + } + } + if !p.is_empty() { + let _ = app.run("file.properties", Value::Object(p)); + } +} + +/// `1234567` → `1.2 MB`. +pub fn format_bytes(b: u64) -> String { + match b { + 0..1024 => format!("{b} bytes"), + 1024..1_048_576 => format!("{:.1} KB", b as f64 / 1024.0), + _ => format!("{:.1} MB", b as f64 / 1_048_576.0), + } +} + +/// Save As: the file type, then the system Save dialog (desktop) or a download (web). A +/// presentation saved as .pptx or .deckcraft takes the new file; the other types are copies. +pub(super) fn save_as(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) { + let t = Tokens::get(ui.ctx()); + let (title, path) = app.session.active().map(|d| (d.title(), d.path.clone())).unwrap_or_default(); + ui.label(RichText::new(&title).font(theme::font(15.0)).color(t.text)); + dim(ui, path.as_deref().unwrap_or("Not saved yet")); + ui.add_space(14.0); + let current = SAVE_TYPES.iter().find(|s| s.0 == bs.file_type).map_or(SAVE_TYPES[0].1, |s| s.1); + ui.horizontal(|ui| { + egui::ComboBox::from_id_salt("backstage_save_type") + .width(340.0) + .selected_text(current) + .show_ui(ui, |ui| { + for (id, label, _) in SAVE_TYPES { + if ui.selectable_label(bs.file_type == id, label).clicked() { + bs.file_type = id; + } + } + }) + .response + .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Save as type")); + let web = app.services.pick_save.is_none(); + let label = if web { "Download" } else { "Save" }; + let save = ui.add(egui::Button::new(RichText::new(label).color(t.accent_text)).fill(t.accent).min_size(vec2(84.0, 26.0))); + if save.on_hover_text(if web { "Download the file" } else { "Choose where to save" }).clicked() { + let fmt = bs.file_type; + let done = if matches!(fmt, "pptx" | "deckcraft") { + let before = app.session.active().map(|d| (d.path.clone(), d.is_dirty())); + app.save_as_dialog(fmt); + web || app.session.active().map(|d| (d.path.clone(), d.is_dirty())) != before + } else { + app.export_as(fmt, json!({})) + }; + if done { + out.close = true; + } + } + }); + ui.add_space(8.0); + if let Some((_, _, detail)) = SAVE_TYPES.iter().find(|s| s.0 == bs.file_type) { + dim(ui, detail); + } +} + +/// Account: the interface's appearance and what DeckCraft is. DeckCraft has no accounts, so +/// there is nothing to sign in to. +pub(super) fn account(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(340.0); + heading(ui, "Appearance"); + let dark = app.ui.brightness == theme::Brightness::Dark; + egui::ComboBox::from_id_salt("backstage_appearance") + .width(260.0) + .selected_text(if dark { "Dark" } else { "Light" }) + .show_ui(ui, |ui| { + for (on, label) in [(false, "Light"), (true, "Dark")] { + if ui.selectable_label(dark == on, label).clicked() { + let _ = app.run("view.dark", json!({"on": on})); + } + } + }) + .response + .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Appearance")); + ui.add_space(16.0); + heading(ui, "User Information"); + dim(ui, "DeckCraft has no accounts: there is nothing to sign in to, and your presentations stay on your device."); + }); + ui.add_space(40.0); + ui.vertical(|ui| { + heading(ui, "Product Information"); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::hover()); + crate::dialogs::paint_logo(ui.painter(), r); + ui.label(RichText::new("DeckCraft").font(theme::bold(24.0)).color(t.text)); + }); + dim(ui, &crate::dialogs::version_line()); + dim(ui, "Open source under MIT OR Apache-2.0. Not affiliated with Microsoft."); + ui.add_space(14.0); + if task( + ui, + Icon::Help, + "About DeckCraft", + "About DeckCraft", + "The version, the people and models who built it, and where to find us.", + true, + ) { + let _ = app.run("app.about", json!({})); + } + ui.hyperlink_to("Join our community on Discord", deckcraft_engine::links::DISCORD); + ui.hyperlink_to("Source on GitHub", deckcraft_engine::links::GITHUB); + }); + }); +} diff --git a/crates/ui-egui/src/backstage/print.rs b/crates/ui-egui/src/backstage/print.rs new file mode 100644 index 00000000..9ebf15c5 --- /dev/null +++ b/crates/ui-egui/src/backstage/print.rs @@ -0,0 +1,370 @@ +use super::files::{place_row, square_button}; +use super::info::SAVE_TYPES; +use super::*; + +/// The slides the Print page prints, in order (0-based). +pub fn print_slides(doc: &deckcraft_model::Presentation, bs: &Backstage, current: usize) -> Result, String> { + let n = doc.slides.len(); + let mut v: Vec = match bs.print_scope { + PrintScope::All => (0..n).collect(), + PrintScope::Current => vec![current.min(n.saturating_sub(1))], + PrintScope::Custom => deckcraft_engine::cmd::file::parse_slide_range(&bs.print_range, n).map_err(|e| e.to_string())?, + }; + if !bs.print_hidden && bs.print_scope != PrintScope::Current { + v.retain(|i| doc.slides.get(*i).is_some_and(|s| !s.hidden)); + } + Ok(v) +} + +/// Handout slides per page for a `print_layout` id (`h3` → 3). +fn per_page(layout: &str) -> usize { + layout.strip_prefix('h').and_then(|n| n.parse::().ok()).filter(|n| matches!(n, 1 | 2 | 3 | 4 | 6 | 9)).unwrap_or(1) +} + +/// The `file.export` / `app.print` params for the Print page's settings. +pub fn print_params(bs: &Backstage, slides: &[usize]) -> Value { + let (layout, per) = match bs.print_layout { + "notes" => ("notes", 0), + l if l.starts_with('h') => ("handouts", per_page(l)), + _ => ("slides", 0), + }; + // The list is already filtered for hidden slides, so the PDF takes it as it is. + json!({ + "layout": layout, + "perPage": per, + "slides": slides, + "includeHidden": true, + "frame": bs.print_frame, + "paper": bs.print_paper, + "orientation": if bs.print_landscape { "landscape" } else { "portrait" }, + }) +} + +/// The printed page size in points: the slide size for full-page slides, else the chosen paper. +pub fn print_page_size(bs: &Backstage, doc: &deckcraft_model::Presentation) -> (f32, f32) { + if bs.print_layout == "slides" { + return (doc.slide_size.width as f32, doc.slide_size.height as f32); + } + let (w, h) = deckcraft_engine::cmd::file::paper_size(bs.print_paper, bs.print_landscape).unwrap_or((612.0, 792.0)); + (w as f32, h as f32) +} + +const LAYOUTS: [(&str, &str); 8] = [ + ("slides", "Full Page Slides"), + ("notes", "Notes Pages"), + ("h1", "Handouts (1 slide per page)"), + ("h2", "Handouts (2 slides per page)"), + ("h3", "Handouts (3 slides per page)"), + ("h4", "Handouts (4 slides per page)"), + ("h6", "Handouts (6 slides per page)"), + ("h9", "Handouts (9 slides per page)"), +]; + +/// Print: the Print button and settings on the left, a preview of each page on the right. +pub(super) fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { + let t = Tokens::get(ui.ctx()); + let Some((doc, current)) = app.session.active().map(|d| (d.doc.clone(), d.selection.slide)) else { + dim(ui, "No presentation is open."); + return; + }; + let slides = print_slides(&doc, bs, current); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(250.0); + let ok = slides.as_ref().is_ok_and(|s| !s.is_empty()); + if square_button(ui, Icon::Print, "Print", ok).clicked() + && let Ok(s) = &slides + { + app.print_pdf(print_params(bs, s)); + } + ui.add_space(6.0); + dim( + ui, + if app.services.pick_save.is_some() { + "DeckCraft prints through a PDF: it opens in your PDF viewer, where you choose the printer." + } else { + "DeckCraft prints through a PDF: it downloads, ready to print from your browser or PDF viewer." + }, + ); + ui.add_space(14.0); + heading(ui, "Settings"); + let scope = match bs.print_scope { + PrintScope::All => "Print All Slides", + PrintScope::Current => "Print Current Slide", + PrintScope::Custom => "Custom Range", + }; + let combo = egui::ComboBox::from_id_salt("backstage_print_scope").width(240.0).selected_text(scope).show_ui(ui, |ui| { + for (s, label) in + [(PrintScope::All, "Print All Slides"), (PrintScope::Current, "Print Current Slide"), (PrintScope::Custom, "Custom Range")] + { + if ui.selectable_label(bs.print_scope == s, label).clicked() { + bs.print_scope = s; + bs.preview_page = 0; + } + } + }); + combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Print range")); + ui.add_space(4.0); + ui.horizontal(|ui| { + let l = ui.label("Slides:"); + let r = ui.add(egui::TextEdit::singleline(&mut bs.print_range).hint_text("e.g. 1-3, 5").desired_width(176.0)).labelled_by(l.id); + if r.changed() { + // Typing a range picks Custom Range, as in PowerPoint. + bs.print_scope = if bs.print_range.trim().is_empty() { PrintScope::All } else { PrintScope::Custom }; + bs.preview_page = 0; + } + }); + ui.add_space(4.0); + let layout = LAYOUTS.iter().find(|l| l.0 == bs.print_layout).map_or("Full Page Slides", |l| l.1); + let combo = egui::ComboBox::from_id_salt("backstage_print_layout").width(240.0).selected_text(layout).show_ui(ui, |ui| { + for (id, label) in LAYOUTS { + if ui.selectable_label(bs.print_layout == id, label).clicked() { + bs.print_layout = id; + bs.preview_page = 0; + } + } + }); + combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Print layout")); + ui.add_space(4.0); + // PowerPoint prints full-page slides on slide-shaped pages; paper and orientation are + // for notes pages and handouts. + ui.add_enabled_ui(bs.print_layout != "slides", |ui| { + let paper = deckcraft_engine::cmd::file::PAPERS.iter().find(|p| p.0 == bs.print_paper).map_or("Letter", |p| p.1); + egui::ComboBox::from_id_salt("backstage_print_paper") + .width(240.0) + .selected_text(paper) + .show_ui(ui, |ui| { + for (id, label, _) in deckcraft_engine::cmd::file::PAPERS { + if ui.selectable_label(bs.print_paper == id, label).clicked() { + bs.print_paper = id; + } + } + }) + .response + .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Paper size")); + ui.add_space(4.0); + let orient = if bs.print_landscape { "Landscape Orientation" } else { "Portrait Orientation" }; + egui::ComboBox::from_id_salt("backstage_print_orientation") + .width(240.0) + .selected_text(orient) + .show_ui(ui, |ui| { + for (land, label) in [(false, "Portrait Orientation"), (true, "Landscape Orientation")] { + if ui.selectable_label(bs.print_landscape == land, label).clicked() { + bs.print_landscape = land; + } + } + }) + .response + .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Orientation")); + }); + ui.add_space(4.0); + if ui.checkbox(&mut bs.print_hidden, "Print Hidden Slides").changed() { + bs.preview_page = 0; + } + ui.checkbox(&mut bs.print_frame, "Frame Slides"); + ui.add_space(6.0); + if ui.link("Edit Header & Footer").clicked() { + let _ = app.run("app.dialog", json!({"id": "headerFooter"})); + } + }); + ui.add_space(20.0); + let (r, _) = ui.allocate_exact_size(vec2(1.0, 560.0), Sense::hover()); + ui.painter().line_segment([r.center_top(), r.center_bottom()], Stroke::new(1.0, t.separator)); + ui.add_space(20.0); + ui.vertical(|ui| match &slides { + Err(e) => dim(ui, e), + Ok(s) if s.is_empty() => dim(ui, "Nothing to print: every slide in the range is hidden."), + Ok(s) => preview(app, ui, bs, &doc, s), + }); + }); +} + +/// One printed page in miniature, with a pager under it. +fn preview(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, doc: &deckcraft_model::Presentation, slides: &[usize]) { + let t = Tokens::get(ui.ctx()); + let per = match bs.print_layout { + "slides" | "notes" => 1, + l => per_page(l), + }; + let pages: Vec<&[usize]> = slides.chunks(per.max(1)).collect(); + let last = pages.len().saturating_sub(1); + bs.preview_page = bs.preview_page.min(last); + let Some(on_page) = pages.get(bs.preview_page).copied() else { return }; + // Full-page slides print on slide-shaped pages; notes and handouts on the chosen paper. + let (pw, ph) = print_page_size(bs, doc); + let avail_w = ui.available_width().clamp(200.0, 760.0); + let scale = (avail_w / pw.max(1.0)).min(520.0 / ph.max(1.0)); + let (area, _) = ui.allocate_exact_size(vec2(avail_w, ph * scale + 24.0), Sense::hover()); + ui.painter().rect_filled(area, CornerRadius::same(2), t.canvas); + let page = Rect::from_center_size(area.center(), vec2(pw * scale, ph * scale)); + let p = ui.painter(); + p.rect_filled(page.translate(vec2(2.0, 3.0)), 0.0, t.shadow); + p.rect_filled(page, 0.0, Color32::WHITE); + let aspect = (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32; + let mut budget = 4u32; + let mut draw_slide = |ui: &mut Ui, i: usize, r: Rect| { + let px = (r.width() * ui.ctx().pixels_per_point()).round().clamp(16.0, 1600.0) as u32; + match app.textures.thumb(ui.ctx(), doc, i, px, &mut budget) { + Some(tex) => { + ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + None => ui.ctx().request_repaint(), + } + if bs.print_frame || bs.print_layout != "slides" { + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_gray(120)), StrokeKind::Outside); + } + }; + match bs.print_layout { + "slides" => { + if let Some(i) = on_page.first() { + draw_slide(ui, *i, page); + } + } + "notes" => { + if let Some(i) = on_page.first() { + let w = page.width() * 0.75; + let r = Rect::from_min_size(pos2(page.center().x - w / 2.0, page.top() + page.height() * 0.08), vec2(w, w * aspect)); + draw_slide(ui, *i, r); + let notes = doc.slides.get(*i).map(|s| s.notes_text()).unwrap_or_default(); + let font = theme::font((11.0 * scale).clamp(5.0, 13.0)); + let text_rect = Rect::from_min_max( + pos2(page.left() + page.width() * 0.12, r.bottom() + 20.0 * scale), + page.max - vec2(page.width() * 0.12, 30.0 * scale), + ); + let galley = ui.painter().layout(notes, font, Color32::from_gray(30), text_rect.width()); + ui.painter().with_clip_rect(text_rect).galley(text_rect.min, galley, Color32::from_gray(30)); + } + } + l => { + let n = per_page(l); + let (cols, rows) = match n { + 1 => (1, 1), + 2 => (1, 2), + 3 => (1, 3), + 4 => (2, 2), + 6 => (2, 3), + _ => (3, 3), + }; + let inner = page.shrink2(vec2(page.width() * 0.08, page.height() * 0.07)); + // Three a page leaves lines for notes beside each slide, as PowerPoint's handouts do. + let slide_area = if n == 3 { Rect::from_min_size(inner.min, vec2(inner.width() * 0.5, inner.height())) } else { inner }; + let cell = vec2(slide_area.width() / cols as f32, slide_area.height() / rows as f32); + for (k, i) in on_page.iter().enumerate() { + let (c, r) = ((k % cols) as f32, (k / cols) as f32); + let cell_rect = Rect::from_min_size(slide_area.min + vec2(c * cell.x, r * cell.y), cell).shrink(cell.x.min(cell.y) * 0.08); + let w = cell_rect.width().min(cell_rect.height() / aspect.max(0.01)); + let sr = Rect::from_center_size(cell_rect.center(), vec2(w, w * aspect)); + draw_slide(ui, *i, sr); + if n == 3 { + for line in 1..=5 { + let y = sr.top() + sr.height() * line as f32 / 6.0; + ui.painter() + .line_segment([pos2(inner.center().x + 10.0, y), pos2(inner.right(), y)], Stroke::new(0.8, Color32::from_gray(150))); + } + } + } + } + } + ui.add_space(6.0); + ui.horizontal(|ui| { + let prev = icon_button(ui, Icon::ChevronLeft, "Previous Page", bs.preview_page > 0); + if prev { + bs.preview_page = bs.preview_page.saturating_sub(1); + } + ui.label(format!("{} of {}", bs.preview_page + 1, pages.len())); + if icon_button(ui, Icon::ChevronRight, "Next Page", bs.preview_page < last) { + bs.preview_page = (bs.preview_page + 1).min(last); + } + }); +} + +/// Export: tasks on the left (Create PDF Document, Create Handouts, Change File Type), the chosen +/// one's description and button on the right. PowerPoint's video, animated GIF and CD package +/// exports aren't offered. +pub(super) fn export(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { + let t = Tokens::get(ui.ctx()); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(250.0); + for (task, icon, label) in [ + (ExportTask::Pdf, Icon::File, "Create PDF Document"), + (ExportTask::Handouts, Icon::Notes, "Create Handouts"), + (ExportTask::FileType, Icon::Export, "Change File Type"), + ] { + if place_row(ui, icon, label, bs.export == task, 244.0).clicked() { + bs.export = task; + } + } + }); + let (r, _) = ui.allocate_exact_size(vec2(1.0, 420.0), Sense::hover()); + ui.painter().line_segment([r.center_top(), r.center_bottom()], Stroke::new(1.0, t.separator)); + ui.add_space(24.0); + ui.vertical(|ui| { + ui.set_max_width(560.0); + match bs.export { + ExportTask::Pdf => { + ui.label(RichText::new("Create a PDF Document").font(theme::font(16.0)).color(t.text)); + bullets( + ui, + &["Keeps the layout, fonts and pictures", "Opens on any device with a PDF viewer", "The presentation keeps its own file"], + ); + ui.checkbox(&mut bs.pdf_hidden, "Include hidden slides"); + ui.add_space(10.0); + if square_button(ui, Icon::File, "Create PDF", true).clicked() { + app.export_as("pdf", json!({"includeHidden": bs.pdf_hidden})); + } + } + ExportTask::Handouts => { + ui.label(RichText::new("Create Handouts").font(theme::font(16.0)).color(t.text)); + bullets(ui, &["Several slides on each page of a PDF", "Three a page leaves lines for notes", "Print them for your audience"]); + let combo = egui::ComboBox::from_id_salt("backstage_handouts") + .width(240.0) + .selected_text(format!("{} slides per page", bs.handouts_per_page)) + .show_ui(ui, |ui| { + for n in [1u8, 2, 3, 4, 6, 9] { + if ui.selectable_label(bs.handouts_per_page == n, format!("{n} slides per page")).clicked() { + bs.handouts_per_page = n; + } + } + }); + combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Slides per page")); + ui.add_space(10.0); + if square_button(ui, Icon::Notes, "Create Handouts", true).clicked() { + app.export_as("pdf", json!({"layout": "handouts", "perPage": bs.handouts_per_page})); + } + } + ExportTask::FileType => { + ui.label(RichText::new("Change File Type").font(theme::font(16.0)).color(t.text)); + ui.add_space(8.0); + for (id, label, detail) in SAVE_TYPES.iter().filter(|s| s.0 != "pdf") { + let selected = bs.file_type == *id; + let (rect, resp) = ui.allocate_exact_size(vec2(520.0, 46.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, *label)); + let p = ui.painter(); + if selected { + p.rect_filled(rect, CornerRadius::same(2), t.selected); + } else if resp.hovered() { + p.rect_filled(rect, CornerRadius::same(2), t.hover); + } + p.text(pos2(rect.left() + 12.0, rect.center().y - 8.0), Align2::LEFT_CENTER, *label, theme::font(13.0), t.text); + p.text(pos2(rect.left() + 12.0, rect.center().y + 9.0), Align2::LEFT_CENTER, *detail, theme::font(11.5), t.text_dim); + if resp.clicked() { + bs.file_type = id; + } + } + ui.add_space(12.0); + let png = bs.file_type == "png"; + if png { + ui.checkbox(&mut bs.png_transparent, "Transparent background") + .on_hover_text("Leave the slide background out: only what is on the slide is drawn"); + ui.add_space(8.0); + } + if square_button(ui, Icon::SaveAs, "Save As", true).clicked() { + let extra = if png && bs.png_transparent { json!({"transparentBackground": true}) } else { json!({}) }; + app.export_as(bs.file_type, extra); + } + } + } + }); + }); +}