rust-api.md raw

The Rust side

include_component!(Name) expands a component file into typed Rust. This page is what that code looks like from your side, plus dev mode and the escape hatch of building trees in plain Rust.

What gets generated

For (component Counter …):

ItemWhat it is
CounterPropsOne plain field per prop; implements Default when every prop has a :default
CounterCxThe handler capability surface (below)
trait CounterLogicOne method per handler, plus one factory per instantiated component type
struct Countermount(...), and one public field per prop, state, and output

Handlers and Cx

Each (handler name) becomes fn name(&mut self, cx: CounterCx); a payload declaration adds the value (value: &str for String, by value for the numeric and bool types). Cx carries, flat, one field per declaration — the same single namespace as the file:

  • props as ReadSignal<T> — read with .get(), not writable from inside the component;
  • states as Signal<T>.get() / .set(v) / .update(f);
  • outputs as Emitter<T>.emit(&value) delivers to every subscriber.

Reads inside handlers usually want .get_untracked() — handlers are not reactive contexts, so there is nothing to track:

fn increment(&mut self, cx: CounterCx) {
    let step = cx.step.get_untracked();          // a prop
    cx.count.set(cx.count.get_untracked() + step); // a state
    cx.stepped.emit(&step);                       // an output
}

Signal writes are equality-gated: setting the current value again is a no-op and wakes nothing.

Cx also carries cx.consume(): called from an event handler, it stops the event reaching handlers later in the dispatch (a bubbling ancestor, a deeper capture), so an inner :on-click can keep a click from also firing the container around it. It is opt-in — a handler that never calls it changes nothing — and a no-op off the event path (an instance output, a query).

Seeding state at mount

A :init is a literal, and a handler only runs in answer to an event — so a component whose data comes from outside the component language (a file, a database, a network fetch) has nothing to fill it with. The generated Logic trait carries one method the file does not declare:

fn mounted(&mut self, cx: WikiCx) {
    cx.page.set(self.store.current_page());
    cx.blocks.set(self.store.load().into_iter().map(…).collect());
}

It runs once, after the subtree is built and every binding and wire is attached — so a signal it sets is already being watched, and the component’s first frame shows the loaded data rather than the :init values. It is also where anything that should run while the component is on screen gets started.

The default does nothing, so a component that needs no seeding declares no hook. Nested instances are mounted during the parent’s walk, so a child’s hook runs before its parent’s.

mounted is the framework’s method name: a handler, or a child component whose factory method would take it, is a compile error naming the collision rather than a silently shadowed hook.

Mounting

let mut tree = WidgetTree::new();
let counter = Counter::mount(
    &mut tree,
    None,                       // parent widget, None = at the root
    CounterProps { label: "ones".into(), step: 1 },
    Logic,
);
guiduck::run("app", tree)?;

The returned struct is the embedding side’s handle:

  • counter.root — the subtree’s root WidgetId.
  • counter.step, counter.count — props and states as writable signals. Props are live: counter.step.set(5) after mount updates every binding that reads it. (Inside the component they are read-only; driving them is the owner’s privilege.)
  • counter.stepped — outputs, for subscribing:
counter.stepped.subscribe(|step| println!("stepped by {step}"));

A component whose file declares a (slot) also gets mount_with_slot(tree, parent, props, logic, slot), where slot is a closure FnOnce(&mut WidgetTree, Option<WidgetId>) invoked at the slot position to build the projected content; plain mount leaves the slot empty.

Logic factories

When a component instantiates others, its Logic trait asks you for each child’s logic — one factory method per child type, called once per instance:

impl CounterPanelLogic for PanelLogic {
    fn tally(&mut self, cx: CounterPanelCx, value: i32) { … }

    fn counter_button(&mut self) -> impl CounterButtonLogic {
        ButtonLogic
    }
}

A missing factory is a compile error, exactly like a missing handler. A component with no handlers and no factories has an empty Logic trait which () implements — pure-presentation components mount with Counter::mount(tree, parent, props, ())-style calls, no boilerplate type.

