Skip to main content

yog_book/
renderer.rs

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