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

nordisk/edda

crates/nornir-robotui/src/mode.rs

//! **Mode selection** — the seam that lets ONE `Scenario` run two ways:
//! [`RunMode::Headless`] (the off-screen `egui_kittest` drive that fills the
//! matrix, for CI) or [`RunMode::Watch`] (a REAL, visible eframe window that
//! replays the same steps paced so a human can watch the robot move).
//!
//! Everything here is **feature-free** on purpose: the enum, the env toggle, the
//! [`Targets`] rect registry and the [`WatchConfig`] all compile in the lean
//! build. Only the actual window-opening backend ([`crate::watch`]) is behind the
//! `watch` feature — so a consumer can write ONE `draw(ui, app, targets)` closure
//! + ONE scenario and pick the mode, whether or not `watch` is compiled in.

use std::collections::HashMap;
use std::time::Duration;

/// Which backend replays a [`Scenario`](crate::Scenario).
///
/// Chosen three ways (all land here): an **API param** to
/// [`run_scenario_mode`](crate::run_scenario_mode); the **env toggle**
/// `ROBOTUI_WATCH=1` via [`RunMode::from_env`]; or the **CLI** subcommand
/// (`robotui watch` → [`Watch`](RunMode::Watch), `robotui run` →
/// [`Headless`](RunMode::Headless)).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum RunMode {
    /// Off-screen `egui_kittest`/AccessKit drive → [`TestResultRow`](crate::TestResultRow)s.
    /// The default; the CI path; needs no windowing stack.
    #[default]
    Headless,
    /// A real visible eframe window that replays the scenario step-by-step with a
    /// pacing delay. Needs the `watch` feature (else
    /// [`run_scenario_mode`](crate::run_scenario_mode) returns a clear error).
    Watch,
}

impl RunMode {
    /// Read the `ROBOTUI_WATCH` env toggle: `1`/`true`/`watch`/`yes` (any case) →
    /// [`Watch`](RunMode::Watch); anything else (incl. unset) →
    /// [`Headless`](RunMode::Headless). The env seam a consumer's own test binary
    /// flips to watch a run without changing code.
    pub fn from_env() -> Self {
        match std::env::var("ROBOTUI_WATCH") {
            Ok(v) => match v.trim().to_ascii_lowercase().as_str() {
                "1" | "true" | "watch" | "yes" | "on" => RunMode::Watch,
                _ => RunMode::Headless,
            },
            Err(_) => RunMode::Headless,
        }
    }

    /// True for [`Watch`](RunMode::Watch).
    pub fn is_watch(self) -> bool {
        matches!(self, RunMode::Watch)
    }
}

/// A per-frame **label → on-screen [`egui::Rect`]** registry — the watch backend's
/// **fallback** side-channel for resolving an [`Action`](crate::Action)'s target
/// label to a pixel position to move the cursor to and click.
///
/// As of 0.2.6 the watch backend resolves labels through the **live egui AccessKit
/// tree first** (the same tree the headless path drives), so a plainly-labelled
/// widget needs NO `Targets` recording to be clickable in the visible window. This
/// registry is only consulted when AccessKit has no match — record a rect here for
/// a bespoke, non-AccessKit surface the robot must still reach.
///
/// When you do record, the consumer's `draw` closure notes the widget exactly like
/// linking a label with `.labelled_by(..)` makes a field addressable in the
/// headless path:
///
/// ```no_run
/// # use nornir_robotui::Targets;
/// # fn demo(ui: &mut egui::Ui, name: &mut String, t: &mut Targets) {
/// if t.record("Bump", ui.button("Bump")).clicked() { /* … */ }
/// let r = ui.text_edit_singleline(name);
/// t.record("Name", r);
/// # }
/// ```
///
/// In **headless** mode the registry is ignored (kittest resolves labels via the
/// AccessKit tree), so the same closure works untouched in both modes.
#[derive(Default)]
pub struct Targets {
    map: HashMap<String, egui::Rect>,
}

impl Targets {
    /// Forget last frame's rects (the watch driver calls this before each redraw).
    pub fn clear(&mut self) {
        self.map.clear();
    }

    /// Record a widget's [`Response`](egui::Response) under `label` and hand the
    /// response straight back, so it drops into an `if …clicked()` unchanged.
    pub fn record(&mut self, label: impl Into<String>, response: egui::Response) -> egui::Response {
        self.map.insert(label.into(), response.rect);
        response
    }

