interpret.rs raw

//! The IR interpreter: widgets, styles, bindings, and event wiring built at
//! runtime, mirroring the proc-macro's generated code. Component instances
//! recurse: the child's IR comes from the compiled set, its handlers from
//! the spec's child factories, its prop signals from expressions evaluated
//! in the parent's context (and driven by parent-scope effects thereafter).

use std::collections::BTreeMap;
use std::rc::Rc;

use guiduck_component_core::ast::TypeKind;
use guiduck_component_core::expr::{BinOp, Expr, Segment, UnOp};
use guiduck_component_core::ir::*;
use guiduck_component_core::registry::{EventProp, PayloadKind, PropTy};
use guiduck_core::component::{
    ComponentSpec, DynCx, DynEmitter, DynSignal, DynValue, Emitter, warn_component_skipped,
    warn_widget_skipped,
};
use guiduck_core::content::ContentBuilder;
use guiduck_core::event::UserValue;
use guiduck_core::graphic::Graphic;
use guiduck_core::signals::{Effect, Scope, Signal};
use guiduck_core::widget::dynamic::Extent;
// The interpreter names no widget type but the fallback box: every widget it
// builds comes out of the link-time registry, which is the whole point.
use guiduck_core::{
    Container, EventKind, RichText, ScrollAxes, TextSpan, WidgetId, WidgetTree, taffy,
};
use guiduck_scene::paint::{Brush, Color};

/// A live interpreted component instance.
pub struct Instantiated {
    pub root: WidgetId,
    /// Owns the binding effects; disposed when the instance is replaced.
    pub scope: Scope,
    /// The component instances this one built (including any inside slot
    /// content it projected), by identity — the reload machinery snapshots
    /// and restores their state through this.
    pub(crate) nested: Vec<NestedInstance>,
}

/// One nested instance: its identity, live context, and its own nesting.
pub(crate) struct NestedInstance {
    pub(crate) id: InstanceId,
    pub(crate) cx: Rc<DynCx>,
    pub(crate) inner: Instantiated,
}

/// Instance identity for state matching across reloads: the component type
/// plus its `:key`, or `#n` for the n-th unkeyed same-type instance.
#[derive(Clone, PartialEq, Eq, Hash)]
pub(crate) struct InstanceId {
    pub(crate) component: String,
    pub(crate) key: String,
}

/// Build the [`DynCx`] for a component: state signals from IR inits, output
/// emitters, and prop signals seeded from the compiled side's snapshot
/// (falling back to the IR's declared default when the snapshot lacks a
/// prop, which can happen mid-reload after the file gained one).
pub fn make_dyn_cx(ir: &Ir, spec_props: &[(String, DynValue)]) -> DynCx {
    let mut cx = DynCx::default();
    for state in &ir.states {
        cx.signals.insert(
            state.name.clone(),
            make_signal(state.ty.into(), &state.init),
        );
    }
    for output in &ir.outputs {
        cx.emitters
            .insert(output.name.clone(), make_emitter(output.ty.into()));
    }
    for prop in &ir.props {
        let ty: DynType = prop.ty.into();
        let signal = match spec_props.iter().find(|(name, _)| *name == prop.name) {
            Some((_, value)) => make_signal_from_value(ty, value),
            None => make_signal(ty, prop.default.as_ref().unwrap_or(&Literal::Int(0))),
        };
        cx.props.insert(prop.name.clone(), signal);
    }
    cx
}

/// The `.gdc` type of a signal, for reload diffing.
#[derive(Copy, Clone, PartialEq, Eq)]
pub(crate) struct DynType {
    pub(crate) kind: DynKind,
    pub(crate) list: bool,
}

#[derive(Copy, Clone, PartialEq, Eq)]
pub(crate) enum DynKind {
    I32,
    I64,
    F32,
    F64,
    Bool,
    Str,
    Record,
}

impl From<guiduck_component_core::ast::TypeKind> for DynKind {
    fn from(ty: guiduck_component_core::ast::TypeKind) -> Self {
        use guiduck_component_core::ast::TypeKind as T;
        match ty {
            T::I32 => Self::I32,
            T::I64 => Self::I64,
            T::F32 => Self::F32,
            T::F64 => Self::F64,
            T::Bool => Self::Bool,
            T::String => Self::Str,
        }
    }
}

impl From<guiduck_component_core::ast::TypeKind> for DynType {
    fn from(ty: guiduck_component_core::ast::TypeKind) -> Self {
        Self {
            kind: ty.into(),
            list: false,
        }
    }
}

impl From<ValueTy> for DynType {
    fn from(ty: ValueTy) -> Self {
        Self {
            kind: match ty.kind {
                ElemKind::Scalar(kind) => kind.into(),
                ElemKind::Record(_) => DynKind::Record,
            },
            list: ty.list,
        }
    }
}

pub(crate) fn signal_type(signal: &DynSignal) -> DynType {
    let (kind, list) = match signal {
        DynSignal::I32(_) => (DynKind::I32, false),
        DynSignal::I64(_) => (DynKind::I64, false),
        DynSignal::F32(_) => (DynKind::F32, false),
        DynSignal::F64(_) => (DynKind::F64, false),
        DynSignal::Bool(_) => (DynKind::Bool, false),
        DynSignal::Str(_) => (DynKind::Str, false),
        DynSignal::ListI32(_) => (DynKind::I32, true),
        DynSignal::ListI64(_) => (DynKind::I64, true),
        DynSignal::ListF32(_) => (DynKind::F32, true),
        DynSignal::ListF64(_) => (DynKind::F64, true),
        DynSignal::ListBool(_) => (DynKind::Bool, true),
        DynSignal::ListStr(_) => (DynKind::Str, true),
        DynSignal::RecordList(_) => (DynKind::Record, true),
        DynSignal::RecordValue(_) => (DynKind::Record, false),
    };
    DynType { kind, list }
}

/// A compile-time literal as a dynamic value.
fn literal_dyn(literal: &Literal) -> DynValue {
    match literal {
        Literal::Int(v) => DynValue::I64(*v),
        Literal::Float(v) => DynValue::F64(*v),
        Literal::Bool(v) => DynValue::Bool(*v),
        Literal::Str(s) => DynValue::Str(s.clone()),
        Literal::Color(..) => DynValue::I64(0),
        Literal::List(items) => DynValue::List(items.iter().map(literal_dyn).collect()),
        Literal::Record { fields, .. } => DynValue::Record(
            fields
                .iter()
                .map(|(name, value)| (name.clone(), literal_dyn(value)))
                .collect(),
        ),
        // These reach the interpreter through the *property* that takes one,
        // which boxes it at that property's own type; they never cross the
        // dynamic value bridge, because no `.gdc` state or prop has one of
        // these types.
        Literal::Path(_) | Literal::Asset(_) | Literal::Rich(_) | Literal::ScrollAxes(_) => {
            unreachable!("not a dynamic value")
        }
    }
}

/// Build a `:source` graphic for a dev-mode mount.
///
/// Where the typed build embeds an asset's bytes, dev mode reads them from
/// disk through the same search path — so editing the file shows up on the
/// next reload without a rebuild.
fn load_graphic(source: &Literal, dirs: &[std::path::PathBuf]) -> Result<Graphic, String> {
    match source {
        Literal::Path(cmds) => Ok(Graphic::from_path_cmds(cmds)),
        Literal::Asset(path) => {
            let found = guiduck_component_core::resolve::find_asset(dirs, path)
                .map_err(|why| format!("cannot resolve asset `{path}`: {why}"))?;
            let bytes = std::fs::read(&found)
                .map_err(|e| format!("cannot read {}: {e}", found.display()))?;
            let brush = guiduck_core::asset::decode_image(&bytes).map_err(|why| {
                format!("asset `{path}` is not an image this build can decode: {why}")
            })?;
            Ok(Graphic::Image(brush))
        }
        _ => Err("a graphic is `(asset \"…\")` or `(path …)`".to_owned()),
    }
}

fn make_signal(ty: DynType, init: &Literal) -> DynSignal {
    if ty.list && ty.kind == DynKind::Record {
        let elements = match literal_dyn(init) {
            DynValue::List(items) => items,
            _ => Vec::new(),
        };
        return DynSignal::RecordList(Signal::new(elements));
    }
    if ty.list {
        let empty = Vec::new();
        let elements = match init {
            Literal::List(items) => items,
            _ => &empty,
        };
        return match ty.kind {
            DynKind::I32 => DynSignal::ListI32(Signal::new(
                elements.iter().map(|e| literal_i64(e) as i32).collect(),
            )),
            DynKind::I64 => {
                DynSignal::ListI64(Signal::new(elements.iter().map(literal_i64).collect()))
            }
            DynKind::F32 => DynSignal::ListF32(Signal::new(
                elements.iter().map(|e| literal_f64(e) as f32).collect(),
            )),
            DynKind::F64 => {
                DynSignal::ListF64(Signal::new(elements.iter().map(literal_f64).collect()))
            }
            DynKind::Bool => DynSignal::ListBool(Signal::new(
                elements
                    .iter()
                    .map(|e| matches!(e, Literal::Bool(true)))
                    .collect(),
            )),
            DynKind::Str => DynSignal::ListStr(Signal::new(
                elements
                    .iter()
                    .map(|e| match e {
                        Literal::Str(s) => s.clone(),
                        _ => String::new(),
                    })
                    .collect(),
            )),
            DynKind::Record => unreachable!("handled above"),
        };
    }
    match ty.kind {
        // Single-record state is rejected during validation; a placeholder
        // keeps this total mid-reload.
        DynKind::Record => DynSignal::RecordValue(Signal::new(DynValue::Record(Vec::new()))),
        DynKind::I32 => DynSignal::I32(Signal::new(literal_i64(init) as i32)),
        DynKind::I64 => DynSignal::I64(Signal::new(literal_i64(init))),
        DynKind::F32 => DynSignal::F32(Signal::new(literal_f64(init) as f32)),
        DynKind::F64 => DynSignal::F64(Signal::new(literal_f64(init))),
        DynKind::Bool => DynSignal::Bool(Signal::new(matches!(init, Literal::Bool(true)))),
        DynKind::Str => DynSignal::Str(Signal::new(match init {
            Literal::Str(s) => s.clone(),
            _ => String::new(),
        })),
    }
}

