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