Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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());
    }
}