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