nornir-saga UI-movement — idiot guide (HOW-TO)
Capture UI movements from your Elm app in dev, then watch/replay them. The capture is compiled out of your release build — it costs nothing shipped.
A. Capture a saga from your app (dev only)
- Add nornir-saga as an OPTIONAL dep behind your own
sagafeature (so a release build without the feature pulls nothing):
# your app's Cargo.toml
[dependencies]
nornir-saga = { version = "0.1", optional = true, features = ["saga"] }
[features]
saga = ["dep:nornir-saga"]
- Tell nornir-saga how your Elm
Msgmaps to aUiMsg— implementSagaMsgonce on your app's message type. ReturnNonefor messages that aren't UI movements (ticks, data loads):
use nornir_saga::ui::{SagaMsg, UiMsg};
impl SagaMsg for AppMsg {
fn to_ui_msg(&self) -> Option<UiMsg> {
match self {
AppMsg::Pointer(x, y) => Some(UiMsg::pointer_move(*x, *y)),
AppMsg::Click(x, y, el) => Some(UiMsg::click(*x, *y, el)),
AppMsg::Navigate(route) => Some(UiMsg::nav(route)),
AppMsg::Focus(el) => Some(UiMsg::focus(el)),
AppMsg::Edit(el, v) => Some(UiMsg::field_edit(el, v)),
_ => None,
}
}
}
- Add ONE tap line at your Elm
updatechoke point — the single place everyMsgflows through:
fn update(&mut self, msg: AppMsg) -> Vec<Effect> {
nornir_saga::ui::tap(&msg); // no-op unless built with `--features saga`
// … your real update …
}
One tap covers a lone facett widget AND a whole app — same hook, because both share the Elm update pipeline.
- Run with capture, then save the saga:
cargo run --features saga # dev: capture is live
// at session end (or on a cadence):
let saga = nornir_saga::ui::drain_saga("my checkout session");
std::fs::write("session.json", saga.to_json())?;
- Ship clean — capture is gone:
cargo build --release # no `saga` feature → tap() is a no-op,
# nothing captured, no extra deps compiled
A′. Stream LIVE (or capture explicitly) via the emit seam
The global tap() above is the zero-config path (one process-global buffer, drained with drain_saga). When you want to stream movements live to a viewer, or hold your own capture buffer, use the pluggable emit seam — Emitter<S> over a UiSink:
| Sink | Use |
VecSink |
in-process capture; .into_saga(title) to persist/replay |
ChannelSink |
LIVE — sends each movement down an mpsc; pair the receiver with a ChannelSource feeding a viewer on another thread |
NullSink |
drop everything (a runtime "capture off") |
use nornir_saga::ui::{Emitter, ChannelSink, ChannelSource, Viewer};
// Host side: tap the SAME app Msg through an Emitter into a live channel.
let (tx, rx) = std::sync::mpsc::channel();
let mut emit = Emitter::new(ChannelSink::new(tx));
fn update(&mut self, msg: AppMsg, emit: &mut Emitter<ChannelSink>) {
emit.tap(&msg); // project (SagaMsg) + stamp t_ms + emit; no-op-shaped
// … real update …
}
// emit.close() ends the stream when the session finishes.
// Viewer side (another thread): fold the live stream as it arrives.
let mut src = ChannelSource::new(rx);
let mut v = Viewer::new();
while let Some(ev) = src.next() { v.apply(ev.t_ms, &ev.msg); } // renders live
Emitter also has emit(UiMsg), emit_at(t_ms, UiMsg) (deterministic, no clock) and emit_at_with_state(t_ms, UiMsg, state_json) (carry the model snapshot you observed, for inject-assert replay).
Prove it with the built-in wire-against fixture (no Njord, no browser)
nornir_saga::ui ships a synthetic saga-enabled ELM app so you can see the whole tap/emit → capture → persist → replay loop end to end in a test:
use nornir_saga::ui::{wire_synthetic, wired_saga, VecSink, ChannelSink, Player};
// capture: drive the scripted app through the emit seam into a VecSink → Saga
let saga = wired_saga("demo session"); // = wire_synthetic(VecSink::new()).into_saga(..)
let last = Player::new(saga).frames().pop().unwrap();
// each event carries the app's observed state_json → replay asserts it matches:
assert_eq!(last.state_json, last.expect_state.unwrap());
// LIVE: same app, streamed through a ChannelSink to a viewer thread
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || { wire_synthetic(ChannelSink::new(tx)); });
// … fold rx via ChannelSource on this thread …
See crates/nornir-saga/tests/wire_against.rs for the full end-to-end assertions.
B. Watch / replay a saga (nornir CLI)
No app of your own? Use the built-in synthetic saga with --demo.
# Final folded model (the whole observable ViewerModel as state_json):
nornir saga view session.json
nornir saga view --demo
# The full deterministic frame sequence (one state_json per movement):
nornir saga view session.json --frames
# Replay: print the deterministic frames (JSONL), time-scaled:
nornir saga replay session.json --speed 2
# Replay in the browser — a self-contained page that actually MOVES
# (cursor + trail + route + log, live over SSE). Needs the web feature:
cargo build --features saga-web
nornir saga replay --demo --web # serves http://127.0.0.1:8971
# Drive the movements at a REAL page via the WebReplayDriver seam.
# Without the drive feature this prints the gestures it WOULD dispatch (mock):
nornir saga replay --demo --drive http://localhost:8080
# With a real browser (needs a running geckodriver/chromedriver):
cargo build --features saga-drive
nornir saga replay session.json --drive http://localhost:8080 \
--webdriver http://localhost:4444
C. Cheat sheet
| Want | Do |
| Capture in dev | app dep nornir-saga optional + saga feature; tap(&msg) at update; cargo run --features saga |
| Ship without capture | cargo build --release (no saga feature) — tap is a no-op, zero deps |
| See the model | nornir saga view <saga.json> (or --demo) |
| Replay frames | nornir saga replay <saga.json> [--speed N] |
| Replay in a browser | build --features saga-web; nornir saga replay … --web |
| Move a real page | --drive <url> (mock), or build --features saga-drive + --webdriver <endpoint> for a real browser |
The contract, the ELM-dispatch tap, and the design rationale are in .nornir/saga-design.md; the original feature request is in .nornir/nornir-saga.md.