Guides menu
Guide

Build a UI — TUI or GUI

serez-ui runs the same components in two places: a terminal UI (TUI) or a native window (GUI). They share the component model but differ in the event loop, the permission, and how the user interacts. This guide builds the same task list both ways so the difference is concrete — pick the one you need.

TUI (terminal)GUI (window)
Startapp.runTui()app.runGui(title, w, h)
serez.json permission"Terminal""Gui"
Outputdrawn in the terminalnative window (title + size)
Interactionraw keyboard via onKey(evt)click, typing + built-in focus/keyboard nav
App onKey(evt)✅ dispatched✗ not dispatched (components handle keys)
QuitqEsc or the close button
The key difference: your own onKey handler is a TUI-only feature — the GUI loop does not call it. GUI components still handle the keyboard themselves (Tab moves focus, Enter/Space/arrows act), so a GUI app interacts through widgets (Button, Input) plus that built-in focus navigation.

Step 0 — Set up

mkdir task-app
cd task-app
sz init --y
sz install serez-ui

Write your UI as JSX in a .szx file. The runtime runs it directly — sz app.szx translates the JSX and runs it, no build step. Set the right permission in serez.json (Terminal for TUI, Guifor GUI) or the app renders but can't take input.

The shared component model

Both versions use the same building blocks: a screen extends Window, a reusable piece extends Component, and both return JSX from render(). State lives in this; mutating it re-renders. A reusable row — named TaskRow (Row is now a built-in layout component) — used by both:

class TaskRow:Component {
    public TaskRow() { super() }
    public render() {
        let mark = "[ ] "
        if (this.props.done)     { mark = "[x] " }
        let cursor = "  "
        if (this.props.selected) { cursor = "> " }
        return (<li>{cursor + mark + this.props.text}</li>)
    }
}
One root per render. A render() must return a single root JSX element. To return siblings without an extra wrapper, group them in a fragment: <> … </>. See fragments.

Option A — Terminal UI (TUI)

Start the TUI with app.runTui(); it is driven by the keyboard: override onKey (a method of the Window, inside the class) and the loop calls it on every keypress. Needs the Terminal permission to read keys.

// app.szx
import "serez-ui"

class TaskRow:Component {
    public TaskRow() { super() }
    public render() {
        let mark = "[ ] "
        if (this.props.done)     { mark = "[x] " }
        let cursor = "  "
        if (this.props.selected) { cursor = "> " }
        return (<li>{cursor + mark + this.props.text}</li>)
    }
}

class Tasks:Window {
    public Tasks() {
        super()
        this.tasks  = ["buy milk", "write docs", "ship release"]
        this.done   = [false, false, false]
        this.cursor = 0
    }

    public render() {
        let tasks  = this.tasks
        let done   = this.done
        let cursor = this.cursor
        return (
            <div>
                <h1>Tasks</h1>
                <p>j/k move · space toggle · q quit</p>
                <ul>
                {
                    tasks.map(fn (t, i) {
                        return (<TaskRow text={t} done={done[i]} selected={i == cursor} />)
                    })
                }
                </ul>
            </div>
        )
    }

    // onKey is a method OF Tasks — keep it INSIDE the class, before the closing brace.
    public void onKey(any evt) {
        let code = evt.code
        if (code == "j") { if (this.cursor < this.tasks.length() - 1) { this.cursor = this.cursor + 1 } }
        if (code == "k") { if (this.cursor > 0) { this.cursor = this.cursor - 1 } }
        if (code == " ") { this.done[this.cursor] = !this.done[this.cursor] }
    }
}

let app = new Tasks()
app.runTui()                    // renders + dispatches keys; quit with q

Declare the permission and run it:

{ "name": "task-app", "version": "1.0.0", "permissions": ["Terminal"] }
sz app.szx
Common mistake: onKey must be inside the class (between render()'s closing brace and the class's closing brace). Placed after the class it becomes a top-level public and fails with "Expected 'class' or 'interface' after visibility modifier".

Option B — Native window (GUI)

Start the GUI with app.runGui(title, w, h) in a real window. It interacts through widgets<Button onClick> and <Input onChange>not onKey (the GUI loop does not dispatch it). Needs the Gui permission.

// app.szx
import "serez-ui"

class Tasks:Window {
    public Tasks() {
        super()
        this.tasks = ["buy milk", "write docs", "ship release"]
        this.idx   = 0
    }

