writing-widgets.md raw
Writing a widget in Rust
The builtin widgets and the framework’s own controls are Rust
types that implement one trait, guiduck_core::Widget. When your application
needs something the framework does not ship — a chart, a canvas, a document
view, a custom gauge — you write it the same way, in your own crate, and it is
a first-class widget: themed, laid out, hit-tested, and made accessible by the
same machinery as a button.
This is the guide to implementing a widget. To then name it from a .gdc,
you declare its interface in a .gdw manifest — see
Widget manifests. A widget you only ever build from Rust
(the WidgetTree API below) needs no manifest at all.
The model
A WidgetTree is a retained tree of widgets. Each widget:
- owns its content and visual state (a paragraph’s text, a checkbox’s checked flag, a scroll area’s offset);
- has a layout style that lives in taffy, not in
the widget — you pass a
taffy::Stylewhen you insert it, and taffy computes every widget’s rectangle; - paints itself into a display-list fragment, which the tree composites with its children.
A widget does not own its children — the tree does. A widget that needs to
render several sub-parts (a document with many paragraphs) measures and paints
them itself; it cannot insert child widgets of its own. Everything else is a
Widget trait method, and every method has a default, so you implement only
what your widget actually does.
use guiduck_core::{Widget, taffy};
use guiduck_core::text::TextContext;
use guiduck_scene::Fragment;
use guiduck_scene::geom::Size;
use guiduck_scene::paint::{Brush, color::palette::css};
/// A solid rectangle of a fixed color — about the smallest useful widget.
pub struct Swatch {
color: Brush,
}
impl Widget for Swatch {
fn paint(&mut self, fragment: &mut Fragment, size: Size) {
let bounds = guiduck_scene::geom::Rect::from_origin_size((0.0, 0.0), size);
fragment.fill(bounds, self.color.clone());
}
fn type_name(&self) -> &'static str {
"swatch"
}
}
Add it to a tree:
let mut tree = WidgetTree::new();
let root = tree.insert(Swatch { color: css::REBECCA_PURPLE.into() }, style, None);
insert(widget, style, parent) returns a WidgetId; pass None as the parent
for the root, or an existing id to nest. The taffy::Style is the layout — flex
direction, size, padding, grow/shrink — exactly as a .gdc’s layout properties
compile to.
Coordinates in paint (and everywhere else a widget sees them) are local:
the widget’s own top-left is (0, 0), and size is the rectangle taffy gave
it. The tree applies the widget’s absolute position when it composites.
Layout: measure and finalize_layout
A leaf widget with intrinsic content — text, an image — reports its natural size so taffy can lay it out:
fn measure(
&mut self,
text: &mut TextContext,
known: taffy::Size<Option<f32>>,
available: taffy::Size<taffy::AvailableSpace>,
) -> taffy::Size<f32> { … }
taffy calls measure only for widgets with no taffy children, and may call it
several times per layout (probing min-content and max-content). known carries
any axis already fixed by the style; available is what taffy offers on each
axis. Return the size your content wants. A widget whose size comes entirely
from its style (a container, the Swatch above) does not implement measure.
finalize_layout runs once per layout, after taffy has assigned the final
rectangle, before paint:
fn finalize_layout(&mut self, text: &mut TextContext, size: Size, content_size: Size) { … }
This is where size-dependent content settles — a paragraph re-wraps to the
assigned width here, so the glyphs painted next are the right ones. content_size
is the extent of the (possibly overflowing) content, which a scroll container
uses to learn its scrollable range.
Text shaping needs the TextContext both methods receive; it holds the font
collection and parley’s caches. Use it to build a
Paragraph — the reusable
“styled, wrapping, hit-testable text” primitive that Text and the markdown
widget are both built from — rather than driving parley yourself.
Painting
paint emits display items into a Fragment:
fn paint(&mut self, fragment: &mut Fragment, size: Size) {
fragment.fill(shape, brush); // filled path/rect
fragment.stroke(shape, brush, Stroke::new(w)); // outlined path
fragment.image(image_brush, dest_rect); // raster
fragment.push_clip(shape); /* … */ fragment.pop_clip(); // clip a region
}
Shapes are kurbo types (re-exported under
guiduck_scene::geom): Rect, RoundedRect, BezPath, Circle. Brushes are
peniko Brushes — a solid Color, a gradient — under
guiduck_scene::paint. To draw text, build a Paragraph and call its paint,
or use guiduck_core::text::append_layout for a parley Layout you hold.
paint_overlay draws over the children (a scroll area paints its thumbs here);
the tree calls it after compositing the children on top of paint’s output. Most
widgets do not implement it.
Do not decide where your widget is on screen or whether it is hovered/focused
by painting differently based on remembered state you could get wrong — paint is
a pure function of your content and size. Interaction state that changes
appearance comes through the theme (see Theming below).
Change tracking: Dirt
The framework repaints and re-lays-out only what changed. A widget with
mutable state keeps a Dirt field and marks it in its setters:
use guiduck_core::Dirt;
pub struct Gauge {
value: f32,
dirt: Dirt,
}
impl Gauge {
pub fn set_value(&mut self, value: f32) {
// Compare-and-set: re-applying the same value marks nothing, which is
// what makes a binding that re-sets the current value free.
if self.value != value {
self.value = value;
self.dirt.mark_paint(); // value changes the drawing, not the size
}
}
}
impl Widget for Gauge {
fn take_dirt(&mut self) -> Dirt {
std::mem::take(&mut self.dirt)
}
// … measure / paint …
}
The rule for which to mark: mark_layout() if the change can alter the
widget’s size or its content’s geometry (new text, a new font size, a different
number of segments); mark_paint() if only the pixels change at the same
geometry (a color, a gauge needle angle). A layout change implies a repaint, so
never mark both. If you are unsure, mark_layout() is the safe (if slightly
pessimistic) choice.
Setters compare-and-set and mark dirt; that is the entire contract. It is
what lets the same setter serve a one-time build value and a reactive binding
without either looping, and it is why a registered widget (below) needs nothing
but Default + Widget plus one setter per property.
Input
Pointer, keyboard, and IME events arrive through hooks, all with local coordinates.
Pointer
fn on_pointer(
&mut self,
kind: EventKind, // PointerDown | PointerMove | PointerUp | Click | PointerEnter | PointerLeave
event: &PointerEvent, // event.local, event.button, event.click_count
text: &mut TextContext,
clipboard: &mut dyn Clipboard,
) -> bool { … }
The gesture model, which the selectable Text and the text input both use:
- A press (
PointerDown) that returnstruecaptures the pointer: the widget then receives everyPointerMoveuntil the release, even outside its bounds — this is how a drag (selection, a slider, a scrollbar thumb) works. A press that returnsfalsedoes not capture. Clickis synthesized by the tree when a press and release land on the same widget. Its return value is ignored by the tree (only a press captures), so use it to act, not to gate.event.click_countis 1/2/3 for single/double/triple, so a widget can select a word on double-click without tracking timing itself.
A widget’s own on_pointer runs before any app handler wired to it, so a
checkbox toggles on its own click and then the app’s :on-toggle sees it.
cursor(local) returns the pointer shape for a given position — a pure function
of where the pointer is, so a paragraph can show the hand only over its links
without tracking hover separately:
fn cursor(&self, local: Point) -> CursorShape {
if self.hit_something(local) { CursorShape::Pointer } else { CursorShape::Default }
}
Keyboard, IME, and focus
on_key and on_ime are delivered only to the focused widget. on_key
returns true to consume a key; an unconsumed key falls through to overlay
dismissal, app shortcuts, and finally Tab traversal — so a text input’s Ctrl+C
wins over an app accelerator, and Tab still works when a widget declines it.
To participate in focus at all, override focusable:
fn focusable(&self) -> bool { true }
tab_stop defaults to focusable, and the two come apart only for a widget you
focus in order to operate rather than to edit — a scroll area is
click-focusable (so arrows scroll it) but not a Tab stop (it should not
interrupt a Tab walk between the controls inside it). A selectable label is the
same: focusable, not a Tab stop. Override tab_stop to false for those.
on_focus_changed(focused) fires when focus arrives or leaves — a text input
starts and stops its caret blink here; a label drops its selection on blur.
The framework maintains a focus invariant: whenever any Tab-stop widget
exists, one has focus. Your widget does not enforce it; making focusable/
tab_stop honest is all it owes.
Reporting to the application
A widget never calls an application handler directly. It emits a semantic event, and the tree collects it on the same borrow it collects dirt and dispatches it:
fn take_emitted(&mut self) -> Vec<EventData> {
std::mem::take(&mut self.emitted)
}
Push an EventData from an input hook when user action changed
something — Changed(String), Toggled(bool), ValueChanged(f64),
Selected, Link(String), or User { name, payload } for an event your
.gdw declared. Pick by what the payload is: the compiler checks the
wire against it, so reporting a number as a Changed string would make a
handler’s declared type a lie. Programmatic setters must not
emit — only user input does — or a :value-style binding would loop: the
handler writes state, the binding re-sets the widget, the widget re-emits,
forever. Compare against the last emitted value if you have to (the text input
keeps a last_emitted string for exactly this).
Timers
A widget that animates or blinks asks for a wake instead of reading a clock:
fn take_wake(&mut self) -> Option<std::time::Duration> { self.wake.take() }
fn on_timer(&mut self) { /* advance, mark paint, re-request a wake to keep going */ }
take_wake is one-shot and collected with dirt; a steady beat re-requests from
on_timer. The widget never sees the current time — the tree anchors the
duration when the platform supplies “now” — which is what makes timed behavior
deterministic in tests (they feed a fabricated clock). A widget that stops
requesting wakes lets the app go idle, preserving the zero-idle-frames
guarantee.
Scrolling
Two hooks make a widget a scroll container, and the tree applies them to painting, hit testing, and absolute-position math from one place, so pixels and pointer targets can never disagree:
content_offset() -> Vec2— the translation applied to children beyond their layout positions (the scroll offset, negated).clips_content() -> bool— whether children are clipped to the widget’s box.on_scroll(delta) -> bool— handle a wheel/touchpad scroll; returntrueif it moved,falseto let an ancestor scrollable take over at the end of range.adjust_style(&mut taffy::Style)— stamp layout requirements the widget owns (a scroll container setsoverflow: scroll) at insert time, so callers cannot forget them.
To make a focused widget’s important sub-rectangle (a caret) stay visible when
it sits inside a scroll area, return it from reveal_rect; scroll containers
implement scroll_reveal to bring a target into view. Most widgets need
neither.
Accessibility
Every widget has a role; add labels, values, and states in accessibility:
fn role(&self) -> accesskit::Role { accesskit::Role::Slider }
fn accessibility(&self, node: &mut accesskit::Node) {
node.set_value(self.value.to_string());
}
A widget that renders sub-parts an assistive technology should reach on their
own — the links inside a paragraph — contributes child nodes in
accessibility_extended (using next_id to allocate ids from the reserved
range and pushing into update), and handles a screen reader acting on one of
them in accessibility_action. A generated node has no widget id, so the tree
routes the action back to the widget that made it; report the result through
take_emitted, like any other input. If a screen reader can see and announce a
control but cannot activate it, it is not accessible — this hook is what closes
that gap.
Accessibility is not optional polish here; it is designed in from the widget’s first version, exactly as the builtins do it.
Theming: apply_style
Do not hard-code colors, fonts, or metrics. Declare them as tokens and read them from the widget’s computed style, so a theme can move them:
fn type_name(&self) -> &'static str { "gauge" }
fn apply_style(&mut self, style: &ComputedStyle) {
// The style engine matches theme rules on `type_name`. Read each token and
// mark dirt only if it changed — the setters' compare-and-set does that.
if let Some(track) = style.brush("track-color") {
self.set_track(track.clone());
}
if let Some(size) = style.number("needle-width") {
self.set_needle_width(size);
}
}
The style pass calls apply_style before your widget’s first paint and again
whenever the widget’s interaction state changes (hover, active, focus, disabled)
or a theme is swapped — so interaction-state appearance is theme-driven: you
do not check “am I hovered” in paint; the theme’s :hover rule supplies the
hovered tokens and apply_style delivers them. Widget-internal state that is
not an interaction state (a checkbox’s checked) the widget reads from its own
field and selects among token variants (box-fill vs box-fill-checked).
For your tokens to resolve, the widget’s type must be known to the theme
registry — which, for an application widget, is what the .gdw manifest’s
(token …) declarations do. A theme then writes (rule (gauge .big) :track-color "#333") with no code involved. See
Theming and Widget manifests.
Deferred content (popups)
Most widgets that have children mount them immediately. A popup — a menu, a dropdown, a dialog — is different: its content should not exist until it opens, and should be built into an overlay rather than under the widget. Your widget can do the same, through two trait methods:
fn defers_content(&self) -> bool { true }
fn set_content(&mut self, content: ContentBuilder) {
self.content = content; // store it; run it when the popup opens
}
When a .gdc gives your widget children, the tree consults defers_content at
mount: false (the default) builds them inline, true hands them to
set_content as a ContentBuilder — a closure you keep and call (as many times
as you reopen) with the overlay you want them under. This is the exact seam the
builtin menus use, and it needs no manifest syntax: whether a widget defers
is its own runtime answer, invisible to the .gdc that writes it. A closed popup
costs nothing — its content lays out nothing and subscribes to nothing until it
opens.
Hosting a popup
A widget that opens a panel — a menu, a dropdown, a combo box — says so
by answering popup():
fn popup(&self) -> Option<Popup> {
Some(Popup {
content: self.content.clone(),
beside: false, // below the opener, not out to the side
match_width: true, // as wide as the opener
})
}
fn defers_content(&self) -> bool { true }
fn set_content(&mut self, content: ContentBuilder) { self.content = content; }
Then report EventData::MenuOpen when the user asks for it, and the tree
does the rest: it opens an overlay, builds the content into it, tracks
the stack, runs the roving highlight and the dismissal walk, and tells
you when the panel closes.
Nothing here is builtin-only. The tree asks the widget rather than checking what kind it is, so your own widget can host a popup on exactly the terms a menu does.
The registered-widget contract
Implementing Widget gives you a widget you can build from Rust and add to a
tree with insert. To also name it from a .gdc, you write a
.gdw manifest describing its interface and connect the
two with one macro call in your crate:
guiduck_core::register_widget!(Gauge, "widgets/gauge.gdw");
register_widget! reads the manifest at build time, generates the factory that
constructs your widget and drives its setters, and — the point — type-checks
that factory against your real methods, so a manifest that promises a setter
you do not have is a compile error at that line.
For this to work, a registered widget must be Default + Widget plus one
setter per declared property, and take no constructor arguments — a
property is a setter call. That is what lets one code path build every widget
(the interpreter constructs a widget it has never heard of and cannot pass
arguments to) and what makes a property settable at build and re-settable from
a binding through the same method. Give the widget sensible defaults via
Default, and let each set_* both initialize and update (compare-and-set, mark
dirt — the same contract as above). The manifest maps
:property to set_property by convention, with a :setter override where your
method’s own name is the right one.
A widget you only build from Rust owes none of this — implement whatever
constructor and setters suit it. See Widget manifests for
the manifest grammar, how discovery finds your .gdw, and the worked example.
Where the builtins live
The framework’s own widgets are the worked examples, in
crates/guiduck-core/src/widget/:
container.rs— the structural minimum (a background and children).text.rs+text/— a selectable, linkable paragraph; the clearest example ofmeasure/finalize_layout, pointer selection, and paragraph a11y.text_input.rs— the full editable control: keyboard, IME, clipboard, the caret blink (timers), focus.scroll_area.rs— the scroll hooks and keyboard operation.checkbox.rs/button.rs— controlled state, activation, theme-driven five-state appearance.
crates/guiduck-markdown/ is a widget in a separate crate, depending only
on guiduck-core — the proof that everything above is reachable from outside
the framework, and the model to copy for your own.