use std::collections::{BTreeMap, HashMap};
use yog_gfx::GfxContext;
use yog_ui::{Align, FlexDir, Stretch, UiRoot, widget};
use crate::state::BookViewState;
use crate::theme::BookTheme;
use crate::{Book, BookPage};
pub const BOOK_TEX_W: f32 = 512.0;
pub const BOOK_TEX_H: f32 = 256.0;
pub const BK_W: f32 = 272.0;
pub const BK_H: f32 = 180.0;
pub const PAGE_W: f32 = 116.0;
pub const PAGE_H: f32 = 156.0;
pub const TOP_PAD: f32 = 18.0; pub const LEFT_X: f32 = 15.0; pub const RIGHT_X: f32 = 141.0; pub const SEP_U: f32 = 140.0;
pub const SEP_V: f32 = 180.0;
pub const SEP_W: f32 = 110.0;
pub const SEP_H: f32 = 3.0;
const SCREEN_MARGIN: f32 = 16.0;
const MIN_SCALE: f32 = 0.4;
pub static BOOK_PNG: &[u8] = include_bytes!("../assets/book_brown.png");
pub static CRAFTING_PNG: &[u8] = include_bytes!("../assets/crafting.png");
pub static LANG: &[(&str, &str)] = &[
("en_us", include_str!("../assets/lang/en_us.json")),
("ru_ru", include_str!("../assets/lang/ru_ru.json")),
];
pub const BOOK_SHEET: &str = "yog:gui/book/book";
pub const CRAFTING_SHEET: &str = "yog:gui/book/crafting";
#[derive(Clone, PartialEq)]
pub(crate) enum SpriteSheet {
Book,
Crafting,
Image(String),
}
#[derive(Clone)]
pub(crate) struct BgSprite {
pub sheet: SpriteSheet,
pub u: f32,
pub v: f32,
pub uw: f32,
pub uh: f32,
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
}
pub type PageDrawer<'a> = &'a mut dyn FnMut(&GfxContext, &str, &yog_data::Value, [f32; 4]);
#[derive(Clone)]
pub(crate) enum OverlayCmd {
Drawn {
drawer: String,
data: yog_data::Value,
x: f32,
y: f32,
w: f32,
h: f32,
},
McItems {
items: Vec<String>,
x: f32,
y: f32,
w: f32,
h: f32,
},
Blocks {
blocks: Vec<((i32, i32, i32), String)>,
x: f32,
y: f32,
w: f32,
h: f32,
},
Entity {
entity: String,
x: f32,
y: f32,
w: f32,
h: f32,
},
McText {
text: String,
x: f32,
y: f32,
color: u32,
},
McItem {
item_id: String,
x: f32,
y: f32,
w: f32,
h: f32,
},
McTexture {
path: String,
x: f32,
y: f32,
w: f32,
h: f32,
},
}
fn push_icon(overlays: &mut Vec<OverlayCmd>, icon: &str, x: f32, y: f32, w: f32, h: f32) {
if icon.ends_with(".png") {
overlays.push(OverlayCmd::McTexture {
path: icon.to_string(),
x,
y,
w,
h,
});
} else {
overlays.push(OverlayCmd::McItem {
item_id: icon.to_string(),
x,
y,
w,
h,
});
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct RecipeVis {
pub kind: String,
pub slots: BTreeMap<String, Vec<Vec<String>>>,
pub output: String,
pub count: u32,
pub values: BTreeMap<String, String>,
}
fn ingredient(v: &yog_data::Value) -> Vec<String> {
use yog_data::Value;
match v {
Value::Str(s) => vec![s.clone()],
Value::Ref(r) => vec![r.id.clone()],
Value::Tag(r) => vec![format!("#{}", r.id)],
Value::List(a) => a.iter().flat_map(ingredient).collect(),
_ => Vec::new(),
}
}
fn parse_recipe(v: &yog_data::Value) -> Option<RecipeVis> {
use yog_data::Value;
let kind = v.get("type")?.as_str()?.to_owned();
let output = match v.get("result")? {
Value::Ref(r) => r.id.clone(),
r => r.as_str()?.to_owned(),
};
let count = v.get("count").and_then(|c| c.as_i64()).unwrap_or(1).max(0) as u32;
let mut slots = BTreeMap::new();
let mut values = BTreeMap::new();
let text = |x: &Value| match x {
Value::Int(i) => i.to_string(),
Value::Float(f) => f.to_string(),
Value::Bool(b) => b.to_string(),
x => x.as_str().map(str::to_owned).unwrap_or_default(),
};
match kind.as_str() {
"shaped" => {
let key = v.get("key")?.as_map()?;
let mut grid = vec![Vec::new(); 9];
for (r, row) in v.get("pattern")?.as_list()?.iter().filter_map(|p| p.as_str()).take(3).enumerate() {
let mut buf = [0u8; 4];
for (c, ch) in row.chars().take(3).enumerate() {
grid[r * 3 + c] = key.get(ch.encode_utf8(&mut buf)).map(ingredient).unwrap_or_default();
}
}
slots.insert("grid".into(), grid);
}
"shapeless" => {
slots.insert("grid".into(), v.get("ingredients")?.as_list()?.iter().map(ingredient).collect());
}
"smelting" | "blasting" | "smoking" | "campfire_cooking" | "stonecutting" => {
slots.insert("input".into(), vec![ingredient(v.get("ingredient")?)]);
let default_time = match kind.as_str() {
"smelting" => Some(200),
"blasting" | "smoking" => Some(100),
"campfire_cooking" => Some(600),
_ => None,
};
if let Some(t) = v.get("cook_time").and_then(|t| t.as_i64()).or(default_time) {
values.insert("time".into(), t.to_string());
}
if let Some(x) = v.get("experience") {
values.insert("experience".into(), text(x));
}
}
"smithing" => {
for part in ["template", "base", "addition"] {
slots.insert(part.into(), vec![v.get(part).map(ingredient).unwrap_or_default()]);
}
}
_ => {
for (name, list) in v.get("slots").and_then(|s| s.as_map()).into_iter().flat_map(|m| m.iter()) {
slots.insert(name.to_string(), list.as_list().into_iter().flatten().map(ingredient).collect());
}
for (name, x) in v.get("values").and_then(|s| s.as_map()).into_iter().flat_map(|m| m.iter()) {
values.insert(name.to_string(), text(x));
}
}
}
Some(RecipeVis { kind, slots, output, count, values })
}
fn normalize_item_id(id: &str) -> String {
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
let name = path
.trim_start_matches("item/")
.trim_start_matches("block/");
format!("{ns}:{name}")
}
fn book_sheet(texture: &str) -> String {
let t = texture.trim();
if t.is_empty() || t == "yog:gui/book" {
return BOOK_SHEET.to_string();
}
let (ns, path) = t.split_once(':').unwrap_or(("minecraft", t));
let path = path.strip_prefix("textures/").unwrap_or(path);
format!("{ns}:{}", path.strip_suffix(".png").unwrap_or(path))
}
static ITEM_NAMES: std::sync::RwLock<Option<Translator>> = std::sync::RwLock::new(None);
type Translator = fn(&str) -> Option<String>;
fn word(key: &str, arg: &str) -> String {
let k = format!("yog.book.{key}");
let tr = *ITEM_NAMES.read().unwrap_or_else(|e| e.into_inner());
let text = tr.and_then(|tr| tr(&k)).filter(|t| *t != k).or_else(|| {
static EN: std::sync::LazyLock<HashMap<String, String>> =
std::sync::LazyLock::new(|| serde_json::from_str(LANG[0].1).unwrap_or_default());
EN.get(&k).cloned()
});
let text = text.unwrap_or(k);
text.replace("%s", arg)
}
fn name_from_path(path: &str) -> String {
path.rsplit('/')
.flat_map(|seg| seg.split('_'))
.filter(|w| !w.is_empty())
.map(|w| {
let mut c = w.chars();
match c.next() {
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn ordinal(n: u32) -> String {
let suffixes = word("ordinals", "");
let s: Vec<&str> = suffixes.split_whitespace().collect();
if s.len() < 4 {
return n.to_string();
}
let i = match (n % 100, n % 10) {
(11..=13, _) => 0,
(_, 1) => 1,
(_, 2) => 2,
(_, 3) => 3,
_ => 0,
};
format!("{n}{}", s[i])
}
fn entity_name(id: &str) -> String {
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
let k = format!("entity.{ns}.{}", path.replace('/', "."));
let tr = *ITEM_NAMES.read().unwrap_or_else(|e| e.into_inner());
tr.and_then(|tr| tr(&k))
.filter(|t| *t != k)
.unwrap_or_else(|| name_from_path(path))
}
fn pretty_item_name(id: &str) -> String {
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
if let Some(tr) = *ITEM_NAMES.read().unwrap_or_else(|e| e.into_inner()) {
let key = path.replace('/', ".");
for kind in ["item", "block"] {
let k = format!("{kind}.{ns}.{key}");
if let Some(name) = tr(&k).filter(|n| *n != k) {
return name;
}
}
}
name_from_path(path)
}
pub struct BookRenderer {
pub book: Book,
pub state: BookViewState,
pub theme: BookTheme,
recipes: HashMap<String, RecipeVis>,
origins: HashMap<String, Vec<usize>>,
written: Book,
measured: bool,
start: std::time::Instant,
pub ui: Option<UiRoot>,
bg_sprites: Vec<BgSprite>,
overlays: Vec<OverlayCmd>,
dirty: bool,
last_sw: f32,
last_sh: f32,
source: Option<Book>,
translator: Option<fn(&str) -> Option<String>>,
probe: Option<Option<String>>,
}
impl BookRenderer {
pub fn new(book: Book) -> Self {
let theme = BookTheme::default().with_nameplate(&book.nameplate_color);
let written = styled(&book);
let (flowed, origins) = flow_traced(written.clone());
let source = book.i18n.then_some(book);
let book = flowed;
Self {
book,
origins,
written,
measured: false,
start: std::time::Instant::now(),
state: BookViewState::default(),
theme,
recipes: HashMap::new(),
ui: None,
bg_sprites: Vec::new(),
overlays: Vec::new(),
dirty: true,
last_sw: 0.0,
last_sh: 0.0,
translator: None,
probe: None,
source,
}
}
pub fn book(&self) -> &Book {
&self.book
}
pub fn set_translator(&mut self, tr: fn(&str) -> Option<String>) {
*ITEM_NAMES.write().unwrap_or_else(|e| e.into_inner()) = Some(tr);
if self.book.i18n {
self.translator = Some(tr);
self.probe = None;
}
}
fn localize(&mut self) {
let (Some(src), Some(tr)) = (&self.source, self.translator) else {
return;
};
let probe = Some(tr(&src.name));
if probe == self.probe {
return;
}
let accent = src.accent.clone();
self.written = src.localized(|k| {
crate::markup::resolve(&tr(k).unwrap_or_else(|| k.to_string()), &accent)
});
(self.book, self.origins) = flow_traced(self.written.clone());
self.probe = probe;
self.dirty = true;
}
pub fn handle_event(&mut self, ev: &str) {
if self.state.handle(ev, &self.book) {
self.dirty = true;
}
}
pub fn add_recipe(&mut self, id: impl Into<String>, recipe: &yog_data::Value) {
if let Some(vis) = parse_recipe(recipe) {
self.recipes.insert(id.into(), vis);
self.dirty = true;
}
}
fn prepare(&mut self, sw: f32, sh: f32) -> (f32, f32, f32, f32) {
let scale = ((sw - SCREEN_MARGIN) / BK_W)
.min((sh - SCREEN_MARGIN) / BK_H)
.clamp(MIN_SCALE, 1.0);
let bw = BK_W * scale;
let bh = BK_H * scale;
let bx = (sw - bw) / 2.0;
let by = (sh - bh) / 2.0;
self.localize();
if self.dirty || sw != self.last_sw || sh != self.last_sh {
let (root, bg_sprites, overlays) = build_ui(
&self.book,
&self.state,
&self.theme,
&self.recipes,
bx,
by,
bw,
bh,
);
self.ui = Some(root);
self.bg_sprites = bg_sprites;
self.overlays = overlays;
self.dirty = false;
self.last_sw = sw;
self.last_sh = sh;
}
if let Some(ui) = &mut self.ui
&& ui.needs_layout
{
ui.layout(sw, sh);
}
(bx, by, bw, bh)
}
pub fn render(&mut self, ctx: &GfxContext, sw: f32, sh: f32) {
self.render_with(ctx, sw, sh, &mut |_, _, _, _| {});
}
pub fn render_with(&mut self, ctx: &GfxContext, sw: f32, sh: f32, draw: PageDrawer<'_>) {
yog_ui::text::use_host_metrics(ctx);
if !self.measured {
self.measured = true;
self.reflow();
}
let (bx, by, bw, bh) = self.prepare(sw, sh);
let d2 = ctx.draw2d();
let book_sheet = book_sheet(&self.book.book_texture);
d2.image_region(
&book_sheet,
bx,
by,
bw,
bh,
[0.0, 0.0, BK_W, BK_H],
0xFFFF_FFFF,
);
for spr in &self.bg_sprites {
let sheet = match &spr.sheet {
SpriteSheet::Book => &book_sheet,
SpriteSheet::Crafting => CRAFTING_SHEET,
SpriteSheet::Image(source) => source.as_str(),
};
d2.image_region(
sheet,
spr.x,
spr.y,
spr.w,
spr.h,
[spr.u, spr.v, spr.uw, spr.uh],
0xFFFF_FFFF,
);
}
if let Some(ui) = &mut self.ui {
ui.render(ctx);
}
let t = self.start.elapsed().as_secs_f32();
for ov in &self.overlays {
match *ov {
OverlayCmd::Drawn {
ref drawer,
ref data,
x,
y,
w,
h,
} => draw(ctx, drawer, data, [x, y, w, h]),
OverlayCmd::Entity {
ref entity,
x,
y,
w,
h,
} => d2.entity(entity, x, y, w, h, -30.0 + t * 30.0, 0.0),
OverlayCmd::Blocks {
ref blocks,
x,
y,
w,
h,
} => {
let list: Vec<((i32, i32, i32), &str)> =
blocks.iter().map(|(p, b)| (*p, b.as_str())).collect();
d2.blocks(&list, x, y, w, h, -45.0 + t * 20.0, 30.0);
}
_ => {}
}
}
self.draw_mc_overlays(ctx);
}
fn draw_mc_overlays(&self, ctx: &GfxContext) {
let second = self.start.elapsed().as_secs() as usize;
for ov in &self.overlays {
match *ov {
OverlayCmd::McItems {
ref items,
x,
y,
w,
h,
} => {
let shown: Vec<&String> =
items.iter().filter(|i| !i.starts_with('#')).collect();
if !shown.is_empty() {
let item = shown[second % shown.len()];
ctx.draw2d().item(&normalize_item_id(item), x, y, w.min(h));
}
}
OverlayCmd::McItem {
ref item_id,
x,
y,
w,
h,
} => {
ctx.draw2d()
.item(&normalize_item_id(item_id), x, y, w.min(h));
}
OverlayCmd::McText {
ref text,
x,
y,
color,
} => {
ctx.draw2d().text(text, x, y, color, false);
}
OverlayCmd::McTexture {
ref path,
x,
y,
w,
h,
} => {
ctx.draw2d().image(path, x, y, w, h);
}
_ => {}
}
}
}
pub fn animated(&self) -> bool {
self.overlays.iter().any(|o| match o {
OverlayCmd::Entity { .. } | OverlayCmd::Blocks { .. } => true,
OverlayCmd::McItems { items, .. } => {
items.iter().filter(|i| !i.starts_with('#')).count() > 1
}
_ => false,
})
}
pub fn click(&mut self, x: f32, y: f32) -> Option<String> {
let ui = self.ui.as_ref()?;
let hit = yog_ui::layout::hit_test(&ui.layout_root, x, y)?;
let action = hit.on_click.clone()?;
self.handle_event(&action);
Some(action)
}
pub fn show(&mut self, entry: &str, page: usize) -> bool {
let Some(e) = self.book.entries.iter().find(|e| e.id == entry) else {
return false;
};
let Some(cat) = self.book.categories.iter().position(|c| c.id == e.category) else {
return false;
};
self.state.cat = cat;
self.state.at_home = false;
let in_cat = self.state.entries_in_cat(&self.book);
let Some(i) = in_cat.iter().position(|x| x.id == entry) else {
return false;
};
let shown = self
.origins
.get(entry)
.and_then(|o| o.iter().position(|&w| w >= page))
.unwrap_or(page);
self.state.entry = i;
self.state.page = shown.min(e.pages.len().saturating_sub(1));
self.dirty = true;
true
}
pub fn shown(&self) -> Option<(String, usize)> {
if self.state.at_home {
return None;
}
let e = self.state.current_entry(&self.book)?;
let page = self
.origins
.get(&e.id)
.and_then(|o| o.get(self.state.page))
.copied()
.unwrap_or(self.state.page);
Some((e.id.clone(), page))
}
fn reflow(&mut self) {
let at = self.shown();
(self.book, self.origins) = flow_traced(self.written.clone());
self.dirty = true;
if let Some((entry, page)) = at {
self.show(&entry, page);
}
}
pub fn set_book(&mut self, book: Book) {
let at = self.shown();
self.written = styled(&book);
self.probe = None;
self.source = book.i18n.then_some(book);
(self.book, self.origins) = flow_traced(self.written.clone());
self.localize();
self.theme = BookTheme::default().with_nameplate(&self.book.nameplate_color);
self.dirty = true;
match at {
Some((entry, page)) if self.show(&entry, page) => {}
_ => {
if self.state.cat >= self.book.categories.len() {
self.state = BookViewState::default();
}
}
}
}
}
thread_local! {
static TEXT_SCALE: std::cell::Cell<f32> = const { std::cell::Cell::new(1.0) };
}
fn text_scale() -> f32 {
TEXT_SCALE.with(|s| s.get())
}
fn lbl(text: impl Into<String>) -> widget::Widget {
widget::label(text).shadow(false).font_scale(text_scale())
}
fn btn(text: impl Into<String>) -> widget::Widget {
widget::button(text).shadow(false).font_scale(text_scale())
}
fn title_lbl(text: impl Into<String>) -> widget::Widget {
let text = text.into();
let w = PAGE_W - 10.0;
let fit = (w / yog_ui::text::str_width(&text, 1.0)).clamp(0.5, 1.0);
widget::label(text)
.shadow(false)
.font_scale(text_scale() * fit)
.no_wrap()
.ellipsis()
.w(w * text_scale())
}
const TEXT_W: f32 = PAGE_W - 8.0;
const BODY_H: f32 = PAGE_H - 22.0 - 12.0 - 14.0;
const MB_VIEW: f32 = 70.0;
const MB_NAME_H: f32 = 14.0;
fn content_above_text(page: &BookPage) -> Option<f32> {
Some(match page {
BookPage::Text { title: Some(_), .. } => 22.0,
BookPage::Text { .. } => 0.0,
BookPage::Spotlight { .. } => 40.0,
BookPage::Recipe { .. } => 80.0,
BookPage::Image { .. } | BookPage::Entity { .. } => 104.0,
BookPage::Drawn { title, height, caption, .. } => {
let title = if title.is_some() { 14.0 } else { 0.0 };
let picture = if *height > 0.0 { height + 4.0 } else { 0.0 };
title + picture + if caption.is_some() { 14.0 } else { 0.0 }
}
BookPage::Multiblock { name, .. } => {
MB_VIEW + 4.0 + if name.is_some() { MB_NAME_H } else { 0.0 }
}
BookPage::Relations { entries, .. } => 12.0 + entries.len() as f32 * 10.0,
_ => return None,
})
}
fn page_text_mut(page: &mut BookPage) -> Option<&mut String> {
match page {
BookPage::Text { text, .. } => Some(text),
BookPage::Spotlight { text, .. }
| BookPage::Recipe { text, .. }
| BookPage::Image { text, .. }
| BookPage::Entity { text, .. }
| BookPage::Multiblock { text, .. }
| BookPage::Relations { text, .. }
| BookPage::Drawn { text, .. } => text.as_mut(),
_ => None,
}
}
fn styled(book: &Book) -> Book {
book.localized(|t| crate::markup::resolve(t, &book.accent))
}
#[cfg(test)]
fn flow(book: Book) -> Book {
flow_traced(book).0
}
fn flow_traced(mut book: Book) -> (Book, HashMap<String, Vec<usize>>) {
let mut origins = HashMap::new();
let line_h = yog_ui::text::LINE_H + yog_ui::text::LINE_GAP;
let fits = |h: f32| (((h + yog_ui::text::LINE_GAP) / line_h).floor() as usize).max(1);
for entry in &mut book.entries {
let mut pages = Vec::with_capacity(entry.pages.len());
let mut from = Vec::with_capacity(entry.pages.len());
for (written, page) in std::mem::take(&mut entry.pages).into_iter().enumerate() {
let split = split_page(page, &book.page_types, &fits);
from.extend(std::iter::repeat_n(written, split.len()));
pages.extend(split);
}
entry.pages = pages;
origins.insert(entry.id.clone(), from);
}
(book, origins)
}
fn custom_text(page: &crate::page_type::CustomPage, types: &[crate::page_type::PageType]) -> Option<(String, f32)> {
use crate::page_type::PageElement as E;
let t = types.iter().find(|t| t.id == page.kind)?;
let line_h = yog_ui::text::LINE_H + yog_ui::text::LINE_GAP;
let shown: Vec<&E> = t.body.iter().filter(|el| match el {
E::Header { visible, .. } | E::Text { visible, .. } | E::Item { visible, .. } | E::Image { visible, .. } | E::Drawn { visible, .. } => t.visible(page, visible),
E::Spacer { .. } => true,
}).collect();
let at = shown.iter().rposition(|el| matches!(el, E::Text { .. }))?;
let E::Text { text, .. } = shown[at] else { return None };
let name = text.trim().strip_prefix("{x:Bind ")?.strip_suffix('}')?.trim();
if !matches!(page.fields.get(name), Some(yog_data::Value::Str(_))) {
return None;
}
let others: f32 = shown.iter().enumerate().filter(|(i, _)| *i != at).map(|(_, el)| match el {
E::Header { .. } => 14.0,
E::Text { text, .. } => t.text(page, text).split('\n').map(|p| yog_ui::text::wrap_text(p, TEXT_W, 1.0).len().max(1)).sum::<usize>() as f32 * line_h + 4.0,
E::Item { .. } => 20.0,
E::Image { height, .. } | E::Drawn { height, .. } | E::Spacer { height } => height + 4.0,
}).sum();
Some((name.to_string(), others))
}
fn split_page(mut page: BookPage, types: &[crate::page_type::PageType], fits: &dyn Fn(f32) -> usize) -> Vec<BookPage> {
let (above, text) = match &mut page {
BookPage::Custom(c) => {
let Some((name, above)) = custom_text(c, types) else { return vec![page] };
let Some(yog_data::Value::Str(text)) = c.fields.get_mut(&name) else { return vec![page] };
(above, text)
}
_ => {
let Some(above) = content_above_text(&page) else {
return vec![page];
};
let Some(text) = page_text_mut(&mut page) else {
return vec![page];
};
(above, text)
}
};
let lines: Vec<String> = text
.split('\n')
.flat_map(|para| {
if para.is_empty() {
vec![String::new()]
} else {
yog_ui::text::wrap_text(para, TEXT_W, 1.0)
}
})
.collect();
let first = fits(BODY_H - above);
if lines.len() <= first {
return vec![page];
}
*text = lines[..first].join("\n");
let rest: Vec<BookPage> = lines[first..]
.chunks(fits(BODY_H))
.map(|chunk| BookPage::Text {
text: chunk.join("\n"),
title: None,
font: None,
font_size: None,
})
.collect();
std::iter::once(page).chain(rest).collect()
}
fn build_ui(
book: &Book,
state: &BookViewState,
theme: &BookTheme,
recipes: &HashMap<String, RecipeVis>,
bx: f32,
by: f32,
bw: f32,
bh: f32,
) -> (UiRoot, Vec<BgSprite>, Vec<OverlayCmd>) {
let mut bg_sprites: Vec<BgSprite> = Vec::new();
let mut overlays: Vec<OverlayCmd> = Vec::new();
let sx = bw / BK_W;
TEXT_SCALE.with(|s| s.set(sx));
let sy = bh / BK_H;
let pw = PAGE_W * sx;
let ph = PAGE_H * sy;
let spine_gap = (RIGHT_X - LEFT_X - PAGE_W) * sx;
let lx = bx + LEFT_X * sx;
let rx = bx + RIGHT_X * sx;
let py = by + TOP_PAD * sy;
let sep_cx = (PAGE_W / 2.0 - SEP_W / 2.0) * sx;
if state.at_home {
bg_sprites.push(BgSprite {
sheet: SpriteSheet::Book,
u: 0.0,
v: 180.0,
uw: 140.0,
uh: 31.0,
x: bx + (LEFT_X - 8.0) * sx,
y: by + 12.0 * sy,
w: 140.0 * sx,
h: 31.0 * sy,
});
}
bg_sprites.push(BgSprite {
sheet: SpriteSheet::Book,
u: SEP_U,
v: SEP_V,
uw: SEP_W,
uh: SEP_H,
x: rx + sep_cx,
y: by + (TOP_PAD + 12.0) * sy,
w: SEP_W * sx,
h: (SEP_H * sy).max(1.0),
});
let (left_page, right_page) = if state.at_home {
let l = build_landing_left(book, theme, pw, ph, sx, sy, bx, by, &mut overlays);
let r = build_categories_right(book, state, theme, pw, ph, rx, py, sx, sy, &mut overlays);
(l, r)
} else {
let l = build_entry_left(
book,
state,
theme,
recipes,
pw,
ph,
lx,
py,
sx,
sy,
sep_cx,
&mut bg_sprites,
&mut overlays,
);
let r = build_entries_right(book, state, theme, pw, ph, rx, py, sx, sy, &mut overlays);
(l, r)
};
let root_widget = widget::panel(FlexDir::Row)
.w(bw)
.h(bh)
.gap(0.0)
.padding(TOP_PAD * sy, 0.0, 0.0, LEFT_X * sx)
.child(left_page)
.child(widget::spacer().w(spine_gap)) .child(right_page);
let outer = widget::panel(FlexDir::Column)
.w(bw)
.h(bh)
.child(root_widget);
let screen_root = widget::panel(FlexDir::Row)
.padding(by, 0.0, 0.0, bx)
.child(outer);
let ui = UiRoot::new(&book.id, screen_root);
(ui, bg_sprites, overlays)
}
fn build_landing_left(
book: &Book,
theme: &BookTheme,
page_w: f32,
page_h: f32,
sx: f32,
sy: f32,
bx: f32,
by: f32,
overlays: &mut Vec<OverlayCmd>,
) -> widget::Widget {
overlays.push(OverlayCmd::McText {
text: book.name.clone(),
x: bx + 13.0 * sx,
y: by + 16.0 * sy,
color: theme.nameplate,
});
let second = match book.version {
0 => book.subtitle.clone(),
n => Some(word("edition", &ordinal(n))),
};
if let Some(text) = second {
overlays.push(OverlayCmd::McText {
text,
x: bx + 24.0 * sx,
y: by + 24.0 * sy,
color: theme.nameplate,
});
}
let mut col = widget::panel(FlexDir::Column)
.w(page_w)
.h(page_h)
.padding(25.0 * sy, 6.0, 4.0, 4.0)
.gap(0.0);
let fit = landing_fit(&book.landing_text);
for para in book.landing_text.split('\n') {
if para.is_empty() {
col = col.child(widget::spacer().h(4.0 * sy));
} else {
col = col.child(lbl(para).color(theme.text).font_scale(text_scale() * fit));
}
}
let total = book.entries.len();
col = col.child(widget::spacer().flex(1.0));
col = col.child(
title_lbl(word("entry_count", &total.to_string()))
.color(theme.nav)
.h(9.0 * sy)
.align(Align::Center),
);
col
}
fn landing_fit(text: &str) -> f32 {
use yog_ui::text::{LINE_GAP, LINE_H, line_count};
let room = PAGE_H - 25.0 - 4.0 - 9.0 - 2.0;
let height = |scale: f32| -> f32 {
text.split('\n')
.map(|p| match p.is_empty() {
true => 4.0,
false => line_count(p, TEXT_W, scale) as f32 * (LINE_H * scale + LINE_GAP),
})
.sum()
};
let mut scale = 1.0;
while scale > 0.6 && height(scale) > room {
scale -= 0.05;
}
scale
}
fn build_categories_right(
book: &Book,
state: &BookViewState,
theme: &BookTheme,
page_w: f32,
page_h: f32,
rx: f32,
py: f32,
sx: f32,
sy: f32,
overlays: &mut Vec<OverlayCmd>,
) -> widget::Widget {
let cell_w = 24.0 * sx;
let cell_h = 24.0 * sy;
let grid_pad_left = 10.0 * sx;
let header_h = 9.0 * sy;
let mut col = widget::panel(FlexDir::Column)
.w(page_w)
.h(page_h)
.padding(0.0, 4.0, 4.0, 0.0)
.gap(0.0);
col = col.child(
title_lbl(word("categories", ""))
.color(theme.divider)
.h(header_h)
.align(Align::Center)
.no_wrap(),
);
col = col.child(widget::spacer().h(25.0 * sy - header_h));
let cats = &book.categories;
let icon_s = 16.0 * sx.min(sy);
let mut row_i = 0usize;
loop {
let row_start = row_i * 4;
if row_start >= cats.len() {
break;
}
let mut row =
widget::panel(FlexDir::Row)
.h(cell_h)
.gap(0.0)
.padding(0.0, 0.0, 0.0, grid_pad_left);
for col_i in 0..4 {
let idx = row_start + col_i;
if idx >= cats.len() {
break;
}
let cat = &cats[idx];
let selected = !state.at_home && idx == state.cat;
let bg = if selected { theme.nav_selected_bg } else { 0 };
if let Some(icon_id) = &cat.icon {
push_icon(
overlays,
icon_id,
rx + (10.0 + col_i as f32 * 24.0 + 4.0) * sx,
py + (25.0 + row_i as f32 * 24.0 + 4.0) * sy,
icon_s,
icon_s,
);
}
let cell = widget::panel(FlexDir::Column)
.w(cell_w)
.h(cell_h)
.bg(bg)
.on_click(format!("cat:{}", idx))
.id(format!("book_cat_{}", idx));
row = row.child(cell);
}
col = col.child(row);
row_i += 1;
}
col
}
fn build_entry_left(
book: &Book,
state: &BookViewState,
theme: &BookTheme,
recipes: &HashMap<String, RecipeVis>,
page_w: f32,
page_h: f32,
ox: f32,
oy: f32,
sx: f32,
sy: f32,
sep_cx: f32,
bg_sprites: &mut Vec<BgSprite>,
overlays: &mut Vec<OverlayCmd>,
) -> widget::Widget {
let entry = state.current_entry(book);
let page = entry.and_then(|e| e.pages.get(state.page));
let page_count = state.page_count(book);
let title_text = entry.map(|e| e.name.as_str()).unwrap_or("");
let title_h = 9.0 * sy;
let sep_h_px = (SEP_H * sy).max(1.0);
let sep_y = oy + 12.0 * sy;
let body_oy = oy + 22.0 * sy;
bg_sprites.push(BgSprite {
sheet: SpriteSheet::Book,
u: SEP_U,
v: SEP_V,
uw: SEP_W,
uh: SEP_H,
x: ox + sep_cx,
y: sep_y,
w: SEP_W * sx,
h: sep_h_px,
});
let page_label = format!("{}/{}", state.page + 1, page_count);
let nav = widget::panel(FlexDir::Row)
.h(12.0 * sy)
.gap(4.0)
.padding(0.0, 2.0, 0.0, 2.0)
.child(
btn("â—€")
.w(12.0 * sx)
.h(12.0 * sy)
.color(theme.nav)
.on_click("prev_page")
.id("prev_page"),
)
.child(
lbl(&page_label)
.color(theme.nav)
.flex(1.0)
.align(Align::Center),
)
.child(
btn("⌂")
.w(12.0 * sx)
.h(12.0 * sy)
.color(theme.nav)
.on_click("home")
.id("book_home"),
)
.child(
btn("â–¶")
.w(12.0 * sx)
.h(12.0 * sy)
.color(theme.nav)
.on_click("next_page")
.id("next_page"),
);
let page_body = build_page(
page,
state.page,
theme,
recipes,
&book.layouts,
&book.page_types,
bg_sprites,
overlays,
ox,
body_oy,
sx,
sy,
);
widget::panel(FlexDir::Column)
.w(page_w)
.h(page_h)
.padding(0.0, 6.0, 4.0, 4.0)
.gap(0.0)
.child(
title_lbl(title_text)
.color(theme.title)
.h(title_h)
.align(Align::Center)
.no_wrap(),
)
.child(widget::spacer().h((12.0 - 9.0) * sy)) .child(widget::spacer().h(sep_h_px)) .child(widget::spacer().h((22.0 - 12.0 - SEP_H) * sy)) .child(page_body)
.child(nav)
}
fn build_entries_right(
book: &Book,
state: &BookViewState,
theme: &BookTheme,
page_w: f32,
page_h: f32,
rx: f32,
py: f32,
sx: f32,
sy: f32,
overlays: &mut Vec<OverlayCmd>,
) -> widget::Widget {
let entries = state.entries_visible(book);
let cat_name = book
.categories
.get(state.cat)
.map(|c| c.name.as_str())
.map(str::to_string)
.unwrap_or_else(|| word("entries", ""));
let spread_count = state.list_spread_count(book);
let header_h = 9.0 * sy;
let row_h = 11.0 * sy;
let icon_size = 8.0 * sx.min(sy);
let mut col = widget::panel(FlexDir::Column)
.w(page_w)
.h(page_h)
.padding(0.0, 4.0, 8.0, 0.0)
.gap(0.0);
col = col.child(
title_lbl(cat_name)
.color(theme.divider)
.h(header_h)
.align(Align::Center),
);
col = col.child(widget::spacer().h((12.0 - 9.0) * sy)); col = col.child(widget::spacer().h((SEP_H * sy).max(1.0))); col = col.child(widget::spacer().h((20.0 - 12.0 - SEP_H) * sy));
let abs_start = state.list_spread_start();
for (i, entry) in entries.iter().enumerate() {
let abs_i = abs_start + i;
let selected = abs_i == state.entry;
let bg = if selected { theme.nav_selected_bg } else { 0 };
let color = if selected {
theme.nav_selected
} else {
theme.nav
};
if let Some(icon_id) = &entry.icon {
push_icon(
overlays,
icon_id,
rx + 1.0 * sx,
py + (20.0 + i as f32 * 11.0 + 1.0) * sy,
icon_size,
icon_size,
);
}
let mut row = widget::panel(FlexDir::Row)
.h(row_h)
.gap(2.0)
.bg(bg)
.on_click(format!("entry:{}", abs_i))
.id(format!("book_entry_{}", abs_i))
.padding(1.0, 2.0, 1.0, 2.0);
row = row.child(widget::spacer().w(icon_size + 2.0));
let fit = ((PAGE_W - 26.0) / yog_ui::text::str_width(&entry.name, 1.0)).clamp(0.5, 1.0);
row = row.child(
lbl(&entry.name)
.color(color)
.font_scale(text_scale() * fit)
.flex(1.0)
.no_wrap(),
);
col = col.child(row);
}
if spread_count > 1 {
col = col.child(widget::spacer().flex(1.0));
let spread_label = format!("{}/{}", state.list_spread + 1, spread_count);
col = col.child(
widget::panel(FlexDir::Row)
.h(12.0 * sy)
.gap(2.0)
.child(
btn("â—€")
.w(12.0 * sx)
.h(12.0 * sy)
.color(theme.nav)
.on_click("prev_list")
.id("prev_list"),
)
.child(
lbl(&spread_label)
.color(theme.nav)
.flex(1.0)
.align(Align::Center),
)
.child(
btn("â–¶")
.w(12.0 * sx)
.h(12.0 * sy)
.color(theme.nav)
.on_click("next_list")
.id("next_list"),
),
);
}
col
}
#[allow(clippy::too_many_arguments)]
fn draw_layout(
theme: &BookTheme,
bg_sprites: &mut Vec<BgSprite>,
overlays: &mut Vec<OverlayCmd>,
col: &mut widget::Widget,
vis: &RecipeVis,
layout: &crate::layout::RecipeLayout,
ox: f32,
oy: f32,
sx: f32,
sy: f32,
) {
use crate::layout::{FRAME_UV, LayoutPart};
let mut c = std::mem::replace(col, widget::panel(FlexDir::Column));
c = c.child(
title_lbl(pretty_item_name(&vis.output))
.color(theme.title)
.h(10.0 * sy)
.align(Align::Center),
);
let (lx, ly) = (PAGE_W / 2.0 - 1.0 - layout.size[0] / 2.0, 10.0);
let at = |p: [f32; 2]| (ox + (lx + p[0]) * sx, oy + (ly + p[1]) * sy);
let icon_s = 16.0 * sx.min(sy);
let frame = |bg: &mut Vec<BgSprite>, p: [f32; 2]| {
let (x, y) = at([p[0] - 4.0, p[1] - 4.0]);
let [u, v, uw, uh] = FRAME_UV;
bg.push(BgSprite {
sheet: SpriteSheet::Crafting,
u,
v,
uw,
uh,
x,
y,
w: uw * sx,
h: uh * sy,
});
};
for part in &layout.parts {
match part {
LayoutPart::Image { source, uv, at: p } => {
let (x, y) = at(*p);
let sheet = if source == CRAFTING_SHEET {
SpriteSheet::Crafting
} else {
SpriteSheet::Image(source.clone())
};
let [u, v, uw, uh] = *uv;
bg_sprites.push(BgSprite {
sheet,
u,
v,
uw,
uh,
x,
y,
w: uw * sx,
h: uh * sy,
});
}
LayoutPart::Slots {
slot,
at: places,
frame: framed,
} => {
let ingredients = vis.slots.get(slot);
for (i, p) in places.iter().enumerate() {
if *framed {
frame(bg_sprites, *p);
}
if let Some(items) =
ingredients.and_then(|l| l.get(i)).filter(|x| !x.is_empty())
{
let (x, y) = at(*p);
overlays.push(OverlayCmd::McItems {
items: items.clone(),
x,
y,
w: icon_s,
h: icon_s,
});
}
}
}
LayoutPart::Result {
at: p,
frame: framed,
} => {
if *framed {
frame(bg_sprites, *p);
}
let (x, y) = at(*p);
overlays.push(OverlayCmd::McItem {
item_id: vis.output.clone(),
x,
y,
w: icon_s,
h: icon_s,
});
}
LayoutPart::Count { at: p } => {
if vis.count > 1 {
let (x, y) = at(*p);
overlays.push(OverlayCmd::McText {
text: format!("x{}", vis.count),
x,
y,
color: theme.title,
});
}
}
LayoutPart::Item { item, at: p } => {
let (x, y) = at(*p);
overlays.push(OverlayCmd::McItem {
item_id: item.clone(),
x,
y,
w: icon_s,
h: icon_s,
});
}
LayoutPart::Text { text, at: p } => {
let mut shown = text.clone();
for (name, value) in &vis.values {
shown = shown.replace(&format!("{{{name}}}"), value);
}
let (x, y) = at(*p);
overlays.push(OverlayCmd::McText {
text: shown,
x,
y,
color: theme.text,
});
}
}
}
c = c.child(widget::spacer().h((layout.size[1] + 6.0) * sy));
*col = c;
}
fn build_page(
page: Option<&BookPage>,
page_num: usize,
theme: &BookTheme,
recipes: &HashMap<String, RecipeVis>,
layouts: &[crate::layout::RecipeLayout],
page_types: &[crate::page_type::PageType],
bg_sprites: &mut Vec<BgSprite>,
overlays: &mut Vec<OverlayCmd>,
ox: f32,
oy: f32,
sx: f32,
sy: f32,
) -> widget::Widget {
let mut col = widget::panel(FlexDir::Column).flex(1.0).gap(4.0);
let Some(page) = page else {
return col.child(lbl(word("no_entries", "")).color(theme.nav));
};
match page {
BookPage::Text {
text,
title,
font,
font_size,
} => {
if page_num > 0
&& let Some(t) = title
{
let sep_h_px = (SEP_H * sy).max(1.0);
let title_h = 9.0 * sy;
col = col.child(
title_lbl(t.as_str())
.color(theme.title)
.h(title_h)
.align(Align::Center)
.no_wrap(),
);
bg_sprites.push(BgSprite {
sheet: SpriteSheet::Book,
u: SEP_U,
v: SEP_V,
uw: SEP_W,
uh: SEP_H,
x: ox + (PAGE_W / 2.0 - SEP_W / 2.0) * sx,
y: oy + 12.0 * sy,
w: SEP_W * sx,
h: sep_h_px,
});
col = col.child(widget::spacer().h((12.0 - 9.0) * sy));
col = col.child(widget::spacer().h(sep_h_px));
col = col.child(widget::spacer().h((22.0 - 12.0 - SEP_H) * sy));
}
let mut l = lbl(text.as_str()).color(theme.text);
if let Some(f) = font {
l = l.font(f.as_str()).font_scale(
text_scale() * font_size.unwrap_or(yog_ui::text::LINE_H) / yog_ui::text::LINE_H,
);
}
col = col.child(l);
}
BookPage::Spotlight { item, title, text } => {
bg_sprites.push(BgSprite {
sheet: SpriteSheet::Crafting,
u: 0.0,
v: 102.0,
uw: 66.0,
uh: 26.0,
x: ox + (PAGE_W / 2.0 - 33.0) * sx,
y: oy + 10.0 * sy,
w: 66.0 * sx,
h: 26.0 * sy,
});
let item_name = title.clone().unwrap_or_else(|| pretty_item_name(item));
col = col.child(
title_lbl(item_name)
.color(theme.title)
.h(10.0 * sy)
.align(Align::Center),
);
let icon_s = 16.0 * sx.min(sy);
overlays.push(OverlayCmd::McItem {
item_id: item.clone(),
x: ox + (PAGE_W / 2.0 - 8.0) * sx,
y: oy + 15.0 * sy,
w: icon_s,
h: icon_s,
});
col = col.child(widget::spacer().h(30.0 * sy));
if let Some(t) = text {
col = col.child(lbl(t.as_str()).color(theme.text));
}
}
BookPage::Recipe { recipe, text } => {
match recipes
.get(recipe.as_str())
.and_then(|vis| crate::layout::find(layouts, &vis.kind).map(|l| (vis, l)))
{
Some((vis, layout)) => draw_layout(
theme, bg_sprites, overlays, &mut col, vis, layout, ox, oy, sx, sy,
),
None => col = col.child(lbl(format!("[Recipe: {}]", recipe)).color(theme.nav)),
}
if let Some(t) = text {
col = col.child(lbl(t.as_str()).color(theme.text));
}
}
BookPage::Image {
texture,
title,
text,
..
} => {
if let Some(t) = title {
col = col.child(title_lbl(t.as_str()).color(theme.title));
}
col = col.child(
widget::image(texture)
.region([0.0, 0.0, 80.0, 80.0])
.stretch(Stretch::Fill)
.w(80.0)
.h(80.0),
);
if let Some(t) = text {
col = col.child(lbl(t.as_str()).color(theme.text));
}
}
BookPage::Relations { entries, text } => {
if let Some(t) = text {
col = col.child(lbl(t.as_str()).color(theme.text));
}
col = col.child(title_lbl(word("see_also", "")).color(theme.title));
for e in entries {
col = col.child(lbl(format!("• {}", e)).color(theme.nav));
}
}
BookPage::Multiblock {
name,
pattern,
mapping,
text,
} => {
let top = if let Some(n) = name {
col = col.child(title_lbl(n.as_str()).color(theme.title).h(10.0 * sy));
MB_NAME_H
} else {
0.0
};
overlays.push(OverlayCmd::Blocks {
blocks: crate::multiblock_blocks(pattern, mapping),
x: ox + (PAGE_W / 2.0 - MB_VIEW / 2.0) * sx,
y: oy + top * sy,
w: MB_VIEW * sx,
h: MB_VIEW * sy,
});
col = col.child(widget::spacer().h(MB_VIEW * sy));
if let Some(t) = text {
col = col.child(lbl(t.as_str()).color(theme.text));
}
}
BookPage::Entity { entity, name, text } => {
let display = name.clone().unwrap_or_else(|| entity_name(entity));
col = col.child(title_lbl(display).color(theme.title));
overlays.push(OverlayCmd::Entity {
entity: entity.clone(),
x: ox + (PAGE_W / 2.0 - 40.0) * sx,
y: oy + 12.0 * sy,
w: 80.0 * sx,
h: 80.0 * sy,
});
col = col.child(widget::spacer().h(84.0 * sy));
if let Some(t) = text {
col = col.child(lbl(t.as_str()).color(theme.text));
}
}
BookPage::Drawn {
drawer,
title,
height,
caption,
text,
data,
} => {
let mut top = 0.0;
if let Some(t) = title {
col = col.child(title_lbl(t.as_str()).color(theme.title).h(10.0 * sy));
top = 14.0;
}
if *height > 0.0 {
overlays.push(OverlayCmd::Drawn {
drawer: drawer.clone(),
data: data.clone(),
x: ox,
y: oy + top * sy,
w: PAGE_W * sx,
h: height * sy,
});
col = col.child(widget::spacer().h(height * sy));
}
if let Some(c) = caption {
col = col.child(lbl(c.as_str()).color(theme.text));
}
if let Some(t) = text {
col = col.child(lbl(t.as_str()).color(theme.text));
}
}
BookPage::Empty => {}
BookPage::Custom(c) => {
use crate::page_type::PageElement as E;
let Some(t) = page_types.iter().find(|t| t.id == c.kind) else {
return col.child(lbl(format!("[{}]", c.kind)).color(theme.nav));
};
let line_h = yog_ui::text::LINE_H + yog_ui::text::LINE_GAP;
let mut top = 0.0;
for el in &t.body {
match el {
E::Header { text, visible } if t.visible(c, visible) => {
col = col.child(title_lbl(t.text(c, text)).color(theme.title).h(10.0 * sy));
top += 14.0;
}
E::Text { text, visible } if t.visible(c, visible) => {
let words = t.text(c, text);
let lines: usize = words.split('\n').map(|p| yog_ui::text::wrap_text(p, TEXT_W, 1.0).len().max(1)).sum();
col = col.child(lbl(words).color(theme.text));
top += lines as f32 * line_h + 4.0;
}
E::Item { item, visible } if t.visible(c, visible) => {
let id = crate::page_type::words(&t.value(c, item));
push_icon(overlays, &id, ox + (PAGE_W / 2.0 - 8.0) * sx, oy + top * sy, 16.0 * sx, 16.0 * sy);
col = col.child(widget::spacer().h(16.0 * sy));
top += 20.0;
}
E::Image { image, height, visible } if t.visible(c, visible) => {
let src = crate::page_type::words(&t.value(c, image));
col = col.child(widget::image(src).stretch(Stretch::Fill).w(PAGE_W * sx).h(height * sy));
top += height + 4.0;
}
E::Drawn { drawer, height, data, visible } if t.visible(c, visible) => {
overlays.push(OverlayCmd::Drawn {
drawer: drawer.clone(),
data: t.value(c, data),
x: ox,
y: oy + top * sy,
w: PAGE_W * sx,
h: height * sy,
});
col = col.child(widget::spacer().h(height * sy));
top += height + 4.0;
}
E::Spacer { height } => {
col = col.child(widget::spacer().h(height * sy));
top += height + 4.0;
}
_ => {}
}
}
}
}
col
}
#[cfg(test)]
mod tests {
use super::*;
fn j(json: &str) -> yog_data::Value {
yog_data::json::from_json_text(json).unwrap()
}
use crate::BookEntry;
fn book(pages: Vec<BookPage>) -> Book {
let mut book = Book {
id: "t:b".into(),
name: "B".into(),
..Default::default()
};
book.entries.push(BookEntry {
id: "e".into(),
name: "E".into(),
pages,
..Default::default()
});
book
}
#[test]
fn long_text_flows_onto_new_pages() {
let long = "word ".repeat(400);
let flowed = flow(book(vec![
BookPage::Text {
text: long.clone(),
title: None,
font: None,
font_size: None,
},
BookPage::Empty,
]));
let pages = &flowed.entries[0].pages;
assert!(pages.len() > 3, "{}", pages.len());
assert!(
matches!(pages.last(), Some(BookPage::Empty)),
"what came after stays after"
);
let line_h = yog_ui::text::LINE_H + yog_ui::text::LINE_GAP;
for p in &pages[..pages.len() - 1] {
let BookPage::Text { text, .. } = p else {
panic!("{p:?}")
};
assert!(text.lines().count() as f32 * line_h <= BODY_H + 1.0);
assert!(
text.lines()
.all(|l| yog_ui::text::str_width(l, 1.0) <= TEXT_W)
);
}
let words: usize = pages
.iter()
.map(|p| match p {
BookPage::Text { text, .. } => text.split_whitespace().count(),
_ => 0,
})
.sum();
assert_eq!(words, 400, "nothing lost");
}
#[test]
fn edits_keep_the_reader_where_they_were() {
let mut b = book(vec![
BookPage::Text {
text: "one".into(),
title: None,
font: None,
font_size: None,
},
BookPage::Text {
text: "two".into(),
title: None,
font: None,
font_size: None,
},
]);
b.categories.push(crate::BookCategory {
id: "c".into(),
name: "C".into(),
..Default::default()
});
b.entries[0].category = "c".into();
let mut r = BookRenderer::new(b.clone());
assert_eq!(r.shown(), None, "opens on the landing page");
assert!(r.show("e", 1));
assert_eq!(r.shown(), Some(("e".into(), 1)));
r.prepare(800.0, 600.0);
assert!(r.ui.is_some());
b.entries[0].pages.push(BookPage::Empty);
r.set_book(b);
assert_eq!(r.shown(), Some(("e".into(), 1)));
assert!(!r.show("missing", 0));
}
#[test]
fn pages_are_counted_as_written() {
let long = "word ".repeat(400);
let mut b = book(vec![
BookPage::Text {
text: long,
title: None,
font: None,
font_size: None,
},
BookPage::Empty,
]);
b.categories.push(crate::BookCategory {
id: "c".into(),
name: "C".into(),
..Default::default()
});
b.entries[0].category = "c".into();
let mut r = BookRenderer::new(b);
assert!(r.show("e", 1));
assert_eq!(
r.shown(),
Some(("e".into(), 1)),
"the second page as written"
);
assert!(r.state.page > 1, "past the first page's flowed text");
r.handle_event("prev_page");
assert_eq!(
r.shown(),
Some(("e".into(), 0)),
"flowed text belongs to the first"
);
}
#[test]
fn reads_yog_recipes() {
let shaped = parse_recipe(&j(r##"{"type":"shaped","pattern":["AB"],"key":{"A":"m:a","B":["m:x","#c:t"]},"result":"m:out","count":2}"##)).unwrap();
assert_eq!(
(shaped.kind.as_str(), shaped.output.as_str(), shaped.count),
("shaped", "m:out", 2)
);
let grid = &shaped.slots["grid"];
assert_eq!(
(grid.len(), &grid[1]),
(9, &vec!["m:x".to_string(), "#c:t".to_string()]),
"cells by place on the 3×3 grid"
);
let blast =
parse_recipe(&j(r##"{"type":"blasting","ingredient":"m:ore","result":"m:ingot"}"##))
.unwrap();
assert_eq!(
(
blast.slots["input"][0][0].as_str(),
blast.values["time"].as_str()
),
("m:ore", "100")
);
let own = parse_recipe(&j(r##"{"type":"m:infusion","slots":{"ring":["m:a",["m:b","m:c"]]},"values":{"time":40},"result":"m:out"}"##)).unwrap();
assert_eq!(
(own.slots["ring"].len(), own.values["time"].as_str()),
(2, "40")
);
assert!(
parse_recipe(&j(r#"{"type":"minecraft:crafting_shaped"}"#)).is_none(),
"vanilla JSON isn't the book's"
);
}
#[test]
fn recipe_pages_are_drawn_by_layouts() {
let theme = BookTheme::default();
let mut recipes = HashMap::new();
recipes.insert("m:r".to_string(), parse_recipe(&j(r##"{"type":"m:infusion","slots":{"ring":["m:a","m:b"]},"values":{"time":40},"result":"m:out"}"##)).unwrap());
recipes.insert("m:s".to_string(), parse_recipe(&j(r##"{"type":"smithing","template":"m:t","base":"m:b","addition":"m:a","result":"m:out"}"##)).unwrap());
let layout: crate::layout::RecipeLayout = yog_data::from_text(
r#"{ kinds: ["m:infusion"], size: [96, 40], parts: [
{ type: "slots", slot: "ring", at: [[0, 0], [20, 0]], frame: true },
{ type: "result", at: [76, 10] },
{ type: "text", text: "{time} ticks", at: [0, 30] },
] }"#,
)
.unwrap()
.value;
let draw = |id: &str| {
let (mut bg, mut ov) = (Vec::new(), Vec::new());
let page = BookPage::Recipe {
recipe: id.into(),
text: None,
};
build_page(
Some(&page),
0,
&theme,
&recipes,
std::slice::from_ref(&layout),
&[],
&mut bg,
&mut ov,
0.0,
0.0,
1.0,
1.0,
);
(bg, ov)
};
let (bg, ov) = draw("m:r");
let lx = PAGE_W / 2.0 - 1.0 - 48.0;
let items: Vec<(f32, f32)> = ov
.iter()
.filter_map(|o| match o {
OverlayCmd::McItems { x, y, .. } | OverlayCmd::McItem { x, y, .. } => {
Some((*x, *y))
}
_ => None,
})
.collect();
assert_eq!(items, [(lx, 10.0), (lx + 20.0, 10.0), (lx + 76.0, 20.0)]);
assert_eq!(bg.len(), 2, "a frame behind each ring slot");
assert!(
ov.iter()
.any(|o| matches!(o, OverlayCmd::McText { text, .. } if text == "40 ticks"))
);
let (bg, ov) = draw("m:s");
assert!(
bg.iter().any(|b| b.u == 11.0 && b.v == 135.0),
"Patchouli's smithing strip"
);
assert_eq!(
ov.iter()
.filter(|o| matches!(o, OverlayCmd::McItems { .. }))
.count(),
3
);
}
#[test]
fn multiblocks_turn_and_animate() {
let pattern = vec![vec!["G".to_string()], vec!["S ".to_string()]];
let mapping = [
("G".to_string(), "minecraft:glass".to_string()),
("S".to_string(), "minecraft:stone".to_string()),
]
.into();
let blocks = crate::multiblock_blocks(&pattern, &mapping);
assert_eq!(
blocks,
[
((0, 1, 0), "minecraft:glass".to_string()),
((0, 0, 0), "minecraft:stone".to_string())
]
);
let mut b = book(vec![BookPage::Multiblock {
name: None,
pattern,
mapping,
text: None,
}]);
b.categories.push(crate::BookCategory {
id: "c".into(),
name: "C".into(),
..Default::default()
});
b.entries[0].category = "c".into();
let mut r = BookRenderer::new(b);
r.prepare(800.0, 600.0);
assert!(!r.animated(), "the landing page is still");
assert_eq!(landing_fit("short"), 1.0);
assert!(
landing_fit(&"many words here ".repeat(60)) < 1.0,
"long landing texts shrink"
);
r.show("e", 0);
r.prepare(800.0, 600.0);
assert!(r.animated(), "a structure turns");
}
#[test]
fn short_text_stays_put() {
let pages = vec![BookPage::Text {
text: "Hello".into(),
title: Some("T".into()),
font: None,
font_size: None,
}];
let flowed = flow(book(pages.clone()));
assert_eq!(flowed.entries[0].pages.len(), 1);
}
#[test]
fn a_drawn_page_hands_its_box_and_data_to_the_mods_drawer() {
let data = yog_data::json::from_json(&serde_json::json!({ "op_id": "hexcasting:manual" }));
let page: BookPage = serde_json::from_value(serde_json::json!({
"type": "drawn", "drawer": "hexcasting:pattern", "title": "Manual", "height": 64,
"caption": "{u|entity} → {u|num}", "text": "Casts.", "data": { "op_id": "hexcasting:manual" }
}))
.unwrap();
assert!(matches!(&page, BookPage::Drawn { data: d, height, .. } if *d == data && *height == 64.0));
assert_eq!(content_above_text(&page), Some(14.0 + 68.0 + 14.0));
let mut b = book(vec![page.clone()]);
b.categories.push(crate::BookCategory { id: "c".into(), name: "C".into(), ..Default::default() });
b.entries[0].category = "c".into();
let mut r = BookRenderer::new(b);
r.show("e", 0);
r.prepare(800.0, 600.0);
let drawn: Vec<_> = r.overlays.iter().filter_map(|o| match o {
OverlayCmd::Drawn { drawer, data, w, h, .. } => Some((drawer.clone(), data.clone(), *w / *h)),
_ => None,
}).collect();
assert_eq!(drawn.len(), 1);
assert_eq!((drawn[0].0.as_str(), &drawn[0].1), ("hexcasting:pattern", &data));
assert!((drawn[0].2 - PAGE_W / 64.0).abs() < 0.01, "the page's width, 64 high");
assert_eq!(serde_json::to_value(&page).unwrap()["data"]["op_id"], "hexcasting:manual");
}
#[test]
fn a_mods_page_type_lays_out_its_pages() {
use crate::page_type::{PageElement as E, PageProperty, PageType, PropKind};
let ty = PageType {
id: "hexcasting:pattern".into(),
properties: vec![
PageProperty { name: "name".into(), kind: PropKind::BookText, default: None },
PageProperty { name: "patterns".into(), kind: PropKind::Data, default: Some(yog_data::Value::List(vec![])) },
],
body: vec![
E::Header { text: "{x:Bind name}".into(), visible: None },
E::Drawn { drawer: "hexcasting:pattern".into(), height: 64.0, data: "{x:Bind patterns}".into(), visible: Some("{x:Bind patterns}".into()) },
],
};
let page = |json: &str| BookPage::Custom(yog_data::read_value(&yog_data::json::from_json_text(json).unwrap()).unwrap());
let mut b = book(vec![
page(r#"{"type":"hexcasting:pattern","name":"Manual","patterns":[{"startdir":"EAST","signature":"qaq"}]}"#),
page(r#"{"type":"hexcasting:pattern","name":"Bare"}"#),
]);
b.page_types.push(ty);
b.categories.push(crate::BookCategory { id: "c".into(), name: "C".into(), ..Default::default() });
b.entries[0].category = "c".into();
let mut r = BookRenderer::new(b);
r.show("e", 0);
r.prepare(800.0, 600.0);
let drawn: Vec<_> = r.overlays.iter().filter_map(|o| match o {
OverlayCmd::Drawn { drawer, data, .. } => Some((drawer.clone(), data.clone())),
_ => None,
}).collect();
assert_eq!(drawn.len(), 1, "the second page has no patterns: no picture");
assert_eq!(drawn[0].0, "hexcasting:pattern");
assert!(matches!(&drawn[0].1, yog_data::Value::List(l) if l.len() == 1), "the field's whole value");
}
#[test]
fn a_mods_page_flows_its_long_text_onto_text_pages() {
use crate::page_type::{PageElement as E, PageProperty, PageType, PropKind};
let ty = PageType {
id: "m:p".into(),
properties: vec![
PageProperty { name: "patterns".into(), kind: PropKind::Data, default: None },
PageProperty { name: "text".into(), kind: PropKind::BookText, default: None },
],
body: vec![
E::Drawn { drawer: "m:d".into(), height: 64.0, data: "{x:Bind patterns}".into(), visible: None },
E::Text { text: "{x:Bind text}".into(), visible: None },
],
};
let long = "word ".repeat(120);
let page = format!(r#"{{"type":"m:p","patterns":[1],"text":"{long}"}}"#);
let mut b = book(vec![BookPage::Custom(yog_data::read_value(&yog_data::json::from_json_text(&page).unwrap()).unwrap())]);
b.page_types.push(ty);
let flowed = flow(b);
let pages = &flowed.entries[0].pages;
assert!(pages.len() >= 2, "{pages:?}");
let BookPage::Custom(first) = &pages[0] else { panic!("{:?}", pages[0]) };
let kept = first.fields.get("text").and_then(|v| v.as_str()).unwrap();
let line_h = yog_ui::text::LINE_H + yog_ui::text::LINE_GAP;
assert!(kept.lines().count() as f32 * line_h <= BODY_H - 68.0 + 1.0, "under the picture: {kept}");
let words: usize = kept.split_whitespace().count()
+ pages[1..].iter().map(|p| match p {
BookPage::Text { text, .. } => text.split_whitespace().count(),
_ => 0,
}).sum::<usize>();
assert_eq!(words, 120, "nothing lost");
}
#[test]
fn a_translated_book_is_flowed_from_its_words_not_its_keys() {
fn tr(k: &str) -> Option<String> {
(k.len() == 3000 && k.starts_with("mymod.")).then(|| "Short.".into())
}
let key = format!("mymod.{}", "x".repeat(2994));
let mut b = book(vec![BookPage::Text {
text: key,
title: None,
font: None,
font_size: None,
}]);
b.i18n = true;
let mut r = BookRenderer::new(b);
r.set_translator(tr);
r.localize();
let pages = &r.book().entries[0].pages;
assert_eq!(pages.len(), 1, "{pages:?}");
assert!(matches!(&pages[0], BookPage::Text { text, .. } if text == "Short."), "{pages:?}");
}
}