Skip to main content

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​

FactoryExample
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:

  1. cancels the CancellationToken (all subscriptions/async commands wake up and observe cancellation on their next check);
  2. invokes model.on_quit();
  3. restores terminal state (alt-screen leave, raw mode off, cursor show);
  4. 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.