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 …):
| Item | What it is |
|---|---|
CounterProps | One plain field per prop; implements Default when every prop has a :default |
CounterCx | The handler capability surface (below) |
trait CounterLogic | One method per handler, plus one factory per instantiated component type |
struct Counter | mount(...), 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 rootWidgetId.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 (
:keyor 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.