mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-31 07:38:16 +00:00
Slide show: going back reverses the left slide's transition (#1531)
Back from slide B to slide A played A's own entrance transition. PowerPoint plays the transition B came in with, backwards. ShowState now remembers that the current slide was reached by Back (`backward`, set by `prev`, cleared by every other way in). The show then takes the transition of the slide it left and runs its frames from the end to the start, with the old and new slides trading places (`transition_layers`). Morph needs no reversing: a Morph from the slide left to the one come back to already is the reversed Morph. A jump (Go to Slide, links, See All Slides) still plays the target slide's transition. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
f893f6af7d
commit
8cc25169ab
6 files changed
+131
-10
No files matched your search
@@ -137,6 +137,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-29 | Slide Show: going back to the previous slide plays the transition of the slide being left, reversed, as PowerPoint does, instead of the previous slide's own entrance transition (#1531); a jump (Go to Slide, links) still plays the target slide's transition |
|
||||
| 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI `<Icon>`, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once |
|
||||
| 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off |
|
||||
| 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table |
|
||||
@@ -294,6 +295,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged |
|
||||
| 2026-10-25 | trivial | Progress log: Windows shortcut icon survives updates (#1114). Scores and milestone names unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: measurement units in centimetres (#1326). Scores unchanged |
|
||||
|
||||
@@ -58,6 +58,9 @@ pub struct ShowState {
|
||||
pub playing: bool,
|
||||
/// On the end screen.
|
||||
pub ended: bool,
|
||||
/// The current slide was reached by going back (← / Backspace), so the show plays the
|
||||
/// transition of the slide it left, in reverse, as PowerPoint does (#1531).
|
||||
pub backward: bool,
|
||||
/// The show has entered a slide (so `slide` and `step` are meaningful and can be saved).
|
||||
active: bool,
|
||||
/// The time of the last [`ShowState::tick`] (seconds), for the triggered animations' clocks.
|
||||
@@ -208,6 +211,7 @@ impl ShowState {
|
||||
self.active = true;
|
||||
self.slide = i;
|
||||
self.ended = false;
|
||||
self.backward = false;
|
||||
// Time spent elsewhere never counts for this slide's triggered animations.
|
||||
self.last_now = None;
|
||||
let steps = ShowState::steps(pres, i);
|
||||
@@ -309,6 +313,7 @@ impl ShowState {
|
||||
}
|
||||
self.step = ShowState::steps(pres, self.slide);
|
||||
self.playing = false;
|
||||
self.backward = false;
|
||||
return ShowAction::Slide(self.slide);
|
||||
}
|
||||
// #844: undoing the automatic first step left the slide as before it appeared, and the show
|
||||
@@ -337,6 +342,7 @@ impl ShowState {
|
||||
self.step = ShowState::steps(pres, i);
|
||||
self.playing = false;
|
||||
}
|
||||
self.backward = true;
|
||||
ShowAction::Slide(i)
|
||||
}
|
||||
None => ShowAction::None,
|
||||
|
||||
@@ -763,6 +763,26 @@ fn show_skips_hidden_slides() {
|
||||
assert_eq!(s.goto(&p, 99), ShowAction::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_remembers_a_slide_was_reached_by_going_back() {
|
||||
// #1531: the UI plays the left slide's transition reversed only for Back.
|
||||
let p = pres(3);
|
||||
let mut s = ShowState::new(&p);
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(1));
|
||||
assert!(!s.backward);
|
||||
assert_eq!(s.prev(&p), ShowAction::Slide(0));
|
||||
assert!(s.backward);
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(1));
|
||||
assert!(!s.backward);
|
||||
assert_eq!(s.goto(&p, 0), ShowAction::Slide(0));
|
||||
assert!(!s.backward, "a jump is not Back");
|
||||
// Back from the end screen shows the last slide again, without a transition to reverse.
|
||||
assert_eq!(s.goto(&p, 2), ShowAction::Slide(2));
|
||||
assert_eq!(s.next(&p), ShowAction::End);
|
||||
assert_eq!(s.prev(&p), ShowAction::Slide(2));
|
||||
assert!(!s.backward);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_loops() {
|
||||
let mut p = pres(3);
|
||||
|
||||
@@ -27,6 +27,8 @@ use presenter::{NOTES_MAX, NOTES_MIN, NOTES_STEP, format_elapsed, presenter_note
|
||||
#[cfg(test)]
|
||||
use render::{below_playing_video, next_rect, render_frame, start_prefetch};
|
||||
use render::{render_backdrop, render_threads};
|
||||
#[cfg(test)]
|
||||
use ui::transition_layers;
|
||||
pub use ui::ui;
|
||||
use zoom::Zoom;
|
||||
#[cfg(test)]
|
||||
@@ -156,6 +158,9 @@ struct Transition {
|
||||
kind: String,
|
||||
option: String,
|
||||
dur: f64,
|
||||
/// Going back: the transition of the slide left behind plays from its end to its start, so
|
||||
/// the old slide takes the new slide's part (#1531).
|
||||
reverse: bool,
|
||||
}
|
||||
|
||||
/// A copy of the show's clock origins for regressions that must not depend on wall time.
|
||||
@@ -450,12 +455,6 @@ impl Show {
|
||||
if self.rehearse {
|
||||
self.timings.push((self.timeline.as_ref().map(|t| t.0).unwrap_or(i), now - self.slide_start));
|
||||
}
|
||||
let (kind, option, dur) = doc
|
||||
.slides
|
||||
.get(i)
|
||||
.and_then(|s| s.transition.as_ref())
|
||||
.map(|t| (t.kind.clone(), t.option.clone(), t.duration_ms as f64 / 1000.0))
|
||||
.unwrap_or(("none".into(), String::new(), 0.0));
|
||||
// Take the old slide's texture rather than cloning its handle: the new slide is
|
||||
// otherwise rendered into the same texture, and the transition blends the new
|
||||
// slide with itself (#33).
|
||||
@@ -463,8 +462,20 @@ impl Show {
|
||||
if from != Some(i) {
|
||||
self.last_viewed = from;
|
||||
}
|
||||
// Going back plays the transition the slide left behind came in with, reversed
|
||||
// (#1531); a queued Back that a later step overtook keeps the forward one.
|
||||
let back = self.state.backward && self.state.slide == i && from.is_some_and(|f| f != i);
|
||||
let (kind, option, dur) = doc
|
||||
.slides
|
||||
.get(if back { from.unwrap_or(i) } else { i })
|
||||
.and_then(|s| s.transition.as_ref())
|
||||
.map(|t| (t.kind.clone(), t.option.clone(), t.duration_ms as f64 / 1000.0))
|
||||
.unwrap_or(("none".into(), String::new(), 0.0));
|
||||
// Morph needs no reversing: a Morph from the old slide to the new one is the
|
||||
// reversed Morph.
|
||||
let reverse = back && kind != "morph";
|
||||
self.trans = if kind != "none" && dur > 0.0 && !doc.show.without_animation {
|
||||
Some(Transition { old, from, morph: None, parts: None, start: now, kind, option, dur })
|
||||
Some(Transition { old, from, morph: None, parts: None, start: now, kind, option, dur, reverse })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -443,6 +443,62 @@ fn a_transition_keeps_the_old_slide_in_its_own_texture() {
|
||||
assert!(show.tex.is_none(), "the new slide gets a texture of its own instead of overwriting the old one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn going_back_plays_the_left_slides_transition_reversed() {
|
||||
// #1531: Back from B to A played A's own entrance transition. PowerPoint plays the one B came
|
||||
// in with, backwards.
|
||||
let mut s = deckcraft_engine::Session::with_new();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
s.execute("transition.set", &json!({"kind": "fade", "index": 0})).unwrap();
|
||||
s.execute("transition.set", &json!({"kind": "push", "index": 1})).unwrap();
|
||||
let doc = s.active().unwrap().doc.clone();
|
||||
let ctx = egui::Context::default();
|
||||
let tex = |slide| {
|
||||
let t = crate::textures::upload(&ctx, "show", egui::ColorImage::filled([1, 1], Color32::RED));
|
||||
Some((Key { slide, step: 0, size: (1, 1), doc: 0, hidden: vec![], gif: 0, plays: vec![] }, t))
|
||||
};
|
||||
let mut show = Show::new(&doc, 0, false, false);
|
||||
show.tex = tex(0);
|
||||
let a = show.state.next(&doc);
|
||||
assert_eq!(a, ShowAction::Slide(1));
|
||||
assert!(show.apply(a, &doc, 0.0));
|
||||
let tr = show.trans.as_ref().expect("B's push plays");
|
||||
assert_eq!((tr.kind.as_str(), tr.reverse), ("push", false));
|
||||
|
||||
show.tex = tex(1);
|
||||
let a = show.state.prev(&doc);
|
||||
assert_eq!(a, ShowAction::Slide(0));
|
||||
assert!(show.apply(a, &doc, 1.0));
|
||||
let tr = show.trans.as_ref().expect("going back plays a transition");
|
||||
assert_eq!((tr.kind.as_str(), tr.reverse, tr.from), ("push", true, Some(1)), "B's push, reversed, not A's fade");
|
||||
|
||||
// Forward again: B's own transition, forward.
|
||||
show.tex = tex(0);
|
||||
let a = show.state.next(&doc);
|
||||
assert!(show.apply(a, &doc, 2.0));
|
||||
let tr = show.trans.as_ref().expect("B's push plays");
|
||||
assert_eq!((tr.kind.as_str(), tr.reverse), ("push", false));
|
||||
|
||||
// A jump to an earlier slide is not Back: the slide's own transition plays.
|
||||
show.tex = tex(1);
|
||||
let a = show.state.goto(&doc, 0);
|
||||
assert!(show.apply(a, &doc, 3.0));
|
||||
let tr = show.trans.as_ref().expect("A's fade plays");
|
||||
assert_eq!((tr.kind.as_str(), tr.reverse), ("fade", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reversed_transition_starts_on_the_slide_left_and_ends_on_the_one_come_back_to() {
|
||||
// #1531: running the frames from the end, with the old and new slides trading places.
|
||||
for kind in ["fade", "push", "wipe", "cover"] {
|
||||
let start = transition_layers(kind, "", true, 0.0, 16.0 / 9.0);
|
||||
let top = start.last().expect("a frame");
|
||||
assert_eq!((top.source, top.alpha), (Source::Old, 1.0), "{kind}: the slide left is shown first");
|
||||
let end = transition_layers(kind, "", true, 1.0, 16.0 / 9.0);
|
||||
assert!(end.iter().all(|l| l.source == Source::New), "{kind}: only the slide come back to is left: {:?}", end);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_in_the_click_sequence_plays_with_its_step() {
|
||||
// #116: media set to play in the click sequence never started; only a click on it did.
|
||||
|
||||
@@ -9,8 +9,8 @@ use super::zoom::{
|
||||
wheel_zoom_lines,
|
||||
};
|
||||
use super::{
|
||||
Align2, BackKey, Click, Color32, CornerRadius, Key, LinkJump, Pos2, Rect, RenderOpts, Sense, ShapeId, ShapeState, Show, ShowAction, SlideApp,
|
||||
Source, Stroke, TextureHandle, Ui, json, pos2, show_fills_screen, state_for, theme, vec2, viewport_visible,
|
||||
Align2, BackKey, Click, Color32, CornerRadius, Key, Layer, LinkJump, Pos2, Rect, RenderOpts, Sense, ShapeId, ShapeState, Show, ShowAction,
|
||||
SlideApp, Source, Stroke, TextureHandle, Ui, json, pos2, show_fills_screen, state_for, theme, vec2, viewport_visible,
|
||||
};
|
||||
use deckcraft_anim::{StepStamp, TriggerPlay};
|
||||
use deckcraft_model::Presentation;
|
||||
@@ -391,7 +391,7 @@ fn draw_transition(show: &mut Show, f: &Fr, sl: &Sl, painter: &egui::Painter, te
|
||||
let aspect = doc.slide_size.width / doc.slide_size.height.max(1.0);
|
||||
let part = |k: usize| tr.parts.as_ref().and_then(|p| p.get(k).cloned().flatten());
|
||||
let parts = [part(0), part(1), part(2), part(3)];
|
||||
for l in deckcraft_anim::transition_frame(&tr.kind, &tr.option, p, aspect) {
|
||||
for l in transition_layers(&tr.kind, &tr.option, tr.reverse, p, aspect) {
|
||||
let old_or_black = |t: Option<&TextureHandle>| match t {
|
||||
Some(o) => quad_mesh(Some(o.id()), srect, &l, Color32::WHITE),
|
||||
None => quad_mesh(None, srect, &l, Color32::BLACK),
|
||||
@@ -421,6 +421,32 @@ fn draw_transition(show: &mut Show, f: &Fr, sl: &Sl, painter: &egui::Painter, te
|
||||
drew
|
||||
}
|
||||
|
||||
/// The layers of a transition at progress `p`. A reversed transition (going back, #1531) runs
|
||||
/// from its end to its start, so the slide being left plays the part of the new slide and the
|
||||
/// slide coming back the part of the old one.
|
||||
pub(super) fn transition_layers(kind: &str, option: &str, reverse: bool, p: f64, aspect: f64) -> Vec<Layer> {
|
||||
if !reverse {
|
||||
return deckcraft_anim::transition_frame(kind, option, p, aspect);
|
||||
}
|
||||
let mut layers = deckcraft_anim::transition_frame(kind, option, 1.0 - p, aspect);
|
||||
for l in &mut layers {
|
||||
l.source = swap_old_new(l.source);
|
||||
}
|
||||
layers
|
||||
}
|
||||
|
||||
fn swap_old_new(s: Source) -> Source {
|
||||
match s {
|
||||
Source::Old => Source::New,
|
||||
Source::New => Source::Old,
|
||||
Source::OldBack => Source::NewBack,
|
||||
Source::NewBack => Source::OldBack,
|
||||
Source::OldContent => Source::NewContent,
|
||||
Source::NewContent => Source::OldContent,
|
||||
Source::Black | Source::White => s,
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_videos(app: &mut SlideApp, show: &mut Show, f: &Fr, sl: &Sl, painter: &egui::Painter, tex: &Option<TextureHandle>) -> (Vec<ShapeId>, bool) {
|
||||
let Fr { doc, full, srect, .. } = *f;
|
||||
if let Some(tex) = &tex {
|
||||
|
||||
Reference in new issue
Block a user