Skip to main content

yog_book/
lib.rs

1//! yog-book — in-game book/documentation framework for Yog mods.
2//!
3//! Provides Patchouli-like book data model plus a full GPU renderer on top of
4//! yog-ui and yog-gfx, with SVG icon support and custom TTF/OTF fonts.
5
6pub mod state;
7pub mod theme;
8pub mod font;
9pub mod svg;
10pub mod renderer;
11
12use serde::{Deserialize, Serialize};
13use yog_registry::ItemDef;
14
15pub use state::BookViewState;
16pub use theme::BookTheme;
17pub use font::{BookFont, BookFontRegistry};
18pub use renderer::BookRenderer;
19
20// ── Macros ───────────────────────────────────────────────────────────────────
21
22/// A macro substitution (e.g. `$(thing)` → red color span).
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct BookMacro(pub String, pub String);
25
26// ── Page types ───────────────────────────────────────────────────────────────
27
28/// A single page variant inside a book entry.
29///
30/// Serialized internally-tagged (`{"type": "Text", ...}`) — the stable wire
31/// format used across the mod ↔ runtime JSON boundary (like Patchouli's
32/// per-page "type" field).
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[serde(tag = "type")]
35pub enum BookPage {
36    /// Plain formatted text, optionally with a section title (for non-first pages).
37    Text {
38        text: String,
39        #[serde(default)]
40        title: Option<String>,
41    },
42    /// Display an item outlined (tooltip on hover).
43    Spotlight {
44        item: ItemDef,
45        title: Option<String>,
46        text: Option<String>,
47    },
48    /// Crafting recipe display (autorenders 3×3 grid).
49    Crafting {
50        recipe_id: String,
51        text: Option<String>,
52    },
53    /// Smelting recipe display.
54    Smelting {
55        recipe_id: String,
56        text: Option<String>,
57    },
58    /// Image overlay page.
59    Image {
60        texture: String,
61        title: Option<String>,
62        text: Option<String>,
63        border: bool,
64    },
65    /// Entity display page (renders a living entity in a box).
66    Entity {
67        entity_type: String,
68        name: Option<String>,
69        text: Option<String>,
70    },
71    /// Link to another entry (like Patchouli's relations).
72    Relations {
73        entries: Vec<String>,
74        text: Option<String>,
75    },
76    /// Empty separator.
77    Empty,
78    /// Custom pattern page for Hexcasting-style mods (like `hexcasting:pattern`).
79    Pattern {
80        op_id: String,
81        anchor: String,
82        input: String,
83        output: String,
84        text: String,
85    },
86    /// SVG image page — rasterized at render time via `resvg`.
87    Svg {
88        /// Raw SVG source string.
89        data:  String,
90        title: Option<String>,
91        text:  Option<String>,
92    },
93    /// Text rendered with a custom TTF/OTF font.
94    CustomText {
95        text:  String,
96        font:  BookFont,
97        /// ARGB color (0xAARRGGBB).
98        color: u32,
99    },
100}
101
102// ── Category ─────────────────────────────────────────────────────────────────
103
104/// Represents a book category tab (e.g. "Basics", "Patterns").
105#[derive(Debug, Clone, Default, Serialize, Deserialize)]
106#[serde(default)]
107pub struct BookCategory {
108    pub id: String,
109    pub name: String,
110    pub description: Option<String>,
111    /// MC texture path for the category icon (e.g. `"minecraft:textures/item/book.png"`).
112    pub icon: Option<String>,
113    /// Raw SVG string for the category icon (takes priority over `icon`).
114    pub icon_svg: Option<String>,
115    /// Sort priority (lower = first).
116    pub sortnum: i32,
117}
118
119// ── Entry ────────────────────────────────────────────────────────────────────
120
121/// One entry in a book (like a "page" in the TOC sidebar).
122#[derive(Debug, Clone, Default, Serialize, Deserialize)]
123#[serde(default)]
124pub struct BookEntry {
125    pub id: String,
126    pub name: String,
127    pub category: String,
128    pub pages: Vec<BookPage>,
129    /// Entry icon (item id or texture path).
130    pub icon: Option<String>,
131    /// Raw SVG icon string (takes priority over `icon`).
132    pub icon_svg: Option<String>,
133    /// If true, hides from the book (used for unlocks).
134    pub secret: bool,
135    /// Sort priority (lower = first).
136    pub priority: i32,
137    /// If true, read by default when opening the book.
138    pub read_by_default: bool,
139    /// Advancement required to unlock.
140    pub advancement: Option<String>,
141}
142
143// ── Book ─────────────────────────────────────────────────────────────────────
144
145/// The top-level book definition — replaces `patchouli_books/<id>/book.json`.
146#[derive(Debug, Clone, Serialize, Deserialize)]
147#[serde(default)]
148pub struct Book {
149    pub id: String,
150    pub name: String,
151    pub nameplate_color: String,
152    pub landing_text: String,
153    pub author: Option<String>,
154    pub book_texture: String,
155    pub filler_texture: String,
156    pub model: String,
157    pub categories: Vec<BookCategory>,
158    pub entries: Vec<BookEntry>,
159    pub macros: Vec<BookMacro>,
160    pub use_resource_pack: bool,
161    pub show_progress: bool,
162    pub i18n: bool,
163    pub creative_tab: Option<String>,
164}
165
166impl Book {
167    pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
168        Self {
169            id: id.into(),
170            name: name.into(),
171            nameplate_color: "FFDD98".into(), // Patchouli default nameplateColor
172            landing_text: String::new(),
173            author: None,
174            book_texture: "yog:textures/gui/book.png".into(),
175            filler_texture: "yog:textures/gui/book_filler.png".into(),
176            model: "minecraft:book".into(),
177            categories: Vec::new(),
178            entries: Vec::new(),
179            macros: Vec::new(),
180            use_resource_pack: false,
181            show_progress: true,
182            i18n: false,
183            creative_tab: None,
184        }
185    }
186
187    pub fn author(mut self, author: impl Into<String>) -> Self {
188        self.author = Some(author.into());
189        self
190    }
191
192    pub fn book_texture(mut self, tex: impl Into<String>) -> Self {
193        self.book_texture = tex.into();
194        self
195    }
196
197    pub fn filler_texture(mut self, tex: impl Into<String>) -> Self {
198        self.filler_texture = tex.into();
199        self
200    }
201
202    pub fn nameplate(mut self, color: impl Into<String>) -> Self {
203        self.nameplate_color = color.into();
204        self
205    }
206
207    pub fn landing_text(mut self, text: impl Into<String>) -> Self {
208        self.landing_text = text.into();
209        self
210    }
211
212    pub fn model(mut self, model: impl Into<String>) -> Self {
213        self.model = model.into();
214        self
215    }
216
217    pub fn creative_tab(mut self, tab: impl Into<String>) -> Self {
218        self.creative_tab = Some(tab.into());
219        self
220    }
221
222    pub fn show_progress(mut self, show: bool) -> Self {
223        self.show_progress = show;
224        self
225    }
226
227    pub fn i18n(mut self, val: bool) -> Self {
228        self.i18n = val;
229        self
230    }
231
232    pub fn use_resource_pack(mut self, val: bool) -> Self {
233        self.use_resource_pack = val;
234        self
235    }
236
237    pub fn add_macro(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
238        self.macros.push(BookMacro(key.into(), value.into()));
239        self
240    }
241
242    pub fn add_category(mut self, category: BookCategory) -> Self {
243        self.categories.push(category);
244        self
245    }
246
247    pub fn add_entry(mut self, entry: BookEntry) -> Self {
248        self.entries.push(entry);
249        self
250    }
251}
252
253impl Default for Book {
254    fn default() -> Self {
255        Self::new("yog:default", "Unknown Book")
256    }
257}
258
259// ── Registry ─────────────────────────────────────────────────────────────────
260
261/// Global registry for all in-game books.
262#[derive(Debug, Default)]
263pub struct BookRegistry {
264    books: std::collections::HashMap<String, Book>,
265}
266
267impl BookRegistry {
268    pub fn register(&mut self, book: Book) {
269        self.books.insert(book.id.clone(), book);
270    }
271
272    pub fn get(&self, id: &str) -> Option<&Book> {
273        self.books.get(id)
274    }
275
276    pub fn all(&self) -> impl Iterator<Item = &Book> {
277        self.books.values()
278    }
279}
280
281// ── Builder helpers ──────────────────────────────────────────────────────────
282
283pub fn text_page(text: impl Into<String>) -> BookPage {
284    BookPage::Text { text: text.into(), title: None }
285}
286
287pub fn text_page_titled(title: impl Into<String>, text: impl Into<String>) -> BookPage {
288    BookPage::Text { text: text.into(), title: Some(title.into()) }
289}
290
291pub fn spotlight_page(item: ItemDef) -> BookPage {
292    BookPage::Spotlight { item, title: None, text: None }
293}
294
295pub fn crafting_page(recipe_id: impl Into<String>) -> BookPage {
296    BookPage::Crafting { recipe_id: recipe_id.into(), text: None }
297}
298
299pub fn crafting_page_with_text(recipe_id: impl Into<String>, text: impl Into<String>) -> BookPage {
300    BookPage::Crafting { recipe_id: recipe_id.into(), text: Some(text.into()) }
301}
302
303pub fn smelting_page(recipe_id: impl Into<String>) -> BookPage {
304    BookPage::Smelting { recipe_id: recipe_id.into(), text: None }
305}
306
307pub fn image_page(texture: impl Into<String>) -> BookPage {
308    BookPage::Image { texture: texture.into(), title: None, text: None, border: true }
309}
310
311pub fn entity_page(entity_type: impl Into<String>) -> BookPage {
312    BookPage::Entity { entity_type: entity_type.into(), name: None, text: None }
313}
314
315pub fn relations_page(entries: Vec<String>) -> BookPage {
316    BookPage::Relations { entries, text: None }
317}
318
319pub fn pattern_page(op_id: impl Into<String>, anchor: impl Into<String>, input: impl Into<String>, output: impl Into<String>, text: impl Into<String>) -> BookPage {
320    BookPage::Pattern {
321        op_id: op_id.into(),
322        anchor: anchor.into(),
323        input: input.into(),
324        output: output.into(),
325        text: text.into(),
326    }
327}
328
329// ── Book → yog-ui bridge ─────────────────────────────────────────────────────
330pub mod book_ui {
331    use crate::{Book, BookEntry, BookPage};
332    use yog_ui::widget::{self, Widget};
333    use yog_ui::{Align, FlexDir, UiRoot};
334
335    /// Build a `UiRoot` from a `Book`.
336    /// The UI has: left panel (categories + entries), right panel (pages),
337    /// prev/next buttons at bottom.
338    pub fn build_book_ui(book: &Book, selected_cat: usize, selected_entry: usize, current_page: usize) -> UiRoot {
339        let mut cats: Vec<Widget> = Vec::new();
340        for (i, cat) in book.categories.iter().enumerate() {
341            let color = if i == selected_cat { 0xFF_FFFF55 } else { 0xFF_CCCCCC };
342            cats.push(widget::button(&cat.name)
343                .color(color)
344                .on_click(format!("cat:{}", i)));
345        }
346
347        let cat = book.categories.get(selected_cat);
348        let mut entries: Vec<Widget> = Vec::new();
349        if let Some(cat) = cat {
350            let cat_entries: Vec<&BookEntry> = book.entries.iter()
351                .filter(|e| e.category == cat.id).collect();
352            for (i, entry) in cat_entries.iter().enumerate() {
353                let color = if i == selected_entry { 0xFF_FFFF55 } else { 0xFF_CCCCCC };
354                let label = if entry.name.len() > 14 { &entry.name[..14] } else { &entry.name };
355                entries.push(widget::button(label)
356                    .color(color)
357                    .on_click(format!("entry:{}", i)));
358            }
359        }
360
361        let mut pages: Vec<Widget> = Vec::new();
362        if let Some(cat) = cat {
363            let cat_entries: Vec<&BookEntry> = book.entries.iter()
364                .filter(|e| e.category == cat.id).collect();
365            if let Some(entry) = cat_entries.get(selected_entry) {
366                if let Some(page) = entry.pages.get(current_page) {
367                    pages.push(render_page(page));
368                }
369            }
370        }
371
372        let nav = widget::panel(FlexDir::Row).gap(4.0)
373            .child(widget::button("<").w(28.0).on_click("prev_page"))
374            .child(widget::label(&format!("{}/{}", current_page + 1,
375                cat.map_or(0, |c| {
376                    book.entries.iter().filter(|e| e.category == c.id).nth(selected_entry)
377                        .map_or(0, |e| e.pages.len())
378                }))).color(0xFF_888888).flex(1.0).align(Align::Center))
379            .child(widget::button(">").w(28.0).on_click("next_page"));
380
381        UiRoot::new(&book.id,
382            widget::panel(FlexDir::Row).gap(2.0)
383                .padding(2.0, 2.0, 2.0, 2.0).bg(0xFF_2A1A0E)
384                .child(
385                    widget::panel(FlexDir::Column).w(104.0)
386                        .child(widget::label("Categories").color(0xFF_888888))
387                        .child(widget::panel(FlexDir::Column).gap(1.0)
388                            .child_many(cats))
389                        .child(widget::label("Entries").color(0xFF_888888))
390                        .child(widget::panel(FlexDir::Column).gap(1.0)
391                            .child_many(entries))
392                )
393                .child(
394                    widget::panel(FlexDir::Column).flex(1.0).gap(2.0)
395                        .child(widget::panel(FlexDir::Column).flex(1.0)
396                            .child_many(pages))
397                        .child(nav)
398                )
399        )
400    }
401
402    fn render_page(page: &BookPage) -> Widget {
403        match page {
404            BookPage::Text { text, .. } =>
405                widget::label(text).color(0xFF_CCCCAA),
406            BookPage::Spotlight { item, title, text } => {
407                let mut p = widget::panel(FlexDir::Column).gap(2.0);
408                if let Some(t) = title { p = p.child(widget::label(t).color(0xFF_FFFF55)); }
409                p = p.child(widget::item_slot(&item.id));
410                if let Some(t) = text { p = p.child(widget::label(t).color(0xFF_CCCCAA)); }
411                p
412            }
413            BookPage::Crafting { recipe_id, text } => {
414                let mut p = widget::panel(FlexDir::Column).gap(2.0);
415                p = p.child(widget::label(format!("Crafting: {}", recipe_id)).color(0xFF_888888));
416                if let Some(t) = text { p = p.child(widget::label(t).color(0xFF_CCCCAA)); }
417                p
418            }
419            BookPage::Smelting { recipe_id, text } => {
420                let mut p = widget::panel(FlexDir::Column).gap(2.0);
421                p = p.child(widget::label(format!("Smelting: {}", recipe_id)).color(0xFF_888888));
422                if let Some(t) = text { p = p.child(widget::label(t).color(0xFF_CCCCAA)); }
423                p
424            }
425            BookPage::Empty => widget::spacer(),
426            _ => widget::label("(unsupported page)").color(0xFF_888888),
427        }
428    }
429
430    // Helper: add multiple children to a widget
431    trait WidgetExt {
432        fn child_many(self, children: Vec<Widget>) -> Self;
433    }
434    impl WidgetExt for Widget {
435        fn child_many(mut self, children: Vec<Widget>) -> Self {
436            for c in children { self = self.child(c); }
437            self
438        }
439    }
440}
441
442// ── JSON serialization ────────────────────────────────────────────────────────
443
444fn esc(s: &str) -> String {
445    s.replace('\\', "\\\\").replace('"', "\\\"")
446}
447
448impl BookPage {
449    pub fn to_json(&self) -> String {
450        match self {
451            Self::Text { text, title } => {
452                let t = title.as_deref().map(|s| format!(r#","title":"{}""#, esc(s))).unwrap_or_default();
453                format!(r#"{{"type":"text","text":"{}"{}}}"#, esc(text), t)
454            }
455            Self::Spotlight { item, title, text } => {
456                let t = title.as_deref().map(|s| format!(r#","title":"{}""#, esc(s))).unwrap_or_default();
457                let tx = text.as_deref().map(|s| format!(r#","text":"{}""#, esc(s))).unwrap_or_default();
458                format!(r#"{{"type":"spotlight","item":"{id}"{t}{tx}}}"#, id = esc(&item.id))
459            }
460            Self::Crafting { recipe_id, text } => {
461                let tx = text.as_deref().map(|s| format!(r#","text":"{}""#, esc(s))).unwrap_or_default();
462                format!(r#"{{"type":"crafting","recipe":"{}"{}}}"#, esc(recipe_id), tx)
463            }
464            Self::Smelting { recipe_id, text } => {
465                let tx = text.as_deref().map(|s| format!(r#","text":"{}""#, esc(s))).unwrap_or_default();
466                format!(r#"{{"type":"smelting","recipe":"{}"{}}}"#, esc(recipe_id), tx)
467            }
468            Self::Image { texture, title, text, border } => {
469                let t = title.as_deref().map(|s| format!(r#","title":"{}""#, esc(s))).unwrap_or_default();
470                let tx = text.as_deref().map(|s| format!(r#","text":"{}""#, esc(s))).unwrap_or_default();
471                format!(r#"{{"type":"image","texture":"{}","border":{}{}{}}}"#,
472                    esc(texture), border, t, tx)
473            }
474            Self::Entity { entity_type, name, text } => {
475                let n = name.as_deref().map(|s| format!(r#","name":"{}""#, esc(s))).unwrap_or_default();
476                let tx = text.as_deref().map(|s| format!(r#","text":"{}""#, esc(s))).unwrap_or_default();
477                format!(r#"{{"type":"entity","entity":"{}"{}{}}}"#, esc(entity_type), n, tx)
478            }
479            Self::Relations { entries, text } => {
480                let e: String = entries.iter().map(|s| format!(r#""{}""#, esc(s))).collect::<Vec<_>>().join(",");
481                let tx = text.as_deref().map(|s| format!(r#","text":"{}""#, esc(s))).unwrap_or_default();
482                format!(r#"{{"type":"relations","entries":[{}]{}}}"#, e, tx)
483            }
484            Self::Empty => r#"{"type":"empty"}"#.to_string(),
485            Self::Pattern { op_id, anchor, input, output, text } =>
486                format!(r#"{{"type":"pattern","op_id":"{}","anchor":"{}","input":"{}","output":"{}","text":"{}"}}"#,
487                    esc(op_id), esc(anchor), esc(input), esc(output), esc(text)),
488            Self::Svg { data, title, text } => {
489                let t = title.as_deref().map(|s| format!(r#","title":"{}""#, esc(s))).unwrap_or_default();
490                let tx = text.as_deref().map(|s| format!(r#","text":"{}""#, esc(s))).unwrap_or_default();
491                format!(r#"{{"type":"svg","data":"{}"{}{}}}"#, esc(data), t, tx)
492            }
493            Self::CustomText { text, font, color } =>
494                format!(r#"{{"type":"custom_text","text":"{}","font_id":"{}","size_px":{},"color":{}}}"#,
495                    esc(text), esc(&font.font_id), font.size_px, color),
496        }
497    }
498}
499
500impl BookEntry {
501    pub fn to_json(&self) -> String {
502        let pages: String = self.pages.iter().map(|p| p.to_json()).collect::<Vec<_>>().join(",");
503        let icon = self.icon.as_deref().map(|s| format!(r#","icon":"{}""#, esc(s))).unwrap_or_default();
504        let adv = self.advancement.as_deref().map(|s| format!(r#","advancement":"{}""#, esc(s))).unwrap_or_default();
505        format!(
506            r#"{{"id":"{}","name":"{}","category":"{}","pages":[{}],"secret":{},"priority":{},"read_by_default":{}{}{}}}"#,
507            esc(&self.id), esc(&self.name), esc(&self.category), pages,
508            self.secret, self.priority, self.read_by_default, icon, adv
509        )
510    }
511}
512
513impl BookCategory {
514    pub fn to_json(&self) -> String {
515        let desc = self.description.as_deref().map(|s| format!(r#","description":"{}""#, esc(s))).unwrap_or_default();
516        let icon = self.icon.as_deref().map(|s| format!(r#","icon":"{}""#, esc(s))).unwrap_or_default();
517        format!(
518            r#"{{"id":"{}","name":"{}","sortnum":{}{}{}}}"#,
519            esc(&self.id), esc(&self.name), self.sortnum, desc, icon
520        )
521    }
522}
523
524impl Book {
525    pub fn to_json(&self) -> String {
526        let cats: String = self.categories.iter().map(|c| c.to_json()).collect::<Vec<_>>().join(",");
527        let entries: String = self.entries.iter().map(|e| e.to_json()).collect::<Vec<_>>().join(",");
528        let author = self.author.as_deref().map(|s| format!(r#","author":"{}""#, esc(s))).unwrap_or_default();
529        let tab = self.creative_tab.as_deref().map(|s| format!(r#","creative_tab":"{}""#, esc(s))).unwrap_or_default();
530        format!(
531            r#"{{"id":"{}","name":"{}","nameplate_color":"{}","landing_text":"{}","book_texture":"{}","filler_texture":"{}","model":"{}","show_progress":{},"i18n":{},"use_resource_pack":{},"categories":[{}],"entries":[{}]{}{}}}"#,
532            esc(&self.id), esc(&self.name), esc(&self.nameplate_color), esc(&self.landing_text),
533            esc(&self.book_texture), esc(&self.filler_texture), esc(&self.model),
534            self.show_progress, self.i18n, self.use_resource_pack,
535            cats, entries, author, tab
536        )
537    }
538}