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