Signals outside components

guiduck::core::signals is a small fine-grained reactive runtime you can use directly: Signal<T> (a Copy handle), Memo<T>, Effect, and Scope for bulk disposal. Effects run once at creation and again after any of the signals they read change; the frame scheduler flushes them before styling and layout, and diamond dependencies run an effect at most once per flush.

Overlays

WidgetTree::open_overlay(style, options) opens a layer above the base tree and returns its root widget — mount anything under it, including a .gdc component. OverlayOptions::dialog() is modal (an input barrier that also traps Tab and focus, restored on close) and Escape-dismissed; OverlayOptions::popup(anchor, AnchorSide::Below) and popup_at(point) are light-dismiss (an outside press closes them and is consumed). Anchored placement flips to the opposite side at the viewport edge and clamps. close_overlay(root) tears the layer down and runs the options’ on_close. Event handlers open overlays through the command queue (ctx.push_command(move |tree| { … })), since they cannot borrow the tree directly.

Shortcuts

WidgetTree::on_shortcut(Keystroke::ctrl("s"), || { … }) registers an application accelerator, which lasts as long as the tree.

Routing order is fixed, and each step is there to stop the one below it from stealing something: the focused widget sees the key first (a text input’s Ctrl+C is the text input’s, and a bare-letter shortcut can never steal typing), then that widget’s :on-key wire and its ancestors’, then overlay dismissal (Escape), then shortcuts, then Tab traversal.

A menu item’s :accel is registered the same way but owned by the menu, so it goes when the menu does. That matters for hot reload: a registration outliving the subtree that made it would shadow the one that replaced it, and since the first match wins, the key you just deleted from the file would go on firing the handler the file no longer names.

Timers

Widgets request wakes instead of reading clocks: Widget::take_wake returns “call my on_timer in this long”, the shell turns it into a WaitUntil on the event loop, and delivers WidgetTree::tick(now) when it comes due. An idle app with no pending wakes still renders zero frames; a focused text input blinks its caret this way. Tests drive next_wake(now)/tick(now) with fabricated times, so timed behavior is fully deterministic headlessly.

Dev mode: hot reload

The interpreter builds the same tree from the same compiled IR that the macro’s generated code builds — a differential test suite holds the two paths to identical output, under input and not merely at mount. That guarantee is what lets the framework choose between them for you.

mount is the entry point an application’s main wants:

Counter::mount(&mut tree, None, CounterProps::default(), Logic);
guiduck::run("counter", tree)?;

It takes structure from the live .gdc while you are building the app, and from the compiled component in anything you ship — no branch in main, no path to supply, nothing to poll. The rule is: a development build (debug_assertions) whose source file is still where the build found it. Both halves matter — the profile is what distinguishes building the app from testing what you will ship, and the file check is what makes a debug binary harmless on someone else’s machine, where the path does not exist. GUIDUCK_LIVE=1 or =0 overrides either way, for iterating in a release profile or shipping a debug build deliberately.

It returns nothing: the live path has no typed handle to give, because its state lives in the interpreter and is rebuilt on every reload. When you need the component back — to drive a signal from Rust — call mount_compiled, which is also what a test should call: mount in a development build would quietly exercise the interpreted path rather than the one under test.

Leaving the interpreter out

The interpreter and its file watcher are behind the facade’s default-on hot-reload feature. Turning it off drops guiduck-component-rt and eight transitive crates (notify, mio, inotify, walkdir and friends), and mount always takes the compiled path:

guiduck = { version = "0.2", default-features = false }

A Cargo feature is not a build profile, and this is the one thing to be clear about: dependency resolution happens before a profile is chosen, so switching it off removes hot reload from every build of that graph, debug included. There is no way to have it in debug and not in release — what you get for free instead is that the watcher never runs in a release build, because mount gates on debug_assertions. Leave the feature on unless your dependency tree matters to you more than reloading does.

Both specific entry points remain, and are how a caller picks a path on purpose:

