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