Skip to main content

yog_book/
lib.rs

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