/// A signal seeded from a dynamic value, coerced to the declared type.
fn make_signal_from_value(ty: DynType, value: &DynValue) -> DynSignal {
    fn as_i64(value: &DynValue) -> i64 {
        match value {
            DynValue::I64(v) => *v,
            DynValue::F64(v) => *v as i64,
            DynValue::Bool(v) => *v as i64,
            DynValue::Str(_) | DynValue::List(_) | DynValue::Record(_) => 0,
        }
    }
    fn as_f64(value: &DynValue) -> f64 {
        match value {
            DynValue::I64(v) => *v as f64,
            DynValue::F64(v) => *v,
            DynValue::Bool(v) => *v as u8 as f64,
            DynValue::Str(_) | DynValue::List(_) | DynValue::Record(_) => 0.0,
        }
    }
    if ty.list && ty.kind == DynKind::Record {
        let elements = match value {
            DynValue::List(items) => items.clone(),
            _ => Vec::new(),
        };
        return DynSignal::RecordList(Signal::new(elements));
    }
    if ty.list {
        let empty = Vec::new();
        let elements = match value {
            DynValue::List(items) => items,
            _ => &empty,
        };
        return match ty.kind {
            DynKind::I32 => DynSignal::ListI32(Signal::new(
                elements.iter().map(|e| as_i64(e) as i32).collect(),
            )),
            DynKind::I64 => DynSignal::ListI64(Signal::new(elements.iter().map(as_i64).collect())),
            DynKind::F32 => DynSignal::ListF32(Signal::new(
                elements.iter().map(|e| as_f64(e) as f32).collect(),
            )),
            DynKind::F64 => DynSignal::ListF64(Signal::new(elements.iter().map(as_f64).collect())),
            DynKind::Bool => DynSignal::ListBool(Signal::new(
                elements
                    .iter()
                    .map(|e| matches!(e, DynValue::Bool(true)))
                    .collect(),
            )),
            DynKind::Str => DynSignal::ListStr(Signal::new(
                elements
                    .iter()
                    .map(|e| match e {
                        DynValue::Str(s) => s.clone(),
                        _ => String::new(),
                    })
                    .collect(),
            )),
            DynKind::Record => unreachable!("handled above"),
        };
    }
    match ty.kind {
        DynKind::Record => DynSignal::RecordValue(Signal::new(value.clone())),
        DynKind::I32 => DynSignal::I32(Signal::new(as_i64(value) as i32)),
        DynKind::I64 => DynSignal::I64(Signal::new(as_i64(value))),
        DynKind::F32 => DynSignal::F32(Signal::new(as_f64(value) as f32)),
        DynKind::F64 => DynSignal::F64(Signal::new(as_f64(value))),
        DynKind::Bool => DynSignal::Bool(Signal::new(matches!(value, DynValue::Bool(true)))),
        DynKind::Str => DynSignal::Str(Signal::new(match value {
            DynValue::Str(s) => s.clone(),
            _ => String::new(),
        })),
    }
}

fn make_emitter(ty: DynKind) -> DynEmitter {
    match ty {
        // Record outputs are rejected during validation.
        DynKind::Record => DynEmitter::I64(Emitter::new()),
        DynKind::I32 => DynEmitter::I32(Emitter::new()),
        DynKind::I64 => DynEmitter::I64(Emitter::new()),
        DynKind::F32 => DynEmitter::F32(Emitter::new()),
        DynKind::F64 => DynEmitter::F64(Emitter::new()),
        DynKind::Bool => DynEmitter::Bool(Emitter::new()),
        DynKind::Str => DynEmitter::Str(Emitter::new()),
    }
}

fn literal_i64(literal: &Literal) -> i64 {
    match literal {
        Literal::Int(v) => *v,
        Literal::Float(v) => *v as i64,
        Literal::Bool(v) => *v as i64,
        _ => 0,
    }
}

fn literal_f64(literal: &Literal) -> f64 {
    match literal {
        Literal::Int(v) => *v as f64,
        Literal::Float(v) => *v,
        _ => 0.0,
    }
}

/// The projected-content builder a parent hands a slotted child: invoked at
/// the child's `(slot)` with the widget that hosts the content.
type SlotFill<'a> = dyn FnMut(&mut WidgetTree, Option<WidgetId>) + 'a;

/// Instantiate the IR's widget tree under `parent`. Every reactive node the
/// instance owns — binding effects, and for nested component instances their
/// scopes, prop/state signals, and driving effects — is created inside the
/// returned scope, so disposing it (plus removing the root) tears the
/// instance down completely.
pub fn instantiate(
    tree: &mut WidgetTree,
    parent: Option<WidgetId>,
    ir: &Ir,
    cx: &Rc<DynCx>,
    spec: &ComponentSpec,
    components: &BTreeMap<String, Ir>,
) -> Instantiated {
    instantiate_slotted(tree, parent, ir, cx, spec, components, None)
}

fn instantiate_slotted(
    tree: &mut WidgetTree,
    parent: Option<WidgetId>,
    ir: &Ir,
    cx: &Rc<DynCx>,
    spec: &ComponentSpec,
    components: &BTreeMap<String, Ir>,
    mut slot: Option<&mut SlotFill<'_>>,
) -> Instantiated {
    let scope = Scope::new();
    let instances = std::cell::RefCell::new(InstanceCollector::default());
    let ctx = BuildCx {
        ir,
        cx,
        spec,
        components,
        instances: &instances,
    };
    let root = scope.run(|| build_node(tree, ctx, 0, parent, slot.as_deref_mut()));
    let root = match root {
        // A component's root is never a structural form (validation rejects
        // it), so the root extent is always a single widget.
        Some(Extent::Single(root)) => root,
        Some(Extent::Region { lead, .. }) => lead,
        // The root itself was a component instance the compiled side does
        // not know (dev-mode only); keep the mount alive with an empty
        // container so a later reload can heal it.
        None => tree.insert(Container::new(), taffy::Style::default(), parent),
    };
    // The subtree is built and wired, which is the moment the compiled path
    // runs the hook too — so a signal it sets drives bindings that are already
    // watching, and a nested instance's hook has already run (it mounted
    // during the walk above), exactly as it does under codegen.
    spec.handlers.borrow_mut().mounted(cx);
    Instantiated {
        root,
        scope,
        nested: instances.into_inner().nested,
    }
}

/// The nested instances a component builds, plus per-type occurrence
/// counters for unkeyed identity.
#[derive(Default)]
struct InstanceCollector {
    nested: Vec<NestedInstance>,
    occurrences: std::collections::HashMap<String, usize>,
}

/// The component whose file is being built: its IR, live context, compiled
/// spec, the compiled component set, and the instance collector. Copyable
/// references, so slot-fill closures capture it wholesale — an instance
/// inside projected content records into the *projecting* component's
/// collector, because it is that component's content.
#[derive(Copy, Clone)]
struct BuildCx<'a> {
    ir: &'a Ir,
    cx: &'a Rc<DynCx>,
    spec: &'a ComponentSpec,
    components: &'a BTreeMap<String, Ir>,
    instances: &'a std::cell::RefCell<InstanceCollector>,
}

