1use core::time::Duration;
26use std::path::Path;
27
28use blockworx_doc::document::{DocIndex, Document};
29use blockworx_doc::id::EntityRef;
30use blockworx_doc::rev::Rev;
31use egui::{Pos2, Rect, Vec2, pos2, vec2};
32
33use crate::canvas::view::{CanvasChrome, View};
34use crate::choreography::{Frame, FrameTrack, FrameValue, TrackKind};
35use crate::grid::GRID_SIZE;
36use crate::path::{BlockPath, Scope};
37use crate::shape::ShapeId;
38use crate::shell::glass;
39use crate::state::RenderMode;
40use crate::theme::{Role, RoleStroke, Style, Theme};
41use crate::tutorial::library::{Watched, clock};
42use crate::tutorial::reader::Tutorial;
43use crate::units::WorldPx;
44use crate::widget::drawing::Drawing;
45
46#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
51pub enum Size {
52 #[default]
53 Corner,
54 Large,
55}
56
57impl Size {
58 fn extent(self, room: Rect) -> Vec2 {
59 let wanted = match self {
60 Size::Corner => vec2(400.0, 300.0),
61 Size::Large => vec2(760.0, 560.0),
62 };
63 vec2(
64 wanted.x.min(room.width() - 2.0 * glass::MARGIN),
65 wanted.y.min(room.height() - 2.0 * glass::MARGIN),
66 )
67 }
68
69 fn toggled(self) -> Self {
70 match self {
71 Size::Corner => Size::Large,
72 Size::Large => Size::Corner,
73 }
74 }
75
76 fn hover(self) -> &'static str {
77 match self {
78 Size::Corner => "Expand",
79 Size::Large => "Shrink",
80 }
81 }
82}
83
84#[derive(Clone, Copy, PartialEq, Eq, Debug)]
86pub enum Running {
87 Playing,
88 Paused,
89 Ended,
92}
93
94pub struct Played {
96 pub reached: Option<usize>,
98 pub closed: bool,
100}
101
102pub struct Player {
104 tutorial: Tutorial,
105 step: Step,
109 elapsed: Duration,
110 running: Running,
111 size: Size,
112 view: View,
116 presentation: crate::presentation::Presentation,
117 index: DocIndex,
118 caption: String,
123 owed_fit: Option<Rect>,
126 framed: bool,
129}
130
131struct Step {
133 at: Rev,
134 before: Document,
136 timeline: crate::choreography::Timeline,
137 span: Duration,
141 region: Option<Rect>,
143 path: BlockPath,
144}
145
146impl Player {
147 pub fn open(
153 root: &Path,
154 at: Option<Rev>,
155 ) -> Result<Self, crate::store::container::ContainerError> {
156 let tutorial = Tutorial::open(root)?;
157 let first = tutorial.revs().next().unwrap_or(Rev::ZERO.next());
158 let mut player = Player {
159 step: Step::at(&tutorial, at.unwrap_or(first)),
160 tutorial,
161 elapsed: Duration::ZERO,
162 running: Running::Playing,
163 size: Size::default(),
164 view: View::new(),
165 presentation: crate::presentation::Presentation::default(),
166 index: DocIndex::default(),
167 caption: String::new(),
168 owed_fit: None,
169 framed: false,
170 };
171 player.arrive();
172 Ok(player)
173 }
174
175 pub fn title(&self) -> &str {
176 self.tutorial.name()
177 }
178
179 pub fn running(&self) -> Running {
180 self.running
181 }
182
183 pub fn teaches(&self) -> Vec<crate::tools::names::ToolName> {
187 self.tutorial.teaches()
188 }
189
190 pub fn chapter(&self) -> Option<usize> {
192 self.tutorial
193 .chapters()
194 .iter()
195 .rposition(|chapter| chapter.at <= self.step.at)
196 }
197
198 pub fn advance(&mut self, dt: Duration) {
203 if self.running != Running::Playing {
204 return;
205 }
206 self.elapsed = self.elapsed.saturating_add(dt);
207 while self.running == Running::Playing && self.elapsed >= self.step.span {
210 self.elapsed -= self.step.span;
211 self.next_rev();
212 }
213 }
214
215 pub fn jump_to_chapter(&mut self, nth: usize) {
219 let Some(chapter) = self.tutorial.chapters().get(nth) else {
220 return;
221 };
222 self.seek(chapter.at);
223 }
224
225 pub fn toggle_play(&mut self) {
228 self.running = match self.running {
229 Running::Playing => Running::Paused,
230 Running::Paused => Running::Playing,
231 Running::Ended => {
232 let first = self.tutorial.revs().next().unwrap_or(Rev::ZERO.next());
233 self.seek(first);
234 Running::Playing
235 }
236 };
237 }
238
239 pub fn toggle_size(&mut self) {
240 self.size = self.size.toggled();
241 }
242
243 pub fn size(&self) -> Size {
244 self.size
245 }
246
247 pub fn caption(&self) -> &str {
249 &self.caption
250 }
251
252 fn seek(&mut self, to: Rev) {
253 self.step = Step::at(&self.tutorial, to);
254 self.elapsed = Duration::ZERO;
255 self.running = Running::Playing;
256 self.caption.clear();
257 self.arrive();
258 }
259
260 fn next_rev(&mut self) {
262 let next = self.step.at.next();
263 if self.tutorial.timeline(next).is_none() {
264 self.running = Running::Ended;
265 self.elapsed = self.step.span;
266 return;
267 }
268 self.step = Step::at(&self.tutorial, next);
269 self.arrive();
270 }
271
272 fn arrive(&mut self) {
278 if let Some(note) = self.tutorial.narration(self.step.at) {
279 self.caption = note.text.clone();
280 }
281 self.owed_fit = self.step.region;
282 }
283}
284
285impl Step {
286 fn at(tutorial: &Tutorial, rev: Rev) -> Self {
287 let timeline = tutorial.timeline(rev).unwrap_or_else(empty_timeline);
288 let before = tutorial.document(rev).unwrap_or_default();
289 let plan = timeline.camera();
290 Step {
291 at: rev,
292 span: tutorial.span(rev),
293 region: plan.map(|plan| crate::edit::lower::px_rect(plan.region)),
294 path: plan.map_or_else(
295 || BlockPath::opening(&before),
296 |plan| path_of(&before, plan.scope),
297 ),
298 timeline,
299 before,
300 }
301 }
302}
303
304fn path_of(doc: &Document, scope: Scope) -> BlockPath {
306 match scope {
307 Scope::Root => BlockPath::empty(),
308 Scope::Block(id) => match BlockPath::to_parent_of(doc, id) {
309 Some(mut path) => {
310 path.push(id);
311 path
312 }
313 None => BlockPath::opening(doc),
314 },
315 }
316}
317
318fn empty_timeline() -> crate::choreography::Timeline {
321 crate::choreography::synthesize(
322 &DocIndex::default().view(&Document::default()),
323 &blockworx_doc::commit::Commit::new(String::new(), Vec::new()),
324 )
325}
326
327pub fn player(
330 ctx: &egui::Context,
331 player: &mut Player,
332 theme: &Theme,
333 watched: &Watched,
334 room: Rect,
335) -> Played {
336 let dt = Duration::from_secs_f32(ctx.input(|i| i.stable_dt).clamp(0.0, MAX_STEP));
339 player.advance(dt);
340 if player.running() == Running::Playing || player.owed_fit.is_some() {
344 ctx.request_repaint();
345 }
346 let extent = player.size.extent(room);
347 let at = room.right_bottom() - extent - Vec2::splat(glass::MARGIN);
348 let mut played = Played {
349 reached: player.chapter(),
350 closed: false,
351 };
352 egui::Area::new(id())
353 .fixed_pos(at)
354 .order(egui::Order::Middle)
355 .movable(false)
356 .fade_in(false)
357 .constrain(false)
358 .show(ctx, |ui| {
359 ui.set_width(extent.x);
360 glass::shell(
361 ui,
362 glass::Shape::Panel,
363 glass::Elevation::Floating,
364 glass::Tint::None,
365 )
366 .show(ui, |ui| {
367 glass::type_scale(ui, glass::Shape::Panel);
368 played.closed = head(ui, player, watched);
369 pane(ui, player, theme, extent);
370 caption(ui, player);
371 transport(ui, player);
372 });
373 });
374 played
375}
376
377fn head(ui: &mut egui::Ui, player: &mut Player, watched: &Watched) -> bool {
380 let mut closed = false;
381 ui.horizontal(|ui| {
382 let chapters = player.tutorial.chapters().to_vec();
383 let standing = player.chapter();
384 egui::containers::menu::MenuButton::new(
388 egui::RichText::new(player.title())
389 .color(glass::full_ink(ui.visuals()))
390 .strong(),
391 )
392 .ui(ui, |ui| {
393 ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
394 for (nth, chapter) in chapters.iter().enumerate() {
395 let here = if Some(nth) == standing {
396 "\u{25b8} "
397 } else {
398 ""
399 };
400 if ui
401 .button(format!(
402 "{here}{} {}",
403 chapter.title,
404 clock(chapter.duration),
405 ))
406 .clicked()
407 {
408 player.jump_to_chapter(nth);
409 }
410 }
411 });
412 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
413 if ui.button("\u{2715}").on_hover_text("Close").clicked() {
414 closed = true;
415 }
416 if ui
417 .button(match player.size() {
418 Size::Corner => "\u{2921}",
419 Size::Large => "\u{2922}",
420 })
421 .on_hover_text(player.size().hover())
422 .clicked()
423 {
424 player.toggle_size();
425 }
426 });
427 });
428 let _ = watched;
429 closed
430}
431
432fn pane(ui: &mut egui::Ui, player: &mut Player, theme: &Theme, extent: Vec2) {
435 let height = (extent.y - CHROME_HEIGHT).max(MIN_PANE);
436 let (rect, _) =
437 ui.allocate_exact_size(vec2(ui.available_width(), height), egui::Sense::hover());
438 let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
439 child.set_clip_rect(rect.intersect(ui.clip_rect()));
440 let palette = theme.palette().clone();
441 let chrome = CanvasChrome {
442 background: palette.resolve(theme.swatch(Role::CanvasBackground)),
443 grid: palette.resolve(theme.swatch(Role::GridLine)),
444 };
445 let frame = player
446 .step
447 .timeline
448 .at(player.elapsed.min(player.step.span));
449 let Player {
450 step,
451 view,
452 presentation,
453 index,
454 owed_fit,
455 framed,
456 ..
457 } = player;
458 if view.viewport().is_positive()
463 && let Some(region) = owed_fit.take()
464 {
465 view.fit_to_rect_instant(region);
466 }
467 let mut gesture = crate::gesture::Gesture::idle();
468 let mut drawing = Drawing::new(
469 index.view(&step.before),
470 &step.path,
471 presentation,
472 &mut gesture,
473 );
474 let mut canvas = view.begin(&mut child, palette, chrome);
475 if !*framed {
481 let mut scene = None;
482 canvas.fit_to(|painter| {
483 scene = drawing.content_bounds(&Style::new(theme, painter));
484 None
485 });
486 if let Some(scene) = scene.filter(|rect| rect.is_positive()) {
487 *owed_fit = Some(scene);
488 *framed = true;
489 }
490 }
491 canvas.paint(|_, painter| {
492 let mut style = Style::new(theme, painter);
493 drawing.refresh_text_extents(&style);
494 let depicted: Vec<ShapeId> = frame
497 .tracks
498 .iter()
499 .filter(|track| track.kind != TrackKind::Appear)
500 .filter_map(|track| shape_of(track.subject))
501 .collect();
502 crate::widget::DrawingPasses::new(&drawing)
503 .shape_mode(move |id| {
504 if depicted.contains(&id) {
505 RenderMode::Hidden
506 } else {
507 RenderMode::Normal
508 }
509 })
510 .draw(&mut style);
511 depict(&frame, &mut style);
512 });
513}
514
515fn depict(frame: &Frame, style: &mut Style<'_, impl crate::canvas::Renderer>) {
524 for track in &frame.tracks {
525 let fade = match track.kind {
526 TrackKind::Vanish => 1.0 - f32::from(track.progress),
527 TrackKind::Appear => f32::from(track.progress).max(ARRIVING),
528 _ => 1.0,
529 };
530 style.with_opacity(fade, |style| mark(track, style));
531 }
532 if let Some(ghost) = &frame.ghost {
533 let at = cells(ghost.at);
534 style.circle(
535 at,
536 WorldPx::new(HAND),
537 Role::WalkthroughGhost,
538 RoleStroke::from((MARK_WIDTH, Role::WalkthroughMark)),
539 );
540 }
541}
542
543fn mark(track: &FrameTrack, style: &mut Style<'_, impl crate::canvas::Renderer>) {
545 let stroke = RoleStroke::from((MARK_WIDTH, Role::WalkthroughMark));
546 match &track.value {
547 FrameValue::Rect { min, max } | FrameValue::Artwork { min, max } => {
548 let scale = if matches!(track.value, FrameValue::Artwork { .. }) {
549 1.0
550 } else {
551 GRID_SIZE
552 };
553 style.rect(
554 Rect::from_min_max(
555 pos2(min[0] * scale, min[1] * scale),
556 pos2(max[0] * scale, max[1] * scale),
557 ),
558 WorldPx::ZERO,
559 Role::Transparent,
560 stroke,
561 );
562 }
563 FrameValue::Point { at } => {
564 style.circle(cells(*at), WorldPx::new(DOT), Role::WalkthroughMark, stroke);
565 }
566 FrameValue::Path(points) if points.len() > 1 => {
567 style.line(points.iter().copied().map(cells).collect(), stroke);
568 }
569 FrameValue::Text { of, to, .. } => {
570 if let Some(at) = written_at(of.at) {
571 style.text(
572 at,
573 egui::Align2::LEFT_BOTTOM,
574 to.as_ref(),
575 &style.theme().title_font.clone(),
576 Role::WalkthroughMark,
577 );
578 }
579 }
580 FrameValue::Path(_)
581 | FrameValue::Along { .. }
582 | FrameValue::Label { .. }
583 | FrameValue::Flag { .. }
584 | FrameValue::Settled => {}
585 }
586}
587
588fn written_at(site: crate::choreography::Site) -> Option<Pos2> {
592 match site {
593 crate::choreography::Site::Shape(rect) => {
594 Some(crate::edit::lower::px_rect(rect).left_top())
595 }
596 crate::choreography::Site::Anchor(at) => Some(crate::edit::lower::px_point(at)),
597 crate::choreography::Site::Wire | crate::choreography::Site::Sheet => None,
598 }
599}
600
601fn cells(at: [f32; 2]) -> Pos2 {
603 pos2(at[0] * GRID_SIZE, at[1] * GRID_SIZE)
604}
605
606fn shape_of(subject: EntityRef) -> Option<ShapeId> {
610 match subject {
611 EntityRef::Block(id) => Some(ShapeId::Rect(id)),
612 EntityRef::Text(id) => Some(ShapeId::Text(id)),
613 EntityRef::Area(id) => Some(ShapeId::Area(id)),
614 EntityRef::Image(id) => Some(ShapeId::Image(id)),
615 EntityRef::Document
616 | EntityRef::Pin(_)
617 | EntityRef::Route(_)
618 | EntityRef::RouteLabel(_)
619 | EntityRef::Asset(_) => None,
620 }
621}
622
623fn caption(ui: &mut egui::Ui, player: &Player) {
625 ui.add_space(CAPTION_AIR);
626 ui.label(
627 egui::RichText::new(if player.caption().is_empty() {
628 " "
629 } else {
630 player.caption()
631 })
632 .color(glass::full_ink(ui.visuals())),
633 );
634}
635
636fn transport(ui: &mut egui::Ui, player: &mut Player) {
638 ui.horizontal(|ui| {
639 if ui
640 .button(match player.running() {
641 Running::Playing => "\u{23f8}",
642 Running::Paused | Running::Ended => "\u{25b6}",
643 })
644 .on_hover_text("Play or pause")
645 .clicked()
646 {
647 player.toggle_play();
648 }
649 let standing = player.chapter();
650 let last = player.tutorial.chapters().len().saturating_sub(1);
651 if ui
652 .add_enabled(
653 standing.is_some_and(|nth| nth > 0),
654 egui::Button::new("\u{23ee}"),
655 )
656 .on_hover_text("Previous chapter")
657 .clicked()
658 && let Some(nth) = standing
659 {
660 player.jump_to_chapter(nth - 1);
661 }
662 if ui
663 .add_enabled(
664 standing.is_some_and(|nth| nth < last),
665 egui::Button::new("\u{23ed}"),
666 )
667 .on_hover_text("Next chapter")
668 .clicked()
669 && let Some(nth) = standing
670 {
671 player.jump_to_chapter(nth + 1);
672 }
673 if let Some(nth) = standing
674 && let Some(chapter) = player.tutorial.chapters().get(nth)
675 {
676 ui.label(
677 egui::RichText::new(format!("{} {}", chapter.title, clock(chapter.duration)))
678 .small()
679 .weak(),
680 );
681 }
682 });
683}
684
685fn id() -> egui::Id {
686 egui::Id::new("tutorial_player")
687}
688
689const MAX_STEP: f32 = 0.25;
693
694const ARRIVING: f32 = 0.25;
698
699const CHROME_HEIGHT: f32 = 118.0;
702const MIN_PANE: f32 = 120.0;
703const CAPTION_AIR: f32 = 4.0;
704
705const MARK_WIDTH: f32 = 2.0;
708const DOT: f32 = 4.0;
709const HAND: f32 = 7.0;
710
711#[cfg(test)]
712mod tests {
713 use super::*;
714 use crate::shell::tests::screen;
715 use crate::tools::painted::Chrome;
716 use crate::tutorial::library::Library;
717
718 struct Watching {
721 chrome: Chrome,
722 player: Player,
723 theme: Theme,
724 watched: Watched,
725 closed: bool,
726 reached: Vec<usize>,
727 }
728
729 impl Watching {
730 fn of(id: &str) -> Self {
731 let library = Library::shipped();
732 let lesson = library
733 .lessons()
734 .iter()
735 .find(|lesson| lesson.title == id)
736 .unwrap_or_else(|| panic!("{id} is not in the library"));
737 let mut watching = Watching {
738 chrome: Chrome::new(screen()),
739 player: Player::open(&lesson.root, None).expect("the walkthrough opens"),
740 theme: Theme::default(),
741 watched: Watched::default(),
742 closed: false,
743 reached: Vec::new(),
744 };
745 watching.frame();
746 watching
747 }
748
749 fn frame(&mut self) {
752 let Watching {
753 chrome,
754 player,
755 theme,
756 watched,
757 closed,
758 reached,
759 } = self;
760 chrome.settle(|ui| {
761 let played = super::player(ui.ctx(), player, theme, watched, screen());
762 *closed |= played.closed;
763 if let Some(nth) = played.reached
764 && reached.last() != Some(&nth)
765 {
766 reached.push(nth);
767 }
768 });
769 }
770
771 fn watch(&mut self, millis: u64) {
774 const STEP: u64 = 40;
775 for _ in 0..millis.div_ceil(STEP) {
776 self.player.advance(Duration::from_millis(STEP));
777 self.frame();
778 }
779 }
780 }
781
782 #[test]
786 fn every_shipped_walkthrough_plays_to_the_end() {
787 for id in ["first-block", "first-route", "resize-move"] {
788 let mut watching = Watching::of(id);
789 let whole: Duration = watching
790 .player
791 .tutorial
792 .revs()
793 .fold(Duration::ZERO, |sum, rev| {
794 sum + watching.player.tutorial.span(rev)
795 });
796 assert!(!whole.is_zero(), "precondition: {id} has something to play");
797 let chapters = watching.player.tutorial.chapters().len();
798 watching.watch(whole.as_millis() as u64 + 200);
799 assert_eq!(
800 watching.player.running(),
801 Running::Ended,
802 "{id} never reached its end",
803 );
804 assert_eq!(
805 watching.reached,
806 (0..chapters).collect::<Vec<_>>(),
807 "{id} did not stand in every chapter in order",
808 );
809 }
810 }
811
812 #[test]
816 fn what_is_said_at_a_rev_reaches_the_screen_and_stands() {
817 let mut watching = Watching::of("first-block");
818 let said = watching.player.caption().to_owned();
819 assert!(
820 said.contains("Your first block"),
821 "the opening chapter is not said: {said:?}",
822 );
823 assert!(
824 watching.chrome.says(&said),
825 "the caption is not painted: {:?}",
826 watching.chrome.texts(),
827 );
828
829 watching.watch(3_000);
830 let later = watching.player.caption().to_owned();
831 assert_ne!(later, said, "the caption never moved on");
832 assert!(!later.is_empty(), "the player fell silent");
833 assert!(
834 watching.chrome.says(&later),
835 "the later caption is not painted: {:?}",
836 watching.chrome.texts(),
837 );
838 }
839
840 #[test]
847 fn a_timeline_in_flight_is_drawn_in_the_guide_role() {
848 let watching = Watching::of("first-block");
849 let theme = Theme::default();
850 let ink = theme.resolve(Role::WalkthroughMark);
851 let drawn = watching
852 .player
853 .tutorial
854 .revs()
855 .filter_map(|rev| watching.player.tutorial.timeline(rev))
856 .filter(|timeline| !timeline.duration().is_zero())
857 .map(|timeline| {
858 let frame = timeline.at(timeline.duration() / 2);
859 assert!(
860 !frame.tracks.is_empty(),
861 "precondition: a depicted commit has tracks",
862 );
863 let mut svg = crate::canvas::SvgRenderer::new(
864 theme.palette().clone(),
865 crate::preferences::FontChoice::Basic,
866 );
867 {
868 let mut style = Style::new(&theme, &mut svg);
869 super::depict(&frame, &mut style);
870 }
871 svg.finish().0
872 })
873 .collect::<Vec<_>>();
874 assert!(!drawn.is_empty(), "precondition: something is depicted");
875 let wanted = format!("#{:02x}{:02x}{:02x}", ink.r(), ink.g(), ink.b());
876 assert!(
877 drawn.iter().any(|svg| svg.contains(&wanted)),
878 "no depiction reached the painter in the guide role {wanted}",
879 );
880 }
881
882 #[test]
885 fn a_chapter_jump_moves_the_playhead_to_it() {
886 let mut watching = Watching::of("first-route");
887 let chapters = watching.player.tutorial.chapters().to_vec();
888 assert!(
889 chapters.len() > 2,
890 "precondition: there are chapters to jump between",
891 );
892 watching.player.jump_to_chapter(2);
893 watching.frame();
894 assert_eq!(watching.player.chapter(), Some(2));
895 assert_eq!(watching.player.step.at, chapters[2].at);
896
897 watching.player.jump_to_chapter(0);
898 watching.frame();
899 assert_eq!(
900 watching.player.chapter(),
901 Some(0),
902 "a jump backwards did not take",
903 );
904 }
905
906 #[test]
909 fn a_paused_player_settles() {
910 let library = Library::shipped();
911 let lesson = &library.lessons()[0];
912 let mut player = Player::open(&lesson.root, None).expect("the walkthrough opens");
913 player.toggle_play();
914 assert_eq!(player.running(), Running::Paused);
915 let theme = Theme::default();
916 let watched = Watched::default();
917 let settle = crate::tools::settle::probe(60, |ui| {
918 super::player(ui.ctx(), &mut player, &theme, &watched, screen());
919 });
920 crate::tools::settle::assert_settles(&settle, 50);
921 }
922
923 #[test]
926 fn the_expand_toggle_changes_the_panel_between_two_sizes() {
927 let room = screen();
928 let corner = Size::Corner.extent(room);
929 let large = Size::Large.extent(room);
930 assert!(
931 large.x > corner.x && large.y > corner.y,
932 "the two sizes are the same size: {corner:?} and {large:?}",
933 );
934
935 let mut watching = Watching::of("first-block");
936 assert_eq!(watching.player.size(), Size::Corner);
937 let at = watching
938 .chrome
939 .rect("\u{2921}")
940 .expect("the expand toggle never drew")
941 .center();
942 let Watching {
943 chrome,
944 player,
945 theme,
946 watched,
947 ..
948 } = &mut watching;
949 chrome.click_at(at, |ui| {
950 super::player(ui.ctx(), player, theme, watched, screen());
951 });
952 assert_eq!(player.size(), Size::Large, "the toggle did not expand it");
953 player.toggle_size();
954 assert_eq!(player.size(), Size::Corner);
955 }
956}