src/viz/holger_tab.rs
//! 📦 Holger tab — the **built-in (married) holger registry** pane.
//!
//! nornir LINKS `holger-server-lib` IN-PROCESS (the `embed-holger` feature,
//! default on — see [`crate::holger_embed`]). The dev pair it embeds is a
//! crates.io-mirror (`/cache`) + a writable release-rehearsal registry
//! (`/sparring`), both backed by znippy archives under
//! `<warehouse>/holger-cache` (the `nornir cache up` data path).
//!
//! This pane reads that on-disk registry through holger's **functional
//! (in-process) API**, NOT gRPC/HTTP: it constructs the dev-pair `Holger`,
//! `instantiate_backends`, then enumerates each repo's backend with
//! `list(None, limit)` ([`crate::holger_embed::read_registry`]). NO server is
//! started just to read.
//!
//! The optional **"bring up /cache + /sparring"** button is the in-process
//! [`EmbeddedHolger::start`](crate::holger_embed::EmbeddedHolger) — the
//! `nornir cache up` action — run on a worker thread so the egui paint thread
//! never blocks, with a `wait_ready` readiness probe.
//!
//! Gated behind `embed-holger`; this module is only compiled when that feature
//! is on (see `viz/mod.rs`), and the whole 📦 pane degrades to a short note when
//! the feature is off.
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use eframe::egui::{self, Color32, Pos2, Sense, Stroke, vec2};
use crate::holger_embed::{self, EmbeddedHolger, RepoSummary};
use super::facett_theme::{AMBER, GREEN, RED, Theme};
/// The 📦 Holger tab's two lenses onto the root registry (N6). The default is the
/// spinning 3-D graph; the table is the classic grid.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HolgerView {
/// A rotating, clickable 3-D node cloud of the root repos (default).
Graph,
/// The classic per-repo table.
Table,
}
impl HolgerView {
fn label(self) -> &'static str {
match self {
HolgerView::Graph => "Graph (3D)",
HolgerView::Table => "Table",
}
}
fn id(self) -> &'static str {
match self {
HolgerView::Graph => "graph3d",
HolgerView::Table => "table",
}
}
const ALL: [HolgerView; 2] = [HolgerView::Graph, HolgerView::Table];
}
/// How many artifact entries to enumerate per repo when summarising (counts +
/// the recent list). The dev-pair archives are small; this is plenty.
const LIST_LIMIT: usize = 100_000;
/// The async "bring up" state machine. The button spawns a worker that starts
/// the embedded dev pair + probes readiness; the slot carries the outcome back.
type BringUpSlot = Arc<Mutex<Option<Result<(), String>>>>;
/// The async registry read. `read_registry` opens znippy archives, so it runs
/// off the paint thread too.
type ReadSlot = Arc<Mutex<Option<Result<Vec<RepoSummary>, String>>>>;
/// What `read_registry` is run against: the real on-disk dev-pair dir (the
/// `cache up` data path), or — in tests — an injected, pre-built summary so the
/// pane can be asserted WITHOUT a live server or a real archive on disk.
enum Source {
/// `(data_dir, grpc_addr, http_addr)` — the dev-pair config the read uses.
Disk {
data_dir: PathBuf,
grpc: String,
http: String,
},
/// Thin/remote client: there is no local holger to read, so fetch the
/// registry summary from a running `nornir-server` over `Viz.HolgerRegistry`
/// (the server runs `read_registry` on its own data dir). Carries the
/// endpoint/token/workspace the gRPC call needs.
Remote {
endpoint: String,
token: String,
workspace: String,
},
/// Test seam: a fixed set of repo summaries.
Inject(Vec<RepoSummary>),
}
pub struct HolgerTab {
source: Source,
/// The grpc/http the **bring-up** button binds (defaults match `cache up`).
grpc: String,
http: String,
/// Loaded repo summaries (`None` until the first read completes).
repos: Option<Result<Vec<RepoSummary>, String>>,
/// In-flight background read (slot + when it started).
reading: Option<(ReadSlot, Instant)>,
loaded: bool,
/// The live embedded dev pair, once the bring-up button started it. Held so
/// the servers keep running; dropping it stops them.
embedded: Option<EmbeddedHolger>,
/// In-flight bring-up (slot + start). The worker returns the readiness
/// result; on success the next read re-enumerates the now-warm registry.
bringing_up: Option<(BringUpSlot, Instant)>,
/// Where the bring-up worker parks the live [`EmbeddedHolger`] handle it
/// built (its tokio runtime owns the server threads). `poll_bring_up`
/// adopts it into `embedded` so the servers outlive the worker.
bring_up_handle_slot: Option<Arc<Mutex<Option<EmbeddedHolger>>>>,
/// Last bring-up outcome string for the pane + `state_json`.
bring_up_status: Option<Result<String, String>>,
theme: Theme,
/// The active lens (N6): the 3-D root-repo graph (default) or the table.
view: HolgerView,
/// The spinning, clickable 3-D graph of root repos (built from `repos`).
graph: RepoGraph3D,
}
impl HolgerTab {
/// The live pane: read the dev-pair registry under `data_dir` (the
/// `nornir cache up` data path = `<warehouse>/holger-cache`).
pub fn disk(data_dir: PathBuf, grpc: String, http: String) -> Self {
Self::with(
Source::Disk {
data_dir,
grpc: grpc.clone(),
http: http.clone(),
},
grpc,
http,
)
}
/// Thin/remote client: fetch the registry summary from a running
/// `nornir-server` over `Viz.HolgerRegistry` (the server reads its own
/// holger data dir). No local holger is touched — the pane lists exactly the
/// repos the server serves (`crates-io` / `cache` / `sparring`). The bring-up
/// button is disabled (nothing local to start).
pub fn remote(endpoint: String, token: String, workspace: String) -> Self {
Self::with(
Source::Remote {
endpoint,
token,
workspace,
},
"127.0.0.1:18443".into(),
"127.0.0.1:18444".into(),
)
}
/// Test seam: inject a fixed set of repo summaries so `state_json` / `draw`
/// can be asserted without a live server or an on-disk archive.
pub fn inject(repos: Vec<RepoSummary>) -> Self {
Self::with(
Source::Inject(repos),
"127.0.0.1:18443".into(),
"127.0.0.1:18444".into(),
)
}
fn with(source: Source, grpc: String, http: String) -> Self {
Self {
source,
grpc,
http,
repos: None,
reading: None,
loaded: false,
embedded: None,
bringing_up: None,
bring_up_handle_slot: None,
bring_up_status: None,
theme: Theme::default(),
view: HolgerView::Graph,
graph: RepoGraph3D::default(),
}
}
pub fn set_palette(&mut self, t: Theme) {
self.theme = t;
}
/// The data dir the pane reads/brings up (the `cache up` path), the server
/// endpoint it fetches from (remote), or `<injected>` (test seam).
fn data_dir_label(&self) -> String {
match &self.source {
Source::Disk { data_dir, .. } => data_dir.display().to_string(),
Source::Remote { endpoint, .. } => format!("server {endpoint}"),
Source::Inject(_) => "<injected>".to_string(),
}
}
/// Whether the embedded dev pair is currently brought up in-process by THIS
/// pane (the bring-up button succeeded and the handle is held).
fn is_up(&self) -> bool {
self.embedded.is_some()
}
/// Kick off a background registry read (the functional API, no server). Runs
/// off the paint thread because opening znippy archives touches disk.
fn start_read(&mut self) {
self.loaded = true;
match &self.source {
Source::Inject(repos) => {
// No I/O — resolve immediately so the test seam is synchronous.
self.repos = Some(Ok(repos.clone()));
}
Source::Disk {
data_dir,
grpc,
http,
} => {
let slot: ReadSlot = Arc::new(Mutex::new(None));
let sink = slot.clone();
let (data_dir, grpc, http) = (data_dir.clone(), grpc.clone(), http.clone());
super::trace::emit_in(
"holger.read_registry",
&serde_json::json!({ "data_dir": data_dir.display().to_string() }),
);
std::thread::spawn(move || {
let res = holger_embed::read_registry(&data_dir, &grpc, &http, LIST_LIMIT)
.map_err(|e| format!("{e:#}"));
*sink.lock().unwrap_or_else(|p| p.into_inner()) = Some(res);
});
self.reading = Some((slot, Instant::now()));
}
Source::Remote {
endpoint,
token,
workspace,
} => {
// Thin mode: fetch the server's registry over Viz.HolgerRegistry
// off the paint thread (it's a blocking gRPC round-trip).
let slot: ReadSlot = Arc::new(Mutex::new(None));
let sink = slot.clone();
let (endpoint, token, workspace) =
(endpoint.clone(), token.clone(), workspace.clone());
super::trace::emit_in(
"holger.fetch_registry",
&serde_json::json!({ "endpoint": endpoint, "workspace": workspace }),
);
std::thread::spawn(move || {
let res = super::remote::fetch_holger_registry(&endpoint, &token, &workspace)
.map_err(|e| format!("{e:#}"));
*sink.lock().unwrap_or_else(|p| p.into_inner()) = Some(res);
});
self.reading = Some((slot, Instant::now()));
}
}
}
/// Drain a finished background read into `self.repos`.
fn poll_read(&mut self) {
if let Some((slot, _)) = &self.reading {
let done = slot.lock().unwrap_or_else(|p| p.into_inner()).take();
if let Some(res) = done {
self.repos = Some(res);
self.reading = None;
}
}
}
/// Start the embedded dev pair (`/cache` + `/sparring`) IN-PROCESS on a
/// worker thread — the same `EmbeddedHolger::start` the `nornir cache up`
/// CLI runs — then probe `wait_ready`. The egui thread never blocks; the
/// outcome lands in `bring_up_status` and triggers a re-read.
fn start_bring_up(&mut self) {
if self.bringing_up.is_some() || self.is_up() {
return;
}
let Source::Disk { data_dir, .. } = &self.source else {
// Nothing to bring up against an injected fixture.
self.bring_up_status = Some(Err("no on-disk registry (injected fixture)".into()));
return;
};
let data_dir = data_dir.clone();
let (grpc, http) = (self.grpc.clone(), self.http.clone());
let slot: BringUpSlot = Arc::new(Mutex::new(None));
let sink = slot.clone();
super::trace::emit_in(
"holger.bring_up",
&serde_json::json!({
"data_dir": data_dir.display().to_string(),
"grpc": grpc, "http": http,
}),
);
// The worker BUILDS the EmbeddedHolger (which owns its own tokio runtime
// + the server threads) and probes readiness. It parks the live handle
// in a shared slot so the UI thread can ADOPT it into `self.embedded`
// (keeping the servers alive), and reports the readiness result via the
// bring-up slot.
let handle_slot: Arc<Mutex<Option<EmbeddedHolger>>> = Arc::new(Mutex::new(None));
let handle_sink = handle_slot.clone();
std::thread::spawn(
move || match EmbeddedHolger::start(&data_dir, &grpc, &http) {
Ok(h) => {
let ready = h.wait_ready(Duration::from_secs(20));
*handle_sink.lock().unwrap_or_else(|p| p.into_inner()) = Some(h);
*sink.lock().unwrap_or_else(|p| p.into_inner()) =
Some(ready.map_err(|e| format!("{e:#}")));
}
Err(e) => {
*sink.lock().unwrap_or_else(|p| p.into_inner()) = Some(Err(format!("{e:#}")));
}
},
);
self.bring_up_handle_slot = Some(handle_slot);
self.bringing_up = Some((slot, Instant::now()));
}
/// Drain a finished bring-up: adopt the live handle (so the servers stay up)
/// and record the readiness outcome; on success, re-read the registry.
fn poll_bring_up(&mut self) {
let Some((slot, _)) = &self.bringing_up else {
return;
};
let done = slot.lock().unwrap_or_else(|p| p.into_inner()).take();
if let Some(res) = done {
// Adopt the handle the worker built (keeps the runtime/servers alive).
if let Some(hs) = self.bring_up_handle_slot.take() {
if let Some(h) = hs.lock().unwrap_or_else(|p| p.into_inner()).take() {
self.embedded = Some(h);
}
}
self.bringing_up = None;
match res {
Ok(()) => {
nornir_testmatrix::functional_status(
"nornir/viz/holger",
"bring_up_dev_pair",
true,
&format!("/cache + /sparring up at {}", self.http),
);
self.bring_up_status =
Some(Ok(format!("/cache + /sparring up at {}", self.http)));
// Re-enumerate now that the registry is warm.
self.loaded = false;
}
Err(e) => {
nornir_testmatrix::functional_status(
"nornir/viz/holger",
"bring_up_dev_pair",
false,
&e,
);
self.bring_up_status = Some(Err(e));
}
}
}
}
/// 📦 Holger tab's slice of `state_json` (LAW #6): is the embedded holger
/// reachable + which data dir, the repositories with per-repo crate/artifact
/// counts + a few recent ids, and the bring-up state.
pub fn state_json(&self) -> serde_json::Value {
let repos = match &self.repos {
None => serde_json::Value::Null,
Some(Err(e)) => serde_json::json!({ "error": e }),
Some(Ok(rs)) => serde_json::json!(
rs.iter()
.map(|r| serde_json::json!({
"name": r.name,
"repo_type": r.repo_type,
"writable": r.writable,
"crate_count": r.crate_count,
"artifact_count": r.artifact_count,
"recent": r.recent,
}))
.collect::<Vec<_>>()
),
};
let repo_names: Vec<String> = match &self.repos {
Some(Ok(rs)) => rs.iter().map(|r| r.name.clone()).collect(),
_ => Vec::new(),
};
// The crates.io-mirror `/cache` + the `/sparring` rehearsal registry are
// the two writable repos; surface their crate counts at the top level so
// the headless matrix reads them without walking the repo list.
let count_for = |name: &str| -> Option<usize> {
self.repos
.as_ref()
.and_then(|r| r.as_ref().ok())
.and_then(|rs| rs.iter().find(|x| x.name == name).map(|x| x.crate_count))
};
let bring_up = match &self.bring_up_status {
None => serde_json::Value::Null,
Some(Ok(s)) => serde_json::json!({ "ok": true, "detail": s }),
Some(Err(e)) => serde_json::json!({ "ok": false, "error": e }),
};
serde_json::json!({
// `embed-holger` is what gates this whole pane; when it's on (this
// module is compiled) the embedded holger is in-process reachable.
"embedded": true,
"data_dir": self.data_dir_label(),
// Whether THIS pane has the dev pair brought up in-process right now.
"up": self.is_up(),
"bringing_up": self.bringing_up.is_some(),
"loaded": self.loaded,
"repositories": repo_names,
"repo_count": match &self.repos { Some(Ok(rs)) => rs.len(), _ => 0 },
"cache_crates": count_for("cache"),
"sparring_crates": count_for("sparring"),
"repos": repos,
"bring_up": bring_up,
"grpc": self.grpc,
"http": self.http,
"palette": self.theme.name,
// N6: which lens is active + the 3-D root-repo graph's observable shape.
"view": self.view.id(),
"graph": self.graph.state_json(),
})
}
pub fn draw(&mut self, ui: &mut egui::Ui) {
self.poll_read();
self.poll_bring_up();
if !self.loaded && self.reading.is_none() {
self.start_read();
}
let theme = self.theme;
ui.horizontal(|ui| {
ui.heading("📦 Holger registry");
if ui.button("⟳ Reload").clicked() {
self.loaded = false;
}
// The "bring up /cache + /sparring" button = the in-process
// `nornir cache up` action. Disabled while up or while a bring-up is
// in flight; the worker keeps the paint thread responsive.
let busy = self.bringing_up.is_some();
let up = self.is_up();
let can_bring_up = matches!(self.source, Source::Disk { .. }) && !up && !busy;
if ui
.add_enabled(
can_bring_up,
egui::Button::new("▶ bring up /cache + /sparring"),
)
.clicked()
{
self.start_bring_up();
}
if up {
ui.colored_label(GREEN, "● up");
} else if busy {
ui.spinner();
ui.colored_label(AMBER, "starting…");
} else {
ui.colored_label(theme.text_dim, "○ down");
}
});
ui.label(
"the married, IN-PROCESS holger dev pair — a crates.io mirror (/cache) + a \
writable release-rehearsal registry (/sparring). Read here via holger's \
functional API (no gRPC/HTTP), the same backends `nornir cache up` serves.",
);
ui.label(format!("data dir: {}", self.data_dir_label()));
if let Some(res) = &self.bring_up_status {
match res {
Ok(s) => ui.colored_label(GREEN, format!("✔ {s}")),
Err(e) => ui.colored_label(RED, format!("✘ bring-up: {e}")),
};
}
ui.separator();
match &self.repos {
None => {
ui.spinner();
ui.label("reading the embedded registry…");
}
Some(Err(e)) => {
ui.colored_label(RED, format!("failed to read registry: {e}"));
}
Some(Ok(repos)) => {
if repos.is_empty() {
ui.colored_label(theme.text_dim, "no repositories configured.");
return;
}
// N6: keep the 3-D graph in sync with the loaded repo set (rebuilt
// only when the repo names change).
self.graph.sync(repos, &theme);
// Lens selector (Graph 3D ⇄ Table).
ui.horizontal(|ui| {
for v in HolgerView::ALL {
ui.selectable_value(&mut self.view, v, v.label());
}
});
ui.separator();
match self.view {
HolgerView::Graph => self.graph.ui(ui, repos, &theme),
HolgerView::Table => Self::draw_table(ui, repos, &theme),
}
}
}
}
/// The classic per-repo table (the `Table` lens).
fn draw_table(ui: &mut egui::Ui, repos: &[RepoSummary], theme: &Theme) {
egui::Grid::new("holger_repos_grid")
.striped(true)
.num_columns(4)
.spacing([18.0, 4.0])
.show(ui, |ui| {
ui.strong("repository");
ui.strong("type");
ui.strong("crates");
ui.strong("versions");
ui.end_row();
for r in repos {
ui.horizontal(|ui| {
ui.label(format!("/{}", r.name));
if r.writable {
ui.colored_label(theme.accent, "rw");
} else {
ui.colored_label(theme.text_dim, "ro");
}
});
ui.label(&r.repo_type);
ui.label(r.crate_count.to_string());
ui.label(r.artifact_count.to_string());
ui.end_row();
}
});
ui.add_space(8.0);
// A few recent /sparring publishes (the rehearsal registry) — the
// most interesting "what did the last rehearsal push?" view.
if let Some(sparring) = repos.iter().find(|r| r.name == "sparring") {
ui.strong("recent /sparring publishes");
if sparring.recent.is_empty() {
ui.colored_label(theme.text_dim, "none yet — run a release rehearsal.");
} else {
for id in &sparring.recent {
ui.label(format!(" • {id}"));
}
}
}
}
}
/// A spinning, clickable **3-D graph of holger's root repos** (N6): a central
/// `holger` hub node with one satellite node per repo — sized by crate count,
/// coloured by writable (rw = accent, ro = dim) — laid out by the shared
/// [`facett_graph3d::Graph3D`] placement, orbiting on its own and grabbable by
/// drag. Clicking a repo node selects it and reveals its detail below the cloud.
struct RepoGraph3D {
/// Node labels; index 0 is the `holger` hub, 1.. are repos in `built_for` order.
labels: Vec<String>,
colors: Vec<Color32>,
/// Per-node paint radius scale (hub biggest, repos by crate count).
sizes: Vec<f32>,
positions: Vec<[f32; 3]>,
edges: Vec<(usize, usize)>,
yaw: f32,
spin: bool,
/// Selected node index (into `labels`), or `None`. 0 = hub.
selected: Option<usize>,
/// The repo names the current layout was built for (rebuild only on change).
built_for: Vec<String>,
}
impl Default for RepoGraph3D {
fn default() -> Self {
Self {
labels: Vec::new(),
colors: Vec::new(),
sizes: Vec::new(),
positions: Vec::new(),
edges: Vec::new(),
yaw: 0.6,
spin: true,
selected: None,
built_for: Vec::new(),
}
}
}
impl RepoGraph3D {
/// Rebuild the hub-and-spoke cloud iff the repo name set changed.
fn sync(&mut self, repos: &[RepoSummary], theme: &Theme) {
let names: Vec<String> = repos.iter().map(|r| r.name.clone()).collect();
if names == self.built_for {
return;
}
self.built_for = names;
self.selected = None;
// Node 0 = the `holger` hub; nodes 1.. = repos.
let max_crates = repos
.iter()
.map(|r| r.crate_count)
.max()
.unwrap_or(0)
.max(1) as f32;
self.labels = std::iter::once("holger".to_string())
.chain(repos.iter().map(|r| r.name.clone()))
.collect();
self.colors = std::iter::once(theme.accent)
.chain(repos.iter().map(|r| {
if r.writable {
theme.accent
} else {
theme.text_dim
}
}))
.collect();
self.sizes = std::iter::once(1.6_f32)
.chain(
repos
.iter()
.map(|r| 0.6 + (r.crate_count as f32 / max_crates)),
)
.collect();
// Hub → each repo (spoke edges).
self.edges = (1..=repos.len()).map(|i| (0, i)).collect();
self.layout_positions();
}
/// (Re)place nodes via the shared facett 3-D force layout (hub pulls its spokes
/// inward), copying positions back onto our coloured/sized nodes.
fn layout_positions(&mut self) {
let nodes: Vec<(String, Color32)> = self
.labels
.iter()
.cloned()
.zip(self.colors.iter().copied())
.collect();
let g = facett_graph3d::Graph3D::new(
nodes,
self.edges.clone(),
facett_graph3d::Layout3D::Force,
);
self.positions = g.nodes.iter().map(|n| n.pos).collect();
}
fn project(&self, p: [f32; 3], center: Pos2, scale: f32) -> (Pos2, f32) {
let (s, c) = self.yaw.sin_cos();
let x = p[0] * c + p[2] * s;
let z = -p[0] * s + p[2] * c;
(center + vec2(x, -p[1]) * scale, z)
}
/// Paint the spinning cloud + handle click-to-select; render the selected
/// repo's detail line below.
fn ui(&mut self, ui: &mut egui::Ui, repos: &[RepoSummary], theme: &Theme) {
if self.labels.is_empty() {
ui.colored_label(theme.text_dim, "no repositories to graph.");
return;
}
ui.horizontal(|ui| {
ui.label(format!(
"{} root repo(s) — drag to spin, click a node to inspect.",
repos.len()
));
if ui.selectable_label(self.spin, "⟳ spin").clicked() {
self.spin = !self.spin;
}
});
let want = vec2(
ui.available_width(),
(ui.available_height() - 40.0).max(220.0),
);
let (rect, resp) = ui.allocate_exact_size(want, Sense::click_and_drag());
if self.spin && !resp.dragged() {
let dt = ui.input(|i| i.stable_dt).min(0.1);
self.yaw += dt * 0.4;
ui.ctx().request_repaint();
}
if resp.dragged() {
self.yaw += resp.drag_delta().x * 0.01;
}
let painter = ui.painter_at(rect);
painter.rect_filled(rect, egui::CornerRadius::ZERO, theme.bg);
let center = rect.center();
let scale = rect.size().min_elem() * 0.36;
let proj: Vec<(Pos2, f32)> = self
.positions
.iter()
.map(|p| self.project(*p, center, scale))
.collect();
// Click hit-test: nearest node within its paint radius wins.
if resp.clicked() {
if let Some(pos) = resp.interact_pointer_pos() {
let mut best: Option<(usize, f32)> = None;
for (i, (p, z)) in proj.iter().enumerate() {
let r = (6.0 + (z + 1.0) * 3.0) * self.sizes[i];
let d = p.distance(pos);
if d <= r.max(8.0) && best.map(|(_, bd)| d < bd).unwrap_or(true) {
best = Some((i, d));
}
}
self.selected = best.map(|(i, _)| i);
}
}
// Edges, faded by mean depth.
for &(a, b) in &self.edges {
if a < proj.len() && b < proj.len() {
let depth = (proj[a].1 + proj[b].1) * 0.5;
let alpha = (120.0 + depth * 100.0).clamp(30.0, 220.0);
painter.line_segment(
[proj[a].0, proj[b].0],
Stroke::new(0.8_f32, theme.edge.gamma_multiply(alpha / 255.0)),
);
}
}
// Nodes back-to-front (nearer = bigger + brighter); label each.
let mut order: Vec<usize> = (0..self.labels.len()).collect();
order.sort_by(|&i, &j| {
proj[i]
.1
.partial_cmp(&proj[j].1)
.unwrap_or(std::cmp::Ordering::Equal)
});
for i in order {
let (p, z) = proj[i];
let base_r = 6.0 + (z + 1.0) * 3.0;
let r = base_r * self.sizes[i];
let bright = (0.5 + (z + 1.0) * 0.25).clamp(0.35, 1.0);
let mut col = self.colors[i].gamma_multiply(bright);
if self.selected == Some(i) {
col = theme.accent;
painter.circle_stroke(p, r + 3.0, Stroke::new(2.0_f32, theme.accent));
}
painter.circle_filled(p, r, col);
painter.text(
p + vec2(r + 3.0, -r),
egui::Align2::LEFT_BOTTOM,
&self.labels[i],
egui::FontId::proportional(11.0),
theme.text.gamma_multiply(bright),
);
}
// Selected repo detail (hub selection just re-states the registry).
ui.add_space(4.0);
match self.selected {
Some(0) | None => {
ui.colored_label(
theme.text_dim,
"the embedded holger registry hub — click a repo node.",
);
}
Some(i) => {
if let Some(r) = repos.get(i - 1) {
ui.horizontal_wrapped(|ui| {
ui.strong(format!("/{}", r.name));
ui.colored_label(
if r.writable {
theme.accent
} else {
theme.text_dim
},
if r.writable { "rw" } else { "ro" },
);
ui.label(format!(
"· {} · {} crate(s) · {} version(s)",
r.repo_type, r.crate_count, r.artifact_count
));
});
if !r.recent.is_empty() {
ui.label(format!("recent: {}", r.recent.join(", ")));
}
}
}
}
}
/// Robot-observable shape — node/edge counts, spin/yaw, and the selection.
fn state_json(&self) -> serde_json::Value {
let finite = self
.positions
.iter()
.filter(|p| p.iter().all(|c| c.is_finite()))
.count();
serde_json::json!({
"nodes": self.labels.len(),
"edges": self.edges.len(),
"finite_positions": finite,
"spin": self.spin,
"yaw": self.yaw,
"selected": self.selected.and_then(|i| self.labels.get(i)).cloned(),
"repos": self.built_for,
})
}
}