/// Build one node — and, below it, its subtree. Returns the node's widget
/// (`None` for a slot marker or a skipped instance). Runs inside the
/// instance's scope, so every reactive node it creates is owned there.
fn build_node(
    tree: &mut WidgetTree,
    ctx: BuildCx<'_>,
    index: usize,
    widget_parent: Option<WidgetId>,
    mut slot: Option<&mut SlotFill<'_>>,
) -> Option<Extent> {
    let BuildCx { ir, cx, spec, .. } = ctx;
    let node = &ir.nodes[index];
    let style = style_to_taffy(&node.style);
    // One widget node, whether the vocabulary it came from is the framework's
    // or an application's. Everything from the construction down — `:class`,
    // `:enabled`, the pointer events, `:tooltip` — is the vocabulary *every*
    // widget has, so it is written once and both fall through it, as in the
    // codegen twin.
    let widget = match &node.widget {
        IrWidget::Widget(widget) => widget,
        IrWidget::Component(child_name) => {
            return instantiate_instance(tree, ctx, widget_parent, node, child_name, slot)
                .map(Extent::Single);
        }
        // The slot marker: hand the projected content the enclosing
        // widget, mid-sequence, so sibling order is preserved.
        IrWidget::Slot => {
            if let Some(fill) = slot {
                fill(tree, widget_parent);
            }
            return None;
        }
        // A nested region reports its anchored run, which is what lets an
        // enclosing region place, move, and retire it as one body.
        IrWidget::If => return build_if(tree, ctx, node, widget_parent),
        IrWidget::For => return build_for(tree, ctx, node, widget_parent),
    };
    let id = build_widget(tree, ctx, widget, style, widget_parent);

    if !node.classes.is_empty() {
        tree.set_classes(id, node.classes.iter().cloned());
    }

    install_enabled(tree, id, node, cx);
    install_bindings(tree, id, &ir.name, widget, cx);

    for wire in &node.events {
        let kind = event_kind(wire.event);
        let handlers = Rc::clone(&spec.handlers);
        let cx = Rc::clone(cx);
        let handler = wire.handler.clone();
        // Keyed on the payload kind the registry declares, not on which event
        // it is — the same rule the typed back end follows, which is what lets
        // `:on-link` ride in with no arm of its own on either side.
        match wire.event.payload() {
            // Payload-carrying events forward the widget's value
            // dynamically; the generated registry re-types it.
            Some(PayloadKind::String) => {
                let arg_exprs = wire.args.clone();
                tree.on_event(id, kind, move |ctx, ev| {
                    let Some(value) = ev.string_payload() else {
                        return;
                    };
                    let args: Vec<DynValue> = if arg_exprs.is_empty() {
                        vec![DynValue::Str(value.to_owned())]
                    } else {
                        let payload_value = Value::Str(value.to_owned());
                        arg_exprs
                            .iter()
                            .map(|arg| {
                                eval_scoped(arg, &cx, &[("payload", &payload_value)]).into_dyn()
                            })
                            .collect()
                    };
                    if handlers.borrow_mut().invoke(&handler, &cx, &args) {
                        ctx.stop_propagation();
                    }
                });
            }
            Some(PayloadKind::Bool) => {
                let arg_exprs = wire.args.clone();
                tree.on_event(id, kind, move |ctx, ev| {
                    let Some(value) = ev.bool_payload() else {
                        return;
                    };
                    let args: Vec<DynValue> = if arg_exprs.is_empty() {
                        vec![DynValue::Bool(value)]
                    } else {
                        let payload_value = Value::Bool(value);
                        arg_exprs
                            .iter()
                            .map(|arg| {
                                eval_scoped(arg, &cx, &[("payload", &payload_value)]).into_dyn()
                            })
                            .collect()
                    };
                    if handlers.borrow_mut().invoke(&handler, &cx, &args) {
                        ctx.stop_propagation();
                    }
                });
            }
            Some(PayloadKind::Number) => {
                let arg_exprs = wire.args.clone();
                tree.on_event(id, kind, move |ctx, ev| {
                    let Some(value) = ev.number_payload() else {
                        return;
                    };
                    let args: Vec<DynValue> = if arg_exprs.is_empty() {
                        vec![DynValue::F64(value)]
                    } else {
                        let payload_value = Value::F64(value);
                        arg_exprs
                            .iter()
                            .map(|arg| {
                                eval_scoped(arg, &cx, &[("payload", &payload_value)]).into_dyn()
                            })
                            .collect()
                    };
                    if handlers.borrow_mut().invoke(&handler, &cx, &args) {
                        ctx.stop_propagation();
                    }
                });
            }
            // Payload-free wires evaluate their arguments at dispatch time,
            // in this node's scope (`cx` is the row's for `for` bodies), with
            // `event` bound on the wires that have one. It rides in as an
            // ordinary record value, so `event.x` resolves through the same
            // field access `todo.id` does — the pseudo-record is only pseudo
            // in the compiler, where it has no table entry to be.
            None => {
                let arg_exprs = wire.args.clone();
                tree.on_event(id, kind, move |ctx, ev| {
                    let event_value = ev
                        .pointer()
                        .map(|p| {
                            Value::Record(vec![
                                ("x".to_owned(), Value::F64(p.local.x)),
                                ("y".to_owned(), Value::F64(p.local.y)),
                                ("click-count".to_owned(), Value::I64(p.click_count as i64)),
                            ])
                        })
                        .or_else(|| {
                            ev.key().map(|spelling| {
                                Value::Record(vec![(
                                    "key".to_owned(),
                                    Value::Str(spelling.to_owned()),
                                )])
                            })
                        })
                        .or_else(|| {
                            ev.scrolled().map(|(x, y)| {
                                Value::Record(vec![
                                    ("offset-x".to_owned(), Value::F64(x)),
                                    ("offset-y".to_owned(), Value::F64(y)),
                                ])
                            })
                        });
                    let args: Vec<DynValue> = arg_exprs
                        .iter()
                        .map(|arg| match &event_value {
                            Some(value) => eval_scoped(arg, &cx, &[("event", value)]).into_dyn(),
                            None => eval(arg, &cx).into_dyn(),
                        })
                        .collect();
                    if handlers.borrow_mut().invoke(&handler, &cx, &args) {
                        ctx.stop_propagation();
                    }
                });
            }
        }
    }

    for wire in &widget.events {
        install_user_event(tree, id, spec, wire, cx);
    }
    for query in &widget.queries {
        install_user_query(tree, id, spec, query, cx);
    }

    // Asking for focus comes after the wires are attached and after
    // `:enabled`: a disabled widget cannot take focus, and a widget that takes
    // it at mount must be able to *report* that through its own
    // `:on-focus-change` — which it cannot if the wire is not there yet. The
    // codegen twin orders these identically.
    if node.autofocus {
        tree.set_focus(Some(id));
    }
    install_focused(tree, id, node, cx);

    if let Some(tooltip) = &node.tooltip {
        tree.set_tooltip(id, Some(tooltip.clone()));
    }

    // Every widget with children is handed its content. The tree consults the
    // widget at runtime: a popup (menu, dropdown, dialog) stores the builder to
    // run when it opens; every other widget mounts its children inline, now.
    // Inline is where a `(slot)` marker lives — the transient fill is threaded
    // through this loop — and a widget that defers never contains one, so the
    // two paths never overlap.
    if tree.defers_content(id) {
        tree.realize_content(id, deferred_content(ctx, node));
        // Accelerators are the exception to deferral: one must fire while its
        // popup is *closed* and its rows do not exist, so it registers here at
        // mount by walking the deferred subtree — the codegen twin does the
        // same, gated on the same runtime fact.
        install_accels(tree, ctx, node, id);
    } else {
        for child in &node.children {
            build_node(tree, ctx, *child, Some(id), slot.as_deref_mut());
        }
    }
    // An ordinary widget occupies one sibling slot, however deep its own
    // subtree: its children are inside it, not beside it.
    Some(Extent::Single(id))
}

/// Build a widget from the link-time registry: construct, then apply each
/// property whose value is known now through the same setter thunk a binding
/// would use.
///
/// **This is the only place the interpreter builds a widget.** The codegen
/// twin emits `<type_path>::default()` followed by setter calls; this does the
/// same thing with both types erased, from the registration `register_widget!`
/// (or `register_builtins!`) submitted. It could name `Container` — it depends
/// on the crate — but that would be a second description of how a builtin is
/// built, free to drift from the registered one. So a `container` is built by
/// the code that builds a `markdown`, and the question cannot arise.
///
/// Dev resilience, the standing convention: a widget this binary has no
/// factory for warns and draws an empty box rather than taking the window down
/// mid-session. The subtree still mounts, so the rest of the file stays
/// reviewable, and a rebuild heals it.
fn build_widget(
    tree: &mut WidgetTree,
    ctx: BuildCx<'_>,
    widget: &IrWidgetNode,
    style: taffy::Style,
    parent: Option<WidgetId>,
) -> WidgetId {
    let BuildCx { ir, spec, .. } = ctx;
    let Some(registration) = guiduck_core::registered_widget(&widget.name) else {
        warn_widget_skipped(&ir.name, &widget.name);
        return tree.insert(Container::default(), style, parent);
    };
    let mut built = (registration.construct)();
    for prop in &widget.props {
        let value = match &prop.value {
            IrPropValue::Static(literal) => match literal_boxed(prop.ty, literal, spec) {
                Ok(value) => value,
                // Dev resilience again: a broken asset warns and draws
                // nothing rather than taking the window down mid-session.
                Err(why) => {
                    eprintln!("guiduck[dev]: {why}");
                    continue;
                }
            },
            // A binding is applied by an effect, not at construction.
            IrPropValue::Binding(_) => continue,
        };
        let Some(setter) = registration.setters.iter().find(|s| s.prop == prop.name) else {
            // The registry is the vocabulary the file was checked against, so a
            // property with no setter means the live file is ahead of the
            // binary; skip it rather than guess a method name.
            warn_widget_prop_skipped(&ir.name, &widget.name, &prop.name);
            continue;
        };
        (setter.apply)(&mut *built, value);
    }
    tree.insert_boxed(built, style, parent)
}

/// Reactive properties: one effect each, applied through the widget's setter.
///
/// [`WidgetTree::bind_dyn`] is [`WidgetTree::bind`] with both types erased —
/// same effect, same command queue, same dirt-collecting borrow — which is
/// what lets the compiled and interpreted builds of the same file produce the
/// same scene.
fn install_bindings(
    tree: &mut WidgetTree,
    id: WidgetId,
    component: &str,
    widget: &IrWidgetNode,
    cx: &Rc<DynCx>,
) {
    let Some(registration) = guiduck_core::registered_widget(&widget.name) else {
        // Already reported by the construction above; this is the empty box.
        return;
    };
    for prop in &widget.props {
        let IrPropValue::Binding(expr) = &prop.value else {
            continue;
        };
        let Some(setter) = registration.setters.iter().find(|s| s.prop == prop.name) else {
            warn_widget_prop_skipped(component, &widget.name, &prop.name);
            continue;
        };
        // The setter thunk is a plain `fn` pointer; `bind_dyn` takes an
        // `Rc`-wrapped one, the shape it shares with the erased typed path.
        let apply = setter.apply;
        let expr = expr.clone();
        let cx = Rc::clone(cx);
        let ty = prop.ty;
        tree.bind_dyn(
            id,
            move || value_boxed(ty, eval(&expr, &cx)),
            std::rc::Rc::new(apply),
        );
    }
}

/// One event wire on a `.gdw`-declared widget.
///
/// Every declared event arrives as the single [`EventKind::User`] — the kind
/// enum is the closed vocabulary's — so the wire matches the name it was
/// written for before it fires, then re-types the payload dynamically the way
/// `:on-change` and `:on-toggle` already do.
/// Install a query wire — `:get-image (load payload)`. The typed work
/// (downcasting the widget, calling the value-returning handler) lives in the
/// generated [`DynHandlers::install_query`]; the interpreter only hands it the
/// widget and the context, keyed by the handler name.
fn install_user_query(
    tree: &mut WidgetTree,
    id: WidgetId,
    spec: &ComponentSpec,
    query: &guiduck_component_core::ir::IrQuery,
    cx: &Rc<DynCx>,
) {
    if let Some(mut widget) = tree.widget_dyn_mut(id) {
        spec.handlers
            .borrow()
            .install_query(&query.handler, &mut *widget, cx);
    }
}

