1use std::collections::HashMap;
5use yog_gfx::{
6 core::{blend, DataType, DrawMode},
7 gl, GfxContext,
8};
9use yog_ui::{widget, Align, FlexDir, 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().map(|s| s as usize).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 !self.svg_tex.contains_key(&hash) {
235 let pixels = svg::rasterize(data, w, h)?;
236 let tex = ctx.create_texture_rgba(w, h, &pixels, true);
237 self.svg_tex.insert(hash, (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 !self.font_atlas.contains_key(&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 self.font_atlas.insert(hash, (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}
607
608impl BookRenderer {
609 pub fn new(book: Book) -> Self {
610 let theme = BookTheme::default().with_nameplate(&book.nameplate_color);
611 Self {
612 book,
613 state: BookViewState::default(),
614 theme,
615 fonts: BookFontRegistry::default(),
616 recipes: HashMap::new(),
617 gl: None,
618 ui: None,
619 bg_sprites: Vec::new(),
620 overlays: Vec::new(),
621 dirty: true,
622 last_sw: 0.0,
623 last_sh: 0.0,
624 }
625 }
626
627 pub fn handle_event(&mut self, ev: &str) {
628 if self.state.handle(ev, &self.book) {
629 self.dirty = true;
630 }
631 }
632
633 pub fn register_font(&mut self, id: impl Into<String>, ttf: Vec<u8>) {
635 self.fonts.register(id, ttf);
636 }
637
638 pub fn add_recipe(&mut self, id: impl Into<String>, json: &str) {
641 if let Some(vis) = parse_recipe(json) {
642 self.recipes.insert(id.into(), vis);
643 self.dirty = true;
644 }
645 }
646
647 pub fn render(&mut self, ctx: &GfxContext, sw: f32, sh: f32) {
648 if self.gl.is_none() {
650 self.gl = BookGl::init(ctx);
651 }
652
653 let scale = ((sw - 16.0) / BK_W)
656 .min((sh - 16.0) / BK_H)
657 .min(1.0)
658 .max(0.4);
659 let bw = BK_W * scale;
660 let bh = BK_H * scale;
661 let bx = (sw - bw) / 2.0;
662 let by = (sh - bh) / 2.0;
663
664 if self.dirty || sw != self.last_sw || sh != self.last_sh {
666 let (root, bg_sprites, overlays) = build_ui(
667 &self.book,
668 &self.state,
669 &self.theme,
670 &self.recipes,
671 sw,
672 sh,
673 bx,
674 by,
675 bw,
676 bh,
677 );
678 self.ui = Some(root);
679 self.bg_sprites = bg_sprites;
680 self.overlays = overlays;
681 self.dirty = false;
682 self.last_sw = sw;
683 self.last_sh = sh;
684 }
685
686 if let Some(gl) = &mut self.gl {
688 gl.begin_frame(ctx, sw, sh);
689 gl.draw_book_bg(ctx, bx, by, bw, bh);
690 }
691
692 if let Some(gl) = &mut self.gl {
694 for spr in &self.bg_sprites {
695 gl.draw_book_sprite(ctx, spr);
696 }
697 }
698
699 if let Some(ui) = &mut self.ui {
701 if ui.needs_layout {
702 ui.layout(sw, sh);
703 }
704 ui.render(ctx);
705 }
706
707 if let Some(gl) = &mut self.gl {
709 gl.begin_frame(ctx, sw, sh);
710 for ov in self.overlays.clone() {
711 match ov {
712 OverlayCmd::Svg { data, x, y, w, h } => gl.draw_svg(ctx, &data, x, y, w, h),
713 OverlayCmd::Text {
714 text,
715 font,
716 x,
717 y,
718 color,
719 } => {
720 if let Some(ttf) = self.fonts.get(&font.font_id) {
721 gl.draw_text_custom(ctx, ttf, font.size_px, &text, x, y, color);
722 }
723 }
724 OverlayCmd::McText { .. }
725 | OverlayCmd::McItem { .. }
726 | OverlayCmd::McTexture { .. } => {}
727 }
728 }
729 }
730
731 for ov in self.overlays.clone() {
734 match ov {
735 OverlayCmd::McItem {
736 item_id,
737 x,
738 y,
739 w,
740 h,
741 } => {
742 ctx.draw2d()
743 .item(&normalize_item_id(&item_id), x, y, w.min(h));
744 }
745 OverlayCmd::McText { text, x, y, color } => {
746 ctx.draw2d().text(&text, x, y, color, false);
747 }
748 OverlayCmd::McTexture { path, x, y, w, h } => {
749 ctx.draw2d().mc_texture(&path, x, y, 0.0, 0.0, w, h, w, h);
752 }
753 _ => {}
754 }
755 }
756 }
757}
758
759fn lbl(text: impl Into<String>) -> widget::Widget {
763 widget::label(text).shadow(false)
764}
765fn btn(text: impl Into<String>) -> widget::Widget {
767 widget::button(text).shadow(false)
768}
769
770fn build_ui(
773 book: &Book,
774 state: &BookViewState,
775 theme: &BookTheme,
776 recipes: &HashMap<String, RecipeVis>,
777 sw: f32,
778 sh: f32,
779 bx: f32,
780 by: f32,
781 bw: f32,
782 bh: f32,
783) -> (UiRoot, Vec<BgSprite>, Vec<OverlayCmd>) {
784 let _ = (sw, sh);
785 let mut bg_sprites: Vec<BgSprite> = Vec::new();
786 let mut overlays: Vec<OverlayCmd> = Vec::new();
787
788 let sx = bw / BK_W;
790 let sy = bh / BK_H;
791
792 let pw = PAGE_W * sx;
794 let ph = PAGE_H * sy;
795 let spine_gap = (RIGHT_X - LEFT_X - PAGE_W) * sx; let lx = bx + LEFT_X * sx;
799 let rx = bx + RIGHT_X * sx;
800 let py = by + TOP_PAD * sy;
801
802 let sep_cx = (PAGE_W / 2.0 - SEP_W / 2.0) * sx; if state.at_home {
806 bg_sprites.push(BgSprite {
808 sheet: SpriteSheet::Book,
809 u: 0.0,
810 v: 180.0,
811 uw: 140.0,
812 uh: 31.0,
813 x: bx + (LEFT_X - 8.0) * sx,
814 y: by + 12.0 * sy,
815 w: 140.0 * sx,
816 h: 31.0 * sy,
817 });
818 bg_sprites.push(BgSprite {
820 sheet: SpriteSheet::Book,
821 u: SEP_U,
822 v: SEP_V,
823 uw: SEP_W,
824 uh: SEP_H,
825 x: rx + sep_cx,
826 y: by + (TOP_PAD + 12.0) * sy,
827 w: SEP_W * sx,
828 h: (SEP_H * sy).max(1.0),
829 });
830 } else {
831 bg_sprites.push(BgSprite {
833 sheet: SpriteSheet::Book,
834 u: SEP_U,
835 v: SEP_V,
836 uw: SEP_W,
837 uh: SEP_H,
838 x: rx + sep_cx,
839 y: by + (TOP_PAD + 12.0) * sy,
840 w: SEP_W * sx,
841 h: (SEP_H * sy).max(1.0),
842 });
843 }
844
845 let (left_page, right_page) = if state.at_home {
846 let l = build_landing_left(book, theme, pw, ph, lx, py, sx, sy, bx, by, &mut overlays);
847 let r = build_categories_right(book, state, theme, pw, ph, rx, py, sx, sy, &mut overlays);
848 (l, r)
849 } else {
850 let l = build_entry_left(
851 book,
852 state,
853 theme,
854 recipes,
855 pw,
856 ph,
857 lx,
858 py,
859 sx,
860 sy,
861 sep_cx,
862 &mut bg_sprites,
863 &mut overlays,
864 );
865 let r = build_entries_right(book, state, theme, pw, ph, rx, py, sx, sy, &mut overlays);
866 (l, r)
867 };
868
869 let root_widget = widget::panel(FlexDir::Row)
877 .w(bw)
878 .h(bh)
879 .gap(0.0)
880 .padding(TOP_PAD * sy, 0.0, 0.0, LEFT_X * sx)
881 .child(left_page)
882 .child(widget::spacer().w(spine_gap)) .child(right_page);
884
885 let outer = widget::panel(FlexDir::Column)
888 .w(bw)
889 .h(bh)
890 .child(root_widget);
891
892 let screen_root = widget::panel(FlexDir::Row)
897 .padding(by, 0.0, 0.0, bx)
898 .child(outer);
899
900 let ui = UiRoot::new(&book.id, screen_root);
901 (ui, bg_sprites, overlays)
902}
903
904fn build_landing_left(
907 book: &Book,
908 theme: &BookTheme,
909 page_w: f32,
910 page_h: f32,
911 _lx: f32,
912 _py: f32,
913 sx: f32,
914 sy: f32,
915 bx: f32,
916 by: f32,
917 overlays: &mut Vec<OverlayCmd>,
918) -> widget::Widget {
919 overlays.push(OverlayCmd::McText {
922 text: book.name.clone(),
923 x: bx + 13.0 * sx,
924 y: by + 16.0 * sy,
925 color: theme.nameplate,
926 });
927 if let Some(author) = &book.author {
928 overlays.push(OverlayCmd::McText {
929 text: format!("by {}", author),
930 x: bx + 24.0 * sx,
931 y: by + 24.0 * sy,
932 color: theme.nameplate,
933 });
934 }
935
936 let mut col = widget::panel(FlexDir::Column)
940 .w(page_w)
941 .h(page_h)
942 .padding(25.0 * sy, 6.0, 4.0, 4.0)
943 .gap(0.0);
944
945 for para in book.landing_text.split('\n') {
946 if para.is_empty() {
947 col = col.child(widget::spacer().h(4.0 * sy));
948 } else {
949 col = col.child(lbl(para).color(theme.text));
950 }
951 }
952
953 let total = book.entries.len();
954 col = col.child(widget::spacer().flex(1.0));
955 col = col.child(
956 lbl(format!("{} entries", total))
957 .color(theme.nav)
958 .h(9.0 * sy)
959 .align(Align::Center),
960 );
961 col
962}
963
964fn build_categories_right(
967 book: &Book,
968 state: &BookViewState,
969 theme: &BookTheme,
970 page_w: f32,
971 page_h: f32,
972 rx: f32,
973 py: f32,
974 sx: f32,
975 sy: f32,
976 overlays: &mut Vec<OverlayCmd>,
977) -> widget::Widget {
978 let cell_w = 24.0 * sx;
983 let cell_h = 24.0 * sy;
984 let grid_pad_left = 10.0 * sx;
985 let header_h = 9.0 * sy;
986
987 let mut col = widget::panel(FlexDir::Column)
988 .w(page_w)
989 .h(page_h)
990 .padding(0.0, 4.0, 4.0, 0.0)
991 .gap(0.0);
992
993 col = col.child(
994 lbl("Categories")
995 .color(theme.divider)
996 .h(header_h)
997 .align(Align::Center)
998 .no_wrap(),
999 );
1000 col = col.child(widget::spacer().h(25.0 * sy - header_h));
1002
1003 let cats = &book.categories;
1005 let icon_s = 16.0 * sx.min(sy);
1006 let mut row_i = 0usize;
1007 loop {
1008 let row_start = row_i * 4;
1009 if row_start >= cats.len() {
1010 break;
1011 }
1012 let mut row =
1013 widget::panel(FlexDir::Row)
1014 .h(cell_h)
1015 .gap(0.0)
1016 .padding(0.0, 0.0, 0.0, grid_pad_left);
1017 for col_i in 0..4 {
1018 let idx = row_start + col_i;
1019 if idx >= cats.len() {
1020 break;
1021 }
1022 let cat = &cats[idx];
1023 let selected = !state.at_home && idx == state.cat;
1024 let bg = if selected { theme.nav_selected_bg } else { 0 };
1025
1026 if let Some(icon_id) = &cat.icon {
1027 push_icon(
1028 overlays,
1029 icon_id,
1030 rx + (10.0 + col_i as f32 * 24.0 + 4.0) * sx,
1031 py + (25.0 + row_i as f32 * 24.0 + 4.0) * sy,
1032 icon_s,
1033 icon_s,
1034 );
1035 }
1036 let cell = widget::panel(FlexDir::Column)
1037 .w(cell_w)
1038 .h(cell_h)
1039 .bg(bg)
1040 .on_click(format!("cat:{}", idx))
1041 .id(format!("book_cat_{}", idx));
1042 row = row.child(cell);
1043 }
1044 col = col.child(row);
1045 row_i += 1;
1046 }
1047 col
1048}
1049
1050fn build_entry_left(
1053 book: &Book,
1054 state: &BookViewState,
1055 theme: &BookTheme,
1056 recipes: &HashMap<String, RecipeVis>,
1057 page_w: f32,
1058 page_h: f32,
1059 ox: f32,
1060 oy: f32,
1061 sx: f32,
1062 sy: f32,
1063 sep_cx: f32,
1064 bg_sprites: &mut Vec<BgSprite>,
1065 overlays: &mut Vec<OverlayCmd>,
1066) -> widget::Widget {
1067 let entry = state.current_entry(book);
1068 let page = entry.and_then(|e| e.pages.get(state.page));
1069 let page_count = state.page_count(book);
1070 let title_text = entry.map(|e| e.name.as_str()).unwrap_or("");
1071
1072 let title_h = 9.0 * sy;
1077 let sep_h_px = (SEP_H * sy).max(1.0);
1078 let sep_y = oy + 12.0 * sy;
1079 let body_oy = oy + 22.0 * sy;
1080
1081 bg_sprites.push(BgSprite {
1082 sheet: SpriteSheet::Book,
1083 u: SEP_U,
1084 v: SEP_V,
1085 uw: SEP_W,
1086 uh: SEP_H,
1087 x: ox + sep_cx,
1088 y: sep_y,
1089 w: SEP_W * sx,
1090 h: sep_h_px,
1091 });
1092
1093 let page_label = format!("{}/{}", state.page + 1, page_count);
1094 let nav = widget::panel(FlexDir::Row)
1095 .h(12.0 * sy)
1096 .gap(4.0)
1097 .padding(0.0, 2.0, 0.0, 2.0)
1098 .child(
1099 btn("◀")
1100 .w(12.0 * sx)
1101 .h(12.0 * sy)
1102 .color(theme.nav)
1103 .on_click("prev_page")
1104 .id("prev_page"),
1105 )
1106 .child(
1107 lbl(&page_label)
1108 .color(theme.nav)
1109 .flex(1.0)
1110 .align(Align::Center),
1111 )
1112 .child(
1113 btn("⌂")
1114 .w(12.0 * sx)
1115 .h(12.0 * sy)
1116 .color(theme.nav)
1117 .on_click("home")
1118 .id("book_home"),
1119 )
1120 .child(
1121 btn("▶")
1122 .w(12.0 * sx)
1123 .h(12.0 * sy)
1124 .color(theme.nav)
1125 .on_click("next_page")
1126 .id("next_page"),
1127 );
1128
1129 let page_body = build_page(
1130 page, state.page, theme, recipes, bg_sprites, overlays, ox, body_oy, sx, sy,
1131 );
1132
1133 widget::panel(FlexDir::Column)
1134 .w(page_w)
1135 .h(page_h)
1136 .padding(0.0, 6.0, 4.0, 4.0)
1137 .gap(0.0)
1138 .child(
1139 lbl(title_text)
1140 .color(theme.title)
1141 .h(title_h)
1142 .align(Align::Center)
1143 .no_wrap(),
1144 )
1145 .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)
1149 .child(nav)
1150}
1151
1152fn build_entries_right(
1155 book: &Book,
1156 state: &BookViewState,
1157 theme: &BookTheme,
1158 page_w: f32,
1159 page_h: f32,
1160 rx: f32,
1161 py: f32,
1162 sx: f32,
1163 sy: f32,
1164 overlays: &mut Vec<OverlayCmd>,
1165) -> widget::Widget {
1166 let entries = state.entries_visible(book);
1167 let cat_name = book
1168 .categories
1169 .get(state.cat)
1170 .map(|c| c.name.as_str())
1171 .unwrap_or("Entries");
1172 let spread_count = state.list_spread_count(book);
1173
1174 let header_h = 9.0 * sy;
1179 let row_h = 11.0 * sy;
1180 let icon_size = 8.0 * sx.min(sy);
1181
1182 let mut col = widget::panel(FlexDir::Column)
1183 .w(page_w)
1184 .h(page_h)
1185 .padding(0.0, 4.0, 4.0, 0.0)
1186 .gap(0.0);
1187
1188 col = col.child(
1189 lbl(cat_name)
1190 .color(theme.divider)
1191 .h(header_h)
1192 .align(Align::Center)
1193 .no_wrap(),
1194 );
1195 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();
1201 for (i, entry) in entries.iter().enumerate() {
1202 let abs_i = abs_start + i;
1203 let selected = abs_i == state.entry;
1204 let bg = if selected { theme.nav_selected_bg } else { 0 };
1205 let color = if selected {
1206 theme.nav_selected
1207 } else {
1208 theme.nav
1209 };
1210
1211 if let Some(icon_id) = &entry.icon {
1214 push_icon(
1215 overlays,
1216 icon_id,
1217 rx + 1.0 * sx,
1218 py + (20.0 + i as f32 * 11.0 + 1.0) * sy,
1219 icon_size,
1220 icon_size,
1221 );
1222 }
1223
1224 let mut row = widget::panel(FlexDir::Row)
1225 .h(row_h)
1226 .gap(2.0)
1227 .bg(bg)
1228 .on_click(format!("entry:{}", abs_i))
1229 .id(format!("book_entry_{}", abs_i))
1230 .padding(1.0, 2.0, 1.0, 2.0);
1231
1232 row = row.child(widget::spacer().w(icon_size + 2.0));
1233 row = row.child(lbl(&entry.name).color(color).flex(1.0).no_wrap());
1234 col = col.child(row);
1235 }
1236
1237 if spread_count > 1 {
1238 col = col.child(widget::spacer().flex(1.0));
1239 let spread_label = format!("{}/{}", state.list_spread + 1, spread_count);
1240 col = col.child(
1241 widget::panel(FlexDir::Row)
1242 .h(12.0 * sy)
1243 .gap(2.0)
1244 .child(
1245 btn("◀")
1246 .w(12.0 * sx)
1247 .h(12.0 * sy)
1248 .color(theme.nav)
1249 .on_click("prev_list")
1250 .id("prev_list"),
1251 )
1252 .child(
1253 lbl(&spread_label)
1254 .color(theme.nav)
1255 .flex(1.0)
1256 .align(Align::Center),
1257 )
1258 .child(
1259 btn("▶")
1260 .w(12.0 * sx)
1261 .h(12.0 * sy)
1262 .color(theme.nav)
1263 .on_click("next_list")
1264 .id("next_list"),
1265 ),
1266 );
1267 }
1268 col
1269}
1270
1271#[allow(clippy::too_many_arguments)]
1275fn draw_crafting_grid(
1276 theme: &BookTheme,
1277 bg_sprites: &mut Vec<BgSprite>,
1278 overlays: &mut Vec<OverlayCmd>,
1279 col: &mut widget::Widget,
1280 grid: &[Option<String>],
1281 width: usize,
1282 output: &str,
1283 count: u32,
1284 shapeless: bool,
1285 ox: f32,
1286 oy: f32,
1287 rx0: f32,
1288 ry: f32,
1289 sx: f32,
1290 sy: f32,
1291) {
1292 let mut c = std::mem::replace(col, widget::panel(FlexDir::Column));
1293 c = c.child(
1294 lbl(pretty_item_name(output))
1295 .color(theme.title)
1296 .h(10.0 * sy)
1297 .align(Align::Center),
1298 );
1299
1300 bg_sprites.push(BgSprite {
1302 sheet: SpriteSheet::Crafting,
1303 u: 0.0,
1304 v: 0.0,
1305 uw: 100.0,
1306 uh: 62.0,
1307 x: ox + (rx0 - 2.0) * sx,
1308 y: oy + (ry - 2.0) * sy,
1309 w: 100.0 * sx,
1310 h: 62.0 * sy,
1311 });
1312 if shapeless {
1314 bg_sprites.push(BgSprite {
1315 sheet: SpriteSheet::Crafting,
1316 u: 0.0,
1317 v: 64.0,
1318 uw: 11.0,
1319 uh: 11.0,
1320 x: ox + (rx0 + 62.0) * sx,
1321 y: oy + (ry + 2.0) * sy,
1322 w: 11.0 * sx,
1323 h: 11.0 * sy,
1324 });
1325 }
1326
1327 let icon_s = 16.0 * sx.min(sy);
1328 let width = width.max(1);
1329 for (i, slot) in grid.iter().enumerate() {
1330 if let Some(item) = slot {
1331 overlays.push(OverlayCmd::McItem {
1332 item_id: item.clone(),
1333 x: ox + (rx0 + 3.0 + (i % width) as f32 * 19.0) * sx,
1334 y: oy + (ry + 3.0 + (i / width) as f32 * 19.0) * sy,
1335 w: icon_s,
1336 h: icon_s,
1337 });
1338 }
1339 }
1340 overlays.push(OverlayCmd::McItem {
1342 item_id: output.to_owned(),
1343 x: ox + (rx0 + 79.0) * sx,
1344 y: oy + (ry + 22.0) * sy,
1345 w: icon_s,
1346 h: icon_s,
1347 });
1348 if count > 1 {
1349 overlays.push(OverlayCmd::McText {
1350 text: format!("x{}", count),
1351 x: ox + (rx0 + 81.0) * sx,
1352 y: oy + (ry + 40.0) * sy,
1353 color: theme.title,
1354 });
1355 }
1356 overlays.push(OverlayCmd::McItem {
1358 item_id: "minecraft:crafting_table".into(),
1359 x: ox + (rx0 + 79.0) * sx,
1360 y: oy + (ry + 41.0) * sy,
1361 w: icon_s,
1362 h: icon_s,
1363 });
1364
1365 c = c.child(widget::spacer().h((ry + 62.0 + 4.0 - 10.0) * sy));
1367 *col = c;
1368}
1369
1370fn build_page(
1376 page: Option<&BookPage>,
1377 page_num: usize,
1378 theme: &BookTheme,
1379 recipes: &HashMap<String, RecipeVis>,
1380 bg_sprites: &mut Vec<BgSprite>,
1381 overlays: &mut Vec<OverlayCmd>,
1382 ox: f32,
1383 oy: f32,
1384 sx: f32,
1385 sy: f32,
1386) -> widget::Widget {
1387 let mut col = widget::panel(FlexDir::Column).flex(1.0).gap(4.0);
1388
1389 let Some(page) = page else {
1390 return col.child(lbl("No entries yet.").color(theme.nav));
1391 };
1392
1393 match page {
1394 BookPage::Text { text, title } => {
1395 if page_num > 0 {
1397 if let Some(t) = title {
1398 let sep_h_px = (SEP_H * sy).max(1.0);
1399 let title_h = 9.0 * sy;
1400 col = col.child(
1401 lbl(t.as_str())
1402 .color(theme.title)
1403 .h(title_h)
1404 .align(Align::Center)
1405 .no_wrap(),
1406 );
1407 bg_sprites.push(BgSprite {
1409 sheet: SpriteSheet::Book,
1410 u: SEP_U,
1411 v: SEP_V,
1412 uw: SEP_W,
1413 uh: SEP_H,
1414 x: ox + (PAGE_W / 2.0 - SEP_W / 2.0) * sx,
1415 y: oy + 12.0 * sy,
1416 w: SEP_W * sx,
1417 h: sep_h_px,
1418 });
1419 col = col.child(widget::spacer().h((12.0 - 9.0) * sy));
1420 col = col.child(widget::spacer().h(sep_h_px));
1421 col = col.child(widget::spacer().h((22.0 - 12.0 - SEP_H) * sy));
1422 }
1423 }
1424 for para in text.split('\n') {
1425 col = col.child(lbl(para).color(theme.text));
1426 }
1427 }
1428
1429 BookPage::Spotlight { item, title, text } => {
1430 bg_sprites.push(BgSprite {
1435 sheet: SpriteSheet::Crafting,
1436 u: 0.0,
1437 v: 102.0,
1438 uw: 66.0,
1439 uh: 26.0,
1440 x: ox + (PAGE_W / 2.0 - 33.0) * sx,
1441 y: oy + 10.0 * sy,
1442 w: 66.0 * sx,
1443 h: 26.0 * sy,
1444 });
1445
1446 let item_name = title
1447 .as_deref()
1448 .or(item.name.as_deref())
1449 .unwrap_or(item.id.as_str());
1450 col = col.child(
1451 lbl(item_name)
1452 .color(theme.title)
1453 .h(10.0 * sy)
1454 .align(Align::Center),
1455 );
1456
1457 let icon_s = 16.0 * sx.min(sy);
1458 overlays.push(OverlayCmd::McItem {
1459 item_id: item.id.clone(),
1460 x: ox + (PAGE_W / 2.0 - 8.0) * sx,
1461 y: oy + 15.0 * sy,
1462 w: icon_s,
1463 h: icon_s,
1464 });
1465
1466 col = col.child(widget::spacer().h(30.0 * sy));
1468
1469 if let Some(t) = text {
1470 col = col.child(lbl(t.as_str()).color(theme.text));
1471 }
1472 }
1473
1474 BookPage::Crafting { recipe_id, text } => {
1475 let rx0 = PAGE_W / 2.0 - 49.0; let ry = 12.0; match recipes.get(recipe_id.as_str()) {
1480 Some(RecipeVis::Shaped {
1481 grid,
1482 width,
1483 output,
1484 count,
1485 }) => {
1486 draw_crafting_grid(
1487 theme, bg_sprites, overlays, &mut col, grid, *width, output, *count, false,
1488 ox, oy, rx0, ry, sx, sy,
1489 );
1490 }
1491 Some(RecipeVis::Shapeless {
1492 ingredients,
1493 output,
1494 count,
1495 }) => {
1496 let grid: Vec<Option<String>> = ingredients.iter().cloned().map(Some).collect();
1497 draw_crafting_grid(
1498 theme, bg_sprites, overlays, &mut col, &grid, 3, output, *count, true, ox,
1499 oy, rx0, ry, sx, sy,
1500 );
1501 }
1502 _ => {
1503 col = col.child(lbl(format!("[Recipe: {}]", recipe_id)).color(theme.nav));
1504 }
1505 }
1506 if let Some(t) = text {
1507 col = col.child(lbl(t.as_str()).color(theme.text));
1508 }
1509 }
1510
1511 BookPage::Smelting { recipe_id, text } => {
1512 let rx0 = PAGE_W / 2.0 - 49.0;
1515 let ry = 12.0;
1516 match recipes.get(recipe_id.as_str()) {
1517 Some(RecipeVis::Smelting { input, output }) => {
1518 col = col.child(
1519 lbl(pretty_item_name(output))
1520 .color(theme.title)
1521 .h(10.0 * sy)
1522 .align(Align::Center),
1523 );
1524 bg_sprites.push(BgSprite {
1525 sheet: SpriteSheet::Crafting,
1526 u: 11.0,
1527 v: 71.0,
1528 uw: 96.0,
1529 uh: 24.0,
1530 x: ox + rx0 * sx,
1531 y: oy + ry * sy,
1532 w: 96.0 * sx,
1533 h: 24.0 * sy,
1534 });
1535 let icon_s = 16.0 * sx.min(sy);
1536 overlays.push(OverlayCmd::McItem {
1537 item_id: input.clone(),
1538 x: ox + (rx0 + 4.0) * sx,
1539 y: oy + (ry + 4.0) * sy,
1540 w: icon_s,
1541 h: icon_s,
1542 });
1543 overlays.push(OverlayCmd::McItem {
1545 item_id: "minecraft:furnace".into(),
1546 x: ox + (rx0 + 40.0) * sx,
1547 y: oy + (ry + 4.0) * sy,
1548 w: icon_s,
1549 h: icon_s,
1550 });
1551 overlays.push(OverlayCmd::McItem {
1552 item_id: output.clone(),
1553 x: ox + (rx0 + 76.0) * sx,
1554 y: oy + (ry + 4.0) * sy,
1555 w: icon_s,
1556 h: icon_s,
1557 });
1558 col = col.child(widget::spacer().h((ry + 24.0 + 4.0 - 10.0) * sy));
1560 }
1561 _ => {
1562 col = col.child(lbl(format!("[Recipe: {}]", recipe_id)).color(theme.nav));
1563 }
1564 }
1565 if let Some(t) = text {
1566 col = col.child(lbl(t.as_str()).color(theme.text));
1567 }
1568 }
1569
1570 BookPage::Image {
1571 texture,
1572 title,
1573 text,
1574 ..
1575 } => {
1576 if let Some(t) = title {
1577 col = col.child(lbl(t.as_str()).color(theme.title));
1578 }
1579 col = col.child(widget::mc_image(texture, 80.0, 80.0));
1580 if let Some(t) = text {
1581 col = col.child(lbl(t.as_str()).color(theme.text));
1582 }
1583 }
1584
1585 BookPage::Svg { data, title, text } => {
1586 if let Some(t) = title {
1587 col = col.child(lbl(t.as_str()).color(theme.title));
1588 }
1589 overlays.push(OverlayCmd::Svg {
1590 data: data.clone(),
1591 x: ox,
1592 y: oy,
1593 w: 64.0,
1594 h: 64.0,
1595 });
1596 col = col.child(widget::spacer().h(68.0));
1597 if let Some(t) = text {
1598 col = col.child(lbl(t.as_str()).color(theme.text));
1599 }
1600 }
1601
1602 BookPage::CustomText { text, font, color } => {
1603 overlays.push(OverlayCmd::Text {
1604 text: text.clone(),
1605 font: font.clone(),
1606 x: ox,
1607 y: oy,
1608 color: *color,
1609 });
1610 col = col.child(widget::spacer().h(font.size_px * 1.5));
1611 }
1612
1613 BookPage::Relations { entries, text } => {
1614 if let Some(t) = text {
1615 col = col.child(lbl(t.as_str()).color(theme.text));
1616 }
1617 col = col.child(lbl("See also:").color(theme.title));
1618 for e in entries {
1619 col = col.child(lbl(format!("• {}", e)).color(theme.nav));
1620 }
1621 }
1622
1623 BookPage::Entity {
1624 entity_type,
1625 name,
1626 text,
1627 } => {
1628 let display = name.as_deref().unwrap_or(entity_type.as_str());
1629 col = col.child(lbl(display).color(theme.title));
1630 if let Some(t) = text {
1631 col = col.child(lbl(t.as_str()).color(theme.text));
1632 }
1633 }
1634
1635 BookPage::Pattern {
1636 op_id,
1637 input,
1638 output,
1639 text,
1640 ..
1641 } => {
1642 col = col.child(lbl(op_id.as_str()).color(theme.title));
1643 col = col.child(lbl(format!("{} → {}", input, output)).color(theme.nav));
1644 if !text.is_empty() {
1645 col = col.child(lbl(text.as_str()).color(theme.text));
1646 }
1647 }
1648
1649 BookPage::Empty => {}
1650 }
1651
1652 col
1653}