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) | |
|---|---|---|
| Start | app.runTui() | app.runGui(title, w, h) |
| serez.json permission | "Terminal" | "Gui" |
| Output | drawn in the terminal | native window (title + size) |
| Interaction | raw keyboard via onKey(evt) | click, typing + built-in focus/keyboard nav |
| App onKey(evt) | ✅ dispatched | ✗ not dispatched (components handle keys) |
| Quit | q | Esc or the close button |
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-uiWrite 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>)
}
}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 qDeclare the permission and run it:
{ "name": "task-app", "version": "1.0.0", "permissions": ["Terminal"] }sz app.szxonKey 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 buttonDeclare the permission and run it:
{ "name": "task-app", "version": "1.0.0", "permissions": ["Gui"] }sz app.szxButton'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.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>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
Rowstacks 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") orthis.viewportWidth()insiderender()to return a different tree per size. - .szs media queries — style by width, e.g.
body (width < 600) { … }; see the .szs reference.
Next steps
- See the serez-ui reference for all components and hooks, and the .szs styling reference.
- Ship it: package it into a double-click app with the packaging tutorial.