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.1 and pkgconf packages; 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.

CallDescription
gui.window(title: string, width: int, height: int, config?)WindowCreate 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 methodDescription
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.windowwin.bind(s) → win.loadFilegui.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: false doesn’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:

  1. arg is JSON-stringified and delivered to your radlang handler as a string.
  2. The handler returns a stringvalid 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 CustomEvent named rad:<channel> is also emitted, so window.addEventListener('rad:status', e => e.detail) works too.
  • JS receives the parsed value, not a JSON string — the opposite of a bind handler’s argument, which arrives as a JSON string you json.parse. So push "\"hi\"" from radlang and the subscriber gets the string hi.
  • Timing: subscribe before the value is pushed. A common shape is: the page calls a binded ready() once loaded, and radlang starts dispatching from there.
  • Thread-safe: win.dispatch marshals onto the main thread internally, so it is safe to call from a background task (not just from inside a bind handler). Note, though, that a bind handler 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 with json.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 (dispatchrad.on). A runnable copy lives in dev/gui/.

External assets work. A loadFile page is a real webview, so it can pull in CDN stylesheets/scripts over https:// (e.g. Bootstrap). Only plaintext http:// is blocked by App Transport Security. The snippet below is plain CSS to stay self-contained; the dev/gui demo 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.webview are fully backed by it; the separate immediate-mode syn.window renderer 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.