fn install_user_event(
    tree: &mut WidgetTree,
    id: WidgetId,
    spec: &ComponentSpec,
    wire: &IrUserEvent,
    cx: &Rc<DynCx>,
) {
    let handlers = Rc::clone(&spec.handlers);
    let cx = Rc::clone(cx);
    let handler = wire.handler.clone();
    let event_name = wire.name.clone();
    let payload_ty = wire.payload;
    let arg_exprs = wire.args.clone();
    tree.on_event(id, EventKind::User, move |ctx, ev| {
        let Some((name, payload)) = ev.user() else {
            return;
        };
        if name != event_name {
            return;
        }
        // A payload of the wrong family is a widget that broke its own
        // contract; the wire declines rather than guessing, as the compiled
        // twin does.
        let delivered = match payload_ty {
            None => None,
            Some(ty) => match payload.and_then(|value| payload_dyn(ty, value)) {
                Some(value) => Some(value),
                None => return,
            },
        };
        let args: Vec<DynValue> = match (&delivered, arg_exprs.is_empty()) {
            // Bare-name form on a payload-carrying event: the payload is the
            // one argument.
            (Some(value), true) => vec![value.clone()],
            (_, true) => Vec::new(),
            // Invocation form: the arguments evaluate at dispatch, in this
            // node's scope, with `payload` bound where the event delivers one.
            (delivered, false) => {
                let payload_value = delivered.as_ref().map(dyn_to_value);
                arg_exprs
                    .iter()
                    .map(|arg| match &payload_value {
                        Some(value) => eval_scoped(arg, &cx, &[("payload", value)]).into_dyn(),
                        None => eval(arg, &cx).into_dyn(),
                    })
                    .collect()
            }
        };
        if handlers.borrow_mut().invoke(&handler, &cx, &args) {
            ctx.stop_propagation();
        }
    });
}

/// A declared event's payload as a dynamic value at the declared width, or
/// `None` if the widget delivered the wrong family. The generated handler
/// registry re-types it from here, exactly as it does a `:on-change` payload.
fn payload_dyn(ty: TypeKind, value: &UserValue) -> Option<DynValue> {
    Some(match ty {
        TypeKind::I32 => DynValue::I64(value.as_int()? as i32 as i64),
        TypeKind::I64 => DynValue::I64(value.as_int()?),
        TypeKind::F32 => DynValue::F64(value.as_float()? as f32 as f64),
        TypeKind::F64 => DynValue::F64(value.as_float()?),
        TypeKind::Bool => DynValue::Bool(value.as_bool()?),
        TypeKind::String => DynValue::Str(value.as_str()?.to_owned()),
    })
}

/// A static property value, boxed at the type its setter takes — the erased
/// twin of the codegen's `prop_literal_tokens`.
///
/// Every type here is one this crate can name, which is what makes an erased
/// table possible at all: the vocabulary a `.gdc` can put into a widget is the
/// framework's own, whether the widget is a builtin or an application's.
fn literal_boxed(
    ty: PropTy,
    literal: &Literal,
    spec: &ComponentSpec,
) -> Result<Box<dyn std::any::Any>, String> {
    Ok(match ty {
        PropTy::Scalar(TypeKind::I32) => Box::new(literal_i64(literal) as i32),
        PropTy::Scalar(TypeKind::I64) => Box::new(literal_i64(literal)),
        PropTy::Scalar(TypeKind::F32) => Box::new(literal_f64(literal) as f32),
        PropTy::Scalar(TypeKind::F64) => Box::new(literal_f64(literal)),
        PropTy::Scalar(TypeKind::Bool) => Box::new(matches!(literal, Literal::Bool(true))),
        PropTy::Scalar(TypeKind::String) => Box::new(match literal {
            Literal::Str(s) => s.clone(),
            _ => String::new(),
        }),
        PropTy::Brush => Box::new(literal_brush(literal)),
        PropTy::Graphic => Box::new(load_graphic(literal, &spec.asset_path)?),
        PropTy::RichText => Box::new(rich_text(literal)),
        PropTy::ScrollAxes => Box::new(match literal {
            Literal::ScrollAxes(ScrollAxesIr::Horizontal) => ScrollAxes::Horizontal,
            Literal::ScrollAxes(ScrollAxesIr::Both) => ScrollAxes::Both,
            _ => ScrollAxes::Vertical,
        }),
    })
}

/// An evaluated binding value, boxed at the type its setter takes — the erased
/// twin of the codegen's `prop_value_tokens` cast.
///
/// The two agree because they are written from the same [`PropTy`], off the
/// same property, and there is one of each — which is what a second lane for
/// the builtins used to make impossible.
fn value_boxed(ty: PropTy, value: Value) -> Box<dyn std::any::Any> {
    match ty {
        PropTy::Scalar(TypeKind::I32) => Box::new(value.as_i64() as i32),
        PropTy::Scalar(TypeKind::I64) => Box::new(value.as_i64()),
        PropTy::Scalar(TypeKind::F32) => Box::new(value.into_f64() as f32),
        PropTy::Scalar(TypeKind::F64) => Box::new(value.into_f64()),
        PropTy::Scalar(TypeKind::Bool) => Box::new(value.truthy()),
        PropTy::Scalar(TypeKind::String) => Box::new(value.into_string()),
        PropTy::Brush => Box::new(value.into_brush()),
        PropTy::RichText => Box::new(RichText::new(value.into_string())),
        PropTy::Graphic | PropTy::ScrollAxes => {
            unreachable!("validation admits no binding on this property")
        }
    }
}

/// A paragraph's content: a plain string, or a `(rich …)` as the joined string
/// plus a span per run that differs from it. The nesting was resolved at
/// compile time, so this is a flat walk that accumulates byte offsets.
fn rich_text(literal: &Literal) -> RichText {
    let runs = match literal {
        Literal::Rich(runs) => runs,
        // Plain text is rich text with no runs.
        Literal::Str(text) => return RichText::new(text.clone()),
        _ => return RichText::default(),
    };
    let mut content = RichText::new(runs.iter().map(|run| run.text.as_str()).collect::<String>());
    let mut at = 0usize;
    for run in runs {
        let start = at;
        at += run.text.len();
        let mut span = TextSpan::new();
        if run.bold {
            span = span.bold();
        }
        if run.italic {
            span = span.italic();
        }
        if run.underline {
            span = span.underline();
        }
        if run.strikethrough {
            span = span.strikethrough();
        }
        if let Some((r, g, b, a)) = run.color {
            span = span.color(guiduck_scene::paint::Color::from_rgba8(r, g, b, a));
        }
        if let Some(size) = run.size {
            span = span.font_size(size as f32);
        }
        if let Some(target) = &run.link {
            span = span.link(target.clone());
        }
        // A run with nothing set is the paragraph's own style: no span.
        if span != TextSpan::new() {
            content = content.span(start..at, span);
        }
    }
    content
}

/// Dev-mode diagnostic for a declared prop the binary has no setter thunk for
/// (the live file's manifest declared it after the build).
fn warn_widget_prop_skipped(component: &str, widget: &str, prop: &str) {
    eprintln!(
        "guiduck[dev]: skipped `:{prop}` on `{widget}` inside `{component}`: this binary \
         has no setter for it, because the widget's manifest did not declare it when \
         it was built (rebuild to restore)"
    );
}

/// A widget's children as deferred content: built into the popup when it
/// opens, and not before — so a closed menu's rows do not exist, lay out
/// nothing, and subscribe to nothing.
fn deferred_content(ctx: BuildCx<'_>, node: &IrNode) -> ContentBuilder {
    let ir = Rc::new(ctx.ir.clone());
    let cx = Rc::clone(ctx.cx);
    let spec = ctx.spec.clone();
    let components = Rc::new(ctx.components.clone());
    let children = node.children.clone();
    ContentBuilder::new(move |tree, parent| {
        let instances = std::cell::RefCell::new(InstanceCollector::default());
        let ctx = BuildCx {
            ir: &ir,
            cx: &cx,
            spec: &spec,
            components: &components,
            instances: &instances,
        };
        for child in &children {
            build_node(tree, ctx, *child, Some(parent), None);
        }
    })
}

/// The owned captures a dynamic region's effect closures need: unlike the
/// borrow-based [`BuildCx`], rows and branches rebuild long after
/// `instantiate` returned.
struct OwnedBuild {
    ir: Rc<Ir>,
    cx: Rc<DynCx>,
    spec: Rc<ComponentSpec>,
    components: Rc<BTreeMap<String, Ir>>,
}

impl OwnedBuild {
    fn capture(ctx: &BuildCx<'_>) -> Rc<Self> {
        Rc::new(Self {
            ir: Rc::new(ctx.ir.clone()),
            cx: Rc::clone(ctx.cx),
            spec: Rc::new(ctx.spec.clone()),
            components: Rc::new(ctx.components.clone()),
        })
    }

    /// Build one node under `parent` with a fresh (discarded) instance
    /// collector — component instances born inside dynamic regions are not
    /// part of the reload-identity snapshot (their state resets on reload;
    /// the region's own list/condition state is what persists).
    fn build(
        &self,
        tree: &mut WidgetTree,
        cx: &Rc<DynCx>,
        index: usize,
        parent: Option<WidgetId>,
    ) -> Option<Extent> {
        let collector = std::cell::RefCell::new(InstanceCollector::default());
        let ctx = BuildCx {
            ir: &self.ir,
            cx,
            spec: &self.spec,
            components: &self.components,
            instances: &collector,
        };
        build_node(tree, ctx, index, parent, None)
    }
}

