1pub mod font;
7pub mod renderer;
8pub mod state;
9pub mod svg;
10pub mod theme;
11
12use serde::{Deserialize, Serialize};
13use yog_registry::ItemDef;
14
15pub use font::{BookFont, BookFontRegistry};
16pub use renderer::BookRenderer;
17pub use state::BookViewState;
18pub use theme::BookTheme;
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 {
335 text: text.into(),
336 title: None,
337 }
338}
339
340pub fn text_page_titled(title: impl Into<String>, text: impl Into<String>) -> BookPage {
341 BookPage::Text {
342 text: text.into(),
343 title: Some(title.into()),
344 }
345}
346
347pub fn spotlight_page(item: ItemDef) -> BookPage {
348 BookPage::Spotlight {
349 item,
350 title: None,
351 text: None,
352 }
353}
354
355pub fn crafting_page(recipe_id: impl Into<String>) -> BookPage {
356 BookPage::Crafting {
357 recipe_id: recipe_id.into(),
358 text: None,
359 }
360}
361
362pub fn crafting_page_with_text(recipe_id: impl Into<String>, text: impl Into<String>) -> BookPage {
363 BookPage::Crafting {
364 recipe_id: recipe_id.into(),
365 text: Some(text.into()),
366 }
367}
368
369pub fn smelting_page(recipe_id: impl Into<String>) -> BookPage {
370 BookPage::Smelting {
371 recipe_id: recipe_id.into(),
372 text: None,
373 }
374}
375
376pub fn image_page(texture: impl Into<String>) -> BookPage {
377 BookPage::Image {
378 texture: texture.into(),
379 title: None,
380 text: None,
381 border: true,
382 }
383}
384
385pub fn entity_page(entity_type: impl Into<String>) -> BookPage {
386 BookPage::Entity {
387 entity_type: entity_type.into(),
388 name: None,
389 text: None,
390 }
391}
392
393pub fn relations_page(entries: Vec<String>) -> BookPage {
394 BookPage::Relations {
395 entries,
396 text: None,
397 }
398}
399
400pub fn pattern_page(
401 op_id: impl Into<String>,
402 anchor: impl Into<String>,
403 input: impl Into<String>,
404 output: impl Into<String>,
405 text: impl Into<String>,
406) -> BookPage {
407 BookPage::Pattern {
408 op_id: op_id.into(),
409 anchor: anchor.into(),
410 input: input.into(),
411 output: output.into(),
412 text: text.into(),
413 }
414}
415
416pub mod book_ui {
418 use crate::{Book, BookEntry, BookPage};
419 use yog_ui::widget::{self, Widget};
420 use yog_ui::{Align, FlexDir, UiRoot};
421
422 pub fn build_book_ui(
426 book: &Book,
427 selected_cat: usize,
428 selected_entry: usize,
429 current_page: usize,
430 ) -> UiRoot {
431 let mut cats: Vec<Widget> = Vec::new();
432 for (i, cat) in book.categories.iter().enumerate() {
433 let color = if i == selected_cat {
434 0xFF_FFFF55
435 } else {
436 0xFF_CCCCCC
437 };
438 cats.push(
439 widget::button(&cat.name)
440 .color(color)
441 .on_click(format!("cat:{}", i)),
442 );
443 }
444
445 let cat = book.categories.get(selected_cat);
446 let mut entries: Vec<Widget> = Vec::new();
447 if let Some(cat) = cat {
448 let cat_entries: Vec<&BookEntry> = book
449 .entries
450 .iter()
451 .filter(|e| e.category == cat.id)
452 .collect();
453 for (i, entry) in cat_entries.iter().enumerate() {
454 let color = if i == selected_entry {
455 0xFF_FFFF55
456 } else {
457 0xFF_CCCCCC
458 };
459 let label = if entry.name.len() > 14 {
460 &entry.name[..14]
461 } else {
462 &entry.name
463 };
464 entries.push(
465 widget::button(label)
466 .color(color)
467 .on_click(format!("entry:{}", i)),
468 );
469 }
470 }
471
472 let mut pages: Vec<Widget> = Vec::new();
473 if let Some(cat) = cat {
474 let cat_entries: Vec<&BookEntry> = book
475 .entries
476 .iter()
477 .filter(|e| e.category == cat.id)
478 .collect();
479 if let Some(entry) = cat_entries.get(selected_entry) {
480 if let Some(page) = entry.pages.get(current_page) {
481 pages.push(render_page(page));
482 }
483 }
484 }
485
486 let nav = widget::panel(FlexDir::Row)
487 .gap(4.0)
488 .child(widget::button("<").w(28.0).on_click("prev_page"))
489 .child(
490 widget::label(format!(
491 "{}/{}",
492 current_page + 1,
493 cat.map_or(0, |c| {
494 book.entries
495 .iter()
496 .filter(|e| e.category == c.id)
497 .nth(selected_entry)
498 .map_or(0, |e| e.pages.len())
499 })
500 ))
501 .color(0xFF_888888)
502 .flex(1.0)
503 .align(Align::Center),
504 )
505 .child(widget::button(">").w(28.0).on_click("next_page"));
506
507 UiRoot::new(
508 &book.id,
509 widget::panel(FlexDir::Row)
510 .gap(2.0)
511 .padding(2.0, 2.0, 2.0, 2.0)
512 .bg(0xFF_2A1A0E)
513 .child(
514 widget::panel(FlexDir::Column)
515 .w(104.0)
516 .child(widget::label("Categories").color(0xFF_888888))
517 .child(widget::panel(FlexDir::Column).gap(1.0).child_many(cats))
518 .child(widget::label("Entries").color(0xFF_888888))
519 .child(widget::panel(FlexDir::Column).gap(1.0).child_many(entries)),
520 )
521 .child(
522 widget::panel(FlexDir::Column)
523 .flex(1.0)
524 .gap(2.0)
525 .child(widget::panel(FlexDir::Column).flex(1.0).child_many(pages))
526 .child(nav),
527 ),
528 )
529 }
530
531 fn render_page(page: &BookPage) -> Widget {
532 match page {
533 BookPage::Text { text, .. } => widget::label(text).color(0xFF_CCCCAA),
534 BookPage::Spotlight { item, title, text } => {
535 let mut p = widget::panel(FlexDir::Column).gap(2.0);
536 if let Some(t) = title {
537 p = p.child(widget::label(t).color(0xFF_FFFF55));
538 }
539 p = p.child(widget::item_slot(&item.id));
540 if let Some(t) = text {
541 p = p.child(widget::label(t).color(0xFF_CCCCAA));
542 }
543 p
544 }
545 BookPage::Crafting { recipe_id, text } => {
546 let mut p = widget::panel(FlexDir::Column).gap(2.0);
547 p = p.child(widget::label(format!("Crafting: {}", recipe_id)).color(0xFF_888888));
548 if let Some(t) = text {
549 p = p.child(widget::label(t).color(0xFF_CCCCAA));
550 }
551 p
552 }
553 BookPage::Smelting { recipe_id, text } => {
554 let mut p = widget::panel(FlexDir::Column).gap(2.0);
555 p = p.child(widget::label(format!("Smelting: {}", recipe_id)).color(0xFF_888888));
556 if let Some(t) = text {
557 p = p.child(widget::label(t).color(0xFF_CCCCAA));
558 }
559 p
560 }
561 BookPage::Empty => widget::spacer(),
562 _ => widget::label("(unsupported page)").color(0xFF_888888),
563 }
564 }
565
566 trait WidgetExt {
568 fn child_many(self, children: Vec<Widget>) -> Self;
569 }
570 impl WidgetExt for Widget {
571 fn child_many(mut self, children: Vec<Widget>) -> Self {
572 for c in children {
573 self = self.child(c);
574 }
575 self
576 }
577 }
578}
579
580fn esc(s: &str) -> String {
583 let mut out = String::with_capacity(s.len());
584 for ch in s.chars() {
585 match ch {
586 '\\' => out.push_str("\\\\"),
587 '"' => out.push_str("\\\""),
588 '\n' => out.push_str("\\n"),
589 '\r' => out.push_str("\\r"),
590 '\t' => out.push_str("\\t"),
591 c if c.is_control() => {
592 out.push_str(&format!("\\u{:04x}", c as u32));
593 }
594 _ => out.push(ch),
595 }
596 }
597 out
598}
599
600impl BookPage {
601 pub fn to_json(&self) -> String {
602 match self {
603 Self::Text { text, title } => {
604 let t = title
605 .as_deref()
606 .map(|s| format!(r#","title":"{}""#, esc(s)))
607 .unwrap_or_default();
608 format!(r#"{{"type":"text","text":"{}"{}}}"#, esc(text), t)
609 }
610 Self::Spotlight { item, title, text } => {
611 let t = title
612 .as_deref()
613 .map(|s| format!(r#","title":"{}""#, esc(s)))
614 .unwrap_or_default();
615 let tx = text
616 .as_deref()
617 .map(|s| format!(r#","text":"{}""#, esc(s)))
618 .unwrap_or_default();
619 format!(
620 r#"{{"type":"spotlight","item":"{id}"{t}{tx}}}"#,
621 id = esc(&item.id)
622 )
623 }
624 Self::Crafting { recipe_id, text } => {
625 let tx = text
626 .as_deref()
627 .map(|s| format!(r#","text":"{}""#, esc(s)))
628 .unwrap_or_default();
629 format!(
630 r#"{{"type":"crafting","recipe":"{}"{}}}"#,
631 esc(recipe_id),
632 tx
633 )
634 }
635 Self::Smelting { recipe_id, text } => {
636 let tx = text
637 .as_deref()
638 .map(|s| format!(r#","text":"{}""#, esc(s)))
639 .unwrap_or_default();
640 format!(
641 r#"{{"type":"smelting","recipe":"{}"{}}}"#,
642 esc(recipe_id),
643 tx
644 )
645 }
646 Self::Image {
647 texture,
648 title,
649 text,
650 border,
651 } => {
652 let t = title
653 .as_deref()
654 .map(|s| format!(r#","title":"{}""#, esc(s)))
655 .unwrap_or_default();
656 let tx = text
657 .as_deref()
658 .map(|s| format!(r#","text":"{}""#, esc(s)))
659 .unwrap_or_default();
660 format!(
661 r#"{{"type":"image","texture":"{}","border":{}{}{}}}"#,
662 esc(texture),
663 border,
664 t,
665 tx
666 )
667 }
668 Self::Entity {
669 entity_type,
670 name,
671 text,
672 } => {
673 let n = name
674 .as_deref()
675 .map(|s| format!(r#","name":"{}""#, esc(s)))
676 .unwrap_or_default();
677 let tx = text
678 .as_deref()
679 .map(|s| format!(r#","text":"{}""#, esc(s)))
680 .unwrap_or_default();
681 format!(
682 r#"{{"type":"entity","entity":"{}"{}{}}}"#,
683 esc(entity_type),
684 n,
685 tx
686 )
687 }
688 Self::Relations { entries, text } => {
689 let e: String = entries
690 .iter()
691 .map(|s| format!(r#""{}""#, esc(s)))
692 .collect::<Vec<_>>()
693 .join(",");
694 let tx = text
695 .as_deref()
696 .map(|s| format!(r#","text":"{}""#, esc(s)))
697 .unwrap_or_default();
698 format!(r#"{{"type":"relations","entries":[{}]{}}}"#, e, tx)
699 }
700 Self::Empty => r#"{"type":"empty"}"#.to_string(),
701 Self::Pattern {
702 op_id,
703 anchor,
704 input,
705 output,
706 text,
707 } => format!(
708 r#"{{"type":"pattern","op_id":"{}","anchor":"{}","input":"{}","output":"{}","text":"{}"}}"#,
709 esc(op_id),
710 esc(anchor),
711 esc(input),
712 esc(output),
713 esc(text)
714 ),
715 Self::Svg { data, title, text } => {
716 let t = title
717 .as_deref()
718 .map(|s| format!(r#","title":"{}""#, esc(s)))
719 .unwrap_or_default();
720 let tx = text
721 .as_deref()
722 .map(|s| format!(r#","text":"{}""#, esc(s)))
723 .unwrap_or_default();
724 format!(r#"{{"type":"svg","data":"{}"{}{}}}"#, esc(data), t, tx)
725 }
726 Self::CustomText { text, font, color } => format!(
727 r#"{{"type":"custom_text","text":"{}","font_id":"{}","size_px":{},"color":{}}}"#,
728 esc(text),
729 esc(&font.font_id),
730 font.size_px,
731 color
732 ),
733 }
734 }
735}
736
737impl BookEntry {
738 pub fn to_json(&self) -> String {
739 let pages: String = self
740 .pages
741 .iter()
742 .map(|p| p.to_json())
743 .collect::<Vec<_>>()
744 .join(",");
745 let icon = self
746 .icon
747 .as_deref()
748 .map(|s| format!(r#","icon":"{}""#, esc(s)))
749 .unwrap_or_default();
750 let adv = self
751 .advancement
752 .as_deref()
753 .map(|s| format!(r#","advancement":"{}""#, esc(s)))
754 .unwrap_or_default();
755 format!(
756 r#"{{"id":"{}","name":"{}","category":"{}","pages":[{}],"secret":{},"priority":{},"read_by_default":{}{}{}}}"#,
757 esc(&self.id),
758 esc(&self.name),
759 esc(&self.category),
760 pages,
761 self.secret,
762 self.priority,
763 self.read_by_default,
764 icon,
765 adv
766 )
767 }
768}
769
770impl BookCategory {
771 pub fn to_json(&self) -> String {
772 let desc = self
773 .description
774 .as_deref()
775 .map(|s| format!(r#","description":"{}""#, esc(s)))
776 .unwrap_or_default();
777 let icon = self
778 .icon
779 .as_deref()
780 .map(|s| format!(r#","icon":"{}""#, esc(s)))
781 .unwrap_or_default();
782 format!(
783 r#"{{"id":"{}","name":"{}","sortnum":{}{}{}}}"#,
784 esc(&self.id),
785 esc(&self.name),
786 self.sortnum,
787 desc,
788 icon
789 )
790 }
791}
792
793impl Book {
794 pub fn to_json(&self) -> String {
795 let cats: String = self
796 .categories
797 .iter()
798 .map(|c| c.to_json())
799 .collect::<Vec<_>>()
800 .join(",");
801 let entries: String = self
802 .entries
803 .iter()
804 .map(|e| e.to_json())
805 .collect::<Vec<_>>()
806 .join(",");
807 let author = self
808 .author
809 .as_deref()
810 .map(|s| format!(r#","author":"{}""#, esc(s)))
811 .unwrap_or_default();
812 let tab = self
813 .creative_tab
814 .as_deref()
815 .map(|s| format!(r#","creative_tab":"{}""#, esc(s)))
816 .unwrap_or_default();
817 format!(
818 r#"{{"id":"{}","name":"{}","nameplate_color":"{}","landing_text":"{}","book_texture":"{}","filler_texture":"{}","model":"{}","show_progress":{},"i18n":{},"use_resource_pack":{},"categories":[{}],"entries":[{}]{}{}}}"#,
819 esc(&self.id),
820 esc(&self.name),
821 esc(&self.nameplate_color),
822 esc(&self.landing_text),
823 esc(&self.book_texture),
824 esc(&self.filler_texture),
825 esc(&self.model),
826 self.show_progress,
827 self.i18n,
828 self.use_resource_pack,
829 cats,
830 entries,
831 author,
832 tab
833 )
834 }
835}
836#[cfg(test)]
839mod wire_format_tests {
840 use super::*;
841
842 #[test]
845 fn book_to_json_roundtrips_through_serde() {
846 let book = Book::new("yog:test", "Test Book")
847 .author("Tester")
848 .landing_text("hello")
849 .add_category(BookCategory {
850 id: "c1".into(),
851 name: "Cat".into(),
852 description: Some("d".into()),
853 icon: Some("yog:item/ruby".into()),
854 icon_svg: None,
855 sortnum: 0,
856 })
857 .add_entry(BookEntry {
858 id: "e1".into(),
859 name: "Entry".into(),
860 category: "c1".into(),
861 pages: vec![
862 text_page("plain"),
863 spotlight_page(yog_registry::ItemDef::new("yog:ruby")),
864 crafting_page("yog:r1"),
865 smelting_page("yog:r2"),
866 BookPage::Empty,
867 ],
868 icon: Some("yog:ruby".into()),
869 icon_svg: None,
870 secret: false,
871 priority: 0,
872 read_by_default: false,
873 advancement: None,
874 });
875
876 let json = book.to_json();
877 let parsed: Book = serde_json::from_str(&json)
878 .unwrap_or_else(|e| panic!("wire JSON failed to parse: {e}\njson: {json}"));
879 assert_eq!(parsed.id, "yog:test");
880 assert_eq!(parsed.entries.len(), 1);
881 assert_eq!(parsed.entries[0].pages.len(), 5);
882 match &parsed.entries[0].pages[1] {
883 BookPage::Spotlight { item, .. } => assert_eq!(item.id, "yog:ruby"),
884 p => panic!("expected spotlight, got {p:?}"),
885 }
886 match &parsed.entries[0].pages[2] {
887 BookPage::Crafting { recipe_id, .. } => assert_eq!(recipe_id, "yog:r1"),
888 p => panic!("expected crafting, got {p:?}"),
889 }
890 }
891}