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