facett-graphview/src/decorated_view.rs
//! **DecoratedGraphView** — the egui Facet that paints a [`GraphModel`] under a
//! caller-supplied [`Decorations`] overlay (per-node status **rings** + corner
//! **badges**, plus emphasis **edges** drawn on top), with pan/drag + scroll-zoom
//! and click-to-select (lighting the clicked node's downstream subtree). It is
//! the egui twin of nornir's `src/viz/graph_render.rs::draw_graph` — the same
//! decorations layer the existing `facett-graphview` render model lacked a *view*
//! for (the CPU/GPU rasterizer renders to RGBA; this is the interactive egui
//! pane). Domain-agnostic: facett never learns what a "release gate" is — rings,
//! badges, and cut edges are opaque [`Decorations`].
//!
//! Canonical [`Facet`]: `local()`/`remote()`, `state_json()` (the painted nodes +
//! their decorations + the lit downstream set), `selection_json()`, `caps()`.
use std::collections::{BTreeSet, HashMap, VecDeque};
use facett_core::clip::{ClipKind, ClipPayload, CopySource};
use facett_core::nav::DrillNav;
use facett_core::{FacetCaps, Semantics};
use serde::{Deserialize, Serialize};
use crate::model::{Color, Decorations, GraphEdge, GraphModel, GraphNode, NodeDecoration, Pos, BOX_H, BOX_W};
/// The downstream-reachable set (BFS over forward edges) from `seed`, in node-id
/// space — the visual twin of an `arch trace`, used to dim everything off the lit
/// path when a node is selected.
pub fn downstream_of(model: &GraphModel, seed: &str) -> BTreeSet<String> {
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
for e in &model.edges {
adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
}
let mut lit: BTreeSet<String> = BTreeSet::new();
let mut q: VecDeque<&str> = VecDeque::new();
lit.insert(seed.to_string());
q.push_back(seed);
while let Some(cur) = q.pop_front() {
if let Some(outs) = adj.get(cur) {
for &nxt in outs {
if lit.insert(nxt.to_string()) {
q.push_back(nxt);
}
}
}
}
lit
}
/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
/// driver (or a host) drives the pane through, the same effect a canvas
/// click/drag/scroll produces. Applied by [`DecoratedGraphView::update`].
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
/// Select a node by stable id (lights its downstream subtree), or `None` to clear.
Select(Option<String>),
/// Pan the camera by a screen-space delta (the drag gesture).
PanBy(f32, f32),
/// Zoom by a scroll amount (multiplicative, clamped) — the scroll-wheel gesture.
ZoomBy(f32),
/// Reset pan + zoom (the ⊙ fit affordance).
Fit,
/// **Navigate back** up one drill level (the right-click gesture) — pops the nav
/// history to the parent level. No-op at the root.
Back,
/// **Drill down** into node `id` — descend a level and unfold its children in place.
Drill(String),
/// **Unfold** node `id` in place — show its children WITHOUT closing it/ancestors.
Unfold(String),
/// **Fold** node `id` — hide its children again (the node itself stays visible).
Fold(String),
/// What a click does to the camera: 📌 Stay (never moves) or 🎯 Focus (flies the
/// clicked node to the centre). See [`facett_core::fly`].
SetNavMode(facett_core::fly::NavMode),
/// **Fly** the camera to `(zoom, pan_x, pan_y)` on the smooth zoom-and-pan path,
/// starting at `now` (seconds, the UI clock), for a canvas `viewport_w` wide. A
/// flight already under way is retargeted from where the camera IS.
FlyTo { zoom: f32, pan_x: f32, pan_y: f32, now: f64, viewport_w: f32 },
/// Fly node `id` to the centre of a `viewport`-sized canvas at a readable zoom that
/// frames it and its children (🎯 Focus).
///
/// `viewport` is the VISIBLE part of the canvas (the canvas ∩ its clip), `offset`
/// its centre minus the canvas centre (screen px): the node lands at the centre of
/// what the viewer sees, not of a canvas a scroll area cut.
FocusOn { id: String, now: f64, viewport: (f32, f32), offset: (f32, f32) },
/// Fly to the whole model framed in a `viewport`-sized canvas, centred (⊙ fit).
/// `viewport` / `offset` as in [`Msg::FocusOn`].
FitView { now: f64, viewport: (f32, f32), offset: (f32, f32) },
/// Advance a flight to `now`.
Tick(f64),
}
/// Margin (px) ⊙ fit and 🎯 Focus leave inside the canvas.
pub const FIT_MARGIN: f32 = 24.0;
/// The zoom 🎯 Focus lands at when the node and its children fit: labels readable.
pub const FOCUS_ZOOM: f32 = 1.0;
/// A 2D flight: the shared path + the canvas width it maps zoom with.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Flight2D {
pub flight: facett_core::fly::Flight,
pub viewport_w: f32,
}
/// Side work as data (FC-8). The pane does no I/O — every [`Msg`] mutates only the
/// in-memory [`GraphViewState`] — so this is uninhabited on purpose: the type-checked
/// statement that [`DecoratedGraphView::update`] never asks the host to do anything.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}
/// **The complete observable state (FC-1 / FC-3)** of a [`DecoratedGraphView`], in one
/// serializable, round-trippable struct: the laid-out [`GraphModel`], the
/// caller-supplied [`Decorations`] overlay, the selected node (keyed on its stable
/// id, FC-5), and the camera (pan + zoom). [`DecoratedGraphView::state`] hands back a
/// `&GraphViewState`; a headless driver ([`facett_core::harness`]) snapshots it after
/// feeding a `Vec<Msg>`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GraphViewState {
/// The graph to paint (nodes laid out in world space + edges).
pub model: GraphModel,
/// The caller-supplied ring/badge + emphasis-edge overlay.
pub decorations: Decorations,
/// The selected node id, if any (lights its downstream subtree).
pub selected: Option<String>,
/// Camera pan (screen-space) — the x component.
pub pan_x: f32,
/// Camera pan (screen-space) — the y component.
pub pan_y: f32,
/// Camera zoom (world→screen scale), clamped to `[0.25, 4.0]` by interaction.
pub zoom: f32,
/// The shared drill-down navigator (levels + history/back + in-place unfold),
/// reused from [`facett_core::nav`] (L5) so the 2D pane and the 3D Code Vault
/// navigate identically. Seeded fully-unfolded when a model is set, so the pane
/// shows the whole graph by default; folding / right-click-back are the opt-ins.
#[serde(default = "DrillNav::warehouse")]
pub nav: DrillNav,
/// What a click does to the camera (📌 Stay by default).
#[serde(default)]
pub nav_mode: facett_core::fly::NavMode,
/// The camera flight under way, if any (UI-time, never serialized).
#[serde(skip)]
pub flight: Option<Flight2D>,
}
impl Default for GraphViewState {
fn default() -> Self {
Self {
model: GraphModel::default(),
decorations: Decorations::default(),
selected: None,
pan_x: 0.0,
pan_y: 0.0,
zoom: 1.0,
nav: DrillNav::warehouse(),
nav_mode: facett_core::fly::NavMode::Stay,
flight: None,
}
}
}
/// An interactive, decorated pan/zoom graph pane Facet.
pub struct DecoratedGraphView {
title: String,
/// All observable state (FC-3).
state: GraphViewState,
/// The screen-space rect the last [`view`](Self::view) painted its canvas into
/// (interior-mutable so the FC-9 `view(&self)` stays pure). NOT part of
/// [`GraphViewState`] — it is a paint artefact, not observable model state. A
/// host that embeds this pane reads it (with [`world_to_screen`](Self::world_to_screen))
/// to map a node's world position onto the screen for out-of-band hit-testing —
/// e.g. a robot driver synthesising a click on a specific node.
paint_rect: std::cell::Cell<Option<egui::Rect>>,
/// Legibility + GPU opt-ins. Default all-off ⇒ the historical pane, pixel for pixel.
opts: crate::legible::ViewOpts,
/// The spatial pick index — so a click on a 100 000-node graph queries a
/// neighbourhood instead of scanning every node. Ephemeral cache (FC-7).
pick: std::cell::RefCell<crate::legible::PickIndex>,
/// What the last painted frame's legibility work did (published in `state_json`).
last_legibility: std::cell::RefCell<crate::legible::ViewReport>,
/// Bumped by every [`with_model`](Self::with_model): what the resident cloud lane keys
/// its upload on, so the pane never hashes its own model.
model_gen: u64,
/// Bumped by every drill-navigator message (fold / unfold / drill / back): the unfold
/// decides which nodes the resident cloud holds.
vis_gen: u64,
/// Bumped by every [`with_decorations`](Self::with_decorations): the resident lane
/// keys its "which nodes carry a ring or badge" list on it.
deco_gen: u64,
/// `(rings, badges)` of the current decorations — counted once in
/// [`with_decorations`](Self::with_decorations), not per frame in the header (a
/// coderoom decorates EVERY node with its size, 100 000 entries at its ceiling).
deco_counts: (usize, usize),
/// The resident cloud lane's per-model work (ephemeral, FC-7).
#[cfg(feature = "gpu-cloud")]
resident: std::cell::RefCell<crate::resident::ResidentCache>,
/// Whether the nav mode was set (by the viewer's remembered choice, the toggle or a
/// host) — until then the first frame loads the viewer's remembered choice.
nav_mode_set: std::cell::Cell<bool>,
}
impl DecoratedGraphView {
/// An empty pane titled `title`.
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
state: GraphViewState::default(),
paint_rect: std::cell::Cell::new(None),
opts: crate::legible::ViewOpts::default(),
pick: std::cell::RefCell::new(crate::legible::PickIndex::default()),
last_legibility: std::cell::RefCell::new(crate::legible::ViewReport::default()),
model_gen: 0,
vis_gen: 0,
deco_gen: 0,
deco_counts: (0, 0),
#[cfg(feature = "gpu-cloud")]
resident: std::cell::RefCell::new(crate::resident::ResidentCache::default()),
nav_mode_set: std::cell::Cell::new(false),
}
}
/// Opt into the **shared instanced GPU cloud** (the renderer `facett-graph3d`
/// installs). Safe unconditionally: with no renderer installed, or without the
/// `gpu-cloud` feature, the CPU painter runs unchanged rather than a blank pane.
#[must_use]
pub fn with_gpu(mut self, on: bool) -> Self {
self.opts.gpu_cloud = on;
self
}
/// Opt into label collision avoidance + the edge ink budget.
#[must_use]
pub fn with_legibility(mut self, on: bool) -> Self {
self.opts.thin_labels = on;
self.opts.thin_edges = on;
self
}
/// What the last painted frame's legibility work did.
#[must_use]
pub fn legibility(&self) -> crate::legible::ViewReport {
self.last_legibility.borrow().clone()
}
/// The screen-space rect the last [`view`](Self::view) drew the graph canvas
/// into, or `None` before the first paint. The projection origin is
/// `rect.center() + (pan_x, pan_y)`.
pub fn last_paint_rect(&self) -> Option<egui::Rect> {
self.paint_rect.get()
}
/// Project a world-space [`Pos`] to the screen using the last painted canvas
/// rect + the current camera (pan/zoom), matching [`view`](Self::view)'s own
/// projection exactly. `None` until the first paint recorded a rect. This is the
/// seam a host/robot uses to synthesise a pointer event on a given node.
pub fn world_to_screen(&self, p: Pos) -> Option<egui::Pos2> {
let rect = self.paint_rect.get()?;
let zoom = if self.state.zoom > 0.0 { self.state.zoom } else { 1.0 };
let origin = rect.center() + egui::vec2(self.state.pan_x, self.state.pan_y);
Some(origin + egui::vec2(p.x, p.y) * zoom)
}
/// Set the graph model (nodes already laid out in world space + edges). Seeds the
/// drill navigator fully-unfolded over this model so the whole graph shows by
/// default (folding is the opt-in restriction).
pub fn with_model(mut self, model: GraphModel) -> Self {
let mut nav = DrillNav::warehouse();
nav.unfold_all(model.nodes.iter().map(|n| n.id.as_str()));
self.state.nav = nav;
self.state.model = model;
self.model_gen = self.model_gen.wrapping_add(1);
self
}
/// Drill-navigator roots: nodes with no incoming edge (the top of the dep DAG).
fn nav_roots(&self) -> Vec<String> {
let incoming: BTreeSet<&str> = self.state.model.edges.iter().map(|e| e.to.as_str()).collect();
self.state.model.nodes.iter().filter(|n| !incoming.contains(n.id.as_str())).map(|n| n.id.clone()).collect()
}
/// **The forward-adjacency index** — every node's children (forward edge targets)
/// in ONE pass over the edge list. The same index [`downstream_of`] builds, for
/// the same reason (LAW 5: one idiom, not two).
///
/// It exists because the child lookup used to be a `children_of(id)` that FILTERED
/// THE WHOLE EDGE LIST by string comparison, called once per expanded node — an
/// `O(V · E)` frame. Under a few hundred nodes that is free; at this engine's
/// declared 100 000-node ceiling it is ~10^10 string compares and the host stops
/// repainting altogether (measured on oden 2026-09-03: 4 904 ms for a 20 000-node
/// unfold, cost ratio 3.99 across a doubling — see `tests/unfold_scales.rs`).
/// Borrowed `&str`, so building it allocates nothing per node.
fn forward_index(&self) -> HashMap<&str, Vec<&str>> {
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
for e in &self.state.model.edges {
adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
}
adj
}
/// The set of node ids **visible** under the current progressive unfold (roots +
/// every expanded node's children; ancestors always retained). Default = all.
///
/// The unfold walk is unchanged — [`DrillNav::visible`] still asks for a node's
/// children and still consults `expanded`, so a folded subtree stays folded. Only
/// the LOOKUP moved, from a rescan per node to [`forward_index`](Self::forward_index).
pub fn visible_ids(&self) -> BTreeSet<String> {
let adj = self.forward_index();
self.state
.nav
.visible(self.nav_roots(), |id| adj.get(id).map_or_else(Vec::new, |cs| cs.iter().map(|c| (*c).to_string()).collect()))
}
/// Read-only access to the drill navigator (levels / history / expanded).
pub fn nav(&self) -> &DrillNav {
&self.state.nav
}
/// Set the decorations overlay (rings/badges + emphasis edges).
pub fn with_decorations(mut self, decorations: Decorations) -> Self {
self.deco_counts = (
decorations.nodes.values().filter(|d| d.ring.is_some()).count(),
decorations.nodes.values().filter(|d| d.badge.is_some()).count(),
);
self.state.decorations = decorations;
self.deco_gen = self.deco_gen.wrapping_add(1);
self
}
/// Re-title (the deck keys facets off [`Facet::title`]).
pub fn set_title(&mut self, title: impl Into<String>) {
self.title = title.into();
}
/// **FC-3** — read the complete observable state at any frame boundary.
pub fn state(&self) -> &GraphViewState {
&self.state
}
/// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
/// (always empty) [`Effect`]s the host should run. The canvas click/drag/scroll
/// and the fit button all route their [`Msg`] here too, so live == headless.
pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
match msg {
Msg::Select(id) => self.state.selected = id,
Msg::PanBy(dx, dy) => {
// a drag takes the camera from wherever the flight had it
self.state.flight = None;
self.state.pan_x += dx;
self.state.pan_y += dy;
}
Msg::ZoomBy(scroll) => {
self.state.flight = None;
self.state.zoom = (self.state.zoom * (1.0 + scroll * 0.001)).clamp(0.25, 4.0);
}
Msg::SetNavMode(m) => {
self.state.nav_mode = m;
self.nav_mode_set.set(true);
}
Msg::FlyTo { zoom, pan_x, pan_y, now, viewport_w } => self.fly_to(zoom, pan_x, pan_y, now, viewport_w),
Msg::FocusOn { id, now, viewport, offset } => {
if let Some((z, px, py)) = self.focus_camera(&id, viewport) {
self.fly_to(z, px + offset.0, py + offset.1, now, viewport.0);
}
}
Msg::FitView { now, viewport, offset } => {
if let Some((z, px, py)) = self.fit_camera(viewport, FIT_MARGIN) {
self.fly_to(z, px + offset.0, py + offset.1, now, viewport.0);
}
}
Msg::Tick(now) => {
if let Some(f) = self.state.flight {
let (c, w) = f.flight.at(now);
let (pan, zoom) = facett_core::fly::to_pan_zoom(c, w, f.viewport_w);
(self.state.pan_x, self.state.pan_y, self.state.zoom) = (pan[0], pan[1], zoom);
if f.flight.done(now) {
self.state.flight = None;
}
}
}
Msg::Fit => {
self.state.flight = None;
self.state.pan_x = 0.0;
self.state.pan_y = 0.0;
self.state.zoom = 1.0;
}
Msg::Back => {
self.state.nav.back();
self.vis_gen += 1;
}
Msg::Drill(id) => {
self.state.nav.drill(id);
self.vis_gen += 1;
}
Msg::Unfold(id) => {
self.state.nav.unfold(id);
self.vis_gen += 1;
}
Msg::Fold(id) => {
self.state.nav.fold(&id);
self.vis_gen += 1;
}
}
Vec::new()
}
/// Reset pan + zoom (the ⊙ fit affordance). A thin wrapper over the FC-2
/// [`update`](Self::update) mutation path.
///
/// Note this **recentres at zoom 1**; it does not frame the model. For a laid-out
/// graph wider than the widget — which is every graph past a few dozen nodes — use
/// [`fit_model`](Self::fit_model).
pub fn fit(&mut self) {
let _ = self.update(Msg::Fit);
}
/// **Frame the whole model in a `viewport`-sized widget** — the honest "⊙ fit" for a
/// laid-out graph, and the one this crate was missing.
///
/// [`fit`](Self::fit) only recentres at zoom 1, and the interactive
/// [`Msg::ZoomBy`] clamps at `0.25`, so a host had **no way at all** to frame a
/// graph whose world box is more than 4× the widget. Measured on korp
/// (2026-09-01): a live 132-node FalkorDB scene laid out at one chip of clearance
/// per node spans ~3 500 world units; in a 900 × 640 pane it added **0.006** of ink
/// over the same pane holding an EMPTY model — the pane was chrome and a handful of
/// stray chips. With this it frames.
///
/// The math is `facett_graphpan::GraphView::fit_bounds` — the same camera model,
/// the proven one (L5, deliberately not a second derivation): set `zoom` so the
/// world box fills the viewport inside `margin` px, and `pan` so the box centre
/// lands at the widget centre. The zoom floor here is `0.001`, not the interactive
/// `0.25`: a fit is a computed answer, not a gesture, and refusing to zoom out far
/// enough to see the data is not a safety rail.
///
/// No-op on an empty model (nothing to frame), leaving the camera untouched.
pub fn fit_model(&mut self, viewport: egui::Vec2, margin: f32) {
if let Some((z, px, py)) = self.fit_camera((viewport.x, viewport.y), margin) {
self.state.flight = None;
(self.state.zoom, self.state.pan_x, self.state.pan_y) = (z, px, py);
}
}
/// The camera `(zoom, pan_x, pan_y)` that frames the whole model, CENTRED, in a
/// `viewport`-sized canvas with `margin` px around it. `None` on an empty model.
#[must_use]
pub fn fit_camera(&self, viewport: (f32, f32), margin: f32) -> Option<(f32, f32, f32)> {
let (min_x, min_y, max_x, max_y) = self.state.model.world_bounds()?;
let bw = (max_x - min_x).max(1.0);
let bh = (max_y - min_y).max(1.0);
let zoom = ((viewport.0 - 2.0 * margin) / bw).min((viewport.1 - 2.0 * margin) / bh).clamp(0.001, 4.0);
Some((zoom, -(min_x + max_x) * 0.5 * zoom, -(min_y + max_y) * 0.5 * zoom))
}
/// The 🎯 Focus camera for node `id` in a `viewport`-sized canvas: the node EXACTLY
/// at the centre, at [`FOCUS_ZOOM`] — or less, so the node and its (visible)
/// children all fit around it. `None` when there is no such node.
#[must_use]
pub fn focus_camera(&self, id: &str, viewport: (f32, f32)) -> Option<(f32, f32, f32)> {
let node = self.state.model.nodes.iter().find(|n| n.id == id)?;
let visible = self.visible_ids();
let pos: HashMap<&str, Pos> = self.state.model.nodes.iter().map(|n| (n.id.as_str(), n.pos)).collect();
let (mut dx, mut dy) = (0.0f32, 0.0f32);
for e in self.state.model.edges.iter().filter(|e| e.from == id && visible.contains(&e.to)) {
if let Some(p) = pos.get(e.to.as_str()) {
dx = dx.max((p.x - node.pos.x).abs());
dy = dy.max((p.y - node.pos.y).abs());
}
}
let half = |v: f32| (v * 0.5 - FIT_MARGIN).max(1.0);
let zoom = FOCUS_ZOOM
.min(half(viewport.0) / (dx + BOX_W * 0.5))
.min(half(viewport.1) / (dy + BOX_H * 0.5))
.clamp(0.001, 4.0);
Some((zoom, -node.pos.x * zoom, -node.pos.y * zoom))
}
/// Start (or retarget) a flight to `(zoom, pan_x, pan_y)` from the camera as it is.
fn fly_to(&mut self, zoom: f32, pan_x: f32, pan_y: f32, now: f64, viewport_w: f32) {
use facett_core::fly;
let (c1, w1) = fly::from_pan_zoom([pan_x, pan_y], zoom, viewport_w);
let flight = match self.state.flight {
Some(f) => {
// retarget from where the camera IS (in the old flight's mapping)
let (c, w) = f.flight.at(now);
let (pan, z) = fly::to_pan_zoom(c, w, f.viewport_w);
let (c0, w0) = fly::from_pan_zoom(pan, z, viewport_w);
fly::Flight::new(now, c0, w0, c1, w1)
}
None => {
let (c0, w0) = fly::from_pan_zoom([self.state.pan_x, self.state.pan_y], self.state.zoom, viewport_w);
fly::Flight::new(now, c0, w0, c1, w1)
}
};
self.state.flight = Some(Flight2D { flight, viewport_w });
}
/// Is the camera flying?
#[must_use]
pub fn flying(&self) -> bool {
self.state.flight.is_some()
}
/// The nav mode (📌 Stay / 🎯 Focus).
#[must_use]
pub fn nav_mode(&self) -> facett_core::fly::NavMode {
self.state.nav_mode
}
/// Whether the nav mode was set (remembered choice loaded, toggled, or a host's).
#[must_use]
pub fn nav_mode_set(&self) -> bool {
self.nav_mode_set.get()
}
/// Put the camera at `(zoom, pan_x, pan_y)` — what [`state`](Self::state) reports —
/// so a host that REBUILDS the pane (a new scene for the same graph: the coderoom's
/// what-breaks colours) keeps the user's view instead of jumping back to zoom 1.
/// The zoom is clamped to [`fit_model`](Self::fit_model)'s range.
pub fn set_camera(&mut self, zoom: f32, pan_x: f32, pan_y: f32) {
self.state.zoom = if zoom.is_finite() { zoom.clamp(0.001, 4.0) } else { 1.0 };
self.state.pan_x = if pan_x.is_finite() { pan_x } else { 0.0 };
self.state.pan_y = if pan_y.is_finite() { pan_y } else { 0.0 };
}
/// Select a node by id (lights its downstream subtree), or `None` to clear. A thin
/// wrapper over the FC-2 [`update`](Self::update) mutation path.
pub fn select(&mut self, id: Option<String>) {
let _ = self.update(Msg::Select(id));
}
/// The downstream-lit set for the current selection (empty when none).
pub fn lit_set(&self) -> BTreeSet<String> {
self.state.selected.as_deref().map(|s| downstream_of(&self.state.model, s)).unwrap_or_default()
}
/// **Discovery-contract `local()`** — a realistic 4-node DAG (a → b, a → c,
/// b → d, c → d) decorated with a green coverage ring on `a`, an amber badge
/// on `b`, and an emphasis "cut" edge `d → a` (dashed, labelled), so the pane
/// paints real chips, rings, a badge, and an overlay edge with no host.
pub fn local() -> Self {
let n = |id: &str, label: &str, x: f32, y: f32, fill: Color| GraphNode {
id: id.into(),
label: label.into(),
fill,
stroke: Color::WHITE,
pos: Pos::new(x, y),
};
let e = |from: &str, to: &str, dashed: bool| GraphEdge {
from: from.into(),
to: to.into(),
color: Color::rgb(200, 200, 220),
dashed,
label: None,
};
let model = GraphModel {
nodes: vec![
n("a", "alpha", 0.0, 0.0, Color::rgb(60, 90, 160)),
n("b", "beta", 260.0, -90.0, Color::rgb(60, 140, 90)),
n("c", "gamma", 260.0, 90.0, Color::rgb(160, 90, 60)),
n("d", "delta", 520.0, 0.0, Color::rgb(120, 90, 160)),
],
edges: vec![e("a", "b", false), e("a", "c", false), e("b", "d", false), e("c", "d", false)],
};
let mut nodes = HashMap::new();
nodes.insert(
"a".to_string(),
NodeDecoration { ring: Some(Color::rgb(90, 200, 140)), ..Default::default() },
);
nodes.insert(
"b".to_string(),
NodeDecoration {
badge: Some("⚠".into()),
badge_color: Some(Color::rgb(230, 180, 60)),
..Default::default()
},
);
let decorations = Decorations {
nodes,
edges: vec![GraphEdge {
from: "d".into(),
to: "a".into(),
color: Color::rgb(230, 90, 90),
dashed: true,
label: Some("cut".into()),
}],
};
Self::new("DecoratedGraph").with_model(model).with_decorations(decorations)
}
/// **Discovery-contract `remote()`** — same surface, re-titled; the variant a
/// host builds from a server-streamed model. Kept so the matrix sees both ctors.
pub fn remote() -> Self {
let mut v = Self::local();
v.title = "DecoratedGraph (remote)".into();
v
}
fn col(c: Color) -> egui::Color32 {
c.into()
}
}
impl DecoratedGraphView {
/// The copyable text: the selected node's `label` when one is selected, else
/// every node label, one per line.
pub fn copy_text(&self) -> Option<String> {
if self.state.model.nodes.is_empty() {
return None;
}
if let Some(id) = self.state.selected.as_deref()
&& let Some(n) = self.state.model.nodes.iter().find(|n| n.id == id)
{
return Some(n.label.clone());
}
Some(self.state.model.nodes.iter().map(|n| n.label.clone()).collect::<Vec<_>>().join("\n"))
}
}
// ── typed copy (§16) — read-only: selected node label or the label list ───────
impl CopySource for DecoratedGraphView {
fn copy_kinds(&self) -> &[ClipKind] {
&[ClipKind::Text]
}
fn copy_payload(&self) -> Option<ClipPayload> {
self.copy_text().map(ClipPayload::Text)
}
}
impl DecoratedGraphView {
/// **FC-9 render** — a **pure** function of `&self`: it paints the header, the
/// edges + decorated chips, and *returns* the [`Msg`]s the interactions produced
/// (fit / pan / zoom / node-select). It MUST NOT mutate the [`GraphViewState`];
/// the [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies the
/// returned messages through [`update`](Self::update). One-frame latency on the
/// camera + selection is the standard immediate-mode Elm trade (a repaint follows).
pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
let mut msgs: Vec<Msg> = Vec::new();
let mut fit_clicked = false;
let now = ui.input(|i| i.time);
// The viewer's remembered 📌 Stay / 🎯 Focus, on the first frame of a pane nobody
// set it on (UI-local memory, one choice for every graph pane).
if !self.nav_mode_set.get() {
let m = facett_core::fly::NavMode::remembered(ui.ctx());
msgs.push(Msg::SetNavMode(m));
}
// A flight under way: advance it, and keep repainting only while it flies.
if self.state.flight.is_some() {
msgs.push(Msg::Tick(now));
ui.ctx().request_repaint();
}
let th = facett_core::theme(ui);
// Effective zoom for this frame's projection: never paint at a non-positive
// scale. Read-only — the stored zoom is kept valid by `update`'s clamp.
let zoom = if self.state.zoom > 0.0 { self.state.zoom } else { 1.0 };
// ── header: counts + a fit button ─────────────────────────────────
ui.horizontal_wrapped(|ui| {
ui.label(
egui::RichText::new(format!("{} nodes · {} edges", self.state.model.nodes.len(), self.state.model.edges.len()))
.color(th.text)
.strong(),
);
ui.separator();
ui.label(
egui::RichText::new(format!("{} ring(s) · {} badge(s)", self.deco_counts.0, self.deco_counts.1))
.color(th.text_dim),
);
if ui.button("⊙ fit").on_hover_text("frame the whole graph, centred").clicked() {
fit_clicked = true;
}
if let Some(m) = facett_core::fly::NavMode::toggle(ui, self.state.nav_mode) {
msgs.push(Msg::SetNavMode(m));
}
});
ui.separator();
if self.state.model.nodes.is_empty() {
// The empty-scene state carries the stable UI error code
// `facet-graphview-1` (`facett_core::errcode`) on its a11y atom, so tests +
// consumers react to the CODE, not a matched string — and the pane is never
// a silent blank canvas.
let resp = ui.label(egui::RichText::new("no scene to lay out").color(th.text_dim));
resp.widget_info(|| {
facett_core::a11y::Semantics::error("no scene to lay out")
.error_code("facet-graphview-1")
.widget_info()
});
}
let (resp, painter) = ui.allocate_painter(facett_core::nav::canvas_size(ui), egui::Sense::click_and_drag());
// ⊙ fit and 🎯 Focus centre on what the viewer SEES: the canvas ∩ its clip (a
// canvas inside a scroll area or a collapsing section is partly off-screen).
let seen = resp.rect.intersect(ui.clip_rect());
let seen = if seen.width() > 1.0 && seen.height() > 1.0 { seen } else { resp.rect };
let viewport = (seen.width(), seen.height());
let offset = (seen.center().x - resp.rect.center().x, seen.center().y - resp.rect.center().y);
if fit_clicked {
msgs.push(Msg::FitView { now, viewport, offset });
}
// Record the canvas rect so an embedding host can map world→screen for
// out-of-band hit-testing (see `world_to_screen`). Interior-mutable: FC-9
// keeps `view(&self)` pure w.r.t. the observable model.
self.paint_rect.set(Some(resp.rect));
painter.rect_filled(resp.rect, 4.0, th.bg);
if resp.dragged() {
let d = resp.drag_delta();
msgs.push(Msg::PanBy(d.x, d.y));
}
if resp.hovered() {
let scroll = ui.input(|i| i.smooth_scroll_delta.y);
if scroll != 0.0 {
msgs.push(Msg::ZoomBy(scroll));
}
}
let origin = resp.rect.center() + egui::vec2(self.state.pan_x, self.state.pan_y);
let project = |p: Pos| origin + egui::vec2(p.x, p.y) * zoom;
// Right-click = navigate BACK up one drill level (the nav-history pop).
if resp.secondary_clicked() {
msgs.push(Msg::Back);
}
// left-click-to-select.
if let Some(click) = resp.clicked().then(|| resp.interact_pointer_pos()).flatten() {
// Picking goes through the SPATIAL INDEX, not a scan of every node: the
// index is rebuilt only when the camera actually moved, so a pointer event
// costs a neighbourhood query. `PickIndex` projects with the same affine
// this frame paints with (origin = rect.centre + pan), and `tests/legibility.rs`
// sweeps probes across both to prove the answers are identical.
let cam = crate::model::Camera {
pan_x: origin.x,
pan_y: origin.y,
zoom,
};
let mut pick = self.pick.borrow_mut();
pick.ensure(&self.state.model, &cam);
let hit = pick
.pick(&cam, resp.rect, click)
.map(|i| self.state.model.nodes[i].id.clone());
// 📌 Stay: a click never moves the camera. 🎯 Focus: it flies the node to the
// centre (a click mid-flight retargets from where the camera is).
if let (facett_core::fly::NavMode::Focus, Some(id)) = (self.state.nav_mode, hit.as_ref()) {
msgs.push(Msg::FocusOn { id: id.clone(), now, viewport, offset });
}
msgs.push(Msg::Select(hit));
}
// ── the RESIDENT cloud at scale: the model uploaded once, per frame the camera
// and the overlays. Everything it needs per model (visible set, id map, lit
// trace) is cached, so none of the per-frame work below runs. `None` (no
// renderer, a zero-pixel rect) falls through to the per-frame / CPU road.
#[cfg(feature = "gpu-cloud")]
if self.opts.gpu_cloud
&& self.state.model.nodes.len() > facett_core::render::gpu::graphcloud::cloud::RESIDENT_ABOVE
&& let Some(report) = self.paint_resident(ui, &painter, resp.rect, origin, zoom, [th.text, th.text_dim, th.accent])
{
*self.last_legibility.borrow_mut() = report;
self.finish_view(&resp, self.state.selected.as_ref().map_or(0, |_| self.resident.borrow().lit.len()));
return msgs;
}
// The progressive-unfold visible set (default = all): folded children vanish
// while ancestors stay. Edges/chips off this set are skipped.
let visible = self.visible_ids();
let lit = self.lit_set();
let highlighting = self.state.selected.is_some();
let dim = |c: egui::Color32| c.linear_multiply(0.22);
let idx: HashMap<&str, &GraphNode> =
self.state.model.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
// ── the GPU cloud lane + the legibility plan ─────────────────────────
// `drew_on_gpu` is the ONLY thing that may suppress the CPU cloud, and it is
// whatever the shared `draw_cloud` gate returns — which is `false` unless a
// host really installed a renderer. Opting in without installing therefore
// paints the unchanged CPU frame, never a blank pane.
let drew_on_gpu = {
let cam = crate::model::Camera { pan_x: origin.x, pan_y: origin.y, zoom };
crate::legible::draw_gpu_cloud(ui, resp.rect, &self.state.model, &cam, self.opts.gpu_cloud)
};
let plan = (!self.opts.is_off()).then(|| {
crate::legible::plan_view(
&self.state.model,
origin,
zoom,
resp.rect,
&visible,
&lit,
self.state.selected.as_deref(),
self.opts,
)
});
{
let mut r = plan.as_ref().map(|p| p.report.clone()).unwrap_or_default();
r.gpu_cloud = drew_on_gpu;
*self.last_legibility.borrow_mut() = r;
}
let thinned_labels = plan.as_ref().and_then(crate::legible::ViewPlan::labels).is_some();
// edges first.
let draw_edge = |e: &GraphEdge, emphasise: bool| {
let (Some(fa), Some(fb)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) else {
return;
};
// Skip wiring to a folded-away node.
if !visible.contains(&e.from) || !visible.contains(&e.to) {
return;
}
let on_trace = highlighting && lit.contains(&e.from) && lit.contains(&e.to);
let mut color = Self::col(e.color);
if !emphasise && highlighting && !on_trace {
color = dim(color);
}
let w = if emphasise { 2.6_f32 } else if on_trace { 2.4_f32 } else { 1.4_f32 };
stroke_edge(&painter, project(fa.pos), project(fb.pos), e, zoom, color, w, emphasise, th.text_dim);
};
if !drew_on_gpu {
match plan.as_ref().and_then(crate::legible::ViewPlan::edges) {
// The ink budget decided; draw exactly the survivors, in index order,
// so the paint order (and the over-draw) is the unthinned one minus
// the dropped edges.
Some(keep) => {
for &ei in keep {
if let Some(e) = self.state.model.edges.get(ei as usize) {
draw_edge(e, false);
}
}
}
None => {
for e in &self.state.model.edges {
draw_edge(e, false);
}
}
}
}
// Decoration edges are never thinned and never GPU-batched: they are the
// answer the user asked for.
for e in &self.state.decorations.edges {
draw_edge(e, true);
}
// chips.
for nd in &self.state.model.nodes {
if !visible.contains(&nd.id) {
continue;
}
let c = project(nd.pos);
let on_trace = highlighting && lit.contains(&nd.id);
let mut fill = Self::col(nd.fill);
if highlighting && !on_trace {
fill = dim(fill);
}
let rect = egui::Rect::from_center_size(c, egui::vec2(BOX_W, BOX_H) * zoom);
// With the GPU cloud up the discs are already on screen; the CPU keeps the
// thin overlays (rings, badges, labels) but not the 2.47 M-triangle cloud.
if !drew_on_gpu {
painter.rect_filled(rect, 5.0 * zoom, fill);
}
// ONE clean ring, and ONLY when the node carries a meaning: the current
// selection, or a status (decoration) ring. Plain nodes get NO ring — the
// old `else` painted a kind-stroke on every chip, which read as an ugly
// second ring next to the status ring.
let deco = self.state.decorations.nodes.get(&nd.id);
let ring = if self.state.selected.as_deref() == Some(nd.id.as_str()) {
Some(egui::Stroke::new(3.0_f32, th.accent))
} else if let Some(rc) = deco.and_then(|d| d.ring) {
let rc = Self::col(rc);
Some(egui::Stroke::new(2.4_f32, if highlighting && !on_trace { dim(rc) } else { rc }))
} else {
None
};
if let Some(ring) = ring {
painter.rect_stroke(rect, 5.0 * zoom, ring, egui::epaint::StrokeKind::Outside);
}
if zoom > 0.45 {
// With collision avoidance on, labels are painted ONCE from the plan
// (below) at the boxes that survived — not once per chip on top of
// whatever was already there.
if !thinned_labels {
painter.text(
c,
egui::Align2::CENTER_CENTER,
&nd.label,
egui::FontId::proportional(11.0 * zoom.clamp(0.7, 1.4)),
th.text,
);
}
if let Some(badge) = deco.and_then(|d| d.badge.as_deref()) {
let bc = deco.and_then(|d| d.badge_color).map(Self::col).unwrap_or(th.text);
painter.text(
rect.right_top() + egui::vec2(-2.0, 1.0),
egui::Align2::RIGHT_TOP,
badge,
egui::FontId::proportional(12.0 * zoom.clamp(0.7, 1.4)),
if highlighting && !on_trace { dim(bc) } else { bc },
);
}
}
}
// The surviving labels, painted once, at the boxes collision avoidance chose.
if zoom > 0.45
&& let Some(p) = plan.as_ref().and_then(crate::legible::ViewPlan::labels)
{
for pl in &p.placed {
let Some(nd) = self.state.model.nodes.get(pl.index) else { continue };
painter.text(
pl.rect.center(),
egui::Align2::CENTER_CENTER,
&nd.label,
egui::FontId::proportional(11.0 * zoom.clamp(0.7, 1.4)),
th.text,
);
}
}
self.finish_view(&resp, lit.len());
msgs
}
/// The pane-level a11y summary + the testmatrix row — the end of every frame, on
/// every lane.
#[cfg_attr(not(feature = "testmatrix"), allow(unused_variables))]
fn finish_view(&self, resp: &egui::Response, lit: usize) {
resp.widget_info(|| {
Semantics::image(format!(
"decorated graph — {} nodes, {} edges, selected {}",
self.state.model.nodes.len(),
self.state.model.edges.len(),
self.state.selected.as_deref().unwrap_or("none"),
))
.widget_info()
});
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-graphview::DecoratedGraphView::view",
"ui_render",
!self.state.model.nodes.is_empty(),
&format!("nodes={} edges={} lit={}", self.state.model.nodes.len(), self.state.model.edges.len(), lit),
);
}
/// **The resident lane's frame** (see [`crate::resident`]): sync the per-model caches,
/// push the camera (and the model when it changed), then paint the overlays — the
/// lit trace, decoration edges, rings + badges of the selected / decorated nodes, and
/// the on-screen labels. `None` when the lane cannot draw.
#[cfg(feature = "gpu-cloud")]
#[allow(clippy::too_many_lines)]
fn paint_resident(
&self,
ui: &egui::Ui,
painter: &egui::Painter,
rect: egui::Rect,
origin: egui::Pos2,
zoom: f32,
[text, text_dim, accent]: [egui::Color32; 3],
) -> Option<crate::legible::ViewReport> {
use crate::resident::{LABEL_CAP, LIT_EDGE_CAP};
use facett_core::render::gpu::graphcloud::{cloud, CloudCamera};
if !cloud::cloud_renderer_installed() {
return None;
}
let mut cache = self.resident.borrow_mut();
cache.sync(
self.model_gen,
self.vis_gen,
&self.state.model,
|| self.visible_ids(),
|| self.lit_set(),
self.state.selected.as_ref(),
);
cache.sync_decorated(self.model_gen, self.vis_gen, self.deco_gen, &self.state.decorations);
let ppp = ui.ctx().pixels_per_point();
let o = (origin - rect.min) * ppp;
let cam = CloudCamera::plane(
[rect.width() * ppp, rect.height() * ppp],
[o.x, o.y],
zoom * ppp,
(BOX_H * 0.5 * zoom * ppp).max(1.0),
// The CPU painter's edge: a 1.4 px stroke (half 0.7). The lane drew 1.5 px
// halves until 2026-10-02 — twice the painter's ink, and at 100k long edges
// (falkor's force layout, the coderoom's ranks) the iGPU's fill bound.
0.7 * ppp,
2.5,
);
if !cache.draw(ui, rect, &self.state.model, cam) {
return None;
}
let cache = &*cache;
let model = &self.state.model;
let project = |p: Pos| origin + egui::vec2(p.x, p.y) * zoom;
let at = |id: &str| cache.idx.get(id).map(|&i| i as usize).filter(|&i| cache.shown(i));
let highlighting = self.state.selected.is_some();
let dim = |c: egui::Color32| c.linear_multiply(0.22);
let mut report = crate::legible::ViewReport { gpu_cloud: true, resident: true, ..Default::default() };
// The lit downstream trace, over the cloud.
for &ei in cache.lit_edges.iter().take(LIT_EDGE_CAP) {
let e = &model.edges[ei as usize];
if let (Some(a), Some(b)) = (at(&e.from), at(&e.to)) {
painter.line_segment([project(model.nodes[a].pos), project(model.nodes[b].pos)], egui::Stroke::new(1.6, accent));
}
}
// Decoration (cut) edges: never thinned, never in the cloud.
for e in &self.state.decorations.edges {
if let (Some(a), Some(b)) = (at(&e.from), at(&e.to)) {
stroke_edge(painter, project(model.nodes[a].pos), project(model.nodes[b].pos), e, zoom, Self::col(e.color), 2.6, true, text_dim);
}
}
// Rings + badges: the selected node and the decorated ones — not every node.
// The decorated list is per model / unfold / decorations (a decoration that is
// only a size — every coderoom node — draws nothing here, so it is not listed).
let mut special: Vec<usize> = cache.decorated.clone();
if let Some(s) = self.state.selected.as_deref().and_then(at) {
special.push(s);
special.sort_unstable();
special.dedup();
}
for &i in &special {
let nd = &model.nodes[i];
let on_trace = highlighting && cache.lit.contains(&nd.id);
let chip = egui::Rect::from_center_size(project(nd.pos), egui::vec2(BOX_W, BOX_H) * zoom);
let deco = self.state.decorations.nodes.get(&nd.id);
let ring = if self.state.selected.as_deref() == Some(nd.id.as_str()) {
Some(egui::Stroke::new(3.0_f32, accent))
} else {
deco.and_then(|d| d.ring).map(|rc| {
let rc = Self::col(rc);
egui::Stroke::new(2.4_f32, if highlighting && !on_trace { dim(rc) } else { rc })
})
};
if let Some(ring) = ring {
painter.rect_stroke(chip, 5.0 * zoom, ring, egui::epaint::StrokeKind::Outside);
}
if zoom > 0.45
&& let Some(badge) = deco.and_then(|d| d.badge.as_deref())
{
let bc = deco.and_then(|d| d.badge_color).map(Self::col).unwrap_or(text);
painter.text(
chip.right_top() + egui::vec2(-2.0, 1.0),
egui::Align2::RIGHT_TOP,
badge,
egui::FontId::proportional(12.0 * zoom.clamp(0.7, 1.4)),
if highlighting && !on_trace { dim(bc) } else { bc },
);
}
}
// Labels: the visible chips on screen only.
if zoom > 0.45 {
let viewport = painter.clip_rect();
let size = egui::vec2(BOX_W, BOX_H) * zoom;
let on: Vec<usize> = (0..model.nodes.len())
.filter(|&i| cache.shown(i) && viewport.intersects(egui::Rect::from_center_size(project(model.nodes[i].pos), size)))
.take(LABEL_CAP)
.collect();
let font = egui::FontId::proportional(11.0 * zoom.clamp(0.7, 1.4));
report.labels_considered = on.len();
if self.opts.thin_labels {
use facett_core::legibility::{plan_frame, FrameInput, FrameOpts};
let char_px = 6.0 * zoom.clamp(0.7, 1.4);
let centres: Vec<egui::Pos2> = on.iter().map(|&i| project(model.nodes[i].pos)).collect();
let sizes: Vec<egui::Vec2> = on
.iter()
.map(|&i| egui::vec2((model.nodes[i].label.chars().count() as f32 * char_px).max(4.0), 11.0 * zoom.clamp(0.7, 1.4)))
.collect();
let prio: Vec<f32> = on
.iter()
.map(|&i| (if cache.lit.contains(&model.nodes[i].id) { 1000.0 } else { 0.0 }) + model.nodes[i].label.chars().count() as f32)
.collect();
let pinned: Vec<bool> = on.iter().map(|&i| self.state.selected.as_deref() == Some(model.nodes[i].id.as_str())).collect();
let plan = plan_frame(
&FrameInput {
centres: ¢res,
label_size: &sizes,
label_priority: &prio,
label_pinned: &pinned,
edges: &[],
edge_always: &[],
edge_weight: &[],
viewport,
},
FrameOpts { thin_labels: true, ..FrameOpts::default() },
);
if let Some(p) = &plan.labels {
for pl in &p.placed {
painter.text(pl.rect.center(), egui::Align2::CENTER_CENTER, &model.nodes[on[pl.index]].label, font.clone(), text);
}
report.labels_placed = p.placed.len();
report.labels_suppressed = p.suppressed;
}
} else {
for &i in &on {
painter.text(project(model.nodes[i].pos), egui::Align2::CENTER_CENTER, &model.nodes[i].label, font.clone(), text);
}
report.labels_placed = on.len();
}
}
report.edges_drawn = model.edges.len();
report.resident_uploads = cache.slot.uploads();
Some(report)
}
}
/// Stroke ONE edge between two projected chip centres: chip side to chip side, dashed
/// when the edge says so, an arrowhead at the callee, and the mid-edge label (above
/// zoom 0.45). Shared by the CPU road and the resident lane's decoration edges.
#[allow(clippy::too_many_arguments)]
fn stroke_edge(
painter: &egui::Painter,
pa: egui::Pos2,
pb: egui::Pos2,
e: &GraphEdge,
zoom: f32,
color: egui::Color32,
w: f32,
emphasise: bool,
text_dim: egui::Color32,
) {
let a = pa + egui::vec2(BOX_W * 0.5 * zoom, 0.0);
let b = pb - egui::vec2(BOX_W * 0.5 * zoom, 0.0);
if e.dashed {
// sampled dashed line.
let n = 16;
for i in 0..n {
if i % 2 == 0 {
let t0 = i as f32 / n as f32;
let t1 = (i + 1) as f32 / n as f32;
painter.line_segment([a.lerp(b, t0), a.lerp(b, t1)], egui::Stroke::new(w, color));
}
}
} else {
painter.line_segment([a, b], egui::Stroke::new(w, color));
}
// arrowhead.
let dir = (b - a).normalized();
let perp = egui::vec2(-dir.y, dir.x);
let head = 6.0 * zoom.clamp(0.6, 1.6);
painter.line_segment([b, b - dir * head + perp * head * 0.5], egui::Stroke::new(w, color));
painter.line_segment([b, b - dir * head - perp * head * 0.5], egui::Stroke::new(w, color));
if let Some(lbl) = &e.label
&& zoom > 0.45
&& !lbl.is_empty()
{
painter.text(
a.lerp(b, 0.5) - egui::vec2(0.0, 6.0 * zoom),
egui::Align2::CENTER_BOTTOM,
lbl,
egui::FontId::proportional(10.0 * zoom.clamp(0.7, 1.3)),
if emphasise { color } else { text_dim },
);
}
}
// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
impl facett_core::Elm for DecoratedGraphView {
type Model = GraphViewState;
type Msg = Msg;
type Effect = Effect;
fn title(&self) -> &str {
&self.title
}
fn state(&self) -> &GraphViewState {
&self.state
}
fn update(&mut self, msg: Msg) -> Vec<Effect> {
DecoratedGraphView::update(self, msg)
}
fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
DecoratedGraphView::view(self, ui)
}
}
// The bridge macro writes `impl Facet for DecoratedGraphView` from the `Elm` impl:
// `title`, the FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the extra
// overrides below. **Form 3** (`custom_state_json`) because the pane publishes a
// RICHER `state_json` than a plain `serde(state())`: the derived counts (rings/badges),
// the lit downstream set, and the flattened per-node/per-edge introspection keys that
// facett-demo's mega_matrix reads are computed from the model + decorations, not the
// raw serde dump — so the default `state_json` is suppressed and this byte-for-byte
// one supplied.
facett_core::impl_facet_via_elm!(DecoratedGraphView, custom_state_json, {
fn state_json(&self) -> serde_json::Value {
let lit = self.lit_set();
serde_json::json!({
"title": self.title,
"node_count": self.state.model.nodes.len(),
"edge_count": self.state.model.edges.len(),
"decoration_edges": self.state.decorations.edges.len(),
"rings": self.deco_counts.0,
"badges": self.deco_counts.1,
"selected": self.state.selected,
"lit": lit.iter().cloned().collect::<Vec<_>>(),
"zoom": self.state.zoom,
"pan": [self.state.pan_x, self.state.pan_y],
"nav_mode": format!("{:?}", self.state.nav_mode),
"flying": self.state.flight.is_some(),
// The shared drill navigator (levels / current level / history / expanded)
// + how many chips are shown under the progressive unfold.
"nav": self.state.nav.state_json(),
"visible_count": self.visible_ids().len(),
// What the last painted frame's legibility work did — so "labels do not
// overplot" and "the GPU lane really painted" are assertable from outside.
"legibility": self.last_legibility.borrow().state_json(),
"nodes": self.state.model.nodes.iter().map(|n| {
let d = self.state.decorations.nodes.get(&n.id);
serde_json::json!({
"id": n.id,
"label": n.label,
"x": n.pos.x,
"y": n.pos.y,
"ring": d.and_then(|d| d.ring).is_some(),
"badge": d.and_then(|d| d.badge.clone()),
})
}).collect::<Vec<_>>(),
"edges": self.state.model.edges.iter().map(|e| serde_json::json!({
"from": e.from, "to": e.to, "dashed": e.dashed,
})).collect::<Vec<_>>(),
// The stable UI ERROR CODE for the empty-scene state
// (`facett_core::errcode`, `facet-graphview-1` — "graphview has an empty
// scene, nothing to lay out"). The code belongs to the graphview COMPONENT,
// not to one view of it: `MetroView` published it already, so a host that
// mounts the decorated view (dwarves' Funnel DAG, nornir's graph panes) got
// silence for the very same condition and had nothing to react to.
// Consumers react to the CODE out of this data — never a matched string.
"error_code": if self.state.model.nodes.is_empty() {
serde_json::json!("facet-graphview-1")
} else {
serde_json::Value::Null
},
})
}
/// STRUCTURAL severity (the Robot-UI gate signal): RED when there is no scene to
/// lay out (the `facet-graphview-1` empty state), the Info floor otherwise —
/// matching [`MetroView`](crate::metro::MetroView).
fn severity(&self) -> facett_core::Severity {
if self.state.model.nodes.is_empty() {
facett_core::Severity::Error
} else {
facett_core::Severity::Info
}
}
fn copy(&mut self) -> Option<String> {
self.copy_payload().map(|p| p.as_text())
}
fn selection_json(&self) -> serde_json::Value {
match &self.state.selected {
Some(id) => serde_json::json!({ "node": id, "downstream": self.lit_set().iter().cloned().collect::<Vec<_>>() }),
None => serde_json::Value::Null,
}
}
/// Themed (chips/rings follow the palette) + resizable + selectable (a node
/// drill-in) + navigable (pan/drag + scroll-zoom + fit).
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().resizable().selectable().navigable().copyable()
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
});
#[cfg(test)]
mod tests {
use super::*;
// `Facet` for `state_json`/`selection_json`/`title`/`caps`; `state()`/`select()`/
// `update()`/`fit()` are inherent.
use facett_core::Facet;
/// The empty scene raises the graphview family's stable code, so a HOST that
/// mounts the decorated view (dwarves' Funnel DAG) has a code to react to —
/// exactly as `MetroView` already gave its hosts. Silence here was the reason
/// `dwarves/funnel` could not be consumer-wired.
#[test]
fn an_empty_scene_publishes_facet_graphview_1_and_reads_red() {
let empty = DecoratedGraphView::new("empty");
assert_eq!(
empty.state_json()["error_code"],
serde_json::json!("facet-graphview-1"),
"the empty-scene CODE is published in the pane's own data"
);
assert_eq!(empty.severity(), facett_core::Severity::Error);
assert_eq!(
facett_core::errcode::reaction_for("facet-graphview-1"),
Some(facett_core::errcode::Reaction::EmptyState),
);
// A populated scene raises nothing.
let full = DecoratedGraphView::local();
assert_eq!(full.state_json()["error_code"], serde_json::Value::Null);
assert_eq!(full.severity(), facett_core::Severity::Info);
}
#[test]
fn typed_copy_is_selected_label_or_the_label_list() {
use facett_core::clip::{ClipKind, CopySource};
let mut v = DecoratedGraphView::local();
// No selection -> a multi-line label list.
let p = v.copy_payload().expect("populated graph copies");
assert_eq!(p.kind(), ClipKind::Text);
assert!(p.as_text().contains('\n'), "label list is multi-line: {}", p.as_text());
// Select node "a" -> the copy narrows to that node's single label.
v.select(Some("a".into()));
let sel = v.copy_payload().unwrap().as_text();
assert!(!sel.contains('\n'), "selected copy is one label: {sel}");
assert_eq!(sel, v.state().model.nodes.iter().find(|n| n.id == "a").unwrap().label);
}
#[test]
fn drill_back_and_in_place_unfold_over_the_local_graph() {
// The 2D twin of the Code Vault nav: drill descends a level, right-click/Back
// returns to the parent, and folding a node collapses its subtree while the
// node (and ancestors) stay — RED-when-broken if unfold ever drops the parent.
let mut v = DecoratedGraphView::local();
// Default: fully unfolded → all four chips visible.
assert_eq!(v.visible_ids().len(), 4);
assert_eq!(v.state_json()["visible_count"], 4);
// Drill descends a level; Back (the right-click) returns to the parent level.
assert_eq!(v.nav().current_level_id(), "constellation");
let _ = v.update(Msg::Drill("a".into()));
assert_eq!(v.nav().current_level_id(), "repo");
let _ = v.update(Msg::Back);
assert_eq!(v.nav().current_level_id(), "constellation", "Back returns to the parent level");
// Fold root "a": its subtree (b/c/d) collapses, but "a" itself stays visible.
let _ = v.update(Msg::Fold("a".into()));
let vis = v.visible_ids();
assert!(vis.contains("a"), "the folded PARENT stays visible");
assert!(!vis.contains("b") && !vis.contains("d"), "the collapsed subtree hides");
assert_eq!(v.state_json()["visible_count"], 1);
// Unfold "a": the children return, the parent is STILL present (in-place unfold).
let _ = v.update(Msg::Unfold("a".into()));
let vis = v.visible_ids();
assert!(vis.contains("a") && vis.contains("b") && vis.contains("d"), "unfold reopens WITHOUT closing the parent");
}
#[test]
fn downstream_is_bfs_closure_from_seed() {
let v = DecoratedGraphView::local();
// a → b, a → c, b → d, c → d. Downstream of a = all; of b = {b, d}.
let from_a = downstream_of(&v.state().model, "a");
assert!(from_a.contains("a") && from_a.contains("b") && from_a.contains("c") && from_a.contains("d"));
let from_b = downstream_of(&v.state().model, "b");
assert!(from_b.contains("b") && from_b.contains("d"));
assert!(!from_b.contains("a"), "BFS is forward-only");
assert!(!from_b.contains("c"), "c is not downstream of b");
}
/// inject-assert (LAW 6): the local view seeds a real decorated graph; assert
/// its `state_json` reports the nodes, the ring + badge decorations, the
/// emphasis edge, and — after a selection — the lit downstream set.
#[test]
fn local_view_reports_decorations_and_selection() {
let mut v = DecoratedGraphView::local();
let j = v.state_json();
assert_eq!(<DecoratedGraphView as Facet>::title(&v), "DecoratedGraph");
assert_eq!(j["node_count"], 4);
assert_eq!(j["edge_count"], 4);
assert_eq!(j["decoration_edges"], 1, "the dashed cut edge");
assert_eq!(j["rings"], 1, "a green coverage ring on `a`");
assert_eq!(j["badges"], 1, "a ⚠ badge on `b`");
// node `a` carries a ring, `b` carries the badge text.
let nodes = j["nodes"].as_array().unwrap();
let a = nodes.iter().find(|n| n["id"] == "a").unwrap();
assert_eq!(a["ring"], true);
let b = nodes.iter().find(|n| n["id"] == "b").unwrap();
assert_eq!(b["badge"], "⚠");
// no selection → null + empty lit set.
assert_eq!(j["selected"], serde_json::Value::Null);
assert!(j["lit"].as_array().unwrap().is_empty());
// select `b` → the lit set is its downstream {b, d}.
v.select(Some("b".into()));
let j2 = v.state_json();
assert_eq!(j2["selected"], "b");
let lit: BTreeSet<String> =
j2["lit"].as_array().unwrap().iter().map(|x| x.as_str().unwrap().to_string()).collect();
assert_eq!(lit, ["b", "d"].iter().map(|s| s.to_string()).collect::<BTreeSet<_>>());
let sel = v.selection_json();
assert_eq!(sel["node"], "b");
}
#[test]
fn fit_resets_pan_and_zoom() {
let mut v = DecoratedGraphView::local();
v.state.pan_x = 20.0;
v.state.pan_y = 10.0;
v.state.zoom = 2.5;
v.fit();
assert_eq!((v.state().pan_x, v.state().pan_y), (0.0, 0.0));
assert_eq!(v.state().zoom, 1.0);
assert_eq!(<DecoratedGraphView as Facet>::title(&DecoratedGraphView::remote()), "DecoratedGraph (remote)");
}
// ── FC-2 → FC-3 as a *headless* property: feed a `Vec<Msg>` through the core
// harness and assert the resulting `GraphViewState` — no egui, no GPU. ──────
#[test]
fn harness_snapshot_drives_camera_and_selection() {
use facett_core::harness;
let mut v = DecoratedGraphView::local();
// Pan + zoom + select are all observable in the headless snapshot.
let snap = harness::snapshot(
&mut v,
[Msg::PanBy(12.0, -4.0), Msg::ZoomBy(500.0), Msg::Select(Some("b".into()))],
);
assert_eq!((snap.pan_x, snap.pan_y), (12.0, -4.0), "pan accumulates");
assert!(snap.zoom > 1.0 && snap.zoom <= 4.0, "zoom grows but stays clamped: {}", snap.zoom);
assert_eq!(snap.selected.as_deref(), Some("b"), "selection is observable headlessly");
assert_eq!(&snap, v.state(), "the snapshot is a clone of the live state");
// Fit resets the camera; ZoomBy is clamped at the low end too.
let snap = harness::snapshot(&mut v, [Msg::ZoomBy(-100000.0), Msg::Fit]);
assert_eq!((snap.pan_x, snap.pan_y, snap.zoom), (0.0, 0.0, 1.0), "Fit re-centers + unit-zooms");
}
#[test]
fn drive_reports_no_effects_and_state_round_trips() {
use facett_core::harness;
let mut v = DecoratedGraphView::local();
// FC-8: the pane does no I/O, so the effect stream is always empty.
let effects = harness::drive(&mut v, [Msg::Select(Some("a".into())), Msg::PanBy(3.0, 3.0)]);
assert!(effects.is_empty(), "FC-8: the pane emits no Effects");
// FC-3: the observable Model (graph + decorations + camera + selection)
// round-trips through serde.
let json = serde_json::to_value(v.state()).unwrap();
assert_eq!(json["selected"], "a");
let back: GraphViewState = serde_json::from_value(json).unwrap();
assert_eq!(&back, v.state(), "serde(state) -> state round-trips (the whole graph survives)");
}
/// The host hit-test seam: after a real paint, `world_to_screen` reproduces the
/// pane's own projection, so a node's world position maps to the exact screen
/// pixel it was drawn at (the anchor a robot/host clicks). RED-when-broken if the
/// paint rect isn't recorded or the projection drifts from `view`'s.
#[test]
fn world_to_screen_matches_the_painted_projection() {
let v = DecoratedGraphView::local();
// No paint yet → no rect, no mapping.
assert!(v.last_paint_rect().is_none());
assert!(v.world_to_screen(Pos::new(0.0, 0.0)).is_none(), "no mapping before the first paint");
// Drive one real egui frame so `view` allocates + records the canvas rect.
let ctx = egui::Context::default();
ctx.run_ui(egui::RawInput::default(), |ui| {
egui::CentralPanel::default().show(ui, |ui| {
let _ = v.view(ui);
});
}).textures_delta.clear();
let rect = v.last_paint_rect().expect("view painted → canvas rect recorded");
// local() seeds node `a` at world (0,0) and `d` at world (520,0); default
// camera is pan (0,0) / zoom 1, so `a` lands on the canvas centre and `d`
// lands 520px to its right — the exact anchors an out-of-band click targets.
let a = v.world_to_screen(Pos::new(0.0, 0.0)).unwrap();
assert!((a.x - rect.center().x).abs() < 1e-3, "node a maps to the canvas centre x");
assert!((a.y - rect.center().y).abs() < 1e-3, "node a maps to the canvas centre y");
let d = v.world_to_screen(Pos::new(520.0, 0.0)).unwrap();
assert!((d.x - (rect.center().x + 520.0)).abs() < 1e-3, "node d is 520px right of centre (zoom 1, pan 0)");
}
/// FC-5: node selection is keyed on the **stable node id**, so it survives a full
/// re-layout — moving every node to new world coordinates leaves the selection and
/// its lit downstream set intact (they resolve by id, never by position).
#[test]
fn selection_survives_relayout_on_stable_id() {
use facett_core::harness;
let mut v = DecoratedGraphView::local();
let _ = harness::drive(&mut v, [Msg::Select(Some("b".into()))]);
let lit_before = v.lit_set();
// Re-layout: shove every node to a fresh position (same ids + edges).
let mut relaid = v.state().model.clone();
for (i, n) in relaid.nodes.iter_mut().enumerate() {
n.pos = Pos::new(1000.0 + i as f32 * 37.0, -500.0 - i as f32 * 19.0);
}
v = v.with_model(relaid);
assert_eq!(v.state().selected.as_deref(), Some("b"), "selection keyed on id survives the move");
assert_eq!(v.lit_set(), lit_before, "the lit downstream set is identical after re-layout");
assert_eq!(v.selection_json()["node"], "b");
// And the id still resolves to a (now-relocated) real node.
assert!(v.state().model.nodes.iter().any(|n| n.id == "b"));
}
/// **`fit_model` frames a graph that `fit` cannot.** The gap this method exists to
/// close: `Msg::Fit` recentres at zoom 1 and `Msg::ZoomBy` clamps at 0.25, so a
/// world box more than 4× the widget was unreachable — the pane painted a corner of
/// the graph and looked like chrome.
///
/// RED (seen): calling `fit()` instead of `fit_model()` leaves zoom at 1.0 and the
/// far corner projects to (2650, 1890) in a 900 × 640 widget — off-screen, which is
/// exactly what the assertion below reports.
#[test]
fn fit_model_frames_a_graph_that_plain_fit_cannot() {
let n = |id: &str, x: f32, y: f32| GraphNode {
id: id.into(),
label: id.into(),
fill: Color::GRAY,
stroke: Color::WHITE,
pos: Pos::new(x, y),
};
// A world box ~3500 units across — the size korp's 132-node live scene lays out
// to at one chip of clearance per node.
let model = GraphModel {
nodes: vec![n("a", -1750.0, -1750.0), n("b", 1750.0, 1750.0), n("c", 0.0, 0.0)],
edges: vec![],
};
let viewport = egui::vec2(900.0, 640.0);
let mut plain = DecoratedGraphView::new("t").with_model(model.clone());
plain.fit();
assert_eq!(plain.state().zoom, 1.0, "plain fit is a recentre, not a frame");
let mut v = DecoratedGraphView::new("t").with_model(model);
v.fit_model(viewport, 12.0);
assert!(
v.state().zoom < 0.25,
"a 3500-unit world in a 900px widget needs a zoom below the interactive \
floor of 0.25; fit_model chose {}",
v.state().zoom
);
// Every node must land INSIDE the viewport. Project the way `view` does, with a
// rect centred at the origin of a 900 x 640 widget.
let rect = egui::Rect::from_center_size(egui::pos2(450.0, 320.0), viewport);
let z = v.state().zoom;
let origin = rect.center() + egui::vec2(v.state().pan_x, v.state().pan_y);
for node in &v.state().model.nodes {
let p = origin + egui::vec2(node.pos.x, node.pos.y) * z;
assert!(
rect.contains(p),
"node {} projects to ({:.0}, {:.0}), outside the {} x {} widget",
node.id,
p.x,
p.y,
viewport.x,
viewport.y
);
// And the SAME answer through the public projection seam, so a host and a
// robot agree with this test.
let _ = v.world_to_screen(node.pos);
}
// A model with nothing in it leaves the camera exactly as it was — a fit of
// nothing is not a reason to move.
let mut empty = DecoratedGraphView::new("t");
empty.fit_model(viewport, 12.0);
assert_eq!(empty.state().zoom, 1.0);
assert_eq!((empty.state().pan_x, empty.state().pan_y), (0.0, 0.0));
}
}