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