Skip to main content

blockworx/script/
driver.rs

1//! The synthetic-input state machine every scripted driver shares: the
2//! pointer, held button and queued editor commit a script's [`SimFrame`]s
3//! imply, and the one implementation of *applying* such a frame to an editing
4//! state through a painter.
5//!
6//! The painter is a parameter, which is the whole trick: the headless test
7//! harness hands in a throwaway painter and gets the golden-replay tests; the
8//! tutorial player hands in its video pane's painter and every `widget()` call
9//! *is* a video frame — previews, selection frames, everything the real tools
10//! draw.
11//!
12//! [`SimClock`] is the other half: real time banked into whole sim frames, so
13//! a hitch can't fast-forward a demo.
14
15use std::time::Duration;
16
17use egui::Pos2;
18
19use crate::doc::Writability;
20use crate::gesture::Gesture;
21use blockworx_doc::document::DocIndex;
22
23use crate::canvas::painter::Painter;
24use crate::canvas::{Event, Interaction, Press};
25use crate::gesture;
26use crate::path::BlockPath;
27use crate::theme::{Style, Theme};
28use crate::tools::commands;
29use crate::tools::tool::{Action, Tool, ToolTrait};
30use blockworx_doc::repo::Repo;
31
32use super::lowering::{SIM_DT, SimFrame};
33
34/// The editing state a scripted frame acts on: a repo to submit to, the
35/// caches beside it, and the armed tool. Separate borrows, so a driver whose
36/// repo and tool live in different fields of a larger struct (the app)
37/// can hand them over without borrowing itself whole.
38pub struct SimTarget<'a> {
39    pub repo: &'a mut Repo,
40    pub index: &'a mut DocIndex,
41    pub path: &'a BlockPath,
42    pub presentation: &'a mut crate::presentation::Presentation,
43    /// The gesture in progress. The driver opens and closes one per frame,
44    /// exactly as the app's canvas pass does.
45    pub gesture: &'a mut Gesture,
46    pub tool: &'a mut Tool,
47}
48
49#[derive(Default)]
50pub struct SimDriver {
51    /// Enter queued by a committed typing frame, delivered on the next frame
52    /// so the editor sees its buffer before the key.
53    pending_enter: bool,
54    /// Where the script's events last put the pointer (world space) — the
55    /// driver's stand-in for a physical pointer, fed to the painter so hover
56    /// affordances light up under the demo cursor.
57    pointer: Option<Pos2>,
58    /// The held button as of the last scripted frame, re-presented by idle
59    /// frames so press affordances stay armed between them.
60    press: Option<Press>,
61    /// Between `DragStarted` and `DragStopped`: idle frames must not
62    /// re-present a hover mid-gesture.
63    dragging: bool,
64}
65
66impl SimDriver {
67    /// One synthetic frame through the real tool, exactly as `View::show`
68    /// would deliver it: apply a toolbar switch, run `widget()` against
69    /// `egui_painter` (in world coordinates — offset zero, zoom one), then
70    /// drive the in-place editor the tool may have opened.
71    pub fn apply(
72        &mut self,
73        frame: SimFrame,
74        target: SimTarget<'_>,
75        theme: &Theme,
76        egui_painter: &egui::Painter,
77    ) {
78        let SimTarget {
79            repo,
80            index,
81            path,
82            presentation,
83            gesture,
84            tool,
85        } = target;
86        if let Some(name) = frame.switch_tool {
87            // Arming a tool authors nothing: the gesture the outgoing tool
88            // was in sealed when its frame ended.
89            *tool = Tool::from_name(name);
90        }
91        self.track(frame.event);
92        self.press = frame.press;
93        let interaction = self.interaction(&frame);
94        let mut painter = Painter::headless(egui_painter.clone(), theme.palette().clone());
95        // The driver's input is exclusively this frame's `Interaction` plus
96        // the synthetic pointer tracked from it: the tools' raw-pointer reads
97        // (hover and press-and-hold affordances) must see the demo's pointer,
98        // never the user's mouse.
99        painter.set_scripted(crate::canvas::painter::ScriptedInput {
100            pointer: self.pointer,
101        });
102        // The frame IS the tool's gesture, exactly as the app's canvas pass
103        // is: everything it writes seals into one commit labelled for the
104        // tool that made it.
105        // A scripted session drives its own scratch repo, which is always
106        // writable — there is no container to be locked and no past to view.
107        *gesture = gesture::Gesture::open(
108            crate::edit::describe::Label::verb(tool.name().verb()),
109            Writability::Writable,
110        );
111        let action = {
112            let mut drawing = gesture::drawing(repo, index, path, presentation, gesture);
113            let mut style = Style::new(theme, &mut painter);
114            crate::tools::tool::frame(tool, &mut drawing, &interaction, &mut style)
115        };
116        gesture::close(repo, index, path, presentation, gesture);
117        // The editor seam: a tool that opened an in-place editor surfaces it
118        // here (the on-screen `View` renders a TextEdit from it). Type by
119        // writing the shared buffer; commit by queueing Enter.
120        if let Some(typing) = frame.typing
121            && let Some(edit) = painter.take_edit_text()
122        {
123            let mut buffer = edit.buffer.borrow_mut();
124            buffer.clear();
125            buffer.push_str(typing.text);
126            if typing.commit {
127                self.pending_enter = true;
128            }
129        }
130        // Actions the tools return apply through the same document-scoped
131        // dispatcher the app uses; app-level requests (export, pickers,
132        // clipboard) are logged and dropped rather than silently diverging.
133        if let Some(action) = action
134            && let Some(other) = dispatch(
135                action,
136                SimTarget {
137                    repo,
138                    index,
139                    path,
140                    presentation,
141                    gesture,
142                    tool,
143                },
144            )
145        {
146            tracing::warn!(
147                "scripted frame ignoring app-level action: {}",
148                action_name(&other)
149            );
150        }
151        if let Some(name) = frame.command {
152            run_command(
153                name,
154                SimTarget {
155                    repo,
156                    index,
157                    path,
158                    presentation,
159                    gesture,
160                    tool,
161                },
162            );
163        }
164    }
165
166    /// The frame a driver feeds when the script owes nothing this instant —
167    /// the player's visible paint pass, the post-script commit flush. It
168    /// re-presents the resting pointer as a hover (a physical pointer doesn't
169    /// vanish between events), so per-frame hover affordances stay lit.
170    pub fn idle_frame(&self) -> SimFrame {
171        SimFrame {
172            event: (!self.dragging)
173                .then_some(self.pointer)
174                .flatten()
175                .map(Event::HoverAt),
176            press: self.press,
177            ..SimFrame::default()
178        }
179    }
180
181    /// [`SimDriver::idle_frame`] as an interaction, for a visible pass that
182    /// runs the tool itself (the app's canvas) instead of through
183    /// [`SimDriver::apply`].
184    pub fn idle_interaction(&mut self) -> Interaction {
185        let idle = self.idle_frame();
186        self.interaction(&idle)
187    }
188
189    pub fn pointer(&self) -> Option<Pos2> {
190        self.pointer
191    }
192
193    /// A committed typing frame owes an Enter, so the script isn't done until
194    /// a later frame has delivered it.
195    pub fn enter_pending(&self) -> bool {
196        self.pending_enter
197    }
198
199    #[cfg(test)]
200    pub fn queue_enter(&mut self) {
201        self.pending_enter = true;
202    }
203
204    fn interaction(&mut self, frame: &SimFrame) -> Interaction {
205        Interaction {
206            event: frame.event,
207            press: frame.press,
208            lost_focus: false,
209            enter_pressed: std::mem::take(&mut self.pending_enter),
210            tab_pressed: false,
211            escape_pressed: false,
212            delete_pressed: false,
213            shift: false,
214        }
215    }
216
217    /// Track the pointer the script's events imply (see [`SimDriver::idle_frame`]).
218    fn track(&mut self, event: Option<Event>) {
219        match event {
220            Some(
221                Event::HoverAt(pos)
222                | Event::Clicked { pos }
223                | Event::DoubleClicked { pos }
224                | Event::Dragging { pos, .. },
225            ) => self.pointer = Some(pos),
226            Some(Event::DragStarted { pos }) => {
227                self.pointer = Some(pos);
228                self.dragging = true;
229            }
230            Some(Event::DragStopped { pos }) => {
231                self.pointer = Some(pos);
232                self.dragging = false;
233            }
234            None => {}
235        }
236    }
237}
238
239/// Apply the document-scoped half of `action` inside its own labelled
240/// gesture, settling the tool where the arm says. Whatever needs app
241/// machinery comes back for the caller to report.
242fn dispatch(action: Action, target: SimTarget<'_>) -> Option<Box<Action>> {
243    let SimTarget {
244        repo,
245        index,
246        path,
247        presentation,
248        gesture,
249        tool,
250    } = target;
251    *gesture = gesture::Gesture::open(
252        crate::edit::describe::Label::verb(action.label()),
253        Writability::Writable,
254    );
255    let outcome = {
256        let mut drawing = gesture::drawing(repo, index, path, presentation, gesture);
257        commands::apply_scripted(action, &mut drawing)
258    };
259    gesture::close(repo, index, path, presentation, gesture);
260    match outcome {
261        commands::ScriptedApply::Applied(settles_on) => {
262            if let Some(next) = settles_on {
263                *tool = next;
264            }
265            None
266        }
267        commands::ScriptedApply::NeedsApp(other) => Some(other),
268    }
269}
270
271/// Dispatch a `command` step: resolve `name` against the frame's command
272/// registry — availability follows the repo's selection and lock state,
273/// exactly as the palette would offer it — and apply the resolved action.
274/// Errors are console reports: an unavailable command leaves the document
275/// unchanged, which the golden replay then flags.
276fn run_command(name: &str, target: SimTarget<'_>) {
277    let SimTarget {
278        repo,
279        index,
280        path,
281        presentation,
282        gesture,
283        tool,
284    } = target;
285    let action = {
286        let history = commands::History {
287            can_undo: repo.can_undo(),
288            can_redo: repo.can_redo(),
289        };
290        let drawing = gesture::drawing(repo, index, path, presentation, gesture);
291        let current_lock: crate::edit::naming::InterfaceLock = drawing.current_locked().into();
292        let mut set = commands::CommandSet::available(&commands::CommandContext {
293            tool,
294            data: &drawing,
295            history,
296            current_lock,
297            // A scripted session drives a scratch repo, which nothing can
298            // hold a lock on, has no container to save into, and never
299            // opens the time machine.
300            writability: crate::doc::Writability::Writable,
301            head: blockworx_doc::rev::Rev::ZERO,
302            saving: crate::doc::Saving::Withheld,
303            viewing: crate::doc::Viewing::Head,
304        });
305        set.take_by_name(name)
306    };
307    let Some(action) = action else {
308        tracing::error!("command {name:?} is not available here");
309        return;
310    };
311    if let Some(other) = dispatch(
312        action,
313        SimTarget {
314            repo,
315            index,
316            path,
317            presentation,
318            gesture,
319            tool,
320        },
321    ) {
322        tracing::error!(
323            "command {name:?} needs the app and cannot run in a script: {}",
324            action_name(&other)
325        );
326    }
327}
328
329/// Sim frames fed per shown frame at most — enough for 2× speed on a 60 Hz
330/// display with headroom, without spiraling after a hitch.
331const MAX_FRAMES_PER_SHOW: u32 = 8;
332
333/// Cap on the real time banked per shown frame, so a long stall doesn't
334/// fast-forward the demo.
335const HITCH_CAP: Duration = Duration::from_millis(100);
336
337/// Playback rate: how much script time one second of real time buys.
338#[derive(Clone, Copy, PartialEq, Debug)]
339pub struct Speed(f32);
340
341impl Speed {
342    pub const HALF: Self = Self(0.5);
343    pub const NORMAL: Self = Self(1.0);
344    pub const DOUBLE: Self = Self(2.0);
345}
346
347impl std::fmt::Display for Speed {
348    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
349        write!(f, "{}", self.0)
350    }
351}
352
353/// Real time banked into whole sim frames: the fixed-timestep clock every
354/// scripted playback runs on.
355#[derive(Default)]
356pub struct SimClock {
357    /// Sim frames fed so far — the cue clock.
358    frames: u32,
359    /// Real time (× speed) not yet consumed by whole sim frames.
360    carry: Duration,
361}
362
363impl SimClock {
364    /// Bank `dt` real time and return how many sim frames came due, capped so
365    /// a hitch can't fast-forward the demo.
366    pub fn begin_show(&mut self, dt: Duration, speed: Speed) -> u32 {
367        self.carry += dt.min(HITCH_CAP).mul_f32(speed.0);
368        // Integer division: a float ratio can round a hair *up* at an exact
369        // multiple and claim a frame the carry can't pay for.
370        let due = self.carry.as_nanos() / SIM_DT.as_nanos();
371        let n = due.min(u128::from(MAX_FRAMES_PER_SHOW)) as u32;
372        self.carry -= SIM_DT * n;
373        n
374    }
375
376    /// One of the banked frames was actually fed.
377    pub fn tick(&mut self) {
378        self.frames += 1;
379    }
380
381    /// The cue/camera clock: sim frames fed so far, in script time.
382    pub fn elapsed(&self) -> Duration {
383        SIM_DT * self.frames
384    }
385}
386
387pub(crate) fn action_name(action: &Action) -> &'static str {
388    match action {
389        Action::SwitchTool(_) => "SwitchTool",
390        Action::Delete(_) => "Delete",
391        Action::Copy(_) => "Copy",
392        Action::CopyPins(_) => "CopyPins",
393        Action::Cut(_) => "Cut",
394        Action::CutPins(_) => "CutPins",
395        Action::SetPinTags { .. } => "SetPinTags",
396        Action::SetShapeTagHidden { .. } => "SetShapeTagHidden",
397        Action::FlipShapePins(_) => "FlipShapePins",
398        Action::FlipBlockVertical(_) => "FlipBlockVertical",
399        Action::SetBlockLocked { .. } => "SetBlockLocked",
400        Action::Paste(_) => "Paste",
401        Action::ExpandBlock(_) => "ExpandBlock",
402        Action::GoToPath(_) => "GoToPath",
403        Action::Zoom(_) => "Zoom",
404        Action::Camera(_) => "Camera",
405        Action::SetRole { .. } => "SetRole",
406        Action::SetPinsKind { .. } => "SetPinsKind",
407        Action::OpenRolePicker { .. } => "OpenRolePicker",
408        Action::OpenPinTypePicker { .. } => "OpenPinTypePicker",
409        Action::GoUp => "GoUp",
410        Action::NavSelect { .. } => "NavSelect",
411        Action::PathBack => "PathBack",
412        Action::PathForward => "PathForward",
413        Action::Undo => "Undo",
414        Action::Redo => "Redo",
415        Action::Nudge { .. } => "Nudge",
416        Action::ResetView => "ResetView",
417        Action::Export { .. } => "Export",
418        Action::ExportRev { .. } => "ExportRev",
419        Action::Import => "Import",
420        Action::Reroute(_) => "Reroute",
421        Action::RerouteBlock(_) => "RerouteBlock",
422        Action::ViewRev(_) => "ViewRev",
423        Action::ViewHead => "ViewHead",
424        Action::RestoreRev(_) => "RestoreRev",
425        Action::TagRev { .. } => "TagRev",
426        Action::OpenTutorial => "OpenTutorial",
427        Action::TutorialLoadLevel(_) => "TutorialLoadLevel",
428        Action::TutorialExit => "TutorialExit",
429        Action::SaveProjection => "SaveProjection",
430        // The File flow is app chrome: a script has no dialogs to drive.
431        #[cfg(not(target_arch = "wasm32"))]
432        Action::NewDocument => "NewDocument",
433        #[cfg(not(target_arch = "wasm32"))]
434        Action::PickFile(_) => "PickFile",
435        #[cfg(not(target_arch = "wasm32"))]
436        Action::OpenRecent(_) => "OpenRecent",
437        #[cfg(not(target_arch = "wasm32"))]
438        Action::RenameDocument(_) => "RenameDocument",
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[test]
447    fn begin_show_caps_frames_and_conserves_carry() {
448        let mut clock = SimClock::default();
449        // A whole second owed is clamped to the hitch cap — 6 frames.
450        assert_eq!(clock.begin_show(Duration::from_secs(1), Speed::NORMAL), 6);
451        assert!(clock.carry < SIM_DT);
452        // Banked carry beyond one show's budget is capped and conserved.
453        clock.carry = Duration::from_millis(200);
454        assert_eq!(
455            clock.begin_show(Duration::ZERO, Speed::NORMAL),
456            MAX_FRAMES_PER_SHOW
457        );
458        let banked = Duration::from_millis(200)
459            .checked_sub(SIM_DT * MAX_FRAMES_PER_SHOW)
460            .unwrap();
461        assert_eq!(clock.carry, banked);
462        // Half a sim frame owed: nothing due, carry keeps it.
463        clock.carry = Duration::ZERO;
464        assert_eq!(clock.begin_show(SIM_DT / 2, Speed::NORMAL), 0);
465        assert_eq!(clock.carry, SIM_DT / 2);
466        // The second half completes exactly one frame.
467        assert_eq!(clock.begin_show(SIM_DT / 2, Speed::NORMAL), 1);
468        assert!(clock.carry < SIM_DT);
469    }
470
471    /// 50 ms of real time is three sim frames at 1×, and the speed scales it
472    /// before the hitch cap can bite.
473    #[test]
474    fn speed_scales_the_banked_time() {
475        let show = Duration::from_millis(50);
476        let due = |speed| SimClock::default().begin_show(show, speed);
477        assert_eq!(due(Speed::NORMAL), 3);
478        assert_eq!(due(Speed::HALF), 1);
479        assert_eq!(due(Speed::DOUBLE), 6);
480    }
481}