mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Show: play a custom show
This commit is contained in:
@@ -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 |
|
||||
|
||||
+71
-9
@@ -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<Vec<usize>>,
|
||||
/// Name of the custom show being played, when there is one.
|
||||
custom: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<usize> {
|
||||
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<Vec<usize>> {
|
||||
let cs = pres.custom_shows.iter().find(|c| c.name == name)?;
|
||||
let order: Vec<usize> = 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<ShowState> {
|
||||
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<usize> {
|
||||
self.next_index(pres)
|
||||
}
|
||||
|
||||
pub fn first_index(&self, pres: &Presentation) -> Option<usize> {
|
||||
self.visit(pres).first().copied()
|
||||
}
|
||||
|
||||
pub fn last_index(&self, pres: &Presentation) -> Option<usize> {
|
||||
self.visit(pres).last().copied()
|
||||
}
|
||||
|
||||
fn visit(&self, pres: &Presentation) -> Vec<usize> {
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
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)),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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<Value> {
|
||||
(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<Value> {
|
||||
})?;
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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<String> = 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");
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-2
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user