Skip to main content

yog_book/
renderer.rs

1//! Book renderer — ties together yog-ui layout, yog-gfx GPU pipeline,
2//! SVG icon rasterization, and custom font rendering.
3
4use std::collections::HashMap;
5use yog_gfx::{GfxContext, gl, core::{DrawMode, DataType, blend}};
6use yog_ui::{widget, FlexDir, Align, UiRoot};
7
8use crate::{Book, BookPage};
9use crate::state::BookViewState;
10use crate::theme::BookTheme;
11use crate::font::{BookFontRegistry, FontAtlas};
12use crate::svg;
13
14// Patchouli-compatible book texture layout (512×256 sprite sheet).
15// UV coordinates are in pixels on a 512×256 sheet.
16// The full open-book background occupies 272×180 at UV (0,0).
17pub const BOOK_TEX_W: f32   = 512.0;
18pub const BOOK_TEX_H: f32   = 256.0;
19// Open-book dimensions in texture-space
20pub const BK_W: f32         = 272.0;
21pub const BK_H: f32         = 180.0;
22// Per-page dimensions and offsets (in book-local coords)
23pub const PAGE_W: f32       = 116.0;
24pub const PAGE_H: f32       = 156.0;
25pub const TOP_PAD: f32      = 18.0;   // vertical space above page text area
26pub const LEFT_X: f32       = 15.0;   // left page X inside book
27pub const RIGHT_X: f32      = 141.0;  // right page X inside book
28// Separator strip UV origin on the sprite sheet
29pub const SEP_U: f32        = 140.0;
30pub const SEP_V: f32        = 180.0;
31pub const SEP_W: f32        = 110.0;
32pub const SEP_H: f32        = 3.0;
33
34// ── Vertex for the custom 2D shader (pos + uv + color) ───────────────────────
35
36#[repr(C)]
37#[derive(Copy, Clone)]
38struct Vert { x: f32, y: f32, u: f32, v: f32, r: f32, g: f32, b: f32, a: f32 }
39
40fn rgba_f(c: u32) -> (f32, f32, f32, f32) {
41    let a = ((c >> 24) & 0xFF) as f32 / 255.0;
42    let r = ((c >> 16) & 0xFF) as f32 / 255.0;
43    let g = ((c >>  8) & 0xFF) as f32 / 255.0;
44    let b = ( c        & 0xFF) as f32 / 255.0;
45    (r, g, b, a)
46}
47
48fn quad(verts: &mut Vec<Vert>, x: f32, y: f32, w: f32, h: f32,
49        u0: f32, v0: f32, u1: f32, v1: f32, color: u32) {
50    let (r, g, b, a) = rgba_f(color);
51    let p = [
52        Vert { x,        y,        u: u0, v: v0, r, g, b, a },
53        Vert { x: x+w,   y,        u: u1, v: v0, r, g, b, a },
54        Vert { x,        y: y+h,   u: u0, v: v1, r, g, b, a },
55        Vert { x: x+w,   y: y+h,   u: u1, v: v1, r, g, b, a },
56    ];
57    // two triangles: 0,1,2  1,3,2
58    verts.extend_from_slice(&[p[0], p[1], p[2], p[1], p[3], p[2]]);
59}
60
61// ── GLSL shaders ─────────────────────────────────────────────────────────────
62
63const VERT: &str = r#"
64#version 330 core
65layout(location = 0) in vec2 aPos;
66layout(location = 1) in vec2 aUv;
67layout(location = 2) in vec4 aCol;
68out vec2 fUv;
69out vec4 fCol;
70uniform vec2 uScreen;
71void main() {
72    fUv  = aUv;
73    fCol = aCol;
74    vec2 ndc = aPos / uScreen * 2.0 - vec2(1.0);
75    gl_Position = vec4(ndc.x, -ndc.y, 0.0, 1.0);
76}
77"#;
78
79const FRAG: &str = r#"
80#version 330 core
81in vec2 fUv;
82in vec4 fCol;
83out vec4 outColor;
84uniform sampler2D uTex;
85uniform int uMode;  // 0 = solid color, 1 = RGBA texture, 2 = alpha-only (font)
86void main() {
87    if (uMode == 0) {
88        outColor = fCol;
89    } else if (uMode == 1) {
90        outColor = texture(uTex, fUv) * fCol;
91    } else {
92        float alpha = texture(uTex, fUv).a;
93        outColor = vec4(fCol.rgb, fCol.a * alpha);
94    }
95}
96"#;
97
98// ── Embedded textures ─────────────────────────────────────────────────────────
99
100static BOOK_PNG:     &[u8] = include_bytes!("../assets/book_brown.png");
101static CRAFTING_PNG: &[u8] = include_bytes!("../assets/crafting.png");
102
103fn decode_png(data: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
104    use png::Decoder;
105    let decoder = Decoder::new(std::io::Cursor::new(data));
106    let mut reader = decoder.read_info().ok()?;
107    let mut buf = vec![0u8; reader.output_buffer_size()];
108    let info = reader.next_frame(&mut buf).ok()?;
109    let rgba = match info.color_type {
110        png::ColorType::Rgba => buf[..info.buffer_size()].to_vec(),
111        png::ColorType::Rgb  => {
112            let rgb = &buf[..info.buffer_size()];
113            let mut out = Vec::with_capacity(rgb.len() / 3 * 4);
114            for px in rgb.chunks(3) { out.extend_from_slice(px); out.push(255); }
115            out
116        }
117        _ => return None,
118    };
119    Some((rgba, info.width, info.height))
120}
121
122// ── GL resource cache ─────────────────────────────────────────────────────────
123
124struct BookGl {
125    prog:         gl::ShaderProgram,
126    vao:          gl::VertexArray,
127    vbo:          gl::Buffer,
128    book_tex:     Option<(gl::Texture, u32, u32)>,
129    crafting_tex: Option<(gl::Texture, u32, u32)>,
130    svg_tex:      HashMap<u64, (gl::Texture, u32, u32)>,
131    font_atlas:   HashMap<u64, (gl::Texture, FontAtlas)>,
132}
133
134impl BookGl {
135    fn init(ctx: &GfxContext) -> Option<Self> {
136        let prog = ctx.create_shader(VERT, FRAG).ok()?;
137        let vbo  = ctx.create_buffer();
138        let vao  = ctx.create_vao();
139
140        const STRIDE: u32 = 32; // 8 × f32
141        vao.attrib(ctx, &vbo, 0, 2, DataType::F32, false, STRIDE, 0);  // pos
142        vao.attrib(ctx, &vbo, 1, 2, DataType::F32, false, STRIDE, 8);  // uv
143        vao.attrib(ctx, &vbo, 2, 4, DataType::F32, false, STRIDE, 16); // col
144
145        let load = |data: &[u8]| decode_png(data).map(|(rgba, w, h)| {
146            let tex = ctx.create_texture_rgba(w, h, &rgba, true);
147            (tex, w, h)
148        });
149        let book_tex     = load(BOOK_PNG);
150        let crafting_tex = load(CRAFTING_PNG);
151
152        Some(BookGl { prog, vao, vbo, book_tex, crafting_tex, svg_tex: HashMap::new(), font_atlas: HashMap::new() })
153    }
154
155    fn svg_tex(&mut self, ctx: &GfxContext, hash: u64, data: &str, w: u32, h: u32)
156        -> Option<&(gl::Texture, u32, u32)>
157    {
158        if !self.svg_tex.contains_key(&hash) {
159            let pixels = svg::rasterize(data, w, h)?;
160            let tex = ctx.create_texture_rgba(w, h, &pixels, true);
161            self.svg_tex.insert(hash, (tex, w, h));
162        }
163        self.svg_tex.get(&hash)
164    }
165
166    fn flush(&self, ctx: &GfxContext, verts: &[Vert]) {
167        if verts.is_empty() { return; }
168        unsafe { self.vbo.upload(ctx, verts, true); }
169        ctx.draw_arrays(&self.vao, &self.prog, DrawMode::Triangles, 0, verts.len() as u32);
170    }
171}
172
173// ── Draw helpers ──────────────────────────────────────────────────────────────
174
175impl BookGl {
176    fn begin_frame(&self, ctx: &GfxContext, sw: f32, sh: f32) {
177        ctx.set_blend(true, blend::SRC_ALPHA, blend::ONE_MINUS_SRC_ALPHA);
178        ctx.set_depth(false, false);
179        self.prog.uniform_2f(ctx, "uScreen", sw, sh);
180        self.prog.uniform_1i(ctx, "uTex", 0);
181    }
182
183    /// Blit a subrect of a sprite sheet at an arbitrary screen position.
184    fn draw_book_sprite(&self, ctx: &GfxContext, spr: &BgSprite) {
185        let sheet = match spr.sheet {
186            SpriteSheet::Book     => &self.book_tex,
187            SpriteSheet::Crafting => &self.crafting_tex,
188        };
189        let Some((tex, tw, th)) = sheet else { return };
190        let u0 = spr.u  / *tw as f32;
191        let v0 = spr.v  / *th as f32;
192        let u1 = (spr.u + spr.uw) / *tw as f32;
193        let v1 = (spr.v + spr.uh) / *th as f32;
194        self.prog.uniform_1i(ctx, "uMode", 1);
195        ctx.bind_texture(0, tex);
196        let mut v = Vec::with_capacity(6);
197        quad(&mut v, spr.x, spr.y, spr.w, spr.h, u0, v0, u1, v1, 0xFF_FFFFFF);
198        self.flush(ctx, &v);
199    }
200
201    /// Blit the open-book background from the embedded book texture.
202    fn draw_book_bg(&self, ctx: &GfxContext, bx: f32, by: f32, bw: f32, bh: f32) {
203        let Some((tex, tw, th)) = &self.book_tex else { return };
204        let su = BK_W / *tw as f32;
205        let sv = BK_H / *th as f32;
206        self.prog.uniform_1i(ctx, "uMode", 1);
207        ctx.bind_texture(0, tex);
208        let mut v = Vec::with_capacity(6);
209        quad(&mut v, bx, by, bw, bh, 0.0, 0.0, su, sv, 0xFF_FFFFFF);
210        self.flush(ctx, &v);
211    }
212
213    fn draw_svg(&mut self, ctx: &GfxContext, data: &str, x: f32, y: f32, w: f32, h: f32) {
214        let hash = svg::svg_hash(data);
215        let tw = w as u32; let th = h as u32;
216        if let Some(&(tex, _, _)) = self.svg_tex(ctx, hash, data, tw, th) {
217            self.prog.uniform_1i(ctx, "uMode", 1);
218            ctx.bind_texture(0, &tex);
219            let mut v = Vec::with_capacity(6);
220            quad(&mut v, x, y, w, h, 0.0, 0.0, 1.0, 1.0, 0xFF_FFFFFF);
221            self.flush(ctx, &v);
222        }
223    }
224
225    fn draw_text_custom(&mut self, ctx: &GfxContext, ttf: &[u8], size_px: f32,
226                        text: &str, mut x: f32, y: f32, color: u32) {
227        let hash = font_hash(ttf);
228        if !self.font_atlas.contains_key(&hash) {
229            if let Some(atlas) = FontAtlas::build(ttf, size_px) {
230                let tex = ctx.create_texture_rgba(
231                    atlas.atlas_size, atlas.atlas_size, &atlas.pixels, true);
232                self.font_atlas.insert(hash, (tex, atlas));
233            }
234        }
235        // Get raw pointers so the borrow of self.font_atlas ends before we call
236        // self.prog / self.flush, which also borrow fields of self.
237        let ptrs = self.font_atlas.get(&hash)
238            .map(|(t, a)| (t as *const gl::Texture, a as *const FontAtlas));
239        if let Some((tex_ptr, atlas_ptr)) = ptrs {
240            // SAFETY: we hold &mut self; font_atlas is not mutated below.
241            let tex   = unsafe { &*tex_ptr   };
242            let atlas = unsafe { &*atlas_ptr };
243            self.prog.uniform_1i(ctx, "uMode", 2);
244            ctx.bind_texture(0, tex);
245            let baseline = y + size_px;
246            let mut verts = Vec::new();
247            for ch in text.chars() {
248                if let Some(g) = atlas.glyphs.get(&ch) {
249                    if g.width > 0 {
250                        let gx = x + g.xoff;
251                        let gy = baseline - g.yoff - g.height as f32;
252                        quad(&mut verts, gx, gy, g.width as f32, g.height as f32,
253                             g.u0, g.v0, g.u1, g.v1, color);
254                    }
255                    x += g.advance;
256                }
257            }
258            self.flush(ctx, &verts);
259        }
260    }
261}
262
263fn font_hash(data: &[u8]) -> u64 {
264    use std::hash::{Hash, Hasher};
265    let mut h = std::collections::hash_map::DefaultHasher::new();
266    data.hash(&mut h);
267    h.finish()
268}
269
270// ── Background sprite (texture blit, drawn before yog-ui) ────────────────────
271
272#[derive(Clone, Copy, PartialEq)]
273pub(crate) enum SpriteSheet { Book, Crafting }
274
275/// A subrect blit from one of the embedded sprite sheets, drawn before yog-ui.
276#[derive(Clone)]
277pub(crate) struct BgSprite {
278    pub sheet: SpriteSheet,
279    /// Source rect in pixels on the sheet.
280    pub u: f32, pub v: f32, pub uw: f32, pub uh: f32,
281    /// Destination on screen.
282    pub x: f32, pub y: f32, pub w: f32, pub h: f32,
283}
284
285// ── Pending overlay draw commands ─────────────────────────────────────────────
286
287#[derive(Clone)]
288pub(crate) enum OverlayCmd {
289    Svg    { data: String, x: f32, y: f32, w: f32, h: f32 },
290    Text   { text: String, font: crate::font::BookFont, x: f32, y: f32, color: u32 },
291    /// MC default-font text rendered via draw2d (e.g. nameplate title/subtitle).
292    McText { text: String, x: f32, y: f32, color: u32 },
293}
294
295// ── BookRenderer ──────────────────────────────────────────────────────────────
296
297pub struct BookRenderer {
298    pub book:  Book,
299    pub state: BookViewState,
300    pub theme: BookTheme,
301    fonts:        BookFontRegistry,
302    gl:           Option<BookGl>,
303    pub ui:       Option<UiRoot>,
304    bg_sprites:   Vec<BgSprite>,
305    overlays:     Vec<OverlayCmd>,
306    dirty:   bool,
307    last_sw: f32,
308    last_sh: f32,
309}
310
311impl BookRenderer {
312    pub fn new(book: Book) -> Self {
313        let theme = BookTheme::default().with_nameplate(&book.nameplate_color);
314        Self {
315            book,
316            state: BookViewState::default(),
317            theme,
318            fonts: BookFontRegistry::default(),
319            gl: None,
320            ui: None,
321            bg_sprites: Vec::new(),
322            overlays: Vec::new(),
323            dirty: true,
324            last_sw: 0.0,
325            last_sh: 0.0,
326        }
327    }
328
329    pub fn handle_event(&mut self, ev: &str) {
330        if self.state.handle(ev, &self.book) {
331            self.dirty = true;
332        }
333    }
334
335    /// Register a custom TTF/OTF font for use in `BookPage::CustomText`.
336    pub fn register_font(&mut self, id: impl Into<String>, ttf: Vec<u8>) {
337        self.fonts.register(id, ttf);
338    }
339
340    pub fn render(&mut self, ctx: &GfxContext, sw: f32, sh: f32) {
341        // Lazy GL init.
342        if self.gl.is_none() {
343            self.gl = BookGl::init(ctx);
344        }
345
346        // Render at Patchouli-native size (272×180 GUI pixels). Scale DOWN only
347        // if the screen is smaller; never scale up (MC font is designed for 1×).
348        let scale = ((sw - 16.0) / BK_W).min((sh - 16.0) / BK_H).min(1.0).max(0.4);
349        let bw = BK_W * scale;
350        let bh = BK_H * scale;
351        let bx = (sw - bw) / 2.0;
352        let by = (sh - bh) / 2.0;
353
354        // Rebuild widget tree if dirty or screen resized.
355        if self.dirty || sw != self.last_sw || sh != self.last_sh {
356            let (root, bg_sprites, overlays) = build_ui(&self.book, &self.state, &self.theme, sw, sh, bx, by, bw, bh);
357            self.ui = Some(root);
358            self.bg_sprites = bg_sprites;
359            self.overlays = overlays;
360            self.dirty = false;
361            self.last_sw = sw;
362            self.last_sh = sh;
363        }
364
365        // 1. Draw book background texture first.
366        if let Some(gl) = &mut self.gl {
367            gl.begin_frame(ctx, sw, sh);
368            gl.draw_book_bg(ctx, bx, by, bw, bh);
369        }
370
371        // 1b. Background sprites (nameplate banner, separator lines) from book texture.
372        if let Some(gl) = &mut self.gl {
373            for spr in &self.bg_sprites {
374                gl.draw_book_sprite(ctx, spr);
375            }
376        }
377
378        // 2. yog-ui: text, buttons, icons (transparent BG — texture is already there).
379        if let Some(ui) = &mut self.ui {
380            if ui.needs_layout { ui.layout(sw, sh); }
381            ui.render(ctx);
382        }
383
384        // 3. Custom GL overlays (SVG icons, custom font text).
385        if let Some(gl) = &mut self.gl {
386            gl.begin_frame(ctx, sw, sh);
387            for ov in self.overlays.clone() {
388                match ov {
389                    OverlayCmd::Svg  { data, x, y, w, h } =>
390                        gl.draw_svg(ctx, &data, x, y, w, h),
391                    OverlayCmd::Text { text, font, x, y, color } => {
392                        if let Some(ttf) = self.fonts.get(&font.font_id) {
393                            gl.draw_text_custom(ctx, ttf, font.size_px, &text, x, y, color);
394                        }
395                    }
396                    OverlayCmd::McText { text, x, y, color } => {
397                        ctx.draw2d().text(&text, x, y, color, false);
398                    }
399                }
400            }
401        }
402    }
403}
404
405// ── UI builder ────────────────────────────────────────────────────────────────
406
407/// Book label: MC font, no drop-shadow (Patchouli draws book text shadowless).
408fn lbl(text: impl Into<String>) -> widget::Widget { widget::label(text).shadow(false) }
409/// Book button: no drop-shadow.
410fn btn(text: impl Into<String>) -> widget::Widget { widget::button(text).shadow(false) }
411
412/// Build the yog-ui widget tree + bg sprites + overlay commands for the current book state.
413/// `bx/by/bw/bh` are the screen-space book rect (same values used to blit the bg texture).
414fn build_ui(book: &Book, state: &BookViewState, theme: &BookTheme,
415             sw: f32, sh: f32,
416             bx: f32, by: f32, bw: f32, bh: f32) -> (UiRoot, Vec<BgSprite>, Vec<OverlayCmd>) {
417    let _ = (sw, sh);
418    let mut bg_sprites: Vec<BgSprite> = Vec::new();
419    let mut overlays:   Vec<OverlayCmd> = Vec::new();
420
421    // Scale from Patchouli's fixed BK_W×BK_H coordinate space → actual bw×bh.
422    let sx = bw / BK_W;
423    let sy = bh / BK_H;
424
425    // In book-local: left page starts at x=LEFT_X, right at RIGHT_X, both y=TOP_PAD.
426    let pw = PAGE_W * sx;
427    let ph = PAGE_H * sy;
428    let spine_gap = (RIGHT_X - LEFT_X - PAGE_W) * sx; // ≈10px scaled
429
430    // Content x/y in screen space (for SVG overlay positioning).
431    let lx = bx + LEFT_X  * sx;
432    let rx = bx + RIGHT_X * sx;
433    let py = by + TOP_PAD  * sy;
434
435    // Separator x offset within a page: centered = PAGE_W/2 - SEP_W/2 = 58-55 = 3.
436    let sep_cx = (PAGE_W / 2.0 - SEP_W / 2.0) * sx; // = 3*sx
437
438    if state.at_home {
439        // Nameplate banner: book-local (-8, 12) from LEFT_PAGE_X, size 140×31.
440        bg_sprites.push(BgSprite {
441            sheet: SpriteSheet::Book,
442            u: 0.0, v: 180.0, uw: 140.0, uh: 31.0,
443            x: bx + (LEFT_X - 8.0) * sx,
444            y: by + 12.0 * sy,
445            w: 140.0 * sx,
446            h: 31.0 * sy,
447        });
448        // Separator below "Categories" header on right page (y=12 page-local), centered.
449        bg_sprites.push(BgSprite {
450            sheet: SpriteSheet::Book,
451            u: SEP_U, v: SEP_V, uw: SEP_W, uh: SEP_H,
452            x: rx + sep_cx,
453            y: by + (TOP_PAD + 12.0) * sy,
454            w: SEP_W * sx,
455            h: (SEP_H * sy).max(1.0),
456        });
457    } else {
458        // Entry view: separator on right page below category name (y=12 page-local).
459        bg_sprites.push(BgSprite {
460            sheet: SpriteSheet::Book,
461            u: SEP_U, v: SEP_V, uw: SEP_W, uh: SEP_H,
462            x: rx + sep_cx,
463            y: by + (TOP_PAD + 12.0) * sy,
464            w: SEP_W * sx,
465            h: (SEP_H * sy).max(1.0),
466        });
467    }
468
469    let (left_page, right_page) = if state.at_home {
470        let l = build_landing_left(book, theme, pw, ph, lx, py, sx, sy, bx, by, &mut overlays);
471        let r = build_categories_right(book, state, theme, pw, ph, rx, py, sx, sy, &mut overlays);
472        (l, r)
473    } else {
474        let l = build_entry_left(book, state, theme, pw, ph, lx, py, sx, sy, sep_cx, &mut bg_sprites, &mut overlays);
475        let r = build_entries_right(book, state, theme, pw, ph, rx, py, sx, sy);
476        (l, r)
477    };
478
479    // Root row spans the full book width.
480    // padding-left = LEFT_X*sx offsets both pages from the left cover edge.
481    // padding-top  = TOP_PAD*sy offsets from the header banner.
482    // spine_gap spacer sits between the two pages.
483    let root_widget = widget::panel(FlexDir::Row)
484        .w(bw).h(bh)
485        .padding(TOP_PAD * sy, 0.0, 0.0, LEFT_X * sx)
486        .child(left_page)
487        .child(widget::spacer().w(spine_gap))  // spine gap
488        .child(right_page);
489
490    // Outer shell: positions the book on screen via a full-screen transparent Row.
491    // We use a Column + Row arrangement to apply top/left offsets without margin support.
492    let outer = widget::panel(FlexDir::Column)
493        .w(bw).h(bh)
494        .child(root_widget);
495
496    // Screen-level container: positions book at (bx, by).
497    // bx offset via padding, by offset via padding-top.
498    // Note: layout computes from (0,0), so we pass bx/by into the UiRoot manually
499    // by wrapping in a full-screen panel with the right padding.
500    let screen_root = widget::panel(FlexDir::Row)
501        .padding(by, 0.0, 0.0, bx)
502        .child(outer);
503
504    let ui = UiRoot::new(&book.id, screen_root);
505    (ui, bg_sprites, overlays)
506}
507
508// ── Left page: landing (home view) ───────────────────────────────────────────
509
510fn build_landing_left(
511    book: &Book, theme: &BookTheme,
512    page_w: f32, page_h: f32,
513    _lx: f32, _py: f32,
514    sx: f32, sy: f32,
515    bx: f32, by: f32,
516    overlays: &mut Vec<OverlayCmd>,
517) -> widget::Widget {
518    // Patchouli: title at book-local (13, 16), subtitle at (24, 24).
519    // These overlap the nameplate sprite which is above/within the page top.
520    overlays.push(OverlayCmd::McText {
521        text: book.name.clone(),
522        x: bx + 13.0 * sx,
523        y: by + 16.0 * sy,
524        color: theme.nameplate,
525    });
526    if let Some(author) = &book.author {
527        overlays.push(OverlayCmd::McText {
528            text: format!("by {}", author),
529            x: bx + 24.0 * sx,
530            y: by + 24.0 * sy,
531            color: theme.nameplate,
532        });
533    }
534
535    // Widget content: landing text + entry count.
536    // Landing text starts at page-local y=25 (= book-local y=43).
537    // We push it down with top padding = 25*sy.
538    let mut col = widget::panel(FlexDir::Column)
539        .w(page_w).h(page_h)
540        .padding(25.0 * sy, 6.0, 4.0, 4.0)
541        .gap(0.0);
542
543    for para in book.landing_text.split('\n') {
544        if para.is_empty() {
545            col = col.child(widget::spacer().h(4.0 * sy));
546        } else {
547            col = col.child(lbl(para).color(theme.text));
548        }
549    }
550
551    let total = book.entries.len();
552    col = col.child(widget::spacer().flex(1.0));
553    col = col.child(widget::spacer().h((SEP_H * sy).max(1.0)).bg(theme.border));
554    col = col.child(
555        lbl(format!("{} entries", total))
556            .color(theme.nav).h(9.0 * sy).align(Align::Center)
557    );
558    col
559}
560
561// ── Right page: category list (home view) ────────────────────────────────────
562
563fn build_categories_right(
564    book: &Book, state: &BookViewState, theme: &BookTheme,
565    page_w: f32, page_h: f32,
566    _rx: f32, _py: f32,
567    sx: f32, sy: f32,
568    overlays: &mut Vec<OverlayCmd>,
569) -> widget::Widget {
570    let _ = overlays;
571
572    // Patchouli right-page layout (landing):
573    //   "Categories" header at TOP_PADDING, centered over RIGHT_PAGE_X..RIGHT_PAGE_X+PAGE_WIDTH
574    //   Separator at TOP_PADDING+12 (drawn as bg sprite, not here)
575    //   4-column 24×24 icon grid starting at (RIGHT_PAGE_X+10, TOP_PADDING+25)
576    //   In page-local terms: left-pad=10, top-pad=(TOP_PAD+25-TOP_PAD)=25, cell=24
577    let cell_w = 24.0 * sx;
578    let cell_h = 24.0 * sy;
579    let grid_pad_left = 10.0 * sx;
580    // TOP_PADDING+12 is where separator is; TOP_PADDING+25 is where the grid starts.
581    // In page-widget space (y=0 = book-local TOP_PAD): grid starts at 25*sy.
582    let grid_top = 25.0 * sy;
583    // Space above grid = grid_top (separator region = 12*sy to 25*sy)
584    let header_h  = 12.0 * sy; // "Categories" label
585    let sep_gap   = (grid_top - header_h).max(0.0); // gap between label and grid
586
587    let mut col = widget::panel(FlexDir::Column)
588        .w(page_w).h(page_h)
589        .padding(0.0, 4.0, 4.0, 0.0)
590        .gap(0.0);
591
592    // Header: "Categories" centered
593    col = col.child(
594        lbl("Categories").color(theme.divider).h(header_h)
595    );
596    // Gap where the separator sprite sits
597    col = col.child(widget::spacer().h(sep_gap));
598
599    // 4-column icon grid
600    let cats = &book.categories;
601    let mut row_i = 0usize;
602    loop {
603        let row_start = row_i * 4;
604        if row_start >= cats.len() { break; }
605        let mut row = widget::panel(FlexDir::Row)
606            .h(cell_h)
607            .gap(0.0)
608            .padding(0.0, 0.0, 0.0, grid_pad_left);
609        for col_i in 0..4 {
610            let idx = row_start + col_i;
611            if idx >= cats.len() { break; }
612            let cat = &cats[idx];
613            let selected = !state.at_home && idx == state.cat;
614            let bg = if selected { theme.nav_selected_bg } else { 0 };
615
616            let icon_w = 16.0 * sx;
617            let icon_h = 16.0 * sy;
618            let pad_xy = (cell_w - icon_w) / 2.0;
619
620            let mut cell = widget::panel(FlexDir::Column)
621                .w(cell_w).h(cell_h).bg(bg)
622                .on_click(format!("cat:{}", idx))
623                .id(format!("book_cat_{}", idx))
624                .padding(pad_xy, pad_xy, pad_xy, pad_xy);
625            cell = if let Some(icon_id) = &cat.icon {
626                cell.child(item_icon_widget(icon_id).w(icon_w).h(icon_h))
627            } else {
628                cell.child(widget::spacer().w(icon_w).h(icon_h))
629            };
630            row = row.child(cell);
631        }
632        col = col.child(row);
633        row_i += 1;
634    }
635    col
636}
637
638// ── Left page: entry content (entry view) ────────────────────────────────────
639
640fn build_entry_left(
641    book: &Book, state: &BookViewState, theme: &BookTheme,
642    page_w: f32, page_h: f32,
643    ox: f32, oy: f32,
644    sx: f32, sy: f32,
645    sep_cx: f32,
646    bg_sprites: &mut Vec<BgSprite>,
647    overlays: &mut Vec<OverlayCmd>,
648) -> widget::Widget {
649    let entry      = state.current_entry(book);
650    let page       = entry.and_then(|e| e.pages.get(state.page));
651    let page_count = state.page_count(book);
652    let title_text = entry.map(|e| e.name.as_str()).unwrap_or("");
653
654    // Patchouli PageText page 0:
655    //   title centered at page-local (PAGE_W/2, 0)
656    //   separator at page-local (0, 12)  → centered ≡ sep_cx offset
657    //   text body at page-local (0, 22)
658    let title_h  = 9.0 * sy;
659    let sep_h_px = (SEP_H * sy).max(1.0);
660    let sep_y    = oy + 12.0 * sy;
661    let body_oy  = oy + 22.0 * sy;
662
663    bg_sprites.push(BgSprite {
664        sheet: SpriteSheet::Book,
665        u: SEP_U, v: SEP_V, uw: SEP_W, uh: SEP_H,
666        x: ox + sep_cx, y: sep_y,
667        w: SEP_W * sx, h: sep_h_px,
668    });
669
670    let page_label = format!("{}/{}", state.page + 1, page_count);
671    let nav = widget::panel(FlexDir::Row)
672        .h(12.0 * sy).gap(4.0)
673        .padding(0.0, 2.0, 0.0, 2.0)
674        .child(btn("◀").w(12.0 * sx).h(12.0 * sy).color(theme.nav)
675            .on_click("prev_page").id("prev_page"))
676        .child(lbl(&page_label).color(theme.nav).flex(1.0).align(Align::Center))
677        .child(btn("⌂").w(12.0 * sx).h(12.0 * sy).color(theme.nav)
678            .on_click("home").id("book_home"))
679        .child(btn("▶").w(12.0 * sx).h(12.0 * sy).color(theme.nav)
680            .on_click("next_page").id("next_page"));
681
682    let page_body = build_page(page, state.page, theme, bg_sprites, overlays, ox, body_oy, sx, sy);
683
684    widget::panel(FlexDir::Column)
685        .w(page_w).h(page_h)
686        .padding(0.0, 6.0, 4.0, 4.0)
687        .gap(0.0)
688        .child(lbl(title_text).color(theme.title).h(title_h).align(Align::Center))
689        .child(widget::spacer().h((12.0 - 9.0) * sy))  // gap: title end → sep start
690        .child(widget::spacer().h(sep_h_px))             // height occupied by sep sprite
691        .child(widget::spacer().h((22.0 - 12.0 - SEP_H) * sy)) // gap: sep end → body
692        .child(page_body)
693        .child(nav)
694}
695
696// ── Right page: entry list for selected category (entry view) ─────────────────
697
698fn build_entries_right(
699    book: &Book, state: &BookViewState, theme: &BookTheme,
700    page_w: f32, page_h: f32, _rx: f32, _py: f32,
701    sx: f32, sy: f32,
702) -> widget::Widget {
703    let entries  = state.entries_visible(book);
704    let cat_name = book.categories.get(state.cat).map(|c| c.name.as_str()).unwrap_or("Entries");
705    let spread_count = state.list_spread_count(book);
706
707    // Patchouli GuiBookEntryList layout (right page):
708    //   category name at page-local y=0, centered
709    //   separator at page-local y=12 (drawn as bg sprite in build_ui)
710    //   entries start at page-local y=20, each h=11
711    let header_h   = 9.0 * sy;
712    let row_h      = 11.0 * sy;
713    let icon_size  = 9.0 * sx.min(sy);
714
715    let mut col = widget::panel(FlexDir::Column)
716        .w(page_w).h(page_h)
717        .padding(0.0, 4.0, 4.0, 0.0)
718        .gap(0.0);
719
720    col = col.child(lbl(cat_name).color(theme.divider).h(header_h).align(Align::Center));
721    // sep region: y=9..15 (sep h=3), then gap to entries start at y=20
722    col = col.child(widget::spacer().h((12.0 - 9.0) * sy));    // gap to sep
723    col = col.child(widget::spacer().h((SEP_H * sy).max(1.0))); // sep height
724    col = col.child(widget::spacer().h((20.0 - 12.0 - SEP_H) * sy)); // gap to entries
725
726    let abs_start = state.list_spread_start();
727    for (i, entry) in entries.iter().enumerate() {
728        let abs_i    = abs_start + i;
729        let selected = abs_i == state.entry;
730        let bg    = if selected { theme.nav_selected_bg } else { 0 };
731        let color = if selected { theme.nav_selected } else { theme.nav };
732
733        let mut row = widget::panel(FlexDir::Row)
734            .h(row_h).gap(2.0).bg(bg)
735            .on_click(format!("entry:{}", abs_i))
736            .id(format!("book_entry_{}", abs_i))
737            .padding(1.0, 2.0, 1.0, 2.0);
738
739        if let Some(icon_id) = &entry.icon {
740            row = row.child(item_icon_widget(icon_id).w(icon_size).h(icon_size));
741        } else {
742            row = row.child(widget::spacer().w(icon_size));
743        }
744        row = row.child(lbl(&entry.name).color(color).flex(1.0));
745        col = col.child(row);
746    }
747
748    if spread_count > 1 {
749        col = col.child(widget::spacer().flex(1.0));
750        let spread_label = format!("{}/{}", state.list_spread + 1, spread_count);
751        col = col.child(
752            widget::panel(FlexDir::Row).h(12.0 * sy).gap(2.0)
753                .child(btn("◀").w(12.0 * sx).h(12.0 * sy).color(theme.nav)
754                    .on_click("prev_list").id("prev_list"))
755                .child(lbl(&spread_label).color(theme.nav)
756                    .flex(1.0).align(Align::Center))
757                .child(btn("▶").w(12.0 * sx).h(12.0 * sy).color(theme.nav)
758                    .on_click("next_list").id("next_list"))
759        );
760    }
761    col
762}
763
764/// Bare 16×16 item icon widget from an item ID ("ns:item_name" or "ns:item/name").
765fn item_icon_widget(item_id: &str) -> widget::Widget {
766    // Normalize "ns:item/name" or "ns:block/name" → "ns:textures/item/name.png"
767    // Normalize "ns:name" → "ns:textures/item/name.png"
768    let tex = if let Some((ns, path)) = item_id.split_once(':') {
769        if path.starts_with("item/") || path.starts_with("block/") {
770            format!("{ns}:textures/{path}.png")
771        } else {
772            format!("{ns}:textures/item/{path}.png")
773        }
774    } else {
775        format!("minecraft:textures/item/{item_id}.png")
776    };
777    widget::mc_image(&tex, 16.0, 16.0)
778}
779
780// ── Page content builder ──────────────────────────────────────────────────────
781
782/// Build the content widget for a single page.
783/// `ox/oy` = screen top-left of the page body area.
784/// `sx/sy` = book-local-to-screen scale factors.
785fn build_page(
786    page: Option<&BookPage>,
787    page_num: usize,
788    theme: &BookTheme,
789    bg_sprites: &mut Vec<BgSprite>,
790    overlays: &mut Vec<OverlayCmd>,
791    ox: f32, oy: f32,
792    sx: f32, sy: f32,
793) -> widget::Widget {
794    let mut col = widget::panel(FlexDir::Column).flex(1.0).gap(4.0);
795
796    let Some(page) = page else {
797        return col.child(lbl("No entries yet.").color(theme.nav));
798    };
799
800    match page {
801        BookPage::Text { text, title } => {
802            // On non-first pages, show an optional section title + separator.
803            if page_num > 0 {
804                if let Some(t) = title {
805                    let sep_h_px = (SEP_H * sy).max(1.0);
806                    let title_h  = 9.0 * sy;
807                    col = col.child(lbl(t.as_str()).color(theme.title)
808                        .h(title_h).align(Align::Center));
809                    // sep at y=12 page-local, centered
810                    bg_sprites.push(BgSprite {
811                        sheet: SpriteSheet::Book,
812                        u: SEP_U, v: SEP_V, uw: SEP_W, uh: SEP_H,
813                        x: ox + (PAGE_W / 2.0 - SEP_W / 2.0) * sx,
814                        y: oy + 12.0 * sy,
815                        w: SEP_W * sx, h: sep_h_px,
816                    });
817                    col = col.child(widget::spacer().h((12.0 - 9.0) * sy));
818                    col = col.child(widget::spacer().h(sep_h_px));
819                    col = col.child(widget::spacer().h((22.0 - 12.0 - SEP_H) * sy));
820                }
821            }
822            for para in text.split('\n') {
823                col = col.child(lbl(para).color(theme.text));
824            }
825        }
826
827        BookPage::Spotlight { item, title, text } => {
828            // Crafting-box frame from crafting.png sprite sheet.
829            // Source: u=0, v=102 (=128-26), w=66, h=26 on 128×256 sheet.
830            // Destination (page-body-local): x = PAGE_W/2 - 33 = 25, y = 10.
831            let box_w   = 66.0 * sx;
832            let box_h   = 26.0 * sy;
833            let box_x   = ox + (PAGE_W / 2.0 - 33.0) * sx;
834            let box_y   = oy + 10.0 * sy;
835            bg_sprites.push(BgSprite {
836                sheet: SpriteSheet::Crafting,
837                u: 0.0, v: 102.0, uw: 66.0, uh: 26.0,
838                x: box_x, y: box_y, w: box_w, h: box_h,
839            });
840
841            // Item title above the box (page-body y=0).
842            let item_name = title.as_deref()
843                .or(item.name.as_deref())
844                .unwrap_or(item.id.as_str());
845            col = col.child(lbl(item_name).color(theme.title)
846                .h(10.0).align(Align::Center));
847
848            // Spacer to box top (y=10 book-local) minus title.
849            col = col.child(widget::spacer().h((10.0 * sy - 10.0 - 4.0).max(0.0)));
850
851            // Item icon centered in box (page-body y=15, x=PAGE_W/2-8).
852            let icon_size = 16.0 * sx.min(sy);
853            col = col.child(
854                widget::panel(FlexDir::Row).h(icon_size)
855                    .child(widget::spacer().flex(1.0))
856                    .child(item_icon_widget(&item.id).w(icon_size).h(icon_size))
857                    .child(widget::spacer().flex(1.0))
858            );
859
860            // Spacer for the rest of the box below the icon.
861            let icon_end = 15.0 * sy + icon_size;
862            let box_end  = 10.0 * sy + box_h;
863            col = col.child(widget::spacer().h((box_end - icon_end + 4.0).max(0.0)));
864
865            if let Some(t) = text {
866                col = col.child(lbl(t.as_str()).color(theme.text));
867            }
868        }
869
870        BookPage::Crafting { recipe_id, text } => {
871            col = col.child(
872                lbl(format!("[Crafting: {}]", recipe_id)).color(theme.nav)
873            );
874            if let Some(t) = text {
875                col = col.child(lbl(t.as_str()).color(theme.text));
876            }
877        }
878
879        BookPage::Smelting { recipe_id, text } => {
880            col = col.child(
881                lbl(format!("[Smelting: {}]", recipe_id)).color(theme.nav)
882            );
883            if let Some(t) = text {
884                col = col.child(lbl(t.as_str()).color(theme.text));
885            }
886        }
887
888        BookPage::Image { texture, title, text, .. } => {
889            if let Some(t) = title {
890                col = col.child(lbl(t.as_str()).color(theme.title));
891            }
892            col = col.child(widget::mc_image(texture, 80.0, 80.0));
893            if let Some(t) = text {
894                col = col.child(lbl(t.as_str()).color(theme.text));
895            }
896        }
897
898        BookPage::Svg { data, title, text } => {
899            if let Some(t) = title {
900                col = col.child(lbl(t.as_str()).color(theme.title));
901            }
902            overlays.push(OverlayCmd::Svg { data: data.clone(), x: ox, y: oy, w: 64.0, h: 64.0 });
903            col = col.child(widget::spacer().h(68.0));
904            if let Some(t) = text {
905                col = col.child(lbl(t.as_str()).color(theme.text));
906            }
907        }
908
909        BookPage::CustomText { text, font, color } => {
910            overlays.push(OverlayCmd::Text {
911                text: text.clone(), font: font.clone(), x: ox, y: oy, color: *color,
912            });
913            col = col.child(widget::spacer().h(font.size_px * 1.5));
914        }
915
916        BookPage::Relations { entries, text } => {
917            if let Some(t) = text {
918                col = col.child(lbl(t.as_str()).color(theme.text));
919            }
920            col = col.child(lbl("See also:").color(theme.title));
921            for e in entries {
922                col = col.child(lbl(format!("• {}", e)).color(theme.nav));
923            }
924        }
925
926        BookPage::Entity { entity_type, name, text } => {
927            let display = name.as_deref().unwrap_or(entity_type.as_str());
928            col = col.child(lbl(display).color(theme.title));
929            if let Some(t) = text {
930                col = col.child(lbl(t.as_str()).color(theme.text));
931            }
932        }
933
934        BookPage::Pattern { op_id, input, output, text, .. } => {
935            col = col.child(lbl(op_id.as_str()).color(theme.title));
936            col = col.child(
937                lbl(format!("{} → {}", input, output)).color(theme.nav)
938            );
939            if !text.is_empty() {
940                col = col.child(lbl(text.as_str()).color(theme.text));
941            }
942        }
943
944        BookPage::Empty => {}
945    }
946
947    col
948}