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 static LANG: &[(&str, &str)] = &[
47 ("en_us", include_str!("../assets/lang/en_us.json")),
48 ("ru_ru", include_str!("../assets/lang/ru_ru.json")),
49];
50pub const BOOK_SHEET: &str = "yog:gui/book/book";
54pub const CRAFTING_SHEET: &str = "yog:gui/book/crafting";
56
57#[derive(Clone, Copy, PartialEq)]
60pub(crate) enum SpriteSheet {
61 Book,
62 Crafting,
63}
64
65#[derive(Clone)]
67pub(crate) struct BgSprite {
68 pub sheet: SpriteSheet,
69 pub u: f32,
71 pub v: f32,
72 pub uw: f32,
73 pub uh: f32,
74 pub x: f32,
76 pub y: f32,
77 pub w: f32,
78 pub h: f32,
79}
80
81#[derive(Clone)]
84pub(crate) enum OverlayCmd {
85 Svg {
86 data: String,
87 x: f32,
88 y: f32,
89 w: f32,
90 h: f32,
91 },
92 McItems {
95 items: Vec<String>,
96 x: f32,
97 y: f32,
98 w: f32,
99 h: f32,
100 },
101 Blocks {
103 blocks: Vec<((i32, i32, i32), String)>,
104 x: f32,
105 y: f32,
106 w: f32,
107 h: f32,
108 },
109 Entity {
111 entity: String,
112 x: f32,
113 y: f32,
114 w: f32,
115 h: f32,
116 },
117 McText {
119 text: String,
120 x: f32,
121 y: f32,
122 color: u32,
123 },
124 McItem {
126 item_id: String,
127 x: f32,
128 y: f32,
129 w: f32,
130 h: f32,
131 },
132 McTexture {
137 path: String,
138 x: f32,
139 y: f32,
140 w: f32,
141 h: f32,
142 },
143}
144
145fn push_icon(overlays: &mut Vec<OverlayCmd>, icon: &str, x: f32, y: f32, w: f32, h: f32) {
149 if icon.ends_with(".png") {
150 overlays.push(OverlayCmd::McTexture {
151 path: icon.to_string(),
152 x,
153 y,
154 w,
155 h,
156 });
157 } else {
158 overlays.push(OverlayCmd::McItem {
159 item_id: icon.to_string(),
160 x,
161 y,
162 w,
163 h,
164 });
165 }
166}
167
168#[derive(Clone)]
171pub(crate) enum RecipeVis {
172 Shaped {
173 grid: Vec<Vec<String>>,
175 width: usize,
176 output: String,
177 count: u32,
178 },
179 Shapeless {
180 ingredients: Vec<Vec<String>>,
181 output: String,
182 count: u32,
183 },
184 Smelting {
186 input: Vec<String>,
187 output: String,
188 station: String,
189 },
190}
191
192fn ingredient(v: &serde_json::Value) -> Vec<String> {
195 match v {
196 serde_json::Value::String(s) => vec![s.clone()],
197 serde_json::Value::Array(a) => a.iter().flat_map(ingredient).collect(),
198 _ => Vec::new(),
199 }
200}
201
202fn parse_recipe(json: &str) -> Option<RecipeVis> {
205 let v: serde_json::Value = serde_json::from_str(json).ok()?;
206 let output = v.get("result")?.as_str()?.to_owned();
207 let count = v.get("count").and_then(|c| c.as_u64()).unwrap_or(1) as u32;
208 let station = |s: &str| Some(s.to_owned());
209 match v.get("type")?.as_str()? {
210 "shaped" => {
211 let pattern: Vec<&str> = v.get("pattern")?.as_array()?.iter().filter_map(|p| p.as_str()).collect();
212 let width = pattern.iter().map(|r| r.chars().count()).max()?.max(1);
213 let key = v.get("key")?.as_object()?;
214 let mut grid = Vec::new();
215 for row in &pattern {
216 let mut chars: Vec<char> = row.chars().collect();
217 chars.resize(width, ' ');
218 for ch in chars {
219 grid.push(key.get(&ch.to_string()).map(ingredient).unwrap_or_default());
220 }
221 }
222 Some(RecipeVis::Shaped { grid, width, output, count })
223 }
224 "shapeless" => Some(RecipeVis::Shapeless {
225 ingredients: v.get("ingredients")?.as_array()?.iter().map(ingredient).collect(),
226 output,
227 count,
228 }),
229 kind @ ("smelting" | "blasting" | "smoking" | "campfire_cooking" | "stonecutting") => Some(RecipeVis::Smelting {
230 input: ingredient(v.get("ingredient")?),
231 output,
232 station: match kind {
233 "blasting" => station("minecraft:blast_furnace"),
234 "smoking" => station("minecraft:smoker"),
235 "campfire_cooking" => station("minecraft:campfire"),
236 "stonecutting" => station("minecraft:stonecutter"),
237 _ => station("minecraft:furnace"),
238 }?,
239 }),
240 _ => None,
241 }
242}
243
244fn normalize_item_id(id: &str) -> String {
247 let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
248 let name = path
249 .trim_start_matches("item/")
250 .trim_start_matches("block/");
251 format!("{ns}:{name}")
252}
253
254fn book_sheet(texture: &str) -> String {
257 let t = texture.trim();
258 if t.is_empty() || t == "yog:gui/book" {
259 return BOOK_SHEET.to_string();
260 }
261 let (ns, path) = t.split_once(':').unwrap_or(("minecraft", t));
262 let path = path.strip_prefix("textures/").unwrap_or(path);
263 format!("{ns}:{}", path.strip_suffix(".png").unwrap_or(path))
264}
265
266static ITEM_NAMES: std::sync::RwLock<Option<Translator>> = std::sync::RwLock::new(None);
268
269type Translator = fn(&str) -> Option<String>;
270
271fn word(key: &str, arg: &str) -> String {
274 let k = format!("yog.book.{key}");
275 let tr = *ITEM_NAMES.read().unwrap_or_else(|e| e.into_inner());
276 let text = tr.and_then(|tr| tr(&k)).filter(|t| *t != k).or_else(|| {
277 static EN: std::sync::LazyLock<HashMap<String, String>> =
279 std::sync::LazyLock::new(|| serde_json::from_str(LANG[0].1).unwrap_or_default());
280 EN.get(&k).cloned()
281 });
282 let text = text.unwrap_or(k);
283 text.replace("%s", arg)
284}
285
286fn name_from_path(path: &str) -> String {
288 path.rsplit('/')
289 .flat_map(|seg| seg.split('_'))
290 .filter(|w| !w.is_empty())
291 .map(|w| {
292 let mut c = w.chars();
293 match c.next() {
294 Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
295 None => String::new(),
296 }
297 })
298 .collect::<Vec<_>>()
299 .join(" ")
300}
301
302fn ordinal(n: u32) -> String {
305 let suffixes = word("ordinals", "");
306 let s: Vec<&str> = suffixes.split_whitespace().collect();
307 if s.len() < 4 {
308 return n.to_string();
309 }
310 let i = match (n % 100, n % 10) {
311 (11..=13, _) => 0,
312 (_, 1) => 1,
313 (_, 2) => 2,
314 (_, 3) => 3,
315 _ => 0,
316 };
317 format!("{n}{}", s[i])
318}
319
320fn entity_name(id: &str) -> String {
323 let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
324 let k = format!("entity.{ns}.{}", path.replace('/', "."));
325 let tr = *ITEM_NAMES.read().unwrap_or_else(|e| e.into_inner());
326 tr.and_then(|tr| tr(&k)).filter(|t| *t != k).unwrap_or_else(|| name_from_path(path))
327}
328
329fn pretty_item_name(id: &str) -> String {
332 let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
333 if let Some(tr) = *ITEM_NAMES.read().unwrap_or_else(|e| e.into_inner()) {
334 let key = path.replace('/', ".");
335 for kind in ["item", "block"] {
336 let k = format!("{kind}.{ns}.{key}");
337 if let Some(name) = tr(&k).filter(|n| *n != k) {
338 return name;
339 }
340 }
341 }
342 name_from_path(path)
343}
344
345pub struct BookRenderer {
348 pub book: Book,
349 pub state: BookViewState,
350 pub theme: BookTheme,
351 recipes: HashMap<String, RecipeVis>,
352 origins: HashMap<String, Vec<usize>>,
354 written: Book,
356 measured: bool,
358 start: std::time::Instant,
360 pub ui: Option<UiRoot>,
361 bg_sprites: Vec<BgSprite>,
362 overlays: Vec<OverlayCmd>,
363 dirty: bool,
364 last_sw: f32,
365 last_sh: f32,
366 source: Option<Book>,
369 translator: Option<fn(&str) -> Option<String>>,
370 probe: Option<Option<String>>,
371}
372
373impl BookRenderer {
374 pub fn new(book: Book) -> Self {
375 let theme = BookTheme::default().with_nameplate(&book.nameplate_color);
376 let written = styled(&book);
377 let (book, origins) = flow_traced(written.clone());
378 Self {
379 book,
380 origins,
381 written,
382 measured: false,
383 start: std::time::Instant::now(),
384 state: BookViewState::default(),
385 theme,
386 recipes: HashMap::new(),
387 ui: None,
388 bg_sprites: Vec::new(),
389 overlays: Vec::new(),
390 dirty: true,
391 last_sw: 0.0,
392 last_sh: 0.0,
393 source: None,
394 translator: None,
395 probe: None,
396 }
397 }
398
399 pub fn set_translator(&mut self, tr: fn(&str) -> Option<String>) {
404 *ITEM_NAMES.write().unwrap_or_else(|e| e.into_inner()) = Some(tr);
405 if self.book.i18n {
406 self.source.get_or_insert_with(|| self.book.clone());
407 self.translator = Some(tr);
409 self.probe = None;
410 }
411 }
412
413 fn localize(&mut self) {
415 let (Some(src), Some(tr)) = (&self.source, self.translator) else {
416 return;
417 };
418 let probe = Some(tr(&src.name));
419 if probe == self.probe {
420 return;
421 }
422 let accent = src.accent.clone();
423 self.written = src.localized(|k| crate::markup::resolve(&tr(k).unwrap_or_else(|| k.to_string()), &accent));
424 (self.book, self.origins) = flow_traced(self.written.clone());
425 self.probe = probe;
426 self.dirty = true;
427 }
428
429 pub fn handle_event(&mut self, ev: &str) {
430 if self.state.handle(ev, &self.book) {
431 self.dirty = true;
432 }
433 }
434
435 pub fn add_recipe(&mut self, id: impl Into<String>, json: &str) {
438 if let Some(vis) = parse_recipe(json) {
439 self.recipes.insert(id.into(), vis);
440 self.dirty = true;
441 }
442 }
443
444 fn prepare(&mut self, sw: f32, sh: f32) -> (f32, f32, f32, f32) {
447 let scale = ((sw - SCREEN_MARGIN) / BK_W)
450 .min((sh - SCREEN_MARGIN) / BK_H)
451 .clamp(MIN_SCALE, 1.0);
452 let bw = BK_W * scale;
453 let bh = BK_H * scale;
454 let bx = (sw - bw) / 2.0;
455 let by = (sh - bh) / 2.0;
456
457 self.localize();
458 if self.dirty || sw != self.last_sw || sh != self.last_sh {
460 let (root, bg_sprites, overlays) = build_ui(
461 &self.book,
462 &self.state,
463 &self.theme,
464 &self.recipes,
465 bx,
466 by,
467 bw,
468 bh,
469 );
470 self.ui = Some(root);
471 self.bg_sprites = bg_sprites;
472 self.overlays = overlays;
473 self.dirty = false;
474 self.last_sw = sw;
475 self.last_sh = sh;
476 }
477 if let Some(ui) = &mut self.ui
478 && ui.needs_layout
479 {
480 ui.layout(sw, sh);
481 }
482 (bx, by, bw, bh)
483 }
484
485 pub fn render(&mut self, ctx: &GfxContext, sw: f32, sh: f32) {
489 yog_ui::text::use_host_metrics(ctx);
491 if !self.measured {
492 self.measured = true;
493 self.reflow();
494 }
495 let (bx, by, bw, bh) = self.prepare(sw, sh);
496 let d2 = ctx.draw2d();
497 let book_sheet = book_sheet(&self.book.book_texture);
498 d2.image_region(&book_sheet, bx, by, bw, bh, [0.0, 0.0, BK_W, BK_H], 0xFFFF_FFFF);
499 for spr in &self.bg_sprites {
500 let sheet = match spr.sheet {
501 SpriteSheet::Book => &book_sheet,
502 SpriteSheet::Crafting => CRAFTING_SHEET,
503 };
504 d2.image_region(sheet, spr.x, spr.y, spr.w, spr.h, [spr.u, spr.v, spr.uw, spr.uh], 0xFFFF_FFFF);
505 }
506 if let Some(ui) = &mut self.ui {
507 ui.render(ctx);
508 }
509 let t = self.start.elapsed().as_secs_f32();
512 for ov in &self.overlays {
513 match *ov {
514 OverlayCmd::Svg { ref data, x, y, w, h } => d2.svg(data, x, y, w, h),
515 OverlayCmd::Entity { ref entity, x, y, w, h } => d2.entity(entity, x, y, w, h, -30.0 + t * 30.0, 0.0),
516 OverlayCmd::Blocks { ref blocks, x, y, w, h } => {
517 let list: Vec<((i32, i32, i32), &str)> = blocks.iter().map(|(p, b)| (*p, b.as_str())).collect();
518 d2.blocks(&list, x, y, w, h, -45.0 + t * 20.0, 30.0);
519 }
520 _ => {}
521 }
522 }
523 self.draw_mc_overlays(ctx);
525 }
526
527 fn draw_mc_overlays(&self, ctx: &GfxContext) {
528 let second = self.start.elapsed().as_secs() as usize;
529 for ov in &self.overlays {
530 match *ov {
531 OverlayCmd::McItems { ref items, x, y, w, h } => {
532 let shown: Vec<&String> = items.iter().filter(|i| !i.starts_with('#')).collect();
533 if !shown.is_empty() {
534 let item = shown[second % shown.len()];
535 ctx.draw2d().item(&normalize_item_id(item), x, y, w.min(h));
536 }
537 }
538 OverlayCmd::McItem { ref item_id, x, y, w, h } => {
539 ctx.draw2d().item(&normalize_item_id(item_id), x, y, w.min(h));
540 }
541 OverlayCmd::McText { ref text, x, y, color } => {
542 ctx.draw2d().text(text, x, y, color, false);
543 }
544 OverlayCmd::McTexture { ref path, x, y, w, h } => {
545 ctx.draw2d().image(path, x, y, w, h);
548 }
549 _ => {}
550 }
551 }
552 }
553
554 pub fn animated(&self) -> bool {
557 self.overlays.iter().any(|o| match o {
558 OverlayCmd::Entity { .. } | OverlayCmd::Blocks { .. } => true,
559 OverlayCmd::McItems { items, .. } => items.iter().filter(|i| !i.starts_with('#')).count() > 1,
560 _ => false,
561 })
562 }
563
564 pub fn click(&mut self, x: f32, y: f32) -> Option<String> {
568 let ui = self.ui.as_ref()?;
569 let hit = yog_ui::layout::hit_test(&ui.layout_root, x, y)?;
570 let action = hit.on_click.clone()?;
571 self.handle_event(&action);
572 Some(action)
573 }
574
575 pub fn show(&mut self, entry: &str, page: usize) -> bool {
579 let Some(e) = self.book.entries.iter().find(|e| e.id == entry) else { return false };
580 let Some(cat) = self.book.categories.iter().position(|c| c.id == e.category) else { return false };
581 self.state.cat = cat;
582 self.state.at_home = false;
583 let in_cat = self.state.entries_in_cat(&self.book);
584 let Some(i) = in_cat.iter().position(|x| x.id == entry) else { return false };
585 let shown = self.origins.get(entry).and_then(|o| o.iter().position(|&w| w >= page)).unwrap_or(page);
586 self.state.entry = i;
587 self.state.page = shown.min(e.pages.len().saturating_sub(1));
588 self.dirty = true;
589 true
590 }
591
592 pub fn shown(&self) -> Option<(String, usize)> {
595 if self.state.at_home {
596 return None;
597 }
598 let e = self.state.current_entry(&self.book)?;
599 let page = self.origins.get(&e.id).and_then(|o| o.get(self.state.page)).copied().unwrap_or(self.state.page);
600 Some((e.id.clone(), page))
601 }
602
603 fn reflow(&mut self) {
605 let at = self.shown();
606 (self.book, self.origins) = flow_traced(self.written.clone());
607 self.dirty = true;
608 if let Some((entry, page)) = at {
609 self.show(&entry, page);
610 }
611 }
612
613 pub fn set_book(&mut self, book: Book) {
615 let at = self.shown();
616 self.written = styled(&book);
617 (self.book, self.origins) = flow_traced(self.written.clone());
618 self.theme = BookTheme::default().with_nameplate(&self.book.nameplate_color);
619 self.dirty = true;
620 match at {
621 Some((entry, page)) if self.show(&entry, page) => {}
622 _ => {
623 if self.state.cat >= self.book.categories.len() {
624 self.state = BookViewState::default();
625 }
626 }
627 }
628 }
629}
630
631thread_local! {
634 static TEXT_SCALE: std::cell::Cell<f32> = const { std::cell::Cell::new(1.0) };
637}
638
639fn text_scale() -> f32 {
640 TEXT_SCALE.with(|s| s.get())
641}
642
643fn lbl(text: impl Into<String>) -> widget::Widget {
645 widget::label(text).shadow(false).font_scale(text_scale())
646}
647fn btn(text: impl Into<String>) -> widget::Widget {
649 widget::button(text).shadow(false).font_scale(text_scale())
650}
651
652fn title_lbl(text: impl Into<String>) -> widget::Widget {
655 let text = text.into();
656 let w = PAGE_W - 10.0;
658 let fit = (w / yog_ui::text::str_width(&text, 1.0)).clamp(0.5, 1.0);
659 widget::label(text)
660 .shadow(false)
661 .font_scale(text_scale() * fit)
662 .no_wrap()
663 .ellipsis()
664 .w(w * text_scale())
665}
666
667const TEXT_W: f32 = PAGE_W - 8.0;
671const BODY_H: f32 = PAGE_H - 22.0 - 12.0 - 14.0;
674
675const MB_VIEW: f32 = 70.0;
678const PATTERN_H: f32 = 64.0;
680const MB_NAME_H: f32 = 14.0;
681
682fn content_above_text(page: &BookPage) -> Option<f32> {
684 Some(match page {
685 BookPage::Text { title: Some(_), .. } => 22.0,
686 BookPage::Text { .. } => 0.0,
687 BookPage::Spotlight { .. } => 40.0,
688 BookPage::Recipe { .. } => 80.0,
690 BookPage::Image { .. } | BookPage::Entity { .. } => 104.0,
691 BookPage::Pattern { patterns, input, output, .. } => {
692 14.0 + if patterns.is_empty() { 0.0 } else { PATTERN_H + 4.0 }
693 + if input.is_empty() && output.is_empty() { 0.0 } else { 14.0 }
694 }
695 BookPage::Multiblock { name, .. } => MB_VIEW + 4.0 + if name.is_some() { MB_NAME_H } else { 0.0 },
696 BookPage::Relations { entries, .. } => 12.0 + entries.len() as f32 * 10.0,
697 _ => return None,
698 })
699}
700
701fn page_text_mut(page: &mut BookPage) -> Option<&mut String> {
702 match page {
703 BookPage::Text { text, .. } | BookPage::Pattern { text, .. } => Some(text),
704 BookPage::Spotlight { text, .. }
705 | BookPage::Recipe { text, .. }
706 | BookPage::Image { text, .. }
707 | BookPage::Entity { text, .. }
708 | BookPage::Multiblock { text, .. }
709 | BookPage::Relations { text, .. } => text.as_mut(),
710 _ => None,
711 }
712}
713
714fn styled(book: &Book) -> Book {
716 book.localized(|t| crate::markup::resolve(t, &book.accent))
717}
718
719#[cfg(test)]
721fn flow(book: Book) -> Book {
722 flow_traced(book).0
723}
724
725fn flow_traced(mut book: Book) -> (Book, HashMap<String, Vec<usize>>) {
728 let mut origins = HashMap::new();
729 let line_h = yog_ui::text::LINE_H + yog_ui::text::LINE_GAP;
730 let fits = |h: f32| (((h + yog_ui::text::LINE_GAP) / line_h).floor() as usize).max(1);
731 for entry in &mut book.entries {
732 let mut pages = Vec::with_capacity(entry.pages.len());
733 let mut from = Vec::with_capacity(entry.pages.len());
734 for (written, page) in std::mem::take(&mut entry.pages).into_iter().enumerate() {
735 let split = split_page(page, &fits);
736 from.extend(std::iter::repeat_n(written, split.len()));
737 pages.extend(split);
738 }
739 entry.pages = pages;
740 origins.insert(entry.id.clone(), from);
741 }
742 (book, origins)
743}
744
745fn split_page(mut page: BookPage, fits: &dyn Fn(f32) -> usize) -> Vec<BookPage> {
747 let Some(above) = content_above_text(&page) else { return vec![page] };
748 let Some(text) = page_text_mut(&mut page) else { return vec![page] };
749 let lines: Vec<String> = text
750 .split('\n')
751 .flat_map(|para| if para.is_empty() { vec![String::new()] } else { yog_ui::text::wrap_text(para, TEXT_W, 1.0) })
752 .collect();
753 let first = fits(BODY_H - above);
754 if lines.len() <= first {
755 return vec![page];
756 }
757 *text = lines[..first].join("\n");
758 let rest: Vec<BookPage> = lines[first..]
759 .chunks(fits(BODY_H))
760 .map(|chunk| BookPage::Text { text: chunk.join("\n"), title: None, font: None, font_size: None })
761 .collect();
762 std::iter::once(page).chain(rest).collect()
763}
764
765fn build_ui(
768 book: &Book,
769 state: &BookViewState,
770 theme: &BookTheme,
771 recipes: &HashMap<String, RecipeVis>,
772 bx: f32,
773 by: f32,
774 bw: f32,
775 bh: f32,
776) -> (UiRoot, Vec<BgSprite>, Vec<OverlayCmd>) {
777 let mut bg_sprites: Vec<BgSprite> = Vec::new();
778 let mut overlays: Vec<OverlayCmd> = Vec::new();
779
780 let sx = bw / BK_W;
782 TEXT_SCALE.with(|s| s.set(sx));
783 let sy = bh / BK_H;
784
785 let pw = PAGE_W * sx;
787 let ph = PAGE_H * sy;
788 let spine_gap = (RIGHT_X - LEFT_X - PAGE_W) * sx; let lx = bx + LEFT_X * sx;
792 let rx = bx + RIGHT_X * sx;
793 let py = by + TOP_PAD * sy;
794
795 let sep_cx = (PAGE_W / 2.0 - SEP_W / 2.0) * sx; if state.at_home {
799 bg_sprites.push(BgSprite {
801 sheet: SpriteSheet::Book,
802 u: 0.0,
803 v: 180.0,
804 uw: 140.0,
805 uh: 31.0,
806 x: bx + (LEFT_X - 8.0) * sx,
807 y: by + 12.0 * sy,
808 w: 140.0 * sx,
809 h: 31.0 * sy,
810 });
811 }
812 bg_sprites.push(BgSprite {
815 sheet: SpriteSheet::Book,
816 u: SEP_U,
817 v: SEP_V,
818 uw: SEP_W,
819 uh: SEP_H,
820 x: rx + sep_cx,
821 y: by + (TOP_PAD + 12.0) * sy,
822 w: SEP_W * sx,
823 h: (SEP_H * sy).max(1.0),
824 });
825
826 let (left_page, right_page) = if state.at_home {
827 let l = build_landing_left(book, theme, pw, ph, sx, sy, bx, by, &mut overlays);
828 let r = build_categories_right(book, state, theme, pw, ph, rx, py, sx, sy, &mut overlays);
829 (l, r)
830 } else {
831 let l = build_entry_left(
832 book,
833 state,
834 theme,
835 recipes,
836 pw,
837 ph,
838 lx,
839 py,
840 sx,
841 sy,
842 sep_cx,
843 &mut bg_sprites,
844 &mut overlays,
845 );
846 let r = build_entries_right(book, state, theme, pw, ph, rx, py, sx, sy, &mut overlays);
847 (l, r)
848 };
849
850 let root_widget = widget::panel(FlexDir::Row)
858 .w(bw)
859 .h(bh)
860 .gap(0.0)
861 .padding(TOP_PAD * sy, 0.0, 0.0, LEFT_X * sx)
862 .child(left_page)
863 .child(widget::spacer().w(spine_gap)) .child(right_page);
865
866 let outer = widget::panel(FlexDir::Column)
869 .w(bw)
870 .h(bh)
871 .child(root_widget);
872
873 let screen_root = widget::panel(FlexDir::Row)
878 .padding(by, 0.0, 0.0, bx)
879 .child(outer);
880
881 let ui = UiRoot::new(&book.id, screen_root);
882 (ui, bg_sprites, overlays)
883}
884
885fn build_landing_left(
888 book: &Book,
889 theme: &BookTheme,
890 page_w: f32,
891 page_h: f32,
892 sx: f32,
893 sy: f32,
894 bx: f32,
895 by: f32,
896 overlays: &mut Vec<OverlayCmd>,
897) -> widget::Widget {
898 overlays.push(OverlayCmd::McText {
901 text: book.name.clone(),
902 x: bx + 13.0 * sx,
903 y: by + 16.0 * sy,
904 color: theme.nameplate,
905 });
906 let second = match book.version {
908 0 => book.subtitle.clone(),
909 n => Some(word("edition", &ordinal(n))),
910 };
911 if let Some(text) = second {
912 overlays.push(OverlayCmd::McText {
913 text,
914 x: bx + 24.0 * sx,
915 y: by + 24.0 * sy,
916 color: theme.nameplate,
917 });
918 }
919
920 let mut col = widget::panel(FlexDir::Column)
924 .w(page_w)
925 .h(page_h)
926 .padding(25.0 * sy, 6.0, 4.0, 4.0)
927 .gap(0.0);
928
929 let fit = landing_fit(&book.landing_text);
931 for para in book.landing_text.split('\n') {
932 if para.is_empty() {
933 col = col.child(widget::spacer().h(4.0 * sy));
934 } else {
935 col = col.child(lbl(para).color(theme.text).font_scale(text_scale() * fit));
936 }
937 }
938
939 let total = book.entries.len();
940 col = col.child(widget::spacer().flex(1.0));
941 col = col.child(
942 title_lbl(word("entry_count", &total.to_string()))
943 .color(theme.nav)
944 .h(9.0 * sy)
945 .align(Align::Center),
946 );
947 col
948}
949
950fn landing_fit(text: &str) -> f32 {
952 use yog_ui::text::{LINE_GAP, LINE_H, line_count};
953 let room = PAGE_H - 25.0 - 4.0 - 9.0 - 2.0;
954 let height = |scale: f32| -> f32 {
955 text.split('\n')
956 .map(|p| match p.is_empty() {
957 true => 4.0,
958 false => line_count(p, TEXT_W, scale) as f32 * (LINE_H * scale + LINE_GAP),
959 })
960 .sum()
961 };
962 let mut scale = 1.0;
963 while scale > 0.6 && height(scale) > room {
964 scale -= 0.05;
965 }
966 scale
967}
968
969fn build_categories_right(
972 book: &Book,
973 state: &BookViewState,
974 theme: &BookTheme,
975 page_w: f32,
976 page_h: f32,
977 rx: f32,
978 py: f32,
979 sx: f32,
980 sy: f32,
981 overlays: &mut Vec<OverlayCmd>,
982) -> widget::Widget {
983 let cell_w = 24.0 * sx;
988 let cell_h = 24.0 * sy;
989 let grid_pad_left = 10.0 * sx;
990 let header_h = 9.0 * sy;
991
992 let mut col = widget::panel(FlexDir::Column)
993 .w(page_w)
994 .h(page_h)
995 .padding(0.0, 4.0, 4.0, 0.0)
996 .gap(0.0);
997
998 col = col.child(
999 title_lbl(word("categories", ""))
1000 .color(theme.divider)
1001 .h(header_h)
1002 .align(Align::Center)
1003 .no_wrap(),
1004 );
1005 col = col.child(widget::spacer().h(25.0 * sy - header_h));
1007
1008 let cats = &book.categories;
1010 let icon_s = 16.0 * sx.min(sy);
1011 let mut row_i = 0usize;
1012 loop {
1013 let row_start = row_i * 4;
1014 if row_start >= cats.len() {
1015 break;
1016 }
1017 let mut row =
1018 widget::panel(FlexDir::Row)
1019 .h(cell_h)
1020 .gap(0.0)
1021 .padding(0.0, 0.0, 0.0, grid_pad_left);
1022 for col_i in 0..4 {
1023 let idx = row_start + col_i;
1024 if idx >= cats.len() {
1025 break;
1026 }
1027 let cat = &cats[idx];
1028 let selected = !state.at_home && idx == state.cat;
1029 let bg = if selected { theme.nav_selected_bg } else { 0 };
1030
1031 if let Some(icon_id) = &cat.icon {
1032 push_icon(
1033 overlays,
1034 icon_id,
1035 rx + (10.0 + col_i as f32 * 24.0 + 4.0) * sx,
1036 py + (25.0 + row_i as f32 * 24.0 + 4.0) * sy,
1037 icon_s,
1038 icon_s,
1039 );
1040 }
1041 let cell = widget::panel(FlexDir::Column)
1042 .w(cell_w)
1043 .h(cell_h)
1044 .bg(bg)
1045 .on_click(format!("cat:{}", idx))
1046 .id(format!("book_cat_{}", idx));
1047 row = row.child(cell);
1048 }
1049 col = col.child(row);
1050 row_i += 1;
1051 }
1052 col
1053}
1054
1055fn build_entry_left(
1058 book: &Book,
1059 state: &BookViewState,
1060 theme: &BookTheme,
1061 recipes: &HashMap<String, RecipeVis>,
1062 page_w: f32,
1063 page_h: f32,
1064 ox: f32,
1065 oy: f32,
1066 sx: f32,
1067 sy: f32,
1068 sep_cx: f32,
1069 bg_sprites: &mut Vec<BgSprite>,
1070 overlays: &mut Vec<OverlayCmd>,
1071) -> widget::Widget {
1072 let entry = state.current_entry(book);
1073 let page = entry.and_then(|e| e.pages.get(state.page));
1074 let page_count = state.page_count(book);
1075 let title_text = entry.map(|e| e.name.as_str()).unwrap_or("");
1076
1077 let title_h = 9.0 * sy;
1082 let sep_h_px = (SEP_H * sy).max(1.0);
1083 let sep_y = oy + 12.0 * sy;
1084 let body_oy = oy + 22.0 * sy;
1085
1086 bg_sprites.push(BgSprite {
1087 sheet: SpriteSheet::Book,
1088 u: SEP_U,
1089 v: SEP_V,
1090 uw: SEP_W,
1091 uh: SEP_H,
1092 x: ox + sep_cx,
1093 y: sep_y,
1094 w: SEP_W * sx,
1095 h: sep_h_px,
1096 });
1097
1098 let page_label = format!("{}/{}", state.page + 1, page_count);
1099 let nav = widget::panel(FlexDir::Row)
1100 .h(12.0 * sy)
1101 .gap(4.0)
1102 .padding(0.0, 2.0, 0.0, 2.0)
1103 .child(
1104 btn("◀")
1105 .w(12.0 * sx)
1106 .h(12.0 * sy)
1107 .color(theme.nav)
1108 .on_click("prev_page")
1109 .id("prev_page"),
1110 )
1111 .child(
1112 lbl(&page_label)
1113 .color(theme.nav)
1114 .flex(1.0)
1115 .align(Align::Center),
1116 )
1117 .child(
1118 btn("⌂")
1119 .w(12.0 * sx)
1120 .h(12.0 * sy)
1121 .color(theme.nav)
1122 .on_click("home")
1123 .id("book_home"),
1124 )
1125 .child(
1126 btn("▶")
1127 .w(12.0 * sx)
1128 .h(12.0 * sy)
1129 .color(theme.nav)
1130 .on_click("next_page")
1131 .id("next_page"),
1132 );
1133
1134 let page_body = build_page(
1135 page, state.page, theme, recipes, bg_sprites, overlays, ox, body_oy, sx, sy,
1136 );
1137
1138 widget::panel(FlexDir::Column)
1139 .w(page_w)
1140 .h(page_h)
1141 .padding(0.0, 6.0, 4.0, 4.0)
1142 .gap(0.0)
1143 .child(
1144 title_lbl(title_text)
1145 .color(theme.title)
1146 .h(title_h)
1147 .align(Align::Center)
1148 .no_wrap(),
1149 )
1150 .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)
1154 .child(nav)
1155}
1156
1157fn build_entries_right(
1160 book: &Book,
1161 state: &BookViewState,
1162 theme: &BookTheme,
1163 page_w: f32,
1164 page_h: f32,
1165 rx: f32,
1166 py: f32,
1167 sx: f32,
1168 sy: f32,
1169 overlays: &mut Vec<OverlayCmd>,
1170) -> widget::Widget {
1171 let entries = state.entries_visible(book);
1172 let cat_name = book
1173 .categories
1174 .get(state.cat)
1175 .map(|c| c.name.as_str())
1176 .map(str::to_string)
1177 .unwrap_or_else(|| word("entries", ""));
1178 let spread_count = state.list_spread_count(book);
1179
1180 let header_h = 9.0 * sy;
1185 let row_h = 11.0 * sy;
1186 let icon_size = 8.0 * sx.min(sy);
1187
1188 let mut col = widget::panel(FlexDir::Column)
1189 .w(page_w)
1190 .h(page_h)
1191 .padding(0.0, 4.0, 8.0, 0.0)
1192 .gap(0.0);
1193
1194 col = col.child(
1195 title_lbl(cat_name)
1196 .color(theme.divider)
1197 .h(header_h)
1198 .align(Align::Center),
1199 );
1200 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();
1206 for (i, entry) in entries.iter().enumerate() {
1207 let abs_i = abs_start + i;
1208 let selected = abs_i == state.entry;
1209 let bg = if selected { theme.nav_selected_bg } else { 0 };
1210 let color = if selected {
1211 theme.nav_selected
1212 } else {
1213 theme.nav
1214 };
1215
1216 if let Some(icon_id) = &entry.icon {
1219 push_icon(
1220 overlays,
1221 icon_id,
1222 rx + 1.0 * sx,
1223 py + (20.0 + i as f32 * 11.0 + 1.0) * sy,
1224 icon_size,
1225 icon_size,
1226 );
1227 }
1228
1229 let mut row = widget::panel(FlexDir::Row)
1230 .h(row_h)
1231 .gap(2.0)
1232 .bg(bg)
1233 .on_click(format!("entry:{}", abs_i))
1234 .id(format!("book_entry_{}", abs_i))
1235 .padding(1.0, 2.0, 1.0, 2.0);
1236
1237 row = row.child(widget::spacer().w(icon_size + 2.0));
1238 let fit = ((PAGE_W - 26.0) / yog_ui::text::str_width(&entry.name, 1.0)).clamp(0.5, 1.0);
1240 row = row.child(lbl(&entry.name).color(color).font_scale(text_scale() * fit).flex(1.0).no_wrap());
1241 col = col.child(row);
1242 }
1243
1244 if spread_count > 1 {
1245 col = col.child(widget::spacer().flex(1.0));
1246 let spread_label = format!("{}/{}", state.list_spread + 1, spread_count);
1247 col = col.child(
1248 widget::panel(FlexDir::Row)
1249 .h(12.0 * sy)
1250 .gap(2.0)
1251 .child(
1252 btn("◀")
1253 .w(12.0 * sx)
1254 .h(12.0 * sy)
1255 .color(theme.nav)
1256 .on_click("prev_list")
1257 .id("prev_list"),
1258 )
1259 .child(
1260 lbl(&spread_label)
1261 .color(theme.nav)
1262 .flex(1.0)
1263 .align(Align::Center),
1264 )
1265 .child(
1266 btn("▶")
1267 .w(12.0 * sx)
1268 .h(12.0 * sy)
1269 .color(theme.nav)
1270 .on_click("next_list")
1271 .id("next_list"),
1272 ),
1273 );
1274 }
1275 col
1276}
1277
1278#[allow(clippy::too_many_arguments)]
1282fn draw_crafting_grid(
1283 theme: &BookTheme,
1284 bg_sprites: &mut Vec<BgSprite>,
1285 overlays: &mut Vec<OverlayCmd>,
1286 col: &mut widget::Widget,
1287 grid: &[Vec<String>],
1288 width: usize,
1289 output: &str,
1290 count: u32,
1291 shapeless: bool,
1292 ox: f32,
1293 oy: f32,
1294 rx0: f32,
1295 ry: f32,
1296 sx: f32,
1297 sy: f32,
1298) {
1299 let mut c = std::mem::replace(col, widget::panel(FlexDir::Column));
1300 c = c.child(
1301 title_lbl(pretty_item_name(output))
1302 .color(theme.title)
1303 .h(10.0 * sy)
1304 .align(Align::Center),
1305 );
1306
1307 bg_sprites.push(BgSprite {
1309 sheet: SpriteSheet::Crafting,
1310 u: 0.0,
1311 v: 0.0,
1312 uw: 100.0,
1313 uh: 62.0,
1314 x: ox + (rx0 - 2.0) * sx,
1315 y: oy + (ry - 2.0) * sy,
1316 w: 100.0 * sx,
1317 h: 62.0 * sy,
1318 });
1319 if shapeless {
1321 bg_sprites.push(BgSprite {
1322 sheet: SpriteSheet::Crafting,
1323 u: 0.0,
1324 v: 64.0,
1325 uw: 11.0,
1326 uh: 11.0,
1327 x: ox + (rx0 + 62.0) * sx,
1328 y: oy + (ry + 2.0) * sy,
1329 w: 11.0 * sx,
1330 h: 11.0 * sy,
1331 });
1332 }
1333
1334 let icon_s = 16.0 * sx.min(sy);
1335 let width = width.max(1);
1336 for (i, slot) in grid.iter().enumerate() {
1337 if !slot.is_empty() {
1338 overlays.push(OverlayCmd::McItems {
1339 items: slot.clone(),
1340 x: ox + (rx0 + 3.0 + (i % width) as f32 * 19.0) * sx,
1341 y: oy + (ry + 3.0 + (i / width) as f32 * 19.0) * sy,
1342 w: icon_s,
1343 h: icon_s,
1344 });
1345 }
1346 }
1347 overlays.push(OverlayCmd::McItem {
1349 item_id: output.to_owned(),
1350 x: ox + (rx0 + 79.0) * sx,
1351 y: oy + (ry + 22.0) * sy,
1352 w: icon_s,
1353 h: icon_s,
1354 });
1355 if count > 1 {
1356 overlays.push(OverlayCmd::McText {
1357 text: format!("x{}", count),
1358 x: ox + (rx0 + 81.0) * sx,
1359 y: oy + (ry + 40.0) * sy,
1360 color: theme.title,
1361 });
1362 }
1363 overlays.push(OverlayCmd::McItem {
1365 item_id: "minecraft:crafting_table".into(),
1366 x: ox + (rx0 + 79.0) * sx,
1367 y: oy + (ry + 41.0) * sy,
1368 w: icon_s,
1369 h: icon_s,
1370 });
1371
1372 c = c.child(widget::spacer().h((ry + 62.0 + 4.0 - 10.0) * sy));
1374 *col = c;
1375}
1376
1377fn build_page(
1383 page: Option<&BookPage>,
1384 page_num: usize,
1385 theme: &BookTheme,
1386 recipes: &HashMap<String, RecipeVis>,
1387 bg_sprites: &mut Vec<BgSprite>,
1388 overlays: &mut Vec<OverlayCmd>,
1389 ox: f32,
1390 oy: f32,
1391 sx: f32,
1392 sy: f32,
1393) -> widget::Widget {
1394 let mut col = widget::panel(FlexDir::Column).flex(1.0).gap(4.0);
1395
1396 let Some(page) = page else {
1397 return col.child(lbl(word("no_entries", "")).color(theme.nav));
1398 };
1399
1400 match page {
1401 BookPage::Text { text, title, font, font_size } => {
1402 if page_num > 0
1404 && let Some(t) = title
1405 {
1406 let sep_h_px = (SEP_H * sy).max(1.0);
1407 let title_h = 9.0 * sy;
1408 col = col.child(
1409 title_lbl(t.as_str())
1410 .color(theme.title)
1411 .h(title_h)
1412 .align(Align::Center)
1413 .no_wrap(),
1414 );
1415 bg_sprites.push(BgSprite {
1417 sheet: SpriteSheet::Book,
1418 u: SEP_U,
1419 v: SEP_V,
1420 uw: SEP_W,
1421 uh: SEP_H,
1422 x: ox + (PAGE_W / 2.0 - SEP_W / 2.0) * sx,
1423 y: oy + 12.0 * sy,
1424 w: SEP_W * sx,
1425 h: sep_h_px,
1426 });
1427 col = col.child(widget::spacer().h((12.0 - 9.0) * sy));
1428 col = col.child(widget::spacer().h(sep_h_px));
1429 col = col.child(widget::spacer().h((22.0 - 12.0 - SEP_H) * sy));
1430 }
1431 let mut l = lbl(text.as_str()).color(theme.text);
1433 if let Some(f) = font {
1435 l = l.font(f.as_str()).font_scale(text_scale() * font_size.unwrap_or(yog_ui::text::LINE_H) / yog_ui::text::LINE_H);
1436 }
1437 col = col.child(l);
1438 }
1439
1440 BookPage::Spotlight { item, title, text } => {
1441 bg_sprites.push(BgSprite {
1446 sheet: SpriteSheet::Crafting,
1447 u: 0.0,
1448 v: 102.0,
1449 uw: 66.0,
1450 uh: 26.0,
1451 x: ox + (PAGE_W / 2.0 - 33.0) * sx,
1452 y: oy + 10.0 * sy,
1453 w: 66.0 * sx,
1454 h: 26.0 * sy,
1455 });
1456
1457 let item_name = title
1458 .clone()
1459 .unwrap_or_else(|| pretty_item_name(item));
1460 col = col.child(
1461 title_lbl(item_name)
1462 .color(theme.title)
1463 .h(10.0 * sy)
1464 .align(Align::Center),
1465 );
1466
1467 let icon_s = 16.0 * sx.min(sy);
1468 overlays.push(OverlayCmd::McItem {
1469 item_id: item.clone(),
1470 x: ox + (PAGE_W / 2.0 - 8.0) * sx,
1471 y: oy + 15.0 * sy,
1472 w: icon_s,
1473 h: icon_s,
1474 });
1475
1476 col = col.child(widget::spacer().h(30.0 * sy));
1478
1479 if let Some(t) = text {
1480 col = col.child(lbl(t.as_str()).color(theme.text));
1481 }
1482 }
1483
1484 BookPage::Recipe { recipe, text } => {
1485 let rx0 = PAGE_W / 2.0 - 49.0; let ry = 12.0; match recipes.get(recipe.as_str()) {
1490 Some(RecipeVis::Shaped {
1491 grid,
1492 width,
1493 output,
1494 count,
1495 }) => {
1496 draw_crafting_grid(
1497 theme, bg_sprites, overlays, &mut col, grid, *width, output, *count, false,
1498 ox, oy, rx0, ry, sx, sy,
1499 );
1500 }
1501 Some(RecipeVis::Shapeless {
1502 ingredients,
1503 output,
1504 count,
1505 }) => {
1506 draw_crafting_grid(
1507 theme, bg_sprites, overlays, &mut col, ingredients, 3, output, *count, true, ox,
1508 oy, rx0, ry, sx, sy,
1509 );
1510 }
1511 Some(RecipeVis::Smelting { input, output, station }) => {
1512 col = col.child(
1513 title_lbl(pretty_item_name(output))
1514 .color(theme.title)
1515 .h(10.0 * sy)
1516 .align(Align::Center),
1517 );
1518 bg_sprites.push(BgSprite {
1519 sheet: SpriteSheet::Crafting,
1520 u: 11.0,
1521 v: 71.0,
1522 uw: 96.0,
1523 uh: 24.0,
1524 x: ox + rx0 * sx,
1525 y: oy + ry * sy,
1526 w: 96.0 * sx,
1527 h: 24.0 * sy,
1528 });
1529 let icon_s = 16.0 * sx.min(sy);
1530 overlays.push(OverlayCmd::McItems {
1531 items: input.clone(),
1532 x: ox + (rx0 + 4.0) * sx,
1533 y: oy + (ry + 4.0) * sy,
1534 w: icon_s,
1535 h: icon_s,
1536 });
1537 overlays.push(OverlayCmd::McItem {
1539 item_id: station.clone(),
1540 x: ox + (rx0 + 40.0) * sx,
1541 y: oy + (ry + 4.0) * sy,
1542 w: icon_s,
1543 h: icon_s,
1544 });
1545 overlays.push(OverlayCmd::McItem {
1546 item_id: output.clone(),
1547 x: ox + (rx0 + 76.0) * sx,
1548 y: oy + (ry + 4.0) * sy,
1549 w: icon_s,
1550 h: icon_s,
1551 });
1552 col = col.child(widget::spacer().h((ry + 24.0 + 4.0 - 10.0) * sy));
1554 }
1555 _ => {
1556 col = col.child(lbl(format!("[Recipe: {}]", recipe)).color(theme.nav));
1557 }
1558 }
1559 if let Some(t) = text {
1560 col = col.child(lbl(t.as_str()).color(theme.text));
1561 }
1562 }
1563
1564 BookPage::Image {
1565 texture,
1566 title,
1567 text,
1568 ..
1569 } => {
1570 if let Some(t) = title {
1571 col = col.child(title_lbl(t.as_str()).color(theme.title));
1572 }
1573 col = col.child(
1574 widget::image(texture)
1575 .region([0.0, 0.0, 80.0, 80.0])
1576 .stretch(Stretch::Fill)
1577 .w(80.0)
1578 .h(80.0),
1579 );
1580 if let Some(t) = text {
1581 col = col.child(lbl(t.as_str()).color(theme.text));
1582 }
1583 }
1584
1585 BookPage::Relations { entries, text } => {
1586 if let Some(t) = text {
1587 col = col.child(lbl(t.as_str()).color(theme.text));
1588 }
1589 col = col.child(title_lbl(word("see_also", "")).color(theme.title));
1590 for e in entries {
1591 col = col.child(lbl(format!("• {}", e)).color(theme.nav));
1592 }
1593 }
1594
1595 BookPage::Multiblock { name, pattern, mapping, text } => {
1596 let top = if let Some(n) = name {
1598 col = col.child(title_lbl(n.as_str()).color(theme.title).h(10.0 * sy));
1599 MB_NAME_H
1600 } else {
1601 0.0
1602 };
1603 overlays.push(OverlayCmd::Blocks {
1604 blocks: crate::multiblock_blocks(pattern, mapping),
1605 x: ox + (PAGE_W / 2.0 - MB_VIEW / 2.0) * sx,
1606 y: oy + top * sy,
1607 w: MB_VIEW * sx,
1608 h: MB_VIEW * sy,
1609 });
1610 col = col.child(widget::spacer().h(MB_VIEW * sy));
1611 if let Some(t) = text {
1612 col = col.child(lbl(t.as_str()).color(theme.text));
1613 }
1614 }
1615
1616 BookPage::Entity {
1617 entity,
1618 name,
1619 text,
1620 } => {
1621 let display = name.clone().unwrap_or_else(|| entity_name(entity));
1622 col = col.child(title_lbl(display).color(theme.title));
1623 overlays.push(OverlayCmd::Entity {
1624 entity: entity.clone(),
1625 x: ox + (PAGE_W / 2.0 - 40.0) * sx,
1626 y: oy + 12.0 * sy,
1627 w: 80.0 * sx,
1628 h: 80.0 * sy,
1629 });
1630 col = col.child(widget::spacer().h(84.0 * sy));
1631 if let Some(t) = text {
1632 col = col.child(lbl(t.as_str()).color(theme.text));
1633 }
1634 }
1635
1636 BookPage::Pattern {
1637 op_id,
1638 name,
1639 input,
1640 output,
1641 text,
1642 patterns,
1643 ..
1644 } => {
1645 col = col.child(title_lbl(name.as_deref().unwrap_or(op_id.as_str())).color(theme.title).h(10.0 * sy));
1646 if !patterns.is_empty() {
1649 overlays.push(OverlayCmd::Svg {
1650 data: crate::pattern::svg(patterns, PAGE_W, PATTERN_H),
1651 x: ox,
1652 y: oy + 14.0 * sy,
1653 w: PAGE_W * sx,
1654 h: PATTERN_H * sy,
1655 });
1656 col = col.child(widget::spacer().h(PATTERN_H * sy));
1657 }
1658 if !input.is_empty() || !output.is_empty() {
1659 col = col.child(lbl(format!("{{u|{input}}} → {{u|{output}}}")).color(theme.text));
1660 }
1661 if !text.is_empty() {
1662 col = col.child(lbl(text.as_str()).color(theme.text));
1663 }
1664 }
1665
1666 BookPage::Empty => {}
1667 }
1668
1669 col
1670}
1671
1672#[cfg(test)]
1673mod tests {
1674 use super::*;
1675 use crate::BookEntry;
1676
1677 fn book(pages: Vec<BookPage>) -> Book {
1678 let mut book = Book { id: "t:b".into(), name: "B".into(), ..Default::default() };
1679 book.entries.push(BookEntry { id: "e".into(), name: "E".into(), pages, ..Default::default() });
1680 book
1681 }
1682
1683 #[test]
1684 fn long_text_flows_onto_new_pages() {
1685 let long = "word ".repeat(400);
1686 let flowed = flow(book(vec![BookPage::Text { text: long.clone(), title: None, font: None, font_size: None }, BookPage::Empty]));
1687 let pages = &flowed.entries[0].pages;
1688 assert!(pages.len() > 3, "{}", pages.len());
1689 assert!(matches!(pages.last(), Some(BookPage::Empty)), "what came after stays after");
1690 let line_h = yog_ui::text::LINE_H + yog_ui::text::LINE_GAP;
1691 for p in &pages[..pages.len() - 1] {
1692 let BookPage::Text { text, .. } = p else { panic!("{p:?}") };
1693 assert!(text.lines().count() as f32 * line_h <= BODY_H + 1.0);
1694 assert!(text.lines().all(|l| yog_ui::text::str_width(l, 1.0) <= TEXT_W));
1695 }
1696 let words: usize = pages.iter().map(|p| match p {
1697 BookPage::Text { text, .. } => text.split_whitespace().count(),
1698 _ => 0,
1699 }).sum();
1700 assert_eq!(words, 400, "nothing lost");
1701 }
1702
1703 #[test]
1704 fn edits_keep_the_reader_where_they_were() {
1705 let mut b = book(vec![BookPage::Text { text: "one".into(), title: None, font: None, font_size: None }, BookPage::Text { text: "two".into(), title: None, font: None, font_size: None }]);
1706 b.categories.push(crate::BookCategory { id: "c".into(), name: "C".into(), ..Default::default() });
1707 b.entries[0].category = "c".into();
1708 let mut r = BookRenderer::new(b.clone());
1709 assert_eq!(r.shown(), None, "opens on the landing page");
1710 assert!(r.show("e", 1));
1711 assert_eq!(r.shown(), Some(("e".into(), 1)));
1712 r.prepare(800.0, 600.0);
1713 assert!(r.ui.is_some());
1714
1715 b.entries[0].pages.push(BookPage::Empty);
1716 r.set_book(b);
1717 assert_eq!(r.shown(), Some(("e".into(), 1)));
1718 assert!(!r.show("missing", 0));
1719 }
1720
1721 #[test]
1722 fn pages_are_counted_as_written() {
1723 let long = "word ".repeat(400);
1724 let mut b = book(vec![BookPage::Text { text: long, title: None, font: None, font_size: None }, BookPage::Empty]);
1725 b.categories.push(crate::BookCategory { id: "c".into(), name: "C".into(), ..Default::default() });
1726 b.entries[0].category = "c".into();
1727 let mut r = BookRenderer::new(b);
1728 assert!(r.show("e", 1));
1729 assert_eq!(r.shown(), Some(("e".into(), 1)), "the second page as written");
1730 assert!(r.state.page > 1, "past the first page's flowed text");
1731 r.handle_event("prev_page");
1732 assert_eq!(r.shown(), Some(("e".into(), 0)), "flowed text belongs to the first");
1733 }
1734
1735 #[test]
1736 fn reads_yog_recipes() {
1737 let shaped = parse_recipe(r##"{"type":"shaped","pattern":["AB"],"key":{"A":"m:a","B":["m:x","#c:t"]},"result":"m:out","count":2}"##);
1738 let Some(RecipeVis::Shaped { grid, width, output, count }) = shaped else { panic!() };
1739 assert_eq!((width, output.as_str(), count), (2, "m:out", 2));
1740 assert_eq!(grid[1], ["m:x", "#c:t"]);
1741 let blast = parse_recipe(r##"{"type":"blasting","ingredient":"m:ore","result":"m:ingot"}"##);
1742 assert!(matches!(blast, Some(RecipeVis::Smelting { station, .. }) if station == "minecraft:blast_furnace"));
1743 assert!(parse_recipe(r#"{"type":"minecraft:crafting_shaped"}"#).is_none(), "vanilla JSON isn't the book's");
1744 }
1745
1746 #[test]
1747 fn multiblocks_turn_and_animate() {
1748 let pattern = vec![vec!["G".to_string()], vec!["S ".to_string()]];
1749 let mapping = [("G".to_string(), "minecraft:glass".to_string()), ("S".to_string(), "minecraft:stone".to_string())].into();
1750 let blocks = crate::multiblock_blocks(&pattern, &mapping);
1751 assert_eq!(blocks, [((0, 1, 0), "minecraft:glass".to_string()), ((0, 0, 0), "minecraft:stone".to_string())]);
1752
1753 let mut b = book(vec![BookPage::Multiblock { name: None, pattern, mapping, text: None }]);
1754 b.categories.push(crate::BookCategory { id: "c".into(), name: "C".into(), ..Default::default() });
1755 b.entries[0].category = "c".into();
1756 let mut r = BookRenderer::new(b);
1757 r.prepare(800.0, 600.0);
1758 assert!(!r.animated(), "the landing page is still");
1759 assert_eq!(landing_fit("short"), 1.0);
1760 assert!(landing_fit(&"many words here ".repeat(60)) < 1.0, "long landing texts shrink");
1761 r.show("e", 0);
1762 r.prepare(800.0, 600.0);
1763 assert!(r.animated(), "a structure turns");
1764 }
1765
1766 #[test]
1767 fn short_text_stays_put() {
1768 let pages = vec![BookPage::Text { text: "Hello".into(), title: Some("T".into()), font: None, font_size: None }];
1769 let flowed = flow(book(pages.clone()));
1770 assert_eq!(flowed.entries[0].pages.len(), 1);
1771 }
1772}