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