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