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    GfxContext,
7    core::{DataType, DrawMode, blend},
8    gl,
9};
10use yog_ui::{Align, FlexDir, Stretch, UiRoot, widget};
11
12use crate::font::{BookFontRegistry, FontAtlas};
13use crate::state::BookViewState;
14use crate::svg;
15use crate::theme::BookTheme;
16use crate::{Book, BookPage};
17
18// Patchouli-compatible book texture layout (512×256 sprite sheet).
19// UV coordinates are in pixels on a 512×256 sheet.
20// The full open-book background occupies 272×180 at UV (0,0).
21pub const BOOK_TEX_W: f32 = 512.0;
22pub const BOOK_TEX_H: f32 = 256.0;
23// Open-book dimensions in texture-space
24pub const BK_W: f32 = 272.0;
25pub const BK_H: f32 = 180.0;
26// Per-page dimensions and offsets (in book-local coords)
27pub const PAGE_W: f32 = 116.0;
28pub const PAGE_H: f32 = 156.0;
29pub const TOP_PAD: f32 = 18.0; // vertical space above page text area
30pub const LEFT_X: f32 = 15.0; // left page X inside book
31pub const RIGHT_X: f32 = 141.0; // right page X inside book
32// Separator strip UV origin on the sprite sheet
33pub const SEP_U: f32 = 140.0;
34pub const SEP_V: f32 = 180.0;
35pub const SEP_W: f32 = 110.0;
36pub const SEP_H: f32 = 3.0;
37/// Room (GUI px) the book keeps free around it before it scales down.
38const SCREEN_MARGIN: f32 = 16.0;
39/// Smallest the book scales on a tiny screen.
40const MIN_SCALE: f32 = 0.4;
41
42// ── Vertex for the custom 2D shader (pos + uv + color) ───────────────────────
43
44#[repr(C)]
45#[derive(Copy, Clone)]
46struct Vert {
47    x: f32,
48    y: f32,
49    u: f32,
50    v: f32,
51    r: f32,
52    g: f32,
53    b: f32,
54    a: f32,
55}
56
57fn rgba_f(c: u32) -> (f32, f32, f32, f32) {
58    let a = ((c >> 24) & 0xFF) as f32 / 255.0;
59    let r = ((c >> 16) & 0xFF) as f32 / 255.0;
60    let g = ((c >> 8) & 0xFF) as f32 / 255.0;
61    let b = (c & 0xFF) as f32 / 255.0;
62    (r, g, b, a)
63}
64
65fn quad(
66    verts: &mut Vec<Vert>,
67    x: f32,
68    y: f32,
69    w: f32,
70    h: f32,
71    u0: f32,
72    v0: f32,
73    u1: f32,
74    v1: f32,
75    color: u32,
76) {
77    let (r, g, b, a) = rgba_f(color);
78    let p = [
79        Vert {
80            x,
81            y,
82            u: u0,
83            v: v0,
84            r,
85            g,
86            b,
87            a,
88        },
89        Vert {
90            x: x + w,
91            y,
92            u: u1,
93            v: v0,
94            r,
95            g,
96            b,
97            a,
98        },
99        Vert {
100            x,
101            y: y + h,
102            u: u0,
103            v: v1,
104            r,
105            g,
106            b,
107            a,
108        },
109        Vert {
110            x: x + w,
111            y: y + h,
112            u: u1,
113            v: v1,
114            r,
115            g,
116            b,
117            a,
118        },
119    ];
120    // two triangles: 0,1,2  1,3,2
121    verts.extend_from_slice(&[p[0], p[1], p[2], p[1], p[3], p[2]]);
122}
123
124// ── GLSL shaders ─────────────────────────────────────────────────────────────
125
126const VERT: &str = r#"
127#version 330 core
128layout(location = 0) in vec2 aPos;
129layout(location = 1) in vec2 aUv;
130layout(location = 2) in vec4 aCol;
131out vec2 fUv;
132out vec4 fCol;
133uniform vec2 uScreen;
134void main() {
135    fUv  = aUv;
136    fCol = aCol;
137    vec2 ndc = aPos / uScreen * 2.0 - vec2(1.0);
138    gl_Position = vec4(ndc.x, -ndc.y, 0.0, 1.0);
139}
140"#;
141
142const FRAG: &str = r#"
143#version 330 core
144in vec2 fUv;
145in vec4 fCol;
146out vec4 outColor;
147uniform sampler2D uTex;
148uniform int uMode;  // 0 = solid color, 1 = RGBA texture, 2 = alpha-only (font)
149void main() {
150    if (uMode == 0) {
151        outColor = fCol;
152    } else if (uMode == 1) {
153        outColor = texture(uTex, fUv) * fCol;
154    } else {
155        float alpha = texture(uTex, fUv).a;
156        outColor = vec4(fCol.rgb, fCol.a * alpha);
157    }
158}
159"#;
160
161// ── Embedded textures ─────────────────────────────────────────────────────────
162
163static BOOK_PNG: &[u8] = include_bytes!("../assets/book_brown.png");
164static CRAFTING_PNG: &[u8] = include_bytes!("../assets/crafting.png");
165
166fn decode_png(data: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
167    use png::Decoder;
168    let decoder = Decoder::new(std::io::Cursor::new(data));
169    let mut reader = decoder.read_info().ok()?;
170    let mut buf = vec![0u8; reader.output_buffer_size().unwrap_or(0)];
171    let info = reader.next_frame(&mut buf).ok()?;
172    let rgba = match info.color_type {
173        png::ColorType::Rgba => buf[..info.buffer_size()].to_vec(),
174        png::ColorType::Rgb => {
175            let rgb = &buf[..info.buffer_size()];
176            let mut out = Vec::with_capacity(rgb.len() / 3 * 4);
177            for px in rgb.chunks(3) {
178                out.extend_from_slice(px);
179                out.push(255);
180            }
181            out
182        }
183        _ => return None,
184    };
185    Some((rgba, info.width, info.height))
186}
187
188// ── GL resource cache ─────────────────────────────────────────────────────────
189
190struct BookGl {
191    prog: gl::ShaderProgram,
192    vao: gl::VertexArray,
193    vbo: gl::Buffer,
194    book_tex: Option<(gl::Texture, u32, u32)>,
195    crafting_tex: Option<(gl::Texture, u32, u32)>,
196    svg_tex: HashMap<u64, (gl::Texture, u32, u32)>,
197    font_atlas: HashMap<u64, (gl::Texture, FontAtlas)>,
198}
199
200impl BookGl {
201    fn init(ctx: &GfxContext) -> Option<Self> {
202        let prog = ctx.create_shader(VERT, FRAG).ok()?;
203        let vbo = ctx.create_buffer();
204        let vao = ctx.create_vao();
205
206        const STRIDE: u32 = 32; // 8 × f32
207        vao.attrib(ctx, &vbo, 0, 2, DataType::F32, false, STRIDE, 0); // pos
208        vao.attrib(ctx, &vbo, 1, 2, DataType::F32, false, STRIDE, 8); // uv
209        vao.attrib(ctx, &vbo, 2, 4, DataType::F32, false, STRIDE, 16); // col
210
211        let load = |data: &[u8]| {
212            decode_png(data).map(|(rgba, w, h)| {
213                let tex = ctx.create_texture_rgba(w, h, &rgba, true);
214                (tex, w, h)
215            })
216        };
217        let book_tex = load(BOOK_PNG);
218        let crafting_tex = load(CRAFTING_PNG);
219
220        Some(BookGl {
221            prog,
222            vao,
223            vbo,
224            book_tex,
225            crafting_tex,
226            svg_tex: HashMap::new(),
227            font_atlas: HashMap::new(),
228        })
229    }
230
231    fn svg_tex(
232        &mut self,
233        ctx: &GfxContext,
234        hash: u64,
235        data: &str,
236        w: u32,
237        h: u32,
238    ) -> Option<&(gl::Texture, u32, u32)> {
239        if let std::collections::hash_map::Entry::Vacant(e) = self.svg_tex.entry(hash) {
240            let pixels = svg::rasterize(data, w, h)?;
241            let tex = ctx.create_texture_rgba(w, h, &pixels, true);
242            e.insert((tex, w, h));
243        }
244        self.svg_tex.get(&hash)
245    }
246
247    fn flush(&self, ctx: &GfxContext, verts: &[Vert]) {
248        if verts.is_empty() {
249            return;
250        }
251        unsafe {
252            self.vbo.upload(ctx, verts, true);
253        }
254        ctx.draw_arrays(
255            &self.vao,
256            &self.prog,
257            DrawMode::Triangles,
258            0,
259            verts.len() as u32,
260        );
261    }
262}
263
264// ── Draw helpers ──────────────────────────────────────────────────────────────
265
266impl BookGl {
267    fn begin_frame(&self, ctx: &GfxContext, sw: f32, sh: f32) {
268        ctx.set_blend(true, blend::SRC_ALPHA, blend::ONE_MINUS_SRC_ALPHA);
269        ctx.set_depth(false, false);
270        self.prog.uniform_2f(ctx, "uScreen", sw, sh);
271        self.prog.uniform_1i(ctx, "uTex", 0);
272    }
273
274    /// Blit a subrect of a sprite sheet at an arbitrary screen position.
275    fn draw_book_sprite(&self, ctx: &GfxContext, spr: &BgSprite) {
276        let sheet = match spr.sheet {
277            SpriteSheet::Book => &self.book_tex,
278            SpriteSheet::Crafting => &self.crafting_tex,
279        };
280        let Some((tex, tw, th)) = sheet else { return };
281        let u0 = spr.u / *tw as f32;
282        let v0 = spr.v / *th as f32;
283        let u1 = (spr.u + spr.uw) / *tw as f32;
284        let v1 = (spr.v + spr.uh) / *th as f32;
285        self.prog.uniform_1i(ctx, "uMode", 1);
286        ctx.bind_texture(0, tex);
287        let mut v = Vec::with_capacity(6);
288        quad(
289            &mut v,
290            spr.x,
291            spr.y,
292            spr.w,
293            spr.h,
294            u0,
295            v0,
296            u1,
297            v1,
298            0xFF_FFFFFF,
299        );
300        self.flush(ctx, &v);
301    }
302
303    /// Blit the open-book background from the embedded book texture.
304    fn draw_book_bg(&self, ctx: &GfxContext, bx: f32, by: f32, bw: f32, bh: f32) {
305        let Some((tex, tw, th)) = &self.book_tex else {
306            return;
307        };
308        let su = BK_W / *tw as f32;
309        let sv = BK_H / *th as f32;
310        self.prog.uniform_1i(ctx, "uMode", 1);
311        ctx.bind_texture(0, tex);
312        let mut v = Vec::with_capacity(6);
313        quad(&mut v, bx, by, bw, bh, 0.0, 0.0, su, sv, 0xFF_FFFFFF);
314        self.flush(ctx, &v);
315    }
316
317    fn draw_svg(&mut self, ctx: &GfxContext, data: &str, x: f32, y: f32, w: f32, h: f32) {
318        let hash = svg::svg_hash(data);
319        let tw = w as u32;
320        let th = h as u32;
321        if let Some(&(tex, _, _)) = self.svg_tex(ctx, hash, data, tw, th) {
322            self.prog.uniform_1i(ctx, "uMode", 1);
323            ctx.bind_texture(0, &tex);
324            let mut v = Vec::with_capacity(6);
325            quad(&mut v, x, y, w, h, 0.0, 0.0, 1.0, 1.0, 0xFF_FFFFFF);
326            self.flush(ctx, &v);
327        }
328    }
329
330    fn draw_text_custom(
331        &mut self,
332        ctx: &GfxContext,
333        ttf: &[u8],
334        size_px: f32,
335        text: &str,
336        mut x: f32,
337        y: f32,
338        color: u32,
339    ) {
340        let hash = font_hash(ttf);
341        if let std::collections::hash_map::Entry::Vacant(e) = self.font_atlas.entry(hash)
342            && let Some(atlas) = FontAtlas::build(ttf, size_px)
343        {
344            let tex =
345                ctx.create_texture_rgba(atlas.atlas_size, atlas.atlas_size, &atlas.pixels, true);
346            e.insert((tex, atlas));
347        }
348        // Get raw pointers so the borrow of self.font_atlas ends before we call
349        // self.prog / self.flush, which also borrow fields of self.
350        let ptrs = self
351            .font_atlas
352            .get(&hash)
353            .map(|(t, a)| (t as *const gl::Texture, a as *const FontAtlas));
354        if let Some((tex_ptr, atlas_ptr)) = ptrs {
355            // SAFETY: we hold &mut self; font_atlas is not mutated below.
356            let tex = unsafe { &*tex_ptr };
357            let atlas = unsafe { &*atlas_ptr };
358            self.prog.uniform_1i(ctx, "uMode", 2);
359            ctx.bind_texture(0, tex);
360            let baseline = y + size_px;
361            let mut verts = Vec::new();
362            for ch in text.chars() {
363                if let Some(g) = atlas.glyphs.get(&ch) {
364                    if g.width > 0 {
365                        let gx = x + g.xoff;
366                        let gy = baseline - g.yoff - g.height as f32;
367                        quad(
368                            &mut verts,
369                            gx,
370                            gy,
371                            g.width as f32,
372                            g.height as f32,
373                            g.u0,
374                            g.v0,
375                            g.u1,
376                            g.v1,
377                            color,
378                        );
379                    }
380                    x += g.advance;
381                }
382            }
383            self.flush(ctx, &verts);
384        }
385    }
386}
387
388fn font_hash(data: &[u8]) -> u64 {
389    use std::hash::{Hash, Hasher};
390    let mut h = std::collections::hash_map::DefaultHasher::new();
391    data.hash(&mut h);
392    h.finish()
393}
394
395// ── Background sprite (texture blit, drawn before yog-ui) ────────────────────
396
397#[derive(Clone, Copy, PartialEq)]
398pub(crate) enum SpriteSheet {
399    Book,
400    Crafting,
401}
402
403/// A subrect blit from one of the embedded sprite sheets, drawn before yog-ui.
404#[derive(Clone)]
405pub(crate) struct BgSprite {
406    pub sheet: SpriteSheet,
407    /// Source rect in pixels on the sheet.
408    pub u: f32,
409    pub v: f32,
410    pub uw: f32,
411    pub uh: f32,
412    /// Destination on screen.
413    pub x: f32,
414    pub y: f32,
415    pub w: f32,
416    pub h: f32,
417}
418
419// ── Pending overlay draw commands ─────────────────────────────────────────────
420
421#[derive(Clone)]
422pub(crate) enum OverlayCmd {
423    Svg {
424        data: String,
425        x: f32,
426        y: f32,
427        w: f32,
428        h: f32,
429    },
430    Text {
431        text: String,
432        font: crate::font::BookFont,
433        x: f32,
434        y: f32,
435        color: u32,
436    },
437    /// MC default-font text rendered via draw2d (e.g. nameplate title/subtitle).
438    McText {
439        text: String,
440        x: f32,
441        y: f32,
442        color: u32,
443    },
444    /// Item icon rendered through our GL pipeline from an MC-managed texture.
445    McItem {
446        item_id: String,
447        x: f32,
448        y: f32,
449        w: f32,
450        h: f32,
451    },
452    /// Flat texture icon blitted as-is (16×16), for icon strings that name a
453    /// texture resource directly rather than an item — matches Patchouli's
454    /// `BookIcon.from`: any icon string ending in `.png` is a raw texture,
455    /// everything else is parsed as an item stack.
456    McTexture {
457        path: String,
458        x: f32,
459        y: f32,
460        w: f32,
461        h: f32,
462    },
463}
464
465/// Push a book-author-supplied icon (category/entry icon field) as the right
466/// overlay kind. Matches Patchouli's `BookIcon.from(String)`: `.png` → raw
467/// texture blit, anything else → item stack render.
468fn push_icon(overlays: &mut Vec<OverlayCmd>, icon: &str, x: f32, y: f32, w: f32, h: f32) {
469    if icon.ends_with(".png") {
470        overlays.push(OverlayCmd::McTexture {
471            path: icon.to_string(),
472            x,
473            y,
474            w,
475            h,
476        });
477    } else {
478        overlays.push(OverlayCmd::McItem {
479            item_id: icon.to_string(),
480            x,
481            y,
482            w,
483            h,
484        });
485    }
486}
487
488// ── Recipe visuals (parsed from registered recipe JSON) ───────────────────────
489
490#[derive(Clone)]
491pub(crate) enum RecipeVis {
492    Shaped {
493        grid: Vec<Option<String>>,
494        width: usize,
495        output: String,
496        count: u32,
497    },
498    Shapeless {
499        ingredients: Vec<String>,
500        output: String,
501        count: u32,
502    },
503    Smelting {
504        input: String,
505        output: String,
506    },
507}
508
509fn parse_recipe(json: &str) -> Option<RecipeVis> {
510    let v: serde_json::Value = serde_json::from_str(json).ok()?;
511    match v.get("type")?.as_str()? {
512        "minecraft:crafting_shaped" => {
513            let pattern: Vec<&str> = v
514                .get("pattern")?
515                .as_array()?
516                .iter()
517                .filter_map(|p| p.as_str())
518                .collect();
519            let width = pattern.iter().map(|r| r.chars().count()).max()?.max(1);
520            let key = v.get("key")?.as_object()?;
521            let mut grid = Vec::new();
522            for row in &pattern {
523                let mut chars: Vec<char> = row.chars().collect();
524                chars.resize(width, ' ');
525                for ch in chars {
526                    grid.push(
527                        key.get(&ch.to_string())
528                            .and_then(|k| k.get("item"))
529                            .and_then(|i| i.as_str())
530                            .map(str::to_owned),
531                    );
532                }
533            }
534            let result = v.get("result")?;
535            Some(RecipeVis::Shaped {
536                grid,
537                width,
538                output: result.get("item")?.as_str()?.to_owned(),
539                count: result.get("count").and_then(|c| c.as_u64()).unwrap_or(1) as u32,
540            })
541        }
542        "minecraft:crafting_shapeless" => {
543            let ingredients = v
544                .get("ingredients")?
545                .as_array()?
546                .iter()
547                .filter_map(|i| i.get("item").and_then(|x| x.as_str()).map(str::to_owned))
548                .collect();
549            let result = v.get("result")?;
550            Some(RecipeVis::Shapeless {
551                ingredients,
552                output: result.get("item")?.as_str()?.to_owned(),
553                count: result.get("count").and_then(|c| c.as_u64()).unwrap_or(1) as u32,
554            })
555        }
556        "minecraft:smelting"
557        | "minecraft:blasting"
558        | "minecraft:smoking"
559        | "minecraft:campfire_cooking" => Some(RecipeVis::Smelting {
560            input: v.get("ingredient")?.get("item")?.as_str()?.to_owned(),
561            output: v.get("result")?.as_str()?.to_owned(),
562        }),
563        _ => None,
564    }
565}
566
567/// Normalize icon ids like "yog:item/ruby" / "yog:block/x" to registry item
568/// ids ("yog:ruby") accepted by the MC item renderer.
569fn normalize_item_id(id: &str) -> String {
570    let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
571    let name = path
572        .trim_start_matches("item/")
573        .trim_start_matches("block/");
574    format!("{ns}:{name}")
575}
576
577/// "yog:ruby_block" → "Ruby Block" (fallback display name from an item id).
578fn pretty_item_name(id: &str) -> String {
579    let name = id.rsplit(':').next().unwrap_or(id);
580    name.split('_')
581        .map(|w| {
582            let mut c = w.chars();
583            match c.next() {
584                Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
585                None => String::new(),
586            }
587        })
588        .collect::<Vec<_>>()
589        .join(" ")
590}
591
592// ── BookRenderer ──────────────────────────────────────────────────────────────
593
594pub struct BookRenderer {
595    pub book: Book,
596    pub state: BookViewState,
597    pub theme: BookTheme,
598    fonts: BookFontRegistry,
599    recipes: HashMap<String, RecipeVis>,
600    gl: Option<BookGl>,
601    pub ui: Option<UiRoot>,
602    bg_sprites: Vec<BgSprite>,
603    overlays: Vec<OverlayCmd>,
604    dirty: bool,
605    last_sw: f32,
606    last_sh: f32,
607    /// i18n books: the untranslated book, the translator and the language
608    /// probe (the book name's text) the shown book was made for.
609    source: Option<Book>,
610    translator: Option<fn(&str) -> Option<String>>,
611    probe: Option<Option<String>>,
612}
613
614impl BookRenderer {
615    pub fn new(book: Book) -> Self {
616        let theme = BookTheme::default().with_nameplate(&book.nameplate_color);
617        Self {
618            book: flow(book),
619            state: BookViewState::default(),
620            theme,
621            fonts: BookFontRegistry::default(),
622            recipes: HashMap::new(),
623            gl: None,
624            ui: None,
625            bg_sprites: Vec::new(),
626            overlays: Vec::new(),
627            dirty: true,
628            last_sw: 0.0,
629            last_sh: 0.0,
630            source: None,
631            translator: None,
632            probe: None,
633        }
634    }
635
636    /// For [`Book::i18n`] books: the book's texts are translation keys,
637    /// looked up with `tr` (unknown keys stay as they are) and re-looked up
638    /// whenever the language changes.
639    pub fn set_translator(&mut self, tr: fn(&str) -> Option<String>) {
640        if self.book.i18n {
641            self.source.get_or_insert_with(|| self.book.clone());
642            // (Already flowed: flowing again only splits what still overflows.)
643            self.translator = Some(tr);
644            self.probe = None;
645        }
646    }
647
648    /// Re-translates the book when the language changed since last time.
649    fn localize(&mut self) {
650        let (Some(src), Some(tr)) = (&self.source, self.translator) else {
651            return;
652        };
653        let probe = Some(tr(&src.name));
654        if probe == self.probe {
655            return;
656        }
657        self.book = flow(src.localized(|k| tr(k).unwrap_or_else(|| k.to_string())));
658        self.probe = probe;
659        self.dirty = true;
660    }
661
662    pub fn handle_event(&mut self, ev: &str) {
663        if self.state.handle(ev, &self.book) {
664            self.dirty = true;
665        }
666    }
667
668    /// Register a custom TTF/OTF font for use in `BookPage::CustomText`.
669    pub fn register_font(&mut self, id: impl Into<String>, ttf: Vec<u8>) {
670        self.fonts.register(id, ttf);
671    }
672
673    /// Register a recipe (vanilla recipe JSON) so Crafting/Smelting pages can
674    /// render it visually. Unknown recipe types are ignored.
675    pub fn add_recipe(&mut self, id: impl Into<String>, json: &str) {
676        if let Some(vis) = parse_recipe(json) {
677            self.recipes.insert(id.into(), vis);
678            self.dirty = true;
679        }
680    }
681
682    pub fn render(&mut self, ctx: &GfxContext, sw: f32, sh: f32) {
683        // Lazy GL init.
684        if self.gl.is_none() {
685            self.gl = BookGl::init(ctx);
686        }
687
688        // Render at Patchouli-native size (272×180 GUI pixels). Scale DOWN only
689        // if the screen is smaller; never scale up (MC font is designed for 1×).
690        let scale = ((sw - SCREEN_MARGIN) / BK_W)
691            .min((sh - SCREEN_MARGIN) / BK_H)
692            .clamp(MIN_SCALE, 1.0);
693        let bw = BK_W * scale;
694        let bh = BK_H * scale;
695        let bx = (sw - bw) / 2.0;
696        let by = (sh - bh) / 2.0;
697
698        self.localize();
699        // Rebuild widget tree if dirty or screen resized.
700        if self.dirty || sw != self.last_sw || sh != self.last_sh {
701            let (root, bg_sprites, overlays) = build_ui(
702                &self.book,
703                &self.state,
704                &self.theme,
705                &self.recipes,
706                bx,
707                by,
708                bw,
709                bh,
710            );
711            self.ui = Some(root);
712            self.bg_sprites = bg_sprites;
713            self.overlays = overlays;
714            self.dirty = false;
715            self.last_sw = sw;
716            self.last_sh = sh;
717        }
718
719        // 1. Draw book background texture first.
720        if let Some(gl) = &mut self.gl {
721            gl.begin_frame(ctx, sw, sh);
722            gl.draw_book_bg(ctx, bx, by, bw, bh);
723        }
724
725        // 1b. Background sprites (nameplate banner, separator lines) from book texture.
726        if let Some(gl) = &mut self.gl {
727            for spr in &self.bg_sprites {
728                gl.draw_book_sprite(ctx, spr);
729            }
730        }
731
732        // 2. yog-ui: text, buttons, icons (transparent BG — texture is already there).
733        if let Some(ui) = &mut self.ui {
734            if ui.needs_layout {
735                ui.layout(sw, sh);
736            }
737            ui.render(ctx);
738        }
739
740        // 3. Custom GL overlays (SVG icons, custom font text) — raw GL first.
741        if let Some(gl) = &mut self.gl {
742            gl.begin_frame(ctx, sw, sh);
743            for ov in &self.overlays {
744                match *ov {
745                    OverlayCmd::Svg {
746                        ref data,
747                        x,
748                        y,
749                        w,
750                        h,
751                    } => gl.draw_svg(ctx, data, x, y, w, h),
752                    OverlayCmd::Text {
753                        ref text,
754                        ref font,
755                        x,
756                        y,
757                        color,
758                    } => {
759                        if let Some(ttf) = self.fonts.get(&font.font_id) {
760                            gl.draw_text_custom(ctx, ttf, font.size_px, text, x, y, color);
761                        }
762                    }
763                    OverlayCmd::McText { .. }
764                    | OverlayCmd::McItem { .. }
765                    | OverlayCmd::McTexture { .. } => {}
766                }
767            }
768        }
769
770        // 4. MC-pipeline overlays (item stacks, MC-font text) — after all raw
771        //    GL, because item rendering resyncs and mutates MC's GL state.
772        for ov in &self.overlays {
773            match *ov {
774                OverlayCmd::McItem {
775                    ref item_id,
776                    x,
777                    y,
778                    w,
779                    h,
780                } => {
781                    ctx.draw2d()
782                        .item(&normalize_item_id(item_id), x, y, w.min(h));
783                }
784                OverlayCmd::McText {
785                    ref text,
786                    x,
787                    y,
788                    color,
789                } => {
790                    ctx.draw2d().text(text, x, y, color, false);
791                }
792                OverlayCmd::McTexture {
793                    ref path,
794                    x,
795                    y,
796                    w,
797                    h,
798                } => {
799                    // Standalone icon texture, not an atlas region — drawn
800                    // whole, scaled into the icon box.
801                    ctx.draw2d().image(path, x, y, w, h);
802                }
803                _ => {}
804            }
805        }
806    }
807}
808
809// ── UI builder ────────────────────────────────────────────────────────────────
810
811thread_local! {
812    /// The scale the book is drawn at (≤ 1): its text is drawn at it too,
813    /// so it stays on the page however small the book gets.
814    static TEXT_SCALE: std::cell::Cell<f32> = const { std::cell::Cell::new(1.0) };
815}
816
817fn text_scale() -> f32 {
818    TEXT_SCALE.with(|s| s.get())
819}
820
821/// Book label: MC font, no drop-shadow (Patchouli draws book text shadowless).
822fn lbl(text: impl Into<String>) -> widget::Widget {
823    widget::label(text).shadow(false).font_scale(text_scale())
824}
825/// Book button: no drop-shadow.
826fn btn(text: impl Into<String>) -> widget::Widget {
827    widget::button(text).shadow(false).font_scale(text_scale())
828}
829
830/// A title: one line, shrunk (down to half size, then cut short) to fit
831/// the page's width.
832fn title_lbl(text: impl Into<String>) -> widget::Widget {
833    let text = text.into();
834    let fit = ((PAGE_W - 6.0) / yog_ui::text::str_width(&text, 1.0)).clamp(0.5, 1.0);
835    widget::label(text).shadow(false).font_scale(text_scale() * fit).no_wrap().ellipsis()
836}
837
838// ── Flowing text ──────────────────────────────────────────────────────────────
839
840/// Width of a page's text, in book pixels.
841const TEXT_W: f32 = PAGE_W - 8.0;
842/// Height a page's body has for text, in book pixels: the page less its
843/// title and separator, the page navigation and the padding.
844const BODY_H: f32 = PAGE_H - 22.0 - 12.0 - 14.0;
845
846/// How much of the body a page's own content takes above its text.
847fn content_above_text(page: &BookPage) -> Option<f32> {
848    Some(match page {
849        BookPage::Text { title: Some(_), .. } => 22.0,
850        BookPage::Text { .. } => 0.0,
851        BookPage::Spotlight { .. } => 40.0,
852        BookPage::Crafting { .. } => 80.0,
853        BookPage::Smelting { .. } => 40.0,
854        BookPage::Image { .. } | BookPage::Entity { .. } => 104.0,
855        BookPage::Relations { entries, .. } => 12.0 + entries.len() as f32 * 10.0,
856        _ => return None,
857    })
858}
859
860fn page_text_mut(page: &mut BookPage) -> Option<&mut String> {
861    match page {
862        BookPage::Text { text, .. } => Some(text),
863        BookPage::Spotlight { text, .. }
864        | BookPage::Crafting { text, .. }
865        | BookPage::Smelting { text, .. }
866        | BookPage::Image { text, .. }
867        | BookPage::Entity { text, .. }
868        | BookPage::Relations { text, .. } => text.as_mut(),
869        _ => None,
870    }
871}
872
873/// Text that doesn't fit its page goes on to new text pages after it.
874fn flow(mut book: Book) -> Book {
875    let line_h = yog_ui::text::LINE_H + yog_ui::text::LINE_GAP;
876    let fits = |h: f32| (((h + yog_ui::text::LINE_GAP) / line_h).floor() as usize).max(1);
877    for entry in &mut book.entries {
878        let mut pages = Vec::with_capacity(entry.pages.len());
879        for mut page in std::mem::take(&mut entry.pages) {
880            let Some(above) = content_above_text(&page) else {
881                pages.push(page);
882                continue;
883            };
884            let Some(text) = page_text_mut(&mut page) else {
885                pages.push(page);
886                continue;
887            };
888            let lines: Vec<String> = text
889                .split('\n')
890                .flat_map(|para| {
891                    if para.is_empty() { vec![String::new()] } else { yog_ui::text::wrap_text(para, TEXT_W, 1.0) }
892                })
893                .collect();
894            let first = fits(BODY_H - above);
895            if lines.len() <= first {
896                pages.push(page);
897                continue;
898            }
899            *text = lines[..first].join("\n");
900            pages.push(page);
901            for chunk in lines[first..].chunks(fits(BODY_H)) {
902                pages.push(BookPage::Text { text: chunk.join("\n"), title: None });
903            }
904        }
905        entry.pages = pages;
906    }
907    book
908}
909
910/// Build the yog-ui widget tree + bg sprites + overlay commands for the current book state.
911/// `bx/by/bw/bh` are the screen-space book rect (same values used to blit the bg texture).
912fn build_ui(
913    book: &Book,
914    state: &BookViewState,
915    theme: &BookTheme,
916    recipes: &HashMap<String, RecipeVis>,
917    bx: f32,
918    by: f32,
919    bw: f32,
920    bh: f32,
921) -> (UiRoot, Vec<BgSprite>, Vec<OverlayCmd>) {
922    let mut bg_sprites: Vec<BgSprite> = Vec::new();
923    let mut overlays: Vec<OverlayCmd> = Vec::new();
924
925    // Scale from Patchouli's fixed BK_W×BK_H coordinate space → actual bw×bh.
926    let sx = bw / BK_W;
927    TEXT_SCALE.with(|s| s.set(sx));
928    let sy = bh / BK_H;
929
930    // In book-local: left page starts at x=LEFT_X, right at RIGHT_X, both y=TOP_PAD.
931    let pw = PAGE_W * sx;
932    let ph = PAGE_H * sy;
933    let spine_gap = (RIGHT_X - LEFT_X - PAGE_W) * sx; // ≈10px scaled
934
935    // Content x/y in screen space (for SVG overlay positioning).
936    let lx = bx + LEFT_X * sx;
937    let rx = bx + RIGHT_X * sx;
938    let py = by + TOP_PAD * sy;
939
940    // Separator x offset within a page: centered = PAGE_W/2 - SEP_W/2 = 58-55 = 3.
941    let sep_cx = (PAGE_W / 2.0 - SEP_W / 2.0) * sx; // = 3*sx
942
943    if state.at_home {
944        // Nameplate banner: book-local (-8, 12) from LEFT_PAGE_X, size 140×31.
945        bg_sprites.push(BgSprite {
946            sheet: SpriteSheet::Book,
947            u: 0.0,
948            v: 180.0,
949            uw: 140.0,
950            uh: 31.0,
951            x: bx + (LEFT_X - 8.0) * sx,
952            y: by + 12.0 * sy,
953            w: 140.0 * sx,
954            h: 31.0 * sy,
955        });
956    }
957    // Separator on the right page below its header ("Categories" or the
958    // category name; y=12 page-local), centered.
959    bg_sprites.push(BgSprite {
960        sheet: SpriteSheet::Book,
961        u: SEP_U,
962        v: SEP_V,
963        uw: SEP_W,
964        uh: SEP_H,
965        x: rx + sep_cx,
966        y: by + (TOP_PAD + 12.0) * sy,
967        w: SEP_W * sx,
968        h: (SEP_H * sy).max(1.0),
969    });
970
971    let (left_page, right_page) = if state.at_home {
972        let l = build_landing_left(book, theme, pw, ph, sx, sy, bx, by, &mut overlays);
973        let r = build_categories_right(book, state, theme, pw, ph, rx, py, sx, sy, &mut overlays);
974        (l, r)
975    } else {
976        let l = build_entry_left(
977            book,
978            state,
979            theme,
980            recipes,
981            pw,
982            ph,
983            lx,
984            py,
985            sx,
986            sy,
987            sep_cx,
988            &mut bg_sprites,
989            &mut overlays,
990        );
991        let r = build_entries_right(book, state, theme, pw, ph, rx, py, sx, sy, &mut overlays);
992        (l, r)
993    };
994
995    // Root row spans the full book width.
996    // padding-left = LEFT_X*sx offsets both pages from the left cover edge.
997    // padding-top  = TOP_PAD*sy offsets from the header banner.
998    // spine_gap spacer sits between the two pages.
999    // gap must be 0: analytic overlay positions assume pages sit exactly at
1000    // LEFT_X / RIGHT_X — any flex gap would shift widgets (and their focus
1001    // highlights) away from the absolutely-positioned icons.
1002    let root_widget = widget::panel(FlexDir::Row)
1003        .w(bw)
1004        .h(bh)
1005        .gap(0.0)
1006        .padding(TOP_PAD * sy, 0.0, 0.0, LEFT_X * sx)
1007        .child(left_page)
1008        .child(widget::spacer().w(spine_gap)) // spine gap
1009        .child(right_page);
1010
1011    // Outer shell: positions the book on screen via a full-screen transparent Row.
1012    // We use a Column + Row arrangement to apply top/left offsets without margin support.
1013    let outer = widget::panel(FlexDir::Column)
1014        .w(bw)
1015        .h(bh)
1016        .child(root_widget);
1017
1018    // Screen-level container: positions book at (bx, by).
1019    // bx offset via padding, by offset via padding-top.
1020    // Note: layout computes from (0,0), so we pass bx/by into the UiRoot manually
1021    // by wrapping in a full-screen panel with the right padding.
1022    let screen_root = widget::panel(FlexDir::Row)
1023        .padding(by, 0.0, 0.0, bx)
1024        .child(outer);
1025
1026    let ui = UiRoot::new(&book.id, screen_root);
1027    (ui, bg_sprites, overlays)
1028}
1029
1030// ── Left page: landing (home view) ───────────────────────────────────────────
1031
1032fn build_landing_left(
1033    book: &Book,
1034    theme: &BookTheme,
1035    page_w: f32,
1036    page_h: f32,
1037    sx: f32,
1038    sy: f32,
1039    bx: f32,
1040    by: f32,
1041    overlays: &mut Vec<OverlayCmd>,
1042) -> widget::Widget {
1043    // Patchouli: title at book-local (13, 16), subtitle at (24, 24).
1044    // These overlap the nameplate sprite which is above/within the page top.
1045    overlays.push(OverlayCmd::McText {
1046        text: book.name.clone(),
1047        x: bx + 13.0 * sx,
1048        y: by + 16.0 * sy,
1049        color: theme.nameplate,
1050    });
1051    if let Some(author) = &book.author {
1052        overlays.push(OverlayCmd::McText {
1053            text: format!("by {}", author),
1054            x: bx + 24.0 * sx,
1055            y: by + 24.0 * sy,
1056            color: theme.nameplate,
1057        });
1058    }
1059
1060    // Widget content: landing text + entry count.
1061    // Landing text starts at page-local y=25 (= book-local y=43).
1062    // We push it down with top padding = 25*sy.
1063    let mut col = widget::panel(FlexDir::Column)
1064        .w(page_w)
1065        .h(page_h)
1066        .padding(25.0 * sy, 6.0, 4.0, 4.0)
1067        .gap(0.0);
1068
1069    for para in book.landing_text.split('\n') {
1070        if para.is_empty() {
1071            col = col.child(widget::spacer().h(4.0 * sy));
1072        } else {
1073            col = col.child(lbl(para).color(theme.text));
1074        }
1075    }
1076
1077    let total = book.entries.len();
1078    col = col.child(widget::spacer().flex(1.0));
1079    col = col.child(
1080        lbl(format!("{} entries", total))
1081            .color(theme.nav)
1082            .h(9.0 * sy)
1083            .align(Align::Center),
1084    );
1085    col
1086}
1087
1088// ── Right page: category list (home view) ────────────────────────────────────
1089
1090fn build_categories_right(
1091    book: &Book,
1092    state: &BookViewState,
1093    theme: &BookTheme,
1094    page_w: f32,
1095    page_h: f32,
1096    rx: f32,
1097    py: f32,
1098    sx: f32,
1099    sy: f32,
1100    overlays: &mut Vec<OverlayCmd>,
1101) -> widget::Widget {
1102    // Patchouli right-page layout (landing):
1103    //   "Categories" header at page-local y=0, centered
1104    //   Separator at page-local y=12 (drawn as bg sprite, not here)
1105    //   4-column 24×24 icon grid starting at page-local (10, 25)
1106    let cell_w = 24.0 * sx;
1107    let cell_h = 24.0 * sy;
1108    let grid_pad_left = 10.0 * sx;
1109    let header_h = 9.0 * sy;
1110
1111    let mut col = widget::panel(FlexDir::Column)
1112        .w(page_w)
1113        .h(page_h)
1114        .padding(0.0, 4.0, 4.0, 0.0)
1115        .gap(0.0);
1116
1117    col = col.child(
1118        lbl("Categories")
1119            .color(theme.divider)
1120            .h(header_h)
1121            .align(Align::Center)
1122            .no_wrap(),
1123    );
1124    // Gap covering the separator region up to grid start (page-local y=25).
1125    col = col.child(widget::spacer().h(25.0 * sy - header_h));
1126
1127    // 4-column icon grid; icons drawn as GL overlays at exact Patchouli positions.
1128    let cats = &book.categories;
1129    let icon_s = 16.0 * sx.min(sy);
1130    let mut row_i = 0usize;
1131    loop {
1132        let row_start = row_i * 4;
1133        if row_start >= cats.len() {
1134            break;
1135        }
1136        let mut row =
1137            widget::panel(FlexDir::Row)
1138                .h(cell_h)
1139                .gap(0.0)
1140                .padding(0.0, 0.0, 0.0, grid_pad_left);
1141        for col_i in 0..4 {
1142            let idx = row_start + col_i;
1143            if idx >= cats.len() {
1144                break;
1145            }
1146            let cat = &cats[idx];
1147            let selected = !state.at_home && idx == state.cat;
1148            let bg = if selected { theme.nav_selected_bg } else { 0 };
1149
1150            if let Some(icon_id) = &cat.icon {
1151                push_icon(
1152                    overlays,
1153                    icon_id,
1154                    rx + (10.0 + col_i as f32 * 24.0 + 4.0) * sx,
1155                    py + (25.0 + row_i as f32 * 24.0 + 4.0) * sy,
1156                    icon_s,
1157                    icon_s,
1158                );
1159            }
1160            let cell = widget::panel(FlexDir::Column)
1161                .w(cell_w)
1162                .h(cell_h)
1163                .bg(bg)
1164                .on_click(format!("cat:{}", idx))
1165                .id(format!("book_cat_{}", idx));
1166            row = row.child(cell);
1167        }
1168        col = col.child(row);
1169        row_i += 1;
1170    }
1171    col
1172}
1173
1174// ── Left page: entry content (entry view) ────────────────────────────────────
1175
1176fn build_entry_left(
1177    book: &Book,
1178    state: &BookViewState,
1179    theme: &BookTheme,
1180    recipes: &HashMap<String, RecipeVis>,
1181    page_w: f32,
1182    page_h: f32,
1183    ox: f32,
1184    oy: f32,
1185    sx: f32,
1186    sy: f32,
1187    sep_cx: f32,
1188    bg_sprites: &mut Vec<BgSprite>,
1189    overlays: &mut Vec<OverlayCmd>,
1190) -> widget::Widget {
1191    let entry = state.current_entry(book);
1192    let page = entry.and_then(|e| e.pages.get(state.page));
1193    let page_count = state.page_count(book);
1194    let title_text = entry.map(|e| e.name.as_str()).unwrap_or("");
1195
1196    // Patchouli PageText page 0:
1197    //   title centered at page-local (PAGE_W/2, 0)
1198    //   separator at page-local (0, 12)  → centered ≡ sep_cx offset
1199    //   text body at page-local (0, 22)
1200    let title_h = 9.0 * sy;
1201    let sep_h_px = (SEP_H * sy).max(1.0);
1202    let sep_y = oy + 12.0 * sy;
1203    let body_oy = oy + 22.0 * sy;
1204
1205    bg_sprites.push(BgSprite {
1206        sheet: SpriteSheet::Book,
1207        u: SEP_U,
1208        v: SEP_V,
1209        uw: SEP_W,
1210        uh: SEP_H,
1211        x: ox + sep_cx,
1212        y: sep_y,
1213        w: SEP_W * sx,
1214        h: sep_h_px,
1215    });
1216
1217    let page_label = format!("{}/{}", state.page + 1, page_count);
1218    let nav = widget::panel(FlexDir::Row)
1219        .h(12.0 * sy)
1220        .gap(4.0)
1221        .padding(0.0, 2.0, 0.0, 2.0)
1222        .child(
1223            btn("◀")
1224                .w(12.0 * sx)
1225                .h(12.0 * sy)
1226                .color(theme.nav)
1227                .on_click("prev_page")
1228                .id("prev_page"),
1229        )
1230        .child(
1231            lbl(&page_label)
1232                .color(theme.nav)
1233                .flex(1.0)
1234                .align(Align::Center),
1235        )
1236        .child(
1237            btn("⌂")
1238                .w(12.0 * sx)
1239                .h(12.0 * sy)
1240                .color(theme.nav)
1241                .on_click("home")
1242                .id("book_home"),
1243        )
1244        .child(
1245            btn("▶")
1246                .w(12.0 * sx)
1247                .h(12.0 * sy)
1248                .color(theme.nav)
1249                .on_click("next_page")
1250                .id("next_page"),
1251        );
1252
1253    let page_body = build_page(
1254        page, state.page, theme, recipes, bg_sprites, overlays, ox, body_oy, sx, sy,
1255    );
1256
1257    widget::panel(FlexDir::Column)
1258        .w(page_w)
1259        .h(page_h)
1260        .padding(0.0, 6.0, 4.0, 4.0)
1261        .gap(0.0)
1262        .child(
1263            title_lbl(title_text)
1264                .color(theme.title)
1265                .h(title_h)
1266                .align(Align::Center)
1267                .no_wrap(),
1268        )
1269        .child(widget::spacer().h((12.0 - 9.0) * sy)) // gap: title end → sep start
1270        .child(widget::spacer().h(sep_h_px)) // height occupied by sep sprite
1271        .child(widget::spacer().h((22.0 - 12.0 - SEP_H) * sy)) // gap: sep end → body
1272        .child(page_body)
1273        .child(nav)
1274}
1275
1276// ── Right page: entry list for selected category (entry view) ─────────────────
1277
1278fn build_entries_right(
1279    book: &Book,
1280    state: &BookViewState,
1281    theme: &BookTheme,
1282    page_w: f32,
1283    page_h: f32,
1284    rx: f32,
1285    py: f32,
1286    sx: f32,
1287    sy: f32,
1288    overlays: &mut Vec<OverlayCmd>,
1289) -> widget::Widget {
1290    let entries = state.entries_visible(book);
1291    let cat_name = book
1292        .categories
1293        .get(state.cat)
1294        .map(|c| c.name.as_str())
1295        .unwrap_or("Entries");
1296    let spread_count = state.list_spread_count(book);
1297
1298    // Patchouli GuiBookEntryList layout (right page):
1299    //   category name at page-local y=0, centered
1300    //   separator at page-local y=12 (drawn as bg sprite in build_ui)
1301    //   entries start at page-local y=20, each h=11
1302    let header_h = 9.0 * sy;
1303    let row_h = 11.0 * sy;
1304    let icon_size = 8.0 * sx.min(sy);
1305
1306    let mut col = widget::panel(FlexDir::Column)
1307        .w(page_w)
1308        .h(page_h)
1309        .padding(0.0, 4.0, 4.0, 0.0)
1310        .gap(0.0);
1311
1312    col = col.child(
1313        lbl(cat_name)
1314            .color(theme.divider)
1315            .h(header_h)
1316            .align(Align::Center)
1317            .no_wrap(),
1318    );
1319    // sep region: y=9..15 (sep h=3), then gap to entries start at y=20
1320    col = col.child(widget::spacer().h((12.0 - 9.0) * sy)); // gap to sep
1321    col = col.child(widget::spacer().h((SEP_H * sy).max(1.0))); // sep height
1322    col = col.child(widget::spacer().h((20.0 - 12.0 - SEP_H) * sy)); // gap to entries
1323
1324    let abs_start = state.list_spread_start();
1325    for (i, entry) in entries.iter().enumerate() {
1326        let abs_i = abs_start + i;
1327        let selected = abs_i == state.entry;
1328        let bg = if selected { theme.nav_selected_bg } else { 0 };
1329        let color = if selected {
1330            theme.nav_selected
1331        } else {
1332            theme.nav
1333        };
1334
1335        // Icon at page-local (1, 20 + i*11 + 1), 8×8 (Patchouli renders entry
1336        // icons at 0.5× scale).
1337        if let Some(icon_id) = &entry.icon {
1338            push_icon(
1339                overlays,
1340                icon_id,
1341                rx + 1.0 * sx,
1342                py + (20.0 + i as f32 * 11.0 + 1.0) * sy,
1343                icon_size,
1344                icon_size,
1345            );
1346        }
1347
1348        let mut row = widget::panel(FlexDir::Row)
1349            .h(row_h)
1350            .gap(2.0)
1351            .bg(bg)
1352            .on_click(format!("entry:{}", abs_i))
1353            .id(format!("book_entry_{}", abs_i))
1354            .padding(1.0, 2.0, 1.0, 2.0);
1355
1356        row = row.child(widget::spacer().w(icon_size + 2.0));
1357        row = row.child(lbl(&entry.name).color(color).flex(1.0).no_wrap());
1358        col = col.child(row);
1359    }
1360
1361    if spread_count > 1 {
1362        col = col.child(widget::spacer().flex(1.0));
1363        let spread_label = format!("{}/{}", state.list_spread + 1, spread_count);
1364        col = col.child(
1365            widget::panel(FlexDir::Row)
1366                .h(12.0 * sy)
1367                .gap(2.0)
1368                .child(
1369                    btn("◀")
1370                        .w(12.0 * sx)
1371                        .h(12.0 * sy)
1372                        .color(theme.nav)
1373                        .on_click("prev_list")
1374                        .id("prev_list"),
1375                )
1376                .child(
1377                    lbl(&spread_label)
1378                        .color(theme.nav)
1379                        .flex(1.0)
1380                        .align(Align::Center),
1381                )
1382                .child(
1383                    btn("▶")
1384                        .w(12.0 * sx)
1385                        .h(12.0 * sy)
1386                        .color(theme.nav)
1387                        .on_click("next_list")
1388                        .id("next_list"),
1389                ),
1390        );
1391    }
1392    col
1393}
1394
1395/// Draw a Patchouli-style crafting recipe: title, 100×62 grid background
1396/// (UV 0,0 on the crafting sheet), 3×N ingredient icons, output item.
1397/// `rx0`/`ry` are body-local recipe origin; `ox`/`oy` the body screen origin.
1398#[allow(clippy::too_many_arguments)]
1399fn draw_crafting_grid(
1400    theme: &BookTheme,
1401    bg_sprites: &mut Vec<BgSprite>,
1402    overlays: &mut Vec<OverlayCmd>,
1403    col: &mut widget::Widget,
1404    grid: &[Option<String>],
1405    width: usize,
1406    output: &str,
1407    count: u32,
1408    shapeless: bool,
1409    ox: f32,
1410    oy: f32,
1411    rx0: f32,
1412    ry: f32,
1413    sx: f32,
1414    sy: f32,
1415) {
1416    let mut c = std::mem::replace(col, widget::panel(FlexDir::Column));
1417    c = c.child(
1418        title_lbl(pretty_item_name(output))
1419            .color(theme.title)
1420            .h(10.0 * sy)
1421            .align(Align::Center),
1422    );
1423
1424    // Grid background: blit at (rx0-2, ry-2) size 100×62, UV(0,0).
1425    bg_sprites.push(BgSprite {
1426        sheet: SpriteSheet::Crafting,
1427        u: 0.0,
1428        v: 0.0,
1429        uw: 100.0,
1430        uh: 62.0,
1431        x: ox + (rx0 - 2.0) * sx,
1432        y: oy + (ry - 2.0) * sy,
1433        w: 100.0 * sx,
1434        h: 62.0 * sy,
1435    });
1436    // Shapeless marker: UV(0,64) 11×11 at (rx0+62, ry+2).
1437    if shapeless {
1438        bg_sprites.push(BgSprite {
1439            sheet: SpriteSheet::Crafting,
1440            u: 0.0,
1441            v: 64.0,
1442            uw: 11.0,
1443            uh: 11.0,
1444            x: ox + (rx0 + 62.0) * sx,
1445            y: oy + (ry + 2.0) * sy,
1446            w: 11.0 * sx,
1447            h: 11.0 * sy,
1448        });
1449    }
1450
1451    let icon_s = 16.0 * sx.min(sy);
1452    let width = width.max(1);
1453    for (i, slot) in grid.iter().enumerate() {
1454        if let Some(item) = slot {
1455            overlays.push(OverlayCmd::McItem {
1456                item_id: item.clone(),
1457                x: ox + (rx0 + 3.0 + (i % width) as f32 * 19.0) * sx,
1458                y: oy + (ry + 3.0 + (i / width) as f32 * 19.0) * sy,
1459                w: icon_s,
1460                h: icon_s,
1461            });
1462        }
1463    }
1464    // Output item at (rx0+79, ry+22); stack count below it when > 1.
1465    overlays.push(OverlayCmd::McItem {
1466        item_id: output.to_owned(),
1467        x: ox + (rx0 + 79.0) * sx,
1468        y: oy + (ry + 22.0) * sy,
1469        w: icon_s,
1470        h: icon_s,
1471    });
1472    if count > 1 {
1473        overlays.push(OverlayCmd::McText {
1474            text: format!("x{}", count),
1475            x: ox + (rx0 + 81.0) * sx,
1476            y: oy + (ry + 40.0) * sy,
1477            color: theme.title,
1478        });
1479    }
1480    // Patchouli's "toast symbol" under the output: the crafting table.
1481    overlays.push(OverlayCmd::McItem {
1482        item_id: "minecraft:crafting_table".into(),
1483        x: ox + (rx0 + 79.0) * sx,
1484        y: oy + (ry + 41.0) * sy,
1485        w: icon_s,
1486        h: icon_s,
1487    });
1488
1489    // Spacer from title end (10) to below the grid (ry+62+4).
1490    c = c.child(widget::spacer().h((ry + 62.0 + 4.0 - 10.0) * sy));
1491    *col = c;
1492}
1493
1494// ── Page content builder ──────────────────────────────────────────────────────
1495
1496/// Build the content widget for a single page.
1497/// `ox/oy` = screen top-left of the page body area.
1498/// `sx/sy` = book-local-to-screen scale factors.
1499fn build_page(
1500    page: Option<&BookPage>,
1501    page_num: usize,
1502    theme: &BookTheme,
1503    recipes: &HashMap<String, RecipeVis>,
1504    bg_sprites: &mut Vec<BgSprite>,
1505    overlays: &mut Vec<OverlayCmd>,
1506    ox: f32,
1507    oy: f32,
1508    sx: f32,
1509    sy: f32,
1510) -> widget::Widget {
1511    let mut col = widget::panel(FlexDir::Column).flex(1.0).gap(4.0);
1512
1513    let Some(page) = page else {
1514        return col.child(lbl("No entries yet.").color(theme.nav));
1515    };
1516
1517    match page {
1518        BookPage::Text { text, title } => {
1519            // On non-first pages, show an optional section title + separator.
1520            if page_num > 0
1521                && let Some(t) = title
1522            {
1523                let sep_h_px = (SEP_H * sy).max(1.0);
1524                let title_h = 9.0 * sy;
1525                col = col.child(
1526                    title_lbl(t.as_str())
1527                        .color(theme.title)
1528                        .h(title_h)
1529                        .align(Align::Center)
1530                        .no_wrap(),
1531                );
1532                // sep at y=12 page-local, centered
1533                bg_sprites.push(BgSprite {
1534                    sheet: SpriteSheet::Book,
1535                    u: SEP_U,
1536                    v: SEP_V,
1537                    uw: SEP_W,
1538                    uh: SEP_H,
1539                    x: ox + (PAGE_W / 2.0 - SEP_W / 2.0) * sx,
1540                    y: oy + 12.0 * sy,
1541                    w: SEP_W * sx,
1542                    h: sep_h_px,
1543                });
1544                col = col.child(widget::spacer().h((12.0 - 9.0) * sy));
1545                col = col.child(widget::spacer().h(sep_h_px));
1546                col = col.child(widget::spacer().h((22.0 - 12.0 - SEP_H) * sy));
1547            }
1548            for para in text.split('\n') {
1549                col = col.child(lbl(para).color(theme.text));
1550            }
1551        }
1552
1553        BookPage::Spotlight { item, title, text } => {
1554            // Patchouli PageSpotlight (page-body-local coordinates):
1555            //   title at y=0 centered, item frame at (PAGE_W/2-33, 10) 66×26
1556            //   (UV 0,102 on the 128×256 crafting sheet), item at (PAGE_W/2-8, 15),
1557            //   text at y=40.
1558            bg_sprites.push(BgSprite {
1559                sheet: SpriteSheet::Crafting,
1560                u: 0.0,
1561                v: 102.0,
1562                uw: 66.0,
1563                uh: 26.0,
1564                x: ox + (PAGE_W / 2.0 - 33.0) * sx,
1565                y: oy + 10.0 * sy,
1566                w: 66.0 * sx,
1567                h: 26.0 * sy,
1568            });
1569
1570            let item_name = title
1571                .as_deref()
1572                .or(item.name.as_deref())
1573                .unwrap_or(item.id.as_str());
1574            col = col.child(
1575                title_lbl(item_name)
1576                    .color(theme.title)
1577                    .h(10.0 * sy)
1578                    .align(Align::Center),
1579            );
1580
1581            let icon_s = 16.0 * sx.min(sy);
1582            overlays.push(OverlayCmd::McItem {
1583                item_id: item.id.clone(),
1584                x: ox + (PAGE_W / 2.0 - 8.0) * sx,
1585                y: oy + 15.0 * sy,
1586                w: icon_s,
1587                h: icon_s,
1588            });
1589
1590            // Spacer from title end (y=10) to text start (y=40).
1591            col = col.child(widget::spacer().h(30.0 * sy));
1592
1593            if let Some(t) = text {
1594                col = col.child(lbl(t.as_str()).color(theme.text));
1595            }
1596        }
1597
1598        BookPage::Crafting { recipe_id, text } => {
1599            // Patchouli PageCrafting: recipe origin at page-local
1600            // (PAGE_W/2-49, 4); we place it in body-local coords below a title.
1601            let rx0 = PAGE_W / 2.0 - 49.0; // = 9
1602            let ry = 12.0; // body-local recipe y (title occupies 0..10)
1603            match recipes.get(recipe_id.as_str()) {
1604                Some(RecipeVis::Shaped {
1605                    grid,
1606                    width,
1607                    output,
1608                    count,
1609                }) => {
1610                    draw_crafting_grid(
1611                        theme, bg_sprites, overlays, &mut col, grid, *width, output, *count, false,
1612                        ox, oy, rx0, ry, sx, sy,
1613                    );
1614                }
1615                Some(RecipeVis::Shapeless {
1616                    ingredients,
1617                    output,
1618                    count,
1619                }) => {
1620                    let grid: Vec<Option<String>> = ingredients.iter().cloned().map(Some).collect();
1621                    draw_crafting_grid(
1622                        theme, bg_sprites, overlays, &mut col, &grid, 3, output, *count, true, ox,
1623                        oy, rx0, ry, sx, sy,
1624                    );
1625                }
1626                _ => {
1627                    col = col.child(lbl(format!("[Recipe: {}]", recipe_id)).color(theme.nav));
1628                }
1629            }
1630            if let Some(t) = text {
1631                col = col.child(lbl(t.as_str()).color(theme.text));
1632            }
1633        }
1634
1635        BookPage::Smelting { recipe_id, text } => {
1636            // Patchouli PageSmelting: bg UV(11,71) 96×24, input at +4,+4,
1637            // output at +76,+4.
1638            let rx0 = PAGE_W / 2.0 - 49.0;
1639            let ry = 12.0;
1640            match recipes.get(recipe_id.as_str()) {
1641                Some(RecipeVis::Smelting { input, output }) => {
1642                    col = col.child(
1643                        title_lbl(pretty_item_name(output))
1644                            .color(theme.title)
1645                            .h(10.0 * sy)
1646                            .align(Align::Center),
1647                    );
1648                    bg_sprites.push(BgSprite {
1649                        sheet: SpriteSheet::Crafting,
1650                        u: 11.0,
1651                        v: 71.0,
1652                        uw: 96.0,
1653                        uh: 24.0,
1654                        x: ox + rx0 * sx,
1655                        y: oy + ry * sy,
1656                        w: 96.0 * sx,
1657                        h: 24.0 * sy,
1658                    });
1659                    let icon_s = 16.0 * sx.min(sy);
1660                    overlays.push(OverlayCmd::McItem {
1661                        item_id: input.clone(),
1662                        x: ox + (rx0 + 4.0) * sx,
1663                        y: oy + (ry + 4.0) * sy,
1664                        w: icon_s,
1665                        h: icon_s,
1666                    });
1667                    // Center: the furnace itself (Patchouli's "toast symbol").
1668                    overlays.push(OverlayCmd::McItem {
1669                        item_id: "minecraft:furnace".into(),
1670                        x: ox + (rx0 + 40.0) * sx,
1671                        y: oy + (ry + 4.0) * sy,
1672                        w: icon_s,
1673                        h: icon_s,
1674                    });
1675                    overlays.push(OverlayCmd::McItem {
1676                        item_id: output.clone(),
1677                        x: ox + (rx0 + 76.0) * sx,
1678                        y: oy + (ry + 4.0) * sy,
1679                        w: icon_s,
1680                        h: icon_s,
1681                    });
1682                    // Spacer from title end (10) to below the furnace strip (ry+24+4).
1683                    col = col.child(widget::spacer().h((ry + 24.0 + 4.0 - 10.0) * sy));
1684                }
1685                _ => {
1686                    col = col.child(lbl(format!("[Recipe: {}]", recipe_id)).color(theme.nav));
1687                }
1688            }
1689            if let Some(t) = text {
1690                col = col.child(lbl(t.as_str()).color(theme.text));
1691            }
1692        }
1693
1694        BookPage::Image {
1695            texture,
1696            title,
1697            text,
1698            ..
1699        } => {
1700            if let Some(t) = title {
1701                col = col.child(title_lbl(t.as_str()).color(theme.title));
1702            }
1703            col = col.child(
1704                widget::image(texture)
1705                    .region([0.0, 0.0, 80.0, 80.0])
1706                    .stretch(Stretch::Fill)
1707                    .w(80.0)
1708                    .h(80.0),
1709            );
1710            if let Some(t) = text {
1711                col = col.child(lbl(t.as_str()).color(theme.text));
1712            }
1713        }
1714
1715        BookPage::Svg { data, title, text } => {
1716            if let Some(t) = title {
1717                col = col.child(title_lbl(t.as_str()).color(theme.title));
1718            }
1719            overlays.push(OverlayCmd::Svg {
1720                data: data.clone(),
1721                x: ox,
1722                y: oy,
1723                w: 64.0,
1724                h: 64.0,
1725            });
1726            col = col.child(widget::spacer().h(68.0));
1727            if let Some(t) = text {
1728                col = col.child(lbl(t.as_str()).color(theme.text));
1729            }
1730        }
1731
1732        BookPage::CustomText { text, font, color } => {
1733            overlays.push(OverlayCmd::Text {
1734                text: text.clone(),
1735                font: font.clone(),
1736                x: ox,
1737                y: oy,
1738                color: *color,
1739            });
1740            col = col.child(widget::spacer().h(font.size_px * 1.5));
1741        }
1742
1743        BookPage::Relations { entries, text } => {
1744            if let Some(t) = text {
1745                col = col.child(lbl(t.as_str()).color(theme.text));
1746            }
1747            col = col.child(title_lbl("See also:").color(theme.title));
1748            for e in entries {
1749                col = col.child(lbl(format!("• {}", e)).color(theme.nav));
1750            }
1751        }
1752
1753        BookPage::Entity {
1754            entity_type,
1755            name,
1756            text,
1757        } => {
1758            let display = name.as_deref().unwrap_or(entity_type.as_str());
1759            col = col.child(title_lbl(display).color(theme.title));
1760            if let Some(t) = text {
1761                col = col.child(lbl(t.as_str()).color(theme.text));
1762            }
1763        }
1764
1765        BookPage::Pattern {
1766            op_id,
1767            input,
1768            output,
1769            text,
1770            ..
1771        } => {
1772            col = col.child(title_lbl(op_id.as_str()).color(theme.title));
1773            col = col.child(lbl(format!("{} → {}", input, output)).color(theme.nav));
1774            if !text.is_empty() {
1775                col = col.child(lbl(text.as_str()).color(theme.text));
1776            }
1777        }
1778
1779        BookPage::Empty => {}
1780    }
1781
1782    col
1783}
1784
1785#[cfg(test)]
1786mod tests {
1787    use super::*;
1788    use crate::BookEntry;
1789
1790    fn book(pages: Vec<BookPage>) -> Book {
1791        let mut book = Book::new("t:b", "B");
1792        book.entries.push(BookEntry { id: "e".into(), name: "E".into(), pages, ..Default::default() });
1793        book
1794    }
1795
1796    #[test]
1797    fn long_text_flows_onto_new_pages() {
1798        let long = "word ".repeat(400);
1799        let flowed = flow(book(vec![BookPage::Text { text: long.clone(), title: None }, BookPage::Empty]));
1800        let pages = &flowed.entries[0].pages;
1801        assert!(pages.len() > 3, "{}", pages.len());
1802        assert!(matches!(pages.last(), Some(BookPage::Empty)), "what came after stays after");
1803        let line_h = yog_ui::text::LINE_H + yog_ui::text::LINE_GAP;
1804        for p in &pages[..pages.len() - 1] {
1805            let BookPage::Text { text, .. } = p else { panic!("{p:?}") };
1806            assert!(text.lines().count() as f32 * line_h <= BODY_H + 1.0);
1807            assert!(text.lines().all(|l| yog_ui::text::str_width(l, 1.0) <= TEXT_W));
1808        }
1809        let words: usize = pages.iter().map(|p| match p {
1810            BookPage::Text { text, .. } => text.split_whitespace().count(),
1811            _ => 0,
1812        }).sum();
1813        assert_eq!(words, 400, "nothing lost");
1814    }
1815
1816    #[test]
1817    fn short_text_stays_put() {
1818        let pages = vec![BookPage::Text { text: "Hello".into(), title: Some("T".into()) }];
1819        let flowed = flow(book(pages.clone()));
1820        assert_eq!(flowed.entries[0].pages.len(), 1);
1821    }
1822}