Skip to main content

yog_cli/design/
book.rs

1//! The `.ybook` designer's backend: the book drawn by yog-book itself —
2//! the renderer the game runs — through the recording `YogGfxApi`, and
3//! clicked through like a reader would (`docs/books.md`).
4//!
5//! Each open `.ybook` keeps its own reader (the page it's on); an edit
6//! swaps the book under it without losing the place.
7
8use yog_ids::Format;
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::sync::{LazyLock, Mutex};
12
13use base64::Engine as _;
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use yog_book::BookRenderer;
17use yog_formats::book::Book;
18
19use super::assets::Resources;
20use super::{gfx, images, raster};
21
22#[derive(Debug, Clone, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct BookArgs {
25    pub path: String,
26    /// The `.ybook` as it is in the editor.
27    pub text: String,
28    pub screen_w: u32,
29    pub screen_h: u32,
30    pub scale: u32,
31    #[serde(default)]
32    pub action: Option<BookAction>,
33    /// The language to read it in (`ru_ru`, …; none: the neutral texts).
34    #[serde(default)]
35    pub locale: Option<String>,
36}
37
38/// What the reader does before the book is drawn.
39#[derive(Debug, Clone, Deserialize)]
40#[serde(tag = "kind", rename_all = "camelCase")]
41pub enum BookAction {
42    /// A click at GUI pixels `x, y`.
43    Click { x: f32, y: f32 },
44    /// Go to an entry's page (the document's selection).
45    Show { entry: String, page: usize },
46    /// Back to the landing page.
47    Home,
48}
49
50#[derive(Debug, Serialize)]
51#[serde(rename_all = "camelCase")]
52pub struct BookRendered {
53    /// `data:image/png;base64,…` — `scale` physical px per GUI px; `None`
54    /// when the book doesn't parse.
55    pub image: Option<String>,
56    pub scale: u32,
57    /// The entry and page shown (`None`: the landing or a list page).
58    pub shown: Option<(String, usize)>,
59    /// What a click did (`"next_page"`, `"entry:2"`, …).
60    pub action: Option<String>,
61    /// The page moves by itself (an entity, a structure): draw it again.
62    pub animated: bool,
63    /// The languages the mod's texts are in (besides the neutral one).
64    pub locales: Vec<String>,
65    pub error: Option<String>,
66}
67
68struct Session {
69    renderer: BookRenderer,
70    /// The resolved book JSON it was last given.
71    json: String,
72}
73
74static SESSIONS: LazyLock<Mutex<HashMap<String, Session>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
75
76/// The mod a content file belongs to: the folder with its `yog.toml`.
77fn mod_root(path: &Path) -> Option<PathBuf> {
78    path.ancestors().find(|p| p.join("yog.toml").is_file()).map(Path::to_path_buf)
79}
80
81/// The texts a player reading in `locale` sees: `Set.name` → text (for
82/// `@Set.name`) and translation key → text (item and block names), from
83/// the mod's `resources/*.yres` — `Set.<locale>.yres` over the neutral
84/// `Set.yres` — and the game's language files under them.
85struct Texts {
86    refs: HashMap<String, String>,
87    lang: HashMap<String, String>,
88}
89
90fn texts(mod_root: Option<&Path>, res: &Resources, locale: Option<&str>) -> Texts {
91    let mut t = Texts { refs: HashMap::new(), lang: HashMap::new() };
92    // The game's (and the mod's assets') language files, the mod's own on top.
93    let lang_file = locale.unwrap_or(yog_ids::game::LOCALE);
94    let mod_id = mod_root.and_then(|r| crate::design::project::mod_id(r)).unwrap_or_default();
95    for ns in [yog_ids::ns::MINECRAFT, mod_id.as_str()] {
96        for file in [yog_ids::game::LOCALE, lang_file] {
97            if let Some(Value::Object(map)) = res.read(ns, &yog_ids::lang_file(file)).and_then(|b| serde_json::from_slice(&b).ok()) {
98                for (k, v) in map {
99                    if let Value::String(v) = v {
100                        t.lang.insert(k, v);
101                    }
102                }
103            }
104        }
105    }
106    // The book's own words, as the loader's jars ship them.
107    for file in [yog_ids::game::LOCALE, lang_file] {
108        if let Some((_, json)) = yog_book::LANG.iter().find(|(l, _)| *l == file)
109            && let Ok(map) = serde_json::from_str::<HashMap<String, String>>(json)
110        {
111            t.lang.extend(map);
112        }
113    }
114    let Some(root) = mod_root else { return t };
115    let mut files: Vec<(String, Option<String>, PathBuf)> = Vec::new();
116    for f in std::fs::read_dir(root.join("resources")).into_iter().flatten().flatten() {
117        let p = f.path();
118        let Some((set, loc)) = p.file_name().and_then(|n| n.to_str()).and_then(yog_formats::yres::split_file_name) else { continue };
119        let (set, loc) = (set.to_string(), loc.map(str::to_string));
120        if loc.is_none() || loc.as_deref() == locale {
121            files.push((set, loc, p));
122        }
123    }
124    // Neutral first, the locale's over it.
125    files.sort_by_key(|(_, loc, _)| loc.is_some());
126    for (set, _, p) in files {
127        let Some(Value::Object(map)) = std::fs::read_to_string(&p).ok().and_then(|t| serde_json::from_str(&t).ok()) else {
128            continue;
129        };
130        for (k, v) in map {
131            let (text, key) = match &v {
132                Value::String(s) => (Some(s.clone()), None),
133                Value::Object(o) if o.get("type").is_none_or(|t| t == "text") => (
134                    o.get("value").and_then(Value::as_str).map(str::to_string),
135                    o.get("key").and_then(Value::as_str).map(str::to_string),
136                ),
137                _ => (None, None),
138            };
139            let Some(text) = text else { continue };
140            // As `yog build` names its translation key.
141            let key = key.unwrap_or_else(|| match set.as_str() {
142                "Items" => format!("item.{mod_id}.{k}"),
143                "Blocks" => format!("block.{mod_id}.{k}"),
144                "Entities" => format!("entity.{mod_id}.{k}"),
145                _ => format!("{mod_id}.{k}"),
146            });
147            t.lang.insert(key, text.clone());
148            t.refs.insert(format!("{set}.{k}"), text);
149        }
150    }
151    t
152}
153
154/// The languages the mod's `resources/*.yres` are translated into.
155fn locales(mod_root: Option<&Path>) -> Vec<String> {
156    let mut out: Vec<String> = mod_root
157        .map(|r| {
158            std::fs::read_dir(r.join("resources"))
159                .into_iter()
160                .flatten()
161                .flatten()
162                .filter_map(|f| {
163                    let name = f.file_name().to_string_lossy().into_owned();
164                    yog_formats::yres::split_file_name(&name)?.1.map(str::to_string)
165                })
166                .collect()
167        })
168        .unwrap_or_default();
169    out.sort();
170    out.dedup();
171    out
172}
173
174/// The game's name of an item or block: `item.<ns>.<path>`, else `block.…`.
175fn item_name(lang: &HashMap<String, String>, id: &str) -> Option<String> {
176    [yog_ids::kind::ITEM, yog_ids::kind::BLOCK].iter().find_map(|k| lang.get(&yog_ids::lang_key(k, id)).cloned())
177}
178
179/// What the book's renderer looks names up in (it takes a plain `fn`).
180static LANG: LazyLock<Mutex<HashMap<String, String>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
181
182fn translate(key: &str) -> Option<String> {
183    LANG.lock().ok()?.get(key).cloned()
184}
185
186/// The mod's recipes, as the game gets them: `content/**/*.yrecipe` in
187/// Yog's recipe format — (id, JSON).
188fn recipes(mod_root: &Path) -> Vec<(String, yog_data::Value)> {
189    let mod_id = crate::design::project::mod_id(mod_root).unwrap_or_default();
190    let mut out = Vec::new();
191    let mut stack = vec![mod_root.join("content")];
192    while let Some(dir) = stack.pop() {
193        for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
194            let p = e.path();
195            if p.is_dir() {
196                stack.push(p);
197                continue;
198            }
199            if !Format::Recipe.is(&p) {
200                continue;
201            }
202            let Some(mut v) = std::fs::read_to_string(&p).ok().and_then(|t| crate::design::data::read_object(&t)).map(Value::Object) else {
203                continue;
204            };
205            crate::design::data::runtime_ids(&mut v);
206            let stem = p.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
207            let id = match v.get("id").and_then(Value::as_str) {
208                Some(id) if id.contains(':') => id.to_string(),
209                Some(id) => format!("{mod_id}:{id}"),
210                None => format!("{mod_id}:{stem}"),
211            };
212            out.push((id, crate::design::data::from_json(&v)));
213        }
214    }
215    out
216}
217
218/// `minecraft:amethyst_dust` → `Amethyst Dust`.
219fn humanize(id: &str) -> String {
220    let path = id.rsplit([':', '/']).next().unwrap_or(id);
221    path.split('_')
222        .filter(|w| !w.is_empty())
223        .map(|w| {
224            let mut c = w.chars();
225            c.next().map(|f| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or_default()
226        })
227        .collect::<Vec<_>>()
228        .join(" ")
229}
230
231/// The book as a player reads it: references (`@item:…` in id fields)
232/// as plain ids, texts as their words (`@Book.title`) and names
233/// (`@item:…`). Whether they exist is `yog check`'s to say; the designer
234/// draws what it can.
235struct Reader<'a> {
236    texts: &'a Texts,
237    missing: Vec<String>,
238}
239
240impl yog_data::Resolve for Reader<'_> {
241    fn text(&mut self, t: &yog_data::Text, _: &yog_data::Path) -> Result<String, String> {
242        Ok(match t {
243            yog_data::Text::Literal(s) => s.clone(),
244            yog_data::Text::NameOf(r) => {
245                let id = yog_formats::book::refs::bare(&r.id);
246                let name = match r.kind.as_str() {
247                    yog_ids::kind::ENTITY => self.texts.lang.get(&yog_ids::lang_key(yog_ids::kind::ENTITY, id)).cloned(),
248                    _ => item_name(&self.texts.lang, id),
249                };
250                name.unwrap_or_else(|| humanize(id))
251            }
252            yog_data::Text::Lang(k) => self.texts.lang.get(k).cloned().unwrap_or_else(|| k.clone()),
253            yog_data::Text::Res(r) => {
254                let key = format!("{}.{}", r.set, r.key);
255                self.texts.refs.get(&key).cloned().unwrap_or_else(|| {
256                    self.missing.push(format!("@{key}"));
257                    format!("@{key}")
258                })
259            }
260        })
261    }
262
263    fn words(&mut self, r: &yog_data::Res) -> Option<String> {
264        self.texts.refs.get(&format!("{}.{}", r.set, r.key)).cloned()
265    }
266}
267
268/// `v` without what the problem at `path` is about, so the rest can be
269/// drawn: the page it's in, else the field (or an empty one if it's missing).
270fn repair(v: &mut yog_data::Value, path: &yog_data::Path, message: &str) -> bool {
271    use yog_data::{Seg, Value};
272    let segs = path.segments();
273    // A missing field: an empty one, in the object the problem is about.
274    if let Some(field) = message.strip_prefix("missing `").and_then(|r| r.split('`').next()) {
275        let mut at = &mut *v;
276        for seg in segs {
277            at = match (seg, at) {
278                (Seg::Key(k), Value::Map(m)) => match m.get_mut(k) {
279                    Some(x) => x,
280                    None => return false,
281                },
282                (Seg::Index(n), Value::List(l)) => match l.get_mut(*n) {
283                    Some(x) => x,
284                    None => return false,
285                },
286                _ => return false,
287            };
288        }
289        if let Value::Map(m) = at
290            && m.get(field).is_none()
291        {
292            m.insert(field.to_string(), Value::from(""));
293            return true;
294        }
295    }
296    if let Some(i) = segs.iter().position(|s| matches!(s, Seg::Key(k) if k == "pages"))
297        && let Some(Seg::Index(page)) = segs.get(i + 1)
298    {
299        let mut at = &mut *v;
300        for seg in &segs[..=i] {
301            at = match (seg, at) {
302                (Seg::Key(k), Value::Map(m)) => match m.get_mut(k) {
303                    Some(x) => x,
304                    None => return false,
305                },
306                (Seg::Index(n), Value::List(l)) => match l.get_mut(*n) {
307                    Some(x) => x,
308                    None => return false,
309                },
310                _ => return false,
311            };
312        }
313        if let Value::List(pages) = at
314            && *page < pages.len()
315        {
316            pages.remove(*page);
317            return true;
318        }
319        return false;
320    }
321    let mut at = &mut *v;
322    let (last, parents) = match segs.split_last() {
323        Some(x) => x,
324        None => return false,
325    };
326    for seg in parents {
327        at = match (seg, at) {
328            (Seg::Key(k), Value::Map(m)) => match m.get_mut(k) {
329                Some(x) => x,
330                None => return false,
331            },
332            (Seg::Index(n), Value::List(l)) => match l.get_mut(*n) {
333                Some(x) => x,
334                None => return false,
335            },
336            _ => return false,
337        };
338    }
339    match (last, at) {
340        (Seg::Key(k), Value::Map(m)) => m.remove(k).is_some(),
341        (Seg::Index(n), Value::List(l)) if *n < l.len() => {
342            l.remove(*n);
343            true
344        }
345        _ => false,
346    }
347}
348
349/// The `.ybook` text → the book a player would see, and the first problem
350/// in it (what's wrong is left out of the drawing).
351pub fn parse(text: &str, mod_root: Option<&Path>, locale: Option<&str>) -> Result<(Book, Option<String>), String> {
352    let doc = yog_data::text::parse(text).map_err(|e| format!("{}: {}", e.at, e.message))?;
353    let res = Resources::for_mod(mod_root);
354    let texts = texts(mod_root, &res, locale);
355    let mut value = doc.value.clone();
356    let mut first: Option<String> = None;
357    let mut reader = Reader { texts: &texts, missing: Vec::new() };
358    let mut book = None;
359    for _ in 0..32 {
360        match yog_data::from_value_with::<Book>(&value, &mut reader) {
361            Ok(read) => {
362                book = Some(read.value);
363                break;
364            }
365            Err(problems) => {
366                if first.is_none()
367                    && let Some(p) = problems.first()
368                {
369                    let at = doc.locate(text, &p.path);
370                    first = Some(if p.path.is_root() { format!("{at}: {}", p.message) } else { format!("{at}: {}: {}", p.path, p.message) });
371                }
372                // Missing fields of the book itself are filled in first, then the rest repaired.
373                let mut changed = false;
374                for p in &problems {
375                    changed |= repair(&mut value, &p.path, &p.message);
376                }
377                if !changed {
378                    break;
379                }
380            }
381        }
382    }
383    let mut book = book.ok_or_else(|| first.clone().unwrap_or_else(|| "not a book".into()))?;
384    // The reader shows texts, not keys.
385    book.i18n = false;
386    // The mod's own recipe layouts and kinds of page, as the build adds them.
387    book.layouts = mod_root.map(|r| crate::design::mod_ui::recipe_layouts(r).into_iter().map(|(_, l)| l).collect()).unwrap_or_default();
388    book.page_types = mod_root.map(crate::design::mod_ui::page_types).unwrap_or_default();
389    let first = first.or_else(|| reader.missing.first().map(|m| format!("{m}: no such text in resources/*.yres")));
390    if let Ok(mut lang) = LANG.lock() {
391        *lang = texts.lang;
392    }
393    Ok((book, first))
394}
395
396pub fn render(args: &BookArgs) -> BookRendered {
397    let root = mod_root(Path::new(&args.path));
398    let locales = locales(root.as_deref());
399    let locale = args.locale.as_deref().filter(|l| !l.is_empty());
400    let fail = |error: String| BookRendered {
401        image: None,
402        scale: args.scale,
403        shown: None,
404        action: None,
405        animated: false,
406        locales: locales.clone(),
407        error: Some(error),
408    };
409    let (book, problem) = match parse(&args.text, root.as_deref(), locale) {
410        Ok(b) => b,
411        Err(e) => return fail(e),
412    };
413    render_book(args, book, problem, root.as_deref(), locales)
414}
415
416/// Draws `book` (already read) for `args`: its session's reader, the action.
417/// The mod whose book is being drawn: where its tags come from.
418static BOOK_MOD: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
419
420/// An item tag's items for the book being drawn (`yog_book::set_tag_items`).
421fn book_tag_items(tag: &str) -> Vec<String> {
422    let root = BOOK_MOD.lock().ok().and_then(|r| r.clone());
423    root.map(|r| super::tags::item_tag(&r.join(yog_ids::file::MANIFEST), tag)).unwrap_or_default()
424}
425
426fn render_book(args: &BookArgs, book: yog_formats::book::Book, problem: Option<String>, root: Option<&Path>, locales: Vec<String>) -> BookRendered {
427    yog_book::set_tag_items(book_tag_items);
428    if let Ok(mut m) = BOOK_MOD.lock() {
429        *m = root.map(Path::to_path_buf);
430    }
431    let (sw, sh) = (args.screen_w.clamp(1, 4096), args.screen_h.clamp(1, 4096));
432    let fail = |error: String| BookRendered {
433        image: None,
434        scale: args.scale,
435        shown: None,
436        action: None,
437        animated: false,
438        locales: locales.clone(),
439        error: Some(error),
440    };
441    let json = serde_json::to_string(&book).unwrap_or_default();
442
443    let mut sessions = SESSIONS.lock().unwrap_or_else(|e| e.into_inner());
444    let session = sessions.entry(args.path.clone()).or_insert_with(|| {
445        let mut renderer = BookRenderer::new(book.clone());
446        renderer.set_translator(translate);
447        for (id, recipe) in root.map(recipes).unwrap_or_default() {
448            renderer.add_recipe(id, &recipe);
449        }
450        Session { renderer, json: json.clone() }
451    });
452    if session.json != json {
453        session.renderer.set_book(book);
454        session.json = json;
455    }
456    let r = &mut session.renderer;
457
458    // Lay out first: a click hits what was last drawn at this size.
459    gfx::record(sw as i32, sh as i32, |ctx| r.render(ctx, sw as f32, sh as f32));
460    let action = match &args.action {
461        Some(BookAction::Click { x, y }) => r.click(*x, *y),
462        Some(BookAction::Show { entry, page }) => {
463            r.show(entry, *page);
464            None
465        }
466        Some(BookAction::Home) => {
467            r.handle_event("home");
468            None
469        }
470        None => None,
471    };
472
473    images::set_document(root);
474    let ops = gfx::record(sw as i32, sh as i32, |ctx| r.render(ctx, sw as f32, sh as f32));
475    let res = Resources::for_mod(root);
476    let scale = raster::fit_scale(sw, sh, args.scale);
477    let canvas = raster::rasterize(&ops, sw, sh, scale, &res);
478    match canvas.png() {
479        Ok(png) => BookRendered {
480            image: Some(format!("data:image/png;base64,{}", base64::engine::general_purpose::STANDARD.encode(png))),
481            scale,
482            shown: r.shown(),
483            action,
484            animated: r.animated(),
485            locales,
486            error: problem,
487        },
488        Err(e) => fail(e),
489    }
490}
491
492/// What a page of a mod's kind shows when the designer has no page to read:
493/// a property's default, else a sample of its kind.
494fn sample(p: &yog_formats::book::page_type::PageProperty) -> serde_json::Value {
495    use yog_formats::book::page_type::PropKind;
496    if let Some(d) = &p.default {
497        return yog_data::json::to_json(d);
498    }
499    match p.kind {
500        PropKind::Text => serde_json::Value::String(format!("‹{}›", p.name)),
501        PropKind::BookText => serde_json::Value::String(format!("‹{}› — the page's text, as a book wraps it.", p.name)),
502        PropKind::Number => serde_json::json!(1),
503        PropKind::Bool => serde_json::json!(true),
504        PropKind::Item => serde_json::json!(yog_ids::game::BOOK),
505        PropKind::Image => serde_json::json!(yog_ids::join(yog_ids::image_scheme::ITEM, yog_ids::game::BOOK)),
506        PropKind::Data => serde_json::Value::Null,
507    }
508}
509
510/// A `.yui` book page (`<Component book_page="…">`) drawn as the book shows
511/// it: one entry of one page of the kind, its fields defaults or samples,
512/// the kind as it is in the editor (not as saved).
513pub fn page_preview(args: &BookArgs) -> BookRendered {
514    let root = mod_root(Path::new(&args.path));
515    let locales = locales(root.as_deref());
516    let fail = |error: String| BookRendered { image: None, scale: args.scale, shown: None, action: None, animated: false, locales: locales.clone(), error: Some(error) };
517    let ns = root.as_deref().map(crate::design::mod_ui::ns).unwrap_or_else(|| yog_ids::ns::MINECRAFT.into());
518    let kind = match yog_formats::yui::parse(&args.text).and_then(|el| yog_formats::book::page_type::compile(&el, &ns)) {
519        Ok(k) => k,
520        Err(e) => return fail(e),
521    };
522    let mut page = serde_json::Map::new();
523    page.insert("type".into(), serde_json::Value::String(kind.id.clone()));
524    for p in &kind.properties {
525        let v = sample(p);
526        if !v.is_null() {
527            page.insert(p.name.clone(), v);
528        }
529    }
530    let entry = format!("{ns}:page_preview");
531    let text = serde_json::json!({
532        "id": format!("{ns}:page_preview"),
533        "name": kind.id,
534        "categories": [{ "id": entry, "name": kind.id }],
535        "entries": [{ "id": entry, "name": kind.id, "category": format!("@category:{entry}"), "pages": [page] }],
536    })
537    .to_string();
538    let locale = args.locale.as_deref().filter(|l| !l.is_empty());
539    let (mut book, problem) = match parse(&text, root.as_deref(), locale) {
540        Ok(b) => b,
541        Err(e) => return fail(e),
542    };
543    book.page_types.retain(|t| t.id != kind.id);
544    book.page_types.push(kind);
545    let args = BookArgs { action: Some(BookAction::Show { entry, page: 0 }), ..args.clone() };
546    render_book(&args, book, problem, root.as_deref(), locales)
547}
548
549/// Forgets a closed `.ybook`'s reader.
550pub fn close(path: &str) {
551    SESSIONS.lock().unwrap_or_else(|e| e.into_inner()).remove(path);
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557
558    #[test]
559    fn resolves_references_to_their_texts() {
560        let mut texts = Texts { refs: HashMap::new(), lang: HashMap::new() };
561        texts.refs.insert("Book.title".to_string(), "Guide".to_string());
562        texts.lang.insert("item.mymod.sapphire".to_string(), "Сапфир".to_string());
563        let v = yog_data::text::parse_value(
564            r#"{
565            name: "@Book.title", subtitle: "@item:mymod:ruby_block", landing_text: "@@x",
566            categories: [{ id: "m:c", name: "@item:mymod:sapphire", icon: "@item:mymod:sapphire" }],
567            entries: [{ id: "m:e", name: "@Book.nope", category: "@category:m:c", pages: [] }],
568            id: "m:b",
569        }"#,
570        )
571        .unwrap();
572        let mut r = Reader { texts: &texts, missing: Vec::new() };
573        let b = yog_data::from_value_with::<Book>(&v, &mut r).unwrap_or_else(|p| panic!("{p:?}")).value;
574        assert_eq!(b.name, "Guide");
575        assert_eq!(b.subtitle.as_deref(), Some("Ruby Block"), "no translation: made from the id");
576        assert_eq!(b.categories[0].name, "Сапфир", "a name in a text");
577        assert_eq!(b.categories[0].icon.as_deref(), Some("mymod:sapphire"), "an id in an id field");
578        assert_eq!(b.entries[0].category, "m:c");
579        assert_eq!(b.landing_text, "@x");
580        assert_eq!(r.missing, ["@Book.nope"]);
581    }
582
583    #[test]
584    fn draws_what_it_can_of_a_broken_book() {
585        let text = r#"{ id: "t:guide", name: "Guide",
586            categories: [{ id: "t:c", name: "C" }],
587            entries: [{ id: "t:e", category: "@category:t:c", pages: [
588                { type: "spotlight", item: "" },
589                { type: "text", text: "kept" },
590            ] }] }"#;
591        let (book, problem) = parse(text, None, None).unwrap();
592        let problem = problem.unwrap();
593        assert!(problem.starts_with("3:23: entries[0]: missing `name`"), "{problem}");
594        assert_eq!(book.entries[0].pages.len(), 1, "the broken page is left out");
595    }
596
597    #[test]
598    fn draws_and_clicks_through_a_book() {
599        let text = r#"{ "id": "t:guide", "name": "Guide",
600            "categories": [{ "id": "t:c", "name": "Basics" }],
601            "entries": [{ "id": "t:e", "name": "First", "category": "@category:t:c",
602                "pages": [{ "type": "text", "text": "one" }, { "type": "text", "text": "two" }] }] }"#;
603        let args = |action| BookArgs { path: "/tmp/test.ybook".into(), text: text.into(), screen_w: 400, screen_h: 240, scale: 1, action, locale: None };
604        let r = render(&args(None));
605        assert!(r.error.is_none() && r.image.is_some(), "{:?}", r.error);
606        assert_eq!(r.shown, None);
607        let r = render(&args(Some(BookAction::Show { entry: "t:e".into(), page: 1 })));
608        assert_eq!(r.shown, Some(("t:e".into(), 1)));
609        let r = render(&args(Some(BookAction::Home)));
610        assert_eq!(r.shown, None);
611        close("/tmp/test.ybook");
612    }
613
614    #[test]
615    fn a_book_page_component_is_drawn_as_the_book_shows_it() {
616        let dir = std::env::temp_dir().join(format!("yog-idle-page-preview-{}", std::process::id()));
617        let _ = std::fs::remove_dir_all(&dir);
618        std::fs::create_dir_all(dir.join("src/ui")).unwrap();
619        std::fs::write(dir.join("yog.toml"), "[mod]\nid = \"mymod\"\n").unwrap();
620        let yui = r#"<Component name="Note" book_page="note">
621            <Property name="title" type="text"/>
622            <Property name="body" type="book_text" default="Hello"/>
623            <BookHeader text="{x:Bind title}"/>
624            <BookText text="{x:Bind body}"/>
625            <Drawn drawer="mymod:thing" h="32" data="{x:Bind title}"/>
626        </Component>"#;
627        let args = BookArgs {
628            path: dir.join("src/ui/note.yui").to_string_lossy().into_owned(),
629            text: yui.into(),
630            screen_w: 480,
631            screen_h: 270,
632            scale: 1,
633            action: None,
634            locale: None,
635        };
636        let r = page_preview(&args);
637        assert!(r.error.is_none(), "{:?}", r.error);
638        assert!(r.image.is_some());
639        assert_eq!(r.shown, Some(("mymod:page_preview".to_string(), 0)));
640        let _ = std::fs::remove_dir_all(&dir);
641    }
642
643}