radlang — Desktop GUI (gui)
The gui namespace builds native desktop applications whose UI is HTML/CSS/JS,
rendered by the operating system’s own webview — no bundled browser, no Node.
Your radlang code drives the window and talks to the page over a small two-way
bridge.
For language fundamentals see the Overview. For the internals and the roadmap to a native immediate-mode backend, see design/gui.md.
Platform support. macOS uses WKWebView and Linux uses WebKitGTK 4.1. Windows (WebView2) is still planned. Linux development builds require the
webkit2gtk-4.1andpkgconfpackages; distributed apps depend on WebKitGTK at runtime rather than bundling a browser.
Overview
gui is a compiler-intrinsic namespace like fs or http — no import, the
name is reserved everywhere. gui.window(...) returns a Window handle; you
call methods on that handle to load content and wire up the bridge, then gui.run(win) enters the native run loop:
fn main() {
Window win = gui.window("Hello", 480, 320)
win.load("<h1>Hello from radlang</h1>")
gui.run(win)
} gui.run(win) blocks until the window is closed, then main continues (and the
program exits). Everything runs on the process main thread — no threading concerns
in your code.
Because the webview is the engine the OS already keeps resident, a radlang GUI binary is tiny (the compiler output + your HTML) and idles far lighter than an Electron app — there’s no per-app Chromium or second JS runtime. See Why not Electron.
API
Two calls live on the gui namespace; everything else is a method on the Window handle it returns.
| Call | Description |
|---|---|
gui.window(title: string, width: int, height: int, config?) → Window | Create and show a window + webview, returning its handle. config is an optional options struct (see Window configuration). |
gui.run(win: Window) | Enter the native run loop; blocks until the window closes. |
Window method | Description |
|---|---|
win.load(html: string) | Load an HTML string as the page. |
win.loadFile(path: string) | Load an HTML file by path (relative to the process cwd). The page may reference sibling CSS/JS/image assets. Preferred for non-trivial UIs. |
win.loadUrl(url: string) | Navigate to a URL (https://…, file://…). |
win.eval(js: string) | Run JavaScript in the page (radlang → page). |
win.bind(name: string, handler: fn(string): string) | Expose a native function to the page as rad.<name> (page → radlang, request/response). |
win.dispatch(channel: string, value: string) | Push a JSON value to the page on channel; JS receives it via rad.on (radlang → page, fire-and-forget). |
win.title(title: string) | Change the window title. |
win.maximize() / win.minimize() | Zoom / miniaturize the window programmatically (callable any time, including from a bind handler). |
Typical order: gui.window → win.bind(s) → win.loadFile → gui.run(win).
Window configuration
gui.window’s optional 4th argument is an options struct. Every flag defaults to true (a standard resizable, minimizable, closable, titled window):
Window win = gui.window("My App", 900, 600, {
resizable: true, // drag-resize + the green maximize/zoom button
minimizable: true, // the yellow minimize button
closable: true, // the red close button
titled: true, // false → transparent, full-size-content title bar
}) titled: falsedoesn’t remove the window chrome — it makes the title bar transparent and lets the webview extend under it, with the traffic-light buttons floating on top (a “chromeless” look that stays draggable). Give your page some top padding so content isn’t hidden behind the buttons.- Omit any field to keep its default; omit the whole struct for an all-on window.
Loading UI from a file
radlang string literals interpolate on { } (see semantics), so
brace-heavy inline HTML/CSS/JS must escape every { as \{. For anything beyond
a snippet, keep the UI in its own .html file and use win.loadFile — no
escaping, and the page can pull in sibling assets:
project/
config.yaml
src/
main.rad
ui.html // <link>/<script> to sibling files work win.loadFile("src/ui.html") The bridge
Two directions, both string-based. Strings carry JSON by convention (the same
convention http and json already use across the runtime boundary), which
keeps the FFI to a single string -> string shape.
radlang → page: win.eval
win.eval("document.getElementById('status').textContent = 'ready'") page → radlang: win.bind
win.bind(name, handler) publishes handler to the page as a promise-returning
function rad.<name>(arg). When the page calls it:
argis JSON-stringified and delivered to your radlanghandleras astring.- The handler returns a
string— valid JSON — which becomes the resolved value of the promise (JSON.parsed on the JS side).
radlang:
win.bind("addUser", fn(string body): string {
var u = json.parse(body) // body is a JSON string from the page
// …persist u…
return "{\"ok\": true}" // returned as a JSON string
}) page:
rad.addUser({name: 'Tim'}).then(function(res) {
if (res.ok) console.log('saved');
}); Handlers run on the main thread when the page invokes them, so calling other radlang code (db, fs, http, …) from inside a handler is safe.
radlang → page push: win.dispatch
bind is request/response — the page asks, radlang answers. win.dispatch is the
other half: radlang pushes a value to the page unprompted, on a named channel.
The page subscribes with rad.on(channel, cb):
radlang:
win.dispatch("status", "\"saving…\"") // value is JSON
win.dispatch("notes", json.stringify(notes)) page:
rad.on('status', function(v){ statusEl.textContent = v; }); // v is the parsed value
rad.on('notes', function(list){ render(list); }); Notes:
- Multiple subscribers per channel are supported; each fires in registration
order. A DOM
CustomEventnamedrad:<channel>is also emitted, sowindow.addEventListener('rad:status', e => e.detail)works too. - JS receives the parsed value, not a JSON string — the opposite of a
bindhandler’s argument, which arrives as a JSON string youjson.parse. So push"\"hi\""from radlang and the subscriber gets the stringhi. - Timing: subscribe before the value is pushed. A common shape is: the page
calls a
bindedready()once loaded, and radlang starts dispatching from there. - Thread-safe:
win.dispatchmarshals onto the main thread internally, so it is safe to call from a background task (not just from inside abindhandler). Note, though, that abindhandler runs synchronously on the main thread — if it loops and dispatches without returning, the pushes won’t paint until it returns (see limits).
Encoding. The argument reaching your handler is
JSON.stringify(arg), so a JS string'Tim'arrives as the 5-character radlang string"Tim"(quotes included). Parse it withjson.parse(or handle the raw JSON) rather than using it as a bare value. Likewise your return value must be JSON — return"\"hi\"", not"hi".
Complete example — bind + dispatch together
Each greet call returns a reply (request/response over bind) and pushes a log line (dispatch → rad.on). A runnable copy lives in dev/gui/.
External assets work. A
loadFilepage is a real webview, so it can pull in CDN stylesheets/scripts overhttps://(e.g. Bootstrap). Only plaintexthttp://is blocked by App Transport Security. The snippet below is plain CSS to stay self-contained; thedev/guidemo uses Bootstrap from a CDN.
src/ui.html:
<!doctype html>
<html><body style="font-family:system-ui,sans-serif;padding:2rem;max-width:34rem;margin:auto">
<h1>radlang GUI demo</h1>
<input id="name" value="Tim">
<button onclick="greet()">Greet</button>
<p id="out" style="font-size:1.5rem"></p>
<h3>log <small>(pushed from radlang)</small></h3>
<ul id="log"></ul>
<script>
async function greet() { // page -> radlang -> page
var msg = await rad.greet(document.getElementById('name').value);
document.getElementById('out').textContent = msg;
}
rad.on('log', function(entry) { // radlang -> page (push)
var li = document.createElement('li');
li.textContent = entry;
document.getElementById('log').appendChild(li);
});
greet(); // fire one on load
</script>
</body></html> src/main.rad:
fn main() {
Window win = gui.window("radlang GUI demo", 520, 420)
win.bind("greet", fn(string body): string {
string name = json.parse<string>(body) // body is a JSON string
win.dispatch("log", json.from("greeted " + name))
return json.from("Hello, " + name + "!") // JSON reply resolves the Promise
})
win.loadFile("src/ui.html")
gui.run(win)
} The win handle is captured by the bind closures, so a handler can call win.dispatch, win.maximize, etc. on the same window it belongs to.
Build and run:
radlang build src/main.rad -o app
./app Notes & limits (iteration 1)
- Windows. Each
gui.window(...)allocates its own webview + bind table, so multiple windows are structurally supported — but it’s lightly tested, and closing any window currently stops the run loop (quits the app). Treat single-window as the supported path for now. - Linux. Requires WebKitGTK 4.1 at build and runtime.
gui/syn.webvieware fully backed by it; the separate immediate-modesyn.windowrenderer is still macOS-only. - Windows. The WebView2 backend is not implemented yet;
gui.*remains a no-op there. - No richer marshalling. The bridge is
string -> string(JSON by convention); structs/lists aren’t auto-marshalled across it yet. - Not yet: menus/tray, file dialogs, DevTools toggle.
A future native immediate-mode backend (“most control”: single-digit-MB, no
web engine) is planned; window/run/bind/title are designed to carry over,
while load/loadFile/loadUrl/eval are webview-specific. See
design/gui.md.