codegen.rs
raw
//! IR → Rust: the typed compile path.
//!
//! For a component `Counter` this emits:
//! - `CounterProps` — one field per prop, `Default` if every prop has one;
//! plain initial values that `mount` turns into signals;
//! - `CounterCx` — the capability surface handlers receive: each prop as a
//! `ReadSignal<T>`, each state as a `Signal<T>`, each output as an
//! `Emitter` clone;
//! - `trait CounterLogic` — one method per declared handler, so a missing
//! or misspelled handler is a compile error at the `impl` site;
//! - `struct Counter` with `mount(tree, parent, props, logic)` building the
//! widget subtree, installing one reactive effect per binding, and wiring
//! events to logic methods.
//!
//! All paths in generated code are rooted at `::guiduck`, the facade crate
//! applications depend on.
use std::collections::BTreeMap;
use guiduck_component_core::ast::TypeKind;
use guiduck_component_core::component_method_name;
use guiduck_component_core::expr::{BinOp, Expr, Segment, UnOp};
use guiduck_component_core::ir::*;
use guiduck_component_core::registry::{EventProp, PayloadKind, PropTy};
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
/// Everything the host resolved that codegen must bake into the expansion but
/// cannot work out for itself: the files read (for rebuild tracking), the
/// three search paths a dev mount has to re-resolve against, each
/// `(asset "…")` reference's absolute path, and the widget vocabulary the
/// `.gdc` was compiled against.
pub struct Host<'a> {
/// Every file this compilation read, absolute — one `include_str!` each,
/// so editing any of them rebuilds the expansion.
pub track_paths: &'a [String],
pub search_dirs: &'a [String],
pub widget_dirs: &'a [String],
pub asset_dirs: &'a [String],
/// Each `(asset "…")` reference, by the path as written, to the absolute
/// path it resolved to.
pub assets: &'a BTreeMap<String, String>,
/// Where this component's own `.gdc` was found, absolute — the file
/// `mount` watches when it is still there at run time.
pub source_path: &'a str,
}
pub fn component(compiled: &Compiled, host: &Host<'_>) -> TokenStream {
let Host {
track_paths,
assets,
source_path,
..
} = host;
let ir = &compiled.root;
let name = format_ident!("{}", ir.name);
let props_name = format_ident!("{}Props", ir.name);
let cx_name = format_ident!("{}Cx", ir.name);
// A query handler's context is read-only over state: a query answers, it
// does not act, so it may read state but not `set` it. The type exists only
// when the component has a query handler.
let query_cx_name = format_ident!("{}QueryCx", ir.name);
let has_queries = ir.handlers.iter().any(|h| h.returns.is_some());
let logic_name = format_ident!("{}Logic", ir.name);
let dyn_name = format_ident!("{}DynHandlers", ir.name);
// One tracking const per file read (the component's own and every
// transitively instantiated one, as absolute paths), so editing any of
// them rebuilds this expansion.
let tracking = track_paths.iter().map(|path| {
quote! {
const _: &str = ::core::include_str!(#path);
}
});
// --- Record structs ---
let record_structs = ir.records.iter().map(|record| {
let record_name = format_ident!("{}", record.name);
let struct_fields = record.fields.iter().map(|(field, kind)| {
let field = snake_ident(field);
let ty = scalar_type_tokens(*kind);
quote! { pub #field: #ty, }
});
let to_dyn_fields = record.fields.iter().map(|(field, kind)| {
let ident = snake_ident(field);
let value = match kind {
TypeKind::I32 | TypeKind::I64 => quote! {
::guiduck::core::component::DynValue::I64(self.#ident as i64)
},
TypeKind::F32 | TypeKind::F64 => quote! {
::guiduck::core::component::DynValue::F64(self.#ident as f64)
},
TypeKind::Bool => quote! {
::guiduck::core::component::DynValue::Bool(self.#ident)
},
TypeKind::String => quote! {
::guiduck::core::component::DynValue::Str(self.#ident.clone())
},
};
quote! { (::std::string::String::from(#field), #value), }
});
let from_dyn_fields = record.fields.iter().map(|(field, kind)| {
let ident = snake_ident(field);
let extract = match kind {
TypeKind::I32 => quote! {
match __field {
::core::option::Option::Some(
::guiduck::core::component::DynValue::I64(v),
) => *v as i32,
_ => ::core::default::Default::default(),
}
},
TypeKind::I64 => quote! {
match __field {
::core::option::Option::Some(
::guiduck::core::component::DynValue::I64(v),
) => *v,
_ => ::core::default::Default::default(),
}
},
TypeKind::F32 => quote! {
match __field {
::core::option::Option::Some(
::guiduck::core::component::DynValue::F64(v),
) => *v as f32,
_ => ::core::default::Default::default(),
}
},
TypeKind::F64 => quote! {
match __field {
::core::option::Option::Some(
::guiduck::core::component::DynValue::F64(v),
) => *v,
_ => ::core::default::Default::default(),
}
},
TypeKind::Bool => quote! {
match __field {
::core::option::Option::Some(
::guiduck::core::component::DynValue::Bool(v),
) => *v,
_ => ::core::default::Default::default(),
}
},
TypeKind::String => quote! {
match __field {
::core::option::Option::Some(
::guiduck::core::component::DynValue::Str(v),
) => v.clone(),
_ => ::core::default::Default::default(),
}
},
};
quote! {
#ident: {
let __field = __fields
.iter()
.find(|(name, _)| name == #field)
.map(|(_, value)| value);
#extract
},
}
});
quote! {
/// A `.gdc`-declared record. Field values round-trip the dev
/// bridge as self-describing dynamic records.
#[derive(Clone, Debug, PartialEq)]
pub struct #record_name {
#(#struct_fields)*
}
impl #record_name {
#[doc(hidden)]
pub fn __to_dyn(&self) -> ::guiduck::core::component::DynValue {
::guiduck::core::component::DynValue::Record(::std::vec![
#(#to_dyn_fields)*
])
}
#[doc(hidden)]
pub fn __from_dyn(value: &::guiduck::core::component::DynValue) -> Self {
let __empty = ::std::vec::Vec::new();
let __fields = match value {
::guiduck::core::component::DynValue::Record(fields) => fields,
_ => &__empty,
};
Self {
#(#from_dyn_fields)*
}
}
}
}
});
// --- Props struct ---
let prop_fields = ir.props.iter().map(|p| {
let field = snake_ident(&p.name);
let ty = type_tokens(&ir.records, p.ty);
quote! { pub #field: #ty, }
});
let props_default = ir.props.iter().all(|p| p.default.is_some()).then(|| {
let defaults = ir.props.iter().map(|p| {
let field = snake_ident(&p.name);
let value = literal_tokens(p.default.as_ref().expect("checked"), Some(p.ty));
quote! { #field: #value, }
});
quote! {
impl ::core::default::Default for #props_name {
fn default() -> Self {
Self { #(#defaults)* }
}
}
}
});
// --- Cx struct ---
let cx_prop_fields = ir.props.iter().map(|p| {
let field = snake_ident(&p.name);
let ty = type_tokens(&ir.records, p.ty);
quote! { pub #field: ::guiduck::core::signals::ReadSignal<#ty>, }
});
let state_fields = ir.states.iter().map(|s| {
let field = snake_ident(&s.name);
let ty = type_tokens(&ir.records, s.ty);
quote! { pub #field: ::guiduck::core::signals::Signal<#ty>, }
});
let output_fields = ir.outputs.iter().map(|o| {
let field = snake_ident(&o.name);
let ty = scalar_type_tokens(o.ty);
quote! { pub #field: ::guiduck::core::component::Emitter<#ty>, }
});
// The read-only counterpart, generated only when a query handler needs it:
// props and outputs as in `Cx`, but state as `ReadSignal` — readable, not
// settable.
let query_cx_struct = has_queries.then(|| {
let props = ir.props.iter().map(|p| {
let field = snake_ident(&p.name);
let ty = type_tokens(&ir.records, p.ty);
quote! { pub #field: ::guiduck::core::signals::ReadSignal<#ty>, }
});
let states = ir.states.iter().map(|s| {
let field = snake_ident(&s.name);
let ty = type_tokens(&ir.records, s.ty);
quote! { pub #field: ::guiduck::core::signals::ReadSignal<#ty>, }
});
let outputs = ir.outputs.iter().map(|o| {
let field = snake_ident(&o.name);
let ty = scalar_type_tokens(o.ty);
quote! { pub #field: ::guiduck::core::component::Emitter<#ty>, }
});
quote! {
/// The read-only context a query handler receives: it may read
/// state but not `set` it — a query answers, it does not act.
pub struct #query_cx_name {
#(#props)*
#(#states)*
#(#outputs)*
}
}
});
// --- Logic trait ---
let logic_methods = ir.handlers.iter().map(|h| {
let method = snake_ident(&h.name);
let params = h.payloads.iter().enumerate().map(|(i, ty)| {
let name = format_ident!("value{i}");
match ty {
TypeKind::String => quote! { #name: &str, },
ty => {
let ty = scalar_type_tokens(*ty);
quote! { #name: #ty, }
}
}
});
// A handler wired to a widget query returns that query's value and gets
// the read-only context; every other handler returns nothing and gets
// the full one.
let (cx_ty, returns) = match &h.returns {
Some(path) => {
let ty = type_path_tokens(path);
(&query_cx_name, quote! { -> #ty })
}
None => (&cx_name, quote! {}),
};
quote! { fn #method(&mut self, cx: #cx_ty, #(#params)*) #returns; }
});
// One logic-factory method per instantiated component type: the parent's
// logic supplies each child instance's logic, so a missing factory is a
// compile error at the impl site, like a missing handler.
let logic_factories = instantiated_components(ir).into_iter().map(|child| {
let method = format_ident!("{}", component_method_name(child));
let child_logic = format_ident!("{}Logic", child);
quote! {
/// Logic for one instance of the named child component.
fn #method(&mut self) -> impl #child_logic;
}
});
// A component with no handlers and no child factories has an empty
// Logic trait; `()` implements it, so pure-presentation components
// mount without a logic type.
let unit_logic =
(ir.handlers.is_empty() && instantiated_components(ir).is_empty()).then(|| {
quote! { impl #logic_name for () {} }
});
// --- mount body ---
let prop_signal_inits = ir.props.iter().map(|p| {
let var = snake_ident(&p.name);
let ty = type_tokens(&ir.records, p.ty);
quote! {
let #var: ::guiduck::core::signals::Signal<#ty> =
::guiduck::core::signals::Signal::new(props.#var);
}
});
let state_inits = ir.states.iter().map(|s| {
let var = snake_ident(&s.name);
let ty = type_tokens(&ir.records, s.ty);
let init = literal_tokens(&s.init, Some(s.ty));
quote! {
let #var: ::guiduck::core::signals::Signal<#ty> =
::guiduck::core::signals::Signal::new(#init);
}
});
let emitter_inits = ir.outputs.iter().map(|o| {
let var = snake_ident(&o.name);
let ty = scalar_type_tokens(o.ty);
quote! {
let #var: ::guiduck::core::component::Emitter<#ty> =
::guiduck::core::component::Emitter::new();
}
});
let cx_construction = cx_construction(ir, &cx_name);
let nodes = build_nodes(&Unit::new(compiled, assets), &cx_construction);
let prop_struct_fields = ir.props.iter().map(|p| {
let field = snake_ident(&p.name);
let ty = type_tokens(&ir.records, p.ty);
quote! {
/// A prop, signal-backed: the embedding side (a parent
/// component or Rust code) drives it post-mount.
pub #field: ::guiduck::core::signals::Signal<#ty>,
}
});
let state_struct_fields = ir.states.iter().map(|s| {
let field = snake_ident(&s.name);
let ty = type_tokens(&ir.records, s.ty);
quote! { pub #field: ::guiduck::core::signals::Signal<#ty>, }
});
let emitter_struct_fields = ir.outputs.iter().map(|o| {
let field = snake_ident(&o.name);
let ty = scalar_type_tokens(o.ty);
quote! { pub #field: ::guiduck::core::component::Emitter<#ty>, }
});
let prop_names = ir.props.iter().map(|p| snake_ident(&p.name));
let state_names = ir.states.iter().map(|s| snake_ident(&s.name));
let emitter_names = ir.outputs.iter().map(|o| snake_ident(&o.name));
let mount_body = quote! {
let logic = ::std::rc::Rc::new(::std::cell::RefCell::new(logic));
#(#prop_signal_inits)*
#(#state_inits)*
#(#emitter_inits)*
#nodes
{
// The hook runs with everything in place — bindings attached, so a
// signal it sets is already being watched. Nothing is dispatching,
// so there is no propagation to stop and the consume flag is dead.
let __consume = ::std::rc::Rc::new(::std::cell::Cell::new(false));
logic.borrow_mut().mounted(#cx_construction);
}
Self {
root: __node0,
#(#prop_names,)*
#(#state_names,)*
#(#emitter_names,)*
}
};
let mount_fns = if has_slot(ir) {
quote! {
/// Build the component's widget subtree under `parent` from the
/// version compiled into this binary, with an empty slot; see
/// [`Self::mount_compiled_with_slot`] to project content.
///
/// [`Self::mount`] is what an application calls; this is the
/// specific path, for a caller that wants the typed handle back
/// or wants the choice pinned (every test does).
pub fn mount_compiled(
tree: &mut ::guiduck::core::WidgetTree,
parent: ::core::option::Option<::guiduck::core::WidgetId>,
props: #props_name,
logic: impl #logic_name,
) -> Self {
Self::mount_compiled_with_slot(tree, parent, props, logic, |_, _| {})
}
/// Build the component's widget subtree under `parent`, wiring
/// bindings and events; `__slot` is invoked at the `(slot)`
/// position to build the projected content.
pub fn mount_compiled_with_slot(
tree: &mut ::guiduck::core::WidgetTree,
parent: ::core::option::Option<::guiduck::core::WidgetId>,
props: #props_name,
logic: impl #logic_name,
__slot: impl FnOnce(
&mut ::guiduck::core::WidgetTree,
::core::option::Option<::guiduck::core::WidgetId>,
),
) -> Self {
#mount_body
}
}
} else {
quote! {
/// Build the component's widget subtree under `parent`, wiring
/// bindings and events, from the version compiled into this
/// binary.
///
/// [`Self::mount`] is what an application calls; this is the
/// specific path, for a caller that wants the typed handle back
/// or wants the choice pinned (every test does).
pub fn mount_compiled(
tree: &mut ::guiduck::core::WidgetTree,
parent: ::core::option::Option<::guiduck::core::WidgetId>,
props: #props_name,
logic: impl #logic_name,
) -> Self {
#mount_body
}
}
};
// The outer entry point. The two specific ones — `mount` and
// `DevMount::new` — stay exactly as they are and remain the way tests
// address a path deliberately; this only chooses between them.
//
// Live structure when *this* build is a development build and the source
// file is still where the build found it. Both halves are load-bearing:
// the profile is what says "I am building this app" rather than "I am
// testing what I will ship", and the file check is what makes a stray
// debug binary harmless on someone else's machine, where the path does not
// exist. `GUIDUCK_LIVE=1`/`0` overrides either way, for iterating in
// release or shipping a debug build deliberately.
let mount_wrapper = quote! {
/// Mount this component the way this build wants it: from the live
/// `.gdc` while you are developing, from the compiled version in
/// anything you ship.
///
/// The entry point an application's `main` wants — no branch, no
/// path, no configuration. It returns nothing, because the live path
/// has no typed handle to give: its state lives in the interpreter
/// and is rebuilt on every reload. Call [`Self::mount_compiled`] when
/// you want the handle, or want the choice pinned rather than made
/// for you — which is what a test wants, since calling this one in a
/// development build would quietly test the interpreted path.
///
/// # Panics
///
/// If the live file is present but does not compile. That is a
/// development-time state with a developer looking at it, and the
/// diagnostics are the useful thing to say.
pub fn mount(
tree: &mut ::guiduck::core::WidgetTree,
parent: ::core::option::Option<::guiduck::core::WidgetId>,
props: #props_name,
logic: impl #logic_name,
) {
const __SOURCE: &::core::primitive::str = #source_path;
// Short-circuits when the interpreter is not in this build:
// no environment read, no filesystem probe, and the dev branch
// below folds away entirely.
let __live = ::guiduck::rt::AVAILABLE
&& match ::std::env::var("GUIDUCK_LIVE") {
::core::result::Result::Ok(v) => v != "0",
::core::result::Result::Err(_) => {
::core::cfg!(debug_assertions)
&& ::std::path::Path::new(__SOURCE).is_file()
}
};
if __live && ::std::path::Path::new(__SOURCE).is_file() {
match ::guiduck::rt::DevMount::new(
tree,
parent,
__SOURCE,
Self::dev_spec(props, logic),
) {
::core::result::Result::Ok(dev) => {
tree.set_live_reload(dev);
return;
}
// The file is there and broken: say so loudly rather than
// quietly running the last build's UI while the developer
// wonders why their edit did nothing.
::core::result::Result::Err(e) => {
::std::panic!("{}", e);
}
}
}
if __live {
::std::eprintln!(
"guiduck: no component source at {__SOURCE}; \
using the compiled component"
);
}
Self::mount_compiled(tree, parent, props, logic);
}
};
let dyn_registry = dyn_registry(
ir,
&name,
&props_name,
&cx_name,
&logic_name,
&dyn_name,
host,
);
quote! {
#(#tracking)*
#(#record_structs)*
pub struct #props_name {
#(#prop_fields)*
}
#props_default
/// Clone to keep a handler's context past the handler.
///
/// Work that finishes later — a file chooser, a download, a worker
/// thread — needs somewhere to put its answer, and that somewhere is
/// the same state a handler writes. Signals are `Copy` handles and
/// emitters are owned, so keeping the whole context costs nothing and
/// saves capturing them one at a time. [`Self::consume`] on a kept
/// context is a no-op, since there is no dispatch left to stop.
#[derive(Clone)]
pub struct #cx_name {
#(#cx_prop_fields)*
#(#state_fields)*
#(#output_fields)*
__consume: ::std::rc::Rc<::std::cell::Cell<bool>>,
}
impl #cx_name {
/// Signal that this handler consumed the event: no handler later in
/// the dispatch — a deeper capture, or a bubbling ancestor — sees
/// it. A no-op off the event path (an instance output, a query),
/// where propagation is not a thing to stop.
pub fn consume(&self) {
self.__consume.set(true);
}
}
#query_cx_struct
pub trait #logic_name: 'static {
/// Run once, after the component's subtree is built and every
/// binding and wire is attached.
///
/// This is where state that comes from outside the component
/// language is seeded — a document read from disk, rows from a
/// database — since a `:init` is a literal and a handler only runs
/// in answer to an event. Setting a signal here drives the
/// bindings that already depend on it, so the component's first
/// frame shows the loaded data.
///
/// The default does nothing.
fn mounted(&mut self, cx: #cx_name) {
let _ = cx;
}
#(#logic_methods)*
#(#logic_factories)*
}
#unit_logic
pub struct #name {
/// The subtree's root widget.
pub root: ::guiduck::core::WidgetId,
#(#prop_struct_fields)*
#(#state_struct_fields)*
#(#emitter_struct_fields)*
}
impl #name {
#mount_fns
#mount_wrapper
}
#dyn_registry
}
}
/// The dev-mode reassembly of the typed `Cx` from a `DynCx`, shared by every
/// path that runs a compiled handler against interpreted state: an event
/// through `invoke`, and a query resolver through `install_query`.
///
/// The lookups read with `?`, so both callers wrap them in an
/// `Option`-returning block; a record-list state becomes a temporary typed
/// signal the caller must then dispose (a query) or write back and dispose (an
/// event).
struct DevCxReassembly {
/// `let field = cx.getter(name)?;` for every prop, state, and output.
lookups: TokenStream,
/// The typed `Cx { … }` built from those locals.
cx_value: TokenStream,
/// Write each record-list temp back to its dynamic signal, then dispose it.
writebacks: TokenStream,
/// Dispose each record-list temp without writing back.
disposals: TokenStream,
}
fn dev_cx_reassembly(ir: &Ir, cx_name: &proc_macro2::Ident) -> DevCxReassembly {
let prop_lookups = ir.props.iter().map(|p| {
let var = snake_ident(&p.name);
let getter = dyn_prop_getter(p.ty);
let prop_name = &p.name;
quote! { let #var = cx.#getter(#prop_name)?.read_only(); }
});
let state_lookups = ir.states.iter().map(|s| {
let var = snake_ident(&s.name);
let state_name = &s.name;
match s.ty.kind {
ElemKind::Record(index) => {
let record_name = format_ident!("{}", ir.records[index as usize].name);
let dyn_var = format_ident!("__dyn_{var}");
quote! {
let #dyn_var = cx.signal_list_record(#state_name)?;
let #var: ::guiduck::core::signals::Signal<
::std::vec::Vec<#record_name>,
> = ::guiduck::core::signals::Signal::new(
#dyn_var
.get_untracked()
.iter()
.map(#record_name::__from_dyn)
.collect(),
);
}
}
ElemKind::Scalar(_) => {
let getter = dyn_signal_getter(s.ty);
quote! { let #var = cx.#getter(#state_name)?; }
}
}
});
let emitter_lookups = ir.outputs.iter().map(|o| {
let var = snake_ident(&o.name);
let getter = dyn_emitter_getter(ValueTy::scalar(o.ty));
let output_name = &o.name;
quote! { let #var = cx.#getter(#output_name)?; }
});
let prop_fields = ir.props.iter().map(|p| snake_ident(&p.name));
let state_fields = ir.states.iter().map(|s| snake_ident(&s.name));
let output_fields = ir.outputs.iter().map(|o| snake_ident(&o.name));
let cx_value = quote! {
#cx_name {
#(#prop_fields,)*
#(#state_fields,)*
#(#output_fields,)*
__consume: ::std::rc::Rc::clone(&__consume),
}
};
let writebacks = ir.states.iter().filter_map(|s| {
let var = snake_ident(&s.name);
match s.ty.kind {
ElemKind::Record(_) => {
let dyn_var = format_ident!("__dyn_{var}");
Some(quote! {
#dyn_var.set(
#var
.get_untracked()
.iter()
.map(|__record| __record.__to_dyn())
.collect(),
);
#var.dispose();
})
}
ElemKind::Scalar(_) => None,
}
});
let disposals = ir.states.iter().filter_map(|s| {
let var = snake_ident(&s.name);
match s.ty.kind {
ElemKind::Record(_) => Some(quote! { #var.dispose(); }),
ElemKind::Scalar(_) => None,
}
});
DevCxReassembly {
lookups: quote! { #(#prop_lookups)* #(#state_lookups)* #(#emitter_lookups)* },
cx_value,
writebacks: quote! { #(#writebacks)* },
disposals: quote! { #(#disposals)* },
}
}
/// One `install_query` match arm per distinct query handler: downcast the
/// widget to its concrete type and install a resolver that calls the compiled
/// handler, reassembling the typed context from the interpreter's `DynCx` at
/// resolve time.
///
/// This is the dev-mode twin of [`query_wire_tokens`] — the compiled path
/// installs the same resolver against typed signals; here it is installed
/// against the interpreter's dynamic ones. Reassembly failure falls back to the
/// return type's `Default`, which for a query is "no value" — exactly the
/// alt-text fallback, and why the return type must be `Default`.
fn query_install_arms(ir: &Ir, cx_name: &proc_macro2::Ident) -> Vec<TokenStream> {
let reassembly = dev_cx_reassembly(ir, cx_name);
let query_cx_name = format_ident!("{}QueryCx", ir.name);
let query_cx = query_cx_construction(ir, &query_cx_name);
let mut seen: Vec<String> = Vec::new();
let mut arms = Vec::new();
for node in &ir.nodes {
let IrWidget::Widget(widget) = &node.widget else {
continue;
};
for query in &widget.queries {
if seen.contains(&query.handler) {
continue;
}
seen.push(query.handler.clone());
let handler_name = &query.handler;
let method = snake_ident(&query.handler);
let widget_ty = type_path_tokens(&widget.type_path);
let setter = snake_ident(&query.setter);
let ret = type_path_tokens(&query.returns);
let arg_ty = match query.payload {
TypeKind::String => quote! { &str },
other => scalar_type_tokens(other),
};
let lookups = reassembly.lookups.clone();
let query_cx = query_cx.clone();
let disposals = reassembly.disposals.clone();
arms.push(quote! {
#handler_name => {
if let ::core::option::Option::Some(__w) =
widget.downcast_mut::<#widget_ty>()
{
let logic = ::std::rc::Rc::clone(&self.logic);
let cx = ::std::rc::Rc::clone(cx);
__w.#setter(move |__src: #arg_ty| -> #ret {
(|| -> ::core::option::Option<#ret> {
#lookups
let __typed_cx = #query_cx;
let __result =
logic.borrow_mut().#method(__typed_cx, __src);
#disposals
::core::option::Option::Some(__result)
})()
.unwrap_or_default()
});
}
}
});
}
}
arms
}
/// The dev-mode registry: a `DynHandlers` impl that reassembles the typed
/// `Cx` from a `DynCx` per invocation, plus `dev_spec` bundling it with a
/// dynamic snapshot of the props.
fn dyn_registry(
ir: &Ir,
name: &proc_macro2::Ident,
props_name: &proc_macro2::Ident,
cx_name: &proc_macro2::Ident,
logic_name: &proc_macro2::Ident,
dyn_name: &proc_macro2::Ident,
host: &Host<'_>,
) -> TokenStream {
let Host {
search_dirs,
widget_dirs,
asset_dirs,
..
} = host;
let component_str = ir.name.clone();
// Query handlers are installed as resolvers, not dispatched as events, and
// their read-only context is a different type — so they take no `invoke`
// arm.
let arms = ir.handlers.iter().filter(|h| h.returns.is_none()).map(|h| {
let handler_name = &h.name;
let method = snake_ident(&h.name);
let DevCxReassembly {
lookups,
cx_value,
writebacks,
..
} = dev_cx_reassembly(ir, cx_name);
// Re-type the dynamic arguments for the logic method, positionally:
// strings pass by reference, the numeric widths narrow from the
// DynValue carriers.
let count = h.payloads.len();
let extracts = h.payloads.iter().enumerate().map(|(i, ty)| {
let name = format_ident!("value{i}");
let index = i;
match ty {
TypeKind::String => quote! {
let ::guiduck::core::component::DynValue::Str(#name) = &args[#index]
else {
return ::core::option::Option::None;
};
let #name: &str = #name;
},
TypeKind::I32 => quote! {
let ::guiduck::core::component::DynValue::I64(#name) = &args[#index]
else {
return ::core::option::Option::None;
};
let #name = *#name as i32;
},
TypeKind::I64 => quote! {
let ::guiduck::core::component::DynValue::I64(#name) = &args[#index]
else {
return ::core::option::Option::None;
};
let #name = *#name;
},
TypeKind::F32 => quote! {
let ::guiduck::core::component::DynValue::F64(#name) = &args[#index]
else {
return ::core::option::Option::None;
};
let #name = *#name as f32;
},
TypeKind::F64 => quote! {
let ::guiduck::core::component::DynValue::F64(#name) = &args[#index]
else {
return ::core::option::Option::None;
};
let #name = *#name;
},
TypeKind::Bool => quote! {
let ::guiduck::core::component::DynValue::Bool(#name) = &args[#index]
else {
return ::core::option::Option::None;
};
let #name = *#name;
},
}
});
let arg_names = (0..count).map(|i| format_ident!("value{i}"));
let call = quote! {
if args.len() != #count {
return ::core::option::Option::None;
}
#(#extracts)*
self.logic.borrow_mut().#method(#cx_value, #(#arg_names),*);
};
quote! {
#handler_name => (|| -> ::core::option::Option<bool> {
// A fresh consume flag the reassembled cx carries; the compiled
// handler sets it through `cx.consume()`, and the interpreter
// stops propagation on the result, exactly as codegen does.
let __consume = ::std::rc::Rc::new(::std::cell::Cell::new(false));
#lookups
#call
#writebacks
::core::option::Option::Some(__consume.get())
})(),
}
});
let prop_snapshots = ir.props.iter().map(|p| {
let field = snake_ident(&p.name);
let prop_name = &p.name;
// Props are boundary types: validation guarantees scalars here.
let ElemKind::Scalar(prop_kind) = p.ty.kind else {
unreachable!("record-typed props are rejected during validation");
};
let scalar = |value: TokenStream| match prop_kind {
TypeKind::I32 | TypeKind::I64 => quote! {
::guiduck::core::component::DynValue::I64(#value as i64)
},
TypeKind::F32 | TypeKind::F64 => quote! {
::guiduck::core::component::DynValue::F64(#value as f64)
},
TypeKind::Bool => quote! {
::guiduck::core::component::DynValue::Bool(#value)
},
TypeKind::String => quote! {
::guiduck::core::component::DynValue::Str(#value.clone())
},
};
let value = if p.ty.list {
let element = scalar(quote! { (*__element) });
let element = match prop_kind {
TypeKind::String => scalar(quote! { __element }),
_ => element,
};
quote! {
::guiduck::core::component::DynValue::List(
props.#field.iter().map(|__element| #element).collect(),
)
}
} else {
scalar(quote! { props.#field })
};
quote! { (::std::string::String::from(#prop_name), #value), }
});
let child_factories = instantiated_components(ir).into_iter().map(|child| {
let child_ty = format_ident!("{}", child);
let factory = format_ident!("{}", component_method_name(child));
quote! {
children.insert(
::std::string::String::from(#child),
::std::rc::Rc::new({
let logic = ::std::rc::Rc::clone(&logic);
move || #child_ty::child_spec(logic.borrow_mut().#factory())
}),
);
}
});
let query_arms = query_install_arms(ir, cx_name);
let mounted_arm = {
let DevCxReassembly {
lookups,
cx_value,
writebacks,
..
} = dev_cx_reassembly(ir, cx_name);
quote! {
// The same reassembly `invoke` does: the hook is compiled logic
// run against interpreted state, so what it sets must land back in
// the interpreter's signals. Nothing is dispatching, so the consume
// flag is dead.
let _: ::core::option::Option<()> = (|| {
let __consume = ::std::rc::Rc::new(::std::cell::Cell::new(false));
#lookups
self.logic.borrow_mut().mounted(#cx_value);
#writebacks
::core::option::Option::Some(())
})();
}
};
quote! {
/// Dev-mode handler registry for the runtime interpreter: handlers
/// stay compiled Rust while structure and bindings are interpreted.
/// The logic is shared with the child-spec factories, which mint a
/// fresh child logic per nested instance.
pub struct #dyn_name<L: #logic_name> {
logic: ::std::rc::Rc<::std::cell::RefCell<L>>,
}
impl<L: #logic_name> ::guiduck::core::component::DynHandlers for #dyn_name<L> {
fn invoke(
&mut self,
name: &str,
cx: &::guiduck::core::component::DynCx,
args: &[::guiduck::core::component::DynValue],
) -> bool {
let handled: ::core::option::Option<bool> = match name {
#(#arms)*
_ => ::core::option::Option::None,
};
match handled {
::core::option::Option::Some(consumed) => consumed,
::core::option::Option::None => {
::guiduck::core::component::warn_handler_skipped(#component_str, name);
false
}
}
}
fn install_query(
&self,
name: &str,
widget: &mut dyn ::guiduck::core::Widget,
cx: &::std::rc::Rc<::guiduck::core::component::DynCx>,
) {
match name {
#(#query_arms)*
_ => {}
}
}
fn mounted(&mut self, cx: &::guiduck::core::component::DynCx) {
#mounted_arm
}
}
impl #name {
/// The compiled side of a *nested* dev-mode instance: the
/// handler registry plus a spec factory per component this one
/// instantiates. Prop values come from the parent's file, so
/// there is no snapshot here.
pub fn child_spec(
logic: impl #logic_name,
) -> ::guiduck::core::component::ComponentSpec {
let logic = ::std::rc::Rc::new(::std::cell::RefCell::new(logic));
let mut children: ::std::collections::HashMap<
::std::string::String,
::std::rc::Rc<dyn Fn() -> ::guiduck::core::component::ComponentSpec>,
> = ::std::collections::HashMap::new();
#(#child_factories)*
::guiduck::core::component::ComponentSpec {
handlers: ::std::rc::Rc::new(::std::cell::RefCell::new(#dyn_name {
logic,
})),
props: ::std::vec::Vec::new(),
children,
search_path: ::std::vec![
#(::std::path::PathBuf::from(#search_dirs),)*
],
widget_path: ::std::vec![
#(::std::path::PathBuf::from(#widget_dirs),)*
],
asset_path: ::std::vec![
#(::std::path::PathBuf::from(#asset_dirs),)*
],
}
}
/// Bundle typed props and logic for a dev-mode (hot-reload)
/// mount driven by the runtime interpreter.
pub fn dev_spec(
props: #props_name,
logic: impl #logic_name,
) -> ::guiduck::core::component::ComponentSpec {
let mut spec = Self::child_spec(logic);
spec.props = ::std::vec![#(#prop_snapshots)*];
spec
}
}
}
}
fn dyn_signal_getter(ty: ValueTy) -> proc_macro2::Ident {
format_ident!("signal_{}", dyn_getter_suffix(ty))
}
fn dyn_prop_getter(ty: ValueTy) -> proc_macro2::Ident {
format_ident!("prop_{}", dyn_getter_suffix(ty))
}
fn dyn_getter_suffix(ty: ValueTy) -> String {
let scalar = match ty.kind {
ElemKind::Scalar(TypeKind::I32) => "i32",
ElemKind::Scalar(TypeKind::I64) => "i64",
ElemKind::Scalar(TypeKind::F32) => "f32",
ElemKind::Scalar(TypeKind::F64) => "f64",
ElemKind::Scalar(TypeKind::Bool) => "bool",
ElemKind::Scalar(TypeKind::String) => "string",
ElemKind::Record(_) => "record",
};
if ty.list {
format!("list_{scalar}")
} else {
scalar.to_owned()
}
}
fn dyn_emitter_getter(ty: ValueTy) -> proc_macro2::Ident {
format_ident!("emitter_{}", dyn_getter_suffix(ty))
}
/// Tokens constructing a `Cx` value inside an event closure, where prop and
/// state signals and emitters are in scope by their snake names.
fn cx_construction(ir: &Ir, cx_name: &proc_macro2::Ident) -> TokenStream {
let props = ir.props.iter().map(|p| {
let field = snake_ident(&p.name);
quote! { #field: #field.read_only(), }
});
let states = ir.states.iter().map(|s| {
let field = snake_ident(&s.name);
quote! { #field, }
});
let outputs = ir.outputs.iter().map(|o| {
let field = snake_ident(&o.name);
quote! { #field: #field.clone(), }
});
quote! {
#cx_name {
#(#props)*
#(#states)*
#(#outputs)*
__consume: ::std::rc::Rc::clone(&__consume),
}
}
}
/// The read-only context a query handler receives — like [`cx_construction`]
/// but every state signal is narrowed to a `ReadSignal`, so the handler cannot
/// `set` it. Built from the same signal locals both the compiled mount and the
/// dev-mode reassembly already have in scope.
fn query_cx_construction(ir: &Ir, query_cx_name: &proc_macro2::Ident) -> TokenStream {
let props = ir.props.iter().map(|p| {
let field = snake_ident(&p.name);
quote! { #field: #field.read_only(), }
});
let states = ir.states.iter().map(|s| {
let field = snake_ident(&s.name);
quote! { #field: #field.read_only(), }
});
let outputs = ir.outputs.iter().map(|o| {
let field = snake_ident(&o.name);
quote! { #field: #field.clone(), }
});
quote! {
#query_cx_name {
#(#props)*
#(#states)*
#(#outputs)*
}
}
}
/// The component types a file instantiates, deduplicated, in node order.
fn instantiated_components(ir: &Ir) -> Vec<&str> {
let mut out: Vec<&str> = Vec::new();
for node in &ir.nodes {
if let IrWidget::Component(name) = &node.widget
&& !out.contains(&name.as_str())
{
out.push(name);
}
}
out
}
/// Everything the node builders need about the compilation unit: the
/// compiled IR, plus the absolute path the host resolved each `(asset "…")`
/// reference to. Finding the files is the host's job — codegen only turns a
/// resolution into an `include_bytes!` target — but the two travel together
/// because a node's `:source` is meaningless without its resolution.
struct Unit<'a> {
compiled: &'a Compiled,
assets: &'a BTreeMap<String, String>,
}
impl<'a> Unit<'a> {
fn new(compiled: &'a Compiled, assets: &'a BTreeMap<String, String>) -> Self {
Self { compiled, assets }
}
}
/// Widget construction, bindings, and event wiring, from the root down.
/// Node variables are `__node0` (root), `__node1`, …; recursion (rather
/// than a flat loop) lets an instance's slot content be emitted inside the
/// closure handed to the child's `mount_with_slot`.
fn build_nodes(unit: &Unit<'_>, cx: &TokenStream) -> TokenStream {
node_tokens(unit, 0, "e! { parent }, cx)
}
/// One node — and, below it, its subtree.
fn node_tokens(
unit: &Unit<'_>,
index: usize,
parent: &TokenStream,
cx: &TokenStream,
) -> TokenStream {
let compiled = unit.compiled;
let ir = &compiled.root;
let node = &ir.nodes[index];
let var = format_ident!("__node{index}");
// One widget node, whether the vocabulary it came from is the framework's
// or an application's. Everything from the construction down — the layout
// style, `:class`, `:enabled`, the pointer events, `:tooltip` — is the
// vocabulary *every* widget has, so it is written once here and both fall
// through it, exactly as both arms of `Registry::lookup` share the
// universal tables.
let widget = match &node.widget {
IrWidget::Widget(widget) => widget,
IrWidget::Component(child_name) => {
return instance_tokens(unit, node, index, child_name, parent, cx);
}
// The slot marker: hand the projected content the enclosing
// widget, mid-sequence, so sibling order is preserved.
IrWidget::Slot => {
return quote! { __slot(tree, #parent); };
}
IrWidget::If => return if_tokens(unit, node, index, parent, cx),
IrWidget::For => return for_tokens(unit, node, index, parent, cx),
};
let constructor = construct_tokens(unit, widget);
let style = style_tokens(&node.style);
let mut out = quote! {
let #var = tree.insert(#constructor, #style, #parent);
};
if !node.classes.is_empty() {
let classes = node.classes.iter().map(|c| {
quote! { ::std::string::String::from(#c) }
});
out.extend(quote! {
tree.set_classes(#var, [#(#classes),*]);
});
}
out.extend(enabled_tokens(ir, node, &var));
for prop in &widget.props {
out.extend(binding_tokens(ir, widget, prop, &var));
}
for wire in &node.events {
let IrEvent {
event,
handler,
args,
} = wire;
let kind = event_kind_tokens(*event);
let method = snake_ident(handler);
let cx = cx.clone();
let emitter_clones = ir.outputs.iter().map(|o| {
let name = snake_ident(&o.name);
quote! { let #name = #name.clone(); }
});
// Payload-carrying events pass the widget's value through to the
// logic method; invocation wires evaluate their arguments at
// dispatch time, in this node's scope (loop variables included).
// Validation guaranteed the declaration matches either way.
// The wire is keyed on the *payload kind* the registry declares, not
// on which event it is: an event that delivers a `String` is plumbed
// like every other one that does, so `:on-link` needed no arm here and
// neither will the next one.
let invoke = match event.payload() {
Some(PayloadKind::String) if args.is_empty() => quote! {
let ::core::option::Option::Some(value) = _ev.string_payload() else {
return;
};
logic.borrow_mut().#method(#cx, value);
},
Some(PayloadKind::String) => {
let call = invocation_call_tokens(ir, handler, args, &cx);
quote! {
let ::core::option::Option::Some(value) = _ev.string_payload() else {
return;
};
let __payload: ::std::string::String = value.to_owned();
#call
}
}
Some(PayloadKind::Bool) if args.is_empty() => quote! {
let ::core::option::Option::Some(value) = _ev.bool_payload() else {
return;
};
logic.borrow_mut().#method(#cx, value);
},
Some(PayloadKind::Bool) => {
let call = invocation_call_tokens(ir, handler, args, &cx);
quote! {
let ::core::option::Option::Some(value) = _ev.bool_payload() else {
return;
};
let __payload: bool = value;
#call
}
}
Some(PayloadKind::Number) if args.is_empty() => quote! {
let ::core::option::Option::Some(value) = _ev.number_payload() else {
return;
};
logic.borrow_mut().#method(#cx, value);
},
Some(PayloadKind::Number) => {
let call = invocation_call_tokens(ir, handler, args, &cx);
quote! {
let ::core::option::Option::Some(value) = _ev.number_payload() else {
return;
};
let __payload: f64 = value;
#call
}
}
// Payload-free events — the pointer family, `:on-select`,
// `:on-close` — are just an invocation.
None => invocation_call_tokens(ir, handler, args, &cx),
};
out.extend(quote! {
{
let logic = ::std::rc::Rc::clone(&logic);
#(#emitter_clones)*
tree.on_event(#var, #kind, move |ctx, _ev| {
// A fresh consume flag per dispatch; `cx.consume()` sets it,
// and a set flag stops propagation to the rest of the path.
let __consume = ::std::rc::Rc::new(::std::cell::Cell::new(false));
#invoke
if __consume.get() {
ctx.stop_propagation();
}
});
}
});
}
for wire in &widget.events {
out.extend(user_event_tokens(ir, wire, &var, cx));
}
for query in &widget.queries {
out.extend(query_wire_tokens(ir, query, &widget.type_path, &var));
}
// 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.
if node.autofocus {
out.extend(quote! {
tree.set_focus(::core::option::Option::Some(#var));
});
}
out.extend(focused_tokens(ir, node, &var));
if let Some(tooltip) = &node.tooltip {
out.extend(quote! {
tree.set_tooltip(#var, ::core::option::Option::Some(#tooltip.to_owned()));
});
}
// How a node's children mount. Content that projects a `(slot)` builds
// inline: the slot's fill is the transient `__slot` closure, in lexical
// scope only while the mount runs, so it cannot be captured into a builder
// — and a widget projecting one therefore never defers, which is why this
// is a static decision rather than a runtime one.
//
// Everything else is handed to the widget as a `ContentBuilder` through
// `realize_content`; the tree consults the widget at runtime and either
// builds it inline (the common case) or, for a popup, stores it to build on
// open. So a menu, a dropdown, a dialog, and a plain container all mount
// their children through one line — deferral is the widget's answer, not a
// codegen branch, so an application widget that hosts a popup needs none.
if subtree_has_slot(ir, index) {
let child_parent = quote! { ::core::option::Option::Some(#var) };
for child in &node.children {
out.extend(node_tokens(unit, *child, &child_parent, cx));
}
} else if !node.children.is_empty() {
let builder = content_builder_tokens(unit, node, cx);
out.extend(quote! {
tree.realize_content(#var, #builder);
});
}
// Accelerators are the exception: they must fire while a popup is closed
// and its rows do not exist, so they register at mount, walking the
// deferred subtree. Only a widget that defers its content leaves those rows
// unbuilt, so the registration is gated on that runtime fact; a widget that
// mounted the same subtree inline registers nothing extra (the block is
// empty unless the subtree holds an accelerator, which only a menu's does).
let accel = accel_tokens(unit, node, cx, &var);
if !accel.is_empty() {
out.extend(quote! {
if tree.defers_content(#var) {
#accel
}
});
}
out
}
/// The `let __argN = …; logic.borrow_mut().method(cx, …)` sequence for an
/// invocation wire, with string payloads passed by reference per the trait
/// signature.
fn invocation_call_tokens(ir: &Ir, handler: &str, args: &[Expr], cx: &TokenStream) -> TokenStream {
let method = snake_ident(handler);
let payloads = ir
.handlers
.iter()
.find(|h| h.name == *handler)
.map(|h| h.payloads.as_slice())
.unwrap_or(&[]);
let arg_values = args.iter().enumerate().map(|(i, arg)| {
let name = format_ident!("__arg{i}");
let value = expr_tokens(ir, arg, false);
quote! { let #name = #value; }
});
let args_tokens = payloads.iter().enumerate().map(|(i, ty)| {
let name = format_ident!("__arg{i}");
match ty {
TypeKind::String => quote! { &#name },
_ => quote! { #name },
}
});
quote! {
#(#arg_values)*
logic.borrow_mut().#method(#cx, #(#args_tokens),*);
}
}
/// Clone tokens for the captures a dynamic-region closure needs beyond the
/// Copy signal handles: the logic Rc and every output emitter.
fn dynamic_captures(ir: &Ir) -> TokenStream {
let emitters = ir.outputs.iter().map(|o| {
let name = snake_ident(&o.name);
quote! { let #name = #name.clone(); }
});
quote! {
let logic = ::std::rc::Rc::clone(&logic);
#(#emitters)*
}
}
/// A branch/row subtree builder body: mount the node (appending), then move
/// it to the region's position and yield its extent.
///
/// The extent is a single widget for an ordinary body and the inner region's
/// anchored run when the body is itself an `(if …)` or `(for …)` — which is
/// the whole of what makes structural forms nest, since a region occupies a
/// run of sibling slots rather than one.
fn dynamic_body(unit: &Unit<'_>, child_index: usize, cx: &TokenStream) -> TokenStream {
let body = node_tokens(unit, child_index, "e! { __dyn_parent }, cx);
let extent = match unit.compiled.root.nodes[child_index].widget {
IrWidget::If | IrWidget::For => {
let region = format_ident!("__region{child_index}");
quote! { #region.borrow().extent() }
}
_ => {
let var = format_ident!("__node{child_index}");
quote! { ::guiduck::core::widget::dynamic::Extent::Single(#var) }
}
};
quote! {
#body
let __extent = #extent;
::guiduck::core::widget::dynamic::place(tree, __dyn_parent, __extent, __at);
}
}
/// A structural `(if …)`: a [`Conditional`] region driven by an effect.
///
/// [`Conditional`]: guiduck::core::widget::dynamic::Conditional
fn if_tokens(
unit: &Unit<'_>,
node: &IrNode,
index: usize,
parent: &TokenStream,
cx: &TokenStream,
) -> TokenStream {
let compiled = unit.compiled;
let ir = &compiled.root;
let Some(ControlIr::If { cond }) = &node.control else {
unreachable!("If nodes carry If control data");
};
let region = format_ident!("__region{index}");
let cond = expr_tokens(ir, cond, false);
let captures = dynamic_captures(ir);
let then_body = dynamic_body(unit, node.children[0], cx);
let build = match node.children.get(1) {
Some(else_index) => {
let else_body = dynamic_body(unit, *else_index, cx);
quote! {
if __cond {
#then_body
::core::option::Option::Some(__extent)
} else {
#else_body
::core::option::Option::Some(__extent)
}
}
}
None => quote! {
if __cond {
#then_body
::core::option::Option::Some(__extent)
} else {
::core::option::Option::None
}
},
};
quote! {
let #region = ::std::rc::Rc::new(::std::cell::RefCell::new(
::guiduck::core::widget::dynamic::Conditional::new(tree, #parent),
));
{
let __region = ::std::rc::Rc::clone(&#region);
let __commands = tree.commands();
#captures
::guiduck::core::signals::Effect::new(move || {
let __cond: bool = #cond;
let __region = ::std::rc::Rc::clone(&__region);
#captures
__commands.push(move |tree| {
__region.borrow_mut().set(
tree,
__cond,
move |tree, __dyn_parent, __at| #build,
);
});
});
}
}
}
/// A keyed `(for …)`: a [`KeyedList`] region reconciled by an effect.
///
/// [`KeyedList`]: guiduck::core::widget::dynamic::KeyedList
fn for_tokens(
unit: &Unit<'_>,
node: &IrNode,
index: usize,
parent: &TokenStream,
cx: &TokenStream,
) -> TokenStream {
let compiled = unit.compiled;
let ir = &compiled.root;
let Some(ControlIr::For {
var,
index: index_name,
list,
element,
key,
}) = &node.control
else {
unreachable!("For nodes carry For control data");
};
let region = format_ident!("__region{index}");
let elem_ty = elem_type_tokens(&ir.records, *element);
let list_ident = snake_ident(list);
let var_ident = snake_ident(var);
let index_binding = index_name.as_ref().map(|name| {
let ident = snake_ident(name);
quote! { let #ident = __row_index; }
});
// The key derives from a plain item value (rows do not exist yet when
// keys are computed), so the loop names bind as `PlainValue`s — same
// `.get()` spelling the expression emitter uses for signals.
let key_of = match key {
Some(key) => {
let key_index_binding = index_name.as_ref().map(|name| {
let ident = snake_ident(name);
quote! {
let #ident = ::guiduck::core::widget::dynamic::PlainValue(
__key_index as i64,
);
}
});
let key_value = expr_tokens(ir, key, false);
quote! {
|__key_index: usize, __key_item: &#elem_ty| {
let #var_ident =
::guiduck::core::widget::dynamic::PlainValue(__key_item.clone());
#key_index_binding
#key_value
}
}
}
// Unkeyed: rows match by position.
None => quote! { |__key_index: usize, _: &#elem_ty| __key_index as i64 },
};
let captures = dynamic_captures(ir);
let row_body = dynamic_body(unit, node.children[0], cx);
quote! {
let #region = ::std::rc::Rc::new(::std::cell::RefCell::new(
::guiduck::core::widget::dynamic::KeyedList::new(tree, #parent),
));
{
let __region = ::std::rc::Rc::clone(&#region);
let __commands = tree.commands();
#captures
::guiduck::core::signals::Effect::new(move || {
let __items: ::std::vec::Vec<#elem_ty> = #list_ident.get();
// 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 = ::std::rc::Rc::clone(&__region);
#captures
__commands.push(move |tree| {
__region.borrow_mut().reconcile(
tree,
&__items,
#key_of,
|tree,
__dyn_parent,
__at,
__item: ::guiduck::core::signals::Signal<#elem_ty>,
__row_index: ::guiduck::core::signals::Signal<i64>| {
let #var_ident = __item;
#index_binding
#row_body
__extent
},
);
});
});
}
}
}
/// One component instance: mount the child with every prop populated
/// (given, or the child's declared default), install one driving effect per
/// non-literal prop expression, and subscribe output wires to the parent's
/// logic methods.
fn instance_tokens(
unit: &Unit<'_>,
node: &IrNode,
index: usize,
child_name: &str,
parent: &TokenStream,
cx: &TokenStream,
) -> TokenStream {
let compiled = unit.compiled;
let ir = &compiled.root;
let child = compiled
.components
.get(child_name)
.expect("component references resolve during compilation");
let child_ty = format_ident!("{}", child_name);
let child_props_ty = format_ident!("{}Props", child_name);
let factory = format_ident!("{}", component_method_name(child_name));
let inst = format_ident!("__component{index}");
let var = format_ident!("__node{index}");
let prop_inits = child.props.iter().map(|declared| {
let field = snake_ident(&declared.name);
let given = node
.component_props
.iter()
.find(|(name, _)| *name == declared.name);
let value = match given {
// A literal coerces to the child's declared type; a reactive
// expression's initial value is its evaluation right now (the
// driving effect below keeps it current).
Some((_, expr)) => match literal_of(expr) {
Some(literal) => literal_tokens(&literal, Some(declared.ty)),
None => expr_tokens(ir, expr, false),
},
None => literal_tokens(
declared
.default
.as_ref()
.expect("validation checked required props"),
Some(declared.ty),
),
};
quote! { #field: #value, }
});
// The child logic is minted *before* the mount call: as a call
// argument, the `borrow_mut` temporary would live for the whole
// statement — including a slot closure that borrows `logic` again.
let child_logic = format_ident!("__logic{index}");
let mint_logic = quote! {
let #child_logic = logic.borrow_mut().#factory();
};
// Slot content — this instance's children — is parent content built
// inside a closure the child invokes at its `(slot)`: names were
// resolved in this file's scope, and the captured `logic`/emitters are
// this component's.
let mount_call = if node.children.is_empty() {
quote! {
#child_ty::mount_compiled(
tree,
#parent,
#child_props_ty { #(#prop_inits)* },
#child_logic,
)
}
} else {
let slot_parent = quote! { __slot_parent };
let content = node
.children
.iter()
.map(|child| node_tokens(unit, *child, &slot_parent, cx));
let emitter_clones = ir.outputs.iter().map(|o| {
let name = snake_ident(&o.name);
quote! { let #name = #name.clone(); }
});
quote! {
#child_ty::mount_compiled_with_slot(
tree,
#parent,
#child_props_ty { #(#prop_inits)* },
#child_logic,
{
let logic = ::std::rc::Rc::clone(&logic);
#(#emitter_clones)*
move |tree: &mut ::guiduck::core::WidgetTree,
__slot_parent: ::core::option::Option<::guiduck::core::WidgetId>| {
#(#content)*
}
},
)
}
};
let mut out = quote! {
#mint_logic
let #inst = #mount_call;
let #var = #inst.root;
};
// Instance-level `:class` merges into the root widget's own classes,
// and the flex-item layout overrides patch its style in place.
if !node.classes.is_empty() {
let classes = node.classes.iter().map(|c| {
quote! { ::std::string::String::from(#c) }
});
out.extend(quote! {
tree.add_classes(#var, [#(#classes),*]);
});
}
let patch = style_patch_tokens(&node.style);
if !patch.is_empty() {
out.extend(quote! {
tree.update_style(#var, |__style| { #patch });
});
}
out.extend(enabled_tokens(ir, node, &var));
for (prop_name, expr) in &node.component_props {
if literal_of(expr).is_some() {
continue;
}
let field = snake_ident(prop_name);
let value = expr_tokens(ir, expr, false);
out.extend(quote! {
{
let __target = #inst.#field;
::guiduck::core::signals::Effect::new(move || __target.set(#value));
}
});
}
for wire in &node.component_outputs {
let output_field = snake_ident(&wire.output);
let method = snake_ident(&wire.handler);
let output_ty = child
.outputs
.iter()
.find(|o| o.name == wire.output)
.expect("validation checked output wires")
.ty;
let emitter_clones = ir.outputs.iter().map(|o| {
let name = snake_ident(&o.name);
quote! { let #name = #name.clone(); }
});
let cx = cx.clone();
let invoke = if wire.args.is_empty() {
// Bare-name form: the output's value is the one argument.
// `&String` coerces to the handler's `&str`; everything else
// in the type set is `Copy` and passes by value.
let pass = match output_ty {
TypeKind::String => quote! { __value },
_ => quote! { *__value },
};
quote! { logic.borrow_mut().#method(#cx, #pass); }
} else {
// Invocation form: `payload` binds the emitted value; the
// arguments evaluate at dispatch, in this node's scope.
let bind_payload = match output_ty {
TypeKind::String => quote! { let __payload = __value.clone(); },
_ => quote! { let __payload = *__value; },
};
let call = invocation_call_tokens(ir, &wire.handler, &wire.args, &cx);
quote! {
#bind_payload
#call
}
};
out.extend(quote! {
{
let logic = ::std::rc::Rc::clone(&logic);
#(#emitter_clones)*
#inst.#output_field.subscribe(move |__value| {
// An instance output has no dispatch to stop; `cx.consume()`
// in a handler wired here is a no-op against a dead flag.
let __consume = ::std::rc::Rc::new(::std::cell::Cell::new(false));
#invoke
});
}
});
}
out
}
/// The literal an expression is, if it is one (the static/reactive split
/// for component props).
fn literal_of(expr: &Expr) -> Option<Literal> {
match expr {
Expr::Int(v, _) => Some(Literal::Int(*v)),
Expr::Float(v, _) => Some(Literal::Float(*v)),
Expr::Bool(v, _) => Some(Literal::Bool(*v)),
Expr::Str(template, _) if template.refs().next().is_none() => Some(Literal::Str(
template
.segments
.iter()
.filter_map(|s| match s {
Segment::Literal(l) => Some(l.as_str()),
Segment::Ref(..) => None,
})
.collect(),
)),
_ => None,
}
}
/// Build a widget: `<Ty as Default>::default()`, then a setter call per
/// property whose value is known now.
///
/// **This is the only place a widget is constructed.** A `container` and an
/// application's `markdown` come through it identically, because the contract
/// is identical — `Default + Widget` plus a setter per property — and what a
/// builtin's descriptor says is what a `.gdw` manifest says. There is no
/// constructor-argument form to mirror: a property is a setter call, so a
/// static value and a bound one reach the widget through the same method, and
/// the only difference is when.
fn construct_tokens(unit: &Unit<'_>, widget: &IrWidgetNode) -> TokenStream {
let ty = type_path_tokens(&widget.type_path);
let setters = widget.props.iter().map(|prop| {
let method = snake_ident(&prop.setter);
let value = match &prop.value {
IrPropValue::Static(literal) => prop_literal_tokens(unit, literal, prop.ty),
// A binding is applied by an effect, not at construction.
IrPropValue::Binding(_) => return TokenStream::new(),
};
quote! { __widget.#method(#value); }
});
quote! {
{
let mut __widget = <#ty as ::core::default::Default>::default();
#(#setters)*
__widget
}
}
}
/// One reactive binding: an effect computing the value, applied through the
/// property's setter (which classifies the dirt).
///
/// The twin of the setter calls [`construct_tokens`] emits, and — the point —
/// it reads the same three facts out of the same [`IrWidgetProp`]: the
/// widget's type, the method, and the type that method takes. A builtin and a
/// declared widget cannot disagree about a value's width, because nothing here
/// knows which it is looking at.
fn binding_tokens(
ir: &Ir,
widget: &IrWidgetNode,
prop: &IrWidgetProp,
var: &proc_macro2::Ident,
) -> TokenStream {
let IrPropValue::Binding(expr) = &prop.value else {
return TokenStream::new();
};
let ty = type_path_tokens(&widget.type_path);
let value_ty = prop_type_tokens(prop.ty);
let value = prop_value_tokens(ir, expr, prop.ty);
let method = snake_ident(&prop.setter);
quote! {
tree.bind::<#ty, #value_ty>(
#var,
move || #value,
|__widget, __value| __widget.#method(__value),
);
}
}
/// An expression at the type a property's setter takes.
///
/// The numeric families widen and narrow freely across a `.gdc` boundary —
/// validation admits an `i64` state on an `f32` property — so the cast is
/// emitted here rather than left for rustc to reject against generated code.
/// Being one function is what makes that true of every property of every
/// widget: there is no second lane left to forget it.
fn prop_value_tokens(ir: &Ir, expr: &Expr, ty: PropTy) -> TokenStream {
let value = expr_tokens(ir, expr, ty == PropTy::Brush);
match ty {
// Already the setter's type; `String` has no cast, and a `bool`
// expression is a `bool`.
PropTy::Scalar(TypeKind::String | TypeKind::Bool) => value,
PropTy::Scalar(kind) => {
let kind = scalar_type_tokens(kind);
quote! { ((#value) as #kind) }
}
// A brush expression evaluates to a `Color` (validation admits color
// literals and `if`s choosing between them); the setter takes the
// wider `Brush`.
PropTy::Brush => quote! { ::guiduck::paint::Brush::from(#value) },
// A bound paragraph is a string: rich text with no styled runs.
PropTy::RichText => quote! { ::guiduck::core::RichText::from(#value) },
PropTy::Graphic | PropTy::ScrollAxes => {
unreachable!("validation admits no binding on this property")
}
}
}
/// A static property value, at the type its setter takes.
fn prop_literal_tokens(unit: &Unit<'_>, literal: &Literal, ty: PropTy) -> TokenStream {
match ty {
PropTy::Scalar(kind) => literal_tokens(literal, Some(ValueTy::scalar(kind))),
PropTy::Brush => {
let color = literal_tokens(literal, None);
quote! { ::guiduck::paint::Brush::from(#color) }
}
PropTy::Graphic => graphic_tokens(unit, literal),
PropTy::RichText => rich_text_tokens(literal),
PropTy::ScrollAxes => {
let Literal::ScrollAxes(axes) = literal else {
unreachable!("validated axis literal");
};
let variant = match axes {
ScrollAxesIr::Vertical => quote! { Vertical },
ScrollAxesIr::Horizontal => quote! { Horizontal },
ScrollAxesIr::Both => quote! { Both },
};
quote! { ::guiduck::core::ScrollAxes::#variant }
}
}
}
/// The Rust type a property's setter takes.
fn prop_type_tokens(ty: PropTy) -> TokenStream {
match ty {
PropTy::Scalar(kind) => scalar_type_tokens(kind),
PropTy::Brush => quote! { ::guiduck::paint::Brush },
PropTy::Graphic => quote! { ::guiduck::core::graphic::Graphic },
PropTy::RichText => quote! { ::guiduck::core::RichText },
PropTy::ScrollAxes => quote! { ::guiduck::core::ScrollAxes },
}
}
/// A graphic literal: `(path …)` geometry, or an `(asset "…")` file's bytes,
/// embedded at build time.
fn graphic_tokens(unit: &Unit<'_>, literal: &Literal) -> TokenStream {
match literal {
Literal::Path(cmds) => {
let cmds = cmds.iter().map(path_cmd_tokens);
quote! {
::guiduck::core::graphic::Graphic::from_path_cmds(&[#(#cmds),*])
}
}
Literal::Asset(raw) => {
// Resolution happened in the macro entry point, so this path is
// absolute and exists.
let absolute = unit.assets.get(raw).map(String::as_str).unwrap_or_default();
quote! {
::guiduck::core::graphic::Graphic::Image(
match ::guiduck::core::asset::embedded_image(
#raw,
::core::include_bytes!(#absolute),
) {
::core::result::Result::Ok(image) => image,
::core::result::Result::Err(why) => ::core::panic!(
"guiduck: asset `{}` is not an image this build \
can decode: {}",
#raw,
why,
),
}
)
}
}
_ => unreachable!("validated graphic literal"),
}
}
/// 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, and a `Copy` kind cannot carry a name — so
/// the wire matches the name it was written for before it fires. Which handler
/// call to make is [`invocation_call_tokens`]', exactly as for a builtin;
/// only reading the payload out is different, because a declared payload's
/// type is the manifest's rather than the registry's.
///
/// [`EventKind::User`]: guiduck::core::EventKind::User
fn user_event_tokens(
ir: &Ir,
wire: &IrUserEvent,
var: &proc_macro2::Ident,
cx: &TokenStream,
) -> TokenStream {
let event_name = &wire.name;
let method = snake_ident(&wire.handler);
let emitter_clones = ir.outputs.iter().map(|o| {
let name = snake_ident(&o.name);
quote! { let #name = #name.clone(); }
});
let cx = cx.clone();
let invoke = match wire.payload {
// Payload-free: a bare handler name or an invocation wire, both of
// which `invocation_call_tokens` already spells.
None => invocation_call_tokens(ir, &wire.handler, &wire.args, &cx),
// Bare-name form: validation checked the handler declares exactly this
// payload, so it passes straight through (strings by reference, per
// the trait signature).
// A `String` payload extracts as `&str`, which is what the trait
// signature already wants; every other type in the set is `Copy`.
Some(ty) if wire.args.is_empty() => {
let extract = payload_extract_tokens(ty);
quote! {
#extract
logic.borrow_mut().#method(#cx, __value);
}
}
// Invocation form: `payload` binds the delivered value and the
// arguments evaluate at dispatch, in this node's scope.
Some(ty) => {
let extract = payload_extract_tokens(ty);
let bind = match ty {
TypeKind::String => {
quote! { let __payload: ::std::string::String = __value.to_owned(); }
}
_ => {
let rust_ty = scalar_type_tokens(ty);
quote! { let __payload: #rust_ty = __value; }
}
};
let call = invocation_call_tokens(ir, &wire.handler, &wire.args, &cx);
quote! {
#extract
#bind
#call
}
}
};
quote! {
{
let logic = ::std::rc::Rc::clone(&logic);
#(#emitter_clones)*
tree.on_event(#var, ::guiduck::core::EventKind::User, move |ctx, _ev| {
let ::core::option::Option::Some((__event, __payload_value)) = _ev.user() else {
return;
};
if __event != #event_name {
return;
}
// A fresh consume flag per dispatch, exactly as a builtin event
// wire carries: `cx.consume()` stops propagation.
let __consume = ::std::rc::Rc::new(::std::cell::Cell::new(false));
#invoke
if __consume.get() {
ctx.stop_propagation();
}
});
}
}
}
/// A `:get-image (load payload)` query wire: install the handler as a resolver
/// closure on the widget, instead of registering it as an event.
///
/// The widget calls it synchronously — `payload` is the value it wants resolved
/// (a URL), and the handler returns the query's declared type — so this is the
/// one wire that hands the widget an answer rather than reporting to the app.
fn query_wire_tokens(
ir: &Ir,
query: &IrQuery,
type_path: &str,
var: &proc_macro2::Ident,
) -> TokenStream {
let ty = type_path_tokens(type_path);
let setter = snake_ident(&query.setter);
let ret = type_path_tokens(&query.returns);
let method = snake_ident(&query.handler);
let query_cx_name = format_ident!("{}QueryCx", ir.name);
let cx = query_cx_construction(ir, &query_cx_name);
let emitter_clones = ir.outputs.iter().map(|o| {
let name = snake_ident(&o.name);
quote! { let #name = #name.clone(); }
});
// The handler receives exactly the value to resolve — a `String` payload as
// `&str` (what the trait signature wants), any other scalar by value — and
// returns the query's type. This is the one wire whose call is an
// expression, not a statement, because a query answers.
let arg_ty = match query.payload {
TypeKind::String => quote! { &str },
other => scalar_type_tokens(other),
};
quote! {
{
let logic = ::std::rc::Rc::clone(&logic);
#(#emitter_clones)*
tree.widget_mut::<#ty>(#var)
.expect("the widget the query is written on exists")
.#setter(move |__src: #arg_ty| -> #ret {
logic.borrow_mut().#method(#cx, __src)
});
}
}
}
/// Bind `__value` to a declared event's payload at the width the manifest
/// declared, out of the `Option<&UserValue>` the event carries. A payload of
/// the wrong family is a widget that broke its own contract; the wire declines
/// to fire rather than guessing, which is the erased spelling of the type
/// mismatch that makes a mismatched `Commands::mutate` a no-op.
fn payload_extract_tokens(ty: TypeKind) -> TokenStream {
let carrier = match ty {
TypeKind::I32 | TypeKind::I64 => quote! { as_int },
TypeKind::F32 | TypeKind::F64 => quote! { as_float },
TypeKind::Bool => quote! { as_bool },
TypeKind::String => quote! { as_str },
};
let narrow = match ty {
TypeKind::I64 | TypeKind::Bool | TypeKind::String | TypeKind::F64 => TokenStream::new(),
_ => {
let rust_ty = scalar_type_tokens(ty);
quote! { let __value = __value as #rust_ty; }
}
};
quote! {
let ::core::option::Option::Some(__value) =
__payload_value.and_then(::guiduck::core::event::UserValue::#carrier)
else {
return;
};
#narrow
}
}
/// A manifest's `(type "…")`, verbatim, as tokens.
///
/// The compiler never resolves it — a proc-macro cannot see another crate's
/// items — so this only tokenizes; rustc reports a path that does not exist,
/// or is not `Default + Widget`, against the construction above.
fn type_path_tokens(path: &str) -> TokenStream {
path.parse().unwrap_or_else(|_| {
let message = format!("`(type \"{path}\")` is not a Rust path");
quote! { ::core::compile_error!(#message) }
})
}
/// A widget's children as **deferred content**: a `ContentBuilder` that
/// builds them into a parent chosen when it is called, instead of statements
/// that mount them now.
///
/// This emits the subtree through `node_tokens` — the same emitter every
/// other widget goes through — so codegen keeps one definition of "build this
/// node". (Not `dynamic_body`, which additionally `move_child`s its result
/// into a region's slot; menu content is appended in order and has no slot to
/// land in.) The captures are re-emitted inside the closure — signals are
/// `Copy`, logic is an `Rc` — which is what makes this an `Fn` a menu can
/// call on every open rather than a `FnOnce` spent at mount.
/// Whether the subtree rooted at `index` contains a `(slot)` marker.
///
/// Such content must mount inline rather than through a [`ContentBuilder`]: a
/// slot is filled by the transient `__slot` closure `mount_with_slot` holds,
/// which lives only while the mount runs, so it cannot be captured into a
/// builder a popup calls later — and a widget that projects one therefore never
/// defers its content. The walk follows the node tree; a widget that does defer
/// (a menu, a dialog) never contains a slot, so it is never forced inline by
/// this.
fn subtree_has_slot(ir: &Ir, index: usize) -> bool {
let node = &ir.nodes[index];
matches!(node.widget, IrWidget::Slot) || node.children.iter().any(|&c| subtree_has_slot(ir, c))
}
fn content_builder_tokens(unit: &Unit<'_>, node: &IrNode, cx: &TokenStream) -> TokenStream {
let ir = &unit.compiled.root;
let captures = dynamic_captures(ir);
let parent = quote! { __content_parent };
let bodies = node
.children
.iter()
.map(|child| node_tokens(unit, *child, &parent, cx));
quote! {
::guiduck::core::content::ContentBuilder::new({
#captures
move |tree, __parent| {
let #parent = ::core::option::Option::Some(__parent);
#(#bodies)*
}
})
}
}
/// Accelerator registrations for every item in a menu's deferred content.
///
/// These are emitted at *mount*, beside the menu, not inside its
/// `ContentBuilder` — an accelerator's whole point is firing while the menu
/// is closed, and a closed menu's rows do not exist. So an accelerator and
/// its row's `:on-select` share the *handler call*, both built from
/// `invocation_call_tokens`, rather than the accelerator dispatching at a
/// widget that may not be there.
///
/// Validation guarantees no `:accel` sits inside `if` or `for`, so every one
/// found here is unconditional and its arguments cannot read a loop variable.
fn accel_tokens(
unit: &Unit<'_>,
node: &IrNode,
cx: &TokenStream,
var: &proc_macro2::Ident,
) -> TokenStream {
let ir = &unit.compiled.root;
let mut out = TokenStream::new();
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 };
let text = accel.display();
let Some(wire) = child
.events
.iter()
.find(|event| event.event == EventProp::Select)
else {
// An accelerator with nothing wired to it would be a key that
// silently does nothing; say so where the file can be seen.
continue;
};
let call = invocation_call_tokens(ir, &wire.handler, &wire.args, cx);
let captures = dynamic_captures(ir);
out.extend(quote! {
{
#captures
tree.on_menu_accel(#var, #text, move || {
#captures
// An accelerator firing is not a dispatch to stop.
let __consume = ::std::rc::Rc::new(::std::cell::Cell::new(false));
#call
});
}
});
}
out
}
/// 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 is already gone — the compiler resolved it — so this is a flat
/// walk that accumulates byte offsets.
fn rich_text_tokens(literal: &Literal) -> TokenStream {
let runs: &[RichRun] = match literal {
Literal::Rich(runs) => runs,
// Plain text is rich text with no runs.
Literal::Str(text) => {
return quote! { ::guiduck::core::RichText::new(#text) };
}
_ => unreachable!("validated text literal"),
};
let full: String = runs.iter().map(|run| run.text.as_str()).collect();
let mut spans = TokenStream::new();
let mut at = 0usize;
for run in runs {
let start = at;
at += run.text.len();
let mut style = quote! { ::guiduck::core::TextSpan::new() };
if run.bold {
style = quote! { #style.bold() };
}
if run.italic {
style = quote! { #style.italic() };
}
if run.underline {
style = quote! { #style.underline() };
}
if run.strikethrough {
style = quote! { #style.strikethrough() };
}
if let Some((r, g, b, a)) = run.color {
style = quote! {
#style.color(::guiduck::paint::Color::from_rgba8(#r, #g, #b, #a))
};
}
if let Some(size) = run.size {
let size = size as f32;
style = quote! { #style.font_size(#size) };
}
if let Some(target) = &run.link {
style = quote! { #style.link(#target) };
}
// A run with nothing set is the paragraph's own style: no span.
if run.bold
|| run.italic
|| run.underline
|| run.strikethrough
|| run.color.is_some()
|| run.size.is_some()
|| run.link.is_some()
{
let end = at;
spans.extend(quote! { .span(#start..#end, #style) });
}
}
quote! { ::guiduck::core::RichText::new(#full) #spans }
}
/// One `(path …)` drawing command, as the runtime enum.
fn path_cmd_tokens(cmd: &PathCmd) -> TokenStream {
let variant = quote! { ::guiduck::core::graphic::PathCmd };
match *cmd {
PathCmd::Move(x, y) => quote! { #variant::Move(#x, #y) },
PathCmd::Line(x, y) => quote! { #variant::Line(#x, #y) },
PathCmd::Quad(cx, cy, x, y) => quote! { #variant::Quad(#cx, #cy, #x, #y) },
PathCmd::Cubic(a, b, c, d, x, y) => quote! { #variant::Cubic(#a, #b, #c, #d, #x, #y) },
PathCmd::Close => quote! { #variant::Close },
}
}
/// `:enabled` — a literal sets the flag at mount; anything else drives it
/// through an effect (a tree-level mutation, so it goes through the command
/// queue rather than `bind`'s widget access).
fn enabled_tokens(ir: &Ir, node: &IrNode, var: &proc_macro2::Ident) -> TokenStream {
bool_prop_tokens(ir, node.enabled.as_ref(), var, quote! { set_enabled })
}
/// `:focused` — the same shape as `:enabled`, and the same reason: focus lives
/// on the tree, not on the widget, so it is a command rather than a `bind`.
fn focused_tokens(ir: &Ir, node: &IrNode, var: &proc_macro2::Ident) -> TokenStream {
bool_prop_tokens(ir, node.focused.as_ref(), var, quote! { set_focused })
}
/// A universal boolean tree property: a literal applies at mount, anything
/// else drives `tree.<method>(id, value)` from an effect. The `bool`
/// annotation makes rustc the type checker for the expression, as with every
/// binding.
fn bool_prop_tokens(
ir: &Ir,
expr: Option<&Expr>,
var: &proc_macro2::Ident,
method: TokenStream,
) -> TokenStream {
match expr {
None => TokenStream::new(),
Some(Expr::Bool(value, _)) => quote! {
tree.#method(#var, #value);
},
Some(expr) => {
let value = expr_tokens(ir, expr, false);
quote! {
{
let __commands = tree.commands();
::guiduck::core::signals::Effect::new(move || {
let __value: bool = #value;
let __commands = __commands.clone();
__commands.push(move |tree| tree.#method(#var, __value));
});
}
}
}
}
}
/// Lower an expression to Rust tokens. State reads become `signal.get()`,
/// prop reads become `props.field` — inside binding closures and Cx
/// construction both are in scope under those names.
fn expr_tokens(ir: &Ir, expr: &Expr, brush: bool) -> TokenStream {
match expr {
// Numeric literals stay unsuffixed so inference gives them the type
// of whatever they combine with (a `.gdc` `1` is width-polymorphic,
// like a Rust integer literal).
Expr::Int(v, _) => {
let v = proc_macro2::Literal::i64_unsuffixed(*v);
quote! { #v }
}
Expr::Float(v, _) => {
let v = proc_macro2::Literal::f64_unsuffixed(*v);
quote! { #v }
}
Expr::Bool(v, _) => quote! { #v },
Expr::Str(template, _) => {
if brush {
// Validation guaranteed this is a color literal.
let text: String = template
.segments
.iter()
.filter_map(|s| match s {
Segment::Literal(l) => Some(l.as_str()),
Segment::Ref(..) => None,
})
.collect();
let Some(Literal::Color(r, g, b, a)) = parse_color(&text) else {
unreachable!("validated color literal");
};
return quote! { ::guiduck::paint::Color::from_rgba8(#r, #g, #b, #a) };
}
if template.refs().next().is_none() {
let text: String = template
.segments
.iter()
.filter_map(|s| match s {
Segment::Literal(l) => Some(l.as_str()),
Segment::Ref(..) => None,
})
.collect();
quote! { ::std::string::String::from(#text) }
} else {
// Interpolation → format!.
let mut fmt = String::new();
let mut args = Vec::new();
for segment in &template.segments {
match segment {
Segment::Literal(l) => {
fmt.push_str(&l.replace('{', "{{").replace('}', "}}"));
}
Segment::Ref(name, _) => {
fmt.push_str("{}");
args.push(name_tokens(ir, name));
}
}
}
quote! { ::std::format!(#fmt #(, #args)*) }
}
}
// `event.x` and friends read the event the wire is firing for, out of
// the `_ev` every wire closure already has. The fallback arms are
// unreachable in practice — validation binds `event` only on wires
// whose data is the matching variant — but a wire is an ordinary
// closure, so there is a value to produce rather than a panic to
// justify.
Expr::Path(segments, _) if segments[0] == "event" => {
let field = segments.get(1).map(String::as_str).unwrap_or_default();
match field {
"key" => quote! {
::std::string::String::from(_ev.key().unwrap_or_default())
},
"offset-x" | "offset-y" => {
let axis = if field == "offset-x" {
quote! { __o.0 }
} else {
quote! { __o.1 }
};
quote! {
match _ev.scrolled() {
::core::option::Option::Some(__o) => #axis,
::core::option::Option::None => 0.0f64,
}
}
}
_ => {
let (read, fallback) = match field {
"x" => (quote! { __p.local.x }, quote! { 0.0f64 }),
"y" => (quote! { __p.local.y }, quote! { 0.0f64 }),
"click-count" => (quote! { (__p.click_count as i32) }, quote! { 0i32 }),
_ => unreachable!("validation admits only `event`'s own fields"),
};
quote! {
match _ev.pointer() {
::core::option::Option::Some(__p) => #read,
::core::option::Option::None => #fallback,
}
}
}
}
}
Expr::Path(segments, _) => name_tokens(ir, &segments.join(".")),
Expr::List(items, _) => {
let items = items.iter().map(|item| expr_tokens(ir, item, false));
quote! { ::std::vec![#(#items),*] }
}
// Validation confines calls to event wires, which never reach the
// expression emitter.
Expr::Call(..) => unreachable!("handler invocation in expression position"),
Expr::RecordLit(name, fields, _) => {
let name = format_ident!("{name}");
let fields = fields.iter().map(|(field, value)| {
let field = snake_ident(field);
let value = expr_tokens(ir, value, false);
quote! { #field: #value, }
});
quote! { #name { #(#fields)* } }
}
// Validation rejects a graphic anywhere an expression is computed;
// the property that takes one reads it out of `static_props`.
Expr::Form(..) => unreachable!("a graphic is not a computed expression"),
Expr::Unary(op, inner, _) => {
let inner = expr_tokens(ir, inner, brush);
match op {
UnOp::Not => quote! { (!#inner) },
UnOp::Neg => quote! { (-#inner) },
}
}
Expr::Binary(op, lhs, rhs, _) => {
let lhs = expr_tokens(ir, lhs, false);
let rhs = expr_tokens(ir, rhs, false);
let op = match op {
BinOp::Add => quote! { + },
BinOp::Sub => quote! { - },
BinOp::Mul => quote! { * },
BinOp::Div => quote! { / },
BinOp::Rem => quote! { % },
BinOp::Lt => quote! { < },
BinOp::Le => quote! { <= },
BinOp::Gt => quote! { > },
BinOp::Ge => quote! { >= },
BinOp::Eq => quote! { == },
BinOp::Ne => quote! { != },
BinOp::And => quote! { && },
BinOp::Or => quote! { || },
};
quote! { (#lhs #op #rhs) }
}
Expr::If(cond, then, otherwise, _) => {
let cond = expr_tokens(ir, cond, false);
let then = expr_tokens(ir, then, brush);
let otherwise = expr_tokens(ir, otherwise, brush);
quote! { (if #cond { #then } else { #otherwise }) }
}
}
}
/// A prop or state read by name — both are signals in scope by their snake
/// names, so a read is a tracked `.get()` either way.
fn name_tokens(ir: &Ir, name: &str) -> TokenStream {
let _ = ir;
// Inside invocation-wire arguments, `payload` is the delivered value,
// bound as a plain local (validation confines it to those positions).
if name == "payload" {
return quote! { __payload.clone() };
}
// Props, states, and `for` loop variables are all signal-backed locals
// under their snake names wherever expressions are emitted; validation
// resolved every name against the file's scope. A dotted name reads a
// field of a record-typed loop variable (the `.get()` clones the
// row-sized record; fine until a profile says otherwise).
let mut segments = name.split('.');
let head = snake_ident(segments.next().expect("non-empty name"));
match segments.next() {
Some(field) => {
let field = snake_ident(field);
quote! { #head.get().#field }
}
None => quote! { #head.get() },
}
}
fn event_kind_tokens(event: EventProp) -> TokenStream {
match event {
EventProp::Click => quote! { ::guiduck::core::EventKind::Click },
EventProp::CountedClick => quote! { ::guiduck::core::EventKind::CountedClick },
EventProp::PointerEnter => quote! { ::guiduck::core::EventKind::PointerEnter },
EventProp::PointerLeave => quote! { ::guiduck::core::EventKind::PointerLeave },
EventProp::PointerDown => quote! { ::guiduck::core::EventKind::PointerDown },
EventProp::PointerUp => quote! { ::guiduck::core::EventKind::PointerUp },
EventProp::Changed => quote! { ::guiduck::core::EventKind::Changed },
EventProp::Toggled => quote! { ::guiduck::core::EventKind::Toggled },
EventProp::Select => quote! { ::guiduck::core::EventKind::Select },
EventProp::Close => quote! { ::guiduck::core::EventKind::Close },
EventProp::Link => quote! { ::guiduck::core::EventKind::Link },
EventProp::FileDrop => quote! { ::guiduck::core::EventKind::FileDrop },
EventProp::FocusChange => quote! { ::guiduck::core::EventKind::FocusChange },
EventProp::Key => quote! { ::guiduck::core::EventKind::Key },
EventProp::ValueChanged => quote! { ::guiduck::core::EventKind::ValueChanged },
EventProp::Scrolled => quote! { ::guiduck::core::EventKind::Scrolled },
}
}
fn style_tokens(style: &StyleIr) -> TokenStream {
let mut fields = TokenStream::new();
if style.width.is_some() || style.height.is_some() {
let width = dim_tokens(style.width);
let height = dim_tokens(style.height);
fields.extend(quote! {
size: ::guiduck::core::taffy::Size { width: #width, height: #height },
});
}
if let Some(padding) = style.padding {
fields.extend(quote! {
padding: ::guiduck::core::taffy::Rect::length(#padding),
});
}
if let Some(gap) = style.gap {
fields.extend(quote! {
gap: ::guiduck::core::taffy::Size {
width: ::guiduck::core::taffy::prelude::length(#gap),
height: ::guiduck::core::taffy::prelude::length(#gap),
},
});
}
if let Some(direction) = style.direction {
let dir = match direction {
DirectionIr::Row => quote! { Row },
DirectionIr::Column => quote! { Column },
};
fields.extend(quote! {
flex_direction: ::guiduck::core::taffy::FlexDirection::#dir,
});
}
if let Some(align) = style.align_items {
let value = align_tokens(align);
fields.extend(quote! {
align_items: ::core::option::Option::Some(#value),
});
}
if let Some(justify) = style.justify_content {
let value = justify_tokens(justify);
fields.extend(quote! {
justify_content: ::core::option::Option::Some(#value),
});
}
if let Some(align) = style.align_self {
let value = align_tokens(align);
fields.extend(quote! {
align_self: ::core::option::Option::Some(#value),
});
}
if let Some(grow) = style.grow {
fields.extend(quote! { flex_grow: #grow, });
}
if let Some(shrink) = style.shrink {
fields.extend(quote! { flex_shrink: #shrink, });
}
if let Some(basis) = style.basis {
let value = dim_tokens(Some(basis));
fields.extend(quote! { flex_basis: #value, });
}
quote! {
::guiduck::core::taffy::Style {
#fields
..::core::default::Default::default()
}
}
}
/// Field assignments applying an IR style onto an existing `__style` —
/// the codegen twin of the interpreter's `apply_style_ir`, emitting only
/// the fields the file gave.
fn style_patch_tokens(style: &StyleIr) -> TokenStream {
let mut out = TokenStream::new();
if style.width.is_some() {
let width = dim_tokens(style.width);
out.extend(quote! { __style.size.width = #width; });
}
if style.height.is_some() {
let height = dim_tokens(style.height);
out.extend(quote! { __style.size.height = #height; });
}
if let Some(padding) = style.padding {
out.extend(quote! {
__style.padding = ::guiduck::core::taffy::Rect::length(#padding);
});
}
if let Some(gap) = style.gap {
out.extend(quote! {
__style.gap = ::guiduck::core::taffy::Size {
width: ::guiduck::core::taffy::prelude::length(#gap),
height: ::guiduck::core::taffy::prelude::length(#gap),
};
});
}
if let Some(direction) = style.direction {
let dir = match direction {
DirectionIr::Row => quote! { Row },
DirectionIr::Column => quote! { Column },
};
out.extend(quote! {
__style.flex_direction = ::guiduck::core::taffy::FlexDirection::#dir;
});
}
if let Some(align) = style.align_items {
let value = align_tokens(align);
out.extend(quote! {
__style.align_items = ::core::option::Option::Some(#value);
});
}
if let Some(justify) = style.justify_content {
let value = justify_tokens(justify);
out.extend(quote! {
__style.justify_content = ::core::option::Option::Some(#value);
});
}
if let Some(align) = style.align_self {
let value = align_tokens(align);
out.extend(quote! {
__style.align_self = ::core::option::Option::Some(#value);
});
}
if let Some(grow) = style.grow {
out.extend(quote! { __style.flex_grow = #grow; });
}
if let Some(shrink) = style.shrink {
out.extend(quote! { __style.flex_shrink = #shrink; });
}
if let Some(basis) = style.basis {
let value = dim_tokens(Some(basis));
out.extend(quote! { __style.flex_basis = #value; });
}
out
}
fn dim_tokens(dim: Option<DimIr>) -> TokenStream {
match dim {
Some(DimIr::Px(v)) => quote! { ::guiduck::core::taffy::prelude::length(#v) },
Some(DimIr::Percent(v)) => quote! { ::guiduck::core::taffy::prelude::percent(#v) },
Some(DimIr::Auto) | None => quote! { ::guiduck::core::taffy::prelude::auto() },
}
}
fn align_tokens(align: AlignIr) -> TokenStream {
match align {
AlignIr::Start => quote! { ::guiduck::core::taffy::AlignItems::FLEX_START },
AlignIr::End => quote! { ::guiduck::core::taffy::AlignItems::FLEX_END },
AlignIr::Center => quote! { ::guiduck::core::taffy::AlignItems::CENTER },
AlignIr::Stretch => quote! { ::guiduck::core::taffy::AlignItems::STRETCH },
}
}
fn justify_tokens(align: AlignIr) -> TokenStream {
match align {
AlignIr::Start => quote! { ::guiduck::core::taffy::JustifyContent::FLEX_START },
AlignIr::End => quote! { ::guiduck::core::taffy::JustifyContent::FLEX_END },
AlignIr::Center => quote! { ::guiduck::core::taffy::JustifyContent::CENTER },
AlignIr::Stretch => quote! { ::guiduck::core::taffy::JustifyContent::STRETCH },
}
}
fn type_tokens(records: &[IrRecord], ty: ValueTy) -> TokenStream {
let element = elem_type_tokens(records, ty.kind);
if ty.list {
quote! { ::std::vec::Vec<#element> }
} else {
element
}
}
fn elem_type_tokens(records: &[IrRecord], kind: ElemKind) -> TokenStream {
match kind {
ElemKind::Scalar(kind) => scalar_type_tokens(kind),
ElemKind::Record(index) => {
let name = format_ident!("{}", records[index as usize].name);
quote! { #name }
}
}
}
fn scalar_type_tokens(kind: TypeKind) -> TokenStream {
match kind {
TypeKind::I32 => quote! { i32 },
TypeKind::I64 => quote! { i64 },
TypeKind::F32 => quote! { f32 },
TypeKind::F64 => quote! { f64 },
TypeKind::Bool => quote! { bool },
TypeKind::String => quote! { ::std::string::String },
}
}
/// A literal, optionally coerced to a declared type.
fn literal_tokens(literal: &Literal, ty: Option<ValueTy>) -> TokenStream {
if let Literal::List(items) = literal {
let element = ty.map(|ty| ValueTy {
kind: ty.kind,
list: false,
});
let items = items.iter().map(|item| literal_tokens(item, element));
return quote! { ::std::vec![#(#items),*] };
}
if let Literal::Record { name, fields } = literal {
let name = format_ident!("{name}");
let fields = fields.iter().map(|(field, value)| {
let field = snake_ident(field);
// Field value coercion rides the generated struct's field
// types; emit uncoerced and let inference (and From) settle it.
let value = literal_tokens(value, None);
quote! { #field: #value, }
});
return quote! { #name { #(#fields)* } };
}
let ty = ty.and_then(|ty| match ty.kind {
ElemKind::Scalar(kind) => Some(kind),
ElemKind::Record(_) => None,
});
match (literal, ty) {
(Literal::Int(v), Some(TypeKind::F32)) => {
let v = *v as f32;
quote! { #v }
}
(Literal::Int(v), Some(TypeKind::F64)) => {
let v = *v as f64;
quote! { #v }
}
(Literal::Int(v), Some(TypeKind::I32)) => {
let v = *v as i32;
quote! { #v }
}
(Literal::Int(v), _) => quote! { #v },
(Literal::Float(v), Some(TypeKind::F32)) => {
let v = *v as f32;
quote! { #v }
}
(Literal::Float(v), _) => quote! { #v },
(Literal::Bool(v), _) => quote! { #v },
(Literal::Str(v), _) => quote! { ::std::string::String::from(#v) },
(Literal::List(..) | Literal::Record { .. }, _) => unreachable!("handled above"),
(Literal::Path(_) | Literal::Asset(_) | Literal::Rich(_) | Literal::ScrollAxes(_), _) => {
// These reach codegen through `prop_literal_tokens`, which
// dispatches on the property's own type — never through the
// generic scalar path.
unreachable!("not a scalar literal")
}
(Literal::Color(r, g, b, a), _) => {
quote! { ::guiduck::paint::Color::from_rgba8(#r, #g, #b, #a) }
}
}
}
/// kebab-case → snake_case identifier.
fn snake_ident(name: &str) -> proc_macro2::Ident {
format_ident!("{}", name.replace('-', "_"))
}