1pub 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
18pub 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#[derive(Debug, Clone, yog_data::Schema)]
58pub struct NodeRect {
59 pub uid: String,
60 pub rect: [f32; 4],
61 pub content: [f32; 4],
62 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#[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 pub image: String,
85 pub scale: u32,
86 pub rects: Vec<NodeRect>,
87 pub assets: Option<AssetsInfo>,
88 pub real_font: bool,
90}
91
92pub 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
103type 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 let table = gfx::api(screen_w as i32, screen_h as i32, 1.0);
114 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
122fn 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#[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 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 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 pub event: String,
271 #[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 pub scrolled: bool,
283 pub hit: Option<String>,
285 pub clicked: Option<String>,
287}
288
289fn 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 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 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 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 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 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
376pub 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 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 #[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}