File tab: PowerPoint's File view (Home, New, Open, Info, Save As, Print, Export, Close) (#76) (#344)

* Engine: file.info and PowerPoint-style print slide ranges (#76)

file.info reports what File › Info shows (name, path, format, unsaved
changes, slide and hidden-slide counts, sections, size on disk,
properties). file.export's PDF `slides` also takes the range typed in
PowerPoint's Print page ("1-3, 5", 1-based); bad or out-of-range text is
an error instead of a silent full export.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* 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>

* Docs: File tab (Backstage) in ui-parity, gaps, checklist and progress log (#76)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 authored and GitHub committed 2026-10-12 23:56:02 -04:00
1 parent f6c143ac85
commit 1660982e58
16 files changed
+2178 -221

No files matched your search

+2
View File
@@ -133,6 +133,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes |
| 2026-10-12 | Text keys like PowerPoint for Windows (#268): Ctrl+arrows and Ctrl+Shift+arrows by word (punctuation, CJK, emoji, RTL), Ctrl+Backspace/Delete with smart spacing, Ctrl+Up/Down by paragraph, Ctrl+Home/End, End on wrapped lines, Ctrl+]/[ and Ctrl+Shift+>/< font size, Shift+F3 case cycle, word and paragraph selection |
| 2026-10-12 | EMF/WMF pictures draw their text (fonts, alignment, dx advances, escapement, symbol fonts), pattern and hatch brushes, PATBLT and the XOR/AND see-through shadow; legacy OLE objects show their VML preview and ActiveX controls their pictures |
| 2026-10-12 | File tab (Backstage) like PowerPoint's (#76): File or Alt+F opens Home, New, Open, Info, Save, Save As, Print, Export, Close, Account and Options over the window, Esc or Back returns, arrows walk the side menu; the start screen is File › Home. Pages run the existing file commands; new `file.info`, print slide ranges (`"1-3, 5"`), Print through a PDF, Ctrl+P, and Close/Ctrl+W ask about unsaved changes |
| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip |
| 2026-10-12 | Render: line widths, dashes and arrowheads inside a group no longer scale with the group's child-coordinate mapping, so a group in a small legacy child space (scale about 1600) draws thin lines instead of covering the slide (#233) |
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
@@ -146,6 +147,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials |
| 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% |
+72 -9
View File
@@ -15,7 +15,16 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as),
cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as),
cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd),
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, dpi?, slides?: [index], includeHidden?, textLayer?, frame?}", has_doc, export),
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, dpi?, slides?: [index] | \"1-3,5\", includeHidden?, textLayer?, frame?}", has_doc, export),
cmd!(
query "file.info",
"Info",
["File"],
None,
"{} → {title, path, format, dirty, slides, hiddenSlides, sections, bytes?, properties}",
has_doc,
info
),
cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool} → {png: base64, width, height}", has_doc, render),
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{}", has_doc, close),
cmd!(
@@ -118,26 +127,59 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
}
}
/// PDF options from command params: `{layout: slides|notes|handouts, perPage, dpi, slides: [i],
/// includeHidden, textLayer, frame}`.
pub fn pdf_options(p: &Value) -> deckcraft_pdf::PdfOptions {
/// PDF options from command params: `{layout: slides|notes|handouts, perPage, dpi, slides,
/// includeHidden, textLayer, frame}` for a presentation of `count` slides. `slides` is a list of
/// 0-based indices or a range as typed in PowerPoint's Print page (`"1-3, 5"`, 1-based).
pub fn pdf_options(p: &Value, count: usize) -> Result<deckcraft_pdf::PdfOptions> {
let mut o = deckcraft_pdf::PdfOptions { layout: deckcraft_pdf::PageLayout::Slides, ..Default::default() };
o.layout = match str_param(p, "layout").unwrap_or("slides") {
"notes" => deckcraft_pdf::PageLayout::Notes,
"handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).min(9) as u8 },
"handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).clamp(1, 9) as u8 },
_ => deckcraft_pdf::PageLayout::Slides,
};
o.dpi = f64_or(p, "dpi", o.dpi).clamp(36.0, 600.0);
o.slides = p.get("slides").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as usize)).collect());
o.slides = match p.get("slides") {
Some(Value::Array(a)) => Some(a.iter().filter_map(|v| v.as_u64().map(|x| x as usize)).filter(|i| *i < count).collect()),
Some(Value::String(text)) if !text.trim().is_empty() => Some(parse_slide_range(text, count)?),
_ => None,
};
o.include_hidden = bool_or(p, "includeHidden", false);
o.text_layer = bool_or(p, "textLayer", true);
o.frame_slides = bool_or(p, "frame", true);
o
Ok(o)
}
/// A slide range as typed in the Print page's Slides box (`"1-3, 5"`; 1-based, either separator
/// `,` or `;`, ranges either way round) → 0-based indices in order, without repeats. Numbers past
/// the last slide or a range that isn't numbers is an error, as in PowerPoint.
pub fn parse_slide_range(text: &str, count: usize) -> Result<Vec<usize>> {
let invalid = || bad("file.export", format!("“{text}” isn't a valid slide range: use numbers and ranges such as 1-3, 5"));
let mut out: Vec<usize> = vec![];
for part in text.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) {
let (a, b) = match part.split_once('-') {
Some((a, b)) => (a.trim(), b.trim()),
None => (part, part),
};
let (a, b) = (a.parse::<usize>().map_err(|_| invalid())?, b.parse::<usize>().map_err(|_| invalid())?);
let (lo, hi) = (a.min(b), a.max(b));
if lo == 0 || hi > count {
return Err(bad("file.export", format!("the presentation has {count} slides; “{part}” is outside them")));
}
for i in lo - 1..hi {
if !out.contains(&i) {
out.push(i);
}
}
}
if out.is_empty() {
return Err(invalid());
}
Ok(out)
}
/// PDF bytes for `doc` with [`pdf_options`] from `p`.
pub fn pdf_bytes(doc: &Presentation, p: &Value) -> Result<Vec<u8>> {
deckcraft_pdf::export(doc, &pdf_options(p)).map_err(|e| EngineError::Other(e.to_string()))
deckcraft_pdf::export(doc, &pdf_options(p, doc.slides.len())?).map_err(|e| EngineError::Other(e.to_string()))
}
pub fn format_for_path(path: &str) -> &'static str {
@@ -264,7 +306,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"files": written}))
}
"pdf" => {
let bytes = deckcraft_pdf::export(&st.doc, &pdf_options(p)).map_err(|e| EngineError::Other(e.to_string()))?;
let bytes = pdf_bytes(&st.doc, p)?;
write_file(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}
@@ -276,6 +318,27 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
}
}
/// File › Info: what the Info page shows about the active presentation.
fn info(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.doc()?;
let doc = &st.doc;
#[cfg(not(target_arch = "wasm32"))]
let bytes = st.path.as_ref().and_then(|p| std::fs::metadata(p).ok()).map(|m| m.len());
#[cfg(target_arch = "wasm32")]
let bytes: Option<u64> = None;
Ok(json!({
"title": st.title(),
"path": st.path,
"format": st.path.as_deref().map(format_for_path),
"dirty": st.is_dirty(),
"slides": doc.slides.len(),
"hiddenSlides": doc.slides.iter().filter(|sl| sl.hidden).count(),
"sections": doc.sections.len(),
"bytes": bytes,
"properties": serde_json::to_value(&doc.props).unwrap_or_default(),
}))
}
fn close(s: &mut Session, _p: &Value) -> Result<Value> {
if let Some(i) = s.active_index() {
s.close_document(i);
+61
View File
@@ -774,3 +774,64 @@ fn ime_composition_underlines_follow_the_text() {
s.commit_text("日本語").unwrap();
assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty());
}
/// File › Info (#76): the active presentation's name, location, counts and properties.
#[test]
fn file_info_reports_the_presentation() {
let mut s = session();
s.execute("slide.new", &json!({})).unwrap();
s.execute("slide.new", &json!({})).unwrap();
s.execute("slide.go", &json!({"index": 1})).unwrap();
s.execute("slide.hide", &json!({})).unwrap();
s.execute("file.properties", &json!({"title": "Quarterly", "author": "Ada"})).unwrap();
let v = s.execute("file.info", &json!({})).unwrap();
assert_eq!(v["slides"], 3);
assert_eq!(v["hiddenSlides"], 1);
assert_eq!(v["path"], serde_json::Value::Null);
assert_eq!(v["dirty"], true);
assert_eq!(v["properties"]["title"], "Quarterly");
assert_eq!(v["properties"]["author"], "Ada");
assert!(v["title"].as_str().is_some_and(|t| !t.is_empty()));
// A query: no undo step, and nothing changes.
let before = s.doc().unwrap().history.undo.len();
s.execute("file.info", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), before);
// Saved to disk, it reports where and how big.
let dir = std::env::temp_dir().join(format!("deckcraft-info-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Info test.pptx").to_string_lossy().to_string();
s.execute("file.saveAs", &json!({"path": path})).unwrap();
let v = s.execute("file.info", &json!({})).unwrap();
assert_eq!(v["path"], path.as_str());
assert_eq!(v["format"], "pptx");
assert_eq!(v["dirty"], false);
assert!(v["bytes"].as_u64().is_some_and(|b| b > 0));
let _ = std::fs::remove_dir_all(&dir);
// Nothing open: an error, not a panic.
let mut empty = Session::new();
assert!(empty.execute("file.info", &json!({})).is_err());
}
/// The Print page's Slides box (#76): PowerPoint-style ranges, 1-based.
#[test]
fn slide_ranges_parse_like_powerpoints_print_box() {
use cmd::file::parse_slide_range as r;
assert_eq!(r("1-3, 5", 6).unwrap(), vec![0, 1, 2, 4]);
assert_eq!(r("3-1;2", 6).unwrap(), vec![0, 1, 2], "reversed ranges and repeats");
assert_eq!(r(" 6 ", 6).unwrap(), vec![5]);
assert!(r("7", 6).is_err(), "past the last slide");
assert!(r("0", 6).is_err());
assert!(r("a-b", 6).is_err());
assert!(r(" , ", 6).is_err());
assert!(r("1-99999999999999999999999", 6).is_err(), "hostile numbers are refused");
// file.export takes the same text for a PDF.
let mut s = session();
s.execute("slide.new", &json!({})).unwrap();
let doc = s.doc().unwrap().doc.clone();
let one = cmd::file::pdf_bytes(&doc, &json!({"slides": "2"})).unwrap();
let both = cmd::file::pdf_bytes(&doc, &json!({"slides": "1-2"})).unwrap();
assert!(one.len() < both.len());
assert!(cmd::file::pdf_bytes(&doc, &json!({"slides": "5"})).is_err());
let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap();
assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped");
}
File diff suppressed because it is too large. Load diff
+438
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");
}
+1 -1
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 };
+2 -1
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()),
+36 -168
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);
}
}
}
}
+34
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);
+209 -29
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(
+21 -1
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"),
],
+17 -2
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);
+1 -1
View File
@@ -15,7 +15,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
| `engine.execute` / `ui.menu.invoke` / `command` | `{command, params}` | Run any command (see `engine.commands`; `deckcraft-cli describe ID` documents one) |
| `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled / disabled reason |
| `document.inspect` | — | Slides, sections, layouts, shapes, selection |
| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, Home (start screen over the open presentations), slide show state, documents, perf (fps, render ms) |
| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state, documents, perf (fps, render ms) |
| `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` |
| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse |
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show) |
+2 -2
View File
@@ -197,8 +197,8 @@ Detail in [ui-parity.md](ui-parity.md).
blocker.**
- **Format Shape pane thinner than PowerPoint's** (Fill/Line/Effects/Size/Text Box pages, picture
and texture options, 3-D). 6–10 h. **Beta blocker.**
- **First-run and start screen** (#17, #76: missing File overlay, broken layout; recent files and
templates thin). 3–5 h.
- **File tab depth** (#76 landed the Backstage): no online templates, cloud places, printer
choice, video/GIF/CD exports or document protection. 4–8 h for the parts that need no cloud.
- **Window chrome** (#71 macOS controls misaligned, #26 GNOME title bar, #41 system light/dark).
2–4 h.
- **Icon consistency across Craft apps** (#73, #56). 2–4 h; coordinate in craftrules.
+4 -4
View File
@@ -7,13 +7,13 @@ The summary between `<!-- SUMMARY -->
| Priority | Features | Done | Partial | Missing | Parity |
|---|---|---|---|---|---|
| P0 | 72 | 60 | 12 | 0 | 92% |
| P0 | 72 | 61 | 11 | 0 | 92% |
| P1 | 65 | 38 | 21 | 6 | 75% |
| P2 | 54 | 13 | 14 | 27 | 37% |
| Area | Parity |
|---|---|
| Application shell | 71% |
| Application shell | 77% |
| Files | 71% |
| Slides | 90% |
| Views | 90% |
@@ -36,13 +36,13 @@ The summary between `<!-- SUMMARY -->
| Automation (✱) | 100% |
| Platforms (✱) | 67% |
Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design), Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending), Format Shape pane, Font, size, bold, italic, underline, strikethrough (only Regular/Bold/Italic faces, #69), Edit chart data, Crop (and crop to shape, aspect ratio), Paste image from clipboard (fails on Windows and Linux, #53), Audio/video playback in the show (WMA/WMV not decodable yet), Animation pane (reorder, timing), Presenter view (notes, next slide, timer), macOS, Windows, Linux builds.
Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design), Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending), Format Shape pane, Font, size, bold, italic, underline, strikethrough (only Regular/Bold/Italic faces, #69), Edit chart data, Crop (and crop to shape, aspect ratio), Paste image from clipboard (fails on Windows and Linux, #53), Audio/video playback in the show (WMA/WMV not decodable yet), Animation pane (reorder, timing), Presenter view (notes, next slide, timer), macOS, Windows, Linux builds.
## Rows
| Area | Feature | Priority | Status |
|---|---|---|---|
| Application shell | Start screen (new, templates/themes, recent) | P0 | P |
| Application shell | Start screen (new, templates/themes, recent) | P0 | D |
| Application shell | Ribbon with Home/Insert/Draw/Design/Transitions/Animations/Slide Show/Review/View tabs | P0 | D |
| Application shell | Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design) | P0 | P |
| Application shell | Quick Access toolbar / title bar commands | P1 | P |
+33 -3
View File
@@ -1,6 +1,6 @@
# UI parity with PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
@@ -18,13 +18,42 @@ AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to pa
| Menu-bar items with a DeckCraft equivalent | 217 app-specific items | 128 matched by name (59%, heuristic lower bound) | See [target-app-parity.md](target-app-parity.md) |
| Ribbon tabs: Home, Insert, Draw, Design, Transitions, Animations, Slide Show, Record, Review, View | 10 | 10 | Record tab is a placeholder |
| Contextual tabs: Shape Format, Picture Format, Table Design, Table Layout, Chart Design, Chart Format, Video/Audio Format and Playback | yes | most; checklist P0 row partial | Chart Format, media tabs thinner |
| Start screen: new, themes, templates, recent, open | yes | yes, thin (#17 broken layout, #76 missing overlay) | 3–5 h |
| File tab (Backstage): Home, New, Open, Info, Save, Save As, Print, Share, Export, Close, Account, Options | yes | yes (#76), see [File tab](#file-tab-backstage); no Share (cloud) | Video/GIF/CD exports, templates beyond the built-in themes |
| Start screen: new, themes, templates, recent, open | yes, the same page as File › Home | yes: File › Home with Home, New, Open, Account and Options (#76) | |
| Status bar: slide counter, language, notes, comments, views, zoom slider | yes | yes | |
| Command search (Tell Me) | yes | command palette | #79 default entry fails |
| Light / dark appearance | follows the system | light or dark; doesn't follow the system (#41) | 1–2 h |
| One window per presentation, New Window, Arrange All | yes | tabs in one window; no New Window | 3–5 h |
| Quick Access Toolbar customization | yes | no | P1 partial |
## File tab (Backstage)
Observed black-box in PowerPoint for Windows (Microsoft 365, Version 2609, October 2026) on a
synthetic presentation. Clicking **File** (or Alt+F) replaces the ribbon, slides and status bar with
a full-window view; the title bar stays. A side menu 160 points wide, a step darker than the page,
lists Back (a circled arrow), Home, New, Open, Share, a rule, Info, Save, Save As, Print, Export,
Close and, at the bottom under a rule, Account and Options. Entries are 40 points tall with an
outline icon; the current page is drawn in the accent colour and outlined. Esc goes back. Alt+F opens
on Home with the keyboard in the side menu; Up and Down move through it and a page shows as soon as
its entry has the focus. PowerPoint's start screen is the same Home page, with only Home, New and
Open at the top. DeckCraft follows all of this (`crates/ui-egui/src/backstage.rs`), with the page and
menu colours taken from the theme tokens (menu `chrome`, page `card`).
| Page | PowerPoint | DeckCraft |
|---|---|---|
| Home | Prompt box (Copilot), New row of themes with More themes, Recent / Favorites / Shared with Me with a file search, More presentations | DeckCraft's mark, Blank Presentation and the first themes with More themes →, Recent with a file search and More presentations → (to Open). No Copilot prompt, Favorites or Shared with Me |
| New | Blank Presentation, template search with suggested searches, gallery of online templates and themes | Blank Presentation, search box, the built-in themes and the sample deck (`file.new {theme}`). No online templates |
| Open | Recent, Shared with Me, OneDrive, This PC, Add a Place, Browse; Presentations / Folders; Recover Unsaved Presentations | Recent and Browse (`app.openDialog`), recent presentations with a search box (`file.open`), Recover Unsaved Presentations (`file.recovery.open`). No cloud places or Folders tab |
| Info | Name and path, Upload / Share / Copy path / Open file location; Protect, Inspect, Version History, Manage Presentation; Properties, Related Dates, Related People | Name and path, Copy path, Open file location; Inspect Presentation (Accessibility Checker), Manage Presentation (Revert); Size, Slides, Hidden slides and editable Title, Subject, Tags, Categories, Company, Comments, Author (`file.info`, `file.properties`). No Protect, Version History or dates |
| Save | Saves; a presentation never saved goes to Save As | Same (`file.save`) |
| Save As | Places, folder, file name, type, Save, More options | Type (PowerPoint, DeckCraft, PDF, Outline, PNG, JPEG) and Save, which opens the system Save dialog (web: Download). .pptx/.deckcraft take the new file; the others are copies (`file.saveAs`, `file.export`) |
| Print | Print and Copies, Printer, Print All / Selection / Current / Custom Range, Slides box, layout (Full Page Slides, Notes Pages, Outline, Handouts 1–9), sides, collation, colour, Edit Header & Footer; page preview with pager and zoom | Print, Print All Slides / Current Slide / Custom Range, Slides box (`1-3, 5`), Full Page Slides / Notes Pages / Handouts 1–9, Print Hidden Slides, Frame Slides, Edit Header & Footer; preview with pager. DeckCraft prints through a PDF (`file.export`) opened in the system viewer, which has the printers (web: downloaded). No printer list, copies, sides or colour |
| Share | Cloud sharing | Not offered |
| Export | Create PDF/XPS Document, Create a Video, Create an Animated GIF, Package Presentation for CD, Create Handouts, Change File Type | Create PDF Document, Create Handouts (a PDF), Change File Type. No video, GIF or CD package |
| Close | Closes; asks Save / Don't Save / Cancel about unsaved changes | Same (`file.close` after the prompt); Ctrl+W asks too |
| Account | User information, sign-in, Office Background and Office Theme, product information, About | Appearance (light/dark, `view.dark`), product information, About DeckCraft. No accounts to sign in to |
| Options | Opens the PowerPoint Options dialog | Opens Preferences |
## Panes and dialogs
| Pane / dialog | PowerPoint | DeckCraft |
@@ -109,7 +138,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
| Native macOS menu bar from `menu_tree()`, filled out to PowerPoint's menu items | 3–5 |
| Format Shape pane depth | 6–10 |
| Missing dialogs (Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 8–14 |
| Start screen, window chrome, system appearance, palette fixes (#17, #41, #71, #76, #79) | 4–7 |
| Window chrome, system appearance, palette fixes (#41, #71, #79) | 3–5 |
| Grid Options, edit points UI, crop depth | 4–7 |
| Multiple windows | 3–5 |
| A screenshot comparison pass on the most-used tabs | 2–4 |
@@ -119,6 +148,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home |
| 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) |
| 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock |
| 2026-10-12 | minor | Text editing keys: Ctrl+arrows, Ctrl+Backspace/Delete, Ctrl+Up/Down, Home/End on wrapped lines, Shift+F3, font size keys, as observed in PowerPoint for Windows (#268) |