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