1use std::collections::{BTreeMap, 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, PartialEq)]
60pub(crate) enum SpriteSheet {
61 Book,
62 Crafting,
63 Image(String),
65}
66
67#[derive(Clone)]
69pub(crate) struct BgSprite {
70 pub sheet: SpriteSheet,
71 pub u: f32,
73 pub v: f32,
74 pub uw: f32,
75 pub uh: f32,
76 pub x: f32,
78 pub y: f32,
79 pub w: f32,
80 pub h: f32,
81}
82
83pub type PageDrawer<'a> = &'a mut dyn FnMut(&GfxContext, &str, &yog_data::Value, [f32; 4]);
87
88#[derive(Clone)]
89pub(crate) enum OverlayCmd {
90 Drawn {
92 drawer: String,
93 data: yog_data::Value,
94 x: f32,
95 y: f32,
96 w: f32,
97 h: f32,
98 },
99 McItems {
102 items: Vec<String>,
103 x: f32,
104 y: f32,
105 w: f32,
106 h: f32,
107 },
108 Blocks {
110 blocks: Vec<((i32, i32, i32), String)>,
111 x: f32,
112 y: f32,
113 w: f32,
114 h: f32,
115 },
116 Entity {
118 entity: String,
119 x: f32,
120 y: f32,
121 w: f32,
122 h: f32,
123 },
124 McText {
126 text: String,
127 x: f32,
128 y: f32,
129 color: u32,
130 },
131 McItem {
133 item_id: String,
134 x: f32,
135 y: f32,
136 w: f32,
137 h: f32,
138 },
139 McTexture {
144 path: String,
145 x: f32,
146 y: f32,
147 w: f32,
148 h: f32,
149 },
150}
151
152fn push_icon(overlays: &mut Vec<OverlayCmd>, icon: &str, x: f32, y: f32, w: f32, h: f32) {
156 if icon.ends_with(".png") {
157 overlays.push(OverlayCmd::McTexture {
158 path: icon.to_string(),
159 x,
160 y,
161 w,
162 h,
163 });
164 } else {
165 overlays.push(OverlayCmd::McItem {
166 item_id: icon.to_string(),
167 x,
168 y,
169 w,
170 h,
171 });
172 }
173}
174
175#[derive(Debug, Clone, PartialEq)]
181pub(crate) struct RecipeVis {
182 pub kind: String,
183 pub slots: BTreeMap<String, Vec<Vec<String>>>,
184 pub output: String,
185 pub count: u32,
186 pub values: BTreeMap<String, String>,
188}
189
190fn ingredient(v: &yog_data::Value) -> Vec<String> {
193 use yog_data::Value;
194 match v {
195 Value::Str(s) => vec![s.clone()],
196 Value::Ref(r) => vec![r.id.clone()],
197 Value::Tag(r) => vec![format!("#{}", r.id)],
198 Value::List(a) => a.iter().flat_map(ingredient).collect(),
199 _ => Vec::new(),
200 }
201}
202
203fn parse_recipe(v: &yog_data::Value) -> Option<RecipeVis> {
206 use yog_data::Value;
207 let kind = v.get("type")?.as_str()?.to_owned();
208 let output = match v.get("result")? {
209 Value::Ref(r) => r.id.clone(),
210 r => r.as_str()?.to_owned(),
211 };
212 let count = v.get("count").and_then(|c| c.as_i64()).unwrap_or(1).max(0) as u32;
213 let mut slots = BTreeMap::new();
214 let mut values = BTreeMap::new();
215 let text = |x: &Value| match x {
216 Value::Int(i) => i.to_string(),
217 Value::Float(f) => f.to_string(),
218 Value::Bool(b) => b.to_string(),
219 x => x.as_str().map(str::to_owned).unwrap_or_default(),
220 };
221 match kind.as_str() {
222 "shaped" => {
223 let key = v.get("key")?.as_map()?;
225 let mut grid = vec![Vec::new(); 9];
226 for (r, row) in v.get("pattern")?.as_list()?.iter().filter_map(|p| p.as_str()).take(3).enumerate() {
227 let mut buf = [0u8; 4];
228 for (c, ch) in row.chars().take(3).enumerate() {
229 grid[r * 3 + c] = key.get(ch.encode_utf8(&mut buf)).map(ingredient).unwrap_or_default();
230 }
231 }
232 slots.insert("grid".into(), grid);
233 }
234 "shapeless" => {
235 slots.insert("grid".into(), v.get("ingredients")?.as_list()?.iter().map(ingredient).collect());
236 }
237 "smelting" | "blasting" | "smoking" | "campfire_cooking" | "stonecutting" => {
238 slots.insert("input".into(), vec![ingredient(v.get("ingredient")?)]);
239 let default_time = match kind.as_str() {
240 "smelting" => Some(200),
241 "blasting" | "smoking" => Some(100),
242 "campfire_cooking" => Some(600),
243 _ => None,
244 };
245 if let Some(t) = v.get("cook_time").and_then(|t| t.as_i64()).or(default_time) {
246 values.insert("time".into(), t.to_string());
247 }
248 if let Some(x) = v.get("experience") {
249 values.insert("experience".into(), text(x));
250 }
251 }
252 "smithing" => {
253 for part in ["template", "base", "addition"] {
254 slots.insert(part.into(), vec![v.get(part).map(ingredient).unwrap_or_default()]);
255 }
256 }
257 _ => {
258 for (name, list) in v.get("slots").and_then(|s| s.as_map()).into_iter().flat_map(|m| m.iter()) {
260 slots.insert(name.to_string(), list.as_list().into_iter().flatten().map(ingredient).collect());
261 }
262 for (name, x) in v.get("values").and_then(|s| s.as_map()).into_iter().flat_map(|m| m.iter()) {
263 values.insert(name.to_string(), text(x));
264 }
265 }
266 }
267 Some(RecipeVis { kind, slots, output, count, values })
268}
269
270fn normalize_item_id(id: &str) -> String {
273 let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
274 let name = path
275 .trim_start_matches("item/")
276 .trim_start_matches("block/");
277 format!("{ns}:{name}")
278}
279
280fn book_sheet(texture: &str) -> String {
283 let t = texture.trim();
284 if t.is_empty() || t == "yog:gui/book" {
285 return BOOK_SHEET.to_string();
286 }
287 let (ns, path) = t.split_once(':').unwrap_or(("minecraft", t));
288 let path = path.strip_prefix("textures/").unwrap_or(path);
289 format!("{ns}:{}", path.strip_suffix(".png").unwrap_or(path))
290}
291
292static ITEM_NAMES: std::sync::RwLock<Option<Translator>> = std::sync::RwLock::new(None);
294
295type Translator = fn(&str) -> Option<String>;
296
297fn word(key: &str, arg: &str) -> String {
300 let k = format!("yog.book.{key}");
301 let tr = *ITEM_NAMES.read().unwrap_or_else(|e| e.into_inner());
302 let text = tr.and_then(|tr| tr(&k)).filter(|t| *t != k).or_else(|| {
303 static EN: std::sync::LazyLock<HashMap<String, String>> =
305 std::sync::LazyLock::new(|| serde_json::from_str(LANG[0].1).unwrap_or_default());
306 EN.get(&k).cloned()
307 });
308 let text = text.unwrap_or(k);
309 text.replace("%s", arg)
310}
311
312fn name_from_path(path: &str) -> String {
314 path.rsplit('/')
315 .flat_map(|seg| seg.split('_'))
316 .filter(|w| !w.is_empty())
317 .map(|w| {
318 let mut c = w.chars();
319 match c.next() {
320 Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
321 None => String::new(),
322 }
323 })
324 .collect::<Vec<_>>()
325 .join(" ")
326}
327
328fn ordinal(n: u32) -> String {
331 let suffixes = word("ordinals", "");
332 let s: Vec<&str> = suffixes.split_whitespace().collect();
333 if s.len() < 4 {
334 return n.to_string();
335 }
336 let i = match (n % 100, n % 10) {
337 (11..=13, _) => 0,
338 (_, 1) => 1,
339 (_, 2) => 2,
340 (_, 3) => 3,
341 _ => 0,
342 };
343 format!("{n}{}", s[i])
344}
345
346fn entity_name(id: &str) -> String {
349 let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
350 let k = format!("entity.{ns}.{}", path.replace('/', "."));
351 let tr = *ITEM_NAMES.read().unwrap_or_else(|e| e.into_inner());
352 tr.and_then(|tr| tr(&k))
353 .filter(|t| *t != k)
354 .unwrap_or_else(|| name_from_path(path))
355}
356
357fn pretty_item_name(id: &str) -> String {
360 let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
361 if let Some(tr) = *ITEM_NAMES.read().unwrap_or_else(|e| e.into_inner()) {
362 let key = path.replace('/', ".");
363 for kind in ["item", "block"] {
364 let k = format!("{kind}.{ns}.{key}");
365 if let Some(name) = tr(&k).filter(|n| *n != k) {
366 return name;
367 }
368 }
369 }
370 name_from_path(path)
371}
372
373pub struct BookRenderer {
376 pub book: Book,
377 pub state: BookViewState,
378 pub theme: BookTheme,
379 recipes: HashMap<String, RecipeVis>,
380 origins: HashMap<String, Vec<usize>>,
382 written: Book,
384 measured: bool,
386 start: std::time::Instant,
388 pub ui: Option<UiRoot>,
389 bg_sprites: Vec<BgSprite>,
390 overlays: Vec<OverlayCmd>,
391 dirty: bool,
392 last_sw: f32,
393 last_sh: f32,
394 source: Option<Book>,
397 translator: Option<fn(&str) -> Option<String>>,
398 probe: Option<Option<String>>,
399}
400
401impl BookRenderer {
402 pub fn new(book: Book) -> Self {
403 let theme = BookTheme::default().with_nameplate(&book.nameplate_color);
404 let written = styled(&book);
405 let (flowed, origins) = flow_traced(written.clone());
406 let source = book.i18n.then_some(book);
409 let book = flowed;
410 Self {
411 book,
412 origins,
413 written,
414 measured: false,
415 start: std::time::Instant::now(),
416 state: BookViewState::default(),
417 theme,
418 recipes: HashMap::new(),
419 ui: None,
420 bg_sprites: Vec::new(),
421 overlays: Vec::new(),
422 dirty: true,
423 last_sw: 0.0,
424 last_sh: 0.0,
425 translator: None,
426 probe: None,
427 source,
428 }
429 }
430
431 pub fn book(&self) -> &Book {
433 &self.book
434 }
435
436 pub fn set_translator(&mut self, tr: fn(&str) -> Option<String>) {
441 *ITEM_NAMES.write().unwrap_or_else(|e| e.into_inner()) = Some(tr);
442 if self.book.i18n {
443 self.translator = Some(tr);
444 self.probe = None;
445 }
446 }
447
448 fn localize(&mut self) {
450 let (Some(src), Some(tr)) = (&self.source, self.translator) else {
451 return;
452 };
453 let probe = Some(tr(&src.name));
454 if probe == self.probe {
455 return;
456 }
457 let accent = src.accent.clone();
458 self.written = src.localized(|k| {
459 crate::markup::resolve(&tr(k).unwrap_or_else(|| k.to_string()), &accent)
460 });
461 (self.book, self.origins) = flow_traced(self.written.clone());
462 self.probe = probe;
463 self.dirty = true;
464 }
465
466 pub fn handle_event(&mut self, ev: &str) {
467 if self.state.handle(ev, &self.book) {
468 self.dirty = true;
469 }
470 }
471
472 pub fn add_recipe(&mut self, id: impl Into<String>, recipe: &yog_data::Value) {
475 if let Some(vis) = parse_recipe(recipe) {
476 self.recipes.insert(id.into(), vis);
477 self.dirty = true;
478 }
479 }
480
481 fn prepare(&mut self, sw: f32, sh: f32) -> (f32, f32, f32, f32) {
484 let scale = ((sw - SCREEN_MARGIN) / BK_W)
487 .min((sh - SCREEN_MARGIN) / BK_H)
488 .clamp(MIN_SCALE, 1.0);
489 let bw = BK_W * scale;
490 let bh = BK_H * scale;
491 let bx = (sw - bw) / 2.0;
492 let by = (sh - bh) / 2.0;
493
494 self.localize();
495 if self.dirty || sw != self.last_sw || sh != self.last_sh {
497 let (root, bg_sprites, overlays) = build_ui(
498 &self.book,
499 &self.state,
500 &self.theme,
501 &self.recipes,
502 bx,
503 by,
504 bw,
505 bh,
506 );
507 self.ui = Some(root);
508 self.bg_sprites = bg_sprites;
509 self.overlays = overlays;
510 self.dirty = false;
511 self.last_sw = sw;
512 self.last_sh = sh;
513 }
514 if let Some(ui) = &mut self.ui
515 && ui.needs_layout
516 {
517 ui.layout(sw, sh);
518 }
519 (bx, by, bw, bh)
520 }
521
522 pub fn render(&mut self, ctx: &GfxContext, sw: f32, sh: f32) {
528 self.render_with(ctx, sw, sh, &mut |_, _, _, _| {});
529 }
530
531 pub fn render_with(&mut self, ctx: &GfxContext, sw: f32, sh: f32, draw: PageDrawer<'_>) {
535 yog_ui::text::use_host_metrics(ctx);
537 if !self.measured {
538 self.measured = true;
539 self.reflow();
540 }
541 let (bx, by, bw, bh) = self.prepare(sw, sh);
542 let d2 = ctx.draw2d();
543 let book_sheet = book_sheet(&self.book.book_texture);
544 d2.image_region(
545 &book_sheet,
546 bx,
547 by,
548 bw,
549 bh,
550 [0.0, 0.0, BK_W, BK_H],
551 0xFFFF_FFFF,
552 );
553 for spr in &self.bg_sprites {
554 let sheet = match &spr.sheet {
555 SpriteSheet::Book => &book_sheet,
556 SpriteSheet::Crafting => CRAFTING_SHEET,
557 SpriteSheet::Image(source) => source.as_str(),
558 };
559 d2.image_region(
560 sheet,
561 spr.x,
562 spr.y,
563 spr.w,
564 spr.h,
565 [spr.u, spr.v, spr.uw, spr.uh],
566 0xFFFF_FFFF,
567 );
568 }
569 if let Some(ui) = &mut self.ui {
570 ui.render(ctx);
571 }
572 let t = self.start.elapsed().as_secs_f32();
575 for ov in &self.overlays {
576 match *ov {
577 OverlayCmd::Drawn {
578 ref drawer,
579 ref data,
580 x,
581 y,
582 w,
583 h,
584 } => draw(ctx, drawer, data, [x, y, w, h]),
585 OverlayCmd::Entity {
586 ref entity,
587 x,
588 y,
589 w,
590 h,
591 } => d2.entity(entity, x, y, w, h, -30.0 + t * 30.0, 0.0),
592 OverlayCmd::Blocks {
593 ref blocks,
594 x,
595 y,
596 w,
597 h,
598 } => {
599 let list: Vec<((i32, i32, i32), &str)> =
600 blocks.iter().map(|(p, b)| (*p, b.as_str())).collect();
601 d2.blocks(&list, x, y, w, h, -45.0 + t * 20.0, 30.0);
602 }
603 _ => {}
604 }
605 }
606 self.draw_mc_overlays(ctx);
608 }
609
610 fn draw_mc_overlays(&self, ctx: &GfxContext) {
611 let second = self.start.elapsed().as_secs() as usize;
612 for ov in &self.overlays {
613 match *ov {
614 OverlayCmd::McItems {
615 ref items,
616 x,
617 y,
618 w,
619 h,
620 } => {
621 let shown: Vec<&String> =
622 items.iter().filter(|i| !i.starts_with('#')).collect();
623 if !shown.is_empty() {
624 let item = shown[second % shown.len()];
625 ctx.draw2d().item(&normalize_item_id(item), x, y, w.min(h));
626 }
627 }
628 OverlayCmd::McItem {
629 ref item_id,
630 x,
631 y,
632 w,
633 h,
634 } => {
635 ctx.draw2d()
636 .item(&normalize_item_id(item_id), x, y, w.min(h));
637 }
638 OverlayCmd::McText {
639 ref text,
640 x,
641 y,
642 color,
643 } => {
644 ctx.draw2d().text(text, x, y, color, false);
645 }
646 OverlayCmd::McTexture {
647 ref path,
648 x,
649 y,
650 w,
651 h,
652 } => {
653 ctx.draw2d().image(path, x, y, w, h);
656 }
657 _ => {}
658 }
659 }
660 }
661
662 pub fn animated(&self) -> bool {
665 self.overlays.iter().any(|o| match o {
666 OverlayCmd::Entity { .. } | OverlayCmd::Blocks { .. } => true,
667 OverlayCmd::McItems { items, .. } => {
668 items.iter().filter(|i| !i.starts_with('#')).count() > 1
669 }
670 _ => false,
671 })
672 }
673
674 pub fn click(&mut self, x: f32, y: f32) -> Option<String> {
678 let ui = self.ui.as_ref()?;
679 let hit = yog_ui::layout::hit_test(&ui.layout_root, x, y)?;
680 let action = hit.on_click.clone()?;
681 self.handle_event(&action);
682 Some(action)
683 }
684
685 pub fn show(&mut self, entry: &str, page: usize) -> bool {
689 let Some(e) = self.book.entries.iter().find(|e| e.id == entry) else {
690 return false;
691 };
692 let Some(cat) = self.book.categories.iter().position(|c| c.id == e.category) else {
693 return false;
694 };
695 self.state.cat = cat;
696 self.state.at_home = false;
697 let in_cat = self.state.entries_in_cat(&self.book);
698 let Some(i) = in_cat.iter().position(|x| x.id == entry) else {
699 return false;
700 };
701 let shown = self
702 .origins
703 .get(entry)
704 .and_then(|o| o.iter().position(|&w| w >= page))
705 .unwrap_or(page);
706 self.state.entry = i;
707 self.state.page = shown.min(e.pages.len().saturating_sub(1));
708 self.dirty = true;
709 true
710 }
711
712 pub fn shown(&self) -> Option<(String, usize)> {
715 if self.state.at_home {
716 return None;
717 }
718 let e = self.state.current_entry(&self.book)?;
719 let page = self
720 .origins
721 .get(&e.id)
722 .and_then(|o| o.get(self.state.page))
723 .copied()
724 .unwrap_or(self.state.page);
725 Some((e.id.clone(), page))
726 }
727
728 fn reflow(&mut self) {
730 let at = self.shown();
731 (self.book, self.origins) = flow_traced(self.written.clone());
732 self.dirty = true;
733 if let Some((entry, page)) = at {
734 self.show(&entry, page);
735 }
736 }
737
738 pub fn set_book(&mut self, book: Book) {
740 let at = self.shown();
741 self.written = styled(&book);
742 self.probe = None;
743 self.source = book.i18n.then_some(book);
744 (self.book, self.origins) = flow_traced(self.written.clone());
745 self.localize();
746 self.theme = BookTheme::default().with_nameplate(&self.book.nameplate_color);
747 self.dirty = true;
748 match at {
749 Some((entry, page)) if self.show(&entry, page) => {}
750 _ => {
751 if self.state.cat >= self.book.categories.len() {
752 self.state = BookViewState::default();
753 }
754 }
755 }
756 }
757}
758
759thread_local! {
762 static TEXT_SCALE: std::cell::Cell<f32> = const { std::cell::Cell::new(1.0) };
765}
766
767fn text_scale() -> f32 {
768 TEXT_SCALE.with(|s| s.get())
769}
770
771fn lbl(text: impl Into<String>) -> widget::Widget {
773 widget::label(text).shadow(false).font_scale(text_scale())
774}
775fn btn(text: impl Into<String>) -> widget::Widget {
777 widget::button(text).shadow(false).font_scale(text_scale())
778}
779
780fn title_lbl(text: impl Into<String>) -> widget::Widget {
783 let text = text.into();
784 let w = PAGE_W - 10.0;
786 let fit = (w / yog_ui::text::str_width(&text, 1.0)).clamp(0.5, 1.0);
787 widget::label(text)
788 .shadow(false)
789 .font_scale(text_scale() * fit)
790 .no_wrap()
791 .ellipsis()
792 .w(w * text_scale())
793}
794
795const TEXT_W: f32 = PAGE_W - 8.0;
799const BODY_H: f32 = PAGE_H - 22.0 - 12.0 - 14.0;
802
803const MB_VIEW: f32 = 70.0;
806const MB_NAME_H: f32 = 14.0;
807
808fn content_above_text(page: &BookPage) -> Option<f32> {
810 Some(match page {
811 BookPage::Text { title: Some(_), .. } => 22.0,
812 BookPage::Text { .. } => 0.0,
813 BookPage::Spotlight { .. } => 40.0,
814 BookPage::Recipe { .. } => 80.0,
816 BookPage::Image { .. } | BookPage::Entity { .. } => 104.0,
817 BookPage::Drawn { title, height, caption, .. } => {
818 let title = if title.is_some() { 14.0 } else { 0.0 };
819 let picture = if *height > 0.0 { height + 4.0 } else { 0.0 };
820 title + picture + if caption.is_some() { 14.0 } else { 0.0 }
821 }
822 BookPage::Multiblock { name, .. } => {
823 MB_VIEW + 4.0 + if name.is_some() { MB_NAME_H } else { 0.0 }
824 }
825 BookPage::Relations { entries, .. } => 12.0 + entries.len() as f32 * 10.0,
826 _ => return None,
827 })
828}
829
830fn page_text_mut(page: &mut BookPage) -> Option<&mut String> {
831 match page {
832 BookPage::Text { text, .. } => Some(text),
833 BookPage::Spotlight { text, .. }
834 | BookPage::Recipe { text, .. }
835 | BookPage::Image { text, .. }
836 | BookPage::Entity { text, .. }
837 | BookPage::Multiblock { text, .. }
838 | BookPage::Relations { text, .. }
839 | BookPage::Drawn { text, .. } => text.as_mut(),
840 _ => None,
841 }
842}
843
844fn styled(book: &Book) -> Book {
846 book.localized(|t| crate::markup::resolve(t, &book.accent))
847}
848
849#[cfg(test)]
851fn flow(book: Book) -> Book {
852 flow_traced(book).0
853}
854
855fn flow_traced(mut book: Book) -> (Book, HashMap<String, Vec<usize>>) {
858 let mut origins = HashMap::new();
859 let line_h = yog_ui::text::LINE_H + yog_ui::text::LINE_GAP;
860 let fits = |h: f32| (((h + yog_ui::text::LINE_GAP) / line_h).floor() as usize).max(1);
861 for entry in &mut book.entries {
862 let mut pages = Vec::with_capacity(entry.pages.len());
863 let mut from = Vec::with_capacity(entry.pages.len());
864 for (written, page) in std::mem::take(&mut entry.pages).into_iter().enumerate() {
865 let split = split_page(page, &book.page_types, &fits);
866 from.extend(std::iter::repeat_n(written, split.len()));
867 pages.extend(split);
868 }
869 entry.pages = pages;
870 origins.insert(entry.id.clone(), from);
871 }
872 (book, origins)
873}
874
875fn custom_text(page: &crate::page_type::CustomPage, types: &[crate::page_type::PageType]) -> Option<(String, f32)> {
879 use crate::page_type::PageElement as E;
880 let t = types.iter().find(|t| t.id == page.kind)?;
881 let line_h = yog_ui::text::LINE_H + yog_ui::text::LINE_GAP;
882 let shown: Vec<&E> = t.body.iter().filter(|el| match el {
883 E::Header { visible, .. } | E::Text { visible, .. } | E::Item { visible, .. } | E::Image { visible, .. } | E::Drawn { visible, .. } => t.visible(page, visible),
884 E::Spacer { .. } => true,
885 }).collect();
886 let at = shown.iter().rposition(|el| matches!(el, E::Text { .. }))?;
887 let E::Text { text, .. } = shown[at] else { return None };
888 let name = text.trim().strip_prefix("{x:Bind ")?.strip_suffix('}')?.trim();
889 if !matches!(page.fields.get(name), Some(yog_data::Value::Str(_))) {
890 return None;
891 }
892 let others: f32 = shown.iter().enumerate().filter(|(i, _)| *i != at).map(|(_, el)| match el {
893 E::Header { .. } => 14.0,
894 E::Text { text, .. } => t.text(page, text).split('\n').map(|p| yog_ui::text::wrap_text(p, TEXT_W, 1.0).len().max(1)).sum::<usize>() as f32 * line_h + 4.0,
895 E::Item { .. } => 20.0,
896 E::Image { height, .. } | E::Drawn { height, .. } | E::Spacer { height } => height + 4.0,
897 }).sum();
898 Some((name.to_string(), others))
899}
900
901fn split_page(mut page: BookPage, types: &[crate::page_type::PageType], fits: &dyn Fn(f32) -> usize) -> Vec<BookPage> {
903 let (above, text) = match &mut page {
904 BookPage::Custom(c) => {
905 let Some((name, above)) = custom_text(c, types) else { return vec![page] };
906 let Some(yog_data::Value::Str(text)) = c.fields.get_mut(&name) else { return vec![page] };
907 (above, text)
908 }
909 _ => {
910 let Some(above) = content_above_text(&page) else {
911 return vec![page];
912 };
913 let Some(text) = page_text_mut(&mut page) else {
914 return vec![page];
915 };
916 (above, text)
917 }
918 };
919 let lines: Vec<String> = text
920 .split('\n')
921 .flat_map(|para| {
922 if para.is_empty() {
923 vec![String::new()]
924 } else {
925 yog_ui::text::wrap_text(para, TEXT_W, 1.0)
926 }
927 })
928 .collect();
929 let first = fits(BODY_H - above);
930 if lines.len() <= first {
931 return vec![page];
932 }
933 *text = lines[..first].join("\n");
934 let rest: Vec<BookPage> = lines[first..]
935 .chunks(fits(BODY_H))
936 .map(|chunk| BookPage::Text {
937 text: chunk.join("\n"),
938 title: None,
939 font: None,
940 font_size: None,
941 })
942 .collect();
943 std::iter::once(page).chain(rest).collect()
944}
945
946fn build_ui(
949 book: &Book,
950 state: &BookViewState,
951 theme: &BookTheme,
952 recipes: &HashMap<String, RecipeVis>,
953 bx: f32,
954 by: f32,
955 bw: f32,
956 bh: f32,
957) -> (UiRoot, Vec<BgSprite>, Vec<OverlayCmd>) {
958 let mut bg_sprites: Vec<BgSprite> = Vec::new();
959 let mut overlays: Vec<OverlayCmd> = Vec::new();
960
961 let sx = bw / BK_W;
963 TEXT_SCALE.with(|s| s.set(sx));
964 let sy = bh / BK_H;
965
966 let pw = PAGE_W * sx;
968 let ph = PAGE_H * sy;
969 let spine_gap = (RIGHT_X - LEFT_X - PAGE_W) * sx; let lx = bx + LEFT_X * sx;
973 let rx = bx + RIGHT_X * sx;
974 let py = by + TOP_PAD * sy;
975
976 let sep_cx = (PAGE_W / 2.0 - SEP_W / 2.0) * sx; if state.at_home {
980 bg_sprites.push(BgSprite {
982 sheet: SpriteSheet::Book,
983 u: 0.0,
984 v: 180.0,
985 uw: 140.0,
986 uh: 31.0,
987 x: bx + (LEFT_X - 8.0) * sx,
988 y: by + 12.0 * sy,
989 w: 140.0 * sx,
990 h: 31.0 * sy,
991 });
992 }
993 bg_sprites.push(BgSprite {
996 sheet: SpriteSheet::Book,
997 u: SEP_U,
998 v: SEP_V,
999 uw: SEP_W,
1000 uh: SEP_H,
1001 x: rx + sep_cx,
1002 y: by + (TOP_PAD + 12.0) * sy,
1003 w: SEP_W * sx,
1004 h: (SEP_H * sy).max(1.0),
1005 });
1006
1007 let (left_page, right_page) = if state.at_home {
1008 let l = build_landing_left(book, theme, pw, ph, sx, sy, bx, by, &mut overlays);
1009 let r = build_categories_right(book, state, theme, pw, ph, rx, py, sx, sy, &mut overlays);
1010 (l, r)
1011 } else {
1012 let l = build_entry_left(
1013 book,
1014 state,
1015 theme,
1016 recipes,
1017 pw,
1018 ph,
1019 lx,
1020 py,
1021 sx,
1022 sy,
1023 sep_cx,
1024 &mut bg_sprites,
1025 &mut overlays,
1026 );
1027 let r = build_entries_right(book, state, theme, pw, ph, rx, py, sx, sy, &mut overlays);
1028 (l, r)
1029 };
1030
1031 let root_widget = widget::panel(FlexDir::Row)
1039 .w(bw)
1040 .h(bh)
1041 .gap(0.0)
1042 .padding(TOP_PAD * sy, 0.0, 0.0, LEFT_X * sx)
1043 .child(left_page)
1044 .child(widget::spacer().w(spine_gap)) .child(right_page);
1046
1047 let outer = widget::panel(FlexDir::Column)
1050 .w(bw)
1051 .h(bh)
1052 .child(root_widget);
1053
1054 let screen_root = widget::panel(FlexDir::Row)
1059 .padding(by, 0.0, 0.0, bx)
1060 .child(outer);
1061
1062 let ui = UiRoot::new(&book.id, screen_root);
1063 (ui, bg_sprites, overlays)
1064}
1065
1066fn build_landing_left(
1069 book: &Book,
1070 theme: &BookTheme,
1071 page_w: f32,
1072 page_h: f32,
1073 sx: f32,
1074 sy: f32,
1075 bx: f32,
1076 by: f32,
1077 overlays: &mut Vec<OverlayCmd>,
1078) -> widget::Widget {
1079 overlays.push(OverlayCmd::McText {
1082 text: book.name.clone(),
1083 x: bx + 13.0 * sx,
1084 y: by + 16.0 * sy,
1085 color: theme.nameplate,
1086 });
1087 let second = match book.version {
1089 0 => book.subtitle.clone(),
1090 n => Some(word("edition", &ordinal(n))),
1091 };
1092 if let Some(text) = second {
1093 overlays.push(OverlayCmd::McText {
1094 text,
1095 x: bx + 24.0 * sx,
1096 y: by + 24.0 * sy,
1097 color: theme.nameplate,
1098 });
1099 }
1100
1101 let mut col = widget::panel(FlexDir::Column)
1105 .w(page_w)
1106 .h(page_h)
1107 .padding(25.0 * sy, 6.0, 4.0, 4.0)
1108 .gap(0.0);
1109
1110 let fit = landing_fit(&book.landing_text);
1112 for para in book.landing_text.split('\n') {
1113 if para.is_empty() {
1114 col = col.child(widget::spacer().h(4.0 * sy));
1115 } else {
1116 col = col.child(lbl(para).color(theme.text).font_scale(text_scale() * fit));
1117 }
1118 }
1119
1120 let total = book.entries.len();
1121 col = col.child(widget::spacer().flex(1.0));
1122 col = col.child(
1123 title_lbl(word("entry_count", &total.to_string()))
1124 .color(theme.nav)
1125 .h(9.0 * sy)
1126 .align(Align::Center),
1127 );
1128 col
1129}
1130
1131fn landing_fit(text: &str) -> f32 {
1133 use yog_ui::text::{LINE_GAP, LINE_H, line_count};
1134 let room = PAGE_H - 25.0 - 4.0 - 9.0 - 2.0;
1135 let height = |scale: f32| -> f32 {
1136 text.split('\n')
1137 .map(|p| match p.is_empty() {
1138 true => 4.0,
1139 false => line_count(p, TEXT_W, scale) as f32 * (LINE_H * scale + LINE_GAP),
1140 })
1141 .sum()
1142 };
1143 let mut scale = 1.0;
1144 while scale > 0.6 && height(scale) > room {
1145 scale -= 0.05;
1146 }
1147 scale
1148}
1149
1150fn build_categories_right(
1153 book: &Book,
1154 state: &BookViewState,
1155 theme: &BookTheme,
1156 page_w: f32,
1157 page_h: f32,
1158 rx: f32,
1159 py: f32,
1160 sx: f32,
1161 sy: f32,
1162 overlays: &mut Vec<OverlayCmd>,
1163) -> widget::Widget {
1164 let cell_w = 24.0 * sx;
1169 let cell_h = 24.0 * sy;
1170 let grid_pad_left = 10.0 * sx;
1171 let header_h = 9.0 * sy;
1172
1173 let mut col = widget::panel(FlexDir::Column)
1174 .w(page_w)
1175 .h(page_h)
1176 .padding(0.0, 4.0, 4.0, 0.0)
1177 .gap(0.0);
1178
1179 col = col.child(
1180 title_lbl(word("categories", ""))
1181 .color(theme.divider)
1182 .h(header_h)
1183 .align(Align::Center)
1184 .no_wrap(),
1185 );
1186 col = col.child(widget::spacer().h(25.0 * sy - header_h));
1188
1189 let cats = &book.categories;
1191 let icon_s = 16.0 * sx.min(sy);
1192 let mut row_i = 0usize;
1193 loop {
1194 let row_start = row_i * 4;
1195 if row_start >= cats.len() {
1196 break;
1197 }
1198 let mut row =
1199 widget::panel(FlexDir::Row)
1200 .h(cell_h)
1201 .gap(0.0)
1202 .padding(0.0, 0.0, 0.0, grid_pad_left);
1203 for col_i in 0..4 {
1204 let idx = row_start + col_i;
1205 if idx >= cats.len() {
1206 break;
1207 }
1208 let cat = &cats[idx];
1209 let selected = !state.at_home && idx == state.cat;
1210 let bg = if selected { theme.nav_selected_bg } else { 0 };
1211
1212 if let Some(icon_id) = &cat.icon {
1213 push_icon(
1214 overlays,
1215 icon_id,
1216 rx + (10.0 + col_i as f32 * 24.0 + 4.0) * sx,
1217 py + (25.0 + row_i as f32 * 24.0 + 4.0) * sy,
1218 icon_s,
1219 icon_s,
1220 );
1221 }
1222 let cell = widget::panel(FlexDir::Column)
1223 .w(cell_w)
1224 .h(cell_h)
1225 .bg(bg)
1226 .on_click(format!("cat:{}", idx))
1227 .id(format!("book_cat_{}", idx));
1228 row = row.child(cell);
1229 }
1230 col = col.child(row);
1231 row_i += 1;
1232 }
1233 col
1234}
1235
1236fn build_entry_left(
1239 book: &Book,
1240 state: &BookViewState,
1241 theme: &BookTheme,
1242 recipes: &HashMap<String, RecipeVis>,
1243 page_w: f32,
1244 page_h: f32,
1245 ox: f32,
1246 oy: f32,
1247 sx: f32,
1248 sy: f32,
1249 sep_cx: f32,
1250 bg_sprites: &mut Vec<BgSprite>,
1251 overlays: &mut Vec<OverlayCmd>,
1252) -> widget::Widget {
1253 let entry = state.current_entry(book);
1254 let page = entry.and_then(|e| e.pages.get(state.page));
1255 let page_count = state.page_count(book);
1256 let title_text = entry.map(|e| e.name.as_str()).unwrap_or("");
1257
1258 let title_h = 9.0 * sy;
1263 let sep_h_px = (SEP_H * sy).max(1.0);
1264 let sep_y = oy + 12.0 * sy;
1265 let body_oy = oy + 22.0 * sy;
1266
1267 bg_sprites.push(BgSprite {
1268 sheet: SpriteSheet::Book,
1269 u: SEP_U,
1270 v: SEP_V,
1271 uw: SEP_W,
1272 uh: SEP_H,
1273 x: ox + sep_cx,
1274 y: sep_y,
1275 w: SEP_W * sx,
1276 h: sep_h_px,
1277 });
1278
1279 let page_label = format!("{}/{}", state.page + 1, page_count);
1280 let nav = widget::panel(FlexDir::Row)
1281 .h(12.0 * sy)
1282 .gap(4.0)
1283 .padding(0.0, 2.0, 0.0, 2.0)
1284 .child(
1285 btn("◀")
1286 .w(12.0 * sx)
1287 .h(12.0 * sy)
1288 .color(theme.nav)
1289 .on_click("prev_page")
1290 .id("prev_page"),
1291 )
1292 .child(
1293 lbl(&page_label)
1294 .color(theme.nav)
1295 .flex(1.0)
1296 .align(Align::Center),
1297 )
1298 .child(
1299 btn("⌂")
1300 .w(12.0 * sx)
1301 .h(12.0 * sy)
1302 .color(theme.nav)
1303 .on_click("home")
1304 .id("book_home"),
1305 )
1306 .child(
1307 btn("▶")
1308 .w(12.0 * sx)
1309 .h(12.0 * sy)
1310 .color(theme.nav)
1311 .on_click("next_page")
1312 .id("next_page"),
1313 );
1314
1315 let page_body = build_page(
1316 page,
1317 state.page,
1318 theme,
1319 recipes,
1320 &book.layouts,
1321 &book.page_types,
1322 bg_sprites,
1323 overlays,
1324 ox,
1325 body_oy,
1326 sx,
1327 sy,
1328 );
1329
1330 widget::panel(FlexDir::Column)
1331 .w(page_w)
1332 .h(page_h)
1333 .padding(0.0, 6.0, 4.0, 4.0)
1334 .gap(0.0)
1335 .child(
1336 title_lbl(title_text)
1337 .color(theme.title)
1338 .h(title_h)
1339 .align(Align::Center)
1340 .no_wrap(),
1341 )
1342 .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)
1346 .child(nav)
1347}
1348
1349fn build_entries_right(
1352 book: &Book,
1353 state: &BookViewState,
1354 theme: &BookTheme,
1355 page_w: f32,
1356 page_h: f32,
1357 rx: f32,
1358 py: f32,
1359 sx: f32,
1360 sy: f32,
1361 overlays: &mut Vec<OverlayCmd>,
1362) -> widget::Widget {
1363 let entries = state.entries_visible(book);
1364 let cat_name = book
1365 .categories
1366 .get(state.cat)
1367 .map(|c| c.name.as_str())
1368 .map(str::to_string)
1369 .unwrap_or_else(|| word("entries", ""));
1370 let spread_count = state.list_spread_count(book);
1371
1372 let header_h = 9.0 * sy;
1377 let row_h = 11.0 * sy;
1378 let icon_size = 8.0 * sx.min(sy);
1379
1380 let mut col = widget::panel(FlexDir::Column)
1381 .w(page_w)
1382 .h(page_h)
1383 .padding(0.0, 4.0, 8.0, 0.0)
1384 .gap(0.0);
1385
1386 col = col.child(
1387 title_lbl(cat_name)
1388 .color(theme.divider)
1389 .h(header_h)
1390 .align(Align::Center),
1391 );
1392 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();
1398 for (i, entry) in entries.iter().enumerate() {
1399 let abs_i = abs_start + i;
1400 let selected = abs_i == state.entry;
1401 let bg = if selected { theme.nav_selected_bg } else { 0 };
1402 let color = if selected {
1403 theme.nav_selected
1404 } else {
1405 theme.nav
1406 };
1407
1408 if let Some(icon_id) = &entry.icon {
1411 push_icon(
1412 overlays,
1413 icon_id,
1414 rx + 1.0 * sx,
1415 py + (20.0 + i as f32 * 11.0 + 1.0) * sy,
1416 icon_size,
1417 icon_size,
1418 );
1419 }
1420
1421 let mut row = widget::panel(FlexDir::Row)
1422 .h(row_h)
1423 .gap(2.0)
1424 .bg(bg)
1425 .on_click(format!("entry:{}", abs_i))
1426 .id(format!("book_entry_{}", abs_i))
1427 .padding(1.0, 2.0, 1.0, 2.0);
1428
1429 row = row.child(widget::spacer().w(icon_size + 2.0));
1430 let fit = ((PAGE_W - 26.0) / yog_ui::text::str_width(&entry.name, 1.0)).clamp(0.5, 1.0);
1432 row = row.child(
1433 lbl(&entry.name)
1434 .color(color)
1435 .font_scale(text_scale() * fit)
1436 .flex(1.0)
1437 .no_wrap(),
1438 );
1439 col = col.child(row);
1440 }
1441
1442 if spread_count > 1 {
1443 col = col.child(widget::spacer().flex(1.0));
1444 let spread_label = format!("{}/{}", state.list_spread + 1, spread_count);
1445 col = col.child(
1446 widget::panel(FlexDir::Row)
1447 .h(12.0 * sy)
1448 .gap(2.0)
1449 .child(
1450 btn("◀")
1451 .w(12.0 * sx)
1452 .h(12.0 * sy)
1453 .color(theme.nav)
1454 .on_click("prev_list")
1455 .id("prev_list"),
1456 )
1457 .child(
1458 lbl(&spread_label)
1459 .color(theme.nav)
1460 .flex(1.0)
1461 .align(Align::Center),
1462 )
1463 .child(
1464 btn("▶")
1465 .w(12.0 * sx)
1466 .h(12.0 * sy)
1467 .color(theme.nav)
1468 .on_click("next_list")
1469 .id("next_list"),
1470 ),
1471 );
1472 }
1473 col
1474}
1475
1476#[allow(clippy::too_many_arguments)]
1479fn draw_layout(
1480 theme: &BookTheme,
1481 bg_sprites: &mut Vec<BgSprite>,
1482 overlays: &mut Vec<OverlayCmd>,
1483 col: &mut widget::Widget,
1484 vis: &RecipeVis,
1485 layout: &crate::layout::RecipeLayout,
1486 ox: f32,
1487 oy: f32,
1488 sx: f32,
1489 sy: f32,
1490) {
1491 use crate::layout::{FRAME_UV, LayoutPart};
1492 let mut c = std::mem::replace(col, widget::panel(FlexDir::Column));
1493 c = c.child(
1494 title_lbl(pretty_item_name(&vis.output))
1495 .color(theme.title)
1496 .h(10.0 * sy)
1497 .align(Align::Center),
1498 );
1499 let (lx, ly) = (PAGE_W / 2.0 - 1.0 - layout.size[0] / 2.0, 10.0);
1500 let at = |p: [f32; 2]| (ox + (lx + p[0]) * sx, oy + (ly + p[1]) * sy);
1501 let icon_s = 16.0 * sx.min(sy);
1502 let frame = |bg: &mut Vec<BgSprite>, p: [f32; 2]| {
1503 let (x, y) = at([p[0] - 4.0, p[1] - 4.0]);
1504 let [u, v, uw, uh] = FRAME_UV;
1505 bg.push(BgSprite {
1506 sheet: SpriteSheet::Crafting,
1507 u,
1508 v,
1509 uw,
1510 uh,
1511 x,
1512 y,
1513 w: uw * sx,
1514 h: uh * sy,
1515 });
1516 };
1517 for part in &layout.parts {
1518 match part {
1519 LayoutPart::Image { source, uv, at: p } => {
1520 let (x, y) = at(*p);
1521 let sheet = if source == CRAFTING_SHEET {
1522 SpriteSheet::Crafting
1523 } else {
1524 SpriteSheet::Image(source.clone())
1525 };
1526 let [u, v, uw, uh] = *uv;
1527 bg_sprites.push(BgSprite {
1528 sheet,
1529 u,
1530 v,
1531 uw,
1532 uh,
1533 x,
1534 y,
1535 w: uw * sx,
1536 h: uh * sy,
1537 });
1538 }
1539 LayoutPart::Slots {
1540 slot,
1541 at: places,
1542 frame: framed,
1543 } => {
1544 let ingredients = vis.slots.get(slot);
1545 for (i, p) in places.iter().enumerate() {
1546 if *framed {
1547 frame(bg_sprites, *p);
1548 }
1549 if let Some(items) =
1550 ingredients.and_then(|l| l.get(i)).filter(|x| !x.is_empty())
1551 {
1552 let (x, y) = at(*p);
1553 overlays.push(OverlayCmd::McItems {
1554 items: items.clone(),
1555 x,
1556 y,
1557 w: icon_s,
1558 h: icon_s,
1559 });
1560 }
1561 }
1562 }
1563 LayoutPart::Result {
1564 at: p,
1565 frame: framed,
1566 } => {
1567 if *framed {
1568 frame(bg_sprites, *p);
1569 }
1570 let (x, y) = at(*p);
1571 overlays.push(OverlayCmd::McItem {
1572 item_id: vis.output.clone(),
1573 x,
1574 y,
1575 w: icon_s,
1576 h: icon_s,
1577 });
1578 }
1579 LayoutPart::Count { at: p } => {
1580 if vis.count > 1 {
1581 let (x, y) = at(*p);
1582 overlays.push(OverlayCmd::McText {
1583 text: format!("x{}", vis.count),
1584 x,
1585 y,
1586 color: theme.title,
1587 });
1588 }
1589 }
1590 LayoutPart::Item { item, at: p } => {
1591 let (x, y) = at(*p);
1592 overlays.push(OverlayCmd::McItem {
1593 item_id: item.clone(),
1594 x,
1595 y,
1596 w: icon_s,
1597 h: icon_s,
1598 });
1599 }
1600 LayoutPart::Text { text, at: p } => {
1601 let mut shown = text.clone();
1602 for (name, value) in &vis.values {
1603 shown = shown.replace(&format!("{{{name}}}"), value);
1604 }
1605 let (x, y) = at(*p);
1606 overlays.push(OverlayCmd::McText {
1607 text: shown,
1608 x,
1609 y,
1610 color: theme.text,
1611 });
1612 }
1613 }
1614 }
1615 c = c.child(widget::spacer().h((layout.size[1] + 6.0) * sy));
1617 *col = c;
1618}
1619
1620fn build_page(
1626 page: Option<&BookPage>,
1627 page_num: usize,
1628 theme: &BookTheme,
1629 recipes: &HashMap<String, RecipeVis>,
1630 layouts: &[crate::layout::RecipeLayout],
1631 page_types: &[crate::page_type::PageType],
1632 bg_sprites: &mut Vec<BgSprite>,
1633 overlays: &mut Vec<OverlayCmd>,
1634 ox: f32,
1635 oy: f32,
1636 sx: f32,
1637 sy: f32,
1638) -> widget::Widget {
1639 let mut col = widget::panel(FlexDir::Column).flex(1.0).gap(4.0);
1640
1641 let Some(page) = page else {
1642 return col.child(lbl(word("no_entries", "")).color(theme.nav));
1643 };
1644
1645 match page {
1646 BookPage::Text {
1647 text,
1648 title,
1649 font,
1650 font_size,
1651 } => {
1652 if page_num > 0
1654 && let Some(t) = title
1655 {
1656 let sep_h_px = (SEP_H * sy).max(1.0);
1657 let title_h = 9.0 * sy;
1658 col = col.child(
1659 title_lbl(t.as_str())
1660 .color(theme.title)
1661 .h(title_h)
1662 .align(Align::Center)
1663 .no_wrap(),
1664 );
1665 bg_sprites.push(BgSprite {
1667 sheet: SpriteSheet::Book,
1668 u: SEP_U,
1669 v: SEP_V,
1670 uw: SEP_W,
1671 uh: SEP_H,
1672 x: ox + (PAGE_W / 2.0 - SEP_W / 2.0) * sx,
1673 y: oy + 12.0 * sy,
1674 w: SEP_W * sx,
1675 h: sep_h_px,
1676 });
1677 col = col.child(widget::spacer().h((12.0 - 9.0) * sy));
1678 col = col.child(widget::spacer().h(sep_h_px));
1679 col = col.child(widget::spacer().h((22.0 - 12.0 - SEP_H) * sy));
1680 }
1681 let mut l = lbl(text.as_str()).color(theme.text);
1683 if let Some(f) = font {
1685 l = l.font(f.as_str()).font_scale(
1686 text_scale() * font_size.unwrap_or(yog_ui::text::LINE_H) / yog_ui::text::LINE_H,
1687 );
1688 }
1689 col = col.child(l);
1690 }
1691
1692 BookPage::Spotlight { item, title, text } => {
1693 bg_sprites.push(BgSprite {
1698 sheet: SpriteSheet::Crafting,
1699 u: 0.0,
1700 v: 102.0,
1701 uw: 66.0,
1702 uh: 26.0,
1703 x: ox + (PAGE_W / 2.0 - 33.0) * sx,
1704 y: oy + 10.0 * sy,
1705 w: 66.0 * sx,
1706 h: 26.0 * sy,
1707 });
1708
1709 let item_name = title.clone().unwrap_or_else(|| pretty_item_name(item));
1710 col = col.child(
1711 title_lbl(item_name)
1712 .color(theme.title)
1713 .h(10.0 * sy)
1714 .align(Align::Center),
1715 );
1716
1717 let icon_s = 16.0 * sx.min(sy);
1718 overlays.push(OverlayCmd::McItem {
1719 item_id: item.clone(),
1720 x: ox + (PAGE_W / 2.0 - 8.0) * sx,
1721 y: oy + 15.0 * sy,
1722 w: icon_s,
1723 h: icon_s,
1724 });
1725
1726 col = col.child(widget::spacer().h(30.0 * sy));
1728
1729 if let Some(t) = text {
1730 col = col.child(lbl(t.as_str()).color(theme.text));
1731 }
1732 }
1733
1734 BookPage::Recipe { recipe, text } => {
1735 match recipes
1736 .get(recipe.as_str())
1737 .and_then(|vis| crate::layout::find(layouts, &vis.kind).map(|l| (vis, l)))
1738 {
1739 Some((vis, layout)) => draw_layout(
1740 theme, bg_sprites, overlays, &mut col, vis, layout, ox, oy, sx, sy,
1741 ),
1742 None => col = col.child(lbl(format!("[Recipe: {}]", recipe)).color(theme.nav)),
1743 }
1744 if let Some(t) = text {
1745 col = col.child(lbl(t.as_str()).color(theme.text));
1746 }
1747 }
1748
1749 BookPage::Image {
1750 texture,
1751 title,
1752 text,
1753 ..
1754 } => {
1755 if let Some(t) = title {
1756 col = col.child(title_lbl(t.as_str()).color(theme.title));
1757 }
1758 col = col.child(
1759 widget::image(texture)
1760 .region([0.0, 0.0, 80.0, 80.0])
1761 .stretch(Stretch::Fill)
1762 .w(80.0)
1763 .h(80.0),
1764 );
1765 if let Some(t) = text {
1766 col = col.child(lbl(t.as_str()).color(theme.text));
1767 }
1768 }
1769
1770 BookPage::Relations { entries, text } => {
1771 if let Some(t) = text {
1772 col = col.child(lbl(t.as_str()).color(theme.text));
1773 }
1774 col = col.child(title_lbl(word("see_also", "")).color(theme.title));
1775 for e in entries {
1776 col = col.child(lbl(format!("• {}", e)).color(theme.nav));
1777 }
1778 }
1779
1780 BookPage::Multiblock {
1781 name,
1782 pattern,
1783 mapping,
1784 text,
1785 } => {
1786 let top = if let Some(n) = name {
1788 col = col.child(title_lbl(n.as_str()).color(theme.title).h(10.0 * sy));
1789 MB_NAME_H
1790 } else {
1791 0.0
1792 };
1793 overlays.push(OverlayCmd::Blocks {
1794 blocks: crate::multiblock_blocks(pattern, mapping),
1795 x: ox + (PAGE_W / 2.0 - MB_VIEW / 2.0) * sx,
1796 y: oy + top * sy,
1797 w: MB_VIEW * sx,
1798 h: MB_VIEW * sy,
1799 });
1800 col = col.child(widget::spacer().h(MB_VIEW * sy));
1801 if let Some(t) = text {
1802 col = col.child(lbl(t.as_str()).color(theme.text));
1803 }
1804 }
1805
1806 BookPage::Entity { entity, name, text } => {
1807 let display = name.clone().unwrap_or_else(|| entity_name(entity));
1808 col = col.child(title_lbl(display).color(theme.title));
1809 overlays.push(OverlayCmd::Entity {
1810 entity: entity.clone(),
1811 x: ox + (PAGE_W / 2.0 - 40.0) * sx,
1812 y: oy + 12.0 * sy,
1813 w: 80.0 * sx,
1814 h: 80.0 * sy,
1815 });
1816 col = col.child(widget::spacer().h(84.0 * sy));
1817 if let Some(t) = text {
1818 col = col.child(lbl(t.as_str()).color(theme.text));
1819 }
1820 }
1821
1822 BookPage::Drawn {
1823 drawer,
1824 title,
1825 height,
1826 caption,
1827 text,
1828 data,
1829 } => {
1830 let mut top = 0.0;
1831 if let Some(t) = title {
1832 col = col.child(title_lbl(t.as_str()).color(theme.title).h(10.0 * sy));
1833 top = 14.0;
1834 }
1835 if *height > 0.0 {
1837 overlays.push(OverlayCmd::Drawn {
1838 drawer: drawer.clone(),
1839 data: data.clone(),
1840 x: ox,
1841 y: oy + top * sy,
1842 w: PAGE_W * sx,
1843 h: height * sy,
1844 });
1845 col = col.child(widget::spacer().h(height * sy));
1846 }
1847 if let Some(c) = caption {
1848 col = col.child(lbl(c.as_str()).color(theme.text));
1849 }
1850 if let Some(t) = text {
1851 col = col.child(lbl(t.as_str()).color(theme.text));
1852 }
1853 }
1854
1855 BookPage::Empty => {}
1856
1857 BookPage::Custom(c) => {
1859 use crate::page_type::PageElement as E;
1860 let Some(t) = page_types.iter().find(|t| t.id == c.kind) else {
1861 return col.child(lbl(format!("[{}]", c.kind)).color(theme.nav));
1862 };
1863 let line_h = yog_ui::text::LINE_H + yog_ui::text::LINE_GAP;
1864 let mut top = 0.0;
1866 for el in &t.body {
1867 match el {
1868 E::Header { text, visible } if t.visible(c, visible) => {
1869 col = col.child(title_lbl(t.text(c, text)).color(theme.title).h(10.0 * sy));
1870 top += 14.0;
1871 }
1872 E::Text { text, visible } if t.visible(c, visible) => {
1873 let words = t.text(c, text);
1874 let lines: usize = words.split('\n').map(|p| yog_ui::text::wrap_text(p, TEXT_W, 1.0).len().max(1)).sum();
1875 col = col.child(lbl(words).color(theme.text));
1876 top += lines as f32 * line_h + 4.0;
1877 }
1878 E::Item { item, visible } if t.visible(c, visible) => {
1879 let id = crate::page_type::words(&t.value(c, item));
1880 push_icon(overlays, &id, ox + (PAGE_W / 2.0 - 8.0) * sx, oy + top * sy, 16.0 * sx, 16.0 * sy);
1881 col = col.child(widget::spacer().h(16.0 * sy));
1882 top += 20.0;
1883 }
1884 E::Image { image, height, visible } if t.visible(c, visible) => {
1885 let src = crate::page_type::words(&t.value(c, image));
1886 col = col.child(widget::image(src).stretch(Stretch::Fill).w(PAGE_W * sx).h(height * sy));
1887 top += height + 4.0;
1888 }
1889 E::Drawn { drawer, height, data, visible } if t.visible(c, visible) => {
1890 overlays.push(OverlayCmd::Drawn {
1891 drawer: drawer.clone(),
1892 data: t.value(c, data),
1893 x: ox,
1894 y: oy + top * sy,
1895 w: PAGE_W * sx,
1896 h: height * sy,
1897 });
1898 col = col.child(widget::spacer().h(height * sy));
1899 top += height + 4.0;
1900 }
1901 E::Spacer { height } => {
1902 col = col.child(widget::spacer().h(height * sy));
1903 top += height + 4.0;
1904 }
1905 _ => {}
1906 }
1907 }
1908 }
1909 }
1910
1911 col
1912}
1913
1914#[cfg(test)]
1915mod tests {
1916 use super::*;
1917
1918 fn j(json: &str) -> yog_data::Value {
1919 yog_data::json::from_json_text(json).unwrap()
1920 }
1921 use crate::BookEntry;
1922
1923 fn book(pages: Vec<BookPage>) -> Book {
1924 let mut book = Book {
1925 id: "t:b".into(),
1926 name: "B".into(),
1927 ..Default::default()
1928 };
1929 book.entries.push(BookEntry {
1930 id: "e".into(),
1931 name: "E".into(),
1932 pages,
1933 ..Default::default()
1934 });
1935 book
1936 }
1937
1938 #[test]
1939 fn long_text_flows_onto_new_pages() {
1940 let long = "word ".repeat(400);
1941 let flowed = flow(book(vec![
1942 BookPage::Text {
1943 text: long.clone(),
1944 title: None,
1945 font: None,
1946 font_size: None,
1947 },
1948 BookPage::Empty,
1949 ]));
1950 let pages = &flowed.entries[0].pages;
1951 assert!(pages.len() > 3, "{}", pages.len());
1952 assert!(
1953 matches!(pages.last(), Some(BookPage::Empty)),
1954 "what came after stays after"
1955 );
1956 let line_h = yog_ui::text::LINE_H + yog_ui::text::LINE_GAP;
1957 for p in &pages[..pages.len() - 1] {
1958 let BookPage::Text { text, .. } = p else {
1959 panic!("{p:?}")
1960 };
1961 assert!(text.lines().count() as f32 * line_h <= BODY_H + 1.0);
1962 assert!(
1963 text.lines()
1964 .all(|l| yog_ui::text::str_width(l, 1.0) <= TEXT_W)
1965 );
1966 }
1967 let words: usize = pages
1968 .iter()
1969 .map(|p| match p {
1970 BookPage::Text { text, .. } => text.split_whitespace().count(),
1971 _ => 0,
1972 })
1973 .sum();
1974 assert_eq!(words, 400, "nothing lost");
1975 }
1976
1977 #[test]
1978 fn edits_keep_the_reader_where_they_were() {
1979 let mut b = book(vec![
1980 BookPage::Text {
1981 text: "one".into(),
1982 title: None,
1983 font: None,
1984 font_size: None,
1985 },
1986 BookPage::Text {
1987 text: "two".into(),
1988 title: None,
1989 font: None,
1990 font_size: None,
1991 },
1992 ]);
1993 b.categories.push(crate::BookCategory {
1994 id: "c".into(),
1995 name: "C".into(),
1996 ..Default::default()
1997 });
1998 b.entries[0].category = "c".into();
1999 let mut r = BookRenderer::new(b.clone());
2000 assert_eq!(r.shown(), None, "opens on the landing page");
2001 assert!(r.show("e", 1));
2002 assert_eq!(r.shown(), Some(("e".into(), 1)));
2003 r.prepare(800.0, 600.0);
2004 assert!(r.ui.is_some());
2005
2006 b.entries[0].pages.push(BookPage::Empty);
2007 r.set_book(b);
2008 assert_eq!(r.shown(), Some(("e".into(), 1)));
2009 assert!(!r.show("missing", 0));
2010 }
2011
2012 #[test]
2013 fn pages_are_counted_as_written() {
2014 let long = "word ".repeat(400);
2015 let mut b = book(vec![
2016 BookPage::Text {
2017 text: long,
2018 title: None,
2019 font: None,
2020 font_size: None,
2021 },
2022 BookPage::Empty,
2023 ]);
2024 b.categories.push(crate::BookCategory {
2025 id: "c".into(),
2026 name: "C".into(),
2027 ..Default::default()
2028 });
2029 b.entries[0].category = "c".into();
2030 let mut r = BookRenderer::new(b);
2031 assert!(r.show("e", 1));
2032 assert_eq!(
2033 r.shown(),
2034 Some(("e".into(), 1)),
2035 "the second page as written"
2036 );
2037 assert!(r.state.page > 1, "past the first page's flowed text");
2038 r.handle_event("prev_page");
2039 assert_eq!(
2040 r.shown(),
2041 Some(("e".into(), 0)),
2042 "flowed text belongs to the first"
2043 );
2044 }
2045
2046 #[test]
2047 fn reads_yog_recipes() {
2048 let shaped = parse_recipe(&j(r##"{"type":"shaped","pattern":["AB"],"key":{"A":"m:a","B":["m:x","#c:t"]},"result":"m:out","count":2}"##)).unwrap();
2049 assert_eq!(
2050 (shaped.kind.as_str(), shaped.output.as_str(), shaped.count),
2051 ("shaped", "m:out", 2)
2052 );
2053 let grid = &shaped.slots["grid"];
2054 assert_eq!(
2055 (grid.len(), &grid[1]),
2056 (9, &vec!["m:x".to_string(), "#c:t".to_string()]),
2057 "cells by place on the 3×3 grid"
2058 );
2059 let blast =
2060 parse_recipe(&j(r##"{"type":"blasting","ingredient":"m:ore","result":"m:ingot"}"##))
2061 .unwrap();
2062 assert_eq!(
2063 (
2064 blast.slots["input"][0][0].as_str(),
2065 blast.values["time"].as_str()
2066 ),
2067 ("m:ore", "100")
2068 );
2069 let own = parse_recipe(&j(r##"{"type":"m:infusion","slots":{"ring":["m:a",["m:b","m:c"]]},"values":{"time":40},"result":"m:out"}"##)).unwrap();
2070 assert_eq!(
2071 (own.slots["ring"].len(), own.values["time"].as_str()),
2072 (2, "40")
2073 );
2074 assert!(
2075 parse_recipe(&j(r#"{"type":"minecraft:crafting_shaped"}"#)).is_none(),
2076 "vanilla JSON isn't the book's"
2077 );
2078 }
2079
2080 #[test]
2081 fn recipe_pages_are_drawn_by_layouts() {
2082 let theme = BookTheme::default();
2083 let mut recipes = HashMap::new();
2084 recipes.insert("m:r".to_string(), parse_recipe(&j(r##"{"type":"m:infusion","slots":{"ring":["m:a","m:b"]},"values":{"time":40},"result":"m:out"}"##)).unwrap());
2085 recipes.insert("m:s".to_string(), parse_recipe(&j(r##"{"type":"smithing","template":"m:t","base":"m:b","addition":"m:a","result":"m:out"}"##)).unwrap());
2086 let layout: crate::layout::RecipeLayout = yog_data::from_text(
2087 r#"{ kinds: ["m:infusion"], size: [96, 40], parts: [
2088 { type: "slots", slot: "ring", at: [[0, 0], [20, 0]], frame: true },
2089 { type: "result", at: [76, 10] },
2090 { type: "text", text: "{time} ticks", at: [0, 30] },
2091 ] }"#,
2092 )
2093 .unwrap()
2094 .value;
2095 let draw = |id: &str| {
2096 let (mut bg, mut ov) = (Vec::new(), Vec::new());
2097 let page = BookPage::Recipe {
2098 recipe: id.into(),
2099 text: None,
2100 };
2101 build_page(
2102 Some(&page),
2103 0,
2104 &theme,
2105 &recipes,
2106 std::slice::from_ref(&layout),
2107 &[],
2108 &mut bg,
2109 &mut ov,
2110 0.0,
2111 0.0,
2112 1.0,
2113 1.0,
2114 );
2115 (bg, ov)
2116 };
2117 let (bg, ov) = draw("m:r");
2118 let lx = PAGE_W / 2.0 - 1.0 - 48.0;
2119 let items: Vec<(f32, f32)> = ov
2120 .iter()
2121 .filter_map(|o| match o {
2122 OverlayCmd::McItems { x, y, .. } | OverlayCmd::McItem { x, y, .. } => {
2123 Some((*x, *y))
2124 }
2125 _ => None,
2126 })
2127 .collect();
2128 assert_eq!(items, [(lx, 10.0), (lx + 20.0, 10.0), (lx + 76.0, 20.0)]);
2129 assert_eq!(bg.len(), 2, "a frame behind each ring slot");
2130 assert!(
2131 ov.iter()
2132 .any(|o| matches!(o, OverlayCmd::McText { text, .. } if text == "40 ticks"))
2133 );
2134 let (bg, ov) = draw("m:s");
2136 assert!(
2137 bg.iter().any(|b| b.u == 11.0 && b.v == 135.0),
2138 "Patchouli's smithing strip"
2139 );
2140 assert_eq!(
2141 ov.iter()
2142 .filter(|o| matches!(o, OverlayCmd::McItems { .. }))
2143 .count(),
2144 3
2145 );
2146 }
2147
2148 #[test]
2149 fn multiblocks_turn_and_animate() {
2150 let pattern = vec![vec!["G".to_string()], vec!["S ".to_string()]];
2151 let mapping = [
2152 ("G".to_string(), "minecraft:glass".to_string()),
2153 ("S".to_string(), "minecraft:stone".to_string()),
2154 ]
2155 .into();
2156 let blocks = crate::multiblock_blocks(&pattern, &mapping);
2157 assert_eq!(
2158 blocks,
2159 [
2160 ((0, 1, 0), "minecraft:glass".to_string()),
2161 ((0, 0, 0), "minecraft:stone".to_string())
2162 ]
2163 );
2164
2165 let mut b = book(vec![BookPage::Multiblock {
2166 name: None,
2167 pattern,
2168 mapping,
2169 text: None,
2170 }]);
2171 b.categories.push(crate::BookCategory {
2172 id: "c".into(),
2173 name: "C".into(),
2174 ..Default::default()
2175 });
2176 b.entries[0].category = "c".into();
2177 let mut r = BookRenderer::new(b);
2178 r.prepare(800.0, 600.0);
2179 assert!(!r.animated(), "the landing page is still");
2180 assert_eq!(landing_fit("short"), 1.0);
2181 assert!(
2182 landing_fit(&"many words here ".repeat(60)) < 1.0,
2183 "long landing texts shrink"
2184 );
2185 r.show("e", 0);
2186 r.prepare(800.0, 600.0);
2187 assert!(r.animated(), "a structure turns");
2188 }
2189
2190 #[test]
2191 fn short_text_stays_put() {
2192 let pages = vec![BookPage::Text {
2193 text: "Hello".into(),
2194 title: Some("T".into()),
2195 font: None,
2196 font_size: None,
2197 }];
2198 let flowed = flow(book(pages.clone()));
2199 assert_eq!(flowed.entries[0].pages.len(), 1);
2200 }
2201
2202 #[test]
2203 fn a_drawn_page_hands_its_box_and_data_to_the_mods_drawer() {
2204 let data = yog_data::json::from_json(&serde_json::json!({ "op_id": "hexcasting:manual" }));
2205 let page: BookPage = serde_json::from_value(serde_json::json!({
2206 "type": "drawn", "drawer": "hexcasting:pattern", "title": "Manual", "height": 64,
2207 "caption": "{u|entity} → {u|num}", "text": "Casts.", "data": { "op_id": "hexcasting:manual" }
2208 }))
2209 .unwrap();
2210 assert!(matches!(&page, BookPage::Drawn { data: d, height, .. } if *d == data && *height == 64.0));
2211 assert_eq!(content_above_text(&page), Some(14.0 + 68.0 + 14.0));
2212 let mut b = book(vec![page.clone()]);
2213 b.categories.push(crate::BookCategory { id: "c".into(), name: "C".into(), ..Default::default() });
2214 b.entries[0].category = "c".into();
2215 let mut r = BookRenderer::new(b);
2216 r.show("e", 0);
2217 r.prepare(800.0, 600.0);
2218 let drawn: Vec<_> = r.overlays.iter().filter_map(|o| match o {
2219 OverlayCmd::Drawn { drawer, data, w, h, .. } => Some((drawer.clone(), data.clone(), *w / *h)),
2220 _ => None,
2221 }).collect();
2222 assert_eq!(drawn.len(), 1);
2223 assert_eq!((drawn[0].0.as_str(), &drawn[0].1), ("hexcasting:pattern", &data));
2224 assert!((drawn[0].2 - PAGE_W / 64.0).abs() < 0.01, "the page's width, 64 high");
2225 assert_eq!(serde_json::to_value(&page).unwrap()["data"]["op_id"], "hexcasting:manual");
2227 }
2228
2229 #[test]
2230 fn a_mods_page_type_lays_out_its_pages() {
2231 use crate::page_type::{PageElement as E, PageProperty, PageType, PropKind};
2232 let ty = PageType {
2233 id: "hexcasting:pattern".into(),
2234 properties: vec![
2235 PageProperty { name: "name".into(), kind: PropKind::BookText, default: None },
2236 PageProperty { name: "patterns".into(), kind: PropKind::Data, default: Some(yog_data::Value::List(vec![])) },
2237 ],
2238 body: vec![
2239 E::Header { text: "{x:Bind name}".into(), visible: None },
2240 E::Drawn { drawer: "hexcasting:pattern".into(), height: 64.0, data: "{x:Bind patterns}".into(), visible: Some("{x:Bind patterns}".into()) },
2241 ],
2242 };
2243 let page = |json: &str| BookPage::Custom(yog_data::read_value(&yog_data::json::from_json_text(json).unwrap()).unwrap());
2244 let mut b = book(vec![
2245 page(r#"{"type":"hexcasting:pattern","name":"Manual","patterns":[{"startdir":"EAST","signature":"qaq"}]}"#),
2246 page(r#"{"type":"hexcasting:pattern","name":"Bare"}"#),
2247 ]);
2248 b.page_types.push(ty);
2249 b.categories.push(crate::BookCategory { id: "c".into(), name: "C".into(), ..Default::default() });
2250 b.entries[0].category = "c".into();
2251 let mut r = BookRenderer::new(b);
2252 r.show("e", 0);
2253 r.prepare(800.0, 600.0);
2254 let drawn: Vec<_> = r.overlays.iter().filter_map(|o| match o {
2255 OverlayCmd::Drawn { drawer, data, .. } => Some((drawer.clone(), data.clone())),
2256 _ => None,
2257 }).collect();
2258 assert_eq!(drawn.len(), 1, "the second page has no patterns: no picture");
2259 assert_eq!(drawn[0].0, "hexcasting:pattern");
2260 assert!(matches!(&drawn[0].1, yog_data::Value::List(l) if l.len() == 1), "the field's whole value");
2261 }
2262
2263 #[test]
2264 fn a_mods_page_flows_its_long_text_onto_text_pages() {
2265 use crate::page_type::{PageElement as E, PageProperty, PageType, PropKind};
2266 let ty = PageType {
2267 id: "m:p".into(),
2268 properties: vec![
2269 PageProperty { name: "patterns".into(), kind: PropKind::Data, default: None },
2270 PageProperty { name: "text".into(), kind: PropKind::BookText, default: None },
2271 ],
2272 body: vec![
2273 E::Drawn { drawer: "m:d".into(), height: 64.0, data: "{x:Bind patterns}".into(), visible: None },
2274 E::Text { text: "{x:Bind text}".into(), visible: None },
2275 ],
2276 };
2277 let long = "word ".repeat(120);
2278 let page = format!(r#"{{"type":"m:p","patterns":[1],"text":"{long}"}}"#);
2279 let mut b = book(vec![BookPage::Custom(yog_data::read_value(&yog_data::json::from_json_text(&page).unwrap()).unwrap())]);
2280 b.page_types.push(ty);
2281 let flowed = flow(b);
2282 let pages = &flowed.entries[0].pages;
2283 assert!(pages.len() >= 2, "{pages:?}");
2284 let BookPage::Custom(first) = &pages[0] else { panic!("{:?}", pages[0]) };
2285 let kept = first.fields.get("text").and_then(|v| v.as_str()).unwrap();
2286 let line_h = yog_ui::text::LINE_H + yog_ui::text::LINE_GAP;
2287 assert!(kept.lines().count() as f32 * line_h <= BODY_H - 68.0 + 1.0, "under the picture: {kept}");
2288 let words: usize = kept.split_whitespace().count()
2289 + pages[1..].iter().map(|p| match p {
2290 BookPage::Text { text, .. } => text.split_whitespace().count(),
2291 _ => 0,
2292 }).sum::<usize>();
2293 assert_eq!(words, 120, "nothing lost");
2294 }
2295
2296 #[test]
2297 fn a_translated_book_is_flowed_from_its_words_not_its_keys() {
2298 fn tr(k: &str) -> Option<String> {
2300 (k.len() == 3000 && k.starts_with("mymod.")).then(|| "Short.".into())
2301 }
2302 let key = format!("mymod.{}", "x".repeat(2994));
2303 let mut b = book(vec![BookPage::Text {
2304 text: key,
2305 title: None,
2306 font: None,
2307 font_size: None,
2308 }]);
2309 b.i18n = true;
2310 let mut r = BookRenderer::new(b);
2311 r.set_translator(tr);
2312 r.localize();
2313 let pages = &r.book().entries[0].pages;
2314 assert_eq!(pages.len(), 1, "{pages:?}");
2315 assert!(matches!(&pages[0], BookPage::Text { text, .. } if text == "Short."), "{pages:?}");
2316 }
2317}