refactor(ui-egui): split backstage.rs into backstage/{files,info,print}.rs

This commit is contained in:
aiongg
2026-10-26 22:08:44 +00:00
parent 4bdd5b5bee
commit 28371e27d7
4 changed files with 939 additions and 922 deletions
+11 -922
View File
@@ -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> =
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<String> =
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<Vec<usize>, String> {
let n = doc.slides.len();
let mut v: Vec<usize> = 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::<usize>().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;
+316
View File
@@ -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> =
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<String> =
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);
});
});
}
+242
View File
@@ -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);
});
});
}
+370
View File
@@ -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<Vec<usize>, String> {
let n = doc.slides.len();
let mut v: Vec<usize> = 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::<usize>().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);
}
}
}
});
});
}