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 .gdc reference: declarations, expressions, bindings, events, composition, slots.
  • Widgets — the builtin widgets and their properties, events, and behavior.
  • Writing a widget in Rust — implementing the Widget trait: layout, paint, input, timers, theming, accessibility.
  • Widget manifests — naming a Rust widget from a .gdc with a .gdw file.
  • Theming — the .gdt theme file reference.
  • The Rust side — generated code, logic traits, signals, dev mode, testing, and building trees in plain Rust.

Repository layout

CrateWhat it is
guiduckThe facade applications depend on; examples live here
guiduck-sceneDisplay list, fragment store, render-backend trait
guiduck-render-velloGPU backend (vello on wgpu)
guiduck-render-tinyskiaSoftware backend; golden-image harness
guiduck-signalsFine-grained signal runtime (signals, memos, effects, scopes)
guiduck-coreWidget tree, layout, events, styles, focus, accessibility
guiduck-component-coreThe .gdc/.gdt compiler: reader → AST → validate → IR
guiduck-component-macroinclude_component! — IR to typed Rust
guiduck-component-rtThe IR interpreter and hot reload (dev mode)
guiduck-appThe 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