Skip to main content

blockworx/tutorial/
player.rs

1//! The walkthrough player (spec §8.3): a floating panel that plays a
2//! tutorial's log back as the animation the choreographer synthesizes for it.
3//!
4//! **It is a panel, not a mode.** The user's own document stays live and
5//! editable behind it — there is no `Mode` enum any more and none returns,
6//! nothing here reaches the open diagram's log, its undo stack, or its
7//! selection, and closing the player leaves the session exactly where it was.
8//! Like the toast it is transient, so it is not one of the shell's berths:
9//! the frame's three persistent regions stay three. It is placed inside the room the chrome measured, so it lands
10//! in a corner of the *canvas* rather than under the navigator.
11//!
12//! **Chapters are the primary navigation** (§8.3). The transport is
13//! play/pause and chapter prev/next; the chapter list is a menu off the
14//! title. There is a scrubber, and it is secondary — it says where in the
15//! chapter the playhead is and can be dragged, but nothing hangs on it.
16//!
17//! **What is drawn is the document being changed, plus the change.** The
18//! pane is a real canvas — its own view, the ordinary drawing pass over the
19//! tutorial's document as it stood before the commit — with the timeline's
20//! [`Frame`] laid over it in one guide role.
21//! The subjects a track depicts are held back from the document pass, so the
22//! wire being drawn and the block being moved are drawn once, by the
23//! depiction, rather than twice.
24
25use 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/// How big the panel stands. Two sizes and a toggle between them, which is
47/// what §8.3's "floats over the canvas so users can follow along" and "the
48/// library browses" together ask for: small enough to work beside, large
49/// enough to watch.
50#[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/// Whether the playhead is moving.
85#[derive(Clone, Copy, PartialEq, Eq, Debug)]
86pub enum Running {
87    Playing,
88    Paused,
89    /// The last rev has played out. Nothing advances, and nothing asks for
90    /// another frame — a finished player must settle.
91    Ended,
92}
93
94/// What one player frame reports.
95pub struct Played {
96    /// The chapter the playhead has reached, for the watched list.
97    pub reached: Option<usize>,
98    /// Whether the viewer closed it.
99    pub closed: bool,
100}
101
102/// A walkthrough, open.
103pub struct Player {
104    tutorial: Tutorial,
105    /// The rev being played, and everything derived from it. Re-derived only
106    /// when the step changes — a fold and a synthesis per step, not per
107    /// frame.
108    step: Step,
109    elapsed: Duration,
110    running: Running,
111    size: Size,
112    /// The pane's own camera. A tutorial frames itself from the timeline's
113    /// camera plan, and the viewer may pan and zoom inside the pane like any
114    /// other canvas.
115    view: View,
116    presentation: crate::presentation::Presentation,
117    index: DocIndex,
118    /// What was last said, which stands until something else is said:
119    /// narration speaks *over* the step it precedes
120    /// (`docs/tutorial-levels.md`), and a note is an empty commit that would
121    /// otherwise be gone in one frame.
122    caption: String,
123    /// The camera the step's timeline planned, waiting for a pane with a
124    /// viewport to be measured against.
125    owed_fit: Option<Rect>,
126    /// Whether the pane has ever been framed. The first step of a converted
127    /// container is a note, which plans no camera, so something has to.
128    framed: bool,
129}
130
131/// One rev's worth of playback, derived once when the step changes.
132struct Step {
133    at: Rev,
134    /// The document the commit is being made to.
135    before: Document,
136    timeline: crate::choreography::Timeline,
137    /// How long this rev holds — the substrate's number
138    /// ([`Tutorial::span`]), so the player spends exactly what the library
139    /// card advertised.
140    span: Duration,
141    /// Where the pane looks while it plays, in world pixels.
142    region: Option<Rect>,
143    path: BlockPath,
144}
145
146impl Player {
147    /// Open `root` at `at`, or at the beginning.
148    ///
149    /// # Errors
150    /// As [`Tutorial::open`]: there is no container there, or its log cannot
151    /// be read.
152    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    /// The tools this walkthrough is about, as its own log says (D18's tags,
184    /// `docs/tutorial-levels.md`). The tool cluster rings them while it
185    /// plays, so a viewer can see which button the hand is reaching for.
186    pub fn teaches(&self) -> Vec<crate::tools::names::ToolName> {
187        self.tutorial.teaches()
188    }
189
190    /// Which chapter the playhead stands in.
191    pub fn chapter(&self) -> Option<usize> {
192        self.tutorial
193            .chapters()
194            .iter()
195            .rposition(|chapter| chapter.at <= self.step.at)
196    }
197
198    /// Advance the clock by `dt`, stepping to the next rev whenever this one
199    /// has played out. The module never reads a clock (C4): the caller's
200    /// frame supplies it, which is what lets a test drive a whole tutorial
201    /// deterministically.
202    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        // A while, not an if: a step that depicts nothing has zero span, so
208        // one long frame may cross several of them.
209        while self.running == Running::Playing && self.elapsed >= self.step.span {
210            self.elapsed -= self.step.span;
211            self.next_rev();
212        }
213    }
214
215    /// Jump to the chapter `nth`, which is what a chapter pick means
216    /// everywhere: the library's rows, the palette's, and the player's own
217    /// list and stepper.
218    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    /// Play or pause. A player that has ended starts again from the top,
226    /// which is what pressing play on a finished thing means.
227    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    /// What is said right now — the note standing at or before the playhead.
248    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    /// Move onto the next rev, or end.
261    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    /// What arriving at a step does besides fold it: take what is said there
273    /// as the caption, and owe the pane the camera the timeline planned.
274    ///
275    /// Owed rather than applied, because a framing needs a viewport to be
276    /// measured against and the pane has none until it has drawn once.
277    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
304/// The path whose scope is `scope`, for the document `doc`.
305fn 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
318/// A timeline for a rev the log does not hold — reached only by a container
319/// with no commits at all, which nothing ships.
320fn 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
327/// Draw the player in a corner of the room the chrome left, and report what
328/// the viewer did with it.
329pub fn player(
330    ctx: &egui::Context,
331    player: &mut Player,
332    theme: &Theme,
333    watched: &Watched,
334    room: Rect,
335) -> Played {
336    // The clock the playback runs on: egui's own smoothed frame time, which
337    // is the only place in this module a duration comes from outside.
338    let dt = Duration::from_secs_f32(ctx.input(|i| i.stable_dt).clamp(0.0, MAX_STEP));
339    player.advance(dt);
340    // A paused or ended player owes nothing — which is what lets an idle
341    // frame with one on screen still settle. One framing still owed is one
342    // more frame, and then it is owed no more.
343    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
377/// The title row: what is playing, the chapter list, the size toggle, and
378/// the way out.
379fn 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        // §8.3's primary affordance, as a cascading submenu rather than a
385        // dialog (house style): the whole list, always, one press from the
386        // title.
387        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
432/// The video: the document the commit is being made to, with the commit's
433/// depiction over it.
434fn 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    // The framing the step owed, now that there is a pane to measure it
459    // against. Instant, not eased: a step change is a cut, and a camera
460    // easing between two of them would still be travelling when the next
461    // one arrived.
462    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    // A rev whose timeline plans no camera — a note, or one of the rows the
476    // 7·2/7·3 seams left region-less — has nothing to frame on, so the first
477    // one is framed on the scene instead. Measured through the fitting
478    // painter and *owed* rather than eased: the pane must be still while a
479    // step plays.
480    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        // The subjects the timeline is depicting are held back, so a block
495        // being moved is drawn once — travelling — rather than twice.
496        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
515/// The [`Frame`] itself: every track at this instant, in the one guide role,
516/// plus the ghost hand.
517///
518/// A value the painter cannot place on its own — an arc length along a wire,
519/// an offset along a shape's side, a discrete register — draws nothing here:
520/// those are the seams `docs/choreographer-playbook.md` 7·2/7·3 left open,
521/// and what the viewer sees instead is the document itself once the commit
522/// lands one step later.
523fn 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
543/// One track's value, drawn.
544fn 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
588/// Where a written value shows, as far as the document can say. A wire's own
589/// registers and the sheet's title block have no world position at all, so
590/// they are not drawn over the canvas (the 7·4 seam).
591fn 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
601/// A frame value's fractional grid cells, in world pixels.
602fn cells(at: [f32; 2]) -> Pos2 {
603    pos2(at[0] * GRID_SIZE, at[1] * GRID_SIZE)
604}
605
606/// The shape a track's subject is, where it is one the document render draws.
607/// A pin is left alone: it is drawn on its owner's boundary, and hiding the
608/// owner to hide a pin would take the whole block off the pane.
609fn 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
623/// What is being said, under the pane.
624fn 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
636/// Play/pause, chapter prev/next, and the secondary scrubber.
637fn 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
689/// How much of a stalled frame the clock will believe. A tab that was in the
690/// background for a minute must not skip a whole tutorial on the frame it
691/// comes back.
692const MAX_STEP: f32 = 0.25;
693
694/// How much of itself an arriving depiction keeps at the instant it begins,
695/// so a shape growing out of its own corner is visible before it has any
696/// size to speak of.
697const ARRIVING: f32 = 0.25;
698
699/// The room the head, the caption and the transport take out of the panel's
700/// height, leaving the rest to the pane.
701const CHROME_HEIGHT: f32 = 118.0;
702const MIN_PANE: f32 = 120.0;
703const CAPTION_AIR: f32 = 4.0;
704
705/// World-pixel weights for the depiction: a stroke a little heavier than a
706/// block outline, a dot at a bare anchor, and the ghost hand.
707const 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    /// A player over a shipped walkthrough, driven through real egui frames
719    /// with a clock the test supplies (C4).
720    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        /// One drawn frame, with no time passing — so a test's clock is the
750        /// only clock.
751        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        /// `millis` of playback, in frames of `STEP` — a real clock's
772        /// granularity, so nothing depends on one long jump.
773        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    /// The exit criterion: every shipped walkthrough plays start to finish
783    /// through the real player, drawn into real frames — the playhead
784    /// crosses every rev, every chapter is stood in, and it ends.
785    #[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    /// Narration reaches the screen at the rev it was written at, and stands
813    /// there: a note is an empty commit, so a caption that died with its own
814    /// step would never be read.
815    #[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    /// The depiction reaches a painter: a timeline sampled part way through
841    /// its own window draws marks, in the guide role and nowhere else.
842    ///
843    /// Through a real [`Renderer`](crate::canvas::Renderer) backend — the SVG
844    /// one, which is text and can be read — rather than through the on-screen
845    /// painter, whose output a test can only count.
846    #[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    /// §8.3's primary affordance: a chapter jump moves the playhead there,
883    /// forwards or back.
884    #[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    /// The player is a panel, not a mode: nothing it does reaches a
907    /// document, and a paused one costs no frames.
908    #[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    /// The expand toggle, both ways, and that the panel really changes size
924    /// with it.
925    #[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}