crates/nornir-saga/src/ui/viewer.rs
//! **The ELM viewer** — fold a [`UiMsg`] stream into a viewer [`ViewerModel`]
//! (cursor + motion trail + event log + current route/focus/scroll) and expose
//! it as `state_json` so it is **robot-testable**.
//!
//! This follows the constellation's `facett-core` `Elm` contract shape — one
//! serializable [`ViewerModel`], every input a [`ViewerMsg`], mutation ONLY in
//! [`Viewer::update`], and the observable state read back via
//! [`Viewer::state`] / [`Viewer::state_json`]. It deliberately does **not**
//! implement `facett_core::Elm` directly: that trait's `view(&self, &mut
//! egui::Ui)` binds egui, and this crate stays egui-free / lean (serde only).
//! The viewer's **render target is the web** ([`crate::ui::web`], feature
//! `web`) and the CLI, both of which paint purely from `state_json`. A native
//! `facett_core::Elm` impl (egui `view` + the headless harness) is a clean
//! follow-on behind an optional `facett` feature.
//!
//! Because `update` is a pure function of `(msg, current model)`, replay is
//! deterministic: the same saga folds to the same model sequence.
use serde::{Deserialize, Serialize};
use crate::ui::msg::UiMsg;
/// How many recent pointer positions the motion trail keeps.
pub const TRAIL_CAP: usize = 64;
/// How many recent event-log lines the viewer keeps.
pub const LOG_CAP: usize = 32;
/// A 2D point in viewport CSS pixels.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
pub struct Point {
pub x: f64,
pub y: f64,
}
/// Per-kind movement counters — a cheap, assertable summary of a session.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct Counts {
pub moves: u64,
pub clicks: u64,
pub scrolls: u64,
pub focuses: u64,
pub navs: u64,
pub edits: u64,
pub keys: u64,
pub notes: u64,
}
/// The complete observable state of the viewer — one serializable struct
/// (facett `Elm` FC-1/FC-3). Everything the live view / replay renders comes
/// from here, so `state_json` is the whole truth and the robot assertion surface.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ViewerModel {
/// Current cursor position.
pub cursor: Point,
/// Recent cursor positions (oldest → newest), capped at [`TRAIL_CAP`].
pub trail: Vec<Point>,
/// Current route / view.
pub route: String,
/// Currently focused target.
pub focus: String,
/// Current scroll offset.
pub scroll: Point,
/// The last field edit observed, as `target = value`.
pub last_edit: String,
/// Recent event-log lines (oldest → newest), capped at [`LOG_CAP`].
pub log: Vec<String>,
/// Per-kind counters.
pub counts: Counts,
/// Total movements applied — a monotonic frame counter.
pub applied: u64,
/// The `t_ms` of the most recently applied movement (for the web clock).
pub t_ms: u64,
}
/// Every input the viewer accepts (facett `Elm` FC-2) — the ONLY way to mutate
/// the model. Either a UI movement folded in, or a control message.
#[derive(Debug, Clone, PartialEq)]
pub enum ViewerMsg {
/// Fold one recorded movement into the model, at time `t_ms`.
Apply { t_ms: u64, msg: UiMsg },
/// Reset to the empty initial model (used by seek-to-start).
Reset,
}
/// Side work as data (facett `Elm` FC-8). The viewer does no I/O, so this is
/// uninhabited — the honest statement that folding a movement is pure.
#[derive(Debug, Clone)]
pub enum ViewerEffect {}
/// The ELM viewer. Holds the [`ViewerModel`]; mutation only via [`update`].
#[derive(Debug, Clone, Default)]
pub struct Viewer {
model: ViewerModel,
}
impl Viewer {
/// A fresh viewer with the empty initial model.
pub fn new() -> Viewer {
Viewer::default()
}
/// **FC-3** — read the complete observable state.
pub fn state(&self) -> &ViewerModel {
&self.model
}
/// **FC-2 / FC-8** — the single mutation path. Apply one [`ViewerMsg`];
/// returns the effects the host should run (always empty — no I/O).
pub fn update(&mut self, msg: ViewerMsg) -> Vec<ViewerEffect> {
match msg {
ViewerMsg::Reset => self.model = ViewerModel::default(),
ViewerMsg::Apply { t_ms, msg } => self.fold(t_ms, &msg),
}
Vec::new()
}
/// Convenience: apply a raw movement at `t_ms` (wraps [`ViewerMsg::Apply`]).
pub fn apply(&mut self, t_ms: u64, msg: &UiMsg) {
let _ = self.update(ViewerMsg::Apply { t_ms, msg: msg.clone() });
}
/// **FC-3 as JSON** — the observable state as a pretty JSON string. The
/// robot/web/CLI assertion + render surface.
pub fn state_json(&self) -> String {
serde_json::to_string(&self.model).unwrap_or_else(|_| "{}".to_string())
}
/// The whole fold, kept private so `update` is the only entry.
fn fold(&mut self, t_ms: u64, msg: &UiMsg) {
let m = &mut self.model;
m.applied += 1;
m.t_ms = t_ms;
match msg {
UiMsg::PointerMove { x, y } => {
m.cursor = Point { x: *x, y: *y };
push_trail(&mut m.trail, m.cursor);
m.counts.moves += 1;
}
UiMsg::Click { x, y, target } => {
m.cursor = Point { x: *x, y: *y };
push_trail(&mut m.trail, m.cursor);
m.counts.clicks += 1;
push_log(&mut m.log, format!("click {target} @ ({x:.0},{y:.0})"));
}
UiMsg::Scroll { x, y } => {
m.scroll = Point { x: *x, y: *y };
m.counts.scrolls += 1;
}
UiMsg::Focus { target } => {
m.focus = target.clone();
m.counts.focuses += 1;
push_log(&mut m.log, format!("focus {target}"));
}
UiMsg::Nav { route } => {
m.route = route.clone();
m.counts.navs += 1;
push_log(&mut m.log, format!("nav → {route}"));
}
UiMsg::FieldEdit { target, value } => {
m.last_edit = format!("{target} = {value}");
m.counts.edits += 1;
push_log(&mut m.log, format!("edit {target} = {value}"));
}
UiMsg::Key { key } => {
m.counts.keys += 1;
push_log(&mut m.log, format!("key {key}"));
}
UiMsg::Note { text } => {
m.counts.notes += 1;
push_log(&mut m.log, format!("· {text}"));
}
}
}
}
fn push_trail(trail: &mut Vec<Point>, p: Point) {
trail.push(p);
if trail.len() > TRAIL_CAP {
let overflow = trail.len() - TRAIL_CAP;
trail.drain(0..overflow);
}
}
fn push_log(log: &mut Vec<String>, line: String) {
log.push(line);
if log.len() > LOG_CAP {
let overflow = log.len() - LOG_CAP;
log.drain(0..overflow);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fold_updates_cursor_route_and_counts() {
let mut v = Viewer::new();
v.apply(0, &UiMsg::nav("/checkout"));
v.apply(10, &UiMsg::pointer_move(100.0, 50.0));
v.apply(20, &UiMsg::click(100.0, 50.0, "#pay"));
v.apply(30, &UiMsg::field_edit("#card", "4111"));
let s = v.state();
assert_eq!(s.route, "/checkout");
assert_eq!(s.cursor, Point { x: 100.0, y: 50.0 });
assert_eq!(s.counts.navs, 1);
assert_eq!(s.counts.moves, 1);
assert_eq!(s.counts.clicks, 1);
assert_eq!(s.counts.edits, 1);
assert_eq!(s.applied, 4);
assert_eq!(s.t_ms, 30);
assert_eq!(s.last_edit, "#card = 4111");
assert_eq!(s.trail.len(), 2, "move + click both extend the trail");
}
#[test]
fn state_json_is_valid_and_carries_the_model() {
let mut v = Viewer::new();
v.apply(5, &UiMsg::nav("/home"));
let json = v.state_json();
let val: serde_json::Value = serde_json::from_str(&json).expect("valid json");
assert_eq!(val["route"], "/home");
assert_eq!(val["counts"]["navs"], 1);
}
#[test]
fn trail_is_capped() {
let mut v = Viewer::new();
for i in 0..(TRAIL_CAP as u64 + 20) {
v.apply(i, &UiMsg::pointer_move(i as f64, 0.0));
}
assert_eq!(v.state().trail.len(), TRAIL_CAP, "trail capped");
// it keeps the NEWEST points (the last x is the most recent).
let last = v.state().trail.last().unwrap();
assert_eq!(last.x, (TRAIL_CAP as f64 + 19.0));
}
#[test]
fn reset_clears_the_model() {
let mut v = Viewer::new();
v.apply(0, &UiMsg::nav("/x"));
let _ = v.update(ViewerMsg::Reset);
assert_eq!(*v.state(), ViewerModel::default());
}
}