1pub 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#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct BookMacro(pub String, pub String);
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(tag = "type", rename_all = "snake_case")]
37pub enum BookPage {
38 Text {
40 text: String,
41 #[serde(default)]
42 title: Option<String>,
43 },
44 Spotlight {
46 #[serde(with = "item_ref")]
49 item: ItemDef,
50 #[serde(default)]
51 title: Option<String>,
52 #[serde(default)]
53 text: Option<String>,
54 },
55 Crafting {
57 #[serde(rename = "recipe", alias = "recipe_id")]
58 recipe_id: String,
59 #[serde(default)]
60 text: Option<String>,
61 },
62 Smelting {
64 #[serde(rename = "recipe", alias = "recipe_id")]
65 recipe_id: String,
66 #[serde(default)]
67 text: Option<String>,
68 },
69 Image {
71 texture: String,
72 #[serde(default)]
73 title: Option<String>,
74 #[serde(default)]
75 text: Option<String>,
76 #[serde(default)]
77 border: bool,
78 },
79 Entity {
81 #[serde(rename = "entity", alias = "entity_type")]
82 entity_type: String,
83 #[serde(default)]
84 name: Option<String>,
85 #[serde(default)]
86 text: Option<String>,
87 },
88 Relations {
90 entries: Vec<String>,
91 #[serde(default)]
92 text: Option<String>,
93 },
94 Empty,
96 Pattern {
98 op_id: String,
99 #[serde(default)]
100 anchor: String,
101 #[serde(default)]
102 input: String,
103 #[serde(default)]
104 output: String,
105 #[serde(default)]
106 text: String,
107 },
108 Svg {
110 data: String,
112 #[serde(default)]
113 title: Option<String>,
114 #[serde(default)]
115 text: Option<String>,
116 },
117 CustomText {
119 text: String,
120 #[serde(flatten)]
122 font: BookFont,
123 color: u32,
125 },
126}
127
128mod item_ref {
131 use serde::{Deserialize, Deserializer, Serializer};
132 use yog_registry::ItemDef;
133
134 pub fn serialize<S: Serializer>(item: &ItemDef, s: S) -> Result<S::Ok, S::Error> {
135 s.serialize_str(&item.id)
136 }
137
138 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<ItemDef, D::Error> {
139 #[derive(Deserialize)]
140 #[serde(untagged)]
141 enum Repr {
142 Id(String),
143 Def(ItemDef),
144 }
145 Ok(match Repr::deserialize(d)? {
146 Repr::Id(id) => ItemDef::new(id),
147 Repr::Def(def) => def,
148 })
149 }
150}
151
152#[derive(Debug, Clone, Default, Serialize, Deserialize)]
156#[serde(default)]
157pub struct BookCategory {
158 pub id: String,
159 pub name: String,
160 pub description: Option<String>,
161 pub icon: Option<String>,
163 pub icon_svg: Option<String>,
165 pub sortnum: i32,
167}
168
169#[derive(Debug, Clone, Default, Serialize, Deserialize)]
173#[serde(default)]
174pub struct BookEntry {
175 pub id: String,
176 pub name: String,
177 pub category: String,
178 pub pages: Vec<BookPage>,
179 pub icon: Option<String>,
181 pub icon_svg: Option<String>,
183 pub secret: bool,
185 pub priority: i32,
187 pub read_by_default: bool,
189 pub advancement: Option<String>,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
197#[serde(default)]
198pub struct Book {
199 pub id: String,
200 pub name: String,
201 pub nameplate_color: String,
202 pub landing_text: String,
203 pub author: Option<String>,
204 pub book_texture: String,
205 pub filler_texture: String,
206 pub model: String,
207 pub categories: Vec<BookCategory>,
208 pub entries: Vec<BookEntry>,
209 pub macros: Vec<BookMacro>,
210 pub use_resource_pack: bool,
211 pub show_progress: bool,
212 pub i18n: bool,
213 pub creative_tab: Option<String>,
214}
215
216impl Book {
217 pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
218 Self {
219 id: id.into(),
220 name: name.into(),
221 nameplate_color: "FFDD98".into(), landing_text: String::new(),
223 author: None,
224 book_texture: "yog:textures/gui/book.png".into(),
225 filler_texture: "yog:textures/gui/book_filler.png".into(),
226 model: "minecraft:book".into(),
227 categories: Vec::new(),
228 entries: Vec::new(),
229 macros: Vec::new(),
230 use_resource_pack: false,
231 show_progress: true,
232 i18n: false,
233 creative_tab: None,
234 }
235 }
236
237 pub fn author(mut self, author: impl Into<String>) -> Self {
238 self.author = Some(author.into());
239 self
240 }
241
242 pub fn book_texture(mut self, tex: impl Into<String>) -> Self {
243 self.book_texture = tex.into();
244 self
245 }
246
247 pub fn filler_texture(mut self, tex: impl Into<String>) -> Self {
248 self.filler_texture = tex.into();
249 self
250 }
251
252 pub fn nameplate(mut self, color: impl Into<String>) -> Self {
253 self.nameplate_color = color.into();
254 self
255 }
256
257 pub fn landing_text(mut self, text: impl Into<String>) -> Self {
258 self.landing_text = text.into();
259 self
260 }
261
262 pub fn model(mut self, model: impl Into<String>) -> Self {
263 self.model = model.into();
264 self
265 }
266
267 pub fn creative_tab(mut self, tab: impl Into<String>) -> Self {
268 self.creative_tab = Some(tab.into());
269 self
270 }
271
272 pub fn show_progress(mut self, show: bool) -> Self {
273 self.show_progress = show;
274 self
275 }
276
277 pub fn i18n(mut self, val: bool) -> Self {
278 self.i18n = val;
279 self
280 }
281
282 pub fn use_resource_pack(mut self, val: bool) -> Self {
283 self.use_resource_pack = val;
284 self
285 }
286
287 pub fn add_macro(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
288 self.macros.push(BookMacro(key.into(), value.into()));
289 self
290 }
291
292 pub fn add_category(mut self, category: BookCategory) -> Self {
293 self.categories.push(category);
294 self
295 }
296
297 pub fn add_entry(mut self, entry: BookEntry) -> Self {
298 self.entries.push(entry);
299 self
300 }
301}
302
303impl Default for Book {
304 fn default() -> Self {
305 Self::new("yog:default", "Unknown Book")
306 }
307}
308
309#[derive(Debug, Default)]
313pub struct BookRegistry {
314 books: std::collections::HashMap<String, Book>,
315}
316
317impl BookRegistry {
318 pub fn register(&mut self, book: Book) {
319 self.books.insert(book.id.clone(), book);
320 }
321
322 pub fn get(&self, id: &str) -> Option<&Book> {
323 self.books.get(id)
324 }
325
326 pub fn all(&self) -> impl Iterator<Item = &Book> {
327 self.books.values()
328 }
329}
330
331pub fn text_page(text: impl Into<String>) -> BookPage {
334 BookPage::Text { text: text.into(), title: None }
335}
336
337pub fn text_page_titled(title: impl Into<String>, text: impl Into<String>) -> BookPage {
338 BookPage::Text { text: text.into(), title: Some(title.into()) }
339}
340
341pub fn spotlight_page(item: ItemDef) -> BookPage {
342 BookPage::Spotlight { item, title: None, text: None }
343}
344
345pub fn crafting_page(recipe_id: impl Into<String>) -> BookPage {
346 BookPage::Crafting { recipe_id: recipe_id.into(), text: None }
347}
348
349pub fn crafting_page_with_text(recipe_id: impl Into<String>, text: impl Into<String>) -> BookPage {
350 BookPage::Crafting { recipe_id: recipe_id.into(), text: Some(text.into()) }
351}
352
353pub fn smelting_page(recipe_id: impl Into<String>) -> BookPage {
354 BookPage::Smelting { recipe_id: recipe_id.into(), text: None }
355}
356
357pub fn image_page(texture: impl Into<String>) -> BookPage {
358 BookPage::Image { texture: texture.into(), title: None, text: None, border: true }
359}
360
361pub fn entity_page(entity_type: impl Into<String>) -> BookPage {
362 BookPage::Entity { entity_type: entity_type.into(), name: None, text: None }
363}
364
365pub fn relations_page(entries: Vec<String>) -> BookPage {
366 BookPage::Relations { entries, text: None }
367}
368
369pub 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 {
370 BookPage::Pattern {
371 op_id: op_id.into(),
372 anchor: anchor.into(),
373 input: input.into(),
374 output: output.into(),
375 text: text.into(),
376 }
377}
378
379pub mod book_ui {
381 use crate::{Book, BookEntry, BookPage};
382 use yog_ui::widget::{self, Widget};
383 use yog_ui::{Align, FlexDir, UiRoot};
384
385 pub fn build_book_ui(book: &Book, selected_cat: usize, selected_entry: usize, current_page: usize) -> UiRoot {
389 let mut cats: Vec<Widget> = Vec::new();
390 for (i, cat) in book.categories.iter().enumerate() {
391 let color = if i == selected_cat { 0xFF_FFFF55 } else { 0xFF_CCCCCC };
392 cats.push(widget::button(&cat.name)
393 .color(color)
394 .on_click(format!("cat:{}", i)));
395 }
396
397 let cat = book.categories.get(selected_cat);
398 let mut entries: Vec<Widget> = Vec::new();
399 if let Some(cat) = cat {
400 let cat_entries: Vec<&BookEntry> = book.entries.iter()
401 .filter(|e| e.category == cat.id).collect();
402 for (i, entry) in cat_entries.iter().enumerate() {
403 let color = if i == selected_entry { 0xFF_FFFF55 } else { 0xFF_CCCCCC };
404 let label = if entry.name.len() > 14 { &entry.name[..14] } else { &entry.name };
405 entries.push(widget::button(label)
406 .color(color)
407 .on_click(format!("entry:{}", i)));
408 }
409 }
410
411 let mut pages: Vec<Widget> = Vec::new();
412 if let Some(cat) = cat {
413 let cat_entries: Vec<&BookEntry> = book.entries.iter()
414 .filter(|e| e.category == cat.id).collect();
415 if let Some(entry) = cat_entries.get(selected_entry) {
416 if let Some(page) = entry.pages.get(current_page) {
417 pages.push(render_page(page));
418 }
419 }
420 }
421
422 let nav = widget::panel(FlexDir::Row).gap(4.0)
423 .child(widget::button("<").w(28.0).on_click("prev_page"))
424 .child(widget::label(&format!("{}/{}", current_page + 1,
425 cat.map_or(0, |c| {
426 book.entries.iter().filter(|e| e.category == c.id).nth(selected_entry)
427 .map_or(0, |e| e.pages.len())
428 }))).color(0xFF_888888).flex(1.0).align(Align::Center))
429 .child(widget::button(">").w(28.0).on_click("next_page"));
430
431 UiRoot::new(&book.id,
432 widget::panel(FlexDir::Row).gap(2.0)
433 .padding(2.0, 2.0, 2.0, 2.0).bg(0xFF_2A1A0E)
434 .child(
435 widget::panel(FlexDir::Column).w(104.0)
436 .child(widget::label("Categories").color(0xFF_888888))
437 .child(widget::panel(FlexDir::Column).gap(1.0)
438 .child_many(cats))
439 .child(widget::label("Entries").color(0xFF_888888))
440 .child(widget::panel(FlexDir::Column).gap(1.0)
441 .child_many(entries))
442 )
443 .child(
444 widget::panel(FlexDir::Column).flex(1.0).gap(2.0)
445 .child(widget::panel(FlexDir::Column).flex(1.0)
446 .child_many(pages))
447 .child(nav)
448 )
449 )
450 }
451
452 fn render_page(page: &BookPage) -> Widget {
453 match page {
454 BookPage::Text { text, .. } =>
455 widget::label(text).color(0xFF_CCCCAA),
456 BookPage::Spotlight { item, title, text } => {
457 let mut p = widget::panel(FlexDir::Column).gap(2.0);
458 if let Some(t) = title { p = p.child(widget::label(t).color(0xFF_FFFF55)); }
459 p = p.child(widget::item_slot(&item.id));
460 if let Some(t) = text { p = p.child(widget::label(t).color(0xFF_CCCCAA)); }
461 p
462 }
463 BookPage::Crafting { recipe_id, text } => {
464 let mut p = widget::panel(FlexDir::Column).gap(2.0);
465 p = p.child(widget::label(format!("Crafting: {}", recipe_id)).color(0xFF_888888));
466 if let Some(t) = text { p = p.child(widget::label(t).color(0xFF_CCCCAA)); }
467 p
468 }
469 BookPage::Smelting { recipe_id, text } => {
470 let mut p = widget::panel(FlexDir::Column).gap(2.0);
471 p = p.child(widget::label(format!("Smelting: {}", recipe_id)).color(0xFF_888888));
472 if let Some(t) = text { p = p.child(widget::label(t).color(0xFF_CCCCAA)); }
473 p
474 }
475 BookPage::Empty => widget::spacer(),
476 _ => widget::label("(unsupported page)").color(0xFF_888888),
477 }
478 }
479
480 trait WidgetExt {
482 fn child_many(self, children: Vec<Widget>) -> Self;
483 }
484 impl WidgetExt for Widget {
485 fn child_many(mut self, children: Vec<Widget>) -> Self {
486 for c in children { self = self.child(c); }
487 self
488 }
489 }
490}
491
492fn esc(s: &str) -> String {
495 let mut out = String::with_capacity(s.len());
496 for ch in s.chars() {
497 match ch {
498 '\\' => out.push_str("\\\\"),
499 '"' => out.push_str("\\\""),
500 '\n' => out.push_str("\\n"),
501 '\r' => out.push_str("\\r"),
502 '\t' => out.push_str("\\t"),
503 c if c.is_control() => {
504 out.push_str(&format!("\\u{:04x}", c as u32));
505 }
506 _ => out.push(ch),
507 }
508 }
509 out
510}
511
512impl BookPage {
513 pub fn to_json(&self) -> String {
514 match self {
515 Self::Text { text, title } => {
516 let t = title.as_deref().map(|s| format!(r#","title":"{}""#, esc(s))).unwrap_or_default();
517 format!(r#"{{"type":"text","text":"{}"{}}}"#, esc(text), t)
518 }
519 Self::Spotlight { item, title, text } => {
520 let t = title.as_deref().map(|s| format!(r#","title":"{}""#, esc(s))).unwrap_or_default();
521 let tx = text.as_deref().map(|s| format!(r#","text":"{}""#, esc(s))).unwrap_or_default();
522 format!(r#"{{"type":"spotlight","item":"{id}"{t}{tx}}}"#, id = esc(&item.id))
523 }
524 Self::Crafting { recipe_id, text } => {
525 let tx = text.as_deref().map(|s| format!(r#","text":"{}""#, esc(s))).unwrap_or_default();
526 format!(r#"{{"type":"crafting","recipe":"{}"{}}}"#, esc(recipe_id), tx)
527 }
528 Self::Smelting { recipe_id, text } => {
529 let tx = text.as_deref().map(|s| format!(r#","text":"{}""#, esc(s))).unwrap_or_default();
530 format!(r#"{{"type":"smelting","recipe":"{}"{}}}"#, esc(recipe_id), tx)
531 }
532 Self::Image { texture, title, text, border } => {
533 let t = title.as_deref().map(|s| format!(r#","title":"{}""#, esc(s))).unwrap_or_default();
534 let tx = text.as_deref().map(|s| format!(r#","text":"{}""#, esc(s))).unwrap_or_default();
535 format!(r#"{{"type":"image","texture":"{}","border":{}{}{}}}"#,
536 esc(texture), border, t, tx)
537 }
538 Self::Entity { entity_type, name, text } => {
539 let n = name.as_deref().map(|s| format!(r#","name":"{}""#, esc(s))).unwrap_or_default();
540 let tx = text.as_deref().map(|s| format!(r#","text":"{}""#, esc(s))).unwrap_or_default();
541 format!(r#"{{"type":"entity","entity":"{}"{}{}}}"#, esc(entity_type), n, tx)
542 }
543 Self::Relations { entries, text } => {
544 let e: String = entries.iter().map(|s| format!(r#""{}""#, esc(s))).collect::<Vec<_>>().join(",");
545 let tx = text.as_deref().map(|s| format!(r#","text":"{}""#, esc(s))).unwrap_or_default();
546 format!(r#"{{"type":"relations","entries":[{}]{}}}"#, e, tx)
547 }
548 Self::Empty => r#"{"type":"empty"}"#.to_string(),
549 Self::Pattern { op_id, anchor, input, output, text } =>
550 format!(r#"{{"type":"pattern","op_id":"{}","anchor":"{}","input":"{}","output":"{}","text":"{}"}}"#,
551 esc(op_id), esc(anchor), esc(input), esc(output), esc(text)),
552 Self::Svg { data, title, text } => {
553 let t = title.as_deref().map(|s| format!(r#","title":"{}""#, esc(s))).unwrap_or_default();
554 let tx = text.as_deref().map(|s| format!(r#","text":"{}""#, esc(s))).unwrap_or_default();
555 format!(r#"{{"type":"svg","data":"{}"{}{}}}"#, esc(data), t, tx)
556 }
557 Self::CustomText { text, font, color } =>
558 format!(r#"{{"type":"custom_text","text":"{}","font_id":"{}","size_px":{},"color":{}}}"#,
559 esc(text), esc(&font.font_id), font.size_px, color),
560 }
561 }
562}
563
564impl BookEntry {
565 pub fn to_json(&self) -> String {
566 let pages: String = self.pages.iter().map(|p| p.to_json()).collect::<Vec<_>>().join(",");
567 let icon = self.icon.as_deref().map(|s| format!(r#","icon":"{}""#, esc(s))).unwrap_or_default();
568 let adv = self.advancement.as_deref().map(|s| format!(r#","advancement":"{}""#, esc(s))).unwrap_or_default();
569 format!(
570 r#"{{"id":"{}","name":"{}","category":"{}","pages":[{}],"secret":{},"priority":{},"read_by_default":{}{}{}}}"#,
571 esc(&self.id), esc(&self.name), esc(&self.category), pages,
572 self.secret, self.priority, self.read_by_default, icon, adv
573 )
574 }
575}
576
577impl BookCategory {
578 pub fn to_json(&self) -> String {
579 let desc = self.description.as_deref().map(|s| format!(r#","description":"{}""#, esc(s))).unwrap_or_default();
580 let icon = self.icon.as_deref().map(|s| format!(r#","icon":"{}""#, esc(s))).unwrap_or_default();
581 format!(
582 r#"{{"id":"{}","name":"{}","sortnum":{}{}{}}}"#,
583 esc(&self.id), esc(&self.name), self.sortnum, desc, icon
584 )
585 }
586}
587
588impl Book {
589 pub fn to_json(&self) -> String {
590 let cats: String = self.categories.iter().map(|c| c.to_json()).collect::<Vec<_>>().join(",");
591 let entries: String = self.entries.iter().map(|e| e.to_json()).collect::<Vec<_>>().join(",");
592 let author = self.author.as_deref().map(|s| format!(r#","author":"{}""#, esc(s))).unwrap_or_default();
593 let tab = self.creative_tab.as_deref().map(|s| format!(r#","creative_tab":"{}""#, esc(s))).unwrap_or_default();
594 format!(
595 r#"{{"id":"{}","name":"{}","nameplate_color":"{}","landing_text":"{}","book_texture":"{}","filler_texture":"{}","model":"{}","show_progress":{},"i18n":{},"use_resource_pack":{},"categories":[{}],"entries":[{}]{}{}}}"#,
596 esc(&self.id), esc(&self.name), esc(&self.nameplate_color), esc(&self.landing_text),
597 esc(&self.book_texture), esc(&self.filler_texture), esc(&self.model),
598 self.show_progress, self.i18n, self.use_resource_pack,
599 cats, entries, author, tab
600 )
601 }
602}
603#[cfg(test)]
606mod wire_format_tests {
607 use super::*;
608
609 #[test]
612 fn book_to_json_roundtrips_through_serde() {
613 let book = Book::new("yog:test", "Test Book")
614 .author("Tester")
615 .landing_text("hello")
616 .add_category(BookCategory {
617 id: "c1".into(), name: "Cat".into(),
618 description: Some("d".into()),
619 icon: Some("yog:item/ruby".into()), icon_svg: None, sortnum: 0,
620 })
621 .add_entry(BookEntry {
622 id: "e1".into(), name: "Entry".into(), category: "c1".into(),
623 pages: vec![
624 text_page("plain"),
625 spotlight_page(yog_registry::ItemDef::new("yog:ruby")),
626 crafting_page("yog:r1"),
627 smelting_page("yog:r2"),
628 BookPage::Empty,
629 ],
630 icon: Some("yog:ruby".into()), icon_svg: None,
631 secret: false, priority: 0, read_by_default: false, advancement: None,
632 });
633
634 let json = book.to_json();
635 let parsed: Book = serde_json::from_str(&json)
636 .unwrap_or_else(|e| panic!("wire JSON failed to parse: {e}\njson: {json}"));
637 assert_eq!(parsed.id, "yog:test");
638 assert_eq!(parsed.entries.len(), 1);
639 assert_eq!(parsed.entries[0].pages.len(), 5);
640 match &parsed.entries[0].pages[1] {
641 BookPage::Spotlight { item, .. } => assert_eq!(item.id, "yog:ruby"),
642 p => panic!("expected spotlight, got {p:?}"),
643 }
644 match &parsed.entries[0].pages[2] {
645 BookPage::Crafting { recipe_id, .. } => assert_eq!(recipe_id, "yog:r1"),
646 p => panic!("expected crafting, got {p:?}"),
647 }
648 }
649}