mannequin/src/gui.rs
//! Mannequin GUI — the holger test-dummy system map, drawn through **facett**
//! components (the nordisk house pattern) instead of raw egui:
//!
//! - the PKI / OIDC / Nexus system map → a **`facett_syschart::SystemChart`**
//! (clickable peer nodes + edges, a per-node event-count **badge**, and a
//! click-select → inline detail panel — the component invented for this GUI);
//! - the selected system's event log → a **`facett_table::Table`** (timestamp /
//! direction / summary rows);
//! - the PKI issued-cert list → a small tree/list, with the **Regenerate**
//! action kept as a live button.
//!
//! Everything stays live: each frame we re-read the shared [`EventLog`] +
//! [`PkiService`] and refresh the chart badges + the table rows, so the map
//! updates as the daemon serves requests.
//!
//! ## The shell is a [`Scene`] — because this pane really is master/detail
//!
//! The map and the log were two things stacked in one `ScrollArea` with a
//! `ui.separator()` between them: the operator could not resize either, could not
//! collapse the map to read a long log, and could not blow the log up to read it.
//! But the relationship was master/detail all along — you pick a system ON the map
//! and the panel below is the detail OF that pick.
//!
//! So the shell is declared as one [`facett_app::scene::Scene`] (L5 — the same
//! shared pane host holger-server-ui's shell adopted) and rendered by [`scene::render`]:
//!
//! ```text
//! mannequin-shell ── Split(V, 0.55, DRAGGABLE)
//! ├─ map_pane (Foldable 🗺) slot:map — the SystemChart (the MASTER)
//! └─ detail_pane (Foldable 📜) slot:detail — the selection's log + PKI controls
//! ```
//!
//! That buys a real draggable divider and — via facett-app's `Chrome::Auto` — a
//! window title bar on each pane carrying fold · minimise · maximise. Nothing about
//! the two bodies changed: the same `SystemChart` and the same `Table` are drawn,
//! through a [`SceneHost`] instead of inline.
use eframe::egui;
use facett_app::scene::{self, Axis, NavState, Pane, Scene, SceneHost};
use facett_core::look::Theme as LookTheme; // the unified facett look & feel (one preset/frame)
use facett_core::Facet;
use facett_syschart::{SysEdge, SysNode, SystemChart};
use facett_table::Table;
use serde_json::{json, Value};
use crate::event_log::{Direction, EventLog, SystemKind};
use crate::PkiService;
use std::sync::Arc;
/// The root pane's unique name — the DRAGGABLE vertical master/detail split. The
/// `Router` reports it as the scene route and a robot drags its divider by the rect
/// the render publishes under `separators`.
pub const SHELL_PANE: &str = "mannequin-shell";
/// The MASTER pane: the PKI / OIDC / Nexus system map.
pub const MAP_PANE: &str = "map_pane";
/// The DETAIL pane: the selected system's event log (+ the PKI controls).
pub const DETAIL_PANE: &str = "detail_pane";
/// Build the mannequin shell as a declarative [`Scene`]: a draggable vertical
/// master/detail split of the system map over the selection's detail. Both panes are
/// foldable, and both wear facett-app's default window chrome (fold · minimise ·
/// maximise) — the root stays bare, as it must.
///
/// Pure and window-free, so a test can build the exact shipped shell and assert its
/// shape without a display.
pub fn mannequin_scene() -> Scene {
let map = Pane::slot(MAP_PANE, "map")
.title("System map")
.foldable_icon(true, "🗺");
let detail = Pane::slot(DETAIL_PANE, "detail")
.title("Detail")
.foldable_icon(true, "📜");
Scene::new(
"mannequin",
Pane::split(SHELL_PANE, Axis::Vertical, 0.55, vec![map, detail]).title("Holger Mannequin"),
)
}
/// Stable node ids for the three mock systems (used for chart selection + the
/// badge/detail refresh).
const ID_PKI: &str = "pki";
const ID_OIDC: &str = "oidc";
const ID_NEXUS: &str = "nexus";
fn kind_id(kind: &SystemKind) -> &'static str {
match kind {
SystemKind::Pki => ID_PKI,
SystemKind::Oidc => ID_OIDC,
SystemKind::Nexus => ID_NEXUS,
}
}
fn id_kind(id: &str) -> Option<SystemKind> {
match id {
ID_PKI => Some(SystemKind::Pki),
ID_OIDC => Some(SystemKind::Oidc),
ID_NEXUS => Some(SystemKind::Nexus),
_ => None,
}
}
/// The EXACT AccessKit label `facett_syschart::SystemChart` registers on a node's
/// clickable button — `"{label} · {badge} events"` (see `facett-syschart` FC-4).
/// The robot addresses the REAL on-map node by this string, and `state_json`
/// publishes it per node, so the drive target and the emit stay one truth.
pub fn node_a11y_label(kind: &SystemKind, badge: u64) -> String {
format!("{} · {} events", kind.label(), badge)
}
pub struct MannequinGui {
event_log: EventLog,
pki: Arc<PkiService>,
/// The system map (facett component). Rebuilt-in-place each frame: badges +
/// detail text are refreshed from the live event log.
chart: SystemChart,
/// Active look preset — an index into [`LookTheme::PRESETS`]. The GUI applies
/// it every frame; `apply` installs the full egui Style AND publishes the
/// derived legacy palette so the facett SystemChart + Table re-theme
/// coherently (COH-1). The mannequin used to ship egui's bare default.
look_preset: usize,
/// The declarative shell (the master/detail pane tree) — the DATA the renderer
/// walks. Constant, so the persisted [`NavState`] stays valid frame-to-frame.
scene: Scene,
/// The scene's runtime state: per-pane fold + minimise, the maximised pane, and the
/// LIVE split ratio a divider drag moves.
nav: NavState,
/// The last [`scene::render`]'s observable facts (per-pane rects + `alloc`, chrome
/// button rects, divider handle rects) — folded into [`state_json`](Self::state_json)
/// so a robot asserts GEOMETRY, not pixels.
last_render: Value,
}
/// The default look preset for the mannequin GUI: the running-OS-matched preset's
/// index into [`LookTheme::PRESETS`]. Linux/unknown → windows-dark (facett's
/// documented default). Pure → unit-testable with no window.
pub fn default_look_preset() -> usize {
let want = LookTheme::from_os(egui::os::OperatingSystem::from_target_os()).name;
LookTheme::preset_names()
.iter()
.position(|n| *n == want)
.unwrap_or(1)
}
/// Build the mannequin system map as a bare facett [`SystemChart`] — the three
/// mock systems (PKI / OIDC / Nexus) as peer nodes, fully wired with edges. This
/// is the **single source of truth** for the chart's topology: the live GUI
/// ([`MannequinGui::new`]) builds from it AND the headless robot-UI matrix cell
/// renders this exact chart offscreen to assert its `state_json`. Badges start at
/// the supplied counts so a test can inject real numbers and read them back.
pub fn build_system_chart(badges: [(SystemKind, u64); 3]) -> SystemChart {
let by_kind = |k: &SystemKind| {
badges
.iter()
.find(|(b, _)| b == k)
.map(|(_, n)| *n)
.unwrap_or(0)
};
let nodes = vec![
SysNode::new(
ID_PKI,
SystemKind::Pki.label(),
egui::Color32::from_rgb(120, 200, 255),
(0.12, 0.15),
)
.badge(by_kind(&SystemKind::Pki)),
SysNode::new(
ID_OIDC,
SystemKind::Oidc.label(),
egui::Color32::from_rgb(200, 160, 255),
(0.88, 0.15),
)
.badge(by_kind(&SystemKind::Oidc)),
SysNode::new(
ID_NEXUS,
SystemKind::Nexus.label(),
egui::Color32::from_rgb(160, 255, 180),
(0.5, 0.9),
)
.badge(by_kind(&SystemKind::Nexus)),
];
let edges = vec![
SysEdge::new(ID_PKI, ID_OIDC),
SysEdge::new(ID_PKI, ID_NEXUS),
SysEdge::new(ID_OIDC, ID_NEXUS),
];
SystemChart::new("Holger Mannequin — System Map", nodes, edges).with_canvas_height(320.0)
}
impl MannequinGui {
pub fn new(event_log: EventLog, pki: Arc<PkiService>) -> Self {
let chart = build_system_chart([
(SystemKind::Pki, 0),
(SystemKind::Oidc, 0),
(SystemKind::Nexus, 0),
]);
let scene = mannequin_scene();
debug_assert!(
scene.validate().is_ok(),
"scene names must be unique: {:?}",
scene.validate()
);
let nav = NavState::new(&scene);
Self {
event_log,
pki,
chart,
look_preset: default_look_preset(),
scene,
nav,
last_render: Value::Null,
}
}
/// The active look preset's [`LookTheme`].
pub fn look_theme(&self) -> LookTheme {
let presets = LookTheme::PRESETS;
presets[self.look_preset.min(presets.len() - 1)]()
}
/// Switch the active look preset (clamped); returns the new preset name.
pub fn set_look_preset(&mut self, idx: usize) -> String {
self.look_preset = idx.min(LookTheme::PRESETS.len() - 1);
self.look_theme().name
}
/// Remote-observable state (LAW 6): **every on-screen VALUE an operator sees**,
/// as data, so a robot asserts the value — never the pixels. The drivable half
/// exists too (`set_look_preset` / `select_system` / `regenerate_pki` /
/// `toggle_fold` …), so a robot can drive each primary function and assert the
/// result IN CODE.
///
/// The roster, matched 1:1 against what the shipped shell paints:
/// * `look_preset` / `look_name` — the chrome bar's look switcher's active pick;
/// * `selected_system` — the map's one selection (drives the detail pane);
/// * `events.{pki,oidc,nexus}` — the per-node badge numbers on the map;
/// * `pki_issued` — the PKI issued-cert count;
/// * `chart_nodes[]` — every map node's `{id,label,badge,selected}` PLUS the
/// exact AccessKit label (`a11y_label`) the facett `SystemChart` registers for
/// it, so a robot can click the REAL on-map node (no proxy);
/// * `detail{}` — what the detail pane renders for the current selection, and its
/// EMPTY / ERROR states as booleans (`empty`, `pki_certs_empty`, `activity_*`),
/// so an empty pane is an asserted value, never an eyeball (LAW 2 / 4);
/// * `scene{}` — the pane-chrome geometry + fold/minimise/maximise/split state.
pub fn state_json(&self) -> serde_json::Value {
let names = LookTheme::preset_names();
serde_json::json!({
"look_preset": self.look_preset,
"look_name": names.get(self.look_preset).map(|s| s.to_string()).unwrap_or_default(),
"preset_count": names.len(),
"selected_system": self.chart.selected(),
// ── live daemon-facing atoms a robot can drive + assert (LAW 6) ──────────
// Per-system event counts (a Regenerate logs one, so a robot proves the
// action landed) and the PKI issued-cert count.
"events": {
"pki": self.event_log.count_for(&SystemKind::Pki),
"oidc": self.event_log.count_for(&SystemKind::Oidc),
"nexus": self.event_log.count_for(&SystemKind::Nexus),
},
"pki_issued": self.pki.list_issued().len(),
// ── the map's node roster + the EXACT AccessKit label per node ──────────
// (what `facett_syschart` paints and registers), so a robot addresses the
// REAL on-map node instead of a proxy control.
"chart_nodes": self.chart_nodes_json(),
// ── the detail pane as data, including its empty/error states ───────────
"detail": self.detail_json(),
// ── the declarative shell as observable data (the Observe step) ─────────
"scene": {
"name": self.scene.name,
"pane_names": self.scene.pane_names(),
// facett-app's own adoption audit — the one blob naming every affordance
// this shell ships (chrome roster · draggable dividers · foldables).
"affordances": self.scene.affordances(),
"draggable_splits": self.scene.draggable_splits(),
"chrome": self.scene.chrome_panes(),
"foldable": self.scene.foldable_panes(),
"folded": self.nav.folded,
"minimized": self.nav.minimized,
"maximized": self.nav.maximized,
"last_chrome": self.nav.last_chrome,
"split_ratio": self.nav.split_ratio,
// The last render's facts: per-pane `rect` + `alloc` + `area`, each
// chrome control's button rect, and the divider handle rects.
"render": self.last_render,
},
})
}
/// The map's node roster as observable data — one entry per painted node with
/// the EXACT AccessKit label `facett_syschart` registers for it
/// (`"{label} · {badge} events"`), so a robot can address the REAL on-map node.
/// This IS the map's LAW-6 populated-state emit: `badge` is the node's live
/// event count, `selected` marks the one the detail pane follows.
fn chart_nodes_json(&self) -> Value {
let sel = self.chart.selected();
let node = |kind: SystemKind| {
let id = kind_id(&kind);
let badge = self.event_log.count_for(&kind) as u64;
json!({
"id": id,
"label": kind.label(),
"badge": badge,
"selected": sel == Some(id),
// The label facett_syschart attaches to the node's AccessKit button.
"a11y_label": node_a11y_label(&kind, badge),
})
};
json!([
node(SystemKind::Pki),
node(SystemKind::Oidc),
node(SystemKind::Nexus)
])
}
/// The detail pane as observable data — what it renders for the current
/// selection AND every empty/error state it can show, each as a boolean value a
/// robot asserts (LAW 2 / 4): `empty` (the "(select a system on the map)"
/// placeholder), `pki_certs_empty` (the "(no child certs issued yet)" line), and
/// `activity_empty` (the per-system "No activity yet" state).
fn detail_json(&self) -> Value {
let sel = self.chart.selected().map(|s| s.to_string());
let sel_kind = sel.as_deref().and_then(id_kind);
let event_count = sel_kind
.as_ref()
.map(|k| self.event_log.count_for(k))
.unwrap_or(0);
let issued = self.pki.list_issued();
json!({
"selected": sel,
// The "(select a system on the map)" placeholder is showing.
"empty": sel.is_none(),
// The selected system's real event count (what the detail Table lists).
"event_count": event_count,
// The PKI issued-cert list the detail pane draws, and whether it is the
// "(no child certs issued yet)" empty line.
"pki_certs": issued,
"pki_certs_empty": issued.is_empty(),
// Per-system "No activity yet" empty state (the chart's inline detail).
"activity_empty": {
"pki": self.event_log.count_for(&SystemKind::Pki) == 0,
"oidc": self.event_log.count_for(&SystemKind::Oidc) == 0,
"nexus": self.event_log.count_for(&SystemKind::Nexus) == 0,
},
})
}
/// Robot/test hook: fold or unfold a scene pane by name (the same toggle the chrome
/// bar's ⏷ control runs). Returns the new folded state.
pub fn toggle_fold(&mut self, pane: &str) -> bool {
self.nav.toggle_fold(pane)
}
/// Robot/test hook: **minimise** a pane — collapse it to its chrome title bar and
/// hand its space back to its sibling. Returns the new minimised state.
pub fn toggle_minimize(&mut self, pane: &str) -> bool {
self.nav.toggle_minimize(pane)
}
/// Robot/test hook: **maximise** a pane — give it the whole shell, shrinking its
/// sibling to zero area. At most one pane holds it. Returns the new state.
pub fn toggle_maximize(&mut self, pane: &str) -> bool {
self.nav.toggle_maximize(pane)
}
/// Robot/test hook: **restore** a pane (drop its minimise AND its maximise). The
/// split ratio was never overwritten, so the layout returns exactly as it was.
pub fn restore_pane(&mut self, pane: &str) -> bool {
self.nav.restore(pane)
}
/// Robot/test hook: the live first-child (map) fraction of the master/detail split —
/// what a divider drag moves.
pub fn split_ratio(&self) -> Option<&Vec<f32>> {
self.nav.split_ratios(SHELL_PANE)
}
/// Robot/test hook: **select a system on the map** (the headless analogue of clicking
/// a chart node) — the ONE selection the detail pane renders. Re-selecting the same
/// node deselects, per the chart's documented toggle. Returns the new selection.
pub fn select_system(&mut self, id: &str) -> Option<String> {
self.chart.select(id);
self.chart.selected().map(|s| s.to_string())
}
/// Robot/test hook: the ONE selected system id (`"pki"`/`"oidc"`/`"nexus"`), or
/// `None` when the map has nothing selected. The read half of [`select_system`].
pub fn selected_system(&self) -> Option<String> {
self.chart.selected().map(|s| s.to_string())
}
/// Robot/test hook: **regenerate the PKI CA** (the headless analogue of clicking the
/// detail pane's 🔄 Regenerate button). On success it logs the same outbound PKI
/// event the live button does, so the event count a robot asserts moves. Returns
/// whether the regenerate succeeded.
pub fn regenerate_pki(&mut self) -> bool {
match self.pki.regenerate() {
Ok(()) => {
self.event_log.log(
SystemKind::Pki,
Direction::Outbound,
"CA regenerated (new keypair)",
None,
);
true
}
Err(e) => {
tracing::error!("PKI regenerate failed: {}", e);
false
}
}
}
/// Robot/test hook: the PKI issued-cert common-names (what the detail pane's cert
/// list renders).
pub fn issued_certs(&self) -> Vec<String> {
self.pki.list_issued()
}
/// Robot/test hook: the live event count for one system (the badge number).
pub fn event_count(&self, kind: &SystemKind) -> usize {
self.event_log.count_for(kind)
}
/// Apply the active preset to `ctx` — installs the full egui Style and
/// publishes the derived legacy palette so the facett SystemChart + Table
/// follow (COH-1).
pub fn apply_look(&self, ctx: &egui::Context) {
self.look_theme().apply(ctx);
}
pub fn run(self) -> anyhow::Result<()> {
let options = eframe::NativeOptions {
// LAW #4 — WebGPU, never OpenGL. Named explicitly rather than left to
// `Renderer::default()`: the default follows whichever backend feature the
// manifest happens to carry, which is exactly how this GUI shipped on glow
// without the word appearing anywhere.
renderer: eframe::Renderer::Wgpu,
viewport: egui::ViewportBuilder::default()
.with_inner_size([900.0, 700.0])
.with_title("Holger Mannequin — System Map"),
..Default::default()
};
eframe::run_native(
"Holger Mannequin",
options,
Box::new(|_cc| Ok(Box::new(self))),
)
.map_err(|e| anyhow::anyhow!("eframe error: {}", e))
}
/// Refresh every chart node's badge (event count) + its inline detail text
/// (the most recent log lines) from the live event log. Runs each frame so
/// the map tracks the daemon.
fn refresh_chart(&mut self) {
for kind in [SystemKind::Pki, SystemKind::Oidc, SystemKind::Nexus] {
let id = kind_id(&kind);
self.chart
.set_badge(id, self.event_log.count_for(&kind) as u64);
self.chart
.set_detail(id, recent_log_text(&self.event_log, &kind, 12));
}
}
}
/// The recent event log for one system as plain text (the chart's inline detail
/// panel renders this; it's also the headless-observable detail).
fn recent_log_text(event_log: &EventLog, kind: &SystemKind, max: usize) -> String {
let entries = event_log.entries_for(kind);
if entries.is_empty() {
return "No activity yet".to_string();
}
entries
.iter()
.rev()
.take(max)
.map(|e| {
let ts = e.timestamp.format("%H:%M:%S");
let arrow = match e.direction {
Direction::Inbound => "→",
Direction::Outbound => "←",
};
format!("{ts} {arrow} {}", e.summary)
})
.collect::<Vec<_>>()
.join("\n")
}
/// Build a facett `Table` of one system's recent events (timestamp/dir/summary).
fn event_table(event_log: &EventLog, kind: &SystemKind) -> Table {
let mut t = Table::new(
format!("{} — events", kind.label()),
vec!["time".into(), "dir".into(), "summary".into()],
);
for e in event_log.entries_for(kind).iter().rev().take(50) {
let dir = match e.direction {
Direction::Inbound => "in",
Direction::Outbound => "out",
};
t.push_row(vec![
e.timestamp.format("%H:%M:%S").to_string(),
dir.into(),
e.summary.clone(),
]);
}
t
}
/// The slot host: fills each `Content::Slot` with the SAME widget the shell drew
/// inline before — the `map` slot is the facett `SystemChart`, the `detail` slot the
/// selection's PKI controls + event-log `Table`. Nothing is forked; the scene only
/// decides WHERE each goes and how much room it gets.
struct Host<'a> {
chart: &'a mut SystemChart,
event_log: &'a EventLog,
pki: &'a Arc<PkiService>,
}
impl SceneHost for Host<'_> {
fn slot(&mut self, id: &str, ui: &mut egui::Ui) -> Value {
match id {
// ── MASTER: the system map. Scrolls inside its own pane, so shrinking the
// pane with the divider never clips the chart away.
"map" => {
egui::ScrollArea::vertical()
.id_salt("mannequin-map-scroll")
.show(ui, |ui| {
self.chart.ui(ui);
});
json!({ "selected": self.chart.selected() })
}
// ── DETAIL: whatever the map's selection points at.
"detail" => {
let Some(kind) = self.chart.selected().and_then(id_kind) else {
ui.weak("(select a system on the map)");
return json!({ "selected": Value::Null, "events": 0 });
};
let mut regenerated = false;
egui::ScrollArea::vertical()
.id_salt("mannequin-detail-scroll")
.show(ui, |ui| {
if kind == SystemKind::Pki {
ui.horizontal(|ui| {
ui.label("🔐 CA: Holger Mannequin CA");
if ui.button("🔄 Regenerate").clicked() {
if let Err(e) = self.pki.regenerate() {
tracing::error!("PKI regenerate failed: {}", e);
} else {
regenerated = true;
}
}
});
// Issued-cert tree/list.
let issued = self.pki.list_issued();
if issued.is_empty() {
ui.weak(" (no child certs issued yet)");
} else {
for cn in &issued {
ui.label(format!(" └─ {cn}"));
}
}
ui.separator();
}
// The event log as a facett Table.
let mut table = event_table(self.event_log, &kind);
table.ui(ui);
});
if regenerated {
self.event_log.log(
SystemKind::Pki,
Direction::Outbound,
"CA regenerated (new keypair)",
None,
);
}
json!({
"selected": kind_id(&kind),
"events": self.event_log.entries_for(&kind).len(),
})
}
other => {
ui.weak(format!("no widget for slot: {other}"));
Value::Null
}
}
}
}
impl eframe::App for MannequinGui {
// eframe 0.35: `ui` IS the render entry point and `update` is `#[deprecated]` — and
// implementing both would draw twice per frame, because eframe calls them in sequence
// (`native/epi_integration.rs`). We get a `&mut Ui` for the root viewport directly.
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
// Live updates: repaint every 500ms.
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(500));
// Unified look & feel: install the active preset's full egui Style +
// publish its derived legacy palette so the facett SystemChart + Table
// re-theme coherently (COH-1). The mannequin previously used egui default.
self.apply_look(&ui.ctx().clone());
// Pull the latest counts/detail into the facett chart before drawing.
self.refresh_chart();
// The app chrome (identity + the look switcher) stays OUTSIDE the scene — the
// scene owns the pane tree, this bar owns the shell-wide controls.
let mut pick = None;
egui::Panel::top("mannequin-chrome").show(ui, |ui| {
ui.horizontal(|ui| {
ui.heading("Holger Mannequin — System Map");
ui.separator();
// The look-&-feel preset switcher (Windows/macOS/Device, light+dark).
ui.label("look:");
let names = LookTheme::preset_names();
for (i, name) in names.iter().enumerate() {
if ui.selectable_label(self.look_preset == i, name).clicked() {
pick = Some(i);
}
}
});
});
if let Some(i) = pick {
self.set_look_preset(i);
}
// ── The shell, rendered from its declarative Scene. Disjoint field borrows so
// the host holds the widgets while `render` reads the scene + mutates the nav.
let Self {
event_log,
pki,
chart,
scene,
nav,
last_render,
..
} = self;
let mut host = Host {
chart: &mut *chart,
event_log: &*event_log,
pki: &*pki,
};
*last_render = scene::render(&*scene, &mut *nav, ui, &mut host);
}
}