Port of the Perry UI framework to Aether.
Declarative widget DSL backed by GTK4 (Linux and FreeBSD), AppKit (macOS),
and a native Win32 backend (Windows) — the three backend implementations
share the same ABI declared in aether_ui_backend.h. Uses Aether's
trailing-block builder pattern.
This module is a from-scratch Aether + C rewrite of the aether-ui Rust crates
from the Perry project by the Perry
contributors. The Rust implementations (aether-ui-gtk4, aether-ui-macos, and
the core aether-ui crate) were used as reference for architecture, widget
API design, reactive state bindings, and platform-specific GTK4/AppKit
patterns. Based on commit
7f1e3f9
of the main branch.
Portions Copyright (c) 2026 Perry Contributors collectively, and portions Copyright (c) 2026 Aether Contributors collectively. MIT License.
Some apps under apps/ port or borrow from existing projects. Each
carries its own NOTICE (and the upstream licence verbatim) beside its
source; the summary:
- Maerkdown (
apps/maerkdown) — the word-as-widget markdown editor. Its extended inline syntax (++insertion++,||spoiler||,==highlight==,^superscript^,~subscript~) is taken from the Extended Markdown Syntax plugin for Obsidian by Kotaindah55 (Sheva Ihza), MIT. The delimiters and their meanings come from that project's documented rules; no code was copied, and the parser is an independent implementation over this editor's own document model. - Font Picker (
apps/font_picker) — a rule-for-rule port of Javascript Font Picker, MIT, portions Copyright (c) 2024-2025 Zygomatic. - Falling Blocks (
apps/falling_blocks) — derived from fallingblocks and therefore GPL-3.0, unlike the rest of this repository. See the header in that app's source before distributing binaries built from it.
sudo apt install libgtk-4-dev # Debian/Ubuntu
./build.sh example_counter.ae build/counter
./build/counterSame GTK4 backend as Linux; build.sh detects FreeBSD and uses clang.
sudo pkg install gtk4 pkgconf # ensure a zlib.pc exists for freetype2 -> zlib
./build.sh example_counter.ae build/counter
./build/countertests/spec_matrix.sh needs no display of its own — on FreeBSD it starts a
private Xvfb (pkg install xorg-vfbserver), unprivileged, and any pre-set
$DISPLAY is respected instead.
./build.sh example_counter.ae build/counter
./build/counterBuild from an MSYS2 MinGW64 shell (no extra dev libraries — USER32, GDI+ and Common Controls ship with Windows itself):
./build.sh example_counter.ae build/counter
./build/counter.exeBackend-level smoke tests (headless, no display needed) for any platform:
# widget + driver tests for current backend
gcc tests/test_widgets.c aether_ui_gtk4.c -o build/test_widgets $(pkg-config --cflags --libs gtk4) -I. -Itests -lpthread -lm && ./build/test_widgets # widget + driver tests for current backend
# microbenchmarks, CSV to stdout # microbenchmarks, CSV to stdoutSee docs/aether-ui-windows.md for Windows-specific details (DPI model, dark mode, widget mappings, known limitations).
Aether UI is a "DSL with Scope" — Matz's own name (he coined it when
asked to name the pattern) for the builder-block style: nested blocks that
describe structure declaratively while keeping full imperative power, with an
implicit receiver so children wire to their parent without explicit
plumbing. It runs in the Smalltalk-blocks / Ruby-Shoes / Groovy-SwingBuilder /
Kotlin-Compose / SwiftUI lineage — and, unlike a markup format, the blocks are
executed code, not parsed into a DOM for some later actioning. See
Paul Hammant's "That Ruby and Groovy Language Feature"
for the full tour, and Aether's own
docs/closures-and-builder-dsl.md
for the mechanism (trailing blocks, the _ctx implicit-receiver convention,
and builder … with "configure then execute").
A UI is opened inside a surface scope. The surface's kind decides its lifecycle (see Surfaces below):
import ui
main() {
counter = aether_ui.ui_state(0)
aether_ui.window("My App", 400, 200) {
aether_ui.root_vstack(10) {
aether_ui.text("Hello World")
aether_ui.text_bound(counter, "Count: ", "")
aether_ui.hstack(5) {
aether_ui.button("+1") callback {
aether_ui.ui_set(counter, aether_ui.ui_get(counter) + 1)
}
aether_ui.button("-1") callback {
aether_ui.ui_set(counter, aether_ui.ui_get(counter) - 1)
}
}
}
}
}
The window(…) { … } block builds the tree, then — because it's a builder
function whose body runs after the block — opens the window and runs the
event loop. No trailing app_run(root): the surface is the entry point.
A surface is the ambient destination a widget/drawing block populates. The kind decides lifecycle:
| Surface | Lifecycle | What it is |
|---|---|---|
window(title, w, h) { … } |
lived — runs the event loop, ends on window close | An on-screen interactive window. Absorbs the old app_run. |
render_to(target, w, h) { … } |
bounded — one render pass, returns | Draw into a target: pixel buffer, PNG, PDF, paper. No event loop. |
record(w, h) { … } |
bounded — captures, returns | A test/recording surface — inspect what was built. No event loop. |
window_run(title, w, h, root) |
lived | Explicit-root variant of window for trees built imperatively (e.g. a root_grid whose cells are grid_place'd in). |
Interactive verbs (onclick, onhover) used inside a bounded surface are
diagnostic-inert: they render but the handler never fires (there's no event
loop to deliver to). The diagnostic is collected on the surface by default
(read it with surface_diagnostics(handle)); routing it to stderr or a hard
fail is an explicit opt-in, never the default — the framework never writes to
a stream you didn't ask it to.
Inside a surface block, use the context-attaching layout verbs (vstack,
hstack, zstack, …) — not the root_* variants (root_vstack,
root_hstack). The root_* verbs are detached: they take no builder context
and so don't attach to the enclosing surface, leaving you with a window that
maps but renders blank. The root_* forms exist only for the explicit-root
window_run(title, w, h, root) path, where you build the tree imperatively and
hand the root in. Inside window {…} / render_to {…} / record {…}, always
vstack (which the compiler auto-parents to the surface via the _ctx
convention).
Why three verbs instead of one app_run? Because app_run welded together
three jobs — create the window, mount the tree, run the loop — and forced that
lived shape onto every program. Most surfaces aren't lived: a render-to-PNG,
a print-to-paper, a headless test needs no loop and ends by reaching }. Only
a live window has "a life of its own" that ends on an external event, so only
window carries the loop.
| Widget | Aether function | GTK4 | AppKit | Win32 |
|---|---|---|---|---|
| Text | ui.text("label") |
GtkLabel | NSTextField (label) | STATIC |
| Button | ui.button("label") callback { } |
GtkButton | NSButton | BUTTON (BS_PUSHBUTTON) |
| VStack | ui.vstack(spacing) { children } |
GtkBox vertical | NSStackView vertical | AetherUIStack (custom) |
| HStack | ui.hstack(spacing) { children } |
GtkBox horizontal | NSStackView horizontal | AetherUIStack (custom) |
| Spacer | ui.spacer() |
Expanding GtkBox | NSView flex filler | flex placeholder |
| Divider | ui.divider() |
GtkSeparator | NSBox separator | GDI line (custom class) |
| TextField | ui.textfield("hint") callback |val| { } |
GtkEntry | NSTextField | EDIT |
| SecureField | ui.securefield("hint") callback |val| { } |
GtkPasswordEntry | NSSecureTextField | EDIT (ES_PASSWORD) |
| Toggle | ui.toggle("label") callback |active| { } |
GtkCheckButton | NSButton (switch) | BUTTON (BS_AUTOCHECKBOX) |
| Slider | ui.slider(min, max, init) callback |val| |
GtkScale | NSSlider | TRACKBAR (comctl32) |
| Picker | ui.picker() callback |idx| { } |
GtkDropDown | NSPopUpButton | COMBOBOX (CBS_DROPDOWNLIST) |
| TextArea | ui.textarea("hint") callback |val| { } |
GtkTextView | NSTextView | EDIT (ES_MULTILINE) |
| ProgressBar | ui.progressbar(0.75) |
GtkProgressBar | NSProgressIndicator | PROGRESS (comctl32) |
| ScrollView | ui.scrollview() { children } |
GtkScrolledWindow | NSScrollView | AetherUIStack + WS_VSCROLL |
| Grid | ui.root_grid(cols, rspace, cspace) + grid_place(...) |
GtkGrid | NSGridView | AetherUIGrid (custom) |
| Menu bar | ui.menu_bar() + menu() + menu_item() |
GMenu / GActionMap | NSMenu | HMENU (CreateMenu/SetMenu) |
counter = aether_ui.ui_state(0) // create state cell
aether_ui.text_bound(counter, "Val: ", "") // auto-updating text
aether_ui.ui_set(counter, 42) // triggers re-render
val = aether_ui.ui_get(counter) // read current value
aether_ui.set_text(handle, "new text") // set textfield value
text = aether_ui.get_text(handle) // get textfield value
aether_ui.set_toggle(handle, 1) // set toggle on/off
aether_ui.set_slider(handle, 75.0) // set slider position
aether_ui.set_progress(handle, 0.5) // set progress bar
| Example | Widgets demonstrated |
|---|---|
example_counter.ae |
text, button, hstack, vstack, spacer, divider, reactive state |
example_form.ae |
textfield, securefield, toggle, slider, textarea, progressbar |
example_picker.ae |
picker (dropdown), picker_add |
example_styled.ae |
form, section, zstack, bg_color, bg_gradient, font_size, corner_radius |
example_system.ae |
alert, clipboard, dark mode detection, sheet |
example_canvas.ae |
canvas drawing, fill_rect, stroke, on_hover, on_double_click |
example_testable.ae |
AetherUIDriver test server, sealed widgets, remote control banner |
Aether UI ships with a built-in HTTP test server that lets any language with an HTTP client drive the app:
aether_ui.enable_test_server(9222, root)
Or set AETHER_UI_TEST_PORT=9222 in the environment before launching —
no code changes needed. A red "Under Remote Control" banner is injected
so a user can't mistake a test-driven session for a real one.
The HTTP API exposes /widgets (list + filter), /widget/{id} (state),
/widget/{id}/click | set_text | toggle | set_value (mutations), and
/state/{id} + /state/{id}/set (reactive-state cells). See the full
reference and end-to-end examples in
docs/aether-ui-testing.md —
including Bash, Python, and JavaScript test-suite skeletons plus the
AETHER_UI_HEADLESS=1 flag for unattended CI.
For most native UI frameworks you have to bolt on Selenium/Appium. With
aether_ui it's part of the framework and works identically on macOS,
Linux, FreeBSD, and Windows via the shared
aether_ui_test_server.c.
Mark widgets as non-automatable — the test server returns 403 for sealed widgets:
danger = aether_ui.button("Delete Everything") callback { ... }
aether_ui.seal_widget(danger)
This maps to Aether's hide/seal philosophy: the app author declares which
capabilities the test harness is denied, not the other way around.
| Layer | File | Role |
|---|---|---|
| Aether DSL | ui/module.ae |
Builder-pattern wrappers with _ctx auto-injection; surface verbs (window/render_to/record) |
| GTK4 backend | aether_ui_gtk4.c |
Linux + FreeBSD: GTK4 C API calls, Cairo canvas, test server |
| macOS backend | aether_ui_macos.m |
macOS: AppKit Objective-C |
| Win32 backend | aether_ui_win32.c |
Windows: USER32 + GDI+ + Common Controls |
| C header | aether_ui_backend.h |
Shared backend ABI — implemented by all three backends (four platforms; FreeBSD shares GTK4) |
| Build script | build.sh |
Auto-detects platform (Darwin/Linux/FreeBSD/MinGW) |
| Test script | test_automation.sh |
Example curl-based test suite (17 assertions) |
| Widget tests | tests/test_widgets.c |
Cross-platform C-level smoke suite (40 assertions) |
| Driver tests | tests/test_driver.sh |
HTTP integration against the embedded test server |
| Benchmarks | benchmarks/bench_widgets.c |
CSV microbenchmarks — widget create, layout, state, canvas |
| Platform | Backend | Status |
|---|---|---|
| Linux | GTK4 (aether_ui_gtk4.c) |
Full — all widgets, canvas, events, styling, AetherUIDriver test server |
| macOS | AppKit (aether_ui_macos.m) |
Full — all widgets, canvas, events, styling, AetherUIDriver test server |
| Windows | Native Win32 (aether_ui_win32.c) |
Full — USER32 + GDI+ + Common Controls; per-monitor DPI v2; immersive dark mode; AetherUIDriver via winsock2 |
| FreeBSD | GTK4 (aether_ui_gtk4.c) |
Full — shares the Linux backend; clang build, private-Xvfb spec runs |
"Full" above means the backend implements the whole widget/canvas/event/
styling surface plus AetherUIDriver — not that every suite is green on every
box. tests/spec_matrix.sh is the authority; run it on the platform you care
about. Most recent full runs: Linux 228/0, FreeBSD 223/0 (only lismusic,
which needs the sqlite contrib archive installed on that host).
All groups (1–7) plus AetherUIDriver are implemented on every backend.
# widget + driver tests for current backend gcc tests/test_widgets.c aether_ui_gtk4.c -o build/test_widgets $(pkg-config --cflags --libs gtk4) -I. -Itests -lpthread -lm && ./build/test_widgets runs the cross-platform smoke suite and, on
Windows, the HTTP driver integration. # microbenchmarks, CSV to stdout prints a
CSV of per-operation latencies.