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