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/capture.rs

//! **The one tap point** — capture a saga from an Elm app's `Msg`-dispatch
//! choke point, DEV-ONLY, gone in a normal release build.
//!
//! ## ELM is the enabling invariant
//! Both a lone facett-core `Elm` component and a whole Njord-alike app route
//! every input through the *same* `Msg → update → view` pipeline. So a saga is
//! captured at **one** place — the `update` dispatch — and the same hook works
//! for a single widget and a full app, identically. There is no per-widget
//! wiring: an app enables capture once by tapping its dispatch.
//!
//! ## DEV-ONLY — stripped from release (feature `saga`)
//! Capture is gated behind the off-by-default **`saga`** feature exactly like
//! [`crate::record`]:
//!
//! * `saga` ON  → [`tap`] maps a `Msg` to a [`UiMsg`] (via [`SagaMsg`]), stamps
//!   it with a monotonic `t_ms`, and buffers it; [`drain_saga`] takes the buffer
//!   as a [`Saga`].
//! * `saga` OFF → [`tap`] / [`capture`] are `#[inline]` no-ops (**zero code, zero
//!   deps** — capture is pure `std`, so a release build carrying no `saga`
//!   feature pulls nothing extra and compiles the hook away).
//!
//! An app therefore takes `nornir-saga` as an **optional dependency behind its
//! own off-by-default `saga` feature** and adds ONE line at its `update` choke
//! point; `cargo run --features saga` captures, `cargo build --release` ships
//! clean. See `.nornir/saga-idiot-guide.md`.
//!
//! ```ignore
//! // The app's Elm Msg → UiMsg projection (only the movements worth a saga).
//! impl nornir_saga::ui::SagaMsg for AppMsg {
//!     fn to_ui_msg(&self) -> Option<nornir_saga::ui::UiMsg> {
//!         match self {
//!             AppMsg::Pointer(x, y) => Some(UiMsg::pointer_move(*x, *y)),
//!             AppMsg::Navigate(r)   => Some(UiMsg::nav(r)),
//!             _ => None,
//!         }
//!     }
//! }
//!
//! // The ONE tap point — first line of the Elm `update`:
//! fn update(&mut self, msg: AppMsg) -> Vec<Effect> {
//!     nornir_saga::ui::tap(&msg);   // no-op unless built with `--features saga`
//!     /* … the real update … */
//! }
//! ```

use crate::ui::msg::UiMsg;

/// An app's Elm `Msg` projects to a [`UiMsg`] for the saga. Return `None` for
/// messages that are not UI movements (internal ticks, data loads, …) so the
/// saga stays a clean movement stream. Implement this once on the app's `Msg`.
pub trait SagaMsg {
    /// The UI movement this message represents, if any.
    fn to_ui_msg(&self) -> Option<UiMsg>;
}

// A `UiMsg` is trivially its own projection — lets a component that already
// speaks `UiMsg` tap directly.
impl SagaMsg for UiMsg {
    fn to_ui_msg(&self) -> Option<UiMsg> {
        Some(self.clone())
    }
}

// ─── feature ON: stamp (monotonic t_ms) + buffer ────────────────────────────

#[cfg(feature = "saga")]
mod cap {
    use crate::ui::msg::{Saga, TimedUiMsg, UiMsg};
    use std::sync::{Mutex, OnceLock};
    use std::time::Instant;

    struct State {
        start: Instant,
        events: Vec<TimedUiMsg>,
    }

    fn state() -> &'static Mutex<State> {
        static S: OnceLock<Mutex<State>> = OnceLock::new();
        S.get_or_init(|| Mutex::new(State { start: Instant::now(), events: Vec::new() }))
    }

    /// Stamp `msg` with ms-since-first-capture and buffer it. Lock poisoning is
    /// recovered from — a panic mid-capture must never wedge an app.
    pub fn capture(msg: UiMsg) {
        let mut s = state().lock().unwrap_or_else(|p| p.into_inner());
        // The first captured event anchors t=0.
        if s.events.is_empty() {
            s.start = Instant::now();
        }
        let t_ms = s.start.elapsed().as_millis() as u64;
        s.events.push(TimedUiMsg { t_ms, msg, state_json: None });
    }

    /// Take + clear the captured movements as a [`Saga`].
    pub fn drain(title: &str) -> Saga {
        let mut s = state().lock().unwrap_or_else(|p| p.into_inner());
        let events = std::mem::take(&mut s.events);
        Saga { version: Saga::VERSION, title: title.to_string(), events }
    }
}

