Skip to main content

blockworx/shell/
learn.rs

1//! The Navigator's Learn segment (spec §8.3): the library.
2//!
3//! **Chapters are primary.** A card names the walkthrough and how long it
4//! runs, and under it stand its chapters — ~65 addressable answers rather
5//! than 20 videos. The filter matches chapter titles, so a viewer looking
6//! for "the opposite corner" lands on the chapter that says it rather than
7//! on the tutorial that contains it.
8//!
9//! **A pick hands off and leaves the panel open** (§8), like a rev pick and
10//! a block pick: the player opens over the canvas beside it, and browsing on
11//! is one open rather than several.
12//!
13//! The web build has no containers to list yet, so its segment says so
14//! plainly instead of opening onto an empty shelf.
15
16#[cfg(not(target_arch = "wasm32"))]
17use crate::{
18    shell::glass,
19    tools::tool::{Action, Walkthrough},
20    tutorial::library::{Library, Progress, Watched, clock},
21};
22
23/// The air between the segment header and the first card.
24const LEAD: f32 = 12.0;
25
26/// What the library is a view of.
27#[cfg(not(target_arch = "wasm32"))]
28#[derive(Clone, Copy)]
29pub struct Learn<'a> {
30    pub library: &'a Library,
31    /// How far this viewer got through each of them — a per-viewer
32    /// convenience, not document data.
33    pub watched: &'a Watched,
34}
35
36/// Draw the library.
37#[cfg(not(target_arch = "wasm32"))]
38pub fn body(ui: &mut egui::Ui, learn: Learn<'_>) -> Option<Action> {
39    let ctx = ui.ctx().clone();
40    let filter_id = filter_id();
41    let mut filter: String = ctx.data_mut(|d| d.get_temp::<String>(filter_id).unwrap_or_default());
42    ui.add(
43        egui::TextEdit::singleline(&mut filter)
44            .id(filter_id)
45            .hint_text("Search chapters\u{2026}")
46            .desired_width(f32::INFINITY),
47    );
48    ctx.data_mut(|d| d.insert_temp(filter_id, filter.clone()));
49    ui.separator();
50    if learn.library.is_empty() {
51        ui.add_space(LEAD);
52        ui.label(
53            egui::RichText::new("No walkthroughs are installed with this build.")
54                .small()
55                .weak(),
56        );
57        return None;
58    }
59    let mut picked = None;
60    egui::ScrollArea::vertical()
61        .auto_shrink([false, false])
62        .show(ui, |ui| {
63            let matched = learn.library.matching(&filter);
64            if matched.is_empty() {
65                ui.add_space(LEAD);
66                ui.label(egui::RichText::new("Nothing matches.").small().weak());
67                return;
68            }
69            for (lesson, chapters) in matched {
70                ui.add_space(LEAD);
71                if let Some(pick) = card(ui, lesson, &chapters, learn.watched) {
72                    picked = Some(pick);
73                }
74            }
75            ui.add_space(LEAD);
76        });
77    picked.map(Action::Walkthrough)
78}
79
80/// One walkthrough: the title row that plays it from where the viewer left
81/// off, and its chapters under it. Reports the rev a pick asked for, or
82/// `None` where the pick meant "from the top".
83#[cfg(not(target_arch = "wasm32"))]
84fn card(
85    ui: &mut egui::Ui,
86    lesson: &crate::tutorial::library::Lesson,
87    chapters: &[&crate::tutorial::reader::Chapter],
88    watched: &Watched,
89) -> Option<Walkthrough> {
90    let mut picked = None;
91    let progress = watched.of(&lesson.title, lesson.chapters.len());
92    let head = ui.add(
93        egui::Button::new(
94            egui::RichText::new(&lesson.title)
95                .color(glass::full_ink(ui.visuals()))
96                .strong(),
97        )
98        .frame_when_inactive(false)
99        .min_size(egui::vec2(ui.available_width(), glass::TAP * 0.7)),
100    );
101    if head.clicked() {
102        // The resume offer *is* the head row's behaviour, not a second
103        // control beside it: a card the viewer left part way through opens
104        // where they left it, and a fresh one opens at the top.
105        picked = Some(Walkthrough {
106            root: lesson.root.clone(),
107            at: match progress {
108                Progress::Resume(nth) => lesson.chapter_at(nth),
109                Progress::Fresh | Progress::Watched => None,
110            },
111        });
112    }
113    ui.label(
114        egui::RichText::new(subtitle(lesson, progress))
115            .small()
116            .weak(),
117    );
118    for chapter in chapters {
119        let nth = lesson
120            .chapters
121            .iter()
122            .position(|held| held.at == chapter.at)
123            .unwrap_or(0);
124        let reached = if matches!(progress, Progress::Watched)
125            || matches!(progress, Progress::Resume(furthest) if furthest >= nth)
126        {
127            Reached::Yes
128        } else {
129            Reached::No
130        };
131        let row = ui.add(
132            egui::Button::new(chapter_line(&chapter.title, chapter.duration, reached))
133                .frame_when_inactive(false)
134                .min_size(egui::vec2(ui.available_width(), glass::TAP * 0.7)),
135        );
136        if row.clicked() {
137            picked = Some(Walkthrough {
138                root: lesson.root.clone(),
139                at: Some(chapter.at),
140            });
141        }
142    }
143    picked
144}
145
146/// What a card says under its name: how much there is, and what this viewer
147/// has done with it.
148#[cfg(not(target_arch = "wasm32"))]
149fn subtitle(lesson: &crate::tutorial::library::Lesson, progress: Progress) -> String {
150    let bulk = format!(
151        "{} chapters \u{b7} {}",
152        lesson.chapters.len(),
153        clock(lesson.duration),
154    );
155    match progress {
156        Progress::Fresh => bulk,
157        Progress::Watched => format!("{bulk} \u{b7} watched"),
158        Progress::Resume(nth) => match lesson.chapters.get(nth) {
159            None => bulk,
160            Some(chapter) => format!("{bulk} \u{b7} resume at \u{201c}{}\u{201d}", chapter.title),
161        },
162    }
163}
164
165/// A chapter row: its title, its length, and a mark where the viewer has
166/// already been.
167#[cfg(not(target_arch = "wasm32"))]
168fn chapter_line(title: &str, duration: core::time::Duration, reached: Reached) -> String {
169    let mark = match reached {
170        Reached::Yes => "\u{2713} ",
171        Reached::No => "",
172    };
173    format!("{mark}{title}  {}", clock(duration))
174}
175
176/// Whether the viewer has already been through a chapter — the mark on its
177/// row. A two-valued enum rather than a `bool`, so the call site says which
178/// of the two it means.
179#[cfg(not(target_arch = "wasm32"))]
180#[derive(Clone, Copy, PartialEq, Eq)]
181enum Reached {
182    Yes,
183    No,
184}
185
186#[cfg(not(target_arch = "wasm32"))]
187fn filter_id() -> egui::Id {
188    egui::Id::new("learn_filter")
189}
190
191/// §8's rule for the Hierarchy filter, applied here for the same reason: the
192/// filter is a transient search, not a view the panel remembers.
193#[cfg(not(target_arch = "wasm32"))]
194pub fn clear_the_filter(ctx: &egui::Context) {
195    ctx.data_mut(|d| d.insert_temp(filter_id(), String::new()));
196}
197
198/// The segment where there are no containers to list.
199#[cfg(target_arch = "wasm32")]
200pub fn body(ui: &mut egui::Ui) {
201    ui.add_space(LEAD);
202    ui.label("Walkthroughs arrive with the web build.");
203    ui.label(
204        egui::RichText::new(
205            "A walkthrough is an ordinary diagram, and the browser gets diagrams of its \
206             own in Phase 8.",
207        )
208        .small()
209        .weak(),
210    );
211}
212
213#[cfg(all(test, not(target_arch = "wasm32")))]
214mod tests {
215    use super::*;
216    use crate::shell::tests::screen;
217    use crate::shell::workspace::{PanelView, Workspace};
218    use crate::tools::painted::Chrome;
219
220    /// The segment, drawn inside the real navigator so the panel's own width
221    /// and clipping are in the answer.
222    struct Shelf {
223        chrome: Chrome,
224        library: Library,
225        watched: Watched,
226        state: Workspace,
227        picked: Vec<Walkthrough>,
228    }
229
230    impl Shelf {
231        fn new() -> Self {
232            let mut state = Workspace::default();
233            state.show(PanelView::Learn);
234            let library = Library::shipped();
235            assert!(
236                !library.is_empty(),
237                "precondition: the build ships walkthroughs to list",
238            );
239            let mut shelf = Shelf {
240                chrome: Chrome::new(screen()),
241                library,
242                watched: Watched::default(),
243                state,
244                picked: Vec::new(),
245            };
246            shelf.settle();
247            shelf
248        }
249
250        fn settle(&mut self) {
251            let Shelf {
252                chrome,
253                library,
254                watched,
255                state,
256                picked,
257            } = self;
258            chrome.settle(|ui| {
259                let mut floating = crate::shell::Chrome::over(ui.ctx(), ui.max_rect());
260                let drawn = crate::shell::navigator::navigator(&mut floating, state, |ui| {
261                    body(ui, Learn { library, watched })
262                });
263                if let Some(Action::Walkthrough(pick)) = drawn.action {
264                    picked.push(pick);
265                }
266            });
267        }
268
269        fn click(&mut self, text: &str) {
270            let found = self.chrome.rect(text);
271            assert!(
272                found.is_some(),
273                "the library drew no {text:?}: {:?}",
274                self.chrome.texts(),
275            );
276            let at = found.unwrap_or(egui::Rect::ZERO).center();
277            let Shelf {
278                chrome,
279                library,
280                watched,
281                state,
282                picked,
283            } = self;
284            chrome.click_at(at, |ui| {
285                let mut floating = crate::shell::Chrome::over(ui.ctx(), ui.max_rect());
286                let drawn = crate::shell::navigator::navigator(&mut floating, state, |ui| {
287                    body(ui, Learn { library, watched })
288                });
289                if let Some(Action::Walkthrough(pick)) = drawn.action {
290                    picked.push(pick);
291                }
292            });
293        }
294    }
295
296    /// §8.3's three facts per card, on screen: the name, what is in it, and
297    /// how long it is — plus the chapters, which are the addressable unit.
298    #[test]
299    fn the_library_lists_every_walkthrough_with_its_chapters() {
300        let shelf = Shelf::new();
301        for lesson in shelf.library.lessons() {
302            assert!(
303                shelf.chrome.shows(&lesson.title),
304                "the library lost {}: {:?}",
305                lesson.title,
306                shelf.chrome.texts(),
307            );
308            for chapter in &lesson.chapters {
309                assert!(
310                    shelf.chrome.says(&chapter.title),
311                    "{} lost the chapter {:?}",
312                    lesson.title,
313                    chapter.title,
314                );
315            }
316            assert!(
317                shelf
318                    .chrome
319                    .texts()
320                    .iter()
321                    .any(|said| said.contains(&clock(lesson.duration))),
322                "{} does not say how long it runs",
323                lesson.title,
324            );
325        }
326    }
327
328    /// A chapter pick opens the player *there* — the addressable answer §8.3
329    /// asks for — and the panel stays open, which is the Navigator's own
330    /// hand-off contract.
331    #[test]
332    fn a_chapter_pick_opens_the_player_at_it_and_keeps_the_panel_open() {
333        let mut shelf = Shelf::new();
334        let lesson = shelf
335            .library
336            .lessons()
337            .iter()
338            .find(|lesson| lesson.title == "first-block")
339            .expect("the first-block walkthrough");
340        let wanted = lesson.chapters[1].clone();
341        let root = lesson.root.clone();
342
343        shelf.click(&chapter_line(&wanted.title, wanted.duration, Reached::No));
344        assert_eq!(
345            shelf.picked,
346            vec![Walkthrough {
347                root,
348                at: Some(wanted.at),
349            }],
350        );
351        assert!(shelf.state.open(), "a pick shut the panel from inside it");
352    }
353
354    /// A fresh card plays from the top; one the viewer left part way through
355    /// offers to resume, and says so on the card.
356    #[test]
357    fn a_card_plays_from_the_top_until_there_is_somewhere_to_resume() {
358        let mut shelf = Shelf::new();
359        shelf.click("first-block");
360        assert_eq!(
361            shelf.picked.last().map(|pick| pick.at),
362            Some(None),
363            "a fresh walkthrough did not open at its beginning",
364        );
365
366        shelf.watched.reached("first-block", 2);
367        shelf.settle();
368        let resume = shelf
369            .library
370            .at(&shelf.picked[0].root.clone())
371            .expect("the lesson is in the library")
372            .chapter_at(2);
373        assert!(
374            shelf
375                .chrome
376                .texts()
377                .iter()
378                .any(|said| said.contains("resume at")),
379            "the card does not offer to resume: {:?}",
380            shelf.chrome.texts(),
381        );
382        shelf.click("first-block");
383        assert_eq!(
384            shelf.picked.last().map(|pick| pick.at),
385            Some(resume),
386            "the card did not resume where the viewer left it",
387        );
388    }
389
390    /// §8.3: the search matches chapter titles, and what it hides is the
391    /// walkthroughs no chapter of which matched.
392    #[test]
393    fn the_filter_narrows_the_shelf_to_matching_chapters() {
394        let mut shelf = Shelf::new();
395        assert!(
396            shelf.chrome.says("Wire two blocks"),
397            "precondition: the unfiltered shelf lists every walkthrough",
398        );
399        clear_the_filter(shelf.chrome.ctx());
400        shelf
401            .chrome
402            .ctx()
403            .data_mut(|d| d.insert_temp(filter_id(), "opposite".to_owned()));
404        shelf.settle();
405        assert!(
406            shelf.chrome.says("first-block"),
407            "the matching walkthrough is gone: {:?}",
408            shelf.chrome.texts(),
409        );
410        assert!(
411            !shelf.chrome.says("first-route"),
412            "a walkthrough no chapter of which matched is still listed: {:?}",
413            shelf.chrome.texts(),
414        );
415    }
416}