1use egui::Pos2;
11
12use crate::canvas::Event;
13use crate::schema::model as schema;
14use crate::tools::names::ToolName;
15
16use super::script::{ClickCount, CueTarget, Script, Step, typed_prefix};
17
18pub const SIM_DT: f32 = 1.0 / 60.0;
20
21#[derive(Clone, Copy, Debug, Default)]
23pub struct SimFrame {
24 pub event: Option<Event>,
27 pub switch_tool: Option<ToolName>,
29 pub typing: Option<Typing>,
32}
33
34#[derive(Clone, Copy, Debug)]
35pub struct Typing {
36 pub text: &'static str,
38 pub commit: bool,
40}
41
42#[derive(Debug)]
47#[allow(dead_code)]
48pub struct UnresolvedTarget {
49 pub step: usize,
50 pub target: CueTarget,
51}
52
53pub struct Lowering {
56 steps: Vec<Step>,
57 step: usize,
58 frame: u32,
60 span: Option<(Option<Pos2>, Option<Pos2>)>,
62 cursor: Option<Pos2>,
64}
65
66fn ease(t: f32) -> f32 {
67 t * t * (3.0 - 2.0 * t)
68}
69
70impl Lowering {
71 pub fn new(script: &Script) -> Self {
72 Self {
73 steps: script.steps().to_vec(),
74 step: 0,
75 frame: 0,
76 span: None,
77 cursor: None,
78 }
79 }
80
81 pub fn next(&mut self, doc: &schema::Document) -> Result<Option<SimFrame>, UnresolvedTarget> {
83 loop {
84 let Some(step) = self.steps.get(self.step) else {
85 return Ok(None);
86 };
87 let frames = step_frames(step);
88 if self.frame >= frames {
89 self.finish_step();
90 continue;
91 }
92 let (from, to) = if let Some(span) = self.span {
93 span
94 } else {
95 let span = self.resolve_span(step, doc)?;
96 self.span = Some(span);
97 span
98 };
99 let p = (self.frame + 1) as f32 / frames as f32;
102 let frame = self.frame;
103 self.frame += 1;
104 let lerp = |p: f32| match (from, to) {
105 (Some(a), Some(b)) => Some(a.lerp(b, ease(p))),
106 (_, b) => b,
107 };
108 let sim = match step {
109 Step::Highlight { .. } | Step::Pause { .. } => SimFrame::default(),
110 Step::MoveTo { .. } | Step::Hover { .. } => SimFrame {
111 event: lerp(p).map(Event::HoverAt),
112 ..SimFrame::default()
113 },
114 Step::Click { target, count } => lower_click(*target, *count, p, to),
115 Step::Drag { .. } => {
116 let (Some(a), Some(b)) = (from, to) else {
117 return Err(self.unresolved(step));
118 };
119 let motion = |f: u32| {
124 let q = f as f32 / (frames - 2) as f32;
125 a.lerp(b, ease(q))
126 };
127 let event = if frame == 0 {
128 Event::DragStarted { pos: a }
129 } else if self.frame == frames {
130 Event::DragStopped { pos: b }
131 } else {
132 Event::Dragging {
133 pos: motion(frame),
134 delta: motion(frame) - motion(frame - 1),
135 }
136 };
137 SimFrame {
138 event: Some(event),
139 ..SimFrame::default()
140 }
141 }
142 Step::Type { text, .. } => SimFrame {
143 typing: Some(Typing {
144 text: typed_prefix(text, p),
145 commit: self.frame == frames,
146 }),
147 ..SimFrame::default()
148 },
149 };
150 return Ok(Some(sim));
151 }
152 }
153
154 fn resolve_span(
157 &self,
158 step: &Step,
159 doc: &schema::Document,
160 ) -> Result<(Option<Pos2>, Option<Pos2>), UnresolvedTarget> {
161 let resolve = |target: &CueTarget| -> Result<Option<Pos2>, UnresolvedTarget> {
162 match target {
163 CueTarget::ToolButton(_) => Ok(None),
165 _ => target
166 .world(doc)
167 .map(Some)
168 .ok_or_else(|| self.unresolved(step)),
169 }
170 };
171 Ok(match step {
172 Step::Highlight { .. } | Step::Pause { .. } | Step::Type { .. } => {
173 (self.cursor, self.cursor)
174 }
175 Step::MoveTo { target, .. }
176 | Step::Hover { target, .. }
177 | Step::Click { target, .. } => (self.cursor, resolve(target)?),
178 Step::Drag { from, to, .. } => (resolve(from)?, resolve(to)?),
179 })
180 }
181
182 fn finish_step(&mut self) {
183 if let Some((_, to)) = self.span.take()
184 && !matches!(
185 self.steps[self.step],
186 Step::Highlight { .. } | Step::Pause { .. } | Step::Type { .. }
187 )
188 {
189 self.cursor = to.or(self.cursor);
190 }
191 self.step += 1;
192 self.frame = 0;
193 }
194
195 fn unresolved(&self, step: &Step) -> UnresolvedTarget {
196 let target = match step {
197 Step::Highlight { target, .. }
198 | Step::MoveTo { target, .. }
199 | Step::Hover { target, .. }
200 | Step::Click { target, .. }
201 | Step::Type { target, .. } => *target,
202 Step::Drag { from, to, .. } => {
203 if matches!(from, CueTarget::World(_)) {
206 *to
207 } else {
208 *from
209 }
210 }
211 Step::Pause { .. } => CueTarget::World(Pos2::ZERO),
213 };
214 UnresolvedTarget {
215 step: self.step,
216 target,
217 }
218 }
219}
220
221fn lower_click(target: CueTarget, count: ClickCount, p: f32, world: Option<Pos2>) -> SimFrame {
224 if p < 1.0 {
225 return SimFrame::default();
226 }
227 match (target, world) {
228 (CueTarget::ToolButton(name), _) => SimFrame {
229 switch_tool: Some(name),
230 ..SimFrame::default()
231 },
232 (_, Some(pos)) => SimFrame {
233 event: Some(match count {
234 ClickCount::Single => Event::Clicked { pos },
235 ClickCount::Double => Event::DoubleClicked { pos },
236 }),
237 ..SimFrame::default()
238 },
239 (_, None) => SimFrame::default(),
240 }
241}
242
243fn step_frames(step: &Step) -> u32 {
246 let secs = match step {
247 Step::Highlight { secs, .. }
248 | Step::MoveTo { secs, .. }
249 | Step::Hover { secs, .. }
250 | Step::Drag { secs, .. }
251 | Step::Type { secs, .. }
252 | Step::Pause { secs } => *secs,
253 Step::Click { count, .. } => match count {
254 ClickCount::Single => super::script::CLICK_PRESS_SECS,
255 ClickCount::Double => 2.0 * super::script::CLICK_PRESS_SECS,
256 },
257 };
258 let floor = if matches!(step, Step::Drag { .. }) {
259 3
260 } else {
261 1
262 };
263 ((secs / SIM_DT).ceil() as u32).max(floor)
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use crate::tools::names::ToolName;
270 use crate::tutorial::script::{Script, at, grid_pos, tool};
271
272 fn drain(script: &Script) -> Vec<SimFrame> {
273 let doc = schema::Document {
274 top: String::new(),
275 blocks: Vec::new(),
276 };
277 let mut lowering = Lowering::new(script);
278 let mut frames = Vec::new();
279 while let Some(f) = lowering.next(&doc).unwrap() {
280 frames.push(f);
281 }
282 frames
283 }
284
285 #[test]
286 fn drag_lowers_to_start_move_stop_with_exact_deltas() {
287 let script = Script::builder().drag(at(0, 0), at(8, 0), 0.5).build();
288 let frames = drain(&script);
289 assert_eq!(frames.len(), 30);
290 assert_eq!(
291 frames[0].event,
292 Some(Event::DragStarted {
293 pos: grid_pos(0, 0)
294 })
295 );
296 assert_eq!(
297 frames.last().unwrap().event,
298 Some(Event::DragStopped {
299 pos: grid_pos(8, 0)
300 })
301 );
302 let sum = frames
305 .iter()
306 .filter_map(|f| match f.event {
307 Some(Event::Dragging { delta, .. }) => Some(delta),
308 _ => None,
309 })
310 .fold(egui::Vec2::ZERO, |acc, d| acc + d);
311 let expected = grid_pos(8, 0) - grid_pos(0, 0);
312 assert!((sum.x - expected.x).abs() < 1e-3, "{sum:?}");
313 assert!(sum.y.abs() < 1e-6);
314 }
315
316 #[test]
317 fn toolbar_click_lowers_to_a_tool_switch() {
318 let script = Script::builder()
319 .highlight(tool(ToolName::Route), 0.1)
320 .click(tool(ToolName::Route))
321 .build();
322 let frames = drain(&script);
323 let switches: Vec<_> = frames.iter().filter_map(|f| f.switch_tool).collect();
324 assert_eq!(switches, vec![ToolName::Route]);
325 assert!(frames.iter().all(|f| f.event.is_none()));
326 }
327
328 #[test]
329 fn click_after_move_lands_on_the_target() {
330 let script = Script::builder()
331 .move_to(at(4, 4), 0.2)
332 .click(at(4, 4))
333 .build();
334 let frames = drain(&script);
335 let clicks: Vec<_> = frames
336 .iter()
337 .filter_map(|f| match f.event {
338 Some(Event::Clicked { pos }) => Some(pos),
339 _ => None,
340 })
341 .collect();
342 assert_eq!(clicks, vec![grid_pos(4, 4)]);
343 assert!(matches!(frames[0].event, Some(Event::HoverAt(_))));
345 }
346
347 #[test]
348 fn typing_grows_and_commits_on_the_final_frame() {
349 let script = Script::builder().type_text(at(0, 0), "CPU", 0.2).build();
350 let frames = drain(&script);
351 let last = frames.last().unwrap().typing.unwrap();
352 assert_eq!(last.text, "CPU");
353 assert!(last.commit);
354 assert!(
355 frames
356 .iter()
357 .rev()
358 .skip(1)
359 .all(|f| !f.typing.unwrap().commit)
360 );
361 }
362
363 #[test]
364 fn unresolved_block_target_reports_the_step() {
365 let script = Script::builder()
366 .move_to(crate::tutorial::script::block("ghost"), 0.2)
367 .build();
368 let doc = schema::Document {
369 top: String::new(),
370 blocks: Vec::new(),
371 };
372 let err = Lowering::new(&script).next(&doc).unwrap_err();
373 assert_eq!(err.step, 0);
374 }
375}