guiduck
Graphical User Interface toolkit for Rust
- Clone
git clone https://git.highenergymagic.org/guiduck.git- Files
- browse the default branch
- Default branch
- master
- Last commit
- 2026-08-17
README.md
guiduck
A data-driven GUI framework for Rust. You describe what your interface is in a small declarative file; you write what it does in plain, typed Rust. guiduck wires the two together with fine-grained reactivity, checks the whole thing with rustc, and repaints only what changed.
A complete app
components/Hello.gdc — the structure, layout, and wiring:
(component Hello
(state count i32 :init 0)
(handler bump)
(container :direction column :padding 16 :gap 12 :background "#ffffff"
(text :font-size 18 :text "Clicked {count} times")
(button :label "Click me" :width 120 :height 36 :on-click bump)))
main.rs — the logic:
use guiduck::core::WidgetTree;
guiduck::include_component!(Hello);
struct Logic;
impl HelloLogic for Logic {
fn bump(&mut self, cx: HelloCx) {
cx.count.set(cx.count.get_untracked() + 1);
}
}
fn main() {
let mut tree = WidgetTree::new();
Hello::mount(&mut tree, None, HelloProps::default(), Logic);
guiduck::run("hello", tree).unwrap();
}
That’s the whole program. {count} in the label is a live binding: when
the handler writes the signal, exactly that text repaints — no diffing,
no manual invalidation, and no frames at all while nothing changes.
include_component! compiles the file at build time into real Rust, so
rustc checks everything: HelloLogic is a generated trait with one
method per declared handler — misspell bump or forget it and your
program refuses to compile, with a helpful error message. Bindings
type-check too.
Easy to grow, hard to outgrow
Compose components like widgets. A capitalized name instantiates
another component; props flow down (reactively — a parent binding keeps
driving a child prop after mount), outputs wire back up to your
handlers, and (slot) lets a component accept content:
(Card :title "Totals: {total}"
(CounterButton :key "fives" :label "fives" :step 5 :on-stepped tally))
Theme without touching code. A widget’s appearance is data: rules in a theme file supply it, keyed on widget type, style class, and interaction state, and are swappable at runtime.
(rule (button .primary) :background accent :corner-radius 8)
(rule (button .primary :hover) :background accent-hover)
That includes shapes, not just colours — a checkbox’s mark is a (path …)
or an (asset …), interchangeably — so restyling a control is a rule
rather than a patch. A built-in default theme means controls look right
with no theme at all, and yours overrides only what it names.
Edit the UI while it runs. A development build takes its structure
from the component file itself and hot-reloads on save — state survives,
handlers stay compiled Rust — while anything you ship uses the compiled
path. There is no flag and no branch in main: mount chooses.
That is only safe because the two paths are held identical. A
differential suite replays real input through both and requires the same
scene after every step, and the oracle is public — guiduck::testing —
so your own components can be held to it too.
Two renderers, one scene. GPU (vello/wgpu) by default, a tiny-skia
software renderer with GUIDUCK_BACKEND=software — same display list,
continuously held to near-identical output, so headless tests and
GPU-less machines are first-class.
A real widget set, and no models. Buttons, checkboxes, radios, switches, text inputs (single- and multi-line), sliders, steppers, progress bars, images, scroll areas, tabs, lists, expanders, separators — and menus with submenus and accelerators, context menus, dropdowns, combo boxes, tooltips, and dialogs.
None of them holds your data. A menu’s rows, a list’s rows, and a tab
strip are all a for over your records, and the wire carries identity:
(menu :label "Recent"
(for doc recents :key doc.id
(menu-item :label doc.name :on-select (open-recent doc.id))))
Popups are built when they open and disposed when they close, all the way down — a closed menu’s rows do not exist, lay out nothing, and subscribe to nothing, so only the path you actually opened costs anything.
The same idea runs through the values. A checkbox, a slider, a dialog, and a tab are all controlled: your state is the truth, the widget reports what the user did, and your binding decides what happens. That is why there is no radio-group widget and no tab-group widget — “one of these” is a comparison in a binding, not a container holding a selection.
(for choice choices :key choice.id
(radio :label choice.name
:checked (= choice.id chosen)
:on-select (choose choice.id)))
Your own widgets are first-class. Implement Widget, describe its
interface in a .gdw manifest, and an application that depends on your
crate can name it in a .gdc with no configuration at all. The framework’s
own widgets go through exactly that path — they are declared in the same
grammar and registered the same way — so there is no privileged set to be
second-class against.
The platform essentials, built in. Flexbox layout (taffy), proper text shaping (parley) with IME support, rich text, clipboard (and primary selection on supported platforms), double/triple-click selection, Tab focus traversal, keyboard scrolling, the desktop’s own file chooser through xdg-desktop-portal, and accessibility designed in from the first line — not retrofitted. Every control announces what it is, which is why a radio is a widget rather than a rounded checkbox.
Documentation
- Getting started — from empty project to a running app.
- The component language — the
.gdcreference: declarations, expressions, bindings, events, composition, slots. - Widgets — the builtin widgets and their properties, events, and behavior.
- Writing a widget in Rust — implementing the
Widgettrait: layout, paint, input, timers, theming, accessibility. - Widget manifests — naming a Rust widget
from a
.gdcwith a.gdwfile. - Theming — the
.gdttheme file reference. - The Rust side — generated code, logic traits, signals, dev mode, testing, and building trees in plain Rust.
Repository layout
| Crate | What it is |
|---|---|
guiduck | The facade applications depend on; examples live here |
guiduck-scene | Display list, fragment store, render-backend trait |
guiduck-render-vello | GPU backend (vello on wgpu) |
guiduck-render-tinyskia | Software backend; golden-image harness |
guiduck-signals | Fine-grained signal runtime (signals, memos, effects, scopes) |
guiduck-core | Widget tree, layout, events, styles, focus, accessibility |
guiduck-component-core | The .gdc/.gdt compiler: reader → AST → validate → IR |
guiduck-component-macro | include_component! — IR to typed Rust |
guiduck-component-rt | The IR interpreter and hot reload (dev mode) |
guiduck-app | The winit shell: window, input, frame scheduling |
Development
cargo check --workspace # compile status
cargo test --workspace # unit, integration, golden, differential
./scripts/parity.sh # render every example on both backends, compare
./scripts/counter-interaction.sh # compositor-driven interaction tests
./scripts/m4-theme.sh # (each milestone has one; they drive a real
./scripts/m10-nested.sh # Wayland compositor via quibble)
./scripts/shutdown.sh # every example exits cleanly when its window closes
The suite is the gate, and the interactive half is not optional: goldens
and differentials pin what is drawn, and the scripts are what exercise
what is registered — a keystroke that never reached its handler renders
identically to one that did. shutdown.sh covers the piece neither can:
an application’s own exit path, which the other scripts skip by tearing the
compositor down underneath a still-running window.
Examples run with cargo run -p guiduck --example <name>; set
GUIDUCK_BACKEND=software to use the software renderer and
GUIDUCK_TRACE_FRAMES=1 to log each rendered frame