1use std::collections::HashMap;
7
8use serde::Deserialize;
9use yog_gfx::Color;
10use yog_ui::widget::{self, Widget};
11use yog_ui::{Align, Dock, FlexDir, FocusStyle, HAlign, Track, Units, VAlign};
12
13#[derive(Debug, Clone, Deserialize)]
15pub struct YuiNode {
16 pub uid: String,
17 pub kind: String,
18 #[serde(default)]
19 pub attrs: HashMap<String, String>,
20 #[serde(default)]
21 pub children: Vec<YuiNode>,
22}
23
24impl YuiNode {
25 pub fn attr(&self, key: &str) -> Option<&str> {
26 self.attrs.get(key).map(String::as_str)
27 }
28}
29
30const CONTAINERS: &[&str] = &[yog_ids::yui::SCREEN, yog_ids::yui::CANVAS, yog_ids::yui::COLUMN, yog_ids::yui::ROW, yog_ids::yui::SCROLL_VIEW, yog_ids::yui::GRID];
31
32pub fn is_container(kind: &str) -> bool {
33 CONTAINERS.contains(&kind)
34}
35
36pub fn is_property_element(node: &YuiNode) -> bool {
38 node.kind.contains('.')
39}
40
41pub fn widget_children(node: &YuiNode) -> impl Iterator<Item = &YuiNode> {
43 node.children.iter().filter(|c| !is_property_element(c))
44}
45
46fn track(v: &str) -> Option<Track> {
48 let v = v.trim();
49 if v.eq_ignore_ascii_case("auto") {
50 return Some(Track::auto());
51 }
52 if let Some(w) = v.strip_suffix('*') {
53 let w = if w.trim().is_empty() {
54 1.0
55 } else {
56 w.trim().parse().ok()?
57 };
58 return Some(Track::star(w));
59 }
60 v.parse().ok().map(Track::px)
61}
62
63fn tracks(node: &YuiNode, row: bool) -> Option<Vec<Track>> {
66 let (attr, prop, size, min, max) = if row {
67 (
68 "rows",
69 "Grid.RowDefinitions",
70 "height",
71 "min_height",
72 "max_height",
73 )
74 } else {
75 (
76 "columns",
77 "Grid.ColumnDefinitions",
78 "width",
79 "min_width",
80 "max_width",
81 )
82 };
83 if let Some(v) = node.attr(attr) {
84 return Some(
85 v.split(',')
86 .map(|p| track(p).unwrap_or(Track::star(1.0)))
87 .collect(),
88 );
89 }
90 let defs = node.children.iter().find(|c| c.kind == prop)?;
91 Some(
92 defs.children
93 .iter()
94 .map(|d| {
95 let mut t = track(d.attr(size).unwrap_or("*")).unwrap_or(Track::star(1.0));
96 if let Some(n) = num(d.attr(min)) {
97 t = t.min(n);
98 }
99 if let Some(n) = num(d.attr(max)) {
100 t = t.max(n);
101 }
102 t
103 })
104 .collect(),
105 )
106}
107
108fn cell_index(v: Option<&str>) -> Option<u16> {
109 v?.trim().parse().ok()
110}
111
112fn num(v: Option<&str>) -> Option<f32> {
113 v?.trim().parse().ok()
114}
115
116pub use crate::yui::color;
118
119fn size(v: &str) -> Option<Units> {
120 let v = v.trim();
121 if v.is_empty() || v == "auto" {
122 return None;
123 }
124 if let Some(p) = v.strip_suffix('%') {
125 return p.trim().parse().ok().map(Units::Percent);
126 }
127 v.parse().ok().map(Units::Px)
128}
129
130fn padding(v: &str) -> Option<[f32; 4]> {
131 let parts: Vec<f32> = v
132 .split([' ', ','])
133 .filter(|s| !s.is_empty())
134 .map(|s| s.parse().ok())
135 .collect::<Option<_>>()?;
136 match parts.as_slice() {
137 [a] => Some([*a; 4]),
138 [v, h] => Some([*v, *h, *v, *h]),
139 [t, r, b, l] => Some([*t, *r, *b, *l]),
140 _ => None,
141 }
142}
143
144pub fn text(value: Option<&str>) -> String {
147 let v = value.unwrap_or_default();
148 match v.strip_prefix('@') {
149 Some(rest) if rest.starts_with('@') => rest.to_string(),
150 Some(rest) if rest.starts_with("lang:") => rest["lang:".len()..].to_string(),
151 _ => v.to_string(),
152 }
153}
154
155pub fn apply_resources(root: &mut YuiNode) {
163 let mut values: HashMap<String, String> = HashMap::new();
164 let mut styles: HashMap<String, Vec<(String, String)>> = HashMap::new();
165 if let Some(res) = root.children.iter().find(|c| c.kind.ends_with(".Resources")) {
166 let mut pending: Vec<&YuiNode> = Vec::new();
167 for r in &res.children {
168 let Some(key) = r.attr("x:Key") else { continue };
169 if r.kind == yog_ids::yui::STYLE {
170 pending.push(r);
171 } else if let Some(v) = r.attr(yog_ids::yui_attr::VALUE) {
172 values.insert(key.to_string(), v.to_string());
173 }
174 }
175 let local = |v: &str, values: &HashMap<String, String>| match v.strip_prefix('@') {
176 Some(n) if !n.contains('.') && !n.starts_with('@') => values.get(n).cloned().unwrap_or_else(|| v.to_string()),
177 _ => v.to_string(),
178 };
179 while !pending.is_empty() {
181 let before = pending.len();
182 let mut rest = Vec::new();
183 for st in pending {
184 let key = st.attr("x:Key").unwrap_or_default().to_string();
185 let mut attrs = match st.attr(yog_ids::yui_attr::BASED_ON).map(|b| b.trim().trim_start_matches('@')) {
186 Some(b) => match styles.get(b) {
187 Some(a) => a.clone(),
188 None => {
189 rest.push(st);
190 continue;
191 }
192 },
193 None => Vec::new(),
194 };
195 let mut own: Vec<(&String, &String)> = st.attrs.iter().collect();
196 own.sort();
197 for (k, v) in own {
198 if matches!(k.as_str(), "x:Key" | "target" | "based_on") {
199 continue;
200 }
201 attrs.retain(|(ek, _)| ek != k);
202 attrs.push((k.clone(), local(v, &values)));
203 }
204 styles.insert(key, attrs);
205 }
206 if rest.len() == before {
207 break;
208 }
209 pending = rest;
210 }
211 }
212 fn walk(n: &mut YuiNode, values: &HashMap<String, String>, styles: &HashMap<String, Vec<(String, String)>>) {
213 let design: Vec<(String, String)> =
217 n.attrs.iter().filter_map(|(k, v)| Some((k.strip_prefix("d:")?.to_string(), v.clone()))).collect();
218 n.attrs.retain(|k, v| !k.starts_with("d:") && !(v.trim_start().starts_with("{x:Bind") || v.trim_start().starts_with("{Binding")));
219 n.attrs.extend(design);
220 for v in n.attrs.values_mut() {
221 if let Some(name) = v.strip_prefix('@')
222 && !name.contains('.')
223 && !name.starts_with('@')
224 && let Some(r) = values.get(name)
225 {
226 *v = r.clone();
227 }
228 }
229 if let Some(name) = n.attrs.remove(yog_ids::yui_attr::STYLE)
230 && let Some(attrs) = styles.get(name.trim().trim_start_matches('@'))
231 {
232 for (k, v) in attrs {
233 n.attrs.entry(k.clone()).or_insert_with(|| v.clone());
234 }
235 }
236 if let Some(name) = n.attrs.get("x:Name").cloned() {
237 n.attrs.entry(yog_ids::yui_attr::ID.into()).or_insert(name);
238 }
239 let horizontal = n.attr(yog_ids::yui_attr::ORIENTATION).is_some_and(|o| o.trim() == "horizontal");
240 n.kind = match n.kind.as_str() {
241 yog_ids::yui::TOGGLE_BUTTON => yog_ids::yui::BUTTON,
242 yog_ids::yui::LIST_VIEW => yog_ids::yui::SCROLL_VIEW,
243 yog_ids::yui::ITEMS_CONTROL if horizontal => yog_ids::yui::ROW,
244 yog_ids::yui::ITEMS_CONTROL | yog_ids::yui::CONTENT_PRESENTER => yog_ids::yui::COLUMN,
245 k => k,
246 }
247 .to_string();
248 for c in &mut n.children {
249 walk(c, values, styles);
250 }
251 }
252 walk(root, &values, &styles);
253}
254
255pub fn apply_theme(root: &mut YuiNode) {
258 let Some(theme) = root.attr(yog_ids::yui_attr::THEME).and_then(yog_ui::theme::Theme::builtin) else {
259 return;
260 };
261 fn walk(n: &mut YuiNode, theme: &yog_ui::theme::Theme) {
262 for (k, v) in theme.attrs(&n.kind) {
263 n.attrs.entry(k.clone()).or_insert_with(|| v.clone());
264 }
265 for c in &mut n.children {
266 walk(c, theme);
267 }
268 }
269 walk(root, &theme);
270}
271
272pub fn widget(node: &YuiNode, in_canvas: bool, scroll: &HashMap<String, f32>) -> Widget {
275 let a = |k: &str| node.attr(k);
276 let mut w = match node.kind.as_str() {
277 yog_ids::yui::SCREEN | yog_ids::yui::CANVAS | yog_ids::yui::COLUMN => widget::panel(FlexDir::Column),
278 yog_ids::yui::ROW => widget::panel(FlexDir::Row),
279 yog_ids::yui::GRID => widget::grid(),
280 yog_ids::yui::SCROLL_VIEW => widget::scroll_view(),
281 yog_ids::yui::LABEL => widget::label(text(a(yog_ids::yui_attr::TEXT))),
282 yog_ids::yui::BUTTON => widget::button(text(a(yog_ids::yui_attr::TEXT))),
283 yog_ids::yui::ITEM_SLOT => widget::item_slot(a(yog_ids::yui_attr::ITEM).unwrap_or_default()),
284 yog_ids::yui::RECIPE_SLOT | yog_ids::yui::RECIPE_RESULT => widget::item_slot(a(yog_ids::yui_attr::ITEM).unwrap_or_default()),
287 yog_ids::yui::RECIPE_COUNT => widget::label(a(yog_ids::yui_attr::TEXT).unwrap_or("2")),
288 yog_ids::yui::INV_SLOT => widget::inv_slot(
289 a(yog_ids::yui_attr::ITEM).unwrap_or_default(),
290 a(yog_ids::yui_attr::COUNT).and_then(|c| c.trim().parse().ok()).unwrap_or(1),
291 ),
292 yog_ids::yui::SLOT => widget::slot(a(yog_ids::yui_attr::INDEX).and_then(|i| i.trim().parse().ok()).unwrap_or(0)),
293 yog_ids::yui::PLAYER_INVENTORY => widget::player_inventory(),
294 yog_ids::yui::IMAGE => super::images::widget(node),
295 yog_ids::yui::SURFACE => widget::surface(a(yog_ids::yui_attr::SOURCE).unwrap_or_default()),
297 yog_ids::yui::TEXT_BOX => widget::text_box(text(a(yog_ids::yui_attr::TEXT))),
298 _ => widget::spacer(),
299 };
300 if node.kind == yog_ids::yui::SCREEN {
301 if let Some(v) = num(a(yog_ids::yui_attr::WIDTH)) {
302 w = w.w(Units::Px(v));
303 }
304 if let Some(v) = num(a(yog_ids::yui_attr::HEIGHT)) {
305 w = w.h(Units::Px(v));
306 }
307 }
308 if node.kind == yog_ids::yui::GRID {
309 if let Some(rows) = tracks(node, true) {
310 w = w.rows(rows);
311 }
312 if let Some(cols) = tracks(node, false) {
313 w = w.cols(cols);
314 }
315 if let Some(n) = num(a(yog_ids::yui_attr::ROW_SPACING)) {
316 w = w.row_spacing(n);
317 }
318 if let Some(n) = num(a(yog_ids::yui_attr::COLUMN_SPACING)) {
319 w = w.col_spacing(n);
320 }
321 }
322 let (row, col) = (cell_index(a(yog_ids::yui_attr::GRID_ROW)), cell_index(a(yog_ids::yui_attr::GRID_COLUMN)));
323 if row.is_some() || col.is_some() {
324 w = w.cell(row.unwrap_or(0), col.unwrap_or(0));
325 }
326 let (rs, cs) = (
327 cell_index(a(yog_ids::yui_attr::GRID_ROWSPAN)),
328 cell_index(a(yog_ids::yui_attr::GRID_COLUMNSPAN)),
329 );
330 if rs.is_some() || cs.is_some() {
331 w = w.span(rs.unwrap_or(1), cs.unwrap_or(1));
332 }
333 let (x, y) = (num(a(yog_ids::yui_attr::X)), num(a(yog_ids::yui_attr::Y)));
334 if in_canvas || x.is_some() || y.is_some() {
335 w = w.at(x.unwrap_or(0.0), y.unwrap_or(0.0));
336 }
337 let mut keys: Vec<&String> = node.attrs.keys().collect();
339 keys.sort();
340 for key in keys {
341 let v = node.attrs[key].as_str();
342 w = match key.as_str() {
343 "id" if node.kind != yog_ids::yui::SCREEN => w.id(v),
344 "on_click" => w.on_click(v),
345 "w" => size(v).map_or(w.clone(), |s| w.clone().w(s)),
346 "h" => size(v).map_or(w.clone(), |s| w.clone().h(s)),
347 "min_w" => num(Some(v)).map_or(w.clone(), |n| w.clone().min_w(n)),
348 "min_h" => num(Some(v)).map_or(w.clone(), |n| w.clone().min_h(n)),
349 "flex" => num(Some(v)).map_or(w.clone(), |n| w.clone().flex(n)),
350 "gap" => num(Some(v)).map_or(w.clone(), |n| w.clone().gap(n)),
351 "font_scale" => num(Some(v)).map_or(w.clone(), |n| w.clone().font_scale(n)),
352 "dock" => w.dock(match v.trim() {
353 "fill" => Dock::Fill,
354 "left" => Dock::Left,
355 "right" => Dock::Right,
356 "top" => Dock::Top,
357 "bottom" => Dock::Bottom,
358 _ => Dock::None,
359 }),
360 "align" => w.align(match v.trim() {
361 "center" => Align::Center,
362 "end" => Align::End,
363 _ => Align::Start,
364 }),
365 "focus_style" => w.focus_style(match v.trim() {
366 "fill" => FocusStyle::Fill,
367 "none" => FocusStyle::None,
368 _ => FocusStyle::Outline,
369 }),
370 "padding" => match padding(v) {
371 Some([t, r, b, l]) => w.padding(t, r, b, l),
372 None => w,
373 },
374 "margin" => match padding(v) {
375 Some([t, r, b, l]) => w.margin(t, r, b, l),
376 None => w,
377 },
378 "halign" => match v.trim() {
379 "left" => w.halign(HAlign::Left),
380 "center" => w.halign(HAlign::Center),
381 "right" => w.halign(HAlign::Right),
382 "stretch" => w.halign(HAlign::Stretch),
383 _ => w,
384 },
385 "valign" => match v.trim() {
386 "top" => w.valign(VAlign::Top),
387 "center" => w.valign(VAlign::Center),
388 "bottom" => w.valign(VAlign::Bottom),
389 "stretch" => w.valign(VAlign::Stretch),
390 _ => w,
391 },
392 "bg" => match color(v) {
393 Some(c) => w.bg(Color(c)),
394 None if v.contains(':') => w.bg_image(v.trim()),
396 None => w,
397 },
398 "color" => match color(v) {
399 Some(c) => w.color(Color(c)),
400 None => w,
401 },
402 "focus_color" => match color(v) {
403 Some(c) => w.focus_color(Color(c)),
404 None => w,
405 },
406 "shadow" => w.shadow(v.trim() == "true"),
407 "enabled" => w.enabled(v.trim() != "false"),
408 yog_ids::yui_attr::FONT if !v.trim().is_empty() => w.font(v.trim()),
409 "placeholder" => w.placeholder(text(Some(v))),
410 "max_length" => match v.trim().parse() {
411 Ok(n) => w.max_length(n),
412 Err(_) => w,
413 },
414 "on_change" => w.on_change(v),
415 "on_submit" => w.on_submit(v),
416 "no_wrap" if v.trim() == "true" => w.no_wrap(),
417 "ellipsis" if v.trim() == "true" => w.ellipsis(),
418 _ => w,
419 };
420 }
421 if node.kind == yog_ids::yui::SCROLL_VIEW
422 && let Some(offset) = scroll.get(&node.uid)
423 {
424 w = w.scroll_y(*offset);
425 }
426 if is_container(&node.kind) {
427 let canvas = matches!(node.kind.as_str(), yog_ids::yui::SCREEN | yog_ids::yui::CANVAS);
428 for child in widget_children(node) {
429 w = w.child(widget(child, canvas, scroll));
430 }
431 }
432 w
433}
434
435const WHOLE: f32 = 100.0;
439
440pub fn screen(
441 root: &YuiNode,
442 screen_w: f32,
443 screen_h: f32,
444 scroll: &HashMap<String, f32>,
445) -> Widget {
446 let width = num(root.attr(yog_ids::yui_attr::WIDTH)).unwrap_or(widget::SCREEN_W);
447 let height = num(root.attr(yog_ids::yui_attr::HEIGHT)).unwrap_or(widget::SCREEN_H);
448 let content = widget(root, false, scroll);
449 let cx = ((screen_w - width) / 2.0).floor();
450 let cy = ((screen_h - height) / 2.0).floor();
451 let anchor = root.attr(yog_ids::yui_attr::ANCHOR).unwrap_or("center").trim();
452 let content = if anchor == "fill" {
453 content
454 .w(Units::Percent(WHOLE))
455 .h(Units::Percent(WHOLE))
456 .at(0.0, 0.0)
457 } else {
458 let (ax, ay) = match anchor {
459 "top_left" => (0.0, 0.0),
460 "top" => (cx, 0.0),
461 "top_right" => (screen_w - width, 0.0),
462 "left" => (0.0, cy),
463 "right" => (screen_w - width, cy),
464 "bottom_left" => (0.0, screen_h - height),
465 "bottom" => (cx, screen_h - height),
466 "bottom_right" => (screen_w - width, screen_h - height),
467 _ => (cx, cy),
468 };
469 content.at(ax.max(0.0), ay.max(0.0))
470 };
471 widget::panel(FlexDir::Column)
472 .w(Units::Px(screen_w))
473 .h(Units::Px(screen_h))
474 .child(content)
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480
481 fn node(kind: &str, attrs: &[(&str, &str)], children: Vec<YuiNode>) -> YuiNode {
482 YuiNode {
483 uid: kind.into(),
484 kind: kind.into(),
485 attrs: attrs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
486 children,
487 }
488 }
489
490 #[test]
491 fn styles_resources_and_kit_elements_apply_as_yog_build_does() {
492 let mut root = node(
493 "Screen",
494 &[],
495 vec![
496 node(
497 "Screen.Resources",
498 &[],
499 vec![
500 node("Color", &[("x:Key", "Accent"), ("value", "#FF112233")], vec![]),
501 node("Style", &[("x:Key", "Base"), ("target", "Button"), ("h", "16")], vec![]),
502 node("Style", &[("x:Key", "Filter"), ("based_on", "@Base"), ("w", "58"), ("color", "@Accent")], vec![]),
503 ],
504 ),
505 node("ToggleButton", &[("style", "@Filter"), ("w", "64"), ("text", "{x:Bind label()}"), ("d:text", "Yog")], vec![]),
506 node("TextBox", &[("text", "{x:Bind search, Mode=TwoWay}")], vec![]),
507 ],
508 );
509 apply_resources(&mut root);
510 let b = &root.children[1];
511 assert_eq!(b.kind, "Button");
512 assert_eq!(b.attr("w"), Some("64"), "its own attribute wins");
513 assert_eq!(b.attr("h"), Some("16"), "from the base style");
514 assert_eq!(b.attr("color"), Some("#FF112233"));
515 assert_eq!(b.attr("text"), Some("Yog"), "the design-time value");
516 assert_eq!(root.children[2].attr("text"), None, "a bound value shows nothing");
517 }
518}