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::{
6    core::{blend, DataType, DrawMode},
7    gl, GfxContext,
8};
9use yog_ui::{widget, Align, FlexDir, UiRoot};
10
11use crate::font::{BookFontRegistry, FontAtlas};
12use crate::state::BookViewState;
13use crate::svg;
14use crate::theme::BookTheme;
15use crate::{Book, BookPage};
16
17// Patchouli-compatible book texture layout (512×256 sprite sheet).
18// UV coordinates are in pixels on a 512×256 sheet.
19// The full open-book background occupies 272×180 at UV (0,0).
20pub const BOOK_TEX_W: f32 = 512.0;
21pub const BOOK_TEX_H: f32 = 256.0;
22// Open-book dimensions in texture-space
23pub const BK_W: f32 = 272.0;
24pub const BK_H: f32 = 180.0;
25// Per-page dimensions and offsets (in book-local coords)
26pub const PAGE_W: f32 = 116.0;
27pub const PAGE_H: f32 = 156.0;
28pub const TOP_PAD: f32 = 18.0; // vertical space above page text area
29pub const LEFT_X: f32 = 15.0; // left page X inside book
30pub const RIGHT_X: f32 = 141.0; // right page X inside book
31                                // Separator strip UV origin on the sprite sheet
32pub 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// ── Vertex for the custom 2D shader (pos + uv + color) ───────────────────────
38
39#[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    // two triangles: 0,1,2  1,3,2
116    verts.extend_from_slice(&[p[0], p[1], p[2], p[1], p[3], p[2]]);
117}
118
119// ── GLSL shaders ─────────────────────────────────────────────────────────────
120
121const 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
156// ── Embedded textures ─────────────────────────────────────────────────────────
157
158static BOOK_PNG: &[u8] = include_bytes!("../assets/book_brown.png");
159static CRAFTING_PNG: &[u8] = include_bytes!("../assets/crafting.png");
160
161fn decode_png(data: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
162    use png::Decoder;
163    let decoder = Decoder::new(std::io::Cursor::new(data));
164    let mut reader = decoder.read_info().ok()?;
165    let mut buf = vec![0u8; reader.output_buffer_size().map(|s| s as usize).unwrap_or(0)];
166    let info = reader.next_frame(&mut buf).ok()?;
167    let rgba = match info.color_type {
168        png::ColorType::Rgba => buf[..info.buffer_size()].to_vec(),
169        png::ColorType::Rgb => {
170            let rgb = &buf[..info.buffer_size()];
171            let mut out = Vec::with_capacity(rgb.len() / 3 * 4);
172            for px in rgb.chunks(3) {
173                out.extend_from_slice(px);
174                out.push(255);
175            }
176            out
177        }
178        _ => return None,
179    };
180    Some((rgba, info.width, info.height))
181}
182
183// ── GL resource cache ─────────────────────────────────────────────────────────
184
185struct 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; // 8 × f32
202        vao.attrib(ctx, &vbo, 0, 2, DataType::F32, false, STRIDE, 0); // pos
203        vao.attrib(ctx, &vbo, 1, 2, DataType::F32, false, STRIDE, 8); // uv
204        vao.attrib(ctx, &vbo, 2, 4, DataType::F32, false, STRIDE, 16); // col
205
206        let load = |data: &[u8]| {
207            decode_png(data).map(|(rgba, w, h)| {
208                let tex = ctx.create_texture_rgba(w, h, &rgba, true);
209                (tex, w, h)
210            })
211        };
212        let book_tex = load(BOOK_PNG);
213        let crafting_tex = load(CRAFTING_PNG);
214
215        Some(BookGl {
216            prog,
217            vao,
218            vbo,
219            book_tex,
220            crafting_tex,
221            svg_tex: HashMap::new(),
222            font_atlas: HashMap::new(),
223        })
224    }
225
226    fn svg_tex(
227        &mut self,
228        ctx: &GfxContext,
229        hash: u64,
230        data: &str,
231        w: u32,
232        h: u32,
233    ) -> Option<&(gl::Texture, u32, u32)> {
234        if !self.svg_tex.contains_key(&hash) {
235            let pixels = svg::rasterize(data, w, h)?;
236            let tex = ctx.create_texture_rgba(w, h, &pixels, true);
237            self.svg_tex.insert(hash, (tex, w, h));
238        }
239        self.svg_tex.get(&hash)
240    }
241
242    fn flush(&self, ctx: &GfxContext, verts: &[Vert]) {
243        if verts.is_empty() {
244            return;
245        }
246        unsafe {
247            self.vbo.upload(ctx, verts, true);
248        }
249        ctx.draw_arrays(
250            &self.vao,
251            &self.prog,
252            DrawMode::Triangles,
253            0,
254            verts.len() as u32,
255        );
256    }
257}
258
259// ── Draw helpers ──────────────────────────────────────────────────────────────
260
261impl 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    /// Blit a subrect of a sprite sheet at an arbitrary screen position.
270    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    /// Blit the open-book background from the embedded book texture.
299    fn draw_book_bg(&self, ctx: &GfxContext, bx: f32, by: f32, bw: f32, bh: f32) {
300        let Some((tex, tw, th)) = &self.book_tex else {
301            return;
302        };
303        let su = BK_W / *tw as f32;
304        let sv = BK_H / *th as f32;
305        self.prog.uniform_1i(ctx, "uMode", 1);
306        ctx.bind_texture(0, tex);
307        let mut v = Vec::with_capacity(6);
308        quad(&mut v, bx, by, bw, bh, 0.0, 0.0, su, sv, 0xFF_FFFFFF);
309        self.flush(ctx, &v);
310    }
311
312    fn draw_svg(&mut self, ctx: &GfxContext, data: &str, x: f32, y: f32, w: f32, h: f32) {
313        let hash = svg::svg_hash(data);
314        let tw = w as u32;
315        let th = h as u32;
316        if let Some(&(tex, _, _)) = self.svg_tex(ctx, hash, data, tw, th) {
317            self.prog.uniform_1i(ctx, "uMode", 1);
318            ctx.bind_texture(0, &tex);
319            let mut v = Vec::with_capacity(6);
320            quad(&mut v, x, y, w, h, 0.0, 0.0, 1.0, 1.0, 0xFF_FFFFFF);
321            self.flush(ctx, &v);
322        }
323    }
324
325    fn draw_text_custom(
326        &mut self,
327        ctx: &GfxContext,
328        ttf: &[u8],
329        size_px: f32,
330        text: &str,
331        mut x: f32,
332        y: f32,
333        color: u32,
334    ) {
335        let hash = font_hash(ttf);
336        if !self.font_atlas.contains_key(&hash) {
337            if let Some(atlas) = FontAtlas::build(ttf, size_px) {
338                let tex = ctx.create_texture_rgba(
339                    atlas.atlas_size,
340                    atlas.atlas_size,
341                    &atlas.pixels,
342                    true,
343                );
344                self.font_atlas.insert(hash, (tex, atlas));
345            }
346        }
347        // Get raw pointers so the borrow of self.font_atlas ends before we call
348        // self.prog / self.flush, which also borrow fields of self.
349        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            // SAFETY: we hold &mut self; font_atlas is not mutated below.
355            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// ── Background sprite (texture blit, drawn before yog-ui) ────────────────────
395
396#[derive(Clone, Copy, PartialEq)]
397pub(crate) enum SpriteSheet {
398    Book,
399    Crafting,
400}
401
402/// A subrect blit from one of the embedded sprite sheets, drawn before yog-ui.
403#[derive(Clone)]
404pub(crate) struct BgSprite {
405    pub sheet: SpriteSheet,
406    /// Source rect in pixels on the sheet.
407    pub u: f32,
408    pub v: f32,
409    pub uw: f32,
410    pub uh: f32,
411    /// Destination on screen.
412    pub x: f32,
413    pub y: f32,
414    pub w: f32,
415    pub h: f32,
416}
417
418// ── Pending overlay draw commands ─────────────────────────────────────────────
419
420#[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    /// MC default-font text rendered via draw2d (e.g. nameplate title/subtitle).
437    McText {
438        text: String,
439        x: f32,
440        y: f32,
441        color: u32,
442    },
443    /// Item icon rendered through our GL pipeline from an MC-managed texture.
444    McItem {
445        item_id: String,
446        x: f32,
447        y: f32,
448        w: f32,
449        h: f32,
450    },
451    /// Flat texture icon blitted as-is (16×16), for icon strings that name a
452    /// texture resource directly rather than an item — matches Patchouli's
453    /// `BookIcon.from`: any icon string ending in `.png` is a raw texture,
454    /// everything else is parsed as an item stack.
455    McTexture {
456        path: String,
457        x: f32,
458        y: f32,
459        w: f32,
460        h: f32,
461    },
462}
463
464/// Push a book-author-supplied icon (category/entry icon field) as the right
465/// overlay kind. Matches Patchouli's `BookIcon.from(String)`: `.png` → raw
466/// texture blit, anything else → item stack render.
467fn 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// ── Recipe visuals (parsed from registered recipe JSON) ───────────────────────
488
489#[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
566/// Normalize icon ids like "yog:item/ruby" / "yog:block/x" to registry item
567/// ids ("yog:ruby") accepted by the MC item renderer.
568fn 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
576/// "yog:ruby_block" → "Ruby Block" (fallback display name from an item id).
577fn 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
591// ── BookRenderer ──────────────────────────────────────────────────────────────
592
593pub 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    /// Register a custom TTF/OTF font for use in `BookPage::CustomText`.
634    pub fn register_font(&mut self, id: impl Into<String>, ttf: Vec<u8>) {
635        self.fonts.register(id, ttf);
636    }
637
638    /// Register a recipe (vanilla recipe JSON) so Crafting/Smelting pages can
639    /// render it visually. Unknown recipe types are ignored.
640    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        // Lazy GL init.
649        if self.gl.is_none() {
650            self.gl = BookGl::init(ctx);
651        }
652
653        // Render at Patchouli-native size (272×180 GUI pixels). Scale DOWN only
654        // if the screen is smaller; never scale up (MC font is designed for 1×).
655        let scale = ((sw - 16.0) / BK_W)
656            .min((sh - 16.0) / BK_H)
657            .min(1.0)
658            .max(0.4);
659        let bw = BK_W * scale;
660        let bh = BK_H * scale;
661        let bx = (sw - bw) / 2.0;
662        let by = (sh - bh) / 2.0;
663
664        // Rebuild widget tree if dirty or screen resized.
665        if self.dirty || sw != self.last_sw || sh != self.last_sh {
666            let (root, bg_sprites, overlays) = build_ui(
667                &self.book,
668                &self.state,
669                &self.theme,
670                &self.recipes,
671                sw,
672                sh,
673                bx,
674                by,
675                bw,
676                bh,
677            );
678            self.ui = Some(root);
679            self.bg_sprites = bg_sprites;
680            self.overlays = overlays;
681            self.dirty = false;
682            self.last_sw = sw;
683            self.last_sh = sh;
684        }
685
686        // 1. Draw book background texture first.
687        if let Some(gl) = &mut self.gl {
688            gl.begin_frame(ctx, sw, sh);
689            gl.draw_book_bg(ctx, bx, by, bw, bh);
690        }
691
692        // 1b. Background sprites (nameplate banner, separator lines) from book texture.
693        if let Some(gl) = &mut self.gl {
694            for spr in &self.bg_sprites {
695                gl.draw_book_sprite(ctx, spr);
696            }
697        }
698
699        // 2. yog-ui: text, buttons, icons (transparent BG — texture is already there).
700        if let Some(ui) = &mut self.ui {
701            if ui.needs_layout {
702                ui.layout(sw, sh);
703            }
704            ui.render(ctx);
705        }
706
707        // 3. Custom GL overlays (SVG icons, custom font text) — raw GL first.
708        if let Some(gl) = &mut self.gl {
709            gl.begin_frame(ctx, sw, sh);
710            for ov in self.overlays.clone() {
711                match ov {
712                    OverlayCmd::Svg { data, x, y, w, h } => gl.draw_svg(ctx, &data, x, y, w, h),
713                    OverlayCmd::Text {
714                        text,
715                        font,
716                        x,
717                        y,
718                        color,
719                    } => {
720                        if let Some(ttf) = self.fonts.get(&font.font_id) {
721                            gl.draw_text_custom(ctx, ttf, font.size_px, &text, x, y, color);
722                        }
723                    }
724                    OverlayCmd::McText { .. }
725                    | OverlayCmd::McItem { .. }
726                    | OverlayCmd::McTexture { .. } => {}
727                }
728            }
729        }
730
731        // 4. MC-pipeline overlays (item stacks, MC-font text) — after all raw
732        //    GL, because item rendering resyncs and mutates MC's GL state.
733        for ov in self.overlays.clone() {
734            match ov {
735                OverlayCmd::McItem {
736                    item_id,
737                    x,
738                    y,
739                    w,
740                    h,
741                } => {
742                    ctx.draw2d()
743                        .item(&normalize_item_id(&item_id), x, y, w.min(h));
744                }
745                OverlayCmd::McText { text, x, y, color } => {
746                    ctx.draw2d().text(&text, x, y, color, false);
747                }
748                OverlayCmd::McTexture { path, x, y, w, h } => {
749                    // Standalone 16×16 icon texture, not an atlas region —
750                    // blit it whole (u0=v0=0, tw/th = the icon's own size).
751                    ctx.draw2d().mc_texture(&path, x, y, 0.0, 0.0, w, h, w, h);
752                }
753                _ => {}
754            }
755        }
756    }
757}
758
759// ── UI builder ────────────────────────────────────────────────────────────────
760
761/// Book label: MC font, no drop-shadow (Patchouli draws book text shadowless).
762fn lbl(text: impl Into<String>) -> widget::Widget {
763    widget::label(text).shadow(false)
764}
765/// Book button: no drop-shadow.
766fn btn(text: impl Into<String>) -> widget::Widget {
767    widget::button(text).shadow(false)
768}
769
770/// Build the yog-ui widget tree + bg sprites + overlay commands for the current book state.
771/// `bx/by/bw/bh` are the screen-space book rect (same values used to blit the bg texture).
772fn build_ui(
773    book: &Book,
774    state: &BookViewState,
775    theme: &BookTheme,
776    recipes: &HashMap<String, RecipeVis>,
777    sw: f32,
778    sh: f32,
779    bx: f32,
780    by: f32,
781    bw: f32,
782    bh: f32,
783) -> (UiRoot, Vec<BgSprite>, Vec<OverlayCmd>) {
784    let _ = (sw, sh);
785    let mut bg_sprites: Vec<BgSprite> = Vec::new();
786    let mut overlays: Vec<OverlayCmd> = Vec::new();
787
788    // Scale from Patchouli's fixed BK_W×BK_H coordinate space → actual bw×bh.
789    let sx = bw / BK_W;
790    let sy = bh / BK_H;
791
792    // In book-local: left page starts at x=LEFT_X, right at RIGHT_X, both y=TOP_PAD.
793    let pw = PAGE_W * sx;
794    let ph = PAGE_H * sy;
795    let spine_gap = (RIGHT_X - LEFT_X - PAGE_W) * sx; // ≈10px scaled
796
797    // Content x/y in screen space (for SVG overlay positioning).
798    let lx = bx + LEFT_X * sx;
799    let rx = bx + RIGHT_X * sx;
800    let py = by + TOP_PAD * sy;
801
802    // Separator x offset within a page: centered = PAGE_W/2 - SEP_W/2 = 58-55 = 3.
803    let sep_cx = (PAGE_W / 2.0 - SEP_W / 2.0) * sx; // = 3*sx
804
805    if state.at_home {
806        // Nameplate banner: book-local (-8, 12) from LEFT_PAGE_X, size 140×31.
807        bg_sprites.push(BgSprite {
808            sheet: SpriteSheet::Book,
809            u: 0.0,
810            v: 180.0,
811            uw: 140.0,
812            uh: 31.0,
813            x: bx + (LEFT_X - 8.0) * sx,
814            y: by + 12.0 * sy,
815            w: 140.0 * sx,
816            h: 31.0 * sy,
817        });
818        // Separator below "Categories" header on right page (y=12 page-local), centered.
819        bg_sprites.push(BgSprite {
820            sheet: SpriteSheet::Book,
821            u: SEP_U,
822            v: SEP_V,
823            uw: SEP_W,
824            uh: SEP_H,
825            x: rx + sep_cx,
826            y: by + (TOP_PAD + 12.0) * sy,
827            w: SEP_W * sx,
828            h: (SEP_H * sy).max(1.0),
829        });
830    } else {
831        // Entry view: separator on right page below category name (y=12 page-local).
832        bg_sprites.push(BgSprite {
833            sheet: SpriteSheet::Book,
834            u: SEP_U,
835            v: SEP_V,
836            uw: SEP_W,
837            uh: SEP_H,
838            x: rx + sep_cx,
839            y: by + (TOP_PAD + 12.0) * sy,
840            w: SEP_W * sx,
841            h: (SEP_H * sy).max(1.0),
842        });
843    }
844
845    let (left_page, right_page) = if state.at_home {
846        let l = build_landing_left(book, theme, pw, ph, lx, py, sx, sy, bx, by, &mut overlays);
847        let r = build_categories_right(book, state, theme, pw, ph, rx, py, sx, sy, &mut overlays);
848        (l, r)
849    } else {
850        let l = build_entry_left(
851            book,
852            state,
853            theme,
854            recipes,
855            pw,
856            ph,
857            lx,
858            py,
859            sx,
860            sy,
861            sep_cx,
862            &mut bg_sprites,
863            &mut overlays,
864        );
865        let r = build_entries_right(book, state, theme, pw, ph, rx, py, sx, sy, &mut overlays);
866        (l, r)
867    };
868
869    // Root row spans the full book width.
870    // padding-left = LEFT_X*sx offsets both pages from the left cover edge.
871    // padding-top  = TOP_PAD*sy offsets from the header banner.
872    // spine_gap spacer sits between the two pages.
873    // gap must be 0: analytic overlay positions assume pages sit exactly at
874    // LEFT_X / RIGHT_X — any flex gap would shift widgets (and their focus
875    // highlights) away from the absolutely-positioned icons.
876    let root_widget = widget::panel(FlexDir::Row)
877        .w(bw)
878        .h(bh)
879        .gap(0.0)
880        .padding(TOP_PAD * sy, 0.0, 0.0, LEFT_X * sx)
881        .child(left_page)
882        .child(widget::spacer().w(spine_gap)) // spine gap
883        .child(right_page);
884
885    // Outer shell: positions the book on screen via a full-screen transparent Row.
886    // We use a Column + Row arrangement to apply top/left offsets without margin support.
887    let outer = widget::panel(FlexDir::Column)
888        .w(bw)
889        .h(bh)
890        .child(root_widget);
891
892    // Screen-level container: positions book at (bx, by).
893    // bx offset via padding, by offset via padding-top.
894    // Note: layout computes from (0,0), so we pass bx/by into the UiRoot manually
895    // by wrapping in a full-screen panel with the right padding.
896    let screen_root = widget::panel(FlexDir::Row)
897        .padding(by, 0.0, 0.0, bx)
898        .child(outer);
899
900    let ui = UiRoot::new(&book.id, screen_root);
901    (ui, bg_sprites, overlays)
902}
903
904// ── Left page: landing (home view) ───────────────────────────────────────────
905
906fn build_landing_left(
907    book: &Book,
908    theme: &BookTheme,
909    page_w: f32,
910    page_h: f32,
911    _lx: f32,
912    _py: f32,
913    sx: f32,
914    sy: f32,
915    bx: f32,
916    by: f32,
917    overlays: &mut Vec<OverlayCmd>,
918) -> widget::Widget {
919    // Patchouli: title at book-local (13, 16), subtitle at (24, 24).
920    // These overlap the nameplate sprite which is above/within the page top.
921    overlays.push(OverlayCmd::McText {
922        text: book.name.clone(),
923        x: bx + 13.0 * sx,
924        y: by + 16.0 * sy,
925        color: theme.nameplate,
926    });
927    if let Some(author) = &book.author {
928        overlays.push(OverlayCmd::McText {
929            text: format!("by {}", author),
930            x: bx + 24.0 * sx,
931            y: by + 24.0 * sy,
932            color: theme.nameplate,
933        });
934    }
935
936    // Widget content: landing text + entry count.
937    // Landing text starts at page-local y=25 (= book-local y=43).
938    // We push it down with top padding = 25*sy.
939    let mut col = widget::panel(FlexDir::Column)
940        .w(page_w)
941        .h(page_h)
942        .padding(25.0 * sy, 6.0, 4.0, 4.0)
943        .gap(0.0);
944
945    for para in book.landing_text.split('\n') {
946        if para.is_empty() {
947            col = col.child(widget::spacer().h(4.0 * sy));
948        } else {
949            col = col.child(lbl(para).color(theme.text));
950        }
951    }
952
953    let total = book.entries.len();
954    col = col.child(widget::spacer().flex(1.0));
955    col = col.child(
956        lbl(format!("{} entries", total))
957            .color(theme.nav)
958            .h(9.0 * sy)
959            .align(Align::Center),
960    );
961    col
962}
963
964// ── Right page: category list (home view) ────────────────────────────────────
965
966fn build_categories_right(
967    book: &Book,
968    state: &BookViewState,
969    theme: &BookTheme,
970    page_w: f32,
971    page_h: f32,
972    rx: f32,
973    py: f32,
974    sx: f32,
975    sy: f32,
976    overlays: &mut Vec<OverlayCmd>,
977) -> widget::Widget {
978    // Patchouli right-page layout (landing):
979    //   "Categories" header at page-local y=0, centered
980    //   Separator at page-local y=12 (drawn as bg sprite, not here)
981    //   4-column 24×24 icon grid starting at page-local (10, 25)
982    let cell_w = 24.0 * sx;
983    let cell_h = 24.0 * sy;
984    let grid_pad_left = 10.0 * sx;
985    let header_h = 9.0 * sy;
986
987    let mut col = widget::panel(FlexDir::Column)
988        .w(page_w)
989        .h(page_h)
990        .padding(0.0, 4.0, 4.0, 0.0)
991        .gap(0.0);
992
993    col = col.child(
994        lbl("Categories")
995            .color(theme.divider)
996            .h(header_h)
997            .align(Align::Center)
998            .no_wrap(),
999    );
1000    // Gap covering the separator region up to grid start (page-local y=25).
1001    col = col.child(widget::spacer().h(25.0 * sy - header_h));
1002
1003    // 4-column icon grid; icons drawn as GL overlays at exact Patchouli positions.
1004    let cats = &book.categories;
1005    let icon_s = 16.0 * sx.min(sy);
1006    let mut row_i = 0usize;
1007    loop {
1008        let row_start = row_i * 4;
1009        if row_start >= cats.len() {
1010            break;
1011        }
1012        let mut row =
1013            widget::panel(FlexDir::Row)
1014                .h(cell_h)
1015                .gap(0.0)
1016                .padding(0.0, 0.0, 0.0, grid_pad_left);
1017        for col_i in 0..4 {
1018            let idx = row_start + col_i;
1019            if idx >= cats.len() {
1020                break;
1021            }
1022            let cat = &cats[idx];
1023            let selected = !state.at_home && idx == state.cat;
1024            let bg = if selected { theme.nav_selected_bg } else { 0 };
1025
1026            if let Some(icon_id) = &cat.icon {
1027                push_icon(
1028                    overlays,
1029                    icon_id,
1030                    rx + (10.0 + col_i as f32 * 24.0 + 4.0) * sx,
1031                    py + (25.0 + row_i as f32 * 24.0 + 4.0) * sy,
1032                    icon_s,
1033                    icon_s,
1034                );
1035            }
1036            let cell = widget::panel(FlexDir::Column)
1037                .w(cell_w)
1038                .h(cell_h)
1039                .bg(bg)
1040                .on_click(format!("cat:{}", idx))
1041                .id(format!("book_cat_{}", idx));
1042            row = row.child(cell);
1043        }
1044        col = col.child(row);
1045        row_i += 1;
1046    }
1047    col
1048}
1049
1050// ── Left page: entry content (entry view) ────────────────────────────────────
1051
1052fn build_entry_left(
1053    book: &Book,
1054    state: &BookViewState,
1055    theme: &BookTheme,
1056    recipes: &HashMap<String, RecipeVis>,
1057    page_w: f32,
1058    page_h: f32,
1059    ox: f32,
1060    oy: f32,
1061    sx: f32,
1062    sy: f32,
1063    sep_cx: f32,
1064    bg_sprites: &mut Vec<BgSprite>,
1065    overlays: &mut Vec<OverlayCmd>,
1066) -> widget::Widget {
1067    let entry = state.current_entry(book);
1068    let page = entry.and_then(|e| e.pages.get(state.page));
1069    let page_count = state.page_count(book);
1070    let title_text = entry.map(|e| e.name.as_str()).unwrap_or("");
1071
1072    // Patchouli PageText page 0:
1073    //   title centered at page-local (PAGE_W/2, 0)
1074    //   separator at page-local (0, 12)  → centered ≡ sep_cx offset
1075    //   text body at page-local (0, 22)
1076    let title_h = 9.0 * sy;
1077    let sep_h_px = (SEP_H * sy).max(1.0);
1078    let sep_y = oy + 12.0 * sy;
1079    let body_oy = oy + 22.0 * sy;
1080
1081    bg_sprites.push(BgSprite {
1082        sheet: SpriteSheet::Book,
1083        u: SEP_U,
1084        v: SEP_V,
1085        uw: SEP_W,
1086        uh: SEP_H,
1087        x: ox + sep_cx,
1088        y: sep_y,
1089        w: SEP_W * sx,
1090        h: sep_h_px,
1091    });
1092
1093    let page_label = format!("{}/{}", state.page + 1, page_count);
1094    let nav = widget::panel(FlexDir::Row)
1095        .h(12.0 * sy)
1096        .gap(4.0)
1097        .padding(0.0, 2.0, 0.0, 2.0)
1098        .child(
1099            btn("◀")
1100                .w(12.0 * sx)
1101                .h(12.0 * sy)
1102                .color(theme.nav)
1103                .on_click("prev_page")
1104                .id("prev_page"),
1105        )
1106        .child(
1107            lbl(&page_label)
1108                .color(theme.nav)
1109                .flex(1.0)
1110                .align(Align::Center),
1111        )
1112        .child(
1113            btn("⌂")
1114                .w(12.0 * sx)
1115                .h(12.0 * sy)
1116                .color(theme.nav)
1117                .on_click("home")
1118                .id("book_home"),
1119        )
1120        .child(
1121            btn("▶")
1122                .w(12.0 * sx)
1123                .h(12.0 * sy)
1124                .color(theme.nav)
1125                .on_click("next_page")
1126                .id("next_page"),
1127        );
1128
1129    let page_body = build_page(
1130        page, state.page, theme, recipes, bg_sprites, overlays, ox, body_oy, sx, sy,
1131    );
1132
1133    widget::panel(FlexDir::Column)
1134        .w(page_w)
1135        .h(page_h)
1136        .padding(0.0, 6.0, 4.0, 4.0)
1137        .gap(0.0)
1138        .child(
1139            lbl(title_text)
1140                .color(theme.title)
1141                .h(title_h)
1142                .align(Align::Center)
1143                .no_wrap(),
1144        )
1145        .child(widget::spacer().h((12.0 - 9.0) * sy)) // gap: title end → sep start
1146        .child(widget::spacer().h(sep_h_px)) // height occupied by sep sprite
1147        .child(widget::spacer().h((22.0 - 12.0 - SEP_H) * sy)) // gap: sep end → body
1148        .child(page_body)
1149        .child(nav)
1150}
1151
1152// ── Right page: entry list for selected category (entry view) ─────────────────
1153
1154fn build_entries_right(
1155    book: &Book,
1156    state: &BookViewState,
1157    theme: &BookTheme,
1158    page_w: f32,
1159    page_h: f32,
1160    rx: f32,
1161    py: f32,
1162    sx: f32,
1163    sy: f32,
1164    overlays: &mut Vec<OverlayCmd>,
1165) -> widget::Widget {
1166    let entries = state.entries_visible(book);
1167    let cat_name = book
1168        .categories
1169        .get(state.cat)
1170        .map(|c| c.name.as_str())
1171        .unwrap_or("Entries");
1172    let spread_count = state.list_spread_count(book);
1173
1174    // Patchouli GuiBookEntryList layout (right page):
1175    //   category name at page-local y=0, centered
1176    //   separator at page-local y=12 (drawn as bg sprite in build_ui)
1177    //   entries start at page-local y=20, each h=11
1178    let header_h = 9.0 * sy;
1179    let row_h = 11.0 * sy;
1180    let icon_size = 8.0 * sx.min(sy);
1181
1182    let mut col = widget::panel(FlexDir::Column)
1183        .w(page_w)
1184        .h(page_h)
1185        .padding(0.0, 4.0, 4.0, 0.0)
1186        .gap(0.0);
1187
1188    col = col.child(
1189        lbl(cat_name)
1190            .color(theme.divider)
1191            .h(header_h)
1192            .align(Align::Center)
1193            .no_wrap(),
1194    );
1195    // sep region: y=9..15 (sep h=3), then gap to entries start at y=20
1196    col = col.child(widget::spacer().h((12.0 - 9.0) * sy)); // gap to sep
1197    col = col.child(widget::spacer().h((SEP_H * sy).max(1.0))); // sep height
1198    col = col.child(widget::spacer().h((20.0 - 12.0 - SEP_H) * sy)); // gap to entries
1199
1200    let abs_start = state.list_spread_start();
1201    for (i, entry) in entries.iter().enumerate() {
1202        let abs_i = abs_start + i;
1203        let selected = abs_i == state.entry;
1204        let bg = if selected { theme.nav_selected_bg } else { 0 };
1205        let color = if selected {
1206            theme.nav_selected
1207        } else {
1208            theme.nav
1209        };
1210
1211        // Icon at page-local (1, 20 + i*11 + 1), 8×8 (Patchouli renders entry
1212        // icons at 0.5× scale).
1213        if let Some(icon_id) = &entry.icon {
1214            push_icon(
1215                overlays,
1216                icon_id,
1217                rx + 1.0 * sx,
1218                py + (20.0 + i as f32 * 11.0 + 1.0) * sy,
1219                icon_size,
1220                icon_size,
1221            );
1222        }
1223
1224        let mut row = widget::panel(FlexDir::Row)
1225            .h(row_h)
1226            .gap(2.0)
1227            .bg(bg)
1228            .on_click(format!("entry:{}", abs_i))
1229            .id(format!("book_entry_{}", abs_i))
1230            .padding(1.0, 2.0, 1.0, 2.0);
1231
1232        row = row.child(widget::spacer().w(icon_size + 2.0));
1233        row = row.child(lbl(&entry.name).color(color).flex(1.0).no_wrap());
1234        col = col.child(row);
1235    }
1236
1237    if spread_count > 1 {
1238        col = col.child(widget::spacer().flex(1.0));
1239        let spread_label = format!("{}/{}", state.list_spread + 1, spread_count);
1240        col = col.child(
1241            widget::panel(FlexDir::Row)
1242                .h(12.0 * sy)
1243                .gap(2.0)
1244                .child(
1245                    btn("◀")
1246                        .w(12.0 * sx)
1247                        .h(12.0 * sy)
1248                        .color(theme.nav)
1249                        .on_click("prev_list")
1250                        .id("prev_list"),
1251                )
1252                .child(
1253                    lbl(&spread_label)
1254                        .color(theme.nav)
1255                        .flex(1.0)
1256                        .align(Align::Center),
1257                )
1258                .child(
1259                    btn("▶")
1260                        .w(12.0 * sx)
1261                        .h(12.0 * sy)
1262                        .color(theme.nav)
1263                        .on_click("next_list")
1264                        .id("next_list"),
1265                ),
1266        );
1267    }
1268    col
1269}
1270
1271/// Draw a Patchouli-style crafting recipe: title, 100×62 grid background
1272/// (UV 0,0 on the crafting sheet), 3×N ingredient icons, output item.
1273/// `rx0`/`ry` are body-local recipe origin; `ox`/`oy` the body screen origin.
1274#[allow(clippy::too_many_arguments)]
1275fn draw_crafting_grid(
1276    theme: &BookTheme,
1277    bg_sprites: &mut Vec<BgSprite>,
1278    overlays: &mut Vec<OverlayCmd>,
1279    col: &mut widget::Widget,
1280    grid: &[Option<String>],
1281    width: usize,
1282    output: &str,
1283    count: u32,
1284    shapeless: bool,
1285    ox: f32,
1286    oy: f32,
1287    rx0: f32,
1288    ry: f32,
1289    sx: f32,
1290    sy: f32,
1291) {
1292    let mut c = std::mem::replace(col, widget::panel(FlexDir::Column));
1293    c = c.child(
1294        lbl(pretty_item_name(output))
1295            .color(theme.title)
1296            .h(10.0 * sy)
1297            .align(Align::Center),
1298    );
1299
1300    // Grid background: blit at (rx0-2, ry-2) size 100×62, UV(0,0).
1301    bg_sprites.push(BgSprite {
1302        sheet: SpriteSheet::Crafting,
1303        u: 0.0,
1304        v: 0.0,
1305        uw: 100.0,
1306        uh: 62.0,
1307        x: ox + (rx0 - 2.0) * sx,
1308        y: oy + (ry - 2.0) * sy,
1309        w: 100.0 * sx,
1310        h: 62.0 * sy,
1311    });
1312    // Shapeless marker: UV(0,64) 11×11 at (rx0+62, ry+2).
1313    if shapeless {
1314        bg_sprites.push(BgSprite {
1315            sheet: SpriteSheet::Crafting,
1316            u: 0.0,
1317            v: 64.0,
1318            uw: 11.0,
1319            uh: 11.0,
1320            x: ox + (rx0 + 62.0) * sx,
1321            y: oy + (ry + 2.0) * sy,
1322            w: 11.0 * sx,
1323            h: 11.0 * sy,
1324        });
1325    }
1326
1327    let icon_s = 16.0 * sx.min(sy);
1328    let width = width.max(1);
1329    for (i, slot) in grid.iter().enumerate() {
1330        if let Some(item) = slot {
1331            overlays.push(OverlayCmd::McItem {
1332                item_id: item.clone(),
1333                x: ox + (rx0 + 3.0 + (i % width) as f32 * 19.0) * sx,
1334                y: oy + (ry + 3.0 + (i / width) as f32 * 19.0) * sy,
1335                w: icon_s,
1336                h: icon_s,
1337            });
1338        }
1339    }
1340    // Output item at (rx0+79, ry+22); stack count below it when > 1.
1341    overlays.push(OverlayCmd::McItem {
1342        item_id: output.to_owned(),
1343        x: ox + (rx0 + 79.0) * sx,
1344        y: oy + (ry + 22.0) * sy,
1345        w: icon_s,
1346        h: icon_s,
1347    });
1348    if count > 1 {
1349        overlays.push(OverlayCmd::McText {
1350            text: format!("x{}", count),
1351            x: ox + (rx0 + 81.0) * sx,
1352            y: oy + (ry + 40.0) * sy,
1353            color: theme.title,
1354        });
1355    }
1356    // Patchouli's "toast symbol" under the output: the crafting table.
1357    overlays.push(OverlayCmd::McItem {
1358        item_id: "minecraft:crafting_table".into(),
1359        x: ox + (rx0 + 79.0) * sx,
1360        y: oy + (ry + 41.0) * sy,
1361        w: icon_s,
1362        h: icon_s,
1363    });
1364
1365    // Spacer from title end (10) to below the grid (ry+62+4).
1366    c = c.child(widget::spacer().h((ry + 62.0 + 4.0 - 10.0) * sy));
1367    *col = c;
1368}
1369
1370// ── Page content builder ──────────────────────────────────────────────────────
1371
1372/// Build the content widget for a single page.
1373/// `ox/oy` = screen top-left of the page body area.
1374/// `sx/sy` = book-local-to-screen scale factors.
1375fn build_page(
1376    page: Option<&BookPage>,
1377    page_num: usize,
1378    theme: &BookTheme,
1379    recipes: &HashMap<String, RecipeVis>,
1380    bg_sprites: &mut Vec<BgSprite>,
1381    overlays: &mut Vec<OverlayCmd>,
1382    ox: f32,
1383    oy: f32,
1384    sx: f32,
1385    sy: f32,
1386) -> widget::Widget {
1387    let mut col = widget::panel(FlexDir::Column).flex(1.0).gap(4.0);
1388
1389    let Some(page) = page else {
1390        return col.child(lbl("No entries yet.").color(theme.nav));
1391    };
1392
1393    match page {
1394        BookPage::Text { text, title } => {
1395            // On non-first pages, show an optional section title + separator.
1396            if page_num > 0 {
1397                if let Some(t) = title {
1398                    let sep_h_px = (SEP_H * sy).max(1.0);
1399                    let title_h = 9.0 * sy;
1400                    col = col.child(
1401                        lbl(t.as_str())
1402                            .color(theme.title)
1403                            .h(title_h)
1404                            .align(Align::Center)
1405                            .no_wrap(),
1406                    );
1407                    // sep at y=12 page-local, centered
1408                    bg_sprites.push(BgSprite {
1409                        sheet: SpriteSheet::Book,
1410                        u: SEP_U,
1411                        v: SEP_V,
1412                        uw: SEP_W,
1413                        uh: SEP_H,
1414                        x: ox + (PAGE_W / 2.0 - SEP_W / 2.0) * sx,
1415                        y: oy + 12.0 * sy,
1416                        w: SEP_W * sx,
1417                        h: sep_h_px,
1418                    });
1419                    col = col.child(widget::spacer().h((12.0 - 9.0) * sy));
1420                    col = col.child(widget::spacer().h(sep_h_px));
1421                    col = col.child(widget::spacer().h((22.0 - 12.0 - SEP_H) * sy));
1422                }
1423            }
1424            for para in text.split('\n') {
1425                col = col.child(lbl(para).color(theme.text));
1426            }
1427        }
1428
1429        BookPage::Spotlight { item, title, text } => {
1430            // Patchouli PageSpotlight (page-body-local coordinates):
1431            //   title at y=0 centered, item frame at (PAGE_W/2-33, 10) 66×26
1432            //   (UV 0,102 on the 128×256 crafting sheet), item at (PAGE_W/2-8, 15),
1433            //   text at y=40.
1434            bg_sprites.push(BgSprite {
1435                sheet: SpriteSheet::Crafting,
1436                u: 0.0,
1437                v: 102.0,
1438                uw: 66.0,
1439                uh: 26.0,
1440                x: ox + (PAGE_W / 2.0 - 33.0) * sx,
1441                y: oy + 10.0 * sy,
1442                w: 66.0 * sx,
1443                h: 26.0 * sy,
1444            });
1445
1446            let item_name = title
1447                .as_deref()
1448                .or(item.name.as_deref())
1449                .unwrap_or(item.id.as_str());
1450            col = col.child(
1451                lbl(item_name)
1452                    .color(theme.title)
1453                    .h(10.0 * sy)
1454                    .align(Align::Center),
1455            );
1456
1457            let icon_s = 16.0 * sx.min(sy);
1458            overlays.push(OverlayCmd::McItem {
1459                item_id: item.id.clone(),
1460                x: ox + (PAGE_W / 2.0 - 8.0) * sx,
1461                y: oy + 15.0 * sy,
1462                w: icon_s,
1463                h: icon_s,
1464            });
1465
1466            // Spacer from title end (y=10) to text start (y=40).
1467            col = col.child(widget::spacer().h(30.0 * sy));
1468
1469            if let Some(t) = text {
1470                col = col.child(lbl(t.as_str()).color(theme.text));
1471            }
1472        }
1473
1474        BookPage::Crafting { recipe_id, text } => {
1475            // Patchouli PageCrafting: recipe origin at page-local
1476            // (PAGE_W/2-49, 4); we place it in body-local coords below a title.
1477            let rx0 = PAGE_W / 2.0 - 49.0; // = 9
1478            let ry = 12.0; // body-local recipe y (title occupies 0..10)
1479            match recipes.get(recipe_id.as_str()) {
1480                Some(RecipeVis::Shaped {
1481                    grid,
1482                    width,
1483                    output,
1484                    count,
1485                }) => {
1486                    draw_crafting_grid(
1487                        theme, bg_sprites, overlays, &mut col, grid, *width, output, *count, false,
1488                        ox, oy, rx0, ry, sx, sy,
1489                    );
1490                }
1491                Some(RecipeVis::Shapeless {
1492                    ingredients,
1493                    output,
1494                    count,
1495                }) => {
1496                    let grid: Vec<Option<String>> = ingredients.iter().cloned().map(Some).collect();
1497                    draw_crafting_grid(
1498                        theme, bg_sprites, overlays, &mut col, &grid, 3, output, *count, true, ox,
1499                        oy, rx0, ry, sx, sy,
1500                    );
1501                }
1502                _ => {
1503                    col = col.child(lbl(format!("[Recipe: {}]", recipe_id)).color(theme.nav));
1504                }
1505            }
1506            if let Some(t) = text {
1507                col = col.child(lbl(t.as_str()).color(theme.text));
1508            }
1509        }
1510
1511        BookPage::Smelting { recipe_id, text } => {
1512            // Patchouli PageSmelting: bg UV(11,71) 96×24, input at +4,+4,
1513            // output at +76,+4.
1514            let rx0 = PAGE_W / 2.0 - 49.0;
1515            let ry = 12.0;
1516            match recipes.get(recipe_id.as_str()) {
1517                Some(RecipeVis::Smelting { input, output }) => {
1518                    col = col.child(
1519                        lbl(pretty_item_name(output))
1520                            .color(theme.title)
1521                            .h(10.0 * sy)
1522                            .align(Align::Center),
1523                    );
1524                    bg_sprites.push(BgSprite {
1525                        sheet: SpriteSheet::Crafting,
1526                        u: 11.0,
1527                        v: 71.0,
1528                        uw: 96.0,
1529                        uh: 24.0,
1530                        x: ox + rx0 * sx,
1531                        y: oy + ry * sy,
1532                        w: 96.0 * sx,
1533                        h: 24.0 * sy,
1534                    });
1535                    let icon_s = 16.0 * sx.min(sy);
1536                    overlays.push(OverlayCmd::McItem {
1537                        item_id: input.clone(),
1538                        x: ox + (rx0 + 4.0) * sx,
1539                        y: oy + (ry + 4.0) * sy,
1540                        w: icon_s,
1541                        h: icon_s,
1542                    });
1543                    // Center: the furnace itself (Patchouli's "toast symbol").
1544                    overlays.push(OverlayCmd::McItem {
1545                        item_id: "minecraft:furnace".into(),
1546                        x: ox + (rx0 + 40.0) * sx,
1547                        y: oy + (ry + 4.0) * sy,
1548                        w: icon_s,
1549                        h: icon_s,
1550                    });
1551                    overlays.push(OverlayCmd::McItem {
1552                        item_id: output.clone(),
1553                        x: ox + (rx0 + 76.0) * sx,
1554                        y: oy + (ry + 4.0) * sy,
1555                        w: icon_s,
1556                        h: icon_s,
1557                    });
1558                    // Spacer from title end (10) to below the furnace strip (ry+24+4).
1559                    col = col.child(widget::spacer().h((ry + 24.0 + 4.0 - 10.0) * sy));
1560                }
1561                _ => {
1562                    col = col.child(lbl(format!("[Recipe: {}]", recipe_id)).color(theme.nav));
1563                }
1564            }
1565            if let Some(t) = text {
1566                col = col.child(lbl(t.as_str()).color(theme.text));
1567            }
1568        }
1569
1570        BookPage::Image {
1571            texture,
1572            title,
1573            text,
1574            ..
1575        } => {
1576            if let Some(t) = title {
1577                col = col.child(lbl(t.as_str()).color(theme.title));
1578            }
1579            col = col.child(widget::mc_image(texture, 80.0, 80.0));
1580            if let Some(t) = text {
1581                col = col.child(lbl(t.as_str()).color(theme.text));
1582            }
1583        }
1584
1585        BookPage::Svg { data, title, text } => {
1586            if let Some(t) = title {
1587                col = col.child(lbl(t.as_str()).color(theme.title));
1588            }
1589            overlays.push(OverlayCmd::Svg {
1590                data: data.clone(),
1591                x: ox,
1592                y: oy,
1593                w: 64.0,
1594                h: 64.0,
1595            });
1596            col = col.child(widget::spacer().h(68.0));
1597            if let Some(t) = text {
1598                col = col.child(lbl(t.as_str()).color(theme.text));
1599            }
1600        }
1601
1602        BookPage::CustomText { text, font, color } => {
1603            overlays.push(OverlayCmd::Text {
1604                text: text.clone(),
1605                font: font.clone(),
1606                x: ox,
1607                y: oy,
1608                color: *color,
1609            });
1610            col = col.child(widget::spacer().h(font.size_px * 1.5));
1611        }
1612
1613        BookPage::Relations { entries, text } => {
1614            if let Some(t) = text {
1615                col = col.child(lbl(t.as_str()).color(theme.text));
1616            }
1617            col = col.child(lbl("See also:").color(theme.title));
1618            for e in entries {
1619                col = col.child(lbl(format!("• {}", e)).color(theme.nav));
1620            }
1621        }
1622
1623        BookPage::Entity {
1624            entity_type,
1625            name,
1626            text,
1627        } => {
1628            let display = name.as_deref().unwrap_or(entity_type.as_str());
1629            col = col.child(lbl(display).color(theme.title));
1630            if let Some(t) = text {
1631                col = col.child(lbl(t.as_str()).color(theme.text));
1632            }
1633        }
1634
1635        BookPage::Pattern {
1636            op_id,
1637            input,
1638            output,
1639            text,
1640            ..
1641        } => {
1642            col = col.child(lbl(op_id.as_str()).color(theme.title));
1643            col = col.child(lbl(format!("{} → {}", input, output)).color(theme.nav));
1644            if !text.is_empty() {
1645                col = col.child(lbl(text.as_str()).color(theme.text));
1646            }
1647        }
1648
1649        BookPage::Empty => {}
1650    }
1651
1652    col
1653}