radlang — Native GUI (syn)
syn is radlang’s native desktop GUI. You describe a tree of nodes — panels,
labels, buttons, form fields — and syn resolves its layout and redraws it every
frame on a native sokol/Metal surface. Layout is intrinsic: nodes size themselves
from their content and font, so you rarely hardcode pixels.
For language fundamentals see the Overview. For the system-webview
surface (syn.webview) see gui.md.
Overview
syn is a compiler-intrinsic namespace — no import needed for the core
primitives. You open a window, hang a tree of nodes off it, and run the loop:
fn main(): void {
NativeWindow win = syn.window("Hello", 480, 320)
SynNode root = win.panel({dir: "column", pad: syn.px(20), gap: syn.px(10)})
root.label("What's your name?")
SynNode name = root.input({placeholder: "type here"})
root.button("Greet", fn() {
sys.output("Hello, " + name.value())
})
syn.run(win)
} Two things are always true:
- Every node attaches to a parent when it’s built. A node has to know where
it lives in the tree the moment you create it, because
syndraws the whole retained tree each frame — there are no detached/orphan nodes. - The parent comes first. For built-in primitives that means calling the
builder on the parent (
root.button(...)); for the layout components it means passing the parent as the first argument (layout.table(root, ...)). See Primitives vs. components.
The window
NativeWindow win = syn.window(title, width, height, {config}?) config is an optional struct of window flags (all default on):
| Flag | Meaning |
|---|---|
resizable | window can be resized |
minimizable | window has a minimize control |
closable | window has a close control |
titled | window has a title bar |
NativeWindow win = syn.window("Fixed", 400, 300, {resizable: false})
syn.run(win) // enters the app loop; returns when the window closes win behaves as the root node: you build the top container with win.panel(...), and everything else nests under that.
The layout model
Each node measures its intrinsic size from its content, then the layout engine places it. You override sizing only where you need to.
Units
Inside a layout spec, sizes are written with unit markers:
| Form | Meaning |
|---|---|
syn.px(n) | n pixels (absolute) |
syn.pct(n) | n percent of the parent along that axis |
| bare number | fraction of the parent (0.5 = half); prefer syn.pct for clarity |
Omit a size entirely and the node hugs its content (the default, and the recommended path — it adapts to the font automatically).
Container options
Any builder that takes a {...} spec accepts these:
| Key | Values | Notes |
|---|---|---|
dir / direction | "column", "row" | how children stack; default absolute/overlap |
gap | syn.px(n) | space between children |
pad / padding | syn.px(n) | inset for this node’s children |
margin | syn.px(n) | inset of this node inside its own slot |
x y w h | syn.px / syn.pct | explicit position/size (else intrinsic) |
anchor | "center", "top", "bottomRight", … | 9-grid placement within the parent |
color / background | syn.rgb(r,g,b) | fill color |
overflow | "auto", "scroll", "hidden", "visible" | main-axis overflow (default auto) |
smooth / antiAliasing | true / false | anti-aliased text; inherited by the subtree |
Anchors: topLeft top topRight left center right bottomLeft bottom bottomRight.
Colors
The lexer has no hex literals, so colors are built with syn.rgb:
SynNode card = root.panel({color: syn.rgb(46, 52, 64)}) Primitives
Primitives are methods on the parent node. Each appends a child and returns its handle, so trees nest naturally.
Containers
SynNode p = parent.panel({dir: "column", gap: syn.px(8)}) // generic box
SynNode g = parent.grid({cols: 3, gap: syn.px(10)}) // wraps into N columns
SynNode s = parent.split({dir: "row", ratio: 0.3}) // two resizable panes Text & buttons
parent.label("Some text")
parent.button("Click me", {w: syn.pct(100)}, fn() { sys.output("clicked") }) button(text, {layout}?, onClick) — the click handler is a () => {} / fn(){} closure; the layout spec is optional and sits between text and handler.
Form fields
SynNode a = parent.input({placeholder: "email", value: "", label: "Email"})
SynNode b = parent.textarea({placeholder: "notes"})
SynNode c = parent.number({value: 30, min: 0, max: 120, step: 1, label: "Age"})
SynNode d = parent.checkbox({label: "Subscribe", checked: true})
SynNode e = parent.select(["Small", "Medium", "Large"], {placeholder: "Size"}) Field-specific options:
| Field | Options | Change handler |
|---|---|---|
input / textarea | placeholder, value, label | (v) => {} on edit |
number | value, min, max, step, placeholder, label | (v) => {} on edit |
checkbox | label, checked | (b) => {} on toggle |
select | (options are the first positional arg), placeholder | (v) => {} on select |
A bare field is just the builder with no options: root.input() gives a plain,
unlabeled input attached to root.
label gives a floating label — it rests inside the empty field and animates
up to a caption when the field gains focus or content.
Keyboard
Tab / Shift+Tab move focus across fields; Enter/Space activate a focused button/checkbox/select; a modal traps focus while it’s open.
Declarative children
The builder form (parent.method(...)) is imperative: each call mutates the
tree and hands back a node. Alongside it, any container accepts a children:[...] option — a declarative form where the code’s nesting mirrors the UI tree. Every
entry in the list is attached to the container automatically, in order:
root.panel({dir: "column", gap: syn.px(10), children: [
syn.label("Title").font({size: 22, weight: "bold"}),
syn.label("Nesting is the tree — no parent handle needed."),
syn.panel({dir: "row", gap: syn.px(8), children: [
syn.button("One", {color: accent}, fn() { sys.output("one") }),
syn.button("Two", {color: accent}, fn() { sys.output("two") })
]})
]}) children entries are built with the syn. builder form — syn.panel, syn.label, syn.button, syn.input, syn.checkbox, syn.grid, … — which
create a detached (parentless) node. The container adopts them; that’s what
makes the attachment automatic. (A parent.method(...) call attaches immediately
to parent instead, so don’t nest those inside children — the node would end up
in two parents.) Containers that adopt: panel, grid, split.
Handles are still recoverable. Build a node into a variable, drop the variable
into children[], and mutate it later from a closure — the declarative form
doesn’t hide it from you:
SynNode status = syn.label("idle").font({italic: true})
root.panel({dir: "row", gap: syn.px(8), children: [
status,
syn.button("Ping", {}, fn() { status.setText("pong") })
]}) Mix freely: use children:[...] for static structure, and parent.method(...) when you’re building nodes in a loop or need the handle inline.
Chainable modifiers
These return the same node, so you chain them onto a builder:
parent.panel({dir: "row"}).grow(1)
parent.label("Title").font({size: 24, weight: "bold"}) | Modifier | Effect |
|---|---|
.grow(n) / .fill() | take a share of leftover main-axis space (flex weight n; fill = 1) |
.font({...}) | typography — see Typography |
.overlay(modal?) / .modal() | float this node above the tree (dialogs/toasts) |
.ttl(seconds) | auto-hide this node after seconds (toasts) |
.hide() / .show() / .setVisible(bool) | visibility |
Getters & setters
Read and mutate widget state live:
string v = field.value() // input/textarea/number text; select → selected text
num n = field.numberValue() // number field as a num
bool on = box.checked() // checkbox
bool vis = node.visible()
field.setValue("new")
box.setChecked(false)
sel.setSelected("Medium")
label.setText("updated")
node.setColor(syn.rgb(0, 120, 0))
win.title("New window title")
node.close() Typography
Text nodes render from a font that inherits down the tree — a node uses its parent’s font unless it overrides it, and it overrides only the attributes you name. The default is Helvetica Neue 15pt regular.
// A base font on the root; everything under it inherits.
SynNode root = win.panel({dir: "column"}).font({family: "Helvetica Neue", size: 15})
root.label("Heading").font({size: 30, weight: "bold"}) // bigger + bolder
root.label("Body inherits 15pt regular.") // no override
root.label("Caption").font({size: 13, weight: "light", italic: true})
// A nested container changes the family; its children inherit that…
SynNode code = root.panel({}).font({family: "Menlo"})
code.label("mono, inherited")
code.label("mono, bold").font({weight: "bold"}) // keeps Menlo, only weight changes .font({...}) options (all optional — unset attributes inherit from the parent):
| Key | Value |
|---|---|
family | font family name, e.g. "Helvetica Neue", "Menlo" |
size | point size (a number) |
weight | "thin", "light", "regular", "medium", "semibold", "bold", "heavy", "black" |
italic | true / false |
Layout stays intrinsic at any size — a 30pt heading hugs to 30pt, no clipping.
Primitives vs. components
There are two layers, distinguished by how you call them:
| Built by | Call shape | |
|---|---|---|
| Primitives | the compiler | parent.method(...) — receiver is the parent |
| Components | the layout library | layout.fn(parent, ...) — parent is arg 1 |
Both attach a child to whatever parent you give them; the tree is always root → containers → widgets. The difference is only who authored the builder:
primitives are compiler-emitted node types, components are composed radlang in ns layout (read/extend them in src/namespace/layout.ns.rad).
Note:
gridandsplitexist in both layers.root.grid({cols: 3})is the raw primitive;layout.grid(root, 3)is the styled component that wraps it.
Layout components
import "layout" first. Each takes its host container as the first argument and
returns the node(s) you fill.
list — a scrollable, fixed-height column
SynNode items = layout.list(root, 240) // 240px tall, scrolls when it overflows
items.label("Row 1")
items.label("Row 2") grid / split — styled containers
SynNode g = layout.grid(root, 3) // 3 equal columns
SynNode[] panes = layout.split(root, "row", 0.3, 400) // [left, right], left = 30%, 400px tall
panes[0].label("sidebar")
panes[1].label("content") accordion — collapsible sections
SynNode acc = layout.accordion(root)
SynNode s1 = layout.section(acc, "Details", true) // open initially; returns the body
s1.label("shown when expanded")
SynNode s2 = layout.section(acc, "More", false) tabs — a tab bar over a content stack
SynNode[] panes = layout.tabs(root, ["Overview", "Settings"])
panes[0].label("overview content")
panes[1].label("settings content") modal — a centered dialog over a dimmed backdrop
SynNode dlg = layout.modal(root) // built hidden; add content, then show it
dlg.label("Delete this item?")
dlg.button("Cancel", fn() { dlg.hide() })
root.button("Delete…", fn() { dlg.show() }) toast — a transient message
SynNode t = layout.toast(root, "Saved!") // built hidden
root.button("Save", fn() { layout.showToast(t, 2.5) }) // flash for 2.5s, auto-hide table — a header row over a scrollable body
SynNode body = layout.table(root, ["Name", "Age", "City"], 300) // 300px body
layout.tableRow(body, ["Ada", "36", "London"])
layout.tableRow(body, ["Alan", "41", "Bletchley"]) Columns are equal-width; rows hug their content vertically (font line height + padding), so they never clip and adapt if the font changes.
Quick reference
Namespace calls: syn.window · syn.run · syn.rgb · syn.px · syn.pct · syn.webview
Node builders: panel label button input textarea number checkbox select grid split
Modifiers: .grow .fill .font .overlay .modal .ttl .hide .show .setVisible
State: .value .numberValue .checked .selected .visible · .setValue .setChecked .setSelected .setText .setColor .title .close
Components (import "layout"): list grid split accordion+section tabs modal toast+showToast table+tableRow