1pub 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
19pub 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#[derive(Debug, Clone, Serialize)]
56pub struct NodeRect {
57 pub uid: String,
58 pub rect: [f32; 4],
59 pub content: [f32; 4],
60 #[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#[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 pub image: String,
84 pub scale: u32,
85 pub rects: Vec<NodeRect>,
86 pub assets: Option<AssetsInfo>,
87 pub real_font: bool,
89}
90
91pub 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
102type 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 let table = gfx::api(screen_w as i32, screen_h as i32, 1.0);
113 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
121fn 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#[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 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 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 pub event: String,
270 #[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 pub scrolled: bool,
282 pub hit: Option<String>,
284 pub clicked: Option<String>,
286}
287
288fn 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 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 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 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 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 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
373pub 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 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_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}