//! 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, } /// One nested instance: its identity, live context, and its own nesting. pub(crate) struct NestedInstance { pub(crate) id: InstanceId, pub(crate) cx: Rc, 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 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 for DynType { fn from(ty: guiduck_component_core::ast::TypeKind) -> Self { Self { kind: ty.into(), list: false, } } } impl From 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 { 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) + '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, ir: &Ir, cx: &Rc, spec: &ComponentSpec, components: &BTreeMap, ) -> Instantiated { instantiate_slotted(tree, parent, ir, cx, spec, components, None) } fn instantiate_slotted( tree: &mut WidgetTree, parent: Option, ir: &Ir, cx: &Rc, spec: &ComponentSpec, components: &BTreeMap, 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, occurrences: std::collections::HashMap, } /// 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, spec: &'a ComponentSpec, components: &'a BTreeMap, instances: &'a std::cell::RefCell, } /// 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, mut slot: Option<&mut SlotFill<'_>>, ) -> Option { 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 = 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 = 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 = 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 = 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 `::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 { 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, ) { 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, ) { 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, ) { 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 = 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 { 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, 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 { 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::()); 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, cx: Rc, spec: Rc, components: Rc>, } impl OwnedBuild { fn capture(ctx: &BuildCx<'_>) -> Rc { 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, index: usize, parent: Option, ) -> Option { 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, ) -> Option { 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(®ion); 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, ) -> Option { 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>, > = 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(®ion); 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, at: usize, item: Signal<$elem>, row_index: Signal| { 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, node: &IrNode, child_name: &str, mut slot: Option<&mut SlotFill<'_>>, ) -> Option { 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| { 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, nested: std::collections::HashMap, } /// Capture the state of every nested instance below `inst`, by identity. pub(crate) fn snapshot_nested( inst: &Instantiated, ) -> std::collections::HashMap { 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, ) { 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 { 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>, parent_cx: Rc, handler: &str, arg_exprs: Vec, ) { 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 = 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 = 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 = 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) { 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) { 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, 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), 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 = items.into_iter().map(Value::into_string).collect(); parts.join(", ") } Value::Record(fields) => { let parts: Vec = 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| 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;