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