1pub(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
15pub(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#[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 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#[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")]
63pub struct Rendered {
65 pub(crate) image: String,
67 pub(crate) scale: u32,
68 pub(crate) rects: Vec<NodeRect>,
69 pub(crate) assets: Option<AssetsInfo>,
70 pub(crate) real_font: bool,
72}
73
74pub(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
85type 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 let table = drawing::gfx::api(screen_w as i32, screen_h as i32, 1.0);
96 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
104fn 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#[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")]
187pub 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
198pub 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 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 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 pub(crate) event: String,
255 #[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 pub(crate) scrolled: bool,
267 pub(crate) hit: Option<String>,
269 pub(crate) clicked: Option<String>,
271}
272
273fn 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 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 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 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 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 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
362pub(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 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 #[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}