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 mod_ui;
11pub mod project;
12pub mod recipe_kinds;
13pub mod refs;
14pub mod service;
15pub mod worldgen;
16pub 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 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 mod assets;
27pub mod blocks3d;
28pub mod book;
29pub mod codebehind;
30pub mod components;
31pub mod custom;
32pub mod font;
33pub mod font_copy;
34mod font_yog;
35pub mod gfx;
36pub mod host;
37pub mod images;
38pub mod item_models;
39pub mod markup;
40pub mod raster;
41pub mod recipe_area;
42pub 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 struct NodeRect {
59    pub uid: String,
60    pub rect: [f32; 4],
61    pub content: [f32; 4],
62    /// A grid's rows and columns, `[start, size]` in GUI px (for its adorners).
63    pub grid: Option<GridRects>,
64}
65
66#[derive(Debug, Clone, yog_data::Schema)]
67pub struct GridRects {
68    pub rows: Vec<[f32; 2]>,
69    pub 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 struct AssetsInfo {
76    pub version: String,
77    pub jar: String,
78}
79
80#[derive(Debug, yog_data::Schema)]
81#[data(rename_all = "camelCase")]
82pub struct Rendered {
83    /// `data:image/png;base64,…` — `scale` physical px per GUI px.
84    pub image: String,
85    pub scale: u32,
86    pub rects: Vec<NodeRect>,
87    pub assets: Option<AssetsInfo>,
88    /// Text uses the game's font (else a stand-in glyph box).
89    pub real_font: bool,
90}
91
92/// The mod a file belongs to: the folder with its `yog.toml` (else the
93/// folder holding the `src/` it's in).
94pub fn mod_root(file: &Path) -> Option<PathBuf> {
95    project::mod_root(file).or_else(|| {
96        file.ancestors()
97            .find(|p| p.file_name().is_some_and(|n| n == "src"))
98            .and_then(|p| p.parent())
99            .map(Path::to_path_buf)
100    })
101}
102
103/// Built and laid out like the generated `root_with`.
104type Laid = UiRoot;
105
106fn lay_out(root: &YuiNode, screen_w: f32, screen_h: f32, scroll: &HashMap<String, f32>) -> Laid {
107    let mut ui = UiRoot::new(
108        "yog-idle:designer",
109        markup::screen(root, screen_w, screen_h, scroll),
110    );
111    // Text widths and image sizes come from the "host" — the designer's own
112    // font and image resolution — as they do from the game in `UiRoot::render`.
113    let table = gfx::api(screen_w as i32, screen_h as i32, 1.0);
114    // SAFETY: every function in `table` is sound to call; only fn pointers are kept.
115    let ctx = unsafe { yog_gfx::GfxContext::from_raw(&table) };
116    yog_ui::text::use_host_metrics(&ctx);
117    yog_ui::image::use_host_images(&ctx);
118    ui.layout(screen_w, screen_h);
119    ui
120}
121
122/// Pairs each `.yui` node with its widget and layout node (the screen's
123/// wrapper skipped; a `TextBox`'s inner parts aren't elements).
124fn walk<'a>(
125    node: &'a YuiNode,
126    w: &'a Widget,
127    l: &'a LayoutNode,
128    visit: &mut impl FnMut(&'a YuiNode, &'a Widget, &'a LayoutNode),
129) {
130    visit(node, w, l);
131    if !markup::is_container(&node.kind) {
132        return;
133    }
134    for (i, child) in markup::widget_children(node).enumerate() {
135        if let (Some(cw), Some(cl)) = (w.children.get(i), l.children.get(i)) {
136            walk(child, cw, cl, visit);
137        }
138    }
139}
140
141fn with_content<'a>(
142    root: &'a YuiNode,
143    laid: &'a Laid,
144    mut visit: impl FnMut(&'a YuiNode, &'a Widget, &'a LayoutNode),
145) {
146    if let (Some(w), Some(l)) = (
147        laid.root.children.first(),
148        laid.layout_root.children.first(),
149    ) {
150        walk(root, w, l, &mut visit);
151    }
152}
153
154fn rects(root: &YuiNode, laid: &Laid) -> Vec<NodeRect> {
155    let mut out = Vec::new();
156    with_content(root, laid, |n, w, l| {
157        let r = l.rect;
158        let p = w.style.pad;
159        out.push(NodeRect {
160            uid: n.uid.clone(),
161            rect: [r.x, r.y, r.w, r.h],
162            content: [
163                r.x + p[3],
164                r.y + p[0],
165                (r.w - p[1] - p[3]).max(0.0),
166                (r.h - p[0] - p[2]).max(0.0),
167            ],
168            grid: l.grid.as_ref().map(|g| GridRects {
169                rows: g.rows.iter().map(|&(a, b)| [a, b]).collect(),
170                cols: g.cols.iter().map(|&(a, b)| [a, b]).collect(),
171            }),
172        });
173    });
174    out
175}
176
177/// Per-file preview state: the scroll views' offsets/drags.
178#[derive(Default)]
179struct Preview {
180    scroll: HashMap<String, ScrollState>,
181}
182
183fn previews() -> &'static Mutex<HashMap<String, Preview>> {
184    static P: OnceLock<Mutex<HashMap<String, Preview>>> = OnceLock::new();
185    P.get_or_init(Default::default)
186}
187
188fn offsets(path: &str, preview: bool) -> HashMap<String, f32> {
189    if !preview {
190        return HashMap::new();
191    }
192    let p = previews().lock();
193    p.get(path)
194        .map(|s| {
195            s.scroll
196                .iter()
197                .map(|(k, v)| (k.clone(), v.offset))
198                .collect()
199        })
200        .unwrap_or_default()
201}
202
203#[derive(Debug, yog_data::Schema)]
204#[data(rename_all = "camelCase")]
205pub struct RenderArgs {
206    pub path: String,
207    pub root: YuiNode,
208    pub screen_w: u32,
209    pub screen_h: u32,
210    pub scale: u32,
211    #[data(default)]
212    pub preview: bool,
213}
214
215pub fn render(args: &RenderArgs) -> Result<Rendered, String> {
216    let (sw, sh) = (args.screen_w.clamp(1, 4096), args.screen_h.clamp(1, 4096));
217    let scroll = offsets(&args.path, args.preview);
218    let root = mod_root(Path::new(&args.path));
219    images::set_document(root.as_deref());
220    // The mod's own components: expanded from markup, or marked spacers here drawn by the designer host below.
221    let mut themed = args.root.clone();
222    markup::apply_resources(&mut themed);
223    markup::apply_theme(&mut themed);
224    recipe_area::expand(&mut themed, root.as_deref());
225    let prepared = custom::prepare(&themed, root.as_deref(), sw as f32, sh as f32);
226    let laid = lay_out(&prepared.root, sw as f32, sh as f32, &scroll);
227    let mut ops = gfx::record(sw as i32, sh as i32, |ctx| laid.render(ctx));
228    let mut node_rects = rects(&prepared.root, &laid);
229    if !prepared.is_empty() {
230        custom::splice(
231            &mut ops,
232            &prepared,
233            &node_rects.iter().map(|r| (r.uid.clone(), r.rect)).collect(),
234        );
235    }
236    // The inside of an expanded component instance isn't the document's.
237    node_rects.retain(|r| !r.uid.contains(components::INNER));
238    let res = Resources::for_mod(root.as_deref());
239    let scale = raster::fit_scale(sw, sh, args.scale);
240    let canvas = raster::rasterize(&ops, sw, sh, scale, &res);
241    let real_font = res.vanilla.is_some() || canvas_font_real(&res);
242    let png = canvas.png()?;
243    Ok(Rendered {
244        image: format!(
245            "data:image/png;base64,{}",
246            base64::engine::general_purpose::STANDARD.encode(png)
247        ),
248        scale,
249        rects: node_rects,
250        assets: res.vanilla.as_ref().map(|v| AssetsInfo {
251            version: v.version.clone(),
252            jar: v.jar.to_string_lossy().into_owned(),
253        }),
254        real_font,
255    })
256}
257
258fn canvas_font_real(res: &Resources) -> bool {
259    font::Font::load(res, yog_ids::yog::DEFAULT_FONT).is_real()
260}
261
262#[derive(Debug, yog_data::Schema)]
263#[data(rename_all = "camelCase")]
264pub struct EventArgs {
265    pub path: String,
266    pub root: YuiNode,
267    pub screen_w: u32,
268    pub screen_h: u32,
269    /// A Yog UI event: `click:X:Y`, `drag:X:Y`, `release:X:Y`, `scroll:DY`, `move:X:Y` (hover only).
270    pub event: String,
271    /// The cursor, for the wheel (which carries no position).
272    #[data(default)]
273    pub x: f32,
274    #[data(default)]
275    pub y: f32,
276}
277
278#[derive(Debug, Default, yog_data::Schema)]
279#[data(rename_all = "camelCase")]
280pub struct EventResult {
281    /// A scroll view used the event (wheel, scrollbar).
282    pub scrolled: bool,
283    /// The clickable element under the cursor (`hit_test`: deepest enabled with `on_click`).
284    pub hit: Option<String>,
285    /// Its `on_click`, for a click.
286    pub clicked: Option<String>,
287}
288
289/// `hit_test` (yog-ui `layout::hit_test`), answering with the `.yui` uid.
290fn hit<'a>(node: &'a YuiNode, l: &'a LayoutNode, mx: f32, my: f32) -> Option<&'a YuiNode> {
291    if !l.rect.contains(Vec2::new(mx, my)) {
292        return None;
293    }
294    if markup::is_container(&node.kind) {
295        // Laid out from the widget children only — property elements have no layout node.
296        let children: Vec<_> = markup::widget_children(node).zip(&l.children).collect();
297        for (child, cl) in children.into_iter().rev() {
298            if let Some(found) = hit(child, cl, mx, my) {
299                return Some(found);
300            }
301        }
302    }
303    (l.on_click.is_some() && l.enabled).then_some(node)
304}
305
306fn xy(rest: &str) -> Option<(f32, f32)> {
307    let mut it = rest.split(':');
308    Some((it.next()?.parse().ok()?, it.next()?.parse().ok()?))
309}
310
311pub fn event(args: &EventArgs) -> EventResult {
312    let (sw, sh) = (
313        args.screen_w.clamp(1, 4096) as f32,
314        args.screen_h.clamp(1, 4096) as f32,
315    );
316    let scroll = offsets(&args.path, true);
317    let mod_root = mod_root(Path::new(&args.path));
318    // Laid out like `render` does: images size by the same mod's resources.
319    images::set_document(mod_root.as_deref());
320    let mut themed = args.root.clone();
321    markup::apply_resources(&mut themed);
322    markup::apply_theme(&mut themed);
323    recipe_area::expand(&mut themed, mod_root.as_deref());
324    let prepared = custom::prepare(&themed, mod_root.as_deref(), sw, sh);
325    let root = &prepared.root;
326    let laid = lay_out(root, sw, sh, &scroll);
327    let mut out = EventResult::default();
328    let pos = args
329        .event
330        .split_once(':')
331        .and_then(|(_, rest)| xy(rest))
332        .unwrap_or((args.x, args.y));
333
334    // Scroll views first, as a screen's code-behind does (`ScrollState::handle`).
335    if !args.event.starts_with("move:") {
336        let mut views: Vec<(&YuiNode, &LayoutNode)> = Vec::new();
337        with_content(root, &laid, |n, _, l| {
338            if n.kind == yog_ids::yui::SCROLL_VIEW {
339                views.push((n, l));
340            }
341        });
342        let mut all = previews().lock();
343        let state = all.entry(args.path.clone()).or_default();
344        let wheel = args.event.starts_with("scroll:");
345        // The wheel goes to the innermost view under the cursor; the rest to every view (each checks its own scrollbar).
346        for (n, l) in views.iter().rev() {
347            if wheel && !l.rect.contains(Vec2::new(pos.0, pos.1)) {
348                continue;
349            }
350            let s = state.scroll.entry(n.uid.clone()).or_default();
351            if s.handle(&args.event, l, yog_ui::scroll::WHEEL_STEP) {
352                out.scrolled = true;
353                break;
354            }
355        }
356    }
357    if let Some(l) = laid.layout_root.children.first() {
358        let found = hit(root, l, pos.0, pos.1);
359        // Inside a component instance: the instance.
360        out.hit = found.map(|n| {
361            n.uid
362                .split(components::INNER)
363                .next()
364                .unwrap_or_default()
365                .to_string()
366        });
367        if args.event.starts_with("click:") && !out.scrolled {
368            out.clicked = found
369                .and_then(|n| n.attr(yog_ids::yui_attr::ON_CLICK))
370                .map(str::to_string);
371        }
372    }
373    out
374}
375
376/// Forgets a file's preview state (scroll positions).
377pub fn reset(path: &str) {
378    previews().lock().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_ids::env::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", "yog:font/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}