ZE-026 - Terminal User Interfaces
Terminal User Interfaces
This proposal is still a draft and is subject to change. Please do not cite or reference it as a finalized design. Features described here may not be implemented as described and cannot be used right now.
Introduction
This proposal adds two things:
terminaldescribes a terminal as a capability value, and its input as a union of events.tuiruns interactive terminal programs in the style of the Elm architecture. A program has three parts:- a model;
- an
updatefunction that returns the next model; - a
viewfunction that renders the model to a string.
os.terminal() hands out the real terminal. Nothing else touches it. The loop is written in plain Zirric on top of the routines and channels of ZE-025.
The design follows Bubble Tea where Bubble Tea is right, and departs from it where Zirric has a better tool. The largest departure is the governing rule: effects are values.
An update never does anything. It returns a tui.Next holding the new model and a list of tui.Effect values, and the loop performs them. This has two consequences:
- Every step of a program is comparable with
==. - Every program can be replayed in a test without a terminal, a timer or a routine.
No language changes are proposed.
Motivation
Since ZE-025, a program can wait on standard input and a timer at the same time. Its spinner example shows how far that gets: it reads single bytes and prints a new line per frame. Three host facilities are still missing, and without them a real interactive program is impossible, not just inconvenient:
- No raw mode. Input arrives only after enter, echoed by the terminal. A key press cannot be a command.
- No key decoding. An arrow key arrives as the three bytes
ESC [ A, split across reads at the host’s whim. Every program would parse escape sequences on its own, and get it wrong in a different way. - No size. There is no way to ask how wide the terminal is, or to hear that it changed, so nothing can be laid out or truncated.
These three need the host, and so belong in os. Beyond them, every TUI would write the same co.select loop by hand:
- read keys in a routine;
- run jobs in routines;
- arm timers;
- redraw without flicker;
- restore the terminal on the way out.
Worse, a text input written for one program’s loop cannot be dropped into another’s. Components only compose over a loop they share.
With this proposal, a counter is a complete program:
mod code.example.counter
import fmt
import os
import terminal { Key }
import tui
// Handles one message. Only keys matter here; everything else keeps the model.
fn update(count: Int, msg) -> tui.Next {
switch msg {
case is Key:
return switch msg.name {
case "up":
tui.keep(count + 1)
case "down":
tui.keep(count - 1)
case "q":
tui.Next(count, [tui.Quit()])
case "ctrl+c":
tui.Next(count, [tui.Quit()])
case _:
tui.keep(count)
}
}
return tui.keep(count)
}
fn view(count: Int) -> String {
return "Count: " + fmt.sprint(count) + "\n↑/↓ to change, q to quit"
}
const app = tui.Program(tui.keep(0), update, view, [])
_ = tui.run(app, os.terminal(), os.timer())
And its test needs no terminal:
@tests.Test()
fn testUpTwiceThenQuit() -> Result {
const trace = tui.replay(counter.app, terminal.Size(40, 5), [
terminal.key("up"),
terminal.key("up"),
terminal.key("q")
])
return assert.all([
assert.equal(2, trace.model),
assert.isTrue(trace.quit)
])
}
Proposed Solution
| Module | Where | Kind | Contents |
|---|---|---|---|
terminal |
stdlib | vocabulary, capability | Terminal value, Event union (Key, Paste, Mouse, Resize), Size, Mode, HasTerminal, a scripted double |
os |
stdlib | host | new: terminal() -> Result |
tui |
stdlib | loop | Program, Next, Effect union, Ticked, run, replay, Trace |
tui.style |
stdlib | pure | colors and text attributes as values; paint, plain |
tui.layout |
stdlib | pure | measuring and arranging blocks of text |
widgets.spinner |
first-party package | component | reference component that uses effects |
widgets.textinput |
first-party package | component | reference component that does not |
This proposal replaces the draft term, ui and colors packages. Where each piece lives, and why, is argued under Alternatives Considered.
A program is data
data Program {
start: Next
update: fn(Any, Any) -> Next
view: fn(Any) -> String
modes: [terminal.Mode]
}
| Field | Meaning |
|---|---|
start |
The initial model, and the effects to perform before the first message. |
update |
Receives the current model and one message, and returns what happens next. |
view |
Renders a model as text. |
modes |
Asks the terminal for full screen, mouse tracking or bracketed paste. |
A model is any value. It is usually a data type, and often a union: a program that is loading, ready or failed is most honestly modelled as three members. update then begins with a switch over the model.
A message is any value, from one of three sources:
- the terminal sends
terminal.Eventmembers; - timers send
tui.Ticked; CallandStreameffects send whatever their tasks produce.
update tells them apart with switch and case is, as it would any other union.
update and view never wait. They are ordinary functions without switch points: all waiting happens in the loop and in the routines effects start. This is a convention, not a check. An update that reads a file still works, but it holds up the frame, and it cannot be replayed.
Next is the whole answer
data Next {
model
effects: [Effect]
}
// Next(model, []): the common case of an update with nothing to do.
fn keep(model) -> Next
Zirric has no tuples and no multiple returns, and needs neither: an update’s answer is one value with two fields. Because == compares type and fields, tui.Next(0, [tui.Quit()]) == tui.Next(0, [tui.Quit()]) holds, so a test can compare a whole step at once.
Effects are values
union Effect {
data Quit
data After { delay: time.Duration, tag }
data Call { task: fn() -> Any }
data Stream { body: fn(co.Channel) -> Any }
data Print { text: String }
}
data Ticked { tag }
| Effect | What the loop does |
|---|---|
Quit() |
Draws the final frame, cancels every routine the program started, restores the terminal, and returns Ok(model) from run. |
After(delay, tag) |
Delivers Ticked(tag) once delay has passed on the program’s co.Timer. |
Call(task) |
Runs task in a routine and delivers its return value as a message. |
Stream(body) |
Runs body in a routine, as co.produce does, and delivers every value it sends as a message. |
Print(text) |
Writes text above an inline program, where it scrolls away with the terminal’s history. |
After fires once. Something that repeats, such as a spinner, schedules the next After when it handles the current Ticked. A component that stops caring simply stops asking. There are no subscriptions to register, or to forget to cancel.
Call is for work with one answer: reading a file, asking a server. The task should return a message of the program’s own type, so update knows what arrived:
data Loaded { result: Result }
const load = tui.Call(fn() { return Loaded(fs.readString(fsys, path)) })
Stream is for work with many answers, such as following a log or reporting progress. Its body receives a channel of its own, and every value sent into it becomes a message:
data Progress { done: Int }
const copying = tui.Stream(fn(ch) {
var done = 0
for path <- paths {
_ = backUp(fsys, path)
done = done + 1
co.send(ch, Progress(done))
}
})
The capabilities a task needs (fsys here) are captured from where the program was built. The program never calls os; the entry point does, and a test passes fs.memory() instead.
The terminal is a capability
union Event {
data Key { name: String, text: String }
data Paste { text: String }
data Mouse { button: String, x: Int, y: Int }
data Resize { width: Int, height: Int }
}
data Size { width: Int, height: Int }
union Mode {
data FullScreen
data MouseTracking
data BracketedPaste
}
data Terminal {
enter: fn([Mode]) -> Result
leave: fn() -> Void
size: fn() -> Size
next: fn() -> Event?
write: fn(String) -> Void
}
Terminal follows fs.FileSystem and co.Timer:
- the primitives go in the fields;
- conveniences go in module functions;
- the real one comes only from
os; - a double ships in the library.
os.terminal() returns a Result, because standard input or output may not be a terminal at all (a pipe, a CI log). That is an expected failure, not a bug.
The real terminal reads input on its own host stream, like os.stdin(), and decodes it into events, including Resize when the window changes.
Following ZE-025, the module decides where a routine gives way, not the value behind it. terminal.next(t) and terminal.write(t, text) are switch points whatever terminal t is, just as io.read is for any reader. A scripted terminal in a test therefore interleaves at the same points as the real one.
A key is identified by one canonical name, which is what switch matches:
| Kind | Names |
|---|---|
| Printable | the character itself: "a", "A", "7", "?"; the space bar is "space" |
| Editing | "enter", "tab", "backspace", "delete", "esc", "insert" |
| Movement | "up", "down", "left", "right", "home", "end", "pgup", "pgdown" |
| Function | "f1" … "f12" |
| Modified | modifiers in the fixed order ctrl+, alt+, shift+: "ctrl+c", "alt+enter", "shift+tab", "ctrl+alt+left" |
text is what the key would insert, and "" for anything that inserts nothing. A text input appends key.text and never needs to know the name of every printable key. An uppercase letter is "A", not "shift+a", because that is what the user typed.
Rendering is a string
view returns a String: lines separated by "\n", possibly carrying the escape sequences tui.style produces. Strings already compose with +, strings, fmt and for expressions, and a test can compare them. tui.layout measures and arranges strings, and tui.style.plain strips styling so a test can compare the text alone.
const title = [style.Bold(), style.Fg(style.cyan)]
fn header(text: String, width: Int) -> String {
return layout.pad(style.paint(text, title), width, layout.Center())
}
A style is an array of style.Look values, not a data type with a field per attribute. Positional construction of eight booleans and two colors would be unreadable. An array names only what it sets, can be reused as a const, and combines with +.
Components are modules
There is no component attribute and no component type. By convention, a component is a module with a data type for its state and these functions:
| Function | Signature | Required |
|---|---|---|
new |
(…) -> T |
yes |
update |
(T, Any) -> T, or (T, Any) -> tui.Next when it has effects |
yes |
view |
(T) -> String |
yes |
| starting effects | (T) -> tui.Effect, named for what they start (spinner.tick) |
when it has any |
A parent stores the component’s state in its own model, forwards messages to its update and places its view. Nothing is registered, and a reader can follow every message by reading the parent’s update.
Detailed Design
The loop
tui.run(program, term, timer) owns the terminal for the duration of the program:
fn run(program: Program, term: terminal.Terminal!, timer: co.Timer) -> Result
term is the Result that os.terminal() returns, the way fs.withFile takes the result of fs.open, so the entry point needs no switch of its own. An Err is returned unchanged.
- Enter the terminal with
program.modes. Raw mode and a hidden cursor are always on. AnErrfromenteris returned. - Open a
co.scope, which owns every routine the program starts. Start the input routine withterminal.events. - Take
program.startas the current step and perform its effects. - Deliver
terminal.Resizewith the current size as the first message. Every program learns its size the same way it learns it later. - Until a
Quithas been performed, repeat:- Call
update(model, message). Its result must be aNext; anything else is a bug in the program, and the loop panics with a message namingupdate. - Draw
view(model). - Perform the step’s effects in order.
After,CallandStreameach spawn a routine into the scope that sends its messages into the loop’s inbox. - Wait with
co.select([input, inbox])for the next message.
- Call
- Cancel the scope, draw the final frame, leave the terminal, and return
Ok(model).
Input comes first. select takes the first ready channel in list order, so a key waiting alongside a task result is handled first. The user is never kept waiting behind work they may be about to cancel. Within the inbox, messages arrive in the order their routines sent them.
The loop needs nothing beyond co. In outline, leaving out drawing and Print:
fn _loop(s: co.Scope, program: Program, t: terminal.Terminal, timer: co.Timer) -> Result {
const input = terminal.events(s, t)
const inbox = co.channel(0)
var model = program.start.model
var quitting = _perform(s, inbox, timer, program.start.effects)
const size = terminal.size(t)
var message = terminal.Resize(size.width, size.height)
for !quitting {
const next = _step(program, model, message)
model = next.model
_draw(t, program.view(model))
quitting = _perform(s, inbox, timer, next.effects)
if !quitting {
const selected = co.select([input, inbox])
switch selected {
case is co.Closed:
quitting = true // standard input ended
case is co.Received:
message = selected.value
}
}
}
co.cancel(s)
return Ok(model)
}
fn _perform(s: co.Scope, inbox: co.Channel, timer: co.Timer, effects: [Effect]) -> Bool {
var quitting = false
for effect <- effects {
switch effect {
case is Quit:
quitting = true
case is After:
_ = co.spawn(s, fn() {
co.sleep(timer, effect.delay)
co.send(inbox, Ticked(effect.tag))
return Ok(void)
})
case is Call:
_ = co.spawn(s, fn() {
co.send(inbox, effect.task())
return Ok(void)
})
case is Stream:
_ = co.spawn(s, fn() {
for message <- co.produce(s, effect.body) {
co.send(inbox, message)
}
return Ok(void)
})
}
}
return quitting
}
Each closure captures its own effect, a property ZE-025 pins with regression tests.
Because ZE-025 runs one routine at a time, update and view never race with a task: the model is only ever touched by the loop’s own routine. Tasks run whenever the loop waits. A task that reads a file or talks to a server gives way at every switch point, so the spinner keeps turning. A task that computes for a long time without one stalls the screen, as it would stall any other routine. Such a task should be split into steps, or become a Stream that reports as it goes.
Quitting and cancelling
Quit cancels the program’s scope. By ZE-025’s rules:
- The input routine and pending
Afters stop at once. - A byte already on its way stays in the host stream.
- A
Callin the middle of a write finishes the write.
Quitting therefore never waits on a server, and never leaves a half-issued write behind.
Effects listed after a Quit in the same step are still performed, but routines they start are cancelled immediately. A program that needs a task to finish before it exits waits for its result message, and only then returns Quit().
run leaves the terminal after Quit, when input ends, and on every Err. A panic cannot be caught in Zirric, so the host restores the terminal before it prints a panic and exits. This is a requirement on the implementation of os.terminal(), not something Zirric code does.
ctrl+c is delivered as terminal.key("ctrl+c"), like any other key, and does not quit on its own. Raw mode turns it into input, and a program decides what it means: cancel a dialog, clear a field. run does not guess.
Replay
data Trace {
model
frames: [String]
effects: [Effect]
quit: Bool
}
fn replay(program: Program, size: terminal.Size, messages: [Any]) -> Trace
replay runs the same steps as run, in the same order: start, then Resize(size), then each message. It records three things:
- the frame drawn after every step;
- every effect produced;
- whether a
Quitwas among them.
Messages after a Quit are ignored.
Replay never performs an effect. It starts no routine and needs no timer, so a replay is a pure function of its arguments.
- A
Callis recorded, not called. A test that wants its result callseffect.task()itself, with whatever doubles it built the program with, and passes the result on as a message. - An
Afteris recorded, not waited for. A test that wants a tick passes atui.Tickedit made. - A
Streamis recorded, not run. A test that wants its messages collects them with the tools ZE-025 already gives it:
const messages = co.scope(fn(s) {
return for message <- co.produce(s, effect.body) { message }
})
terminal.key(name) builds the Key a terminal would send for a name:
terminal.key("a") == terminal.Key("a", "a")terminal.key("down") == terminal.Key("down", "")
terminal.typed(text) builds one key per character, so a test reads like the keystrokes it stands for.
Running for real in a test
replay covers a program’s logic. To test run itself, or a program end to end, terminal.scripted provides a terminal of a fixed size that hands out the given events and records what is written to it:
@tests.Test()
fn testCounterEndToEnd() -> Result {
const script = terminal.scripted(terminal.Size(40, 5), [terminal.key("up"), terminal.key("q")])
return assert.equal(Ok(1), tui.run(counter.app, Ok(script.terminal), co.neverTimer()))
}
The scripted terminal’s events interleave with task results wherever the scheduler reaches them. A test of this kind should not depend on a key arriving before or after a Call’s answer. Ordering is exactly what replay puts in the test’s hands.
Drawing
The loop draws line by line, and a line equal to the one already on screen is not rewritten. Lines are cut, never wrapped, at the terminal’s width as tui.layout.width measures it. The loop therefore always knows how many rows a frame occupies.
| Mode | Where the frame goes | On exit |
|---|---|---|
| inline (default) | from the cursor down, growing and shrinking with the frame | the final frame stays; the shell prompt follows it |
FullScreen() |
the alternate screen, cut at the terminal’s height | the previous screen is restored |
Print writes above an inline frame. It is dropped in full-screen mode, where there is no history to write into.
Every frame is written with a single terminal.write, so it reaches the screen whole. view runs once per message, and a burst of messages draws a burst of frames. Line diffing keeps each one cheap. Coalescing frames would need a receive that does not block, which ZE-025 leaves for later; see Open Questions.
Width
tui.layout.width counts terminal cells:
- escape sequences count zero;
- most characters count one;
- wide characters (CJK, most emoji) count two.
This needs the host’s Unicode tables, so it is an extern with a Zirric-facing function over it, like strings.count. Neither len (bytes) nor strings.count (code points) is the width of text on a screen.
Composition with the language
| Feature | Interaction |
|---|---|
data, union |
Models, messages and effects are ordinary values. Unions make phases explicit. |
switch / is |
The only dispatch mechanism. case is Key: narrows, so msg.name is checked. |
== |
Steps, effects (except Call and Stream) and keys compare by value; tests rely on it. |
co |
The loop is a scope; effects are routines; input and results are channels. No new rules. |
| attributes | None required. HasTerminal exists for code that wants the capability from an environment. |
for expressions |
Build lists of lines; continue filters; collect a Stream in tests. |
?. / !. |
Unaffected. Tasks can use !. inside a function returning a result. |
| static checks | Hints on Program document intent. The checker does not look inside function-typed hints yet, so they cannot raise false alarms. |
| formatter, LSP, tree-sitter | No changes. |
Call and Stream hold functions, and functions compare by identity. Tests check them with is tui.Call or is tui.Stream, and by running them.
Deliberately not
- No parallelism. Inherited from ZE-025, and relied on: the model has one owner.
- No layout engine.
tui.layoutjoins and pads strings; it does not solve constraints. - No focus manager, key-binding registry or command palette. They are components or patterns, and belong in packages.
- No global quit key.
ctrl+cis a key. - No subscriptions. Repetition is an
Afterscheduled again. A long-lived source is aStream. - No view tree.
viewreturns a string. - No components in the standard library. See Alternatives Considered.
Changes to the Standard Library
All additions. Nothing existing changes.
os
// Returns the terminal attached to standard input and output, or an Err when either is not a terminal.
extern fn terminal() -> Result
terminal
Vocabulary and capability, as above, plus:
// Provides the terminal, for code that takes its capabilities from an environment.
attr HasTerminal {
terminal(self: @HasTerminal) -> Terminal
}
fn enter(t: Terminal, modes: [Mode]) -> Result
fn leave(t: Terminal) -> Void
fn size(t: Terminal) -> Size
// The next event, or None() once input has ended. A switch point.
fn next(t: Terminal) -> Event?
// Writes text as one piece. A switch point.
fn write(t: Terminal, text: String) -> Void
// Every event, in a channel that closes when input ends.
fn events(s: co.Scope, t: Terminal) -> co.Channel
// The Key a terminal sends for a canonical name.
fn key(name: String) -> Key
// One Key per character of text.
fn typed(text: String) -> [Key]
// A terminal of a fixed size that hands out the given events, then ends its input,
// and records everything written to it.
fn scripted(size: Size, events: [Event]) -> Script
data Script {
terminal: Terminal
output: fn() -> String
}
tui
Program, Next, Effect with Quit, After, Call, Stream and Print, Ticked, Trace, and:
fn keep(model) -> Next
fn run(program: Program, term: terminal.Terminal!, timer: co.Timer) -> Result
fn replay(program: Program, size: terminal.Size, messages: [Any]) -> Trace
tui.style
union Color {
data Ansi { index: Int } // 0–255
data Rgb { red: Int, green: Int, blue: Int } // 0–255 each
}
const black = Ansi(0)
const red = Ansi(1)
const green = Ansi(2)
const yellow = Ansi(3)
const blue = Ansi(4)
const magenta = Ansi(5)
const cyan = Ansi(6)
const white = Ansi(7)
const gray = Ansi(8)
union Look {
data Fg { color: Color }
data Bg { color: Color }
data Bold
data Faint
data Italic
data Underline
data Reverse
}
// text with every look applied, reset at each line end so styles never leak.
fn paint(text: String, looks: [Look]) -> String
// text with every escape sequence removed.
fn plain(text: String) -> String
run decides from the terminal whether color is emitted at all (for example, NO_COLOR). paint does not, so a view is the same string in a test and on a screen. replay keeps styles; plain removes them.
tui.layout
union Align {
data Start
data Center
data End
}
fn width(text: String) -> Int // cells of the widest line
fn height(text: String) -> Int // number of lines
fn pad(text: String, width: Int, align: Align) -> String
fn truncate(text: String, width: Int) -> String // cut with "…"
fn row(blocks: [String], gap: Int) -> String // side by side, top-aligned
fn column(blocks: [String]) -> String // one below the other
fn box(text: String) -> String // a rounded border around text
The Components Package
Components ship in a first-party package outside the standard library, working name widgets. This proposal specifies only its first two components. Their job is to show that the convention holds, both with effects and without. Further components (lists, viewports, tables, progress bars) are the package’s business, and need no proposal.
widgets.spinner
data Spinner {
frames: [String]
index: Int
interval: time.Duration
tag
}
fn new(tag) -> Spinner
fn tick(s: Spinner) -> tui.Effect {
return tui.After(s.interval, s.tag)
}
fn update(s: Spinner, msg) -> tui.Next {
if msg is tui.Ticked && msg.tag == s.tag {
const moved = Spinner(s.frames, (s.index + 1) % len(s.frames), s.interval, s.tag)
return tui.Next(moved, [tick(moved)])
}
return tui.keep(s)
}
fn view(s: Spinner) -> String {
return s.frames[s.index]
}
The tag is how two spinners in one program tell their ticks apart. It is any value the parent chooses, compared with ==.
widgets.textinput
data TextInput {
value: String
cursor: Int
placeholder: String
focused: Bool
}
fn new(placeholder: String) -> TextInput
fn focus(input: TextInput) -> TextInput
fn blur(input: TextInput) -> TextInput
fn update(input: TextInput, msg) -> TextInput // no effects, so no Next
fn view(input: TextInput) -> String
A Complete Program
A todo picker. It reads a file while a spinner turns, lets the user move through the lines, and prints the chosen one.
mod code.example.todos
import fmt
import fs
import os
import strings
import terminal { Key }
import tui
import tui.layout
import tui.style
import widgets.spinner
// The message the load task sends back.
data Loaded {
result: Result
}
// Where the program is. Each phase holds only what it needs.
union Phase {
data Loading { busy: spinner.Spinner }
data Ready { items: [String], cursor: Int }
data Failed { reason }
}
// Builds the program. The filesystem comes in, so tests can pass fs.memory().
fn program(fsys: fs.FileSystem, path: String) -> tui.Program {
const busy = spinner.new("loading")
const load = tui.Call(fn() { return Loaded(fs.readString(fsys, path)) })
return tui.Program(tui.Next(Loading(busy), [load, spinner.tick(busy)]), update, view, [])
}
fn update(model: Phase, msg) -> tui.Next {
if msg == terminal.key("ctrl+c") {
return tui.Next(model, [tui.Quit()])
}
switch model {
case is Loading:
return whileLoading(model, msg)
case is Ready:
return whileReady(model, msg)
}
switch msg {
case is Key:
return tui.Next(model, [tui.Quit()])
}
return tui.keep(model)
}
fn whileLoading(model: Loading, msg) -> tui.Next {
switch msg {
case is Loaded:
return tui.keep(fromFile(msg.result))
case is tui.Ticked:
const next = spinner.update(model.busy, msg)
return tui.Next(Loading(next.model), next.effects)
}
return tui.keep(model)
}
fn fromFile(result: Result) -> Phase {
switch result {
case is Err:
return Failed(result.reason)
}
const items = for line <- strings.split(result.value, "\n") {
if line == "" { continue } else { line }
}
return Ready(items, 0)
}
fn whileReady(model: Ready, msg) -> tui.Next {
switch msg {
case is Key:
return switch msg.name {
case "up":
tui.keep(move(model, -1))
case "down":
tui.keep(move(model, 1))
case "enter":
tui.Next(model, [tui.Quit()])
case "q":
tui.Next(model, [tui.Quit()])
case _:
tui.keep(model)
}
}
return tui.keep(model)
}
fn move(ready: Ready, by: Int) -> Ready {
const wanted = ready.cursor + by
const last = len(ready.items) - 1
const cursor = if wanted < 0 { 0 } else if wanted > last { last } else { wanted }
return Ready(ready.items, cursor)
}
fn view(model: Phase) -> String {
return switch model {
case is Loading:
spinner.view(model.busy) + " Reading todos…"
case is Ready:
viewReady(model)
case _:
style.paint("Could not read todos: " + fmt.sprint(model.reason), [style.Fg(style.red)])
}
}
fn viewReady(ready: Ready) -> String {
var lines = []
var index = 0
for item <- ready.items {
const line = if index == ready.cursor {
style.paint("> " + item, [style.Bold(), style.Fg(style.cyan)])
} else {
" " + item
}
lines = append(lines, line)
index = index + 1
}
return layout.column(lines)
}
// Entry point: the only place that reaches for os.
const finished = tui.run(program(os.fs(), "todo.txt"), os.terminal(), os.timer())
switch finished {
case is Err:
fmt.fprintln(finished.reason, os.stderr())
os.exit(1)
case is Ok:
const last = finished.value
if last is Ready {
fmt.fprintln(last.items[last.cursor], os.stdout())
}
}
And its tests:
mod code.example.todos._t
import fs
import terminal
import tests
import tests.assert
import tui
import tui.style
import code.example.todos
const size = terminal.Size(40, 10)
@tests.Test()
fn testStartsLoadingAndTicking() -> Result {
const trace = tui.replay(todos.program(fs.memory(), "todo.txt"), size, [])
return assert.all([
assert.isTrue(trace.model is todos.Loading),
assert.isTrue(trace.effects[0] is tui.Call),
assert.isTrue(trace.effects[1] is tui.After)
])
}
@tests.Test()
fn testPicksSecondItem() -> Result {
const fsys = fs.memory()
_ = fs.writeString(fsys, "todo.txt", "milk\neggs\n")
const program = todos.program(fsys, "todo.txt")
// replay performs no effects: run the load here and hand its message in
const load = tui.replay(program, size, []).effects[0]
const trace = tui.replay(program, size, [load.task(), terminal.key("down"), terminal.key("enter")])
return assert.all([
assert.equal(todos.Ready(["milk", "eggs"], 1), trace.model),
assert.isTrue(trace.quit),
assert.equal(" milk\n> eggs", style.plain(trace.frames[len(trace.frames) - 1]))
])
}
@tests.Test()
fn testMissingFileFails() -> Result {
const program = todos.program(fs.memory(), "todo.txt")
const load = tui.replay(program, size, []).effects[0]
const trace = tui.replay(program, size, [load.task()])
return assert.isTrue(trace.model is todos.Failed)
}
Compatibility
Purely additive. No syntax, keyword, operator or checker rule changes, and no work for the formatter, language server or tree-sitter grammar. The draft term, ui and colors packages are superseded; nothing in the standard library depends on them.
Dependencies on Other Proposals
| Proposal | Dependency |
|---|---|
| ZE-008 | os.terminal() and run report expected failures as Results. |
| ZE-010 | Tests collect a Stream with a for expression over a channel. |
| ZE-017 | update dispatches with case is; Program’s fields carry function type hints. |
| ZE-018 | os is the only module that reaches the host; terminal() joins it. |
| ZE-022 | Hints on Program must only ever report certainties. |
| ZE-024 | Examples use qualified module paths. |
| ZE-025 | The loop is a co.scope; effects are routines; terminal.next and write are switch points; timers are a co.Timer. |
Alternatives Considered
Do nothing: every program writes its own loop
Since ZE-025, a program can write its own co.select loop, as ZE-025’s spinner example does. That is the strongest form of doing nothing, and it fails in two places:
- Raw mode, key decoding and size need the host, and by the design of ZE-018 only
osreaches the host. At minimum,os.terminal()and theterminalmodule must exist. - Hand-written loops do not compose. A text input written against one program’s inbox and message types cannot be dropped into another program. The loop is the contract components are written against, just as
@io.Writeris the contract for streams.
Where each piece lives
The draft term, ui and colors packages split the domain differently. This proposal replaces them, with each piece placed by one question: what breaks if it changes?
terminalbelongs in the standard library. It is the only way to reach the host, likefsandco.Timer, and its real implementation lives inos.tuibelongs in the standard library. It is small, depends only onterminal,coandtime, and is the shared contract that makes packages composable. Two competing loops would split the ecosystem, and a loop that changes breaks every component at once. The standard library is where such a contract earns stability.tui.styleandtui.layoutbelong in the standard library, next totui. They are pure and small, and they are coupled to the loop:layout.widthmust skip exactly the escape sequencesstyle.paintemits, and the loop cuts lines by that width. As a separate package (colors), that agreement would be an accident rather than a guarantee.- Components do not belong in the standard library. They grow, churn and carry taste. The standard library is meant to be basic, and “which list widget” is not a basic question. A first-party package can move quickly, and third-party components compete on equal terms, because they all target the same
tuicontract.
A program as an attribute on the model type
@tui.App(update, view) on data Model is closest to Bubble Tea’s Model interface. It is rejected because a program is one value, not an open set of types, and nothing needs to discover it by reflection. By the standard library’s own order, a data value is cheaper than an attribute. An attribute would also force the model to be a data type, when a union is often the better model.
Commands as functions
In Bubble Tea, tea.Cmd is a function (fn() -> Any) that the runtime runs later. Making that the only kind of effect is rejected: a function cannot be compared, printed or inspected, so a test could not see that an update asked to quit. Call and Stream keep that flexibility for the two cases that need it, and ZE-025 is what now runs them.
Letting update spawn routines itself
update could take a co.Scope and start its own routines. This is rejected because updates would stop being replayable, the model would gain a second writer, and every test would need a scope and a timer. Effects keep that power in the loop.
Returning the model and effects separately
Effects could come from a second function effects(model), or be stored in the model. This is rejected because it splits one decision across two places, and effects stored in the model must be cleared by hand after they run.
Returning either a model or a Next
Letting update return a bare model would spare the common case its tui.keep. This is rejected because update would then return two shapes, the loop would have to guess which one it got, and a model that happens to be a Next would be misread. One return shape, no ambiguity.
Lowercase helpers for effects and looks
Helpers such as tui.quit() and style.bold() beside tui.Quit() and style.Bold() are rejected. A field-less member is already constructed with a call, so the helpers would only be a second spelling of the same thing. tui.keep stays because it is not a spelling: it supplies the empty effect list.
An immediate-mode or widget-tree API
ncurses and tview work with a draw call per cell or a tree of widgets. This is rejected because it hides the state in the widgets, and a test must drive a fake screen to see anything.
A view tree or cell buffer
A tree or buffer, like Elm’s Html or Ratatui’s Buffer, is stronger for layout, since the renderer knows the structure. It is rejected for now: a string is what every other module already produces, and a tree brings a layout engine with it. A later proposal can add one beside strings.
A Style data type with one field per attribute
This is Lip Gloss’s Style. It is rejected because, without named construction (ZE-003, rejected), a ten-field positional constructor is unreadable. An array of Look values names only what it sets.
Open Questions
- Coalescing frames. The loop draws after every message. Draining whatever else is ready before drawing needs a
selectthat does not block, which ZE-025 lists as a possible later addition. Is that addition worth making for this use, or is line diffing enough? - Rebuilding models. Updating one field of a large
datamodel means passing every other field to its constructor again. Unions of small phases keep this in check in the examples. Is that enough, or does this deserve its own proposal? StreamandCall.Callis aStreamthat sends once. Keeping both makes the common case testable by calling a function. Is one effect with one test pattern better than two?- Priority. Input comes before task results. A held-down key could delay results, but never the reverse. Is that the right way round?
- Handing over the terminal. Running
$EDITORor a pager from a TUI means leaving raw mode and giving standard input to a child process. ZE-025 notes this needs the host stream to be quiesced. Shouldtuigain anExeceffect once a process module exists? - The components package. Is
widgetsthe right name, and should it live underzirric-langalongside the standard library’s repository?
Acknowledgements
The architecture is The Elm Architecture, by way of Charm’s Bubble Tea, whose split into model, update, view and commands this proposal keeps. Elm’s commands are also data interpreted by a runtime, but they are opaque; here they are an ordinary union.
Other prior art: