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