1use std::time::Duration;
9
10use blockworx_doc::{
11 block_model::Block,
12 document::{IndexedDocument, chronological},
13 id::{BlockId, PinId},
14};
15use egui::Pos2;
16
17use crate::grid::{GRID_SIZE, px};
18use crate::progress::Progress;
19use crate::schema::lower::SourceIds;
20use crate::shape::block::BlockShape;
21use crate::shape::pin::{Pin, slot};
22use crate::tools::names::ToolName;
23
24pub fn grid_pos(x: i32, y: i32) -> Pos2 {
27 egui::pos2(px(x), px(y))
28}
29
30fn lerp_rect(a: egui::Rect, b: egui::Rect, p: Progress) -> egui::Rect {
32 let t = p.get();
33 egui::Rect::from_min_max(a.min.lerp(b.min, t), a.max.lerp(b.max, t))
34}
35
36#[cfg(test)]
39pub fn at(x: i32, y: i32) -> CueTarget {
40 CueTarget::World(grid_pos(x, y))
41}
42
43#[cfg(test)]
45pub fn tool(name: ToolName) -> CueTarget {
46 CueTarget::ToolButton(name)
47}
48
49#[cfg(test)]
51pub fn block(title: &'static str) -> CueTarget {
52 CueTarget::Block(title)
53}
54
55#[cfg(test)]
57pub fn corner(title: &'static str, handle: Handle) -> CueTarget {
58 CueTarget::Corner(title, handle)
59}
60
61#[cfg(test)]
63pub fn by(dx: i32, dy: i32) -> CueTarget {
64 CueTarget::Relative { dx, dy }
65}
66
67#[derive(Clone, Copy, PartialEq, Eq, Debug)]
69pub enum Handle {
70 LeftTop,
71 RightTop,
72 LeftBottom,
73 RightBottom,
74}
75
76#[derive(Clone, Copy, PartialEq, Debug)]
81pub enum CueTarget {
82 ToolButton(ToolName),
83 World(Pos2),
84 Block(&'static str),
86 Corner(&'static str, Handle),
88 BlockId(&'static str),
90 PinAnchor {
92 block: &'static str,
93 pin: &'static str,
94 },
95 Relative {
99 dx: i32,
100 dy: i32,
101 },
102}
103
104#[derive(Clone, Copy, PartialEq, Debug)]
108pub enum Anchoring {
109 Toolbar(ToolName),
112 Document,
115 FromDragBase(egui::Vec2),
118}
119
120#[derive(Clone, Copy)]
128pub struct CueScope<'a> {
129 indexed: IndexedDocument<'a>,
130 ids: &'a SourceIds,
131}
132
133impl<'a> CueScope<'a> {
134 pub fn new(indexed: IndexedDocument<'a>, ids: &'a SourceIds) -> Self {
135 Self { indexed, ids }
136 }
137
138 fn block(&self, id: BlockId) -> Option<&'a Block> {
139 let live = self.indexed.doc.block(&id)?;
140 live.is_alive().then(|| live.as_ref())
141 }
142
143 fn by_title(&self, title: &str) -> Option<&'a Block> {
147 chronological(
148 self.indexed
149 .doc
150 .blocks()
151 .filter(|(_, live)| live.is_alive()),
152 )
153 .into_iter()
154 .find_map(|id| self.block(id).filter(|b| b.title.name.as_ref() == title))
155 }
156
157 fn by_source_id(&self, id: &str) -> Option<&'a Block> {
158 self.block(self.ids.block(id)?)
159 }
160
161 fn rect_of(block: &Block) -> egui::Rect {
162 crate::edit::lower::px_rect(*block.rect.as_ref())
163 }
164
165 fn pin_anchor(&self, owner: BlockId, pin: PinId) -> Option<Pos2> {
169 let block = self.block(owner)?;
170 let pins: Vec<(PinId, &Pin)> = self
171 .indexed
172 .index
173 .scope(owner)?
174 .pins
175 .iter()
176 .filter_map(|&id| Some((id, self.indexed.doc.pin(&id)?.as_ref())))
177 .collect();
178 let placed = slot(pins.iter().find(|(id, _)| *id == pin)?.1);
179 let shape = BlockShape::new(block, pins, crate::path::structure(&self.indexed, owner));
180 Some(shape.pin_anchor_at(shape.rect(), placed.side, placed.offset))
181 }
182}
183
184#[cfg(test)]
187pub struct CueFixture {
188 doc: blockworx_doc::document::Document,
189 index: blockworx_doc::document::DocIndex,
190 ids: SourceIds,
191}
192
193#[cfg(test)]
194impl CueFixture {
195 pub fn lowered(kdl: &str) -> Self {
198 let parsed = crate::schema::model::Document::parse_kdl(kdl, "cue-fixture")
199 .expect("the fixture parses");
200 let lowered = crate::schema::lower::lower(&parsed, "cue-fixture");
201 let mut doc = blockworx_doc::document::Document::default();
202 for commit in &lowered.commits {
203 doc = doc.try_apply(commit).expect("the lowered commit folds");
204 }
205 Self {
206 index: blockworx_doc::document::DocIndex::of(&doc),
207 doc,
208 ids: lowered.ids,
209 }
210 }
211
212 pub fn empty() -> Self {
214 let doc = blockworx_doc::document::Document::default();
215 Self {
216 index: blockworx_doc::document::DocIndex::of(&doc),
217 doc,
218 ids: SourceIds::default(),
219 }
220 }
221
222 pub fn scope(&mut self) -> CueScope<'_> {
223 CueScope::new(self.index.view(&self.doc), &self.ids)
224 }
225}
226
227impl CueTarget {
228 pub fn anchoring(self) -> Anchoring {
229 match self {
230 CueTarget::ToolButton(name) => Anchoring::Toolbar(name),
231 CueTarget::Relative { dx, dy } => {
232 Anchoring::FromDragBase(egui::vec2(dx as f32, dy as f32) * GRID_SIZE)
233 }
234 CueTarget::World(_)
235 | CueTarget::Block(_)
236 | CueTarget::Corner(..)
237 | CueTarget::BlockId(_)
238 | CueTarget::PinAnchor { .. } => Anchoring::Document,
239 }
240 }
241
242 pub fn world(self, scope: &CueScope<'_>) -> Option<Pos2> {
247 match self {
248 CueTarget::ToolButton(_) | CueTarget::Relative { .. } => None,
249 CueTarget::World(p) => Some(p),
250 CueTarget::Block(title) => scope.by_title(title).map(|b| CueScope::rect_of(b).center()),
251 CueTarget::Corner(title, handle) => scope.by_title(title).map(|b| {
252 let r = CueScope::rect_of(b);
253 match handle {
254 Handle::LeftTop => r.left_top(),
255 Handle::RightTop => r.right_top(),
256 Handle::LeftBottom => r.left_bottom(),
257 Handle::RightBottom => r.right_bottom(),
258 }
259 }),
260 CueTarget::BlockId(id) => scope
261 .by_source_id(id)
262 .map(|b| CueScope::rect_of(b).center()),
263 CueTarget::PinAnchor { block, pin } => {
264 scope.pin_anchor(scope.ids.block(block)?, scope.ids.pin(block, pin)?)
265 }
266 }
267 }
268}
269
270#[derive(Clone, Copy, PartialEq, Eq, Debug)]
271pub enum ClickCount {
272 Single,
273 Double,
274}
275
276impl ClickCount {
277 fn presses(self) -> u32 {
278 match self {
279 ClickCount::Single => 1,
280 ClickCount::Double => 2,
281 }
282 }
283}
284
285pub const CLICK_PRESS: Duration = Duration::from_millis(450);
288
289#[derive(Clone, Copy, PartialEq, Eq, Debug)]
295pub enum HeldKey {
296 Ctrl,
297 Shift,
298 Alt,
299 Space,
300}
301
302impl HeldKey {
303 pub fn label(self) -> &'static str {
305 match self {
306 HeldKey::Ctrl => "ctrl",
307 HeldKey::Shift => "shift",
308 HeldKey::Alt => "alt",
309 HeldKey::Space => "space",
310 }
311 }
312
313 pub fn parse(name: &str) -> Option<Self> {
314 [HeldKey::Ctrl, HeldKey::Shift, HeldKey::Alt, HeldKey::Space]
315 .into_iter()
316 .find(|key| key.label() == name)
317 }
318}
319
320#[derive(Clone, Copy, PartialEq, Debug)]
321pub enum Step {
322 Highlight {
324 target: CueTarget,
325 duration: Duration,
326 },
327 MoveTo {
329 target: CueTarget,
330 duration: Duration,
331 },
332 Hover {
334 target: CueTarget,
335 duration: Duration,
336 },
337 Click {
339 target: CueTarget,
340 count: ClickCount,
341 },
342 Drag {
344 from: CueTarget,
345 to: CueTarget,
346 duration: Duration,
347 },
348 Type {
351 target: CueTarget,
352 text: &'static str,
353 duration: Duration,
354 },
355 Pause {
356 duration: Duration,
357 },
358 Camera {
363 rect: egui::Rect,
364 duration: Duration,
365 },
366 Instruct {
369 text: &'static str,
370 },
371 Hold {
374 key: Option<HeldKey>,
375 },
376 Command {
381 name: &'static str,
382 },
383}
384
385impl Step {
386 fn duration(&self) -> Duration {
387 match self {
388 Step::Highlight { duration, .. }
389 | Step::MoveTo { duration, .. }
390 | Step::Hover { duration, .. }
391 | Step::Drag { duration, .. }
392 | Step::Type { duration, .. }
393 | Step::Pause { duration }
394 | Step::Camera { duration, .. } => *duration,
395 Step::Click { count, .. } => CLICK_PRESS * count.presses(),
396 Step::Instruct { .. } | Step::Command { .. } | Step::Hold { .. } => Duration::ZERO,
397 }
398 }
399
400 fn end_cursor(&self) -> Option<CueTarget> {
403 match self {
404 Step::Highlight { .. }
405 | Step::Type { .. }
406 | Step::Pause { .. }
407 | Step::Camera { .. }
408 | Step::Instruct { .. }
409 | Step::Command { .. }
410 | Step::Hold { .. } => None,
411 Step::MoveTo { target, .. }
412 | Step::Hover { target, .. }
413 | Step::Click { target, .. } => Some(*target),
414 Step::Drag { from, to, .. } => Some(match to.anchoring() {
417 Anchoring::FromDragBase(_) => *from,
418 Anchoring::Toolbar(_) | Anchoring::Document => *to,
419 }),
420 }
421 }
422}
423
424#[derive(Clone, Copy, PartialEq, Debug)]
427pub enum CursorPos {
428 At(CueTarget),
429 Between {
430 from: CueTarget,
431 to: CueTarget,
432 t: Progress,
433 },
434}
435
436#[derive(Clone, Copy, PartialEq, Debug)]
437pub enum ButtonState {
438 Up,
439 Down,
441 Flash {
443 t: Progress,
444 },
445}
446
447#[derive(Clone, Copy, PartialEq, Debug)]
450pub struct TypingCue {
451 pub target: CueTarget,
452 pub typed: &'static str,
453}
454
455#[derive(Clone, Copy, PartialEq, Debug)]
457pub struct CueFrame {
458 pub cursor: Option<CursorPos>,
461 pub button: ButtonState,
462 pub held: Option<HeldKey>,
464 pub highlight: Option<CueTarget>,
465 pub typing: Option<TypingCue>,
466}
467
468#[derive(Clone)]
469pub struct Script {
470 steps: Vec<Step>,
471}
472
473#[cfg(test)]
478#[derive(Default)]
479pub struct ScriptBuilder {
480 steps: Vec<Step>,
481}
482
483#[cfg(test)]
486#[allow(dead_code)]
487impl ScriptBuilder {
488 #[must_use]
489 pub fn highlight(self, target: CueTarget, duration: Duration) -> Self {
490 self.step(Step::Highlight { target, duration })
491 }
492
493 #[must_use]
494 pub fn move_to(self, target: CueTarget, duration: Duration) -> Self {
495 self.step(Step::MoveTo { target, duration })
496 }
497
498 #[must_use]
499 pub fn hover(self, target: CueTarget, duration: Duration) -> Self {
500 self.step(Step::Hover { target, duration })
501 }
502
503 #[must_use]
504 pub fn click(self, target: CueTarget) -> Self {
505 self.step(Step::Click {
506 target,
507 count: ClickCount::Single,
508 })
509 }
510
511 #[must_use]
512 pub fn double_click(self, target: CueTarget) -> Self {
513 self.step(Step::Click {
514 target,
515 count: ClickCount::Double,
516 })
517 }
518
519 #[must_use]
520 pub fn drag(self, from: CueTarget, to: CueTarget, duration: Duration) -> Self {
521 self.step(Step::Drag { from, to, duration })
522 }
523
524 #[must_use]
525 pub fn type_text(self, target: CueTarget, text: &'static str, duration: Duration) -> Self {
526 self.step(Step::Type {
527 target,
528 text,
529 duration,
530 })
531 }
532
533 #[must_use]
534 pub fn pause(self, duration: Duration) -> Self {
535 self.step(Step::Pause { duration })
536 }
537
538 pub fn build(self) -> Script {
539 Script::new(self.steps)
540 }
541
542 #[must_use]
543 fn step(mut self, step: Step) -> Self {
544 self.steps.push(step);
545 self
546 }
547}
548
549#[cfg(test)]
550impl From<ScriptBuilder> for Script {
551 fn from(builder: ScriptBuilder) -> Self {
552 builder.build()
553 }
554}
555
556impl Script {
557 #[cfg(test)]
558 pub fn builder() -> ScriptBuilder {
559 ScriptBuilder::default()
560 }
561
562 pub fn new(steps: Vec<Step>) -> Self {
563 Self { steps }
564 }
565
566 pub fn concat<'a>(scripts: impl IntoIterator<Item = &'a Script>) -> Script {
569 Script::new(
570 scripts
571 .into_iter()
572 .flat_map(|s| s.steps.iter().copied())
573 .collect(),
574 )
575 }
576
577 pub fn steps(&self) -> &[Step] {
578 &self.steps
579 }
580
581 pub fn total(&self) -> Duration {
582 self.steps.iter().map(Step::duration).sum()
583 }
584
585 pub fn camera_at(&self, elapsed: Duration) -> Option<egui::Rect> {
591 let mut acc = Duration::ZERO;
592 let mut current: Option<egui::Rect> = None;
593 for step in &self.steps {
594 if acc > elapsed {
595 break;
596 }
597 if let Step::Camera { rect, duration } = step {
598 current = Some(match current {
599 Some(from) if elapsed < acc + *duration => {
600 let p = Progress::through(elapsed.saturating_sub(acc), *duration).eased();
601 lerp_rect(from, *rect, p)
602 }
603 _ => *rect,
604 });
605 }
606 acc += step.duration();
607 }
608 current
609 }
610
611 pub fn instruction_at(&self, elapsed: Duration) -> Option<&'static str> {
614 self.applied(elapsed, |step| match step {
615 Step::Instruct { text } => Some(*text),
616 _ => None,
617 })
618 }
619
620 fn applied<T>(&self, elapsed: Duration, pick: impl Fn(&Step) -> Option<T>) -> Option<T> {
624 let mut acc = Duration::ZERO;
625 let mut current = None;
626 for step in &self.steps {
627 if acc > elapsed {
628 break;
629 }
630 if let Some(value) = pick(step) {
631 current = Some(value);
632 }
633 acc += step.duration();
634 }
635 current
636 }
637
638 pub fn sample(&self, elapsed: Duration) -> Option<CueFrame> {
642 let mut remaining = elapsed;
643 let mut cursor: Option<CueTarget> = None;
645 let mut held: Option<HeldKey> = None;
646 for step in &self.steps {
647 if let Step::Hold { key } = step {
649 held = *key;
650 }
651 let duration = step.duration();
652 if remaining < duration {
653 let p = Progress::through(remaining, duration);
654 return Some(CueFrame {
655 held,
656 ..Self::frame(step, cursor, p, remaining)
657 });
658 }
659 remaining -= duration;
660 if let Some(c) = step.end_cursor() {
661 cursor = Some(c);
662 }
663 }
664 None
665 }
666
667 fn frame(step: &Step, prev: Option<CueTarget>, p: Progress, into_step: Duration) -> CueFrame {
670 let parked = prev.map(CursorPos::At);
671 let base = CueFrame {
672 cursor: parked,
673 button: ButtonState::Up,
674 held: None,
675 highlight: None,
676 typing: None,
677 };
678 match step {
679 Step::Highlight { target, .. } => CueFrame {
680 highlight: Some(*target),
681 ..base
682 },
683 Step::Pause { .. }
686 | Step::Camera { .. }
687 | Step::Instruct { .. }
688 | Step::Command { .. }
689 | Step::Hold { .. } => base,
690 Step::Hover { target, .. } => CueFrame {
691 cursor: Some(CursorPos::At(*target)),
692 ..base
693 },
694 Step::Type { target, text, .. } => CueFrame {
695 typing: Some(TypingCue {
696 target: *target,
697 typed: typed_prefix(text, p),
698 }),
699 ..base
700 },
701 Step::MoveTo { target, .. } => CueFrame {
702 cursor: Some(match prev {
704 Some(from) => CursorPos::Between {
705 from,
706 to: *target,
707 t: p.eased(),
708 },
709 None => CursorPos::At(*target),
710 }),
711 ..base
712 },
713 Step::Click { target, .. } => CueFrame {
714 cursor: Some(CursorPos::At(*target)),
715 button: ButtonState::Flash {
716 t: Progress::new(into_step.div_duration_f32(CLICK_PRESS).fract()),
717 },
718 ..base
719 },
720 Step::Drag { from, to, .. } => CueFrame {
721 cursor: Some(match to.anchoring() {
722 Anchoring::FromDragBase(_) => CursorPos::At(*from),
727 Anchoring::Toolbar(_) | Anchoring::Document => CursorPos::Between {
728 from: *from,
729 to: *to,
730 t: p.eased(),
731 },
732 }),
733 button: ButtonState::Down,
734 ..base
735 },
736 }
737 }
738}
739
740pub(super) fn typed_prefix(text: &'static str, p: Progress) -> &'static str {
744 let entered = (p.get() * text.chars().count() as f32).ceil() as usize;
745 let end = text
746 .char_indices()
747 .nth(entered)
748 .map_or(text.len(), |(i, _)| i);
749 &text[..end]
750}
751
752#[cfg(test)]
753mod tests {
754 use super::*;
755
756 fn demo() -> Script {
757 Script::new(vec![
758 Step::Highlight {
759 target: CueTarget::ToolButton(ToolName::NewBlock),
760 duration: Duration::from_secs(1),
761 },
762 Step::Click {
763 target: CueTarget::ToolButton(ToolName::NewBlock),
764 count: ClickCount::Double,
765 },
766 Step::Drag {
767 from: CueTarget::World(grid_pos(0, 0)),
768 to: CueTarget::World(grid_pos(8, 0)),
769 duration: Duration::from_secs(2),
770 },
771 Step::Pause {
772 duration: Duration::from_millis(500),
773 },
774 ])
775 }
776
777 #[test]
778 fn camera_glides_between_framings() {
779 let a = egui::Rect::from_min_max(grid_pos(0, 0), grid_pos(10, 10));
780 let b = egui::Rect::from_min_max(grid_pos(20, 0), grid_pos(40, 20));
781 let script = Script::new(vec![
782 Step::Camera {
783 rect: a,
784 duration: Duration::ZERO,
785 },
786 Step::Camera {
787 rect: b,
788 duration: Duration::from_secs(2),
789 },
790 ]);
791 assert_eq!(script.camera_at(Duration::ZERO), Some(a));
792 let mid = script.camera_at(Duration::from_secs(1)).unwrap();
794 assert_eq!(mid.min, a.min.lerp(b.min, 0.5));
795 assert_eq!(mid.max, a.max.lerp(b.max, 0.5));
796 assert_eq!(script.camera_at(Duration::from_secs(2)), Some(b));
797 assert_eq!(script.camera_at(Duration::from_secs(10)), Some(b));
798 assert_eq!(script.total(), Duration::from_secs(2));
800 }
801
802 #[test]
803 fn a_leading_glide_cuts_to_its_target() {
804 let b = egui::Rect::from_min_max(grid_pos(20, 0), grid_pos(40, 20));
805 let script = Script::new(vec![Step::Camera {
806 rect: b,
807 duration: Duration::from_secs(2),
808 }]);
809 assert_eq!(script.camera_at(Duration::ZERO), Some(b));
810 assert_eq!(script.camera_at(Duration::from_secs(1)), Some(b));
811 }
812
813 #[test]
816 fn a_held_key_persists_until_released() {
817 let script = Script::new(vec![
818 Step::Pause {
819 duration: Duration::from_secs(1),
820 },
821 Step::Hold {
822 key: Some(HeldKey::Space),
823 },
824 Step::Drag {
825 from: at(0, 0),
826 to: at(4, 0),
827 duration: Duration::from_secs(2),
828 },
829 Step::Hold { key: None },
830 Step::Pause {
831 duration: Duration::from_secs(1),
832 },
833 ]);
834 let held = |secs| script.sample(Duration::from_secs(secs)).unwrap().held;
835 assert_eq!(held(0), None, "before the hold");
836 assert_eq!(held(2), Some(HeldKey::Space), "mid-drag");
837 assert_eq!(held(3), None, "after the release");
838 }
839
840 #[test]
841 fn totals_sum_step_durations() {
842 let expected = Duration::from_millis(3500) + CLICK_PRESS * 2;
844 assert_eq!(demo().total(), expected);
845 }
846
847 #[test]
848 fn highlight_has_no_cursor_until_one_is_established() {
849 let frame = demo().sample(Duration::from_millis(500)).unwrap();
850 assert_eq!(frame.cursor, None);
851 assert_eq!(
852 frame.highlight,
853 Some(CueTarget::ToolButton(ToolName::NewBlock))
854 );
855 assert_eq!(frame.button, ButtonState::Up);
856 }
857
858 #[test]
859 fn double_click_flashes_twice() {
860 let script = demo();
861 let just_in = Duration::from_secs(1) + Duration::from_millis(100);
863 let first = script.sample(just_in).unwrap();
864 let second = script.sample(just_in + CLICK_PRESS).unwrap();
866 for frame in [first, second] {
867 assert!(matches!(frame.button, ButtonState::Flash { .. }));
868 assert_eq!(
869 frame.cursor,
870 Some(CursorPos::At(CueTarget::ToolButton(ToolName::NewBlock)))
871 );
872 }
873 let ButtonState::Flash { t: t1 } = first.button else {
875 unreachable!()
876 };
877 let ButtonState::Flash { t: t2 } = second.button else {
878 unreachable!()
879 };
880 assert!((t1.get() - t2.get()).abs() < 1e-3, "{t1:?} vs {t2:?}");
881 }
882
883 #[test]
884 fn drag_interpolates_between_endpoints_with_button_down() {
885 let script = demo();
886 let drag_start = Duration::from_secs(1) + CLICK_PRESS * 2;
887 let frame = script.sample(drag_start + Duration::from_secs(1)).unwrap();
888 assert_eq!(frame.button, ButtonState::Down);
889 let Some(CursorPos::Between { t, .. }) = frame.cursor else {
890 panic!("expected a lerping cursor, got {:?}", frame.cursor);
891 };
892 assert!((t.get() - 0.5).abs() < 1e-3, "{t:?}");
894 }
895
896 #[test]
897 fn pause_keeps_the_cursor_where_the_drag_left_it() {
898 let script = demo();
899 let pause_at = script
900 .total()
901 .checked_sub(Duration::from_millis(250))
902 .unwrap();
903 let frame = script.sample(pause_at).unwrap();
904 assert_eq!(
905 frame.cursor,
906 Some(CursorPos::At(CueTarget::World(grid_pos(8, 0))))
907 );
908 }
909
910 #[test]
914 fn doc_anchored_targets_follow_the_lowered_block() {
915 let mut fixture = CueFixture::lowered(
916 r#"
917top "b0"
918
919block "b0" x=0 y=0 w=28 h=20 {
920 title "sheet"
921 children "b1"
922}
923
924block "b1" x=4 y=4 w=8 h=7 {
925 title "core"
926 pin "p1" "in" loc="w1" dir="input"
927}
928"#,
929 );
930 let doc = &fixture.scope();
931 assert_eq!(
932 block("core").world(doc),
933 Some(grid_pos(8, 7).lerp(grid_pos(8, 8), 0.5))
934 );
935 assert_eq!(
936 corner("core", Handle::RightBottom).world(doc),
937 Some(grid_pos(12, 11))
938 );
939 assert_eq!(
940 CueTarget::BlockId("b1").world(doc),
941 block("core").world(doc),
942 "the file's own id names the same block its title does",
943 );
944 assert_eq!(
947 CueTarget::PinAnchor {
948 block: "b1",
949 pin: "p1"
950 }
951 .world(doc),
952 Some(egui::pos2(
953 px(4) - GRID_SIZE,
954 crate::grid::pin_offset_y(px(4), 1)
955 )),
956 );
957 assert_eq!(block("missing").world(doc), None);
958 assert_eq!(CueTarget::BlockId("b9").world(doc), None);
959 assert_eq!(tool(ToolName::NewBlock).world(doc), None);
960 assert_eq!(at(3, 5).world(doc), Some(grid_pos(3, 5)));
961 }
962
963 #[test]
964 fn typing_reveals_characters_and_leaves_the_cursor_parked() {
965 let script = Script::new(vec![
966 Step::MoveTo {
967 target: CueTarget::World(grid_pos(4, 0)),
968 duration: Duration::from_secs(1),
969 },
970 Step::Type {
971 target: CueTarget::World(grid_pos(0, 0)),
972 text: "CPU",
973 duration: Duration::from_secs(3),
974 },
975 ]);
976 let at = |millis: u64| {
977 script
978 .sample(Duration::from_secs(1) + Duration::from_millis(millis))
979 .unwrap()
980 };
981 assert_eq!(at(500).typing.unwrap().typed, "C");
982 assert_eq!(at(1500).typing.unwrap().typed, "CP");
983 assert_eq!(at(2900).typing.unwrap().typed, "CPU");
984 assert_eq!(
985 at(1500).cursor,
986 Some(CursorPos::At(CueTarget::World(grid_pos(4, 0))))
987 );
988 }
989
990 #[test]
991 fn a_finished_script_yields_no_frame() {
992 let script = demo();
993 assert!(
994 script
995 .sample(script.total() + Duration::from_millis(10))
996 .is_none()
997 );
998 }
999}