Skip to main content

blockworx/tutorial/
session.rs

1//! The shared demo engine: a complete editing session — document, path, and
2//! the active real `Tool` — driven by synthetic [`SimFrame`]s instead of a
3//! pointer. The painter is a parameter, which is the whole trick: the headless
4//! test runner (`super::runner`) hands in a throwaway painter and gets the
5//! golden-replay tests; the player hands in its video pane's painter and every
6//! `widget()` call *is* a video frame — previews, selection frames, everything
7//! the real tools draw.
8
9use egui::{Pos2, Vec2};
10
11use crate::canvas::Interaction;
12use crate::canvas::image::ImageRegistry;
13use crate::canvas::painter::Painter;
14use crate::document_ng::{BlockPath, Document};
15use crate::icons::Icons;
16use crate::schema::model as schema;
17use crate::theme::{Style, Theme};
18#[cfg(test)]
19use crate::tools::RenameTitle;
20use crate::tools::SelectTool;
21use crate::tools::tool::{Action, Tool, ToolTrait};
22use crate::widget::drawing::{Drawing, finalize_load};
23
24use super::lowering::SimFrame;
25
26pub struct Session {
27    document: Document,
28    path: BlockPath,
29    tool: Tool,
30    images: std::rc::Rc<std::cell::RefCell<ImageRegistry>>,
31    icons: Icons,
32    /// Enter queued by a committed typing frame, delivered on the next frame
33    /// so the editor sees its buffer before the key.
34    pending_enter: bool,
35    /// Where the script's events last put the pointer (world space) — the
36    /// session's stand-in for a physical pointer, fed to the painter so hover
37    /// affordances light up under the demo cursor.
38    pointer: Option<Pos2>,
39    /// Between `DragStarted` and `DragStopped`: idle frames must not
40    /// re-present a hover mid-gesture.
41    dragging: bool,
42}
43
44impl Session {
45    /// A session at a level's starting point: the initial document (or an
46    /// empty one) with the Select tool armed — exactly how the app loads a
47    /// level. An initial that fails to parse starts the session empty (the
48    /// level parse test catches broken embedded KDL long before this).
49    pub fn new(initial_kdl: Option<&str>, name: &str) -> Self {
50        let document =
51            match initial_kdl.map(|src| crate::document_ng::schema_convert::from_kdl(src, name)) {
52                Some(Ok(mut doc)) => {
53                    finalize_load(&mut doc);
54                    doc
55                }
56                Some(Err(e)) => {
57                    tracing::error!("session initial for {name} failed to parse: {e:?}");
58                    Document::default()
59                }
60                None => Document::default(),
61            };
62        Self {
63            document,
64            path: BlockPath::empty(),
65            tool: Tool::Select(SelectTool),
66            images: std::rc::Rc::new(std::cell::RefCell::new(ImageRegistry::default())),
67            icons: Icons::default(),
68            pending_enter: false,
69            pointer: None,
70            dragging: false,
71        }
72    }
73
74    /// The frame a driver feeds when the script owes nothing this instant —
75    /// the player's visible paint pass, the post-script commit flush. It
76    /// re-presents the resting pointer as a hover (a physical pointer doesn't
77    /// vanish between events), so per-frame hover affordances stay lit.
78    pub fn idle_frame(&self) -> SimFrame {
79        let event = (!self.dragging)
80            .then_some(self.pointer)
81            .flatten()
82            .map(crate::canvas::Event::HoverAt);
83        SimFrame {
84            event,
85            ..SimFrame::default()
86        }
87    }
88
89    /// Track the pointer the script's events imply (see [`Session::idle_frame`]).
90    fn track_pointer(&mut self, event: Option<crate::canvas::Event>) {
91        use crate::canvas::Event;
92        match event {
93            Some(
94                Event::HoverAt(pos)
95                | Event::Clicked { pos }
96                | Event::DoubleClicked { pos }
97                | Event::Dragging { pos, .. },
98            ) => self.pointer = Some(pos),
99            Some(Event::DragStarted { pos }) => {
100                self.pointer = Some(pos);
101                self.dragging = true;
102            }
103            Some(Event::DragStopped { pos }) => {
104                self.pointer = Some(pos);
105                self.dragging = false;
106            }
107            None => {}
108        }
109    }
110
111    /// One synthetic frame through the real tool, exactly as `View::show`
112    /// would deliver it: apply a toolbar switch, run `widget()` against
113    /// `egui_painter` (in world coordinates — offset zero, zoom one), then
114    /// drive the in-place editor the tool may have opened.
115    pub fn step(&mut self, frame: SimFrame, theme: &Theme, egui_painter: &egui::Painter) {
116        if let Some(name) = frame.switch_tool {
117            self.tool = Tool::from_name(name);
118        }
119        self.track_pointer(frame.event);
120        let interaction = Interaction {
121            event: frame.event,
122            lost_focus: false,
123            enter_pressed: std::mem::take(&mut self.pending_enter),
124            tab_pressed: false,
125            escape_pressed: false,
126            delete_pressed: false,
127            shift: false,
128        };
129        let mut painter = Painter::new(
130            egui_painter.clone(),
131            Pos2::ZERO,
132            1.0,
133            Vec2::ZERO,
134            theme.palette().clone(),
135            self.images.clone(),
136            self.icons.clone(),
137        );
138        // The session's input is exclusively this frame's `Interaction` plus
139        // the synthetic pointer tracked from it: the tools' raw-pointer reads
140        // (hover and press-and-hold affordances) must see the demo's pointer,
141        // never the user's mouse.
142        painter.set_scripted(crate::canvas::painter::ScriptedInput {
143            pointer: self.pointer,
144        });
145        let mut drawing = Drawing::new(&mut self.document, &self.path);
146        let action = {
147            let mut style = Style::new(theme, &mut painter);
148            self.tool.widget(&mut drawing, &interaction, &mut style)
149        };
150        // The editor seam: a tool that opened an in-place editor surfaces it
151        // here (the on-screen `View` renders a TextEdit from it). Type by
152        // writing the shared buffer; commit by queueing Enter.
153        if let Some(typing) = frame.typing
154            && let Some(edit) = painter.take_edit_text()
155        {
156            let mut buffer = edit.buffer.borrow_mut();
157            buffer.clear();
158            buffer.push_str(typing.text);
159            if typing.commit {
160                self.pending_enter = true;
161            }
162        }
163        if let Some(action) = action {
164            self.apply(action);
165        }
166    }
167
168    /// A committed typing entry queues Enter for a frame the script no longer
169    /// has — the driver owes the session one more (default) frame. The player
170    /// needn't ask: its every shown frame ends in a default paint pass.
171    #[cfg(test)]
172    pub fn needs_commit_frame(&self) -> bool {
173        self.pending_enter
174    }
175
176    /// The slice of `Action` the tools return mid-gesture. Anything else is a
177    /// chrome-level request (export, pickers, navigation) that a demo script
178    /// has no business triggering — log and drop rather than silently diverge.
179    fn apply(&mut self, action: Action) {
180        match action {
181            Action::SwitchTool(tool) => self.tool = tool,
182            other => {
183                tracing::warn!(
184                    "tutorial session ignoring non-tool action: {}",
185                    action_name(&other)
186                );
187            }
188        }
189    }
190
191    pub fn projected(&self) -> schema::Document {
192        schema::Document::from(&self.document)
193    }
194
195    /// The armed tool, for the video's presentational toolbar.
196    pub fn tool_name(&self) -> crate::tools::names::ToolName {
197        self.tool.name()
198    }
199
200    /// Whether the session is mid-rename (used by tests to sanity-check the
201    /// editor seam engaged).
202    #[cfg(test)]
203    pub fn renaming(&self) -> bool {
204        matches!(self.tool, Tool::RenameTitle(RenameTitle::Renaming { .. }))
205    }
206}
207
208fn action_name(action: &Action) -> &'static str {
209    match action {
210        Action::SwitchTool(_) => "SwitchTool",
211        Action::Delete(_) => "Delete",
212        Action::Copy(_) => "Copy",
213        Action::CopyPins(_) => "CopyPins",
214        Action::Cut(_) => "Cut",
215        Action::CutPins(_) => "CutPins",
216        Action::SetPinTags { .. } => "SetPinTags",
217        Action::SetShapeTagHidden { .. } => "SetShapeTagHidden",
218        Action::FlipShapePins(_) => "FlipShapePins",
219        Action::FlipBlockVertical(_) => "FlipBlockVertical",
220        Action::SetBlockLocked { .. } => "SetBlockLocked",
221        Action::Paste(_) => "Paste",
222        Action::ExpandBlock(_) => "ExpandBlock",
223        Action::OpenRolePicker { .. } => "OpenRolePicker",
224        Action::OpenPinTypePicker { .. } => "OpenPinTypePicker",
225        Action::GoUp => "GoUp",
226        Action::NavSelect { .. } => "NavSelect",
227        Action::PathBack => "PathBack",
228        Action::PathForward => "PathForward",
229        Action::Undo => "Undo",
230        Action::Redo => "Redo",
231        Action::Nudge { .. } => "Nudge",
232        Action::ResetView => "ResetView",
233        Action::Export { .. } => "Export",
234        Action::Import => "Import",
235        Action::Reroute(_) => "Reroute",
236        Action::RerouteBlock(_) => "RerouteBlock",
237        Action::OpenTutorial => "OpenTutorial",
238        Action::TutorialLoadLevel(_) => "TutorialLoadLevel",
239        Action::TutorialExit => "TutorialExit",
240    }
241}