diff --git a/ROADMAP.md b/ROADMAP.md index 28c52d95..2dba0e27 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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% | diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index cab58058..f6424f7c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -15,7 +15,16 @@ pub fn specs() -> Vec { 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> { } } -/// 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 { 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> { + 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 = 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::().map_err(|_| invalid())?, b.parse::().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> { - 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 { 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 { } } +/// File › Info: what the Info page shows about the active presentation. +fn info(s: &mut Session, _p: &Value) -> Result { + 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 = 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 { if let Some(i) = s.active_index() { s.close_document(i); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 673f8ec3..e83f72ef 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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"); +} diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs new file mode 100644 index 00000000..f1d0fde3 --- /dev/null +++ b/crates/ui-egui/src/backstage.rs @@ -0,0 +1,1245 @@ +//! The File tab (Backstage, #76): a full-window view that replaces the ribbon, slides and status +//! bar until Esc or the back arrow returns to the presentation. With nothing open it is the start +//! screen: PowerPoint's start screen and File › Home are the same page. +//! +//! A side menu on the left picks a page (Home, New, Open, Info, Save As, Print, Export, Account); +//! Save, Close and Options act at once. Every action runs an existing command through +//! [`SlideApp::run`] or one of the app's file helpers ([`SlideApp::export_as`], +//! [`SlideApp::print_pdf`], [`SlideApp::save_as_dialog`]); the state here is UI-only and never +//! saved. Recent presentations are listed by path: nothing on disk is read until one is picked. +//! +//! Laid out after PowerPoint for Windows (observed black-box): a 160-point side menu darker than +//! the page, 40-point entries with an outline icon, the current page in the accent colour. + +use egui::{Align2, Color32, CornerRadius, Key, Rect, RichText, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::{Value, json}; + +use crate::SlideApp; +use crate::icons::{self, Icon}; +use crate::theme::{self, Tokens}; + +/// Width of the side menu. +pub const NAV_W: f32 = 160.0; +/// Height of a side-menu entry (the back arrow's row is a little taller). +const ITEM_H: f32 = 40.0; +const BACK_H: f32 = 46.0; +/// The widest a page's content grows. +const CONTENT_W: f32 = 1180.0; + +/// A page of the File tab. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Page { + Home, + New, + Open, + Info, + SaveAs, + Print, + Export, + Account, +} + +impl Page { + pub const ALL: [Page; 8] = [Page::Home, Page::New, Page::Open, Page::Info, Page::SaveAs, Page::Print, Page::Export, Page::Account]; + + /// The id `app.backstage {page}` takes. + pub fn id(self) -> &'static str { + match self { + Page::Home => "home", + Page::New => "new", + Page::Open => "open", + Page::Info => "info", + Page::SaveAs => "saveAs", + Page::Print => "print", + Page::Export => "export", + Page::Account => "account", + } + } + + pub fn parse(id: &str) -> Option { + let id = id.trim(); + Page::ALL.into_iter().find(|p| p.id().eq_ignore_ascii_case(id)) + } + + /// Side-menu label and page heading. + pub fn label(self) -> &'static str { + match self { + Page::Home => "Home", + Page::New => "New", + Page::Open => "Open", + Page::Info => "Info", + Page::SaveAs => "Save As", + Page::Print => "Print", + Page::Export => "Export", + Page::Account => "Account", + } + } + + fn icon(self) -> Icon { + match self { + Page::Home => Icon::Home, + Page::New => Icon::NewFile, + Page::Open => Icon::OpenFolder, + Page::Info => Icon::Info, + Page::SaveAs => Icon::SaveAs, + Page::Print => Icon::Print, + Page::Export => Icon::Export, + Page::Account => Icon::Account, + } + } + + /// Pages about the open presentation (hidden on the start screen). + pub fn needs_doc(self) -> bool { + matches!(self, Page::Info | Page::SaveAs | Page::Print | Page::Export) + } +} + +/// A side-menu entry: a page, or an action that runs at once. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Nav { + Page(Page), + Save, + Close, + Options, +} + +impl Nav { + fn label(self) -> &'static str { + match self { + Nav::Page(p) => p.label(), + Nav::Save => "Save", + Nav::Close => "Close", + Nav::Options => "Options", + } + } + fn icon(self) -> Icon { + match self { + Nav::Page(p) => p.icon(), + Nav::Save => Icon::Save, + Nav::Close => Icon::File, + Nav::Options => Icon::Settings, + } + } +} + +/// The side menu, top group then bottom group, in keyboard order. PowerPoint's Share (cloud) is +/// left out; with nothing open only Home, New and Open stay at the top. +pub fn entries(has_doc: bool) -> (Vec