let counter = Counter::mount_compiled(&mut tree, None, props, Logic);
counter.count.set(7);
use guiduck::rt::DevMount;

let spec = Counter::dev_spec(CounterProps::default(), Logic);
let mut dev = DevMount::new(&mut tree, None, "components/Counter.gdc", spec)?;
  • Saving the file (or any component file it transitively instantiates) rebuilds the subtree. Structure, layout, bindings, and styles come from the live file; handlers are always your compiled Rust, dispatched through a generated registry.
  • State survives reloads: top-level by name and type, nested instances by identity (:key or occurrence — see the component language).
  • A save that doesn’t compile keeps the previous UI and prints the diagnostics; fixing the file heals it.
  • Component references resolve through the same search path the typed build used (baked into dev_spec), with the mounted file’s own directory as a fallback.

A DevMount handed to tree.set_live_reload(dev) is polled by the shell on every wake, and given a waker so a save applies as you make it — which is all mount does. Driving it yourself is still available: dev.poll(&mut tree) when the loop wakes, dev.set_waker(...) for the immediate wake, dev.reload_now(&mut tree) to force one. The m5_dev example is that longhand pattern.

Checking the two paths agree

The guarantee the switch rests on is checkable for your own components, with the oracle guiduck uses on its own:

use guiduck::testing::{assert_paths_agree, Step};

assert_paths_agree(&mut compiled, &mut interpreted, viewport, &[
    Step::ctrl(Key::Character("s".into())),
    Step::Click(point),
]);

It replays the script through both trees and compares after every step. Drive input rather than only mounting: two freshly-mounted scenes agree whenever the drawing matches, and are blind to a difference in what was registered — a menu item renders its accelerator identically whether or not the keystroke was ever wired up.

Comparing two trees

WidgetTree::structural_diff(&other) walks two trees in lockstep and describes the first place they part company — widget type, classes, tooltip, enabled, attached handler count, child count and layout rectangle at every position, plus the tree-level registrations (shortcuts, overlays, dialogs, open menus, pending wakes, accessibility owners) that belong to no node. It returns a path rather than a bool, because “these differ” is not something you can act on and root > child 2: handler count 3 vs 4 is.

It exists for the question a scene comparison cannot answer. Two trees that draw the same thing may still differ in what was registered — a menu item renders its accelerator identically whether or not the keystroke was ever wired up — and the invariant a hot reload has to meet is stated with it: a reloaded tree is indistinguishable from a freshly mounted one.

assert_eq!(fresh.structural_diff(&reloaded), None);

Plain Rust, no .gdc

Components are a layer over an ordinary retained widget API — you can use that API directly, and mix freely with mounted components:

use guiduck::core::{Container, Text, WidgetTree, taffy};

let mut tree = WidgetTree::new();
let root = tree.insert(
    Container::new().background(guiduck::paint::Color::WHITE),
    taffy::Style { padding: taffy::Rect::length(16.0_f32), ..Default::default() },
    None,
);
let label = tree.insert(Text::new("hi"), taffy::Style::default(), Some(root));

let count = guiduck::core::signals::Signal::new(0);
tree.bind::<Text, _>(label, move || format!("count: {}", count.get()),
                     |w, v| w.set_text(v));
tree.on_event(root, guiduck::core::EventKind::Click, move |_ctx, _ev| {
    count.set(count.get_untracked() + 1);
});

tree.bind is the same mechanism component bindings compile to: a reactive effect computing a value, applied through the widget’s setter, which classifies the damage (paint-only vs. layout) so the frame does the minimum. The examples m1_widgets, m2_counter, and m9_scroll are all built this way.

The shell

guiduck::run(title, source) opens a window and drives the event loop; any FrameSource works, and WidgetTree is one. The renderer is chosen by GUIDUCK_BACKEND (vello, the default, or software). Closing the window exits. GUIDUCK_TRACE_FRAMES=1 logs each rendered frame — an idle app logs nothing, and the test suite enforces that.