UI: the File tab (Backstage) like PowerPoint's (#76)

File (first ribbon tab) or Alt+F opens a full-window view in place of the
ribbon, slides and status bar: a side menu (Back, Home, New, Open, Info,
Save, Save As, Print, Export, Close; Account and Options at the bottom)
and the chosen page. Esc or Back returns; Up/Down walk the side menu and
show each page as it gets the focus; Enter runs Save/Close/Options.
Laid out after PowerPoint for Windows as observed: 160-pt menu a step
darker than the page, outline icons, current page in the accent colour.

The start screen is now File › Home (as in PowerPoint), with only Home,
New and Open at the top and no Back. Pages call existing commands and
file helpers: file.new {theme} and the sample deck (Home, New); recent
files with search, Browse and Recover Unsaved (Open); file.info and
file.properties, Accessibility Checker, Revert (Info); file.save /
file.saveAs / file.export by type (Save, Save As); a PDF with the Print
page's slides, layout (full page, notes, handouts 1-9), hidden slides and
frames, opened in the system viewer or downloaded on the web, with a
page preview (Print, Ctrl+P); Create PDF, Create Handouts and Change File
Type (Export); appearance and About (Account); Preferences (Options).
Share (cloud) is left out.

Close and Ctrl+W now ask Save / Don't Save / Cancel about unsaved
changes instead of closing at once. The Export dialog shares
SlideApp::export_as with the Export page. New UI commands: app.backstage
{page}, app.closePresentation, app.print; ui.inspect reports the page.
New icons drawn in code: back, save as, account, recent, new file, open
folder.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-12 23:44:57 -04:00
parent c24ef3cd64
commit d3659655ff
9 changed files with 2003 additions and 202 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,438 @@
//! The File tab (Backstage, #76) through real egui input: the File tab, Alt+F and Esc, every page,
//! and the commands its pages run.
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::cell::RefCell;
use std::rc::Rc;
use deckcraft_engine::Session;
use egui::accesskit::Role;
use egui::{Key, Modifiers};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
use super::*;
use crate::{Services, SlideApp};
fn harness(app: SlideApp) -> Harness<'static, SlideApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
// The app keeps asking for frames, so step instead of run().
h.run_steps(3);
h
}
fn new_app() -> SlideApp {
SlideApp::new(Session::with_new(), Services::default())
}
/// A folder of its own for one test's files.
fn temp_dir(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("deckcraft-backstage-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// Desktop services whose Save dialog answers with `dir`/<suggested name> and records the names.
fn saving_into(dir: &std::path::Path) -> (Services, Rc<RefCell<Vec<String>>>) {
let asked: Rc<RefCell<Vec<String>>> = Default::default();
let log = asked.clone();
let dir = dir.to_path_buf();
let services = Services {
pick_save: Some(Box::new(move |suggested: &str| {
log.borrow_mut().push(suggested.to_string());
Some(dir.join(suggested).to_string_lossy().to_string())
})),
..Default::default()
};
(services, asked)
}
/// Is `r` in the side menu (the harness insets the window by its default margin)?
fn in_nav(r: egui::Rect) -> bool {
r.left() < 20.0 && r.right() <= NAV_W + 20.0
}
/// Clicks the button called `label` on the page (not the side menu's entry of the same name).
fn click_on_page(h: &Harness<'_, SlideApp>, label: &str) {
let node = h
.query_all_by_role_and_label(Role::Button, label)
.find(|n| !in_nav(n.rect()))
.or_else(|| h.query_all_by_label(label).find(|n| !in_nav(n.rect())));
node.unwrap_or_else(|| panic!("no “{label}” button on the page")).click();
}
/// Clicks the side menu's entry called `label`.
fn click_nav(h: &Harness<'_, SlideApp>, label: &str) {
let node = h.query_all_by_label(label).find(|n| in_nav(n.rect()));
node.unwrap_or_else(|| panic!("no “{label}” in the side menu")).click();
}
fn page(h: &Harness<'_, SlideApp>) -> Option<Page> {
h.state().backstage.as_ref().map(|b| b.page)
}
#[test]
fn page_ids_round_trip_and_unknown_ids_are_refused() {
for p in Page::ALL {
assert_eq!(Page::parse(p.id()), Some(p));
}
assert_eq!(Page::parse(" SAVEAS "), Some(Page::SaveAs));
assert_eq!(Page::parse("share"), None);
assert_eq!(Page::parse(""), None);
let (top, bottom) = entries(false);
assert_eq!(top, vec![Nav::Page(Page::Home), Nav::Page(Page::New), Nav::Page(Page::Open)], "the start screen has no presentation pages");
assert_eq!(bottom, vec![Nav::Page(Page::Account), Nav::Options]);
assert_eq!(entries(true).0.len(), 9);
assert_eq!(split_path("/home/me/Talk.pptx"), ("Talk.pptx".into(), "/home/me".into()));
assert_eq!(format_bytes(512), "512 bytes");
assert_eq!(format_bytes(33_485), "32.7 KB");
}
/// The File tab opens Home in place of the ribbon; Esc and the back arrow return.
#[test]
fn the_file_tab_opens_the_backstage_and_esc_or_back_returns() {
let mut h = harness(new_app());
assert!(h.query_by_label("Blank Presentation").is_none(), "the slide shows first");
h.get_by_label("File").click();
h.run_steps(3);
assert_eq!(page(&h), Some(Page::Home));
assert!(h.state().backstage_open());
assert!(h.query_by_label("Blank Presentation").is_some(), "Home offers new presentations");
assert!(h.query_by_label("File").is_none(), "the ribbon is hidden");
h.key_press(Key::Escape);
h.run_steps(3);
assert!(!h.state().backstage_open(), "Esc goes back to the slide");
assert!(h.query_by_label("File").is_some());
h.get_by_label("File").click();
h.run_steps(3);
h.get_by_label("Back").click();
h.run_steps(3);
assert!(!h.state().backstage_open(), "so does the back arrow");
assert_eq!(h.state().session.documents().len(), 1, "nothing was created or closed");
}
/// Alt+F opens it with the keyboard in the side menu; arrows walk it (pages show as they get the
/// focus); Enter runs an action; Esc goes back.
#[test]
fn alt_f_arrows_enter_and_esc_drive_the_side_menu() {
let mut h = harness(new_app());
h.key_press_modifiers(Modifiers::ALT, Key::F);
h.run_steps(3);
assert_eq!(page(&h), Some(Page::Home));
assert_eq!(h.state().backstage.as_ref().and_then(|b| b.focus), Some(0));
for want in [Page::New, Page::Open, Page::Info] {
h.key_press(Key::ArrowDown);
h.run_steps(2);
assert_eq!(page(&h), Some(want));
}
// Save has the focus now; the page stays Info until it is run.
h.key_press(Key::ArrowDown);
h.run_steps(2);
assert_eq!(page(&h), Some(Page::Info));
// A presentation that was never saved goes to Save As, as in PowerPoint.
h.key_press(Key::Enter);
h.run_steps(2);
assert_eq!(page(&h), Some(Page::SaveAs));
// Up from the top wraps to Options at the bottom.
for _ in 0..20 {
h.key_press(Key::ArrowUp);
}
h.run_steps(2);
h.key_press(Key::Escape);
h.run_steps(3);
assert!(!h.state().backstage_open());
assert!(!h.state().session.active().unwrap().is_dirty(), "the keys never reached the slide");
}
/// Every page draws its own content.
#[test]
fn every_page_renders() {
let mut h = harness(new_app());
let last_theme = deckcraft_model::theme::builtin_themes().last().map(|t| t.name.clone()).unwrap();
let checks: [(Page, &str); 8] = [
(Page::Home, "Recent"),
(Page::New, &last_theme),
(Page::Open, "Browse"),
(Page::Info, "Hidden slides"),
(Page::SaveAs, "Not saved yet"),
(Page::Print, "Print Hidden Slides"),
(Page::Export, "Create PDF Document"),
(Page::Account, "About DeckCraft"),
];
for (p, marker) in checks {
h.state_mut().run("app.backstage", json!({"page": p.id()})).unwrap();
h.run_steps(3);
assert_eq!(page(&h), Some(p));
assert!(h.query_all_by_label_contains(marker).next().is_some(), "{} shows “{marker}”", p.label());
let selected = h.query_all_by_label(p.label()).any(|n| in_nav(n.rect()));
assert!(selected, "{} is in the side menu", p.label());
}
assert!(h.state_mut().run("app.backstage", json!({"page": "share"})).is_err(), "Share (cloud) isn't offered");
}
/// New and Home create presentations, which leaves the File tab.
#[test]
fn new_creates_a_presentation() {
let mut h = harness(new_app());
h.state_mut().run("app.backstage", json!({"page": "new"})).unwrap();
h.run_steps(3);
h.get_by_label("Blank Presentation").click();
h.run_steps(3);
assert_eq!(h.state().session.documents().len(), 2);
assert!(h.state().backstage.is_none(), "the new presentation shows");
let theme = deckcraft_model::theme::builtin_themes().into_iter().nth(1).unwrap();
h.state_mut().run("app.backstage", json!({})).unwrap();
h.run_steps(3);
h.get_by_label(&theme.name).click();
h.run_steps(3);
assert_eq!(h.state().session.documents().len(), 3);
assert_eq!(h.state().session.active().unwrap().doc.masters[0].theme.name, theme.name);
assert!(h.state().backstage.is_none());
}
/// Open lists recent presentations by name (filtered by the search box) and opens one.
#[test]
fn open_lists_recent_files_and_opens_one() {
let dir = temp_dir("open");
let path = dir.join("Quarterly review.pptx").to_string_lossy().to_string();
let mut s = Session::with_new();
s.execute("file.saveAs", &json!({"path": path})).unwrap();
let mut app = SlideApp::new(Session::with_new(), Services::default());
app.ui.recent = vec![path.clone(), dir.join("Other.pptx").to_string_lossy().to_string()];
let mut h = harness(app);
h.state_mut().run("app.backstage", json!({"page": "open"})).unwrap();
h.run_steps(3);
assert!(h.query_by_label("Other.pptx").is_some());
h.state_mut().backstage.as_mut().unwrap().query = "quarter".into();
h.run_steps(2);
assert!(h.query_by_label("Other.pptx").is_none(), "the search box filters by name");
h.get_by_label("Quarterly review.pptx").click();
h.run_steps(3);
assert_eq!(h.state().session.documents().len(), 2);
assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some(path.as_str()));
assert!(h.state().backstage.is_none(), "the opened presentation shows");
assert_eq!(h.state().ui.recent.first(), Some(&path));
let _ = std::fs::remove_dir_all(&dir);
}
/// Save As saves through the Save dialog and the presentation takes the new file; Export writes
/// copies (PDF, another file type) and leaves the presentation's own file alone.
#[test]
fn save_as_and_export_run_the_file_commands() {
let dir = temp_dir("saveas");
let (services, asked) = saving_into(&dir);
let mut h = harness(SlideApp::new(Session::with_new(), services));
let title = h.state().session.active().unwrap().title();
// Save on a presentation without a file goes to Save As.
h.state_mut().run("app.backstage", json!({})).unwrap();
h.run_steps(3);
click_nav(&h, "Save");
h.run_steps(3);
assert_eq!(page(&h), Some(Page::SaveAs));
click_on_page(&h, "Save");
h.run_steps(3);
let pptx = dir.join(format!("{title}.pptx")).to_string_lossy().to_string();
assert!(std::path::Path::new(&pptx).is_file(), "saved as PowerPoint by default");
assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some(pptx.as_str()));
assert!(h.state().backstage.is_none(), "a save returns to the slide");
// Export › Create PDF Document.
h.state_mut().run("app.backstage", json!({"page": "export"})).unwrap();
h.run_steps(3);
click_on_page(&h, "Create PDF");
h.run_steps(3);
let pdf = std::fs::read(dir.join(format!("{title}.pdf"))).unwrap();
assert!(pdf.starts_with(b"%PDF"));
// Export › Change File Type › Outline.
click_on_page(&h, "Change File Type");
h.run_steps(3);
click_on_page(&h, "Outline (*.txt)");
h.run_steps(2);
click_on_page(&h, "Save As");
h.run_steps(3);
assert!(dir.join(format!("{title}.txt")).is_file());
assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some(pptx.as_str()), "exports are copies");
assert_eq!(*asked.borrow(), vec![format!("{title}.pptx"), format!("{title}.pdf"), format!("{title}.txt")]);
// Save with a file saves in place and goes back.
h.state_mut().run("slide.new", json!({})).unwrap();
h.state_mut().run("app.backstage", json!({})).unwrap();
h.run_steps(3);
click_nav(&h, "Save");
h.run_steps(3);
assert!(!h.state().session.active().unwrap().is_dirty());
assert!(h.state().backstage.is_none());
assert_eq!(asked.borrow().len(), 3, "no Save dialog for a presentation with a file");
let _ = std::fs::remove_dir_all(&dir);
}
/// Close closes a saved presentation at once and asks about unsaved changes first.
#[test]
fn close_closes_and_asks_about_unsaved_changes() {
let mut h = harness(new_app());
h.state_mut().run("app.backstage", json!({})).unwrap();
h.run_steps(3);
click_nav(&h, "Close");
h.run_steps(3);
assert_eq!(h.state().session.documents().len(), 0);
assert_eq!(page(&h), Some(Page::Home), "the start screen shows");
assert!(h.query_by_label("Back").is_none() && h.query_by_label("Close").is_none(), "nothing to go back to or close");
// Unsaved: Cancel keeps it, Don't Save closes it.
h.get_by_label("Blank Presentation").click();
h.run_steps(3);
h.state_mut().run("slide.new", json!({})).unwrap();
h.state_mut().run("app.backstage", json!({})).unwrap();
h.run_steps(3);
click_nav(&h, "Close");
h.run_steps(3);
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("close"));
h.get_by_label("Cancel").click();
h.run_steps(3);
assert_eq!(h.state().session.documents().len(), 1);
assert!(h.state().backstage_open(), "Cancel leaves the File tab as it was");
click_nav(&h, "Close");
h.run_steps(3);
h.get_by_label("Don't Save").click();
h.run_steps(3);
assert_eq!(h.state().session.documents().len(), 0);
}
/// Ctrl+W asks too (it used to close unsaved presentations without a word).
#[test]
fn ctrl_w_asks_before_closing_unsaved_changes() {
let mut h = harness(new_app());
h.state_mut().run("slide.new", json!({})).unwrap();
h.run_steps(2);
h.key_press_modifiers(Modifiers::COMMAND, Key::W);
h.run_steps(3);
assert_eq!(h.state().session.documents().len(), 1);
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("close"));
}
/// Info shows the presentation's counts and properties, and edits them through file.properties.
#[test]
fn info_shows_and_edits_the_properties() {
let mut app = new_app();
app.run("file.properties", json!({"title": "Quarterly", "keywords": "sales"})).unwrap();
app.run("slide.new", json!({})).unwrap();
app.run("slide.hide", json!({})).unwrap();
let mut h = harness(app);
h.state_mut().run("app.backstage", json!({"page": "info"})).unwrap();
h.run_steps(3);
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Title").value().as_deref(), Some("Quarterly"));
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Tags").value().as_deref(), Some("sales"));
assert!(h.query_all_by_label("Not saved yet").next().is_some());
let author = h.get_by_role_and_label(Role::TextInput, "Author");
author.click();
h.run_steps(2);
h.get_by_role_and_label(Role::TextInput, "Author").type_text("Ada");
h.run_steps(2);
h.key_press(Key::Enter);
h.run_steps(3);
assert_eq!(h.state().session.active().unwrap().doc.props.author, "Ada");
// Esc in a field leaves the field; the next one goes back.
assert!(h.state().backstage_open());
}
/// With nothing open the File tab is the start screen: Home, New and Open, no back arrow, and Esc
/// has nowhere to go.
#[test]
fn the_start_screen_is_file_home() {
let mut h = harness(SlideApp::new(Session::new(), Services::default()));
assert_eq!(page(&h), Some(Page::Home));
assert!(h.query_by_label("Back").is_none());
assert!(h.query_all_by_label("Info").next().is_none());
h.key_press(Key::Escape);
h.run_steps(2);
assert_eq!(page(&h), Some(Page::Home));
h.key_press(Key::ArrowDown);
h.run_steps(2);
assert_eq!(page(&h), Some(Page::New));
assert!(h.state_mut().run("app.backstage", json!({"page": "print"})).is_err(), "nothing to print");
h.get_by_label("Blank Presentation").click();
h.run_steps(3);
assert_eq!(h.state().session.documents().len(), 1);
assert!(h.state().backstage.is_none());
}
/// Print previews the pages its settings make and prints through a PDF opened in the system
/// viewer.
#[test]
fn print_previews_pages_and_prints_a_pdf() {
let dir = temp_dir("print");
let (services, _) = saving_into(&dir);
let mut app = SlideApp::new(Session::with_new(), services);
for _ in 0..3 {
app.run("slide.new", json!({})).unwrap();
}
app.run("slide.hide", json!({"index": 1})).unwrap();
let mut h = harness(app);
h.key_press_modifiers(Modifiers::COMMAND, Key::P);
h.run_steps(3);
assert_eq!(page(&h), Some(Page::Print), "Ctrl+P opens File › Print");
assert!(h.query_by_label("1 of 3").is_some(), "hidden slides are left out");
h.state_mut().backstage.as_mut().unwrap().print_layout = "h4";
h.run_steps(2);
assert!(h.query_by_label("1 of 1").is_some(), "four slides a page");
h.state_mut().backstage.as_mut().unwrap().print_layout = "slides";
h.state_mut().backstage.as_mut().unwrap().print_hidden = true;
h.run_steps(2);
assert!(h.query_by_label("1 of 4").is_some());
h.get_by_label("Next Page").click();
h.run_steps(2);
assert!(h.query_by_label("2 of 4").is_some());
{
let bs = h.state_mut().backstage.as_mut().unwrap();
bs.print_scope = PrintScope::Custom;
bs.print_range = "9".into();
}
h.run_steps(2);
assert!(h.query_by_label_contains("outside them").is_some(), "a bad range says why");
h.state_mut().backstage.as_mut().unwrap().print_range = "1-2".into();
h.run_steps(2);
click_on_page(&h, "Print");
h.run_steps(2);
let url = h.state().pending_urls.first().cloned().unwrap_or_default();
// The URL was handed to egui at the end of the frame; check what it pointed at.
let printed = std::env::temp_dir().join(format!("{} (print).pdf", h.state().session.active().unwrap().title()));
assert!(printed.is_file(), "the PDF to print was written: {url}");
assert!(std::fs::read(&printed).unwrap().starts_with(b"%PDF"));
let _ = std::fs::remove_file(printed);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn print_slides_follow_the_settings() {
let mut s = Session::with_new();
for _ in 0..4 {
s.execute("slide.new", &json!({})).unwrap();
}
s.execute("slide.hide", &json!({"index": 2})).unwrap();
let doc = s.doc().unwrap().doc.clone();
let mut bs = Backstage::new(Page::Print, (1, Some(0)));
assert_eq!(print_slides(&doc, &bs, 0).unwrap(), vec![0, 1, 3, 4]);
bs.print_hidden = true;
assert_eq!(print_slides(&doc, &bs, 0).unwrap().len(), 5);
bs.print_scope = PrintScope::Current;
assert_eq!(print_slides(&doc, &bs, 2).unwrap(), vec![2], "the current slide prints even when hidden");
bs.print_scope = PrintScope::Custom;
bs.print_range = "2-4".into();
bs.print_hidden = false;
assert_eq!(print_slides(&doc, &bs, 0).unwrap(), vec![1, 3]);
bs.print_layout = "h6";
assert_eq!(print_params(&bs, &[1, 3])["perPage"], 6);
assert_eq!(print_params(&bs, &[1, 3])["layout"], "handouts");
}

View File

@ -216,7 +216,7 @@ pub fn ime_area(app: &SlideApp, xf: Xf) -> Option<Rect> {
/// separate jamo (ㅇ ㅏ ㄴ) instead of syllables. A focused text field sets its own.
fn ime_output(app: &SlideApp, ui: &Ui, xf: Xf) {
let ctx = ui.ctx();
if ctx.memory(|m| m.focused().is_some()) || app.dialog.as_ref().is_some_and(|d| d.modal()) || app.palette.is_some() || app.home_open() {
if ctx.memory(|m| m.focused().is_some()) || app.dialog.as_ref().is_some_and(|d| d.modal()) || app.palette.is_some() || app.backstage_open() {
return;
}
let Some(r) = ime_area(app, xf) else { return };

View File

@ -73,7 +73,8 @@ pub fn inspect(app: &SlideApp, ctx: &egui::Context) -> Value {
"slideRect": app.slide_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])),
"canvasScale": app.canvas_scale,
"dialog": app.dialog.as_ref().map(|d| d.id.clone()),
"home": app.home_open(),
"home": app.backstage_open(),
"backstage": app.backstage.as_ref().map(|b| b.page.id()),
"showing": app.show.as_ref().map(|s| json!({"slide": s.state.slide, "step": s.state.step, "ended": s.ended})),
"documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty(), "path": d.path})).collect::<Vec<_>>(),
"status": app.status.as_ref().map(|s| s.0.clone()),

View File

@ -1,11 +1,11 @@
//! Dialogs (Insert Table, Slide Size, Header & Footer, Hyperlink, Set Up Show, Zoom, …), the start
//! screen and the About window.
//! Dialogs (Insert Table, Slide Size, Header & Footer, Hyperlink, Set Up Show, Zoom, …) and the
//! About window. The start screen is File › Home (`backstage.rs`).
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::{Value, json};
use crate::SlideApp;
use crate::theme::{self, Tokens};
use crate::{SlideApp, ribbon};
#[derive(Clone, Debug, serde::Serialize)]
pub struct Dialog {
@ -290,6 +290,37 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
});
done
}
"close" => {
// File › Close and Ctrl+W on a presentation with unsaved changes.
let Some(name) = app.session.active().map(|d| d.title()) else { return true };
ui.label(egui::RichText::new(format!("Do you want to save the changes you made to “{name}”?")).strong());
ui.label("Your changes will be lost if you don't save them.");
ui.add_space(8.0);
let mut done = false;
ui.horizontal(|ui| {
let t = Tokens::get(ui.ctx());
if ui.add(egui::Button::new("Don't Save").min_size(vec2(84.0, 24.0))).clicked() {
run(app, "file.close", json!({}));
done = true;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let save = ui.add(egui::Button::new(egui::RichText::new("Save").color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0)));
if save.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
app.save();
// In the browser a save is a download: the presentation stays "unsaved".
let web = app.services.pick_save.is_none() && app.services.download.is_some();
if web || app.session.active().is_some_and(|d| !d.is_dirty()) {
run(app, "file.close", json!({}));
}
done = true;
}
if ui.add(egui::Button::new("Cancel").min_size(vec2(72.0, 24.0))).clicked() {
done = true;
}
});
});
done
}
"zoom" => {
for pct in [400, 200, 150, 100, 75, 66, 50, 33] {
if ui.radio(false, format!("{pct}%")).clicked() {
@ -430,45 +461,8 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
};
let (ok, cancel) = buttons(ui, "Export");
if ok {
let name = app.session.active().map(|s| s.title()).unwrap_or_else(|| "Presentation".into());
let (ext, all) = match fmt.as_str() {
"pdf" => ("pdf", false),
"pngAll" => ("png", true),
"jpeg" => ("jpg", false),
"pptx" => ("pptx", false),
"outline" => ("txt", false),
_ => ("png", false),
};
let suggested = format!("{name}.{ext}");
if let Some(pick) = app.services.pick_save.as_mut() {
if let Some(path) = pick(&suggested) {
let mut params = json!({"path": path, "all": all});
if ext == "pdf"
&& let (Some(o), Some(extra)) = (params.as_object_mut(), pdf_params.as_object())
{
o.extend(extra.clone());
}
let r = app.session.execute("file.export", &params);
match r {
Ok(_) => app.set_status(format!("Exported {path}")),
Err(e) => app.set_status(e.to_string()),
}
}
} else if let (Some(dl), Some(s)) = (app.services.download.as_mut(), app.session.active()) {
let bytes = match ext {
"png" | "jpg" => {
let img = deckcraft_render::render_slide(
&s.doc,
s.selection.slide,
&deckcraft_render::RenderOpts { scale: 2.0, ..Default::default() },
);
if ext == "png" { img.to_png() } else { img.to_jpeg(92) }
}
"pdf" => deckcraft_pdf_bytes(&s.doc, &pdf_params),
other => deckcraft_engine::cmd::file::save_bytes(&s.doc, other).unwrap_or_default(),
};
dl(&suggested, &bytes);
}
let extra = if fmt == "pdf" { pdf_params } else { json!({}) };
app.export_as(&fmt, extra);
}
ok || cancel
}
@ -835,129 +829,3 @@ pub fn paint_logo(p: &egui::Painter, r: Rect) {
Stroke::NONE,
));
}
/// The start screen (no presentation open): new from themes, open, recent files.
pub fn start_screen(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
egui::ScrollArea::vertical().show(ui, |ui| {
ui.add_space(24.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
let (r, _) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::hover());
paint_logo(ui.painter(), r);
ui.label(egui::RichText::new("DeckCraft").font(theme::bold(26.0)));
});
if app.home_open() {
let title = app.session.active().map(|d| d.title()).unwrap_or_default();
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
if ui.button(format!("← Back to {title}")).on_hover_text("Esc").clicked() {
let _ = app.run("app.home", json!({"on": false}));
}
});
}
ui.add_space(16.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
ui.label(egui::RichText::new("New presentation").font(theme::bold(16.0)));
});
ui.add_space(8.0);
inset_wrapped(ui, |ui| {
for th in deckcraft_model::theme::builtin_themes() {
let (r, resp) = ui.allocate_exact_size(vec2(180.0, 128.0), Sense::click());
let tile = Rect::from_min_size(r.min, vec2(180.0, 101.0));
ribbon::paint_theme_tile(ui.painter(), tile, &th);
if resp.hovered() {
ui.painter().rect_stroke(tile, CornerRadius::same(2), Stroke::new(2.0, t.accent), egui::StrokeKind::Outside);
}
ui.painter().text(pos2(r.min.x, r.max.y - 10.0), Align2::LEFT_CENTER, &th.name, theme::font(12.0), t.text);
if resp.clicked() {
let _ = app.run("file.new", json!({"theme": th.name}));
}
}
});
ui.add_space(16.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
if ui.button("Open…").clicked() {
let _ = app.run("app.openDialog", json!({}));
}
if ui.button("Open the sample deck").clicked()
&& let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session)
{
app.set_status(e.to_string());
}
});
if !app.ui.recent.is_empty() {
ui.add_space(16.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
ui.label(egui::RichText::new("Recent").font(theme::bold(16.0)));
});
for p in app.ui.recent.clone() {
ui.horizontal(|ui| {
ui.add_space(40.0);
if ui.link(&p).clicked()
&& let Err(e) = app.open_path(&p)
{
app.set_status(e);
}
});
}
}
ui.add_space(30.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
ui.hyperlink_to("Join the ArtCraft community on Discord", deckcraft_engine::links::DISCORD);
});
});
}
/// Wrapped rows that all start 40 points in from the left, in line with the start screen's
/// headings. The inset is a margin on the container: an `add_space` inside `horizontal_wrapped`
/// would indent only the first row.
fn inset_wrapped(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui)) {
egui::Frame::NONE.inner_margin(egui::Margin { left: 40, ..Default::default() }).show(ui, |ui| {
ui.horizontal_wrapped(add_contents);
});
}
/// PDF bytes with the export dialog's options (web: downloaded instead of written).
fn deckcraft_pdf_bytes(doc: &deckcraft_model::Presentation, params: &serde_json::Value) -> Vec<u8> {
deckcraft_engine::cmd::file::pdf_bytes(doc, params).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::{Cell, RefCell};
/// Start screen theme tiles that wrap onto a second row keep the 40-point inset (#17).
#[test]
fn inset_wrapped_indents_every_row() {
let left = Cell::new(0.0_f32);
let tiles = RefCell::new(Vec::<Rect>::new());
let mut harness = egui_kittest::Harness::builder().with_size(vec2(500.0, 400.0)).build_ui(|ui| {
left.set(ui.max_rect().left());
tiles.borrow_mut().clear();
inset_wrapped(ui, |ui| {
for _ in 0..4 {
let (r, _) = ui.allocate_exact_size(vec2(180.0, 128.0), Sense::click());
tiles.borrow_mut().push(r);
}
});
});
harness.run();
let tiles = tiles.borrow().clone();
let first_top = tiles.first().map_or(0.0, |r| r.top());
assert!(tiles.iter().any(|r| r.top() > first_top), "tiles should wrap onto a second row: {tiles:?}");
let mut row_top = f32::NEG_INFINITY;
for r in &tiles {
if r.top() > row_top {
row_top = r.top();
assert!((r.left() - (left.get() + 40.0)).abs() < 0.5, "row starts at {} instead of {}", r.left(), left.get() + 40.0);
}
}
}
}

