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