/// A structural `(if …)`: a [`Conditional`] region driven by an effect.
fn build_if(
    tree: &mut WidgetTree,
    ctx: BuildCx<'_>,
    node: &IrNode,
    parent: Option<WidgetId>,
) -> Option<Extent> {
    let Some(ControlIr::If { cond }) = &node.control else {
        return None;
    };
    let region = Rc::new(std::cell::RefCell::new(
        guiduck_core::widget::dynamic::Conditional::new(tree, parent),
    ));
    let cond = cond.clone();
    let then_index = node.children[0];
    let else_index = node.children.get(1).copied();
    let owned = OwnedBuild::capture(&ctx);
    let commands = tree.commands();
    let extent = region.borrow().extent();
    Effect::new(move || {
        let value = eval(&cond, &owned.cx).truthy();
        let region = Rc::clone(&region);
        let owned = Rc::clone(&owned);
        commands.push(move |tree| {
            region.borrow_mut().set(tree, value, |tree, parent, at| {
                let index = if value { Some(then_index) } else { else_index }?;
                let extent = owned.build(tree, &owned.cx, index, parent)?;
                guiduck_core::widget::dynamic::place(tree, parent, extent, at);
                Some(extent)
            });
        });
    });
    Some(extent)
}

/// A keyed `(for …)`: a [`KeyedList`] region reconciled by an effect, one
/// monomorphization per element type, dispatched on the list signal.
fn build_for(
    tree: &mut WidgetTree,
    ctx: BuildCx<'_>,
    node: &IrNode,
    parent: Option<WidgetId>,
) -> Option<Extent> {
    let Some(ControlIr::For {
        var,
        index,
        list,
        element: _,
        key,
    }) = &node.control
    else {
        return None;
    };
    let Some(signal) = ctx
        .cx
        .signals
        .get(list)
        .or_else(|| ctx.cx.props.get(list))
        .copied()
    else {
        return None;
    };
    let body_index = node.children[0];
    let var = var.clone();
    let index_name = index.clone();
    let key = key.clone();
    let owned = OwnedBuild::capture(&ctx);
    let commands = tree.commands();

    macro_rules! drive {
        ($list_signal:expr, $elem:ty, $wrap:expr, $to_value:expr) => {{
            let list_signal = $list_signal;
            let region: Rc<
                std::cell::RefCell<guiduck_core::widget::dynamic::KeyedList<Value, $elem>>,
            > = Rc::new(std::cell::RefCell::new(
                guiduck_core::widget::dynamic::KeyedList::new(tree, parent),
            ));
            // Read before the effect takes the region: an enclosing region
            // needs this row's anchors to place it.
            let extent = region.borrow().extent();
            Effect::new(move || {
                let items: Vec<$elem> = list_signal.get();
                let to_value: fn(&$elem) -> Value = $to_value;
                {
                    let key = &key;
                    let var = &var;
                    let index_name = &index_name;
                    let owned = &owned;
                    let key_of = |position: usize, item: &$elem| match key {
                        Some(expr) => {
                            let item_value = to_value(item);
                            let index_value = Value::I64(position as i64);
                            let mut locals = vec![(var.as_str(), &item_value)];
                            if let Some(name) = index_name {
                                locals.push((name.as_str(), &index_value));
                            }
                            eval_scoped(expr, &owned.cx, &locals)
                        }
                        None => Value::I64(position as i64),
                    };
                    // Signal updates for surviving rows happen here, in
                    // the effect phase, so dependent effects re-run this
                    // flush.
                    region.borrow_mut().sync_rows(&items, key_of);
                }
                let region = Rc::clone(&region);
                let owned = Rc::clone(&owned);
                let var = var.clone();
                let index_name = index_name.clone();
                let key = key.clone();
                commands.push(move |tree| {
                    let key_of = |position: usize, item: &$elem| match &key {
                        Some(expr) => {
                            let item_value = to_value(item);
                            let index_value = Value::I64(position as i64);
                            let mut locals = vec![(var.as_str(), &item_value)];
                            if let Some(name) = &index_name {
                                locals.push((name.as_str(), &index_value));
                            }
                            eval_scoped(expr, &owned.cx, &locals)
                        }
                        None => Value::I64(position as i64),
                    };
                    let wrap: fn(Signal<$elem>) -> DynSignal = $wrap;
                    let build = |tree: &mut WidgetTree,
                                 parent: Option<WidgetId>,
                                 at: usize,
                                 item: Signal<$elem>,
                                 row_index: Signal<i64>| {
                        let mut derived = (*owned.cx).clone();
                        derived.signals.insert(var.clone(), wrap(item));
                        if let Some(name) = &index_name {
                            derived
                                .signals
                                .insert(name.clone(), DynSignal::I64(row_index));
                        }
                        let row_cx = Rc::new(derived);
                        // A row body that builds nothing is a *dev* state, not
                        // a broken program: the live file names a component
                        // this binary was not built with, or a nested `for`'s
                        // list signal has not caught up mid-reload (which
                        // `build_for` declines on purpose, to heal on the next
                        // edit). Both already warn where they happen. Keep an
                        // empty row so the reload can heal it, exactly as the
                        // mount root does for the same reason — a row is not
                        // the one place the file is expected to be ahead of
                        // the binary.
                        let extent = owned
                            .build(tree, &row_cx, body_index, parent)
                            .unwrap_or_else(|| {
                                Extent::Single(tree.insert(
                                    Container::new(),
                                    taffy::Style::default(),
                                    parent,
                                ))
                            });
                        guiduck_core::widget::dynamic::place(tree, parent, extent, at);
                        extent
                    };
                    region.borrow_mut().reconcile(tree, &items, key_of, build);
                });
            });
            Some(extent)
        }};
    }

    match signal {
        DynSignal::ListI32(s) => drive!(s, i32, DynSignal::I32, |v| Value::I64(*v as i64)),
        DynSignal::ListI64(s) => drive!(s, i64, DynSignal::I64, |v| Value::I64(*v)),
        DynSignal::ListF32(s) => drive!(s, f32, DynSignal::F32, |v| Value::F64(*v as f64)),
        DynSignal::ListF64(s) => drive!(s, f64, DynSignal::F64, |v| Value::F64(*v)),
        DynSignal::ListBool(s) => drive!(s, bool, DynSignal::Bool, |v| Value::Bool(*v)),
        DynSignal::ListStr(s) => drive!(s, String, DynSignal::Str, |v| Value::Str(v.clone())),
        DynSignal::RecordList(s) => {
            drive!(s, DynValue, DynSignal::RecordValue, |v| dyn_to_value(v))
        }
        // A scalar signal here means the live file diverged from the
        // compiled interface mid-reload; skip until it heals.
        _ => None,
    }
}

/// Build one nested component instance: child prop signals seeded from the
/// parent's expressions (and driven by parent-scope effects), output wires
/// into the parent's handler registry, slot content (this node's children,
/// built in the *parent's* context), and a recursive instantiation whose
/// scope nests inside the caller's. Returns `None` — with a warning — when
/// the compiled side does not know the component.
fn instantiate_instance(
    tree: &mut WidgetTree,
    ctx: BuildCx<'_>,
    parent: Option<WidgetId>,
    node: &IrNode,
    child_name: &str,
    mut slot: Option<&mut SlotFill<'_>>,
) -> Option<WidgetId> {
    let BuildCx {
        ir,
        cx,
        spec,
        components,
        instances,
    } = ctx;
    let Some(child_ir) = components.get(child_name) else {
        warn_component_skipped(&ir.name, child_name);
        return None;
    };
    let Some(factory) = spec.children.get(child_name) else {
        warn_component_skipped(&ir.name, child_name);
        return None;
    };
    let child_spec = factory();

    // The child's context: states and emitters from its own IR, prop
    // signals seeded by evaluating the parent's expressions (or the child's
    // declared default when a defaulted prop is not given).
    let mut child_cx = DynCx::default();
    for state in &child_ir.states {
        child_cx.signals.insert(
            state.name.clone(),
            make_signal(state.ty.into(), &state.init),
        );
    }
    for output in &child_ir.outputs {
        child_cx
            .emitters
            .insert(output.name.clone(), make_emitter(output.ty.into()));
    }
    for prop in &child_ir.props {
        let ty: DynType = prop.ty.into();
        let given = node
            .component_props
            .iter()
            .find(|(name, _)| *name == prop.name);
        let signal = match given {
            Some((_, expr)) => make_signal_from_value(ty, &eval(expr, cx).into_dyn()),
            None => make_signal(ty, prop.default.as_ref().unwrap_or(&Literal::Int(0))),
        };
        child_cx.props.insert(prop.name.clone(), signal);
    }

    // Reactive props: one effect per non-literal expression keeps the child
    // prop signal current as the parent's signals change. (Its first run
    // re-applies the seed value, which the equality gate swallows.)
    for (prop_name, expr) in &node.component_props {
        if expr.is_literal() {
            continue;
        }
        let Some(target) = child_cx.props.get(prop_name).copied() else {
            continue;
        };
        let expr = expr.clone();
        let parent_cx = Rc::clone(cx);
        Effect::new(move || set_dyn_signal(&target, eval(&expr, &parent_cx)));
    }

    // Output wires: the child's emitters invoke the *parent's* compiled
    // handlers, payload re-typed dynamically. Invocation wires evaluate
    // their arguments at dispatch in this node's scope, with `payload`
    // bound to the emitted value.
    for wire in &node.component_outputs {
        let Some(emitter) = child_cx.emitters.get(&wire.output) else {
            continue;
        };
        subscribe_output(
            emitter,
            Rc::clone(&spec.handlers),
            Rc::clone(cx),
            &wire.handler,
            wire.args.clone(),
        );
    }

    let child_cx = Rc::new(child_cx);
    // Slot content is this node's children — parent content, so it builds
    // with the parent's BuildCx (names, handlers, factories) and with the
    // parent's own slot threaded through (a `(slot)` inside the content
    // belongs to the file being built, not to the child receiving it).
    let mut fill = |tree: &mut WidgetTree, slot_parent: Option<WidgetId>| {
        for child_index in &node.children {
            build_node(tree, ctx, *child_index, slot_parent, slot.as_deref_mut());
        }
    };
    let child = instantiate_slotted(
        tree,
        parent,
        child_ir,
        &child_cx,
        &child_spec,
        components,
        Some(&mut fill),
    );

    // Instance-level `:class` merges into the root widget's own classes,
    // and the flex-item layout overrides patch its style in place (never
    // clobbering what the child's file or `adjust_style` set).
    if !node.classes.is_empty() {
        tree.add_classes(child.root, node.classes.iter().cloned());
    }
    if node.style != StyleIr::default() {
        let style = node.style.clone();
        tree.update_style(child.root, |out| apply_style_ir(&style, out));
    }
    // Instance-level `:enabled` gates the instance root (evaluated in the
    // parent's scope, like the driving prop effects below).
    install_enabled(tree, child.root, node, cx);

    // The child's scope was created inside the caller's `scope.run`, so it
    // nests under the parent instance's scope and cascades on disposal.
    // Record the instance — identity, live context, and its own nesting —
    // so the reload machinery can carry its state across rebuilds.
    let id = InstanceId {
        component: child_name.to_owned(),
        key: match &node.component_key {
            Some(key) => key.clone(),
            None => {
                let mut collector = instances.borrow_mut();
                let n = collector
                    .occurrences
                    .entry(child_name.to_owned())
                    .or_insert(0);
                let key = format!("#{n}");
                *n += 1;
                key
            }
        },
    };
    let root = child.root;
    instances.borrow_mut().nested.push(NestedInstance {
        id,
        cx: Rc::clone(&child_cx),
        inner: child,
    });
    Some(root)
}

