facett-releaseview/src/lib.rs
//! **facett-releaseview** — visualize a release process as **three converging
//! graphs**. The same constellation dependency DAG in three states — LOCAL
//! (working-tree / manifest), SPARRING (the Gate-B holger rehearsal), CRATES_IO
//! (the published target) — that a healthy release drives to **converge**. All
//! three equal ⇒ green; divergence marks *where to fix / release* (see
//! [`model::ConvergeStatus`]).
//!
//! ## Two renders (GPU-hero philosophy, CPU-honest)
//!
//! Mirroring facett's "beautiful hero + always-works fallback" split, the view has
//! **two modes** ([`ViewMode`]), both pure-CPU egui-painter draws (wasm-clean,
//! headless-snapshottable — no wgpu required to land green):
//!
//! * [`ViewMode::Planes3D`] — **the hero**: the three states drawn as three receding
//! depth **planes** (LOCAL front → SPARRING → CRATES_IO back). A crate that has
//! converged is drawn **once** where the planes coincide (green); a crate whose
//! versions diverge **splits** across the planes with a red/amber beam + glow. As
//! crates converge the planes **snap together** (separation ∝ 1 − converged
//! fraction). A documented follow-up (see `.nornir`) upgrades this to the
//! `facett-graph3d` **CodeVault** wgpu renderer with a real depth buffer.
//! * [`ViewMode::TriBadge2D`] — **the fallback**: one flat DAG, each node showing
//! three version **chips** L / S / C coloured by convergence (a diverging chip
//! flags red/amber).
//!
//! ## Engine-agnostic (one-way, like the runa adapter)
//!
//! The view takes a [`model::ReleaseConvergence`] **in** and renders — it has **no**
//! dwarves / release-engine dependency. A release engine feeds the model from its
//! `graph_diff` + the three version sources (manifests / Gate-B rehearsal / a
//! crates.io probe); types flow one way, exactly the
//! [`RunaPipelineAdapter`](facett_pipeline) pattern.
//!
//! ## Realtime
//!
//! [`ReleaseConvergence::apply`](model::ReleaseConvergence::apply) (surfaced through
//! [`Msg::Event`]) flips one crate's state, so a live release cascade animates the
//! convergence node-by-node.
use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Stroke, Vec2};
use facett_core::effects::{easing, glow_rect};
use facett_core::nav::Navigable;
use facett_graph::{DepEdge as GraphEdge, DepGraphView, DepNode};
use serde::{Deserialize, Serialize};
pub mod model;
pub use model::{
ConvergeStatus, CrateConvergence, DepEdge, ReleaseConvergence, ReleaseEvent, derive_status,
version_cmp,
};
/// The GPU-hero lowering onto the shared facett-graph3d **CodeVault** (feature
/// `graph3d`): [`vault::convergence_to_codegraph`] places the three planes as
/// depth-staggered boxes that collapse as the release converges. Off by default so the
/// CPU planes + `TriBadge2D` fallback stay feature-free and wasm-clean.
#[cfg(feature = "graph3d")]
pub mod vault;
/// Which of the two renders is showing.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Serialize, Deserialize)]
pub enum ViewMode {
/// The hero: three receding depth planes that snap together as crates converge.
/// With the `graph3d` feature on, this renders through the shared facett-graph3d
/// [`CodeVault`](facett_graph3d::CodeVault) — a real depth-composited 3D box scene
/// whose laggard planes collapse onto each crate's target box as the release
/// converges; without it, the feature-free CPU parallelogram painter draws (the
/// wasm-clean / headless fallback).
#[default]
Planes3D,
/// The always-works fallback: one flat DAG with L/S/C version chips per node.
TriBadge2D,
}
impl ViewMode {
/// A short, stable label (JSON / a11y / the toggle button).
pub fn label(self) -> &'static str {
match self {
ViewMode::Planes3D => "3-plane",
ViewMode::TriBadge2D => "tri-badge",
}
}
}
/// The three graph planes, back-to-front. The 3-plane render draws one layer per
/// variant; the tri-badge render draws one chip per variant.
pub const PLANES: [&str; 3] = ["cratesio", "sparring", "local"];
/// **The themed marker colour for a convergence status.** Green = converged,
/// red = release/lock blocker, amber = drift, accent = publishing-in-flight. Pure
/// (no `ui`) so the colour mapping is unit-testable (a diverged node must NOT read
/// the same colour as a converged one).
pub fn status_color(status: ConvergeStatus, th: &facett_core::Theme) -> Color32 {
match status {
ConvergeStatus::Converged => th.point,
ConvergeStatus::NeedsRelease => facett_core::look::RED,
ConvergeStatus::LockDesync => Color32::from_rgb(230, 96, 150),
ConvergeStatus::BumpDrift => Color32::from_rgb(240, 180, 70),
ConvergeStatus::Publishing => th.accent,
}
}
/// The fixed hue for each version chip / plane role, so LOCAL / SPARRING /
/// CRATES_IO read distinctly regardless of theme. `role` is `0=local`,
/// `1=sparring`, `2=cratesio` (matching [`PLANES`] reversed).
pub(crate) fn role_color(role: usize) -> Color32 {
match role {
0 => Color32::from_rgb(120, 180, 255), // local — blue
1 => Color32::from_rgb(240, 190, 90), // sparring — amber
_ => Color32::from_rgb(120, 210, 150), // cratesio — green
}
}
/// The complete observable, serializable state (FC-3).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ReleaseState {
/// The engine-agnostic convergence model (nodes + edges).
pub conv: ReleaseConvergence,
/// Which render is showing.
pub mode: ViewMode,
/// Click-selected crate (by name), for the detail line.
pub selected: Option<String>,
/// Injected animation clock (seconds; `None` ⇒ follow ambient time). Pinning it
/// makes the plane-separation pulse a pure function of state, so headless
/// renders are identical.
pub clock_s: Option<f64>,
/// Shared pan/zoom camera.
pub nav: Navigable,
/// Whether the camera has been fit to the graph yet.
pub fitted: bool,
}
/// A robot-/CLI-addressable control message (FC-2) — the only way the model mutates.
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
/// Click-select a crate by name.
SelectCrate(String),
/// Switch the render mode.
SetMode(ViewMode),
/// Apply a realtime release event (flip one crate's state).
Event(ReleaseEvent),
/// Pan the camera by a screen-pixel delta.
Pan(Vec2),
/// Zoom the camera by `factor` around a screen `anchor`.
Zoom { factor: f32, anchor: Pos2 },
/// Fit the camera to the whole graph within `rect`.
Fit(Rect),
/// Ask for a re-fit on the next paint (the rect is only known at paint time).
RequestRefit,
/// Pin the injected animation clock (seconds).
SetClock(f64),
/// Advance the injected clock by `dt` seconds.
Tick(f64),
}
/// Side work as data (FC-8). The release view does no I/O — an uninhabited effect.
#[derive(Clone, Debug, PartialEq)]
pub enum ReleaseEffect {}
/// The release-convergence view: three converging graphs as a [`Facet`](facett_core::Facet).
pub struct ReleaseView {
title: String,
state: ReleaseState,
/// The dependency-graph layout — **derived** from the model at construction
/// (reused for the toposort columns), so it is not part of the serializable
/// [`ReleaseState`]. Rebuilt on construction; the topology never changes at
/// runtime (only versions/status flip), so it stays valid across `apply`.
dag: DepGraphView,
}
const NODE_W: f32 = 150.0;
const NODE_H: f32 = 62.0;
const COL_GAP: f32 = 210.0;
const ROW_GAP: f32 = 110.0;
const LEFT_PAD: f32 = 40.0;
const TOP_PAD: f32 = 60.0;
/// Max per-plane offset (scene px) at full divergence, in the 3-plane render.
const PLANE_DX: f32 = 46.0;
const PLANE_DY: f32 = 30.0;
const WHEEL_ZOOM_RATE: f32 = 0.0015;
impl ReleaseView {
/// Build the view from the engine-agnostic [`ReleaseConvergence`] model.
pub fn from_model(conv: ReleaseConvergence) -> Self {
let dag = build_dag(&conv);
let title = if conv.title.is_empty() { "release".to_string() } else { conv.title.clone() };
Self {
title,
state: ReleaseState {
conv,
mode: ViewMode::default(),
selected: None,
clock_s: None,
nav: Navigable::default(),
fitted: false,
},
dag,
}
}
/// Retitle the pane (the deck / discovery key).
pub fn with_title(mut self, t: impl Into<String>) -> Self {
self.title = t.into();
self
}
/// Start in a specific render mode.
pub fn with_mode(mut self, mode: ViewMode) -> Self {
self.state.mode = mode;
self
}
/// Pin the injected animation clock (builder form; makes the render deterministic).
pub fn with_clock(mut self, seconds: f64) -> Self {
self.state.clock_s = Some(seconds);
self
}
/// **FC-3** — read the complete observable state.
pub fn state(&self) -> &ReleaseState {
&self.state
}
/// The convergence model (read-only).
pub fn model(&self) -> &ReleaseConvergence {
&self.state.conv
}
/// Apply a realtime release event (host-facing shortcut around [`Msg::Event`]).
/// Returns `true` if a node changed.
pub fn apply_event(&mut self, event: ReleaseEvent) -> bool {
self.state.conv.apply(event)
}
/// Advance the injected clock by `dt` seconds.
pub fn tick(&mut self, dt: f64) {
let _ = self.update(Msg::Tick(dt));
}
/// Pin the injected clock to an absolute time.
pub fn set_clock(&mut self, seconds: f64) {
let _ = self.update(Msg::SetClock(seconds));
}
/// The currently selected crate name.
pub fn selected(&self) -> Option<&str> {
self.state.selected.as_deref()
}
/// **FC-2** — the single mutation path.
pub fn update(&mut self, msg: Msg) -> Vec<ReleaseEffect> {
match msg {
Msg::SelectCrate(name) => self.state.selected = Some(name),
Msg::SetMode(mode) => self.state.mode = mode,
Msg::Event(ev) => {
self.state.conv.apply(ev);
}
Msg::Pan(delta) => self.state.nav.pan(delta),
Msg::Zoom { factor, anchor } => self.state.nav.zoom_to(factor, anchor),
Msg::Fit(rect) => {
if let Some(bb) = self.scene_bbox() {
self.state.nav.fit(bb, rect, 0.10);
self.state.fitted = true;
}
}
Msg::RequestRefit => self.state.fitted = false,
Msg::SetClock(s) => self.state.clock_s = Some(s),
Msg::Tick(dt) => self.state.clock_s = Some(self.state.clock_s.unwrap_or(0.0) + dt),
}
Vec::new()
}
/// Node centres in **scene** coords (fixed-grid toposort layout), the anchor the
/// camera maps to screen. This is the *converged* position; the 3-plane render
/// offsets each plane from it.
fn layout_scene(&self) -> Vec<Pos2> {
let col = self.dag.columns();
let n = self.state.conv.nodes.len();
let mut row_of = vec![0usize; n];
let mut per_col: std::collections::BTreeMap<usize, usize> = std::collections::BTreeMap::new();
for (i, &c) in col.iter().enumerate() {
let r = per_col.entry(c).or_insert(0);
row_of[i] = *r;
*r += 1;
}
(0..n)
.map(|i| {
Pos2::new(
LEFT_PAD + col.get(i).copied().unwrap_or(0) as f32 * COL_GAP + NODE_W / 2.0,
TOP_PAD + row_of[i] as f32 * ROW_GAP + NODE_H / 2.0,
)
})
.collect()
}
/// Scene bounding box (with margin for the exploded planes), for fit-to-view.
fn scene_bbox(&self) -> Option<Rect> {
let centers = self.layout_scene();
if centers.is_empty() {
return None;
}
let mut bb = Rect::from_center_size(centers[0], Vec2::new(NODE_W, NODE_H));
for c in ¢ers[1..] {
bb = bb.union(Rect::from_center_size(*c, Vec2::new(NODE_W, NODE_H)));
}
// Room for the plane offsets + labels.
Some(bb.expand2(Vec2::new(NODE_W * 0.6 + PLANE_DX * 2.0, NODE_H * 0.9 + PLANE_DY * 2.0)))
}
/// This frame's separation multiplier in `[0,1]` — how far apart the planes are
/// drawn. `1 − converged_fraction` (fully converged ⇒ 0 ⇒ planes coincide), with
/// a gentle breathing pulse so a mid-release still reads as "alive".
fn separation(&self, time: f64) -> f32 {
let base = 1.0 - self.state.conv.converged_fraction();
let pulse = 0.06 * ((time * 1.3).sin() as f32 * 0.5 + 0.5);
(base + pulse * base).clamp(0.0, 1.0)
}
// ── the pure render (FC-9): paints + returns the Msgs interactions produced ──
fn paint(&self, ui: &mut egui::Ui, time: f64) -> Vec<Msg> {
let mut msgs: Vec<Msg> = Vec::new();
let n = self.state.conv.nodes.len();
if n == 0 {
ui.label("no crates");
return msgs;
}
// The GPU hero: when the `graph3d` feature is on, the 3-plane mode renders
// through the shared CodeVault (a real depth-composited 3D scene) instead of the
// CPU parallelogram. The CodeVault allocates its own canvas, so route to it
// BEFORE the generic allocation below. `TriBadge2D` + the CPU planes stay the
// feature-free path (headless / no-GPU / wasm).
#[cfg(feature = "graph3d")]
if self.state.mode == ViewMode::Planes3D {
return self.paint_vault(ui, time);
}
let th = facett_core::theme(ui);
let avail = ui.available_size();
let canvas = Vec2::new(avail.x.max(160.0), avail.y.max(360.0));
let (rect, resp) = ui.allocate_exact_size(canvas, Sense::click_and_drag());
if !self.state.fitted {
msgs.push(Msg::Fit(rect));
}
msgs.extend(self.nav_input_msgs(ui, rect, &resp));
let painter = ui.painter_at(rect);
painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);
match self.state.mode {
ViewMode::Planes3D => msgs.extend(self.paint_planes(ui, &painter, rect, time, &th)),
ViewMode::TriBadge2D => msgs.extend(self.paint_tribadge(ui, &painter, rect, time, &th)),
}
// Keep animating only while following the ambient clock.
if self.state.clock_s.is_none() {
ui.ctx().request_repaint();
}
msgs
}
/// **The CodeVault hero render** (feature `graph3d`). Lower the model to a
/// depth-staggered [`vault::convergence_to_codegraph`] scene and render it through
/// the shared [`CodeVault`](facett_graph3d::CodeVault) — reused verbatim (orbit
/// camera + depth-sorted projection + glitch flicker + its GPU bloom lane; L5). The
/// laggard boxes on the LOCAL/SPARRING/CRATES_IO planes **collapse onto** each
/// crate's green target box as it converges (`separation = 1 − converged_fraction`),
/// so the animation IS the convergence. Camera spin is a pure function of the
/// injected clock (headless-deterministic); a box click round-trips back to
/// [`Msg::SelectCrate`].
#[cfg(feature = "graph3d")]
fn paint_vault(&self, ui: &mut egui::Ui, time: f64) -> Vec<Msg> {
use facett_graph3d::{CodeVault, CodeVaultMsg, Layout3D, Msg as CamMsg};
let sep = self.separation(time);
let (cg, positions, meta) = vault::convergence_to_codegraph(&self.state.conv, sep);
let mut cv = CodeVault::new(cg, Layout3D::Force)
.with_positions(&positions)
.with_clock(time)
.with_title(self.title.clone());
// Reflect the current selection onto the selected crate's target box.
if let Some(sel) = self.state.selected.as_deref() {
if let Some(ci) = self.state.conv.node_index(sel) {
if let Some(bi) = meta.iter().position(|m| m.krate == ci && m.target) {
let _ = cv.update(CodeVaultMsg::Camera(CamMsg::SelectNode(bi)));
}
}
}
let out = cv.view(ui);
// Round-trip a box selection back to a crate selection (any box → its crate).
let mut msgs: Vec<Msg> = Vec::new();
for m in out {
if let CodeVaultMsg::Camera(CamMsg::SelectNode(bi)) = m {
if let Some(node) = meta.get(bi).and_then(|mm| self.state.conv.nodes.get(mm.krate)) {
msgs.push(Msg::SelectCrate(node.krate.clone()));
}
}
}
if self.state.clock_s.is_none() {
ui.ctx().request_repaint();
}
msgs
}
/// **The 3-plane hero render.** Three receding depth planes (CRATES_IO back →
/// SPARRING → LOCAL front). A converged crate sits once where the planes meet
/// (green); a diverged crate splits across the planes it differs on, joined by a
/// severity-coloured beam + glow. The plane separation is `separation(time)`, so
/// as crates converge the planes snap together.
fn paint_planes(
&self,
ui: &mut egui::Ui,
painter: &egui::Painter,
rect: Rect,
time: f64,
th: &facett_core::Theme,
) -> Vec<Msg> {
let mut msgs = Vec::new();
let scene = self.layout_scene();
let sep = self.separation(time);
let scale = self.state.nav.scale;
// Per-plane scene offset: cratesio (back) pushed up-left, local (front)
// down-right, sparring in the middle. `role`: 0=local,1=sparring,2=cratesio.
let plane_off = |role: usize| -> Vec2 {
let depth = role as f32 - 1.0; // local=-1, sparring=0, cratesio=+1
Vec2::new(-depth * PLANE_DX * sep, -depth * PLANE_DY * sep)
};
let to_screen = |scene_p: Pos2, off: Vec2| self.state.nav.to_screen(scene_p + off);
// ── plane frames + labels (back to front) so nearer planes overlay ──
for role in (0..3usize).rev() {
let off = plane_off(role);
let depth_dim = 0.55 + 0.15 * role as f32; // back planes dimmer
let col = role_color(role).gamma_multiply(depth_dim);
// A parallelogram hull around all node boxes on this plane.
if let Some(bb) = self.scene_bbox() {
let corners = [
to_screen(bb.left_top(), off),
to_screen(bb.right_top(), off),
to_screen(bb.right_bottom(), off),
to_screen(bb.left_bottom(), off),
];
painter.add(egui::Shape::closed_line(
corners.to_vec(),
Stroke::new(1.0_f32, col.gamma_multiply(0.5)),
));
// Plane label at the top-left corner (an a11y node so a robot can
// read "3 planes present").
let lp = corners[0] + Vec2::new(6.0, -4.0) * scale;
let label = PLANES[role]; // role reversed == PLANES order
let lrect = Rect::from_min_size(lp, Vec2::new(90.0, 16.0) * scale);
let sem = facett_core::a11y::Semantics::button(format!("plane {label}"));
let _ = facett_core::a11y::node(ui, ui.id().with("plane"), label, Sense::hover(), lrect, sem);
painter.text(lp, Align2::LEFT_BOTTOM, label.to_uppercase(), FontId::monospace(11.0 * scale), col);
}
}
// ── edges on the LOCAL (front) plane, faint ──
let front = plane_off(0);
for e in &self.state.conv.edges {
if e.from < scene.len() && e.to < scene.len() {
let a = to_screen(scene[e.to], front);
let b = to_screen(scene[e.from], front);
painter.line_segment([a, b], Stroke::new(1.2 * scale, th.edge));
}
}
// ── crate nodes: split across planes when diverged, single when converged ──
let base_id = ui.id().with("release-nodes");
for (i, node) in self.state.conv.nodes.iter().enumerate() {
let sc = status_color(node.status, th);
let (l_off, s_off, c_off) = node.chip_divergence();
let converged = !(l_off || s_off || c_off) || node.status.is_converged();
let nsize = Vec2::new(NODE_W, NODE_H) * 0.5 * scale;
// The three plane positions for this crate.
let p_local = to_screen(scene[i], plane_off(0));
let p_spar = to_screen(scene[i], plane_off(1));
let p_crat = to_screen(scene[i], plane_off(2));
if !converged {
// A severity beam joining the split copies + a glow at the front.
painter.line_segment([p_crat, p_local], Stroke::new(2.0 * scale, sc.gamma_multiply(0.8)));
let intensity = if node.status == ConvergeStatus::Publishing {
0.35 + 0.55 * easing::ease_in_out_cubic(((time * 3.0).sin() as f32) * 0.5 + 0.5)
} else {
0.7
};
glow_rect(painter, Rect::from_center_size(p_local, nsize * 2.0), sc, intensity, 8);
// Ghost copies on the sparring + cratesio planes (dim), so the split
// is visible as three depth-staggered boxes.
self.paint_ghost(painter, p_crat, nsize, role_color(2), scale, c_off);
self.paint_ghost(painter, p_spar, nsize, role_color(1), scale, s_off);
}
// The front (LOCAL) box carries the label + the interaction target.
let nrect = Rect::from_center_size(p_local, nsize * 2.0);
let a11y = format!("{} · {}", node.krate, node.status.label());
let sem = facett_core::a11y::Semantics::button(a11y)
.selected(self.state.selected.as_deref() == Some(node.krate.as_str()));
let r = facett_core::a11y::node(ui, base_id, &node.krate, Sense::click(), nrect, sem);
if r.clicked() {
msgs.push(Msg::SelectCrate(node.krate.clone()));
}
let cr = egui::CornerRadius::same((5.0 * scale).round().clamp(0.0, 255.0) as u8);
painter.rect_filled(nrect, cr, th.node_fill);
let selected = self.state.selected.as_deref() == Some(node.krate.as_str());
painter.rect_stroke(nrect, cr, Stroke::new(if selected { 3.0 } else { 2.0 } * scale, sc), egui::StrokeKind::Inside);
painter.text(p_local - Vec2::new(0.0, 8.0 * scale), Align2::CENTER_CENTER, &node.krate, FontId::proportional(13.0 * scale), th.text);
painter.text(p_local + Vec2::new(0.0, 11.0 * scale), Align2::CENTER_CENTER, node.status.label(), FontId::monospace(10.0 * scale), sc);
if converged {
// A convergence tick when the three planes meet.
painter.text(nrect.right_top() + Vec2::new(-8.0 * scale, 8.0 * scale), Align2::CENTER_CENTER, "✓", FontId::proportional(12.0 * scale), th.point);
}
}
self.paint_legend(painter, rect, th, scale);
msgs
}
/// A dim ghost box for a crate's copy on a back plane (only when that plane's
/// chip actually diverges).
fn paint_ghost(&self, painter: &egui::Painter, c: Pos2, half: Vec2, col: Color32, scale: f32, diverges: bool) {
if !diverges {
return;
}
let nrect = Rect::from_center_size(c, half * 1.7);
let cr = egui::CornerRadius::same((4.0 * scale).round().clamp(0.0, 255.0) as u8);
painter.rect_stroke(nrect, cr, Stroke::new(1.3 * scale, col.gamma_multiply(0.8)), egui::StrokeKind::Inside);
}
/// **The 2D tri-badge fallback.** One flat DAG; each node shows three version
/// chips L / S / C, a diverging chip flagged in the node's severity colour.
fn paint_tribadge(
&self,
ui: &mut egui::Ui,
painter: &egui::Painter,
rect: Rect,
_time: f64,
th: &facett_core::Theme,
) -> Vec<Msg> {
let mut msgs = Vec::new();
let scene = self.layout_scene();
let scale = self.state.nav.scale;
let centers: Vec<Pos2> = scene.iter().map(|&c| self.state.nav.to_screen(c)).collect();
let nsize = Vec2::new(NODE_W, NODE_H) * scale;
// Edges.
for e in &self.state.conv.edges {
if e.from < centers.len() && e.to < centers.len() {
let a = centers[e.to];
let b = centers[e.from];
painter.line_segment([a, b], Stroke::new(1.4 * scale, th.edge));
}
}
let base_id = ui.id().with("release-nodes-2d");
for (i, node) in self.state.conv.nodes.iter().enumerate() {
let c = centers[i];
let nrect = Rect::from_center_size(c, nsize);
if !rect.intersects(nrect.expand(40.0 * scale)) {
continue;
}
let sc = status_color(node.status, th);
let a11y = format!("{} · {}", node.krate, node.status.label());
let sem = facett_core::a11y::Semantics::button(a11y)
.selected(self.state.selected.as_deref() == Some(node.krate.as_str()));
let r = facett_core::a11y::node(ui, base_id, &node.krate, Sense::click(), nrect, sem);
if r.clicked() {
msgs.push(Msg::SelectCrate(node.krate.clone()));
}
if node.status.severity() > 0 {
glow_rect(painter, nrect, sc, if node.status.severity() == 2 { 0.6 } else { 0.4 }, 6);
}
let cr = egui::CornerRadius::same((6.0 * scale).round().clamp(0.0, 255.0) as u8);
painter.rect_filled(nrect, cr, th.node_fill);
let selected = self.state.selected.as_deref() == Some(node.krate.as_str());
painter.rect_stroke(nrect, cr, Stroke::new(if selected { 3.0 } else { 2.0 } * scale, sc), egui::StrokeKind::Inside);
painter.text(c - Vec2::new(0.0, 16.0 * scale), Align2::CENTER_CENTER, &node.krate, FontId::proportional(13.0 * scale), th.text);
// The three version chips L / S / C.
let (l_off, s_off, c_off) = node.chip_divergence();
let chips = [
("L", node.local_ver.as_str(), 0usize, l_off),
("S", node.sparring_ver.as_str(), 1, s_off),
("C", node.cratesio_ver.as_str(), 2, c_off),
];
let chip_w = (nsize.x - 8.0 * scale) / 3.0;
for (ci, (tag, ver, role, diverges)) in chips.iter().enumerate() {
let cx = c.x - nsize.x / 2.0 + 4.0 * scale + chip_w * (ci as f32 + 0.5);
let cy = c.y + 8.0 * scale;
let chip_rect = Rect::from_center_size(Pos2::new(cx, cy), Vec2::new(chip_w - 3.0 * scale, 20.0 * scale));
let base = role_color(*role);
let ring = if *diverges { sc } else { base.gamma_multiply(0.7) };
painter.rect_filled(chip_rect, egui::CornerRadius::same(3), th.bg.gamma_multiply(0.7));
painter.rect_stroke(chip_rect, egui::CornerRadius::same(3), Stroke::new(1.2 * scale, ring), egui::StrokeKind::Inside);
let txt = format!("{tag} {ver}");
let col = if *diverges { sc } else { th.text_dim };
painter.text(chip_rect.center(), Align2::CENTER_CENTER, txt, FontId::monospace(8.5 * scale), col);
}
}
self.paint_legend(painter, rect, th, scale);
msgs
}
/// A tiny status legend + convergence readout in the top-left of the canvas.
fn paint_legend(&self, painter: &egui::Painter, rect: Rect, th: &facett_core::Theme, scale: f32) {
let (conv, need, lock, drift, pubb) = self.state.conv.status_counts();
let frac = (self.state.conv.converged_fraction() * 100.0).round() as i32;
let txt = format!(
"{} · {frac}% converged · ✓{conv} ⚑{need} ⌥{lock} ~{drift} ⟳{pubb}",
self.state.mode.label()
);
let p = rect.left_top() + Vec2::new(10.0 * scale.max(0.6), 8.0 * scale.max(0.6));
painter.text(p, Align2::LEFT_TOP, txt, FontId::monospace(11.0), th.text_dim);
}
/// Route this frame's pan/zoom input into camera [`Msg`]s (pure; the bridge
/// applies them).
fn nav_input_msgs(&self, ui: &mut egui::Ui, rect: Rect, resp: &egui::Response) -> Vec<Msg> {
let mut msgs = Vec::new();
if resp.dragged() {
msgs.push(Msg::Pan(resp.drag_delta()));
}
let pointer = ui.input(|i| i.pointer.hover_pos()).unwrap_or(rect.center());
let anchor = if rect.contains(pointer) { pointer } else { rect.center() };
let (scroll, zoom_delta) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta()));
if resp.hovered() {
if (zoom_delta - 1.0).abs() > 1e-4 {
msgs.push(Msg::Zoom { factor: zoom_delta, anchor });
} else if scroll.y.abs() > 0.0 {
let k = (1.0 + scroll.y * WHEEL_ZOOM_RATE).clamp(0.5, 2.0);
msgs.push(Msg::Zoom { factor: k, anchor });
}
}
msgs
}
/// **FC-9 render** — the pure view: paints the toolbar + the active render and
/// *returns* the [`Msg`]s the interactions produced.
pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
facett_core::navgraph::declare_own_draw_order(ui); // atoms follow the data order (navgraph §9)
let mut msgs: Vec<Msg> = Vec::new();
let th = facett_core::theme(ui);
// Toolbar: the mode toggle + a converged verdict chip.
ui.horizontal(|ui| {
for m in [ViewMode::Planes3D, ViewMode::TriBadge2D] {
let on = self.state.mode == m;
if ui.selectable_label(on, m.label()).clicked() {
msgs.push(Msg::SetMode(m));
}
}
ui.separator();
let (verdict, col) = if self.state.conv.is_converged() {
("● all converged".to_string(), th.point)
} else {
let (_, need, lock, drift, _) = self.state.conv.status_counts();
(format!("● diverged — {} to fix", need + lock + drift), facett_core::look::RED)
};
ui.colored_label(col, verdict);
});
ui.separator();
let time = self.state.clock_s.unwrap_or_else(|| ui.input(|i| i.time));
msgs.extend(self.paint(ui, time));
// Selected-crate detail line.
if let Some(name) = self.state.selected.clone()
&& let Some(node) = self.state.conv.nodes.iter().find(|n| n.krate == name)
{
ui.separator();
ui.horizontal(|ui| {
ui.strong(&node.krate);
ui.colored_label(status_color(node.status, &th), node.status.label());
ui.label(egui::RichText::new(format!("L {} · S {} · C {}", node.local_ver, node.sparring_ver, node.cratesio_ver)).monospace().color(th.text_dim));
});
}
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-releaseview::ReleaseView::ui",
"ui_render",
!self.state.conv.nodes.is_empty(),
&format!("nodes={} edges={} converged={}", self.state.conv.nodes.len(), self.state.conv.edges.len(), self.state.conv.is_converged()),
);
msgs
}
/// The rich, headless-assertable `state_json` (LAW 6): exactly what the render
/// draws, so a driver can assert per-crate status / versions / the convergence.
fn state_json_value(&self) -> serde_json::Value {
let (conv, need, lock, drift, pubb) = self.state.conv.status_counts();
let nodes: Vec<serde_json::Value> = self
.state
.conv
.nodes
.iter()
.map(|n| {
let (l, s, c) = n.chip_divergence();
serde_json::json!({
"krate": n.krate,
"local": n.local_ver,
"sparring": n.sparring_ver,
"cratesio": n.cratesio_ver,
"status": n.status.label(),
"severity": n.status.severity(),
"diverges": { "local": l, "sparring": s, "cratesio": c },
})
})
.collect();
#[allow(unused_mut)]
let mut out = serde_json::json!({
"component": "ReleaseView",
"title": self.title,
"mode": self.state.mode.label(),
"planes": PLANES,
"node_count": self.state.conv.nodes.len(),
"edge_count": self.state.conv.edges.len(),
"nodes": nodes,
"statuses": {
"converged": conv,
"needs_release": need,
"lock_desync": lock,
"bump_drift": drift,
"publishing": pubb,
},
"converged": self.state.conv.is_converged(),
"converged_fraction": self.state.conv.converged_fraction(),
"selected": self.state.selected,
"clock_s": self.state.clock_s,
"nav": { "scale": self.state.nav.scale },
});
// The CodeVault hero's observable (LAW 6): the 3-plane collapse as data — box
// tally, glitch count, whether the planes have collapsed, and the per-crate
// separated planes. Reported at the (deterministic) clock separation so a robot
// asserts the convergence-collapse without a GPU.
#[cfg(feature = "graph3d")]
{
let sep = self.separation(self.state.clock_s.unwrap_or(0.0));
out["vault"] = vault::vault_state_json(&self.state.conv, sep);
}
out
}
}
/// Build the toposort layout helper from the model (nodes coloured by status, edges
/// by index). Reused only for its `columns()`; the ReleaseView paints its own boxes.
fn build_dag(conv: &ReleaseConvergence) -> DepGraphView {
let dep_nodes: Vec<DepNode> = conv
.nodes
.iter()
.map(|n| DepNode {
label: n.krate.clone(),
sublabel: Some(n.status.label().to_string()),
color: Color32::from_gray(140),
})
.collect();
let dep_edges: Vec<GraphEdge> = conv
.edges
.iter()
.filter(|e| e.from < conv.nodes.len() && e.to < conv.nodes.len())
.map(|e| GraphEdge { from: e.from, to: e.to, via: vec![] })
.collect();
DepGraphView::new(dep_nodes, dep_edges)
}
impl facett_core::Elm for ReleaseView {
type Model = ReleaseState;
type Msg = Msg;
type Effect = ReleaseEffect;
fn title(&self) -> &str {
&self.title
}
fn state(&self) -> &ReleaseState {
&self.state
}
fn update(&mut self, msg: Msg) -> Vec<ReleaseEffect> {
ReleaseView::update(self, msg)
}
fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
ReleaseView::view(self, ui)
}
}
facett_core::impl_facet_via_elm!(ReleaseView, custom_state_json, {
fn state_json(&self) -> serde_json::Value {
self.state_json_value()
}
fn caps(&self) -> facett_core::FacetCaps {
facett_core::FacetCaps { themeable: true, navigable: true, selectable: true, ..facett_core::FacetCaps::NONE }
}
fn selection_json(&self) -> serde_json::Value {
match self.state.selected.as_deref().and_then(|name| self.state.conv.nodes.iter().find(|n| n.krate == name)) {
Some(n) => serde_json::json!({ "krate": n.krate, "status": n.status.label() }),
None => serde_json::Value::Null,
}
}
});
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_colors_are_distinct_red_amber_green() {
let th = facett_core::Theme::default();
let green = status_color(ConvergeStatus::Converged, &th);
let red = status_color(ConvergeStatus::NeedsRelease, &th);
let amber = status_color(ConvergeStatus::BumpDrift, &th);
assert_ne!(green, red, "a converged (green) node must NOT read the same colour as a needs-release (red) one");
assert_ne!(green, amber, "converged (green) differs from drift (amber)");
assert_ne!(red, amber, "red differs from amber");
}
}