1use std::collections::HashMap;
9use std::fmt::Write as _;
10
11use crate::yui::{self, Element, RAW_CALL, RAW_VALUE, UI_USE, err};
12
13const CODEGEN: u32 = 2;
15
16const STATES: &[(&str, &str)] = &[
17 ("hover", "HOVER"),
18 ("pressed", "PRESSED"),
19 ("focused", "FOCUSED"),
20 ("selected", "SELECTED"),
21 ("checked", "CHECKED"),
22 ("disabled", "DISABLED"),
23];
24
25#[derive(Debug, Clone, PartialEq)]
28enum Tok {
29 Ident(String),
30 Num(String),
31 Str(String),
32 PathSep,
33 Dot,
34 OptDot,
35 Open,
36 Close,
37 Comma,
38 Eq,
39 Ne,
40 Not,
41}
42
43fn lex(src: &str) -> Result<Vec<Tok>, String> {
44 let mut out = Vec::new();
45 let cs: Vec<char> = src.chars().collect();
46 let mut i = 0;
47 while i < cs.len() {
48 let c = cs[i];
49 let next = cs.get(i + 1).copied();
50 match c {
51 _ if c.is_whitespace() => i += 1,
52 _ if c.is_alphabetic() || c == '_' => {
53 let s = i;
54 while i < cs.len() && (cs[i].is_alphanumeric() || cs[i] == '_') {
55 i += 1;
56 }
57 out.push(Tok::Ident(cs[s..i].iter().collect()));
58 }
59 _ if c.is_ascii_digit() || (c == '-' && next.is_some_and(|n| n.is_ascii_digit())) => {
60 let s = i;
61 i += 1;
62 while i < cs.len()
63 && (cs[i].is_ascii_alphanumeric()
64 || cs[i] == '_'
65 || (cs[i] == '.' && cs.get(i + 1).is_some_and(|n| n.is_ascii_digit())))
66 {
67 i += 1;
68 }
69 out.push(Tok::Num(cs[s..i].iter().collect()));
70 }
71 '\'' => {
72 let s = i + 1;
73 i += 1;
74 while i < cs.len() && cs[i] != '\'' {
75 i += 1;
76 }
77 if i >= cs.len() {
78 return Err("unterminated 'string'".into());
79 }
80 out.push(Tok::Str(cs[s..i].iter().collect()));
81 i += 1;
82 }
83 ':' if next == Some(':') => {
84 out.push(Tok::PathSep);
85 i += 2;
86 }
87 '?' if next == Some('.') => {
88 out.push(Tok::OptDot);
89 i += 2;
90 }
91 '=' if next == Some('=') => {
92 out.push(Tok::Eq);
93 i += 2;
94 }
95 '!' if next == Some('=') => {
96 out.push(Tok::Ne);
97 i += 2;
98 }
99 '!' => {
100 out.push(Tok::Not);
101 i += 1;
102 }
103 '.' => {
104 out.push(Tok::Dot);
105 i += 1;
106 }
107 '(' => {
108 out.push(Tok::Open);
109 i += 1;
110 }
111 ')' => {
112 out.push(Tok::Close);
113 i += 1;
114 }
115 ',' => {
116 out.push(Tok::Comma);
117 i += 1;
118 }
119 _ => {
120 return Err(format!(
121 "unexpected `{c}` (bindings allow paths, calls, literals, !, == and !=; use a view-model method for anything else)"
122 ));
123 }
124 }
125 }
126 Ok(out)
127}
128
129#[derive(Debug, Clone)]
130struct Seg {
131 opt: bool,
132 name: String,
133 args: Option<Vec<Ex>>,
134}
135
136#[derive(Debug, Clone)]
137enum Head {
138 Ident(String, Option<Vec<Ex>>),
139 Type(String, Option<Vec<Ex>>),
140 Lit(String),
141 Paren(Box<Ex>),
142}
143
144#[derive(Debug, Clone)]
145enum Ex {
146 Post(Head, Vec<Seg>),
147 Not(Box<Ex>),
148 Cmp(Box<Ex>, &'static str, Box<Ex>),
149}
150
151struct ExParser {
152 toks: Vec<Tok>,
153 pos: usize,
154}
155
156impl ExParser {
157 fn peek(&self) -> Option<&Tok> {
158 self.toks.get(self.pos)
159 }
160 fn bump(&mut self) -> Option<Tok> {
161 let t = self.toks.get(self.pos).cloned();
162 self.pos += 1;
163 t
164 }
165 fn eat(&mut self, t: &Tok) -> bool {
166 if self.peek() == Some(t) {
167 self.pos += 1;
168 true
169 } else {
170 false
171 }
172 }
173 fn expr(&mut self) -> Result<Ex, String> {
174 let a = self.unary()?;
175 let op = if self.eat(&Tok::Eq) {
176 "=="
177 } else if self.eat(&Tok::Ne) {
178 "!="
179 } else {
180 return Ok(a);
181 };
182 let b = self.unary()?;
183 Ok(Ex::Cmp(Box::new(a), op, Box::new(b)))
184 }
185 fn unary(&mut self) -> Result<Ex, String> {
186 if self.eat(&Tok::Not) {
187 return Ok(Ex::Not(Box::new(self.postfix()?)));
188 }
189 self.postfix()
190 }
191 fn call(&mut self) -> Result<Option<Vec<Ex>>, String> {
192 if !self.eat(&Tok::Open) {
193 return Ok(None);
194 }
195 let mut args = Vec::new();
196 if self.eat(&Tok::Close) {
197 return Ok(Some(args));
198 }
199 loop {
200 args.push(self.expr()?);
201 if self.eat(&Tok::Close) {
202 return Ok(Some(args));
203 }
204 if !self.eat(&Tok::Comma) {
205 return Err("expected `,` or `)`".into());
206 }
207 }
208 }
209 fn postfix(&mut self) -> Result<Ex, String> {
210 let head = match self.bump() {
211 Some(Tok::Ident(id)) => {
212 if self.peek() == Some(&Tok::PathSep) {
213 let mut path = id;
214 while self.eat(&Tok::PathSep) {
215 match self.bump() {
216 Some(Tok::Ident(n)) => {
217 path.push_str("::");
218 path.push_str(&n);
219 }
220 _ => return Err("expected a name after `::`".into()),
221 }
222 }
223 Head::Type(path, self.call()?)
224 } else if id == "true" || id == "false" {
225 Head::Lit(id)
226 } else {
227 Head::Ident(id.clone(), self.call()?)
228 }
229 }
230 Some(Tok::Num(n)) => Head::Lit(n),
231 Some(Tok::Str(s)) => Head::Lit(format!("{s:?}")),
232 Some(Tok::Open) => {
233 let e = self.expr()?;
234 if !self.eat(&Tok::Close) {
235 return Err("expected `)`".into());
236 }
237 Head::Paren(Box::new(e))
238 }
239 Some(t) => return Err(format!("unexpected {t:?}")),
240 None => return Err("expected an expression".into()),
241 };
242 let mut segs = Vec::new();
243 loop {
244 let opt = if self.eat(&Tok::Dot) {
245 false
246 } else if self.eat(&Tok::OptDot) {
247 true
248 } else {
249 break;
250 };
251 let Some(Tok::Ident(name)) = self.bump() else {
252 return Err("expected a name after `.`".into());
253 };
254 let args = self.call()?;
255 segs.push(Seg { opt, name, args });
256 }
257 Ok(Ex::Post(head, segs))
258 }
259}
260
261fn parse_expr(src: &str) -> Result<Ex, String> {
262 let mut p = ExParser {
263 toks: lex(src)?,
264 pos: 0,
265 };
266 let e = p.expr()?;
267 if p.pos < p.toks.len() {
268 return Err(format!("unexpected {:?}", p.toks[p.pos]));
269 }
270 Ok(e)
271}
272
273#[derive(Clone, Copy, PartialEq)]
275enum Scope {
276 Screen,
277 Template,
278}
279
280impl Ex {
281 fn has_opt(&self) -> bool {
282 match self {
283 Ex::Post(h, segs) => {
284 segs.iter()
285 .any(|s| s.opt || s.args.iter().flatten().any(Ex::has_opt))
286 || match h {
287 Head::Ident(_, a) | Head::Type(_, a) => a.iter().flatten().any(Ex::has_opt),
288 Head::Paren(e) => e.has_opt(),
289 Head::Lit(_) => false,
290 }
291 }
292 Ex::Not(e) => e.has_opt(),
293 Ex::Cmp(a, _, b) => a.has_opt() || b.has_opt(),
294 }
295 }
296
297 fn is_place(&self) -> bool {
300 match self {
301 Ex::Post(Head::Ident(n, None), segs) => {
302 n != "cx" && segs.iter().all(|s| !s.opt && s.args.is_none())
303 }
304 _ => false,
305 }
306 }
307
308 fn emit(&self, scope: Scope, command: bool) -> Result<String, String> {
309 let args = |a: &Option<Vec<Ex>>, by_ref: bool| -> Result<String, String> {
310 let Some(a) = a else {
311 return Ok(String::new());
312 };
313 let parts = a
314 .iter()
315 .map(|e| {
316 let s = e.emit(scope, command)?;
317 Ok(if by_ref && e.is_place() {
318 format!("&{s}")
319 } else {
320 s
321 })
322 })
323 .collect::<Result<Vec<_>, String>>()?;
324 Ok(format!("({})", parts.join(", ")))
325 };
326 Ok(match self {
327 Ex::Post(h, segs) => {
328 let mut s = match h {
329 Head::Lit(l) => l.clone(),
330 Head::Paren(e) => format!("({})", e.emit(scope, command)?),
331 Head::Type(p, a) => format!("{p}{}", args(a, true)?),
332 Head::Ident(n, a) => {
333 let root = match (n.as_str(), scope) {
334 ("vm", _) => "vm".to_string(),
335 ("cx", _) if command => "cx".to_string(),
336 ("cx", _) => return Err("`cx` is only available in commands".into()),
337 ("item" | "index", Scope::Template) => n.clone(),
338 ("item" | "index", Scope::Screen) => {
339 return Err(format!("`{n}` is only available in item templates"));
340 }
341 (_, Scope::Screen) => format!("vm.{n}"),
342 (_, Scope::Template) => format!("item.{n}"),
343 };
344 format!("{root}{}", args(a, false)?)
345 }
346 };
347 for seg in segs {
348 if seg.opt {
349 s.push('?');
350 }
351 let _ = write!(s, ".{}{}", seg.name, args(&seg.args, false)?);
352 }
353 s
354 }
355 Ex::Not(e) => format!("!{}", e.emit(scope, command)?),
356 Ex::Cmp(a, op, b) => format!(
357 "{} {op} {}",
358 a.emit(scope, command)?,
359 b.emit(scope, command)?
360 ),
361 })
362 }
363}
364
365#[derive(Debug, Clone, Copy, PartialEq)]
366enum Mode {
367 OneWay,
368 TwoWay,
369}
370
371#[derive(Debug, Clone)]
373struct Binding {
374 raw: String,
376 expr: Option<Ex>,
377 rust: Option<String>,
379 mode: Mode,
380 bind_back: Option<String>,
381 fallback: Option<String>,
382}
383
384impl Binding {
385 fn rust(&self, scope: Scope, el: &Element) -> Result<String, String> {
387 match (&self.rust, &self.expr) {
388 (Some(r), _) => Ok(r.clone()),
389 (None, Some(e)) => e
390 .emit(scope, false)
391 .map_err(|e| format!("line {}: {e}", el.line)),
392 (None, None) => unreachable!("a binding has an expression"),
393 }
394 }
395}
396
397fn split_top(s: &str) -> Vec<&str> {
399 let (mut depth, mut quote, mut start) = (0i32, false, 0);
400 let mut out = Vec::new();
401 for (i, c) in s.char_indices() {
402 match c {
403 '\'' => quote = !quote,
404 '(' if !quote => depth += 1,
405 ')' if !quote => depth -= 1,
406 ',' if !quote && depth == 0 => {
407 out.push(&s[start..i]);
408 start = i + 1;
409 }
410 _ => {}
411 }
412 }
413 out.push(&s[start..]);
414 out
415}
416
417fn parse_binding(value: &str) -> Option<Result<Binding, String>> {
418 let inner = value.trim().strip_prefix("{x:Bind")?.strip_suffix('}')?;
419 Some((|| {
420 let mut b = Binding {
421 raw: value.to_string(),
422 expr: None,
423 rust: None,
424 mode: Mode::OneWay,
425 bind_back: None,
426 fallback: None,
427 };
428 for (i, part) in split_top(inner).into_iter().enumerate() {
429 let part = part.trim();
430 if i == 0 {
431 if part.is_empty() {
432 return Err("empty binding — write {x:Bind expression}".to_string());
433 }
434 b.expr = Some(parse_expr(part).map_err(|e| format!("{value}: {e}"))?);
435 continue;
436 }
437 let Some((k, v)) = part.split_once('=') else {
438 return Err(format!(
439 "{value}: expected Mode=, BindBack= or Fallback=, got `{part}`"
440 ));
441 };
442 let v = v.trim();
443 match k.trim() {
444 "Mode" => {
445 b.mode = match v {
446 "OneWay" | "OneTime" => Mode::OneWay,
447 "TwoWay" => Mode::TwoWay,
448 _ => return Err(format!("{value}: Mode is OneWay, OneTime or TwoWay")),
449 }
450 }
451 "BindBack" => b.bind_back = Some(v.to_string()),
452 "Fallback" => b.fallback = Some(v.trim_matches('\'').to_string()),
453 other => return Err(format!("{value}: unknown binding option `{other}`")),
454 }
455 }
456 Ok(b)
457 })())
458}
459
460pub(crate) fn compile_bind(value: &str) -> Option<Result<String, String>> {
467 let b = match parse_binding(value)? {
468 Ok(b) => b,
469 Err(e) => return Some(Err(e)),
470 };
471 Some(b.expr.expect("parsed").emit(Scope::Screen, false))
472}
473
474#[derive(Clone, Copy, PartialEq)]
478enum Target {
479 Text,
480 Placeholder,
481 Tooltip,
482 Item,
483 Source,
484 Count,
485 Color(&'static str),
486 Enabled,
487 Visible,
488 Checked,
489 Size(&'static str),
490 Number(&'static str),
491 ScrollKey,
492}
493
494fn target(kind: &str, attr: &str) -> Option<Target> {
495 Some(match attr {
496 "text" if matches!(kind, "Label" | "Button" | "TextBox") => Target::Text,
497 "placeholder" if kind == "TextBox" => Target::Placeholder,
498 "tooltip" => Target::Tooltip,
499 "item" if matches!(kind, "ItemSlot" | "InvSlot") => Target::Item,
500 "source" if matches!(kind, "Image" | "Surface") => Target::Source,
501 "count" if kind == "InvSlot" => Target::Count,
502 "bg" => Target::Color("bg"),
503 "color" => Target::Color("color"),
504 "focus_color" => Target::Color("focus_color"),
505 "enabled" => Target::Enabled,
506 "visible" => Target::Visible,
507 "checked" if kind == "Button" => Target::Checked,
508 "w" => Target::Size("w"),
509 "h" => Target::Size("h"),
510 "gap" => Target::Number("gap"),
511 "font_scale" => Target::Number("font_scale"),
512 "flex" => Target::Number("flex"),
513 "min_w" => Target::Number("min_w"),
514 "min_h" => Target::Number("min_h"),
515 "scroll_key" if kind == "ScrollView" => Target::ScrollKey,
516 _ => return None,
517 })
518}
519
520struct Ctx {
522 scope: Scope,
523 fields: &'static str,
525 tree: &'static str,
527 owner: u16,
529 slots: Vec<(String, String)>,
531 update: String,
532}
533
534impl Ctx {
535 fn new(scope: Scope, fields: &'static str, tree: &'static str, owner: u16) -> Self {
536 Ctx {
537 scope,
538 fields,
539 tree,
540 owner,
541 slots: Vec::new(),
542 update: String::new(),
543 }
544 }
545}
546
547struct VmGen<'a> {
548 file: String,
549 mod_id: Option<&'a str>,
550 theme: Option<yog_ui::theme::Theme>,
552 resources: HashMap<String, String>,
553 styles: HashMap<String, Vec<(String, String)>>,
554 ids: yui::Names,
555 view_fields: Vec<(String, String, String)>,
557 parts: String,
558 dispatch: Vec<(String, String)>,
559 templates: String,
560 n_slots: usize,
561 n_lists: u16,
562 n_templates: usize,
563 n_scrollers: u16,
564 n_text_boxes: u16,
565 cmd_slots: HashMap<u16, u16>,
567}
568
569fn path_lit(path: &[u16]) -> String {
570 format!(
571 "&[{}]",
572 path.iter()
573 .map(u16::to_string)
574 .collect::<Vec<_>>()
575 .join(", ")
576 )
577}
578
579impl VmGen<'_> {
580 fn slot(&mut self, ctx: &mut Ctx, ty: &str) -> String {
581 let name = format!("b{}", self.n_slots);
582 self.n_slots += 1;
583 if ctx.scope == Scope::Template && ctx.fields == "inst." {
584 ctx.slots.push((name.clone(), ty.to_string()));
585 } else {
586 self.view_fields.push((
587 name.clone(),
588 format!("::yog_kit::bind::Slot<{ty}>"),
589 "::yog_kit::bind::Slot::new()".into(),
590 ));
591 }
592 name
593 }
594
595 fn next_cmd(&mut self, owner: u16) -> u16 {
596 let n = self.cmd_slots.entry(owner).or_insert(0);
597 let s = *n;
598 *n += 1;
599 s
600 }
601
602 fn resolve(&self, el: &Element) -> Result<Element, String> {
604 let mut out = el.clone();
605 out.attrs.clear();
606 let mut style: Vec<(String, String)> = Vec::new();
607 for (k, v) in &el.attrs {
608 let v = match v.strip_prefix('@') {
609 Some(name) if !name.starts_with('@') && !name.contains('.') && !name.is_empty() => {
610 match self.resources.get(name) {
611 Some(r) => r.clone(),
612 None if k == "style" => v.clone(),
613 None => {
614 return err(
615 el,
616 format!("{k}=\"{v}\": no resource `{name}` in <Screen.Resources>"),
617 );
618 }
619 }
620 }
621 _ => v.clone(),
622 };
623 match k.as_str() {
624 "x:Name" => out.attrs.push(("id".into(), v)),
625 "style" => {
626 let name = v.trim().trim_start_matches('@');
627 let Some(s) = self.styles.get(name) else {
628 return err(
629 el,
630 format!(
631 "style=\"{v}\": no <Style x:Key=\"{name}\"> in <Screen.Resources>"
632 ),
633 );
634 };
635 style = s.clone();
636 }
637 _ => out.attrs.push((k.clone(), v)),
638 }
639 }
640 for (k, v) in style {
641 if !out.attrs.iter().any(|(ek, _)| *ek == k) {
642 out.attrs.push((k, v));
643 }
644 }
645 if let Some(theme) = &self.theme {
647 theme.apply(&el.name, &mut out.attrs);
648 }
649 Ok(out)
650 }
651
652 fn collect_resources(&mut self, res: &Element) -> Result<(), String> {
653 let mut pending: Vec<&Element> = Vec::new();
654 for r in &res.children {
655 let Some(key) = r.attr("x:Key") else {
656 return err(r, "needs an x:Key");
657 };
658 if r.name == "Style" {
659 pending.push(r);
660 continue;
661 }
662 let Some(v) = r.attr("value") else {
663 return err(
664 r,
665 "needs a value (e.g. <Color x:Key=\"Accent\" value=\"#FFD700\"/>)",
666 );
667 };
668 self.resources.insert(key.to_string(), v.to_string());
669 }
670 while !pending.is_empty() {
672 let before = pending.len();
673 let mut rest = Vec::new();
674 for s in pending {
675 let key = s.attr("x:Key").unwrap_or_default().to_string();
676 let base = s
677 .attr("based_on")
678 .map(|b| b.trim().trim_start_matches('@').to_string());
679 let mut attrs: Vec<(String, String)> = match &base {
680 Some(b) => match self.styles.get(b) {
681 Some(a) => a.clone(),
682 None => {
683 rest.push(s);
684 continue;
685 }
686 },
687 None => Vec::new(),
688 };
689 for (k, v) in &s.attrs {
690 if matches!(k.as_str(), "x:Key" | "target" | "based_on") {
691 continue;
692 }
693 let v = match v.strip_prefix('@') {
694 Some(n) if !n.contains('.') && !n.starts_with('@') => {
695 self.resources.get(n).cloned().ok_or_else(|| {
696 format!("line {}: <Style> {k}=\"{v}\": no resource `{n}`", s.line)
697 })?
698 }
699 _ => v.clone(),
700 };
701 attrs.retain(|(ek, _)| ek != k);
702 attrs.push((k.clone(), v));
703 }
704 self.styles.insert(key, attrs);
705 }
706 if rest.len() == before {
707 return err(rest[0], "based_on names an unknown (or circular) style");
708 }
709 pending = rest;
710 }
711 Ok(())
712 }
713
714 #[allow(clippy::too_many_arguments)]
716 fn bind_attr(
717 &mut self,
718 ctx: &mut Ctx,
719 el: &Element,
720 kind: &str,
721 key: &str,
722 b: &Binding,
723 path: &[u16],
724 text_box: Option<u16>,
725 scroller: Option<u16>,
726 ) -> Result<(), String> {
727 let Some(t) = target(kind, key) else {
728 return err(el, format!("{key} can't be bound"));
729 };
730 let expr = b.rust(ctx.scope, el)?;
731 let opt = b.expr.as_ref().is_some_and(Ex::has_opt);
732 let (ty, conv) = match t {
733 Target::Text | Target::Placeholder | Target::Tooltip | Target::Item | Target::Source => {
734 ("String", "::yog_kit::bind::text(&(E))".to_string())
735 }
736 Target::Count => ("u32", "::yog_kit::bind::count(E)".into()),
737 Target::Color(_) => ("::yog_api::ui::Color", "::yog_kit::bind::color(E)".into()),
738 Target::Enabled | Target::Visible | Target::Checked => {
739 ("bool", "::yog_kit::bind::flag(E)".into())
740 }
741 Target::Size(_) => ("::yog_api::ui::Units", "::yog_kit::bind::units(E)".into()),
742 Target::Number(_) => ("f32", "::yog_kit::bind::number(E)".into()),
743 Target::ScrollKey => ("u64", "::yog_kit::bind::key(&(E))".into()),
744 };
745 let value = if opt {
746 let fallback = match (&b.fallback, ty) {
747 (Some(f), "String") => format!("::std::string::String::from({f:?})"),
748 (Some(f), _) => f.clone(),
749 (None, _) => "::std::default::Default::default()".into(),
750 };
751 format!(
752 "(|| ::std::option::Option::Some({}))().unwrap_or_else(|| {fallback})",
753 conv.replace('E', &expr)
754 )
755 } else {
756 conv.replacen('E', &expr, 1)
757 };
758 let slot = self.slot(ctx, ty);
759 let f = format!("{}{slot}", ctx.fields);
760 let w = format!("{}.at_path_mut({})", ctx.tree, path_lit(path));
761 let apply = match t {
762 Target::Text if kind == "TextBox" => {
763 let Some(tb) = text_box else {
764 return err(el, "text boxes in item templates aren't supported");
765 };
766 format!("u.set_text_box({tb}, {f}.get());")
767 }
768 Target::Text => {
769 format!("{w}.set_text(::std::clone::Clone::clone({f}.get())); u.relayout();")
770 }
771 Target::Placeholder => {
772 format!("::yog_kit::bind::set_placeholder({w}, {f}.get()); u.repaint();")
773 }
774 Target::Tooltip => format!("{w}.set_tooltip(::std::clone::Clone::clone({f}.get()));"),
775 Target::Item => format!("::yog_kit::bind::set_item({w}, {f}.get()); u.repaint();"),
776 Target::Source => format!("::yog_kit::bind::set_source({w}, {f}.get()); u.relayout();"),
777 Target::Count => format!("::yog_kit::bind::set_count({w}, *{f}.get()); u.repaint();"),
778 Target::Color(c) => format!("{w}.style.{c} = *{f}.get(); u.repaint();"),
779 Target::Enabled => format!("{w}.enabled = *{f}.get(); u.repaint();"),
780 Target::Visible => format!("{w}.visible = *{f}.get(); u.relayout();"),
781 Target::Checked => {
782 format!("::yog_kit::bind::set_checked({w}, *{f}.get()); u.repaint();")
783 }
784 Target::Size(a) => format!("{w}.style.{a} = *{f}.get(); u.relayout();"),
785 Target::Number(a) => format!("{w}.style.{a} = *{f}.get(); u.relayout();"),
786 Target::ScrollKey => {
787 let Some(s) = scroller else {
788 return err(el, "scroll_key needs a ScrollView outside item templates");
789 };
790 format!("u.reset_scroll({s});")
791 }
792 };
793 let _ = write!(
794 ctx.update,
795 " // @yui {}:{} {key}=\"{}\"\n if {f}.set({value}) {{ {apply} }}\n",
796 self.file,
797 el.line,
798 b.raw.replace('\n', " ")
799 );
800 Ok(())
801 }
802
803 fn command(
805 &self,
806 el: &Element,
807 b: &Binding,
808 scope: Scope,
809 items: Option<&str>,
810 ) -> Result<String, String> {
811 let Some(e) = &b.expr else {
812 return err(el, "command needs {x:Bind method(...)}");
813 };
814 let line = el.line;
815 match scope {
816 Scope::Screen => Ok(format!(
817 "{};",
818 e.emit(Scope::Screen, true)
819 .map_err(|m| format!("line {line}: {m}"))?
820 )),
821 Scope::Template => {
822 let Ex::Post(head, segs) = e else {
825 return err(el, "command must be a call: vm.method(...) or cx.close()");
826 };
827 let rooted = matches!(head, Head::Ident(n, None) if n == "vm" || n == "cx");
828 let Some(last) = segs.last().filter(|s| s.args.is_some() && rooted) else {
829 return err(
830 el,
831 "commands in item templates call the view model: write vm.method(...) (or cx....)",
832 );
833 };
834 let args = last.args.clone().unwrap_or_default();
835 let mut call = e.clone();
836 if let Ex::Post(_, segs) = &mut call {
837 let last = segs.last_mut().expect("checked");
838 last.args = Some(
839 (0..args.len())
840 .map(|i| Ex::Post(Head::Lit(format!("__a{i}")), Vec::new()))
841 .collect(),
842 );
843 }
844 let call = call
845 .emit(Scope::Template, true)
846 .map_err(|m| format!("line {line}: {m}"))?;
847 if args.is_empty() {
848 return Ok(format!("let _ = index; {call};"));
849 }
850 let items = items.expect("template has items");
851 let cloned = args
852 .iter()
853 .map(|a| {
854 a.emit(Scope::Template, false)
855 .map(|s| format!("::std::clone::Clone::clone(&({s}))"))
856 })
857 .collect::<Result<Vec<_>, _>>()
858 .map_err(|m| format!("line {line}: {m}"))?;
859 let names: Vec<String> = (0..args.len()).map(|i| format!("__a{i}")).collect();
860 Ok(format!(
861 "let __args = ::std::iter::IntoIterator::into_iter({items}).enumerate().nth(index as usize).map(|(index, item)| {{ let _ = (&index, &item); ({},) }});\n if let ::std::option::Option::Some(({},)) = __args {{ {call}; }}",
862 cloned.join(", "),
863 names.join(", ")
864 ))
865 }
866 }
867 }
868
869 fn element(
871 &mut self,
872 ctx: &mut Ctx,
873 el: &Element,
874 path: &[u16],
875 items: Option<&str>,
876 ) -> Result<Element, String> {
877 let el = &self.resolve(el)?;
878 let name = el.name.as_str();
879 let (kind, horizontal) = match name {
880 "ToggleButton" => ("Button", false),
881 "ListView" => ("ScrollView", false),
882 "ItemsControl" => (
883 "Column",
884 el.attr("orientation")
885 .is_some_and(|o| o.trim() == "horizontal"),
886 ),
887 "ContentPresenter" => ("Column", false),
888 "Screen.Resources" | "DataTemplate" => return err(el, "isn't allowed here"),
889 k => (k, false),
890 };
891 let kind = if horizontal { "Row" } else { kind };
892 let is_list = matches!(name, "ListView" | "ItemsControl" | "ContentPresenter");
893 let screen = ctx.scope == Scope::Screen;
894
895 let mut out = Element {
896 name: kind.to_string(),
897 attrs: Vec::new(),
898 children: Vec::new(),
899 line: el.line,
900 };
901 let mut raw = 0;
902 let mut push_raw = |out: &mut Element, call: String| {
903 out.attrs.push((format!("{RAW_CALL}{raw}"), call));
904 raw += 1;
905 };
906 let mut patches: Vec<(&'static str, Vec<String>)> = Vec::new();
907 let mut bindings: Vec<(String, Binding)> = Vec::new();
908 let mut command: Option<Binding> = None;
909 let mut list_attrs: HashMap<String, String> = HashMap::new();
910
911 for (k, v) in &el.attrs {
912 let k = k.as_str();
913 if k.starts_with("d:") || matches!(k, "x:ViewModel" | "x:Create" | "x:DataType") {
914 continue;
915 }
916 if matches!(k, "on_click" | "on_change" | "on_submit") {
917 return err(
918 el,
919 format!(
920 "{k}: view-model screens use command=\"{{x:Bind method()}}\" (and Mode=TwoWay text) instead"
921 ),
922 );
923 }
924 if let Some((state, attr)) = k.split_once(':') {
925 let Some((_, flag)) = STATES.iter().find(|(s, _)| *s == state) else {
926 return err(
927 el,
928 format!(
929 "unknown attribute prefix `{state}:` (states: hover, pressed, focused, selected, checked, disabled)"
930 ),
931 );
932 };
933 let field = match attr {
934 "bg" if yui::is_image_value(v) => {
935 let src = yui::image_source(el, k, v.trim(), self.mod_id)?;
936 format!("bg_image: ::std::option::Option::Some(::std::string::String::from({src:?}))")
937 }
938 "bg" | "color" | "focus_color" => {
939 let c = yui::color_attr(el, k, v)?;
940 format!("{attr}: ::std::option::Option::Some(::yog_api::ui::Color(0x{c:08X}))")
941 }
942 "shadow" => format!(
943 "shadow: ::std::option::Option::Some({})",
944 yui::boolean(el, k, v)?
945 ),
946 _ => {
947 return err(
948 el,
949 format!(
950 "{k}: only paint attributes (bg, color, shadow, focus_color) take a state prefix"
951 ),
952 );
953 }
954 };
955 match patches.iter_mut().find(|(f, _)| f == flag) {
956 Some((_, fs)) => fs.push(field),
957 None => patches.push((flag, vec![field])),
958 }
959 continue;
960 }
961 if let Some(b) = parse_binding(v) {
962 let b = b.map_err(|e| format!("line {}: {e}", el.line))?;
963 if k == "command" {
964 command = Some(b);
965 } else if matches!(k, "items" | "content" | "key" | "selected") {
966 if !is_list {
967 return err(
968 el,
969 format!("{k} is for ListView, ItemsControl and ContentPresenter"),
970 );
971 }
972 bindings.push((k.to_string(), b));
973 } else {
974 bindings.push((k.to_string(), b));
975 }
976 continue;
977 }
978 match k {
979 "command" => return err(el, "command needs {x:Bind method(...)}"),
980 "items" | "content" | "key" | "selected" => {
981 return err(el, format!("{k} needs a binding: {k}=\"{{x:Bind ...}}\""));
982 }
983 "item_height" | "orientation" if is_list => {
984 list_attrs.insert(k.into(), v.clone());
985 }
986 "visible" => {
987 let b = yui::boolean(el, k, v)?;
988 push_raw(&mut out, format!("visible({b})"));
989 }
990 "checked" if kind == "Button" => {
991 let b = yui::boolean(el, k, v)?;
992 bindings.push((
993 k.into(),
994 Binding {
995 raw: v.clone(),
996 expr: Some(Ex::Post(Head::Lit(b.to_string()), Vec::new())),
997 rust: None,
998 mode: Mode::OneWay,
999 bind_back: None,
1000 fallback: None,
1001 },
1002 ));
1003 }
1004 "text" | "placeholder" | "tooltip"
1005 if v.starts_with('@') && !v.starts_with("@@") && v.len() > 1 =>
1006 {
1007 bindings.push((
1009 k.into(),
1010 Binding {
1011 raw: v.clone(),
1012 expr: None,
1013 rust: Some(yui::text_attr(el, k, v)?),
1014 mode: Mode::OneWay,
1015 bind_back: None,
1016 fallback: None,
1017 },
1018 ));
1019 }
1020 _ => out.attrs.push((k.to_string(), v.clone())),
1021 }
1022 }
1023 for (flag, fields) in patches {
1024 push_raw(
1025 &mut out,
1026 format!(
1027 "state_style(::yog_api::ui::StateFlags::{flag}, ::yog_api::ui::StylePatch {{ {}, ..::std::default::Default::default() }})",
1028 fields.join(", ")
1029 ),
1030 );
1031 }
1032 if let Some(id) = out.attr("id") {
1033 self.ids.push(id);
1034 }
1035
1036 let text_box = if kind == "TextBox" && screen {
1038 let n = self.n_text_boxes;
1039 self.n_text_boxes += 1;
1040 let _ = writeln!(self.parts, " parts.text_box({});", path_lit(path));
1041 Some(n)
1042 } else {
1043 None
1044 };
1045 let scroller = if kind == "ScrollView" {
1046 if !screen {
1047 return err(el, "scroll views inside item templates aren't supported");
1048 }
1049 let n = self.n_scrollers;
1050 self.n_scrollers += 1;
1051 let _ = writeln!(self.parts, " parts.scroller({});", path_lit(path));
1052 Some(n)
1053 } else {
1054 None
1055 };
1056
1057 let toggle_place = bindings
1059 .iter()
1060 .find(|(k, b)| k == "checked" && b.mode == Mode::TwoWay)
1061 .map(|(_, b)| b.clone());
1062 if kind == "TextBox" {
1063 let tb = text_box.ok_or_else(|| {
1064 format!(
1065 "line {}: text boxes in item templates aren't supported",
1066 el.line
1067 )
1068 })?;
1069 if let Some((_, b)) = bindings.iter().find(|(k, _)| k == "text") {
1070 let write = match (&b.bind_back, b.mode, &b.expr) {
1071 (Some(m), _, _) => Some(format!("vm.{m}(text);")),
1072 (None, Mode::TwoWay, Some(e)) if e.is_place() => {
1073 Some(format!("{} = text;", e.emit(Scope::Screen, false)?))
1074 }
1075 (None, Mode::TwoWay, _) => {
1076 return err(
1077 el,
1078 "Mode=TwoWay needs a field (text=\"{x:Bind search, Mode=TwoWay}\") or BindBack=method",
1079 );
1080 }
1081 _ => None,
1082 };
1083 if let Some(write) = write {
1084 self.dispatch.push((
1085 format!("::yog_kit::ViewEvent::TextChanged {{ slot: {tb}, text }}"),
1086 write,
1087 ));
1088 }
1089 }
1090 if let Some(c) = &command {
1091 let stmt = self.command(el, c, Scope::Screen, None)?;
1092 self.dispatch.push((
1093 format!("::yog_kit::ViewEvent::TextSubmitted {{ slot: {tb}, text }}"),
1094 format!("let _ = text; {stmt}"),
1095 ));
1096 }
1097 } else if command.is_some() || toggle_place.is_some() {
1098 let owner = ctx.owner;
1099 let slot = self.next_cmd(owner);
1100 push_raw(
1101 &mut out,
1102 format!(
1103 "action(::yog_api::ui::Action {{ owner: {owner}, slot: {slot}, index: 0 }})"
1104 ),
1105 );
1106 let mut body = String::new();
1107 if let Some(t) = &toggle_place {
1108 if ctx.scope == Scope::Template {
1109 return err(
1110 el,
1111 "Mode=TwoWay toggles in item templates aren't supported — use a command",
1112 );
1113 }
1114 match (&t.bind_back, &t.expr) {
1115 (Some(m), Some(e)) => {
1116 let cur = e.emit(Scope::Screen, false)?;
1117 let _ = write!(body, "let __v = !{cur}; vm.{m}(__v); ");
1118 }
1119 (None, Some(e)) if e.is_place() => {
1120 let p = e.emit(Scope::Screen, false)?;
1121 let _ = write!(body, "{p} = !{p}; ");
1122 }
1123 _ => {
1124 return err(
1125 el,
1126 "checked Mode=TwoWay needs a bool field or BindBack=method",
1127 );
1128 }
1129 }
1130 }
1131 if let Some(c) = &command {
1132 body.push_str(&self.command(el, c, ctx.scope, items)?);
1133 }
1134 let pat = if owner == 0 {
1135 format!(
1136 "::yog_kit::ViewEvent::Action(::yog_api::ui::Action {{ owner: 0, slot: {slot}, .. }})"
1137 )
1138 } else {
1139 format!(
1140 "::yog_kit::ViewEvent::Action(::yog_api::ui::Action {{ owner: {owner}, slot: {slot}, index }})"
1141 )
1142 };
1143 self.dispatch.push((pat, body.trim_end().to_string()));
1144 if screen {
1145 let _ = writeln!(self.parts, " parts.focusable({});", path_lit(path));
1146 }
1147 }
1148
1149 if is_list {
1151 if !screen {
1152 return err(el, "lists inside item templates aren't supported");
1153 }
1154 self.list(ctx, el, &mut bindings, &list_attrs, path, scroller)?;
1155 }
1156
1157 let rebuild = if yui::is_custom_component(kind)
1160 && bindings.iter().any(|(k, _)| !yui::is_common_attr(k))
1161 {
1162 Some(self.component_props(ctx, el, &out, &mut bindings, path)?)
1163 } else {
1164 None
1165 };
1166 let slots_before = self.n_slots;
1167 if bindings.iter().any(|(k, _)| k == "source") {
1168 match kind {
1170 "Image" => out.attrs.push(("source".into(), "item:air".into())),
1171 "Surface" => out.attrs.push(("source".into(), "yog:none".into())),
1172 _ => {}
1173 }
1174 }
1175 for (k, b) in &bindings {
1176 if matches!(k.as_str(), "items" | "content" | "key" | "selected") {
1177 continue;
1178 }
1179 self.bind_attr(ctx, el, kind, k, b, path, text_box, scroller)?;
1180 }
1181 if let Some(marker) = rebuild {
1182 let resets: String = (slots_before..self.n_slots)
1183 .map(|i| format!(" {}b{i}.reset();", ctx.fields))
1184 .collect();
1185 ctx.update = ctx.update.replace(&marker, &resets);
1186 }
1187
1188 if !is_list {
1189 let mut i = 0u16;
1190 for child in &el.children {
1191 if child.name.contains('.') {
1192 if child.name == "Screen.Resources" && path.is_empty() {
1193 continue;
1194 }
1195 return err(child, "isn't allowed here");
1196 }
1197 let mut p = path.to_vec();
1198 p.push(i);
1199 out.children.push(self.element(ctx, child, &p, items)?);
1200 i += 1;
1201 }
1202 }
1203 Ok(out)
1204 }
1205
1206 fn component_props(
1212 &mut self,
1213 ctx: &mut Ctx,
1214 el: &Element,
1215 out: &Element,
1216 bindings: &mut Vec<(String, Binding)>,
1217 path: &[u16],
1218 ) -> Result<String, String> {
1219 let mut inst = out.clone();
1220 let mut sets = String::new();
1221 let mut i = 0;
1222 while i < bindings.len() {
1223 if yui::is_common_attr(&bindings[i].0) {
1224 i += 1;
1225 continue;
1226 }
1227 let (key, b) = bindings.remove(i);
1228 if b.mode == Mode::TwoWay || b.bind_back.is_some() {
1229 return err(el, format!("{key}: component properties bind one way"));
1230 }
1231 let expr = b.rust(ctx.scope, el)?;
1232 let value = if b.expr.as_ref().is_some_and(Ex::has_opt) {
1233 let fallback = b.fallback.clone().unwrap_or_default();
1234 format!(
1235 "(|| ::std::option::Option::Some(::yog_kit::bind::text(&({expr}))))().unwrap_or_else(|| ::std::string::String::from({fallback:?}))"
1236 )
1237 } else {
1238 format!("::yog_kit::bind::text(&({expr}))")
1239 };
1240 let slot = self.slot(ctx, "String");
1241 let f = format!("{}{slot}", ctx.fields);
1242 let _ = write!(
1243 sets,
1244 " // @yui {}:{} {key}=\"{}\"\n __props |= {f}.set({value});\n",
1245 self.file,
1246 el.line,
1247 b.raw.replace('\n', " ")
1248 );
1249 inst.attrs.push((key, format!("{RAW_VALUE}*{f}.get()")));
1250 }
1251 let (expr, _) = yui::static_widget(&inst, false, 2, self.mod_id)?;
1252 let marker = format!("\u{1}resets{}\u{1}", self.n_slots);
1253 let _ = write!(
1254 ctx.update,
1255 " let mut __props = false;\n{sets} if __props {{\n #[allow(unused_imports)]\n {UI_USE}\n *{}.at_path_mut({}) = {expr};\n u.relayout();{marker}\n }}\n",
1256 ctx.tree,
1257 path_lit(path),
1258 );
1259 Ok(marker)
1260 }
1261
1262 fn only_child(el: &Element) -> Result<&Element, String> {
1264 let mut c = el;
1265 if c.children.len() == 1 && c.children[0].name == "DataTemplate" {
1266 c = &c.children[0];
1267 }
1268 match c.children.as_slice() {
1269 [one] => Ok(one),
1270 _ => err(el, "needs exactly one child element"),
1271 }
1272 }
1273
1274 fn list(
1275 &mut self,
1276 ctx: &mut Ctx,
1277 el: &Element,
1278 bindings: &mut [(String, Binding)],
1279 attrs: &HashMap<String, String>,
1280 path: &[u16],
1281 scroller: Option<u16>,
1282 ) -> Result<(), String> {
1283 let name = el.name.as_str();
1284 let owner = {
1285 self.n_lists += 1;
1286 self.n_lists
1287 };
1288 let get = |k: &str| {
1289 bindings
1290 .iter()
1291 .find(|(bk, _)| bk == k)
1292 .map(|(_, b)| b.clone())
1293 };
1294 let source = if name == "ContentPresenter" {
1295 "content"
1296 } else {
1297 "items"
1298 };
1299 let Some(items_b) = get(source) else {
1300 return err(el, format!("needs {source}=\"{{x:Bind ...}}\""));
1301 };
1302 let items_ex = items_b.expr.clone().expect("parsed");
1303 let mut items = items_ex
1304 .emit(Scope::Screen, false)
1305 .map_err(|e| format!("line {}: {e}", el.line))?;
1306 if items_ex.is_place() {
1307 items = format!("&{items}");
1308 }
1309 let key = match get("key") {
1310 Some(b) => b
1311 .expr
1312 .expect("parsed")
1313 .emit(Scope::Template, false)
1314 .map_err(|e| format!("line {}: {e}", el.line))?,
1315 None => "index".to_string(),
1316 };
1317 let selected = get("selected");
1318 if selected.is_some() && name != "ListView" {
1319 return err(el, "selected is for ListView");
1320 }
1321 let item_height = match attrs.get("item_height") {
1322 Some(v) => Some(yui::number(el, "item_height", v)?),
1323 None => None,
1324 };
1325
1326 let mut template = None;
1328 let mut empty = None;
1329 for c in &el.children {
1330 match c.name.strip_prefix(name).and_then(|n| n.strip_prefix('.')) {
1331 Some("ItemTemplate" | "ContentTemplate") => template = Some(Self::only_child(c)?),
1332 Some("Empty") => empty = Some(Self::only_child(c)?),
1333 _ if c.name == "DataTemplate" => template = Some(Self::only_child(c)?),
1334 _ => {
1335 return err(
1336 c,
1337 format!(
1338 "isn't allowed in <{name}> (expected <{name}.ItemTemplate> or <{name}.Empty>)"
1339 ),
1340 );
1341 }
1342 }
1343 }
1344 let Some(template) = template else {
1345 return err(
1346 el,
1347 format!("needs an item template (<{name}.ItemTemplate><DataTemplate>...)"),
1348 );
1349 };
1350
1351 let t = self.n_templates;
1352 self.n_templates += 1;
1353 let mut tctx = Ctx::new(Scope::Template, "inst.", "w", owner);
1354 let saved = std::mem::take(&mut self.n_slots);
1355 let tel = self.element(&mut tctx, template, &[], Some(&items))?;
1356 self.n_slots = saved;
1357 let (texpr, ids) = yui::static_widget(&tel, false, 1, self.mod_id)?;
1358 self.absorb(ids);
1359 let fields: String = tctx
1360 .slots
1361 .iter()
1362 .map(|(n, ty)| format!(" {n}: ::yog_kit::bind::Slot<{ty}>,\n"))
1363 .collect();
1364 let inits: String = tctx
1365 .slots
1366 .iter()
1367 .map(|(n, _)| format!("{n}: ::yog_kit::bind::Slot::new(), "))
1368 .collect();
1369 let _ = write!(
1370 self.templates,
1371 "/// Item template {t} ({}:{}).\n#[doc(hidden)]\n#[allow(non_camel_case_types)]\nstruct __T{t} {{\n{fields}}}\n\nimpl __T{t} {{\n fn build() -> (Self, ::yog_api::ui::Widget) {{\n #[allow(unused_imports)]\n {UI_USE}\n (Self {{ {inits}}}, {texpr})\n }}\n}}\n\n",
1372 self.file, template.line
1373 );
1374
1375 let mut empty_update = String::new();
1377 let empty_fn = match empty {
1378 Some(e) => {
1379 let mut ectx = Ctx::new(Scope::Screen, "self.", "w", 0);
1380 let eel = self.element(&mut ectx, e, &[], None)?;
1381 let (eexpr, ids) = yui::static_widget(&eel, false, 1, self.mod_id)?;
1382 self.absorb(ids);
1383 empty_update = ectx.update;
1384 let _ = write!(
1385 self.templates,
1386 "#[doc(hidden)]\n#[allow(non_snake_case)]\nfn __E{t}() -> ::yog_api::ui::Widget {{\n #[allow(unused_imports)]\n {UI_USE}\n {eexpr}\n}}\n\n"
1387 );
1388 format!("::std::option::Option::Some(__E{t})")
1389 }
1390 None => "::std::option::Option::None".to_string(),
1391 };
1392
1393 let ih = item_height.map_or("::std::option::Option::None".to_string(), |h| {
1394 format!("::std::option::Option::Some({h:?})")
1395 });
1396 let sc = scroller.map_or("::std::option::Option::None".to_string(), |s| {
1397 format!("::std::option::Option::Some({s})")
1398 });
1399 let _ = writeln!(
1400 self.parts,
1401 " parts.list({owner}, {}, {sc}, {ih});",
1402 path_lit(path)
1403 );
1404 let field = format!("l{owner}");
1405 self.view_fields.push((
1406 field.clone(),
1407 format!("::yog_kit::items::ItemsHost<__T{t}>"),
1408 format!(
1409 "::yog_kit::items::ItemsHost::new({owner}, {}, {ih}, {sc}, {})",
1410 path_lit(path),
1411 selected.is_some()
1412 ),
1413 ));
1414
1415 let mut u = String::new();
1417 let _ = writeln!(
1418 u,
1419 " // @yui {}:{} {source}=\"{}\"",
1420 self.file, el.line, items_b.raw
1421 );
1422 u.push_str(" {\n");
1423 let sel_expr = match &selected {
1424 Some(b) => {
1425 let e = b
1426 .expr
1427 .as_ref()
1428 .expect("parsed")
1429 .emit(Scope::Screen, false)?;
1430 let _ = writeln!(u, " let __sel = {e};");
1431 format!("::yog_kit::bind::is_selected(&__sel, &({key}))")
1432 }
1433 None => "false".into(),
1434 };
1435 let _ = writeln!(u, " let mut __it = self.{field}.begin(root, u);");
1436 let _ = writeln!(
1437 u,
1438 " for (index, item) in ::std::iter::IntoIterator::into_iter({items}).enumerate() {{"
1439 );
1440 u.push_str(" let _ = (&index, &item);\n");
1441 if tctx.update.is_empty() {
1442 let _ = writeln!(
1443 u,
1444 " let _ = __it.next(::yog_kit::bind::key(&({key})), {sel_expr}, __T{t}::build);"
1445 );
1446 } else {
1447 let _ = writeln!(
1448 u,
1449 " let ::std::option::Option::Some(__row) = __it.next(::yog_kit::bind::key(&({key})), {sel_expr}, __T{t}::build) else {{ continue }};"
1450 );
1451 u.push_str(" let ::yog_kit::items::Item { inst, w, u } = __row;\n");
1452 for line in tctx.update.lines() {
1453 let _ = writeln!(u, " {line}");
1454 }
1455 }
1456 u.push_str(" }\n");
1457 if empty_update.is_empty() {
1458 let _ = writeln!(u, " __it.finish({empty_fn});");
1459 } else {
1460 let _ = writeln!(
1461 u,
1462 " if let ::std::option::Option::Some((w, u)) = __it.finish({empty_fn}) {{"
1463 );
1464 for line in empty_update.lines() {
1465 let _ = writeln!(u, " {line}");
1466 }
1467 u.push_str(" }\n");
1468 }
1469 u.push_str(" }\n");
1470 ctx.update.push_str(&u);
1471
1472 if let Some(b) = &selected {
1474 let write = match (&b.bind_back, b.mode, &b.expr) {
1475 (Some(m), _, _) => format!("vm.{m}(__k);"),
1476 (None, Mode::TwoWay, Some(e)) if e.is_place() => {
1477 format!(
1478 "{} = ::std::option::Option::Some(__k);",
1479 e.emit(Scope::Screen, false)?
1480 )
1481 }
1482 (None, Mode::TwoWay, _) => {
1483 return err(el, "selected Mode=TwoWay needs a field or BindBack=method");
1484 }
1485 _ => String::new(),
1486 };
1487 if !write.is_empty() {
1488 self.dispatch.push((
1489 format!(
1490 "::yog_kit::ViewEvent::Action(::yog_api::ui::Action {{ owner: {owner}, slot: ::yog_kit::items::SELECT, index }})\n | ::yog_kit::ViewEvent::Select {{ list: {owner}, index }}"
1491 ),
1492 format!(
1493 "let __k = ::std::iter::IntoIterator::into_iter({items}).enumerate().nth(index as usize).map(|(index, item)| {{ let _ = (&index, &item); ::std::clone::Clone::clone(&({key})) }});\n if let ::std::option::Option::Some(__k) = __k {{ {write} }}"
1494 ),
1495 ));
1496 }
1497 }
1498 Ok(())
1501 }
1502
1503 fn absorb(&mut self, ids: Vec<String>) {
1504 for id in &ids {
1505 self.ids.push(id);
1506 }
1507 }
1508}
1509
1510pub fn generate(
1512 root: &Element,
1513 name: &str,
1514 mod_id: Option<&str>,
1515) -> Result<(String, Vec<String>), String> {
1516 if root.name != "Screen" {
1517 return err(root, "a view-model screen's root must be <Screen>");
1518 }
1519 let id = root
1520 .attr("id")
1521 .filter(|s| !s.trim().is_empty())
1522 .ok_or_else(|| {
1523 format!(
1524 "line {}: <Screen> needs an id (the UI id, e.g. \"mymod:main\")",
1525 root.line
1526 )
1527 })?
1528 .to_string();
1529 let vm = root
1530 .attr("x:ViewModel")
1531 .unwrap_or_default()
1532 .trim()
1533 .to_string();
1534 if vm.is_empty() {
1535 return err(root, "x:ViewModel names the view-model type");
1536 }
1537 let create = root
1538 .attr("x:Create")
1539 .map(str::to_string)
1540 .unwrap_or_else(|| format!("<{vm} as ::std::default::Default>::default()"));
1541 let (width, height, anchor) = yui::screen_frame(root)?;
1542 let anchor = yui::pascal(anchor);
1543 let modal = root
1544 .attr("modal")
1545 .map(|v| yui::boolean(root, "modal", v))
1546 .transpose()?
1547 .unwrap_or(true);
1548 let pause = root
1549 .attr("pause")
1550 .map(|v| yui::boolean(root, "pause", v))
1551 .transpose()?
1552 .unwrap_or(false);
1553 let live = root
1554 .attr("live")
1555 .map(|v| yui::boolean(root, "live", v))
1556 .transpose()?
1557 .unwrap_or(false);
1558
1559 let mut g = VmGen {
1560 file: format!("{name}.yui"),
1561 mod_id,
1562 theme: yui::screen_theme(root)?,
1563 resources: HashMap::new(),
1564 styles: HashMap::new(),
1565 ids: yui::Names::default(),
1566 view_fields: Vec::new(),
1567 parts: String::new(),
1568 dispatch: Vec::new(),
1569 templates: String::new(),
1570 n_slots: 0,
1571 n_lists: 0,
1572 n_templates: 0,
1573 n_scrollers: 0,
1574 n_text_boxes: 0,
1575 cmd_slots: HashMap::new(),
1576 };
1577 for c in &root.children {
1578 if c.name == "Screen.Resources" {
1579 g.collect_resources(c)?;
1580 }
1581 }
1582 let mut content = root.clone();
1583 content.attrs.retain(|(k, _)| k != "id");
1584 let mut ctx = Ctx::new(Scope::Screen, "self.", "root", 0);
1585 let tree = g.element(&mut ctx, &content, &[], None)?;
1586 let (expr, ids) = yui::static_widget(&tree, false, 0, mod_id)?;
1587 g.absorb(ids);
1588
1589 let mut out = String::new();
1590 let _ = write!(
1591 out,
1592 r#"// <auto-generated>
1593// Generated by yog from src/{name}.yui (view model {vm}, codegen {CODEGEN}) —
1594// do not edit: changes are lost on the next `yog build` / `yog check` /
1595// `yog ui gen`. Edit the .yui file instead.
1596// </auto-generated>
1597//
1598// Pulled into the code-behind module (which defines `{vm}`) with:
1599//
1600// yog_api::ui_include!("{name}");
1601
1602const _: () = ::yog_kit::__codegen::require({CODEGEN});
1603
1604/// The UI id this screen registers under.
1605#[allow(dead_code)]
1606pub const ID: &str = {id:?};
1607/// Design-time size in GUI pixels.
1608#[allow(dead_code)]
1609pub const WIDTH: f32 = {width:?};
1610#[allow(dead_code)]
1611pub const HEIGHT: f32 = {height:?};
1612
1613/// Ids of this screen's named widgets.
1614#[allow(dead_code)]
1615pub mod ids {{
1616"#
1617 );
1618 out.push_str(&yui::str_consts(g.ids.as_slice()));
1619 out.push_str("}\n\n");
1620 out.push_str(&g.templates);
1621
1622 out.push_str("/// The compiled view of this screen — hosted by `yog_kit::Screen`.\n/// Private like the view model it names; tests in the code-behind\n/// module drive it with `yog_kit::testing::TestHost<View>`.\nstruct View {\n");
1624 for (n, ty, _) in &g.view_fields {
1625 let _ = writeln!(out, " {n}: {ty},");
1626 }
1627 out.push_str("}\n\n");
1628
1629 let _ = write!(
1630 out,
1631 r#"impl ::yog_kit::View for View {{
1632 type Vm = {vm};
1633 const META: ::yog_kit::ScreenMeta = ::yog_kit::ScreenMeta {{
1634 id: ID,
1635 w: WIDTH,
1636 h: HEIGHT,
1637 anchor: ::yog_kit::Anchor::{anchor},
1638 modal: {modal},
1639 pause: {pause},
1640 live: {live},
1641 }};
1642
1643 fn build() -> (Self, ::yog_api::ui::Widget, ::yog_kit::Parts) {{
1644 #[allow(unused_imports)]
1645 {UI_USE}
1646 let root = {expr};
1647 #[allow(unused_mut)]
1648 let mut parts = ::yog_kit::Parts::new();
1649{parts} let view = View {{
1650"#,
1651 parts = g.parts,
1652 );
1653 for (n, _, init) in &g.view_fields {
1654 let _ = writeln!(out, " {n}: {init},");
1655 }
1656 let _ = write!(
1657 out,
1658 r#" }};
1659 (view, root, parts)
1660 }}
1661
1662 #[allow(unused_variables)]
1663 fn update(&mut self, root: &mut ::yog_api::ui::Widget, vm: &{vm}, u: &mut ::yog_kit::Update) {{
1664{update} }}
1665
1666 #[allow(unused_variables, clippy::let_unit_value, clippy::single_match, clippy::match_single_binding)]
1667 fn dispatch(&mut self, vm: &mut {vm}, cx: &mut ::yog_kit::Cx<'_, {vm}>, ev: ::yog_kit::ViewEvent) {{
1668 match ev {{
1669"#,
1670 update = ctx.update,
1671 );
1672 for (pat, body) in &g.dispatch {
1673 let _ = writeln!(
1674 out,
1675 " {pat} => {{\n {body}\n }}"
1676 );
1677 }
1678 let _ = write!(
1679 out,
1680 r#" _ => {{}}
1681 }}
1682 }}
1683}}
1684
1685static __HANDLE: ::std::sync::OnceLock<::yog_kit::ScreenHandle<{vm}>> = ::std::sync::OnceLock::new();
1686
1687/// Registers the screen (rendering + input) with a new view model
1688/// (`{create}`); returns its handle. Call once, from `Mod::register`.
1689#[allow(dead_code, private_interfaces)]
1690pub fn register(registry: &mut ::yog_api::Registry) -> ::yog_kit::ScreenHandle<{vm}> {{
1691 register_with(registry, {create})
1692}}
1693
1694/// [`register`] with a given view model.
1695#[allow(dead_code, private_interfaces)]
1696pub fn register_with(registry: &mut ::yog_api::Registry, vm: {vm}) -> ::yog_kit::ScreenHandle<{vm}> {{
1697 ::std::clone::Clone::clone(__HANDLE.get_or_init(|| ::yog_kit::Screen::<View>::register(registry, vm)))
1698}}
1699
1700/// The handle [`register`] returned (`None` before it).
1701#[allow(dead_code, private_interfaces)]
1702pub fn handle() -> ::std::option::Option<::yog_kit::ScreenHandle<{vm}>> {{
1703 __HANDLE.get().cloned()
1704}}
1705
1706/// Opens the screen (modal: {modal}, pauses the game: {pause}).
1707#[allow(dead_code)]
1708pub fn open() {{
1709 ::yog_api::open_ui(ID, {modal}, {pause});
1710}}
1711
1712/// Closes the screen if it's open.
1713#[allow(dead_code)]
1714pub fn close() {{
1715 ::yog_api::close_ui(ID);
1716}}
1717"#
1718 );
1719 Ok((out, Vec::new()))
1720}
1721
1722#[cfg(test)]
1723mod tests {
1724 use super::*;
1725
1726 fn codegen(body: &str) -> Result<String, String> {
1727 let src = format!(
1728 r#"<Screen id="t:s" width="200" height="100" x:ViewModel="Vm">{body}</Screen>"#
1729 );
1730 crate::yui::generate_for(&src, "s", None).map(|(c, _)| c)
1731 }
1732
1733 #[test]
1734 fn themes() {
1735 let src = r##"<Screen id="t:s" width="200" height="100" theme="minecraft:vanilla" x:ViewModel="Vm">
1736 <Screen.Resources><Style x:Key="Red" target="Button" bg="#FFFF0000"/></Screen.Resources>
1737 <Column>
1738 <Button x:Name="plain" command="{x:Bind go()}"/>
1739 <Button x:Name="own" bg="#FF00FF00" command="{x:Bind go()}"/>
1740 <Button x:Name="styled" style="@Red" command="{x:Bind go()}"/>
1741 </Column></Screen>"##;
1742 let code = crate::yui::generate_for(src, "s", None).unwrap().0;
1743 assert!(code.contains(r#".bg_image("sprite:panel")"#), "{code}");
1744 assert_eq!(code.matches(r#".bg_image("sprite:button")"#).count(), 1, "{code}");
1745 assert!(code.contains("bg(::yog_api::ui::Color(0xFF00FF00))") && code.contains("bg(::yog_api::ui::Color(0xFFFF0000))"), "{code}");
1746 let bad = src.replace("minecraft:vanilla", "nope:nope");
1747 assert!(crate::yui::generate_for(&bad, "s", None).unwrap_err().contains("no such theme"));
1748 }
1749
1750 #[test]
1751 fn tooltips() {
1752 let code = codegen(r#"<Column><Label tooltip="Static"/><Label tooltip="{x:Bind hint()}"/></Column>"#).unwrap();
1753 assert!(code.contains(r#".tooltip("Static")"#), "{code}");
1754 assert!(code.contains("::yog_kit::bind::text(&(vm.hint()))"), "{code}");
1755 assert!(code.contains(".set_tooltip(::std::clone::Clone::clone("), "{code}");
1756 }
1757
1758 #[test]
1759 fn image_state_backgrounds() {
1760 let code = codegen(r#"<Button bg="sprite:button" hover:bg="sprite:button_hover" disabled:bg="sprite:button_disabled" command="{x:Bind go()}"/>"#).unwrap();
1761 assert!(code.contains(r#".bg_image("sprite:button")"#), "{code}");
1762 assert!(code.contains(r#"StateFlags::HOVER, ::yog_api::ui::StylePatch { bg_image: ::std::option::Option::Some(::std::string::String::from("sprite:button_hover"))"#), "{code}");
1763 assert!(code.contains(r#"bg_image: ::std::option::Option::Some(::std::string::String::from("sprite:button_disabled"))"#), "{code}");
1764 }
1765
1766 #[test]
1767 fn expressions() {
1768 let e = |s: &str, scope| parse_expr(s).unwrap().emit(scope, false).unwrap();
1769 assert_eq!(e("count", Scope::Screen), "vm.count");
1770 assert_eq!(e("count_text()", Scope::Screen), "vm.count_text()");
1771 assert_eq!(
1772 e("filter == Filter::All", Scope::Screen),
1773 "vm.filter == Filter::All"
1774 );
1775 assert_eq!(
1776 e("!has(Filter::Yog)", Scope::Screen),
1777 "!vm.has(Filter::Yog)"
1778 );
1779 assert_eq!(
1780 e("Strings::count(n, 'x')", Scope::Screen),
1781 "Strings::count(&vm.n, \"x\")"
1782 );
1783 assert_eq!(
1784 e("Strings::v(version)", Scope::Template),
1785 "Strings::v(&item.version)"
1786 );
1787 assert_eq!(e("selected()?.name", Scope::Screen), "vm.selected()?.name");
1788 assert_eq!(e("vm.x.len()", Scope::Template), "vm.x.len()");
1789 assert_eq!(e("item", Scope::Template), "item");
1790 assert_eq!(e("1.5", Scope::Screen), "1.5");
1791 assert!(parse_expr("a < b").is_err());
1792 assert!(
1793 parse_expr("cx.close()")
1794 .unwrap()
1795 .emit(Scope::Screen, false)
1796 .is_err()
1797 );
1798 assert!(
1799 parse_expr("index")
1800 .unwrap()
1801 .emit(Scope::Screen, false)
1802 .is_err()
1803 );
1804 }
1805
1806 #[test]
1807 fn yres_colors_everywhere() {
1808 let mut refs = crate::yres::Refs::default();
1809 refs.colors.insert("Colors.accent".into(), 0xFF11_2233);
1810 refs.colors.insert("Colors.hot".into(), 0xFF44_5566);
1811 refs.colors.insert("Colors.tint".into(), 0x8077_8899);
1812 crate::yui::set_resource_refs(&refs);
1813 let code = codegen(
1814 r##"
1815 <Screen.Resources>
1816 <Color x:Key="Accent" value="@Colors.accent"/>
1817 <Style x:Key="Btn" target="Button" bg="@Colors.accent" hover:bg="@Colors.hot"/>
1818 </Screen.Resources>
1819 <Column w="100%">
1820 <Label text="a" color="@Accent" selected:color="@Accent" disabled:bg="@Colors.hot"/>
1821 <Button style="@Btn" text="b"/>
1822 <Image source="item:air" tint="@Colors.tint"/>
1823 </Column>"##,
1824 );
1825 let missing = codegen(r#"<Label text="a" hover:bg="@Colors.nope"/>"#);
1826 crate::yui::set_resource_refs(&crate::yres::Refs::default());
1827 let code = code.unwrap();
1828 for want in [
1829 ".color(::yog_api::ui::Color(0xFF112233))",
1830 "StateFlags::SELECTED, ::yog_api::ui::StylePatch { color: ::std::option::Option::Some(::yog_api::ui::Color(0xFF112233))",
1831 "StateFlags::DISABLED, ::yog_api::ui::StylePatch { bg: ::std::option::Option::Some(::yog_api::ui::Color(0xFF445566))",
1832 ".bg(::yog_api::ui::Color(0xFF112233))",
1833 "StateFlags::HOVER, ::yog_api::ui::StylePatch { bg: ::std::option::Option::Some(::yog_api::ui::Color(0xFF445566))",
1834 ".tint(::yog_api::ui::Color(0x80778899))",
1835 ] {
1836 assert!(code.contains(want), "missing {want}:\n{code}");
1837 }
1838 assert!(missing.unwrap_err().contains("no color `Colors.nope`"));
1839 }
1840
1841 #[test]
1842 fn bindings_commands_and_parts() {
1843 let code = codegen(r##"
1844 <Screen.Resources>
1845 <Color x:Key="Accent" value="#FFD700"/>
1846 <Style x:Key="Btn" target="Button" w="40" h="14" bg="#FF333333" hover:bg="#FF444444"/>
1847 <Style x:Key="Toggle" based_on="@Btn" checked:bg="#FF555555"/>
1848 </Screen.Resources>
1849 <Column w="100%">
1850 <Label x:Name="count" text="{x:Bind count_text()}" color="@Accent"/>
1851 <Label text="@lang:mymod.title"/>
1852 <Button style="@Btn" text="Close" command="{x:Bind cx.close()}"/>
1853 <ToggleButton style="@Toggle" text="Yog" checked="{x:Bind filter == Filter::Yog}"
1854 enabled="{x:Bind has(Filter::Yog)}" command="{x:Bind set_filter(Filter::Yog)}"/>
1855 <TextBox w="100" h="14" text="{x:Bind search, Mode=TwoWay}" placeholder="@lang:mymod.search"
1856 command="{x:Bind submit()}"/>
1857 <ScrollView h="50" scroll_key="{x:Bind search}" visible="{x:Bind show}"/>
1858 </Column>"##)
1859 .unwrap();
1860 for want in [
1861 "const _: () = ::yog_kit::__codegen::require(2);",
1862 "type Vm = Vm;",
1863 ".color(::yog_api::ui::Color(0xFFFFD700))",
1864 r#".id("count")"#,
1865 "pub const COUNT: &str = \"count\";",
1866 "if self.b0.set(::yog_kit::bind::text(&(vm.count_text()))) { root.at_path_mut(&[0, 0]).set_text(",
1867 r#"::yog_kit::bind::text(&(::yog_api::tr("mymod.title")))"#,
1868 ".state_style(::yog_api::ui::StateFlags::HOVER, ::yog_api::ui::StylePatch { bg: ::std::option::Option::Some(::yog_api::ui::Color(0xFF444444)), ..::std::default::Default::default() })",
1869 ".state_style(::yog_api::ui::StateFlags::CHECKED",
1870 ".action(::yog_api::ui::Action { owner: 0, slot: 0, index: 0 })",
1871 ".action(::yog_api::ui::Action { owner: 0, slot: 1, index: 0 })",
1872 "::yog_kit::bind::set_checked(root.at_path_mut(&[0, 3]), *self.",
1873 "::yog_kit::bind::flag(vm.filter == Filter::Yog)",
1874 "slot: 0, .. }) => {\n cx.close();",
1875 "slot: 1, .. }) => {\n vm.set_filter(Filter::Yog);",
1876 "parts.focusable(&[0, 2]);",
1877 "parts.text_box(&[0, 4]);",
1878 "parts.scroller(&[0, 5]);",
1879 "u.set_text_box(0, self.",
1880 "::yog_kit::ViewEvent::TextChanged { slot: 0, text } => {\n vm.search = text;",
1881 "::yog_kit::ViewEvent::TextSubmitted { slot: 0, text } => {\n let _ = text; vm.submit();",
1882 "u.reset_scroll(0);",
1883 ".visible = *self.",
1884 "::yog_kit::Screen::<View>::register(registry, vm)",
1885 "<Vm as ::std::default::Default>::default()",
1886 ] {
1887 assert!(code.contains(want), "missing {want}\n{code}");
1888 }
1889 assert!(
1890 !code.contains("text=") || code.contains("// @yui"),
1891 "{code}"
1892 );
1893 }
1894
1895 #[test]
1896 fn bound_sources() {
1897 let code = codegen(
1898 r##"
1899 <Column>
1900 <Surface w="10" h="10" source="{x:Bind grid_id()}"/>
1901 <Image w="10" h="10" source="{x:Bind icon}"/>
1902 </Column>"##,
1903 )
1904 .unwrap();
1905 for want in [
1906 r#"widget::surface("yog:none")"#,
1907 r#"widget::image("item:air")"#,
1908 "::yog_kit::bind::set_source(root.at_path_mut(&[0, 0]), ",
1909 "::yog_kit::bind::set_source(root.at_path_mut(&[0, 1]), ",
1910 ] {
1911 assert!(code.contains(want), "missing {want}\n{code}");
1912 }
1913 }
1914
1915 #[test]
1916 fn component_properties() {
1917 let code = codegen(r##"
1918 <Column>
1919 <StatBar id="hp" w="80" label="HP" value="{x:Bind hp}" fill="{x:Bind color()}" visible="{x:Bind alive}"/>
1920 <ListView h="40" items="{x:Bind rows}" key="{x:Bind id}">
1921 <ListView.ItemTemplate>
1922 <DataTemplate>
1923 <Pill text="{x:Bind name}"/>
1924 </DataTemplate>
1925 </ListView.ItemTemplate>
1926 </ListView>
1927 </Column>"##)
1928 .unwrap();
1929 for want in [
1930 "__props |= self.b0.set(::yog_kit::bind::text(&(vm.hp)));",
1931 "__props |= self.b1.set(::yog_kit::bind::text(&(vm.color())));",
1932 r#"::yog_api::ui::component::build("StatBar", &[("label", "HP"), ("value", &**self.b0.get()), ("fill", &**self.b1.get())])"#,
1933 "u.relayout(); self.b2.reset();",
1934 r#"::yog_api::ui::component::build("Pill", &[("text", &**inst."#,
1935 ] {
1936 assert!(code.contains(want), "missing {want}\n{code}");
1937 }
1938 assert!(codegen(r#"<StatBar value="{x:Bind hp, Mode=TwoWay}"/>"#).is_err());
1939 }
1940
1941 #[test]
1942 fn lists() {
1943 let code = codegen(
1944 r##"
1945 <ListView w="100%" h="80" items="{x:Bind visible()}" key="{x:Bind id}"
1946 selected="{x:Bind selected_id(), BindBack=select}" item_height="12">
1947 <ListView.ItemTemplate>
1948 <DataTemplate x:DataType="Entry">
1949 <Row w="100%" selected:bg="#FF333366">
1950 <Label text="{x:Bind name}"/>
1951 <Button text="x" command="{x:Bind vm.remove(id)}"/>
1952 </Row>
1953 </DataTemplate>
1954 </ListView.ItemTemplate>
1955 <ListView.Empty>
1956 <Label text="{x:Bind empty_text()}"/>
1957 </ListView.Empty>
1958 </ListView>
1959 <ContentPresenter content="{x:Bind selected()}">
1960 <DataTemplate><Label text="{x:Bind Strings::v(version)}"/></DataTemplate>
1961 </ContentPresenter>"##,
1962 )
1963 .unwrap();
1964 for want in [
1965 "struct __T0 {",
1966 "fn __E0() -> ::yog_api::ui::Widget",
1967 "l1: ::yog_kit::items::ItemsHost<__T0>,",
1968 "::yog_kit::items::ItemsHost::new(1, &[0], ::std::option::Option::Some(12.0), ::std::option::Option::Some(0), true)",
1969 "parts.list(1, &[0], ::std::option::Option::Some(0), ::std::option::Option::Some(12.0));",
1970 "let __sel = vm.selected_id();",
1971 "for (index, item) in ::std::iter::IntoIterator::into_iter(vm.visible()).enumerate() {",
1972 "__it.next(::yog_kit::bind::key(&(item.id)), ::yog_kit::bind::is_selected(&__sel, &(item.id)), __T0::build)",
1973 "if inst.b0.set(::yog_kit::bind::text(&(item.name))) { w.at_path_mut(&[0]).set_text(",
1974 "if let ::std::option::Option::Some((w, u)) = __it.finish(::std::option::Option::Some(__E0)) {",
1975 "self.b0.set(::yog_kit::bind::text(&(vm.empty_text())))",
1976 ".action(::yog_api::ui::Action { owner: 1, slot: 0, index: 0 })",
1977 "::yog_kit::ViewEvent::Action(::yog_api::ui::Action { owner: 1, slot: 0, index }) => {",
1978 "::std::clone::Clone::clone(&(item.id))",
1979 "if let ::std::option::Option::Some((__a0,)) = __args { vm.remove(__a0); }",
1980 "| ::yog_kit::ViewEvent::Select { list: 1, index } => {",
1981 "vm.select(__k);",
1982 "::yog_kit::items::ItemsHost::new(2, &[1], ::std::option::Option::None, ::std::option::Option::None, false)",
1983 "into_iter(vm.selected()).enumerate()",
1984 "Strings::v(&item.version)",
1985 "__it.finish(::std::option::Option::None);",
1986 ] {
1987 assert!(code.contains(want), "missing {want}\n{code}");
1988 }
1989 }
1990
1991 #[test]
1992 fn errors() {
1993 let e = |b: &str| codegen(b).unwrap_err();
1994 assert!(e(r#"<Button on_click="x"/>"#).contains("command="),);
1995 assert!(e(r#"<Label w="{x:Bind a}" hover:w="3"/>"#).contains("paint attributes"));
1996 assert!(e(r#"<TextBox text="{x:Bind name(), Mode=TwoWay}"/>"#).contains("TwoWay"));
1997 assert!(e(r#"<Label text="{x:Bind a < b}"/>"#).contains("unexpected"));
1998 assert!(e(r#"<Label color="@Nope"/>"#).contains("no resource"));
1999 assert!(e(r#"<ListView items="{x:Bind xs}"/>"#).contains("item template"));
2000 assert!(e(r#"<Label text="{x:Bind index}"/>"#).contains("item templates"));
2001 }
2002
2003 #[test]
2006 fn kit_fixture() {
2007 let dir =
2008 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../yog-kit/tests/fixtures");
2009 for name in ["list", "grid"] {
2010 let src = std::fs::read_to_string(dir.join(format!("{name}.yui"))).unwrap();
2011 let (code, warnings) = crate::yui::generate_for(&src, name, None).unwrap();
2012 assert!(warnings.is_empty(), "{warnings:?}");
2013 let out = dir.join(format!("{name}.g.rs"));
2014 if std::env::var_os("UPDATE_GOLDEN").is_some() {
2015 std::fs::write(&out, &code).unwrap();
2016 }
2017 let want = std::fs::read_to_string(&out).unwrap_or_default();
2018 assert!(
2019 want == code,
2020 "{} is stale; rerun with UPDATE_GOLDEN=1",
2021 out.display()
2022 );
2023 }
2024 }
2025
2026 #[test]
2027 fn legacy_screens_unchanged() {
2028 let src = r#"<Screen id="t:s"><Label text="@lang:t.k" on_click="go"/></Screen>"#;
2029 let (code, _) = crate::yui::generate_for(src, "s", None).unwrap();
2030 assert!(code.contains("pub fn render(gfx"), "{code}");
2031 assert!(!code.contains("yog_kit"), "{code}");
2032 }
2033}