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 backend/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 examples/counter/counter.ae
./build/counterThe floor is GTK 4.6 / GLib 2.72 (Ubuntu 22.04). On GTK < 4.8 two things
degrade: image_fill can only keep or ignore aspect ratio (original and
cover apply, and report, as contain), and a chrome-drawn toggle keeps its
native face because a 4.6 check button has no child slot.
Same 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 examples/counter/counter.ae
./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 examples/counter/counter.ae
./build/counterBuild from an MSYS2 MinGW64 shell (no extra dev libraries — USER32, GDI+ and Common Controls ship with Windows itself):
./build.sh examples/counter/counter.ae
./build/counter.exeThe C-level suites build through the same script (a .c source links against
the platform backend directly), and run headless:
# widget + driver smoke suite for the current backend
./build.sh tests/test_widgets.c test_widgets && AETHER_UI_HEADLESS=1 ./build/test_widgets
# microbenchmarks, CSV to stdout
./build.sh benchmarks/bench_widgets.c bench_widgets && AETHER_UI_HEADLESS=1 ./build/bench_widgetsThe whole pipeline, the way CI runs it:
./ci.sh # build everything, smoke-launch, run every driver spec
./tests/spec_matrix.sh # just the AetherUIDriver specsci.sh also cross-compiles AND LINKS the Win32 backend when a mingw-w64
compiler is present (brew install mingw-w64, or apt install gcc-mingw-w64-x86-64), and says SKIP when there is none. The link half is not
redundant: a Windows API declared in a header whose import library is missing
compiles perfectly and fails at link, so dropping -lole32 leaves the syntax
check at zero errors while the link reports __imp_CoCreateInstance.
That is not a substitute for running on Windows, which nothing here does; it is what stops the one backend nobody can execute from being edited blind.
See docs/design/win32-gdiplus-renderer.md for the Win32 rendering model, and docs/README.md for the rest of the design notes.
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 = ui.ui_state(0)
ui.window("My App", 400, 200) {
root = ui.vstack(10) {
ui.text("Hello World")
ui.text_bound(counter, "Count: ", "")
ui.hstack(5) {
ui.btn("+1") callback {
ui.ui_set(counter, ui.ui_get(counter) + 1)
}
ui.btn("-1") callback {
ui.ui_set(counter, ui.ui_get(counter) - 1)
}
}
}
_ci = ui.content_inset(root) // the standard 12px window gutter
}
}
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.
content_inset(root) is the one line that keeps the content off the window's
frame; it is opt-in, because a container's geometry is a contract apps read
(on_layout, pointer mapping), so nothing insets a window behind your back.
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 | grid(cols, rspace, cspace) { ... } (children fill it row by row; equal_cells() for a keypad), or ui.root_grid(...) + grid_place(...) |
GtkGrid | NSGridView | AetherUIGrid (custom) |
| Menu bar | ui.menu_bar() + menu() + menu_item() |
GMenu / GActionMap | NSMenu | HMENU (CreateMenu/SetMenu) |
| GPU view | ui.gpuview_create(w, h) (#92) |
GtkGLArea | NSOpenGLView | not yet (reports 0) |
| Native view | ui.native_view(w, h) (#193) |
not yet (reports 0) | not yet (reports 0) | child HWND, handle handed to the app |
| Tabs | ui.tabs() { tab("title") { … } } |
GtkStackSwitcher + GtkStack | NSTabView | button strip over a page zstack |
| SplitView | ui.splitview("h") { pane1 pane2 } |
GtkPaned | NSSplitView | own divider band, mouse-capture drag |
| ListBox | ui.listbox(spacing) callback |item, i, row| |
composed from a stack of rows, identical on all backends | ||
| Table | ui.table(cols) callback |item, col| |
composed on a ListBox, identical on all backends | ||
| Tree | ui.tree(roots) |
composed on a ListBox, identical on all backends | ||
| VList | ui.vlist("v", rows, |item, i, parent| { }) |
GtkListView | NSTableView | composed window (no native list) |
gpuview hands back a GL context the toolkit owns and presents for you.
A native view hands back the window and gets out of the way, which is what
an engine holding its own device, queue and swapchain needs: the panel's
platform handle goes to the app, which makes a Vulkan surface
(VK_KHR_win32_surface) or a D3D swap chain from it and presents straight
into the panel, with the toolkit's own menus, inspectors and gizmo canvas
around and over it.
if ui.native_view_available() == 1 {
view = ui.native_view(880, 620)
ui.on_native_view_realize(view) callback |w: int, h: int| {
engine_attach(ui.native_view_handle(view), w, h) // make the swapchain
}
ui.on_native_view_resize(view) callback |w: int, h: int| {
engine_resize(w, h) // remake it
}
}
Ask native_view_available() first, as with gpuview: win32 answers 1
today and the others answer 0, and an app that checks keeps its software path
instead of showing a rectangle nobody presents into. native_view_kind() says
what the handle is (1 = Win32 HWND), because an engine has to know what to
pass to its platform surface call.
Take the handle in the realize hook, not after native_view(). There is
no window to present into until the layout has placed one; realize fires on
the UI thread the moment there is, with the size in PIXELS.
The panel is the app's ground until you take the handle, and yours after. Nothing of the toolkit's touches those pixels once an engine owns them — a frame of ours between the engine's would be a flash — so a view whose owner never presents stays as it was. ae3d's editor on Windows was rendering to a framebuffer of its own, reading it back and blitting it into a canvas: 41 fps for a scene its own window runs at 140 (#193, ae3d#374).
gpuview is a widget that owns a real GL context, so a hardware-rendered
viewport can sit inside ordinary native chrome instead of living in its own
GLFW window with no panels, menus or dialogs around it.
if ui.gpuview_available() == 1 {
gpu = ui.gpuview_create(640, 480)
ui.gpuview_on_resize(gpu) callback |w: int, h: int| {
glViewport(0, 0, w, h) // PIXELS, not points
}
ui.gpuview_on_render(gpu) callback |dt: float| {
glClearColor(0.0, 1.0, 0.0, 1.0)
glClear(GL_COLOR_BUFFER_BIT) // draw only; the backend presents
}
}
Three things are worth knowing before you write the renderer.
Ask gpuview_available() first. It answers for this backend and this
display: AppKit and GTK4 host a context, win32 and UIKit do not yet, and a
headless machine with no GL device answers 0 even on a backend that can. An app
that checks falls back to its software path instead of showing a rectangle that
never draws.
GL takes 32-bit floats, and Aether's float is a C double. An extern
declared with float pushes the wrong width, and glClearColor then receives
values it reads as zero, which looks exactly like a renderer that never ran.
Declare them f32:
extern glClearColor(r: f32, g: f32, b: f32, a: f32)
Your renderer links GL, the toolkit does not link it for you. gpuview
hands an app a context; it does not put a GL dependency on every app that never
touches one. On macOS the framework is already there for the backend's own use;
elsewhere the app asks for it in its .build.ae:
if string.equals(platform(), "darwin") == 0 {
link_flag("-lGL")
}
Resize gives you PIXELS, not points. Passing points to glViewport renders
a HiDPI viewport into the bottom-left quarter of itself.
gpuview_read_pixel(gpu, x, y) returns one rendered pixel as 0xRRGGBBAA,
read back off the GPU, and the driver exposes the same thing at
GET /gpuview/{id}/pixel?x=&y=. That is how spec_gpuview_demo asserts the
GPU drew what was asked rather than only that the widget exists, with no window
on screen.
Setters and handlers that control how existing widgets behave. Each names the platform mechanism, and where a platform cannot do something it is said here rather than left to be discovered: the driver reports the mode that was actually applied, never the one that was requested.
ui.text_truncate(label, "middle") // none | head | middle | tail
ui.image_fill(pic, "cover") // original | contain | cover | stretch
ui.image_tint(icon, 0.2, 0.5, 1.0) // recolour a template/symbolic image
icon = ui.file_icon("/some/path") // the OS icon for that KIND of file
ui.set_file_icon(icon, "other.md") // rebind a live icon widget
dir = ui.pick_folder("New file in", "") // native folder chooser
| Verb | GTK4 | AppKit | Win32 |
|---|---|---|---|
text_truncate(h, mode) |
PangoEllipsizeMode |
NSLineBreakByTruncating* |
SS_ENDELLIPSIS / SS_PATHELLIPSIS; no head ellipsis, so head applies as tail |
image_fill(h, mode) |
GtkPicture content-fit |
NSImageScaling, cover drawn directly |
one style bit only; contain and cover apply as original, never stretch |
image_tint(h, r, g, b) / image_untint(h) |
CSS colour of a symbolic GIcon |
template image + contentTintColor |
bitmap recoloured once, original kept for untint |
file_icon(path) / set_file_icon(h, path) |
GIcon from the content type |
NSWorkspace iconForFile |
SHGetFileInfoW |
pick_folder(title, start_dir) |
SELECT_FOLDER chooser |
NSOpenPanel (directories) |
SHBrowseForFolderW |
window_on_key(cb) / on_key(widget, cb) |
BUBBLE-phase key controller | NSEvent local monitor |
WM_KEYDOWN translated ahead of IsDialogMessageW |
on_file_drop(cb) |
GtkDropTarget over GDK_TYPE_FILE_LIST |
NSPasteboardTypeFileURL |
DragAcceptFiles + WM_DROPFILES |
draggable(h, path) |
GtkDragSource over a GFile |
NSDraggingSession over an NSURL |
OLE DoDragDrop offering CF_HDROP |
open_file(title, start_dir), save_file(title, name) and pick_folder are
native modals, so under AETHER_UI_HEADLESS none of them opens: each takes
the next answer a spec queued for its kind with the driver's
POST /prompts/answer?kind=open|save|folder&value=<path> (FIFO per kind),
and returns "" (cancel) when none is queued. alert does not block either.
GET /prompts lists every dialog the app asked for, in order, with its
title, start folder or default name (an alert's message) and the answer it
got. open_url(url) under
AETHER_UI_HEADLESS does not hand the URL to the OS at all, so a spec that
clicks a link never launches a browser; it is recorded instead, and the
driver's GET /opened_urls lists every URL asked for, in order.
shortcut("Ctrl+R") and friends answer "was THIS combo pressed".
window_on_key answers "what was pressed", which is what type-ahead needs
and what no number of registered shortcuts can express. The any-key handler
fires only when no shortcut consumed the key, so accelerators keep priority,
and it never swallows the key, so whatever has focus still receives it.
A combo is modifiers and a key joined by +: Ctrl, Shift, Alt
(Option), Cmd (Super, Meta), and Primary, the platform's own
accelerator key in the sense of GTK's <Primary>: Command on macOS and iOS,
Control on GTK4 and Win32. Ctrl always means the Control key itself. Write
Primary+S for the keys a user expects under their platform's accelerator, and
Ctrl only where Control is meant; enable_undo_shortcuts() registers
Primary+Z and Primary+Shift+Z, so undo is ⌘Z on a Mac. Menus display
Primary as the key it resolves to.
widget_shortcut(w, combo, cb) and on_key(w, cb) are scoped to focus being
on w or anywhere inside it, so a container can own a key for all of its
children.
ui.shortcut("Ctrl+R") callback { reload() } // a bound combo
ui.shortcut("Primary+S") callback { save() } // ⌘S on a Mac, Ctrl+S elsewhere
ui.window_on_key(|k: string, m: int| { // anything at all
if k == "BackSpace" { go_up() }
})
ui.on_file_drop(|paths: ptr, n: int| { // files from another app
first = string.string_array_get(paths, 0)
})
ui.draggable(row, "/home/me/notes.md") // drag a file OUT
image(path) hands the file to the platform's image decoder, and none of the
three decodes SVG dependably. aether-ui already ships a complete SVG stack in
vg (the one apps/svg_render_png matches rsvg-convert with), so ui.svg
routes an SVG through that instead: identical pixels on every backend, no
platform decoder, no conversion step.
import ui.svg (svg_image, svg_image_sized)
svg_image("assets/logo.svg", 64) // longer side 64, aspect kept
svg_image_sized("assets/logo.svg", 80, 24) // exact box, aspect ignored
Opt-in like ui.icons, and for the same reason: an app showing SVG assets
should not have to link the icon vocabulary, and an app that wants a close
glyph should not link the SVG parser. The result is a canvas (a drawing), not
a bitmap, so image_fill and image_tint have nothing to act on; size it at
the call. A file that cannot be read returns handle 0 rather than taking the
window down.
The other way round from ui.svg: a buffer of pixels a program computes,
drawn into an AeVG scene as an element. image(x, y, w, h) is an SVG
<image>-alike: its box is in viewBox units, and it is transformable,
hit-testable (on_click, tooltip), hideable (visible_when) and has
opacity like any shape. What it draws is set in its block, or later by
the node it returned, and the scene is presented again with
scene_present (or any flush: a click, a resize, the live loop).
buf = malloc(64 * 64 * 4) // RGBA8, straight alpha, row-major
img = image(10, 10, 80, 80) {
image_pixels(buf, 64, 64) // BORROWED: you own it, update it in place
image_fit("contain") // stretch (default) / contain / cover / original
image_rendering("pixelated") // auto (smoothed, default) / pixelated
on_click() callback |x, y| { ... }
}
image(0, 0, 100, 100) { image_bytes_str(png, png_len) } // decoded by the toolkit, OWNED
... write into buf, then scene_present(node_scene(img))
image_set_pixels(img, other_buf, 128, 128) // or a new buffer, or new dims
image_pixels borrows: the buffer must outlive the element, and every flush
re-reads it; the canvas copies it into the frame, so it may be freed or
resized the moment a flush returns. image_bytes / image_bytes_str
(PNG, JPEG, GIF, BMP: GdkTexture, ImageIO, GDI+, UIImage, BitmapFactory,
through aether_ui_image_decode_rgba_impl) decode into pixels the element
owns. The blit is each backend's scaled image draw: GTK4, AppKit, UIKit and
Android smooth a small buffer scaled up (bilinear), Win32 enlarges it
unsmoothed (nearest neighbour, on both its renderers: GDI+'s bilinear
blends a cell's centre at large scales, so the honest choice there is the
exact colour). image_rendering("pixelated") (CSS's image-rendering)
asks every backend for nearest-neighbour sampling, each source pixel a
crisp square, for that image; it is canvas_image_smoothing(cid, 0), the
web canvas's imageSmoothingEnabled, around the one blit
(examples/pixelated_demo). A transform
maps the box's corners and draws into their axis-aligned bounds: translate
and scale are exact, a rotation draws the unrotated image into the rotated
box's bounds. examples/vg_image_demo and tests/vg_image_demo are the
example and the pixel-reading spec; sae's vg.raster / vg.image sit on it.
counter = ui.ui_state(0) // create state cell
ui.text_bound(counter, "Val: ", "") // auto-updating text
ui.ui_set(counter, 42) // triggers re-render
val = ui.ui_get(counter) // read current value
Observers of state (computed_s, each_bind) run on every set. To change
several cells as one, set them in a batch: observers then run once each, after
all of them, so a computed cell over two inputs never sees one new and one old.
ui.computed_s(|| {
_r = ui.ui_set_s(full, string.concat(ui.ui_get_s(first), ui.ui_get_s(last)))
}, first, last)
ui.ui_batch() callback {
_a = ui.ui_set_s(first, "Grace")
_b = ui.ui_set_s(last, "Hopper") // `full` recomputes once, here
}
An each_bind stops when its each group's container is destroyed.
_ok = ui.background(|| {
r = malloc(sizeof(Scan)) as *Scan // on a pool thread: block freely
scan_disk(r)
return r as ptr
}) callback |result: ptr| {
sc = result as *Scan // on the UI thread: touch widgets
ui.set_text(status, "${sc.files} files")
free(result)
}
work runs on one of Aether's std.worker pool threads; its ptr result is
handed to the completion on the UI thread, so the completion may set text,
rebuild lists and read state without a race. The backend installs the poster
(g_idle_add, a PostMessage to a message-only window, dispatch_async to the
main queue) that std.worker leaves to its host. ui.on_ui_thread() says
which thread the caller is on; ui.background_detached(work) drops the result.
Under AETHER_UI_HEADLESS there is no loop to post to on GTK4, Win32 and
AppKit, so completions wait on worker.drain().
background's completion runs whatever happened meanwhile, including after
the window that started the work has closed. When the result is for a widget,
own the work by it:
job = ui.background_for(panel, |job: int| {
r = new_scan()
while more_to_scan(r) {
if ui.background_cancelled(job) == 1 { return r as ptr } // stop early
scan_some(r)
}
return r as ptr
}, |result: ptr| {
show_scan(panel, result) // panel is still alive
free(result)
}, |result: ptr| {
free(result) // panel was destroyed, or the job cancelled
})
ui.background_cancel(job) // e.g. from a Stop button
When the work finishes, done runs only if owner (a widget handle; for a
window, its body) is still in the registry and the job was not cancelled;
otherwise dropped runs instead, so the result is still freed and nothing
touches a dead widget. Cancelling is cooperative: work polls
background_cancelled(job).
t = ui.timer(250) callback { refresh() } // repeats until timer_cancel(t)
o = ui.timer_once(1500) callback { hide_toast() } // runs once; timer_cancel(o) first stops it
f = ui.on_frame() callback |t_ms: float| { // once per display frame
angle = angle + spin * (t_ms - last_ms)
last_ms = t_ms
redraw()
}
ui.frame_cancel(f)
All three run on the UI thread. timer_once cancels its timer before it runs
the block, so a block slower than its own delay still runs once. (after is an
Aether keyword, hence the name.)
on_frame is the display-synced tick, requestAnimationFrame-shaped: GTK4's
frame clock (a tick callback on the main window), a CADisplayLink from the
screen on macOS 14+ (CVDisplayLink before) and on iOS, Android's
Choreographer, and DwmFlush on a helper thread on Windows, which has no vsync
callback. t_ms is milliseconds on a monotonic clock (the frame's vsync time
where the platform gives one), the same for every subscriber in one frame and
strictly increasing. The clock runs only while something is subscribed.
A display can stop delivering frames: a GTK window that is realized but never
mapped (AETHER_UI_HEADLESS), a display that sleeps, a backgrounded mobile
app, a Windows session with no compositor (SSH, services). After 100 ms without
a native frame a timer at the display's refresh rate stands in until native
frames resume, so on_frame throttles but never silently stops.
ui.frame_source() reports the effective clock of the last frame
(gtk-frame-clock, cadisplaylink, cvdisplaylink, choreographer,
dwm-flush, or timer), and ui.frame_count() the frames delivered so far.
examples/frametick_demo and its spec count frames
over half a second and check their timestamps against wall time.
ui.set_text(handle, "new text") // a label, a textfield or a textarea
text = ui.get_text(handle) // a textfield or a textarea
ui.set_toggle(handle, 1) // set toggle on/off
value = ui.get_toggle(handle)
ui.set_slider(handle, 75.0) // set slider position
value = ui.get_slider(handle)
ui.set_progress(handle, 0.5) // set progress bar
set_text writes a label, a textfield or a textarea, and get_text reads the
last two. A label reads back "": no platform exposes a getter for one, so
there is nothing honest to return.
A programmatic setter is not the user acting. None of these run the
widget's own on_change, so an app writing into its own fields does not fight
itself. tab_select is the deliberate exception and does notify, because a
tab change is a navigation the app usually wants to hear about.
ui.set_width(handle, 280) // exactly this wide
ui.set_min_width(handle, 280) // at least this wide
ui.set_height(handle, 120)
ui.set_min_height(handle, 120)
w = ui.get_width(handle) // what it ACTUALLY got, 0 before layout
m = ui.get_min_width(handle) // the floor as requested, 0 if none
set_width states a size and holds it. set_min_width states a size the
widget will not go below and stops there, so a parent that wants it bigger, or
a splitview divider dragged outward, still gets its way.
That difference is the whole point of having both. A side panel wants to open at an inspector's width and still be draggable, and a pin cannot do the second half: an exact size is not something a drag can move, so a panel was either the right size or resizable. A floor gives both, and it is also what stops a panel being dragged away to nothing.
The drag is real on every desktop backend, and the floor is honoured by each: GTK4's paned and AppKit's split view stop at it, and win32's own divider drag goes through the layout pass, whose clamp reads the floor.
get_width reads the allocation, so it answers 0 until the first layout pass,
and a widget tree with no window may never have one. get_min_width answers
what was requested, which is readable immediately. On GTK4 the two verbs share
one size request per axis, so a width given by set_width reads back through
get_min_width as well: what it reports is the size the widget will not go
below, which is true of either verb.
A listbox renders one row per item from a closure, and table and tree are
built on it, so all three share their selection behaviour.
lb = listbox(2) callback |item: ptr, i: int, row: int| {
_lbl = text(row, (item as *Row).name)
}
listbox_update(lb, items) // swap the model
on_select(lb) callback |i: int| { } // a row was picked
i = listbox_selected(lb) // which row, -1 for none
listbox_move(lb, from, to) // reorder, in place
listbox_multi toggles rows instead, with listbox_is_selected,
listbox_selected_count, listbox_set_selected and listbox_clear_selection.
Selection survives an update. It is carried by ITEM, not by row index, so refreshing, filtering, sorting or reordering keeps the row the user picked, and it is dropped only when that item is gone. A table refreshing on a timer can hold a selection.
cols = table_cols()
table_col(cols, "Name", 160)
t = table(cols) callback |item: ptr, c: int| {
return (item as *Row).name
}
table_update(t, items)
table_sorter(t, items) // header clicks sort
table_filter_text(t, "needle") // the search-box filter
item = table_item_at(t, table_selected(t))
Sorting orders the view and leaves the app's list alone, and a numeric column sorts numerically, so 95 comes before 100 rather than after.
root = tree_node("docs")
tree_add_child(root, tree_node("report"))
t = tree(roots) // roots is a std.list of nodes
tree_on_select(t) callback |node: ptr| { } // the NODE, not a row index
node = tree_selected(t)
tree_set_expanded(root, 1)
tree_refresh(t)
The selection is a node, so expanding an unrelated branch keeps it, and collapsing its parent then re-expanding brings it back rather than making the user find their place again.
vlist shows a window into a large model. Where the platform has a collection
view it uses it, otherwise it composes the window itself, and vlist_native(v)
says which. That matters to a test, because the two virtualize to different
numbers of realized rows.
A command is one action with many surfaces: a button, a menu item and an
accelerator that all route through the same enable/disable state.
save = command("Save", "Ctrl+S", || { })
command_set_enabled(save, 0) // greys every surface, key goes inert
command_attach(save, button)
Undo is an app-wide edit stack. undoable runs an action now and records how
to reverse it; undo_group collapses a gesture into one step, so a drag that
records an edit per pixel costs the user one undo press rather than thirty.
undoable("Add", || { attach_one() }, || { detach_one() })
undo_group("Move 3 frames") callback {
nudge(a)
nudge(b)
}
_u = undo()
_r = redo()
depth = undo_depth()
label = undo_label()
A keymap makes bindings data, which is what a "customise shortcuts" panel
needs. shortcut bakes its key into the registration and cannot be
enumerated or moved; a keymap separates the key from the command it names, so
either can change at runtime.
km = keymap(null) // or keymap(parent), chained
keymap_register(km, "file.save", save) // a NAME is a command
keymap_bind(km, "Ctrl+S", "file.save") // a KEY names a command
keymap_attach(km) // register the accelerators
_u = keymap_unbind(km, "Ctrl+S") // the old key goes inert
keymap_bind(km, "Ctrl+Shift+S", "file.save") // rebound, at runtime
Keymaps chain, so an app keymap can ship defaults a user keymap overrides, and
keymap_count / keymap_key_at / keymap_name_at enumerate the bindings for
a rebind UI.
Once a keymap is attached it owns the keys of the commands registered in it: a
key bound after keymap_attach becomes a live accelerator at once, the
command's own command(label, accel) key stops firing on its own, and a
menu_item_command item shows the key the keymap binds now, relabelled
when a bind, unbind or register changes it.
| Example | Widgets demonstrated |
|---|---|
examples/counter |
text, button, hstack, vstack, spacer, divider, reactive state |
examples/form |
textfield, securefield, toggle, slider, textarea, progressbar |
examples/picker |
picker (dropdown), picker_add |
examples/styled |
form, section, zstack, bg_color, bg_gradient, font_size, corner_radius |
examples/system |
alert, clipboard, dark mode detection, sheet |
examples/canvas |
canvas drawing, fill_rect, stroke, on_hover, on_double_click |
examples/testable |
AetherUIDriver test server, sealed widgets, remote control banner |
examples/rebuild_demo |
clear_children / remove_child on a grid and a stack |
examples/fileicon_demo |
file_icon, set_file_icon, the OS icon for a kind of file |
examples/imagefill_demo |
image_fill: original / contain / cover / stretch |
examples/keyhandler_demo |
window_on_key type-ahead, and accelerator priority |
examples/filedrop_demo |
on_file_drop, files dropped from another app |
examples/svgimage_demo |
ui.svg: an SVG file as a widget, drawn through vg |
examples/vg_image_demo |
vg.image: a program's RGBA buffer, a decoded PNG, fit modes, opacity and transform in a scene |
examples/scrollbg_demo |
small content inside a scroll area, and theming it |
examples/barfill_demo |
a pinned toolbar with a body that takes the slack |
examples/frametick_demo |
on_frame (the display-synced frame clock), frame_source, timer_once |
Aether UI ships with a built-in HTTP test server that lets any language with an HTTP client drive the app:
ui.enable_test_server(9222)
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),
/state/{id} + /state/{id}/set (reactive-state cells), and what a
headless app asked of the OS: /opened_urls, /prompts, and
/prompts/answer?kind=&value= to script a file dialog's answer. See the full
reference and end-to-end examples in
tests/test_driver.sh (curl against every route)
and the Aether specs under tests/ driven by
tests/lib/uidriver.ae. Set AETHER_UI_HEADLESS=1
to run any of them with no window on screen.
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
backend/aether_ui_test_server.c.
Mark widgets as non-automatable — the test server returns 403 for sealed widgets:
danger = ui.btn("Delete Everything") callback { ... }
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 | backend/aether_ui_gtk4.c |
Linux + FreeBSD: GTK4 C API calls, Cairo canvas, test server |
| macOS backend | backend/aether_ui_macos.m |
macOS: AppKit Objective-C |
| Win32 backend | backend/aether_ui_win32.c |
Windows: USER32 + GDI+ + Common Controls |
| UIKit backend | backend/aether_ui_uikit.m |
iOS / iPadOS: UIKit Objective-C, one UIWindowScene |
| C header | backend/aether_ui_backend.h |
Shared backend ABI — implemented by all four backends (five platforms; FreeBSD shares GTK4) |
| Build script | build.sh |
Auto-detects platform (Darwin/Linux/FreeBSD/MinGW) |
| Spec matrix | tests/spec_matrix.sh |
Runs every AetherUIDriver spec, one app at a time |
| 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 (backend/aether_ui_gtk4.c) |
Full — all widgets, canvas, events, styling, AetherUIDriver test server |
| macOS | AppKit (backend/aether_ui_macos.m) |
Full — all widgets, canvas, events, styling, AetherUIDriver test server |
| Windows | Native Win32 (backend/aether_ui_win32.c) |
Full — USER32 + GDI+ + Common Controls v6 (themed); the system UI font at the monitor's DPI (per-monitor DPI v2, re-fonted on a DPI change); follows the system's dark mode (title bar, ground, controls); AetherUIDriver via winsock2 |
| FreeBSD | GTK4 (backend/aether_ui_gtk4.c) |
Full — shares the Linux backend; clang build, private-Xvfb spec runs |
| iOS | UIKit (backend/aether_ui_uikit.m) |
Whole ABI implemented; AetherUIDriver served on the main thread. CI links listbox_demo against ae build --target=aarch64-ios-simulator --emit=staticlib, installs it in a booted iPhone simulator and runs its driver spec there (ci.sh Phase 1e2). No menu bar, tray, or window resize (those routes answer 404); file pickers are async on iOS and answer an empty selection. |
"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.
./build.sh tests/test_widgets.c test_widgets builds the cross-platform smoke
suite (47 assertions, headless) and ./build.sh benchmarks/bench_widgets.c bench_widgets builds the microbenchmarks, which print a CSV of per-operation
latencies. ./ci.sh runs everything.