/// State of one instance subtree, captured by value: nested signals are
/// scope-owned and die with the instance (nothing outside it can hold
/// their handles), so — unlike the top level's handle preservation — the
/// reload machinery carries nested state as values.
#[derive(Default)]
pub(crate) struct StateSnapshot {
    states: std::collections::HashMap<String, (DynType, DynValue)>,
    nested: std::collections::HashMap<InstanceId, StateSnapshot>,
}

/// Capture the state of every nested instance below `inst`, by identity.
pub(crate) fn snapshot_nested(
    inst: &Instantiated,
) -> std::collections::HashMap<InstanceId, StateSnapshot> {
    inst.nested
        .iter()
        .map(|nested| {
            let states = nested
                .cx
                .signals
                .iter()
                .map(|(name, signal)| {
                    (
                        name.clone(),
                        (signal_type(signal), dyn_signal_value(signal)),
                    )
                })
                .collect();
            let snapshot = StateSnapshot {
                states,
                nested: snapshot_nested(&nested.inner),
            };
            (nested.id.clone(), snapshot)
        })
        .collect()
}

/// Restore captured state into a rebuilt tree: instances match by identity,
/// states by name and type; everything else keeps its fresh init.
pub(crate) fn restore_nested(
    inst: &Instantiated,
    saved: &std::collections::HashMap<InstanceId, StateSnapshot>,
) {
    for nested in &inst.nested {
        let Some(snapshot) = saved.get(&nested.id) else {
            continue;
        };
        for (name, signal) in &nested.cx.signals {
            if let Some((ty, value)) = snapshot.states.get(name)
                && *ty == signal_type(signal)
            {
                restore_signal_value(signal, value);
            }
        }
        restore_nested(&nested.inner, &snapshot.nested);
    }
}

/// A dynamic signal's current value (untracked).
pub(crate) fn dyn_signal_value(signal: &DynSignal) -> DynValue {
    match signal {
        DynSignal::I32(s) => DynValue::I64(s.get_untracked() as i64),
        DynSignal::I64(s) => DynValue::I64(s.get_untracked()),
        DynSignal::F32(s) => DynValue::F64(s.get_untracked() as f64),
        DynSignal::F64(s) => DynValue::F64(s.get_untracked()),
        DynSignal::Bool(s) => DynValue::Bool(s.get_untracked()),
        DynSignal::Str(s) => DynValue::Str(s.get_untracked()),
        DynSignal::ListI32(s) => DynValue::List(
            s.get_untracked()
                .into_iter()
                .map(|v| DynValue::I64(v as i64))
                .collect(),
        ),
        DynSignal::ListI64(s) => {
            DynValue::List(s.get_untracked().into_iter().map(DynValue::I64).collect())
        }
        DynSignal::ListF32(s) => DynValue::List(
            s.get_untracked()
                .into_iter()
                .map(|v| DynValue::F64(v as f64))
                .collect(),
        ),
        DynSignal::ListF64(s) => {
            DynValue::List(s.get_untracked().into_iter().map(DynValue::F64).collect())
        }
        DynSignal::ListBool(s) => {
            DynValue::List(s.get_untracked().into_iter().map(DynValue::Bool).collect())
        }
        DynSignal::ListStr(s) => {
            DynValue::List(s.get_untracked().into_iter().map(DynValue::Str).collect())
        }
        DynSignal::RecordList(s) => DynValue::List(s.get_untracked()),
        DynSignal::RecordValue(s) => s.get_untracked(),
    }
}

/// Write a snapshot value back into a same-typed signal — the round trip of
/// [`dyn_signal_value`], so no width is lost.
pub(crate) fn restore_signal_value(signal: &DynSignal, value: &DynValue) {
    match (signal, value) {
        (DynSignal::I32(s), DynValue::I64(v)) => s.set(*v as i32),
        (DynSignal::I64(s), DynValue::I64(v)) => s.set(*v),
        (DynSignal::F32(s), DynValue::F64(v)) => s.set(*v as f32),
        (DynSignal::F64(s), DynValue::F64(v)) => s.set(*v),
        (DynSignal::Bool(s), DynValue::Bool(v)) => s.set(*v),
        (DynSignal::Str(s), DynValue::Str(v)) => s.set(v.clone()),
        (DynSignal::ListI32(s), DynValue::List(items)) => s.set(
            items
                .iter()
                .map(|v| match v {
                    DynValue::I64(v) => *v as i32,
                    _ => 0,
                })
                .collect(),
        ),
        (DynSignal::ListI64(s), DynValue::List(items)) => s.set(
            items
                .iter()
                .map(|v| match v {
                    DynValue::I64(v) => *v,
                    _ => 0,
                })
                .collect(),
        ),
        (DynSignal::ListF32(s), DynValue::List(items)) => s.set(
            items
                .iter()
                .map(|v| match v {
                    DynValue::F64(v) => *v as f32,
                    _ => 0.0,
                })
                .collect(),
        ),
        (DynSignal::ListF64(s), DynValue::List(items)) => s.set(
            items
                .iter()
                .map(|v| match v {
                    DynValue::F64(v) => *v,
                    _ => 0.0,
                })
                .collect(),
        ),
        (DynSignal::ListBool(s), DynValue::List(items)) => s.set(
            items
                .iter()
                .map(|v| matches!(v, DynValue::Bool(true)))
                .collect(),
        ),
        (DynSignal::ListStr(s), DynValue::List(items)) => s.set(
            items
                .iter()
                .map(|v| match v {
                    DynValue::Str(v) => v.clone(),
                    _ => String::new(),
                })
                .collect(),
        ),
        (DynSignal::RecordList(s), DynValue::List(items)) => s.set(items.clone()),
        (DynSignal::RecordValue(s), value @ DynValue::Record(_)) => s.set(value.clone()),
        // Type agreement was checked against the snapshot before restoring.
        _ => {}
    }
}

/// Write an evaluated expression value into a dynamic signal, coerced to
/// the signal's type (the dynamic mirror of rustc unifying the generated
/// effect's expression with the child's prop signal).
fn set_dyn_signal(signal: &DynSignal, value: Value) {
    fn elements(value: Value) -> Vec<Value> {
        match value {
            Value::List(items) => items,
            _ => Vec::new(),
        }
    }
    match signal {
        DynSignal::I32(s) => s.set(value.as_i64() as i32),
        DynSignal::I64(s) => s.set(value.as_i64()),
        DynSignal::F32(s) => s.set(value.into_f64() as f32),
        DynSignal::F64(s) => s.set(value.into_f64()),
        DynSignal::Bool(s) => s.set(value.truthy()),
        DynSignal::Str(s) => s.set(value.into_string()),
        DynSignal::ListI32(s) => s.set(elements(value).iter().map(|v| v.as_i64() as i32).collect()),
        DynSignal::ListI64(s) => s.set(elements(value).iter().map(Value::as_i64).collect()),
        DynSignal::ListF32(s) => s.set(
            elements(value)
                .into_iter()
                .map(|v| v.into_f64() as f32)
                .collect(),
        ),
        DynSignal::ListF64(s) => s.set(elements(value).into_iter().map(Value::into_f64).collect()),
        DynSignal::ListBool(s) => s.set(elements(value).iter().map(Value::truthy).collect()),
        DynSignal::ListStr(s) => s.set(
            elements(value)
                .into_iter()
                .map(Value::into_string)
                .collect(),
        ),
        DynSignal::RecordList(s) => {
            s.set(elements(value).into_iter().map(Value::into_dyn).collect())
        }
        DynSignal::RecordValue(s) => s.set(value.into_dyn()),
    }
}

