Skip to main content

blockworx/script/
lowering.rs

1//! Lower a [`Script`] into the per-frame synthetic input a human performing
2//! it would have produced: hover glides, click/drag events, tool switches,
3//! and editor typing, at a fixed timestep so replays are deterministic.
4//!
5//! Lowering is *streaming*: each step's endpoints resolve against the
6//! document as the step begins, because earlier frames may have created or
7//! moved the very objects a later step targets (a drag that moves a block
8//! must not chase the block it is moving).
9
10use std::time::Duration;
11
12use egui::Pos2;
13
14use crate::canvas::{Event, Press};
15use crate::progress::Progress;
16use crate::tools::names::ToolName;
17
18use super::step::{Anchoring, ClickCount, CueScope, CueTarget, Script, Step, typed_prefix};
19
20/// The fixed simulation timestep (one frame at 60 Hz).
21pub const SIM_DT: Duration = Duration::from_nanos(1_000_000_000 / 60);
22
23/// One frame of synthetic input.
24#[derive(Clone, Copy, Debug, Default)]
25pub struct SimFrame {
26    /// Canvas event, world space — what `compute_interaction` would have
27    /// produced from the real pointer.
28    pub event: Option<Event>,
29    /// The held primary button, world space — the level `compute_interaction`
30    /// reports while a click or drag is in progress.
31    pub press: Option<Press>,
32    /// A toolbar press this frame: arm this tool.
33    pub switch_tool: Option<ToolName>,
34    /// Text to force into the open in-place editor, plus whether this frame
35    /// finishes the entry (Enter on the next frame).
36    pub typing: Option<Typing>,
37    /// A registry command to dispatch this frame, by its typeable name.
38    pub command: Option<&'static str>,
39}
40
41#[derive(Clone, Copy, Debug)]
42pub struct Typing {
43    /// The prefix typed so far (grows frame over frame).
44    pub text: &'static str,
45    /// This frame completes the entry; the runner presses Enter next frame.
46    pub commit: bool,
47}
48
49/// A doc-anchored target named an object the document doesn't have — the
50/// stage's demo references something its prerequisites didn't create.
51// The fields reach error reports through the derived Debug, which dead-code
52// analysis deliberately ignores.
53#[derive(Debug)]
54#[allow(dead_code)]
55pub struct UnresolvedTarget {
56    pub step: usize,
57    pub target: CueTarget,
58}
59
60/// Streaming lowerer over one script. Call [`Lowering::next`] with the
61/// *current* document each frame until it returns `Ok(None)`.
62pub struct Lowering {
63    steps: Vec<Step>,
64    step: usize,
65    /// Frames already emitted for the current step.
66    frame: u32,
67    /// World endpoints of the current step, resolved when it began.
68    span: Option<(Option<Pos2>, Option<Pos2>)>,
69    /// Where the cursor rests, in world space (`None` after toolbar targets).
70    cursor: Option<Pos2>,
71}
72
73impl Lowering {
74    pub fn new(script: &Script) -> Self {
75        Self {
76            steps: script.steps().to_vec(),
77            step: 0,
78            frame: 0,
79            span: None,
80            cursor: None,
81        }
82    }
83
84    /// Which step the frame just handed out came from.
85    #[cfg(test)]
86    pub fn step(&self) -> usize {
87        self.step
88    }
89
90    /// The next frame of input, or `Ok(None)` when the script has finished.
91    ///
92    /// `scope` is read only where the lowerer actually resolves endpoints —
93    /// the first frame of a step.
94    pub fn next(&mut self, scope: &CueScope<'_>) -> Result<Option<SimFrame>, UnresolvedTarget> {
95        loop {
96            let Some(step) = self.steps.get(self.step) else {
97                return Ok(None);
98            };
99            let frames = step_frames(step);
100            if self.frame >= frames {
101                self.finish_step();
102                continue;
103            }
104            let (from, to) = if let Some(span) = self.span {
105                span
106            } else {
107                let span = self.resolve_span(step, scope)?;
108                self.span = Some(span);
109                span
110            };
111            // Progress through the step at this frame's *end*, so the final
112            // frame lands exactly on the target.
113            let p = Progress::new((self.frame + 1) as f32 / frames as f32);
114            let frame = self.frame;
115            self.frame += 1;
116            let lerp = |p: Progress| match (from, to) {
117                (Some(a), Some(b)) => Some(a.lerp(b, p.eased().get())),
118                (_, b) => b,
119            };
120            let sim = match step {
121                Step::Highlight { .. }
122                | Step::Pause { .. }
123                | Step::Camera { .. }
124                | Step::Instruct { .. }
125                | Step::Hold { .. } => SimFrame::default(),
126                Step::Command { name } => SimFrame {
127                    command: Some(name),
128                    ..SimFrame::default()
129                },
130                Step::MoveTo { .. } | Step::Hover { .. } => SimFrame {
131                    event: lerp(p).map(Event::HoverAt),
132                    ..SimFrame::default()
133                },
134                Step::Click { target, count } => lower_click(*target, *count, p, to),
135                Step::Drag { .. } => {
136                    let (Some(a), Some(b)) = (from, to) else {
137                        return Err(self.unresolved(step));
138                    };
139                    // The glide occupies frames 0..frames-1 and *completes* on
140                    // the last Dragging frame, so per-frame deltas sum exactly
141                    // to the displacement (movers accumulate deltas and ignore
142                    // the stop position). DragStopped is its own final frame.
143                    let motion = |f: u32| {
144                        let q = Progress::new(f as f32 / (frames - 2) as f32);
145                        a.lerp(b, q.eased().get())
146                    };
147                    // The button releases on the stop frame, exactly as a live
148                    // drag reports it.
149                    let (event, press) = if frame == 0 {
150                        (Event::DragStarted { pos: a }, Some(Press { origin: a }))
151                    } else if self.frame == frames {
152                        (Event::DragStopped { pos: b }, None)
153                    } else {
154                        (
155                            Event::Dragging {
156                                pos: motion(frame),
157                                delta: motion(frame) - motion(frame - 1),
158                            },
159                            Some(Press { origin: a }),
160                        )
161                    };
162                    SimFrame {
163                        event: Some(event),
164                        press,
165                        ..SimFrame::default()
166                    }
167                }
168                Step::Type { text, .. } => SimFrame {
169                    typing: Some(Typing {
170                        text: typed_prefix(text, p),
171                        commit: self.frame == frames,
172                    }),
173                    ..SimFrame::default()
174                },
175            };
176            return Ok(Some(sim));
177        }
178    }
179
180    /// Resolve the step's world endpoints as it begins: `from` is where the
181    /// cursor rests, `to` the step's target.
182    fn resolve_span(
183        &self,
184        step: &Step,
185        scope: &CueScope<'_>,
186    ) -> Result<(Option<Pos2>, Option<Pos2>), UnresolvedTarget> {
187        let resolve = |target: &CueTarget| -> Result<Option<Pos2>, UnresolvedTarget> {
188            match target.anchoring() {
189                // Toolbar targets have no world position by design.
190                Anchoring::Toolbar(_) => Ok(None),
191                Anchoring::Document | Anchoring::FromDragBase(_) => target
192                    .world(scope)
193                    .map(Some)
194                    .ok_or_else(|| self.unresolved(step)),
195            }
196        };
197        Ok(match step {
198            Step::Highlight { .. }
199            | Step::Pause { .. }
200            | Step::Type { .. }
201            | Step::Camera { .. }
202            | Step::Instruct { .. }
203            | Step::Command { .. }
204            | Step::Hold { .. } => (self.cursor, self.cursor),
205            Step::MoveTo { target, .. }
206            | Step::Hover { target, .. }
207            | Step::Click { target, .. } => (self.cursor, resolve(target)?),
208            Step::Drag { from, to, .. } => {
209                let a = resolve(from)?;
210                let b = match to.anchoring() {
211                    Anchoring::FromDragBase(offset) => a.map(|p| p + offset),
212                    Anchoring::Toolbar(_) | Anchoring::Document => resolve(to)?,
213                };
214                (a, b)
215            }
216        })
217    }
218
219    fn finish_step(&mut self) {
220        if let Some((_, to)) = self.span.take()
221            && !matches!(
222                self.steps[self.step],
223                Step::Highlight { .. }
224                    | Step::Pause { .. }
225                    | Step::Type { .. }
226                    | Step::Camera { .. }
227                    | Step::Instruct { .. }
228                    | Step::Command { .. }
229                    | Step::Hold { .. }
230            )
231        {
232            self.cursor = to.or(self.cursor);
233        }
234        self.step += 1;
235        self.frame = 0;
236    }
237
238    fn unresolved(&self, step: &Step) -> UnresolvedTarget {
239        let target = match step {
240            Step::Highlight { target, .. }
241            | Step::MoveTo { target, .. }
242            | Step::Hover { target, .. }
243            | Step::Click { target, .. }
244            | Step::Type { target, .. } => *target,
245            Step::Drag { from, to, .. } => {
246                // Whichever endpoint failed; prefer naming the one that can't
247                // be a fixed position.
248                if matches!(from, CueTarget::World(_)) {
249                    *to
250                } else {
251                    *from
252                }
253            }
254            // These have no target and resolve unconditionally.
255            Step::Pause { .. }
256            | Step::Camera { .. }
257            | Step::Instruct { .. }
258            | Step::Command { .. }
259            | Step::Hold { .. } => CueTarget::World(Pos2::ZERO),
260        };
261        UnresolvedTarget {
262            step: self.step,
263            target,
264        }
265    }
266}
267
268/// A click flashes for its press duration but acts once, on its final
269/// frame: the canvas event or the toolbar switch. The frames before it hold
270/// the button down at the target — the release is what egui's click *is* —
271/// so press-and-hold affordances see the same sequence a live click makes.
272fn lower_click(target: CueTarget, count: ClickCount, p: Progress, world: Option<Pos2>) -> SimFrame {
273    if !p.is_complete() {
274        return SimFrame {
275            press: world.map(|origin| Press { origin }),
276            ..SimFrame::default()
277        };
278    }
279    match (target, world) {
280        (CueTarget::ToolButton(name), _) => SimFrame {
281            switch_tool: Some(name),
282            ..SimFrame::default()
283        },
284        (_, Some(pos)) => SimFrame {
285            event: Some(match count {
286                ClickCount::Single => Event::Clicked { pos },
287                ClickCount::Double => Event::DoubleClicked { pos },
288            }),
289            ..SimFrame::default()
290        },
291        (_, None) => SimFrame::default(),
292    }
293}
294
295/// Frames a step occupies at [`SIM_DT`] (at least one; a drag needs start,
296/// one full-displacement `Dragging`, and stop — at least three).
297fn step_frames(step: &Step) -> u32 {
298    let duration = match step {
299        Step::Highlight { duration, .. }
300        | Step::MoveTo { duration, .. }
301        | Step::Hover { duration, .. }
302        | Step::Drag { duration, .. }
303        | Step::Type { duration, .. }
304        | Step::Pause { duration }
305        | Step::Camera { duration, .. } => *duration,
306        Step::Click { count, .. } => match count {
307            ClickCount::Single => super::step::CLICK_PRESS,
308            ClickCount::Double => super::step::CLICK_PRESS * 2,
309        },
310        // Instantaneous steps; the one-frame floor below keeps the replay
311        // loop uniform.
312        Step::Instruct { .. } | Step::Command { .. } | Step::Hold { .. } => Duration::ZERO,
313    };
314    let floor = if matches!(step, Step::Drag { .. }) {
315        3
316    } else {
317        1
318    };
319    ((duration.div_duration_f32(SIM_DT)).round() as u32).max(floor)
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::script::step::{CueFixture, Script, at, grid_pos, tool};
326    use crate::tools::names::ToolName;
327
328    fn drain(script: &Script) -> Vec<SimFrame> {
329        // A document with no blocks: lowering only reads it to resolve
330        // targets, and these cases resolve none.
331        let mut fixture = CueFixture::empty();
332        let scope = fixture.scope();
333        let mut lowering = Lowering::new(script);
334        let mut frames = Vec::new();
335        while let Some(f) = lowering.next(&scope).unwrap() {
336            frames.push(f);
337        }
338        frames
339    }
340
341    #[test]
342    fn drag_lowers_to_start_move_stop_with_exact_deltas() {
343        let script = Script::builder()
344            .drag(at(0, 0), at(8, 0), Duration::from_millis(500))
345            .build();
346        let frames = drain(&script);
347        assert_eq!(frames.len(), 30);
348        assert_eq!(
349            frames[0].event,
350            Some(Event::DragStarted {
351                pos: grid_pos(0, 0)
352            })
353        );
354        assert_eq!(
355            frames.last().unwrap().event,
356            Some(Event::DragStopped {
357                pos: grid_pos(8, 0)
358            })
359        );
360        // Movers accumulate Dragging deltas and ignore the stop position, so
361        // the deltas must sum to exactly the displacement.
362        let sum = frames
363            .iter()
364            .filter_map(|f| match f.event {
365                Some(Event::Dragging { delta, .. }) => Some(delta),
366                _ => None,
367            })
368            .fold(egui::Vec2::ZERO, |acc, d| acc + d);
369        let expected = grid_pos(8, 0) - grid_pos(0, 0);
370        assert!((sum.x - expected.x).abs() < 1e-3, "{sum:?}");
371        assert!(sum.y.abs() < 1e-6);
372    }
373
374    #[test]
375    fn relative_drag_lands_at_the_offset_from_its_start() {
376        let script = Script::builder()
377            .drag(
378                at(2, 3),
379                crate::script::step::by(4, -3),
380                Duration::from_millis(500),
381            )
382            .build();
383        let frames = drain(&script);
384        assert_eq!(
385            frames[0].event,
386            Some(Event::DragStarted {
387                pos: grid_pos(2, 3)
388            })
389        );
390        assert_eq!(
391            frames.last().unwrap().event,
392            Some(Event::DragStopped {
393                pos: grid_pos(6, 0)
394            })
395        );
396    }
397
398    #[test]
399    fn toolbar_click_lowers_to_a_tool_switch() {
400        let script = Script::builder()
401            .highlight(tool(ToolName::Route), Duration::from_millis(100))
402            .click(tool(ToolName::Route))
403            .build();
404        let frames = drain(&script);
405        let switches: Vec<_> = frames.iter().filter_map(|f| f.switch_tool).collect();
406        assert_eq!(switches, vec![ToolName::Route]);
407        assert!(frames.iter().all(|f| f.event.is_none()));
408    }
409
410    #[test]
411    fn click_after_move_lands_on_the_target() {
412        let script = Script::builder()
413            .move_to(at(4, 4), Duration::from_millis(200))
414            .click(at(4, 4))
415            .build();
416        let frames = drain(&script);
417        let clicks: Vec<_> = frames
418            .iter()
419            .filter_map(|f| match f.event {
420                Some(Event::Clicked { pos }) => Some(pos),
421                _ => None,
422            })
423            .collect();
424        assert_eq!(clicks, vec![grid_pos(4, 4)]);
425        // Hover frames precede the click and end on the target.
426        assert!(matches!(frames[0].event, Some(Event::HoverAt(_))));
427    }
428
429    #[test]
430    fn typing_grows_and_commits_on_the_final_frame() {
431        let script = Script::builder()
432            .type_text(at(0, 0), "CPU", Duration::from_millis(200))
433            .build();
434        let frames = drain(&script);
435        let last = frames.last().unwrap().typing.unwrap();
436        assert_eq!(last.text, "CPU");
437        assert!(last.commit);
438        assert!(
439            frames
440                .iter()
441                .rev()
442                .skip(1)
443                .all(|f| !f.typing.unwrap().commit)
444        );
445    }
446
447    #[test]
448    fn unresolved_block_target_reports_the_step() {
449        let script = Script::builder()
450            .move_to(
451                crate::script::step::block("ghost"),
452                Duration::from_millis(200),
453            )
454            .build();
455        let mut fixture = CueFixture::empty();
456        let err = Lowering::new(&script).next(&fixture.scope()).unwrap_err();
457        assert_eq!(err.step, 0);
458    }
459}