Skip to main content

yog_book/
renderer.rs

1//! Book renderer — ties together yog-ui layout, yog-gfx GPU pipeline,
2//! SVG icon rasterization, and custom font rendering.
3
4use 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
14// Patchouli-compatible book texture layout (512×256 sprite sheet).
15// UV coordinates are in pixels on a 512×256 sheet.
16// The full open-book background occupies 272×180 at UV (0,0).
17pub const BOOK_TEX_W: f32   = 512.0;
18pub const BOOK_TEX_H: f32   = 256.0;
19// Open-book dimensions in texture-space
20pub const BK_W: f32         = 272.0;
21pub const BK_H: f32         = 180.0;
22// Per-page dimensions and offsets (in book-local coords)
23pub const PAGE_W: f32       = 116.0;
24pub const PAGE_H: f32       = 156.0;
25pub const TOP_PAD: f32      = 18.0;   // vertical space above page text area
26pub const LEFT_X: f32       = 15.0;   // left page X inside book
27pub const RIGHT_X: f32      = 141.0;  // right page X inside book
28// Separator strip UV origin on the sprite sheet
29pub 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// ── Vertex for the custom 2D shader (pos + uv + color) ───────────────────────
35
36#[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    // two triangles: 0,1,2  1,3,2
58    verts.extend_from_slice(&[p[0], p[1], p[2], p[1], p[3], p[2]]);
59}
60
61// ── GLSL shaders ─────────────────────────────────────────────────────────────
62
63const 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
98// ── Embedded textures ─────────────────────────────────────────────────────────
99
100static 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
122// ── GL resource cache ─────────────────────────────────────────────────────────
123
124struct 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}
133
134impl BookGl {
135    fn init(ctx: &GfxContext) -> Option<Self> {
136        let prog = ctx.create_shader(VERT, FRAG).ok()?;
137        let vbo  = ctx.create_buffer();
138        let vao  = ctx.create_vao();
139
140        const STRIDE: u32 = 32; // 8 × f32
141        vao.attrib(ctx, &vbo, 0, 2, DataType::F32, false, STRIDE, 0);  // pos
142        vao.attrib(ctx, &vbo, 1, 2, DataType::F32, false, STRIDE, 8);  // uv
143        vao.attrib(ctx, &vbo, 2, 4, DataType::F32, false, STRIDE, 16); // col
144
145        let load = |data: &[u8]| decode_png(data).map(|(rgba, w, h)| {
146            let tex = ctx.create_texture_rgba(w, h, &rgba, true);
147            (tex, w, h)
148        });
149        let book_tex     = load(BOOK_PNG);
150        let crafting_tex = load(CRAFTING_PNG);
151
152        Some(BookGl { prog, vao, vbo, book_tex, crafting_tex,
153                      svg_tex: HashMap::new(), font_atlas: HashMap::new() })
154    }
155
156    fn svg_tex(&mut self, ctx: &GfxContext, hash: u64, data: &str, w: u32, h: u32)
157        -> Option<&(gl::Texture, u32, u32)>
158    {
159        if !self.svg_tex.contains_key(&hash) {
160            let pixels = svg::rasterize(data, w, h)?;
161            let tex = ctx.create_texture_rgba(w, h, &pixels, true);
162            self.svg_tex.insert(hash, (tex, w, h));
163        }
164        self.svg_tex.get(&hash)
165    }
166
167    fn flush(&self, ctx: &GfxContext, verts: &[Vert]) {
168        if verts.is_empty() { return; }
169        unsafe { self.vbo.upload(ctx, verts, true); }
170        ctx.draw_arrays(&self.vao, &self.prog, DrawMode::Triangles, 0, verts.len() as u32);
171    }
172}
173
174// ── Draw helpers ──────────────────────────────────────────────────────────────
175
176impl BookGl {
177    fn begin_frame(&self, ctx: &GfxContext, sw: f32, sh: f32) {
178        ctx.set_blend(true, blend::SRC_ALPHA, blend::ONE_MINUS_SRC_ALPHA);
179        ctx.set_depth(false, false);
180        self.prog.uniform_2f(ctx, "uScreen", sw, sh);
181        self.prog.uniform_1i(ctx, "uTex", 0);
182    }
183
184    /// Blit a subrect of a sprite sheet at an arbitrary screen position.
185    fn draw_book_sprite(&self, ctx: &GfxContext, spr: &BgSprite) {
186        let sheet = match spr.sheet {
187            SpriteSheet::Book     => &self.book_tex,
188            SpriteSheet::Crafting => &self.crafting_tex,
189        };
190        let Some((tex, tw, th)) = sheet else { return };
191        let u0 = spr.u  / *tw as f32;
192        let v0 = spr.v  / *th as f32;
193        let u1 = (spr.u + spr.uw) / *tw as f32;
194        let v1 = (spr.v + spr.uh) / *th as f32;
195        self.prog.uniform_1i(ctx, "uMode", 1);
196        ctx.bind_texture(0, tex);
197        let mut v = Vec::with_capacity(6);
198        quad(&mut v, spr.x, spr.y, spr.w, spr.h, u0, v0, u1, v1, 0xFF_FFFFFF);
199        self.flush(ctx, &v);
200    }
201
202    /// Blit the open-book background from the embedded book texture.
203    fn draw_book_bg(&self, ctx: &GfxContext, bx: f32, by: f32, bw: f32, bh: f32) {
204        let Some((tex, tw, th)) = &self.book_tex else { return };
205        let su = BK_W / *tw as f32;
206        let sv = BK_H / *th as f32;
207        self.prog.uniform_1i(ctx, "uMode", 1);
208        ctx.bind_texture(0, tex);
209        let mut v = Vec::with_capacity(6);
210        quad(&mut v, bx, by, bw, bh, 0.0, 0.0, su, sv, 0xFF_FFFFFF);
211        self.flush(ctx, &v);
212    }
213
214    fn draw_svg(&mut self, ctx: &GfxContext, data: &str, x: f32, y: f32, w: f32, h: f32) {
215        let hash = svg::svg_hash(data);
216        let tw = w as u32; let th = h as u32;
217        if let Some(&(tex, _, _)) = self.svg_tex(ctx, hash, data, tw, th) {
218            self.prog.uniform_1i(ctx, "uMode", 1);
219            ctx.bind_texture(0, &tex);
220            let mut v = Vec::with_capacity(6);
221            quad(&mut v, x, y, w, h, 0.0, 0.0, 1.0, 1.0, 0xFF_FFFFFF);
222            self.flush(ctx, &v);
223        }
224    }
225
226    fn draw_text_custom(&mut self, ctx: &GfxContext, ttf: &[u8], size_px: f32,
227                        text: &str, mut x: f32, y: f32, color: u32) {
228        let hash = font_hash(ttf);
229        if !self.font_atlas.contains_key(&hash) {
230            if let Some(atlas) = FontAtlas::build(ttf, size_px) {
231                let tex = ctx.create_texture_rgba(
232                    atlas.atlas_size, atlas.atlas_size, &atlas.pixels, true);
233                self.font_atlas.insert(hash, (tex, atlas));
234            }
235        }
236        // Get raw pointers so the borrow of self.font_atlas ends before we call
237        // self.prog / self.flush, which also borrow fields of self.
238        let ptrs = self.font_atlas.get(&hash)
239            .map(|(t, a)| (t as *const gl::Texture, a as *const FontAtlas));
240        if let Some((tex_ptr, atlas_ptr)) = ptrs {
241            // SAFETY: we hold &mut self; font_atlas is not mutated below.
242            let tex   = unsafe { &*tex_ptr   };
243            let atlas = unsafe { &*atlas_ptr };
244            self.prog.uniform_1i(ctx, "uMode", 2);
245            ctx.bind_texture(0, tex);
246            let baseline = y + size_px;
247            let mut verts = Vec::new();
248            for ch in text.chars() {
249                if let Some(g) = atlas.glyphs.get(&ch) {
250                    if g.width > 0 {
251                        let gx = x + g.xoff;
252                        let gy = baseline - g.yoff - g.height as f32;
253                        quad(&mut verts, gx, gy, g.width as f32, g.height as f32,
254                             g.u0, g.v0, g.u1, g.v1, color);
255                    }
256                    x += g.advance;
257                }
258            }
259            self.flush(ctx, &verts);
260        }
261    }
262}
263
264fn font_hash(data: &[u8]) -> u64 {
265    use std::hash::{Hash, Hasher};
266    let mut h = std::collections::hash_map::DefaultHasher::new();
267    data.hash(&mut h);
268    h.finish()
269}
270
271// ── Background sprite (texture blit, drawn before yog-ui) ────────────────────
272
273#[derive(Clone, Copy, PartialEq)]
274pub(crate) enum SpriteSheet { Book, Crafting }
275
276/// A subrect blit from one of the embedded sprite sheets, drawn before yog-ui.
277#[derive(Clone)]
278pub(crate) struct BgSprite {
279    pub sheet: SpriteSheet,
280    /// Source rect in pixels on the sheet.
281    pub u: f32, pub v: f32, pub uw: f32, pub uh: f32,
282    /// Destination on screen.
283    pub x: f32, pub y: f32, pub w: f32, pub h: f32,
284}
285
286// ── Pending overlay draw commands ─────────────────────────────────────────────
287
288#[derive(Clone)]
289pub(crate) enum OverlayCmd {
290    Svg    { data: String, x: f32, y: f32, w: f32, h: f32 },
291    Text   { text: String, font: crate::font::BookFont, x: f32, y: f32, color: u32 },
292    /// MC default-font text rendered via draw2d (e.g. nameplate title/subtitle).
293    McText { text: String, x: f32, y: f32, color: u32 },
294    /// Item icon rendered through our GL pipeline from an MC-managed texture.
295    McItem { item_id: String, x: f32, y: f32, w: f32, h: f32 },
296    /// Flat texture icon blitted as-is (16×16), for icon strings that name a
297    /// texture resource directly rather than an item — matches Patchouli's
298    /// `BookIcon.from`: any icon string ending in `.png` is a raw texture,
299    /// everything else is parsed as an item stack.
300    McTexture { path: String, x: f32, y: f32, w: f32, h: f32 },
301}
302
303/// Push a book-author-supplied icon (category/entry icon field) as the right
304/// overlay kind. Matches Patchouli's `BookIcon.from(String)`: `.png` → raw
305/// texture blit, anything else → item stack render.
306fn push_icon(overlays: &mut Vec<OverlayCmd>, icon: &str, x: f32, y: f32, w: f32, h: f32) {
307    if icon.ends_with(".png") {
308        overlays.push(OverlayCmd::McTexture { path: icon.to_string(), x, y, w, h });
309    } else {
310        overlays.push(OverlayCmd::McItem { item_id: icon.to_string(), x, y, w, h });
311    }
312}
313
314// ── Recipe visuals (parsed from registered recipe JSON) ───────────────────────
315
316#[derive(Clone)]
317pub(crate) enum RecipeVis {
318    Shaped    { grid: Vec<Option<String>>, width: usize, output: String, count: u32 },
319    Shapeless { ingredients: Vec<String>, output: String, count: u32 },
320    Smelting  { input: String, output: String },
321}
322
323fn parse_recipe(json: &str) -> Option<RecipeVis> {
324    let v: serde_json::Value = serde_json::from_str(json).ok()?;
325    match v.get("type")?.as_str()? {
326        "minecraft:crafting_shaped" => {
327            let pattern: Vec<&str> = v.get("pattern")?.as_array()?
328                .iter().filter_map(|p| p.as_str()).collect();
329            let width = pattern.iter().map(|r| r.chars().count()).max()?.max(1);
330            let key = v.get("key")?.as_object()?;
331            let mut grid = Vec::new();
332            for row in &pattern {
333                let mut chars: Vec<char> = row.chars().collect();
334                chars.resize(width, ' ');
335                for ch in chars {
336                    grid.push(key.get(&ch.to_string())
337                        .and_then(|k| k.get("item")).and_then(|i| i.as_str())
338                        .map(str::to_owned));
339                }
340            }
341            let result = v.get("result")?;
342            Some(RecipeVis::Shaped {
343                grid, width,
344                output: result.get("item")?.as_str()?.to_owned(),
345                count:  result.get("count").and_then(|c| c.as_u64()).unwrap_or(1) as u32,
346            })
347        }
348        "minecraft:crafting_shapeless" => {
349            let ingredients = v.get("ingredients")?.as_array()?.iter()
350                .filter_map(|i| i.get("item").and_then(|x| x.as_str()).map(str::to_owned))
351                .collect();
352            let result = v.get("result")?;
353            Some(RecipeVis::Shapeless {
354                ingredients,
355                output: result.get("item")?.as_str()?.to_owned(),
356                count:  result.get("count").and_then(|c| c.as_u64()).unwrap_or(1) as u32,
357            })
358        }
359        "minecraft:smelting" | "minecraft:blasting"
360        | "minecraft:smoking" | "minecraft:campfire_cooking" => {
361            Some(RecipeVis::Smelting {
362                input:  v.get("ingredient")?.get("item")?.as_str()?.to_owned(),
363                output: v.get("result")?.as_str()?.to_owned(),
364            })
365        }
366        _ => None,
367    }
368}
369
370/// Normalize icon ids like "yog:item/ruby" / "yog:block/x" to registry item
371/// ids ("yog:ruby") accepted by the MC item renderer.
372fn normalize_item_id(id: &str) -> String {
373    let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
374    let name = path.trim_start_matches("item/").trim_start_matches("block/");
375    format!("{ns}:{name}")
376}
377
378/// "yog:ruby_block" → "Ruby Block" (fallback display name from an item id).
379fn pretty_item_name(id: &str) -> String {
380    let name = id.rsplit(':').next().unwrap_or(id);
381    name.split('_')
382        .map(|w| {
383            let mut c = w.chars();
384            match c.next() {
385                Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
386                None    => String::new(),
387            }
388        })
389        .collect::<Vec<_>>()
390        .join(" ")
391}
392
393// ── BookRenderer ──────────────────────────────────────────────────────────────
394
395pub struct BookRenderer {
396    pub book:  Book,
397    pub state: BookViewState,
398    pub theme: BookTheme,
399    fonts:        BookFontRegistry,
400    recipes:      HashMap<String, RecipeVis>,
401    gl:           Option<BookGl>,
402    pub ui:       Option<UiRoot>,
403    bg_sprites:   Vec<BgSprite>,
404    overlays:     Vec<OverlayCmd>,
405    dirty:   bool,
406    last_sw: f32,
407    last_sh: f32,
408}
409
410impl BookRenderer {
411    pub fn new(book: Book) -> Self {
412        let theme = BookTheme::default().with_nameplate(&book.nameplate_color);
413        Self {
414            book,
415            state: BookViewState::default(),
416            theme,
417            fonts: BookFontRegistry::default(),
418            recipes: HashMap::new(),
419            gl: None,
420            ui: None,
421            bg_sprites: Vec::new(),
422            overlays: Vec::new(),
423            dirty: true,
424            last_sw: 0.0,
425            last_sh: 0.0,
426        }
427    }
428
429    pub fn handle_event(&mut self, ev: &str) {
430        if self.state.handle(ev, &self.book) {
431            self.dirty = true;
432        }
433    }
434
435    /// Register a custom TTF/OTF font for use in `BookPage::CustomText`.
436    pub fn register_font(&mut self, id: impl Into<String>, ttf: Vec<u8>) {
437        self.fonts.register(id, ttf);
438    }
439
440    /// Register a recipe (vanilla recipe JSON) so Crafting/Smelting pages can
441    /// render it visually. Unknown recipe types are ignored.
442    pub fn add_recipe(&mut self, id: impl Into<String>, json: &str) {
443        if let Some(vis) = parse_recipe(json) {
444            self.recipes.insert(id.into(), vis);
445            self.dirty = true;
446        }
447    }
448
449    pub fn render(&mut self, ctx: &GfxContext, sw: f32, sh: f32) {
450        // Lazy GL init.
451        if self.gl.is_none() {
452            self.gl = BookGl::init(ctx);
453        }
454
455        // Render at Patchouli-native size (272×180 GUI pixels). Scale DOWN only
456        // if the screen is smaller; never scale up (MC font is designed for 1×).
457        let scale = ((sw - 16.0) / BK_W).min((sh - 16.0) / BK_H).min(1.0).max(0.4);
458        let bw = BK_W * scale;
459        let bh = BK_H * scale;
460        let bx = (sw - bw) / 2.0;
461        let by = (sh - bh) / 2.0;
462
463        // Rebuild widget tree if dirty or screen resized.
464        if self.dirty || sw != self.last_sw || sh != self.last_sh {
465            let (root, bg_sprites, overlays) = build_ui(&self.book, &self.state, &self.theme, &self.recipes, sw, sh, bx, by, bw, bh);
466            self.ui = Some(root);
467            self.bg_sprites = bg_sprites;
468            self.overlays = overlays;
469            self.dirty = false;
470            self.last_sw = sw;
471            self.last_sh = sh;
472        }
473
474        // 1. Draw book background texture first.
475        if let Some(gl) = &mut self.gl {
476            gl.begin_frame(ctx, sw, sh);
477            gl.draw_book_bg(ctx, bx, by, bw, bh);
478        }
479
480        // 1b. Background sprites (nameplate banner, separator lines) from book texture.
481        if let Some(gl) = &mut self.gl {
482            for spr in &self.bg_sprites {
483                gl.draw_book_sprite(ctx, spr);
484            }
485        }
486
487        // 2. yog-ui: text, buttons, icons (transparent BG — texture is already there).
488        if let Some(ui) = &mut self.ui {
489            if ui.needs_layout { ui.layout(sw, sh); }
490            ui.render(ctx);
491        }
492
493        // 3. Custom GL overlays (SVG icons, custom font text) — raw GL first.
494        if let Some(gl) = &mut self.gl {
495            gl.begin_frame(ctx, sw, sh);
496            for ov in self.overlays.clone() {
497                match ov {
498                    OverlayCmd::Svg  { data, x, y, w, h } =>
499                        gl.draw_svg(ctx, &data, x, y, w, h),
500                    OverlayCmd::Text { text, font, x, y, color } => {
501                        if let Some(ttf) = self.fonts.get(&font.font_id) {
502                            gl.draw_text_custom(ctx, ttf, font.size_px, &text, x, y, color);
503                        }
504                    }
505                    OverlayCmd::McText { .. } | OverlayCmd::McItem { .. }
506                    | OverlayCmd::McTexture { .. } => {}
507                }
508            }
509        }
510
511        // 4. MC-pipeline overlays (item stacks, MC-font text) — after all raw
512        //    GL, because item rendering resyncs and mutates MC's GL state.
513        for ov in self.overlays.clone() {
514            match ov {
515                OverlayCmd::McItem { item_id, x, y, w, h } => {
516                    ctx.draw2d().item(&normalize_item_id(&item_id), x, y, w.min(h));
517                }
518                OverlayCmd::McText { text, x, y, color } => {
519                    ctx.draw2d().text(&text, x, y, color, false);
520                }
521                OverlayCmd::McTexture { path, x, y, w, h } => {
522                    // Standalone 16×16 icon texture, not an atlas region —
523                    // blit it whole (u0=v0=0, tw/th = the icon's own size).
524                    ctx.draw2d().mc_texture(&path, x, y, 0.0, 0.0, w, h, w, h);
525                }
526                _ => {}
527            }
528        }
529    }
530}
531
532// ── UI builder ────────────────────────────────────────────────────────────────
533
534/// Book label: MC font, no drop-shadow (Patchouli draws book text shadowless).
535fn lbl(text: impl Into<String>) -> widget::Widget { widget::label(text).shadow(false) }
536/// Book button: no drop-shadow.
537fn btn(text: impl Into<String>) -> widget::Widget { widget::button(text).shadow(false) }
538
539/// Build the yog-ui widget tree + bg sprites + overlay commands for the current book state.
540/// `bx/by/bw/bh` are the screen-space book rect (same values used to blit the bg texture).
541fn build_ui(book: &Book, state: &BookViewState, theme: &BookTheme,
542             recipes: &HashMap<String, RecipeVis>,
543             sw: f32, sh: f32,
544             bx: f32, by: f32, bw: f32, bh: f32) -> (UiRoot, Vec<BgSprite>, Vec<OverlayCmd>) {
545    let _ = (sw, sh);
546    let mut bg_sprites: Vec<BgSprite> = Vec::new();
547    let mut overlays:   Vec<OverlayCmd> = Vec::new();
548
549    // Scale from Patchouli's fixed BK_W×BK_H coordinate space → actual bw×bh.
550    let sx = bw / BK_W;
551    let sy = bh / BK_H;
552
553    // In book-local: left page starts at x=LEFT_X, right at RIGHT_X, both y=TOP_PAD.
554    let pw = PAGE_W * sx;
555    let ph = PAGE_H * sy;
556    let spine_gap = (RIGHT_X - LEFT_X - PAGE_W) * sx; // ≈10px scaled
557
558    // Content x/y in screen space (for SVG overlay positioning).
559    let lx = bx + LEFT_X  * sx;
560    let rx = bx + RIGHT_X * sx;
561    let py = by + TOP_PAD  * sy;
562
563    // Separator x offset within a page: centered = PAGE_W/2 - SEP_W/2 = 58-55 = 3.
564    let sep_cx = (PAGE_W / 2.0 - SEP_W / 2.0) * sx; // = 3*sx
565
566    if state.at_home {
567        // Nameplate banner: book-local (-8, 12) from LEFT_PAGE_X, size 140×31.
568        bg_sprites.push(BgSprite {
569            sheet: SpriteSheet::Book,
570            u: 0.0, v: 180.0, uw: 140.0, uh: 31.0,
571            x: bx + (LEFT_X - 8.0) * sx,
572            y: by + 12.0 * sy,
573            w: 140.0 * sx,
574            h: 31.0 * sy,
575        });
576        // Separator below "Categories" header on right page (y=12 page-local), centered.
577        bg_sprites.push(BgSprite {
578            sheet: SpriteSheet::Book,
579            u: SEP_U, v: SEP_V, uw: SEP_W, uh: SEP_H,
580            x: rx + sep_cx,
581            y: by + (TOP_PAD + 12.0) * sy,
582            w: SEP_W * sx,
583            h: (SEP_H * sy).max(1.0),
584        });
585    } else {
586        // Entry view: separator on right page below category name (y=12 page-local).
587        bg_sprites.push(BgSprite {
588            sheet: SpriteSheet::Book,
589            u: SEP_U, v: SEP_V, uw: SEP_W, uh: SEP_H,
590            x: rx + sep_cx,
591            y: by + (TOP_PAD + 12.0) * sy,
592            w: SEP_W * sx,
593            h: (SEP_H * sy).max(1.0),
594        });
595    }
596
597    let (left_page, right_page) = if state.at_home {
598        let l = build_landing_left(book, theme, pw, ph, lx, py, sx, sy, bx, by, &mut overlays);
599        let r = build_categories_right(book, state, theme, pw, ph, rx, py, sx, sy, &mut overlays);
600        (l, r)
601    } else {
602        let l = build_entry_left(book, state, theme, recipes, pw, ph, lx, py, sx, sy, sep_cx, &mut bg_sprites, &mut overlays);
603        let r = build_entries_right(book, state, theme, pw, ph, rx, py, sx, sy, &mut overlays);
604        (l, r)
605    };
606
607    // Root row spans the full book width.
608    // padding-left = LEFT_X*sx offsets both pages from the left cover edge.
609    // padding-top  = TOP_PAD*sy offsets from the header banner.
610    // spine_gap spacer sits between the two pages.
611    // gap must be 0: analytic overlay positions assume pages sit exactly at
612    // LEFT_X / RIGHT_X — any flex gap would shift widgets (and their focus
613    // highlights) away from the absolutely-positioned icons.
614    let root_widget = widget::panel(FlexDir::Row)
615        .w(bw).h(bh)
616        .gap(0.0)
617        .padding(TOP_PAD * sy, 0.0, 0.0, LEFT_X * sx)
618        .child(left_page)
619        .child(widget::spacer().w(spine_gap))  // spine gap
620        .child(right_page);
621
622    // Outer shell: positions the book on screen via a full-screen transparent Row.
623    // We use a Column + Row arrangement to apply top/left offsets without margin support.
624    let outer = widget::panel(FlexDir::Column)
625        .w(bw).h(bh)
626        .child(root_widget);
627
628    // Screen-level container: positions book at (bx, by).
629    // bx offset via padding, by offset via padding-top.
630    // Note: layout computes from (0,0), so we pass bx/by into the UiRoot manually
631    // by wrapping in a full-screen panel with the right padding.
632    let screen_root = widget::panel(FlexDir::Row)
633        .padding(by, 0.0, 0.0, bx)
634        .child(outer);
635
636    let ui = UiRoot::new(&book.id, screen_root);
637    (ui, bg_sprites, overlays)
638}
639
640// ── Left page: landing (home view) ───────────────────────────────────────────
641
642fn build_landing_left(
643    book: &Book, theme: &BookTheme,
644    page_w: f32, page_h: f32,
645    _lx: f32, _py: f32,
646    sx: f32, sy: f32,
647    bx: f32, by: f32,
648    overlays: &mut Vec<OverlayCmd>,
649) -> widget::Widget {
650    // Patchouli: title at book-local (13, 16), subtitle at (24, 24).
651    // These overlap the nameplate sprite which is above/within the page top.
652    overlays.push(OverlayCmd::McText {
653        text: book.name.clone(),
654        x: bx + 13.0 * sx,
655        y: by + 16.0 * sy,
656        color: theme.nameplate,
657    });
658    if let Some(author) = &book.author {
659        overlays.push(OverlayCmd::McText {
660            text: format!("by {}", author),
661            x: bx + 24.0 * sx,
662            y: by + 24.0 * sy,
663            color: theme.nameplate,
664        });
665    }
666
667    // Widget content: landing text + entry count.
668    // Landing text starts at page-local y=25 (= book-local y=43).
669    // We push it down with top padding = 25*sy.
670    let mut col = widget::panel(FlexDir::Column)
671        .w(page_w).h(page_h)
672        .padding(25.0 * sy, 6.0, 4.0, 4.0)
673        .gap(0.0);
674
675    for para in book.landing_text.split('\n') {
676        if para.is_empty() {
677            col = col.child(widget::spacer().h(4.0 * sy));
678        } else {
679            col = col.child(lbl(para).color(theme.text));
680        }
681    }
682
683    let total = book.entries.len();
684    col = col.child(widget::spacer().flex(1.0));
685    col = col.child(
686        lbl(format!("{} entries", total))
687            .color(theme.nav).h(9.0 * sy).align(Align::Center)
688    );
689    col
690}
691
692// ── Right page: category list (home view) ────────────────────────────────────
693
694fn build_categories_right(
695    book: &Book, state: &BookViewState, theme: &BookTheme,
696    page_w: f32, page_h: f32,
697    rx: f32, py: f32,
698    sx: f32, sy: f32,
699    overlays: &mut Vec<OverlayCmd>,
700) -> widget::Widget {
701    // Patchouli right-page layout (landing):
702    //   "Categories" header at page-local y=0, centered
703    //   Separator at page-local y=12 (drawn as bg sprite, not here)
704    //   4-column 24×24 icon grid starting at page-local (10, 25)
705    let cell_w = 24.0 * sx;
706    let cell_h = 24.0 * sy;
707    let grid_pad_left = 10.0 * sx;
708    let header_h = 9.0 * sy;
709
710    let mut col = widget::panel(FlexDir::Column)
711        .w(page_w).h(page_h)
712        .padding(0.0, 4.0, 4.0, 0.0)
713        .gap(0.0);
714
715    col = col.child(
716        lbl("Categories").color(theme.divider).h(header_h).align(Align::Center).no_wrap()
717    );
718    // Gap covering the separator region up to grid start (page-local y=25).
719    col = col.child(widget::spacer().h(25.0 * sy - header_h));
720
721    // 4-column icon grid; icons drawn as GL overlays at exact Patchouli positions.
722    let cats = &book.categories;
723    let icon_s = 16.0 * sx.min(sy);
724    let mut row_i = 0usize;
725    loop {
726        let row_start = row_i * 4;
727        if row_start >= cats.len() { break; }
728        let mut row = widget::panel(FlexDir::Row)
729            .h(cell_h)
730            .gap(0.0)
731            .padding(0.0, 0.0, 0.0, grid_pad_left);
732        for col_i in 0..4 {
733            let idx = row_start + col_i;
734            if idx >= cats.len() { break; }
735            let cat = &cats[idx];
736            let selected = !state.at_home && idx == state.cat;
737            let bg = if selected { theme.nav_selected_bg } else { 0 };
738
739            if let Some(icon_id) = &cat.icon {
740                push_icon(
741                    overlays, icon_id,
742                    rx + (10.0 + col_i as f32 * 24.0 + 4.0) * sx,
743                    py + (25.0 + row_i as f32 * 24.0 + 4.0) * sy,
744                    icon_s, icon_s,
745                );
746            }
747            let cell = widget::panel(FlexDir::Column)
748                .w(cell_w).h(cell_h).bg(bg)
749                .on_click(format!("cat:{}", idx))
750                .id(format!("book_cat_{}", idx));
751            row = row.child(cell);
752        }
753        col = col.child(row);
754        row_i += 1;
755    }
756    col
757}
758
759// ── Left page: entry content (entry view) ────────────────────────────────────
760
761fn build_entry_left(
762    book: &Book, state: &BookViewState, theme: &BookTheme,
763    recipes: &HashMap<String, RecipeVis>,
764    page_w: f32, page_h: f32,
765    ox: f32, oy: f32,
766    sx: f32, sy: f32,
767    sep_cx: f32,
768    bg_sprites: &mut Vec<BgSprite>,
769    overlays: &mut Vec<OverlayCmd>,
770) -> widget::Widget {
771    let entry      = state.current_entry(book);
772    let page       = entry.and_then(|e| e.pages.get(state.page));
773    let page_count = state.page_count(book);
774    let title_text = entry.map(|e| e.name.as_str()).unwrap_or("");
775
776    // Patchouli PageText page 0:
777    //   title centered at page-local (PAGE_W/2, 0)
778    //   separator at page-local (0, 12)  → centered ≡ sep_cx offset
779    //   text body at page-local (0, 22)
780    let title_h  = 9.0 * sy;
781    let sep_h_px = (SEP_H * sy).max(1.0);
782    let sep_y    = oy + 12.0 * sy;
783    let body_oy  = oy + 22.0 * sy;
784
785    bg_sprites.push(BgSprite {
786        sheet: SpriteSheet::Book,
787        u: SEP_U, v: SEP_V, uw: SEP_W, uh: SEP_H,
788        x: ox + sep_cx, y: sep_y,
789        w: SEP_W * sx, h: sep_h_px,
790    });
791
792    let page_label = format!("{}/{}", state.page + 1, page_count);
793    let nav = widget::panel(FlexDir::Row)
794        .h(12.0 * sy).gap(4.0)
795        .padding(0.0, 2.0, 0.0, 2.0)
796        .child(btn("◀").w(12.0 * sx).h(12.0 * sy).color(theme.nav)
797            .on_click("prev_page").id("prev_page"))
798        .child(lbl(&page_label).color(theme.nav).flex(1.0).align(Align::Center))
799        .child(btn("⌂").w(12.0 * sx).h(12.0 * sy).color(theme.nav)
800            .on_click("home").id("book_home"))
801        .child(btn("▶").w(12.0 * sx).h(12.0 * sy).color(theme.nav)
802            .on_click("next_page").id("next_page"));
803
804    let page_body = build_page(page, state.page, theme, recipes, bg_sprites, overlays, ox, body_oy, sx, sy);
805
806    widget::panel(FlexDir::Column)
807        .w(page_w).h(page_h)
808        .padding(0.0, 6.0, 4.0, 4.0)
809        .gap(0.0)
810        .child(lbl(title_text).color(theme.title).h(title_h).align(Align::Center).no_wrap())
811        .child(widget::spacer().h((12.0 - 9.0) * sy))  // gap: title end → sep start
812        .child(widget::spacer().h(sep_h_px))             // height occupied by sep sprite
813        .child(widget::spacer().h((22.0 - 12.0 - SEP_H) * sy)) // gap: sep end → body
814        .child(page_body)
815        .child(nav)
816}
817
818// ── Right page: entry list for selected category (entry view) ─────────────────
819
820fn build_entries_right(
821    book: &Book, state: &BookViewState, theme: &BookTheme,
822    page_w: f32, page_h: f32, rx: f32, py: f32,
823    sx: f32, sy: f32,
824    overlays: &mut Vec<OverlayCmd>,
825) -> widget::Widget {
826    let entries  = state.entries_visible(book);
827    let cat_name = book.categories.get(state.cat).map(|c| c.name.as_str()).unwrap_or("Entries");
828    let spread_count = state.list_spread_count(book);
829
830    // Patchouli GuiBookEntryList layout (right page):
831    //   category name at page-local y=0, centered
832    //   separator at page-local y=12 (drawn as bg sprite in build_ui)
833    //   entries start at page-local y=20, each h=11
834    let header_h   = 9.0 * sy;
835    let row_h      = 11.0 * sy;
836    let icon_size  = 8.0 * sx.min(sy);
837
838    let mut col = widget::panel(FlexDir::Column)
839        .w(page_w).h(page_h)
840        .padding(0.0, 4.0, 4.0, 0.0)
841        .gap(0.0);
842
843    col = col.child(lbl(cat_name).color(theme.divider).h(header_h).align(Align::Center).no_wrap());
844    // sep region: y=9..15 (sep h=3), then gap to entries start at y=20
845    col = col.child(widget::spacer().h((12.0 - 9.0) * sy));    // gap to sep
846    col = col.child(widget::spacer().h((SEP_H * sy).max(1.0))); // sep height
847    col = col.child(widget::spacer().h((20.0 - 12.0 - SEP_H) * sy)); // gap to entries
848
849    let abs_start = state.list_spread_start();
850    for (i, entry) in entries.iter().enumerate() {
851        let abs_i    = abs_start + i;
852        let selected = abs_i == state.entry;
853        let bg    = if selected { theme.nav_selected_bg } else { 0 };
854        let color = if selected { theme.nav_selected } else { theme.nav };
855
856        // Icon at page-local (1, 20 + i*11 + 1), 8×8 (Patchouli renders entry
857        // icons at 0.5× scale).
858        if let Some(icon_id) = &entry.icon {
859            push_icon(
860                overlays, icon_id,
861                rx + 1.0 * sx,
862                py + (20.0 + i as f32 * 11.0 + 1.0) * sy,
863                icon_size, icon_size,
864            );
865        }
866
867        let mut row = widget::panel(FlexDir::Row)
868            .h(row_h).gap(2.0).bg(bg)
869            .on_click(format!("entry:{}", abs_i))
870            .id(format!("book_entry_{}", abs_i))
871            .padding(1.0, 2.0, 1.0, 2.0);
872
873        row = row.child(widget::spacer().w(icon_size + 2.0));
874        row = row.child(lbl(&entry.name).color(color).flex(1.0).no_wrap());
875        col = col.child(row);
876    }
877
878    if spread_count > 1 {
879        col = col.child(widget::spacer().flex(1.0));
880        let spread_label = format!("{}/{}", state.list_spread + 1, spread_count);
881        col = col.child(
882            widget::panel(FlexDir::Row).h(12.0 * sy).gap(2.0)
883                .child(btn("◀").w(12.0 * sx).h(12.0 * sy).color(theme.nav)
884                    .on_click("prev_list").id("prev_list"))
885                .child(lbl(&spread_label).color(theme.nav)
886                    .flex(1.0).align(Align::Center))
887                .child(btn("▶").w(12.0 * sx).h(12.0 * sy).color(theme.nav)
888                    .on_click("next_list").id("next_list"))
889        );
890    }
891    col
892}
893
894/// Draw a Patchouli-style crafting recipe: title, 100×62 grid background
895/// (UV 0,0 on the crafting sheet), 3×N ingredient icons, output item.
896/// `rx0`/`ry` are body-local recipe origin; `ox`/`oy` the body screen origin.
897#[allow(clippy::too_many_arguments)]
898fn draw_crafting_grid(
899    theme: &BookTheme,
900    bg_sprites: &mut Vec<BgSprite>,
901    overlays: &mut Vec<OverlayCmd>,
902    col: &mut widget::Widget,
903    grid: &[Option<String>], width: usize,
904    output: &str, count: u32, shapeless: bool,
905    ox: f32, oy: f32, rx0: f32, ry: f32, sx: f32, sy: f32,
906) {
907    let mut c = std::mem::replace(col, widget::panel(FlexDir::Column));
908    c = c.child(lbl(pretty_item_name(output)).color(theme.title)
909        .h(10.0 * sy).align(Align::Center));
910
911    // Grid background: blit at (rx0-2, ry-2) size 100×62, UV(0,0).
912    bg_sprites.push(BgSprite {
913        sheet: SpriteSheet::Crafting,
914        u: 0.0, v: 0.0, uw: 100.0, uh: 62.0,
915        x: ox + (rx0 - 2.0) * sx, y: oy + (ry - 2.0) * sy,
916        w: 100.0 * sx, h: 62.0 * sy,
917    });
918    // Shapeless marker: UV(0,64) 11×11 at (rx0+62, ry+2).
919    if shapeless {
920        bg_sprites.push(BgSprite {
921            sheet: SpriteSheet::Crafting,
922            u: 0.0, v: 64.0, uw: 11.0, uh: 11.0,
923            x: ox + (rx0 + 62.0) * sx, y: oy + (ry + 2.0) * sy,
924            w: 11.0 * sx, h: 11.0 * sy,
925        });
926    }
927
928    let icon_s = 16.0 * sx.min(sy);
929    let width = width.max(1);
930    for (i, slot) in grid.iter().enumerate() {
931        if let Some(item) = slot {
932            overlays.push(OverlayCmd::McItem {
933                item_id: item.clone(),
934                x: ox + (rx0 + 3.0 + (i % width) as f32 * 19.0) * sx,
935                y: oy + (ry  + 3.0 + (i / width) as f32 * 19.0) * sy,
936                w: icon_s, h: icon_s,
937            });
938        }
939    }
940    // Output item at (rx0+79, ry+22); stack count below it when > 1.
941    overlays.push(OverlayCmd::McItem {
942        item_id: output.to_owned(),
943        x: ox + (rx0 + 79.0) * sx, y: oy + (ry + 22.0) * sy,
944        w: icon_s, h: icon_s,
945    });
946    if count > 1 {
947        overlays.push(OverlayCmd::McText {
948            text: format!("x{}", count),
949            x: ox + (rx0 + 81.0) * sx, y: oy + (ry + 40.0) * sy,
950            color: theme.title,
951        });
952    }
953    // Patchouli's "toast symbol" under the output: the crafting table.
954    overlays.push(OverlayCmd::McItem {
955        item_id: "minecraft:crafting_table".into(),
956        x: ox + (rx0 + 79.0) * sx, y: oy + (ry + 41.0) * sy,
957        w: icon_s, h: icon_s,
958    });
959
960    // Spacer from title end (10) to below the grid (ry+62+4).
961    c = c.child(widget::spacer().h((ry + 62.0 + 4.0 - 10.0) * sy));
962    *col = c;
963}
964
965// ── Page content builder ──────────────────────────────────────────────────────
966
967/// Build the content widget for a single page.
968/// `ox/oy` = screen top-left of the page body area.
969/// `sx/sy` = book-local-to-screen scale factors.
970fn build_page(
971    page: Option<&BookPage>,
972    page_num: usize,
973    theme: &BookTheme,
974    recipes: &HashMap<String, RecipeVis>,
975    bg_sprites: &mut Vec<BgSprite>,
976    overlays: &mut Vec<OverlayCmd>,
977    ox: f32, oy: f32,
978    sx: f32, sy: f32,
979) -> widget::Widget {
980    let mut col = widget::panel(FlexDir::Column).flex(1.0).gap(4.0);
981
982    let Some(page) = page else {
983        return col.child(lbl("No entries yet.").color(theme.nav));
984    };
985
986    match page {
987        BookPage::Text { text, title } => {
988            // On non-first pages, show an optional section title + separator.
989            if page_num > 0 {
990                if let Some(t) = title {
991                    let sep_h_px = (SEP_H * sy).max(1.0);
992                    let title_h  = 9.0 * sy;
993                    col = col.child(lbl(t.as_str()).color(theme.title)
994                        .h(title_h).align(Align::Center).no_wrap());
995                    // sep at y=12 page-local, centered
996                    bg_sprites.push(BgSprite {
997                        sheet: SpriteSheet::Book,
998                        u: SEP_U, v: SEP_V, uw: SEP_W, uh: SEP_H,
999                        x: ox + (PAGE_W / 2.0 - SEP_W / 2.0) * sx,
1000                        y: oy + 12.0 * sy,
1001                        w: SEP_W * sx, h: sep_h_px,
1002                    });
1003                    col = col.child(widget::spacer().h((12.0 - 9.0) * sy));
1004                    col = col.child(widget::spacer().h(sep_h_px));
1005                    col = col.child(widget::spacer().h((22.0 - 12.0 - SEP_H) * sy));
1006                }
1007            }
1008            for para in text.split('\n') {
1009                col = col.child(lbl(para).color(theme.text));
1010            }
1011        }
1012
1013        BookPage::Spotlight { item, title, text } => {
1014            // Patchouli PageSpotlight (page-body-local coordinates):
1015            //   title at y=0 centered, item frame at (PAGE_W/2-33, 10) 66×26
1016            //   (UV 0,102 on the 128×256 crafting sheet), item at (PAGE_W/2-8, 15),
1017            //   text at y=40.
1018            bg_sprites.push(BgSprite {
1019                sheet: SpriteSheet::Crafting,
1020                u: 0.0, v: 102.0, uw: 66.0, uh: 26.0,
1021                x: ox + (PAGE_W / 2.0 - 33.0) * sx,
1022                y: oy + 10.0 * sy,
1023                w: 66.0 * sx, h: 26.0 * sy,
1024            });
1025
1026            let item_name = title.as_deref()
1027                .or(item.name.as_deref())
1028                .unwrap_or(item.id.as_str());
1029            col = col.child(lbl(item_name).color(theme.title)
1030                .h(10.0 * sy).align(Align::Center));
1031
1032            let icon_s = 16.0 * sx.min(sy);
1033            overlays.push(OverlayCmd::McItem {
1034                item_id: item.id.clone(),
1035                x: ox + (PAGE_W / 2.0 - 8.0) * sx,
1036                y: oy + 15.0 * sy,
1037                w: icon_s, h: icon_s,
1038            });
1039
1040            // Spacer from title end (y=10) to text start (y=40).
1041            col = col.child(widget::spacer().h(30.0 * sy));
1042
1043            if let Some(t) = text {
1044                col = col.child(lbl(t.as_str()).color(theme.text));
1045            }
1046        }
1047
1048        BookPage::Crafting { recipe_id, text } => {
1049            // Patchouli PageCrafting: recipe origin at page-local
1050            // (PAGE_W/2-49, 4); we place it in body-local coords below a title.
1051            let rx0 = PAGE_W / 2.0 - 49.0; // = 9
1052            let ry  = 12.0;                // body-local recipe y (title occupies 0..10)
1053            match recipes.get(recipe_id.as_str()) {
1054                Some(RecipeVis::Shaped { grid, width, output, count })  => {
1055                    draw_crafting_grid(theme, bg_sprites, overlays, &mut col,
1056                        grid, *width, output, *count, false, ox, oy, rx0, ry, sx, sy);
1057                }
1058                Some(RecipeVis::Shapeless { ingredients, output, count }) => {
1059                    let grid: Vec<Option<String>> =
1060                        ingredients.iter().cloned().map(Some).collect();
1061                    draw_crafting_grid(theme, bg_sprites, overlays, &mut col,
1062                        &grid, 3, output, *count, true, ox, oy, rx0, ry, sx, sy);
1063                }
1064                _ => {
1065                    col = col.child(lbl(format!("[Recipe: {}]", recipe_id))
1066                        .color(theme.nav));
1067                }
1068            }
1069            if let Some(t) = text {
1070                col = col.child(lbl(t.as_str()).color(theme.text));
1071            }
1072        }
1073
1074        BookPage::Smelting { recipe_id, text } => {
1075            // Patchouli PageSmelting: bg UV(11,71) 96×24, input at +4,+4,
1076            // output at +76,+4.
1077            let rx0 = PAGE_W / 2.0 - 49.0;
1078            let ry  = 12.0;
1079            match recipes.get(recipe_id.as_str()) {
1080                Some(RecipeVis::Smelting { input, output }) => {
1081                    col = col.child(lbl(pretty_item_name(output)).color(theme.title)
1082                        .h(10.0 * sy).align(Align::Center));
1083                    bg_sprites.push(BgSprite {
1084                        sheet: SpriteSheet::Crafting,
1085                        u: 11.0, v: 71.0, uw: 96.0, uh: 24.0,
1086                        x: ox + rx0 * sx, y: oy + ry * sy,
1087                        w: 96.0 * sx, h: 24.0 * sy,
1088                    });
1089                    let icon_s = 16.0 * sx.min(sy);
1090                    overlays.push(OverlayCmd::McItem {
1091                        item_id: input.clone(),
1092                        x: ox + (rx0 + 4.0) * sx, y: oy + (ry + 4.0) * sy,
1093                        w: icon_s, h: icon_s,
1094                    });
1095                    // Center: the furnace itself (Patchouli's "toast symbol").
1096                    overlays.push(OverlayCmd::McItem {
1097                        item_id: "minecraft:furnace".into(),
1098                        x: ox + (rx0 + 40.0) * sx, y: oy + (ry + 4.0) * sy,
1099                        w: icon_s, h: icon_s,
1100                    });
1101                    overlays.push(OverlayCmd::McItem {
1102                        item_id: output.clone(),
1103                        x: ox + (rx0 + 76.0) * sx, y: oy + (ry + 4.0) * sy,
1104                        w: icon_s, h: icon_s,
1105                    });
1106                    // Spacer from title end (10) to below the furnace strip (ry+24+4).
1107                    col = col.child(widget::spacer().h((ry + 24.0 + 4.0 - 10.0) * sy));
1108                }
1109                _ => {
1110                    col = col.child(lbl(format!("[Recipe: {}]", recipe_id))
1111                        .color(theme.nav));
1112                }
1113            }
1114            if let Some(t) = text {
1115                col = col.child(lbl(t.as_str()).color(theme.text));
1116            }
1117        }
1118
1119        BookPage::Image { texture, title, text, .. } => {
1120            if let Some(t) = title {
1121                col = col.child(lbl(t.as_str()).color(theme.title));
1122            }
1123            col = col.child(widget::mc_image(texture, 80.0, 80.0));
1124            if let Some(t) = text {
1125                col = col.child(lbl(t.as_str()).color(theme.text));
1126            }
1127        }
1128
1129        BookPage::Svg { data, title, text } => {
1130            if let Some(t) = title {
1131                col = col.child(lbl(t.as_str()).color(theme.title));
1132            }
1133            overlays.push(OverlayCmd::Svg { data: data.clone(), x: ox, y: oy, w: 64.0, h: 64.0 });
1134            col = col.child(widget::spacer().h(68.0));
1135            if let Some(t) = text {
1136                col = col.child(lbl(t.as_str()).color(theme.text));
1137            }
1138        }
1139
1140        BookPage::CustomText { text, font, color } => {
1141            overlays.push(OverlayCmd::Text {
1142                text: text.clone(), font: font.clone(), x: ox, y: oy, color: *color,
1143            });
1144            col = col.child(widget::spacer().h(font.size_px * 1.5));
1145        }
1146
1147        BookPage::Relations { entries, text } => {
1148            if let Some(t) = text {
1149                col = col.child(lbl(t.as_str()).color(theme.text));
1150            }
1151            col = col.child(lbl("See also:").color(theme.title));
1152            for e in entries {
1153                col = col.child(lbl(format!("• {}", e)).color(theme.nav));
1154            }
1155        }
1156
1157        BookPage::Entity { entity_type, name, text } => {
1158            let display = name.as_deref().unwrap_or(entity_type.as_str());
1159            col = col.child(lbl(display).color(theme.title));
1160            if let Some(t) = text {
1161                col = col.child(lbl(t.as_str()).color(theme.text));
1162            }
1163        }
1164
1165        BookPage::Pattern { op_id, input, output, text, .. } => {
1166            col = col.child(lbl(op_id.as_str()).color(theme.title));
1167            col = col.child(
1168                lbl(format!("{} → {}", input, output)).color(theme.nav)
1169            );
1170            if !text.is_empty() {
1171                col = col.child(lbl(text.as_str()).color(theme.text));
1172            }
1173        }
1174
1175        BookPage::Empty => {}
1176    }
1177
1178    col
1179}