dwarves-gui/src/robot_shell.rs
//! **Shared robot-drive shell** — the ONE `Observable` app + the ONE
//! `draw(ui, app, targets)` closure that BOTH robot modes drive, so the headless
//! wire-remote mount ([`crate::robot_remote`]) and the visible WATCH replay
//! ([`crate::robot_watch`]) exercise the SAME shell, built the SAME way, driven
//! IDENTICALLY (no twin app, no twin oracle).
//!
//! ## Both modes now resolve clicks through the LIVE AccessKit tree (robotui 0.2.6)
//! `nornir_robotui::run_scenario_mode` takes ONE `app` + ONE `draw` closure and picks
//! the backend by [`RunMode`](nornir_robotui::RunMode). As of `nornir-robotui` 0.2.6
//! (edda `194ce3f`) BOTH backends resolve a `ClickLabel(<label>)` the SAME way — through
//! the live egui AccessKit tree:
//!
//! * **Headless** (`egui_kittest`): the label is resolved through the kittest AccessKit
//! tree.
//! * **Watch** (a real eframe window): the backend taps the window's own AccessKit
//! output (`ctx.enable_accesskit()` + a `Plugin::output_hook`) and resolves the label
//! against it — the SAME resolution headless uses. It falls back to the [`Targets`]
//! rect side-channel ONLY when AccessKit has no match.
//!
//! So dwarves no longer records a `Targets` "rail" of tab-header rects: any widget the
//! headless mount can click, the visible watch window now clicks against the real
//! on-screen AccessKit node — zero per-app recording. The [`Targets`] parameter is kept
//! only because `run_scenario_mode`'s closure signature carries it for back-compat; this
//! closure writes nothing to it.
use facett_core::Facet;
use nornir_robotui::egui_kittest::Harness;
use nornir_robotui::{Observable, RobotSession, Targets};
use serde_json::Value;
use crate::tabbed::TabbedView;
/// The whole dwarves tabbed shell wrapped as an [`Observable`] — the robot reads
/// exactly what the shell renders through the SAME `Facet::state_json` the remote
/// seam exposes (no twinned oracle).
pub(crate) struct ShellApp(pub TabbedView);
impl Observable for ShellApp {
fn state_json(&self) -> Value {
Facet::state_json(&self.0)
}
}
/// Build the offline-safe shell the robot drives: a dead forge endpoint, the
/// FAT/embedded funnel (no `nornir-server` needed), an out-of-repo ledger root —
/// the SAME headless build the in-crate robot tests use, so constructing it dials
/// no network. The start-up About modal is dismissed so the tab strip is
/// unobstructed for the driver.
pub(crate) fn build_shell_app() -> ShellApp {
build_shell_app_at("http://127.0.0.1:0")
}
/// The SAME shell over a named forge endpoint — what the LIVE headless tour
/// (`--robot-mode headless --robot-dump <dir>`, [`crate::robot_mode::run_headless_live`])
/// drives against a real server. Identical to what `main` builds: the endpoint is the
/// only thing that differs from the hermetic shell above.
pub(crate) fn build_shell_app_at(endpoint: &str) -> ShellApp {
let mut app = crate::build_native_app(
endpoint.to_string(),
std::path::PathBuf::from("/nonexistent/dwarves-gui/robot"),
);
Facet::update_json(&mut app, r#"{"about":false}"#);
ShellApp(app)
}
/// **The off-screen drive surface, in points.** ONE declaration, because the width is
/// load-bearing and a second copy that drifted would silently un-reach the last tabs.
///
/// The shell mounts twenty-four tabs and facett-app's `TabView::show` draws its headers
/// in a plain `ui.horizontal` — it neither wraps nor scrolls. Headless layout does not
/// scroll either, so a tab laid out past the body pane's right edge falls OUT of the
/// AccessKit tree and no robot can click it: at 2400pt the last tab (`Manual`) dropped
/// out and the driver saw `/active_tab` stuck. 3000pt fits all 24 with room for more.
///
/// ⚠ Still a workaround for a REAL bug that also hits humans on a window narrower than
/// ~1650pt — the fix belongs upstream in facett (`horizontal_wrapped`, or the header row
/// in a `ScrollArea::horizontal`). `tabbed.rs`'s **Go** menu is the width-independent
/// path until it lands.
pub(crate) const ROBOT_SURFACE: egui::Vec2 = egui::Vec2::new(3000.0, 1400.0);
/// **Build the SETTLED off-screen session over the real shell** — the ONE headless drive
/// surface, used by the wire-remote mount ([`crate::robot_remote`]) and the
/// `--robot-mode headless` tour ([`crate::robot_mode`]).
///
/// It exists because [`nornir_robotui::run_scenario_mode`]'s headless arm builds its
/// harness with `Harness::new_ui_state` and therefore at kittest's DEFAULT size — it
/// takes no size from `WatchConfig`, whose `size` field only ever reaches the watch
/// window. Driving THIS shell on the default surface clips the tab strip, so every
/// tab-select step fails while every within-pane assertion still passes: 34 red steps
/// that say nothing whatsoever about the product. Sizing the harness here is what makes
/// a headless red mean something.
///
/// Returns a session already settled (`run(2)`), so every tab is in the AccessKit tree
/// before the first step tries to resolve a label. Settling is bundled in deliberately:
/// it is invisible when forgotten (labels just fail to resolve) and both call sites need
/// it, so it must not be a step a caller can omit.
pub(crate) fn build_shell_session(repo: impl Into<String>) -> RobotSession<'static, ShellApp> {
build_shell_session_over(build_shell_app(), repo)
}
/// [`build_shell_session`] over the shell at `endpoint` (the live tour).
pub(crate) fn build_shell_session_at(
endpoint: &str,
repo: impl Into<String>,
) -> RobotSession<'static, ShellApp> {
build_shell_session_over(build_shell_app_at(endpoint), repo)
}
fn build_shell_session_over(
app: ShellApp,
repo: impl Into<String>,
) -> RobotSession<'static, ShellApp> {
let harness = Harness::builder().with_size(ROBOT_SURFACE).build_ui_state(
// Since robotui 0.2.6 BOTH modes resolve `ClickLabel` through the live AccessKit
// tree, so the closure records no `Targets` rail — the throwaway `Targets` here
// just satisfies the shared closure signature.
|ui, a: &mut ShellApp| {
let mut targets = Targets::default();
shell_draw(ui, a, &mut targets);
},
app,
);
let mut session = RobotSession::new(harness, repo);
session.run(2);
session
}
/// The ONE draw closure both modes use: render the real shell. Since robotui 0.2.6 the
/// visible watch backend resolves `ClickLabel` against the window's live AccessKit tree
/// (the SAME resolution headless uses), so there is NO tab-rect "rail" to record — a
/// real widget is a real AccessKit node in both modes. The `_targets` side-channel is
/// left untouched (kept only for the closure's back-compat signature).
pub(crate) fn shell_draw(ui: &mut egui::Ui, app: &mut ShellApp, _targets: &mut Targets) {
Facet::ui(&mut app.0, ui);
}