    /// Record a bare rect under `label` (when you have the rect but not a
    /// [`Response`](egui::Response) to give back).
    pub fn record_rect(&mut self, label: impl Into<String>, rect: egui::Rect) {
        self.map.insert(label.into(), rect);
    }

    /// The recorded rect for `label`, if the closure registered one this frame.
    pub fn rect(&self, label: &str) -> Option<egui::Rect> {
        self.map.get(label).copied()
    }

    /// Whether any widget is registered under `label` (the watch-mode analogue of
    /// `label_visible`).
    pub fn has(&self, label: &str) -> bool {
        self.map.contains_key(label)
    }

    /// How many widgets were registered this frame (0 ⇒ the closure drew nothing
    /// targetable — the watch-mode `Drew` signal).
    pub fn len(&self) -> usize {
        self.map.len()
    }

    /// True when no widget was registered this frame.
    pub fn is_empty(&self) -> bool {
        self.map.is_empty()
    }
}

/// Which GPU backend the [`Watch`](RunMode::Watch) window uses.
///
/// **There is only WebGPU.** LAW #4 — modern platforms only, no WebGL/glow. This enum
/// used to carry a `Glow` variant and `Auto` used to mean "wgpu if the `watch-wgpu`
/// feature is compiled, else glow", which meant the live watch window silently painted
/// on OpenGL and every GPU pane (korp's Map wgpu vector basemap, facett's 3D) degraded
/// to the CPU painter without saying so. `Glow` is **removed** in 0.3 rather than
/// deprecated: a variant that cannot be honoured is worse than no variant.
///
/// **Feature-free** on purpose (it lives in the lean `mode` module); the actual
/// `eframe::Renderer` selection happens in [`crate::watch::run`], which only exists
/// under the `watch` feature.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum WatchRenderer {
    /// WebGPU. The default, and the only backend.
    #[default]
    Wgpu,
}

impl WatchRenderer {
    /// Read the `ROBOTUI_WATCH_RENDERER` env toggle. `wgpu`/`gpu`/`auto`/empty all
    /// select [`Wgpu`](WatchRenderer::Wgpu), the only backend.
    ///
    /// `glow`/`gl` return `None` **and say why on stderr** rather than being silently
    /// treated as `auto`. Somebody setting that variable has an expectation, and LAW #4
    /// means it cannot be met — so it is answered, not swallowed. `None` for any other
    /// value too (the caller keeps its current choice).
    pub fn from_env() -> Option<Self> {
        match std::env::var("ROBOTUI_WATCH_RENDERER")
            .ok()?
            .trim()
            .to_ascii_lowercase()
            .as_str()
        {
            "wgpu" | "gpu" | "auto" | "" => Some(Self::Wgpu),
            g @ ("glow" | "gl") => {
                eprintln!(
                    "robotui watch: ROBOTUI_WATCH_RENDERER={g} is not available — this \
                     fleet is WebGPU only (LAW #4) and the glow backend is not compiled. \
                     Painting with wgpu."
                );
                None
            }
            _ => None,
        }
    }
}

/// Knobs for a [`Watch`](RunMode::Watch) run: window title/size, the per-step
/// pacing delay (how long the robot lingers on each step so the motion is
/// watchable), how long to hold the finished window before auto-closing, and which
/// GPU [`renderer`](WatchConfig::renderer) the window paints with.
#[derive(Clone, Debug)]
pub struct WatchConfig {
    /// The native window title.
    pub title: String,
    /// Initial inner window size, in points.
    pub size: egui::Vec2,
    /// Wall-clock time spent on EACH step (cursor travel + click + dwell). Bigger
    /// = slower, more watchable.
    pub step_delay: Duration,
    /// After the last step, how long to hold the window before auto-closing (set
    /// `ROBOTUI_WATCH_HOLD=1` to keep it open until the user closes it).
    pub linger: Duration,
    /// Which GPU backend the window paints with:
    /// [`Wgpu`](WatchRenderer::Wgpu), the default and the ONLY one (LAW #4). No
    /// feature and no env var can select another — `watch-wgpu` is a no-op alias of
    /// `watch`, and `ROBOTUI_WATCH_RENDERER=glow` is refused BY NAME on stderr rather
    /// than silently treated as auto (see [`WatchRenderer::from_env`]).
    ///
    /// This doc said "`Auto` by default (wgpu when `watch-wgpu` is compiled, else
    /// glow)" until 2026-08-24, and `Auto` had already been removed with `Glow` — so
    /// rustdoc emitted `unresolved link to WatchRenderer::Auto` and the rendered page
    /// documented a knob that does not exist.
    pub renderer: WatchRenderer,
    /// **Force the window's UI scale** (`pixels_per_point`), or `None` to use
    /// whatever the display reports.
    ///
    /// This exists because a drive at scale 1 cannot see a whole class of bug. egui
    /// computes `pixels_per_point = zoom_factor * native_pixels_per_point`
    /// (`egui::Context`), and that factor is what separates AccessKit's physical-pixel
    /// bounds from the point space the synthetic pointer events live in. A driver
    /// that mixes the two is *exactly correct* at scale 1 and wrong everywhere else —
    /// so `Xvfb`, which is always scale 1, can never catch it.
    ///
    /// Setting this applies [`egui::Context::set_zoom_factor`], which drives the same
    /// `pixels_per_point` the display would, so a HiDPI drive is reproducible on any
    /// box — including a nested compositor whose virtual output only offers scale 1.
    /// `ROBOTUI_WATCH_SCALE=2` sets it from the environment.
    pub scale: Option<f32>,
}

