Skip to main content

yog_cli/
yui_vm.rs

1//! View-model screens: a `.yui` whose `<Screen>` has `x:ViewModel="MyVm"`
2//! compiles into a `yog_kit::View` — the static widget tree (built by
3//! [`crate::yui`]'s generator), compiled `{x:Bind ...}` bindings patching
4//! it, commands, two-way text boxes and toggles, lists with item templates,
5//! and the `register`/`open`/`close` functions. See `docs/mvvm.md` in
6//! Yog-IDLE for the format.
7
8use std::collections::HashMap;
9use std::fmt::Write as _;
10
11use crate::yui::{self, Element, RAW_CALL, RAW_VALUE, UI_USE, err};
12
13/// The codegen version checked by `yog_kit::__codegen::require`.
14const 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// ── binding expressions ──────────────────────────────────────────────────────
26
27#[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/// Which names a binding sees.
274#[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    /// A field path (`search`, `vm.state.name`, `item.id`): assignable, and
298    /// passed by reference to type-path calls.
299    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/// One `{x:Bind ...}` (or a `@Set.name` text, compiled as a binding).
372#[derive(Debug, Clone)]
373struct Binding {
374    /// The attribute value as written, for comments.
375    raw: String,
376    expr: Option<Ex>,
377    /// Pre-emitted Rust (resource texts).
378    rust: Option<String>,
379    mode: Mode,
380    bind_back: Option<String>,
381    fallback: Option<String>,
382}
383
384impl Binding {
385    /// The bound value as Rust in `scope`.
386    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
397/// Splits on commas outside parentheses and quotes.
398fn 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
460/// Compiles an attribute value `{x:Bind expr}` into a Rust expression that
461/// reads the view model `vm` (bare names become `vm.name`, `Type::f(x)`
462/// gets `&vm.x`, `a?.b` stays a `?`-chain — wrap it in a closure returning
463/// `Option`). `None` when `value` isn't a binding. Options (`Mode=`,
464/// `BindBack=`, `Fallback=`) are parsed and ignored here. Shared with the
465/// `.yui` component generator so there's one binding syntax.
466pub(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// ── generator ────────────────────────────────────────────────────────────────
475
476/// What a bound attribute converts to and how it's applied.
477#[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
520/// Where generated update code writes: the view or a template instance.
521struct Ctx {
522    scope: Scope,
523    /// `self.` or `inst.`
524    fields: &'static str,
525    /// The tree variable: `root` or `w`.
526    tree: &'static str,
527    /// List owner of actions (0 = screen).
528    owner: u16,
529    /// Slot fields declared (name, type) — for templates.
530    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    /// `<Screen theme>`: element defaults under its own attributes and styles.
551    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 (name, type, init).
556    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    /// Next command slot per owner.
566    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    /// Resolves `@local` resources, `x:Name` and `style` into plain attributes.
603    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        // Theme defaults come last: own attributes and styles win.
646        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        // Styles, resolving based_on chains in any order.
671        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    /// Converts and applies one bound attribute.
715    #[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    /// The command statement for `expr`, in screen scope.
804    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                // vm.method(args) / cx.method(): item arguments are cloned
823                // out of the item first.
824                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    /// Compiles `el` at `path` in `ctx`; returns the static element.
870    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                    // Looked up on every update, so it follows the language.
1008                    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        // Interactive parts (screen scope only).
1037        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        // Commands and two-way toggles → an action.
1058        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        // Lists.
1150        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        // A component's bound properties: when any changes, the instance is
1158        // rebuilt with the new values (then its other bindings re-applied).
1159        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            // A valid stand-in for the static checks; the binding sets it.
1169            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    /// Binds the component properties among `bindings` (removing them):
1207    /// one text slot each, and an update block that rebuilds the instance
1208    /// at `path` from `out` (its static attributes) plus the slots' values.
1209    /// Returns the marker the block leaves for the resets of the element's
1210    /// other bindings.
1211    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    /// The single element inside a template/empty property element.
1263    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        // Templates.
1327        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        // Empty state: built once, bound in view scope.
1376        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        // Update.
1416        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        // Selection write-back.
1473        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        // A list's own bindings other than items/key/selected (bg, w, ...)
1499        // are applied by the caller.
1500        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
1510/// Generates a view-model screen's `.g.rs` (see the module docs).
1511pub 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    // View struct.
1623    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 &lt; 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    /// The yog-kit fixture's committed `.g.rs` matches the generator
2004    /// (`UPDATE_GOLDEN=1` rewrites it); yog-kit compiles and runs it.
2005    #[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}