mirror of
https://github.com/aphrody-labs/deckcraft.git
synced 2026-10-13 19:26:55 +00:00
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:
1 parent
f6c143ac85
commit
1660982e58
16 files changed
+2178
-221
No files matched your search
@@ -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% |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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", ¶ms);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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, ¶ms);
|
||||
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(
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
Reference in new issue
Block a user