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, &quote! { 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, &quote! { __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('-', "_"))
}