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(®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<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(®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<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;