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:

  1. 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 syn draws the whole retained tree each frame — there are no detached/orphan nodes.
  2. 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):

FlagMeaning
resizablewindow can be resized
minimizablewindow has a minimize control
closablewindow has a close control
titledwindow 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:

FormMeaning
syn.px(n)n pixels (absolute)
syn.pct(n)n percent of the parent along that axis
bare numberfraction 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:

KeyValuesNotes
dir / direction"column", "row"how children stack; default absolute/overlap
gapsyn.px(n)space between children
pad / paddingsyn.px(n)inset for this node’s children
marginsyn.px(n)inset of this node inside its own slot
x y w hsyn.px / syn.pctexplicit position/size (else intrinsic)
anchor"center", "top", "bottomRight", …9-grid placement within the parent
color / backgroundsyn.rgb(r,g,b)fill color
overflow"auto", "scroll", "hidden", "visible"main-axis overflow (default auto)
smooth / antiAliasingtrue / falseanti-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:

FieldOptionsChange handler
input / textareaplaceholder, value, label(v) => {} on edit
numbervalue, min, max, step, placeholder, label(v) => {} on edit
checkboxlabel, 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 formsyn.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"})
ModifierEffect
.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):

KeyValue
familyfont family name, e.g. "Helvetica Neue", "Menlo"
sizepoint size (a number)
weight"thin", "light", "regular", "medium", "semibold", "bold", "heavy", "black"
italictrue / 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 byCall shape
Primitivesthe compilerparent.method(...) — receiver is the parent
Componentsthe layout librarylayout.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: grid and split exist 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")
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