Skip to main content

yog_cli/design/
mod.rs

1//! The `.yui` screen designer's backend: the canvas drawn by yog-ui itself
2//! (the crate mods render with in game) through a recording `YogGfxApi`,
3//! rasterized with Minecraft's own font and textures from the local game
4//! files; a preview mode driving the same tree with real UI events
5//! (`click:X:Y`, `scroll:DY`, scrollbar drags); and code-behind handlers.
6
7pub(crate) mod advancements;
8pub(crate) mod content;
9pub(crate) mod data;
10pub(crate) mod mod_ui;
11pub(crate) mod project;
12pub(crate) mod recipe_kinds;
13pub(crate) mod refs;
14pub(crate) mod service;
15pub(crate) mod worldgen;
16pub(crate) mod yui_codegen;
17
18/// This CLI as a command, for a design call that runs one of its
19/// subcommands: the running binary when it's `yog`, else the `yog` on `PATH`.
20pub(crate) fn this_cli() -> std::process::Command {
21    let exe = std::env::current_exe()
22        .ok()
23        .filter(|e| e.file_stem().is_some_and(|s| s == yog_ids::program::CLI));
24    std::process::Command::new(exe.unwrap_or_else(|| yog_ids::program::CLI.into()))
25}
26pub(crate) mod assets;
27pub(crate) mod blocks3d;
28pub(crate) mod book;
29pub(crate) mod codebehind;
30pub(crate) mod components;
31pub(crate) mod custom;
32pub(crate) mod font;
33pub(crate) mod font_copy;
34mod font_yog;
35pub(crate) mod gfx;
36pub mod host;
37pub(crate) mod images;
38pub(crate) mod item_models;
39pub(crate) mod markup;
40pub(crate) mod raster;
41pub(crate) mod recipe_area;
42pub(crate) mod tags;
43
44use std::collections::HashMap;
45use std::path::{Path, PathBuf};
46use yog_sync::{Mutex, OnceLock};
47
48use base64::Engine as _;
49use yog_gfx::Vec2;
50use yog_ui::layout::LayoutNode;
51use yog_ui::{ScrollState, UiRoot, Widget};
52
53use self::assets::Resources;
54use self::markup::YuiNode;
55
56/// A laid-out widget: its border box and its content box (inside padding), GUI px.
57#[derive(Debug, Clone, yog_data::Schema)]
58pub(crate) struct NodeRect {
59    pub(crate) uid: String,
60    pub(crate) rect: [f32; 4],
61    pub(crate) content: [f32; 4],
62    /// A grid's rows and columns, `[start, size]` in GUI px (for its adorners).
63    pub(crate) grid: Option<GridRects>,
64}
65
66#[derive(Debug, Clone, yog_data::Schema)]
67pub(crate) struct GridRects {
68    pub(crate) rows: Vec<[f32; 2]>,
69    pub(crate) cols: Vec<[f32; 2]>,
70}
71
72/// Where the game resources came from (`None`: no client jar found).
73#[derive(Debug, Clone, yog_data::Schema)]
74#[data(rename_all = "camelCase")]
75pub(crate) struct AssetsInfo {
76    pub(crate) version: String,
77    pub(crate) jar: String,
78}
79
80#[derive(Debug, yog_data::Schema)]
81#[data(rename_all = "camelCase")]
82/// A screen drawn by [`render`]: its picture and where each element went.
83pub struct Rendered {
84    /// `data:image/png;base64,…` — `scale` physical px per GUI px.
85    pub(crate) image: String,
86    pub(crate) scale: u32,
87    pub(crate) rects: Vec<NodeRect>,
88    pub(crate) assets: Option<AssetsInfo>,
89    /// Text uses the game's font (else a stand-in glyph box).
90    pub(crate) real_font: bool,
91}
92
93/// The mod a file belongs to: the folder with its `yog.toml` (else the
94/// folder holding the `src/` it's in).
95pub(crate) fn mod_root(file: &Path) -> Option<PathBuf> {
96    project::mod_root(file).or_else(|| {
97        file.ancestors()
98            .find(|p| p.file_name().is_some_and(|n| n == "src"))
99            .and_then(|p| p.parent())
100            .map(Path::to_path_buf)
101    })
102}
103
104/// Built and laid out like the generated `root_with`.
105type Laid = UiRoot;
106
107fn lay_out(root: &YuiNode, screen_w: f32, screen_h: f32, scroll: &HashMap<String, f32>) -> Laid {
108    let mut ui = UiRoot::new(
109        "yog-idle:designer",
110        markup::screen(root, screen_w, screen_h, scroll),
111    );
112    // Text widths and image sizes come from the "host" — the designer's own
113    // font and image resolution — as they do from the game in `UiRoot::render`.
114    let table = gfx::api(screen_w as i32, screen_h as i32, 1.0);
115    // SAFETY: every function in `table` is sound to call; only fn pointers are kept.
116    let ctx = unsafe { yog_gfx::GfxContext::from_raw(&table) };
117    yog_ui::text::use_host_metrics(&ctx);
118    yog_ui::image::use_host_images(&ctx);
119    ui.layout(screen_w, screen_h);
120    ui
121}
122
123/// Pairs each `.yui` node with its widget and layout node (the screen's
124/// wrapper skipped; a `TextBox`'s inner parts aren't elements).
125fn walk<'a>(
126    node: &'a YuiNode,
127    w: &'a Widget,
128    l: &'a LayoutNode,
129    visit: &mut impl FnMut(&'a YuiNode, &'a Widget, &'a LayoutNode),
130) {
131    visit(node, w, l);
132    if !markup::is_container(&node.kind) {
133        return;
134    }
135    for (i, child) in markup::widget_children(node).enumerate() {
136        if let (Some(cw), Some(cl)) = (w.children.get(i), l.children.get(i)) {
137            walk(child, cw, cl, visit);
138        }
139    }
140}
141
142fn with_content<'a>(
143    root: &'a YuiNode,
144    laid: &'a Laid,
145    mut visit: impl FnMut(&'a YuiNode, &'a Widget, &'a LayoutNode),
146) {
147    if let (Some(w), Some(l)) = (
148        laid.root.children.first(),
149        laid.layout_root.children.first(),
150    ) {
151        walk(root, w, l, &mut visit);
152    }
153}
154
155fn rects(root: &YuiNode, laid: &Laid) -> Vec<NodeRect> {
156    let mut out = Vec::new();
157    with_content(root, laid, |n, w, l| {
158        let r = l.rect;
159        let p = w.style.pad;
160        out.push(NodeRect {
161            uid: n.uid.clone(),
162            rect: [r.x, r.y, r.w, r.h],
163            content: [
164                r.x + p[3],
165                r.y + p[0],
166                (r.w - p[1] - p[3]).max(0.0),
167                (r.h - p[0] - p[2]).max(0.0),
168            ],
169            grid: l.grid.as_ref().map(|g| GridRects {
170                rows: g.rows.iter().map(|&(a, b)| [a, b]).collect(),
171                cols: g.cols.iter().map(|&(a, b)| [a, b]).collect(),
172            }),
173        });
174    });
175    out
176}
177
178/// Per-file preview state: the scroll views' offsets/drags.
179#[derive(Default)]
180struct Preview {
181    scroll: HashMap<String, ScrollState>,
182}
183
184fn previews() -> &'static Mutex<HashMap<String, Preview>> {
185    static P: OnceLock<Mutex<HashMap<String, Preview>>> = OnceLock::new();
186    P.get_or_init(Default::default)
187}
188
189fn offsets(path: &str, preview: bool) -> HashMap<String, f32> {
190    if !preview {
191        return HashMap::new();
192    }
193    let p = previews().lock();
194    p.get(path)
195        .map(|s| {
196            s.scroll
197                .iter()
198                .map(|(k, v)| (k.clone(), v.offset))
199                .collect()
200        })
201        .unwrap_or_default()
202}
203
204#[derive(Debug, yog_data::Schema)]
205#[data(rename_all = "camelCase")]
206/// What [`render`] draws: a `.yui` screen's tree at a screen size and scale.
207pub struct RenderArgs {
208    pub(crate) path: String,
209    pub(crate) root: YuiNode,
210    pub(crate) screen_w: u32,
211    pub(crate) screen_h: u32,
212    pub(crate) scale: u32,
213    #[data(default)]
214    pub(crate) preview: bool,
215}
216
217/// Draws a screen as the game would: its picture and its elements' boxes.
218pub fn render(args: &RenderArgs) -> Result<Rendered, String> {
219    let (sw, sh) = (args.screen_w.clamp(1, 4096), args.screen_h.clamp(1, 4096));
220    let scroll = offsets(&args.path, args.preview);
221    let root = mod_root(Path::new(&args.path));
222    images::set_document(root.as_deref());
223    // The mod's own components: expanded from markup, or marked spacers here drawn by the designer host below.
224    let mut themed = args.root.clone();
225    markup::apply_resources(&mut themed);
226    markup::apply_theme(&mut themed);
227    recipe_area::expand(&mut themed, root.as_deref());
228    let prepared = custom::prepare(&themed, root.as_deref(), sw as f32, sh as f32);
229    let laid = lay_out(&prepared.root, sw as f32, sh as f32, &scroll);
230    let mut ops = gfx::record(sw as i32, sh as i32, |ctx| laid.render(ctx));
231    let mut node_rects = rects(&prepared.root, &laid);
232    if !prepared.is_empty() {
233        custom::splice(
234            &mut ops,
235            &prepared,
236            &node_rects.iter().map(|r| (r.uid.clone(), r.rect)).collect(),
237        );
238    }
239    // The inside of an expanded component instance isn't the document's.
240    node_rects.retain(|r| !r.uid.contains(components::INNER));
241    let res = Resources::for_mod(root.as_deref());
242    let scale = raster::fit_scale(sw, sh, args.scale);
243    let canvas = raster::rasterize(&ops, sw, sh, scale, &res);
244    let real_font = res.vanilla.is_some() || canvas_font_real(&res);
245    let png = canvas.png()?;
246    Ok(Rendered {
247        image: format!(
248            "data:image/png;base64,{}",
249            base64::engine::general_purpose::STANDARD.encode(png)
250        ),
251        scale,
252        rects: node_rects,
253        assets: res.vanilla.as_ref().map(|v| AssetsInfo {
254            version: v.version.clone(),
255            jar: v.jar.to_string_lossy().into_owned(),
256        }),
257        real_font,
258    })
259}
260
261fn canvas_font_real(res: &Resources) -> bool {
262    font::Font::load(res, yog_ids::yog::DEFAULT_FONT).is_real()
263}
264
265#[derive(Debug, yog_data::Schema)]
266#[data(rename_all = "camelCase")]
267pub(crate) struct EventArgs {
268    pub(crate) path: String,
269    pub(crate) root: YuiNode,
270    pub(crate) screen_w: u32,
271    pub(crate) screen_h: u32,
272    /// A Yog UI event: `click:X:Y`, `drag:X:Y`, `release:X:Y`, `scroll:DY`, `move:X:Y` (hover only).
273    pub(crate) event: String,
274    /// The cursor, for the wheel (which carries no position).
275    #[data(default)]
276    pub(crate) x: f32,
277    #[data(default)]
278    pub(crate) y: f32,
279}
280
281#[derive(Debug, Default, yog_data::Schema)]
282#[data(rename_all = "camelCase")]
283pub(crate) struct EventResult {
284    /// A scroll view used the event (wheel, scrollbar).
285    pub(crate) scrolled: bool,
286    /// The clickable element under the cursor (`hit_test`: deepest enabled with `on_click`).
287    pub(crate) hit: Option<String>,
288    /// Its `on_click`, for a click.
289    pub(crate) clicked: Option<String>,
290}
291
292/// `hit_test` (yog-ui `layout::hit_test`), answering with the `.yui` uid.
293fn hit<'a>(node: &'a YuiNode, l: &'a LayoutNode, mx: f32, my: f32) -> Option<&'a YuiNode> {
294    if !l.rect.contains(Vec2::new(mx, my)) {
295        return None;
296    }
297    if markup::is_container(&node.kind) {
298        // Laid out from the widget children only — property elements have no layout node.
299        let children: Vec<_> = markup::widget_children(node).zip(&l.children).collect();
300        for (child, cl) in children.into_iter().rev() {
301            if let Some(found) = hit(child, cl, mx, my) {
302                return Some(found);
303            }
304        }
305    }
306    (l.on_click.is_some() && l.enabled).then_some(node)
307}
308
309fn xy(rest: &str) -> Option<(f32, f32)> {
310    let mut it = rest.split(':');
311    Some((it.next()?.parse().ok()?, it.next()?.parse().ok()?))
312}
313
314pub(crate) fn event(args: &EventArgs) -> EventResult {
315    let (sw, sh) = (
316        args.screen_w.clamp(1, 4096) as f32,
317        args.screen_h.clamp(1, 4096) as f32,
318    );
319    let scroll = offsets(&args.path, true);
320    let mod_root = mod_root(Path::new(&args.path));
321    // Laid out like `render` does: images size by the same mod's resources.
322    images::set_document(mod_root.as_deref());
323    let mut themed = args.root.clone();
324    markup::apply_resources(&mut themed);
325    markup::apply_theme(&mut themed);
326    recipe_area::expand(&mut themed, mod_root.as_deref());
327    let prepared = custom::prepare(&themed, mod_root.as_deref(), sw, sh);
328    let root = &prepared.root;
329    let laid = lay_out(root, sw, sh, &scroll);
330    let mut out = EventResult::default();
331    let pos = args
332        .event
333        .split_once(':')
334        .and_then(|(_, rest)| xy(rest))
335        .unwrap_or((args.x, args.y));
336
337    // Scroll views first, as a screen's code-behind does (`ScrollState::handle`).
338    if !args.event.starts_with("move:") {
339        let mut views: Vec<(&YuiNode, &LayoutNode)> = Vec::new();
340        with_content(root, &laid, |n, _, l| {
341            if n.kind == yog_ids::yui::SCROLL_VIEW {
342                views.push((n, l));
343            }
344        });
345        let mut all = previews().lock();
346        let state = all.entry(args.path.clone()).or_default();
347        let wheel = args.event.starts_with("scroll:");
348        // The wheel goes to the innermost view under the cursor; the rest to every view (each checks its own scrollbar).
349        for (n, l) in views.iter().rev() {
350            if wheel && !l.rect.contains(Vec2::new(pos.0, pos.1)) {
351                continue;
352            }
353            let s = state.scroll.entry(n.uid.clone()).or_default();
354            if s.handle(&args.event, l, yog_ui::scroll::WHEEL_STEP) {
355                out.scrolled = true;
356                break;
357            }
358        }
359    }
360    if let Some(l) = laid.layout_root.children.first() {
361        let found = hit(root, l, pos.0, pos.1);
362        // Inside a component instance: the instance.
363        out.hit = found.map(|n| {
364            n.uid
365                .split(components::INNER)
366                .next()
367                .unwrap_or_default()
368                .to_string()
369        });
370        if args.event.starts_with("click:") && !out.scrolled {
371            out.clicked = found
372                .and_then(|n| n.attr(yog_ids::yui_attr::ON_CLICK))
373                .map(str::to_string);
374        }
375    }
376    out
377}
378
379/// Forgets a file's preview state (scroll positions).
380pub(crate) fn reset(path: &str) {
381    previews().lock().remove(path);
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use crate::design::raster::DrawOp;
388
389    fn node(uid: &str, kind: &str, attrs: &[(&str, &str)], children: Vec<YuiNode>) -> YuiNode {
390        YuiNode {
391            uid: uid.into(),
392            kind: kind.into(),
393            attrs: attrs
394                .iter()
395                .map(|(k, v)| (k.to_string(), v.to_string()))
396                .collect(),
397            children,
398        }
399    }
400
401    fn screen() -> YuiNode {
402        node(
403            "s",
404            "Screen",
405            &[
406                ("id", "m:s"),
407                ("width", "100"),
408                ("height", "60"),
409                ("bg", "#FF101010"),
410            ],
411            vec![
412                node(
413                    "b",
414                    "Button",
415                    &[
416                        ("x", "10"),
417                        ("y", "10"),
418                        ("w", "40"),
419                        ("h", "20"),
420                        ("text", "Play"),
421                        ("on_click", "play"),
422                    ],
423                    vec![],
424                ),
425                node(
426                    "sv",
427                    "ScrollView",
428                    &[("x", "60"), ("y", "0"), ("w", "30"), ("h", "30")],
429                    (0..10)
430                        .map(|i| {
431                            node(
432                                &format!("r{i}"),
433                                "Spacer",
434                                &[("w", "20"), ("h", "10")],
435                                vec![],
436                            )
437                        })
438                        .collect(),
439                ),
440            ],
441        )
442    }
443
444    #[test]
445    fn rects_come_from_yog_ui_layout() {
446        let laid = lay_out(&screen(), 200.0, 100.0, &HashMap::new());
447        let r = rects(&screen(), &laid);
448        // Centered: ((200 - 100) / 2, (100 - 60) / 2).
449        assert_eq!(r[0].uid, "s");
450        assert_eq!(r[0].rect, [50.0, 20.0, 100.0, 60.0]);
451        let b = r.iter().find(|n| n.uid == "b").unwrap();
452        assert_eq!(b.rect, [60.0, 30.0, 40.0, 20.0]);
453    }
454
455    #[test]
456    fn grids_place_children_in_cells() {
457        let defs = node(
458            "d",
459            "Grid.ColumnDefinitions",
460            &[],
461            vec![
462                node("c0", "ColumnDefinition", &[("width", "30")], vec![]),
463                node("c1", "ColumnDefinition", &[], vec![]),
464            ],
465        );
466        let root = node(
467            "s",
468            "Screen",
469            &[("width", "100"), ("height", "60")],
470            vec![node(
471                "g",
472                "Grid",
473                &[
474                    ("w", "100"),
475                    ("h", "60"),
476                    ("rows", "Auto,*"),
477                    ("column_spacing", "10"),
478                ],
479                vec![
480                    defs,
481                    node(
482                        "a",
483                        "Spacer",
484                        &[("h", "12"), ("Grid.ColumnSpan", "2")],
485                        vec![],
486                    ),
487                    node(
488                        "b",
489                        "Spacer",
490                        &[("Grid.Row", "1"), ("Grid.Column", "1"), ("margin", "2")],
491                        vec![],
492                    ),
493                ],
494            )],
495        );
496        let laid = lay_out(&root, 100.0, 60.0, &HashMap::new());
497        let r = rects(&root, &laid);
498        let at = |uid: &str| r.iter().find(|n| n.uid == uid).unwrap();
499        let g = at("g").grid.as_ref().unwrap();
500        assert_eq!(g.rows, [[0.0, 12.0], [12.0, 48.0]]);
501        assert_eq!(g.cols, [[0.0, 30.0], [40.0, 60.0]]);
502        assert_eq!(at("a").rect, [0.0, 0.0, 100.0, 12.0]);
503        assert_eq!(at("b").rect, [42.0, 14.0, 56.0, 44.0]);
504    }
505
506    #[test]
507    fn renders_through_the_recorder() {
508        let args = RenderArgs {
509            path: "/nowhere/src/x.yui".into(),
510            root: screen(),
511            screen_w: 200,
512            screen_h: 100,
513            scale: 2,
514            preview: false,
515        };
516        let laid = lay_out(&args.root, 200.0, 100.0, &HashMap::new());
517        let ops = gfx::record(200, 100, |ctx| laid.render(ctx));
518        assert!(
519            matches!(
520                ops.first(),
521                Some(DrawOp::Rect {
522                    color: 0xFF10_1010,
523                    ..
524                })
525            ),
526            "{ops:?}"
527        );
528        assert!(
529            ops.iter()
530                .any(|o| matches!(o, DrawOp::Text { text, .. } if text == "Play"))
531        );
532        let out = render(&args).unwrap();
533        assert!(out.image.starts_with("data:image/png;base64,"));
534        assert_eq!(out.rects.len(), 13);
535    }
536
537    #[test]
538    fn preview_clicks_and_scrolls() {
539        let path = "/nowhere/src/preview_test.yui";
540        let ev = |e: &str, x: f32, y: f32| {
541            event(&EventArgs {
542                path: path.into(),
543                root: screen(),
544                screen_w: 200,
545                screen_h: 100,
546                event: e.into(),
547                x,
548                y,
549            })
550        };
551        let r = ev("click:70:40", 0.0, 0.0);
552        assert_eq!(r.clicked.as_deref(), Some("play"));
553        assert_eq!(r.hit.as_deref(), Some("b"));
554        assert!(ev("click:5:5", 0.0, 0.0).clicked.is_none());
555        let r = ev("scroll:-1", 120.0, 30.0);
556        assert!(r.scrolled);
557        assert!(offsets(path, true)["sv"] > 0.0);
558        reset(path);
559        assert!(offsets(path, true).is_empty());
560    }
561
562    #[test]
563    fn clicks_skip_property_elements() {
564        let defs = node(
565            "d",
566            "Grid.ColumnDefinitions",
567            &[],
568            vec![node("c0", "ColumnDefinition", &[], vec![])],
569        );
570        let button = node(
571            "b",
572            "Button",
573            &[("w", "40"), ("h", "20"), ("on_click", "go")],
574            vec![],
575        );
576        let root = node(
577            "s",
578            "Screen",
579            &[("width", "100"), ("height", "60")],
580            vec![node(
581                "g",
582                "Grid",
583                &[("w", "100"), ("h", "60")],
584                vec![defs, button],
585            )],
586        );
587        let r = event(&EventArgs {
588            path: "/nowhere/src/grid_click.yui".into(),
589            root,
590            screen_w: 100,
591            screen_h: 60,
592            event: "click:50:30".into(),
593            x: 0.0,
594            y: 0.0,
595        });
596        assert_eq!(r.hit.as_deref(), Some("b"));
597        assert_eq!(r.clicked.as_deref(), Some("go"));
598    }
599}
600
601#[cfg(test)]
602mod visual {
603    use super::*;
604
605    /// `YOG_DESIGNER_PNG=out.png cargo test --lib designer::visual -- --ignored`:
606    /// a sample screen drawn with the local game files, for eyeballing.
607    #[test]
608    #[ignore]
609    fn sample_png() {
610        let Ok(out) = std::env::var(yog_ids::env::DESIGNER_PNG) else {
611            return;
612        };
613        let n = |uid: &str, kind: &str, attrs: &[(&str, &str)]| YuiNode {
614            uid: uid.into(),
615            kind: kind.into(),
616            attrs: attrs
617                .iter()
618                .map(|(k, v)| (k.to_string(), v.to_string()))
619                .collect(),
620            children: vec![],
621        };
622        let mut root = n(
623            "s",
624            "Screen",
625            &[("width", "200"), ("height", "120"), ("bg", "#C0101010")],
626        );
627        root.children = vec![
628            n(
629                "l",
630                "Label",
631                &[
632                    ("x", "6"),
633                    ("y", "6"),
634                    ("text", "Привет, Minecraft! §cRed §lBold"),
635                    ("color", "#FFFFFF"),
636                    ("no_wrap", "true"),
637                ],
638            ),
639            n(
640                "b",
641                "Button",
642                &[
643                    ("x", "6"),
644                    ("y", "24"),
645                    ("w", "80"),
646                    ("h", "20"),
647                    ("text", "Play"),
648                    ("bg", "#FF555555"),
649                    ("color", "#FFFFFF"),
650                ],
651            ),
652            n(
653                "t",
654                "TextBox",
655                &[
656                    ("x", "96"),
657                    ("y", "26"),
658                    ("w", "90"),
659                    ("h", "16"),
660                    ("placeholder", "Search…"),
661                ],
662            ),
663            n(
664                "i",
665                "InvSlot",
666                &[
667                    ("x", "6"),
668                    ("y", "50"),
669                    ("item", "minecraft:diamond"),
670                    ("count", "64"),
671                ],
672            ),
673            n(
674                "g",
675                "Label",
676                &[
677                    ("x", "6"),
678                    ("y", "76"),
679                    ("text", "Galactic"),
680                    ("font", "yog:font/alt"),
681                    ("no_wrap", "true"),
682                ],
683            ),
684            n(
685                "u",
686                "Label",
687                &[
688                    ("x", "6"),
689                    ("y", "90"),
690                    ("text", "日本語 ✓ ♥"),
691                    ("no_wrap", "true"),
692                ],
693            ),
694        ];
695        let args = RenderArgs {
696            path: "/nowhere/src/x.yui".into(),
697            root,
698            screen_w: 220,
699            screen_h: 130,
700            scale: 4,
701            preview: false,
702        };
703        let r = render(&args).unwrap();
704        let b64 = r.image.trim_start_matches("data:image/png;base64,");
705        std::fs::write(
706            out,
707            base64::engine::general_purpose::STANDARD
708                .decode(b64)
709                .unwrap(),
710        )
711        .unwrap();
712    }
713}