A minimal TUI
Verum's term module follows the Elm architecture: a Model
holds state, update(msg) returns a new model, view renders
the model as a frame.
The whole program
mount core.term.*;
type Counter is { count: Int };
type Msg is
| Increment
| Decrement
| Reset
| Quit;
implement Counter {
fn new() -> Counter { Counter { count: 0 } }
}
implement Model for Counter {
type Message = Msg;
fn update(&mut self, msg: Msg) -> Command<Msg> {
match msg {
Msg.Increment => { self.count += 1; none() }
Msg.Decrement => { self.count -= 1; none() }
Msg.Reset => { self.count = 0; none() }
Msg.Quit => Command.exit(),
}
}
fn view(&self, f: &mut Frame) {
let area = f.area();
let color = if self.count >= 0 { Color.Green } else { Color.Red };
// `new()` takes no text — `text(List<Line>)` is the builder step.
Paragraph.new().text([Line.raw(&f"Count: {self.count}")])
.block(Block.new()
.title(&" counter ")
.borders(Borders.All)
.border_style(Style.new().fg(Color.Cyan)))
.alignment(Alignment.Centre)
.style(Style.new().fg(color).bold())
.render(f, area);
}
// Terminal input arrives through `handle_event`, not through a
// subscription: `Subscription` carries timers and streams
// (`interval`, `every`, `once`, `sub_from_stream`, `sub_batch`) and
// has no event variant. The protocol method is
// `fn handle_event(&self, event: Event) -> Maybe<Self.Msg>`, and
// returning `Maybe.None` means "not for me".
fn handle_event(&self, e: Event) -> Maybe<Msg> {
match e {
Event.Key(k) => match k.code {
KeyCode.Char('+') | KeyCode.Up => Maybe.Some(Msg.Increment),
KeyCode.Char('-') | KeyCode.Down => Maybe.Some(Msg.Decrement),
KeyCode.Char('r') | KeyCode.Char('R') => Maybe.Some(Msg.Reset),
KeyCode.Char('q') | KeyCode.Escape => Maybe.Some(Msg.Quit),
_ => Maybe.None,
},
_ => Maybe.None,
}
}
}
async fn main() {
run(Counter.new()).await.expect("tui");
}
Run:
$ verum run --release
You see:
╭──────────── counter ────────────╮
│ │
│ Count: 0 │
│ │
╰─────────────────────────────────╯
Press Up / Down (or + / -) to change the counter, R
to reset, Q / Esc to quit.
The three pieces
1. Model + Message. The Counter record + Msg variants
enumerate every possible transition.
2. update. Pure function: old state + message → new state +
Command (side effect). none() = no side effect.
Command.exit() = quit the app.
3. view. Pure function: state → drawn Frame. Widgets are
stacked/laid-out using the layout sub-module.
4. subscriptions. Event sources mapped to messages. Most TUIs
just subscribe to key events; you can also subscribe to timers
(interval(1.secs(), |_| Msg.Tick)) or streams.
Layering widgets
fn view(&self, f: &mut Frame) {
let area = f.area();
let layout = Flex.new(Direction.Vertical)
.constraints(&[
LayoutConstraint.Length(3), // header
Constraint.Fill, // body
LayoutConstraint.Length(1), // status
])
.split(area);
render_header(f, layout[0]);
render_body(f, layout[1], self);
render_status(f, layout[2], self);
}
Scrollable list
type AppState is { items: List<Text>, list_state: ListState };
implement Model for AppState {
type Message = Msg;
fn view(&self, f: &mut Frame) {
SelectableList.new(&self.items)
.block(Block.new().title(&"Files").borders(Borders.All))
.highlight_style(Style.new().reversed())
.highlight_symbol(&">> ")
.render(f.area(), f.buffer, &mut self.list_state.clone());
}
fn update(&mut self, msg: Msg) -> Command<Msg> {
match msg {
Msg.Up => self.list_state.previous(self.items.len()),
Msg.Down => self.list_state.next(self.items.len()),
_ => (),
}
none()
}
// ... subscriptions ...
}
Async commands
Trigger work from update:
Msg.Load(path) => {
let path = path.clone();
task(async move {
match fs.read_to_string_async(&path).await {
Result.Ok(text) => Msg.Loaded(text),
Result.Err(e) => Msg.Error(e.to_string()),
}
})
}
task(async { … }) spawns the async work; when it
completes, its result is delivered back to update as a message.
Text input
type AppState is { input: TextInputState };
fn view(&self, f: &mut Frame) {
TextInput.new()
.placeholder(&"type a name")
.render(area, f.buffer, &mut self.input.clone());
}
// Every event, forwarded verbatim. `handle_event` is the protocol
// method for terminal input; `subscriptions` carries timers and
// streams and returns ONE `Subscription`, not a list.
fn handle_event(&self, e: Event) -> Maybe<Msg> {
Maybe.Some(Msg.InputEvent(e))
}
fn update(&mut self, msg: Msg) -> Command<Msg> {
match msg {
Msg.InputEvent(e) => {
self.input.handle_event(&e);
if e.is_enter() {
// ... submit self.input.buffer ...
self.input.clear();
}
none()
}
_ => none()
}
}
Colour themes
let theme = Theme.dark(); // or Theme.light()
// or follow the terminal's reported background colour
let theme = Theme.auto_detect(Maybe.Some(Rgb.new(12, 12, 16)));
// A theme is a record of Style fields; `role` looks one up by name.
// Roles: surface, surface_dim, on_surface, primary, on_primary,
// secondary, on_secondary, error, warning, success, info, border,
// border_focused, divider, text, text_dim, text_muted,
// text_highlight, selection, cursor. An unknown name yields Style.new().
Paragraph.new().text(lines)
.style(theme.role(&"text"))
.render(f, area);
// A custom theme is a record literal — every role is named explicitly.
// Verum has no record-update shorthand: start from a built-in theme and
// rebind the fields you want to change.
let base = Theme.dark();
let branded = Theme {
primary: Style.new().fg(Color.TrueColor(Rgb.new(255, 140, 0))),
text: base.text,
surface: base.surface,
// ... the remaining roles, each taken from `base`
};
See also
- term — 7-layer TUI framework with every widget.
- Language → async & concurrency
— what
Command.taskandSubscriptioncompose over.