1use std::collections::HashMap;
5use yog_gfx::{
6 GfxContext,
7 core::{DataType, DrawMode, blend},
8 gl,
9};
10use yog_ui::{Align, FlexDir, Stretch, UiRoot, widget};
11
12use crate::font::{BookFontRegistry, FontAtlas};
13use crate::state::BookViewState;
14use crate::svg;
15use crate::theme::BookTheme;
16use crate::{Book, BookPage};
17
18pub const BOOK_TEX_W: f32 = 512.0;
22pub const BOOK_TEX_H: f32 = 256.0;
23pub const BK_W: f32 = 272.0;
25pub const BK_H: f32 = 180.0;
26pub const PAGE_W: f32 = 116.0;
28pub const PAGE_H: f32 = 156.0;
29pub 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;
34pub const SEP_V: f32 = 180.0;
35pub const SEP_W: f32 = 110.0;
36pub const SEP_H: f32 = 3.0;
37const SCREEN_MARGIN: f32 = 16.0;
39const MIN_SCALE: f32 = 0.4;
41
42#[repr(C)]
45#[derive(Copy, Clone)]
46struct Vert {
47 x: f32,
48 y: f32,
49 u: f32,
50 v: f32,
51 r: f32,
52 g: f32,
53 b: f32,
54 a: f32,
55}
56
57fn rgba_f(c: u32) -> (f32, f32, f32, f32) {
58 let a = ((c >> 24) & 0xFF) as f32 / 255.0;
59 let r = ((c >> 16) & 0xFF) as f32 / 255.0;
60 let g = ((c >> 8) & 0xFF) as f32 / 255.0;
61 let b = (c & 0xFF) as f32 / 255.0;
62 (r, g, b, a)
63}
64
65fn quad(
66 verts: &mut Vec<Vert>,
67 x: f32,
68 y: f32,
69 w: f32,
70 h: f32,
71 u0: f32,
72 v0: f32,
73 u1: f32,
74 v1: f32,
75 color: u32,
76) {
77 let (r, g, b, a) = rgba_f(color);
78 let p = [
79 Vert {
80 x,
81 y,
82 u: u0,
83 v: v0,
84 r,
85 g,
86 b,
87 a,
88 },
89 Vert {
90 x: x + w,
91 y,
92 u: u1,
93 v: v0,
94 r,
95 g,
96 b,
97 a,
98 },
99 Vert {
100 x,
101 y: y + h,
102 u: u0,
103 v: v1,
104 r,
105 g,
106 b,
107 a,
108 },
109 Vert {
110 x: x + w,
111 y: y + h,
112 u: u1,
113 v: v1,
114 r,
115 g,
116 b,
117 a,
118 },
119 ];
120 verts.extend_from_slice(&[p[0], p[1], p[2], p[1], p[3], p[2]]);
122}
123
124const VERT: &str = r#"
127#version 330 core
128layout(location = 0) in vec2 aPos;
129layout(location = 1) in vec2 aUv;
130layout(location = 2) in vec4 aCol;
131out vec2 fUv;
132out vec4 fCol;
133uniform vec2 uScreen;
134void main() {
135 fUv = aUv;
136 fCol = aCol;
137 vec2 ndc = aPos / uScreen * 2.0 - vec2(1.0);
138 gl_Position = vec4(ndc.x, -ndc.y, 0.0, 1.0);
139}
140"#;
141
142const FRAG: &str = r#"
143#version 330 core
144in vec2 fUv;
145in vec4 fCol;
146out vec4 outColor;
147uniform sampler2D uTex;
148uniform int uMode; // 0 = solid color, 1 = RGBA texture, 2 = alpha-only (font)
149void main() {
150 if (uMode == 0) {
151 outColor = fCol;
152 } else if (uMode == 1) {
153 outColor = texture(uTex, fUv) * fCol;
154 } else {
155 float alpha = texture(uTex, fUv).a;
156 outColor = vec4(fCol.rgb, fCol.a * alpha);
157 }
158}
159"#;
160
161static BOOK_PNG: &[u8] = include_bytes!("../assets/book_brown.png");
164static CRAFTING_PNG: &[u8] = include_bytes!("../assets/crafting.png");
165
166fn decode_png(data: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
167 use png::Decoder;
168 let decoder = Decoder::new(std::io::Cursor::new(data));
169 let mut reader = decoder.read_info().ok()?;
170 let mut buf = vec![0u8; reader.output_buffer_size().unwrap_or(0)];
171 let info = reader.next_frame(&mut buf).ok()?;
172 let rgba = match info.color_type {
173 png::ColorType::Rgba => buf[..info.buffer_size()].to_vec(),
174 png::ColorType::Rgb => {
175 let rgb = &buf[..info.buffer_size()];
176 let mut out = Vec::with_capacity(rgb.len() / 3 * 4);
177 for px in rgb.chunks(3) {
178 out.extend_from_slice(px);
179 out.push(255);
180 }
181 out
182 }
183 _ => return None,
184 };
185 Some((rgba, info.width, info.height))
186}
187
188struct BookGl {
191 prog: gl::ShaderProgram,
192 vao: gl::VertexArray,
193 vbo: gl::Buffer,
194 book_tex: Option<(gl::Texture, u32, u32)>,
195 crafting_tex: Option<(gl::Texture, u32, u32)>,
196 svg_tex: HashMap<u64, (gl::Texture, u32, u32)>,
197 font_atlas: HashMap<u64, (gl::Texture, FontAtlas)>,
198}
199
200impl BookGl {
201 fn init(ctx: &GfxContext) -> Option<Self> {
202 let prog = ctx.create_shader(VERT, FRAG).ok()?;
203 let vbo = ctx.create_buffer();
204 let vao = ctx.create_vao();
205
206 const STRIDE: u32 = 32; vao.attrib(ctx, &vbo, 0, 2, DataType::F32, false, STRIDE, 0); vao.attrib(ctx, &vbo, 1, 2, DataType::F32, false, STRIDE, 8); vao.attrib(ctx, &vbo, 2, 4, DataType::F32, false, STRIDE, 16); let load = |data: &[u8]| {
212 decode_png(data).map(|(rgba, w, h)| {
213 let tex = ctx.create_texture_rgba(w, h, &rgba, true);
214 (tex, w, h)
215 })
216 };
217 let book_tex = load(BOOK_PNG);
218 let crafting_tex = load(CRAFTING_PNG);
219
220 Some(BookGl {
221 prog,
222 vao,
223 vbo,
224 book_tex,
225 crafting_tex,
226 svg_tex: HashMap::new(),
227 font_atlas: HashMap::new(),
228 })
229 }
230
231 fn svg_tex(
232 &mut self,
233 ctx: &GfxContext,
234 hash: u64,
235 data: &str,
236 w: u32,
237 h: u32,
238 ) -> Option<&(gl::Texture, u32, u32)> {
239 if let std::collections::hash_map::Entry::Vacant(e) = self.svg_tex.entry(hash) {
240 let pixels = svg::rasterize(data, w, h)?;
241 let tex = ctx.create_texture_rgba(w, h, &pixels, true);
242 e.insert((tex, w, h));
243 }
244 self.svg_tex.get(&hash)
245 }
246
247 fn flush(&self, ctx: &GfxContext, verts: &[Vert]) {
248 if verts.is_empty() {
249 return;
250 }
251 unsafe {
252 self.vbo.upload(ctx, verts, true);
253 }
254 ctx.draw_arrays(
255 &self.vao,
256 &self.prog,
257 DrawMode::Triangles,
258 0,
259 verts.len() as u32,
260 );
261 }
262}
263
264impl BookGl {
267 fn begin_frame(&self, ctx: &GfxContext, sw: f32, sh: f32) {
268 ctx.set_blend(true, blend::SRC_ALPHA, blend::ONE_MINUS_SRC_ALPHA);
269 ctx.set_depth(false, false);
270 self.prog.uniform_2f(ctx, "uScreen", sw, sh);
271 self.prog.uniform_1i(ctx, "uTex", 0);
272 }
273
274 fn draw_book_sprite(&self, ctx: &GfxContext, spr: &BgSprite) {
276 let sheet = match spr.sheet {
277 SpriteSheet::Book => &self.book_tex,
278 SpriteSheet::Crafting => &self.crafting_tex,
279 };
280 let Some((tex, tw, th)) = sheet else { return };
281 let u0 = spr.u / *tw as f32;
282 let v0 = spr.v / *th as f32;
283 let u1 = (spr.u + spr.uw) / *tw as f32;
284 let v1 = (spr.v + spr.uh) / *th as f32;
285 self.prog.uniform_1i(ctx, "uMode", 1);
286 ctx.bind_texture(0, tex);
287 let mut v = Vec::with_capacity(6);
288 quad(
289 &mut v,
290 spr.x,
291 spr.y,
292 spr.w,
293 spr.h,
294 u0,
295 v0,
296 u1,
297 v1,
298 0xFF_FFFFFF,
299 );
300 self.flush(ctx, &v);
301 }
302
303 fn draw_book_bg(&self, ctx: &GfxContext, bx: f32, by: f32, bw: f32, bh: f32) {
305 let Some((tex, tw, th)) = &self.book_tex else {
306 return;
307 };
308 let su = BK_W / *tw as f32;
309 let sv = BK_H / *th as f32;
310 self.prog.uniform_1i(ctx, "uMode", 1);
311 ctx.bind_texture(0, tex);
312 let mut v = Vec::with_capacity(6);
313 quad(&mut v, bx, by, bw, bh, 0.0, 0.0, su, sv, 0xFF_FFFFFF);
314 self.flush(ctx, &v);
315 }
316
317 fn draw_svg(&mut self, ctx: &GfxContext, data: &str, x: f32, y: f32, w: f32, h: f32) {
318 let hash = svg::svg_hash(data);
319 let tw = w as u32;
320 let th = h as u32;
321 if let Some(&(tex, _, _)) = self.svg_tex(ctx, hash, data, tw, th) {
322 self.prog.uniform_1i(ctx, "uMode", 1);
323 ctx.bind_texture(0, &tex);
324 let mut v = Vec::with_capacity(6);
325 quad(&mut v, x, y, w, h, 0.0, 0.0, 1.0, 1.0, 0xFF_FFFFFF);
326 self.flush(ctx, &v);
327 }
328 }
329
330 fn draw_text_custom(
331 &mut self,
332 ctx: &GfxContext,
333 ttf: &[u8],
334 size_px: f32,
335 text: &str,
336 mut x: f32,
337 y: f32,
338 color: u32,
339 ) {
340 let hash = font_hash(ttf);
341 if let std::collections::hash_map::Entry::Vacant(e) = self.font_atlas.entry(hash)
342 && let Some(atlas) = FontAtlas::build(ttf, size_px)
343 {
344 let tex =
345 ctx.create_texture_rgba(atlas.atlas_size, atlas.atlas_size, &atlas.pixels, true);
346 e.insert((tex, atlas));
347 }
348 let ptrs = self
351 .font_atlas
352 .get(&hash)
353 .map(|(t, a)| (t as *const gl::Texture, a as *const FontAtlas));
354 if let Some((tex_ptr, atlas_ptr)) = ptrs {
355 let tex = unsafe { &*tex_ptr };
357 let atlas = unsafe { &*atlas_ptr };
358 self.prog.uniform_1i(ctx, "uMode", 2);
359 ctx.bind_texture(0, tex);
360 let baseline = y + size_px;
361 let mut verts = Vec::new();
362 for ch in text.chars() {
363 if let Some(g) = atlas.glyphs.get(&ch) {
364 if g.width > 0 {
365 let gx = x + g.xoff;
366 let gy = baseline - g.yoff - g.height as f32;
367 quad(
368 &mut verts,
369 gx,
370 gy,
371 g.width as f32,
372 g.height as f32,
373 g.u0,
374 g.v0,
375 g.u1,
376 g.v1,
377 color,
378 );
379 }
380 x += g.advance;
381 }
382 }
383 self.flush(ctx, &verts);
384 }
385 }
386}
387
388fn font_hash(data: &[u8]) -> u64 {
389 use std::hash::{Hash, Hasher};
390 let mut h = std::collections::hash_map::DefaultHasher::new();
391 data.hash(&mut h);
392 h.finish()
393}
394
395#[derive(Clone, Copy, PartialEq)]
398pub(crate) enum SpriteSheet {
399 Book,
400 Crafting,
401}
402
403#[derive(Clone)]
405pub(crate) struct BgSprite {
406 pub sheet: SpriteSheet,
407 pub u: f32,
409 pub v: f32,
410 pub uw: f32,
411 pub uh: f32,
412 pub x: f32,
414 pub y: f32,
415 pub w: f32,
416 pub h: f32,
417}
418
419#[derive(Clone)]
422pub(crate) enum OverlayCmd {
423 Svg {
424 data: String,
425 x: f32,
426 y: f32,
427 w: f32,
428 h: f32,
429 },
430 Text {
431 text: String,
432 font: crate::font::BookFont,
433 x: f32,
434 y: f32,
435 color: u32,
436 },
437 McText {
439 text: String,
440 x: f32,
441 y: f32,
442 color: u32,
443 },
444 McItem {
446 item_id: String,
447 x: f32,
448 y: f32,
449 w: f32,
450 h: f32,
451 },
452 McTexture {
457 path: String,
458 x: f32,
459 y: f32,
460 w: f32,
461 h: f32,
462 },
463}
464
465fn push_icon(overlays: &mut Vec<OverlayCmd>, icon: &str, x: f32, y: f32, w: f32, h: f32) {
469 if icon.ends_with(".png") {
470 overlays.push(OverlayCmd::McTexture {
471 path: icon.to_string(),
472 x,
473 y,
474 w,
475 h,
476 });
477 } else {
478 overlays.push(OverlayCmd::McItem {
479 item_id: icon.to_string(),
480 x,
481 y,
482 w,
483 h,
484 });
485 }
486}
487
488#[derive(Clone)]
491pub(crate) enum RecipeVis {
492 Shaped {
493 grid: Vec<Option<String>>,
494 width: usize,
495 output: String,
496 count: u32,
497 },
498 Shapeless {
499 ingredients: Vec<String>,
500 output: String,
501 count: u32,
502 },
503 Smelting {
504 input: String,
505 output: String,
506 },
507}
508
509fn parse_recipe(json: &str) -> Option<RecipeVis> {
510 let v: serde_json::Value = serde_json::from_str(json).ok()?;
511 match v.get("type")?.as_str()? {
512 "minecraft:crafting_shaped" => {
513 let pattern: Vec<&str> = v
514 .get("pattern")?
515 .as_array()?
516 .iter()
517 .filter_map(|p| p.as_str())
518 .collect();
519 let width = pattern.iter().map(|r| r.chars().count()).max()?.max(1);
520 let key = v.get("key")?.as_object()?;
521 let mut grid = Vec::new();
522 for row in &pattern {
523 let mut chars: Vec<char> = row.chars().collect();
524 chars.resize(width, ' ');
525 for ch in chars {
526 grid.push(
527 key.get(&ch.to_string())
528 .and_then(|k| k.get("item"))
529 .and_then(|i| i.as_str())
530 .map(str::to_owned),
531 );
532 }
533 }
534 let result = v.get("result")?;
535 Some(RecipeVis::Shaped {
536 grid,
537 width,
538 output: result.get("item")?.as_str()?.to_owned(),
539 count: result.get("count").and_then(|c| c.as_u64()).unwrap_or(1) as u32,
540 })
541 }
542 "minecraft:crafting_shapeless" => {
543 let ingredients = v
544 .get("ingredients")?
545 .as_array()?
546 .iter()
547 .filter_map(|i| i.get("item").and_then(|x| x.as_str()).map(str::to_owned))
548 .collect();
549 let result = v.get("result")?;
550 Some(RecipeVis::Shapeless {
551 ingredients,
552 output: result.get("item")?.as_str()?.to_owned(),
553 count: result.get("count").and_then(|c| c.as_u64()).unwrap_or(1) as u32,
554 })
555 }
556 "minecraft:smelting"
557 | "minecraft:blasting"
558 | "minecraft:smoking"
559 | "minecraft:campfire_cooking" => Some(RecipeVis::Smelting {
560 input: v.get("ingredient")?.get("item")?.as_str()?.to_owned(),
561 output: v.get("result")?.as_str()?.to_owned(),
562 }),
563 _ => None,
564 }
565}
566
567fn normalize_item_id(id: &str) -> String {
570 let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
571 let name = path
572 .trim_start_matches("item/")
573 .trim_start_matches("block/");
574 format!("{ns}:{name}")
575}
576
577fn pretty_item_name(id: &str) -> String {
579 let name = id.rsplit(':').next().unwrap_or(id);
580 name.split('_')
581 .map(|w| {
582 let mut c = w.chars();
583 match c.next() {
584 Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
585 None => String::new(),
586 }
587 })
588 .collect::<Vec<_>>()
589 .join(" ")
590}
591
592pub struct BookRenderer {
595 pub book: Book,
596 pub state: BookViewState,
597 pub theme: BookTheme,
598 fonts: BookFontRegistry,
599 recipes: HashMap<String, RecipeVis>,
600 gl: Option<BookGl>,
601 pub ui: Option<UiRoot>,
602 bg_sprites: Vec<BgSprite>,
603 overlays: Vec<OverlayCmd>,
604 dirty: bool,
605 last_sw: f32,
606 last_sh: f32,
607 source: Option<Book>,
610 translator: Option<fn(&str) -> Option<String>>,
611 probe: Option<Option<String>>,
612}
613
614impl BookRenderer {
615 pub fn new(book: Book) -> Self {
616 let theme = BookTheme::default().with_nameplate(&book.nameplate_color);
617 Self {
618 book: flow(book),
619 state: BookViewState::default(),
620 theme,
621 fonts: BookFontRegistry::default(),
622 recipes: HashMap::new(),
623 gl: None,
624 ui: None,
625 bg_sprites: Vec::new(),
626 overlays: Vec::new(),
627 dirty: true,
628 last_sw: 0.0,
629 last_sh: 0.0,
630 source: None,
631 translator: None,
632 probe: None,
633 }
634 }
635
636 pub fn set_translator(&mut self, tr: fn(&str) -> Option<String>) {
640 if self.book.i18n {
641 self.source.get_or_insert_with(|| self.book.clone());
642 self.translator = Some(tr);
644 self.probe = None;
645 }
646 }
647
648 fn localize(&mut self) {
650 let (Some(src), Some(tr)) = (&self.source, self.translator) else {
651 return;
652 };
653 let probe = Some(tr(&src.name));
654 if probe == self.probe {
655 return;
656 }
657 self.book = flow(src.localized(|k| tr(k).unwrap_or_else(|| k.to_string())));
658 self.probe = probe;
659 self.dirty = true;
660 }
661
662 pub fn handle_event(&mut self, ev: &str) {
663 if self.state.handle(ev, &self.book) {
664 self.dirty = true;
665 }
666 }
667
668 pub fn register_font(&mut self, id: impl Into<String>, ttf: Vec<u8>) {
670 self.fonts.register(id, ttf);
671 }
672
673 pub fn add_recipe(&mut self, id: impl Into<String>, json: &str) {
676 if let Some(vis) = parse_recipe(json) {
677 self.recipes.insert(id.into(), vis);
678 self.dirty = true;
679 }
680 }
681
682 pub fn render(&mut self, ctx: &GfxContext, sw: f32, sh: f32) {
683 if self.gl.is_none() {
685 self.gl = BookGl::init(ctx);
686 }
687
688 let scale = ((sw - SCREEN_MARGIN) / BK_W)
691 .min((sh - SCREEN_MARGIN) / BK_H)
692 .clamp(MIN_SCALE, 1.0);
693 let bw = BK_W * scale;
694 let bh = BK_H * scale;
695 let bx = (sw - bw) / 2.0;
696 let by = (sh - bh) / 2.0;
697
698 self.localize();
699 if self.dirty || sw != self.last_sw || sh != self.last_sh {
701 let (root, bg_sprites, overlays) = build_ui(
702 &self.book,
703 &self.state,
704 &self.theme,
705 &self.recipes,
706 bx,
707 by,
708 bw,
709 bh,
710 );
711 self.ui = Some(root);
712 self.bg_sprites = bg_sprites;
713 self.overlays = overlays;
714 self.dirty = false;
715 self.last_sw = sw;
716 self.last_sh = sh;
717 }
718
719 if let Some(gl) = &mut self.gl {
721 gl.begin_frame(ctx, sw, sh);
722 gl.draw_book_bg(ctx, bx, by, bw, bh);
723 }
724
725 if let Some(gl) = &mut self.gl {
727 for spr in &self.bg_sprites {
728 gl.draw_book_sprite(ctx, spr);
729 }
730 }
731
732 if let Some(ui) = &mut self.ui {
734 if ui.needs_layout {
735 ui.layout(sw, sh);
736 }
737 ui.render(ctx);
738 }
739
740 if let Some(gl) = &mut self.gl {
742 gl.begin_frame(ctx, sw, sh);
743 for ov in &self.overlays {
744 match *ov {
745 OverlayCmd::Svg {
746 ref data,
747 x,
748 y,
749 w,
750 h,
751 } => gl.draw_svg(ctx, data, x, y, w, h),
752 OverlayCmd::Text {
753 ref text,
754 ref font,
755 x,
756 y,
757 color,
758 } => {
759 if let Some(ttf) = self.fonts.get(&font.font_id) {
760 gl.draw_text_custom(ctx, ttf, font.size_px, text, x, y, color);
761 }
762 }
763 OverlayCmd::McText { .. }
764 | OverlayCmd::McItem { .. }
765 | OverlayCmd::McTexture { .. } => {}
766 }
767 }
768 }
769
770 for ov in &self.overlays {
773 match *ov {
774 OverlayCmd::McItem {
775 ref item_id,
776 x,
777 y,
778 w,
779 h,
780 } => {
781 ctx.draw2d()
782 .item(&normalize_item_id(item_id), x, y, w.min(h));
783 }
784 OverlayCmd::McText {
785 ref text,
786 x,
787 y,
788 color,
789 } => {
790 ctx.draw2d().text(text, x, y, color, false);
791 }
792 OverlayCmd::McTexture {
793 ref path,
794 x,
795 y,
796 w,
797 h,
798 } => {
799 ctx.draw2d().image(path, x, y, w, h);
802 }
803 _ => {}
804 }
805 }
806 }
807}
808
809thread_local! {
812 static TEXT_SCALE: std::cell::Cell<f32> = const { std::cell::Cell::new(1.0) };
815}
816
817fn text_scale() -> f32 {
818 TEXT_SCALE.with(|s| s.get())
819}
820
821fn lbl(text: impl Into<String>) -> widget::Widget {
823 widget::label(text).shadow(false).font_scale(text_scale())
824}
825fn btn(text: impl Into<String>) -> widget::Widget {
827 widget::button(text).shadow(false).font_scale(text_scale())
828}
829
830fn title_lbl(text: impl Into<String>) -> widget::Widget {
833 let text = text.into();
834 let fit = ((PAGE_W - 6.0) / yog_ui::text::str_width(&text, 1.0)).clamp(0.5, 1.0);
835 widget::label(text).shadow(false).font_scale(text_scale() * fit).no_wrap().ellipsis()
836}
837
838const TEXT_W: f32 = PAGE_W - 8.0;
842const BODY_H: f32 = PAGE_H - 22.0 - 12.0 - 14.0;
845
846fn content_above_text(page: &BookPage) -> Option<f32> {
848 Some(match page {
849 BookPage::Text { title: Some(_), .. } => 22.0,
850 BookPage::Text { .. } => 0.0,
851 BookPage::Spotlight { .. } => 40.0,
852 BookPage::Crafting { .. } => 80.0,
853 BookPage::Smelting { .. } => 40.0,
854 BookPage::Image { .. } | BookPage::Entity { .. } => 104.0,
855 BookPage::Relations { entries, .. } => 12.0 + entries.len() as f32 * 10.0,
856 _ => return None,
857 })
858}
859
860fn page_text_mut(page: &mut BookPage) -> Option<&mut String> {
861 match page {
862 BookPage::Text { text, .. } => Some(text),
863 BookPage::Spotlight { text, .. }
864 | BookPage::Crafting { text, .. }
865 | BookPage::Smelting { text, .. }
866 | BookPage::Image { text, .. }
867 | BookPage::Entity { text, .. }
868 | BookPage::Relations { text, .. } => text.as_mut(),
869 _ => None,
870 }
871}
872
873fn flow(mut book: Book) -> Book {
875 let line_h = yog_ui::text::LINE_H + yog_ui::text::LINE_GAP;
876 let fits = |h: f32| (((h + yog_ui::text::LINE_GAP) / line_h).floor() as usize).max(1);
877 for entry in &mut book.entries {
878 let mut pages = Vec::with_capacity(entry.pages.len());
879 for mut page in std::mem::take(&mut entry.pages) {
880 let Some(above) = content_above_text(&page) else {
881 pages.push(page);
882 continue;
883 };
884 let Some(text) = page_text_mut(&mut page) else {
885 pages.push(page);
886 continue;
887 };
888 let lines: Vec<String> = text
889 .split('\n')
890 .flat_map(|para| {
891 if para.is_empty() { vec![String::new()] } else { yog_ui::text::wrap_text(para, TEXT_W, 1.0) }
892 })
893 .collect();
894 let first = fits(BODY_H - above);
895 if lines.len() <= first {
896 pages.push(page);
897 continue;
898 }
899 *text = lines[..first].join("\n");
900 pages.push(page);
901 for chunk in lines[first..].chunks(fits(BODY_H)) {
902 pages.push(BookPage::Text { text: chunk.join("\n"), title: None });
903 }
904 }
905 entry.pages = pages;
906 }
907 book
908}
909
910fn build_ui(
913 book: &Book,
914 state: &BookViewState,
915 theme: &BookTheme,
916 recipes: &HashMap<String, RecipeVis>,
917 bx: f32,
918 by: f32,
919 bw: f32,
920 bh: f32,
921) -> (UiRoot, Vec<BgSprite>, Vec<OverlayCmd>) {
922 let mut bg_sprites: Vec<BgSprite> = Vec::new();
923 let mut overlays: Vec<OverlayCmd> = Vec::new();
924
925 let sx = bw / BK_W;
927 TEXT_SCALE.with(|s| s.set(sx));
928 let sy = bh / BK_H;
929
930 let pw = PAGE_W * sx;
932 let ph = PAGE_H * sy;
933 let spine_gap = (RIGHT_X - LEFT_X - PAGE_W) * sx; let lx = bx + LEFT_X * sx;
937 let rx = bx + RIGHT_X * sx;
938 let py = by + TOP_PAD * sy;
939
940 let sep_cx = (PAGE_W / 2.0 - SEP_W / 2.0) * sx; if state.at_home {
944 bg_sprites.push(BgSprite {
946 sheet: SpriteSheet::Book,
947 u: 0.0,
948 v: 180.0,
949 uw: 140.0,
950 uh: 31.0,
951 x: bx + (LEFT_X - 8.0) * sx,
952 y: by + 12.0 * sy,
953 w: 140.0 * sx,
954 h: 31.0 * sy,
955 });
956 }
957 bg_sprites.push(BgSprite {
960 sheet: SpriteSheet::Book,
961 u: SEP_U,
962 v: SEP_V,
963 uw: SEP_W,
964 uh: SEP_H,
965 x: rx + sep_cx,
966 y: by + (TOP_PAD + 12.0) * sy,
967 w: SEP_W * sx,
968 h: (SEP_H * sy).max(1.0),
969 });
970
971 let (left_page, right_page) = if state.at_home {
972 let l = build_landing_left(book, theme, pw, ph, sx, sy, bx, by, &mut overlays);
973 let r = build_categories_right(book, state, theme, pw, ph, rx, py, sx, sy, &mut overlays);
974 (l, r)
975 } else {
976 let l = build_entry_left(
977 book,
978 state,
979 theme,
980 recipes,
981 pw,
982 ph,
983 lx,
984 py,
985 sx,
986 sy,
987 sep_cx,
988 &mut bg_sprites,
989 &mut overlays,
990 );
991 let r = build_entries_right(book, state, theme, pw, ph, rx, py, sx, sy, &mut overlays);
992 (l, r)
993 };
994
995 let root_widget = widget::panel(FlexDir::Row)
1003 .w(bw)
1004 .h(bh)
1005 .gap(0.0)
1006 .padding(TOP_PAD * sy, 0.0, 0.0, LEFT_X * sx)
1007 .child(left_page)
1008 .child(widget::spacer().w(spine_gap)) .child(right_page);
1010
1011 let outer = widget::panel(FlexDir::Column)
1014 .w(bw)
1015 .h(bh)
1016 .child(root_widget);
1017
1018 let screen_root = widget::panel(FlexDir::Row)
1023 .padding(by, 0.0, 0.0, bx)
1024 .child(outer);
1025
1026 let ui = UiRoot::new(&book.id, screen_root);
1027 (ui, bg_sprites, overlays)
1028}
1029
1030fn build_landing_left(
1033 book: &Book,
1034 theme: &BookTheme,
1035 page_w: f32,
1036 page_h: f32,
1037 sx: f32,
1038 sy: f32,
1039 bx: f32,
1040 by: f32,
1041 overlays: &mut Vec<OverlayCmd>,
1042) -> widget::Widget {
1043 overlays.push(OverlayCmd::McText {
1046 text: book.name.clone(),
1047 x: bx + 13.0 * sx,
1048 y: by + 16.0 * sy,
1049 color: theme.nameplate,
1050 });
1051 if let Some(author) = &book.author {
1052 overlays.push(OverlayCmd::McText {
1053 text: format!("by {}", author),
1054 x: bx + 24.0 * sx,
1055 y: by + 24.0 * sy,
1056 color: theme.nameplate,
1057 });
1058 }
1059
1060 let mut col = widget::panel(FlexDir::Column)
1064 .w(page_w)
1065 .h(page_h)
1066 .padding(25.0 * sy, 6.0, 4.0, 4.0)
1067 .gap(0.0);
1068
1069 for para in book.landing_text.split('\n') {
1070 if para.is_empty() {
1071 col = col.child(widget::spacer().h(4.0 * sy));
1072 } else {
1073 col = col.child(lbl(para).color(theme.text));
1074 }
1075 }
1076
1077 let total = book.entries.len();
1078 col = col.child(widget::spacer().flex(1.0));
1079 col = col.child(
1080 lbl(format!("{} entries", total))
1081 .color(theme.nav)
1082 .h(9.0 * sy)
1083 .align(Align::Center),
1084 );
1085 col
1086}
1087
1088fn build_categories_right(
1091 book: &Book,
1092 state: &BookViewState,
1093 theme: &BookTheme,
1094 page_w: f32,
1095 page_h: f32,
1096 rx: f32,
1097 py: f32,
1098 sx: f32,
1099 sy: f32,
1100 overlays: &mut Vec<OverlayCmd>,
1101) -> widget::Widget {
1102 let cell_w = 24.0 * sx;
1107 let cell_h = 24.0 * sy;
1108 let grid_pad_left = 10.0 * sx;
1109 let header_h = 9.0 * sy;
1110
1111 let mut col = widget::panel(FlexDir::Column)
1112 .w(page_w)
1113 .h(page_h)
1114 .padding(0.0, 4.0, 4.0, 0.0)
1115 .gap(0.0);
1116
1117 col = col.child(
1118 lbl("Categories")
1119 .color(theme.divider)
1120 .h(header_h)
1121 .align(Align::Center)
1122 .no_wrap(),
1123 );
1124 col = col.child(widget::spacer().h(25.0 * sy - header_h));
1126
1127 let cats = &book.categories;
1129 let icon_s = 16.0 * sx.min(sy);
1130 let mut row_i = 0usize;
1131 loop {
1132 let row_start = row_i * 4;
1133 if row_start >= cats.len() {
1134 break;
1135 }
1136 let mut row =
1137 widget::panel(FlexDir::Row)
1138 .h(cell_h)
1139 .gap(0.0)
1140 .padding(0.0, 0.0, 0.0, grid_pad_left);
1141 for col_i in 0..4 {
1142 let idx = row_start + col_i;
1143 if idx >= cats.len() {
1144 break;
1145 }
1146 let cat = &cats[idx];
1147 let selected = !state.at_home && idx == state.cat;
1148 let bg = if selected { theme.nav_selected_bg } else { 0 };
1149
1150 if let Some(icon_id) = &cat.icon {
1151 push_icon(
1152 overlays,
1153 icon_id,
1154 rx + (10.0 + col_i as f32 * 24.0 + 4.0) * sx,
1155 py + (25.0 + row_i as f32 * 24.0 + 4.0) * sy,
1156 icon_s,
1157 icon_s,
1158 );
1159 }
1160 let cell = widget::panel(FlexDir::Column)
1161 .w(cell_w)
1162 .h(cell_h)
1163 .bg(bg)
1164 .on_click(format!("cat:{}", idx))
1165 .id(format!("book_cat_{}", idx));
1166 row = row.child(cell);
1167 }
1168 col = col.child(row);
1169 row_i += 1;
1170 }
1171 col
1172}
1173
1174fn build_entry_left(
1177 book: &Book,
1178 state: &BookViewState,
1179 theme: &BookTheme,
1180 recipes: &HashMap<String, RecipeVis>,
1181 page_w: f32,
1182 page_h: f32,
1183 ox: f32,
1184 oy: f32,
1185 sx: f32,
1186 sy: f32,
1187 sep_cx: f32,
1188 bg_sprites: &mut Vec<BgSprite>,
1189 overlays: &mut Vec<OverlayCmd>,
1190) -> widget::Widget {
1191 let entry = state.current_entry(book);
1192 let page = entry.and_then(|e| e.pages.get(state.page));
1193 let page_count = state.page_count(book);
1194 let title_text = entry.map(|e| e.name.as_str()).unwrap_or("");
1195
1196 let title_h = 9.0 * sy;
1201 let sep_h_px = (SEP_H * sy).max(1.0);
1202 let sep_y = oy + 12.0 * sy;
1203 let body_oy = oy + 22.0 * sy;
1204
1205 bg_sprites.push(BgSprite {
1206 sheet: SpriteSheet::Book,
1207 u: SEP_U,
1208 v: SEP_V,
1209 uw: SEP_W,
1210 uh: SEP_H,
1211 x: ox + sep_cx,
1212 y: sep_y,
1213 w: SEP_W * sx,
1214 h: sep_h_px,
1215 });
1216
1217 let page_label = format!("{}/{}", state.page + 1, page_count);
1218 let nav = widget::panel(FlexDir::Row)
1219 .h(12.0 * sy)
1220 .gap(4.0)
1221 .padding(0.0, 2.0, 0.0, 2.0)
1222 .child(
1223 btn("◀")
1224 .w(12.0 * sx)
1225 .h(12.0 * sy)
1226 .color(theme.nav)
1227 .on_click("prev_page")
1228 .id("prev_page"),
1229 )
1230 .child(
1231 lbl(&page_label)
1232 .color(theme.nav)
1233 .flex(1.0)
1234 .align(Align::Center),
1235 )
1236 .child(
1237 btn("⌂")
1238 .w(12.0 * sx)
1239 .h(12.0 * sy)
1240 .color(theme.nav)
1241 .on_click("home")
1242 .id("book_home"),
1243 )
1244 .child(
1245 btn("▶")
1246 .w(12.0 * sx)
1247 .h(12.0 * sy)
1248 .color(theme.nav)
1249 .on_click("next_page")
1250 .id("next_page"),
1251 );
1252
1253 let page_body = build_page(
1254 page, state.page, theme, recipes, bg_sprites, overlays, ox, body_oy, sx, sy,
1255 );
1256
1257 widget::panel(FlexDir::Column)
1258 .w(page_w)
1259 .h(page_h)
1260 .padding(0.0, 6.0, 4.0, 4.0)
1261 .gap(0.0)
1262 .child(
1263 title_lbl(title_text)
1264 .color(theme.title)
1265 .h(title_h)
1266 .align(Align::Center)
1267 .no_wrap(),
1268 )
1269 .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)
1273 .child(nav)
1274}
1275
1276fn build_entries_right(
1279 book: &Book,
1280 state: &BookViewState,
1281 theme: &BookTheme,
1282 page_w: f32,
1283 page_h: f32,
1284 rx: f32,
1285 py: f32,
1286 sx: f32,
1287 sy: f32,
1288 overlays: &mut Vec<OverlayCmd>,
1289) -> widget::Widget {
1290 let entries = state.entries_visible(book);
1291 let cat_name = book
1292 .categories
1293 .get(state.cat)
1294 .map(|c| c.name.as_str())
1295 .unwrap_or("Entries");
1296 let spread_count = state.list_spread_count(book);
1297
1298 let header_h = 9.0 * sy;
1303 let row_h = 11.0 * sy;
1304 let icon_size = 8.0 * sx.min(sy);
1305
1306 let mut col = widget::panel(FlexDir::Column)
1307 .w(page_w)
1308 .h(page_h)
1309 .padding(0.0, 4.0, 4.0, 0.0)
1310 .gap(0.0);
1311
1312 col = col.child(
1313 lbl(cat_name)
1314 .color(theme.divider)
1315 .h(header_h)
1316 .align(Align::Center)
1317 .no_wrap(),
1318 );
1319 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();
1325 for (i, entry) in entries.iter().enumerate() {
1326 let abs_i = abs_start + i;
1327 let selected = abs_i == state.entry;
1328 let bg = if selected { theme.nav_selected_bg } else { 0 };
1329 let color = if selected {
1330 theme.nav_selected
1331 } else {
1332 theme.nav
1333 };
1334
1335 if let Some(icon_id) = &entry.icon {
1338 push_icon(
1339 overlays,
1340 icon_id,
1341 rx + 1.0 * sx,
1342 py + (20.0 + i as f32 * 11.0 + 1.0) * sy,
1343 icon_size,
1344 icon_size,
1345 );
1346 }
1347
1348 let mut row = widget::panel(FlexDir::Row)
1349 .h(row_h)
1350 .gap(2.0)
1351 .bg(bg)
1352 .on_click(format!("entry:{}", abs_i))
1353 .id(format!("book_entry_{}", abs_i))
1354 .padding(1.0, 2.0, 1.0, 2.0);
1355
1356 row = row.child(widget::spacer().w(icon_size + 2.0));
1357 row = row.child(lbl(&entry.name).color(color).flex(1.0).no_wrap());
1358 col = col.child(row);
1359 }
1360
1361 if spread_count > 1 {
1362 col = col.child(widget::spacer().flex(1.0));
1363 let spread_label = format!("{}/{}", state.list_spread + 1, spread_count);
1364 col = col.child(
1365 widget::panel(FlexDir::Row)
1366 .h(12.0 * sy)
1367 .gap(2.0)
1368 .child(
1369 btn("◀")
1370 .w(12.0 * sx)
1371 .h(12.0 * sy)
1372 .color(theme.nav)
1373 .on_click("prev_list")
1374 .id("prev_list"),
1375 )
1376 .child(
1377 lbl(&spread_label)
1378 .color(theme.nav)
1379 .flex(1.0)
1380 .align(Align::Center),
1381 )
1382 .child(
1383 btn("▶")
1384 .w(12.0 * sx)
1385 .h(12.0 * sy)
1386 .color(theme.nav)
1387 .on_click("next_list")
1388 .id("next_list"),
1389 ),
1390 );
1391 }
1392 col
1393}
1394
1395#[allow(clippy::too_many_arguments)]
1399fn draw_crafting_grid(
1400 theme: &BookTheme,
1401 bg_sprites: &mut Vec<BgSprite>,
1402 overlays: &mut Vec<OverlayCmd>,
1403 col: &mut widget::Widget,
1404 grid: &[Option<String>],
1405 width: usize,
1406 output: &str,
1407 count: u32,
1408 shapeless: bool,
1409 ox: f32,
1410 oy: f32,
1411 rx0: f32,
1412 ry: f32,
1413 sx: f32,
1414 sy: f32,
1415) {
1416 let mut c = std::mem::replace(col, widget::panel(FlexDir::Column));
1417 c = c.child(
1418 title_lbl(pretty_item_name(output))
1419 .color(theme.title)
1420 .h(10.0 * sy)
1421 .align(Align::Center),
1422 );
1423
1424 bg_sprites.push(BgSprite {
1426 sheet: SpriteSheet::Crafting,
1427 u: 0.0,
1428 v: 0.0,
1429 uw: 100.0,
1430 uh: 62.0,
1431 x: ox + (rx0 - 2.0) * sx,
1432 y: oy + (ry - 2.0) * sy,
1433 w: 100.0 * sx,
1434 h: 62.0 * sy,
1435 });
1436 if shapeless {
1438 bg_sprites.push(BgSprite {
1439 sheet: SpriteSheet::Crafting,
1440 u: 0.0,
1441 v: 64.0,
1442 uw: 11.0,
1443 uh: 11.0,
1444 x: ox + (rx0 + 62.0) * sx,
1445 y: oy + (ry + 2.0) * sy,
1446 w: 11.0 * sx,
1447 h: 11.0 * sy,
1448 });
1449 }
1450
1451 let icon_s = 16.0 * sx.min(sy);
1452 let width = width.max(1);
1453 for (i, slot) in grid.iter().enumerate() {
1454 if let Some(item) = slot {
1455 overlays.push(OverlayCmd::McItem {
1456 item_id: item.clone(),
1457 x: ox + (rx0 + 3.0 + (i % width) as f32 * 19.0) * sx,
1458 y: oy + (ry + 3.0 + (i / width) as f32 * 19.0) * sy,
1459 w: icon_s,
1460 h: icon_s,
1461 });
1462 }
1463 }
1464 overlays.push(OverlayCmd::McItem {
1466 item_id: output.to_owned(),
1467 x: ox + (rx0 + 79.0) * sx,
1468 y: oy + (ry + 22.0) * sy,
1469 w: icon_s,
1470 h: icon_s,
1471 });
1472 if count > 1 {
1473 overlays.push(OverlayCmd::McText {
1474 text: format!("x{}", count),
1475 x: ox + (rx0 + 81.0) * sx,
1476 y: oy + (ry + 40.0) * sy,
1477 color: theme.title,
1478 });
1479 }
1480 overlays.push(OverlayCmd::McItem {
1482 item_id: "minecraft:crafting_table".into(),
1483 x: ox + (rx0 + 79.0) * sx,
1484 y: oy + (ry + 41.0) * sy,
1485 w: icon_s,
1486 h: icon_s,
1487 });
1488
1489 c = c.child(widget::spacer().h((ry + 62.0 + 4.0 - 10.0) * sy));
1491 *col = c;
1492}
1493
1494fn build_page(
1500 page: Option<&BookPage>,
1501 page_num: usize,
1502 theme: &BookTheme,
1503 recipes: &HashMap<String, RecipeVis>,
1504 bg_sprites: &mut Vec<BgSprite>,
1505 overlays: &mut Vec<OverlayCmd>,
1506 ox: f32,
1507 oy: f32,
1508 sx: f32,
1509 sy: f32,
1510) -> widget::Widget {
1511 let mut col = widget::panel(FlexDir::Column).flex(1.0).gap(4.0);
1512
1513 let Some(page) = page else {
1514 return col.child(lbl("No entries yet.").color(theme.nav));
1515 };
1516
1517 match page {
1518 BookPage::Text { text, title } => {
1519 if page_num > 0
1521 && let Some(t) = title
1522 {
1523 let sep_h_px = (SEP_H * sy).max(1.0);
1524 let title_h = 9.0 * sy;
1525 col = col.child(
1526 title_lbl(t.as_str())
1527 .color(theme.title)
1528 .h(title_h)
1529 .align(Align::Center)
1530 .no_wrap(),
1531 );
1532 bg_sprites.push(BgSprite {
1534 sheet: SpriteSheet::Book,
1535 u: SEP_U,
1536 v: SEP_V,
1537 uw: SEP_W,
1538 uh: SEP_H,
1539 x: ox + (PAGE_W / 2.0 - SEP_W / 2.0) * sx,
1540 y: oy + 12.0 * sy,
1541 w: SEP_W * sx,
1542 h: sep_h_px,
1543 });
1544 col = col.child(widget::spacer().h((12.0 - 9.0) * sy));
1545 col = col.child(widget::spacer().h(sep_h_px));
1546 col = col.child(widget::spacer().h((22.0 - 12.0 - SEP_H) * sy));
1547 }
1548 for para in text.split('\n') {
1549 col = col.child(lbl(para).color(theme.text));
1550 }
1551 }
1552
1553 BookPage::Spotlight { item, title, text } => {
1554 bg_sprites.push(BgSprite {
1559 sheet: SpriteSheet::Crafting,
1560 u: 0.0,
1561 v: 102.0,
1562 uw: 66.0,
1563 uh: 26.0,
1564 x: ox + (PAGE_W / 2.0 - 33.0) * sx,
1565 y: oy + 10.0 * sy,
1566 w: 66.0 * sx,
1567 h: 26.0 * sy,
1568 });
1569
1570 let item_name = title
1571 .as_deref()
1572 .or(item.name.as_deref())
1573 .unwrap_or(item.id.as_str());
1574 col = col.child(
1575 title_lbl(item_name)
1576 .color(theme.title)
1577 .h(10.0 * sy)
1578 .align(Align::Center),
1579 );
1580
1581 let icon_s = 16.0 * sx.min(sy);
1582 overlays.push(OverlayCmd::McItem {
1583 item_id: item.id.clone(),
1584 x: ox + (PAGE_W / 2.0 - 8.0) * sx,
1585 y: oy + 15.0 * sy,
1586 w: icon_s,
1587 h: icon_s,
1588 });
1589
1590 col = col.child(widget::spacer().h(30.0 * sy));
1592
1593 if let Some(t) = text {
1594 col = col.child(lbl(t.as_str()).color(theme.text));
1595 }
1596 }
1597
1598 BookPage::Crafting { recipe_id, text } => {
1599 let rx0 = PAGE_W / 2.0 - 49.0; let ry = 12.0; match recipes.get(recipe_id.as_str()) {
1604 Some(RecipeVis::Shaped {
1605 grid,
1606 width,
1607 output,
1608 count,
1609 }) => {
1610 draw_crafting_grid(
1611 theme, bg_sprites, overlays, &mut col, grid, *width, output, *count, false,
1612 ox, oy, rx0, ry, sx, sy,
1613 );
1614 }
1615 Some(RecipeVis::Shapeless {
1616 ingredients,
1617 output,
1618 count,
1619 }) => {
1620 let grid: Vec<Option<String>> = ingredients.iter().cloned().map(Some).collect();
1621 draw_crafting_grid(
1622 theme, bg_sprites, overlays, &mut col, &grid, 3, output, *count, true, ox,
1623 oy, rx0, ry, sx, sy,
1624 );
1625 }
1626 _ => {
1627 col = col.child(lbl(format!("[Recipe: {}]", recipe_id)).color(theme.nav));
1628 }
1629 }
1630 if let Some(t) = text {
1631 col = col.child(lbl(t.as_str()).color(theme.text));
1632 }
1633 }
1634
1635 BookPage::Smelting { recipe_id, text } => {
1636 let rx0 = PAGE_W / 2.0 - 49.0;
1639 let ry = 12.0;
1640 match recipes.get(recipe_id.as_str()) {
1641 Some(RecipeVis::Smelting { input, output }) => {
1642 col = col.child(
1643 title_lbl(pretty_item_name(output))
1644 .color(theme.title)
1645 .h(10.0 * sy)
1646 .align(Align::Center),
1647 );
1648 bg_sprites.push(BgSprite {
1649 sheet: SpriteSheet::Crafting,
1650 u: 11.0,
1651 v: 71.0,
1652 uw: 96.0,
1653 uh: 24.0,
1654 x: ox + rx0 * sx,
1655 y: oy + ry * sy,
1656 w: 96.0 * sx,
1657 h: 24.0 * sy,
1658 });
1659 let icon_s = 16.0 * sx.min(sy);
1660 overlays.push(OverlayCmd::McItem {
1661 item_id: input.clone(),
1662 x: ox + (rx0 + 4.0) * sx,
1663 y: oy + (ry + 4.0) * sy,
1664 w: icon_s,
1665 h: icon_s,
1666 });
1667 overlays.push(OverlayCmd::McItem {
1669 item_id: "minecraft:furnace".into(),
1670 x: ox + (rx0 + 40.0) * sx,
1671 y: oy + (ry + 4.0) * sy,
1672 w: icon_s,
1673 h: icon_s,
1674 });
1675 overlays.push(OverlayCmd::McItem {
1676 item_id: output.clone(),
1677 x: ox + (rx0 + 76.0) * sx,
1678 y: oy + (ry + 4.0) * sy,
1679 w: icon_s,
1680 h: icon_s,
1681 });
1682 col = col.child(widget::spacer().h((ry + 24.0 + 4.0 - 10.0) * sy));
1684 }
1685 _ => {
1686 col = col.child(lbl(format!("[Recipe: {}]", recipe_id)).color(theme.nav));
1687 }
1688 }
1689 if let Some(t) = text {
1690 col = col.child(lbl(t.as_str()).color(theme.text));
1691 }
1692 }
1693
1694 BookPage::Image {
1695 texture,
1696 title,
1697 text,
1698 ..
1699 } => {
1700 if let Some(t) = title {
1701 col = col.child(title_lbl(t.as_str()).color(theme.title));
1702 }
1703 col = col.child(
1704 widget::image(texture)
1705 .region([0.0, 0.0, 80.0, 80.0])
1706 .stretch(Stretch::Fill)
1707 .w(80.0)
1708 .h(80.0),
1709 );
1710 if let Some(t) = text {
1711 col = col.child(lbl(t.as_str()).color(theme.text));
1712 }
1713 }
1714
1715 BookPage::Svg { data, title, text } => {
1716 if let Some(t) = title {
1717 col = col.child(title_lbl(t.as_str()).color(theme.title));
1718 }
1719 overlays.push(OverlayCmd::Svg {
1720 data: data.clone(),
1721 x: ox,
1722 y: oy,
1723 w: 64.0,
1724 h: 64.0,
1725 });
1726 col = col.child(widget::spacer().h(68.0));
1727 if let Some(t) = text {
1728 col = col.child(lbl(t.as_str()).color(theme.text));
1729 }
1730 }
1731
1732 BookPage::CustomText { text, font, color } => {
1733 overlays.push(OverlayCmd::Text {
1734 text: text.clone(),
1735 font: font.clone(),
1736 x: ox,
1737 y: oy,
1738 color: *color,
1739 });
1740 col = col.child(widget::spacer().h(font.size_px * 1.5));
1741 }
1742
1743 BookPage::Relations { entries, text } => {
1744 if let Some(t) = text {
1745 col = col.child(lbl(t.as_str()).color(theme.text));
1746 }
1747 col = col.child(title_lbl("See also:").color(theme.title));
1748 for e in entries {
1749 col = col.child(lbl(format!("• {}", e)).color(theme.nav));
1750 }
1751 }
1752
1753 BookPage::Entity {
1754 entity_type,
1755 name,
1756 text,
1757 } => {
1758 let display = name.as_deref().unwrap_or(entity_type.as_str());
1759 col = col.child(title_lbl(display).color(theme.title));
1760 if let Some(t) = text {
1761 col = col.child(lbl(t.as_str()).color(theme.text));
1762 }
1763 }
1764
1765 BookPage::Pattern {
1766 op_id,
1767 input,
1768 output,
1769 text,
1770 ..
1771 } => {
1772 col = col.child(title_lbl(op_id.as_str()).color(theme.title));
1773 col = col.child(lbl(format!("{} → {}", input, output)).color(theme.nav));
1774 if !text.is_empty() {
1775 col = col.child(lbl(text.as_str()).color(theme.text));
1776 }
1777 }
1778
1779 BookPage::Empty => {}
1780 }
1781
1782 col
1783}
1784
1785#[cfg(test)]
1786mod tests {
1787 use super::*;
1788 use crate::BookEntry;
1789
1790 fn book(pages: Vec<BookPage>) -> Book {
1791 let mut book = Book::new("t:b", "B");
1792 book.entries.push(BookEntry { id: "e".into(), name: "E".into(), pages, ..Default::default() });
1793 book
1794 }
1795
1796 #[test]
1797 fn long_text_flows_onto_new_pages() {
1798 let long = "word ".repeat(400);
1799 let flowed = flow(book(vec![BookPage::Text { text: long.clone(), title: None }, BookPage::Empty]));
1800 let pages = &flowed.entries[0].pages;
1801 assert!(pages.len() > 3, "{}", pages.len());
1802 assert!(matches!(pages.last(), Some(BookPage::Empty)), "what came after stays after");
1803 let line_h = yog_ui::text::LINE_H + yog_ui::text::LINE_GAP;
1804 for p in &pages[..pages.len() - 1] {
1805 let BookPage::Text { text, .. } = p else { panic!("{p:?}") };
1806 assert!(text.lines().count() as f32 * line_h <= BODY_H + 1.0);
1807 assert!(text.lines().all(|l| yog_ui::text::str_width(l, 1.0) <= TEXT_W));
1808 }
1809 let words: usize = pages.iter().map(|p| match p {
1810 BookPage::Text { text, .. } => text.split_whitespace().count(),
1811 _ => 0,
1812 }).sum();
1813 assert_eq!(words, 400, "nothing lost");
1814 }
1815
1816 #[test]
1817 fn short_text_stays_put() {
1818 let pages = vec![BookPage::Text { text: "Hello".into(), title: Some("T".into()) }];
1819 let flowed = flow(book(pages.clone()));
1820 assert_eq!(flowed.entries[0].pages.len(), 1);
1821 }
1822}