facett-graph/src/lib.rs
//! **facett-graph** — the graph (node/edge) viewer component, on
//! [`facett_core`]. Build a [`Scene`] from a labelled edge list (the common
//! shape: a graph query, a dataflow DAG) and draw it. Source-agnostic — the
//! caller turns its Arrow/Cypher/whatever into edges first.
//!
//! ## FC contract (the canonical Elm split, FC-2 / FC-9)
//! Both facets in this crate — [`GraphView`] (the 2D node/edge skin) and
//! [`DepGraphView`] (the layered dep-DAG) — adopt the [`facett_core::Elm`] split:
//! - **[`GraphState`]** / **[`depgraph::DepGraphState`]** — the complete observable,
//! serializable, round-trippable *interaction* state. The bulk graph data (the
//! [`Scene`] / the `nodes`+`edges`) is the *input* held on the struct — `Color32`
//! is not serde-round-trippable and it is not the state a headless driver mutates,
//! so it lives OUTSIDE the Model (mirrors how facett-grid keeps its Arrow data on
//! the struct, not in its `GridState`).
//! - **[`Msg`] + `update`** — the single mutation path (FC-2): a host/robot toggles
//! the layout ([`GraphView`]) or selects/clears a node ([`DepGraphView`]); a
//! headless driver ([`facett_core::harness`]) reaches the identical transitions.
//! - **`view`** — a **pure** paint (FC-9): reads `&self`, paints, and *returns* the
//! [`Msg`]s the interactions produced; the
//! [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies them.
//! - **Rich `state_json`** — the node/edge/label counts (and the dep-graph's column
//! depth) a driver reads are *derived* from the input `Scene`/`nodes`, not the
//! interaction Model, so the macro is invoked in **form 3** (`custom_state_json`)
//! to publish those exact pre-migration keys instead of a plain `serde(state())`.
//!
//! ## Within-pane addressability recipe (FC-4 / FC-5 — the headless-drive unlock)
//! A custom-*painted* sub-widget (a graph node, a matrix/grid cell, a pipeline DAG
//! box, a list row) draws pixels but emits **no** AccessKit node — so a headless
//! `cmd_json` / robotui driver can select the pane's tab but cannot reach *inside*
//! it (the recorded "probe_only" gap). The fix is one call per interactive element,
//! reusing the shared helper — **no second addressing scheme, AccessKit only**:
//!
//! ```ignore
//! // key on the element's STABLE domain identity (its name/id), NOT the paint index:
//! let resp = facett_core::a11y::node(
//! ui, base_id, &node.label, egui::Sense::click(), hit_rect,
//! facett_core::a11y::Semantics::button(node.label.clone()).selected(is_sel),
//! );
//! if resp.clicked() { msgs.push(Msg::SelectNode(node.label.clone())); }
//! ```
//!
//! Then (a) route the click through the FC-2 `Msg`/`update` path so live == headless,
//! and (b) publish the addressable identities + the selection in `state_json`
//! (`"node_targets"` + `"selected"` here) so a scenario author can *discover* the
//! targets and assert the click landed on the RIGHT one. A driver now does
//! `Locate { Target::label("<name>") } → Act(Click)` — the SAME `Target`/`Action`
//! vocabulary tab selection uses. [`GraphView`] (this recipe), [`DepGraphView`], and
//! `facett-syschart::SystemChart` are the worked examples; the **matrix cell /
//! pipeline node / list row** families follow the identical four steps
//! (stable-key `a11y::node` → `Msg` → `state_json` targets → robot test).
use std::collections::{BTreeMap, HashMap};
pub use facett_core::{Edge, Facet, FacetCaps, Layout, Node, Scene, Theme, draw, hash_color, set_theme, theme};
use serde::{Deserialize, Serialize};
/// Side length (px) of a node's square AccessKit hit target, centred on the node
/// dot — generous enough to click (the dot is ~5px) yet small enough that
/// well-separated nodes don't steal each other's clicks.
const NODE_HIT: f32 = 18.0;
mod depgraph;
pub mod scale;
pub use scale::{A11Y_BUDGET, SCALE_ABOVE};
pub use depgraph::{
DepEdge, DepGraphState, DepGraphView, DepNode, draw_arrow, Effect as DepGraphEffect, Msg as DepGraphMsg,
};
/// Build a [`Scene`] from a labelled edge list — each row is
/// `(src_id, dst_id, src_label, dst_label)`. Distinct ids become nodes, coloured
/// per label (FNV-hashed). The reusable "graph from edges" adapter korp's Graph
/// and Pipelines views both want.
pub fn scene_from_labeled_edges<I>(rows: I) -> Scene
where
I: IntoIterator<Item = (i64, i64, String, String)>,
{
let mut scene = Scene::new();
let mut idx: HashMap<i64, usize> = HashMap::new();
for (s, d, sl, dl) in rows {
let si = *idx.entry(s).or_insert_with(|| scene.node(sl.clone(), hash_color(&sl)));
let di = *idx.entry(d).or_insert_with(|| scene.node(dl.clone(), hash_color(&dl)));
scene.edge(si, di);
}
scene
}
/// A serde-round-trippable mirror of [`Layout`] (which is `Copy` but neither
/// `Serialize` nor `Debug`, so it cannot live in the [`GraphState`] Model directly).
/// The [`Msg`] and the Model carry this; conversions to/from the core [`Layout`]
/// keep the painter and public API speaking the core type.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GraphLayout {
/// Nodes evenly spaced on a circle.
#[default]
Circular,
/// Deterministic Fruchterman–Reingold force layout.
Force,
}
impl From<Layout> for GraphLayout {
fn from(l: Layout) -> Self {
match l {
Layout::Circular => GraphLayout::Circular,
Layout::Force => GraphLayout::Force,
}
}
}
impl From<GraphLayout> for Layout {
fn from(l: GraphLayout) -> Self {
match l {
GraphLayout::Circular => Layout::Circular,
GraphLayout::Force => Layout::Force,
}
}
}
/// **The complete observable interaction state (FC-1 / FC-3)** of a [`GraphView`]:
/// the layout knob a host can toggle. The bulk [`Scene`] is *input* held on
/// [`GraphView`] (not serde-round-trippable, not driver-mutated), so it is
/// deliberately kept OUT of this Model — the derived node/edge/label counts a driver
/// reads are published via the form-3 `state_json` instead.
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct GraphState {
/// The node-placement strategy (the one knob a host/robot toggles).
pub layout: GraphLayout,
/// The drilled-in node selection, keyed on the node's stable **label** (FC-5)
/// so a re-layout / reorder can't silently re-point it the way a paint-order
/// index would. `None` = nothing selected. A painted node click (or a headless
/// [`Msg::SelectNode`]) toggles this — the within-pane interaction a robot
/// driver reaches once the node rides an AccessKit id (see the module docs
/// "within-pane addressability" recipe).
pub selected: Option<String>,
}
/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
/// driver (or a host) drives the [`GraphView`] through. Applied by
/// [`GraphView::update`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Msg {
/// Switch the node-placement strategy.
SetLayout(GraphLayout),
/// Toggle-select a painted node by its stable **label** — the same effect a
/// node click produces (re-selecting the open node clears it). This is the
/// within-pane gesture a headless robot reaches once the node carries an
/// AccessKit id (`Locate { node } → Act(Click)`).
SelectNode(String),
/// Clear the node selection.
ClearSelection,
}
/// Side work as data (FC-8). The graph view does no I/O — every [`Msg`] mutates
/// only the in-memory [`GraphState`] — so this is uninhabited on purpose.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Effect {}
/// A simple graph-view widget: a [`Scene`] + a [`Layout`]. Implements [`Facet`],
/// so it drops into a `FacetDeck` and exposes `state_json` for free.
pub struct GraphView {
/// The graph data being drawn — *input* (like facett-grid's Arrow data): held
/// on the struct, outside the observable [`GraphState`] Model.
pub scene: Scene,
/// Shown centred when the scene has no nodes.
pub empty_hint: String,
/// The deck keys facets off this.
pub title: String,
/// All observable interaction state (FC-3).
state: GraphState,
/// **Converge-once-then-freeze** layout cache (not observable state) — the force
/// layout is a pure function of the scene structure + rect, so it is computed once
/// and reused every frame until the graph or rect changes. This stops the O(n log n)
/// (Barnes–Hut) / O(n²) (small) layout from re-running each paint — the freeze half
/// of scaling to 30 000 nodes. Interior-mutable so the FC-9 pure `show`/`view`
/// (`&self`) can fill it. Rebuilt from scratch on `Clone`/default.
layout_cache: std::cell::RefCell<facett_core::ForceCache>,
/// The host selected the shared GPU cloud lane ([`GraphView::with_gpu`]).
use_gpu: bool,
/// What the LAST frame did with the node/edge cloud: `Some(true)` GPU instances,
/// `Some(false)` the egui painter, `None` not painted yet. Written by the pure
/// `view` (a `Cell`, like `layout_cache`), read by `state_json`.
lane_drew: std::cell::Cell<Option<bool>>,
/// The scale path's per-scene work (above [`SCALE_ABOVE`] nodes; see [`scale`]).
scale: std::cell::RefCell<scale::ScaleCache>,
/// The scale path's pan/zoom camera — a view transient like the layout cache, not
/// the observable Model (published in `state_json` at scale).
camera: std::cell::Cell<scale::ScaleCamera>,
/// The rect the last scale frame painted into (for [`focus_node`](Self::focus_node)).
scale_rect: std::cell::Cell<Option<egui::Rect>>,
}
impl GraphView {
pub fn new(scene: Scene) -> Self {
Self {
scene,
empty_hint: "empty".into(),
title: "graph".into(),
state: GraphState::default(),
layout_cache: std::cell::RefCell::new(facett_core::ForceCache::default()),
use_gpu: false,
lane_drew: std::cell::Cell::new(None),
scale: std::cell::RefCell::new(scale::ScaleCache::default()),
camera: std::cell::Cell::new(scale::ScaleCamera::default()),
scale_rect: std::cell::Cell::new(None),
}
}
/// **Bring node `label` to the centre of the pane at `zoom`** (scale path; needs one
/// painted frame for the pane size). The "pan / zoom to reach it" a robot or a
/// search box uses to get an off-screen node into the a11y tree. `false` when the
/// label is unknown, the scene is below [`SCALE_ABOVE`], or nothing was painted yet.
pub fn focus_node(&self, label: &str, zoom: f32) -> bool {
let (Some(rect), Some(i)) = (self.scale_rect.get(), self.scene.nodes.iter().position(|n| n.label == label)) else {
return false;
};
let c = self.scale.borrow();
let Some(p) = c.pos.get(i) else { return false };
let mut cam = scale::ScaleCamera { zoom: zoom.clamp(0.05, 10_000.0), pan: egui::Vec2::ZERO };
cam.pan = -p.to_vec2() * cam.scale(rect);
self.camera.set(cam);
true
}
/// Does this scene take the scale path (more than [`SCALE_ABOVE`] nodes)?
#[must_use]
pub fn at_scale(&self) -> bool {
self.scene.nodes.len() > SCALE_ABOVE
}
/// The scale path's camera (zoom 1, no pan until the user moves it).
#[must_use]
pub fn camera(&self) -> scale::ScaleCamera {
self.camera.get()
}
/// Move the scale path's camera (what a drag / scroll does; a host or robot may set
/// it directly). No effect on the fixture painter below [`SCALE_ABOVE`].
pub fn set_camera(&self, cam: scale::ScaleCamera) {
self.camera.set(cam);
}
/// Scenes the scale path has laid out (once per scene change, never per frame).
#[must_use]
pub fn scale_layouts(&self) -> u64 {
self.scale.borrow().layouts
}
/// Models the scale path has uploaded to the resident GPU cloud (`0` without one).
#[must_use]
pub fn resident_uploads(&self) -> u64 {
#[cfg(feature = "wgpu")]
{
self.scale.borrow().slot.uploads()
}
#[cfg(not(feature = "wgpu"))]
{
0
}
}
/// **The FC-4 budget, as data**: how many nodes the last frame had on screen, how
/// many it put in the AccessKit tree, and the rule. Below [`SCALE_ABOVE`] every node
/// is in the tree (`budget: null`).
#[must_use]
pub fn a11y_json(&self) -> serde_json::Value {
let n = self.scene.nodes.len();
if !self.at_scale() {
return serde_json::json!({ "total": n, "emitted": n, "budget": null, "policy": "every node" });
}
let c = self.scale.borrow();
serde_json::json!({
"total": n,
"on_screen": c.last.on_screen,
"emitted": c.last.emitted.len(),
"budget": A11Y_BUDGET,
"labels": c.last.labels,
"policy": format!(
"above {SCALE_ABOVE} nodes: one AccessKit button per ON-SCREEN node, in scene order, at most {A11Y_BUDGET} \
(the selected node always); pan/zoom brings others in; Msg::SelectNode reaches every node"
),
})
}
/// The cheap state at scale — counts, selection, lanes, camera, the a11y budget;
/// nothing per node (a host publishing state every frame calls this, not
/// `state_json`, above [`SCALE_ABOVE`]).
#[must_use]
pub fn scale_json(&self) -> serde_json::Value {
let cam = self.camera.get();
serde_json::json!({
"nodes": self.scene.nodes.len(),
"edges": self.scene.edges.len(),
"selected": self.state.selected.clone(),
"lane": facett_core::render::lane::cloud_lane_json(self.use_gpu, Self::gpu_installed(), self.lane_drew.get()),
"resident_uploads": self.resident_uploads(),
"scale_layouts": self.scale_layouts(),
"layout_lane": self.scale.borrow().lane,
"layout_pending": self.scale.borrow().pending(),
"camera": { "zoom": cam.zoom, "pan": [cam.pan.x, cam.pan.y] },
"a11y": self.a11y_json(),
})
}
/// Select the shared instanced GPU cloud lane (facett-core `graphcloud`). The nodes
/// and edges go to the GPU as instances; labels, the selection ring and the a11y
/// nodes stay on egui. Takes effect only with the `wgpu` feature AND a renderer the
/// host installed (`graphcloud::cloud::install_cloud_renderer`); otherwise the egui
/// painter draws and `state_json()["lane"]` says so (`gpu: "no GPU"`).
#[must_use]
pub fn with_gpu(mut self, on: bool) -> Self {
self.use_gpu = on;
self
}
/// Push the node/edge cloud through the shared GPU lane; `true` only if it drew
/// (the one gate the painter consults before skipping its CPU loops).
#[cfg(feature = "wgpu")]
fn draw_gpu_cloud(&self, ui: &egui::Ui, rect: egui::Rect, pos: &[egui::Pos2]) -> bool {
use facett_core::render::gpu::graphcloud::{CloudBuilder, cloud};
let n = self.scene.nodes.len();
if n == 0 || pos.len() != n {
return false;
}
let edge = facett_core::theme(ui).edge;
cloud::draw_cloud(ui, rect, self.use_gpu, |px_w, px_h| {
// The offscreen is in physical px; the positions are in points.
let sx = px_w / rect.width().max(1.0);
let sy = px_h / rect.height().max(1.0);
let to_px = |p: egui::Pos2| egui::pos2((p.x - rect.min.x) * sx, (p.y - rect.min.y) * sy);
CloudBuilder::default().scene(
self.scene.nodes.iter().enumerate().map(|(i, nd)| (to_px(pos[i]), 5.0 * sy, nd.color)),
self.scene
.edges
.iter()
.filter(|e| e.src < n && e.dst < n)
.map(|e| (to_px(pos[e.src]), to_px(pos[e.dst]), 0.6 * sy, edge)),
)
})
}
#[cfg(not(feature = "wgpu"))]
fn draw_gpu_cloud(&self, _ui: &egui::Ui, _rect: egui::Rect, _pos: &[egui::Pos2]) -> bool {
false
}
/// Is a GPU cloud renderer installed in this process (the lane can draw)?
fn gpu_installed() -> bool {
#[cfg(feature = "wgpu")]
{
facett_core::render::gpu::graphcloud::cloud::cloud_renderer_installed()
}
#[cfg(not(feature = "wgpu"))]
{
false
}
}
pub fn empty_hint(mut self, hint: impl Into<String>) -> Self {
self.empty_hint = hint.into();
self
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = title.into();
self
}
/// Set the layout strategy at build time.
pub fn with_layout(mut self, layout: Layout) -> Self {
self.state.layout = layout.into();
self
}
/// **FC-3** — read the complete observable interaction state.
pub fn state(&self) -> &GraphState {
&self.state
}
/// The active node-placement strategy (as the core [`Layout`]).
pub fn layout(&self) -> Layout {
self.state.layout.into()
}
/// Switch the layout — a thin wrapper over the FC-2 [`update`](Self::update)
/// mutation path (the sole way the Model changes).
pub fn set_layout(&mut self, layout: Layout) {
let _ = self.update(Msg::SetLayout(layout.into()));
}
/// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
/// (always empty) [`Effect`]s the host should run.
pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
match msg {
Msg::SetLayout(layout) => self.state.layout = layout,
// Toggle on the stable label: re-selecting the open node clears it —
// the identical transition the painted node click drives, so a headless
// driver reaching this Msg reproduces the live click.
Msg::SelectNode(label) => {
self.state.selected =
if self.state.selected.as_deref() == Some(label.as_str()) { None } else { Some(label) };
}
Msg::ClearSelection => self.state.selected = None,
}
Vec::new()
}
/// The currently-selected node's stable label, if any.
pub fn selected(&self) -> Option<&str> {
self.state.selected.as_deref()
}
/// Drill into a node by its stable label (or `None` to clear) — a deterministic
/// *set* (never a toggle), a thin wrapper over the FC-2 [`update`](Self::update)
/// mutation path (the sole way the Model changes).
pub fn select(&mut self, node: Option<String>) {
let _ = self.update(Msg::ClearSelection);
if let Some(n) = node {
let _ = self.update(Msg::SelectNode(n));
}
}
/// The distinct node **labels** a robot / scenario author can address (the
/// AccessKit targets `Locate { Target::label(..) }` resolves to), sorted. A
/// label is reliably addressable when it is unique in the scene; this is the
/// discoverable target list published in [`state_json`](Facet::state_json).
pub fn node_targets(&self) -> Vec<String> {
self.label_counts().into_keys().collect()
}
/// Paint the scene (pure draw helper; no observable-state mutation). The layout is
/// pulled through the freeze [`ForceCache`](facett_core::ForceCache): the force
/// positions are computed once and reused until the scene structure or the paint
/// rect changes, so a big graph does NOT re-run its O(n log n) layout every frame.
pub fn show(&self, ui: &mut egui::Ui) {
let (rect, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());
let layout: Layout = self.state.layout.into();
let mut cache = self.layout_cache.borrow_mut();
let pos = cache.positions(layout, &self.scene, rect);
facett_core::draw_positions(ui, &self.scene, pos, rect, &self.empty_hint);
}
/// **FC-9 render** — a **pure** function of `&self`: it paints the scene and
/// *returns* the [`Msg`]s the interactions produced. It MUST NOT mutate the
/// Model or do I/O; the [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm)
/// bridge applies the returned messages through [`update`](Self::update).
///
/// **Within-pane addressability (FC-4 / FC-5).** Each painted node rides into
/// the AccessKit tree as a clickable, labelled `Button` keyed on the node's
/// **stable label** (via [`facett_core::a11y::node`]) — so a headless robot can
/// `Locate { Target::label("<node>") } → Act(Click)` a *specific* node, not just
/// the pane. A click emits [`Msg::SelectNode`]; the selected node gets an accent
/// ring. Before this the nodes were `painter.circle_filled` only — invisible to
/// `get_by_label` / the `cmd_json` driver (probe-only within the pane).
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)
if self.at_scale() {
return self.view_at_scale(ui);
}
let mut msgs: Vec<Msg> = Vec::new();
let (rect, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());
let layout: Layout = self.state.layout.into();
// Frozen-cache positions, copied out so the RefCell borrow is released
// before the per-node interaction loop (which borrows `ui` mutably).
let pos: Vec<egui::Pos2> = {
let mut cache = self.layout_cache.borrow_mut();
cache.positions(layout, &self.scene, rect).to_vec()
};
// The node/edge cloud: the shared GPU lane when selected and installed, else
// the egui painter. Labels stay on egui either way.
let drew_gpu = self.draw_gpu_cloud(ui, rect, &pos);
if drew_gpu {
facett_core::draw_position_labels(ui, &self.scene, &pos, rect);
} else {
facett_core::draw_positions(ui, &self.scene, &pos, rect, &self.empty_hint);
}
self.lane_drew.set(Some(drew_gpu));
if self.scene.nodes.is_empty() {
// facet-graph-1: the empty state carries its stable code (pink, under the hint).
facett_core::errcode::paint_code(&ui.painter_at(rect), rect.center() + egui::vec2(0.0, 16.0), "facet-graph-1");
}
// ── FC-4/FC-5: ride each painted node into the AccessKit tree as a
// clickable, labelled Button keyed on its STABLE label, so a headless
// driver can address a specific node. A click emits a toggle Msg (FC-9).
if self.scene.nodes.len() == pos.len() {
let base_id = ui.id().with("graph-nodes");
let sel = self.state.selected.clone();
for (i, node) in self.scene.nodes.iter().enumerate() {
let is_sel = sel.as_deref() == Some(node.label.as_str());
let hit = egui::Rect::from_center_size(pos[i], egui::vec2(NODE_HIT, NODE_HIT));
let sem = facett_core::a11y::Semantics::button(node.label.clone()).selected(is_sel);
let resp = facett_core::a11y::node(ui, base_id, &node.label, egui::Sense::click(), hit, sem);
if resp.clicked() {
msgs.push(Msg::SelectNode(node.label.clone()));
}
}
// Accent ring on the selected node — visible selection feedback, kept
// inside the allocated rect (the render-confinement invariant holds).
if let Some(sel_label) = sel.as_deref()
&& let Some(i) = self.scene.nodes.iter().position(|n| n.label == sel_label)
{
let th = facett_core::theme(ui);
ui.painter_at(rect).circle_stroke(pos[i], 8.0, egui::Stroke::new(2.0_f32, th.accent));
}
}
// ── render-lane emit: this pure-view paint path RAN ───────────────────
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-graph::GraphView::view",
"ui_render",
!self.scene.nodes.is_empty() || !self.empty_hint.is_empty(),
&format!("nodes={} edges={}", self.scene.nodes.len(), self.scene.edges.len()),
);
msgs
}
/// **The scale path's frame** (see [`scale`]): one layout per scene, a pan/zoom
/// camera, the resident GPU cloud (or the egui painter without one), labels and
/// AccessKit buttons for the on-screen nodes only, under [`A11Y_BUDGET`].
#[allow(clippy::too_many_lines)]
fn view_at_scale(&self, ui: &mut egui::Ui) -> Vec<Msg> {
let mut msgs: Vec<Msg> = Vec::new();
let (rect, resp) = ui.allocate_exact_size(ui.available_size(), egui::Sense::click_and_drag());
let n = self.scene.nodes.len();
// ── camera: drag pans, scroll zooms about the pointer ─────────────────────
let mut cam = self.camera.get();
if resp.dragged() {
cam.pan += resp.drag_delta();
}
if resp.hovered() {
let scroll = ui.input(|i| i.smooth_scroll_delta.y);
if scroll != 0.0 {
let at = resp.hover_pos().unwrap_or(rect.center());
cam.zoom_about(rect, at, (scroll * 0.0025).exp());
}
}
self.camera.set(cam);
self.scale_rect.set(Some(rect));
let mut cache = self.scale.borrow_mut();
cache.sync(self.state.layout.into(), &self.scene);
if cache.pending() {
ui.ctx().request_repaint();
}
let th = facett_core::theme(ui);
let painter = ui.painter_at(rect);
let k = cam.scale(rect);
let project = |p: egui::Pos2| rect.center() + cam.pan + p.to_vec2() * k;
// ── the cloud: resident on the GPU, else the egui painter ─────────────────
let drew_gpu = self.draw_resident(ui, rect, &cache, cam);
self.lane_drew.set(Some(drew_gpu));
if !drew_gpu {
for e in &self.scene.edges {
if e.src < n && e.dst < n {
let (a, b) = (project(cache.pos[e.src]), project(cache.pos[e.dst]));
if rect.intersects(egui::Rect::from_two_pos(a, b)) {
painter.line_segment([a, b], egui::Stroke::new(0.6_f32, th.edge));
}
}
}
for (i, node) in self.scene.nodes.iter().enumerate() {
let p = project(cache.pos[i]);
if rect.expand(5.0).contains(p) {
painter.circle_filled(p, 5.0, node.color);
}
}
}
// ── on screen: labels (≤ 60 on screen) + the budgeted AccessKit buttons ───
let on: Vec<usize> = (0..n).filter(|&i| rect.contains(project(cache.pos[i]))).collect();
let labels = if on.len() <= scale::LABELS_ON_SCREEN { on.len() } else { 0 };
for &i in on.iter().take(labels) {
painter.text(project(cache.pos[i]) + egui::vec2(7.0, 0.0), egui::Align2::LEFT_CENTER, &self.scene.nodes[i].label, egui::FontId::proportional(10.0), th.text);
}
let sel = self.state.selected.clone();
let sel_idx = sel.as_deref().and_then(|l| self.scene.nodes.iter().position(|nd| nd.label == l));
let mut emitted: Vec<usize> = on.iter().copied().take(A11Y_BUDGET).collect();
if let Some(s) = sel_idx
&& rect.contains(project(cache.pos[s]))
&& !emitted.contains(&s)
{
emitted.push(s);
}
// The budget, in the tree: the canvas says how much of the graph it exposes.
let summary = format!(
"{}: {n} nodes · {} on screen · {} addressable (budget {A11Y_BUDGET}) — pan / zoom to reach the others",
self.title,
on.len(),
emitted.len()
);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, summary.clone()));
let base_id = ui.id().with("graph-nodes");
for &i in &emitted {
let node = &self.scene.nodes[i];
let is_sel = sel_idx == Some(i);
let hit = egui::Rect::from_center_size(project(cache.pos[i]), egui::vec2(NODE_HIT, NODE_HIT));
let sem = facett_core::a11y::Semantics::button(node.label.clone()).selected(is_sel);
if facett_core::a11y::node(ui, base_id, &node.label, egui::Sense::click(), hit, sem).clicked() {
msgs.push(Msg::SelectNode(node.label.clone()));
}
}
// A click on the canvas itself picks the nearest on-screen node within reach
// (the buttons cover only the budget).
if let Some(click) = resp.clicked().then(|| resp.interact_pointer_pos()).flatten() {
let reach2 = (NODE_HIT * 0.5) * (NODE_HIT * 0.5);
let hit = on
.iter()
.map(|&i| (i, project(cache.pos[i]).distance_sq(click)))
.filter(|&(_, d)| d <= reach2)
.min_by(|a, b| a.1.total_cmp(&b.1));
if let Some((i, _)) = hit {
msgs.push(Msg::SelectNode(self.scene.nodes[i].label.clone()));
}
}
if let Some(s) = sel_idx {
painter.circle_stroke(project(cache.pos[s]), 8.0, egui::Stroke::new(2.0_f32, th.accent));
}
cache.last = scale::A11yFrame { on_screen: on.len(), emitted, labels };
msgs
}
/// Push the resident cloud: the camera every frame, the scene once per layout.
#[cfg(feature = "wgpu")]
fn draw_resident(&self, ui: &egui::Ui, rect: egui::Rect, cache: &scale::ScaleCache, cam: scale::ScaleCamera) -> bool {
use facett_core::render::gpu::graphcloud::{CloudCamera, CloudPoint};
if !self.use_gpu {
return false;
}
let ppp = ui.ctx().pixels_per_point();
let o = (rect.center() - rect.min + cam.pan) * ppp;
let ccam = CloudCamera::plane([rect.width() * ppp, rect.height() * ppp], [o.x, o.y], cam.scale(rect) * ppp, 5.0 * ppp, 0.5 * ppp, 1.5);
let n = self.scene.nodes.len();
let build = || {
let pts = self.scene.nodes.iter().enumerate().map(|(i, nd)| CloudPoint::new([cache.pos[i].x, -cache.pos[i].y, 0.0], nd.color)).collect();
let eds = self.scene.edges.iter().filter(|e| e.src < n && e.dst < n).map(|e| [e.src as u32, e.dst as u32]).collect();
(pts, eds)
};
cache.slot.draw(ui, rect, cache.generation, build, ccam)
}
#[cfg(not(feature = "wgpu"))]
fn draw_resident(&self, _ui: &egui::Ui, _rect: egui::Rect, _cache: &scale::ScaleCache, _cam: scale::ScaleCamera) -> bool {
false
}
/// Per-label node counts (for the introspection).
pub fn label_counts(&self) -> BTreeMap<String, usize> {
let mut m = BTreeMap::new();
for n in &self.scene.nodes {
*m.entry(n.label.clone()).or_insert(0) += 1;
}
m
}
}
impl GraphView {
/// The copyable text: every node label, one per line (the graph carries no
/// per-node selection, so this is the whole scene's label list).
pub fn copy_text(&self) -> Option<String> {
if self.scene.nodes.is_empty() {
return None;
}
Some(self.scene.nodes.iter().map(|n| n.label.clone()).collect::<Vec<_>>().join("\n"))
}
}
// ── typed copy (§16) — read-only: the scene's node labels as Text ─────────────
impl facett_core::clip::CopySource for GraphView {
fn copy_kinds(&self) -> &[facett_core::clip::ClipKind] {
use facett_core::clip::ClipKind;
&[ClipKind::Text]
}
fn copy_payload(&self) -> Option<facett_core::clip::ClipPayload> {
self.copy_text().map(facett_core::clip::ClipPayload::Text)
}
}
// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
impl facett_core::Elm for GraphView {
type Model = GraphState;
type Msg = Msg;
type Effect = Effect;
fn title(&self) -> &str {
&self.title
}
fn state(&self) -> &GraphState {
&self.state
}
fn update(&mut self, msg: Msg) -> Vec<Effect> {
GraphView::update(self, msg)
}
fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
GraphView::view(self, ui)
}
}
// The bridge macro writes `impl Facet for GraphView` from the `Elm` impl: `title`,
// the FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the overrides below.
// **Form 3** (`custom_state_json`) because the view publishes a RICHER `state_json`
// than a plain `serde(state())`: the `nodes`/`edges`/`labels` counts facett-demo's
// mega_matrix reads are *derived* from the input `Scene`, not the layout Model — so
// the default `state_json` is suppressed and these EXACT pre-migration keys supplied.
facett_core::impl_facet_via_elm!(GraphView, custom_state_json, {
fn state_json(&self) -> serde_json::Value {
if self.at_scale() {
// The tree holds the budgeted on-screen buttons, so those ARE the targets.
let mut j = self.scale_json();
let c = self.scale.borrow();
j["labels"] = serde_json::json!(self.label_counts());
j["node_targets"] = c.last.emitted.iter().map(|&i| self.scene.nodes[i].label.clone()).collect::<Vec<_>>().into();
j["error_code"] = serde_json::Value::Null;
return j;
}
serde_json::json!({
"nodes": self.scene.nodes.len(),
"edges": self.scene.edges.len(),
"labels": self.label_counts(),
// Within-pane interaction state a driver reads/asserts:
"selected": self.state.selected.clone(),
// Discoverable AccessKit targets (Locate { Target::label(..) }).
"node_targets": self.node_targets(),
// Which lane drew the node/edge cloud (facett-core `lane::cloud_lane_json`).
"lane": facett_core::render::lane::cloud_lane_json(self.use_gpu, Self::gpu_installed(), self.lane_drew.get()),
// The stable UI error code for the empty state (facett_core::errcode).
"error_code": if self.scene.nodes.is_empty() { serde_json::json!("facet-graph-1") } else { serde_json::Value::Null },
})
}
fn copy(&mut self) -> Option<String> {
use facett_core::clip::CopySource as _;
self.copy_payload().map(|p| p.as_text())
}
/// Painted via [`draw`], which reads the active [`Theme`] (edges/labels/empty
/// hint) from the context; each node is click-selectable (drives the accent
/// ring); the canvas takes the host's `available_size`.
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().selectable().resizable().copyable()
}
/// STRUCTURAL severity: RED for the empty scene (`facet-graph-1`), Info otherwise.
fn severity(&self) -> facett_core::Severity {
if self.scene.nodes.is_empty() { facett_core::Severity::Error } else { facett_core::Severity::Info }
}
fn selection_json(&self) -> serde_json::Value {
match &self.state.selected {
Some(l) => serde_json::json!({ "node": l }),
None => serde_json::Value::Null,
}
}
});
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn typed_copy_is_the_node_label_list() {
use facett_core::clip::{ClipKind, CopySource};
let scene = scene_from_labeled_edges(vec![(1, 2, "Person".into(), "Company".into())]);
let mut g = GraphView::new(scene);
let p = g.copy_payload().expect("a populated scene copies");
assert_eq!(p.kind(), ClipKind::Text);
assert!(p.as_text().contains("Person") && p.as_text().contains("Company"));
assert_eq!(<GraphView as Facet>::copy(&mut g), Some(p.as_text()));
}
#[test]
fn builds_scene_from_labeled_edges() {
let scene = scene_from_labeled_edges(vec![
(1, 2, "Person".into(), "Company".into()),
(1, 3, "Person".into(), "Address".into()),
]);
assert_eq!(scene.nodes.len(), 3, "1, 2, 3 distinct");
assert_eq!(scene.edges.len(), 2);
assert_eq!(scene.nodes[0].label, "Person");
}
// ── Elm split: state_json keys preserved byte-for-byte ───────────────────
#[test]
fn state_json_preserves_the_pre_migration_keys() {
let scene = scene_from_labeled_edges(vec![
(1, 2, "Person".into(), "Company".into()),
(1, 3, "Person".into(), "Address".into()),
]);
let g = GraphView::new(scene);
let j = Facet::state_json(&g);
// The pre-migration `nodes` / `edges` / `labels` count keys are preserved
// byte-for-byte (no layout key leaks from the Model).
assert_eq!(j["nodes"], 3);
assert_eq!(j["edges"], 2);
assert_eq!(j["labels"]["Person"], 1);
// Plus the within-pane addressability keys: the selection (null when nothing
// is selected) and the discoverable AccessKit target list.
assert!(j["selected"].is_null(), "nothing selected initially");
assert_eq!(
j["node_targets"],
serde_json::json!(["Address", "Company", "Person"]),
"node_targets lists the distinct addressable node labels, sorted"
);
let obj = j.as_object().unwrap();
assert_eq!(
obj.len(),
7,
"nodes/edges/labels + selected/node_targets + lane/error_code: {:?}",
obj.keys().collect::<Vec<_>>()
);
for k in ["nodes", "edges", "labels", "selected", "node_targets", "lane", "error_code"] {
assert!(obj.contains_key(k), "state_json carries {k}");
}
// Not painted yet, no GPU lane selected, no renderer installed in a unit test:
// the lane says so plainly instead of reading `cpu`.
assert_eq!(j["lane"]["lane_flag"], "pending");
assert_eq!(j["lane"]["gpu"], "no GPU");
assert!(j["error_code"].is_null(), "a populated graph carries no error code");
let empty = Facet::state_json(&GraphView::new(facett_core::Scene::new()));
assert_eq!(empty["error_code"], "facet-graph-1");
}
// ── FC-2 → FC-3: the within-pane node selection drives headlessly (the same
// transition the painted-node click produces). ─────────────────────────────
#[test]
fn harness_drives_selectnode_toggle_and_clear() {
use facett_core::harness;
let mut g = GraphView::new(scene_from_labeled_edges(vec![
(1, 2, "A".into(), "B".into()),
(2, 3, "B".into(), "C".into()),
]));
assert!(g.selected().is_none(), "starts unselected");
// Selecting a node by its stable label is observable in the snapshot.
let snap = harness::snapshot(&mut g, [Msg::SelectNode("B".into())]);
assert_eq!(snap.selected.as_deref(), Some("B"), "selection observable headlessly");
assert_eq!(Facet::selection_json(&g)["node"], "B");
assert_eq!(Facet::state_json(&g)["selected"], "B");
// Re-selecting the open node toggles it off (the click behaviour).
let snap = harness::snapshot(&mut g, [Msg::SelectNode("B".into())]);
assert!(snap.selected.is_none(), "re-select toggles off");
// ClearSelection wipes any selection.
let snap = harness::snapshot(&mut g, [Msg::SelectNode("A".into()), Msg::ClearSelection]);
assert!(snap.selected.is_none(), "ClearSelection empties the selection");
assert!(Facet::selection_json(&g).is_null());
}
// ── FC-2 → FC-3 as a *headless* property via the core harness ─────────────
#[test]
fn harness_drives_setlayout_and_snapshots_state() {
use facett_core::harness;
let mut g = GraphView::new(scene_from_labeled_edges(vec![(1, 2, "A".into(), "B".into())]));
assert_eq!(g.state().layout, GraphLayout::Circular, "default layout");
// SetLayout is observable in the snapshot.
let snap = harness::snapshot(&mut g, [Msg::SetLayout(GraphLayout::Force)]);
assert_eq!(snap.layout, GraphLayout::Force, "layout is observable headlessly");
assert_eq!(&snap, g.state(), "the snapshot is a clone of the live state");
assert_eq!(g.state().layout, GraphLayout::Force, "the live view reflects the driven layout");
assert!(matches!(g.layout(), Layout::Force), "the accessor maps back to the core Layout");
}
#[test]
fn drive_reports_no_effects_and_state_round_trips() {
use facett_core::harness;
let mut g = GraphView::new(Scene::new());
// FC-8: the graph view does no I/O, so the effect stream is always empty.
let effects = harness::drive(&mut g, [Msg::SetLayout(GraphLayout::Force)]);
assert!(effects.is_empty(), "FC-8: graph view emits no Effects");
// FC-3: the observable Model round-trips through serde.
let json = serde_json::to_value(g.state()).unwrap();
assert_eq!(json["layout"], "force");
let back: GraphState = serde_json::from_value(json).unwrap();
assert_eq!(&back, g.state(), "serde(state) -> state round-trips");
}
#[test]
fn headless_render_draws_and_reports_state() {
use facett_core::harness;
let mut g = GraphView::new(scene_from_labeled_edges(vec![
(1, 2, "Person".into(), "Company".into()),
(2, 3, "Company".into(), "Address".into()),
]))
.with_title("graph");
let r = harness::headless_render(&mut g);
assert_eq!(r.title, "graph");
assert_eq!(r.state["nodes"], 3);
assert_eq!(r.state["edges"], 2);
assert!(r.drew(), "a 3-node graph tessellates to vertices");
}
/// The GPU lane selected in a process with no cloud renderer (this unit test has no
/// device): the state says `no GPU` plainly, the frame is reported as the egui
/// painter's, and the painter really drew. With a renderer installed the same pane
/// reads `lane_flag: gpu` — measured in a WebGPU browser by `gallery_probe`.
#[test]
fn a_selected_gpu_lane_without_a_renderer_says_no_gpu() {
use facett_core::harness;
let mut g = GraphView::new(scene_from_labeled_edges(vec![(1, 2, "A".into(), "B".into())])).with_gpu(true);
let r = harness::headless_render(&mut g);
assert!(r.drew());
let lane = &r.state["lane"];
assert_eq!(lane["selected"], true);
assert_eq!(lane["gpu"], "no GPU");
assert_eq!(lane["lane_flag"], "cpu", "the frame went through the egui painter: {lane}");
assert_eq!(lane["gpu_cloud"], false);
}
}