1use 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
20pub const SIM_DT: Duration = Duration::from_nanos(1_000_000_000 / 60);
22
23#[derive(Clone, Copy, Debug, Default)]
25pub struct SimFrame {
26 pub event: Option<Event>,
29 pub press: Option<Press>,
32 pub switch_tool: Option<ToolName>,
34 pub typing: Option<Typing>,
37 pub command: Option<&'static str>,
39}
40
41#[derive(Clone, Copy, Debug)]
42pub struct Typing {
43 pub text: &'static str,
45 pub commit: bool,
47}
48
49#[derive(Debug)]
54#[allow(dead_code)]
55pub struct UnresolvedTarget {
56 pub step: usize,
57 pub target: CueTarget,
58}
59
60pub struct Lowering {
63 steps: Vec<Step>,
64 step: usize,
65 frame: u32,
67 span: Option<(Option<Pos2>, Option<Pos2>)>,
69 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 #[cfg(test)]
86 pub fn step(&self) -> usize {
87 self.step
88 }
89
90 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 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 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 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 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 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 if matches!(from, CueTarget::World(_)) {
249 *to
250 } else {
251 *from
252 }
253 }
254 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
268fn 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
295fn 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 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 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 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 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}