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::Style when 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 returns true captures the pointer: the widget then receives every PointerMove until the release, even outside its bounds — this is how a drag (selection, a slider, a scrollbar thumb) works. A press that returns false does not capture.
  • Click is 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_count is 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; return true if it moved, false to 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 sets overflow: 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 of measure/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.