diff --git a/ROADMAP.md b/ROADMAP.md index ec5a6ed2..4adde1a7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 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 | @@ -152,6 +153,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 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 dbc0a8ae..644eecd6 100644 --- a/crates/anim/src/show.rs +++ b/crates/anim/src/show.rs @@ -53,7 +53,7 @@ pub enum LinkJump { } /// Where a slide show is. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +#[derive(Clone, Debug, PartialEq, Eq, Default)] pub struct ShowState { /// Index into `presentation.slides`. pub slide: usize, @@ -63,6 +63,10 @@ pub struct ShowState { pub playing: bool, /// On the end screen. pub ended: bool, + /// 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 @@ -83,6 +87,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" } @@ -104,9 +120,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) { @@ -128,7 +183,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), @@ -136,7 +191,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), @@ -165,7 +220,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) @@ -215,7 +270,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 { @@ -247,11 +302,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 1fd82d78..e16f77fd 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 8e3a532c..7148a169 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Set Up Slide Show…", ["Slide Show", "Set Up"], None, - "{type?: speaker|browsed|kiosk, loop?: bool, noNarration?, noAnimation?, useTimings?, from?, to?}", + "{type?: speaker|browsed|kiosk, loop?: bool, noNarration?, noAnimation?, useTimings?, from?, to?, custom?: name}", has_doc, setup ), @@ -245,6 +245,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()) }) } @@ -264,3 +275,23 @@ fn custom_show(s: &mut Session, p: &Value) -> Result { })?; ok() } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use super::*; + + #[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 c7e7fc4e..e0cf76df 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 d1934be9..12d72ba9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -247,10 +247,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 } @@ -575,14 +590,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 ec97e6e6..9b4ae09c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -151,7 +151,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.collapseRibbon", "Collapse the Ribbon", Some("Cmd+F1"), "{}"), ("view.dark", "Dark Mode", None, "{on?: bool}"), ("view.grayscale", "Grayscale", None, "{on?: bool}"), - ("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?}"), @@ -494,8 +494,14 @@ impl SlideApp { } "view.grayscale" => self.ui.grayscale = self.flag(p, self.ui.grayscale), "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" => { @@ -554,6 +560,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() { @@ -561,6 +572,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 25398b45..2a254cbb 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 428eff0b..3c1c9611 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -60,7 +60,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()); @@ -1707,7 +1707,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); @@ -1728,7 +1728,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; } @@ -1757,7 +1757,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 51a84c4f..6b472c9d 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -338,8 +338,16 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { } P if m.command => show.pen = !show.pen, A if m.command => show.pen = false, - 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(&mut show, Color32::BLACK), W | Comma => toggle_blank(&mut show, Color32::WHITE), E => show.strokes.clear(), @@ -928,6 +936,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); @@ -1233,7 +1242,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 a20fd991..9d06bf91 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, 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: G is See All Slides, Z arms zoom, T pauses the presenter timer, R resets it, `[` and `]` change notes size, Esc closes the grid and zoom before it leaves the show) | @@ -26,7 +26,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 02db563b..786f1b65 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # DeckCraft roadmap: milestones and what's next -> **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 @@ -17,7 +17,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`; threads are flattened on `.pptx` save (#70); spelling is a small built-in list | 4–8 | @@ -109,6 +109,7 @@ plan above. | Date | Change | Summary | |---|---|---| +| 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 582aac8d..de5ad1ec 100644 --- a/docs/slideshow-parity.md +++ b/docs/slideshow-parity.md @@ -1,6 +1,6 @@ # Slide show and presenter view: parity with PowerPoint -> **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 @@ -19,7 +19,7 @@ ready** (estimated), **10–16 h** to parity. | End screen, Esc to leave | yes | yes | | | Step clock: a click after a long pause plays its step (#11) | yes | yes | | | Hidden slides skipped | yes | yes | | -| Hyperlinks and action settings followed (slides, web, mail, End Show, Last Viewed) | yes | yes | Only `http(s)` and `mailto` open outside the show, by design (`safe_url`) | +| 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 | | Animated GIFs play; pause from their corner button | yes | yes (#39) | | | See All Slides grid during the show | yes | yes: G, or All on the show bar and in presenter view; click a thumbnail, Esc closes the grid | Hidden slides are labelled; the first 48 slides are shown | @@ -27,7 +27,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 | **no** | 1–2 h | | 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 | | @@ -72,6 +72,7 @@ Reading View in a window with navigation: present (checklist row done). | Date | Change | Summary | |---|---|---| +| 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 1a71823d..7f6540a7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DeckCraft vs Microsoft PowerPoint: parity assessment -> **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; @@ -229,8 +229,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, accessibility checker, a basic spelling list. @@ -277,6 +277,7 @@ scored done (16 open issues). | Date | Change | Summary | |---|---|---| +| 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 |