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