Merge PR #660 from NikitkaRa: Show: play a custom show

# Conflicts:
#	crates/anim/src/show.rs
#	crates/engine/src/cmd/review.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/show.rs
This commit is contained in:
Brandon Thomas
2026-10-14 13:49:10 -04:00
14 changed files with 217 additions and 30 deletions
+70 -8
View File
@@ -74,6 +74,10 @@ pub struct ShowState {
pub(crate) last_now: Option<f64>,
/// What was played on each visited slide (index into `presentation.slides`).
pub(crate) runs: BTreeMap<usize, SlideRun>,
/// 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
@@ -94,6 +98,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"
}
@@ -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<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) {
@@ -165,7 +220,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),
@@ -173,7 +228,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),
@@ -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)),
+31
View File
@@ -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);