mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-27 03:56:50 -06:00
refactor(ui-egui): split backstage.rs into backstage/{files,info,print}.rs
This commit is contained in:
+11
-922
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user