Show: refuse a show.start from that is not a slide

show.start {from} turned a negative or fractional index into slide 0 and an index past the end into a show that opened on the end screen, over the command and the control channel alike. Both now refuse it, as the slide commands do since #1063; a hidden slide still plays.
This commit is contained in:
Marco Arnulfo
2026-10-23 14:48:45 +02:00
parent b47195feec
commit 90d40e2ecc
3 changed files with 51 additions and 9 deletions
+5 -4
View File
@@ -335,11 +335,12 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
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);
let from = match app.show_from(p) {
Ok(from) => from.unwrap_or_else(|| app.show_start_index()),
Err(e) => return err(e),
};
app.start_show(from, reading);
}
ok(Value::Null)
}
+45 -4
View File
@@ -952,11 +952,9 @@ impl SlideApp {
"show.start" => {
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);
let from = self.show_from(p)?.unwrap_or_else(|| self.show_start_index());
self.start_show(from, false);
}
}
"show.presenter" => self.ui.presenter_view = self.flag(p, self.ui.presenter_view),
@@ -1072,6 +1070,21 @@ impl SlideApp {
self.session.active().and_then(|d| deckcraft_anim::show_order(&d.doc).first().copied()).unwrap_or(0)
}
/// The slide `show.start {from}` names: `Ok(None)` when absent, `Err` when it is not a slide of
/// the open deck (negative, non-integer, past the end), so a bad index never plays slide 0 or
/// opens on the end screen. Hidden slides are allowed, as in "from current slide".
pub(crate) fn show_from(&self, p: &Value) -> Result<Option<usize>, String> {
let from = match p.get("from") {
None | Some(Value::Null) => return Ok(None),
Some(v) => v.as_u64().and_then(|v| usize::try_from(v).ok()).ok_or_else(|| format!("`from` is not a valid index: {v}"))?,
};
let count = self.session.active().map_or(0, |d| d.doc.slides.len());
if from >= count {
return Err(format!("no slide {from}"));
}
Ok(Some(from))
}
pub fn start_show(&mut self, from: usize, reading: bool) {
self.anim_preview = None;
self.media.stop_owned(media::Owner::Editor);
@@ -3353,6 +3366,34 @@ mod tests {
assert_eq!(slide_at(harness.state()), 0, "zoomed in, a sideways swipe pans instead of turning the page");
}
/// `show.start {from}` that names no slide of the deck is refused, over `run` and over the
/// control channel, instead of starting at slide 0 or on the end screen. A hidden slide is a
/// slide: From Current Slide plays it.
#[test]
fn show_start_refuses_a_from_that_is_not_a_slide() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({})).unwrap();
s.execute("slide.new", &json!({})).unwrap();
s.execute("slide.hide", &json!({"index": 1, "hidden": true})).unwrap();
let mut app = SlideApp::new(s, Services::default());
let ctx = egui::Context::default();
for from in [json!(-1), json!(1.5), json!("1"), json!(3), json!(u64::MAX)] {
assert!(app.run("show.start", json!({"from": from})).is_err(), "{from}");
assert!(app.show.is_none(), "{from}");
let (req, _rx) = control::ControlRequest::new("show.start", json!({"from": from}));
let control::Outcome::Done(answer) = control::handle(&mut app, &ctx, &req) else { panic!("{from}") };
assert_eq!(answer["ok"], false, "{from}: {answer}");
assert!(app.show.is_none(), "{from}");
}
app.run("show.start", json!({"from": 1})).unwrap();
assert_eq!(app.show.as_ref().map(|s| (s.state.slide, s.state.ended)), Some((1, false)));
app.show = None;
let (req, _rx) = control::ControlRequest::new("show.start", json!({"from": 2, "reading": true}));
let control::Outcome::Done(answer) = control::handle(&mut app, &ctx, &req) else { panic!() };
assert_eq!(answer["ok"], true, "{answer}");
assert_eq!(app.show.as_ref().map(|s| (s.state.slide, s.reading)), Some((2, true)));
}
#[test]
fn a_swipe_during_the_show_turns_the_page() {
let mut harness = flick_harness();
+1 -1
View File
@@ -48,7 +48,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, 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 |
| `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. A `from` that is not a slide of the deck (negative, not a whole number, past the end) is refused; a hidden slide plays |
| `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}` or `{"path": "deck.gif", "width": "large", "secondsPerSlide": 3, "loop": false}`, or `{"path": "slide.png", "transparentBackground": true}` for a PNG without the slide background) |
The MCP server (`deckcraft-cli mcp`, see [mcp.md](mcp.md)) wraps the same methods for Claude