1pub mod font;
7pub mod renderer;
8pub mod state;
9pub mod theme;
10
11use serde::{Deserialize, Serialize};
12use yog_registry::ItemDef;
13
14pub use font::BookFont;
15pub fn multiblock_blocks(
18 pattern: &[Vec<String>],
19 mapping: &std::collections::BTreeMap<String, String>,
20) -> Vec<((i32, i32, i32), String)> {
21 let mut out = Vec::new();
22 let layers = pattern.len() as i32;
23 for (li, layer) in pattern.iter().enumerate() {
24 for (z, row) in layer.iter().enumerate() {
25 for (x, ch) in row.chars().enumerate() {
26 if ch == ' ' {
27 continue;
28 }
29 if let Some(state) = mapping.get(&ch.to_string()) {
30 out.push(((x as i32, layers - 1 - li as i32, z as i32), state.clone()));
31 }
32 }
33 }
34 }
35 out
36}
37
38pub use renderer::{BOOK_PNG, BOOK_SHEET, BookRenderer, CRAFTING_PNG, CRAFTING_SHEET};
39pub use state::BookViewState;
40pub use theme::BookTheme;
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct BookMacro(pub String, pub String);
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(tag = "type", rename_all = "snake_case")]
59pub enum BookPage {
60 Text {
62 text: String,
63 #[serde(default)]
64 title: Option<String>,
65 },
66 Spotlight {
68 #[serde(with = "item_ref")]
71 item: ItemDef,
72 #[serde(default)]
73 title: Option<String>,
74 #[serde(default)]
75 text: Option<String>,
76 },
77 Crafting {
79 #[serde(rename = "recipe")]
80 recipe_id: String,
81 #[serde(default)]
82 text: Option<String>,
83 },
84 Smelting {
86 #[serde(rename = "recipe")]
87 recipe_id: String,
88 #[serde(default)]
89 text: Option<String>,
90 },
91 Image {
93 texture: String,
94 #[serde(default)]
95 title: Option<String>,
96 #[serde(default)]
97 text: Option<String>,
98 #[serde(default)]
99 border: bool,
100 },
101 Entity {
103 #[serde(rename = "entity")]
104 entity_type: String,
105 #[serde(default)]
106 name: Option<String>,
107 #[serde(default)]
108 text: Option<String>,
109 },
110 Multiblock {
115 #[serde(default)]
116 name: Option<String>,
117 pattern: Vec<Vec<String>>,
118 mapping: std::collections::BTreeMap<String, String>,
119 #[serde(default)]
120 text: Option<String>,
121 },
122 Relations {
124 entries: Vec<String>,
125 #[serde(default)]
126 text: Option<String>,
127 },
128 Empty,
130 Pattern {
132 op_id: String,
133 #[serde(default)]
134 anchor: String,
135 #[serde(default)]
136 input: String,
137 #[serde(default)]
138 output: String,
139 #[serde(default)]
140 text: String,
141 },
142 Svg {
144 data: String,
146 #[serde(default)]
147 title: Option<String>,
148 #[serde(default)]
149 text: Option<String>,
150 },
151 CustomText {
153 text: String,
154 #[serde(flatten)]
156 font: BookFont,
157 color: u32,
159 },
160}
161
162mod item_ref {
165 use serde::{Deserialize, Deserializer, Serializer};
166 use yog_registry::ItemDef;
167
168 pub fn serialize<S: Serializer>(item: &ItemDef, s: S) -> Result<S::Ok, S::Error> {
169 s.serialize_str(&item.id)
170 }
171
172 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<ItemDef, D::Error> {
173 #[derive(Deserialize)]
174 #[serde(untagged)]
175 enum Repr {
176 Id(String),
177 Def(ItemDef),
178 }
179 Ok(match Repr::deserialize(d)? {
180 Repr::Id(id) => ItemDef::new(id),
181 Repr::Def(def) => def,
182 })
183 }
184}
185
186#[derive(Debug, Clone, Default, Serialize, Deserialize)]
190#[serde(default)]
191pub struct BookCategory {
192 pub id: String,
193 pub name: String,
194 pub description: Option<String>,
195 pub icon: Option<String>,
197 pub icon_svg: Option<String>,
199 pub sortnum: i32,
201}
202
203#[derive(Debug, Clone, Default, Serialize, Deserialize)]
207#[serde(default)]
208pub struct BookEntry {
209 pub id: String,
210 pub name: String,
211 pub category: String,
212 pub pages: Vec<BookPage>,
213 pub icon: Option<String>,
215 pub icon_svg: Option<String>,
217 pub secret: bool,
219 pub priority: i32,
221 pub read_by_default: bool,
223 pub advancement: Option<String>,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
231#[serde(default)]
232pub struct Book {
233 pub id: String,
234 pub name: String,
235 pub nameplate_color: String,
236 pub landing_text: String,
237 pub author: Option<String>,
238 pub book_texture: String,
239 pub filler_texture: String,
240 pub model: String,
241 pub categories: Vec<BookCategory>,
242 pub entries: Vec<BookEntry>,
243 pub macros: Vec<BookMacro>,
244 pub use_resource_pack: bool,
245 pub show_progress: bool,
246 pub i18n: bool,
247 pub creative_tab: Option<String>,
248}
249
250impl Book {
251 pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
252 Self {
253 id: id.into(),
254 name: name.into(),
255 nameplate_color: "FFDD98".into(), landing_text: String::new(),
257 author: None,
258 book_texture: "yog:gui/book".into(),
259 filler_texture: "yog:gui/book_filler".into(),
260 model: "minecraft:book".into(),
261 categories: Vec::new(),
262 entries: Vec::new(),
263 macros: Vec::new(),
264 use_resource_pack: false,
265 show_progress: true,
266 i18n: false,
267 creative_tab: None,
268 }
269 }
270
271 pub fn author(mut self, author: impl Into<String>) -> Self {
272 self.author = Some(author.into());
273 self
274 }
275
276 pub fn book_texture(mut self, tex: impl Into<String>) -> Self {
277 self.book_texture = tex.into();
278 self
279 }
280
281 pub fn filler_texture(mut self, tex: impl Into<String>) -> Self {
282 self.filler_texture = tex.into();
283 self
284 }
285
286 pub fn nameplate(mut self, color: impl Into<String>) -> Self {
287 self.nameplate_color = color.into();
288 self
289 }
290
291 pub fn landing_text(mut self, text: impl Into<String>) -> Self {
292 self.landing_text = text.into();
293 self
294 }
295
296 pub fn model(mut self, model: impl Into<String>) -> Self {
297 self.model = model.into();
298 self
299 }
300
301 pub fn creative_tab(mut self, tab: impl Into<String>) -> Self {
302 self.creative_tab = Some(tab.into());
303 self
304 }
305
306 pub fn show_progress(mut self, show: bool) -> Self {
307 self.show_progress = show;
308 self
309 }
310
311 pub fn localized(&self, tr: impl Fn(&str) -> String) -> Book {
316 let opt = |o: &mut Option<String>| {
317 if let Some(t) = o {
318 *t = tr(t);
319 }
320 };
321 let mut b = self.clone();
322 b.name = tr(&b.name);
323 b.landing_text = tr(&b.landing_text);
324 for c in &mut b.categories {
325 c.name = tr(&c.name);
326 opt(&mut c.description);
327 }
328 for e in &mut b.entries {
329 e.name = tr(&e.name);
330 for p in &mut e.pages {
331 match p {
332 BookPage::Text { text, title } => {
333 *text = tr(text);
334 opt(title);
335 }
336 BookPage::Spotlight { title, text, .. }
337 | BookPage::Image { title, text, .. }
338 | BookPage::Svg { title, text, .. } => {
339 opt(title);
340 opt(text);
341 }
342 BookPage::Entity { name, text, .. } | BookPage::Multiblock { name, text, .. } => {
343 opt(name);
344 opt(text);
345 }
346 BookPage::Crafting { text, .. }
347 | BookPage::Smelting { text, .. }
348 | BookPage::Relations { text, .. } => opt(text),
349 BookPage::Pattern { text, .. } | BookPage::CustomText { text, .. } => {
350 *text = tr(text)
351 }
352 BookPage::Empty => {}
353 }
354 }
355 }
356 b
357 }
358
359 pub fn i18n(mut self, val: bool) -> Self {
360 self.i18n = val;
361 self
362 }
363
364 pub fn use_resource_pack(mut self, val: bool) -> Self {
365 self.use_resource_pack = val;
366 self
367 }
368
369 pub fn add_macro(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
370 self.macros.push(BookMacro(key.into(), value.into()));
371 self
372 }
373
374 pub fn add_category(mut self, category: BookCategory) -> Self {
375 self.categories.push(category);
376 self
377 }
378
379 pub fn add_entry(mut self, entry: BookEntry) -> Self {
380 self.entries.push(entry);
381 self
382 }
383}
384
385impl Default for Book {
386 fn default() -> Self {
387 Self::new("yog:default", "Unknown Book")
388 }
389}
390
391#[derive(Debug, Default)]
395pub struct BookRegistry {
396 books: std::collections::HashMap<String, Book>,
397}
398
399impl BookRegistry {
400 pub fn register(&mut self, book: Book) {
401 self.books.insert(book.id.clone(), book);
402 }
403
404 pub fn get(&self, id: &str) -> Option<&Book> {
405 self.books.get(id)
406 }
407
408 pub fn all(&self) -> impl Iterator<Item = &Book> {
409 self.books.values()
410 }
411}
412
413pub fn text_page(text: impl Into<String>) -> BookPage {
416 BookPage::Text {
417 text: text.into(),
418 title: None,
419 }
420}
421
422pub fn text_page_titled(title: impl Into<String>, text: impl Into<String>) -> BookPage {
423 BookPage::Text {
424 text: text.into(),
425 title: Some(title.into()),
426 }
427}
428
429pub fn spotlight_page(item: ItemDef) -> BookPage {
430 BookPage::Spotlight {
431 item,
432 title: None,
433 text: None,
434 }
435}
436
437pub fn crafting_page(recipe_id: impl Into<String>) -> BookPage {
438 BookPage::Crafting {
439 recipe_id: recipe_id.into(),
440 text: None,
441 }
442}
443
444pub fn crafting_page_with_text(recipe_id: impl Into<String>, text: impl Into<String>) -> BookPage {
445 BookPage::Crafting {
446 recipe_id: recipe_id.into(),
447 text: Some(text.into()),
448 }
449}
450
451pub fn smelting_page(recipe_id: impl Into<String>) -> BookPage {
452 BookPage::Smelting {
453 recipe_id: recipe_id.into(),
454 text: None,
455 }
456}
457
458pub fn image_page(texture: impl Into<String>) -> BookPage {
459 BookPage::Image {
460 texture: texture.into(),
461 title: None,
462 text: None,
463 border: true,
464 }
465}
466
467pub fn entity_page(entity_type: impl Into<String>) -> BookPage {
468 BookPage::Entity {
469 entity_type: entity_type.into(),
470 name: None,
471 text: None,
472 }
473}
474
475pub fn relations_page(entries: Vec<String>) -> BookPage {
476 BookPage::Relations {
477 entries,
478 text: None,
479 }
480}
481
482pub fn pattern_page(
483 op_id: impl Into<String>,
484 anchor: impl Into<String>,
485 input: impl Into<String>,
486 output: impl Into<String>,
487 text: impl Into<String>,
488) -> BookPage {
489 BookPage::Pattern {
490 op_id: op_id.into(),
491 anchor: anchor.into(),
492 input: input.into(),
493 output: output.into(),
494 text: text.into(),
495 }
496}
497
498pub mod book_ui {
500 use crate::{Book, BookEntry, BookPage};
501 use yog_ui::widget::{self, Widget};
502 use yog_ui::{Align, FlexDir, UiRoot};
503
504 pub fn build_book_ui(
508 book: &Book,
509 selected_cat: usize,
510 selected_entry: usize,
511 current_page: usize,
512 ) -> UiRoot {
513 let mut cats: Vec<Widget> = Vec::new();
514 for (i, cat) in book.categories.iter().enumerate() {
515 let color = if i == selected_cat {
516 0xFF_FFFF55
517 } else {
518 0xFF_CCCCCC
519 };
520 cats.push(
521 widget::button(&cat.name)
522 .color(color)
523 .on_click(format!("cat:{}", i)),
524 );
525 }
526
527 let cat = book.categories.get(selected_cat);
528 let mut entries: Vec<Widget> = Vec::new();
529 if let Some(cat) = cat {
530 let cat_entries: Vec<&BookEntry> = book
531 .entries
532 .iter()
533 .filter(|e| e.category == cat.id)
534 .collect();
535 for (i, entry) in cat_entries.iter().enumerate() {
536 let color = if i == selected_entry {
537 0xFF_FFFF55
538 } else {
539 0xFF_CCCCCC
540 };
541 let label = if entry.name.len() > 14 {
542 &entry.name[..14]
543 } else {
544 &entry.name
545 };
546 entries.push(
547 widget::button(label)
548 .color(color)
549 .on_click(format!("entry:{}", i)),
550 );
551 }
552 }
553
554 let mut pages: Vec<Widget> = Vec::new();
555 if let Some(cat) = cat {
556 let cat_entries: Vec<&BookEntry> = book
557 .entries
558 .iter()
559 .filter(|e| e.category == cat.id)
560 .collect();
561 if let Some(entry) = cat_entries.get(selected_entry)
562 && let Some(page) = entry.pages.get(current_page)
563 {
564 pages.push(render_page(page));
565 }
566 }
567
568 let nav = widget::panel(FlexDir::Row)
569 .gap(4.0)
570 .child(widget::button("<").w(28.0).on_click("prev_page"))
571 .child(
572 widget::label(format!(
573 "{}/{}",
574 current_page + 1,
575 cat.map_or(0, |c| {
576 book.entries
577 .iter()
578 .filter(|e| e.category == c.id)
579 .nth(selected_entry)
580 .map_or(0, |e| e.pages.len())
581 })
582 ))
583 .color(0xFF_888888)
584 .flex(1.0)
585 .align(Align::Center),
586 )
587 .child(widget::button(">").w(28.0).on_click("next_page"));
588
589 UiRoot::new(
590 &book.id,
591 widget::panel(FlexDir::Row)
592 .gap(2.0)
593 .padding(2.0, 2.0, 2.0, 2.0)
594 .bg(0xFF_2A1A0E)
595 .child(
596 widget::panel(FlexDir::Column)
597 .w(104.0)
598 .child(widget::label("Categories").color(0xFF_888888))
599 .child(widget::panel(FlexDir::Column).gap(1.0).child_many(cats))
600 .child(widget::label("Entries").color(0xFF_888888))
601 .child(widget::panel(FlexDir::Column).gap(1.0).child_many(entries)),
602 )
603 .child(
604 widget::panel(FlexDir::Column)
605 .flex(1.0)
606 .gap(2.0)
607 .child(widget::panel(FlexDir::Column).flex(1.0).child_many(pages))
608 .child(nav),
609 ),
610 )
611 }
612
613 fn render_page(page: &BookPage) -> Widget {
614 match page {
615 BookPage::Text { text, .. } => widget::label(text).color(0xFF_CCCCAA),
616 BookPage::Spotlight { item, title, text } => {
617 let mut p = widget::panel(FlexDir::Column).gap(2.0);
618 if let Some(t) = title {
619 p = p.child(widget::label(t).color(0xFF_FFFF55));
620 }
621 p = p.child(widget::item_slot(&item.id));
622 if let Some(t) = text {
623 p = p.child(widget::label(t).color(0xFF_CCCCAA));
624 }
625 p
626 }
627 BookPage::Crafting { recipe_id, text } => {
628 let mut p = widget::panel(FlexDir::Column).gap(2.0);
629 p = p.child(widget::label(format!("Crafting: {}", recipe_id)).color(0xFF_888888));
630 if let Some(t) = text {
631 p = p.child(widget::label(t).color(0xFF_CCCCAA));
632 }
633 p
634 }
635 BookPage::Smelting { recipe_id, text } => {
636 let mut p = widget::panel(FlexDir::Column).gap(2.0);
637 p = p.child(widget::label(format!("Smelting: {}", recipe_id)).color(0xFF_888888));
638 if let Some(t) = text {
639 p = p.child(widget::label(t).color(0xFF_CCCCAA));
640 }
641 p
642 }
643 BookPage::Empty => widget::spacer(),
644 _ => widget::label("(unsupported page)").color(0xFF_888888),
645 }
646 }
647
648 trait WidgetExt {
650 fn child_many(self, children: Vec<Widget>) -> Self;
651 }
652 impl WidgetExt for Widget {
653 fn child_many(mut self, children: Vec<Widget>) -> Self {
654 for c in children {
655 self = self.child(c);
656 }
657 self
658 }
659 }
660}
661
662fn esc(s: &str) -> String {
665 let mut out = String::with_capacity(s.len());
666 for ch in s.chars() {
667 match ch {
668 '\\' => out.push_str("\\\\"),
669 '"' => out.push_str("\\\""),
670 '\n' => out.push_str("\\n"),
671 '\r' => out.push_str("\\r"),
672 '\t' => out.push_str("\\t"),
673 c if c.is_control() => {
674 out.push_str(&format!("\\u{:04x}", c as u32));
675 }
676 _ => out.push(ch),
677 }
678 }
679 out
680}
681
682impl BookPage {
683 pub fn to_json(&self) -> String {
685 serde_json::to_string(self).unwrap_or_default()
686 }
687}
688
689impl BookEntry {
690 pub fn to_json(&self) -> String {
691 let pages: String = self
692 .pages
693 .iter()
694 .map(|p| p.to_json())
695 .collect::<Vec<_>>()
696 .join(",");
697 let icon = self
698 .icon
699 .as_deref()
700 .map(|s| format!(r#","icon":"{}""#, esc(s)))
701 .unwrap_or_default();
702 let adv = self
703 .advancement
704 .as_deref()
705 .map(|s| format!(r#","advancement":"{}""#, esc(s)))
706 .unwrap_or_default();
707 format!(
708 r#"{{"id":"{}","name":"{}","category":"{}","pages":[{}],"secret":{},"priority":{},"read_by_default":{}{}{}}}"#,
709 esc(&self.id),
710 esc(&self.name),
711 esc(&self.category),
712 pages,
713 self.secret,
714 self.priority,
715 self.read_by_default,
716 icon,
717 adv
718 )
719 }
720}
721
722impl BookCategory {
723 pub fn to_json(&self) -> String {
724 let desc = self
725 .description
726 .as_deref()
727 .map(|s| format!(r#","description":"{}""#, esc(s)))
728 .unwrap_or_default();
729 let icon = self
730 .icon
731 .as_deref()
732 .map(|s| format!(r#","icon":"{}""#, esc(s)))
733 .unwrap_or_default();
734 format!(
735 r#"{{"id":"{}","name":"{}","sortnum":{}{}{}}}"#,
736 esc(&self.id),
737 esc(&self.name),
738 self.sortnum,
739 desc,
740 icon
741 )
742 }
743}
744
745impl Book {
746 pub fn to_json(&self) -> String {
747 let cats: String = self
748 .categories
749 .iter()
750 .map(|c| c.to_json())
751 .collect::<Vec<_>>()
752 .join(",");
753 let entries: String = self
754 .entries
755 .iter()
756 .map(|e| e.to_json())
757 .collect::<Vec<_>>()
758 .join(",");
759 let author = self
760 .author
761 .as_deref()
762 .map(|s| format!(r#","author":"{}""#, esc(s)))
763 .unwrap_or_default();
764 let tab = self
765 .creative_tab
766 .as_deref()
767 .map(|s| format!(r#","creative_tab":"{}""#, esc(s)))
768 .unwrap_or_default();
769 format!(
770 r#"{{"id":"{}","name":"{}","nameplate_color":"{}","landing_text":"{}","book_texture":"{}","filler_texture":"{}","model":"{}","show_progress":{},"i18n":{},"use_resource_pack":{},"categories":[{}],"entries":[{}]{}{}}}"#,
771 esc(&self.id),
772 esc(&self.name),
773 esc(&self.nameplate_color),
774 esc(&self.landing_text),
775 esc(&self.book_texture),
776 esc(&self.filler_texture),
777 esc(&self.model),
778 self.show_progress,
779 self.i18n,
780 self.use_resource_pack,
781 cats,
782 entries,
783 author,
784 tab
785 )
786 }
787}
788#[cfg(test)]
791mod wire_format_tests {
792 use super::*;
793
794 #[test]
797 fn book_to_json_roundtrips_through_serde() {
798 let book = Book::new("yog:test", "Test Book")
799 .author("Tester")
800 .landing_text("hello")
801 .add_category(BookCategory {
802 id: "c1".into(),
803 name: "Cat".into(),
804 description: Some("d".into()),
805 icon: Some("yog:item/ruby".into()),
806 icon_svg: None,
807 sortnum: 0,
808 })
809 .add_entry(BookEntry {
810 id: "e1".into(),
811 name: "Entry".into(),
812 category: "c1".into(),
813 pages: vec![
814 text_page("plain"),
815 spotlight_page(yog_registry::ItemDef::new("yog:ruby")),
816 crafting_page("yog:r1"),
817 smelting_page("yog:r2"),
818 BookPage::Empty,
819 ],
820 icon: Some("yog:ruby".into()),
821 icon_svg: None,
822 secret: false,
823 priority: 0,
824 read_by_default: false,
825 advancement: None,
826 });
827
828 let json = book.to_json();
829 let parsed: Book = serde_json::from_str(&json)
830 .unwrap_or_else(|e| panic!("wire JSON failed to parse: {e}\njson: {json}"));
831 assert_eq!(parsed.id, "yog:test");
832 assert_eq!(parsed.entries.len(), 1);
833 assert_eq!(parsed.entries[0].pages.len(), 5);
834 match &parsed.entries[0].pages[1] {
835 BookPage::Spotlight { item, .. } => assert_eq!(item.id, "yog:ruby"),
836 p => panic!("expected spotlight, got {p:?}"),
837 }
838 match &parsed.entries[0].pages[2] {
839 BookPage::Crafting { recipe_id, .. } => assert_eq!(recipe_id, "yog:r1"),
840 p => panic!("expected crafting, got {p:?}"),
841 }
842 }
843}
844
845#[cfg(test)]
846mod localize_tests {
847 use super::*;
848
849 #[test]
850 fn localized_translates_every_text() {
851 let book = Book::new("m:b", "m.book.name")
852 .landing_text("m.book.landing")
853 .add_category(BookCategory {
854 id: "basics".into(),
855 name: "m.cat".into(),
856 ..Default::default()
857 })
858 .add_entry(BookEntry {
859 id: "e".into(),
860 name: "m.entry".into(),
861 category: "basics".into(),
862 pages: vec![text_page_titled("m.title", "m.text"), text_page("raw text")],
863 ..Default::default()
864 });
865 let out = book.localized(|k| {
866 if k.starts_with("m.") {
867 k.to_uppercase()
868 } else {
869 k.to_string()
870 }
871 });
872 assert_eq!(out.name, "M.BOOK.NAME");
873 assert_eq!(out.landing_text, "M.BOOK.LANDING");
874 assert_eq!(out.categories[0].name, "M.CAT");
875 assert_eq!(out.entries[0].name, "M.ENTRY");
876 match &out.entries[0].pages[0] {
877 BookPage::Text { text, title } => {
878 assert_eq!(text, "M.TEXT");
879 assert_eq!(title.as_deref(), Some("M.TITLE"));
880 }
881 p => panic!("{p:?}"),
882 }
883 assert!(
884 matches!(&out.entries[0].pages[1], BookPage::Text { text, .. } if text == "raw text")
885 );
886 assert_eq!(out.entries[0].id, "e");
887 }
888}