pub mod font;
pub mod renderer;
pub mod state;
pub mod theme;
use serde::{Deserialize, Serialize};
use yog_registry::ItemDef;
pub use font::BookFont;
pub use renderer::{BOOK_PNG, BOOK_SHEET, BookRenderer, CRAFTING_PNG, CRAFTING_SHEET};
pub use state::BookViewState;
pub use theme::BookTheme;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BookMacro(pub String, pub String);
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum BookPage {
Text {
text: String,
#[serde(default)]
title: Option<String>,
},
Spotlight {
#[serde(with = "item_ref")]
item: ItemDef,
#[serde(default)]
title: Option<String>,
#[serde(default)]
text: Option<String>,
},
Crafting {
#[serde(rename = "recipe")]
recipe_id: String,
#[serde(default)]
text: Option<String>,
},
Smelting {
#[serde(rename = "recipe")]
recipe_id: String,
#[serde(default)]
text: Option<String>,
},
Image {
texture: String,
#[serde(default)]
title: Option<String>,
#[serde(default)]
text: Option<String>,
#[serde(default)]
border: bool,
},
Entity {
#[serde(rename = "entity")]
entity_type: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
text: Option<String>,
},
Relations {
entries: Vec<String>,
#[serde(default)]
text: Option<String>,
},
Empty,
Pattern {
op_id: String,
#[serde(default)]
anchor: String,
#[serde(default)]
input: String,
#[serde(default)]
output: String,
#[serde(default)]
text: String,
},
Svg {
data: String,
#[serde(default)]
title: Option<String>,
#[serde(default)]
text: Option<String>,
},
CustomText {
text: String,
#[serde(flatten)]
font: BookFont,
color: u32,
},
}
mod item_ref {
use serde::{Deserialize, Deserializer, Serializer};
use yog_registry::ItemDef;
pub fn serialize<S: Serializer>(item: &ItemDef, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&item.id)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<ItemDef, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Repr {
Id(String),
Def(ItemDef),
}
Ok(match Repr::deserialize(d)? {
Repr::Id(id) => ItemDef::new(id),
Repr::Def(def) => def,
})
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct BookCategory {
pub id: String,
pub name: String,
pub description: Option<String>,
pub icon: Option<String>,
pub icon_svg: Option<String>,
pub sortnum: i32,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct BookEntry {
pub id: String,
pub name: String,
pub category: String,
pub pages: Vec<BookPage>,
pub icon: Option<String>,
pub icon_svg: Option<String>,
pub secret: bool,
pub priority: i32,
pub read_by_default: bool,
pub advancement: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Book {
pub id: String,
pub name: String,
pub nameplate_color: String,
pub landing_text: String,
pub author: Option<String>,
pub book_texture: String,
pub filler_texture: String,
pub model: String,
pub categories: Vec<BookCategory>,
pub entries: Vec<BookEntry>,
pub macros: Vec<BookMacro>,
pub use_resource_pack: bool,
pub show_progress: bool,
pub i18n: bool,
pub creative_tab: Option<String>,
}
impl Book {
pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
Self {
id: id.into(),
name: name.into(),
nameplate_color: "FFDD98".into(), landing_text: String::new(),
author: None,
book_texture: "yog:gui/book".into(),
filler_texture: "yog:gui/book_filler".into(),
model: "minecraft:book".into(),
categories: Vec::new(),
entries: Vec::new(),
macros: Vec::new(),
use_resource_pack: false,
show_progress: true,
i18n: false,
creative_tab: None,
}
}
pub fn author(mut self, author: impl Into<String>) -> Self {
self.author = Some(author.into());
self
}
pub fn book_texture(mut self, tex: impl Into<String>) -> Self {
self.book_texture = tex.into();
self
}
pub fn filler_texture(mut self, tex: impl Into<String>) -> Self {
self.filler_texture = tex.into();
self
}
pub fn nameplate(mut self, color: impl Into<String>) -> Self {
self.nameplate_color = color.into();
self
}
pub fn landing_text(mut self, text: impl Into<String>) -> Self {
self.landing_text = text.into();
self
}
pub fn model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self
}
pub fn creative_tab(mut self, tab: impl Into<String>) -> Self {
self.creative_tab = Some(tab.into());
self
}
pub fn show_progress(mut self, show: bool) -> Self {
self.show_progress = show;
self
}
pub fn localized(&self, tr: impl Fn(&str) -> String) -> Book {
let opt = |o: &mut Option<String>| {
if let Some(t) = o {
*t = tr(t);
}
};
let mut b = self.clone();
b.name = tr(&b.name);
b.landing_text = tr(&b.landing_text);
for c in &mut b.categories {
c.name = tr(&c.name);
opt(&mut c.description);
}
for e in &mut b.entries {
e.name = tr(&e.name);
for p in &mut e.pages {
match p {
BookPage::Text { text, title } => {
*text = tr(text);
opt(title);
}
BookPage::Spotlight { title, text, .. }
| BookPage::Image { title, text, .. }
| BookPage::Svg { title, text, .. } => {
opt(title);
opt(text);
}
BookPage::Entity { name, text, .. } => {
opt(name);
opt(text);
}
BookPage::Crafting { text, .. }
| BookPage::Smelting { text, .. }
| BookPage::Relations { text, .. } => opt(text),
BookPage::Pattern { text, .. } | BookPage::CustomText { text, .. } => {
*text = tr(text)
}
BookPage::Empty => {}
}
}
}
b
}
pub fn i18n(mut self, val: bool) -> Self {
self.i18n = val;
self
}
pub fn use_resource_pack(mut self, val: bool) -> Self {
self.use_resource_pack = val;
self
}
pub fn add_macro(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.macros.push(BookMacro(key.into(), value.into()));
self
}
pub fn add_category(mut self, category: BookCategory) -> Self {
self.categories.push(category);
self
}
pub fn add_entry(mut self, entry: BookEntry) -> Self {
self.entries.push(entry);
self
}
}
impl Default for Book {
fn default() -> Self {
Self::new("yog:default", "Unknown Book")
}
}
#[derive(Debug, Default)]
pub struct BookRegistry {
books: std::collections::HashMap<String, Book>,
}
impl BookRegistry {
pub fn register(&mut self, book: Book) {
self.books.insert(book.id.clone(), book);
}
pub fn get(&self, id: &str) -> Option<&Book> {
self.books.get(id)
}
pub fn all(&self) -> impl Iterator<Item = &Book> {
self.books.values()
}
}
pub fn text_page(text: impl Into<String>) -> BookPage {
BookPage::Text {
text: text.into(),
title: None,
}
}
pub fn text_page_titled(title: impl Into<String>, text: impl Into<String>) -> BookPage {
BookPage::Text {
text: text.into(),
title: Some(title.into()),
}
}
pub fn spotlight_page(item: ItemDef) -> BookPage {
BookPage::Spotlight {
item,
title: None,
text: None,
}
}
pub fn crafting_page(recipe_id: impl Into<String>) -> BookPage {
BookPage::Crafting {
recipe_id: recipe_id.into(),
text: None,
}
}
pub fn crafting_page_with_text(recipe_id: impl Into<String>, text: impl Into<String>) -> BookPage {
BookPage::Crafting {
recipe_id: recipe_id.into(),
text: Some(text.into()),
}
}
pub fn smelting_page(recipe_id: impl Into<String>) -> BookPage {
BookPage::Smelting {
recipe_id: recipe_id.into(),
text: None,
}
}
pub fn image_page(texture: impl Into<String>) -> BookPage {
BookPage::Image {
texture: texture.into(),
title: None,
text: None,
border: true,
}
}
pub fn entity_page(entity_type: impl Into<String>) -> BookPage {
BookPage::Entity {
entity_type: entity_type.into(),
name: None,
text: None,
}
}
pub fn relations_page(entries: Vec<String>) -> BookPage {
BookPage::Relations {
entries,
text: None,
}
}
pub 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 {
BookPage::Pattern {
op_id: op_id.into(),
anchor: anchor.into(),
input: input.into(),
output: output.into(),
text: text.into(),
}
}
pub mod book_ui {
use crate::{Book, BookEntry, BookPage};
use yog_ui::widget::{self, Widget};
use yog_ui::{Align, FlexDir, UiRoot};
pub fn build_book_ui(
book: &Book,
selected_cat: usize,
selected_entry: usize,
current_page: usize,
) -> UiRoot {
let mut cats: Vec<Widget> = Vec::new();
for (i, cat) in book.categories.iter().enumerate() {
let color = if i == selected_cat {
0xFF_FFFF55
} else {
0xFF_CCCCCC
};
cats.push(
widget::button(&cat.name)
.color(color)
.on_click(format!("cat:{}", i)),
);
}
let cat = book.categories.get(selected_cat);
let mut entries: Vec<Widget> = Vec::new();
if let Some(cat) = cat {
let cat_entries: Vec<&BookEntry> = book
.entries
.iter()
.filter(|e| e.category == cat.id)
.collect();
for (i, entry) in cat_entries.iter().enumerate() {
let color = if i == selected_entry {
0xFF_FFFF55
} else {
0xFF_CCCCCC
};
let label = if entry.name.len() > 14 {
&entry.name[..14]
} else {
&entry.name
};
entries.push(
widget::button(label)
.color(color)
.on_click(format!("entry:{}", i)),
);
}
}
let mut pages: Vec<Widget> = Vec::new();
if let Some(cat) = cat {
let cat_entries: Vec<&BookEntry> = book
.entries
.iter()
.filter(|e| e.category == cat.id)
.collect();
if let Some(entry) = cat_entries.get(selected_entry)
&& let Some(page) = entry.pages.get(current_page)
{
pages.push(render_page(page));
}
}
let nav = widget::panel(FlexDir::Row)
.gap(4.0)
.child(widget::button("<").w(28.0).on_click("prev_page"))
.child(
widget::label(format!(
"{}/{}",
current_page + 1,
cat.map_or(0, |c| {
book.entries
.iter()
.filter(|e| e.category == c.id)
.nth(selected_entry)
.map_or(0, |e| e.pages.len())
})
))
.color(0xFF_888888)
.flex(1.0)
.align(Align::Center),
)
.child(widget::button(">").w(28.0).on_click("next_page"));
UiRoot::new(
&book.id,
widget::panel(FlexDir::Row)
.gap(2.0)
.padding(2.0, 2.0, 2.0, 2.0)
.bg(0xFF_2A1A0E)
.child(
widget::panel(FlexDir::Column)
.w(104.0)
.child(widget::label("Categories").color(0xFF_888888))
.child(widget::panel(FlexDir::Column).gap(1.0).child_many(cats))
.child(widget::label("Entries").color(0xFF_888888))
.child(widget::panel(FlexDir::Column).gap(1.0).child_many(entries)),
)
.child(
widget::panel(FlexDir::Column)
.flex(1.0)
.gap(2.0)
.child(widget::panel(FlexDir::Column).flex(1.0).child_many(pages))
.child(nav),
),
)
}
fn render_page(page: &BookPage) -> Widget {
match page {
BookPage::Text { text, .. } => widget::label(text).color(0xFF_CCCCAA),
BookPage::Spotlight { item, title, text } => {
let mut p = widget::panel(FlexDir::Column).gap(2.0);
if let Some(t) = title {
p = p.child(widget::label(t).color(0xFF_FFFF55));
}
p = p.child(widget::item_slot(&item.id));
if let Some(t) = text {
p = p.child(widget::label(t).color(0xFF_CCCCAA));
}
p
}
BookPage::Crafting { recipe_id, text } => {
let mut p = widget::panel(FlexDir::Column).gap(2.0);
p = p.child(widget::label(format!("Crafting: {}", recipe_id)).color(0xFF_888888));
if let Some(t) = text {
p = p.child(widget::label(t).color(0xFF_CCCCAA));
}
p
}
BookPage::Smelting { recipe_id, text } => {
let mut p = widget::panel(FlexDir::Column).gap(2.0);
p = p.child(widget::label(format!("Smelting: {}", recipe_id)).color(0xFF_888888));
if let Some(t) = text {
p = p.child(widget::label(t).color(0xFF_CCCCAA));
}
p
}
BookPage::Empty => widget::spacer(),
_ => widget::label("(unsupported page)").color(0xFF_888888),
}
}
trait WidgetExt {
fn child_many(self, children: Vec<Widget>) -> Self;
}
impl WidgetExt for Widget {
fn child_many(mut self, children: Vec<Widget>) -> Self {
for c in children {
self = self.child(c);
}
self
}
}
}
fn esc(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() => {
out.push_str(&format!("\\u{:04x}", c as u32));
}
_ => out.push(ch),
}
}
out
}
impl BookPage {
pub fn to_json(&self) -> String {
match self {
Self::Text { text, title } => {
let t = title
.as_deref()
.map(|s| format!(r#","title":"{}""#, esc(s)))
.unwrap_or_default();
format!(r#"{{"type":"text","text":"{}"{}}}"#, esc(text), t)
}
Self::Spotlight { item, title, text } => {
let t = title
.as_deref()
.map(|s| format!(r#","title":"{}""#, esc(s)))
.unwrap_or_default();
let tx = text
.as_deref()
.map(|s| format!(r#","text":"{}""#, esc(s)))
.unwrap_or_default();
format!(
r#"{{"type":"spotlight","item":"{id}"{t}{tx}}}"#,
id = esc(&item.id)
)
}
Self::Crafting { recipe_id, text } => {
let tx = text
.as_deref()
.map(|s| format!(r#","text":"{}""#, esc(s)))
.unwrap_or_default();
format!(
r#"{{"type":"crafting","recipe":"{}"{}}}"#,
esc(recipe_id),
tx
)
}
Self::Smelting { recipe_id, text } => {
let tx = text
.as_deref()
.map(|s| format!(r#","text":"{}""#, esc(s)))
.unwrap_or_default();
format!(
r#"{{"type":"smelting","recipe":"{}"{}}}"#,
esc(recipe_id),
tx
)
}
Self::Image {
texture,
title,
text,
border,
} => {
let t = title
.as_deref()
.map(|s| format!(r#","title":"{}""#, esc(s)))
.unwrap_or_default();
let tx = text
.as_deref()
.map(|s| format!(r#","text":"{}""#, esc(s)))
.unwrap_or_default();
format!(
r#"{{"type":"image","texture":"{}","border":{}{}{}}}"#,
esc(texture),
border,
t,
tx
)
}
Self::Entity {
entity_type,
name,
text,
} => {
let n = name
.as_deref()
.map(|s| format!(r#","name":"{}""#, esc(s)))
.unwrap_or_default();
let tx = text
.as_deref()
.map(|s| format!(r#","text":"{}""#, esc(s)))
.unwrap_or_default();
format!(
r#"{{"type":"entity","entity":"{}"{}{}}}"#,
esc(entity_type),
n,
tx
)
}
Self::Relations { entries, text } => {
let e: String = entries
.iter()
.map(|s| format!(r#""{}""#, esc(s)))
.collect::<Vec<_>>()
.join(",");
let tx = text
.as_deref()
.map(|s| format!(r#","text":"{}""#, esc(s)))
.unwrap_or_default();
format!(r#"{{"type":"relations","entries":[{}]{}}}"#, e, tx)
}
Self::Empty => r#"{"type":"empty"}"#.to_string(),
Self::Pattern {
op_id,
anchor,
input,
output,
text,
} => format!(
r#"{{"type":"pattern","op_id":"{}","anchor":"{}","input":"{}","output":"{}","text":"{}"}}"#,
esc(op_id),
esc(anchor),
esc(input),
esc(output),
esc(text)
),
Self::Svg { data, title, text } => {
let t = title
.as_deref()
.map(|s| format!(r#","title":"{}""#, esc(s)))
.unwrap_or_default();
let tx = text
.as_deref()
.map(|s| format!(r#","text":"{}""#, esc(s)))
.unwrap_or_default();
format!(r#"{{"type":"svg","data":"{}"{}{}}}"#, esc(data), t, tx)
}
Self::CustomText { text, font, color } => format!(
r#"{{"type":"custom_text","text":"{}","font_id":"{}","size_px":{},"color":{}}}"#,
esc(text),
esc(&font.font_id),
font.size_px,
color
),
}
}
}
impl BookEntry {
pub fn to_json(&self) -> String {
let pages: String = self
.pages
.iter()
.map(|p| p.to_json())
.collect::<Vec<_>>()
.join(",");
let icon = self
.icon
.as_deref()
.map(|s| format!(r#","icon":"{}""#, esc(s)))
.unwrap_or_default();
let adv = self
.advancement
.as_deref()
.map(|s| format!(r#","advancement":"{}""#, esc(s)))
.unwrap_or_default();
format!(
r#"{{"id":"{}","name":"{}","category":"{}","pages":[{}],"secret":{},"priority":{},"read_by_default":{}{}{}}}"#,
esc(&self.id),
esc(&self.name),
esc(&self.category),
pages,
self.secret,
self.priority,
self.read_by_default,
icon,
adv
)
}
}
impl BookCategory {
pub fn to_json(&self) -> String {
let desc = self
.description
.as_deref()
.map(|s| format!(r#","description":"{}""#, esc(s)))
.unwrap_or_default();
let icon = self
.icon
.as_deref()
.map(|s| format!(r#","icon":"{}""#, esc(s)))
.unwrap_or_default();
format!(
r#"{{"id":"{}","name":"{}","sortnum":{}{}{}}}"#,
esc(&self.id),
esc(&self.name),
self.sortnum,
desc,
icon
)
}
}
impl Book {
pub fn to_json(&self) -> String {
let cats: String = self
.categories
.iter()
.map(|c| c.to_json())
.collect::<Vec<_>>()
.join(",");
let entries: String = self
.entries
.iter()
.map(|e| e.to_json())
.collect::<Vec<_>>()
.join(",");
let author = self
.author
.as_deref()
.map(|s| format!(r#","author":"{}""#, esc(s)))
.unwrap_or_default();
let tab = self
.creative_tab
.as_deref()
.map(|s| format!(r#","creative_tab":"{}""#, esc(s)))
.unwrap_or_default();
format!(
r#"{{"id":"{}","name":"{}","nameplate_color":"{}","landing_text":"{}","book_texture":"{}","filler_texture":"{}","model":"{}","show_progress":{},"i18n":{},"use_resource_pack":{},"categories":[{}],"entries":[{}]{}{}}}"#,
esc(&self.id),
esc(&self.name),
esc(&self.nameplate_color),
esc(&self.landing_text),
esc(&self.book_texture),
esc(&self.filler_texture),
esc(&self.model),
self.show_progress,
self.i18n,
self.use_resource_pack,
cats,
entries,
author,
tab
)
}
}
#[cfg(test)]
mod wire_format_tests {
use super::*;
#[test]
fn book_to_json_roundtrips_through_serde() {
let book = Book::new("yog:test", "Test Book")
.author("Tester")
.landing_text("hello")
.add_category(BookCategory {
id: "c1".into(),
name: "Cat".into(),
description: Some("d".into()),
icon: Some("yog:item/ruby".into()),
icon_svg: None,
sortnum: 0,
})
.add_entry(BookEntry {
id: "e1".into(),
name: "Entry".into(),
category: "c1".into(),
pages: vec![
text_page("plain"),
spotlight_page(yog_registry::ItemDef::new("yog:ruby")),
crafting_page("yog:r1"),
smelting_page("yog:r2"),
BookPage::Empty,
],
icon: Some("yog:ruby".into()),
icon_svg: None,
secret: false,
priority: 0,
read_by_default: false,
advancement: None,
});
let json = book.to_json();
let parsed: Book = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("wire JSON failed to parse: {e}\njson: {json}"));
assert_eq!(parsed.id, "yog:test");
assert_eq!(parsed.entries.len(), 1);
assert_eq!(parsed.entries[0].pages.len(), 5);
match &parsed.entries[0].pages[1] {
BookPage::Spotlight { item, .. } => assert_eq!(item.id, "yog:ruby"),
p => panic!("expected spotlight, got {p:?}"),
}
match &parsed.entries[0].pages[2] {
BookPage::Crafting { recipe_id, .. } => assert_eq!(recipe_id, "yog:r1"),
p => panic!("expected crafting, got {p:?}"),
}
}
}
#[cfg(test)]
mod localize_tests {
use super::*;
#[test]
fn localized_translates_every_text() {
let book = Book::new("m:b", "m.book.name")
.landing_text("m.book.landing")
.add_category(BookCategory {
id: "basics".into(),
name: "m.cat".into(),
..Default::default()
})
.add_entry(BookEntry {
id: "e".into(),
name: "m.entry".into(),
category: "basics".into(),
pages: vec![text_page_titled("m.title", "m.text"), text_page("raw text")],
..Default::default()
});
let out = book.localized(|k| {
if k.starts_with("m.") {
k.to_uppercase()
} else {
k.to_string()
}
});
assert_eq!(out.name, "M.BOOK.NAME");
assert_eq!(out.landing_text, "M.BOOK.LANDING");
assert_eq!(out.categories[0].name, "M.CAT");
assert_eq!(out.entries[0].name, "M.ENTRY");
match &out.entries[0].pages[0] {
BookPage::Text { text, title } => {
assert_eq!(text, "M.TEXT");
assert_eq!(title.as_deref(), Some("M.TITLE"));
}
p => panic!("{p:?}"),
}
assert!(
matches!(&out.entries[0].pages[1], BookPage::Text { text, .. } if text == "raw text")
);
assert_eq!(out.entries[0].id, "e");
}
}