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