    public render() {
        let current = this.tasks[this.idx]
        return (
            <div>
                <h1>Tasks</h1>
                <h2>{current}</h2>
                <Button onClick={() => { this.idx = (this.idx + 1) % this.tasks.length() }}>Next</Button>
            </div>
        )
    }
}

let app = new Tasks()
app.runGui("Tasks", 440, 320)      // native window — quit with Esc or the close button

Declare the permission and run it:

{ "name": "task-app", "version": "1.0.0", "permissions": ["Gui"] }
sz app.szx
GUI interaction is through widgets. Clicks go to a Button's onClick and typing goes to a focused Input's onChange. If you need raw keyboard navigation (arrow keys, vim-style), that is the TUI.
Retained-mode performance: Since version 2.3.0, serez-ui automatically translates your components into a low-level retained-mode scene graph, rendering shapes and text extremely fast in Rust without executing heavy immediate-mode drawing loops. Version 4.3+ goes further with an opt-in native renderer (app.useNativeRenderer(true), core ≥ 9.2) that also moves layout and CSS resolution into Rust.

Secondary Windows (Panels)

If your GUI app requires more than one window, you can open secondary windows (known as panels) with this.openPanel(title, w, h) and render their content by overriding renderPanel(id):

class Tasks:Window {
    public Tasks() {
        super()
        this.tasks = ["buy milk", "write docs", "ship release"]
        this.idx = 0
        this.panelId = -1
    }

    public render() {
        let current = this.tasks[this.idx]
        return (
            <div>
                <h1>Tasks</h1>
                <h2>{current}</h2>
                <Button onClick={() => { this.idx = (this.idx + 1) % this.tasks.length() }}>Next</Button>
                <Button onClick={() => {
                    if (this.panelId == -1) {
                        this.panelId = this.openPanel("Details", 300, 200)
                    }
                }}>Show Details</Button>
            </div>
        )
    }

    // Override renderPanel to draw the secondary window
    public renderPanel(int id) {
        if (id == this.panelId) {
            let current = this.tasks[this.idx]
            return (
                <div>
                    <h2>Task Details</h2>
                    <p>Current active task is: "{current}"</p>
                    <Button onClick={() => {
                        this.closePanel(this.panelId)
                        this.panelId = -1
                    }}>Close</Button>
                </div>
            )
        }
        return null
    }
}

Each panel carries its own full input state (serez-ui 4.4+): editable Input / Textarea, its own focus order, dropdowns and undo — isolated from the main window, with the keyboard following whichever window has OS focus. See the panels reference.

Which one?

  • TUI — keyboard-driven terminal tools (dashboards, pickers, vim-style). app.runTui() + Terminal + onKey.
  • GUI — clickable desktop apps in a window. app.runGui(...) + Gui + Button/Input.
  • The components, render(), props and state are identical — only the event loop, permission and input model change.

Richer widgets

The GUI ships 24 built-in components — beyond Button / Input they follow the same controlled pattern: pass the current value/state and an onChange (or onToggle) that updates this. One example per category:

<Button onClick={save} disabled={!this.canSave}>Save</Button>
<Input value={this.name} placeholder="your name" onChange={(v) => { this.name = v }} />
<Switch checked={this.dark} label="Dark mode" onChange={(b) => { this.dark = b }} />
<Tabs tabs={["General", "Usage"]} active={this.tab} onChange={(i) => { this.tab = i }} />
<FileInput exts="png,jpg" onChange={(p) => { this.file = p }} />
<Chart data={[3, 7, 5, 9, 6, 11, 8]} type="area" height={140} dots={true} />
<Modal open={this.confirm} title="Delete item?"><p>This cannot be undone.</p></Modal>
The full catalog — all 24 components grouped by type, each with its props (types and defaults), keyboard behavior and working examples — is the Component catalog, a dedicated page with its own component index.

Tabs draws the strip and you render the panel for the active index; Collapsible reveals its children when open; Chart plots a numeric series with the core vector primitives. The window also surfaces OS events — onFilesDropped, focus, IME, touch/pinch — as Window overrides. See the full component reference and the raw Gui drawing primitives.

Make the GUI responsive

A GUI window can be resized, and serez-ui adapts on its own:

  • Autosize — full-width controls stretch and shrink; a Row stacks vertically when its children no longer fit; content taller than the window scrolls with the mouse wheel.
  • Breakpoints in code — read this.breakpoint() ("sm"/"md"/"lg") or this.viewportWidth() inside render() to return a different tree per size.
  • .szs media queries — style by width, e.g. body (width < 600) { … }; see the .szs reference.

Next steps