/// Wire a child output emitter to a parent handler through the parent's
/// compiled registry.
fn subscribe_output(
    emitter: &DynEmitter,
    handlers: Rc<std::cell::RefCell<dyn guiduck_core::component::DynHandlers>>,
    parent_cx: Rc<DynCx>,
    handler: &str,
    arg_exprs: Vec<Expr>,
) {
    let handler = handler.to_owned();
    macro_rules! wire {
        ($e:expr, $to_dyn:expr) => {{
            let convert = $to_dyn;
            $e.subscribe(move |value| {
                let delivered = convert(value);
                let args: Vec<DynValue> = if arg_exprs.is_empty() {
                    // Bare-name form: the output's value is the argument.
                    vec![delivered]
                } else {
                    let payload_value = dyn_to_value(&delivered);
                    arg_exprs
                        .iter()
                        .map(|arg| {
                            eval_scoped(arg, &parent_cx, &[("payload", &payload_value)]).into_dyn()
                        })
                        .collect()
                };
                handlers.borrow_mut().invoke(&handler, &parent_cx, &args);
            });
        }};
    }
    match emitter {
        DynEmitter::I32(e) => wire!(e, |v: &i32| DynValue::I64(*v as i64)),
        DynEmitter::I64(e) => wire!(e, |v: &i64| DynValue::I64(*v)),
        DynEmitter::F32(e) => wire!(e, |v: &f32| DynValue::F64(*v as f64)),
        DynEmitter::F64(e) => wire!(e, |v: &f64| DynValue::F64(*v)),
        DynEmitter::Bool(e) => wire!(e, |v: &bool| DynValue::Bool(*v)),
        DynEmitter::Str(e) => wire!(e, |v: &String| DynValue::Str(v.clone())),
    }
}

fn event_kind(event: EventProp) -> EventKind {
    match event {
        EventProp::Click => EventKind::Click,
        EventProp::CountedClick => EventKind::CountedClick,
        EventProp::PointerEnter => EventKind::PointerEnter,
        EventProp::PointerLeave => EventKind::PointerLeave,
        EventProp::PointerDown => EventKind::PointerDown,
        EventProp::PointerUp => EventKind::PointerUp,
        EventProp::Changed => EventKind::Changed,
        EventProp::Toggled => EventKind::Toggled,
        EventProp::Select => EventKind::Select,
        EventProp::Close => EventKind::Close,
        EventProp::Link => EventKind::Link,
        EventProp::FileDrop => EventKind::FileDrop,
        EventProp::FocusChange => EventKind::FocusChange,
        EventProp::Key => EventKind::Key,
        EventProp::ValueChanged => EventKind::ValueChanged,
        EventProp::Scrolled => EventKind::Scrolled,
    }
}

/// Register the accelerators of a deferred subtree.
///
/// An accelerator and its row's `:on-select` share the *handler call* rather
/// than the widget: the row may not exist when the key is pressed, which is
/// the whole point of an accelerator. Validation guarantees no `:accel` sits
/// inside `if` or `for`, so every one found here is unconditional and its
/// arguments cannot read a loop variable — they evaluate in this node's own
/// scope when the key fires.
fn install_accels(tree: &mut WidgetTree, ctx: BuildCx<'_>, node: &IrNode, owner: WidgetId) {
    let BuildCx { ir, cx, spec, .. } = ctx;
    let mut stack: Vec<usize> = node.children.clone();
    while let Some(index) = stack.pop() {
        let child = &ir.nodes[index];
        stack.extend(child.children.iter().copied());
        let Some(accel) = &child.accel else { continue };
        // An accelerator with nothing wired to it is a key that does nothing;
        // there is no handler to share, so there is nothing to register.
        let Some(wire) = child
            .events
            .iter()
            .find(|event| event.event == EventProp::Select)
        else {
            continue;
        };
        let handlers = Rc::clone(&spec.handlers);
        let cx = Rc::clone(cx);
        let handler = wire.handler.clone();
        let args = wire.args.clone();
        tree.on_menu_accel(owner, &accel.display(), move || {
            let values: Vec<DynValue> = args.iter().map(|arg| eval(arg, &cx).into_dyn()).collect();
            // An accelerator firing is not a dispatch there is anything to
            // stop, so the handler's consume answer has nowhere to go.
            handlers.borrow_mut().invoke(&handler, &cx, &values);
        });
    }
}

/// `:enabled` — a literal sets the flag at build; anything else drives it
/// through an effect and the command queue (a tree-level mutation, outside
/// `bind`'s per-widget access).
fn install_enabled(tree: &mut WidgetTree, id: WidgetId, node: &IrNode, cx: &Rc<DynCx>) {
    install_bool_prop(tree, id, node.enabled.as_ref(), cx, WidgetTree::set_enabled);
}

/// `:focused` — the same shape as `:enabled`, and the same reason: focus lives
/// on the tree, not on the widget.
fn install_focused(tree: &mut WidgetTree, id: WidgetId, node: &IrNode, cx: &Rc<DynCx>) {
    install_bool_prop(tree, id, node.focused.as_ref(), cx, WidgetTree::set_focused);
}

/// A universal boolean tree property: a literal applies at build, anything
/// else drives `apply` from an effect through the command queue.
fn install_bool_prop(
    tree: &mut WidgetTree,
    id: WidgetId,
    expr: Option<&Expr>,
    cx: &Rc<DynCx>,
    apply: fn(&mut WidgetTree, WidgetId, bool),
) {
    let Some(expr) = expr else {
        return;
    };
    if let Expr::Bool(value, _) = expr {
        apply(tree, id, *value);
        return;
    }
    let expr = expr.clone();
    let cx = Rc::clone(cx);
    let commands = tree.commands();
    Effect::new(move || {
        let value = eval(&expr, &cx).truthy();
        let commands = commands.clone();
        commands.push(move |tree| apply(tree, id, value));
    });
}

/// An evaluated expression value.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Value {
    I64(i64),
    F64(f64),
    Bool(bool),
    Str(String),
    List(Vec<Value>),
    Record(Vec<(String, Value)>),
}

impl Value {
    fn into_string(self) -> String {
        match self {
            Value::Str(s) => s,
            Value::I64(v) => v.to_string(),
            Value::F64(v) => v.to_string(),
            Value::Bool(v) => v.to_string(),
            Value::List(items) => {
                let parts: Vec<String> = items.into_iter().map(Value::into_string).collect();
                parts.join(", ")
            }
            Value::Record(fields) => {
                let parts: Vec<String> = fields
                    .into_iter()
                    .map(|(name, value)| format!("{name}: {}", value.into_string()))
                    .collect();
                parts.join(", ")
            }
        }
    }

    fn into_f64(self) -> f64 {
        match self {
            Value::F64(v) => v,
            Value::I64(v) => v as f64,
            Value::Bool(v) => v as u8 as f64,
            Value::Str(_) | Value::List(_) | Value::Record(_) => 0.0,
        }
    }

    /// The value as an integer, coerced the way [`into_f64`](Self::into_f64)
    /// coerces to a float: the one definition every consumer that wants an
    /// integer out of a dynamic value uses.
    fn as_i64(&self) -> i64 {
        match self {
            Value::I64(v) => *v,
            Value::F64(v) => *v as i64,
            Value::Bool(v) => *v as i64,
            Value::Str(_) | Value::List(_) | Value::Record(_) => 0,
        }
    }

    fn truthy(&self) -> bool {
        match self {
            Value::Bool(v) => *v,
            Value::I64(v) => *v != 0,
            Value::F64(v) => *v != 0.0,
            Value::Str(s) => !s.is_empty(),
            Value::List(items) => !items.is_empty(),
            Value::Record(_) => true,
        }
    }

    fn into_dyn(self) -> DynValue {
        match self {
            Value::I64(v) => DynValue::I64(v),
            Value::F64(v) => DynValue::F64(v),
            Value::Bool(v) => DynValue::Bool(v),
            Value::Str(v) => DynValue::Str(v),
            Value::List(items) => DynValue::List(items.into_iter().map(Value::into_dyn).collect()),
            Value::Record(fields) => DynValue::Record(
                fields
                    .into_iter()
                    .map(|(name, value)| (name, value.into_dyn()))
                    .collect(),
            ),
        }
    }

    fn into_brush(self) -> Brush {
        // Validation restricted brush expressions to color-string leaves.
        match &self {
            Value::Str(s) => match parse_color(s) {
                Some(Literal::Color(r, g, b, a)) => Color::from_rgba8(r, g, b, a).into(),
                _ => Color::BLACK.into(),
            },
            _ => Color::BLACK.into(),
        }
    }
}

/// Evaluate a binding expression against the component's dynamic context.
/// Signal reads track, so the enclosing effect re-runs on change.
pub(crate) fn eval(expr: &Expr, cx: &DynCx) -> Value {
    eval_scoped(expr, cx, &[])
}

