1use std::collections::HashMap;
7use yog_gfx::GfxContext;
8use yog_ui::{Align, FlexDir, Stretch, UiRoot, widget};
9
10use crate::state::BookViewState;
11use crate::theme::BookTheme;
12use crate::{Book, BookPage};
13
14pub const BOOK_TEX_W: f32 = 512.0;
18pub const BOOK_TEX_H: f32 = 256.0;
19pub const BK_W: f32 = 272.0;
21pub const BK_H: f32 = 180.0;
22pub const PAGE_W: f32 = 116.0;
24pub const PAGE_H: f32 = 156.0;
25pub const TOP_PAD: f32 = 18.0; pub const LEFT_X: f32 = 15.0; pub const RIGHT_X: f32 = 141.0; pub const SEP_U: f32 = 140.0;
30pub const SEP_V: f32 = 180.0;
31pub const SEP_W: f32 = 110.0;
32pub const SEP_H: f32 = 3.0;
33const SCREEN_MARGIN: f32 = 16.0;
35const MIN_SCALE: f32 = 0.4;
37
38pub static BOOK_PNG: &[u8] = include_bytes!("../assets/book_brown.png");
42pub static CRAFTING_PNG: &[u8] = include_bytes!("../assets/crafting.png");
44pub const BOOK_SHEET: &str = "yog:gui/book/book";
48pub const CRAFTING_SHEET: &str = "yog:gui/book/crafting";
50
51#[derive(Clone, Copy, PartialEq)]
54pub(crate) enum SpriteSheet {
55 Book,
56 Crafting,
57}
58
59#[derive(Clone)]
61pub(crate) struct BgSprite {
62 pub sheet: SpriteSheet,
63 pub u: f32,
65 pub v: f32,
66 pub uw: f32,
67 pub uh: f32,
68 pub x: f32,
70 pub y: f32,
71 pub w: f32,
72 pub h: f32,
73}
74
75#[derive(Clone)]
78pub(crate) enum OverlayCmd {
79 Svg {
80 data: String,
81 x: f32,
82 y: f32,
83 w: f32,
84 h: f32,
85 },
86 McItems {
89 items: Vec<String>,
90 x: f32,
91 y: f32,
92 w: f32,
93 h: f32,
94 },
95 Blocks {
97 blocks: Vec<((i32, i32, i32), String)>,
98 x: f32,
99 y: f32,
100 w: f32,
101 h: f32,
102 },
103 Entity {
105 entity: String,
106 x: f32,
107 y: f32,
108 w: f32,
109 h: f32,
110 },
111 McText {
113 text: String,
114 x: f32,
115 y: f32,
116 color: u32,
117 },
118 McItem {
120 item_id: String,
121 x: f32,
122 y: f32,
123 w: f32,
124 h: f32,
125 },
126 McTexture {
131 path: String,
132 x: f32,
133 y: f32,
134 w: f32,
135 h: f32,
136 },
137}
138
139fn push_icon(overlays: &mut Vec<OverlayCmd>, icon: &str, x: f32, y: f32, w: f32, h: f32) {
143 if icon.ends_with(".png") {
144 overlays.push(OverlayCmd::McTexture {
145 path: icon.to_string(),
146 x,
147 y,
148 w,
149 h,
150 });
151 } else {
152 overlays.push(OverlayCmd::McItem {
153 item_id: icon.to_string(),
154 x,
155 y,
156 w,
157 h,
158 });
159 }
160}
161
162#[derive(Clone)]
165pub(crate) enum RecipeVis {
166 Shaped {
167 grid: Vec<Vec<String>>,
169 width: usize,
170 output: String,
171 count: u32,
172 },
173 Shapeless {
174 ingredients: Vec<Vec<String>>,
175 output: String,
176 count: u32,
177 },
178 Smelting {
180 input: Vec<String>,
181 output: String,
182 station: String,
183 },
184}
185
186fn ingredient(v: &serde_json::Value) -> Vec<String> {
189 match v {
190 serde_json::Value::String(s) => vec![s.clone()],
191 serde_json::Value::Array(a) => a.iter().flat_map(ingredient).collect(),
192 _ => Vec::new(),
193 }
194}
195
196fn parse_recipe(json: &str) -> Option<RecipeVis> {
199 let v: serde_json::Value = serde_json::from_str(json).ok()?;
200 let output = v.get("result")?.as_str()?.to_owned();
201 let count = v.get("count").and_then(|c| c.as_u64()).unwrap_or(1) as u32;
202 let station = |s: &str| Some(s.to_owned());
203 match v.get("type")?.as_str()? {
204 "shaped" => {
205 let pattern: Vec<&str> = v.get("pattern")?.as_array()?.iter().filter_map(|p| p.as_str()).collect();
206 let width = pattern.iter().map(|r| r.chars().count()).max()?.max(1);
207 let key = v.get("key")?.as_object()?;
208 let mut grid = Vec::new();
209 for row in &pattern {
210 let mut chars: Vec<char> = row.chars().collect();
211 chars.resize(width, ' ');
212 for ch in chars {
213 grid.push(key.get(&ch.to_string()).map(ingredient).unwrap_or_default());
214 }
215 }
216 Some(RecipeVis::Shaped { grid, width, output, count })
217 }
218 "shapeless" => Some(RecipeVis::Shapeless {
219 ingredients: v.get("ingredients")?.as_array()?.iter().map(ingredient).collect(),
220 output,
221 count,
222 }),
223 kind @ ("smelting" | "blasting" | "smoking" | "campfire_cooking" | "stonecutting") => Some(RecipeVis::Smelting {
224 input: ingredient(v.get("ingredient")?),
225 output,
226 station: match kind {
227 "blasting" => station("minecraft:blast_furnace"),
228 "smoking" => station("minecraft:smoker"),
229 "campfire_cooking" => station("minecraft:campfire"),
230 "stonecutting" => station("minecraft:stonecutter"),
231 _ => station("minecraft:furnace"),
232 }?,
233 }),
234 _ => None,
235 }
236}
237
238fn normalize_item_id(id: &str) -> String {
241 let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
242 let name = path
243 .trim_start_matches("item/")
244 .trim_start_matches("block/");
245 format!("{ns}:{name}")
246}
247
248fn pretty_item_name(id: &str) -> String {
250 let name = id.rsplit(':').next().unwrap_or(id);
251 name.split('_')
252 .map(|w| {
253 let mut c = w.chars();
254 match c.next() {
255 Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
256 None => String::new(),
257 }
258 })
259 .collect::<Vec<_>>()
260 .join(" ")
261}
262
263pub struct BookRenderer {
266 pub book: Book,
267 pub state: BookViewState,
268 pub theme: BookTheme,
269 recipes: HashMap<String, RecipeVis>,
270 origins: HashMap<String, Vec<usize>>,
272 written: Book,
274 measured: bool,
276 start: std::time::Instant,
278 pub ui: Option<UiRoot>,
279 bg_sprites: Vec<BgSprite>,
280 overlays: Vec<OverlayCmd>,
281 dirty: bool,
282 last_sw: f32,
283 last_sh: f32,
284 source: Option<Book>,
287 translator: Option<fn(&str) -> Option<String>>,
288 probe: Option<Option<String>>,
289}
290
291impl BookRenderer {
292 pub fn new(book: Book) -> Self {
293 let theme = BookTheme::default().with_nameplate(&book.nameplate_color);
294 let written = book.clone();
295 let (book, origins) = flow_traced(book);
296 Self {
297 book,
298 origins,
299 written,
300 measured: false,
301 start: std::time::Instant::now(),
302 state: BookViewState::default(),
303 theme,
304 recipes: HashMap::new(),
305 ui: None,
306 bg_sprites: Vec::new(),
307 overlays: Vec::new(),
308 dirty: true,
309 last_sw: 0.0,
310 last_sh: 0.0,
311 source: None,
312 translator: None,
313 probe: None,
314 }
315 }
316
317 pub fn set_translator(&mut self, tr: fn(&str) -> Option<String>) {
321 if self.book.i18n {
322 self.source.get_or_insert_with(|| self.book.clone());
323 self.translator = Some(tr);
325 self.probe = None;
326 }
327 }
328
329 fn localize(&mut self) {
331 let (Some(src), Some(tr)) = (&self.source, self.translator) else {
332 return;
333 };
334 let probe = Some(tr(&src.name));
335 if probe == self.probe {
336 return;
337 }
338 self.written = src.localized(|k| tr(k).unwrap_or_else(|| k.to_string()));
339 (self.book, self.origins) = flow_traced(self.written.clone());
340 self.probe = probe;
341 self.dirty = true;
342 }
343
344 pub fn handle_event(&mut self, ev: &str) {
345 if self.state.handle(ev, &self.book) {
346 self.dirty = true;
347 }
348 }
349
350 pub fn add_recipe(&mut self, id: impl Into<String>, json: &str) {
353 if let Some(vis) = parse_recipe(json) {
354 self.recipes.insert(id.into(), vis);
355 self.dirty = true;
356 }
357 }
358
359 fn prepare(&mut self, sw: f32, sh: f32) -> (f32, f32, f32, f32) {
362 let scale = ((sw - SCREEN_MARGIN) / BK_W)
365 .min((sh - SCREEN_MARGIN) / BK_H)
366 .clamp(MIN_SCALE, 1.0);
367 let bw = BK_W * scale;
368 let bh = BK_H * scale;
369 let bx = (sw - bw) / 2.0;
370 let by = (sh - bh) / 2.0;
371
372 self.localize();
373 if self.dirty || sw != self.last_sw || sh != self.last_sh {
375 let (root, bg_sprites, overlays) = build_ui(
376 &self.book,
377 &self.state,
378 &self.theme,
379 &self.recipes,
380 bx,
381 by,
382 bw,
383 bh,
384 );
385 self.ui = Some(root);
386 self.bg_sprites = bg_sprites;
387 self.overlays = overlays;
388 self.dirty = false;
389 self.last_sw = sw;
390 self.last_sh = sh;
391 }
392 if let Some(ui) = &mut self.ui
393 && ui.needs_layout
394 {
395 ui.layout(sw, sh);
396 }
397 (bx, by, bw, bh)
398 }
399
400 pub fn render(&mut self, ctx: &GfxContext, sw: f32, sh: f32) {
404 yog_ui::text::use_host_metrics(ctx);
406 if !self.measured {
407 self.measured = true;
408 self.reflow();
409 }
410 let (bx, by, bw, bh) = self.prepare(sw, sh);
411 let d2 = ctx.draw2d();
412 d2.image_region(BOOK_SHEET, bx, by, bw, bh, [0.0, 0.0, BK_W, BK_H], 0xFFFF_FFFF);
413 for spr in &self.bg_sprites {
414 let sheet = match spr.sheet {
415 SpriteSheet::Book => BOOK_SHEET,
416 SpriteSheet::Crafting => CRAFTING_SHEET,
417 };
418 d2.image_region(sheet, spr.x, spr.y, spr.w, spr.h, [spr.u, spr.v, spr.uw, spr.uh], 0xFFFF_FFFF);
419 }
420 if let Some(ui) = &mut self.ui {
421 ui.render(ctx);
422 }
423 let t = self.start.elapsed().as_secs_f32();
426 for ov in &self.overlays {
427 match *ov {
428 OverlayCmd::Svg { ref data, x, y, w, h } => d2.svg(data, x, y, w, h),
429 OverlayCmd::Entity { ref entity, x, y, w, h } => d2.entity(entity, x, y, w, h, -30.0 + t * 30.0, 0.0),
430 OverlayCmd::Blocks { ref blocks, x, y, w, h } => {
431 let list: Vec<((i32, i32, i32), &str)> = blocks.iter().map(|(p, b)| (*p, b.as_str())).collect();
432 d2.blocks(&list, x, y, w, h, -45.0 + t * 20.0, 30.0);
433 }
434 _ => {}
435 }
436 }
437 self.draw_mc_overlays(ctx);
439 }
440
441 fn draw_mc_overlays(&self, ctx: &GfxContext) {
442 let second = self.start.elapsed().as_secs() as usize;
443 for ov in &self.overlays {
444 match *ov {
445 OverlayCmd::McItems { ref items, x, y, w, h } => {
446 let shown: Vec<&String> = items.iter().filter(|i| !i.starts_with('#')).collect();
447 if !shown.is_empty() {
448 let item = shown[second % shown.len()];
449 ctx.draw2d().item(&normalize_item_id(item), x, y, w.min(h));
450 }
451 }
452 OverlayCmd::McItem { ref item_id, x, y, w, h } => {
453 ctx.draw2d().item(&normalize_item_id(item_id), x, y, w.min(h));
454 }
455 OverlayCmd::McText { ref text, x, y, color } => {
456 ctx.draw2d().text(text, x, y, color, false);
457 }
458 OverlayCmd::McTexture { ref path, x, y, w, h } => {
459 ctx.draw2d().image(path, x, y, w, h);
462 }
463 _ => {}
464 }
465 }
466 }
467
468 pub fn animated(&self) -> bool {
471 self.overlays.iter().any(|o| match o {
472 OverlayCmd::Entity { .. } | OverlayCmd::Blocks { .. } => true,
473 OverlayCmd::McItems { items, .. } => items.iter().filter(|i| !i.starts_with('#')).count() > 1,
474 _ => false,
475 })
476 }
477
478 pub fn click(&mut self, x: f32, y: f32) -> Option<String> {
482 let ui = self.ui.as_ref()?;
483 let hit = yog_ui::layout::hit_test(&ui.layout_root, x, y)?;
484 let action = hit.on_click.clone()?;
485 self.handle_event(&action);
486 Some(action)
487 }
488
489 pub fn show(&mut self, entry: &str, page: usize) -> bool {
493 let Some(e) = self.book.entries.iter().find(|e| e.id == entry) else { return false };
494 let Some(cat) = self.book.categories.iter().position(|c| c.id == e.category) else { return false };
495 self.state.cat = cat;
496 self.state.at_home = false;
497 let in_cat = self.state.entries_in_cat(&self.book);
498 let Some(i) = in_cat.iter().position(|x| x.id == entry) else { return false };
499 let shown = self.origins.get(entry).and_then(|o| o.iter().position(|&w| w >= page)).unwrap_or(page);
500 self.state.entry = i;
501 self.state.page = shown.min(e.pages.len().saturating_sub(1));
502 self.dirty = true;
503 true
504 }
505
506 pub fn shown(&self) -> Option<(String, usize)> {
509 if self.state.at_home {
510 return None;
511 }
512 let e = self.state.current_entry(&self.book)?;
513 let page = self.origins.get(&e.id).and_then(|o| o.get(self.state.page)).copied().unwrap_or(self.state.page);
514 Some((e.id.clone(), page))
515 }
516
517 fn reflow(&mut self) {
519 let at = self.shown();
520 (self.book, self.origins) = flow_traced(self.written.clone());
521 self.dirty = true;
522 if let Some((entry, page)) = at {
523 self.show(&entry, page);
524 }
525 }
526
527 pub fn set_book(&mut self, book: Book) {
529 let at = self.shown();
530 self.written = book.clone();
531 (self.book, self.origins) = flow_traced(book);
532 self.theme = BookTheme::default().with_nameplate(&self.book.nameplate_color);
533 self.dirty = true;
534 match at {
535 Some((entry, page)) if self.show(&entry, page) => {}
536 _ => {
537 if self.state.cat >= self.book.categories.len() {
538 self.state = BookViewState::default();
539 }
540 }
541 }
542 }
543}
544
545thread_local! {
548 static TEXT_SCALE: std::cell::Cell<f32> = const { std::cell::Cell::new(1.0) };
551}
552
553fn text_scale() -> f32 {
554 TEXT_SCALE.with(|s| s.get())
555}
556
557fn lbl(text: impl Into<String>) -> widget::Widget {
559 widget::label(text).shadow(false).font_scale(text_scale())
560}
561fn btn(text: impl Into<String>) -> widget::Widget {
563 widget::button(text).shadow(false).font_scale(text_scale())
564}
565
566fn title_lbl(text: impl Into<String>) -> widget::Widget {
569 let text = text.into();
570 let fit = ((PAGE_W - 6.0) / yog_ui::text::str_width(&text, 1.0)).clamp(0.5, 1.0);
571 widget::label(text)
573 .shadow(false)
574 .font_scale(text_scale() * fit)
575 .no_wrap()
576 .ellipsis()
577 .w((PAGE_W - 6.0) * text_scale())
578}
579
580const TEXT_W: f32 = PAGE_W - 8.0;
584const BODY_H: f32 = PAGE_H - 22.0 - 12.0 - 14.0;
587
588fn content_above_text(page: &BookPage) -> Option<f32> {
590 Some(match page {
591 BookPage::Text { title: Some(_), .. } => 22.0,
592 BookPage::Text { .. } => 0.0,
593 BookPage::Spotlight { .. } => 40.0,
594 BookPage::Crafting { .. } => 80.0,
595 BookPage::Smelting { .. } => 40.0,
596 BookPage::Image { .. } | BookPage::Entity { .. } => 104.0,
597 BookPage::Multiblock { .. } => 114.0,
598 BookPage::Relations { entries, .. } => 12.0 + entries.len() as f32 * 10.0,
599 _ => return None,
600 })
601}
602
603fn page_text_mut(page: &mut BookPage) -> Option<&mut String> {
604 match page {
605 BookPage::Text { text, .. } => Some(text),
606 BookPage::Spotlight { text, .. }
607 | BookPage::Crafting { text, .. }
608 | BookPage::Smelting { text, .. }
609 | BookPage::Image { text, .. }
610 | BookPage::Entity { text, .. }
611 | BookPage::Multiblock { text, .. }
612 | BookPage::Relations { text, .. } => text.as_mut(),
613 _ => None,
614 }
615}
616
617#[cfg(test)]
619fn flow(book: Book) -> Book {
620 flow_traced(book).0
621}
622
623fn flow_traced(mut book: Book) -> (Book, HashMap<String, Vec<usize>>) {
626 let mut origins = HashMap::new();
627 let line_h = yog_ui::text::LINE_H + yog_ui::text::LINE_GAP;
628 let fits = |h: f32| (((h + yog_ui::text::LINE_GAP) / line_h).floor() as usize).max(1);
629 for entry in &mut book.entries {
630 let mut pages = Vec::with_capacity(entry.pages.len());
631 let mut from = Vec::with_capacity(entry.pages.len());
632 for (written, page) in std::mem::take(&mut entry.pages).into_iter().enumerate() {
633 let split = split_page(page, &fits);
634 from.extend(std::iter::repeat_n(written, split.len()));
635 pages.extend(split);
636 }
637 entry.pages = pages;
638 origins.insert(entry.id.clone(), from);
639 }
640 (book, origins)
641}
642
643fn split_page(mut page: BookPage, fits: &dyn Fn(f32) -> usize) -> Vec<BookPage> {
645 let Some(above) = content_above_text(&page) else { return vec![page] };
646 let Some(text) = page_text_mut(&mut page) else { return vec![page] };
647 let lines: Vec<String> = text
648 .split('\n')
649 .flat_map(|para| if para.is_empty() { vec![String::new()] } else { yog_ui::text::wrap_text(para, TEXT_W, 1.0) })
650 .collect();
651 let first = fits(BODY_H - above);
652 if lines.len() <= first {
653 return vec![page];
654 }
655 *text = lines[..first].join("\n");
656 let rest: Vec<BookPage> = lines[first..]
657 .chunks(fits(BODY_H))
658 .map(|chunk| BookPage::Text { text: chunk.join("\n"), title: None })
659 .collect();
660 std::iter::once(page).chain(rest).collect()
661}
662
663fn build_ui(
666 book: &Book,
667 state: &BookViewState,
668 theme: &BookTheme,
669 recipes: &HashMap<String, RecipeVis>,
670 bx: f32,
671 by: f32,
672 bw: f32,
673 bh: f32,
674) -> (UiRoot, Vec<BgSprite>, Vec<OverlayCmd>) {
675 let mut bg_sprites: Vec<BgSprite> = Vec::new();
676 let mut overlays: Vec<OverlayCmd> = Vec::new();
677
678 let sx = bw / BK_W;
680 TEXT_SCALE.with(|s| s.set(sx));
681 let sy = bh / BK_H;
682
683 let pw = PAGE_W * sx;
685 let ph = PAGE_H * sy;
686 let spine_gap = (RIGHT_X - LEFT_X - PAGE_W) * sx; let lx = bx + LEFT_X * sx;
690 let rx = bx + RIGHT_X * sx;
691 let py = by + TOP_PAD * sy;
692
693 let sep_cx = (PAGE_W / 2.0 - SEP_W / 2.0) * sx; if state.at_home {
697 bg_sprites.push(BgSprite {
699 sheet: SpriteSheet::Book,
700 u: 0.0,
701 v: 180.0,
702 uw: 140.0,
703 uh: 31.0,
704 x: bx + (LEFT_X - 8.0) * sx,
705 y: by + 12.0 * sy,
706 w: 140.0 * sx,
707 h: 31.0 * sy,
708 });
709 }
710 bg_sprites.push(BgSprite {
713 sheet: SpriteSheet::Book,
714 u: SEP_U,
715 v: SEP_V,
716 uw: SEP_W,
717 uh: SEP_H,
718 x: rx + sep_cx,
719 y: by + (TOP_PAD + 12.0) * sy,
720 w: SEP_W * sx,
721 h: (SEP_H * sy).max(1.0),
722 });
723
724 let (left_page, right_page) = if state.at_home {
725 let l = build_landing_left(book, theme, pw, ph, sx, sy, bx, by, &mut overlays);
726 let r = build_categories_right(book, state, theme, pw, ph, rx, py, sx, sy, &mut overlays);
727 (l, r)
728 } else {
729 let l = build_entry_left(
730 book,
731 state,
732 theme,
733 recipes,
734 pw,
735 ph,
736 lx,
737 py,
738 sx,
739 sy,
740 sep_cx,
741 &mut bg_sprites,
742 &mut overlays,
743 );
744 let r = build_entries_right(book, state, theme, pw, ph, rx, py, sx, sy, &mut overlays);
745 (l, r)
746 };
747
748 let root_widget = widget::panel(FlexDir::Row)
756 .w(bw)
757 .h(bh)
758 .gap(0.0)
759 .padding(TOP_PAD * sy, 0.0, 0.0, LEFT_X * sx)
760 .child(left_page)
761 .child(widget::spacer().w(spine_gap)) .child(right_page);
763
764 let outer = widget::panel(FlexDir::Column)
767 .w(bw)
768 .h(bh)
769 .child(root_widget);
770
771 let screen_root = widget::panel(FlexDir::Row)
776 .padding(by, 0.0, 0.0, bx)
777 .child(outer);
778
779 let ui = UiRoot::new(&book.id, screen_root);
780 (ui, bg_sprites, overlays)
781}
782
783fn build_landing_left(
786 book: &Book,
787 theme: &BookTheme,
788 page_w: f32,
789 page_h: f32,
790 sx: f32,
791 sy: f32,
792 bx: f32,
793 by: f32,
794 overlays: &mut Vec<OverlayCmd>,
795) -> widget::Widget {
796 overlays.push(OverlayCmd::McText {
799 text: book.name.clone(),
800 x: bx + 13.0 * sx,
801 y: by + 16.0 * sy,
802 color: theme.nameplate,
803 });
804 if let Some(author) = &book.author {
805 overlays.push(OverlayCmd::McText {
806 text: format!("by {}", author),
807 x: bx + 24.0 * sx,
808 y: by + 24.0 * sy,
809 color: theme.nameplate,
810 });
811 }
812
813 let mut col = widget::panel(FlexDir::Column)
817 .w(page_w)
818 .h(page_h)
819 .padding(25.0 * sy, 6.0, 4.0, 4.0)
820 .gap(0.0);
821
822 for para in book.landing_text.split('\n') {
823 if para.is_empty() {
824 col = col.child(widget::spacer().h(4.0 * sy));
825 } else {
826 col = col.child(lbl(para).color(theme.text));
827 }
828 }
829
830 let total = book.entries.len();
831 col = col.child(widget::spacer().flex(1.0));
832 col = col.child(
833 title_lbl(format!("{} entries", total))
834 .color(theme.nav)
835 .h(9.0 * sy)
836 .align(Align::Center),
837 );
838 col
839}
840
841fn build_categories_right(
844 book: &Book,
845 state: &BookViewState,
846 theme: &BookTheme,
847 page_w: f32,
848 page_h: f32,
849 rx: f32,
850 py: f32,
851 sx: f32,
852 sy: f32,
853 overlays: &mut Vec<OverlayCmd>,
854) -> widget::Widget {
855 let cell_w = 24.0 * sx;
860 let cell_h = 24.0 * sy;
861 let grid_pad_left = 10.0 * sx;
862 let header_h = 9.0 * sy;
863
864 let mut col = widget::panel(FlexDir::Column)
865 .w(page_w)
866 .h(page_h)
867 .padding(0.0, 4.0, 4.0, 0.0)
868 .gap(0.0);
869
870 col = col.child(
871 title_lbl("Categories")
872 .color(theme.divider)
873 .h(header_h)
874 .align(Align::Center)
875 .no_wrap(),
876 );
877 col = col.child(widget::spacer().h(25.0 * sy - header_h));
879
880 let cats = &book.categories;
882 let icon_s = 16.0 * sx.min(sy);
883 let mut row_i = 0usize;
884 loop {
885 let row_start = row_i * 4;
886 if row_start >= cats.len() {
887 break;
888 }
889 let mut row =
890 widget::panel(FlexDir::Row)
891 .h(cell_h)
892 .gap(0.0)
893 .padding(0.0, 0.0, 0.0, grid_pad_left);
894 for col_i in 0..4 {
895 let idx = row_start + col_i;
896 if idx >= cats.len() {
897 break;
898 }
899 let cat = &cats[idx];
900 let selected = !state.at_home && idx == state.cat;
901 let bg = if selected { theme.nav_selected_bg } else { 0 };
902
903 if let Some(icon_id) = &cat.icon {
904 push_icon(
905 overlays,
906 icon_id,
907 rx + (10.0 + col_i as f32 * 24.0 + 4.0) * sx,
908 py + (25.0 + row_i as f32 * 24.0 + 4.0) * sy,
909 icon_s,
910 icon_s,
911 );
912 }
913 let cell = widget::panel(FlexDir::Column)
914 .w(cell_w)
915 .h(cell_h)
916 .bg(bg)
917 .on_click(format!("cat:{}", idx))
918 .id(format!("book_cat_{}", idx));
919 row = row.child(cell);
920 }
921 col = col.child(row);
922 row_i += 1;
923 }
924 col
925}
926
927fn build_entry_left(
930 book: &Book,
931 state: &BookViewState,
932 theme: &BookTheme,
933 recipes: &HashMap<String, RecipeVis>,
934 page_w: f32,
935 page_h: f32,
936 ox: f32,
937 oy: f32,
938 sx: f32,
939 sy: f32,
940 sep_cx: f32,
941 bg_sprites: &mut Vec<BgSprite>,
942 overlays: &mut Vec<OverlayCmd>,
943) -> widget::Widget {
944 let entry = state.current_entry(book);
945 let page = entry.and_then(|e| e.pages.get(state.page));
946 let page_count = state.page_count(book);
947 let title_text = entry.map(|e| e.name.as_str()).unwrap_or("");
948
949 let title_h = 9.0 * sy;
954 let sep_h_px = (SEP_H * sy).max(1.0);
955 let sep_y = oy + 12.0 * sy;
956 let body_oy = oy + 22.0 * sy;
957
958 bg_sprites.push(BgSprite {
959 sheet: SpriteSheet::Book,
960 u: SEP_U,
961 v: SEP_V,
962 uw: SEP_W,
963 uh: SEP_H,
964 x: ox + sep_cx,
965 y: sep_y,
966 w: SEP_W * sx,
967 h: sep_h_px,
968 });
969
970 let page_label = format!("{}/{}", state.page + 1, page_count);
971 let nav = widget::panel(FlexDir::Row)
972 .h(12.0 * sy)
973 .gap(4.0)
974 .padding(0.0, 2.0, 0.0, 2.0)
975 .child(
976 btn("◀")
977 .w(12.0 * sx)
978 .h(12.0 * sy)
979 .color(theme.nav)
980 .on_click("prev_page")
981 .id("prev_page"),
982 )
983 .child(
984 lbl(&page_label)
985 .color(theme.nav)
986 .flex(1.0)
987 .align(Align::Center),
988 )
989 .child(
990 btn("⌂")
991 .w(12.0 * sx)
992 .h(12.0 * sy)
993 .color(theme.nav)
994 .on_click("home")
995 .id("book_home"),
996 )
997 .child(
998 btn("▶")
999 .w(12.0 * sx)
1000 .h(12.0 * sy)
1001 .color(theme.nav)
1002 .on_click("next_page")
1003 .id("next_page"),
1004 );
1005
1006 let page_body = build_page(
1007 page, state.page, theme, recipes, bg_sprites, overlays, ox, body_oy, sx, sy,
1008 );
1009
1010 widget::panel(FlexDir::Column)
1011 .w(page_w)
1012 .h(page_h)
1013 .padding(0.0, 6.0, 4.0, 4.0)
1014 .gap(0.0)
1015 .child(
1016 title_lbl(title_text)
1017 .color(theme.title)
1018 .h(title_h)
1019 .align(Align::Center)
1020 .no_wrap(),
1021 )
1022 .child(widget::spacer().h((12.0 - 9.0) * sy)) .child(widget::spacer().h(sep_h_px)) .child(widget::spacer().h((22.0 - 12.0 - SEP_H) * sy)) .child(page_body)
1026 .child(nav)
1027}
1028
1029fn build_entries_right(
1032 book: &Book,
1033 state: &BookViewState,
1034 theme: &BookTheme,
1035 page_w: f32,
1036 page_h: f32,
1037 rx: f32,
1038 py: f32,
1039 sx: f32,
1040 sy: f32,
1041 overlays: &mut Vec<OverlayCmd>,
1042) -> widget::Widget {
1043 let entries = state.entries_visible(book);
1044 let cat_name = book
1045 .categories
1046 .get(state.cat)
1047 .map(|c| c.name.as_str())
1048 .unwrap_or("Entries");
1049 let spread_count = state.list_spread_count(book);
1050
1051 let header_h = 9.0 * sy;
1056 let row_h = 11.0 * sy;
1057 let icon_size = 8.0 * sx.min(sy);
1058
1059 let mut col = widget::panel(FlexDir::Column)
1060 .w(page_w)
1061 .h(page_h)
1062 .padding(0.0, 4.0, 4.0, 0.0)
1063 .gap(0.0);
1064
1065 col = col.child(
1066 title_lbl(cat_name)
1067 .color(theme.divider)
1068 .h(header_h)
1069 .align(Align::Center),
1070 );
1071 col = col.child(widget::spacer().h((12.0 - 9.0) * sy)); col = col.child(widget::spacer().h((SEP_H * sy).max(1.0))); col = col.child(widget::spacer().h((20.0 - 12.0 - SEP_H) * sy)); let abs_start = state.list_spread_start();
1077 for (i, entry) in entries.iter().enumerate() {
1078 let abs_i = abs_start + i;
1079 let selected = abs_i == state.entry;
1080 let bg = if selected { theme.nav_selected_bg } else { 0 };
1081 let color = if selected {
1082 theme.nav_selected
1083 } else {
1084 theme.nav
1085 };
1086
1087 if let Some(icon_id) = &entry.icon {
1090 push_icon(
1091 overlays,
1092 icon_id,
1093 rx + 1.0 * sx,
1094 py + (20.0 + i as f32 * 11.0 + 1.0) * sy,
1095 icon_size,
1096 icon_size,
1097 );
1098 }
1099
1100 let mut row = widget::panel(FlexDir::Row)
1101 .h(row_h)
1102 .gap(2.0)
1103 .bg(bg)
1104 .on_click(format!("entry:{}", abs_i))
1105 .id(format!("book_entry_{}", abs_i))
1106 .padding(1.0, 2.0, 1.0, 2.0);
1107
1108 row = row.child(widget::spacer().w(icon_size + 2.0));
1109 row = row.child(lbl(&entry.name).color(color).flex(1.0).no_wrap());
1110 col = col.child(row);
1111 }
1112
1113 if spread_count > 1 {
1114 col = col.child(widget::spacer().flex(1.0));
1115 let spread_label = format!("{}/{}", state.list_spread + 1, spread_count);
1116 col = col.child(
1117 widget::panel(FlexDir::Row)
1118 .h(12.0 * sy)
1119 .gap(2.0)
1120 .child(
1121 btn("◀")
1122 .w(12.0 * sx)
1123 .h(12.0 * sy)
1124 .color(theme.nav)
1125 .on_click("prev_list")
1126 .id("prev_list"),
1127 )
1128 .child(
1129 lbl(&spread_label)
1130 .color(theme.nav)
1131 .flex(1.0)
1132 .align(Align::Center),
1133 )
1134 .child(
1135 btn("▶")
1136 .w(12.0 * sx)
1137 .h(12.0 * sy)
1138 .color(theme.nav)
1139 .on_click("next_list")
1140 .id("next_list"),
1141 ),
1142 );
1143 }
1144 col
1145}
1146
1147#[allow(clippy::too_many_arguments)]
1151fn draw_crafting_grid(
1152 theme: &BookTheme,
1153 bg_sprites: &mut Vec<BgSprite>,
1154 overlays: &mut Vec<OverlayCmd>,
1155 col: &mut widget::Widget,
1156 grid: &[Vec<String>],
1157 width: usize,
1158 output: &str,
1159 count: u32,
1160 shapeless: bool,
1161 ox: f32,
1162 oy: f32,
1163 rx0: f32,
1164 ry: f32,
1165 sx: f32,
1166 sy: f32,
1167) {
1168 let mut c = std::mem::replace(col, widget::panel(FlexDir::Column));
1169 c = c.child(
1170 title_lbl(pretty_item_name(output))
1171 .color(theme.title)
1172 .h(10.0 * sy)
1173 .align(Align::Center),
1174 );
1175
1176 bg_sprites.push(BgSprite {
1178 sheet: SpriteSheet::Crafting,
1179 u: 0.0,
1180 v: 0.0,
1181 uw: 100.0,
1182 uh: 62.0,
1183 x: ox + (rx0 - 2.0) * sx,
1184 y: oy + (ry - 2.0) * sy,
1185 w: 100.0 * sx,
1186 h: 62.0 * sy,
1187 });
1188 if shapeless {
1190 bg_sprites.push(BgSprite {
1191 sheet: SpriteSheet::Crafting,
1192 u: 0.0,
1193 v: 64.0,
1194 uw: 11.0,
1195 uh: 11.0,
1196 x: ox + (rx0 + 62.0) * sx,
1197 y: oy + (ry + 2.0) * sy,
1198 w: 11.0 * sx,
1199 h: 11.0 * sy,
1200 });
1201 }
1202
1203 let icon_s = 16.0 * sx.min(sy);
1204 let width = width.max(1);
1205 for (i, slot) in grid.iter().enumerate() {
1206 if !slot.is_empty() {
1207 overlays.push(OverlayCmd::McItems {
1208 items: slot.clone(),
1209 x: ox + (rx0 + 3.0 + (i % width) as f32 * 19.0) * sx,
1210 y: oy + (ry + 3.0 + (i / width) as f32 * 19.0) * sy,
1211 w: icon_s,
1212 h: icon_s,
1213 });
1214 }
1215 }
1216 overlays.push(OverlayCmd::McItem {
1218 item_id: output.to_owned(),
1219 x: ox + (rx0 + 79.0) * sx,
1220 y: oy + (ry + 22.0) * sy,
1221 w: icon_s,
1222 h: icon_s,
1223 });
1224 if count > 1 {
1225 overlays.push(OverlayCmd::McText {
1226 text: format!("x{}", count),
1227 x: ox + (rx0 + 81.0) * sx,
1228 y: oy + (ry + 40.0) * sy,
1229 color: theme.title,
1230 });
1231 }
1232 overlays.push(OverlayCmd::McItem {
1234 item_id: "minecraft:crafting_table".into(),
1235 x: ox + (rx0 + 79.0) * sx,
1236 y: oy + (ry + 41.0) * sy,
1237 w: icon_s,
1238 h: icon_s,
1239 });
1240
1241 c = c.child(widget::spacer().h((ry + 62.0 + 4.0 - 10.0) * sy));
1243 *col = c;
1244}
1245
1246fn build_page(
1252 page: Option<&BookPage>,
1253 page_num: usize,
1254 theme: &BookTheme,
1255 recipes: &HashMap<String, RecipeVis>,
1256 bg_sprites: &mut Vec<BgSprite>,
1257 overlays: &mut Vec<OverlayCmd>,
1258 ox: f32,
1259 oy: f32,
1260 sx: f32,
1261 sy: f32,
1262) -> widget::Widget {
1263 let mut col = widget::panel(FlexDir::Column).flex(1.0).gap(4.0);
1264
1265 let Some(page) = page else {
1266 return col.child(lbl("No entries yet.").color(theme.nav));
1267 };
1268
1269 match page {
1270 BookPage::Text { text, title } => {
1271 if page_num > 0
1273 && let Some(t) = title
1274 {
1275 let sep_h_px = (SEP_H * sy).max(1.0);
1276 let title_h = 9.0 * sy;
1277 col = col.child(
1278 title_lbl(t.as_str())
1279 .color(theme.title)
1280 .h(title_h)
1281 .align(Align::Center)
1282 .no_wrap(),
1283 );
1284 bg_sprites.push(BgSprite {
1286 sheet: SpriteSheet::Book,
1287 u: SEP_U,
1288 v: SEP_V,
1289 uw: SEP_W,
1290 uh: SEP_H,
1291 x: ox + (PAGE_W / 2.0 - SEP_W / 2.0) * sx,
1292 y: oy + 12.0 * sy,
1293 w: SEP_W * sx,
1294 h: sep_h_px,
1295 });
1296 col = col.child(widget::spacer().h((12.0 - 9.0) * sy));
1297 col = col.child(widget::spacer().h(sep_h_px));
1298 col = col.child(widget::spacer().h((22.0 - 12.0 - SEP_H) * sy));
1299 }
1300 col = col.child(lbl(text.as_str()).color(theme.text));
1302 }
1303
1304 BookPage::Spotlight { item, title, text } => {
1305 bg_sprites.push(BgSprite {
1310 sheet: SpriteSheet::Crafting,
1311 u: 0.0,
1312 v: 102.0,
1313 uw: 66.0,
1314 uh: 26.0,
1315 x: ox + (PAGE_W / 2.0 - 33.0) * sx,
1316 y: oy + 10.0 * sy,
1317 w: 66.0 * sx,
1318 h: 26.0 * sy,
1319 });
1320
1321 let item_name = title
1322 .as_deref()
1323 .or(item.name.as_deref())
1324 .unwrap_or(item.id.as_str());
1325 col = col.child(
1326 title_lbl(item_name)
1327 .color(theme.title)
1328 .h(10.0 * sy)
1329 .align(Align::Center),
1330 );
1331
1332 let icon_s = 16.0 * sx.min(sy);
1333 overlays.push(OverlayCmd::McItem {
1334 item_id: item.id.clone(),
1335 x: ox + (PAGE_W / 2.0 - 8.0) * sx,
1336 y: oy + 15.0 * sy,
1337 w: icon_s,
1338 h: icon_s,
1339 });
1340
1341 col = col.child(widget::spacer().h(30.0 * sy));
1343
1344 if let Some(t) = text {
1345 col = col.child(lbl(t.as_str()).color(theme.text));
1346 }
1347 }
1348
1349 BookPage::Crafting { recipe_id, text } => {
1350 let rx0 = PAGE_W / 2.0 - 49.0; let ry = 12.0; match recipes.get(recipe_id.as_str()) {
1355 Some(RecipeVis::Shaped {
1356 grid,
1357 width,
1358 output,
1359 count,
1360 }) => {
1361 draw_crafting_grid(
1362 theme, bg_sprites, overlays, &mut col, grid, *width, output, *count, false,
1363 ox, oy, rx0, ry, sx, sy,
1364 );
1365 }
1366 Some(RecipeVis::Shapeless {
1367 ingredients,
1368 output,
1369 count,
1370 }) => {
1371 draw_crafting_grid(
1372 theme, bg_sprites, overlays, &mut col, ingredients, 3, output, *count, true, ox,
1373 oy, rx0, ry, sx, sy,
1374 );
1375 }
1376 _ => {
1377 col = col.child(lbl(format!("[Recipe: {}]", recipe_id)).color(theme.nav));
1378 }
1379 }
1380 if let Some(t) = text {
1381 col = col.child(lbl(t.as_str()).color(theme.text));
1382 }
1383 }
1384
1385 BookPage::Smelting { recipe_id, text } => {
1386 let rx0 = PAGE_W / 2.0 - 49.0;
1389 let ry = 12.0;
1390 match recipes.get(recipe_id.as_str()) {
1391 Some(RecipeVis::Smelting { input, output, station }) => {
1392 col = col.child(
1393 title_lbl(pretty_item_name(output))
1394 .color(theme.title)
1395 .h(10.0 * sy)
1396 .align(Align::Center),
1397 );
1398 bg_sprites.push(BgSprite {
1399 sheet: SpriteSheet::Crafting,
1400 u: 11.0,
1401 v: 71.0,
1402 uw: 96.0,
1403 uh: 24.0,
1404 x: ox + rx0 * sx,
1405 y: oy + ry * sy,
1406 w: 96.0 * sx,
1407 h: 24.0 * sy,
1408 });
1409 let icon_s = 16.0 * sx.min(sy);
1410 overlays.push(OverlayCmd::McItems {
1411 items: input.clone(),
1412 x: ox + (rx0 + 4.0) * sx,
1413 y: oy + (ry + 4.0) * sy,
1414 w: icon_s,
1415 h: icon_s,
1416 });
1417 overlays.push(OverlayCmd::McItem {
1419 item_id: station.clone(),
1420 x: ox + (rx0 + 40.0) * sx,
1421 y: oy + (ry + 4.0) * sy,
1422 w: icon_s,
1423 h: icon_s,
1424 });
1425 overlays.push(OverlayCmd::McItem {
1426 item_id: output.clone(),
1427 x: ox + (rx0 + 76.0) * sx,
1428 y: oy + (ry + 4.0) * sy,
1429 w: icon_s,
1430 h: icon_s,
1431 });
1432 col = col.child(widget::spacer().h((ry + 24.0 + 4.0 - 10.0) * sy));
1434 }
1435 _ => {
1436 col = col.child(lbl(format!("[Recipe: {}]", recipe_id)).color(theme.nav));
1437 }
1438 }
1439 if let Some(t) = text {
1440 col = col.child(lbl(t.as_str()).color(theme.text));
1441 }
1442 }
1443
1444 BookPage::Image {
1445 texture,
1446 title,
1447 text,
1448 ..
1449 } => {
1450 if let Some(t) = title {
1451 col = col.child(title_lbl(t.as_str()).color(theme.title));
1452 }
1453 col = col.child(
1454 widget::image(texture)
1455 .region([0.0, 0.0, 80.0, 80.0])
1456 .stretch(Stretch::Fill)
1457 .w(80.0)
1458 .h(80.0),
1459 );
1460 if let Some(t) = text {
1461 col = col.child(lbl(t.as_str()).color(theme.text));
1462 }
1463 }
1464
1465 BookPage::Svg { data, title, text } => {
1466 let top = if let Some(t) = title {
1467 col = col.child(title_lbl(t.as_str()).color(theme.title));
1468 12.0
1469 } else {
1470 0.0
1471 };
1472 overlays.push(OverlayCmd::Svg {
1473 data: data.clone(),
1474 x: ox + (PAGE_W / 2.0 - 32.0) * sx,
1475 y: oy + top * sy,
1476 w: 64.0 * sx,
1477 h: 64.0 * sy,
1478 });
1479 col = col.child(widget::spacer().h(68.0 * sy));
1480 if let Some(t) = text {
1481 col = col.child(lbl(t.as_str()).color(theme.text));
1482 }
1483 }
1484
1485 BookPage::CustomText { text, font, color } => {
1486 col = col.child(
1489 lbl(text.as_str())
1490 .font(font.font_id.as_str())
1491 .font_scale(text_scale() * font.size_px / yog_ui::text::LINE_H)
1492 .color(*color),
1493 );
1494 }
1495
1496 BookPage::Relations { entries, text } => {
1497 if let Some(t) = text {
1498 col = col.child(lbl(t.as_str()).color(theme.text));
1499 }
1500 col = col.child(title_lbl("See also:").color(theme.title));
1501 for e in entries {
1502 col = col.child(lbl(format!("• {}", e)).color(theme.nav));
1503 }
1504 }
1505
1506 BookPage::Multiblock { name, pattern, mapping, text } => {
1507 if let Some(n) = name {
1508 col = col.child(title_lbl(n.as_str()).color(theme.title));
1509 }
1510 overlays.push(OverlayCmd::Blocks {
1511 blocks: crate::multiblock_blocks(pattern, mapping),
1512 x: ox + (PAGE_W / 2.0 - 45.0) * sx,
1513 y: oy + 12.0 * sy,
1514 w: 90.0 * sx,
1515 h: 90.0 * sy,
1516 });
1517 col = col.child(widget::spacer().h(94.0 * sy));
1518 if let Some(t) = text {
1519 col = col.child(lbl(t.as_str()).color(theme.text));
1520 }
1521 }
1522
1523 BookPage::Entity {
1524 entity_type,
1525 name,
1526 text,
1527 } => {
1528 let display = name.as_deref().unwrap_or(entity_type.as_str());
1529 col = col.child(title_lbl(display).color(theme.title));
1530 overlays.push(OverlayCmd::Entity {
1531 entity: entity_type.clone(),
1532 x: ox + (PAGE_W / 2.0 - 40.0) * sx,
1533 y: oy + 12.0 * sy,
1534 w: 80.0 * sx,
1535 h: 80.0 * sy,
1536 });
1537 col = col.child(widget::spacer().h(84.0 * sy));
1538 if let Some(t) = text {
1539 col = col.child(lbl(t.as_str()).color(theme.text));
1540 }
1541 }
1542
1543 BookPage::Pattern {
1544 op_id,
1545 input,
1546 output,
1547 text,
1548 ..
1549 } => {
1550 col = col.child(title_lbl(op_id.as_str()).color(theme.title));
1551 col = col.child(lbl(format!("{} → {}", input, output)).color(theme.nav));
1552 if !text.is_empty() {
1553 col = col.child(lbl(text.as_str()).color(theme.text));
1554 }
1555 }
1556
1557 BookPage::Empty => {}
1558 }
1559
1560 col
1561}
1562
1563#[cfg(test)]
1564mod tests {
1565 use super::*;
1566 use crate::BookEntry;
1567
1568 fn book(pages: Vec<BookPage>) -> Book {
1569 let mut book = Book::new("t:b", "B");
1570 book.entries.push(BookEntry { id: "e".into(), name: "E".into(), pages, ..Default::default() });
1571 book
1572 }
1573
1574 #[test]
1575 fn long_text_flows_onto_new_pages() {
1576 let long = "word ".repeat(400);
1577 let flowed = flow(book(vec![BookPage::Text { text: long.clone(), title: None }, BookPage::Empty]));
1578 let pages = &flowed.entries[0].pages;
1579 assert!(pages.len() > 3, "{}", pages.len());
1580 assert!(matches!(pages.last(), Some(BookPage::Empty)), "what came after stays after");
1581 let line_h = yog_ui::text::LINE_H + yog_ui::text::LINE_GAP;
1582 for p in &pages[..pages.len() - 1] {
1583 let BookPage::Text { text, .. } = p else { panic!("{p:?}") };
1584 assert!(text.lines().count() as f32 * line_h <= BODY_H + 1.0);
1585 assert!(text.lines().all(|l| yog_ui::text::str_width(l, 1.0) <= TEXT_W));
1586 }
1587 let words: usize = pages.iter().map(|p| match p {
1588 BookPage::Text { text, .. } => text.split_whitespace().count(),
1589 _ => 0,
1590 }).sum();
1591 assert_eq!(words, 400, "nothing lost");
1592 }
1593
1594 #[test]
1595 fn edits_keep_the_reader_where_they_were() {
1596 let mut b = book(vec![BookPage::Text { text: "one".into(), title: None }, BookPage::Text { text: "two".into(), title: None }]);
1597 b.categories.push(crate::BookCategory { id: "c".into(), name: "C".into(), ..Default::default() });
1598 b.entries[0].category = "c".into();
1599 let mut r = BookRenderer::new(b.clone());
1600 assert_eq!(r.shown(), None, "opens on the landing page");
1601 assert!(r.show("e", 1));
1602 assert_eq!(r.shown(), Some(("e".into(), 1)));
1603 r.prepare(800.0, 600.0);
1604 assert!(r.ui.is_some());
1605
1606 b.entries[0].pages.push(BookPage::Empty);
1607 r.set_book(b);
1608 assert_eq!(r.shown(), Some(("e".into(), 1)));
1609 assert!(!r.show("missing", 0));
1610 }
1611
1612 #[test]
1613 fn pages_are_counted_as_written() {
1614 let long = "word ".repeat(400);
1615 let mut b = book(vec![BookPage::Text { text: long, title: None }, BookPage::Empty]);
1616 b.categories.push(crate::BookCategory { id: "c".into(), name: "C".into(), ..Default::default() });
1617 b.entries[0].category = "c".into();
1618 let mut r = BookRenderer::new(b);
1619 assert!(r.show("e", 1));
1620 assert_eq!(r.shown(), Some(("e".into(), 1)), "the second page as written");
1621 assert!(r.state.page > 1, "past the first page's flowed text");
1622 r.handle_event("prev_page");
1623 assert_eq!(r.shown(), Some(("e".into(), 0)), "flowed text belongs to the first");
1624 }
1625
1626 #[test]
1627 fn reads_yog_recipes() {
1628 let shaped = parse_recipe(r##"{"type":"shaped","pattern":["AB"],"key":{"A":"m:a","B":["m:x","#c:t"]},"result":"m:out","count":2}"##);
1629 let Some(RecipeVis::Shaped { grid, width, output, count }) = shaped else { panic!() };
1630 assert_eq!((width, output.as_str(), count), (2, "m:out", 2));
1631 assert_eq!(grid[1], ["m:x", "#c:t"]);
1632 let blast = parse_recipe(r##"{"type":"blasting","ingredient":"m:ore","result":"m:ingot"}"##);
1633 assert!(matches!(blast, Some(RecipeVis::Smelting { station, .. }) if station == "minecraft:blast_furnace"));
1634 assert!(parse_recipe(r#"{"type":"minecraft:crafting_shaped"}"#).is_none(), "vanilla JSON isn't the book's");
1635 }
1636
1637 #[test]
1638 fn multiblocks_turn_and_animate() {
1639 let pattern = vec![vec!["G".to_string()], vec!["S ".to_string()]];
1640 let mapping = [("G".to_string(), "minecraft:glass".to_string()), ("S".to_string(), "minecraft:stone".to_string())].into();
1641 let blocks = crate::multiblock_blocks(&pattern, &mapping);
1642 assert_eq!(blocks, [((0, 1, 0), "minecraft:glass".to_string()), ((0, 0, 0), "minecraft:stone".to_string())]);
1643
1644 let mut b = book(vec![BookPage::Multiblock { name: None, pattern, mapping, text: None }]);
1645 b.categories.push(crate::BookCategory { id: "c".into(), name: "C".into(), ..Default::default() });
1646 b.entries[0].category = "c".into();
1647 let mut r = BookRenderer::new(b);
1648 r.prepare(800.0, 600.0);
1649 assert!(!r.animated(), "the landing page is still");
1650 r.show("e", 0);
1651 r.prepare(800.0, 600.0);
1652 assert!(r.animated(), "a structure turns");
1653 }
1654
1655 #[test]
1656 fn short_text_stays_put() {
1657 let pages = vec![BookPage::Text { text: "Hello".into(), title: Some("T".into()) }];
1658 let flowed = flow(book(pages.clone()));
1659 assert_eq!(flowed.entries[0].pages.len(), 1);
1660 }
1661}