Skip to main content

blockworx/tutorial/
script.rs

1//! Declarative instruction-animation scripts. A [`Script`] is a sequence of
2//! timed [`Step`]s; playback is the pure [`Script::sample`] — a function of
3//! elapsed seconds only, so the whole engine is testable with a fake clock.
4//! Cursor positions stay symbolic ([`CueTarget`]) because a glide can span
5//! coordinate spaces (toolbar button → canvas); the drawing pass resolves both
6//! endpoints to screen space and lerps there.
7
8use egui::Pos2;
9
10use crate::grid::{GRID_SIZE, px};
11use crate::schema::model as schema;
12use crate::tools::names::ToolName;
13
14/// The world-space position of a grid cell's corner — for authoring canvas
15/// targets in the same units the level KDL uses.
16pub fn grid_pos(x: i32, y: i32) -> Pos2 {
17    egui::pos2(px(x), px(y))
18}
19
20/// A cue target on the canvas, in grid cells: `at(8, 6)`. Shipped scripts are
21/// authored in level KDL; these constructors serve the tests.
22#[cfg(test)]
23pub fn at(x: i32, y: i32) -> CueTarget {
24    CueTarget::World(grid_pos(x, y))
25}
26
27/// A cue target on the toolbar: `tool(NewBlock)`.
28#[cfg(test)]
29pub fn tool(name: ToolName) -> CueTarget {
30    CueTarget::ToolButton(name)
31}
32
33/// A cue target at the live position of the block with this title: `block("core")`.
34#[cfg(test)]
35pub fn block(title: &'static str) -> CueTarget {
36    CueTarget::Block(title)
37}
38
39/// A cue target on a live block's resize handle: `corner("core", RightBottom)`.
40#[cfg(test)]
41pub fn corner(title: &'static str, handle: Handle) -> CueTarget {
42    CueTarget::Corner(title, handle)
43}
44
45/// Which resize handle of a block a cue points at.
46#[derive(Clone, Copy, PartialEq, Eq, Debug)]
47pub enum Handle {
48    LeftTop,
49    RightTop,
50    LeftBottom,
51    RightBottom,
52}
53
54/// Where a cue points: a toolbar button (screen space, resolved from the
55/// toolbar's stashed button rects), a fixed canvas position (world space), or a
56/// spot anchored to the *live* document — the block with a given title, which
57/// follows the block wherever the user actually put it.
58#[derive(Clone, Copy, PartialEq, Debug)]
59pub enum CueTarget {
60    ToolButton(ToolName),
61    World(Pos2),
62    /// Center of the block with this title.
63    Block(&'static str),
64    /// A corner (resize handle) of the block with this title.
65    Corner(&'static str, Handle),
66}
67
68impl CueTarget {
69    /// The world position this target names, resolved against the projected
70    /// document. `None` for screen-space targets ([`CueTarget::ToolButton`])
71    /// and for document anchors naming a block that doesn't exist (yet).
72    pub fn world(self, doc: &schema::Document) -> Option<Pos2> {
73        let title_name =
74            |b: &schema::Block| b.title.as_ref().map_or("", |l| l.name.as_str()).to_owned();
75        let block_rect = |title: &str| {
76            doc.blocks.iter().find(|b| title_name(b) == title).map(|b| {
77                egui::Rect::from_min_size(
78                    egui::pos2(b.x as f32, b.y as f32) * GRID_SIZE,
79                    egui::vec2(b.w as f32, b.h as f32) * GRID_SIZE,
80                )
81            })
82        };
83        match self {
84            CueTarget::ToolButton(_) => None,
85            CueTarget::World(p) => Some(p),
86            CueTarget::Block(title) => block_rect(title).map(|r| r.center()),
87            CueTarget::Corner(title, handle) => block_rect(title).map(|r| match handle {
88                Handle::LeftTop => r.left_top(),
89                Handle::RightTop => r.right_top(),
90                Handle::LeftBottom => r.left_bottom(),
91                Handle::RightBottom => r.right_bottom(),
92            }),
93        }
94    }
95}
96
97#[derive(Clone, Copy, PartialEq, Eq, Debug)]
98pub enum ClickCount {
99    Single,
100    Double,
101}
102
103impl ClickCount {
104    fn presses(self) -> u32 {
105        match self {
106            ClickCount::Single => 1,
107            ClickCount::Double => 2,
108        }
109    }
110}
111
112/// Seconds per animated click press (a [`Step::Click`] lasts one press per
113/// [`ClickCount`]).
114pub const CLICK_PRESS_SECS: f32 = 0.3;
115
116#[derive(Clone, Copy)]
117pub enum Step {
118    /// Pulse a ring around the target; the cursor stays where it was.
119    Highlight {
120        target: CueTarget,
121        secs: f32,
122    },
123    /// Glide the cursor to the target, button up.
124    MoveTo {
125        target: CueTarget,
126        secs: f32,
127    },
128    /// Park the cursor on the target, button up — depicts hovering.
129    Hover {
130        target: CueTarget,
131        secs: f32,
132    },
133    /// Flash the button at the target: one press per [`ClickCount`].
134    Click {
135        target: CueTarget,
136        count: ClickCount,
137    },
138    /// Glide with the button held — depicts a drag.
139    Drag {
140        from: CueTarget,
141        to: CueTarget,
142        secs: f32,
143    },
144    /// Depict typing into an editor a previous step opened: `text` appears at
145    /// the target a character at a time. The cursor stays where it was.
146    Type {
147        target: CueTarget,
148        text: &'static str,
149        secs: f32,
150    },
151    Pause {
152        secs: f32,
153    },
154}
155
156impl Step {
157    fn secs(&self) -> f32 {
158        match self {
159            Step::Highlight { secs, .. }
160            | Step::MoveTo { secs, .. }
161            | Step::Hover { secs, .. }
162            | Step::Drag { secs, .. }
163            | Step::Type { secs, .. }
164            | Step::Pause { secs } => *secs,
165            Step::Click { count, .. } => count.presses() as f32 * CLICK_PRESS_SECS,
166        }
167    }
168
169    /// Where the cursor rests once this step completes; `None` leaves it where
170    /// the previous steps put it.
171    fn end_cursor(&self) -> Option<CueTarget> {
172        match self {
173            Step::Highlight { .. } | Step::Type { .. } | Step::Pause { .. } => None,
174            Step::MoveTo { target, .. }
175            | Step::Hover { target, .. }
176            | Step::Click { target, .. } => Some(*target),
177            Step::Drag { to, .. } => Some(*to),
178        }
179    }
180}
181
182/// The fake cursor's position this frame. `Between` keeps both endpoints
183/// symbolic so the draw pass can lerp them in screen space.
184#[derive(Clone, Copy, PartialEq, Debug)]
185pub enum CursorPos {
186    At(CueTarget),
187    Between {
188        from: CueTarget,
189        to: CueTarget,
190        t: f32,
191    },
192}
193
194#[derive(Clone, Copy, PartialEq, Debug)]
195pub enum ButtonState {
196    Up,
197    /// Held through a drag.
198    Down,
199    /// A click press; `t` runs 0..1 through the press (drives the flash ring).
200    Flash {
201        t: f32,
202    },
203}
204
205/// Text mid-entry: the prefix of a [`Step::Type`]'s text typed so far, and
206/// where the editor receiving it sits.
207#[derive(Clone, Copy, PartialEq, Debug)]
208pub struct TypingCue {
209    pub target: CueTarget,
210    pub typed: &'static str,
211}
212
213/// One sampled animation frame.
214#[derive(Clone, Copy, PartialEq, Debug)]
215pub struct CueFrame {
216    /// `None` until a step first establishes a cursor position (e.g. during an
217    /// opening `Highlight`).
218    pub cursor: Option<CursorPos>,
219    pub button: ButtonState,
220    pub highlight: Option<CueTarget>,
221    pub typing: Option<TypingCue>,
222}
223
224#[derive(Clone)]
225pub struct Script {
226    steps: Vec<Step>,
227}
228
229fn ease(t: f32) -> f32 {
230    t * t * (3.0 - 2.0 * t)
231}
232
233/// Assembles a [`Script`] one step at a time, so a test's script reads as a
234/// sentence instead of a struct literal. Shipped scripts come from level KDL
235/// (see [`super::level`]); the builder survives as the tests' authoring
236/// surface.
237#[cfg(test)]
238#[derive(Default)]
239pub struct ScriptBuilder {
240    steps: Vec<Step>,
241}
242
243// A test utility keeps its full step vocabulary even when no current test
244// exercises every method.
245#[cfg(test)]
246#[allow(dead_code)]
247impl ScriptBuilder {
248    #[must_use]
249    pub fn highlight(self, target: CueTarget, secs: f32) -> Self {
250        self.step(Step::Highlight { target, secs })
251    }
252
253    #[must_use]
254    pub fn move_to(self, target: CueTarget, secs: f32) -> Self {
255        self.step(Step::MoveTo { target, secs })
256    }
257
258    #[must_use]
259    pub fn hover(self, target: CueTarget, secs: f32) -> Self {
260        self.step(Step::Hover { target, secs })
261    }
262
263    #[must_use]
264    pub fn click(self, target: CueTarget) -> Self {
265        self.step(Step::Click {
266            target,
267            count: ClickCount::Single,
268        })
269    }
270
271    #[must_use]
272    pub fn double_click(self, target: CueTarget) -> Self {
273        self.step(Step::Click {
274            target,
275            count: ClickCount::Double,
276        })
277    }
278
279    #[must_use]
280    pub fn drag(self, from: CueTarget, to: CueTarget, secs: f32) -> Self {
281        self.step(Step::Drag { from, to, secs })
282    }
283
284    #[must_use]
285    pub fn type_text(self, target: CueTarget, text: &'static str, secs: f32) -> Self {
286        self.step(Step::Type { target, text, secs })
287    }
288
289    #[must_use]
290    pub fn pause(self, secs: f32) -> Self {
291        self.step(Step::Pause { secs })
292    }
293
294    pub fn build(self) -> Script {
295        Script::new(self.steps)
296    }
297
298    #[must_use]
299    fn step(mut self, step: Step) -> Self {
300        self.steps.push(step);
301        self
302    }
303}
304
305#[cfg(test)]
306impl From<ScriptBuilder> for Script {
307    fn from(builder: ScriptBuilder) -> Self {
308        builder.build()
309    }
310}
311
312impl Script {
313    #[cfg(test)]
314    pub fn builder() -> ScriptBuilder {
315        ScriptBuilder::default()
316    }
317
318    pub fn new(steps: Vec<Step>) -> Self {
319        Self { steps }
320    }
321
322    /// One script playing `scripts` back to back — the whole-level demo
323    /// derived from the per-stage scripts.
324    pub fn concat<'a>(scripts: impl IntoIterator<Item = &'a Script>) -> Script {
325        Script::new(
326            scripts
327                .into_iter()
328                .flat_map(|s| s.steps.iter().copied())
329                .collect(),
330        )
331    }
332
333    pub fn steps(&self) -> &[Step] {
334        &self.steps
335    }
336
337    pub fn total_secs(&self) -> f32 {
338        self.steps.iter().map(Step::secs).sum()
339    }
340
341    /// The frame at `elapsed` seconds into the script, or `None` once the
342    /// script has finished — it plays once; replaying is the caller re-anchoring
343    /// its start time.
344    pub fn sample(&self, elapsed: f64) -> Option<CueFrame> {
345        if elapsed < 0.0 {
346            return None;
347        }
348        let mut remaining = elapsed as f32;
349        // The resting position accumulated from completed steps.
350        let mut cursor: Option<CueTarget> = None;
351        for step in &self.steps {
352            let secs = step.secs();
353            if remaining < secs {
354                let p = remaining / secs.max(f32::EPSILON);
355                return Some(Self::frame(step, cursor, p, remaining));
356            }
357            remaining -= secs;
358            if let Some(c) = step.end_cursor() {
359                cursor = Some(c);
360            }
361        }
362        None
363    }
364
365    /// The frame `into_step` seconds (progress `p` in 0..1) into `step`, with
366    /// the cursor previously resting at `prev`.
367    fn frame(step: &Step, prev: Option<CueTarget>, p: f32, into_step: f32) -> CueFrame {
368        let parked = prev.map(CursorPos::At);
369        let base = CueFrame {
370            cursor: parked,
371            button: ButtonState::Up,
372            highlight: None,
373            typing: None,
374        };
375        match step {
376            Step::Highlight { target, .. } => CueFrame {
377                highlight: Some(*target),
378                ..base
379            },
380            Step::Pause { .. } => base,
381            Step::Hover { target, .. } => CueFrame {
382                cursor: Some(CursorPos::At(*target)),
383                ..base
384            },
385            Step::Type { target, text, .. } => CueFrame {
386                typing: Some(TypingCue {
387                    target: *target,
388                    typed: typed_prefix(text, p),
389                }),
390                ..base
391            },
392            Step::MoveTo { target, .. } => CueFrame {
393                // With no prior position the cursor appears at the target.
394                cursor: Some(match prev {
395                    Some(from) => CursorPos::Between {
396                        from,
397                        to: *target,
398                        t: ease(p),
399                    },
400                    None => CursorPos::At(*target),
401                }),
402                ..base
403            },
404            Step::Click { target, .. } => CueFrame {
405                cursor: Some(CursorPos::At(*target)),
406                button: ButtonState::Flash {
407                    t: (into_step / CLICK_PRESS_SECS).fract(),
408                },
409                ..base
410            },
411            Step::Drag { from, to, .. } => CueFrame {
412                cursor: Some(CursorPos::Between {
413                    from: *from,
414                    to: *to,
415                    t: ease(p),
416                }),
417                button: ButtonState::Down,
418                ..base
419            },
420        }
421    }
422}
423
424/// The characters of `text` entered by progress `p` through a [`Step::Type`].
425/// Shared with the lowering pass, which types the same prefixes into the
426/// captured editor buffer.
427pub(super) fn typed_prefix(text: &'static str, p: f32) -> &'static str {
428    let entered = (p.clamp(0.0, 1.0) * text.chars().count() as f32).ceil() as usize;
429    let end = text
430        .char_indices()
431        .nth(entered)
432        .map_or(text.len(), |(i, _)| i);
433    &text[..end]
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    fn demo() -> Script {
441        Script::new(vec![
442            Step::Highlight {
443                target: CueTarget::ToolButton(ToolName::NewBlock),
444                secs: 1.0,
445            },
446            Step::Click {
447                target: CueTarget::ToolButton(ToolName::NewBlock),
448                count: ClickCount::Double,
449            },
450            Step::Drag {
451                from: CueTarget::World(grid_pos(0, 0)),
452                to: CueTarget::World(grid_pos(8, 0)),
453                secs: 2.0,
454            },
455            Step::Pause { secs: 0.5 },
456        ])
457    }
458
459    #[test]
460    fn totals_sum_step_durations() {
461        // 1.0 highlight + 2 presses * 0.3 + 2.0 drag + 0.5 pause.
462        let total = demo().total_secs();
463        assert!((total - 4.1).abs() < 1e-6, "{total}");
464    }
465
466    #[test]
467    fn highlight_has_no_cursor_until_one_is_established() {
468        let frame = demo().sample(0.5).unwrap();
469        assert_eq!(frame.cursor, None);
470        assert_eq!(
471            frame.highlight,
472            Some(CueTarget::ToolButton(ToolName::NewBlock))
473        );
474        assert_eq!(frame.button, ButtonState::Up);
475    }
476
477    #[test]
478    fn double_click_flashes_twice() {
479        let script = demo();
480        // First press: just after the highlight ends.
481        let first = script.sample(1.0 + 0.1).unwrap();
482        // Second press: one press-length later.
483        let second = script.sample(1.0 + CLICK_PRESS_SECS as f64 + 0.1).unwrap();
484        for frame in [first, second] {
485            assert!(matches!(frame.button, ButtonState::Flash { .. }));
486            assert_eq!(
487                frame.cursor,
488                Some(CursorPos::At(CueTarget::ToolButton(ToolName::NewBlock)))
489            );
490        }
491        // The flash phase restarts for the second press.
492        let ButtonState::Flash { t: t1 } = first.button else {
493            unreachable!()
494        };
495        let ButtonState::Flash { t: t2 } = second.button else {
496            unreachable!()
497        };
498        assert!((t1 - t2).abs() < 1e-3, "{t1} vs {t2}");
499    }
500
501    #[test]
502    fn drag_interpolates_between_endpoints_with_button_down() {
503        let script = demo();
504        let drag_start = 1.0 + 2.0 * f64::from(CLICK_PRESS_SECS);
505        let frame = script.sample(drag_start + 1.0).unwrap();
506        assert_eq!(frame.button, ButtonState::Down);
507        let Some(CursorPos::Between { t, .. }) = frame.cursor else {
508            panic!("expected a lerping cursor, got {:?}", frame.cursor);
509        };
510        // Halfway through the eased glide.
511        assert!((t - 0.5).abs() < 1e-3, "{t}");
512    }
513
514    #[test]
515    fn pause_keeps_the_cursor_where_the_drag_left_it() {
516        let script = demo();
517        let pause_at = f64::from(script.total_secs()) - 0.25;
518        let frame = script.sample(pause_at).unwrap();
519        assert_eq!(
520            frame.cursor,
521            Some(CursorPos::At(CueTarget::World(grid_pos(8, 0))))
522        );
523    }
524
525    #[test]
526    fn doc_anchored_targets_follow_the_projected_block() {
527        let doc = schema::Document::parse_kdl(
528            r#"
529top "b0"
530
531block "b0" x=0 y=0 w=28 h=20 {
532    title "sheet"
533    children "b1"
534}
535
536block "b1" x=4 y=4 w=8 h=7 {
537    title "core"
538}
539"#,
540            "test",
541        )
542        .unwrap();
543        assert_eq!(
544            block("core").world(&doc),
545            Some(grid_pos(8, 7).lerp(grid_pos(8, 8), 0.5))
546        );
547        assert_eq!(
548            corner("core", Handle::RightBottom).world(&doc),
549            Some(grid_pos(12, 11))
550        );
551        assert_eq!(block("missing").world(&doc), None);
552        assert_eq!(tool(ToolName::NewBlock).world(&doc), None);
553        assert_eq!(at(3, 5).world(&doc), Some(grid_pos(3, 5)));
554    }
555
556    #[test]
557    fn typing_reveals_characters_and_leaves_the_cursor_parked() {
558        let script = Script::new(vec![
559            Step::MoveTo {
560                target: CueTarget::World(grid_pos(4, 0)),
561                secs: 1.0,
562            },
563            Step::Type {
564                target: CueTarget::World(grid_pos(0, 0)),
565                text: "CPU",
566                secs: 3.0,
567            },
568        ]);
569        let at = |t: f64| script.sample(1.0 + t).unwrap();
570        assert_eq!(at(0.5).typing.unwrap().typed, "C");
571        assert_eq!(at(1.5).typing.unwrap().typed, "CP");
572        assert_eq!(at(2.9).typing.unwrap().typed, "CPU");
573        assert_eq!(
574            at(1.5).cursor,
575            Some(CursorPos::At(CueTarget::World(grid_pos(4, 0))))
576        );
577    }
578
579    #[test]
580    fn finished_and_negative_times_yield_no_frame() {
581        let script = demo();
582        assert!(script.sample(-0.1).is_none());
583        assert!(
584            script
585                .sample(f64::from(script.total_secs()) + 0.01)
586                .is_none()
587        );
588    }
589}