/// [`eval`], with `locals` shadowing the context's names — how a `for`
/// `:key` expression sees the loop variable before any row signal exists.
fn eval_scoped(expr: &Expr, cx: &DynCx, locals: &[(&str, &Value)]) -> Value {
    match expr {
        Expr::Int(v, _) => Value::I64(*v),
        Expr::Float(v, _) => Value::F64(*v),
        Expr::Bool(v, _) => Value::Bool(*v),
        Expr::Str(template, _) => {
            let mut out = String::new();
            for segment in &template.segments {
                match segment {
                    Segment::Literal(l) => out.push_str(l),
                    Segment::Ref(name, _) => {
                        out.push_str(&read_name_scoped(name, cx, locals).into_string());
                    }
                }
            }
            Value::Str(out)
        }
        Expr::Path(segments, _) => {
            let value = read_name_scoped(&segments[0], cx, locals);
            match segments.get(1) {
                Some(field) => record_field(&value, field),
                None => value,
            }
        }
        Expr::List(items, _) => Value::List(
            items
                .iter()
                .map(|item| eval_scoped(item, cx, locals))
                .collect(),
        ),
        // Validation confines calls to event wires, which are dispatched
        // through `invoke`, never evaluated as expressions.
        Expr::Call(..) => Value::I64(0),
        // Validation rejects a graphic anywhere an expression is evaluated.
        Expr::Form(..) => unreachable!("a graphic is not a computed expression"),
        Expr::RecordLit(name, fields, _) => {
            let _ = name;
            Value::Record(
                fields
                    .iter()
                    .map(|(field, value)| (field.clone(), eval_scoped(value, cx, locals)))
                    .collect(),
            )
        }
        Expr::Unary(op, inner, _) => {
            let inner = eval_scoped(inner, cx, locals);
            match op {
                UnOp::Not => Value::Bool(!inner.truthy()),
                UnOp::Neg => match inner {
                    Value::I64(v) => Value::I64(-v),
                    other => Value::F64(-other.into_f64()),
                },
            }
        }
        Expr::Binary(op, lhs, rhs, _) => {
            let l = eval_scoped(lhs, cx, locals);
            // Short-circuit the boolean operators.
            match op {
                BinOp::And => {
                    return if l.truthy() {
                        eval_scoped(rhs, cx, locals)
                    } else {
                        Value::Bool(false)
                    };
                }
                BinOp::Or => {
                    return if l.truthy() {
                        l
                    } else {
                        eval_scoped(rhs, cx, locals)
                    };
                }
                _ => {}
            }
            let r = eval_scoped(rhs, cx, locals);
            binary(*op, l, r)
        }
        Expr::If(cond, then, otherwise, _) => {
            if eval_scoped(cond, cx, locals).truthy() {
                eval_scoped(then, cx, locals)
            } else {
                eval_scoped(otherwise, cx, locals)
            }
        }
    }
}

/// A dynamic value as an interpreter value.
fn dyn_to_value(value: &DynValue) -> Value {
    match value {
        DynValue::I64(v) => Value::I64(*v),
        DynValue::F64(v) => Value::F64(*v),
        DynValue::Bool(v) => Value::Bool(*v),
        DynValue::Str(s) => Value::Str(s.clone()),
        DynValue::List(items) => Value::List(items.iter().map(dyn_to_value).collect()),
        DynValue::Record(fields) => Value::Record(
            fields
                .iter()
                .map(|(name, value)| (name.clone(), dyn_to_value(value)))
                .collect(),
        ),
    }
}

/// A field of a record value; zero for anything else (heals on reload).
fn record_field(value: &Value, field: &str) -> Value {
    match value {
        Value::Record(fields) => fields
            .iter()
            .find(|(name, _)| name == field)
            .map(|(_, value)| value.clone())
            .unwrap_or(Value::I64(0)),
        _ => Value::I64(0),
    }
}

fn read_name_scoped(name: &str, cx: &DynCx, locals: &[(&str, &Value)]) -> Value {
    // Template refs arrive as dotted strings; split field access off.
    if let Some((head, field)) = name.split_once('.') {
        let value = read_name_scoped(head, cx, locals);
        return record_field(&value, field);
    }
    if let Some((_, value)) = locals.iter().find(|(local, _)| *local == name) {
        return (*value).clone();
    }
    match cx.signals.get(name).or_else(|| cx.props.get(name)) {
        Some(DynSignal::I32(s)) => Value::I64(s.get() as i64),
        Some(DynSignal::I64(s)) => Value::I64(s.get()),
        Some(DynSignal::F32(s)) => Value::F64(s.get() as f64),
        Some(DynSignal::F64(s)) => Value::F64(s.get()),
        Some(DynSignal::Bool(s)) => Value::Bool(s.get()),
        Some(DynSignal::Str(s)) => Value::Str(s.get()),
        Some(DynSignal::ListI32(s)) => {
            Value::List(s.get().into_iter().map(|v| Value::I64(v as i64)).collect())
        }
        Some(DynSignal::ListI64(s)) => Value::List(s.get().into_iter().map(Value::I64).collect()),
        Some(DynSignal::ListF32(s)) => {
            Value::List(s.get().into_iter().map(|v| Value::F64(v as f64)).collect())
        }
        Some(DynSignal::ListF64(s)) => Value::List(s.get().into_iter().map(Value::F64).collect()),
        Some(DynSignal::ListBool(s)) => Value::List(s.get().into_iter().map(Value::Bool).collect()),
        Some(DynSignal::ListStr(s)) => Value::List(s.get().into_iter().map(Value::Str).collect()),
        Some(DynSignal::RecordList(s)) => Value::List(s.get().iter().map(dyn_to_value).collect()),
        Some(DynSignal::RecordValue(s)) => dyn_to_value(&s.get()),
        // Validation resolved names against the same IR; a miss can only
        // happen mid-reload and heals on the next instantiation.
        None => Value::I64(0),
    }
}

fn binary(op: BinOp, l: Value, r: Value) -> Value {
    use BinOp::*;
    // String concatenation and equality get their own paths; everything
    // else promotes to i64 when both sides are integers, f64 otherwise.
    match (op, &l, &r) {
        (Add, Value::Str(a), b) => return Value::Str(format!("{a}{}", b.clone().into_string())),
        (Eq, _, _) => return Value::Bool(values_equal(&l, &r)),
        (Ne, _, _) => return Value::Bool(!values_equal(&l, &r)),
        _ => {}
    }
    if let (Value::I64(a), Value::I64(b)) = (&l, &r) {
        let (a, b) = (*a, *b);
        return match op {
            Add => Value::I64(a + b),
            Sub => Value::I64(a - b),
            Mul => Value::I64(a * b),
            Div => Value::I64(if b != 0 { a / b } else { 0 }),
            Rem => Value::I64(if b != 0 { a % b } else { 0 }),
            Lt => Value::Bool(a < b),
            Le => Value::Bool(a <= b),
            Gt => Value::Bool(a > b),
            Ge => Value::Bool(a >= b),
            And | Or | Eq | Ne => unreachable!("handled above"),
        };
    }
    let (a, b) = (l.into_f64(), r.into_f64());
    match op {
        Add => Value::F64(a + b),
        Sub => Value::F64(a - b),
        Mul => Value::F64(a * b),
        Div => Value::F64(if b != 0.0 { a / b } else { 0.0 }),
        Rem => Value::F64(if b != 0.0 { a % b } else { 0.0 }),
        Lt => Value::Bool(a < b),
        Le => Value::Bool(a <= b),
        Gt => Value::Bool(a > b),
        Ge => Value::Bool(a >= b),
        And | Or | Eq | Ne => unreachable!("handled above"),
    }
}

fn values_equal(l: &Value, r: &Value) -> bool {
    match (l, r) {
        (Value::Str(a), Value::Str(b)) => a == b,
        (Value::Bool(a), Value::Bool(b)) => a == b,
        (Value::I64(a), Value::I64(b)) => a == b,
        _ => l.clone().into_f64() == r.clone().into_f64(),
    }
}

/// Apply an IR style onto an existing taffy style, touching only the
/// fields the file gave — the one IR→taffy translation, used both to build
/// a fresh style (patch onto default) and to overlay a parent's item-layout
/// overrides on an instance root. The codegen emits the equivalent as
/// tokens; the differential test holds the two translations together.
pub(crate) fn apply_style_ir(style: &StyleIr, out: &mut taffy::Style) {
    use taffy::prelude::{auto, length, percent};
    let dim = |d: Option<DimIr>| match d {
        Some(DimIr::Px(v)) => length(v),
        Some(DimIr::Percent(v)) => percent(v),
        Some(DimIr::Auto) | None => auto(),
    };
    let align = |a: AlignIr| match a {
        AlignIr::Start => taffy::AlignItems::FLEX_START,
        AlignIr::End => taffy::AlignItems::FLEX_END,
        AlignIr::Center => taffy::AlignItems::CENTER,
        AlignIr::Stretch => taffy::AlignItems::STRETCH,
    };
    let justify = |a: AlignIr| match a {
        AlignIr::Start => taffy::JustifyContent::FLEX_START,
        AlignIr::End => taffy::JustifyContent::FLEX_END,
        AlignIr::Center => taffy::JustifyContent::CENTER,
        AlignIr::Stretch => taffy::JustifyContent::STRETCH,
    };
    if style.width.is_some() {
        out.size.width = dim(style.width);
    }
    if style.height.is_some() {
        out.size.height = dim(style.height);
    }
    if let Some(padding) = style.padding {
        out.padding = taffy::Rect::length(padding);
    }
    if let Some(gap) = style.gap {
        out.gap = taffy::Size {
            width: taffy::prelude::length(gap),
            height: taffy::prelude::length(gap),
        };
    }
    if let Some(direction) = style.direction {
        out.flex_direction = match direction {
            DirectionIr::Row => taffy::FlexDirection::Row,
            DirectionIr::Column => taffy::FlexDirection::Column,
        };
    }
    if let Some(a) = style.align_items {
        out.align_items = Some(align(a));
    }
    if let Some(a) = style.justify_content {
        out.justify_content = Some(justify(a));
    }
    if let Some(a) = style.align_self {
        out.align_self = Some(align(a));
    }
    if let Some(grow) = style.grow {
        out.flex_grow = grow;
    }
    if let Some(shrink) = style.shrink {
        out.flex_shrink = shrink;
    }
    if let Some(basis) = style.basis {
        out.flex_basis = dim(Some(basis));
    }
}

/// A fresh taffy style from IR: [`apply_style_ir`] onto the default.
pub(crate) fn style_to_taffy(style: &StyleIr) -> taffy::Style {
    let mut out = taffy::Style::default();
    apply_style_ir(style, &mut out);
    out
}

fn literal_brush(literal: &Literal) -> Brush {
    match literal {
        Literal::Color(r, g, b, a) => Color::from_rgba8(*r, *g, *b, *a).into(),
        _ => Color::BLACK.into(),
    }
}

#[cfg(test)]
mod tests;