Guides menu
Guide

Build a GUI app

A step-by-step tutorial: build an interactive terminal app with serez-ui— a React-style UI library with components, state, and a render loop. We'll build a keyboard-driven task tracker.

What you'll learn: the Window / Component model, reusable components with props, rendering lists with .map(), handling input with onKey, and driving the screen with EventLoop.

Step 1 — Set up

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

serez-ui uses a React-like model: components extend Window and return a tree from render(). You can write that tree as JSX in a .szx file, which the bundled translator turns into .sz.

Step 2 — A reusable row component

serez-ui has two base classes: Window is the root interface (your screen), and Component is a reusable piece you can drop in anywhere. A component receives props (data passed to it) and children (content between its tags) — just like React.

Create app.szx and define a row component. It reads its data from this.props with plain dot access:

import "serez-ui"

// A reusable row. Renders one task line based on its props.
class TaskRow:Component {
    public TaskRow() { super() }

    public render() {
        let mark    = "[ ] "
        if (this.props.done)     { mark = "[x] " }
        let pointer = "  "
        if (this.props.selected) { pointer = "> " }
        return (
            <li>{pointer + mark + this.props.text}</li>
        )
    }
}
Why this is nice: the row's look lives in one place. You write this.props.textwith a dot — serez-ui's translator turns it into the dict access serez-code needs, so you never see the plumbing.

Step 3 — Render the list with .map()

Now the screen. TaskTracker holds the state and renders one <TaskRow> per task with .map() — no manual loops, no h(...)calls. The list is just an expression in the JSX:

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

    public render() {
        // capture state in locals so the map callback can see it
        let tasks  = this.tasks
        let done   = this.done
        let cursor = this.cursor
        return (
            <div>
                <h1>Task Tracker</h1>
                <p>j/k move · space toggle · q quit</p>
                <hr />
                <ul>
                {
                    tasks.map(fn (t, i) {
                        return (
                            <TaskRow text={t} done={done[i]} selected={i == cursor} />
                        )
                    })
                }
                </ul>
            </div>
        )
    }
}

That's the whole list — one .map() returning a component per item. Adding a column or changing the look means editing TaskRow, nothing else.

Step 4 — Handle the keyboard

EventLoop calls onKey on every keypress with an event whose code is the key. Move the cursor and toggle completion:

    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]
        }
    }
Note: q exits by default — the EventLoop handles it for you. Override quitKey on the loop to change it.

Step 5 — Start the loop

At the bottom of the file, mount the component and start rendering:

let app  = new TaskTracker()
let loop = new EventLoop(app)
loop.start()   // blocks, renders, dispatches keys until you press q

Run it

.szxfiles are JSX — translate & run them with the bundled wrapper:

# Windows
.\packages\serez-ui\tools\szx.ps1 app.szx

# macOS / Linux
./packages/serez-ui/tools/szx.sh app.szx

Prefer plain .sz? Write the tree with h(tag, props, children) instead of JSX and run it with sz run dev — no translation step.

Next steps