View File

@ -306,6 +306,13 @@ icons! {
Collapse => "collapse",
Expand => "expand",
Pin => "pin",
// File tab (Backstage)
BackCircle => "back-circle",
SaveAs => "save-as",
Account => "account",
Recent => "recent",
NewFile => "new-file",
OpenFolder => "open-folder",
// Status bar (small, monochrome)
NotesSmall => "notes-small",
CommentsSmall => "comments-small",
@ -2361,6 +2368,33 @@ fn draw(p: &Pen<'_>, icon: Icon) {
p.rshape(&m(&[(-4.2, -10.0), (4.2, -10.0), (4.2, -7.0), (-4.2, -7.0)]), 0.6, R, N);
}
// ----- File tab (Backstage) navigation: outlines in the ink colour -----
I::BackCircle => {
p.circle((12.0, 12.0), 9.8, N, K);
p.arrow((17.0, 12.0), (6.5, 12.0), K);
}
I::SaveAs => {
p.rline(&[(11.0, 20.5), (2.5, 20.5), (2.5, 3.0), (15.0, 3.0), (18.0, 6.0), (18.0, 10.5)], 1.2, K);
p.rect(6.0, 3.0, 13.0, 7.5, 0.6, N, K);
p.seg_w((13.5, 21.0), (21.5, 13.0), O, 1.4);
p.seg_w((12.5, 22.0), (13.5, 21.0), K, 1.0);
}
I::Account => {
p.circle((12.0, 8.0), 4.5, N, K);
p.line(&arc_pts((12.0, 22.0), 8.5, 7.5, 180.0, 360.0), K);
}
I::Recent => {
p.circle((12.0, 12.0), 9.8, N, K);
p.line(&[(12.0, 6.0), (12.0, 12.0), (16.0, 14.5)], K);
}
I::NewFile => {
p.rline(&[(14.0, 2.5), (5.0, 2.5), (5.0, 21.5), (19.0, 21.5), (19.0, 7.5), (14.0, 2.5), (14.0, 7.5), (19.0, 7.5)], 0.8, K);
}
I::OpenFolder => {
p.rline(&[(18.5, 9.0), (18.5, 6.5), (10.0, 6.5), (8.0, 4.0), (2.5, 4.0), (2.5, 19.5), (18.0, 19.5)], 1.0, K);
p.rline(&[(2.5, 19.5), (6.0, 10.0), (22.0, 10.0), (18.0, 19.5)], 1.0, K);
}
// ----- Status bar (monochrome) -----
I::NotesSmall => {
p.rect(3.0, 4.0, 21.0, 20.0, 1.5, N, K);

View File

@ -6,6 +6,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod backstage;
pub mod canvas;
pub mod control;
pub mod credits;
@ -154,6 +155,9 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("show.presenter", "Presenter View", None, "{on?: bool}"),
("show.end", "End Show", None, "{}"),
("app.home", "Home", None, "{on?}"),
("app.backstage", "File", Some("Alt+F"), "{page?: home|new|open|info|saveAs|print|export|account, on?: bool}"),
("app.closePresentation", "Close", Some("Cmd+W"), "{}"),
("app.print", "Print", Some("Cmd+P"), "{layout?, perPage?, slides?, includeHidden?, frame?}"),
("app.about", "About DeckCraft", None, "{}"),
("app.palette", "Command Palette", Some("Cmd+Shift+P"), "{}"),
("app.preferences", "Preferences…", Some("Cmd+,"), "{}"),
@ -200,9 +204,10 @@ pub struct SlideApp {
pub palette: Option<(String, usize)>,
/// Integrated title bar (macOS traffic lights over our chrome).
pub integrated_titlebar: bool,
/// The start screen is shown over the open presentations (Home), with the document count
/// and active index when it was opened: opening or creating a presentation leaves it.
pub home: Option<(usize, Option<usize>)>,
/// The File tab's full-window view (Backstage). Over a presentation it replaces the ribbon,
/// slides and status bar until Esc or Back; with nothing open it is the start screen. Opening,
/// creating or closing a presentation leaves it.
pub backstage: Option<backstage::Backstage>,
pending_urls: Vec<String>,
shot_token: u64,
queued_shots: Vec<(u64, f64, u32)>,
@ -247,7 +252,7 @@ impl SlideApp {
canvas_region: None,
palette: None,
integrated_titlebar: false,
home: None,
backstage: None,
pending_urls: vec![],
shot_token: 0,
queued_shots: vec![],
@ -289,13 +294,153 @@ impl SlideApp {
r
}
fn docs_key(&self) -> (usize, Option<usize>) {
pub(crate) fn docs_key(&self) -> (usize, Option<usize>) {
(self.session.documents().len(), self.session.active_index())
}
/// Home is open over a presentation (not just the start screen with nothing open).
pub fn home_open(&self) -> bool {
self.home.is_some() && self.session.active().is_some()
/// The File tab's view is open over a presentation (not just the start screen with nothing
/// open).
pub fn backstage_open(&self) -> bool {
self.backstage.is_some() && self.session.active().is_some()
}
/// Opens the File tab's view on `page` (keeping its state when it is already open).
pub fn open_backstage(&mut self, page: backstage::Page) {
let key = self.docs_key();
match &mut self.backstage {
Some(b) => b.go(page),
None => self.backstage = Some(backstage::Backstage::new(page, key)),
}
}
/// Back to the presentation (no effect on the start screen, where nothing is open).
pub fn close_backstage(&mut self) {
if self.session.active().is_some() {
self.backstage = None;
}
}
/// Keeps the File tab's view in step with the open presentations: it closes when one is
/// opened, created or closed (the result shows), and it is the start screen when none is open.
fn sync_backstage(&mut self) {
let key = self.docs_key();
let has_doc = self.session.active().is_some();
let stale = self.backstage.as_ref().is_some_and(|b| b.docs != key);
if has_doc && stale {
self.backstage = None;
} else if !has_doc && (stale || self.backstage.is_none()) {
self.backstage = Some(backstage::Backstage::new(backstage::Page::Home, key));
}
}
/// File › Close and Ctrl+W: closes the active presentation, asking first when it has unsaved
/// changes (Save, Don't Save, Cancel), as PowerPoint does.
pub fn close_presentation(&mut self) {
match self.session.active() {
None => {}
Some(d) if d.is_dirty() => self.dialog = Some(dialogs::Dialog::new("close")),
Some(_) => {
let _ = self.run("file.close", json!({}));
}
}
}
/// The file name a save or export suggests: the presentation's title with `ext`.
fn suggested_name(&self, ext: &str) -> String {
let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into());
format!("{name}.{ext}")
}
/// Exports the active presentation as `fmt` (`pdf`, `png`, `pngAll`, `jpeg`, `pptx`,
/// `deckcraft`, `outline`) through `file.export`: the save picker on the desktop, a download
/// on the web. `extra` adds `file.export` params (the PDF's layout, slides…). The
/// presentation keeps its own file. Returns whether a file was written or handed over.
pub fn export_as(&mut self, fmt: &str, extra: Value) -> bool {
let Some(slide) = self.session.active().map(|d| d.selection.slide) else { return false };
let (ext, format, all) = match fmt {
"pdf" => ("pdf", "pdf", false),
"png" => ("png", "png", false),
"pngAll" => ("png", "png", true),
"jpeg" | "jpg" => ("jpg", "jpeg", false),
"pptx" => ("pptx", "pptx", false),
"outline" | "txt" => ("txt", "outline", false),
"deckcraft" => ("deckcraft", "deckcraft", false),
other => {
self.set_status(format!("DeckCraft can't export “{other}”"));
return false;
}
};
let suggested = self.suggested_name(ext);
if let Some(pick) = self.services.pick_save.as_mut() {
let Some(path) = pick(&suggested) else { return false };
let mut params = json!({"path": path, "format": format, "all": all});
if let (Some(o), Some(x)) = (params.as_object_mut(), extra.as_object()) {
o.extend(x.clone());
}
return match self.run("file.export", params) {
Ok(_) => {
self.set_status(format!("Exported {path}"));
true
}
Err(_) => false,
};
}
let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return false };
let bytes = match format {
"png" | "jpeg" => {
let img = deckcraft_render::render_slide(&doc, slide, &deckcraft_render::RenderOpts { scale: 2.0, ..Default::default() });
Ok(if format == "png" { img.to_png() } else { img.to_jpeg(92) })
}
"pdf" => deckcraft_engine::cmd::file::pdf_bytes(&doc, &extra),
other => deckcraft_engine::cmd::file::save_bytes(&doc, other),
};
self.download(&suggested, bytes)
}
/// Hands `bytes` to the browser as a download (web); false (with the reason in the status
/// bar) when there are no bytes or no way to download.
fn download(&mut self, name: &str, bytes: deckcraft_engine::Result<Vec<u8>>) -> bool {
match (bytes, self.services.download.as_mut()) {
(Ok(b), Some(dl)) => {
dl(name, &b);
true
}
(Err(e), _) => {
self.set_status(e.to_string());
false
}
(Ok(_), None) => {
self.set_status("Saving a copy needs a file picker, which this window doesn't have.");
false
}
}
}
/// File › Print: DeckCraft prints through a PDF. On the desktop the PDF (with the Print page's
/// layout, slides and options) is written to the temporary folder and opened in the system's
/// PDF viewer, which has the printers; on the web it downloads.
pub fn print_pdf(&mut self, params: Value) -> bool {
let Some((title, doc)) = self.session.active().map(|d| (d.title(), d.doc.clone())) else { return false };
let mut params = if params.is_object() { params } else { json!({}) };
if self.services.pick_save.is_none() {
// Web: the browser hands the PDF to the user.
let bytes = deckcraft_engine::cmd::file::pdf_bytes(&doc, &params);
return self.download(&format!("{title}.pdf"), bytes);
}
let safe: String = title.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect();
let path = std::env::temp_dir().join(format!("{safe} (print).pdf")).to_string_lossy().to_string();
if let Some(o) = params.as_object_mut() {
o.insert("path".into(), json!(path));
o.insert("format".into(), json!("pdf"));
}
match self.run("file.export", params) {
Ok(_) => {
self.open_url(&format!("file:///{}", path.replace('\\', "/").trim_start_matches('/')));
self.set_status("Opened the presentation as a PDF to print");
true
}
Err(_) => false,
}
}
fn flag(&mut self, params: &Value, cur: bool) -> bool {
@ -358,8 +503,34 @@ impl SlideApp {
self.media.stop_owned(media::Owner::Show);
}
"app.home" => {
let on = self.flag(p, self.home.is_some());
self.home = (on && self.session.active().is_some()).then(|| self.docs_key());
let on = self.flag(p, self.backstage_open());
if on {
self.open_backstage(backstage::Page::Home);
} else {
self.close_backstage();
}
}
"app.backstage" => {
if !p.get("on").and_then(Value::as_bool).unwrap_or(true) {
self.close_backstage();
return Ok(json!({"page": self.backstage.as_ref().map(|b| b.page.id())}));
}
let page = match p.get("page").and_then(Value::as_str) {
Some(id) => backstage::Page::parse(id).ok_or_else(|| format!("unknown File page `{id}`"))?,
None => backstage::Page::Home,
};
if page.needs_doc() && self.session.active().is_none() {
return Err(format!("File › {} needs an open presentation", page.label()));
}
self.open_backstage(page);
return Ok(json!({"page": page.id()}));
}
"app.closePresentation" => self.close_presentation(),
"app.print" => {
if self.session.active().is_none() {
return Err("nothing to print: no presentation is open".into());
}
return Ok(json!({"printed": self.print_pdf(p.clone())}));
}
"app.about" => self.dialog = Some(dialogs::Dialog::new("about")),
"app.palette" => self.palette = Some((String::new(), 0)),
@ -678,16 +849,22 @@ impl SlideApp {
return;
}
let events = ctx.input(|i| i.events.clone());
if self.home_open() {
// Only app shortcuts (Open, Command Palette…) and Escape, which goes back to the
// presentation: typing, pasting or deleting would edit the hidden presentation.
if self.backstage.is_some() {
// The File tab: its own keys (Esc goes back, arrows walk the side menu) and app
// shortcuts (New, Open, Command Palette…); typing, pasting or deleting would edit the
// hidden presentation.
for e in events {
if let egui::Event::Key { key, pressed: true, modifiers, .. } = e {
let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl };
if key == egui::Key::Escape {
self.home = None;
} else if mods.cmd || mods.alt {
menus::ui_shortcut(self, key, mods);
if backstage::key(self, key, mods) || !(mods.cmd || mods.alt) || menus::ui_shortcut(self, key, mods) {
continue;
}
// Engine shortcuts that don't touch the hidden slide (Ctrl+N).
if mods.cmd
&& matches!(key, egui::Key::N)
&& let Err(e) = self.session.key(key_name(key), mods)
{
self.set_status(e.to_string());
}
}
}
@ -763,18 +940,21 @@ impl SlideApp {
self.perf.frame_ms = now_ms() - t0;
return;
}
if self.home.is_some_and(|k| k != self.docs_key()) {
self.home = None;
}
self.sync_backstage();
self.keyboard(&ctx);
self.sync_backstage();
ribbon::title_bar(self, ui);
if self.session.active().is_some() && !self.home_open() {
if self.backstage.is_none() && self.session.active().is_some() {
ribbon::show(self, ui);
status::status_bar(self, ui);
}
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {
if self.session.active().is_none() || self.home_open() {
dialogs::start_screen(self, ui);
if self.backstage.is_some() {
// File tab: the Backstage view replaces everything below the title bar.
backstage::show(self, ui);
return;
}
if self.session.active().is_none() {
return;
}
match self.ui.view {
@ -1010,7 +1190,7 @@ mod tests {
harness.state_mut().run("app.home", json!({})).unwrap();
harness.run_steps(2);
assert!(harness.state().home_open());
assert!(harness.state().backstage_open());
assert_eq!(harness.state().session.documents().len(), 1, "Home doesn't create a presentation");
// Typing on the start screen doesn't reach the presentation behind it; Escape goes back.
@ -1021,18 +1201,18 @@ mod tests {
assert!(!harness.state().session.active().unwrap().is_dirty());
harness.key_press(egui::Key::Escape);
harness.run_steps(2);
assert!(!harness.state().home_open());
assert!(!harness.state().backstage_open());
// Home again toggles; picking a theme on the start screen (a new presentation) leaves it.
harness.state_mut().run("app.home", json!({})).unwrap();
harness.run_steps(2);
assert!(harness.state().home_open());
assert!(harness.state().backstage_open());
harness.state_mut().run("app.home", json!({})).unwrap();
assert!(!harness.state().home_open());
assert!(!harness.state().backstage_open());
harness.state_mut().run("app.home", json!({"on": true})).unwrap();
harness.state_mut().run("file.new", json!({})).unwrap();
harness.run_steps(2);
assert!(!harness.state().home_open(), "a new presentation leaves Home");
assert!(!harness.state().backstage_open(), "a new presentation leaves Home");
assert_eq!(harness.state().session.documents().len(), 2);
}
@ -1123,7 +1303,7 @@ mod tests {
fn home_needs_an_open_presentation() {
let mut app = SlideApp::new(Session::new(), Services::default());
app.run("app.home", json!({})).unwrap();
assert!(!app.home_open() && app.home.is_none(), "with nothing open the start screen shows anyway");
assert!(!app.backstage_open(), "with nothing open the start screen shows anyway");
}
fn canvas_harness() -> egui_kittest::Harness<'static, SlideApp> {
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(

View File

@ -29,6 +29,25 @@ pub fn ui_shortcut(app: &mut SlideApp, key: egui::Key, m: Mods) -> bool {
return true;
}
(S, true, true, false) => "app.saveAsDialog",
// File tab: Alt+F opens it on Home with the keyboard in its side menu; Ctrl+P on Print.
(F, false, false, true) => {
app.open_backstage(crate::backstage::Page::Home);
if let Some(b) = app.backstage.as_mut() {
b.focus = Some(0);
}
return true;
}
(P, true, false, false) => {
if app.session.active().is_none() {
return false;
}
app.open_backstage(crate::backstage::Page::Print);
return true;
}
(W, true, false, false) => {
app.close_presentation();
return true;
}
(O, true, false, false) => "app.openDialog",
(P, true, true, false) => "app.palette",
(Comma, true, false, false) => "app.preferences",
@ -133,10 +152,11 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
("New Presentation", "file.new"),
("Open…", "app.openDialog"),
("-", ""),
("Close", "file.close"),
("Close", "app.closePresentation"),
("Save", "file.save"),
("Save As…", "app.saveAsDialog"),
("Export…", "app.exportDialog"),
("Print…", "app.print"),
("-", ""),
("Properties", "file.properties"),
],

View File

@ -47,7 +47,7 @@ pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) {
child.spacing_mut().item_spacing.x = 2.0;
let has_doc = app.session.active().is_some();
let dirty = app.session.active().is_some_and(|d| d.is_dirty());
if icon_toggle(&mut child, Icon::Home, "Home", true, app.home_open()).clicked() {
if icon_toggle(&mut child, Icon::Home, "Home", true, app.backstage_open()).clicked() {
let _ = app.run("app.home", json!({}));
}
if icon_toggle(&mut child, Icon::Save, "Save (⌘S)", has_doc, false).clicked() {
@ -143,9 +143,24 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
let h = 28.0 + if app.ui.ribbon_collapsed { 4.0 } else { RIBBON_H + 20.0 };
egui::Panel::top("ribbon").exact_size(h).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {
let rect = ui.max_rect();
// Tabs.
// Tabs. File comes first and opens the File tab's full-window view (Backstage), as in
// PowerPoint.
let mut x = rect.min.x + 12.0;
let y = rect.min.y + 2.0;
{
let w = ui.fonts_mut(|f| f.layout_no_wrap("File".to_string(), theme::font(13.0), t.text).size().x) + 18.0;
let r = Rect::from_min_size(pos2(x, y), vec2(w, 24.0));
let resp = ui.interact(r, ui.id().with("tab_file"), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "File"));
if resp.hovered() {
ui.painter().rect_filled(r.shrink2(vec2(2.0, 2.0)), CornerRadius::same(4), t.hover);
}
ui.painter().text(r.center(), Align2::CENTER_CENTER, "File", theme::font(13.0), t.text);
if resp.on_hover_text("File (Alt+F)").clicked() {
app.open_backstage(crate::backstage::Page::Home);
}
x += w + 2.0;
}
let all: Vec<(&str, bool)> = TABS.iter().map(|s| (*s, false)).chain(ctx_tabs.iter().map(|s| (*s, true))).collect();
for (name, contextual) in all {
let font = theme::font(13.0);