Skip to main content

blockworx/script/
step.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 std::time::Duration;
9
10use blockworx_doc::{
11    block_model::Block,
12    document::{IndexedDocument, chronological},
13    id::{BlockId, PinId},
14};
15use egui::Pos2;
16
17use crate::grid::{GRID_SIZE, px};
18use crate::progress::Progress;
19use crate::schema::lower::SourceIds;
20use crate::shape::block::BlockShape;
21use crate::shape::pin::{Pin, slot};
22use crate::tools::names::ToolName;
23
24/// The world-space position of a grid cell's corner — for authoring canvas
25/// targets in the same units the level KDL uses.
26pub fn grid_pos(x: i32, y: i32) -> Pos2 {
27    egui::pos2(px(x), px(y))
28}
29
30/// Linear blend of two camera rects at eased progress `p`.
31fn lerp_rect(a: egui::Rect, b: egui::Rect, p: Progress) -> egui::Rect {
32    let t = p.get();
33    egui::Rect::from_min_max(a.min.lerp(b.min, t), a.max.lerp(b.max, t))
34}
35
36/// A cue target on the canvas, in grid cells: `at(8, 6)`. Shipped scripts are
37/// authored in level KDL; these constructors serve the tests.
38#[cfg(test)]
39pub fn at(x: i32, y: i32) -> CueTarget {
40    CueTarget::World(grid_pos(x, y))
41}
42
43/// A cue target on the toolbar: `tool(NewBlock)`.
44#[cfg(test)]
45pub fn tool(name: ToolName) -> CueTarget {
46    CueTarget::ToolButton(name)
47}
48
49/// A cue target at the live position of the block with this title: `block("core")`.
50#[cfg(test)]
51pub fn block(title: &'static str) -> CueTarget {
52    CueTarget::Block(title)
53}
54
55/// A cue target on a live block's resize handle: `corner("core", RightBottom)`.
56#[cfg(test)]
57pub fn corner(title: &'static str, handle: Handle) -> CueTarget {
58    CueTarget::Corner(title, handle)
59}
60
61/// A drag destination `dx,dy` grid cells from the drag's start.
62#[cfg(test)]
63pub fn by(dx: i32, dy: i32) -> CueTarget {
64    CueTarget::Relative { dx, dy }
65}
66
67/// Which resize handle of a block a cue points at.
68#[derive(Clone, Copy, PartialEq, Eq, Debug)]
69pub enum Handle {
70    LeftTop,
71    RightTop,
72    LeftBottom,
73    RightBottom,
74}
75
76/// Where a cue points: a toolbar button (screen space, resolved from the
77/// toolbar's stashed button rects), a fixed canvas position (world space), or a
78/// spot anchored to the *live* document — by block title or by logical id —
79/// which follows the object wherever the demo actually put it.
80#[derive(Clone, Copy, PartialEq, Debug)]
81pub enum CueTarget {
82    ToolButton(ToolName),
83    World(Pos2),
84    /// Center of the block with this title.
85    Block(&'static str),
86    /// A corner (resize handle) of the block with this title.
87    Corner(&'static str, Handle),
88    /// Center of the block with this id (`"b4"`).
89    BlockId(&'static str),
90    /// A pin's wire anchor, by logical ids (`"b4:p3"`).
91    PinAnchor {
92        block: &'static str,
93        pin: &'static str,
94    },
95    /// A destination offset from a drag's start, in grid cells (`"+4,-3"` in
96    /// the KDL). Only meaningful as a [`Step::Drag`] `to`; it has no world
97    /// position of its own.
98    Relative {
99        dx: i32,
100        dy: i32,
101    },
102}
103
104/// How a [`CueTarget`] gets its position. Every consumer switches on this
105/// distinction rather than on the variants, so a new target kind is classified
106/// once, here, instead of falling through a wildcard at each site.
107#[derive(Clone, Copy, PartialEq, Debug)]
108pub enum Anchoring {
109    /// Screen space: the toolbar owns the button's rect, so there is no world
110    /// position by design (as opposed to one that failed to resolve).
111    Toolbar(ToolName),
112    /// A world position, resolved against the live document by
113    /// [`CueTarget::world`].
114    Document,
115    /// A displacement from the drag's start, in world units — no position of
116    /// its own, so a cursor rides the drag's base instead of gliding.
117    FromDragBase(egui::Vec2),
118}
119
120/// What a cue target resolves against: the session's live document, and the
121/// table naming the entities its level file authored.
122///
123/// The document alone cannot answer `"b1:p1"` — every lowering mints fresh
124/// uuids, so the file's own spellings survive only in
125/// [`SourceIds`]. Titles and world
126/// positions come off the document, wherever the demo has since put them.
127#[derive(Clone, Copy)]
128pub struct CueScope<'a> {
129    indexed: IndexedDocument<'a>,
130    ids: &'a SourceIds,
131}
132
133impl<'a> CueScope<'a> {
134    pub fn new(indexed: IndexedDocument<'a>, ids: &'a SourceIds) -> Self {
135        Self { indexed, ids }
136    }
137
138    fn block(&self, id: BlockId) -> Option<&'a Block> {
139        let live = self.indexed.doc.block(&id)?;
140        live.is_alive().then(|| live.as_ref())
141    }
142
143    /// The live block carrying this title. Titles are not unique by
144    /// construction, so the oldest wins — the same `chronological` order
145    /// the draw and hit passes take.
146    fn by_title(&self, title: &str) -> Option<&'a Block> {
147        chronological(
148            self.indexed
149                .doc
150                .blocks()
151                .filter(|(_, live)| live.is_alive()),
152        )
153        .into_iter()
154        .find_map(|id| self.block(id).filter(|b| b.title.name.as_ref() == title))
155    }
156
157    fn by_source_id(&self, id: &str) -> Option<&'a Block> {
158        self.block(self.ids.block(id)?)
159    }
160
161    fn rect_of(block: &Block) -> egui::Rect {
162        crate::edit::lower::px_rect(*block.rect.as_ref())
163    }
164
165    /// Where a pin's wire anchors — the same point the block draws its stub
166    /// tip at, resolved through the one anchor rule
167    /// ([`BlockShape::pin_anchor_at`]).
168    fn pin_anchor(&self, owner: BlockId, pin: PinId) -> Option<Pos2> {
169        let block = self.block(owner)?;
170        let pins: Vec<(PinId, &Pin)> = self
171            .indexed
172            .index
173            .scope(owner)?
174            .pins
175            .iter()
176            .filter_map(|&id| Some((id, self.indexed.doc.pin(&id)?.as_ref())))
177            .collect();
178        let placed = slot(pins.iter().find(|(id, _)| *id == pin)?.1);
179        let shape = BlockShape::new(block, pins, crate::path::structure(&self.indexed, owner));
180        Some(shape.pin_anchor_at(shape.rect(), placed.side, placed.offset))
181    }
182}
183
184/// A lowered document held with the pieces a [`CueScope`] borrows — the
185/// fixture the cue, lowering and replay tests resolve targets against.
186#[cfg(test)]
187pub struct CueFixture {
188    doc: blockworx_doc::document::Document,
189    index: blockworx_doc::document::DocIndex,
190    ids: SourceIds,
191}
192
193#[cfg(test)]
194impl CueFixture {
195    /// What a level's `initial { … }` KDL becomes once the bridge has
196    /// lowered it and the session has folded it.
197    pub fn lowered(kdl: &str) -> Self {
198        let parsed = crate::schema::model::Document::parse_kdl(kdl, "cue-fixture")
199            .expect("the fixture parses");
200        let lowered = crate::schema::lower::lower(&parsed, "cue-fixture");
201        let mut doc = blockworx_doc::document::Document::default();
202        for commit in &lowered.commits {
203            doc = doc.try_apply(commit).expect("the lowered commit folds");
204        }
205        Self {
206            index: blockworx_doc::document::DocIndex::of(&doc),
207            doc,
208            ids: lowered.ids,
209        }
210    }
211
212    /// A document with nothing in it: the cases that resolve no target.
213    pub fn empty() -> Self {
214        let doc = blockworx_doc::document::Document::default();
215        Self {
216            index: blockworx_doc::document::DocIndex::of(&doc),
217            doc,
218            ids: SourceIds::default(),
219        }
220    }
221
222    pub fn scope(&mut self) -> CueScope<'_> {
223        CueScope::new(self.index.view(&self.doc), &self.ids)
224    }
225}
226
227impl CueTarget {
228    pub fn anchoring(self) -> Anchoring {
229        match self {
230            CueTarget::ToolButton(name) => Anchoring::Toolbar(name),
231            CueTarget::Relative { dx, dy } => {
232                Anchoring::FromDragBase(egui::vec2(dx as f32, dy as f32) * GRID_SIZE)
233            }
234            CueTarget::World(_)
235            | CueTarget::Block(_)
236            | CueTarget::Corner(..)
237            | CueTarget::BlockId(_)
238            | CueTarget::PinAnchor { .. } => Anchoring::Document,
239        }
240    }
241
242    /// The world position this target names, resolved against the session's
243    /// document. `None` for the targets that have no world position of their
244    /// own (see [`CueTarget::anchoring`]) and for document anchors naming an
245    /// object that doesn't exist (yet).
246    pub fn world(self, scope: &CueScope<'_>) -> Option<Pos2> {
247        match self {
248            CueTarget::ToolButton(_) | CueTarget::Relative { .. } => None,
249            CueTarget::World(p) => Some(p),
250            CueTarget::Block(title) => scope.by_title(title).map(|b| CueScope::rect_of(b).center()),
251            CueTarget::Corner(title, handle) => scope.by_title(title).map(|b| {
252                let r = CueScope::rect_of(b);
253                match handle {
254                    Handle::LeftTop => r.left_top(),
255                    Handle::RightTop => r.right_top(),
256                    Handle::LeftBottom => r.left_bottom(),
257                    Handle::RightBottom => r.right_bottom(),
258                }
259            }),
260            CueTarget::BlockId(id) => scope
261                .by_source_id(id)
262                .map(|b| CueScope::rect_of(b).center()),
263            CueTarget::PinAnchor { block, pin } => {
264                scope.pin_anchor(scope.ids.block(block)?, scope.ids.pin(block, pin)?)
265            }
266        }
267    }
268}
269
270#[derive(Clone, Copy, PartialEq, Eq, Debug)]
271pub enum ClickCount {
272    Single,
273    Double,
274}
275
276impl ClickCount {
277    fn presses(self) -> u32 {
278        match self {
279            ClickCount::Single => 1,
280            ClickCount::Double => 2,
281        }
282    }
283}
284
285/// Length of one animated click press (a [`Step::Click`] lasts one press per
286/// [`ClickCount`]).
287pub const CLICK_PRESS: Duration = Duration::from_millis(450);
288
289/// A key the demo is holding down, drawn beside the mouse badge so a gesture
290/// that depends on a modifier (space-drag to pan, shift-drag to extend a
291/// selection) reads as more than a plain drag. Purely a cue: scripted input
292/// carries no modifiers, so the *effect* is scripted separately (a `camera`
293/// glide for a pan).
294#[derive(Clone, Copy, PartialEq, Eq, Debug)]
295pub enum HeldKey {
296    Ctrl,
297    Shift,
298    Alt,
299    Space,
300}
301
302impl HeldKey {
303    /// The key's name, as the badge shows it and a level spells it.
304    pub fn label(self) -> &'static str {
305        match self {
306            HeldKey::Ctrl => "ctrl",
307            HeldKey::Shift => "shift",
308            HeldKey::Alt => "alt",
309            HeldKey::Space => "space",
310        }
311    }
312
313    pub fn parse(name: &str) -> Option<Self> {
314        [HeldKey::Ctrl, HeldKey::Shift, HeldKey::Alt, HeldKey::Space]
315            .into_iter()
316            .find(|key| key.label() == name)
317    }
318}
319
320#[derive(Clone, Copy, PartialEq, Debug)]
321pub enum Step {
322    /// Pulse a ring around the target; the cursor stays where it was.
323    Highlight {
324        target: CueTarget,
325        duration: Duration,
326    },
327    /// Glide the cursor to the target, button up.
328    MoveTo {
329        target: CueTarget,
330        duration: Duration,
331    },
332    /// Park the cursor on the target, button up — depicts hovering.
333    Hover {
334        target: CueTarget,
335        duration: Duration,
336    },
337    /// Flash the button at the target: one press per [`ClickCount`].
338    Click {
339        target: CueTarget,
340        count: ClickCount,
341    },
342    /// Glide with the button held — depicts a drag.
343    Drag {
344        from: CueTarget,
345        to: CueTarget,
346        duration: Duration,
347    },
348    /// Depict typing into an editor a previous step opened: `text` appears at
349    /// the target a character at a time. The cursor stays where it was.
350    Type {
351        target: CueTarget,
352        text: &'static str,
353        duration: Duration,
354    },
355    Pause {
356        duration: Duration,
357    },
358    /// Frame the player's view on this world rect from here on. With a zero
359    /// `duration` it cuts; otherwise the view eases from the previous framing
360    /// over `duration` (demonstrating pan/zoom), holding the input timeline
361    /// while it glides. Input replay ignores it either way.
362    Camera {
363        rect: egui::Rect,
364        duration: Duration,
365    },
366    /// Show this instruction centered over the player from here on (until the
367    /// next `Instruct`). Instantaneous; input replay ignores it.
368    Instruct {
369        text: &'static str,
370    },
371    /// Hold a key down (or release it, with `None`) from here on, so the cue
372    /// badge shows it. Instantaneous; input replay ignores it.
373    Hold {
374        key: Option<HeldKey>,
375    },
376    /// Dispatch the named registry command (see `CommandId::name`) against the
377    /// session — an overlay button press or palette pick, by its typeable
378    /// spelling. Resolved when the step executes, so availability follows
379    /// whatever the demo has selected. Instantaneous.
380    Command {
381        name: &'static str,
382    },
383}
384
385impl Step {
386    fn duration(&self) -> Duration {
387        match self {
388            Step::Highlight { duration, .. }
389            | Step::MoveTo { duration, .. }
390            | Step::Hover { duration, .. }
391            | Step::Drag { duration, .. }
392            | Step::Type { duration, .. }
393            | Step::Pause { duration }
394            | Step::Camera { duration, .. } => *duration,
395            Step::Click { count, .. } => CLICK_PRESS * count.presses(),
396            Step::Instruct { .. } | Step::Command { .. } | Step::Hold { .. } => Duration::ZERO,
397        }
398    }
399
400    /// Where the cursor rests once this step completes; `None` leaves it where
401    /// the previous steps put it.
402    fn end_cursor(&self) -> Option<CueTarget> {
403        match self {
404            Step::Highlight { .. }
405            | Step::Type { .. }
406            | Step::Pause { .. }
407            | Step::Camera { .. }
408            | Step::Instruct { .. }
409            | Step::Command { .. }
410            | Step::Hold { .. } => None,
411            Step::MoveTo { target, .. }
412            | Step::Hover { target, .. }
413            | Step::Click { target, .. } => Some(*target),
414            // A relative drag moved its own base, so the anchor re-resolves
415            // to the dragged position.
416            Step::Drag { from, to, .. } => Some(match to.anchoring() {
417                Anchoring::FromDragBase(_) => *from,
418                Anchoring::Toolbar(_) | Anchoring::Document => *to,
419            }),
420        }
421    }
422}
423
424/// The fake cursor's position this frame. `Between` keeps both endpoints
425/// symbolic so the draw pass can lerp them in screen space.
426#[derive(Clone, Copy, PartialEq, Debug)]
427pub enum CursorPos {
428    At(CueTarget),
429    Between {
430        from: CueTarget,
431        to: CueTarget,
432        t: Progress,
433    },
434}
435
436#[derive(Clone, Copy, PartialEq, Debug)]
437pub enum ButtonState {
438    Up,
439    /// Held through a drag.
440    Down,
441    /// A click press; `t` runs through the press (drives the flash ring).
442    Flash {
443        t: Progress,
444    },
445}
446
447/// Text mid-entry: the prefix of a [`Step::Type`]'s text typed so far, and
448/// where the editor receiving it sits.
449#[derive(Clone, Copy, PartialEq, Debug)]
450pub struct TypingCue {
451    pub target: CueTarget,
452    pub typed: &'static str,
453}
454
455/// One sampled animation frame.
456#[derive(Clone, Copy, PartialEq, Debug)]
457pub struct CueFrame {
458    /// `None` until a step first establishes a cursor position (e.g. during an
459    /// opening `Highlight`).
460    pub cursor: Option<CursorPos>,
461    pub button: ButtonState,
462    /// The key held as of this frame (see [`Step::Hold`]).
463    pub held: Option<HeldKey>,
464    pub highlight: Option<CueTarget>,
465    pub typing: Option<TypingCue>,
466}
467
468#[derive(Clone)]
469pub struct Script {
470    steps: Vec<Step>,
471}
472
473/// Assembles a [`Script`] one step at a time, so a test's script reads as a
474/// sentence instead of a struct literal. Shipped scripts come from level KDL
475/// (see `crate::tutorial::level`); the builder survives as the tests' authoring
476/// surface.
477#[cfg(test)]
478#[derive(Default)]
479pub struct ScriptBuilder {
480    steps: Vec<Step>,
481}
482
483// A test utility keeps its full step vocabulary even when no current test
484// exercises every method.
485#[cfg(test)]
486#[allow(dead_code)]
487impl ScriptBuilder {
488    #[must_use]
489    pub fn highlight(self, target: CueTarget, duration: Duration) -> Self {
490        self.step(Step::Highlight { target, duration })
491    }
492
493    #[must_use]
494    pub fn move_to(self, target: CueTarget, duration: Duration) -> Self {
495        self.step(Step::MoveTo { target, duration })
496    }
497
498    #[must_use]
499    pub fn hover(self, target: CueTarget, duration: Duration) -> Self {
500        self.step(Step::Hover { target, duration })
501    }
502
503    #[must_use]
504    pub fn click(self, target: CueTarget) -> Self {
505        self.step(Step::Click {
506            target,
507            count: ClickCount::Single,
508        })
509    }
510
511    #[must_use]
512    pub fn double_click(self, target: CueTarget) -> Self {
513        self.step(Step::Click {
514            target,
515            count: ClickCount::Double,
516        })
517    }
518
519    #[must_use]
520    pub fn drag(self, from: CueTarget, to: CueTarget, duration: Duration) -> Self {
521        self.step(Step::Drag { from, to, duration })
522    }
523
524    #[must_use]
525    pub fn type_text(self, target: CueTarget, text: &'static str, duration: Duration) -> Self {
526        self.step(Step::Type {
527            target,
528            text,
529            duration,
530        })
531    }
532
533    #[must_use]
534    pub fn pause(self, duration: Duration) -> Self {
535        self.step(Step::Pause { duration })
536    }
537
538    pub fn build(self) -> Script {
539        Script::new(self.steps)
540    }
541
542    #[must_use]
543    fn step(mut self, step: Step) -> Self {
544        self.steps.push(step);
545        self
546    }
547}
548
549#[cfg(test)]
550impl From<ScriptBuilder> for Script {
551    fn from(builder: ScriptBuilder) -> Self {
552        builder.build()
553    }
554}
555
556impl Script {
557    #[cfg(test)]
558    pub fn builder() -> ScriptBuilder {
559        ScriptBuilder::default()
560    }
561
562    pub fn new(steps: Vec<Step>) -> Self {
563        Self { steps }
564    }
565
566    /// One script playing `scripts` back to back — the whole-level demo
567    /// derived from the per-stage scripts.
568    pub fn concat<'a>(scripts: impl IntoIterator<Item = &'a Script>) -> Script {
569        Script::new(
570            scripts
571                .into_iter()
572                .flat_map(|s| s.steps.iter().copied())
573                .collect(),
574        )
575    }
576
577    pub fn steps(&self) -> &[Step] {
578        &self.steps
579    }
580
581    pub fn total(&self) -> Duration {
582        self.steps.iter().map(Step::duration).sum()
583    }
584
585    /// The player camera as of `elapsed`: the last [`Step::Camera`] whose
586    /// start has been reached — eased from the previous framing while a
587    /// glide (non-zero duration) is in progress. Past the end, the final
588    /// camera. A glide with no previous camera cuts (there is nothing to
589    /// glide from).
590    pub fn camera_at(&self, elapsed: Duration) -> Option<egui::Rect> {
591        let mut acc = Duration::ZERO;
592        let mut current: Option<egui::Rect> = None;
593        for step in &self.steps {
594            if acc > elapsed {
595                break;
596            }
597            if let Step::Camera { rect, duration } = step {
598                current = Some(match current {
599                    Some(from) if elapsed < acc + *duration => {
600                        let p = Progress::through(elapsed.saturating_sub(acc), *duration).eased();
601                        lerp_rect(from, *rect, p)
602                    }
603                    _ => *rect,
604                });
605            }
606            acc += step.duration();
607        }
608        current
609    }
610
611    /// The centered instruction as of `elapsed`: the last [`Step::Instruct`]
612    /// whose start has been reached.
613    pub fn instruction_at(&self, elapsed: Duration) -> Option<&'static str> {
614        self.applied(elapsed, |step| match step {
615            Step::Instruct { text } => Some(*text),
616            _ => None,
617        })
618    }
619
620    /// The last `Some` that `pick` yields over the steps whose start time is
621    /// at or before `elapsed` — how the zero-duration state steps (camera,
622    /// instruction) persist until superseded.
623    fn applied<T>(&self, elapsed: Duration, pick: impl Fn(&Step) -> Option<T>) -> Option<T> {
624        let mut acc = Duration::ZERO;
625        let mut current = None;
626        for step in &self.steps {
627            if acc > elapsed {
628                break;
629            }
630            if let Some(value) = pick(step) {
631                current = Some(value);
632            }
633            acc += step.duration();
634        }
635        current
636    }
637
638    /// The frame at `elapsed` into the script, or `None` once the script has
639    /// finished — it plays once; replaying is the caller re-anchoring its
640    /// start time.
641    pub fn sample(&self, elapsed: Duration) -> Option<CueFrame> {
642        let mut remaining = elapsed;
643        // The resting position accumulated from completed steps.
644        let mut cursor: Option<CueTarget> = None;
645        let mut held: Option<HeldKey> = None;
646        for step in &self.steps {
647            // Zero-duration state steps apply as they are passed.
648            if let Step::Hold { key } = step {
649                held = *key;
650            }
651            let duration = step.duration();
652            if remaining < duration {
653                let p = Progress::through(remaining, duration);
654                return Some(CueFrame {
655                    held,
656                    ..Self::frame(step, cursor, p, remaining)
657                });
658            }
659            remaining -= duration;
660            if let Some(c) = step.end_cursor() {
661                cursor = Some(c);
662            }
663        }
664        None
665    }
666
667    /// The frame `into_step` into `step` (progress `p`), with the cursor
668    /// previously resting at `prev`.
669    fn frame(step: &Step, prev: Option<CueTarget>, p: Progress, into_step: Duration) -> CueFrame {
670        let parked = prev.map(CursorPos::At);
671        let base = CueFrame {
672            cursor: parked,
673            button: ButtonState::Up,
674            held: None,
675            highlight: None,
676            typing: None,
677        };
678        match step {
679            Step::Highlight { target, .. } => CueFrame {
680                highlight: Some(*target),
681                ..base
682            },
683            // Nothing to depict: a pause, a camera glide (the view itself
684            // moves), or the instantaneous instruction/command steps.
685            Step::Pause { .. }
686            | Step::Camera { .. }
687            | Step::Instruct { .. }
688            | Step::Command { .. }
689            | Step::Hold { .. } => base,
690            Step::Hover { target, .. } => CueFrame {
691                cursor: Some(CursorPos::At(*target)),
692                ..base
693            },
694            Step::Type { target, text, .. } => CueFrame {
695                typing: Some(TypingCue {
696                    target: *target,
697                    typed: typed_prefix(text, p),
698                }),
699                ..base
700            },
701            Step::MoveTo { target, .. } => CueFrame {
702                // With no prior position the cursor appears at the target.
703                cursor: Some(match prev {
704                    Some(from) => CursorPos::Between {
705                        from,
706                        to: *target,
707                        t: p.eased(),
708                    },
709                    None => CursorPos::At(*target),
710                }),
711                ..base
712            },
713            Step::Click { target, .. } => CueFrame {
714                cursor: Some(CursorPos::At(*target)),
715                button: ButtonState::Flash {
716                    t: Progress::new(into_step.div_duration_f32(CLICK_PRESS).fract()),
717                },
718                ..base
719            },
720            Step::Drag { from, to, .. } => CueFrame {
721                cursor: Some(match to.anchoring() {
722                    // A relative destination drags its own base: the doc
723                    // anchor follows the session's pointer frame by frame, so
724                    // riding the re-resolved anchor IS the drag path (a
725                    // symbolic lerp against the moving base would overshoot).
726                    Anchoring::FromDragBase(_) => CursorPos::At(*from),
727                    Anchoring::Toolbar(_) | Anchoring::Document => CursorPos::Between {
728                        from: *from,
729                        to: *to,
730                        t: p.eased(),
731                    },
732                }),
733                button: ButtonState::Down,
734                ..base
735            },
736        }
737    }
738}
739
740/// The characters of `text` entered by progress `p` through a [`Step::Type`].
741/// Shared with the lowering pass, which types the same prefixes into the
742/// captured editor buffer.
743pub(super) fn typed_prefix(text: &'static str, p: Progress) -> &'static str {
744    let entered = (p.get() * text.chars().count() as f32).ceil() as usize;
745    let end = text
746        .char_indices()
747        .nth(entered)
748        .map_or(text.len(), |(i, _)| i);
749    &text[..end]
750}
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755
756    fn demo() -> Script {
757        Script::new(vec![
758            Step::Highlight {
759                target: CueTarget::ToolButton(ToolName::NewBlock),
760                duration: Duration::from_secs(1),
761            },
762            Step::Click {
763                target: CueTarget::ToolButton(ToolName::NewBlock),
764                count: ClickCount::Double,
765            },
766            Step::Drag {
767                from: CueTarget::World(grid_pos(0, 0)),
768                to: CueTarget::World(grid_pos(8, 0)),
769                duration: Duration::from_secs(2),
770            },
771            Step::Pause {
772                duration: Duration::from_millis(500),
773            },
774        ])
775    }
776
777    #[test]
778    fn camera_glides_between_framings() {
779        let a = egui::Rect::from_min_max(grid_pos(0, 0), grid_pos(10, 10));
780        let b = egui::Rect::from_min_max(grid_pos(20, 0), grid_pos(40, 20));
781        let script = Script::new(vec![
782            Step::Camera {
783                rect: a,
784                duration: Duration::ZERO,
785            },
786            Step::Camera {
787                rect: b,
788                duration: Duration::from_secs(2),
789            },
790        ]);
791        assert_eq!(script.camera_at(Duration::ZERO), Some(a));
792        // Halfway through the glide the eased curve is exactly midway.
793        let mid = script.camera_at(Duration::from_secs(1)).unwrap();
794        assert_eq!(mid.min, a.min.lerp(b.min, 0.5));
795        assert_eq!(mid.max, a.max.lerp(b.max, 0.5));
796        assert_eq!(script.camera_at(Duration::from_secs(2)), Some(b));
797        assert_eq!(script.camera_at(Duration::from_secs(10)), Some(b));
798        // The glide holds the input timeline for its duration.
799        assert_eq!(script.total(), Duration::from_secs(2));
800    }
801
802    #[test]
803    fn a_leading_glide_cuts_to_its_target() {
804        let b = egui::Rect::from_min_max(grid_pos(20, 0), grid_pos(40, 20));
805        let script = Script::new(vec![Step::Camera {
806            rect: b,
807            duration: Duration::from_secs(2),
808        }]);
809        assert_eq!(script.camera_at(Duration::ZERO), Some(b));
810        assert_eq!(script.camera_at(Duration::from_secs(1)), Some(b));
811    }
812
813    /// A held key persists across the steps that follow it, until released —
814    /// the badge shows "+ ctrl" for the whole gesture, not one step of it.
815    #[test]
816    fn a_held_key_persists_until_released() {
817        let script = Script::new(vec![
818            Step::Pause {
819                duration: Duration::from_secs(1),
820            },
821            Step::Hold {
822                key: Some(HeldKey::Space),
823            },
824            Step::Drag {
825                from: at(0, 0),
826                to: at(4, 0),
827                duration: Duration::from_secs(2),
828            },
829            Step::Hold { key: None },
830            Step::Pause {
831                duration: Duration::from_secs(1),
832            },
833        ]);
834        let held = |secs| script.sample(Duration::from_secs(secs)).unwrap().held;
835        assert_eq!(held(0), None, "before the hold");
836        assert_eq!(held(2), Some(HeldKey::Space), "mid-drag");
837        assert_eq!(held(3), None, "after the release");
838    }
839
840    #[test]
841    fn totals_sum_step_durations() {
842        // 1 s highlight + 2 click presses + 2 s drag + 0.5 s pause.
843        let expected = Duration::from_millis(3500) + CLICK_PRESS * 2;
844        assert_eq!(demo().total(), expected);
845    }
846
847    #[test]
848    fn highlight_has_no_cursor_until_one_is_established() {
849        let frame = demo().sample(Duration::from_millis(500)).unwrap();
850        assert_eq!(frame.cursor, None);
851        assert_eq!(
852            frame.highlight,
853            Some(CueTarget::ToolButton(ToolName::NewBlock))
854        );
855        assert_eq!(frame.button, ButtonState::Up);
856    }
857
858    #[test]
859    fn double_click_flashes_twice() {
860        let script = demo();
861        // First press: just after the highlight ends.
862        let just_in = Duration::from_secs(1) + Duration::from_millis(100);
863        let first = script.sample(just_in).unwrap();
864        // Second press: one press-length later.
865        let second = script.sample(just_in + CLICK_PRESS).unwrap();
866        for frame in [first, second] {
867            assert!(matches!(frame.button, ButtonState::Flash { .. }));
868            assert_eq!(
869                frame.cursor,
870                Some(CursorPos::At(CueTarget::ToolButton(ToolName::NewBlock)))
871            );
872        }
873        // The flash phase restarts for the second press.
874        let ButtonState::Flash { t: t1 } = first.button else {
875            unreachable!()
876        };
877        let ButtonState::Flash { t: t2 } = second.button else {
878            unreachable!()
879        };
880        assert!((t1.get() - t2.get()).abs() < 1e-3, "{t1:?} vs {t2:?}");
881    }
882
883    #[test]
884    fn drag_interpolates_between_endpoints_with_button_down() {
885        let script = demo();
886        let drag_start = Duration::from_secs(1) + CLICK_PRESS * 2;
887        let frame = script.sample(drag_start + Duration::from_secs(1)).unwrap();
888        assert_eq!(frame.button, ButtonState::Down);
889        let Some(CursorPos::Between { t, .. }) = frame.cursor else {
890            panic!("expected a lerping cursor, got {:?}", frame.cursor);
891        };
892        // Halfway through the eased glide.
893        assert!((t.get() - 0.5).abs() < 1e-3, "{t:?}");
894    }
895
896    #[test]
897    fn pause_keeps_the_cursor_where_the_drag_left_it() {
898        let script = demo();
899        let pause_at = script
900            .total()
901            .checked_sub(Duration::from_millis(250))
902            .unwrap();
903        let frame = script.sample(pause_at).unwrap();
904        assert_eq!(
905            frame.cursor,
906            Some(CursorPos::At(CueTarget::World(grid_pos(8, 0))))
907        );
908    }
909
910    /// Doc-anchored targets resolve against the *lowered* document: by
911    /// title off its registers, and by the file's own ids through the
912    /// bridge's table (the uuids it minted leave nothing else to key on).
913    #[test]
914    fn doc_anchored_targets_follow_the_lowered_block() {
915        let mut fixture = CueFixture::lowered(
916            r#"
917top "b0"
918
919block "b0" x=0 y=0 w=28 h=20 {
920    title "sheet"
921    children "b1"
922}
923
924block "b1" x=4 y=4 w=8 h=7 {
925    title "core"
926    pin "p1" "in" loc="w1" dir="input"
927}
928"#,
929        );
930        let doc = &fixture.scope();
931        assert_eq!(
932            block("core").world(doc),
933            Some(grid_pos(8, 7).lerp(grid_pos(8, 8), 0.5))
934        );
935        assert_eq!(
936            corner("core", Handle::RightBottom).world(doc),
937            Some(grid_pos(12, 11))
938        );
939        assert_eq!(
940            CueTarget::BlockId("b1").world(doc),
941            block("core").world(doc),
942            "the file's own id names the same block its title does",
943        );
944        // The wire anchor is the pin's tip: one cell outside the west edge,
945        // `pin_offset_y` down from the block's top.
946        assert_eq!(
947            CueTarget::PinAnchor {
948                block: "b1",
949                pin: "p1"
950            }
951            .world(doc),
952            Some(egui::pos2(
953                px(4) - GRID_SIZE,
954                crate::grid::pin_offset_y(px(4), 1)
955            )),
956        );
957        assert_eq!(block("missing").world(doc), None);
958        assert_eq!(CueTarget::BlockId("b9").world(doc), None);
959        assert_eq!(tool(ToolName::NewBlock).world(doc), None);
960        assert_eq!(at(3, 5).world(doc), Some(grid_pos(3, 5)));
961    }
962
963    #[test]
964    fn typing_reveals_characters_and_leaves_the_cursor_parked() {
965        let script = Script::new(vec![
966            Step::MoveTo {
967                target: CueTarget::World(grid_pos(4, 0)),
968                duration: Duration::from_secs(1),
969            },
970            Step::Type {
971                target: CueTarget::World(grid_pos(0, 0)),
972                text: "CPU",
973                duration: Duration::from_secs(3),
974            },
975        ]);
976        let at = |millis: u64| {
977            script
978                .sample(Duration::from_secs(1) + Duration::from_millis(millis))
979                .unwrap()
980        };
981        assert_eq!(at(500).typing.unwrap().typed, "C");
982        assert_eq!(at(1500).typing.unwrap().typed, "CP");
983        assert_eq!(at(2900).typing.unwrap().typed, "CPU");
984        assert_eq!(
985            at(1500).cursor,
986            Some(CursorPos::At(CueTarget::World(grid_pos(4, 0))))
987        );
988    }
989
990    #[test]
991    fn a_finished_script_yields_no_frame() {
992        let script = demo();
993        assert!(
994            script
995                .sample(script.total() + Duration::from_millis(10))
996                .is_none()
997        );
998    }
999}