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

nordisk/facett

facett-demo/src/attract.rs

//! **Attract mode** — the demo's AUTO-DEMO / kiosk loop (TASK #82).
//!
//! When the demo sits untouched for [`IDLE_ARM_SECS`], it **arms** and auto-advances
//! through a fixed [`TOUR`] of the showcase tabs — System Map → Code Vault → Scene →
//! OSM 3D → OSM 2D → Graphs — dwelling [`DWELL_SECS`] on each and **looping forever**.
//! Any real user interaction (pointer move / click / key / a tab click) **cancels** it
//! immediately and resets the idle timer; it **re-arms** after another full idle window.
//! A subtle on-screen indicator says so while it runs.
//!
//! The clock is driven purely by the caller's per-frame `dt` (from `ctx.input(stable_dt)`)
//! and an `interacted` flag (also from `ctx.input`) — **no `Date::now`, no wall-clock**,
//! so it is wasm-safe AND runs on the SAME shared `DemoState::draw_app` path natively.
//! [`AttractMode::update`] is a pure state machine (deterministic in its inputs), so a
//! headless robot test drives the exact same code path the live UI runs.

/// Seconds of no user interaction before the auto-demo arms — both from load and to
/// **re-arm** after the user takes control.
pub const IDLE_ARM_SECS: f32 = 5.0;

/// Seconds the auto-demo dwells on each tab before advancing to the next.
pub const DWELL_SECS: f32 = 5.0;

/// The auto-demo tour, in order — deck tab **titles** (the `set_active_by_title` keys,
/// NOT the human labels). The loop advances through these and wraps forever:
/// System Map → Code Vault → Scene → OSM 3D → OSM 2D → Graphs.
pub const TOUR: &[&str] = &[
    "System Map", // 🫧 System Map (fluid)
    "codevault",  // Wreck-It-Ralph Code Vault
    "scene",      // the declarative Scene shell
    "OSM 3D",     // extruded 3D city
    "OSM 2D",     // slippy 2D OSM map
    "Graphs",     // the graph navigator (3-D rotating cloud)
];