impl Default for WatchConfig {
    fn default() -> Self {
        Self {
            title: "robotui watch".to_string(),
            size: egui::vec2(760.0, 520.0),
            step_delay: Duration::from_millis(1100),
            linger: Duration::from_millis(2000),
            renderer: WatchRenderer::default(),
            scale: None,
        }
    }
}

impl WatchConfig {
    /// Default config, then overlay env overrides: `ROBOTUI_WATCH_DELAY_MS` (per
    /// step) and `ROBOTUI_WATCH_LINGER_MS` (hold at end). `ROBOTUI_WATCH_HOLD=1`
    /// disables auto-close (represented as a very long linger).
    pub fn from_env() -> Self {
        let mut cfg = Self::default();
        if let Some(ms) = std::env::var("ROBOTUI_WATCH_DELAY_MS").ok().and_then(|v| v.trim().parse::<u64>().ok()) {
            cfg.step_delay = Duration::from_millis(ms);
        }
        if let Some(ms) = std::env::var("ROBOTUI_WATCH_LINGER_MS").ok().and_then(|v| v.trim().parse::<u64>().ok()) {
            cfg.linger = Duration::from_millis(ms);
        }
        if matches!(std::env::var("ROBOTUI_WATCH_HOLD").ok().as_deref(), Some("1") | Some("true") | Some("yes")) {
            cfg.linger = Duration::from_secs(60 * 60 * 24);
        }
        if let Some(r) = WatchRenderer::from_env() {
            cfg.renderer = r;
        }
        // Only a finite, positive scale is honoured — a `0` or `nan` here would make
        // every widget rect infinite, which is a worse failure than ignoring it.
        if let Some(s) = std::env::var("ROBOTUI_WATCH_SCALE")
            .ok()
            .and_then(|v| v.trim().parse::<f32>().ok())
            .filter(|s| s.is_finite() && *s > 0.0)
        {
            cfg.scale = Some(s);
        }
        cfg
    }

    /// Builder: force the UI scale (`pixels_per_point`) — see
    /// [`scale`](Self::scale).
    pub fn with_scale(mut self, scale: f32) -> Self {
        self.scale = Some(scale);
        self
    }

    /// Builder: set the per-step delay.
    pub fn with_step_delay(mut self, d: Duration) -> Self {
        self.step_delay = d;
        self
    }

    /// Builder: set the window title.
    pub fn with_title(mut self, t: impl Into<String>) -> Self {
        self.title = t.into();
        self
    }
}

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

    #[test]
    fn run_mode_default_is_headless() {
        assert_eq!(RunMode::default(), RunMode::Headless);
        assert!(!RunMode::default().is_watch());
    }

    #[test]
    fn targets_records_and_resolves_rects() {
        let mut t = Targets::default();
        assert!(t.is_empty());
        t.record_rect("Bump", egui::Rect::from_min_size(egui::pos2(1.0, 2.0), egui::vec2(10.0, 4.0)));
        assert!(t.has("Bump"));
        assert_eq!(t.len(), 1);
        assert_eq!(t.rect("Bump").map(|r| r.center()), Some(egui::pos2(6.0, 4.0)));
        assert!(t.rect("Ghost").is_none());
        t.clear();
        assert!(t.is_empty());
    }

    #[test]
    fn watch_config_default_is_paced() {
        let c = WatchConfig::default();
        assert!(c.step_delay.as_millis() >= 500, "paced slow enough to watch");
    }
}