The Elm pattern in Verum
Verum's TUI framework is built around The Elm Architecture (TEA): a pure state-transition function, a pure view function, and a reified description of the effects the runtime should perform. TEA is not just a stylistic choice — it is what lets the framework give you structured concurrency, deterministic tests, and safe cancellation for free.
The Model protocol
public type Model is protocol {
type Msg;
fn init(&self) -> Command<Self.Msg> { none() }
fn update(&mut self, msg: Self.Msg) -> Command<Self.Msg>;
fn view(&self, frame: &mut Frame);
fn handle_event(&self, event: Event) -> Maybe<Self.Msg> { None }
fn subscriptions(&self) -> Subscription<Self.Msg> { Subscription.None }
fn on_quit(&mut self) {}
};
Only update and view are mandatory. The others have sensible defaults.
init
Returns the first Command to dispatch at startup. Use it for loading
persisted state, priming caches, or registering a one-shot greeter tick.
update
Must be pure. Given a Msg, update self and return a Command
describing what the runtime should do next. Don't perform I/O here;
reify it as Command.Async(future) or Command.Tick(duration, msg).
view
Must be pure and deterministic in self. Called at most once per
frame; no mutation outside the provided Frame.
handle_event
Maps a low-level Event into a high-level Msg. Return None to ignore
the event. This method is also the place to filter out events you don't
care about — e.g. returning None for Event.Mouse(_) when your app is
keyboard-only.
subscriptions
Declaratively describes message sources the runtime should keep alive for
you: timers, streams, signal handlers. Each returned Subscription is
hoisted to a detached task that observes the global CancellationToken.
on_quit
Invoked after the event loop exits and before the terminal is restored. Use it to flush state, log a final summary, etc.
Commands — out-going effects
public type Command<Msg> is
| Noop
| Perform(fn() -> Msg) // sync thunk
| Async(Heap<dyn Future<Output = Msg>>) // async future
| Batch(List<Command<Msg>>) // parallel fan-out
| Sequence(List<Command<Msg>>) // serial chain
| Tick(Duration, fn() -> Msg) // delayed thunk
| Quit; // exit runtime
Builders
| Factory | Example |
|---|---|
none() | none() |
perform(thunk) | `perform( |
task(fut) | task(async { fetch(url).await }) |
batch([a, b]) | kicks both in parallel |
sequence([a, b]) | a completes before b starts |
| `tick(Duration.from_millis(500), | |
quit() | graceful shutdown |
Combinators
Command provides an applicative-style DSL:
let c = task(load_profile())
.and(task(load_friends())) // fan-out
.then(perform(|| Msg.Ready)); // serial
and absorbs Noop and flattens nested Batches; then flattens nested
Sequences. Both preserve ordering semantics.
Subscriptions — incoming streams
public type Subscription<Msg> is
| None
| Interval(Duration, fn() -> Msg)
| Every(Duration, fn(Instant) -> Msg)
| Once(Duration, fn() -> Msg)
| StreamSub(Heap<dyn Stream<Item = Msg>>)
| Batch(List<Subscription<Msg>>);
Typical patterns:
// Fire a Tick every 60 ms for animations.
interval(Duration.from_millis(60), || Msg.AnimationTick) | |
// Combine a clock and a filesystem watcher. | |
sub_batch([ | |
every(Duration.from_secs(1), |t| Msg.Clock(t)), | |
sub_from_stream(Heap(fs_watcher("./"))), | |
]) |
Execution model
Internally, run(model) does block_on(run_async(model)). The async version
creates a CancellationTokenSource, an unbounded MPSC channel of Msg,
spawns the initial command on the runtime, and enters the event loop:
Drain is bounded per tick (default 64 messages) to prevent a message flood from starving render. Frame budget defaults to 16 ms (≈ 60 FPS).
On exit — Command.Quit, Ctrl+C, or a view that raised — the runtime:
- cancels the
CancellationToken(all subscriptions/async commands wake up and observe cancellation on their next check); - invokes
model.on_quit(); - restores terminal state (alt-screen leave, raw mode off, cursor show);
- returns the loop's
IoResult<()>to the caller.
Testing update
Because update is pure, unit-testing is trivial:
@test
fn increments_counter() {
let mut m = CounterModel { counter: 0 };
let cmd = m.update(Msg.Increment);
assert_eq(m.counter, 1);
assert(cmd.is_noop());
}
The view function is equally testable against a mock Frame/Buffer.
Snapshot testing compares the resulting Buffer.to_lines() to a checked-in
expected output — see the testing guide.