/// What the caller should do this frame after ticking the clock with
/// [`AttractMode::update`].
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum AttractAction {
    /// Nothing to do this frame.
    None,
    /// Switch the deck to this tab title — the tour just armed or advanced.
    Switch(&'static str),
}

/// The auto-demo state machine. Pure + `Copy`: it holds only the idle/dwell clocks and
/// the tour cursor, and every transition is a function of `(dt, interacted)`.
#[derive(Clone, Copy, Debug)]
pub struct AttractMode {
    /// Seconds of accumulated idle (while NOT running) toward [`IDLE_ARM_SECS`].
    idle_secs: f32,
    /// Seconds dwelt on the current tour tab (while running) toward [`DWELL_SECS`].
    dwell_secs: f32,
    /// Whether the auto-demo is currently running (advancing tabs).
    running: bool,
    /// Cursor into [`TOUR`] of the tab currently shown (while running).
    tour_idx: usize,
    /// **Pinned**: the tour is off for the rest of the session and can never arm.
    /// Set by an explicit "show me THIS tab" request (the `$FACETT_DEMO_TAB` /
    /// `#<tab>` deep-link), which the kiosk tour must not steal back.
    pinned: bool,
}

impl Default for AttractMode {
    fn default() -> Self {
        Self { idle_secs: 0.0, dwell_secs: 0.0, running: false, tour_idx: 0, pinned: false }
    }
}

impl AttractMode {
    /// A fresh, disarmed attract-mode (idle clock at zero).
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Whether the auto-demo is currently running (drives the on-screen indicator).
    #[must_use]
    pub fn running(&self) -> bool {
        self.running
    }

    /// Seconds of idle accumulated so far (while disarmed) — observability.
    #[must_use]
    pub fn idle_secs(&self) -> f32 {
        self.idle_secs
    }

    /// Seconds dwelt on the current tour tab (while running) — observability.
    #[must_use]
    pub fn dwell_secs(&self) -> f32 {
        self.dwell_secs
    }

    /// The tour cursor (index into [`TOUR`]) of the tab currently shown.
    #[must_use]
    pub fn tour_index(&self) -> usize {
        self.tour_idx
    }

    /// The tab title the tour is currently showing, or `None` when not running.
    #[must_use]
    pub fn current_tab(&self) -> Option<&'static str> {
        self.running.then(|| TOUR[self.tour_idx % TOUR.len()])
    }

    /// **Pin** the deck: stop the tour and forbid it from ever arming again in this
    /// session. One-way on purpose — it answers an explicit "open THIS tab" request,
    /// and a request that a 5-second timer can undo is not a request.
    ///
    /// Without this the deep-link is only advisory: [`IDLE_ARM_SECS`] is 5.0, so a
    /// deep-linked tab is silently replaced by `TOUR[0]` five seconds after launch —
    /// long before a human (or a screenshotter waiting on a cold start) can look at
    /// it. Measured on oden: every attempt to show `GFX v2` came back showing
    /// `System Map`, `OSM 2D` or `scene`, and jiggling the pointer to keep the idle
    /// clock down is a race, not a fix.
    pub fn pin(&mut self) {
        self.pinned = true;
        self.running = false;
        self.idle_secs = 0.0;
        self.dwell_secs = 0.0;
    }

    /// Whether the tour has been pinned off — observability (`state_json`).
    #[must_use]
    pub fn pinned(&self) -> bool {
        self.pinned
    }

    /// Advance the clock by `dt` seconds given whether the user **interacted** this
    /// frame, returning the [`AttractAction`] the caller applies to the deck. Pure and
    /// deterministic (no wall-clock) so it is wasm-safe and headless-drivable.
    ///
    /// - **interacted** → cancel any run, reset the idle timer (re-arms after a fresh
    ///   [`IDLE_ARM_SECS`] window);
    /// - **idle ≥ [`IDLE_ARM_SECS`]** (disarmed) → **arm**: restart the tour at the top
    ///   (`System Map`) and switch to it;
    /// - **dwell ≥ [`DWELL_SECS`]** (running) → advance to the next tour tab, wrapping.
    pub fn update(&mut self, dt: f32, interacted: bool) -> AttractAction {
        // A backgrounded/paused tab can report a multi-second stall in one frame; clamp
        // so a single long frame can't jump several tabs at once.
        let dt = dt.clamp(0.0, 1.0);

        // PINNED: an explicit tab request outranks the kiosk tour, forever. Checked
        // before the idle clock so no amount of idling can arm it.
        if self.pinned {
            self.running = false;
            return AttractAction::None;
        }

        if interacted {
            // Any real interaction cancels the auto-demo AND resets the idle timer; it
            // re-arms only after another full idle window.
            self.running = false;
            self.idle_secs = 0.0;
            self.dwell_secs = 0.0;
            return AttractAction::None;
        }

        if !self.running {
            self.idle_secs += dt;
            if self.idle_secs >= IDLE_ARM_SECS {
                // ARM: (re)start the tour from the top and show its first tab.
                self.running = true;
                self.tour_idx = 0;
                self.dwell_secs = 0.0;
                self.idle_secs = 0.0;
                return AttractAction::Switch(TOUR[0]);
            }
            return AttractAction::None;
        }

        // Running: dwell on the current tab, then advance (looping) to the next.
        self.dwell_secs += dt;
        if self.dwell_secs >= DWELL_SECS {
            self.dwell_secs = 0.0;
            self.tour_idx = (self.tour_idx + 1) % TOUR.len();
            return AttractAction::Switch(TOUR[self.tour_idx]);
        }
        AttractAction::None
    }

    /// Register an external **take-control** event — a programmatic tab switch (the
    /// robot/control-channel equivalent of a human clicking a tab header). Cancels any
    /// run and resets the idle timer, exactly like a real interaction; the auto-demo
    /// re-arms only after a fresh [`IDLE_ARM_SECS`] window. The auto-demo's OWN advances
    /// go straight to the deck and never call this, so they don't self-cancel.
    pub fn interrupt(&mut self) {
        self.running = false;
        self.idle_secs = 0.0;
        self.dwell_secs = 0.0;
    }

    /// The observable state as JSON (folded into `DemoState::state_json` under
    /// `attract`), so a headless robot / agent reads the auto-demo status as DATA:
    /// whether it runs, the clocks, the tour cursor + current tab, and the tour itself.
    #[must_use]
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "running": self.running,
            // Pinned off by an explicit tab request. Without this key a pinned deck and
            // a merely-idle one look identical from outside (`running: false`,
            // `idle_secs: 0.0`), so a robot cannot tell "the tour is disabled" from
            // "the tour is about to arm".
            "pinned": self.pinned,
            "idle_secs": self.idle_secs,
            "dwell_secs": self.dwell_secs,
            "tour_index": self.tour_idx,
            "current_tab": self.current_tab(),
            "tour": TOUR,
            "idle_arm_secs": IDLE_ARM_SECS,
            "dwell_secs_each": DWELL_SECS,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Feed `secs` of idle (no interaction) in small steps, collecting every tab the
    /// tour switched to. Small steps mirror real per-frame `dt` and stay under the
    /// 1 s clamp in [`AttractMode::update`].
    fn idle(a: &mut AttractMode, secs: f32) -> Vec<&'static str> {
        let mut out = Vec::new();
        let step = 0.25;
        let n = (secs / step).round() as usize;
        for _ in 0..n {
            if let AttractAction::Switch(t) = a.update(step, false) {
                out.push(t);
            }
        }
        out
    }

    #[test]
    fn arms_after_idle_and_advances_in_order_then_loops() {
        let mut a = AttractMode::new();
        assert!(!a.running(), "disarmed on construction");

        // Just under the arm window: still disarmed, no switch.
        assert!(idle(&mut a, 4.5).is_empty(), "no arm before the idle window");
        assert!(!a.running());

        // Cross the arm window → arms on System Map.
        let sw = idle(&mut a, 1.0);
        assert_eq!(sw, ["System Map"], "arms on the first tour tab");
        assert!(a.running());
        assert_eq!(a.current_tab(), Some("System Map"));

        // Each further dwell window advances to the next tour tab, in order.
        assert_eq!(idle(&mut a, 5.0), ["codevault"]);
        assert_eq!(idle(&mut a, 5.0), ["scene"]);
        assert_eq!(idle(&mut a, 5.0), ["OSM 3D"]);
        assert_eq!(idle(&mut a, 5.0), ["OSM 2D"]);
        assert_eq!(idle(&mut a, 5.0), ["Graphs"]);
        // …and LOOPS back to the top.
        assert_eq!(idle(&mut a, 5.0), ["System Map"], "the tour loops forever");
    }

    #[test]
    fn interaction_cancels_and_re_arms() {
        let mut a = AttractMode::new();
        assert_eq!(idle(&mut a, 5.0), ["System Map"], "armed");
        assert!(a.running());

        // A real interaction stops it immediately and resets the idle timer.
        assert_eq!(a.update(0.016, true), AttractAction::None);
        assert!(!a.running(), "interaction cancels the auto-demo");
        assert_eq!(a.current_tab(), None);

        // It does NOT re-arm until a fresh full idle window elapses.
        assert!(idle(&mut a, 4.5).is_empty(), "no premature re-arm");
        assert!(!a.running());
        assert_eq!(idle(&mut a, 1.0), ["System Map"], "re-arms from the top after idle");
        assert!(a.running());
    }

    #[test]
    fn a_long_stall_frame_advances_at_most_one_tab() {
        let mut a = AttractMode::new();
        assert_eq!(idle(&mut a, 5.0), ["System Map"]);
        // A 10 s backgrounded frame is clamped to 1 s → cannot skip tabs.
        assert_eq!(a.update(10.0, false), AttractAction::None);
        assert_eq!(a.current_tab(), Some("System Map"));
    }
}