1use std::collections::HashMap;
7use yog_gfx::GfxContext;
8use yog_ui::{Align, FlexDir, Stretch, UiRoot, widget};
9
10use crate::state::BookViewState;
11use crate::theme::BookTheme;
12use crate::{Book, BookPage};
13
14pub const BOOK_TEX_W: f32 = 512.0;
18pub const BOOK_TEX_H: f32 = 256.0;
19pub const BK_W: f32 = 272.0;
21pub const BK_H: f32 = 180.0;
22pub const PAGE_W: f32 = 116.0;
24pub const PAGE_H: f32 = 156.0;
25pub const TOP_PAD: f32 = 18.0; pub const LEFT_X: f32 = 15.0; pub const RIGHT_X: f32 = 141.0; pub const SEP_U: f32 = 140.0;
30pub const SEP_V: f32 = 180.0;
31pub const SEP_W: f32 = 110.0;
32pub const SEP_H: f32 = 3.0;
33const SCREEN_MARGIN: f32 = 16.0;
35const MIN_SCALE: f32 = 0.4;
37
38pub static BOOK_PNG: &[u8] = include_bytes!("../assets/book_brown.png");
42pub static CRAFTING_PNG: &[u8] = include_bytes!("../assets/crafting.png");
44pub const BOOK_SHEET: &str = "yog:gui/book/book";
48pub const CRAFTING_SHEET: &str = "yog:gui/book/crafting";
50
51#[derive(Clone, Copy, PartialEq)]
54pub(crate) enum SpriteSheet {
55 Book,
56 Crafting,
57}
58
59#[derive(Clone)]
61pub(crate) struct BgSprite {
62 pub sheet: SpriteSheet,
63 pub u: f32,
65 pub v: f32,
66 pub uw: f32,
67 pub uh: f32,
68 pub x: f32,
70 pub y: f32,
71 pub w: f32,
72 pub h: f32,
73}
74
75#[derive(Clone)]
78pub(crate) enum OverlayCmd {
79 Svg {
80 data: String,
81 x: f32,
82 y: f32,
83 w: f32,
84 h: f32,
85 },
86 Entity {
88 entity: String,
89 x: f32,
90 y: f32,
91 w: f32,
92 h: f32,
93 },
94 McText {
96 text: String,
97 x: f32,
98 y: f32,
99 color: u32,
100 },
101 McItem {
103 item_id: String,
104 x: f32,
105 y: f32,
106 w: f32,
107 h: f32,
108 },
109 McTexture {
114 path: String,
115 x: f32,
116 y: f32,
117 w: f32,
118 h: f32,
119 },
120}
121
122fn push_icon(overlays: &mut Vec<OverlayCmd>, icon: &str, x: f32, y: f32, w: f32, h: f32) {
126 if icon.ends_with(".png") {
127 overlays.push(OverlayCmd::McTexture {
128 path: icon.to_string(),
129 x,
130 y,
131 w,
132 h,
133 });
134 } else {
135 overlays.push(OverlayCmd::McItem {
136 item_id: icon.to_string(),
137 x,
138 y,
139 w,
140 h,
141 });
142 }
143}
144
145#[derive(Clone)]
148pub(crate) enum RecipeVis {
149 Shaped {
150 grid: Vec<Option<String>>,
151 width: usize,
152 output: String,
153 count: u32,
154 },
155 Shapeless {
156 ingredients: Vec<String>,
157 output: String,
158 count: u32,
159 },
160 Smelting {
161 input: String,
162 output: String,
163 },
164}
165
166fn parse_recipe(json: &str) -> Option<RecipeVis> {
167 let v: serde_json::Value = serde_json::from_str(json).ok()?;
168 match v.get("type")?.as_str()? {
169 "minecraft:crafting_shaped" => {
170 let pattern: Vec<&str> = v
171 .get("pattern")?
172 .as_array()?
173 .iter()
174 .filter_map(|p| p.as_str())
175 .collect();
176 let width = pattern.iter().map(|r| r.chars().count()).max()?.max(1);
177 let key = v.get("key")?.as_object()?;
178 let mut grid = Vec::new();
179 for row in &pattern {
180 let mut chars: Vec<char> = row.chars().collect();
181 chars.resize(width, ' ');
182 for ch in chars {
183 grid.push(
184 key.get(&ch.to_string())
185 .and_then(|k| k.get("item"))
186 .and_then(|i| i.as_str())
187 .map(str::to_owned),
188 );
189 }
190 }
191 let result = v.get("result")?;
192 Some(RecipeVis::Shaped {
193 grid,
194 width,
195 output: result.get("item")?.as_str()?.to_owned(),
196 count: result.get("count").and_then(|c| c.as_u64()).unwrap_or(1) as u32,
197 })
198 }
199 "minecraft:crafting_shapeless" => {
200 let ingredients = v
201 .get("ingredients")?
202 .as_array()?
203 .iter()
204 .filter_map(|i| i.get("item").and_then(|x| x.as_str()).map(str::to_owned))
205 .collect();
206 let result = v.get("result")?;
207 Some(RecipeVis::Shapeless {
208 ingredients,
209 output: result.get("item")?.as_str()?.to_owned(),
210 count: result.get("count").and_then(|c| c.as_u64()).unwrap_or(1) as u32,
211 })
212 }
213 "minecraft:smelting"
214 | "minecraft:blasting"
215 | "minecraft:smoking"
216 | "minecraft:campfire_cooking" => Some(RecipeVis::Smelting {
217 input: v.get("ingredient")?.get("item")?.as_str()?.to_owned(),
218 output: v.get("result")?.as_str()?.to_owned(),
219 }),
220 _ => None,
221 }
222}
223
224fn normalize_item_id(id: &str) -> String {
227 let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
228 let name = path
229 .trim_start_matches("item/")
230 .trim_start_matches("block/");
231 format!("{ns}:{name}")
232}
233
234fn pretty_item_name(id: &str) -> String {
236 let name = id.rsplit(':').next().unwrap_or(id);
237 name.split('_')
238 .map(|w| {
239 let mut c = w.chars();
240 match c.next() {
241 Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
242 None => String::new(),
243 }
244 })
245 .collect::<Vec<_>>()
246 .join(" ")
247}
248
249pub struct BookRenderer {
252 pub book: Book,
253 pub state: BookViewState,
254 pub theme: BookTheme,
255 recipes: HashMap<String, RecipeVis>,
256 pub ui: Option<UiRoot>,
257 bg_sprites: Vec<BgSprite>,
258 overlays: Vec<OverlayCmd>,
259 dirty: bool,
260 last_sw: f32,
261 last_sh: f32,
262 source: Option<Book>,
265 translator: Option<fn(&str) -> Option<String>>,
266 probe: Option<Option<String>>,
267}
268
269impl BookRenderer {
270 pub fn new(book: Book) -> Self {
271 let theme = BookTheme::default().with_nameplate(&book.nameplate_color);
272 Self {
273 book: flow(book),
274 state: BookViewState::default(),
275 theme,
276 recipes: HashMap::new(),
277 ui: None,
278 bg_sprites: Vec::new(),
279 overlays: Vec::new(),
280 dirty: true,
281 last_sw: 0.0,
282 last_sh: 0.0,
283 source: None,
284 translator: None,
285 probe: None,
286 }
287 }
288
289 pub fn set_translator(&mut self, tr: fn(&str) -> Option<String>) {
293 if self.book.i18n {
294 self.source.get_or_insert_with(|| self.book.clone());
295 self.translator = Some(tr);
297 self.probe = None;
298 }
299 }
300
301 fn localize(&mut self) {
303 let (Some(src), Some(tr)) = (&self.source, self.translator) else {
304 return;
305 };
306 let probe = Some(tr(&src.name));
307 if probe == self.probe {
308 return;
309 }
310 self.book = flow(src.localized(|k| tr(k).unwrap_or_else(|| k.to_string())));
311 self.probe = probe;
312 self.dirty = true;
313 }
314
315 pub fn handle_event(&mut self, ev: &str) {
316 if self.state.handle(ev, &self.book) {
317 self.dirty = true;
318 }
319 }
320
321 pub fn add_recipe(&mut self, id: impl Into<String>, json: &str) {
324 if let Some(vis) = parse_recipe(json) {
325 self.recipes.insert(id.into(), vis);
326 self.dirty = true;
327 }
328 }
329
330 fn prepare(&mut self, sw: f32, sh: f32) -> (f32, f32, f32, f32) {
333 let scale = ((sw - SCREEN_MARGIN) / BK_W)
336 .min((sh - SCREEN_MARGIN) / BK_H)
337 .clamp(MIN_SCALE, 1.0);
338 let bw = BK_W * scale;
339 let bh = BK_H * scale;
340 let bx = (sw - bw) / 2.0;
341 let by = (sh - bh) / 2.0;
342
343 self.localize();
344 if self.dirty || sw != self.last_sw || sh != self.last_sh {
346 let (root, bg_sprites, overlays) = build_ui(
347 &self.book,
348 &self.state,
349 &self.theme,
350 &self.recipes,
351 bx,
352 by,
353 bw,
354 bh,
355 );
356 self.ui = Some(root);
357 self.bg_sprites = bg_sprites;
358 self.overlays = overlays;
359 self.dirty = false;
360 self.last_sw = sw;
361 self.last_sh = sh;
362 }
363 if let Some(ui) = &mut self.ui
364 && ui.needs_layout
365 {
366 ui.layout(sw, sh);
367 }
368 (bx, by, bw, bh)
369 }
370
371 pub fn render(&mut self, ctx: &GfxContext, sw: f32, sh: f32) {
375 let (bx, by, bw, bh) = self.prepare(sw, sh);
376 let d2 = ctx.draw2d();
377 d2.image_region(BOOK_SHEET, bx, by, bw, bh, [0.0, 0.0, BK_W, BK_H], 0xFFFF_FFFF);
378 for spr in &self.bg_sprites {
379 let sheet = match spr.sheet {
380 SpriteSheet::Book => BOOK_SHEET,
381 SpriteSheet::Crafting => CRAFTING_SHEET,
382 };
383 d2.image_region(sheet, spr.x, spr.y, spr.w, spr.h, [spr.u, spr.v, spr.uw, spr.uh], 0xFFFF_FFFF);
384 }
385 if let Some(ui) = &mut self.ui {
386 ui.render(ctx);
387 }
388 for ov in &self.overlays {
389 match *ov {
390 OverlayCmd::Svg { ref data, x, y, w, h } => d2.svg(data, x, y, w, h),
391 OverlayCmd::Entity { ref entity, x, y, w, h } => d2.entity(entity, x, y, w, h, 30.0, 0.0),
392 _ => {}
393 }
394 }
395 self.draw_mc_overlays(ctx);
397 }
398
399 fn draw_mc_overlays(&self, ctx: &GfxContext) {
400 for ov in &self.overlays {
401 match *ov {
402 OverlayCmd::McItem { ref item_id, x, y, w, h } => {
403 ctx.draw2d().item(&normalize_item_id(item_id), x, y, w.min(h));
404 }
405 OverlayCmd::McText { ref text, x, y, color } => {
406 ctx.draw2d().text(text, x, y, color, false);
407 }
408 OverlayCmd::McTexture { ref path, x, y, w, h } => {
409 ctx.draw2d().image(path, x, y, w, h);
412 }
413 _ => {}
414 }
415 }
416 }
417
418 pub fn click(&mut self, x: f32, y: f32) -> Option<String> {
422 let ui = self.ui.as_ref()?;
423 let hit = yog_ui::layout::hit_test(&ui.layout_root, x, y)?;
424 let action = hit.on_click.clone()?;
425 self.handle_event(&action);
426 Some(action)
427 }
428
429 pub fn show(&mut self, entry: &str, page: usize) -> bool {
432 let Some(e) = self.book.entries.iter().find(|e| e.id == entry) else { return false };
433 let Some(cat) = self.book.categories.iter().position(|c| c.id == e.category) else { return false };
434 self.state.cat = cat;
435 self.state.at_home = false;
436 let in_cat = self.state.entries_in_cat(&self.book);
437 let Some(i) = in_cat.iter().position(|x| x.id == entry) else { return false };
438 self.state.entry = i;
439 self.state.page = page.min(e.pages.len().saturating_sub(1));
440 self.dirty = true;
441 true
442 }
443
444 pub fn shown(&self) -> Option<(String, usize)> {
447 if self.state.at_home {
448 return None;
449 }
450 self.state.current_entry(&self.book).map(|e| (e.id.clone(), self.state.page))
451 }
452
453 pub fn set_book(&mut self, book: Book) {
455 let at = self.shown();
456 self.book = flow(book);
457 self.theme = BookTheme::default().with_nameplate(&self.book.nameplate_color);
458 self.dirty = true;
459 match at {
460 Some((entry, page)) if self.show(&entry, page) => {}
461 _ => {
462 if self.state.cat >= self.book.categories.len() {
463 self.state = BookViewState::default();
464 }
465 }
466 }
467 }
468}
469
470thread_local! {
473 static TEXT_SCALE: std::cell::Cell<f32> = const { std::cell::Cell::new(1.0) };
476}
477
478fn text_scale() -> f32 {
479 TEXT_SCALE.with(|s| s.get())
480}
481
482fn lbl(text: impl Into<String>) -> widget::Widget {
484 widget::label(text).shadow(false).font_scale(text_scale())
485}
486fn btn(text: impl Into<String>) -> widget::Widget {
488 widget::button(text).shadow(false).font_scale(text_scale())
489}
490
491fn title_lbl(text: impl Into<String>) -> widget::Widget {
494 let text = text.into();
495 let fit = ((PAGE_W - 6.0) / yog_ui::text::str_width(&text, 1.0)).clamp(0.5, 1.0);
496 widget::label(text).shadow(false).font_scale(text_scale() * fit).no_wrap().ellipsis()
497}
498
499const TEXT_W: f32 = PAGE_W - 8.0;
503const BODY_H: f32 = PAGE_H - 22.0 - 12.0 - 14.0;
506
507fn content_above_text(page: &BookPage) -> Option<f32> {
509 Some(match page {
510 BookPage::Text { title: Some(_), .. } => 22.0,
511 BookPage::Text { .. } => 0.0,
512 BookPage::Spotlight { .. } => 40.0,
513 BookPage::Crafting { .. } => 80.0,
514 BookPage::Smelting { .. } => 40.0,
515 BookPage::Image { .. } | BookPage::Entity { .. } => 104.0,
516 BookPage::Relations { entries, .. } => 12.0 + entries.len() as f32 * 10.0,
517 _ => return None,
518 })
519}
520
521fn page_text_mut(page: &mut BookPage) -> Option<&mut String> {
522 match page {
523 BookPage::Text { text, .. } => Some(text),
524 BookPage::Spotlight { text, .. }
525 | BookPage::Crafting { text, .. }
526 | BookPage::Smelting { text, .. }
527 | BookPage::Image { text, .. }
528 | BookPage::Entity { text, .. }
529 | BookPage::Relations { text, .. } => text.as_mut(),
530 _ => None,
531 }
532}
533
534fn flow(mut book: Book) -> Book {
536 let line_h = yog_ui::text::LINE_H + yog_ui::text::LINE_GAP;
537 let fits = |h: f32| (((h + yog_ui::text::LINE_GAP) / line_h).floor() as usize).max(1);
538 for entry in &mut book.entries {
539 let mut pages = Vec::with_capacity(entry.pages.len());
540 for mut page in std::mem::take(&mut entry.pages) {
541 let Some(above) = content_above_text(&page) else {
542 pages.push(page);
543 continue;
544 };
545 let Some(text) = page_text_mut(&mut page) else {
546 pages.push(page);
547 continue;
548 };
549 let lines: Vec<String> = text
550 .split('\n')
551 .flat_map(|para| {
552 if para.is_empty() { vec![String::new()] } else { yog_ui::text::wrap_text(para, TEXT_W, 1.0) }
553 })
554 .collect();
555 let first = fits(BODY_H - above);
556 if lines.len() <= first {
557 pages.push(page);
558 continue;
559 }
560 *text = lines[..first].join("\n");
561 pages.push(page);
562 for chunk in lines[first..].chunks(fits(BODY_H)) {
563 pages.push(BookPage::Text { text: chunk.join("\n"), title: None });
564 }
565 }
566 entry.pages = pages;
567 }
568 book
569}
570
571fn build_ui(
574 book: &Book,
575 state: &BookViewState,
576 theme: &BookTheme,
577 recipes: &HashMap<String, RecipeVis>,
578 bx: f32,
579 by: f32,
580 bw: f32,
581 bh: f32,
582) -> (UiRoot, Vec<BgSprite>, Vec<OverlayCmd>) {
583 let mut bg_sprites: Vec<BgSprite> = Vec::new();
584 let mut overlays: Vec<OverlayCmd> = Vec::new();
585
586 let sx = bw / BK_W;
588 TEXT_SCALE.with(|s| s.set(sx));
589 let sy = bh / BK_H;
590
591 let pw = PAGE_W * sx;
593 let ph = PAGE_H * sy;
594 let spine_gap = (RIGHT_X - LEFT_X - PAGE_W) * sx; let lx = bx + LEFT_X * sx;
598 let rx = bx + RIGHT_X * sx;
599 let py = by + TOP_PAD * sy;
600
601 let sep_cx = (PAGE_W / 2.0 - SEP_W / 2.0) * sx; if state.at_home {
605 bg_sprites.push(BgSprite {
607 sheet: SpriteSheet::Book,
608 u: 0.0,
609 v: 180.0,
610 uw: 140.0,
611 uh: 31.0,
612 x: bx + (LEFT_X - 8.0) * sx,
613 y: by + 12.0 * sy,
614 w: 140.0 * sx,
615 h: 31.0 * sy,
616 });
617 }
618 bg_sprites.push(BgSprite {
621 sheet: SpriteSheet::Book,
622 u: SEP_U,
623 v: SEP_V,
624 uw: SEP_W,
625 uh: SEP_H,
626 x: rx + sep_cx,
627 y: by + (TOP_PAD + 12.0) * sy,
628 w: SEP_W * sx,
629 h: (SEP_H * sy).max(1.0),
630 });
631
632 let (left_page, right_page) = if state.at_home {
633 let l = build_landing_left(book, theme, pw, ph, sx, sy, bx, by, &mut overlays);
634 let r = build_categories_right(book, state, theme, pw, ph, rx, py, sx, sy, &mut overlays);
635 (l, r)
636 } else {
637 let l = build_entry_left(
638 book,
639 state,
640 theme,
641 recipes,
642 pw,
643 ph,
644 lx,
645 py,
646 sx,
647 sy,
648 sep_cx,
649 &mut bg_sprites,
650 &mut overlays,
651 );
652 let r = build_entries_right(book, state, theme, pw, ph, rx, py, sx, sy, &mut overlays);
653 (l, r)
654 };
655
656 let root_widget = widget::panel(FlexDir::Row)
664 .w(bw)
665 .h(bh)
666 .gap(0.0)
667 .padding(TOP_PAD * sy, 0.0, 0.0, LEFT_X * sx)
668 .child(left_page)
669 .child(widget::spacer().w(spine_gap)) .child(right_page);
671
672 let outer = widget::panel(FlexDir::Column)
675 .w(bw)
676 .h(bh)
677 .child(root_widget);
678
679 let screen_root = widget::panel(FlexDir::Row)
684 .padding(by, 0.0, 0.0, bx)
685 .child(outer);
686
687 let ui = UiRoot::new(&book.id, screen_root);
688 (ui, bg_sprites, overlays)
689}
690
691fn build_landing_left(
694 book: &Book,
695 theme: &BookTheme,
696 page_w: f32,
697 page_h: f32,
698 sx: f32,
699 sy: f32,
700 bx: f32,
701 by: f32,
702 overlays: &mut Vec<OverlayCmd>,
703) -> widget::Widget {
704 overlays.push(OverlayCmd::McText {
707 text: book.name.clone(),
708 x: bx + 13.0 * sx,
709 y: by + 16.0 * sy,
710 color: theme.nameplate,
711 });
712 if let Some(author) = &book.author {
713 overlays.push(OverlayCmd::McText {
714 text: format!("by {}", author),
715 x: bx + 24.0 * sx,
716 y: by + 24.0 * sy,
717 color: theme.nameplate,
718 });
719 }
720
721 let mut col = widget::panel(FlexDir::Column)
725 .w(page_w)
726 .h(page_h)
727 .padding(25.0 * sy, 6.0, 4.0, 4.0)
728 .gap(0.0);
729
730 for para in book.landing_text.split('\n') {
731 if para.is_empty() {
732 col = col.child(widget::spacer().h(4.0 * sy));
733 } else {
734 col = col.child(lbl(para).color(theme.text));
735 }
736 }
737
738 let total = book.entries.len();
739 col = col.child(widget::spacer().flex(1.0));
740 col = col.child(
741 lbl(format!("{} entries", total))
742 .color(theme.nav)
743 .h(9.0 * sy)
744 .align(Align::Center),
745 );
746 col
747}
748
749fn build_categories_right(
752 book: &Book,
753 state: &BookViewState,
754 theme: &BookTheme,
755 page_w: f32,
756 page_h: f32,
757 rx: f32,
758 py: f32,
759 sx: f32,
760 sy: f32,
761 overlays: &mut Vec<OverlayCmd>,
762) -> widget::Widget {
763 let cell_w = 24.0 * sx;
768 let cell_h = 24.0 * sy;
769 let grid_pad_left = 10.0 * sx;
770 let header_h = 9.0 * sy;
771
772 let mut col = widget::panel(FlexDir::Column)
773 .w(page_w)
774 .h(page_h)
775 .padding(0.0, 4.0, 4.0, 0.0)
776 .gap(0.0);
777
778 col = col.child(
779 lbl("Categories")
780 .color(theme.divider)
781 .h(header_h)
782 .align(Align::Center)
783 .no_wrap(),
784 );
785 col = col.child(widget::spacer().h(25.0 * sy - header_h));
787
788 let cats = &book.categories;
790 let icon_s = 16.0 * sx.min(sy);
791 let mut row_i = 0usize;
792 loop {
793 let row_start = row_i * 4;
794 if row_start >= cats.len() {
795 break;
796 }
797 let mut row =
798 widget::panel(FlexDir::Row)
799 .h(cell_h)
800 .gap(0.0)
801 .padding(0.0, 0.0, 0.0, grid_pad_left);
802 for col_i in 0..4 {
803 let idx = row_start + col_i;
804 if idx >= cats.len() {
805 break;
806 }
807 let cat = &cats[idx];
808 let selected = !state.at_home && idx == state.cat;
809 let bg = if selected { theme.nav_selected_bg } else { 0 };
810
811 if let Some(icon_id) = &cat.icon {
812 push_icon(
813 overlays,
814 icon_id,
815 rx + (10.0 + col_i as f32 * 24.0 + 4.0) * sx,
816 py + (25.0 + row_i as f32 * 24.0 + 4.0) * sy,
817 icon_s,
818 icon_s,
819 );
820 }
821 let cell = widget::panel(FlexDir::Column)
822 .w(cell_w)
823 .h(cell_h)
824 .bg(bg)
825 .on_click(format!("cat:{}", idx))
826 .id(format!("book_cat_{}", idx));
827 row = row.child(cell);
828 }
829 col = col.child(row);
830 row_i += 1;
831 }
832 col
833}
834
835fn build_entry_left(
838 book: &Book,
839 state: &BookViewState,
840 theme: &BookTheme,
841 recipes: &HashMap<String, RecipeVis>,
842 page_w: f32,
843 page_h: f32,
844 ox: f32,
845 oy: f32,
846 sx: f32,
847 sy: f32,
848 sep_cx: f32,
849 bg_sprites: &mut Vec<BgSprite>,
850 overlays: &mut Vec<OverlayCmd>,
851) -> widget::Widget {
852 let entry = state.current_entry(book);
853 let page = entry.and_then(|e| e.pages.get(state.page));
854 let page_count = state.page_count(book);
855 let title_text = entry.map(|e| e.name.as_str()).unwrap_or("");
856
857 let title_h = 9.0 * sy;
862 let sep_h_px = (SEP_H * sy).max(1.0);
863 let sep_y = oy + 12.0 * sy;
864 let body_oy = oy + 22.0 * sy;
865
866 bg_sprites.push(BgSprite {
867 sheet: SpriteSheet::Book,
868 u: SEP_U,
869 v: SEP_V,
870 uw: SEP_W,
871 uh: SEP_H,
872 x: ox + sep_cx,
873 y: sep_y,
874 w: SEP_W * sx,
875 h: sep_h_px,
876 });
877
878 let page_label = format!("{}/{}", state.page + 1, page_count);
879 let nav = widget::panel(FlexDir::Row)
880 .h(12.0 * sy)
881 .gap(4.0)
882 .padding(0.0, 2.0, 0.0, 2.0)
883 .child(
884 btn("◀")
885 .w(12.0 * sx)
886 .h(12.0 * sy)
887 .color(theme.nav)
888 .on_click("prev_page")
889 .id("prev_page"),
890 )
891 .child(
892 lbl(&page_label)
893 .color(theme.nav)
894 .flex(1.0)
895 .align(Align::Center),
896 )
897 .child(
898 btn("⌂")
899 .w(12.0 * sx)
900 .h(12.0 * sy)
901 .color(theme.nav)
902 .on_click("home")
903 .id("book_home"),
904 )
905 .child(
906 btn("▶")
907 .w(12.0 * sx)
908 .h(12.0 * sy)
909 .color(theme.nav)
910 .on_click("next_page")
911 .id("next_page"),
912 );
913
914 let page_body = build_page(
915 page, state.page, theme, recipes, bg_sprites, overlays, ox, body_oy, sx, sy,
916 );
917
918 widget::panel(FlexDir::Column)
919 .w(page_w)
920 .h(page_h)
921 .padding(0.0, 6.0, 4.0, 4.0)
922 .gap(0.0)
923 .child(
924 title_lbl(title_text)
925 .color(theme.title)
926 .h(title_h)
927 .align(Align::Center)
928 .no_wrap(),
929 )
930 .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)
934 .child(nav)
935}
936
937fn build_entries_right(
940 book: &Book,
941 state: &BookViewState,
942 theme: &BookTheme,
943 page_w: f32,
944 page_h: f32,
945 rx: f32,
946 py: f32,
947 sx: f32,
948 sy: f32,
949 overlays: &mut Vec<OverlayCmd>,
950) -> widget::Widget {
951 let entries = state.entries_visible(book);
952 let cat_name = book
953 .categories
954 .get(state.cat)
955 .map(|c| c.name.as_str())
956 .unwrap_or("Entries");
957 let spread_count = state.list_spread_count(book);
958
959 let header_h = 9.0 * sy;
964 let row_h = 11.0 * sy;
965 let icon_size = 8.0 * sx.min(sy);
966
967 let mut col = widget::panel(FlexDir::Column)
968 .w(page_w)
969 .h(page_h)
970 .padding(0.0, 4.0, 4.0, 0.0)
971 .gap(0.0);
972
973 col = col.child(
974 lbl(cat_name)
975 .color(theme.divider)
976 .h(header_h)
977 .align(Align::Center)
978 .no_wrap(),
979 );
980 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();
986 for (i, entry) in entries.iter().enumerate() {
987 let abs_i = abs_start + i;
988 let selected = abs_i == state.entry;
989 let bg = if selected { theme.nav_selected_bg } else { 0 };
990 let color = if selected {
991 theme.nav_selected
992 } else {
993 theme.nav
994 };
995
996 if let Some(icon_id) = &entry.icon {
999 push_icon(
1000 overlays,
1001 icon_id,
1002 rx + 1.0 * sx,
1003 py + (20.0 + i as f32 * 11.0 + 1.0) * sy,
1004 icon_size,
1005 icon_size,
1006 );
1007 }
1008
1009 let mut row = widget::panel(FlexDir::Row)
1010 .h(row_h)
1011 .gap(2.0)
1012 .bg(bg)
1013 .on_click(format!("entry:{}", abs_i))
1014 .id(format!("book_entry_{}", abs_i))
1015 .padding(1.0, 2.0, 1.0, 2.0);
1016
1017 row = row.child(widget::spacer().w(icon_size + 2.0));
1018 row = row.child(lbl(&entry.name).color(color).flex(1.0).no_wrap());
1019 col = col.child(row);
1020 }
1021
1022 if spread_count > 1 {
1023 col = col.child(widget::spacer().flex(1.0));
1024 let spread_label = format!("{}/{}", state.list_spread + 1, spread_count);
1025 col = col.child(
1026 widget::panel(FlexDir::Row)
1027 .h(12.0 * sy)
1028 .gap(2.0)
1029 .child(
1030 btn("◀")
1031 .w(12.0 * sx)
1032 .h(12.0 * sy)
1033 .color(theme.nav)
1034 .on_click("prev_list")
1035 .id("prev_list"),
1036 )
1037 .child(
1038 lbl(&spread_label)
1039 .color(theme.nav)
1040 .flex(1.0)
1041 .align(Align::Center),
1042 )
1043 .child(
1044 btn("▶")
1045 .w(12.0 * sx)
1046 .h(12.0 * sy)
1047 .color(theme.nav)
1048 .on_click("next_list")
1049 .id("next_list"),
1050 ),
1051 );
1052 }
1053 col
1054}
1055
1056#[allow(clippy::too_many_arguments)]
1060fn draw_crafting_grid(
1061 theme: &BookTheme,
1062 bg_sprites: &mut Vec<BgSprite>,
1063 overlays: &mut Vec<OverlayCmd>,
1064 col: &mut widget::Widget,
1065 grid: &[Option<String>],
1066 width: usize,
1067 output: &str,
1068 count: u32,
1069 shapeless: bool,
1070 ox: f32,
1071 oy: f32,
1072 rx0: f32,
1073 ry: f32,
1074 sx: f32,
1075 sy: f32,
1076) {
1077 let mut c = std::mem::replace(col, widget::panel(FlexDir::Column));
1078 c = c.child(
1079 title_lbl(pretty_item_name(output))
1080 .color(theme.title)
1081 .h(10.0 * sy)
1082 .align(Align::Center),
1083 );
1084
1085 bg_sprites.push(BgSprite {
1087 sheet: SpriteSheet::Crafting,
1088 u: 0.0,
1089 v: 0.0,
1090 uw: 100.0,
1091 uh: 62.0,
1092 x: ox + (rx0 - 2.0) * sx,
1093 y: oy + (ry - 2.0) * sy,
1094 w: 100.0 * sx,
1095 h: 62.0 * sy,
1096 });
1097 if shapeless {
1099 bg_sprites.push(BgSprite {
1100 sheet: SpriteSheet::Crafting,
1101 u: 0.0,
1102 v: 64.0,
1103 uw: 11.0,
1104 uh: 11.0,
1105 x: ox + (rx0 + 62.0) * sx,
1106 y: oy + (ry + 2.0) * sy,
1107 w: 11.0 * sx,
1108 h: 11.0 * sy,
1109 });
1110 }
1111
1112 let icon_s = 16.0 * sx.min(sy);
1113 let width = width.max(1);
1114 for (i, slot) in grid.iter().enumerate() {
1115 if let Some(item) = slot {
1116 overlays.push(OverlayCmd::McItem {
1117 item_id: item.clone(),
1118 x: ox + (rx0 + 3.0 + (i % width) as f32 * 19.0) * sx,
1119 y: oy + (ry + 3.0 + (i / width) as f32 * 19.0) * sy,
1120 w: icon_s,
1121 h: icon_s,
1122 });
1123 }
1124 }
1125 overlays.push(OverlayCmd::McItem {
1127 item_id: output.to_owned(),
1128 x: ox + (rx0 + 79.0) * sx,
1129 y: oy + (ry + 22.0) * sy,
1130 w: icon_s,
1131 h: icon_s,
1132 });
1133 if count > 1 {
1134 overlays.push(OverlayCmd::McText {
1135 text: format!("x{}", count),
1136 x: ox + (rx0 + 81.0) * sx,
1137 y: oy + (ry + 40.0) * sy,
1138 color: theme.title,
1139 });
1140 }
1141 overlays.push(OverlayCmd::McItem {
1143 item_id: "minecraft:crafting_table".into(),
1144 x: ox + (rx0 + 79.0) * sx,
1145 y: oy + (ry + 41.0) * sy,
1146 w: icon_s,
1147 h: icon_s,
1148 });
1149
1150 c = c.child(widget::spacer().h((ry + 62.0 + 4.0 - 10.0) * sy));
1152 *col = c;
1153}
1154
1155fn build_page(
1161 page: Option<&BookPage>,
1162 page_num: usize,
1163 theme: &BookTheme,
1164 recipes: &HashMap<String, RecipeVis>,
1165 bg_sprites: &mut Vec<BgSprite>,
1166 overlays: &mut Vec<OverlayCmd>,
1167 ox: f32,
1168 oy: f32,
1169 sx: f32,
1170 sy: f32,
1171) -> widget::Widget {
1172 let mut col = widget::panel(FlexDir::Column).flex(1.0).gap(4.0);
1173
1174 let Some(page) = page else {
1175 return col.child(lbl("No entries yet.").color(theme.nav));
1176 };
1177
1178 match page {
1179 BookPage::Text { text, title } => {
1180 if page_num > 0
1182 && let Some(t) = title
1183 {
1184 let sep_h_px = (SEP_H * sy).max(1.0);
1185 let title_h = 9.0 * sy;
1186 col = col.child(
1187 title_lbl(t.as_str())
1188 .color(theme.title)
1189 .h(title_h)
1190 .align(Align::Center)
1191 .no_wrap(),
1192 );
1193 bg_sprites.push(BgSprite {
1195 sheet: SpriteSheet::Book,
1196 u: SEP_U,
1197 v: SEP_V,
1198 uw: SEP_W,
1199 uh: SEP_H,
1200 x: ox + (PAGE_W / 2.0 - SEP_W / 2.0) * sx,
1201 y: oy + 12.0 * sy,
1202 w: SEP_W * sx,
1203 h: sep_h_px,
1204 });
1205 col = col.child(widget::spacer().h((12.0 - 9.0) * sy));
1206 col = col.child(widget::spacer().h(sep_h_px));
1207 col = col.child(widget::spacer().h((22.0 - 12.0 - SEP_H) * sy));
1208 }
1209 for para in text.split('\n') {
1210 col = col.child(lbl(para).color(theme.text));
1211 }
1212 }
1213
1214 BookPage::Spotlight { item, title, text } => {
1215 bg_sprites.push(BgSprite {
1220 sheet: SpriteSheet::Crafting,
1221 u: 0.0,
1222 v: 102.0,
1223 uw: 66.0,
1224 uh: 26.0,
1225 x: ox + (PAGE_W / 2.0 - 33.0) * sx,
1226 y: oy + 10.0 * sy,
1227 w: 66.0 * sx,
1228 h: 26.0 * sy,
1229 });
1230
1231 let item_name = title
1232 .as_deref()
1233 .or(item.name.as_deref())
1234 .unwrap_or(item.id.as_str());
1235 col = col.child(
1236 title_lbl(item_name)
1237 .color(theme.title)
1238 .h(10.0 * sy)
1239 .align(Align::Center),
1240 );
1241
1242 let icon_s = 16.0 * sx.min(sy);
1243 overlays.push(OverlayCmd::McItem {
1244 item_id: item.id.clone(),
1245 x: ox + (PAGE_W / 2.0 - 8.0) * sx,
1246 y: oy + 15.0 * sy,
1247 w: icon_s,
1248 h: icon_s,
1249 });
1250
1251 col = col.child(widget::spacer().h(30.0 * sy));
1253
1254 if let Some(t) = text {
1255 col = col.child(lbl(t.as_str()).color(theme.text));
1256 }
1257 }
1258
1259 BookPage::Crafting { recipe_id, text } => {
1260 let rx0 = PAGE_W / 2.0 - 49.0; let ry = 12.0; match recipes.get(recipe_id.as_str()) {
1265 Some(RecipeVis::Shaped {
1266 grid,
1267 width,
1268 output,
1269 count,
1270 }) => {
1271 draw_crafting_grid(
1272 theme, bg_sprites, overlays, &mut col, grid, *width, output, *count, false,
1273 ox, oy, rx0, ry, sx, sy,
1274 );
1275 }
1276 Some(RecipeVis::Shapeless {
1277 ingredients,
1278 output,
1279 count,
1280 }) => {
1281 let grid: Vec<Option<String>> = ingredients.iter().cloned().map(Some).collect();
1282 draw_crafting_grid(
1283 theme, bg_sprites, overlays, &mut col, &grid, 3, output, *count, true, ox,
1284 oy, rx0, ry, sx, sy,
1285 );
1286 }
1287 _ => {
1288 col = col.child(lbl(format!("[Recipe: {}]", recipe_id)).color(theme.nav));
1289 }
1290 }
1291 if let Some(t) = text {
1292 col = col.child(lbl(t.as_str()).color(theme.text));
1293 }
1294 }
1295
1296 BookPage::Smelting { recipe_id, text } => {
1297 let rx0 = PAGE_W / 2.0 - 49.0;
1300 let ry = 12.0;
1301 match recipes.get(recipe_id.as_str()) {
1302 Some(RecipeVis::Smelting { input, output }) => {
1303 col = col.child(
1304 title_lbl(pretty_item_name(output))
1305 .color(theme.title)
1306 .h(10.0 * sy)
1307 .align(Align::Center),
1308 );
1309 bg_sprites.push(BgSprite {
1310 sheet: SpriteSheet::Crafting,
1311 u: 11.0,
1312 v: 71.0,
1313 uw: 96.0,
1314 uh: 24.0,
1315 x: ox + rx0 * sx,
1316 y: oy + ry * sy,
1317 w: 96.0 * sx,
1318 h: 24.0 * sy,
1319 });
1320 let icon_s = 16.0 * sx.min(sy);
1321 overlays.push(OverlayCmd::McItem {
1322 item_id: input.clone(),
1323 x: ox + (rx0 + 4.0) * sx,
1324 y: oy + (ry + 4.0) * sy,
1325 w: icon_s,
1326 h: icon_s,
1327 });
1328 overlays.push(OverlayCmd::McItem {
1330 item_id: "minecraft:furnace".into(),
1331 x: ox + (rx0 + 40.0) * sx,
1332 y: oy + (ry + 4.0) * sy,
1333 w: icon_s,
1334 h: icon_s,
1335 });
1336 overlays.push(OverlayCmd::McItem {
1337 item_id: output.clone(),
1338 x: ox + (rx0 + 76.0) * sx,
1339 y: oy + (ry + 4.0) * sy,
1340 w: icon_s,
1341 h: icon_s,
1342 });
1343 col = col.child(widget::spacer().h((ry + 24.0 + 4.0 - 10.0) * sy));
1345 }
1346 _ => {
1347 col = col.child(lbl(format!("[Recipe: {}]", recipe_id)).color(theme.nav));
1348 }
1349 }
1350 if let Some(t) = text {
1351 col = col.child(lbl(t.as_str()).color(theme.text));
1352 }
1353 }
1354
1355 BookPage::Image {
1356 texture,
1357 title,
1358 text,
1359 ..
1360 } => {
1361 if let Some(t) = title {
1362 col = col.child(title_lbl(t.as_str()).color(theme.title));
1363 }
1364 col = col.child(
1365 widget::image(texture)
1366 .region([0.0, 0.0, 80.0, 80.0])
1367 .stretch(Stretch::Fill)
1368 .w(80.0)
1369 .h(80.0),
1370 );
1371 if let Some(t) = text {
1372 col = col.child(lbl(t.as_str()).color(theme.text));
1373 }
1374 }
1375
1376 BookPage::Svg { data, title, text } => {
1377 let top = if let Some(t) = title {
1378 col = col.child(title_lbl(t.as_str()).color(theme.title));
1379 12.0
1380 } else {
1381 0.0
1382 };
1383 overlays.push(OverlayCmd::Svg {
1384 data: data.clone(),
1385 x: ox + (PAGE_W / 2.0 - 32.0) * sx,
1386 y: oy + top * sy,
1387 w: 64.0 * sx,
1388 h: 64.0 * sy,
1389 });
1390 col = col.child(widget::spacer().h(68.0 * sy));
1391 if let Some(t) = text {
1392 col = col.child(lbl(t.as_str()).color(theme.text));
1393 }
1394 }
1395
1396 BookPage::CustomText { text, font, color } => {
1397 col = col.child(
1400 lbl(text.as_str())
1401 .font(font.font_id.as_str())
1402 .font_scale(text_scale() * font.size_px / yog_ui::text::LINE_H)
1403 .color(*color),
1404 );
1405 }
1406
1407 BookPage::Relations { entries, text } => {
1408 if let Some(t) = text {
1409 col = col.child(lbl(t.as_str()).color(theme.text));
1410 }
1411 col = col.child(title_lbl("See also:").color(theme.title));
1412 for e in entries {
1413 col = col.child(lbl(format!("• {}", e)).color(theme.nav));
1414 }
1415 }
1416
1417 BookPage::Entity {
1418 entity_type,
1419 name,
1420 text,
1421 } => {
1422 let display = name.as_deref().unwrap_or(entity_type.as_str());
1423 col = col.child(title_lbl(display).color(theme.title));
1424 overlays.push(OverlayCmd::Entity {
1425 entity: entity_type.clone(),
1426 x: ox + (PAGE_W / 2.0 - 40.0) * sx,
1427 y: oy + 12.0 * sy,
1428 w: 80.0 * sx,
1429 h: 80.0 * sy,
1430 });
1431 col = col.child(widget::spacer().h(84.0 * sy));
1432 if let Some(t) = text {
1433 col = col.child(lbl(t.as_str()).color(theme.text));
1434 }
1435 }
1436
1437 BookPage::Pattern {
1438 op_id,
1439 input,
1440 output,
1441 text,
1442 ..
1443 } => {
1444 col = col.child(title_lbl(op_id.as_str()).color(theme.title));
1445 col = col.child(lbl(format!("{} → {}", input, output)).color(theme.nav));
1446 if !text.is_empty() {
1447 col = col.child(lbl(text.as_str()).color(theme.text));
1448 }
1449 }
1450
1451 BookPage::Empty => {}
1452 }
1453
1454 col
1455}
1456
1457#[cfg(test)]
1458mod tests {
1459 use super::*;
1460 use crate::BookEntry;
1461
1462 fn book(pages: Vec<BookPage>) -> Book {
1463 let mut book = Book::new("t:b", "B");
1464 book.entries.push(BookEntry { id: "e".into(), name: "E".into(), pages, ..Default::default() });
1465 book
1466 }
1467
1468 #[test]
1469 fn long_text_flows_onto_new_pages() {
1470 let long = "word ".repeat(400);
1471 let flowed = flow(book(vec![BookPage::Text { text: long.clone(), title: None }, BookPage::Empty]));
1472 let pages = &flowed.entries[0].pages;
1473 assert!(pages.len() > 3, "{}", pages.len());
1474 assert!(matches!(pages.last(), Some(BookPage::Empty)), "what came after stays after");
1475 let line_h = yog_ui::text::LINE_H + yog_ui::text::LINE_GAP;
1476 for p in &pages[..pages.len() - 1] {
1477 let BookPage::Text { text, .. } = p else { panic!("{p:?}") };
1478 assert!(text.lines().count() as f32 * line_h <= BODY_H + 1.0);
1479 assert!(text.lines().all(|l| yog_ui::text::str_width(l, 1.0) <= TEXT_W));
1480 }
1481 let words: usize = pages.iter().map(|p| match p {
1482 BookPage::Text { text, .. } => text.split_whitespace().count(),
1483 _ => 0,
1484 }).sum();
1485 assert_eq!(words, 400, "nothing lost");
1486 }
1487
1488 #[test]
1489 fn edits_keep_the_reader_where_they_were() {
1490 let mut b = book(vec![BookPage::Text { text: "one".into(), title: None }, BookPage::Text { text: "two".into(), title: None }]);
1491 b.categories.push(crate::BookCategory { id: "c".into(), name: "C".into(), ..Default::default() });
1492 b.entries[0].category = "c".into();
1493 let mut r = BookRenderer::new(b.clone());
1494 assert_eq!(r.shown(), None, "opens on the landing page");
1495 assert!(r.show("e", 1));
1496 assert_eq!(r.shown(), Some(("e".into(), 1)));
1497 r.prepare(800.0, 600.0);
1498 assert!(r.ui.is_some());
1499
1500 b.entries[0].pages.push(BookPage::Empty);
1501 r.set_book(b);
1502 assert_eq!(r.shown(), Some(("e".into(), 1)));
1503 assert!(!r.show("missing", 0));
1504 }
1505
1506 #[test]
1507 fn short_text_stays_put() {
1508 let pages = vec![BookPage::Text { text: "Hello".into(), title: Some("T".into()) }];
1509 let flowed = flow(book(pages.clone()));
1510 assert_eq!(flowed.entries[0].pages.len(), 1);
1511 }
1512}