Skip to main content

yog_book/
renderer.rs

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