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