Skip to main content

blockworx/tutorial/
library.rs

1//! The Learn library (spec §8.3): what there is to watch, and what the
2//! viewer has watched of it.
3//!
4//! **Chapters are the addressable unit.** A library of twenty walkthroughs is
5//! twenty answers; the same twenty broken into chapters is sixty-five, and
6//! that is what the search matches and what a pick opens the player at. So a
7//! [`Lesson`] carries its chapters whole rather than a count, and
8//! [`Library::matching`] answers with the chapters that matched.
9//!
10//! Nothing here reads a `.bwx` twice: opening one costs a replay, so the
11//! library is built once and holds what the cards and the palette need —
12//! title, chapters, length, and the tools the log's `teaches/` tags name. The
13//! container itself is re-opened only when something is actually played.
14//!
15//! [`Watched`] is a per-viewer convenience and not document data: how far
16//! through a tutorial this person got has no business in anyone's log, so it
17//! rides the eframe storage DB beside the recent-files list.
18
19use core::time::Duration;
20use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22
23use blockworx_doc::rev::Rev;
24
25use crate::tools::names::ToolName;
26use crate::tutorial::reader::{Chapter, Tutorial};
27
28/// Everything the Learn segment, the palette and the selection overlay know
29/// about what there is to watch.
30#[derive(Default)]
31pub struct Library {
32    lessons: Vec<Lesson>,
33}
34
35/// One walkthrough, as a card: what it is called, what is in it, how long it
36/// runs, and where to re-open it.
37pub struct Lesson {
38    /// The container's own root — the only thing a player needs to open it.
39    pub root: PathBuf,
40    /// The diagram's own name, which is what a tutorial is called (D20).
41    pub title: String,
42    pub chapters: Vec<Chapter>,
43    /// The whole of it: the sum of its chapters, which are the sum of their
44    /// commits' synthesized timelines. The substrate's number, never the
45    /// UI's (`docs/choreographer-playbook.md` 7·8).
46    pub duration: Duration,
47    pub teaches: Vec<ToolName>,
48}
49
50impl Library {
51    /// Open every tutorial the build ships, skipping any that will not read.
52    ///
53    /// A container that has rotted is not worth refusing the whole library
54    /// over: what a viewer loses is one card, and what they would lose
55    /// otherwise is the segment.
56    pub fn shipped() -> Self {
57        Self::under(&shipped_root())
58    }
59
60    /// Every `.bwx` in `dir`, in the order the directory names them (sorted,
61    /// so the library reads the same on every platform).
62    pub fn under(dir: &Path) -> Self {
63        let Ok(entries) = std::fs::read_dir(dir) else {
64            return Library::default();
65        };
66        let mut roots: Vec<PathBuf> = entries
67            .filter_map(Result::ok)
68            .map(|entry| entry.path())
69            .filter(|path| path.extension().is_some_and(|ext| ext == "bwx"))
70            .collect();
71        roots.sort();
72        Library {
73            lessons: roots.iter().filter_map(|root| Lesson::open(root)).collect(),
74        }
75    }
76
77    pub fn lessons(&self) -> &[Lesson] {
78        &self.lessons
79    }
80
81    pub fn is_empty(&self) -> bool {
82        self.lessons.is_empty()
83    }
84
85    /// The lessons `query` reaches, each with the chapters that matched.
86    ///
87    /// An empty query is every lesson with every chapter — the library at
88    /// rest. Otherwise a chapter matches on its own title and a lesson
89    /// matches on its name, in which case all of its chapters are offered:
90    /// searching for a tutorial by name must not hide what is inside it.
91    pub fn matching<'a>(&'a self, query: &str) -> Vec<(&'a Lesson, Vec<&'a Chapter>)> {
92        let query = query.trim().to_lowercase();
93        self.lessons
94            .iter()
95            .filter_map(|lesson| {
96                if query.is_empty() || lesson.title.to_lowercase().contains(&query) {
97                    return Some((lesson, lesson.chapters.iter().collect()));
98                }
99                let hits: Vec<&Chapter> = lesson
100                    .chapters
101                    .iter()
102                    .filter(|chapter| chapter.title.to_lowercase().contains(&query))
103                    .collect();
104                (!hits.is_empty()).then_some((lesson, hits))
105            })
106            .collect()
107    }
108
109    /// The lessons that teach `tool` — what the selection overlay's
110    /// walkthrough entry (R15) and the tool cluster's ring are looking for.
111    pub fn teaching(&self, tool: ToolName) -> impl Iterator<Item = &Lesson> {
112        self.lessons
113            .iter()
114            .filter(move |lesson| lesson.teaches.contains(&tool))
115    }
116
117    /// The lesson at `root`, for a pick that named one.
118    pub fn at(&self, root: &Path) -> Option<&Lesson> {
119        self.lessons.iter().find(|lesson| lesson.root == root)
120    }
121}
122
123impl Lesson {
124    fn open(root: &Path) -> Option<Self> {
125        let tutorial = Tutorial::open(root)
126            .inspect_err(|error| tracing::warn!("{}: {error}", root.display()))
127            .ok()?;
128        let chapters = tutorial.chapters().to_vec();
129        Some(Lesson {
130            root: root.to_path_buf(),
131            title: tutorial.name().to_owned(),
132            duration: chapters.iter().map(|chapter| chapter.duration).sum(),
133            teaches: tutorial.teaches(),
134            chapters,
135        })
136    }
137
138    /// Where this lesson's `nth` chapter begins, for a pick that named one.
139    pub fn chapter_at(&self, nth: usize) -> Option<Rev> {
140        self.chapters.get(nth).map(|chapter| chapter.at)
141    }
142
143    /// Which chapter holds `rev` — the one that opened most recently at or
144    /// before it. `None` for a rev before the first chapter opens, which is
145    /// the setup a converted container carries.
146    pub fn chapter_of(&self, rev: Rev) -> Option<usize> {
147        self.chapters.iter().rposition(|chapter| chapter.at <= rev)
148    }
149}
150
151/// How far through each tutorial this viewer has got: the furthest chapter
152/// they have reached, by the tutorial's title.
153///
154/// Not document data and never in a log — it is one person's place in one
155/// browser or one desktop session, so it lives where the recent-files list
156/// and the navigator's own state live.
157#[derive(Clone, Default, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
158pub struct Watched(BTreeMap<String, usize>);
159
160/// What the library offers a viewer about a lesson they have met before.
161#[derive(Clone, Copy, PartialEq, Eq, Debug)]
162pub enum Progress {
163    /// Never opened.
164    Fresh,
165    /// Left part way through, at this chapter.
166    Resume(usize),
167    /// Reached the last chapter.
168    Watched,
169}
170
171impl Watched {
172    /// Record that `title`'s `nth` chapter was reached. Furthest, not last:
173    /// jumping back to chapter one is re-watching, not losing your place.
174    pub fn reached(&mut self, title: &str, nth: usize) {
175        let furthest = self.0.entry(title.to_owned()).or_default();
176        *furthest = (*furthest).max(nth);
177    }
178
179    /// What to offer for a lesson with `chapters` chapters.
180    pub fn of(&self, title: &str, chapters: usize) -> Progress {
181        match self.0.get(title) {
182            None => Progress::Fresh,
183            Some(&nth) if nth + 1 >= chapters => Progress::Watched,
184            Some(&nth) => Progress::Resume(nth),
185        }
186    }
187
188    /// Forget everything — the one thing a viewer might reasonably ask of a
189    /// watched list.
190    pub fn clear(&mut self) {
191        self.0.clear();
192    }
193}
194
195/// A duration as a viewer reads one: `m:ss`, the "4 minutes / 40 seconds" of
196/// §8.3 written the way a player writes it. Stated once, because the library
197/// card, the palette row and the overlay's walkthrough entry must all say the
198/// same number the same way.
199pub fn clock(of: Duration) -> String {
200    let seconds = of.as_secs();
201    format!("{}:{:02}", seconds / 60, seconds % 60)
202}
203
204/// Where the shipped tutorials are: beside the executable in an installed
205/// build, and in the source tree while developing.
206///
207/// Named, not fixed: packaging has not been decided (Phase 8), so the second
208/// candidate is what actually answers today.
209fn shipped_root() -> PathBuf {
210    let beside_the_binary = std::env::current_exe()
211        .ok()
212        .and_then(|exe| exe.parent().map(|dir| dir.join("tutorials")))
213        .filter(|dir| dir.is_dir());
214    beside_the_binary
215        .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/tutorials"))
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    fn library() -> Library {
223        let library = Library::shipped();
224        assert_eq!(
225            library.lessons().len(),
226            3,
227            "precondition: the build ships three tutorials",
228        );
229        library
230    }
231
232    /// Every shipped tutorial is a card with the three things §8.3 asks a
233    /// library to show: what it is called, what is in it, and how long it is.
234    #[test]
235    fn the_library_reads_every_shipped_tutorial() {
236        for lesson in library().lessons() {
237            assert!(!lesson.title.is_empty(), "a lesson with no name");
238            assert!(
239                !lesson.chapters.is_empty(),
240                "{} has no chapter to navigate by",
241                lesson.title,
242            );
243            assert_eq!(
244                lesson.duration,
245                lesson.chapters.iter().map(|c| c.duration).sum::<Duration>(),
246                "{}'s length is not the sum of its chapters",
247                lesson.title,
248            );
249            assert!(
250                !lesson.teaches.is_empty(),
251                "{} does not say what it teaches",
252                lesson.title,
253            );
254        }
255    }
256
257    /// §8.3: *"Search matches chapter titles."* A word only one chapter uses
258    /// finds that chapter and nothing else, and the lesson it belongs to
259    /// comes with it so the result is placeable.
260    #[test]
261    fn the_search_matches_chapter_titles() {
262        let library = library();
263        let hits = library.matching("opposite");
264        let [(lesson, chapters)] = hits.as_slice() else {
265            panic!("\"opposite\" reached {} lessons, not one", hits.len());
266        };
267        assert_eq!(lesson.title, "first-block");
268        assert!(
269            chapters
270                .iter()
271                .all(|chapter| chapter.title.to_lowercase().contains("opposite")),
272            "the search offered chapters that do not match: {:?}",
273            chapters.iter().map(|c| &c.title).collect::<Vec<_>>(),
274        );
275        assert!(
276            chapters.len() < lesson.chapters.len(),
277            "precondition: the lesson has chapters the query does not reach",
278        );
279    }
280
281    /// A lesson found by its own name offers everything in it: the query
282    /// named the tutorial, not a place inside it.
283    #[test]
284    fn a_lesson_found_by_name_keeps_all_its_chapters() {
285        let library = library();
286        let hits = library.matching("first-route");
287        let [(lesson, chapters)] = hits.as_slice() else {
288            panic!("\"first-route\" reached {} lessons, not one", hits.len());
289        };
290        assert_eq!(chapters.len(), lesson.chapters.len());
291        assert_eq!(library.matching("").len(), 3, "an empty query is the shelf");
292        assert!(library.matching("zzzz").is_empty());
293    }
294
295    /// The `teaches/` tags reaching the two surfaces that consume them.
296    #[test]
297    fn a_lesson_is_found_by_the_tool_it_teaches() {
298        let library = library();
299        let taught: Vec<&str> = library
300            .teaching(ToolName::NewBlock)
301            .map(|lesson| lesson.title.as_str())
302            .collect();
303        assert_eq!(taught, ["first-block"]);
304        assert_eq!(library.teaching(ToolName::AddText).count(), 0);
305    }
306
307    /// Watched state: furthest reached, offered as a resume until the last
308    /// chapter, and then not offered at all.
309    #[test]
310    fn the_watched_list_offers_a_resume_until_the_end() {
311        let mut watched = Watched::default();
312        assert_eq!(watched.of("first-block", 4), Progress::Fresh);
313
314        watched.reached("first-block", 2);
315        assert_eq!(watched.of("first-block", 4), Progress::Resume(2));
316
317        watched.reached("first-block", 1);
318        assert_eq!(
319            watched.of("first-block", 4),
320            Progress::Resume(2),
321            "going back a chapter lost the viewer's place",
322        );
323
324        watched.reached("first-block", 3);
325        assert_eq!(watched.of("first-block", 4), Progress::Watched);
326        assert_eq!(watched.of("first-route", 5), Progress::Fresh);
327
328        watched.clear();
329        assert_eq!(watched.of("first-block", 4), Progress::Fresh);
330    }
331
332    /// The one spelling of a length, so three surfaces cannot word the same
333    /// number three ways.
334    #[test]
335    fn a_length_reads_as_minutes_and_seconds() {
336        assert_eq!(clock(Duration::ZERO), "0:00");
337        assert_eq!(clock(Duration::from_millis(9_900)), "0:09");
338        assert_eq!(clock(Duration::from_secs(65)), "1:05");
339        assert_eq!(clock(Duration::from_mins(10)), "10:00");
340    }
341
342    /// A rev lands in the chapter that opened at or before it. The converted
343    /// containers all open on a chapter (7·9: *"the opening chapter covers
344    /// the setup"*), so nothing in one falls outside every chapter — only a
345    /// rev before the log begins does.
346    #[test]
347    fn a_rev_belongs_to_the_chapter_that_opened_it() {
348        let library = library();
349        let lesson = library
350            .lessons()
351            .iter()
352            .find(|lesson| lesson.title == "first-block")
353            .expect("the first-block lesson");
354        assert!(
355            lesson.chapters.len() > 1,
356            "precondition: there is a second chapter to fall into",
357        );
358        assert_eq!(lesson.chapter_of(Rev::ZERO), None);
359        assert_eq!(lesson.chapter_of(lesson.chapters[0].at), Some(0));
360        assert_eq!(
361            lesson.chapter_of(lesson.chapters[1].at),
362            Some(1),
363            "a chapter's own opening rev belongs to it",
364        );
365        assert_eq!(
366            lesson.chapter_of(lesson.chapters[1].at.prev().expect("a rev before it")),
367            Some(0),
368            "the rev before a chapter opens still belongs to the one above it",
369        );
370        assert_eq!(lesson.chapter_at(1), Some(lesson.chapters[1].at));
371        assert_eq!(lesson.chapter_at(99), None);
372    }
373}