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