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