1#[cfg(not(target_arch = "wasm32"))]
17use crate::{
18 shell::glass,
19 tools::tool::{Action, Walkthrough},
20 tutorial::library::{Library, Progress, Watched, clock},
21};
22
23const LEAD: f32 = 12.0;
25
26#[cfg(not(target_arch = "wasm32"))]
28#[derive(Clone, Copy)]
29pub struct Learn<'a> {
30 pub library: &'a Library,
31 pub watched: &'a Watched,
34}
35
36#[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#[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 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#[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#[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#[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#[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#[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 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 #[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 #[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 #[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 #[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}