diff --git a/ROADMAP.md b/ROADMAP.md index 13e146bb..2d6f848f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -180,6 +180,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-13 | Playback stays with its document and its clip (#415): switching decks or closing the last one stops the previous sound, replacing a clip plays the new one, and the duration and decode caches keep the bytes they are keyed by | | 2026-10-13 | Slide show: a laser pointer (Cmd/Ctrl+L, the show's laser colour, no ink). Checklist row Partial; weighted breadth stays 79%, P2 summary 39%, slide show area 68%. The presenter window does not move the dot | | 2026-10-14 | Slide Show: Show Media Controls writes the show setting and hides the playback bar and GIF button during the show | +| 2026-10-14 | A custom show plays only its slides: Show in the Custom Shows dialog, Set Up Slide Show, or a click action. Breadth stays 79% | | 2026-10-14 | Presenter view pauses and resets its timer (T, R) and changes notes size (A-/A+, brackets). The clock shows local time. Second-display placement stays with #433. Breadth stays 79% | | 2026-10-14 | See All Slides and zoom during a show: a thumbnail grid (G, or All), click to jump, and click-to-zoom at 2× (Z). Presenter view has Black, White, All slides and Zoom. Breadth stays 79% | | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | @@ -230,6 +231,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-13 | trivial | Progress log: media playback follows the open document and clip (#415) | | 2026-10-13 | trivial | Progress log: laser pointer. Headline P2 36% → 39% to match the checklist (the row moves P2 unweighted 38% → 39%; breadth stays 79%) | | 2026-10-14 | trivial | Progress log: Show Media Controls | +| 2026-10-14 | trivial | Progress log: custom show playback. Breadth stays 79% | | 2026-10-14 | trivial | Progress log: presenter timer pause/reset, clock and notes size. Breadth stays 79% | | 2026-10-14 | trivial | Progress log: See All Slides and zoom during a show. Breadth stays 79% | | 2026-10-13 | trivial | Progress log: wild text cases | diff --git a/crates/anim/src/show.rs b/crates/anim/src/show.rs index 4fc0d5e1..2c49cc0c 100644 --- a/crates/anim/src/show.rs +++ b/crates/anim/src/show.rs @@ -74,6 +74,10 @@ pub struct ShowState { pub(crate) last_now: Option, /// What was played on each visited slide (index into `presentation.slides`). pub(crate) runs: BTreeMap, + /// One-shot visit order (Custom Show from the dialog or a click). `None` uses [`show_order`]. + order: Option>, + /// Name of the custom show being played, when there is one. + custom: Option, } /// Slide indices the show visits in order: the custom show or range from Set Up Slide Show, minus @@ -94,6 +98,18 @@ pub fn show_order(pres: &Presentation) -> Vec { base.into_iter().filter(|i| pres.slides.get(*i).is_some_and(|s| !s.hidden)).collect() } +/// Visible slides of custom show `name`, in that show's order. +fn custom_indices(pres: &Presentation, name: &str) -> Option> { + let cs = pres.custom_shows.iter().find(|c| c.name == name)?; + let order: Vec = cs + .slides + .iter() + .filter_map(|id| pres.slides.iter().position(|s| s.id == *id)) + .filter(|&i| pres.slides.get(i).is_some_and(|s| !s.hidden)) + .collect(); + if order.is_empty() { None } else { Some(order) } +} + fn looping(pres: &Presentation) -> bool { pres.show.loop_until_esc || pres.show.show_type == "kiosk" } @@ -115,9 +131,48 @@ impl ShowState { } else { s.ended = true; } + if let Some(name) = pres.show.custom_show.clone() { + s.custom = Some(name); + } s } + /// Play custom show `name` from its first slide, without changing Set Up Slide Show. + /// `None` when that show is missing or has no visible slides. + pub fn play_custom(pres: &Presentation, name: &str) -> Option { + let order = custom_indices(pres, name)?; + let &first = order.first()?; + let mut s = ShowState::start_at(pres, first); + s.order = Some(order); + s.custom = Some(name.to_string()); + Some(s) + } + + /// Name of the custom show this playback is limited to. + pub fn custom_name(&self) -> Option<&str> { + self.custom.as_deref().filter(|s| !s.is_empty()) + } + + /// The slide that follows the current one in this show. + pub fn peek_next(&self, pres: &Presentation) -> Option { + self.next_index(pres) + } + + pub fn first_index(&self, pres: &Presentation) -> Option { + self.visit(pres).first().copied() + } + + pub fn last_index(&self, pres: &Presentation) -> Option { + self.visit(pres).last().copied() + } + + fn visit(&self, pres: &Presentation) -> Vec { + match &self.order { + Some(order) => order.iter().copied().filter(|&i| pres.slides.get(i).is_some_and(|s| !s.hidden)).collect(), + None => show_order(pres), + } + } + /// The timeline of slide `i`. pub fn timeline(pres: &Presentation, i: usize) -> Timeline { match pres.slides.get(i) { @@ -165,7 +220,7 @@ impl ShowState { } fn next_index(&self, pres: &Presentation) -> Option { - let order = show_order(pres); + let order = self.visit(pres); match order.iter().position(|&i| i == self.slide) { Some(p) => order.get(p + 1).copied(), None => order.iter().copied().find(|&i| i > self.slide), @@ -173,7 +228,7 @@ impl ShowState { } fn prev_index(&self, pres: &Presentation) -> Option { - let order = show_order(pres); + let order = self.visit(pres); match order.iter().position(|&i| i == self.slide) { Some(p) => p.checked_sub(1).and_then(|q| order.get(q).copied()), None => order.iter().copied().rev().find(|&i| i < self.slide), @@ -202,7 +257,7 @@ impl ShowState { self.enter(pres, i); ShowAction::Slide(i) } - None if looping(pres) => match show_order(pres).first() { + None if looping(pres) => match self.visit(pres).first() { Some(&i) => { self.enter(pres, i); ShowAction::Slide(i) @@ -252,7 +307,7 @@ impl ShowState { } let target = match self.prev_index(pres) { Some(i) => Some(i), - None if looping(pres) => show_order(pres).last().copied().filter(|&i| i != self.slide), + None if looping(pres) => self.visit(pres).last().copied().filter(|&i| i != self.slide), None => None, }; match target { @@ -289,11 +344,18 @@ impl ShowState { Action::Slide { slide } => pres.slides.iter().position(|s| s.id == *slide), Action::NextSlide => self.next_index(pres), Action::PreviousSlide => self.prev_index(pres), - Action::FirstSlide => show_order(pres).first().copied(), - Action::LastSlide => show_order(pres).last().copied(), + Action::FirstSlide => self.visit(pres).first().copied(), + Action::LastSlide => self.visit(pres).last().copied(), Action::LastViewed => last_viewed, - // Custom shows, programs and media actions aren't followed (yet). - Action::CustomShow { .. } | Action::Program { .. } | Action::PlayMedia => None, + Action::CustomShow { name } => { + let Some(order) = custom_indices(pres, name) else { return LinkJump::None }; + let Some(&i) = order.first() else { return LinkJump::None }; + self.order = Some(order); + self.custom = Some(name.clone()); + return LinkJump::Show(self.goto(pres, i)); + } + // Programs and media actions aren't followed. + Action::Program { .. } | Action::PlayMedia => None, }; match target { Some(i) => LinkJump::Show(self.goto(pres, i)), diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs index 3e9208cb..f8fdfd4b 100644 --- a/crates/anim/src/tests.rs +++ b/crates/anim/src/tests.rs @@ -702,6 +702,37 @@ fn show_custom_show_and_range() { assert_eq!(show_order(&p), vec![0, 1, 2, 3, 4]); } +#[test] +fn playing_a_custom_show_stays_on_its_slides() { + use deckcraft_model::text::Action; + let mut p = pres(5); + p.custom_shows = vec![CustomShow { name: "Ends".into(), slides: vec![SlideId(1000), SlideId(1004)] }]; + assert!(p.show.custom_show.is_none()); + let mut s = ShowState::play_custom(&p, "Ends").expect("the show exists"); + assert_eq!(s.custom_name(), Some("Ends")); + assert_eq!(s.slide, 0); + assert_eq!(s.peek_next(&p), Some(4)); + assert_eq!(s.next(&p), ShowAction::Slide(4)); + assert_eq!(s.next(&p), ShowAction::End); + assert_eq!(s.prev(&p), ShowAction::Slide(4)); + assert_eq!(s.prev(&p), ShowAction::Slide(0)); + assert!(ShowState::play_custom(&p, "Missing").is_none()); + assert!(p.show.custom_show.is_none(), "one-shot playback does not change Set Up"); + + Arc::make_mut(&mut p.slides[4]).hidden = true; + let only = ShowState::play_custom(&p, "Ends").expect("the visible slide remains"); + assert_eq!(only.first_index(&p), Some(0)); + assert_eq!(only.last_index(&p), Some(0)); + + let mut s = ShowState::new(&p); + p.slides[4] = Arc::new(Slide { id: SlideId(1004), ..Default::default() }); + assert_eq!(s.follow(&p, &Action::CustomShow { name: "Ends".into() }, None), LinkJump::Show(ShowAction::Slide(0))); + assert_eq!(s.custom_name(), Some("Ends")); + assert_eq!(s.next(&p), ShowAction::Slide(4)); + assert_eq!(s.follow(&p, &Action::CustomShow { name: "Missing".into() }, None), LinkJump::None); + assert_eq!(s.slide, 4, "an unknown custom show leaves the current slide"); +} + #[test] fn show_steps_through_builds() { let mut p = pres(2); diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 75d43883..1067d131 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Set Up Slide Show…", ["Slide Show", "Set Up"], None, - "{type?: speaker|browsed|kiosk, loop?: bool, noNarration?, noAnimation?, useTimings?, showMediaControls?, from?, to?}", + "{type?: speaker|browsed|kiosk, loop?: bool, noNarration?, noAnimation?, useTimings?, showMediaControls?, from?, to?, custom?: name}", has_doc, setup ), @@ -320,6 +320,17 @@ fn setup(s: &mut Session, p: &Value) -> Result { (None, None) if p.get("all").is_some() => sh.range = None, _ => {} } + if p.get("all").and_then(Value::as_bool) == Some(true) { + sh.range = None; + sh.custom_show = None; + } + if p.get("custom").is_some() { + let name = p.get("custom").and_then(Value::as_str).unwrap_or(""); + sh.custom_show = if name.is_empty() { None } else { Some(name.to_string()) }; + if sh.custom_show.is_some() { + sh.range = None; + } + } Ok(serde_json::to_value(&*sh).unwrap_or_default()) }) } @@ -415,4 +426,17 @@ mod tests { s.execute("show.setup", &json!({"showMediaControls": true})).expect("on"); assert!(s.doc().expect("doc").doc.show.show_media_controls); } + + #[test] + fn setup_remembers_a_custom_show() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("show.customShow", &json!({"name": "Short", "slides": [0, 1]})).unwrap(); + let v = s.execute("show.setup", &json!({"custom": "Short"})).unwrap(); + assert_eq!(v.get("customShow").and_then(Value::as_str), Some("Short")); + assert!(s.active().unwrap().doc.show.range.is_none()); + s.execute("show.setup", &json!({"custom": ""})).unwrap(); + assert!(s.active().unwrap().doc.show.custom_show.is_none()); + } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 5f8e0aaa..09379b50 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -300,8 +300,15 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> ok(Value::Null) } "show.start" => { - let from = p.get("from").and_then(Value::as_u64).unwrap_or(0) as usize; - app.start_show(from, p.get("reading").and_then(Value::as_bool).unwrap_or(false)); + let reading = p.get("reading").and_then(Value::as_bool).unwrap_or(false); + if let Some(name) = p.get("custom").and_then(Value::as_str).filter(|s| !s.is_empty()) { + app.start_custom_show(name, reading); + } else if p.get("from").is_some() { + let from = p.get("from").and_then(Value::as_u64).unwrap_or(0) as usize; + app.start_show(from, reading); + } else { + app.start_show(app.show_start_index(), reading); + } ok(Value::Null) } "show.end" => { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 204f6c1d..859cf5e7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -262,10 +262,25 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let nn = check(ui, d, "Show without narration", "nn", sh.without_narration); let na = check(ui, d, "Show without animation", "na", sh.without_animation); let ut = check(ui, d, "Use timings, if present", "ut", sh.use_timings); + let names: Vec = app.session.active().map(|s| s.doc.custom_shows.iter().map(|c| c.name.clone()).collect()).unwrap_or_default(); + let chosen = d.fields.entry("custom".into()).or_insert_with(|| sh.custom_show.clone().unwrap_or_default()).clone(); + ui.label("Slides"); + let label = if chosen.is_empty() { "All slides".to_string() } else { chosen.clone() }; + egui::ComboBox::from_id_salt("setup_custom").selected_text(label).show_ui(ui, |ui| { + if ui.selectable_label(chosen.is_empty(), "All slides").clicked() { + d.fields.insert("custom".into(), String::new()); + } + for n in &names { + if ui.selectable_label(&chosen == n, n).clicked() { + d.fields.insert("custom".into(), n.clone()); + } + } + }); let (ok, cancel) = buttons(ui, "OK"); if ok { let ty = d.get("type", "speaker"); - run(app, "show.setup", json!({"type": ty, "loop": lp, "noNarration": nn, "noAnimation": na, "useTimings": ut})); + let custom = d.fields.get("custom").cloned().unwrap_or_default(); + run(app, "show.setup", json!({"type": ty, "loop": lp, "noNarration": nn, "noAnimation": na, "useTimings": ut, "custom": custom})); } ok || cancel } @@ -712,14 +727,22 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } "customShow" => { let shows = app.session.active().map(|s| s.doc.custom_shows.clone()).unwrap_or_default(); + let mut started = false; for s in &shows { ui.horizontal(|ui| { ui.label(format!("{} ({} slides)", s.name, s.slides.len())); + if ui.small_button("Show").clicked() { + run(app, "show.start", json!({"custom": s.name})); + started = true; + } if ui.small_button("Delete").clicked() { let _ = app.run("show.customShow", json!({"name": s.name, "delete": true})); } }); } + if started { + return true; + } field(ui, d, "New show name:", "name", "Custom Show 1"); field(ui, d, "Slides (e.g. 1,3,5):", "slides", "1"); let (ok, cancel) = buttons(ui, "Create"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b42b52e3..6a51d5cb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -172,7 +172,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.dark", "Dark Mode", None, "{on?: bool}"), ("view.grayscale", "Grayscale", None, "{on?: bool}"), ("view.newWindow", "New Window", None, "{on?: bool, slide?: index, delta?: int}"), - ("show.start", "Slide Show", Some("F5"), "{from?: index}"), + ("show.start", "Slide Show", Some("F5"), "{from?: index, custom?: name}"), ("show.presenter", "Presenter View", None, "{on?: bool}"), ("show.end", "End Show", None, "{}"), ("app.home", "Home", None, "{on?}"), @@ -548,8 +548,14 @@ impl SlideApp { "view.grayscale" => self.ui.grayscale = self.flag(p, self.ui.grayscale), "view.newWindow" => return doc_window::command(self, p), "show.start" => { - let from = p.get("from").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(0); - self.start_show(from, false); + if let Some(name) = p.get("custom").and_then(Value::as_str).filter(|s| !s.is_empty()) { + self.start_custom_show(name, false); + } else if p.get("from").is_some() { + let from = p.get("from").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(0); + self.start_show(from, false); + } else { + self.start_show(self.show_start_index(), false); + } } "show.presenter" => self.ui.presenter_view = self.flag(p, self.ui.presenter_view), "show.end" => { @@ -609,6 +615,11 @@ impl SlideApp { Ok(Value::Null) } + /// First slide Play from Start should open: the custom show or range, skipping hidden slides. + pub fn show_start_index(&self) -> usize { + self.session.active().and_then(|d| deckcraft_anim::show_order(&d.doc).first().copied()).unwrap_or(0) + } + pub fn start_show(&mut self, from: usize, reading: bool) { self.media.stop_owned(media::Owner::Editor); if let Some(d) = self.session.active() { @@ -616,6 +627,17 @@ impl SlideApp { } } + /// Play custom show `name` from its first slide. Set Up Slide Show is left as it was. + pub fn start_custom_show(&mut self, name: &str, reading: bool) { + let state = self.session.active().and_then(|d| deckcraft_anim::ShowState::play_custom(&d.doc, name)); + let Some(state) = state else { return }; + let from = state.slide; + self.start_show(from, reading); + if let Some(show) = self.show.as_mut() { + show.state = state; + } + } + fn pick_and_open(&mut self) { if let Some(pick) = self.services.pick_open.as_mut() { if let Some(path) = pick("open") diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d2a216e5..e6312cfc 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -12,7 +12,7 @@ pub fn ui_shortcut(app: &mut SlideApp, key: egui::Key, m: Mods) -> bool { use egui::Key::*; let id = match (key, m.cmd, m.shift, m.alt) { (F5, _, false, _) => { - app.start_show(0, false); + app.start_show(app.show_start_index(), false); return true; } (F5, _, true, _) | (Enter, true, false, false) => { @@ -21,7 +21,7 @@ pub fn ui_shortcut(app: &mut SlideApp, key: egui::Key, m: Mods) -> bool { return true; } (Enter, true, true, _) => { - app.start_show(0, false); + app.start_show(app.show_start_index(), false); return true; } (S, true, false, false) => { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 5a512f47..f79654d4 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -63,7 +63,7 @@ pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) { run(app, "edit.redo", json!({})); } if icon_toggle(&mut child, Icon::PlayFromStart, "Play from Start (F5)", has_doc, false).clicked() { - app.start_show(0, false); + app.start_show(app.show_start_index(), false); } let title = app.session.active().map(|d| format!("{}{}", d.title(), if dirty { " — Edited" } else { "" })).unwrap_or_else(|| "DeckCraft".into()); @@ -1825,7 +1825,7 @@ pub fn paint_anim_tile(p: &egui::Painter, r: Rect, label: &str, class: AnimClass fn slide_show(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { if big_button(ui, Icon::PlayFromStart, "Play from\nStart", true).clicked() { - app.start_show(0, false); + app.start_show(app.show_start_index(), false); } if big_button(ui, Icon::PlayFromCurrent, "Play from\nCurrent Slide", true).clicked() { let i = app.session.active().map(|d| d.selection.slide).unwrap_or(0); @@ -1846,7 +1846,7 @@ fn slide_show(app: &mut SlideApp, ui: &mut Ui) { }); group(ui, |ui| { if big_button(ui, Icon::RehearseTimings, "Rehearse\nTimings", true).clicked() { - app.start_show(0, false); + app.start_show(app.show_start_index(), false); if let Some(s) = app.show.as_mut() { s.rehearse = true; } @@ -1877,7 +1877,7 @@ fn record(app: &mut SlideApp, ui: &mut Ui) { let r = big_button(ui, Icon::Cameo, "Cameo", false); let _ = r.on_hover_text("Camera capture is not available yet"); if big_button(ui, Icon::PlayFromStart, "From\nBeginning", true).clicked() { - app.start_show(0, false); + app.start_show(app.show_start_index(), false); if let Some(s) = app.show.as_mut() { s.rehearse = true; } diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index df907ccb..e23e8575 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -455,8 +455,16 @@ fn keyboard(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64) P if m.command => show.set_pen(!show.pen), L if m.command => show.set_laser(!show.laser), A if m.command => show.arrow(), - Home => actions.push(show.state.goto(doc, 0)), - End => actions.push(show.state.goto(doc, doc.slides.len().saturating_sub(1))), + Home => { + if let Some(i) = show.state.first_index(doc) { + actions.push(show.state.goto(doc, i)); + } + } + End => { + if let Some(i) = show.state.last_index(doc) { + actions.push(show.state.goto(doc, i)); + } + } B | Period => toggle_blank(show, Color32::BLACK), W | Comma => toggle_blank(show, Color32::WHITE), E => show.strokes.clear(), @@ -1318,6 +1326,7 @@ impl Show { "presenterPaused": self.timer.paused(), "presenterSecs": self.timer.elapsed(now), "notesPx": self.notes_px, + "custom": self.state.custom_name(), }); if self.grid { let slides = grid_indices(n_slides); @@ -1623,7 +1632,7 @@ fn render_thumb(ctx: &egui::Context, show: &mut Show, doc: &Presentation, i: usi fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64, keep: &mut bool) { let cur = show.state.slide; - let next = deckcraft_anim::show_order(doc).into_iter().find(|i| *i > cur); + let next = show.state.peek_next(doc); let cur_tex = show.tex.as_ref().map(|(_, t)| t.clone()); if let Some(n) = next && show.next_tex.as_ref().is_none_or(|(k, _)| *k != n) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4b60c473..4bce5ea1 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state (`grid`, `zoomArm`, `zoom`, `blank`, `presenterPaused`, `presenterSecs`, `notesPx`; `cells` while See All Slides is open), 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 (`grid`, `zoomArm`, `zoom`, `blank`, `presenterPaused`, `presenterSecs`, `notesPx`, `custom`; `cells` while See All Slides is open), 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\|contextclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse (`contextclick`: the right-button press that selects before the context menu) | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show) | @@ -28,7 +28,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note | `ui.screenshot` | `{path?}` | PNG of the whole window (base64 when no path) | | `ui.render` | `{path?, slide?, scale?}` | Render a slide headlessly | | `ui.resize` / `ui.focus` | | Window control | -| `show.start` / `show.end` | `{from?: slide index, presenter?, reading?}` | Slide show | +| `show.start` / `show.end` | `{from?: slide index, custom?: name, reading?}` | Slide show. `custom` plays that custom show from its first slide and does not change Set Up. With no `from` and no `custom`, playback starts at the first slide of the show order | | `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}`) | The MCP server (`deckcraft-cli mcp`, see [mcp.md](mcp.md)) wraps the same methods for Claude diff --git a/docs/roadmap.md b/docs/roadmap.md index 7e2695b2..729e6c29 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -12,6 +12,7 @@ > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (owned PPTX corpus covers more deck kinds; handout-master text kept; previous: minor, core-workflow alpha gate) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (show: See All Slides and zoom; previous: minor, core-workflow alpha gate) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (presenter timer pause/reset, clock and notes size; previous: trivial, See All Slides and zoom) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (custom show playback; previous: trivial, presenter timer) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Forward-looking: where DeckCraft is going and in what order. The one-page summary is [ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in @@ -28,7 +29,7 @@ points and lead the beta plan. |---|---|---|---| | Build a deck from scratch (theme, layouts, titles and bullets, shapes, pictures from file), save, close and reopen it | yes | Commands for all of it; `.deckcraft` and `.pptx` round-trip tests (`crates/pptx/tests/roundtrip.rs`, `crates/format`); AutoRecover. Close discards edits without asking (#60, PR #75); image *paste* fails on Windows/Linux (#53), though insert-from-file works | 1–3 | | Open a colleague's `.pptx`, edit it, save it back as `.pptx` that PowerPoint opens | partial (not blocking) | The reader is lenient and keeps unknown objects. Files open in PowerPoint without repair (checked 2026-10-06). But only generated decks have been tested; text inside EMF/WMF pictures is missing; comment threads, media options and rewind are lost (#70, #74, #77) | 31–55 | -| Present: full-screen show with transitions, animations, media, presenter view with notes, next slide and timer | yes | 48/48 transitions, 64 effects, triggers, builds, media playback, presenter window, See All Slides and zoom. The presenter timer pauses and resets, and notes have a size control. Presenter view isn't placed on the second display automatically (drag it there; #433) | 2–4 | +| Present: full-screen show with transitions, animations, media, presenter view with notes, next slide and timer | yes | 48/48 transitions, 64 effects, triggers, builds, media playback, presenter window, See All Slides and zoom. The presenter timer pauses and resets, and notes have a size control. A custom show plays only its slides. Presenter view isn't placed on the second display automatically (drag it there; #433) | 2–4 | | Add business content: tables, charts with their data, simple diagrams | partial (not blocking) | Tables with styles, merge/split, all D or P. Charts insert with the common types and edit data in the Chart Data dialog (P0 partial); SmartArt is basic (pre-drawn shapes) | 12–20 | | Share the deck: PDF of slides, notes or handouts; images | yes | `file.export` PDF with text layer, links, bookmarks, handouts 1–9; PNG/JPEG. Artwork is raster in the PDF; no direct print (print the PDF) | 10–16 | | Review: comments with replies, resolve, spelling | partial (not blocking) | Comments, replies and resolve work and survive `.deckcraft` and `.pptx` (#70); spelling is a small built-in list | 4–8 | @@ -154,6 +155,7 @@ plan above. | 2026-10-14 | trivial | Review workflow: previous and next comment across the deck | | 2026-10-14 | minor | After beta: six original starter decks exist; a larger template gallery remains | | 2026-10-13 | trivial | Owned PPTX corpus covers tables, charts, animations, media, sections and masters; handout-master text kept. Beta item 2 stays open for a larger corpus | +| 2026-10-14 | trivial | Present workflow notes custom show playback | | 2026-10-14 | trivial | Present workflow notes presenter timer pause/reset, clock and notes size. Display placement stays open | | 2026-10-14 | trivial | Present workflow notes See All Slides and zoom | | 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha | diff --git a/docs/slideshow-parity.md b/docs/slideshow-parity.md index f4dcd2c4..555a1816 100644 --- a/docs/slideshow-parity.md +++ b/docs/slideshow-parity.md @@ -7,6 +7,7 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Show Media Controls hides the show's playback bar and GIF button; breadth stays 66%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (See All Slides and zoom during the show; black and white from the presenter window; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (presenter timer pause/reset, clock and notes size; previous: minor, See All Slides and zoom) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (custom show playback; previous: minor, presenter timer pause/reset) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Presenting is the job PowerPoint is hired for. This doc covers the show window, presenter view, rehearsal and recording. Transition and animation playback are in @@ -29,6 +30,7 @@ ready** (estimated), **10–16 h** to parity. | A shape with a triggered animation plays it when clicked; shapes an animation has hidden cannot be clicked; a shape without an action on top of one with an action blocks it; the pointer is a hand over what can be clicked | yes | yes | Click boxes are bounding boxes; "play sound" actions (`ppaction://media`) do nothing yet | | A slide the show returns to (link, arrow keys) looks as it was left; a triggered animation goes on where it stopped | yes | yes | A main-sequence step still playing when the show leaves is finished | | "Advance on click" off: a click never leaves the slide (animation steps still play, keys still move on) | yes | yes | | +| Hyperlinks and action settings followed (slides, web, mail, End Show, Last Viewed, Custom Show) | yes | yes | Only `http(s)` and `mailto` open outside the show, by design (`safe_url`). A Custom Show action plays that show | | Media playback with controls, play across slides, full-screen video | yes | yes | WMA/WMV not decodable | | Media playback with controls, play across slides, full-screen video | yes | yes | Seeks and loops preserve new-generation video frames and end-of-stream; queue regression and repeated seeks across H.264/HEVC/VP9/AV1 in `crates/media`. WMA/WMV not decodable | | Media playback with controls, play across slides, full-screen video, Play/Pause/Stop in the click sequence | yes | yes | WMA/WMV not decodable; click sequence since #116 | @@ -42,7 +44,7 @@ ready** (estimated), **10–16 h** to parity. | Pen and highlighter ink | yes | pen only (Cmd+P pen, Cmd+A arrow, E erases all ink) | No highlighter, per-stroke eraser, ink colours, or keep/discard annotations prompt | | Laser pointer | yes | yes in the show | Cmd/Ctrl+L and the on-screen button draw the show's laser colour and leave no ink. The presenter window does not move that dot | | Subtitles and live captions | yes (cloud speech) | no | AI dimension | -| Custom shows | yes | yes | | +| Custom shows | yes | yes: Show plays that list in order and does not change Set Up; Set Up Slide Show can keep one for Play from Start | Hidden slides in the list are skipped | | Set Up Show: loop until Esc, kiosk (browsed at a kiosk), show without animation/narration, pen colour, slides range | yes | yes, most options | | | Use rehearsed timings | yes | yes | | @@ -100,6 +102,7 @@ Reading View in a window with navigation: present (checklist row done). | 2026-10-11 | minor | Media effects (Play, Pause, Stop) in the click sequence play with their step (#116) | | 2026-10-13 | minor | The show has a laser pointer (Cmd/Ctrl+L, the show's laser colour, no ink). Presenter view still does not drive it | | 2026-10-14 | trivial | Show Media Controls hides the show's playback bar and GIF button | +| 2026-10-14 | minor | A custom show plays only its slides, from the Custom Shows dialog, from Set Up Slide Show, or from a click action. Checklist breadth stays 66% | | 2026-10-14 | minor | Presenter view pauses and resets its timer (T, R) and changes notes size (A-/A+, brackets). The clock shows local time. Second-display placement and swap stay with #433. Checklist breadth stays 66% | | 2026-10-14 | minor | See All Slides (G) and click-to-zoom (Z) during the show. Presenter view opens that grid and sets black, white and zoom. Checklist breadth stays 66% | | 2026-10-10 | major | Created: show, presenter view, rehearse and record, compared with PowerPoint 16.113's Slide Show tab and menu | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a855342e..a1157107 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -7,6 +7,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (six original starter decks; ecosystem stays ~25% until the gallery is large; previous: minor, hours to ~95% per audience added; ready 56% → 55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (slide show lists See All Slides and zoom; breadth stays 79%; previous: minor, hours to ~95% per audience) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (slide show lists presenter timer pause/reset, clock and notes size; breadth stays 79%; previous: trivial, See All Slides and zoom) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (slide show plays a custom show; breadth stays 79%; previous: trivial, presenter timer) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?". [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall; @@ -236,8 +237,8 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc triggers, by-paragraph builds, Animation Painter, on a shared timeline engine. - **Slide show:** full screen, keyboard/mouse navigation, blank screens, go-to-slide, See All Slides, click-to-zoom, pen, presenter window (black, white, zoom, the slide grid, a timer that - pauses and resets, a local clock, and notes size), rehearse timings, custom shows, Set Up Show, - Reading View; links and shape actions followed on click. + pauses and resets, a local clock, and notes size), rehearse timings, custom shows played on + their own, Set Up Show, Reading View; links and shape actions followed on click. - **Views:** Normal, Outline, Slide Sorter, Notes Page, Reading View, Slide Master; zoom; grayscale. - **Review:** comments with replies and resolve, previous and next across the deck, accessibility checker, a basic spelling list. @@ -290,6 +291,7 @@ scored done (16 open issues). | 2026-10-14 | trivial | Size and Position dialog. Command count 226 → 227. Breadth stays 79%, ready stays ~55%, UI stays ~60% | | 2026-10-14 | trivial | 228 commands; Review lists previous and next comment. Breadth stays 79%; the comments checklist row stays D | | 2026-10-14 | minor | Ecosystem evidence: six original starter decks. The dimension stays ~25% because PowerPoint's large gallery is still absent | +| 2026-10-14 | trivial | Slide show evidence includes custom show playback. Breadth stays 79% | | 2026-10-14 | trivial | Slide show evidence includes presenter timer pause/reset, a local clock and notes size. Breadth stays 79% | | 2026-10-14 | trivial | Slide show evidence includes See All Slides and zoom. Breadth stays 79% | | 2026-10-10 | minor | Added the readiness-by-audience table: each number gets its own hours to ~95% (full 340–600, mainstream 150–250, essentials 50–90). The full number stays an additive weighted sum; no change to the percentages |