1use egui::Pos2;
9
10use crate::grid::{GRID_SIZE, px};
11use crate::schema::model as schema;
12use crate::tools::names::ToolName;
13
14pub fn grid_pos(x: i32, y: i32) -> Pos2 {
17 egui::pos2(px(x), px(y))
18}
19
20#[cfg(test)]
23pub fn at(x: i32, y: i32) -> CueTarget {
24 CueTarget::World(grid_pos(x, y))
25}
26
27#[cfg(test)]
29pub fn tool(name: ToolName) -> CueTarget {
30 CueTarget::ToolButton(name)
31}
32
33#[cfg(test)]
35pub fn block(title: &'static str) -> CueTarget {
36 CueTarget::Block(title)
37}
38
39#[cfg(test)]
41pub fn corner(title: &'static str, handle: Handle) -> CueTarget {
42 CueTarget::Corner(title, handle)
43}
44
45#[derive(Clone, Copy, PartialEq, Eq, Debug)]
47pub enum Handle {
48 LeftTop,
49 RightTop,
50 LeftBottom,
51 RightBottom,
52}
53
54#[derive(Clone, Copy, PartialEq, Debug)]
59pub enum CueTarget {
60 ToolButton(ToolName),
61 World(Pos2),
62 Block(&'static str),
64 Corner(&'static str, Handle),
66}
67
68impl CueTarget {
69 pub fn world(self, doc: &schema::Document) -> Option<Pos2> {
73 let title_name =
74 |b: &schema::Block| b.title.as_ref().map_or("", |l| l.name.as_str()).to_owned();
75 let block_rect = |title: &str| {
76 doc.blocks.iter().find(|b| title_name(b) == title).map(|b| {
77 egui::Rect::from_min_size(
78 egui::pos2(b.x as f32, b.y as f32) * GRID_SIZE,
79 egui::vec2(b.w as f32, b.h as f32) * GRID_SIZE,
80 )
81 })
82 };
83 match self {
84 CueTarget::ToolButton(_) => None,
85 CueTarget::World(p) => Some(p),
86 CueTarget::Block(title) => block_rect(title).map(|r| r.center()),
87 CueTarget::Corner(title, handle) => block_rect(title).map(|r| match handle {
88 Handle::LeftTop => r.left_top(),
89 Handle::RightTop => r.right_top(),
90 Handle::LeftBottom => r.left_bottom(),
91 Handle::RightBottom => r.right_bottom(),
92 }),
93 }
94 }
95}
96
97#[derive(Clone, Copy, PartialEq, Eq, Debug)]
98pub enum ClickCount {
99 Single,
100 Double,
101}
102
103impl ClickCount {
104 fn presses(self) -> u32 {
105 match self {
106 ClickCount::Single => 1,
107 ClickCount::Double => 2,
108 }
109 }
110}
111
112pub const CLICK_PRESS_SECS: f32 = 0.3;
115
116#[derive(Clone, Copy)]
117pub enum Step {
118 Highlight {
120 target: CueTarget,
121 secs: f32,
122 },
123 MoveTo {
125 target: CueTarget,
126 secs: f32,
127 },
128 Hover {
130 target: CueTarget,
131 secs: f32,
132 },
133 Click {
135 target: CueTarget,
136 count: ClickCount,
137 },
138 Drag {
140 from: CueTarget,
141 to: CueTarget,
142 secs: f32,
143 },
144 Type {
147 target: CueTarget,
148 text: &'static str,
149 secs: f32,
150 },
151 Pause {
152 secs: f32,
153 },
154}
155
156impl Step {
157 fn secs(&self) -> f32 {
158 match self {
159 Step::Highlight { secs, .. }
160 | Step::MoveTo { secs, .. }
161 | Step::Hover { secs, .. }
162 | Step::Drag { secs, .. }
163 | Step::Type { secs, .. }
164 | Step::Pause { secs } => *secs,
165 Step::Click { count, .. } => count.presses() as f32 * CLICK_PRESS_SECS,
166 }
167 }
168
169 fn end_cursor(&self) -> Option<CueTarget> {
172 match self {
173 Step::Highlight { .. } | Step::Type { .. } | Step::Pause { .. } => None,
174 Step::MoveTo { target, .. }
175 | Step::Hover { target, .. }
176 | Step::Click { target, .. } => Some(*target),
177 Step::Drag { to, .. } => Some(*to),
178 }
179 }
180}
181
182#[derive(Clone, Copy, PartialEq, Debug)]
185pub enum CursorPos {
186 At(CueTarget),
187 Between {
188 from: CueTarget,
189 to: CueTarget,
190 t: f32,
191 },
192}
193
194#[derive(Clone, Copy, PartialEq, Debug)]
195pub enum ButtonState {
196 Up,
197 Down,
199 Flash {
201 t: f32,
202 },
203}
204
205#[derive(Clone, Copy, PartialEq, Debug)]
208pub struct TypingCue {
209 pub target: CueTarget,
210 pub typed: &'static str,
211}
212
213#[derive(Clone, Copy, PartialEq, Debug)]
215pub struct CueFrame {
216 pub cursor: Option<CursorPos>,
219 pub button: ButtonState,
220 pub highlight: Option<CueTarget>,
221 pub typing: Option<TypingCue>,
222}
223
224#[derive(Clone)]
225pub struct Script {
226 steps: Vec<Step>,
227}
228
229fn ease(t: f32) -> f32 {
230 t * t * (3.0 - 2.0 * t)
231}
232
233#[cfg(test)]
238#[derive(Default)]
239pub struct ScriptBuilder {
240 steps: Vec<Step>,
241}
242
243#[cfg(test)]
246#[allow(dead_code)]
247impl ScriptBuilder {
248 #[must_use]
249 pub fn highlight(self, target: CueTarget, secs: f32) -> Self {
250 self.step(Step::Highlight { target, secs })
251 }
252
253 #[must_use]
254 pub fn move_to(self, target: CueTarget, secs: f32) -> Self {
255 self.step(Step::MoveTo { target, secs })
256 }
257
258 #[must_use]
259 pub fn hover(self, target: CueTarget, secs: f32) -> Self {
260 self.step(Step::Hover { target, secs })
261 }
262
263 #[must_use]
264 pub fn click(self, target: CueTarget) -> Self {
265 self.step(Step::Click {
266 target,
267 count: ClickCount::Single,
268 })
269 }
270
271 #[must_use]
272 pub fn double_click(self, target: CueTarget) -> Self {
273 self.step(Step::Click {
274 target,
275 count: ClickCount::Double,
276 })
277 }
278
279 #[must_use]
280 pub fn drag(self, from: CueTarget, to: CueTarget, secs: f32) -> Self {
281 self.step(Step::Drag { from, to, secs })
282 }
283
284 #[must_use]
285 pub fn type_text(self, target: CueTarget, text: &'static str, secs: f32) -> Self {
286 self.step(Step::Type { target, text, secs })
287 }
288
289 #[must_use]
290 pub fn pause(self, secs: f32) -> Self {
291 self.step(Step::Pause { secs })
292 }
293
294 pub fn build(self) -> Script {
295 Script::new(self.steps)
296 }
297
298 #[must_use]
299 fn step(mut self, step: Step) -> Self {
300 self.steps.push(step);
301 self
302 }
303}
304
305#[cfg(test)]
306impl From<ScriptBuilder> for Script {
307 fn from(builder: ScriptBuilder) -> Self {
308 builder.build()
309 }
310}
311
312impl Script {
313 #[cfg(test)]
314 pub fn builder() -> ScriptBuilder {
315 ScriptBuilder::default()
316 }
317
318 pub fn new(steps: Vec<Step>) -> Self {
319 Self { steps }
320 }
321
322 pub fn concat<'a>(scripts: impl IntoIterator<Item = &'a Script>) -> Script {
325 Script::new(
326 scripts
327 .into_iter()
328 .flat_map(|s| s.steps.iter().copied())
329 .collect(),
330 )
331 }
332
333 pub fn steps(&self) -> &[Step] {
334 &self.steps
335 }
336
337 pub fn total_secs(&self) -> f32 {
338 self.steps.iter().map(Step::secs).sum()
339 }
340
341 pub fn sample(&self, elapsed: f64) -> Option<CueFrame> {
345 if elapsed < 0.0 {
346 return None;
347 }
348 let mut remaining = elapsed as f32;
349 let mut cursor: Option<CueTarget> = None;
351 for step in &self.steps {
352 let secs = step.secs();
353 if remaining < secs {
354 let p = remaining / secs.max(f32::EPSILON);
355 return Some(Self::frame(step, cursor, p, remaining));
356 }
357 remaining -= secs;
358 if let Some(c) = step.end_cursor() {
359 cursor = Some(c);
360 }
361 }
362 None
363 }
364
365 fn frame(step: &Step, prev: Option<CueTarget>, p: f32, into_step: f32) -> CueFrame {
368 let parked = prev.map(CursorPos::At);
369 let base = CueFrame {
370 cursor: parked,
371 button: ButtonState::Up,
372 highlight: None,
373 typing: None,
374 };
375 match step {
376 Step::Highlight { target, .. } => CueFrame {
377 highlight: Some(*target),
378 ..base
379 },
380 Step::Pause { .. } => base,
381 Step::Hover { target, .. } => CueFrame {
382 cursor: Some(CursorPos::At(*target)),
383 ..base
384 },
385 Step::Type { target, text, .. } => CueFrame {
386 typing: Some(TypingCue {
387 target: *target,
388 typed: typed_prefix(text, p),
389 }),
390 ..base
391 },
392 Step::MoveTo { target, .. } => CueFrame {
393 cursor: Some(match prev {
395 Some(from) => CursorPos::Between {
396 from,
397 to: *target,
398 t: ease(p),
399 },
400 None => CursorPos::At(*target),
401 }),
402 ..base
403 },
404 Step::Click { target, .. } => CueFrame {
405 cursor: Some(CursorPos::At(*target)),
406 button: ButtonState::Flash {
407 t: (into_step / CLICK_PRESS_SECS).fract(),
408 },
409 ..base
410 },
411 Step::Drag { from, to, .. } => CueFrame {
412 cursor: Some(CursorPos::Between {
413 from: *from,
414 to: *to,
415 t: ease(p),
416 }),
417 button: ButtonState::Down,
418 ..base
419 },
420 }
421 }
422}
423
424pub(super) fn typed_prefix(text: &'static str, p: f32) -> &'static str {
428 let entered = (p.clamp(0.0, 1.0) * text.chars().count() as f32).ceil() as usize;
429 let end = text
430 .char_indices()
431 .nth(entered)
432 .map_or(text.len(), |(i, _)| i);
433 &text[..end]
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439
440 fn demo() -> Script {
441 Script::new(vec![
442 Step::Highlight {
443 target: CueTarget::ToolButton(ToolName::NewBlock),
444 secs: 1.0,
445 },
446 Step::Click {
447 target: CueTarget::ToolButton(ToolName::NewBlock),
448 count: ClickCount::Double,
449 },
450 Step::Drag {
451 from: CueTarget::World(grid_pos(0, 0)),
452 to: CueTarget::World(grid_pos(8, 0)),
453 secs: 2.0,
454 },
455 Step::Pause { secs: 0.5 },
456 ])
457 }
458
459 #[test]
460 fn totals_sum_step_durations() {
461 let total = demo().total_secs();
463 assert!((total - 4.1).abs() < 1e-6, "{total}");
464 }
465
466 #[test]
467 fn highlight_has_no_cursor_until_one_is_established() {
468 let frame = demo().sample(0.5).unwrap();
469 assert_eq!(frame.cursor, None);
470 assert_eq!(
471 frame.highlight,
472 Some(CueTarget::ToolButton(ToolName::NewBlock))
473 );
474 assert_eq!(frame.button, ButtonState::Up);
475 }
476
477 #[test]
478 fn double_click_flashes_twice() {
479 let script = demo();
480 let first = script.sample(1.0 + 0.1).unwrap();
482 let second = script.sample(1.0 + CLICK_PRESS_SECS as f64 + 0.1).unwrap();
484 for frame in [first, second] {
485 assert!(matches!(frame.button, ButtonState::Flash { .. }));
486 assert_eq!(
487 frame.cursor,
488 Some(CursorPos::At(CueTarget::ToolButton(ToolName::NewBlock)))
489 );
490 }
491 let ButtonState::Flash { t: t1 } = first.button else {
493 unreachable!()
494 };
495 let ButtonState::Flash { t: t2 } = second.button else {
496 unreachable!()
497 };
498 assert!((t1 - t2).abs() < 1e-3, "{t1} vs {t2}");
499 }
500
501 #[test]
502 fn drag_interpolates_between_endpoints_with_button_down() {
503 let script = demo();
504 let drag_start = 1.0 + 2.0 * f64::from(CLICK_PRESS_SECS);
505 let frame = script.sample(drag_start + 1.0).unwrap();
506 assert_eq!(frame.button, ButtonState::Down);
507 let Some(CursorPos::Between { t, .. }) = frame.cursor else {
508 panic!("expected a lerping cursor, got {:?}", frame.cursor);
509 };
510 assert!((t - 0.5).abs() < 1e-3, "{t}");
512 }
513
514 #[test]
515 fn pause_keeps_the_cursor_where_the_drag_left_it() {
516 let script = demo();
517 let pause_at = f64::from(script.total_secs()) - 0.25;
518 let frame = script.sample(pause_at).unwrap();
519 assert_eq!(
520 frame.cursor,
521 Some(CursorPos::At(CueTarget::World(grid_pos(8, 0))))
522 );
523 }
524
525 #[test]
526 fn doc_anchored_targets_follow_the_projected_block() {
527 let doc = schema::Document::parse_kdl(
528 r#"
529top "b0"
530
531block "b0" x=0 y=0 w=28 h=20 {
532 title "sheet"
533 children "b1"
534}
535
536block "b1" x=4 y=4 w=8 h=7 {
537 title "core"
538}
539"#,
540 "test",
541 )
542 .unwrap();
543 assert_eq!(
544 block("core").world(&doc),
545 Some(grid_pos(8, 7).lerp(grid_pos(8, 8), 0.5))
546 );
547 assert_eq!(
548 corner("core", Handle::RightBottom).world(&doc),
549 Some(grid_pos(12, 11))
550 );
551 assert_eq!(block("missing").world(&doc), None);
552 assert_eq!(tool(ToolName::NewBlock).world(&doc), None);
553 assert_eq!(at(3, 5).world(&doc), Some(grid_pos(3, 5)));
554 }
555
556 #[test]
557 fn typing_reveals_characters_and_leaves_the_cursor_parked() {
558 let script = Script::new(vec![
559 Step::MoveTo {
560 target: CueTarget::World(grid_pos(4, 0)),
561 secs: 1.0,
562 },
563 Step::Type {
564 target: CueTarget::World(grid_pos(0, 0)),
565 text: "CPU",
566 secs: 3.0,
567 },
568 ]);
569 let at = |t: f64| script.sample(1.0 + t).unwrap();
570 assert_eq!(at(0.5).typing.unwrap().typed, "C");
571 assert_eq!(at(1.5).typing.unwrap().typed, "CP");
572 assert_eq!(at(2.9).typing.unwrap().typed, "CPU");
573 assert_eq!(
574 at(1.5).cursor,
575 Some(CursorPos::At(CueTarget::World(grid_pos(4, 0))))
576 );
577 }
578
579 #[test]
580 fn finished_and_negative_times_yield_no_frame() {
581 let script = demo();
582 assert!(script.sample(-0.1).is_none());
583 assert!(
584 script
585 .sample(f64::from(script.total_secs()) + 0.01)
586 .is_none()
587 );
588 }
589}