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