1use std::collections::HashMap;
5use yog_gfx::{GfxContext, gl, core::{DrawMode, DataType, blend}};
6use yog_ui::{widget, FlexDir, Align, UiRoot};
7
8use crate::{Book, BookPage};
9use crate::state::BookViewState;
10use crate::theme::BookTheme;
11use crate::font::{BookFontRegistry, FontAtlas};
12use crate::svg;
13
14pub const BOOK_TEX_W: f32 = 512.0;
18pub const BOOK_TEX_H: f32 = 256.0;
19pub const BK_W: f32 = 272.0;
21pub const BK_H: f32 = 180.0;
22pub const PAGE_W: f32 = 116.0;
24pub const PAGE_H: f32 = 156.0;
25pub const TOP_PAD: f32 = 18.0; pub const LEFT_X: f32 = 15.0; pub const RIGHT_X: f32 = 141.0; pub const SEP_U: f32 = 140.0;
30pub const SEP_V: f32 = 180.0;
31pub const SEP_W: f32 = 110.0;
32pub const SEP_H: f32 = 3.0;
33
34#[repr(C)]
37#[derive(Copy, Clone)]
38struct Vert { x: f32, y: f32, u: f32, v: f32, r: f32, g: f32, b: f32, a: f32 }
39
40fn rgba_f(c: u32) -> (f32, f32, f32, f32) {
41 let a = ((c >> 24) & 0xFF) as f32 / 255.0;
42 let r = ((c >> 16) & 0xFF) as f32 / 255.0;
43 let g = ((c >> 8) & 0xFF) as f32 / 255.0;
44 let b = ( c & 0xFF) as f32 / 255.0;
45 (r, g, b, a)
46}
47
48fn quad(verts: &mut Vec<Vert>, x: f32, y: f32, w: f32, h: f32,
49 u0: f32, v0: f32, u1: f32, v1: f32, color: u32) {
50 let (r, g, b, a) = rgba_f(color);
51 let p = [
52 Vert { x, y, u: u0, v: v0, r, g, b, a },
53 Vert { x: x+w, y, u: u1, v: v0, r, g, b, a },
54 Vert { x, y: y+h, u: u0, v: v1, r, g, b, a },
55 Vert { x: x+w, y: y+h, u: u1, v: v1, r, g, b, a },
56 ];
57 verts.extend_from_slice(&[p[0], p[1], p[2], p[1], p[3], p[2]]);
59}
60
61const VERT: &str = r#"
64#version 330 core
65layout(location = 0) in vec2 aPos;
66layout(location = 1) in vec2 aUv;
67layout(location = 2) in vec4 aCol;
68out vec2 fUv;
69out vec4 fCol;
70uniform vec2 uScreen;
71void main() {
72 fUv = aUv;
73 fCol = aCol;
74 vec2 ndc = aPos / uScreen * 2.0 - vec2(1.0);
75 gl_Position = vec4(ndc.x, -ndc.y, 0.0, 1.0);
76}
77"#;
78
79const FRAG: &str = r#"
80#version 330 core
81in vec2 fUv;
82in vec4 fCol;
83out vec4 outColor;
84uniform sampler2D uTex;
85uniform int uMode; // 0 = solid color, 1 = RGBA texture, 2 = alpha-only (font)
86void main() {
87 if (uMode == 0) {
88 outColor = fCol;
89 } else if (uMode == 1) {
90 outColor = texture(uTex, fUv) * fCol;
91 } else {
92 float alpha = texture(uTex, fUv).a;
93 outColor = vec4(fCol.rgb, fCol.a * alpha);
94 }
95}
96"#;
97
98static BOOK_PNG: &[u8] = include_bytes!("../assets/book_brown.png");
101static CRAFTING_PNG: &[u8] = include_bytes!("../assets/crafting.png");
102
103fn decode_png(data: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
104 use png::Decoder;
105 let decoder = Decoder::new(std::io::Cursor::new(data));
106 let mut reader = decoder.read_info().ok()?;
107 let mut buf = vec![0u8; reader.output_buffer_size()];
108 let info = reader.next_frame(&mut buf).ok()?;
109 let rgba = match info.color_type {
110 png::ColorType::Rgba => buf[..info.buffer_size()].to_vec(),
111 png::ColorType::Rgb => {
112 let rgb = &buf[..info.buffer_size()];
113 let mut out = Vec::with_capacity(rgb.len() / 3 * 4);
114 for px in rgb.chunks(3) { out.extend_from_slice(px); out.push(255); }
115 out
116 }
117 _ => return None,
118 };
119 Some((rgba, info.width, info.height))
120}
121
122struct BookGl {
125 prog: gl::ShaderProgram,
126 vao: gl::VertexArray,
127 vbo: gl::Buffer,
128 book_tex: Option<(gl::Texture, u32, u32)>,
129 crafting_tex: Option<(gl::Texture, u32, u32)>,
130 svg_tex: HashMap<u64, (gl::Texture, u32, u32)>,
131 font_atlas: HashMap<u64, (gl::Texture, FontAtlas)>,
132 mc_item_tex: HashMap<String, u32>,
134}
135
136impl BookGl {
137 fn init(ctx: &GfxContext) -> Option<Self> {
138 let prog = ctx.create_shader(VERT, FRAG).ok()?;
139 let vbo = ctx.create_buffer();
140 let vao = ctx.create_vao();
141
142 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]| decode_png(data).map(|(rgba, w, h)| {
148 let tex = ctx.create_texture_rgba(w, h, &rgba, true);
149 (tex, w, h)
150 });
151 let book_tex = load(BOOK_PNG);
152 let crafting_tex = load(CRAFTING_PNG);
153
154 Some(BookGl { prog, vao, vbo, book_tex, crafting_tex,
155 svg_tex: HashMap::new(), font_atlas: HashMap::new(),
156 mc_item_tex: HashMap::new() })
157 }
158
159 fn svg_tex(&mut self, ctx: &GfxContext, hash: u64, data: &str, w: u32, h: u32)
160 -> Option<&(gl::Texture, u32, u32)>
161 {
162 if !self.svg_tex.contains_key(&hash) {
163 let pixels = svg::rasterize(data, w, h)?;
164 let tex = ctx.create_texture_rgba(w, h, &pixels, true);
165 self.svg_tex.insert(hash, (tex, w, h));
166 }
167 self.svg_tex.get(&hash)
168 }
169
170 fn flush(&self, ctx: &GfxContext, verts: &[Vert]) {
171 if verts.is_empty() { return; }
172 unsafe { self.vbo.upload(ctx, verts, true); }
173 ctx.draw_arrays(&self.vao, &self.prog, DrawMode::Triangles, 0, verts.len() as u32);
174 }
175}
176
177impl BookGl {
180 fn begin_frame(&self, ctx: &GfxContext, sw: f32, sh: f32) {
181 ctx.set_blend(true, blend::SRC_ALPHA, blend::ONE_MINUS_SRC_ALPHA);
182 ctx.set_depth(false, false);
183 self.prog.uniform_2f(ctx, "uScreen", sw, sh);
184 self.prog.uniform_1i(ctx, "uTex", 0);
185 }
186
187 fn draw_book_sprite(&self, ctx: &GfxContext, spr: &BgSprite) {
189 let sheet = match spr.sheet {
190 SpriteSheet::Book => &self.book_tex,
191 SpriteSheet::Crafting => &self.crafting_tex,
192 };
193 let Some((tex, tw, th)) = sheet else { return };
194 let u0 = spr.u / *tw as f32;
195 let v0 = spr.v / *th as f32;
196 let u1 = (spr.u + spr.uw) / *tw as f32;
197 let v1 = (spr.v + spr.uh) / *th as f32;
198 self.prog.uniform_1i(ctx, "uMode", 1);
199 ctx.bind_texture(0, tex);
200 let mut v = Vec::with_capacity(6);
201 quad(&mut v, spr.x, spr.y, spr.w, spr.h, u0, v0, u1, v1, 0xFF_FFFFFF);
202 self.flush(ctx, &v);
203 }
204
205 fn draw_book_bg(&self, ctx: &GfxContext, bx: f32, by: f32, bw: f32, bh: f32) {
207 let Some((tex, tw, th)) = &self.book_tex else { return };
208 let su = BK_W / *tw as f32;
209 let sv = BK_H / *th as f32;
210 self.prog.uniform_1i(ctx, "uMode", 1);
211 ctx.bind_texture(0, tex);
212 let mut v = Vec::with_capacity(6);
213 quad(&mut v, bx, by, bw, bh, 0.0, 0.0, su, sv, 0xFF_FFFFFF);
214 self.flush(ctx, &v);
215 }
216
217 fn resolve_item_tex(&mut self, ctx: &GfxContext, item_id: &str) -> u32 {
220 if let Some(&h) = self.mc_item_tex.get(item_id) { return h; }
221 let candidates: Vec<String> = if let Some((ns, path)) = item_id.split_once(':') {
222 if path.starts_with("item/") || path.starts_with("block/") || path.starts_with("textures/") {
223 let p = if path.starts_with("textures/") { path.to_owned() }
224 else { format!("textures/{path}") };
225 vec![format!("{ns}:{}.png", p.trim_end_matches(".png"))]
226 } else {
227 vec![
228 format!("{ns}:textures/item/{path}.png"),
229 format!("{ns}:textures/block/{path}.png"),
230 format!("{ns}:textures/block/{path}_front.png"),
231 format!("{ns}:textures/block/{path}_top.png"),
232 format!("{ns}:textures/block/{path}_side.png"),
233 ]
234 }
235 } else {
236 vec![format!("minecraft:textures/item/{item_id}.png")]
237 };
238 let mut handle = 0u32;
239 for c in &candidates {
240 let t = ctx.texture_from_mc(c);
241 if t.handle != 0 { handle = t.handle; break; }
242 }
243 self.mc_item_tex.insert(item_id.to_owned(), handle);
244 handle
245 }
246
247 fn draw_mc_item(&mut self, ctx: &GfxContext, item_id: &str, x: f32, y: f32, w: f32, h: f32) {
249 let handle = self.resolve_item_tex(ctx, item_id);
250 if handle == 0 { return; }
251 self.prog.uniform_1i(ctx, "uMode", 1);
252 ctx.bind_texture(0, &gl::Texture { handle });
253 let mut v = Vec::with_capacity(6);
254 quad(&mut v, x, y, w, h, 0.0, 0.0, 1.0, 1.0, 0xFF_FFFFFF);
255 self.flush(ctx, &v);
256 }
257
258 fn draw_svg(&mut self, ctx: &GfxContext, data: &str, x: f32, y: f32, w: f32, h: f32) {
259 let hash = svg::svg_hash(data);
260 let tw = w as u32; let th = h as u32;
261 if let Some(&(tex, _, _)) = self.svg_tex(ctx, hash, data, tw, th) {
262 self.prog.uniform_1i(ctx, "uMode", 1);
263 ctx.bind_texture(0, &tex);
264 let mut v = Vec::with_capacity(6);
265 quad(&mut v, x, y, w, h, 0.0, 0.0, 1.0, 1.0, 0xFF_FFFFFF);
266 self.flush(ctx, &v);
267 }
268 }
269
270 fn draw_text_custom(&mut self, ctx: &GfxContext, ttf: &[u8], size_px: f32,
271 text: &str, mut x: f32, y: f32, color: u32) {
272 let hash = font_hash(ttf);
273 if !self.font_atlas.contains_key(&hash) {
274 if let Some(atlas) = FontAtlas::build(ttf, size_px) {
275 let tex = ctx.create_texture_rgba(
276 atlas.atlas_size, atlas.atlas_size, &atlas.pixels, true);
277 self.font_atlas.insert(hash, (tex, atlas));
278 }
279 }
280 let ptrs = self.font_atlas.get(&hash)
283 .map(|(t, a)| (t as *const gl::Texture, a as *const FontAtlas));
284 if let Some((tex_ptr, atlas_ptr)) = ptrs {
285 let tex = unsafe { &*tex_ptr };
287 let atlas = unsafe { &*atlas_ptr };
288 self.prog.uniform_1i(ctx, "uMode", 2);
289 ctx.bind_texture(0, tex);
290 let baseline = y + size_px;
291 let mut verts = Vec::new();
292 for ch in text.chars() {
293 if let Some(g) = atlas.glyphs.get(&ch) {
294 if g.width > 0 {
295 let gx = x + g.xoff;
296 let gy = baseline - g.yoff - g.height as f32;
297 quad(&mut verts, gx, gy, g.width as f32, g.height as f32,
298 g.u0, g.v0, g.u1, g.v1, color);
299 }
300 x += g.advance;
301 }
302 }
303 self.flush(ctx, &verts);
304 }
305 }
306}
307
308fn font_hash(data: &[u8]) -> u64 {
309 use std::hash::{Hash, Hasher};
310 let mut h = std::collections::hash_map::DefaultHasher::new();
311 data.hash(&mut h);
312 h.finish()
313}
314
315#[derive(Clone, Copy, PartialEq)]
318pub(crate) enum SpriteSheet { Book, Crafting }
319
320#[derive(Clone)]
322pub(crate) struct BgSprite {
323 pub sheet: SpriteSheet,
324 pub u: f32, pub v: f32, pub uw: f32, pub uh: f32,
326 pub x: f32, pub y: f32, pub w: f32, pub h: f32,
328}
329
330#[derive(Clone)]
333pub(crate) enum OverlayCmd {
334 Svg { data: String, x: f32, y: f32, w: f32, h: f32 },
335 Text { text: String, font: crate::font::BookFont, x: f32, y: f32, color: u32 },
336 McText { text: String, x: f32, y: f32, color: u32 },
338 McItem { item_id: String, x: f32, y: f32, w: f32, h: f32 },
340}
341
342#[derive(Clone)]
345pub(crate) enum RecipeVis {
346 Shaped { grid: Vec<Option<String>>, width: usize, output: String, count: u32 },
347 Shapeless { ingredients: Vec<String>, output: String, count: u32 },
348 Smelting { input: String, output: String },
349}
350
351fn parse_recipe(json: &str) -> Option<RecipeVis> {
352 let v: serde_json::Value = serde_json::from_str(json).ok()?;
353 match v.get("type")?.as_str()? {
354 "minecraft:crafting_shaped" => {
355 let pattern: Vec<&str> = v.get("pattern")?.as_array()?
356 .iter().filter_map(|p| p.as_str()).collect();
357 let width = pattern.iter().map(|r| r.chars().count()).max()?.max(1);
358 let key = v.get("key")?.as_object()?;
359 let mut grid = Vec::new();
360 for row in &pattern {
361 let mut chars: Vec<char> = row.chars().collect();
362 chars.resize(width, ' ');
363 for ch in chars {
364 grid.push(key.get(&ch.to_string())
365 .and_then(|k| k.get("item")).and_then(|i| i.as_str())
366 .map(str::to_owned));
367 }
368 }
369 let result = v.get("result")?;
370 Some(RecipeVis::Shaped {
371 grid, width,
372 output: result.get("item")?.as_str()?.to_owned(),
373 count: result.get("count").and_then(|c| c.as_u64()).unwrap_or(1) as u32,
374 })
375 }
376 "minecraft:crafting_shapeless" => {
377 let ingredients = v.get("ingredients")?.as_array()?.iter()
378 .filter_map(|i| i.get("item").and_then(|x| x.as_str()).map(str::to_owned))
379 .collect();
380 let result = v.get("result")?;
381 Some(RecipeVis::Shapeless {
382 ingredients,
383 output: result.get("item")?.as_str()?.to_owned(),
384 count: result.get("count").and_then(|c| c.as_u64()).unwrap_or(1) as u32,
385 })
386 }
387 "minecraft:smelting" | "minecraft:blasting"
388 | "minecraft:smoking" | "minecraft:campfire_cooking" => {
389 Some(RecipeVis::Smelting {
390 input: v.get("ingredient")?.get("item")?.as_str()?.to_owned(),
391 output: v.get("result")?.as_str()?.to_owned(),
392 })
393 }
394 _ => None,
395 }
396}
397
398fn pretty_item_name(id: &str) -> String {
400 let name = id.rsplit(':').next().unwrap_or(id);
401 name.split('_')
402 .map(|w| {
403 let mut c = w.chars();
404 match c.next() {
405 Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
406 None => String::new(),
407 }
408 })
409 .collect::<Vec<_>>()
410 .join(" ")
411}
412
413pub struct BookRenderer {
416 pub book: Book,
417 pub state: BookViewState,
418 pub theme: BookTheme,
419 fonts: BookFontRegistry,
420 recipes: HashMap<String, RecipeVis>,
421 gl: Option<BookGl>,
422 pub ui: Option<UiRoot>,
423 bg_sprites: Vec<BgSprite>,
424 overlays: Vec<OverlayCmd>,
425 dirty: bool,
426 last_sw: f32,
427 last_sh: f32,
428}
429
430impl BookRenderer {
431 pub fn new(book: Book) -> Self {
432 let theme = BookTheme::default().with_nameplate(&book.nameplate_color);
433 Self {
434 book,
435 state: BookViewState::default(),
436 theme,
437 fonts: BookFontRegistry::default(),
438 recipes: HashMap::new(),
439 gl: None,
440 ui: None,
441 bg_sprites: Vec::new(),
442 overlays: Vec::new(),
443 dirty: true,
444 last_sw: 0.0,
445 last_sh: 0.0,
446 }
447 }
448
449 pub fn handle_event(&mut self, ev: &str) {
450 if self.state.handle(ev, &self.book) {
451 self.dirty = true;
452 }
453 }
454
455 pub fn register_font(&mut self, id: impl Into<String>, ttf: Vec<u8>) {
457 self.fonts.register(id, ttf);
458 }
459
460 pub fn add_recipe(&mut self, id: impl Into<String>, json: &str) {
463 if let Some(vis) = parse_recipe(json) {
464 self.recipes.insert(id.into(), vis);
465 self.dirty = true;
466 }
467 }
468
469 pub fn render(&mut self, ctx: &GfxContext, sw: f32, sh: f32) {
470 if self.gl.is_none() {
472 self.gl = BookGl::init(ctx);
473 }
474
475 let scale = ((sw - 16.0) / BK_W).min((sh - 16.0) / BK_H).min(1.0).max(0.4);
478 let bw = BK_W * scale;
479 let bh = BK_H * scale;
480 let bx = (sw - bw) / 2.0;
481 let by = (sh - bh) / 2.0;
482
483 if self.dirty || sw != self.last_sw || sh != self.last_sh {
485 let (root, bg_sprites, overlays) = build_ui(&self.book, &self.state, &self.theme, &self.recipes, sw, sh, bx, by, bw, bh);
486 self.ui = Some(root);
487 self.bg_sprites = bg_sprites;
488 self.overlays = overlays;
489 self.dirty = false;
490 self.last_sw = sw;
491 self.last_sh = sh;
492 }
493
494 if let Some(gl) = &mut self.gl {
496 gl.begin_frame(ctx, sw, sh);
497 gl.draw_book_bg(ctx, bx, by, bw, bh);
498 }
499
500 if let Some(gl) = &mut self.gl {
502 for spr in &self.bg_sprites {
503 gl.draw_book_sprite(ctx, spr);
504 }
505 }
506
507 if let Some(ui) = &mut self.ui {
509 if ui.needs_layout { ui.layout(sw, sh); }
510 ui.render(ctx);
511 }
512
513 if let Some(gl) = &mut self.gl {
515 gl.begin_frame(ctx, sw, sh);
516 for ov in self.overlays.clone() {
517 match ov {
518 OverlayCmd::Svg { data, x, y, w, h } =>
519 gl.draw_svg(ctx, &data, x, y, w, h),
520 OverlayCmd::Text { text, font, x, y, color } => {
521 if let Some(ttf) = self.fonts.get(&font.font_id) {
522 gl.draw_text_custom(ctx, ttf, font.size_px, &text, x, y, color);
523 }
524 }
525 OverlayCmd::McText { text, x, y, color } => {
526 ctx.draw2d().text(&text, x, y, color, false);
527 }
528 OverlayCmd::McItem { item_id, x, y, w, h } => {
529 gl.draw_mc_item(ctx, &item_id, x, y, w, h);
530 }
531 }
532 }
533 }
534 }
535}
536
537fn lbl(text: impl Into<String>) -> widget::Widget { widget::label(text).shadow(false) }
541fn btn(text: impl Into<String>) -> widget::Widget { widget::button(text).shadow(false) }
543
544fn build_ui(book: &Book, state: &BookViewState, theme: &BookTheme,
547 recipes: &HashMap<String, RecipeVis>,
548 sw: f32, sh: f32,
549 bx: f32, by: f32, bw: f32, bh: f32) -> (UiRoot, Vec<BgSprite>, Vec<OverlayCmd>) {
550 let _ = (sw, sh);
551 let mut bg_sprites: Vec<BgSprite> = Vec::new();
552 let mut overlays: Vec<OverlayCmd> = Vec::new();
553
554 let sx = bw / BK_W;
556 let sy = bh / BK_H;
557
558 let pw = PAGE_W * sx;
560 let ph = PAGE_H * sy;
561 let spine_gap = (RIGHT_X - LEFT_X - PAGE_W) * sx; let lx = bx + LEFT_X * sx;
565 let rx = bx + RIGHT_X * sx;
566 let py = by + TOP_PAD * sy;
567
568 let sep_cx = (PAGE_W / 2.0 - SEP_W / 2.0) * sx; if state.at_home {
572 bg_sprites.push(BgSprite {
574 sheet: SpriteSheet::Book,
575 u: 0.0, v: 180.0, uw: 140.0, uh: 31.0,
576 x: bx + (LEFT_X - 8.0) * sx,
577 y: by + 12.0 * sy,
578 w: 140.0 * sx,
579 h: 31.0 * sy,
580 });
581 bg_sprites.push(BgSprite {
583 sheet: SpriteSheet::Book,
584 u: SEP_U, v: SEP_V, uw: SEP_W, uh: SEP_H,
585 x: rx + sep_cx,
586 y: by + (TOP_PAD + 12.0) * sy,
587 w: SEP_W * sx,
588 h: (SEP_H * sy).max(1.0),
589 });
590 } else {
591 bg_sprites.push(BgSprite {
593 sheet: SpriteSheet::Book,
594 u: SEP_U, v: SEP_V, uw: SEP_W, uh: SEP_H,
595 x: rx + sep_cx,
596 y: by + (TOP_PAD + 12.0) * sy,
597 w: SEP_W * sx,
598 h: (SEP_H * sy).max(1.0),
599 });
600 }
601
602 let (left_page, right_page) = if state.at_home {
603 let l = build_landing_left(book, theme, pw, ph, lx, py, sx, sy, bx, by, &mut overlays);
604 let r = build_categories_right(book, state, theme, pw, ph, rx, py, sx, sy, &mut overlays);
605 (l, r)
606 } else {
607 let l = build_entry_left(book, state, theme, recipes, pw, ph, lx, py, sx, sy, sep_cx, &mut bg_sprites, &mut overlays);
608 let r = build_entries_right(book, state, theme, pw, ph, rx, py, sx, sy, &mut overlays);
609 (l, r)
610 };
611
612 let root_widget = widget::panel(FlexDir::Row)
617 .w(bw).h(bh)
618 .padding(TOP_PAD * sy, 0.0, 0.0, LEFT_X * sx)
619 .child(left_page)
620 .child(widget::spacer().w(spine_gap)) .child(right_page);
622
623 let outer = widget::panel(FlexDir::Column)
626 .w(bw).h(bh)
627 .child(root_widget);
628
629 let screen_root = widget::panel(FlexDir::Row)
634 .padding(by, 0.0, 0.0, bx)
635 .child(outer);
636
637 let ui = UiRoot::new(&book.id, screen_root);
638 (ui, bg_sprites, overlays)
639}
640
641fn build_landing_left(
644 book: &Book, theme: &BookTheme,
645 page_w: f32, page_h: f32,
646 _lx: f32, _py: f32,
647 sx: f32, sy: f32,
648 bx: f32, by: f32,
649 overlays: &mut Vec<OverlayCmd>,
650) -> widget::Widget {
651 overlays.push(OverlayCmd::McText {
654 text: book.name.clone(),
655 x: bx + 13.0 * sx,
656 y: by + 16.0 * sy,
657 color: theme.nameplate,
658 });
659 if let Some(author) = &book.author {
660 overlays.push(OverlayCmd::McText {
661 text: format!("by {}", author),
662 x: bx + 24.0 * sx,
663 y: by + 24.0 * sy,
664 color: theme.nameplate,
665 });
666 }
667
668 let mut col = widget::panel(FlexDir::Column)
672 .w(page_w).h(page_h)
673 .padding(25.0 * sy, 6.0, 4.0, 4.0)
674 .gap(0.0);
675
676 for para in book.landing_text.split('\n') {
677 if para.is_empty() {
678 col = col.child(widget::spacer().h(4.0 * sy));
679 } else {
680 col = col.child(lbl(para).color(theme.text));
681 }
682 }
683
684 let total = book.entries.len();
685 col = col.child(widget::spacer().flex(1.0));
686 col = col.child(
687 lbl(format!("{} entries", total))
688 .color(theme.nav).h(9.0 * sy).align(Align::Center)
689 );
690 col
691}
692
693fn build_categories_right(
696 book: &Book, state: &BookViewState, theme: &BookTheme,
697 page_w: f32, page_h: f32,
698 rx: f32, py: f32,
699 sx: f32, sy: f32,
700 overlays: &mut Vec<OverlayCmd>,
701) -> widget::Widget {
702 let cell_w = 24.0 * sx;
707 let cell_h = 24.0 * sy;
708 let grid_pad_left = 10.0 * sx;
709 let header_h = 9.0 * sy;
710
711 let mut col = widget::panel(FlexDir::Column)
712 .w(page_w).h(page_h)
713 .padding(0.0, 4.0, 4.0, 0.0)
714 .gap(0.0);
715
716 col = col.child(
717 lbl("Categories").color(theme.divider).h(header_h).align(Align::Center)
718 );
719 col = col.child(widget::spacer().h(25.0 * sy - header_h));
721
722 let cats = &book.categories;
724 let icon_s = 16.0 * sx.min(sy);
725 let mut row_i = 0usize;
726 loop {
727 let row_start = row_i * 4;
728 if row_start >= cats.len() { break; }
729 let mut row = widget::panel(FlexDir::Row)
730 .h(cell_h)
731 .gap(0.0)
732 .padding(0.0, 0.0, 0.0, grid_pad_left);
733 for col_i in 0..4 {
734 let idx = row_start + col_i;
735 if idx >= cats.len() { break; }
736 let cat = &cats[idx];
737 let selected = !state.at_home && idx == state.cat;
738 let bg = if selected { theme.nav_selected_bg } else { 0 };
739
740 if let Some(icon_id) = &cat.icon {
741 overlays.push(OverlayCmd::McItem {
742 item_id: icon_id.clone(),
743 x: rx + (10.0 + col_i as f32 * 24.0 + 4.0) * sx,
744 y: py + (25.0 + row_i as f32 * 24.0 + 4.0) * sy,
745 w: icon_s, h: icon_s,
746 });
747 }
748 let cell = widget::panel(FlexDir::Column)
749 .w(cell_w).h(cell_h).bg(bg)
750 .on_click(format!("cat:{}", idx))
751 .id(format!("book_cat_{}", idx));
752 row = row.child(cell);
753 }
754 col = col.child(row);
755 row_i += 1;
756 }
757 col
758}
759
760fn build_entry_left(
763 book: &Book, state: &BookViewState, theme: &BookTheme,
764 recipes: &HashMap<String, RecipeVis>,
765 page_w: f32, page_h: f32,
766 ox: f32, oy: f32,
767 sx: f32, sy: f32,
768 sep_cx: f32,
769 bg_sprites: &mut Vec<BgSprite>,
770 overlays: &mut Vec<OverlayCmd>,
771) -> widget::Widget {
772 let entry = state.current_entry(book);
773 let page = entry.and_then(|e| e.pages.get(state.page));
774 let page_count = state.page_count(book);
775 let title_text = entry.map(|e| e.name.as_str()).unwrap_or("");
776
777 let title_h = 9.0 * sy;
782 let sep_h_px = (SEP_H * sy).max(1.0);
783 let sep_y = oy + 12.0 * sy;
784 let body_oy = oy + 22.0 * sy;
785
786 bg_sprites.push(BgSprite {
787 sheet: SpriteSheet::Book,
788 u: SEP_U, v: SEP_V, uw: SEP_W, uh: SEP_H,
789 x: ox + sep_cx, y: sep_y,
790 w: SEP_W * sx, h: sep_h_px,
791 });
792
793 let page_label = format!("{}/{}", state.page + 1, page_count);
794 let nav = widget::panel(FlexDir::Row)
795 .h(12.0 * sy).gap(4.0)
796 .padding(0.0, 2.0, 0.0, 2.0)
797 .child(btn("◀").w(12.0 * sx).h(12.0 * sy).color(theme.nav)
798 .on_click("prev_page").id("prev_page"))
799 .child(lbl(&page_label).color(theme.nav).flex(1.0).align(Align::Center))
800 .child(btn("⌂").w(12.0 * sx).h(12.0 * sy).color(theme.nav)
801 .on_click("home").id("book_home"))
802 .child(btn("▶").w(12.0 * sx).h(12.0 * sy).color(theme.nav)
803 .on_click("next_page").id("next_page"));
804
805 let page_body = build_page(page, state.page, theme, recipes, bg_sprites, overlays, ox, body_oy, sx, sy);
806
807 widget::panel(FlexDir::Column)
808 .w(page_w).h(page_h)
809 .padding(0.0, 6.0, 4.0, 4.0)
810 .gap(0.0)
811 .child(lbl(title_text).color(theme.title).h(title_h).align(Align::Center))
812 .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)
816 .child(nav)
817}
818
819fn build_entries_right(
822 book: &Book, state: &BookViewState, theme: &BookTheme,
823 page_w: f32, page_h: f32, rx: f32, py: f32,
824 sx: f32, sy: f32,
825 overlays: &mut Vec<OverlayCmd>,
826) -> widget::Widget {
827 let entries = state.entries_visible(book);
828 let cat_name = book.categories.get(state.cat).map(|c| c.name.as_str()).unwrap_or("Entries");
829 let spread_count = state.list_spread_count(book);
830
831 let header_h = 9.0 * sy;
836 let row_h = 11.0 * sy;
837 let icon_size = 8.0 * sx.min(sy);
838
839 let mut col = widget::panel(FlexDir::Column)
840 .w(page_w).h(page_h)
841 .padding(0.0, 4.0, 4.0, 0.0)
842 .gap(0.0);
843
844 col = col.child(lbl(cat_name).color(theme.divider).h(header_h).align(Align::Center));
845 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();
851 for (i, entry) in entries.iter().enumerate() {
852 let abs_i = abs_start + i;
853 let selected = abs_i == state.entry;
854 let bg = if selected { theme.nav_selected_bg } else { 0 };
855 let color = if selected { theme.nav_selected } else { theme.nav };
856
857 if let Some(icon_id) = &entry.icon {
860 overlays.push(OverlayCmd::McItem {
861 item_id: icon_id.clone(),
862 x: rx + 1.0 * sx,
863 y: py + (20.0 + i as f32 * 11.0 + 1.0) * sy,
864 w: icon_size, h: icon_size,
865 });
866 }
867
868 let mut row = widget::panel(FlexDir::Row)
869 .h(row_h).gap(2.0).bg(bg)
870 .on_click(format!("entry:{}", abs_i))
871 .id(format!("book_entry_{}", abs_i))
872 .padding(1.0, 2.0, 1.0, 2.0);
873
874 row = row.child(widget::spacer().w(icon_size + 2.0));
875 row = row.child(lbl(&entry.name).color(color).flex(1.0));
876 col = col.child(row);
877 }
878
879 if spread_count > 1 {
880 col = col.child(widget::spacer().flex(1.0));
881 let spread_label = format!("{}/{}", state.list_spread + 1, spread_count);
882 col = col.child(
883 widget::panel(FlexDir::Row).h(12.0 * sy).gap(2.0)
884 .child(btn("◀").w(12.0 * sx).h(12.0 * sy).color(theme.nav)
885 .on_click("prev_list").id("prev_list"))
886 .child(lbl(&spread_label).color(theme.nav)
887 .flex(1.0).align(Align::Center))
888 .child(btn("▶").w(12.0 * sx).h(12.0 * sy).color(theme.nav)
889 .on_click("next_list").id("next_list"))
890 );
891 }
892 col
893}
894
895#[allow(clippy::too_many_arguments)]
899fn draw_crafting_grid(
900 theme: &BookTheme,
901 bg_sprites: &mut Vec<BgSprite>,
902 overlays: &mut Vec<OverlayCmd>,
903 col: &mut widget::Widget,
904 grid: &[Option<String>], width: usize,
905 output: &str, count: u32, shapeless: bool,
906 ox: f32, oy: f32, rx0: f32, ry: f32, sx: f32, sy: f32,
907) {
908 let mut c = std::mem::replace(col, widget::panel(FlexDir::Column));
909 c = c.child(lbl(pretty_item_name(output)).color(theme.title)
910 .h(10.0 * sy).align(Align::Center));
911
912 bg_sprites.push(BgSprite {
914 sheet: SpriteSheet::Crafting,
915 u: 0.0, v: 0.0, uw: 100.0, uh: 62.0,
916 x: ox + (rx0 - 2.0) * sx, y: oy + (ry - 2.0) * sy,
917 w: 100.0 * sx, h: 62.0 * sy,
918 });
919 if shapeless {
921 bg_sprites.push(BgSprite {
922 sheet: SpriteSheet::Crafting,
923 u: 0.0, v: 64.0, uw: 11.0, uh: 11.0,
924 x: ox + (rx0 + 62.0) * sx, y: oy + (ry + 2.0) * sy,
925 w: 11.0 * sx, h: 11.0 * sy,
926 });
927 }
928
929 let icon_s = 16.0 * sx.min(sy);
930 let width = width.max(1);
931 for (i, slot) in grid.iter().enumerate() {
932 if let Some(item) = slot {
933 overlays.push(OverlayCmd::McItem {
934 item_id: item.clone(),
935 x: ox + (rx0 + 3.0 + (i % width) as f32 * 19.0) * sx,
936 y: oy + (ry + 3.0 + (i / width) as f32 * 19.0) * sy,
937 w: icon_s, h: icon_s,
938 });
939 }
940 }
941 overlays.push(OverlayCmd::McItem {
943 item_id: output.to_owned(),
944 x: ox + (rx0 + 79.0) * sx, y: oy + (ry + 22.0) * sy,
945 w: icon_s, h: icon_s,
946 });
947 if count > 1 {
948 overlays.push(OverlayCmd::McText {
949 text: format!("x{}", count),
950 x: ox + (rx0 + 81.0) * sx, y: oy + (ry + 40.0) * sy,
951 color: theme.title,
952 });
953 }
954
955 c = c.child(widget::spacer().h((ry + 62.0 + 4.0 - 10.0) * sy));
957 *col = c;
958}
959
960fn build_page(
966 page: Option<&BookPage>,
967 page_num: usize,
968 theme: &BookTheme,
969 recipes: &HashMap<String, RecipeVis>,
970 bg_sprites: &mut Vec<BgSprite>,
971 overlays: &mut Vec<OverlayCmd>,
972 ox: f32, oy: f32,
973 sx: f32, sy: f32,
974) -> widget::Widget {
975 let mut col = widget::panel(FlexDir::Column).flex(1.0).gap(4.0);
976
977 let Some(page) = page else {
978 return col.child(lbl("No entries yet.").color(theme.nav));
979 };
980
981 match page {
982 BookPage::Text { text, title } => {
983 if page_num > 0 {
985 if let Some(t) = title {
986 let sep_h_px = (SEP_H * sy).max(1.0);
987 let title_h = 9.0 * sy;
988 col = col.child(lbl(t.as_str()).color(theme.title)
989 .h(title_h).align(Align::Center));
990 bg_sprites.push(BgSprite {
992 sheet: SpriteSheet::Book,
993 u: SEP_U, v: SEP_V, uw: SEP_W, uh: SEP_H,
994 x: ox + (PAGE_W / 2.0 - SEP_W / 2.0) * sx,
995 y: oy + 12.0 * sy,
996 w: SEP_W * sx, h: sep_h_px,
997 });
998 col = col.child(widget::spacer().h((12.0 - 9.0) * sy));
999 col = col.child(widget::spacer().h(sep_h_px));
1000 col = col.child(widget::spacer().h((22.0 - 12.0 - SEP_H) * sy));
1001 }
1002 }
1003 for para in text.split('\n') {
1004 col = col.child(lbl(para).color(theme.text));
1005 }
1006 }
1007
1008 BookPage::Spotlight { item, title, text } => {
1009 bg_sprites.push(BgSprite {
1014 sheet: SpriteSheet::Crafting,
1015 u: 0.0, v: 102.0, uw: 66.0, uh: 26.0,
1016 x: ox + (PAGE_W / 2.0 - 33.0) * sx,
1017 y: oy + 10.0 * sy,
1018 w: 66.0 * sx, h: 26.0 * sy,
1019 });
1020
1021 let item_name = title.as_deref()
1022 .or(item.name.as_deref())
1023 .unwrap_or(item.id.as_str());
1024 col = col.child(lbl(item_name).color(theme.title)
1025 .h(10.0 * sy).align(Align::Center));
1026
1027 let icon_s = 16.0 * sx.min(sy);
1028 overlays.push(OverlayCmd::McItem {
1029 item_id: item.id.clone(),
1030 x: ox + (PAGE_W / 2.0 - 8.0) * sx,
1031 y: oy + 15.0 * sy,
1032 w: icon_s, h: icon_s,
1033 });
1034
1035 col = col.child(widget::spacer().h(30.0 * sy));
1037
1038 if let Some(t) = text {
1039 col = col.child(lbl(t.as_str()).color(theme.text));
1040 }
1041 }
1042
1043 BookPage::Crafting { recipe_id, text } => {
1044 let rx0 = PAGE_W / 2.0 - 49.0; let ry = 12.0; match recipes.get(recipe_id.as_str()) {
1049 Some(RecipeVis::Shaped { grid, width, output, count }) => {
1050 draw_crafting_grid(theme, bg_sprites, overlays, &mut col,
1051 grid, *width, output, *count, false, ox, oy, rx0, ry, sx, sy);
1052 }
1053 Some(RecipeVis::Shapeless { ingredients, output, count }) => {
1054 let grid: Vec<Option<String>> =
1055 ingredients.iter().cloned().map(Some).collect();
1056 draw_crafting_grid(theme, bg_sprites, overlays, &mut col,
1057 &grid, 3, output, *count, true, ox, oy, rx0, ry, sx, sy);
1058 }
1059 _ => {
1060 col = col.child(lbl(format!("[Recipe: {}]", recipe_id))
1061 .color(theme.nav));
1062 }
1063 }
1064 if let Some(t) = text {
1065 col = col.child(lbl(t.as_str()).color(theme.text));
1066 }
1067 }
1068
1069 BookPage::Smelting { recipe_id, text } => {
1070 let rx0 = PAGE_W / 2.0 - 49.0;
1073 let ry = 12.0;
1074 match recipes.get(recipe_id.as_str()) {
1075 Some(RecipeVis::Smelting { input, output }) => {
1076 col = col.child(lbl(pretty_item_name(output)).color(theme.title)
1077 .h(10.0 * sy).align(Align::Center));
1078 bg_sprites.push(BgSprite {
1079 sheet: SpriteSheet::Crafting,
1080 u: 11.0, v: 71.0, uw: 96.0, uh: 24.0,
1081 x: ox + rx0 * sx, y: oy + ry * sy,
1082 w: 96.0 * sx, h: 24.0 * sy,
1083 });
1084 let icon_s = 16.0 * sx.min(sy);
1085 overlays.push(OverlayCmd::McItem {
1086 item_id: input.clone(),
1087 x: ox + (rx0 + 4.0) * sx, y: oy + (ry + 4.0) * sy,
1088 w: icon_s, h: icon_s,
1089 });
1090 overlays.push(OverlayCmd::McItem {
1091 item_id: output.clone(),
1092 x: ox + (rx0 + 76.0) * sx, y: oy + (ry + 4.0) * sy,
1093 w: icon_s, h: icon_s,
1094 });
1095 col = col.child(widget::spacer().h((ry + 24.0 + 4.0 - 10.0) * sy));
1097 }
1098 _ => {
1099 col = col.child(lbl(format!("[Recipe: {}]", recipe_id))
1100 .color(theme.nav));
1101 }
1102 }
1103 if let Some(t) = text {
1104 col = col.child(lbl(t.as_str()).color(theme.text));
1105 }
1106 }
1107
1108 BookPage::Image { texture, title, text, .. } => {
1109 if let Some(t) = title {
1110 col = col.child(lbl(t.as_str()).color(theme.title));
1111 }
1112 col = col.child(widget::mc_image(texture, 80.0, 80.0));
1113 if let Some(t) = text {
1114 col = col.child(lbl(t.as_str()).color(theme.text));
1115 }
1116 }
1117
1118 BookPage::Svg { data, title, text } => {
1119 if let Some(t) = title {
1120 col = col.child(lbl(t.as_str()).color(theme.title));
1121 }
1122 overlays.push(OverlayCmd::Svg { data: data.clone(), x: ox, y: oy, w: 64.0, h: 64.0 });
1123 col = col.child(widget::spacer().h(68.0));
1124 if let Some(t) = text {
1125 col = col.child(lbl(t.as_str()).color(theme.text));
1126 }
1127 }
1128
1129 BookPage::CustomText { text, font, color } => {
1130 overlays.push(OverlayCmd::Text {
1131 text: text.clone(), font: font.clone(), x: ox, y: oy, color: *color,
1132 });
1133 col = col.child(widget::spacer().h(font.size_px * 1.5));
1134 }
1135
1136 BookPage::Relations { entries, text } => {
1137 if let Some(t) = text {
1138 col = col.child(lbl(t.as_str()).color(theme.text));
1139 }
1140 col = col.child(lbl("See also:").color(theme.title));
1141 for e in entries {
1142 col = col.child(lbl(format!("• {}", e)).color(theme.nav));
1143 }
1144 }
1145
1146 BookPage::Entity { entity_type, name, text } => {
1147 let display = name.as_deref().unwrap_or(entity_type.as_str());
1148 col = col.child(lbl(display).color(theme.title));
1149 if let Some(t) = text {
1150 col = col.child(lbl(t.as_str()).color(theme.text));
1151 }
1152 }
1153
1154 BookPage::Pattern { op_id, input, output, text, .. } => {
1155 col = col.child(lbl(op_id.as_str()).color(theme.title));
1156 col = col.child(
1157 lbl(format!("{} → {}", input, output)).color(theme.nav)
1158 );
1159 if !text.is_empty() {
1160 col = col.child(lbl(text.as_str()).color(theme.text));
1161 }
1162 }
1163
1164 BookPage::Empty => {}
1165 }
1166
1167 col
1168}