/// Capture one [`UiMsg`] into the process-global saga buffer.
///
/// `saga` ON → stamps + buffers; `saga` OFF → `#[inline]` no-op (compiled away).
/// Prefer [`tap`] at the Elm dispatch choke point; call this directly only when
/// a component already speaks [`UiMsg`].
#[cfg(feature = "saga")]
pub fn capture(msg: UiMsg) {
    cap::capture(msg);
}

/// No-op when `saga` is OFF — the movement is dropped, nothing compiled in.
#[cfg(not(feature = "saga"))]
#[inline]
pub fn capture(_msg: UiMsg) {}

/// **The tap.** Call this at the Elm `update` choke point with the incoming
/// `Msg`; it projects via [`SagaMsg::to_ui_msg`] and captures the movement.
///
/// `saga` ON → captures when the projection is `Some`; `saga` OFF → `#[inline]`
/// no-op (the `to_ui_msg` call is stripped too, so a release build pays nothing).
#[cfg(feature = "saga")]
pub fn tap<M: SagaMsg>(msg: &M) {
    if let Some(ui) = msg.to_ui_msg() {
        capture(ui);
    }
}

/// No-op tap when `saga` is OFF.
#[cfg(not(feature = "saga"))]
#[inline]
pub fn tap<M: SagaMsg>(_msg: &M) {}

/// Drain (take + clear) the captured movements as a titled [`Saga`]. An app
/// calls this at session end (or on a cadence) to persist / view / replay.
/// `saga` OFF → always an empty saga (no global compiled in).
#[cfg(feature = "saga")]
pub fn drain_saga(title: &str) -> crate::ui::msg::Saga {
    cap::drain(title)
}

/// No-op drain when `saga` is OFF: always an empty saga.
#[cfg(not(feature = "saga"))]
#[inline]
pub fn drain_saga(title: &str) -> crate::ui::msg::Saga {
    crate::ui::msg::Saga::new(title)
}

#[cfg(all(test, feature = "saga"))]
mod tests {
    use super::*;

    // Serialize capture tests against the process-global buffer.
    fn lock() -> std::sync::MutexGuard<'static, ()> {
        use std::sync::{Mutex, OnceLock};
        static L: OnceLock<Mutex<()>> = OnceLock::new();
        L.get_or_init(|| Mutex::new(())).lock().unwrap_or_else(|p| p.into_inner())
    }

    // A tiny app Msg to prove the ONE tap works for an arbitrary Elm Msg.
    #[derive(Clone)]
    enum AppMsg {
        Move(f64, f64),
        Go(&'static str),
        Tick, // not a movement → filtered out
    }
    impl SagaMsg for AppMsg {
        fn to_ui_msg(&self) -> Option<UiMsg> {
            match self {
                AppMsg::Move(x, y) => Some(UiMsg::pointer_move(*x, *y)),
                AppMsg::Go(r) => Some(UiMsg::nav(r)),
                AppMsg::Tick => None,
            }
        }
    }

    #[test]
    fn tap_projects_and_filters_then_drains_a_saga() {
        let _g = lock();
        let _ = drain_saga("reset"); // start clean

        tap(&AppMsg::Go("/home"));
        tap(&AppMsg::Tick); // filtered — not a movement
        tap(&AppMsg::Move(12.0, 34.0));

        let saga = drain_saga("njord session");
        assert_eq!(saga.title, "njord session");
        assert_eq!(saga.len(), 2, "the non-movement Tick was filtered out");
        assert!(matches!(saga.events[0].msg, UiMsg::Nav { .. }));
        assert!(matches!(saga.events[1].msg, UiMsg::PointerMove { .. }));
        // t_ms is non-decreasing (monotonic clock).
        assert!(saga.events[1].t_ms >= saga.events[0].t_ms);
        // drained → empty.
        assert!(drain_saga("x").is_empty(), "drain clears the buffer");
    }
}