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::{BTreeMap, HashMap};
7use yog_gfx::{Color, GfxContext, Rect, Vec2};
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 book's own words (`yog.book.*`) per language, as the loader's jars
45/// ship them in `assets/yog/lang/` (for hosts without them, like Yog-IDLE).
46pub static LANG: &[(&str, &str)] = &[
47    ("en_us", include_str!("../assets/lang/en_us.json")),
48    ("ru_ru", include_str!("../assets/lang/ru_ru.json")),
49];
50/// The image source the book draws [`BOOK_PNG`] as: the loader ships it
51/// as `assets/yog/textures/gui/book/book.png` (Yog-IDLE resolves it to
52/// the bytes).
53pub const BOOK_SHEET: &str = "yog:gui/book/book";
54/// The image source the book draws [`CRAFTING_PNG`] as.
55pub const CRAFTING_SHEET: &str = "yog:gui/book/crafting";
56
57// ── Background sprite (texture blit, drawn before yog-ui) ────────────────────
58
59#[derive(Clone, PartialEq)]
60pub(crate) enum SpriteSheet {
61    Book,
62    Crafting,
63    /// Any image source (a recipe layout's).
64    Image(String),
65}
66
67/// A subrect blit from one of the embedded sprite sheets, drawn before yog-ui.
68#[derive(Clone)]
69pub(crate) struct BgSprite {
70    pub sheet: SpriteSheet,
71    /// Source rect in pixels on the sheet.
72    pub src: Rect,
73    /// Destination on screen.
74    pub dst: Rect,
75}
76
77// ── Pending overlay draw commands ─────────────────────────────────────────────
78
79/// Draws a mod's picture (`BookPage::Drawn`): `(ctx, drawer, data, [x, y, w, h])`.
80pub type PageDrawer<'a> = &'a mut dyn FnMut(&GfxContext, &str, &yog_data::Value, [f32; 4]);
81
82#[derive(Clone)]
83pub(crate) enum OverlayCmd {
84    /// A mod's picture: its drawer (`BookPage::Drawn`) draws `data` in the box.
85    Drawn {
86        drawer: String,
87        data: yog_data::Value,
88        x: f32,
89        y: f32,
90        w: f32,
91        h: f32,
92    },
93    /// One of `items` at a time, a new one every second (an ingredient's
94    /// alternatives); tags (`#…`) are skipped.
95    McItems {
96        items: Vec<String>,
97        x: f32,
98        y: f32,
99        w: f32,
100        h: f32,
101    },
102    /// Blocks in 3D, turning.
103    Blocks {
104        blocks: Vec<((i32, i32, i32), String)>,
105        x: f32,
106        y: f32,
107        w: f32,
108        h: f32,
109    },
110    /// A living entity standing in the box.
111    Entity {
112        entity: String,
113        x: f32,
114        y: f32,
115        w: f32,
116        h: f32,
117    },
118    /// MC default-font text rendered via draw2d (e.g. nameplate title/subtitle).
119    McText {
120        text: String,
121        x: f32,
122        y: f32,
123        color: Color,
124    },
125    /// Item icon rendered through our GL pipeline from an MC-managed texture.
126    McItem {
127        item_id: String,
128        x: f32,
129        y: f32,
130        w: f32,
131        h: f32,
132    },
133    /// Flat texture icon blitted as-is (16×16), for icon strings that name a
134    /// texture resource directly rather than an item — matches Patchouli's
135    /// `BookIcon.from`: any icon string ending in `.png` is a raw texture,
136    /// everything else is parsed as an item stack.
137    McTexture {
138        path: String,
139        x: f32,
140        y: f32,
141        w: f32,
142        h: f32,
143    },
144}
145
146/// Push a book-author-supplied icon (category/entry icon field) as the right
147/// overlay kind. Matches Patchouli's `BookIcon.from(String)`: `.png` → raw
148/// texture blit, anything else → item stack render.
149fn push_icon(overlays: &mut Vec<OverlayCmd>, icon: &str, x: f32, y: f32, w: f32, h: f32) {
150    if icon.ends_with(".png") {
151        overlays.push(OverlayCmd::McTexture {
152            path: icon.to_string(),
153            x,
154            y,
155            w,
156            h,
157        });
158    } else {
159        overlays.push(OverlayCmd::McItem {
160            item_id: icon.to_string(),
161            x,
162            y,
163            w,
164            h,
165        });
166    }
167}
168
169// ── Recipe visuals (parsed from registered recipe JSON) ───────────────────────
170
171/// A recipe as a book shows it: its kind (which [`crate::layout`] draws
172/// it), each slot's ingredients (each one's alternatives; empty: none),
173/// result and values.
174#[derive(Debug, Clone, PartialEq)]
175pub(crate) struct RecipeVis {
176    pub kind: String,
177    pub slots: BTreeMap<String, Vec<Vec<String>>>,
178    pub output: String,
179    pub count: u32,
180    /// `time`, `experience`, a mod kind's `values` — for a layout's texts.
181    pub values: BTreeMap<String, String>,
182}
183
184/// An ingredient in Yog's recipe format: an id, a `#tag`, or an array of
185/// those (any of them).
186fn ingredient(v: &yog_data::Value) -> Vec<String> {
187    use yog_data::Value;
188    match v {
189        Value::Str(s) => vec![s.clone()],
190        Value::Ref(r) => vec![r.id.clone()],
191        Value::Tag(r) => vec![format!("#{}", r.id)],
192        Value::List(a) => a.iter().flat_map(ingredient).collect(),
193        _ => Vec::new(),
194    }
195}
196
197/// A recipe in Yog's own format (what a `.yrecipe` says, ids resolved —
198/// see `yog_registry::Recipe`), as the book shows it.
199fn parse_recipe(v: &yog_data::Value) -> Option<RecipeVis> {
200    use yog_data::Value;
201    let kind = v.get("type")?.as_str()?.to_owned();
202    let output = match v.get("result")? {
203        Value::Ref(r) => r.id.clone(),
204        r => r.as_str()?.to_owned(),
205    };
206    let count = v.get("count").and_then(|c| c.as_i64()).unwrap_or(1).max(0) as u32;
207    let mut slots = BTreeMap::new();
208    let mut values = BTreeMap::new();
209    let text = |x: &Value| match x {
210        Value::Int(i) => i.to_string(),
211        Value::Float(f) => f.to_string(),
212        Value::Bool(b) => b.to_string(),
213        x => x.as_str().map(str::to_owned).unwrap_or_default(),
214    };
215    match kind.as_str() {
216        "shaped" => {
217            // The pattern's cells by position on the 3×3 grid.
218            let key = v.get("key")?.as_map()?;
219            let mut grid = vec![Vec::new(); 9];
220            for (r, row) in v.get("pattern")?.as_list()?.iter().filter_map(|p| p.as_str()).take(3).enumerate() {
221                let mut buf = [0u8; 4];
222                for (c, ch) in row.chars().take(3).enumerate() {
223                    grid[r * 3 + c] = key.get(ch.encode_utf8(&mut buf)).map(ingredient).unwrap_or_default();
224                }
225            }
226            slots.insert("grid".into(), grid);
227        }
228        "shapeless" => {
229            slots.insert("grid".into(), v.get("ingredients")?.as_list()?.iter().map(ingredient).collect());
230        }
231        "smelting" | "blasting" | "smoking" | "campfire_cooking" | "stonecutting" => {
232            slots.insert("input".into(), vec![ingredient(v.get("ingredient")?)]);
233            let default_time = match kind.as_str() {
234                "smelting" => Some(200),
235                "blasting" | "smoking" => Some(100),
236                "campfire_cooking" => Some(600),
237                _ => None,
238            };
239            if let Some(t) = v.get("cook_time").and_then(|t| t.as_i64()).or(default_time) {
240                values.insert("time".into(), t.to_string());
241            }
242            if let Some(x) = v.get("experience") {
243                values.insert("experience".into(), text(x));
244            }
245        }
246        "smithing" => {
247            for part in ["template", "base", "addition"] {
248                slots.insert(part.into(), vec![v.get(part).map(ingredient).unwrap_or_default()]);
249            }
250        }
251        _ => {
252            // A mod's own kind: named slots, values.
253            for (name, list) in v.get("slots").and_then(|s| s.as_map()).into_iter().flat_map(|m| m.iter()) {
254                slots.insert(name.to_string(), list.as_list().into_iter().flatten().map(ingredient).collect());
255            }
256            for (name, x) in v.get("values").and_then(|s| s.as_map()).into_iter().flat_map(|m| m.iter()) {
257                values.insert(name.to_string(), text(x));
258            }
259        }
260    }
261    Some(RecipeVis { kind, slots, output, count, values })
262}
263
264/// Normalize icon ids like "yog:item/ruby" / "yog:block/x" to registry item
265/// ids ("yog:ruby") accepted by the MC item renderer.
266fn normalize_item_id(id: &str) -> String {
267    let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
268    let name = path
269        .trim_start_matches("item/")
270        .trim_start_matches("block/");
271    format!("{ns}:{name}")
272}
273
274/// The image source of a book's sheet: its own (`book_texture`, a sheet
275/// laid out as [`BOOK_PNG`]; `ns:textures/x.png` and `ns:x` alike) or Yog's.
276fn book_sheet(texture: &str) -> String {
277    let t = texture.trim();
278    if t.is_empty() || t == "yog:gui/book" {
279        return BOOK_SHEET.to_string();
280    }
281    let (ns, path) = t.split_once(':').unwrap_or(("minecraft", t));
282    let path = path.strip_prefix("textures/").unwrap_or(path);
283    format!("{ns}:{}", path.strip_suffix(".png").unwrap_or(path))
284}
285
286/// The game's translations: item names and the book's own words (set with the translator).
287static ITEM_NAMES: std::sync::RwLock<Option<Translator>> = std::sync::RwLock::new(None);
288
289type Translator = fn(&str) -> Option<String>;
290
291/// The book's own word `yog.book.<key>` in the game's language (`%s`:
292/// `arg`), else English.
293fn word(key: &str, arg: &str) -> String {
294    let k = format!("yog.book.{key}");
295    let tr = *ITEM_NAMES.read().unwrap_or_else(|e| e.into_inner());
296    let text = tr.and_then(|tr| tr(&k)).filter(|t| *t != k).or_else(|| {
297        // English, from the shipped file.
298        static EN: std::sync::LazyLock<HashMap<String, String>> =
299            std::sync::LazyLock::new(|| serde_json::from_str(LANG[0].1).unwrap_or_default());
300        EN.get(&k).cloned()
301    });
302    let text = text.unwrap_or(k);
303    text.replace("%s", arg)
304}
305
306/// "staff/oak" → "Oak Staff".
307fn name_from_path(path: &str) -> String {
308    path.rsplit('/')
309        .flat_map(|seg| seg.split('_'))
310        .filter(|w| !w.is_empty())
311        .map(|w| {
312            let mut c = w.chars();
313            match c.next() {
314                Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
315                None => String::new(),
316            }
317        })
318        .collect::<Vec<_>>()
319        .join(" ")
320}
321
322/// `n` as the game's language counts editions: "1st" in English (its
323/// `yog.book.ordinals`), plain "1" where it has none.
324fn ordinal(n: u32) -> String {
325    let suffixes = word("ordinals", "");
326    let s: Vec<&str> = suffixes.split_whitespace().collect();
327    if s.len() < 4 {
328        return n.to_string();
329    }
330    let i = match (n % 100, n % 10) {
331        (11..=13, _) => 0,
332        (_, 1) => 1,
333        (_, 2) => 2,
334        (_, 3) => 3,
335        _ => 0,
336    };
337    format!("{n}{}", s[i])
338}
339
340/// An entity's name: the game's (`entity.<ns>.<path>`), else one made
341/// from its id.
342fn entity_name(id: &str) -> String {
343    let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
344    let k = format!("entity.{ns}.{}", path.replace('/', "."));
345    let tr = *ITEM_NAMES.read().unwrap_or_else(|e| e.into_inner());
346    tr.and_then(|tr| tr(&k))
347        .filter(|t| *t != k)
348        .unwrap_or_else(|| name_from_path(path))
349}
350
351/// An item's name: the game's (`item.<ns>.<path>`, else `block.…`), else
352/// one made from its id — "hexcasting:staff/oak" → "Oak Staff".
353fn pretty_item_name(id: &str) -> String {
354    let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
355    if let Some(tr) = *ITEM_NAMES.read().unwrap_or_else(|e| e.into_inner()) {
356        let key = path.replace('/', ".");
357        for kind in ["item", "block"] {
358            let k = format!("{kind}.{ns}.{key}");
359            if let Some(name) = tr(&k).filter(|n| *n != k) {
360                return name;
361            }
362        }
363    }
364    name_from_path(path)
365}
366
367// ── BookRenderer ──────────────────────────────────────────────────────────────
368
369pub struct BookRenderer {
370    pub book: Book,
371    pub state: BookViewState,
372    pub theme: BookTheme,
373    recipes: HashMap<String, RecipeVis>,
374    /// Per entry: which page as written each shown page came from.
375    origins: HashMap<String, Vec<usize>>,
376    /// The book as written (localized), before its text flowed.
377    written: Book,
378    /// Flowed with the host's own text metrics (else estimated ones).
379    measured: bool,
380    /// When the renderer was made: what animations count from.
381    start: std::time::Instant,
382    pub ui: Option<UiRoot>,
383    bg_sprites: Vec<BgSprite>,
384    overlays: Vec<OverlayCmd>,
385    dirty: bool,
386    last_sw: f32,
387    last_sh: f32,
388    /// i18n books: the untranslated book, the translator and the language
389    /// probe (the book name's text) the shown book was made for.
390    source: Option<Book>,
391    translator: Option<fn(&str) -> Option<String>>,
392    probe: Option<Option<String>>,
393}
394
395impl BookRenderer {
396    pub fn new(book: Book) -> Self {
397        let theme = BookTheme::default().with_nameplate(&book.nameplate_color);
398        let written = styled(&book);
399        let (flowed, origins) = flow_traced(written.clone());
400        // Translated, it's flowed again from the book as written: flowing
401        // its keys would split them.
402        let source = book.i18n.then_some(book);
403        let book = flowed;
404        Self {
405            book,
406            origins,
407            written,
408            measured: false,
409            start: std::time::Instant::now(),
410            state: BookViewState::default(),
411            theme,
412            recipes: HashMap::new(),
413            ui: None,
414            bg_sprites: Vec::new(),
415            overlays: Vec::new(),
416            dirty: true,
417            last_sw: 0.0,
418            last_sh: 0.0,
419            translator: None,
420            probe: None,
421            source,
422        }
423    }
424
425    /// The book it draws (its long texts flowed onto more pages).
426    pub fn book(&self) -> &Book {
427        &self.book
428    }
429
430    /// The game's translations: items' names in recipe titles, and, for
431    /// [`Book::i18n`] books, the book's texts — translation keys, looked up
432    /// with `tr` (unknown keys stay as they are) and re-looked up whenever
433    /// the language changes.
434    pub fn set_translator(&mut self, tr: fn(&str) -> Option<String>) {
435        *ITEM_NAMES.write().unwrap_or_else(|e| e.into_inner()) = Some(tr);
436        if self.book.i18n {
437            self.translator = Some(tr);
438            self.probe = None;
439        }
440    }
441
442    /// Re-translates the book when the language changed since last time.
443    fn localize(&mut self) {
444        let (Some(src), Some(tr)) = (&self.source, self.translator) else {
445            return;
446        };
447        let probe = Some(tr(&src.name));
448        if probe == self.probe {
449            return;
450        }
451        let accent = src.accent.clone();
452        self.written = src.localized(|k| {
453            crate::markup::resolve(&tr(k).unwrap_or_else(|| k.to_string()), &accent)
454        });
455        (self.book, self.origins) = flow_traced(self.written.clone());
456        self.probe = probe;
457        self.dirty = true;
458    }
459
460    pub fn handle_event(&mut self, ev: &str) {
461        if self.state.handle(ev, &self.book) {
462            self.dirty = true;
463        }
464    }
465
466    /// Register a recipe (Yog's recipe, as Yog Data) so recipe pages can
467    /// render it visually. Unknown recipe types are ignored.
468    pub fn add_recipe(&mut self, id: impl Into<String>, recipe: &yog_data::Value) {
469        if let Some(vis) = parse_recipe(recipe) {
470            self.recipes.insert(id.into(), vis);
471            self.dirty = true;
472        }
473    }
474
475    /// Lays the book out for a `sw`×`sh` screen (when something changed);
476    /// the book's rect.
477    fn prepare(&mut self, sw: f32, sh: f32) -> (f32, f32, f32, f32) {
478        // Render at Patchouli-native size (272×180 GUI pixels). Scale DOWN only
479        // if the screen is smaller; never scale up (MC font is designed for 1×).
480        let scale = ((sw - SCREEN_MARGIN) / BK_W)
481            .min((sh - SCREEN_MARGIN) / BK_H)
482            .clamp(MIN_SCALE, 1.0);
483        let bw = BK_W * scale;
484        let bh = BK_H * scale;
485        let bx = (sw - bw) / 2.0;
486        let by = (sh - bh) / 2.0;
487
488        self.localize();
489        // Rebuild widget tree if dirty or screen resized.
490        if self.dirty || sw != self.last_sw || sh != self.last_sh {
491            let (root, bg_sprites, overlays) = build_ui(
492                &self.book,
493                &self.state,
494                &self.theme,
495                &self.recipes,
496                bx,
497                by,
498                bw,
499                bh,
500            );
501            self.ui = Some(root);
502            self.bg_sprites = bg_sprites;
503            self.overlays = overlays;
504            self.dirty = false;
505            self.last_sw = sw;
506            self.last_sh = sh;
507        }
508        if let Some(ui) = &mut self.ui
509            && ui.needs_layout
510        {
511            ui.layout(sw, sh);
512        }
513        (bx, by, bw, bh)
514    }
515
516    /// Draws the book into a `sw`×`sh` screen — all of it through
517    /// `draw2d` (its sprite sheets as the image sources [`BOOK_SHEET`] and
518    /// [`CRAFTING_SHEET`]).
519    /// Draws the book; mods' pictures (`BookPage::Drawn`) are left empty —
520    /// see [`BookRenderer::render_with`].
521    pub fn render(&mut self, ctx: &GfxContext, sw: f32, sh: f32) {
522        self.render_with(ctx, sw, sh, &mut |_, _, _, _| {});
523    }
524
525    /// Draws the book, mods' pictures with `draw(ctx, drawer, data, [x, y,
526    /// w, h])` (screen pixels) — the game calls the mod that registered
527    /// `drawer`.
528    pub fn render_with(&mut self, ctx: &GfxContext, sw: f32, sh: f32, draw: PageDrawer<'_>) {
529        // Text flows by the host's own font metrics once it can measure.
530        yog_ui::text::use_host_metrics(ctx);
531        if !self.measured {
532            self.measured = true;
533            self.reflow();
534        }
535        let (bx, by, bw, bh) = self.prepare(sw, sh);
536        let d2 = ctx.draw2d();
537        let book_sheet = book_sheet(&self.book.book_texture);
538        d2.image_region(&book_sheet, Rect::new(bx, by, bw, bh), Some(Rect::new(0.0, 0.0, BK_W, BK_H)), Color::WHITE);
539        for spr in &self.bg_sprites {
540            let sheet = match &spr.sheet {
541                SpriteSheet::Book => &book_sheet,
542                SpriteSheet::Crafting => CRAFTING_SHEET,
543                SpriteSheet::Image(source) => source.as_str(),
544            };
545            d2.image_region(sheet, spr.dst, Some(spr.src), Color::WHITE);
546        }
547        if let Some(ui) = &mut self.ui {
548            ui.render(ctx);
549        }
550        // Animations: entities and structures turn (Patchouli's pace), an
551        // ingredient's alternatives take turns every second.
552        let t = self.start.elapsed().as_secs_f32();
553        for ov in &self.overlays {
554            match *ov {
555                OverlayCmd::Drawn {
556                    ref drawer,
557                    ref data,
558                    x,
559                    y,
560                    w,
561                    h,
562                } => draw(ctx, drawer, data, [x, y, w, h]),
563                OverlayCmd::Entity {
564                    ref entity,
565                    x,
566                    y,
567                    w,
568                    h,
569                } => d2.entity(entity, Rect::new(x, y, w, h), -30.0 + t * 30.0, 0.0),
570                OverlayCmd::Blocks {
571                    ref blocks,
572                    x,
573                    y,
574                    w,
575                    h,
576                } => {
577                    let list: Vec<((i32, i32, i32), &str)> =
578                        blocks.iter().map(|(p, b)| (*p, b.as_str())).collect();
579                    d2.blocks(&list, Rect::new(x, y, w, h), -45.0 + t * 20.0, 30.0);
580                }
581                _ => {}
582            }
583        }
584        // Item stacks and MC-font text last: item rendering changes GL state.
585        self.draw_mc_overlays(ctx);
586    }
587
588    fn draw_mc_overlays(&self, ctx: &GfxContext) {
589        let second = self.start.elapsed().as_secs() as usize;
590        for ov in &self.overlays {
591            match *ov {
592                OverlayCmd::McItems {
593                    ref items,
594                    x,
595                    y,
596                    w,
597                    h,
598                } => {
599                    let shown: Vec<&String> =
600                        items.iter().filter(|i| !i.starts_with('#')).collect();
601                    if !shown.is_empty() {
602                        let item = shown[second % shown.len()];
603                        ctx.draw2d().item(&normalize_item_id(item), Vec2::new(x, y), w.min(h));
604                    }
605                }
606                OverlayCmd::McItem {
607                    ref item_id,
608                    x,
609                    y,
610                    w,
611                    h,
612                } => {
613                    ctx.draw2d()
614                        .item(&normalize_item_id(item_id), Vec2::new(x, y), w.min(h));
615                }
616                OverlayCmd::McText {
617                    ref text,
618                    x,
619                    y,
620                    color,
621                } => {
622                    ctx.draw2d().text(text, Vec2::new(x, y), color, false);
623                }
624                OverlayCmd::McTexture {
625                    ref path,
626                    x,
627                    y,
628                    w,
629                    h,
630                } => {
631                    // Standalone icon texture, not an atlas region — drawn
632                    // whole, scaled into the icon box.
633                    ctx.draw2d().image(path, Rect::new(x, y, w, h));
634                }
635                _ => {}
636            }
637        }
638    }
639
640    /// The page shown moves by itself (an entity, a structure, an
641    /// ingredient's alternatives): the host should keep redrawing it.
642    pub fn animated(&self) -> bool {
643        self.overlays.iter().any(|o| match o {
644            OverlayCmd::Entity { .. } | OverlayCmd::Blocks { .. } => true,
645            OverlayCmd::McItems { items, .. } => {
646                items.iter().filter(|i| !i.starts_with('#')).count() > 1
647            }
648            _ => false,
649        })
650    }
651
652    /// A click at `(x, y)` (screen GUI pixels, as last rendered): what it
653    /// hit does its thing. The action it was (`"next_page"`, `"entry:3"`,
654    /// …), if any.
655    pub fn click(&mut self, x: f32, y: f32) -> Option<String> {
656        let ui = self.ui.as_ref()?;
657        let hit = yog_ui::layout::hit_test(&ui.layout_root, x, y)?;
658        let action = hit.on_click.clone()?;
659        self.handle_event(&action);
660        Some(action)
661    }
662
663    /// Shows `entry` (an entry id) at `page` — its page as written, not
664    /// counting the pages long text flowed onto — as a reader would have
665    /// got there; `false` if there's no such entry.
666    pub fn show(&mut self, entry: &str, page: usize) -> bool {
667        let Some(e) = self.book.entries.iter().find(|e| e.id == entry) else {
668            return false;
669        };
670        let Some(cat) = self.book.categories.iter().position(|c| c.id == e.category) else {
671            return false;
672        };
673        self.state.cat = cat;
674        self.state.at_home = false;
675        let in_cat = self.state.entries_in_cat(&self.book);
676        let Some(i) = in_cat.iter().position(|x| x.id == entry) else {
677            return false;
678        };
679        let shown = self
680            .origins
681            .get(entry)
682            .and_then(|o| o.iter().position(|&w| w >= page))
683            .unwrap_or(page);
684        self.state.entry = i;
685        self.state.page = shown.min(e.pages.len().saturating_sub(1));
686        self.dirty = true;
687        true
688    }
689
690    /// The entry shown (its id) and the page of it as written, or `None`
691    /// on the landing and list pages.
692    pub fn shown(&self) -> Option<(String, usize)> {
693        if self.state.at_home {
694            return None;
695        }
696        let e = self.state.current_entry(&self.book)?;
697        let page = self
698            .origins
699            .get(&e.id)
700            .and_then(|o| o.get(self.state.page))
701            .copied()
702            .unwrap_or(self.state.page);
703        Some((e.id.clone(), page))
704    }
705
706    /// Flows the text again (the metrics changed), keeping the reader's place.
707    fn reflow(&mut self) {
708        let at = self.shown();
709        (self.book, self.origins) = flow_traced(self.written.clone());
710        self.dirty = true;
711        if let Some((entry, page)) = at {
712            self.show(&entry, page);
713        }
714    }
715
716    /// Replaces the book (an edit), keeping the reader where they were.
717    pub fn set_book(&mut self, book: Book) {
718        let at = self.shown();
719        self.written = styled(&book);
720        self.probe = None;
721        self.source = book.i18n.then_some(book);
722        (self.book, self.origins) = flow_traced(self.written.clone());
723        self.localize();
724        self.theme = BookTheme::default().with_nameplate(&self.book.nameplate_color);
725        self.dirty = true;
726        match at {
727            Some((entry, page)) if self.show(&entry, page) => {}
728            _ => {
729                if self.state.cat >= self.book.categories.len() {
730                    self.state = BookViewState::default();
731                }
732            }
733        }
734    }
735}
736
737// ── UI builder ────────────────────────────────────────────────────────────────
738
739thread_local! {
740    /// The scale the book is drawn at (≤ 1): its text is drawn at it too,
741    /// so it stays on the page however small the book gets.
742    static TEXT_SCALE: std::cell::Cell<f32> = const { std::cell::Cell::new(1.0) };
743}
744
745fn text_scale() -> f32 {
746    TEXT_SCALE.with(|s| s.get())
747}
748
749/// Book label: MC font, no drop-shadow (Patchouli draws book text shadowless).
750fn lbl(text: impl Into<String>) -> widget::Widget {
751    widget::label(text).shadow(false).font_scale(text_scale())
752}
753/// Book button: no drop-shadow.
754fn btn(text: impl Into<String>) -> widget::Widget {
755    widget::button(text).shadow(false).font_scale(text_scale())
756}
757
758/// A title: one line, shrunk (down to half size, then cut short) to fit
759/// the page's width.
760fn title_lbl(text: impl Into<String>) -> widget::Widget {
761    let text = text.into();
762    // As wide as the page's inside (less its padding), so `align` can centre it.
763    let w = PAGE_W - 10.0;
764    let fit = (w / yog_ui::text::str_width(&text, 1.0)).clamp(0.5, 1.0);
765    widget::label(text)
766        .shadow(false)
767        .font_scale(text_scale() * fit)
768        .no_wrap()
769        .ellipsis()
770        .w(w * text_scale())
771}
772
773// ── Flowing text ──────────────────────────────────────────────────────────────
774
775/// Width of a page's text, in book pixels.
776const TEXT_W: f32 = PAGE_W - 8.0;
777/// Height a page's body has for text, in book pixels: the page less its
778/// title and separator, the page navigation and the padding.
779const BODY_H: f32 = PAGE_H - 22.0 - 12.0 - 14.0;
780
781/// A multiblock page's view of its structure, and its name's height (with
782/// the gap under it), in book pixels.
783const MB_VIEW: f32 = 70.0;
784const MB_NAME_H: f32 = 14.0;
785
786/// How much of the body a page's own content takes above its text.
787fn content_above_text(page: &BookPage) -> Option<f32> {
788    Some(match page {
789        BookPage::Text { title: Some(_), .. } => 22.0,
790        BookPage::Text { .. } => 0.0,
791        BookPage::Spotlight { .. } => 40.0,
792        // (A crafting grid's height: a furnace's is less.)
793        BookPage::Recipe { .. } => 80.0,
794        BookPage::Image { .. } | BookPage::Entity { .. } => 104.0,
795        BookPage::Drawn { title, height, caption, .. } => {
796            let title = if title.is_some() { 14.0 } else { 0.0 };
797            let picture = if *height > 0.0 { height + 4.0 } else { 0.0 };
798            title + picture + if caption.is_some() { 14.0 } else { 0.0 }
799        }
800        BookPage::Multiblock { name, .. } => {
801            MB_VIEW + 4.0 + if name.is_some() { MB_NAME_H } else { 0.0 }
802        }
803        BookPage::Relations { entries, .. } => 12.0 + entries.len() as f32 * 10.0,
804        _ => return None,
805    })
806}
807
808fn page_text_mut(page: &mut BookPage) -> Option<&mut String> {
809    match page {
810        BookPage::Text { text, .. } => Some(text),
811        BookPage::Spotlight { text, .. }
812        | BookPage::Recipe { text, .. }
813        | BookPage::Image { text, .. }
814        | BookPage::Entity { text, .. }
815        | BookPage::Multiblock { text, .. }
816        | BookPage::Relations { text, .. }
817        | BookPage::Drawn { text, .. } => text.as_mut(),
818        _ => None,
819    }
820}
821
822/// `book` with its texts' own words (see [`crate::markup`]) as Yog text's.
823fn styled(book: &Book) -> Book {
824    book.localized(|t| crate::markup::resolve(t, &book.accent))
825}
826
827/// Text that doesn't fit its page goes on to new text pages after it.
828#[cfg(test)]
829fn flow(book: Book) -> Book {
830    flow_traced(book).0
831}
832
833/// [`flow`], and for each entry where each of its pages came from: the
834/// index of the page in the book as written.
835fn flow_traced(mut book: Book) -> (Book, HashMap<String, Vec<usize>>) {
836    let mut origins = HashMap::new();
837    let line_h = yog_ui::text::LINE_H + yog_ui::text::LINE_GAP;
838    let fits = |h: f32| (((h + yog_ui::text::LINE_GAP) / line_h).floor() as usize).max(1);
839    for entry in &mut book.entries {
840        let mut pages = Vec::with_capacity(entry.pages.len());
841        let mut from = Vec::with_capacity(entry.pages.len());
842        for (written, page) in std::mem::take(&mut entry.pages).into_iter().enumerate() {
843            let split = split_page(page, &book.page_types, &fits);
844            from.extend(std::iter::repeat_n(written, split.len()));
845            pages.extend(split);
846        }
847        entry.pages = pages;
848        origins.insert(entry.id.clone(), from);
849    }
850    (book, origins)
851}
852
853/// A mod's page's room for its text: the property its last text shows
854/// (`<BookText text="{x:Bind text}"/>`, nothing else in it), and the
855/// height the rest of the page takes, in book pixels.
856fn custom_text(page: &crate::page_type::CustomPage, types: &[crate::page_type::PageType]) -> Option<(String, f32)> {
857    use crate::page_type::PageElement as E;
858    let t = types.iter().find(|t| t.id == page.kind)?;
859    let line_h = yog_ui::text::LINE_H + yog_ui::text::LINE_GAP;
860    let shown: Vec<&E> = t.body.iter().filter(|el| match el {
861        E::Header { visible, .. } | E::Text { visible, .. } | E::Item { visible, .. } | E::Image { visible, .. } | E::Drawn { visible, .. } => t.visible(page, visible),
862        E::Spacer { .. } => true,
863    }).collect();
864    let at = shown.iter().rposition(|el| matches!(el, E::Text { .. }))?;
865    let E::Text { text, .. } = shown[at] else { return None };
866    let name = text.trim().strip_prefix("{x:Bind ")?.strip_suffix('}')?.trim();
867    if !matches!(page.fields.get(name), Some(yog_data::Value::Str(_))) {
868        return None;
869    }
870    let others: f32 = shown.iter().enumerate().filter(|(i, _)| *i != at).map(|(_, el)| match el {
871        E::Header { .. } => 14.0,
872        E::Text { text, .. } => t.text(page, text).split('\n').map(|p| yog_ui::text::wrap_text(p, TEXT_W, 1.0).len().max(1)).sum::<usize>() as f32 * line_h + 4.0,
873        E::Item { .. } => 20.0,
874        E::Image { height, .. } | E::Drawn { height, .. } | E::Spacer { height } => height + 4.0,
875    }).sum();
876    Some((name.to_string(), others))
877}
878
879/// `page`, and the text pages its overflowing text goes on to.
880fn split_page(mut page: BookPage, types: &[crate::page_type::PageType], fits: &dyn Fn(f32) -> usize) -> Vec<BookPage> {
881    let (above, text) = match &mut page {
882        BookPage::Custom(c) => {
883            let Some((name, above)) = custom_text(c, types) else { return vec![page] };
884            let Some(yog_data::Value::Str(text)) = c.fields.get_mut(&name) else { return vec![page] };
885            (above, text)
886        }
887        _ => {
888            let Some(above) = content_above_text(&page) else {
889                return vec![page];
890            };
891            let Some(text) = page_text_mut(&mut page) else {
892                return vec![page];
893            };
894            (above, text)
895        }
896    };
897    let lines: Vec<String> = text
898        .split('\n')
899        .flat_map(|para| {
900            if para.is_empty() {
901                vec![String::new()]
902            } else {
903                yog_ui::text::wrap_text(para, TEXT_W, 1.0)
904            }
905        })
906        .collect();
907    let first = fits(BODY_H - above);
908    if lines.len() <= first {
909        return vec![page];
910    }
911    *text = lines[..first].join("\n");
912    let rest: Vec<BookPage> = lines[first..]
913        .chunks(fits(BODY_H))
914        .map(|chunk| BookPage::Text {
915            text: chunk.join("\n"),
916            title: None,
917            font: None,
918            font_size: None,
919        })
920        .collect();
921    std::iter::once(page).chain(rest).collect()
922}
923
924/// Build the yog-ui widget tree + bg sprites + overlay commands for the current book state.
925/// `bx/by/bw/bh` are the screen-space book rect (same values used to blit the bg texture).
926fn build_ui(
927    book: &Book,
928    state: &BookViewState,
929    theme: &BookTheme,
930    recipes: &HashMap<String, RecipeVis>,
931    bx: f32,
932    by: f32,
933    bw: f32,
934    bh: f32,
935) -> (UiRoot, Vec<BgSprite>, Vec<OverlayCmd>) {
936    let mut bg_sprites: Vec<BgSprite> = Vec::new();
937    let mut overlays: Vec<OverlayCmd> = Vec::new();
938
939    // Scale from Patchouli's fixed BK_W×BK_H coordinate space → actual bw×bh.
940    let sx = bw / BK_W;
941    TEXT_SCALE.with(|s| s.set(sx));
942    let sy = bh / BK_H;
943
944    // In book-local: left page starts at x=LEFT_X, right at RIGHT_X, both y=TOP_PAD.
945    let pw = PAGE_W * sx;
946    let ph = PAGE_H * sy;
947    let spine_gap = (RIGHT_X - LEFT_X - PAGE_W) * sx; // ≈10px scaled
948
949    // Content x/y in screen space (for SVG overlay positioning).
950    let lx = bx + LEFT_X * sx;
951    let rx = bx + RIGHT_X * sx;
952    let py = by + TOP_PAD * sy;
953
954    // Separator x offset within a page: centered = PAGE_W/2 - SEP_W/2 = 58-55 = 3.
955    let sep_cx = (PAGE_W / 2.0 - SEP_W / 2.0) * sx; // = 3*sx
956
957    if state.at_home {
958        // Nameplate banner: book-local (-8, 12) from LEFT_PAGE_X, size 140×31.
959        bg_sprites.push(BgSprite {
960            sheet: SpriteSheet::Book,
961            src: Rect::new(0.0, 180.0, 140.0, 31.0),
962            dst: Rect::new(bx + (LEFT_X - 8.0) * sx, by + 12.0 * sy, 140.0 * sx, 31.0 * sy),
963        });
964    }
965    // Separator on the right page below its header ("Categories" or the
966    // category name; y=12 page-local), centered.
967    bg_sprites.push(BgSprite {
968        sheet: SpriteSheet::Book,
969        src: Rect::new(SEP_U, SEP_V, SEP_W, SEP_H),
970        dst: Rect::new(rx + sep_cx, by + (TOP_PAD + 12.0) * sy, SEP_W * sx, (SEP_H * sy).max(1.0)),
971    });
972
973    let (left_page, right_page) = if state.at_home {
974        let l = build_landing_left(book, theme, pw, ph, sx, sy, bx, by, &mut overlays);
975        let r = build_categories_right(book, state, theme, pw, ph, rx, py, sx, sy, &mut overlays);
976        (l, r)
977    } else {
978        let l = build_entry_left(
979            book,
980            state,
981            theme,
982            recipes,
983            pw,
984            ph,
985            lx,
986            py,
987            sx,
988            sy,
989            sep_cx,
990            &mut bg_sprites,
991            &mut overlays,
992        );
993        let r = build_entries_right(book, state, theme, pw, ph, rx, py, sx, sy, &mut overlays);
994        (l, r)
995    };
996
997    // Root row spans the full book width.
998    // padding-left = LEFT_X*sx offsets both pages from the left cover edge.
999    // padding-top  = TOP_PAD*sy offsets from the header banner.
1000    // spine_gap spacer sits between the two pages.
1001    // gap must be 0: analytic overlay positions assume pages sit exactly at
1002    // LEFT_X / RIGHT_X — any flex gap would shift widgets (and their focus
1003    // highlights) away from the absolutely-positioned icons.
1004    let root_widget = widget::panel(FlexDir::Row)
1005        .w(bw)
1006        .h(bh)
1007        .gap(0.0)
1008        .padding(TOP_PAD * sy, 0.0, 0.0, LEFT_X * sx)
1009        .child(left_page)
1010        .child(widget::spacer().w(spine_gap)) // spine gap
1011        .child(right_page);
1012
1013    // Outer shell: positions the book on screen via a full-screen transparent Row.
1014    // We use a Column + Row arrangement to apply top/left offsets without margin support.
1015    let outer = widget::panel(FlexDir::Column)
1016        .w(bw)
1017        .h(bh)
1018        .child(root_widget);
1019
1020    // Screen-level container: positions book at (bx, by).
1021    // bx offset via padding, by offset via padding-top.
1022    // Note: layout computes from (0,0), so we pass bx/by into the UiRoot manually
1023    // by wrapping in a full-screen panel with the right padding.
1024    let screen_root = widget::panel(FlexDir::Row)
1025        .padding(by, 0.0, 0.0, bx)
1026        .child(outer);
1027
1028    let ui = UiRoot::new(&book.id, screen_root);
1029    (ui, bg_sprites, overlays)
1030}
1031
1032// ── Left page: landing (home view) ───────────────────────────────────────────
1033
1034fn build_landing_left(
1035    book: &Book,
1036    theme: &BookTheme,
1037    page_w: f32,
1038    page_h: f32,
1039    sx: f32,
1040    sy: f32,
1041    bx: f32,
1042    by: f32,
1043    overlays: &mut Vec<OverlayCmd>,
1044) -> widget::Widget {
1045    // Patchouli: title at book-local (13, 16), subtitle at (24, 24).
1046    // These overlap the nameplate sprite which is above/within the page top.
1047    overlays.push(OverlayCmd::McText {
1048        text: book.name.clone(),
1049        x: bx + 13.0 * sx,
1050        y: by + 16.0 * sy,
1051        color: theme.nameplate,
1052    });
1053    // Its edition ("1st Edition"), else its subtitle — as Patchouli.
1054    let second = match book.version {
1055        0 => book.subtitle.clone(),
1056        n => Some(word("edition", &ordinal(n))),
1057    };
1058    if let Some(text) = second {
1059        overlays.push(OverlayCmd::McText {
1060            text,
1061            x: bx + 24.0 * sx,
1062            y: by + 24.0 * sy,
1063            color: theme.nameplate,
1064        });
1065    }
1066
1067    // Widget content: landing text + entry count.
1068    // Landing text starts at page-local y=25 (= book-local y=43).
1069    // We push it down with top padding = 25*sy.
1070    let mut col = widget::panel(FlexDir::Column)
1071        .w(page_w)
1072        .h(page_h)
1073        .padding(25.0 * sy, 6.0, 4.0, 4.0)
1074        .gap(0.0);
1075
1076    // As small as it takes to fit above the entry count.
1077    let fit = landing_fit(&book.landing_text);
1078    for para in book.landing_text.split('\n') {
1079        if para.is_empty() {
1080            col = col.child(widget::spacer().h(4.0 * sy));
1081        } else {
1082            col = col.child(lbl(para).color(theme.text).font_scale(text_scale() * fit));
1083        }
1084    }
1085
1086    let total = book.entries.len();
1087    col = col.child(widget::spacer().flex(1.0));
1088    col = col.child(
1089        title_lbl(word("entry_count", &total.to_string()))
1090            .color(theme.nav)
1091            .h(9.0 * sy)
1092            .align(Align::Center),
1093    );
1094    col
1095}
1096
1097/// The scale (≤ 1) the landing text fits its page at.
1098fn landing_fit(text: &str) -> f32 {
1099    use yog_ui::text::{LINE_GAP, LINE_H, line_count};
1100    let room = PAGE_H - 25.0 - 4.0 - 9.0 - 2.0;
1101    let height = |scale: f32| -> f32 {
1102        text.split('\n')
1103            .map(|p| match p.is_empty() {
1104                true => 4.0,
1105                false => line_count(p, TEXT_W, scale) as f32 * (LINE_H * scale + LINE_GAP),
1106            })
1107            .sum()
1108    };
1109    let mut scale = 1.0;
1110    while scale > 0.6 && height(scale) > room {
1111        scale -= 0.05;
1112    }
1113    scale
1114}
1115
1116// ── Right page: category list (home view) ────────────────────────────────────
1117
1118fn build_categories_right(
1119    book: &Book,
1120    state: &BookViewState,
1121    theme: &BookTheme,
1122    page_w: f32,
1123    page_h: f32,
1124    rx: f32,
1125    py: f32,
1126    sx: f32,
1127    sy: f32,
1128    overlays: &mut Vec<OverlayCmd>,
1129) -> widget::Widget {
1130    // Patchouli right-page layout (landing):
1131    //   "Categories" header at page-local y=0, centered
1132    //   Separator at page-local y=12 (drawn as bg sprite, not here)
1133    //   4-column 24×24 icon grid starting at page-local (10, 25)
1134    let cell_w = 24.0 * sx;
1135    let cell_h = 24.0 * sy;
1136    let grid_pad_left = 10.0 * sx;
1137    let header_h = 9.0 * sy;
1138
1139    let mut col = widget::panel(FlexDir::Column)
1140        .w(page_w)
1141        .h(page_h)
1142        .padding(0.0, 4.0, 4.0, 0.0)
1143        .gap(0.0);
1144
1145    col = col.child(
1146        title_lbl(word("categories", ""))
1147            .color(theme.divider)
1148            .h(header_h)
1149            .align(Align::Center)
1150            .no_wrap(),
1151    );
1152    // Gap covering the separator region up to grid start (page-local y=25).
1153    col = col.child(widget::spacer().h(25.0 * sy - header_h));
1154
1155    // 4-column icon grid; icons drawn as GL overlays at exact Patchouli positions.
1156    let cats = &book.categories;
1157    let icon_s = 16.0 * sx.min(sy);
1158    let mut row_i = 0usize;
1159    loop {
1160        let row_start = row_i * 4;
1161        if row_start >= cats.len() {
1162            break;
1163        }
1164        let mut row =
1165            widget::panel(FlexDir::Row)
1166                .h(cell_h)
1167                .gap(0.0)
1168                .padding(0.0, 0.0, 0.0, grid_pad_left);
1169        for col_i in 0..4 {
1170            let idx = row_start + col_i;
1171            if idx >= cats.len() {
1172                break;
1173            }
1174            let cat = &cats[idx];
1175            let selected = !state.at_home && idx == state.cat;
1176            let bg = if selected { theme.nav_selected_bg } else { Color::TRANSPARENT };
1177
1178            if let Some(icon_id) = &cat.icon {
1179                push_icon(
1180                    overlays,
1181                    icon_id,
1182                    rx + (10.0 + col_i as f32 * 24.0 + 4.0) * sx,
1183                    py + (25.0 + row_i as f32 * 24.0 + 4.0) * sy,
1184                    icon_s,
1185                    icon_s,
1186                );
1187            }
1188            let cell = widget::panel(FlexDir::Column)
1189                .w(cell_w)
1190                .h(cell_h)
1191                .bg(bg)
1192                .on_click(format!("cat:{}", idx))
1193                .id(format!("book_cat_{}", idx));
1194            row = row.child(cell);
1195        }
1196        col = col.child(row);
1197        row_i += 1;
1198    }
1199    col
1200}
1201
1202// ── Left page: entry content (entry view) ────────────────────────────────────
1203
1204fn build_entry_left(
1205    book: &Book,
1206    state: &BookViewState,
1207    theme: &BookTheme,
1208    recipes: &HashMap<String, RecipeVis>,
1209    page_w: f32,
1210    page_h: f32,
1211    ox: f32,
1212    oy: f32,
1213    sx: f32,
1214    sy: f32,
1215    sep_cx: f32,
1216    bg_sprites: &mut Vec<BgSprite>,
1217    overlays: &mut Vec<OverlayCmd>,
1218) -> widget::Widget {
1219    let entry = state.current_entry(book);
1220    let page = entry.and_then(|e| e.pages.get(state.page));
1221    let page_count = state.page_count(book);
1222    let title_text = entry.map(|e| e.name.as_str()).unwrap_or("");
1223
1224    // Patchouli PageText page 0:
1225    //   title centered at page-local (PAGE_W/2, 0)
1226    //   separator at page-local (0, 12)  → centered ≡ sep_cx offset
1227    //   text body at page-local (0, 22)
1228    let title_h = 9.0 * sy;
1229    let sep_h_px = (SEP_H * sy).max(1.0);
1230    let sep_y = oy + 12.0 * sy;
1231    let body_oy = oy + 22.0 * sy;
1232
1233    bg_sprites.push(BgSprite {
1234        sheet: SpriteSheet::Book,
1235        src: Rect::new(SEP_U, SEP_V, SEP_W, SEP_H),
1236        dst: Rect::new(ox + sep_cx, sep_y, SEP_W * sx, sep_h_px),
1237    });
1238
1239    let page_label = format!("{}/{}", state.page + 1, page_count);
1240    let nav = widget::panel(FlexDir::Row)
1241        .h(12.0 * sy)
1242        .gap(4.0)
1243        .padding(0.0, 2.0, 0.0, 2.0)
1244        .child(
1245            btn("◀")
1246                .w(12.0 * sx)
1247                .h(12.0 * sy)
1248                .color(theme.nav)
1249                .on_click("prev_page")
1250                .id("prev_page"),
1251        )
1252        .child(
1253            lbl(&page_label)
1254                .color(theme.nav)
1255                .flex(1.0)
1256                .align(Align::Center),
1257        )
1258        .child(
1259            btn("⌂")
1260                .w(12.0 * sx)
1261                .h(12.0 * sy)
1262                .color(theme.nav)
1263                .on_click("home")
1264                .id("book_home"),
1265        )
1266        .child(
1267            btn("▶")
1268                .w(12.0 * sx)
1269                .h(12.0 * sy)
1270                .color(theme.nav)
1271                .on_click("next_page")
1272                .id("next_page"),
1273        );
1274
1275    let page_body = build_page(
1276        page,
1277        state.page,
1278        theme,
1279        recipes,
1280        &book.layouts,
1281        &book.page_types,
1282        bg_sprites,
1283        overlays,
1284        ox,
1285        body_oy,
1286        sx,
1287        sy,
1288    );
1289
1290    widget::panel(FlexDir::Column)
1291        .w(page_w)
1292        .h(page_h)
1293        .padding(0.0, 6.0, 4.0, 4.0)
1294        .gap(0.0)
1295        .child(
1296            title_lbl(title_text)
1297                .color(theme.title)
1298                .h(title_h)
1299                .align(Align::Center)
1300                .no_wrap(),
1301        )
1302        .child(widget::spacer().h((12.0 - 9.0) * sy)) // gap: title end → sep start
1303        .child(widget::spacer().h(sep_h_px)) // height occupied by sep sprite
1304        .child(widget::spacer().h((22.0 - 12.0 - SEP_H) * sy)) // gap: sep end → body
1305        .child(page_body)
1306        .child(nav)
1307}
1308
1309// ── Right page: entry list for selected category (entry view) ─────────────────
1310
1311fn build_entries_right(
1312    book: &Book,
1313    state: &BookViewState,
1314    theme: &BookTheme,
1315    page_w: f32,
1316    page_h: f32,
1317    rx: f32,
1318    py: f32,
1319    sx: f32,
1320    sy: f32,
1321    overlays: &mut Vec<OverlayCmd>,
1322) -> widget::Widget {
1323    let entries = state.entries_visible(book);
1324    let cat_name = book
1325        .categories
1326        .get(state.cat)
1327        .map(|c| c.name.as_str())
1328        .map(str::to_string)
1329        .unwrap_or_else(|| word("entries", ""));
1330    let spread_count = state.list_spread_count(book);
1331
1332    // Patchouli GuiBookEntryList layout (right page):
1333    //   category name at page-local y=0, centered
1334    //   separator at page-local y=12 (drawn as bg sprite in build_ui)
1335    //   entries start at page-local y=20, each h=11
1336    let header_h = 9.0 * sy;
1337    let row_h = 11.0 * sy;
1338    let icon_size = 8.0 * sx.min(sy);
1339
1340    let mut col = widget::panel(FlexDir::Column)
1341        .w(page_w)
1342        .h(page_h)
1343        .padding(0.0, 4.0, 8.0, 0.0)
1344        .gap(0.0);
1345
1346    col = col.child(
1347        title_lbl(cat_name)
1348            .color(theme.divider)
1349            .h(header_h)
1350            .align(Align::Center),
1351    );
1352    // sep region: y=9..15 (sep h=3), then gap to entries start at y=20
1353    col = col.child(widget::spacer().h((12.0 - 9.0) * sy)); // gap to sep
1354    col = col.child(widget::spacer().h((SEP_H * sy).max(1.0))); // sep height
1355    col = col.child(widget::spacer().h((20.0 - 12.0 - SEP_H) * sy)); // gap to entries
1356
1357    let abs_start = state.list_spread_start();
1358    for (i, entry) in entries.iter().enumerate() {
1359        let abs_i = abs_start + i;
1360        let selected = abs_i == state.entry;
1361        let bg = if selected { theme.nav_selected_bg } else { Color::TRANSPARENT };
1362        let color = if selected {
1363            theme.nav_selected
1364        } else {
1365            theme.nav
1366        };
1367
1368        // Icon at page-local (1, 20 + i*11 + 1), 8×8 (Patchouli renders entry
1369        // icons at 0.5× scale).
1370        if let Some(icon_id) = &entry.icon {
1371            push_icon(
1372                overlays,
1373                icon_id,
1374                rx + 1.0 * sx,
1375                py + (20.0 + i as f32 * 11.0 + 1.0) * sy,
1376                icon_size,
1377                icon_size,
1378            );
1379        }
1380
1381        let mut row = widget::panel(FlexDir::Row)
1382            .h(row_h)
1383            .gap(2.0)
1384            .bg(bg)
1385            .on_click(format!("entry:{}", abs_i))
1386            .id(format!("book_entry_{}", abs_i))
1387            .padding(1.0, 2.0, 1.0, 2.0);
1388
1389        row = row.child(widget::spacer().w(icon_size + 2.0));
1390        // Long names shrink to the row (as titles do).
1391        let fit = ((PAGE_W - 26.0) / yog_ui::text::str_width(&entry.name, 1.0)).clamp(0.5, 1.0);
1392        row = row.child(
1393            lbl(&entry.name)
1394                .color(color)
1395                .font_scale(text_scale() * fit)
1396                .flex(1.0)
1397                .no_wrap(),
1398        );
1399        col = col.child(row);
1400    }
1401
1402    if spread_count > 1 {
1403        col = col.child(widget::spacer().flex(1.0));
1404        let spread_label = format!("{}/{}", state.list_spread + 1, spread_count);
1405        col = col.child(
1406            widget::panel(FlexDir::Row)
1407                .h(12.0 * sy)
1408                .gap(2.0)
1409                .child(
1410                    btn("◀")
1411                        .w(12.0 * sx)
1412                        .h(12.0 * sy)
1413                        .color(theme.nav)
1414                        .on_click("prev_list")
1415                        .id("prev_list"),
1416                )
1417                .child(
1418                    lbl(&spread_label)
1419                        .color(theme.nav)
1420                        .flex(1.0)
1421                        .align(Align::Center),
1422                )
1423                .child(
1424                    btn("▶")
1425                        .w(12.0 * sx)
1426                        .h(12.0 * sy)
1427                        .color(theme.nav)
1428                        .on_click("next_list")
1429                        .id("next_list"),
1430                ),
1431        );
1432    }
1433    col
1434}
1435
1436/// Draws `vis` the way `layout` lays it out, under a title with its result's
1437/// name, centred on the page (body-local coordinates, like Patchouli's pages).
1438#[allow(clippy::too_many_arguments)]
1439fn draw_layout(
1440    theme: &BookTheme,
1441    bg_sprites: &mut Vec<BgSprite>,
1442    overlays: &mut Vec<OverlayCmd>,
1443    col: &mut widget::Widget,
1444    vis: &RecipeVis,
1445    layout: &crate::layout::RecipeLayout,
1446    ox: f32,
1447    oy: f32,
1448    sx: f32,
1449    sy: f32,
1450) {
1451    use crate::layout::{FRAME_UV, LayoutPart};
1452    let mut c = std::mem::replace(col, widget::panel(FlexDir::Column));
1453    c = c.child(
1454        title_lbl(pretty_item_name(&vis.output))
1455            .color(theme.title)
1456            .h(10.0 * sy)
1457            .align(Align::Center),
1458    );
1459    let (lx, ly) = (PAGE_W / 2.0 - 1.0 - layout.size[0] / 2.0, 10.0);
1460    let at = |p: [f32; 2]| (ox + (lx + p[0]) * sx, oy + (ly + p[1]) * sy);
1461    let icon_s = 16.0 * sx.min(sy);
1462    let frame = |bg: &mut Vec<BgSprite>, p: [f32; 2]| {
1463        let (x, y) = at([p[0] - 4.0, p[1] - 4.0]);
1464        let [u, v, uw, uh] = FRAME_UV;
1465        bg.push(BgSprite {
1466            sheet: SpriteSheet::Crafting,
1467            src: Rect::new(u, v, uw, uh),
1468            dst: Rect::new(x, y, uw * sx, uh * sy),
1469        });
1470    };
1471    for part in &layout.parts {
1472        match part {
1473            LayoutPart::Image { source, uv, at: p } => {
1474                let (x, y) = at(*p);
1475                let sheet = if source == CRAFTING_SHEET {
1476                    SpriteSheet::Crafting
1477                } else {
1478                    SpriteSheet::Image(source.clone())
1479                };
1480                let [u, v, uw, uh] = *uv;
1481                bg_sprites.push(BgSprite {
1482                    sheet: sheet,
1483                    src: Rect::new(u, v, uw, uh),
1484                    dst: Rect::new(x, y, uw * sx, uh * sy),
1485                });
1486            }
1487            LayoutPart::Slots {
1488                slot,
1489                at: places,
1490                frame: framed,
1491            } => {
1492                let ingredients = vis.slots.get(slot);
1493                for (i, p) in places.iter().enumerate() {
1494                    if *framed {
1495                        frame(bg_sprites, *p);
1496                    }
1497                    if let Some(items) =
1498                        ingredients.and_then(|l| l.get(i)).filter(|x| !x.is_empty())
1499                    {
1500                        let (x, y) = at(*p);
1501                        overlays.push(OverlayCmd::McItems {
1502                            items: items.clone(),
1503                            x,
1504                            y,
1505                            w: icon_s,
1506                            h: icon_s,
1507                        });
1508                    }
1509                }
1510            }
1511            LayoutPart::Result {
1512                at: p,
1513                frame: framed,
1514            } => {
1515                if *framed {
1516                    frame(bg_sprites, *p);
1517                }
1518                let (x, y) = at(*p);
1519                overlays.push(OverlayCmd::McItem {
1520                    item_id: vis.output.clone(),
1521                    x,
1522                    y,
1523                    w: icon_s,
1524                    h: icon_s,
1525                });
1526            }
1527            LayoutPart::Count { at: p } => {
1528                if vis.count > 1 {
1529                    let (x, y) = at(*p);
1530                    overlays.push(OverlayCmd::McText {
1531                        text: format!("x{}", vis.count),
1532                        x,
1533                        y,
1534                        color: theme.title,
1535                    });
1536                }
1537            }
1538            LayoutPart::Item { item, at: p } => {
1539                let (x, y) = at(*p);
1540                overlays.push(OverlayCmd::McItem {
1541                    item_id: item.clone(),
1542                    x,
1543                    y,
1544                    w: icon_s,
1545                    h: icon_s,
1546                });
1547            }
1548            LayoutPart::Text { text, at: p } => {
1549                let mut shown = text.clone();
1550                for (name, value) in &vis.values {
1551                    shown = shown.replace(&format!("{{{name}}}"), value);
1552                }
1553                let (x, y) = at(*p);
1554                overlays.push(OverlayCmd::McText {
1555                    text: shown,
1556                    x,
1557                    y,
1558                    color: theme.text,
1559                });
1560            }
1561        }
1562    }
1563    // The title is 10 high; the layout, and a little room under it.
1564    c = c.child(widget::spacer().h((layout.size[1] + 6.0) * sy));
1565    *col = c;
1566}
1567
1568// ── Page content builder ──────────────────────────────────────────────────────
1569
1570/// Build the content widget for a single page.
1571/// `ox/oy` = screen top-left of the page body area.
1572/// `sx/sy` = book-local-to-screen scale factors.
1573fn build_page(
1574    page: Option<&BookPage>,
1575    page_num: usize,
1576    theme: &BookTheme,
1577    recipes: &HashMap<String, RecipeVis>,
1578    layouts: &[crate::layout::RecipeLayout],
1579    page_types: &[crate::page_type::PageType],
1580    bg_sprites: &mut Vec<BgSprite>,
1581    overlays: &mut Vec<OverlayCmd>,
1582    ox: f32,
1583    oy: f32,
1584    sx: f32,
1585    sy: f32,
1586) -> widget::Widget {
1587    let mut col = widget::panel(FlexDir::Column).flex(1.0).gap(4.0);
1588
1589    let Some(page) = page else {
1590        return col.child(lbl(word("no_entries", "")).color(theme.nav));
1591    };
1592
1593    match page {
1594        BookPage::Text {
1595            text,
1596            title,
1597            font,
1598            font_size,
1599        } => {
1600            // On non-first pages, show an optional section title + separator.
1601            if page_num > 0
1602                && let Some(t) = title
1603            {
1604                let sep_h_px = (SEP_H * sy).max(1.0);
1605                let title_h = 9.0 * sy;
1606                col = col.child(
1607                    title_lbl(t.as_str())
1608                        .color(theme.title)
1609                        .h(title_h)
1610                        .align(Align::Center)
1611                        .no_wrap(),
1612                );
1613                // sep at y=12 page-local, centered
1614                bg_sprites.push(BgSprite {
1615                    sheet: SpriteSheet::Book,
1616                    src: Rect::new(SEP_U, SEP_V, SEP_W, SEP_H),
1617                    dst: Rect::new(ox + (PAGE_W / 2.0 - SEP_W / 2.0) * sx, oy + 12.0 * sy, SEP_W * sx, sep_h_px),
1618                });
1619                col = col.child(widget::spacer().h((12.0 - 9.0) * sy));
1620                col = col.child(widget::spacer().h(sep_h_px));
1621                col = col.child(widget::spacer().h((22.0 - 12.0 - SEP_H) * sy));
1622            }
1623            // One label: its lines are as far apart as the flowing counted.
1624            let mut l = lbl(text.as_str()).color(theme.text);
1625            // Another font (a resource-pack one), `font_size` pixels a line.
1626            if let Some(f) = font {
1627                l = l.font(f.as_str()).font_scale(
1628                    text_scale() * font_size.unwrap_or(yog_ui::text::LINE_H) / yog_ui::text::LINE_H,
1629                );
1630            }
1631            col = col.child(l);
1632        }
1633
1634        BookPage::Spotlight { item, title, text } => {
1635            // Patchouli PageSpotlight (page-body-local coordinates):
1636            //   title at y=0 centered, item frame at (PAGE_W/2-33, 10) 66×26
1637            //   (UV 0,102 on the 128×256 crafting sheet), item at (PAGE_W/2-8, 15),
1638            //   text at y=40.
1639            bg_sprites.push(BgSprite {
1640                sheet: SpriteSheet::Crafting,
1641                src: Rect::new(0.0, 102.0, 66.0, 26.0),
1642                dst: Rect::new(ox + (PAGE_W / 2.0 - 33.0) * sx, oy + 10.0 * sy, 66.0 * sx, 26.0 * sy),
1643            });
1644
1645            let item_name = title.clone().unwrap_or_else(|| pretty_item_name(item));
1646            col = col.child(
1647                title_lbl(item_name)
1648                    .color(theme.title)
1649                    .h(10.0 * sy)
1650                    .align(Align::Center),
1651            );
1652
1653            let icon_s = 16.0 * sx.min(sy);
1654            overlays.push(OverlayCmd::McItem {
1655                item_id: item.clone(),
1656                x: ox + (PAGE_W / 2.0 - 8.0) * sx,
1657                y: oy + 15.0 * sy,
1658                w: icon_s,
1659                h: icon_s,
1660            });
1661
1662            // Spacer from title end (y=10) to text start (y=40).
1663            col = col.child(widget::spacer().h(30.0 * sy));
1664
1665            if let Some(t) = text {
1666                col = col.child(lbl(t.as_str()).color(theme.text));
1667            }
1668        }
1669
1670        BookPage::Recipe { recipe, text } => {
1671            match recipes
1672                .get(recipe.as_str())
1673                .and_then(|vis| crate::layout::find(layouts, &vis.kind).map(|l| (vis, l)))
1674            {
1675                Some((vis, layout)) => draw_layout(
1676                    theme, bg_sprites, overlays, &mut col, vis, layout, ox, oy, sx, sy,
1677                ),
1678                None => col = col.child(lbl(format!("[Recipe: {}]", recipe)).color(theme.nav)),
1679            }
1680            if let Some(t) = text {
1681                col = col.child(lbl(t.as_str()).color(theme.text));
1682            }
1683        }
1684
1685        BookPage::Image {
1686            texture,
1687            title,
1688            text,
1689            ..
1690        } => {
1691            if let Some(t) = title {
1692                col = col.child(title_lbl(t.as_str()).color(theme.title));
1693            }
1694            col = col.child(
1695                widget::image(texture)
1696                    .region(Rect::new(0.0, 0.0, 80.0, 80.0))
1697                    .stretch(Stretch::Fill)
1698                    .w(80.0)
1699                    .h(80.0),
1700            );
1701            if let Some(t) = text {
1702                col = col.child(lbl(t.as_str()).color(theme.text));
1703            }
1704        }
1705
1706        BookPage::Relations { entries, text } => {
1707            if let Some(t) = text {
1708                col = col.child(lbl(t.as_str()).color(theme.text));
1709            }
1710            col = col.child(title_lbl(word("see_also", "")).color(theme.title));
1711            for e in entries {
1712                col = col.child(lbl(format!("• {}", e)).color(theme.nav));
1713            }
1714        }
1715
1716        BookPage::Multiblock {
1717            name,
1718            pattern,
1719            mapping,
1720            text,
1721        } => {
1722            // A 70px view (under its name), its text below.
1723            let top = if let Some(n) = name {
1724                col = col.child(title_lbl(n.as_str()).color(theme.title).h(10.0 * sy));
1725                MB_NAME_H
1726            } else {
1727                0.0
1728            };
1729            overlays.push(OverlayCmd::Blocks {
1730                blocks: crate::multiblock_blocks(pattern, mapping),
1731                x: ox + (PAGE_W / 2.0 - MB_VIEW / 2.0) * sx,
1732                y: oy + top * sy,
1733                w: MB_VIEW * sx,
1734                h: MB_VIEW * sy,
1735            });
1736            col = col.child(widget::spacer().h(MB_VIEW * sy));
1737            if let Some(t) = text {
1738                col = col.child(lbl(t.as_str()).color(theme.text));
1739            }
1740        }
1741
1742        BookPage::Entity { entity, name, text } => {
1743            let display = name.clone().unwrap_or_else(|| entity_name(entity));
1744            col = col.child(title_lbl(display).color(theme.title));
1745            overlays.push(OverlayCmd::Entity {
1746                entity: entity.clone(),
1747                x: ox + (PAGE_W / 2.0 - 40.0) * sx,
1748                y: oy + 12.0 * sy,
1749                w: 80.0 * sx,
1750                h: 80.0 * sy,
1751            });
1752            col = col.child(widget::spacer().h(84.0 * sy));
1753            if let Some(t) = text {
1754                col = col.child(lbl(t.as_str()).color(theme.text));
1755            }
1756        }
1757
1758        BookPage::Drawn {
1759            drawer,
1760            title,
1761            height,
1762            caption,
1763            text,
1764            data,
1765        } => {
1766            let mut top = 0.0;
1767            if let Some(t) = title {
1768                col = col.child(title_lbl(t.as_str()).color(theme.title).h(10.0 * sy));
1769                top = 14.0;
1770            }
1771            // The picture: a box the page's width, drawn by the mod's code.
1772            if *height > 0.0 {
1773                overlays.push(OverlayCmd::Drawn {
1774                    drawer: drawer.clone(),
1775                    data: data.clone(),
1776                    x: ox,
1777                    y: oy + top * sy,
1778                    w: PAGE_W * sx,
1779                    h: height * sy,
1780                });
1781                col = col.child(widget::spacer().h(height * sy));
1782            }
1783            if let Some(c) = caption {
1784                col = col.child(lbl(c.as_str()).color(theme.text));
1785            }
1786            if let Some(t) = text {
1787                col = col.child(lbl(t.as_str()).color(theme.text));
1788            }
1789        }
1790
1791        BookPage::Empty => {}
1792
1793        // A mod's own kind of page: its type's elements, top to bottom.
1794        BookPage::Custom(c) => {
1795            use crate::page_type::PageElement as E;
1796            let Some(t) = page_types.iter().find(|t| t.id == c.kind) else {
1797                return col.child(lbl(format!("[{}]", c.kind)).color(theme.nav));
1798            };
1799            let line_h = yog_ui::text::LINE_H + yog_ui::text::LINE_GAP;
1800            // How far down the next element starts, in book pixels (pictures are overlays).
1801            let mut top = 0.0;
1802            for el in &t.body {
1803                match el {
1804                    E::Header { text, visible } if t.visible(c, visible) => {
1805                        col = col.child(title_lbl(t.text(c, text)).color(theme.title).h(10.0 * sy));
1806                        top += 14.0;
1807                    }
1808                    E::Text { text, visible } if t.visible(c, visible) => {
1809                        let words = t.text(c, text);
1810                        let lines: usize = words.split('\n').map(|p| yog_ui::text::wrap_text(p, TEXT_W, 1.0).len().max(1)).sum();
1811                        col = col.child(lbl(words).color(theme.text));
1812                        top += lines as f32 * line_h + 4.0;
1813                    }
1814                    E::Item { item, visible } if t.visible(c, visible) => {
1815                        let id = crate::page_type::words(&t.value(c, item));
1816                        push_icon(overlays, &id, ox + (PAGE_W / 2.0 - 8.0) * sx, oy + top * sy, 16.0 * sx, 16.0 * sy);
1817                        col = col.child(widget::spacer().h(16.0 * sy));
1818                        top += 20.0;
1819                    }
1820                    E::Image { image, height, visible } if t.visible(c, visible) => {
1821                        let src = crate::page_type::words(&t.value(c, image));
1822                        col = col.child(widget::image(src).stretch(Stretch::Fill).w(PAGE_W * sx).h(height * sy));
1823                        top += height + 4.0;
1824                    }
1825                    E::Drawn { drawer, height, data, visible } if t.visible(c, visible) => {
1826                        overlays.push(OverlayCmd::Drawn {
1827                            drawer: drawer.clone(),
1828                            data: t.value(c, data),
1829                            x: ox,
1830                            y: oy + top * sy,
1831                            w: PAGE_W * sx,
1832                            h: height * sy,
1833                        });
1834                        col = col.child(widget::spacer().h(height * sy));
1835                        top += height + 4.0;
1836                    }
1837                    E::Spacer { height } => {
1838                        col = col.child(widget::spacer().h(height * sy));
1839                        top += height + 4.0;
1840                    }
1841                    _ => {}
1842                }
1843            }
1844        }
1845    }
1846
1847    col
1848}
1849
1850#[cfg(test)]
1851mod tests {
1852    use super::*;
1853
1854    fn j(json: &str) -> yog_data::Value {
1855        yog_data::json::from_json_text(json).unwrap()
1856    }
1857    use crate::BookEntry;
1858
1859    fn book(pages: Vec<BookPage>) -> Book {
1860        let mut book = Book {
1861            id: "t:b".into(),
1862            name: "B".into(),
1863            ..Default::default()
1864        };
1865        book.entries.push(BookEntry {
1866            id: "e".into(),
1867            name: "E".into(),
1868            pages,
1869            ..Default::default()
1870        });
1871        book
1872    }
1873
1874    #[test]
1875    fn long_text_flows_onto_new_pages() {
1876        let long = "word ".repeat(400);
1877        let flowed = flow(book(vec![
1878            BookPage::Text {
1879                text: long.clone(),
1880                title: None,
1881                font: None,
1882                font_size: None,
1883            },
1884            BookPage::Empty,
1885        ]));
1886        let pages = &flowed.entries[0].pages;
1887        assert!(pages.len() > 3, "{}", pages.len());
1888        assert!(
1889            matches!(pages.last(), Some(BookPage::Empty)),
1890            "what came after stays after"
1891        );
1892        let line_h = yog_ui::text::LINE_H + yog_ui::text::LINE_GAP;
1893        for p in &pages[..pages.len() - 1] {
1894            let BookPage::Text { text, .. } = p else {
1895                panic!("{p:?}")
1896            };
1897            assert!(text.lines().count() as f32 * line_h <= BODY_H + 1.0);
1898            assert!(
1899                text.lines()
1900                    .all(|l| yog_ui::text::str_width(l, 1.0) <= TEXT_W)
1901            );
1902        }
1903        let words: usize = pages
1904            .iter()
1905            .map(|p| match p {
1906                BookPage::Text { text, .. } => text.split_whitespace().count(),
1907                _ => 0,
1908            })
1909            .sum();
1910        assert_eq!(words, 400, "nothing lost");
1911    }
1912
1913    #[test]
1914    fn edits_keep_the_reader_where_they_were() {
1915        let mut b = book(vec![
1916            BookPage::Text {
1917                text: "one".into(),
1918                title: None,
1919                font: None,
1920                font_size: None,
1921            },
1922            BookPage::Text {
1923                text: "two".into(),
1924                title: None,
1925                font: None,
1926                font_size: None,
1927            },
1928        ]);
1929        b.categories.push(crate::BookCategory {
1930            id: "c".into(),
1931            name: "C".into(),
1932            ..Default::default()
1933        });
1934        b.entries[0].category = "c".into();
1935        let mut r = BookRenderer::new(b.clone());
1936        assert_eq!(r.shown(), None, "opens on the landing page");
1937        assert!(r.show("e", 1));
1938        assert_eq!(r.shown(), Some(("e".into(), 1)));
1939        r.prepare(800.0, 600.0);
1940        assert!(r.ui.is_some());
1941
1942        b.entries[0].pages.push(BookPage::Empty);
1943        r.set_book(b);
1944        assert_eq!(r.shown(), Some(("e".into(), 1)));
1945        assert!(!r.show("missing", 0));
1946    }
1947
1948    #[test]
1949    fn pages_are_counted_as_written() {
1950        let long = "word ".repeat(400);
1951        let mut b = book(vec![
1952            BookPage::Text {
1953                text: long,
1954                title: None,
1955                font: None,
1956                font_size: None,
1957            },
1958            BookPage::Empty,
1959        ]);
1960        b.categories.push(crate::BookCategory {
1961            id: "c".into(),
1962            name: "C".into(),
1963            ..Default::default()
1964        });
1965        b.entries[0].category = "c".into();
1966        let mut r = BookRenderer::new(b);
1967        assert!(r.show("e", 1));
1968        assert_eq!(
1969            r.shown(),
1970            Some(("e".into(), 1)),
1971            "the second page as written"
1972        );
1973        assert!(r.state.page > 1, "past the first page's flowed text");
1974        r.handle_event("prev_page");
1975        assert_eq!(
1976            r.shown(),
1977            Some(("e".into(), 0)),
1978            "flowed text belongs to the first"
1979        );
1980    }
1981
1982    #[test]
1983    fn reads_yog_recipes() {
1984        let shaped = parse_recipe(&j(r##"{"type":"shaped","pattern":["AB"],"key":{"A":"m:a","B":["m:x","#c:t"]},"result":"m:out","count":2}"##)).unwrap();
1985        assert_eq!(
1986            (shaped.kind.as_str(), shaped.output.as_str(), shaped.count),
1987            ("shaped", "m:out", 2)
1988        );
1989        let grid = &shaped.slots["grid"];
1990        assert_eq!(
1991            (grid.len(), &grid[1]),
1992            (9, &vec!["m:x".to_string(), "#c:t".to_string()]),
1993            "cells by place on the 3×3 grid"
1994        );
1995        let blast =
1996            parse_recipe(&j(r##"{"type":"blasting","ingredient":"m:ore","result":"m:ingot"}"##))
1997                .unwrap();
1998        assert_eq!(
1999            (
2000                blast.slots["input"][0][0].as_str(),
2001                blast.values["time"].as_str()
2002            ),
2003            ("m:ore", "100")
2004        );
2005        let own = parse_recipe(&j(r##"{"type":"m:infusion","slots":{"ring":["m:a",["m:b","m:c"]]},"values":{"time":40},"result":"m:out"}"##)).unwrap();
2006        assert_eq!(
2007            (own.slots["ring"].len(), own.values["time"].as_str()),
2008            (2, "40")
2009        );
2010        assert!(
2011            parse_recipe(&j(r#"{"type":"minecraft:crafting_shaped"}"#)).is_none(),
2012            "vanilla JSON isn't the book's"
2013        );
2014    }
2015
2016    #[test]
2017    fn recipe_pages_are_drawn_by_layouts() {
2018        let theme = BookTheme::default();
2019        let mut recipes = HashMap::new();
2020        recipes.insert("m:r".to_string(), parse_recipe(&j(r##"{"type":"m:infusion","slots":{"ring":["m:a","m:b"]},"values":{"time":40},"result":"m:out"}"##)).unwrap());
2021        recipes.insert("m:s".to_string(), parse_recipe(&j(r##"{"type":"smithing","template":"m:t","base":"m:b","addition":"m:a","result":"m:out"}"##)).unwrap());
2022        let layout: crate::layout::RecipeLayout = yog_data::from_text(
2023            r#"{ kinds: ["m:infusion"], size: [96, 40], parts: [
2024                { type: "slots", slot: "ring", at: [[0, 0], [20, 0]], frame: true },
2025                { type: "result", at: [76, 10] },
2026                { type: "text", text: "{time} ticks", at: [0, 30] },
2027            ] }"#,
2028        )
2029        .unwrap()
2030        .value;
2031        let draw = |id: &str| {
2032            let (mut bg, mut ov) = (Vec::new(), Vec::new());
2033            let page = BookPage::Recipe {
2034                recipe: id.into(),
2035                text: None,
2036            };
2037            build_page(
2038                Some(&page),
2039                0,
2040                &theme,
2041                &recipes,
2042                std::slice::from_ref(&layout),
2043                &[],
2044                &mut bg,
2045                &mut ov,
2046                0.0,
2047                0.0,
2048                1.0,
2049                1.0,
2050            );
2051            (bg, ov)
2052        };
2053        let (bg, ov) = draw("m:r");
2054        let lx = PAGE_W / 2.0 - 1.0 - 48.0;
2055        let items: Vec<(f32, f32)> = ov
2056            .iter()
2057            .filter_map(|o| match o {
2058                OverlayCmd::McItems { x, y, .. } | OverlayCmd::McItem { x, y, .. } => {
2059                    Some((*x, *y))
2060                }
2061                _ => None,
2062            })
2063            .collect();
2064        assert_eq!(items, [(lx, 10.0), (lx + 20.0, 10.0), (lx + 76.0, 20.0)]);
2065        assert_eq!(bg.len(), 2, "a frame behind each ring slot");
2066        assert!(
2067            ov.iter()
2068                .any(|o| matches!(o, OverlayCmd::McText { text, .. } if text == "40 ticks"))
2069        );
2070        // The game's kinds: built-in layouts (smithing had none before).
2071        let (bg, ov) = draw("m:s");
2072        assert!(
2073            bg.iter().any(|b| b.src.x == 11.0 && b.src.y == 135.0),
2074            "Patchouli's smithing strip"
2075        );
2076        assert_eq!(
2077            ov.iter()
2078                .filter(|o| matches!(o, OverlayCmd::McItems { .. }))
2079                .count(),
2080            3
2081        );
2082    }
2083
2084    #[test]
2085    fn multiblocks_turn_and_animate() {
2086        let pattern = vec![vec!["G".to_string()], vec!["S ".to_string()]];
2087        let mapping = [
2088            ("G".to_string(), "minecraft:glass".to_string()),
2089            ("S".to_string(), "minecraft:stone".to_string()),
2090        ]
2091        .into();
2092        let blocks = crate::multiblock_blocks(&pattern, &mapping);
2093        assert_eq!(
2094            blocks,
2095            [
2096                ((0, 1, 0), "minecraft:glass".to_string()),
2097                ((0, 0, 0), "minecraft:stone".to_string())
2098            ]
2099        );
2100
2101        let mut b = book(vec![BookPage::Multiblock {
2102            name: None,
2103            pattern,
2104            mapping,
2105            text: None,
2106        }]);
2107        b.categories.push(crate::BookCategory {
2108            id: "c".into(),
2109            name: "C".into(),
2110            ..Default::default()
2111        });
2112        b.entries[0].category = "c".into();
2113        let mut r = BookRenderer::new(b);
2114        r.prepare(800.0, 600.0);
2115        assert!(!r.animated(), "the landing page is still");
2116        assert_eq!(landing_fit("short"), 1.0);
2117        assert!(
2118            landing_fit(&"many words here ".repeat(60)) < 1.0,
2119            "long landing texts shrink"
2120        );
2121        r.show("e", 0);
2122        r.prepare(800.0, 600.0);
2123        assert!(r.animated(), "a structure turns");
2124    }
2125
2126    #[test]
2127    fn short_text_stays_put() {
2128        let pages = vec![BookPage::Text {
2129            text: "Hello".into(),
2130            title: Some("T".into()),
2131            font: None,
2132            font_size: None,
2133        }];
2134        let flowed = flow(book(pages.clone()));
2135        assert_eq!(flowed.entries[0].pages.len(), 1);
2136    }
2137
2138    #[test]
2139    fn a_drawn_page_hands_its_box_and_data_to_the_mods_drawer() {
2140        let data = yog_data::json::from_json(&serde_json::json!({ "op_id": "hexcasting:manual" }));
2141        let page: BookPage = serde_json::from_value(serde_json::json!({
2142            "type": "drawn", "drawer": "hexcasting:pattern", "title": "Manual", "height": 64,
2143            "caption": "{u|entity} → {u|num}", "text": "Casts.", "data": { "op_id": "hexcasting:manual" }
2144        }))
2145        .unwrap();
2146        assert!(matches!(&page, BookPage::Drawn { data: d, height, .. } if *d == data && *height == 64.0));
2147        assert_eq!(content_above_text(&page), Some(14.0 + 68.0 + 14.0));
2148        let mut b = book(vec![page.clone()]);
2149        b.categories.push(crate::BookCategory { id: "c".into(), name: "C".into(), ..Default::default() });
2150        b.entries[0].category = "c".into();
2151        let mut r = BookRenderer::new(b);
2152        r.show("e", 0);
2153        r.prepare(800.0, 600.0);
2154        let drawn: Vec<_> = r.overlays.iter().filter_map(|o| match o {
2155            OverlayCmd::Drawn { drawer, data, w, h, .. } => Some((drawer.clone(), data.clone(), *w / *h)),
2156            _ => None,
2157        }).collect();
2158        assert_eq!(drawn.len(), 1);
2159        assert_eq!((drawn[0].0.as_str(), &drawn[0].1), ("hexcasting:pattern", &data));
2160        assert!((drawn[0].2 - PAGE_W / 64.0).abs() < 0.01, "the page's width, 64 high");
2161        // The JSON form keeps the data.
2162        assert_eq!(serde_json::to_value(&page).unwrap()["data"]["op_id"], "hexcasting:manual");
2163    }
2164
2165    #[test]
2166    fn a_mods_page_type_lays_out_its_pages() {
2167        use crate::page_type::{PageElement as E, PageProperty, PageType, PropKind};
2168        let ty = PageType {
2169            id: "hexcasting:pattern".into(),
2170            properties: vec![
2171                PageProperty { name: "name".into(), kind: PropKind::BookText, default: None },
2172                PageProperty { name: "patterns".into(), kind: PropKind::Data, default: Some(yog_data::Value::List(vec![])) },
2173            ],
2174            body: vec![
2175                E::Header { text: "{x:Bind name}".into(), visible: None },
2176                E::Drawn { drawer: "hexcasting:pattern".into(), height: 64.0, data: "{x:Bind patterns}".into(), visible: Some("{x:Bind patterns}".into()) },
2177            ],
2178        };
2179        let page = |json: &str| BookPage::Custom(yog_data::read_value(&yog_data::json::from_json_text(json).unwrap()).unwrap());
2180        let mut b = book(vec![
2181            page(r#"{"type":"hexcasting:pattern","name":"Manual","patterns":[{"startdir":"EAST","signature":"qaq"}]}"#),
2182            page(r#"{"type":"hexcasting:pattern","name":"Bare"}"#),
2183        ]);
2184        b.page_types.push(ty);
2185        b.categories.push(crate::BookCategory { id: "c".into(), name: "C".into(), ..Default::default() });
2186        b.entries[0].category = "c".into();
2187        let mut r = BookRenderer::new(b);
2188        r.show("e", 0);
2189        r.prepare(800.0, 600.0);
2190        let drawn: Vec<_> = r.overlays.iter().filter_map(|o| match o {
2191            OverlayCmd::Drawn { drawer, data, .. } => Some((drawer.clone(), data.clone())),
2192            _ => None,
2193        }).collect();
2194        assert_eq!(drawn.len(), 1, "the second page has no patterns: no picture");
2195        assert_eq!(drawn[0].0, "hexcasting:pattern");
2196        assert!(matches!(&drawn[0].1, yog_data::Value::List(l) if l.len() == 1), "the field's whole value");
2197    }
2198
2199    #[test]
2200    fn a_mods_page_flows_its_long_text_onto_text_pages() {
2201        use crate::page_type::{PageElement as E, PageProperty, PageType, PropKind};
2202        let ty = PageType {
2203            id: "m:p".into(),
2204            properties: vec![
2205                PageProperty { name: "patterns".into(), kind: PropKind::Data, default: None },
2206                PageProperty { name: "text".into(), kind: PropKind::BookText, default: None },
2207            ],
2208            body: vec![
2209                E::Drawn { drawer: "m:d".into(), height: 64.0, data: "{x:Bind patterns}".into(), visible: None },
2210                E::Text { text: "{x:Bind text}".into(), visible: None },
2211            ],
2212        };
2213        let long = "word ".repeat(120);
2214        let page = format!(r#"{{"type":"m:p","patterns":[1],"text":"{long}"}}"#);
2215        let mut b = book(vec![BookPage::Custom(yog_data::read_value(&yog_data::json::from_json_text(&page).unwrap()).unwrap())]);
2216        b.page_types.push(ty);
2217        let flowed = flow(b);
2218        let pages = &flowed.entries[0].pages;
2219        assert!(pages.len() >= 2, "{pages:?}");
2220        let BookPage::Custom(first) = &pages[0] else { panic!("{:?}", pages[0]) };
2221        let kept = first.fields.get("text").and_then(|v| v.as_str()).unwrap();
2222        let line_h = yog_ui::text::LINE_H + yog_ui::text::LINE_GAP;
2223        assert!(kept.lines().count() as f32 * line_h <= BODY_H - 68.0 + 1.0, "under the picture: {kept}");
2224        let words: usize = kept.split_whitespace().count()
2225            + pages[1..].iter().map(|p| match p {
2226                BookPage::Text { text, .. } => text.split_whitespace().count(),
2227                _ => 0,
2228            }).sum::<usize>();
2229        assert_eq!(words, 120, "nothing lost");
2230    }
2231
2232    #[test]
2233    fn a_translated_book_is_flowed_from_its_words_not_its_keys() {
2234        // A key too long for a page: flowed as it is, it'd be cut in pieces.
2235        fn tr(k: &str) -> Option<String> {
2236            (k.len() == 3000 && k.starts_with("mymod.")).then(|| "Short.".into())
2237        }
2238        let key = format!("mymod.{}", "x".repeat(2994));
2239        let mut b = book(vec![BookPage::Text {
2240            text: key,
2241            title: None,
2242            font: None,
2243            font_size: None,
2244        }]);
2245        b.i18n = true;
2246        let mut r = BookRenderer::new(b);
2247        r.set_translator(tr);
2248        r.localize();
2249        let pages = &r.book().entries[0].pages;
2250        assert_eq!(pages.len(), 1, "{pages:?}");
2251        assert!(matches!(&pages[0], BookPage::Text { text, .. } if text == "Short."), "{pages:?}");
2252    }
2253}