Skip to main content

facett_core/
lib.rs

1//! **facett-core** — the visual kernel. Render a node/edge **`Scene`** into egui.
2//! Source-agnostic: build a `Scene` from anything (Arrow rows, a graph, a DAG),
3//! hand it here, get pixels. The CPU painter is the reference; a **wgpu** fast
4//! path (GPU viewport-cull + indirect draw, seeded from katana-osm's
5//! `osm-viewer`) lands behind this same `draw()` call — consumers don't change.
6
7use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
8
9pub mod a11y;
10/// **Barnes–Hut N-body repulsion** — the shared O(n log n) force-layout kernel
11/// (dimension-generic quadtree/octree, gatling-parallel) that every facett
12/// force-directed layout consumes so a 30 000-node graph lays out without the
13/// O(n²)-per-iteration hang. See [`barnes_hut`].
14pub mod barnes_hut;
15/// The shared **action bus** (BUS-1) — a typed, deterministic outbox facets use
16/// to hand side work (a drag move, a time-scrub, a command) to the host. The
17/// common carrier [`dragdrop`] / [`time_axis`] `Effect`s flow through.
18pub mod action_bus;
19pub mod caps;
20pub mod chrome;
21pub mod clip;
22pub mod clipboard;
23pub mod deckfx;
24/// The shared **drag-and-drop** primitive (DND-1) — the one reducer behind
25/// items-dragged-onto-zones (cards→columns, bars→lanes). Engine-agnostic sibling
26/// of [`nav::Navigable`]; the caps flag [`FacetCaps::draggable`] points here.
27pub mod dragdrop;
28pub mod edges;
29pub mod effects;
30/// The canonical **Elm contract** (FC-2 / FC-9) — the [`Elm`] trait +
31/// [`impl_facet_via_elm!`] bridge macro that lift `facett-security`'s hand-rolled
32/// Model/Msg/Effect/pure-view pattern into reusable infra. Pair with
33/// [`harness`] to drive an `Elm` component headlessly.
34pub mod elm;
35pub mod focus;
36pub mod imgscan; // image-analysis oracle (SCAN-THE-PIXELS law): spoke/high-freq/
37                 // coverage/centroid features computed FROM the rendered pixels.
38pub mod labels3d;
39pub mod harness;
40pub mod look;
41pub mod nav;
42pub mod overlay;
43/// The constellation-wide **`Panel` trait** (Phase 0 foundation) — the primary
44/// UI-pane seam, with backend (1) native in-process (the blanket
45/// `impl<T: Facet> Panel for T`) and backend (4) headless ([`panel::drive`]).
46/// See `.nornir/wasm-ui-panels-design.md` §1.
47pub mod panel;
48pub mod rabbit;
49/// The L0 shared render kernel (CONS-CORE) — shared `Camera`, z-ordered
50/// `LayerStack`, the CPU rect scissor, and (feature `wgpu`) the extracted GPU
51/// scaffold. Map skins + `facett-graphview` draw through this.
52pub mod render;
53pub mod runtrace; // in-memory, wasm-safe "what RAN" ledger (no FS) — folded into
54                  // state_json["trace"]["ran"], read via the JS hook on wasm.
55pub mod scroll_engine;
56pub mod testmatrix; // functional-status → nornir test-matrix bridge (feature
57                    // `testmatrix`); no-op in release.
58pub mod theme;
59/// The shared **pan/zoom-in-time axis** (TIME-1) — the one time-window model
60/// behind gantt/CFD/calendar/timeline, linkable across facets. Temporal sibling
61/// of [`nav::Navigable`]; the caps flag [`FacetCaps::time_scrollable`] points here.
62pub mod time_axis;
63pub mod trace; // structured IN/OUT/END event stream ($FACETT_TRACE) — the
64               // machine-readable data a facet actually rendered.
65pub use a11y::{Semantics, node as a11y_node, stable_id};
66pub use action_bus::{ActionBus, BusAction, BusMsg};
67pub use caps::FacetCaps;
68pub use dragdrop::{DragDrop, DragEffect, DragMsg, Move as DragMove};
69pub use clip::{ArrowColumnRef, ClipKind, ClipPayload, CopySource, PasteTarget};
70pub use clipboard::ClipAction;
71pub use deckfx::{DeckFx, DeckRaven};
72pub use elm::Elm;
73pub use imgscan::{BBox, Rgba, ScanReport, coverage, high_freq_ratio, painted_centroid_and_bbox, scan, spoke_score};
74pub use look::{Action, KeyMap, Palette};
75pub use nav::{Dir4, Navigable, nearest_in_direction};
76pub use panel::Panel;
77pub use rabbit::{Rabbit, RabbitMesh, rabbit_mesh, rabbit_outline};
78pub use scroll_engine::SmoothScroll;
79pub use theme::{Theme, set_theme, theme};
80pub use time_axis::{TimeAxis, TimeEffect, TimeMsg};
81
82// The rich look-&-feel `Theme` (the work-order architecture) is re-exported under
83// an unambiguous alias so it coexists with the legacy flat palette `Theme` above.
84pub use look::Theme as LookTheme;
85
86/// A node: a label + a colour (the *consumer* picks the colour policy — hash by
87/// label, by status, …).
88#[derive(Clone)]
89pub struct Node {
90    pub label: String,
91    pub color: Color32,
92}
93
94/// A directed edge between node indices.
95#[derive(Clone, Copy)]
96pub struct Edge {
97    pub src: usize,
98    pub dst: usize,
99}
100
101/// A drawable graph: nodes + edges (edges index into `nodes`).
102#[derive(Default, Clone)]
103pub struct Scene {
104    pub nodes: Vec<Node>,
105    pub edges: Vec<Edge>,
106}
107
108impl Scene {
109    pub fn new() -> Self {
110        Self::default()
111    }
112    /// Push a node, returning its index.
113    pub fn node(&mut self, label: impl Into<String>, color: Color32) -> usize {
114        self.nodes.push(Node { label: label.into(), color });
115        self.nodes.len() - 1
116    }
117    pub fn edge(&mut self, src: usize, dst: usize) {
118        self.edges.push(Edge { src, dst });
119    }
120    pub fn is_empty(&self) -> bool {
121        self.nodes.is_empty()
122    }
123}
124
125/// Node placement strategy.
126#[derive(Clone, Copy, PartialEq, Eq, Default)]
127pub enum Layout {
128    #[default]
129    Circular,
130    /// Deterministic Fruchterman–Reingold (edges pull, all nodes repel). O(n²)
131    /// per iteration — best for small/medium graphs.
132    Force,
133}
134
135/// Draw a `Scene` into `ui` — the reusable render primitive. Empty scenes show
136/// `empty_hint`. Labels render when the node count is small enough to read.
137pub fn draw(ui: &mut Ui, scene: &Scene, layout: Layout, empty_hint: &str) {
138    let (rect, _) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
139    let n = scene.nodes.len();
140    if n == 0 {
141        let th = theme(ui);
142        ui.painter_at(rect).text(rect.center(), Align2::CENTER_CENTER, empty_hint, FontId::proportional(13.0), th.text_dim);
143        return;
144    }
145    let pos = positions(layout, scene, rect);
146    draw_positions(ui, scene, &pos, rect, empty_hint);
147}
148
149/// Paint a `Scene` into `rect` using **pre-computed** node positions — the drawing
150/// half of [`draw`], split out so a stateful host can **freeze** the layout: compute
151/// the O(n log n) force positions once (cached until the graph structure changes, see
152/// [`ForceCache`]) and paint them every frame WITHOUT re-running the layout. `pos`
153/// must be indexed like `scene.nodes`; short/empty falls back to the empty hint.
154pub fn draw_positions(ui: &mut Ui, scene: &Scene, pos: &[Pos2], rect: Rect, empty_hint: &str) {
155    let th = theme(ui);
156    let painter = ui.painter_at(rect);
157    let n = scene.nodes.len();
158    if n == 0 || pos.len() != n {
159        painter.text(rect.center(), Align2::CENTER_CENTER, empty_hint, FontId::proportional(13.0), th.text_dim);
160        return;
161    }
162    for e in &scene.edges {
163        if e.src < n && e.dst < n {
164            painter.line_segment([pos[e.src], pos[e.dst]], Stroke::new(0.6, th.edge));
165        }
166    }
167    for (i, node) in scene.nodes.iter().enumerate() {
168        painter.circle_filled(pos[i], 5.0, node.color);
169    }
170    if n <= 60 {
171        for (i, node) in scene.nodes.iter().enumerate() {
172            painter.text(pos[i] + vec2(7.0, 0.0), Align2::LEFT_CENTER, &node.label, FontId::proportional(10.0), th.text);
173        }
174    }
175}
176
177/// **Converge-once-then-freeze** layout cache. A force layout is a pure function of
178/// the graph structure (node count + edge set) and the paint `rect`, so this holds the
179/// last-computed positions and only re-runs [`layout_positions`] when the structure or
180/// rect actually changes. Embed it on a stateful host (e.g. a `GraphView`) and call
181/// [`ForceCache::positions`] from the render path — the O(n log n) (with Barnes–Hut)
182/// or O(n²) (small graphs) layout stops running every frame, which is the other half
183/// (besides Barnes–Hut) of killing the 30 000-node hang.
184#[derive(Default, Clone)]
185pub struct ForceCache {
186    sig: u64,
187    rect: [u32; 4],
188    pos: Vec<Pos2>,
189}
190
191impl ForceCache {
192    /// The node positions for `scene` under `layout` in `rect`, computed on the first
193    /// call and on any structural/rect change, and returned from cache otherwise.
194    pub fn positions(&mut self, layout: Layout, scene: &Scene, rect: Rect) -> &[Pos2] {
195        let n = scene.nodes.len();
196        let key = self.structure_key(layout, scene);
197        let rb = [rect.min.x.to_bits(), rect.min.y.to_bits(), rect.max.x.to_bits(), rect.max.y.to_bits()];
198        if self.sig != key || self.rect != rb || self.pos.len() != n {
199            self.pos = positions(layout, scene, rect);
200            self.sig = key;
201            self.rect = rb;
202        }
203        &self.pos
204    }
205
206    /// Whether the next [`positions`](Self::positions) call for this `layout`/`scene`
207    /// in this `rect` will be a cache hit (a no-op relayout) — the *frozen/settled*
208    /// signal a `tick` can assert.
209    #[must_use]
210    pub fn is_settled(&self, layout: Layout, scene: &Scene, rect: Rect) -> bool {
211        let rb = [rect.min.x.to_bits(), rect.min.y.to_bits(), rect.max.x.to_bits(), rect.max.y.to_bits()];
212        self.sig == self.structure_key(layout, scene) && self.rect == rb && self.pos.len() == scene.nodes.len()
213    }
214
215    /// Force a recompute on the next [`positions`](Self::positions) call.
216    pub fn invalidate(&mut self) {
217        self.sig = 0;
218        self.rect = [0; 4];
219        self.pos.clear();
220    }
221
222    fn structure_key(&self, layout: Layout, scene: &Scene) -> u64 {
223        let edges: Vec<(usize, usize)> = scene.edges.iter().map(|e| (e.src, e.dst)).collect();
224        let key = match layout {
225            Layout::Circular => "circular",
226            Layout::Force => "force",
227        };
228        crate::barnes_hut::structure_sig(key, scene.nodes.len(), &edges)
229    }
230}
231
232/// **Test/host hook (additive).** The public, return-asserted view of the
233/// private [`positions`] layout node — the exact node centres [`draw`] paints for
234/// `scene` under `layout` inside `rect`. Exposed so the graph-skin call-chain
235/// matrix can assert the *layout* stage (finite, in-rect, count == nodes,
236/// circular radius, force-fit normalisation) without a painter. Calls the **same**
237/// private fn `draw` uses, so it IS the layout the pixels come from — additive,
238/// no behaviour change.
239pub fn layout_positions(layout: Layout, scene: &Scene, rect: Rect) -> Vec<Pos2> {
240    positions(layout, scene, rect)
241}
242
243fn positions(layout: Layout, scene: &Scene, rect: Rect) -> Vec<Pos2> {
244    let n = scene.nodes.len();
245    let center = rect.center();
246    let radius = rect.size().min_elem() * 0.42;
247    let circular = |i: usize| {
248        let a = std::f32::consts::TAU * (i as f32) / (n as f32);
249        vec2(a.cos(), a.sin())
250    };
251    match layout {
252        Layout::Circular => (0..n).map(|i| center + radius * circular(i)).collect(),
253        Layout::Force => {
254            // Deterministic Fruchterman–Reingold from a circular seed (unit space).
255            let mut p: Vec<egui::Vec2> = (0..n).map(circular).collect();
256            let k = (1.0 / (n.max(1) as f32).sqrt()).clamp(0.05, 1.0);
257            // Above the threshold the O(n²) all-pairs repulsion is the 30 000-node
258            // hang — swap it for the shared Barnes–Hut O(n log n) kernel (parallel,
259            // deterministic). Below the threshold the exact loop runs unchanged, so
260            // every small-graph golden is byte-for-byte identical (additive).
261            let use_bh = n >= crate::barnes_hut::BH_THRESHOLD;
262            for _ in 0..120 {
263                let mut disp = vec![egui::Vec2::ZERO; n];
264                if use_bh {
265                    let pts: Vec<[f32; 2]> = p.iter().map(|v| [v.x, v.y]).collect();
266                    let rep = crate::barnes_hut::repulsion_forces::<2>(&pts, k, crate::barnes_hut::BH_THETA);
267                    for i in 0..n {
268                        disp[i] = egui::vec2(rep[i][0], rep[i][1]);
269                    }
270                } else {
271                    for i in 0..n {
272                        for j in (i + 1)..n {
273                            let d = p[i] - p[j];
274                            let dist = d.length().max(1e-3);
275                            let f = k * k / dist;
276                            let dir = d / dist;
277                            disp[i] += dir * f;
278                            disp[j] -= dir * f;
279                        }
280                    }
281                }
282                for e in &scene.edges {
283                    if e.src < n && e.dst < n {
284                        let d = p[e.src] - p[e.dst];
285                        let dist = d.length().max(1e-3);
286                        let f = dist * dist / k;
287                        let dir = d / dist;
288                        disp[e.src] -= dir * f;
289                        disp[e.dst] += dir * f;
290                    }
291                }
292                for i in 0..n {
293                    let dl = disp[i].length().max(1e-3);
294                    p[i] += disp[i] / dl * dl.min(0.04); // capped step (cooling-free, deterministic)
295                }
296            }
297            // Normalise to fit the rect.
298            let (mut mn, mut mx) = (egui::vec2(f32::MAX, f32::MAX), egui::vec2(f32::MIN, f32::MIN));
299            for v in &p {
300                mn.x = mn.x.min(v.x);
301                mn.y = mn.y.min(v.y);
302                mx.x = mx.x.max(v.x);
303                mx.y = mx.y.max(v.y);
304            }
305            let span = (mx - mn).max(egui::vec2(1e-3, 1e-3));
306            p.iter()
307                .map(|v| center + egui::vec2(((v.x - mn.x) / span.x - 0.5) * 2.0 * radius, ((v.y - mn.y) / span.y - 0.5) * 2.0 * radius))
308                .collect()
309        }
310    }
311}
312
313/// The facett **component contract**. Every facet — graph, map, pipeline, table,
314/// the ported nornir viewers — implements this, so consumers (korp, nornir, …)
315/// compose them uniformly *and* get headless robot-testing for free.
316///
317/// The things a component owes its host:
318/// 1. a **title** (tab label / panel heading),
319/// 2. how to **draw** itself into egui,
320/// 3. its **observable state** as JSON — dumped to `$APP_STATE` for headless
321///    assertions. **Rule:** every visible list/status/count goes in `state_json`.
322/// 4. (**defaulted**) [`update_json`](Facet::update_json) — the Elm mutation path,
323///    a no-op by default so it costs existing facets nothing; overriding it (and
324///    the three above) makes a facet interchangeable with a [`Panel`](crate::Panel).
325pub trait Facet {
326    fn title(&self) -> &str;
327    fn ui(&mut self, ui: &mut Ui);
328    fn state_json(&self) -> serde_json::Value;
329
330    /// The **Elm mutation path** — apply one message (JSON). This is the fourth
331    /// member of the component contract (`title`/`ui`/`state_json`/`update_json`),
332    /// and it is **defaulted to a no-op** so it is purely ADDITIVE: every existing
333    /// `impl Facet` keeps compiling unchanged, while a facet that wants the writable
334    /// input surface overrides it (an Elm-backed facet routes the JSON through its
335    /// `update`). With this default in place `Facet` and [`Panel`](crate::Panel)
336    /// share the same four-method shape, which is what lets the blanket
337    /// `impl<T: Facet> Panel for T` (see [`panel`](crate::panel)) make **every**
338    /// `Facet` a `Panel` for free. Unknown/undriven messages are ignored — the
339    /// default simply does nothing.
340    fn update_json(&mut self, _msg_json: &str) {}
341
342    // --- uniform capability surface (all defaulted; see caps.rs / clipboard.rs) ---
343
344    /// What this facet can do. Override to opt into capabilities.
345    fn caps(&self) -> FacetCaps {
346        FacetCaps::NONE
347    }
348
349    /// Current uniform scale (1.0 = native). Override if `caps().scalable`.
350    fn scale(&self) -> f32 {
351        1.0
352    }
353    /// Set the uniform scale; clamp internally. Default no-op (not scalable).
354    fn set_scale(&mut self, _scale: f32) {}
355
356    /// The current selection as JSON (also folded into `state_json` by
357    /// convention). `Null` when nothing/none selectable.
358    fn selection_json(&self) -> serde_json::Value {
359        serde_json::Value::Null
360    }
361
362    /// Clipboard hooks — see clipboard.rs. Defaults: nothing to give/take.
363    /// Returns the text to place on the clipboard (None = nothing copyable now).
364    fn copy(&mut self) -> Option<String> {
365        None
366    }
367    /// Like `copy`, but also removes the selection. Default delegates to `copy`.
368    fn cut(&mut self) -> Option<String> {
369        self.copy()
370    }
371    /// Accept pasted text. Returns true if consumed.
372    fn paste(&mut self, _text: &str) -> bool {
373        false
374    }
375
376    /// Optional downcast handle for hosts that need typed access to a specific
377    /// facet living inside a [`FacetDeck`] (e.g. a robot-UI driver clicking an
378    /// app-level control that must forward to a concrete component's own API).
379    /// Defaulted to `None` so no existing facet has to change; a component opts in
380    /// by returning `Some(self)`.
381    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
382        None
383    }
384
385    // --- cross-instance state clone (copy/paste BETWEEN same-component instances) ---
386    // See `.nornir/design/copy-paste-between-instances.md` + `clipboard.rs`. The trio
387    // below is the type-tagged STATE layer on top of the text clipboard: two
388    // instances with the SAME `kind()` exchange `portable_state()` via the OS
389    // clipboard envelope (`clipboard::encode_component`/`decode_component`). Each is
390    // defaulted to the opt-OUT floor — a component that doesn't implement all three
391    // neither copies nor accepts cross-instance state, and nothing panics.
392
393    /// Stable component-type id (e.g. `"jobview"`, `"graphpan"`, `"table"`). Two
394    /// instances with the **same** kind can exchange portable state; the empty
395    /// default `""` means **opted out** of cross-instance clone.
396    fn kind(&self) -> &'static str {
397        ""
398    }
399
400    /// The **portable** subset of this facet's state — the fields a same-kind
401    /// sibling can adopt. `None` = not cloneable. Kept SEPARATE from
402    /// [`state_json`](Self::state_json) (the introspection dump, which may carry
403    /// derived / render-only data) so this stays round-trippable through
404    /// [`load_state`](Self::load_state).
405    fn portable_state(&self) -> Option<serde_json::Value> {
406        None
407    }
408
409    /// Adopt a portable state produced by [`portable_state`](Self::portable_state)
410    /// on a same-kind sibling. Returns `true` if accepted. Default `false`
411    /// (opt-in per component).
412    fn load_state(&mut self, _state: &serde_json::Value) -> bool {
413        false
414    }
415}
416
417/// A tabbed set of [`Facet`]s — the reusable multi-component shell. Draws a tab
418/// bar + the active facet, and composes **every** facet's `state_json` under its
419/// title, so the whole-app introspection contract is free. korp/nornir can build
420/// their window from a `FacetDeck` instead of hand-rolling tabs + the state dump.
421pub struct FacetDeck {
422    facets: Vec<Box<dyn Facet>>,
423    active: usize,
424    /// Opt-in deck effects (palette override + glow). `Default` = all off, so a
425    /// deck that never opts in is unchanged and pays nothing.
426    fx: DeckFx,
427    /// A raven summoned through the deck, in flight or perched (or `None`).
428    raven: Option<DeckRaven>,
429    /// A transient, themed component-clone toast (message + the `ctx.input.time`
430    /// it was raised at), shown briefly after a Copy-/Paste-component gesture —
431    /// chiefly the type-mismatch rejection ("clipboard holds a `table`, not a
432    /// `graphpan`"). `None` = nothing to show. See [`Self::component_toast`].
433    toast: Option<(String, f64)>,
434}
435
436/// How long a component-clone [`toast`](FacetDeck::toast) stays on screen.
437const TOAST_SECS: f64 = 2.6;
438
439impl FacetDeck {
440    pub fn new(facets: Vec<Box<dyn Facet>>) -> Self {
441        Self { facets, active: 0, fx: DeckFx::OFF, raven: None, toast: None }
442    }
443    /// Append a facet (the incremental form of [`new`](Self::new)). Lets a host
444    /// build a deck pane-by-pane as it discovers what to show (e.g. one pane per
445    /// warehouse table it finds).
446    pub fn push(&mut self, facet: Box<dyn Facet>) {
447        self.facets.push(facet);
448    }
449    pub fn active(&self) -> usize {
450        self.active
451    }
452
453    /// The title of the currently-active facet (the deck's `state_json["active"]`),
454    /// or `None` if the deck is empty.
455    pub fn active_title(&self) -> Option<&str> {
456        self.facets.get(self.active).map(|f| f.title())
457    }
458
459    /// The titles of every tabbed facet, in tab order — the discoverable surface a
460    /// host (or a robot-UI control channel) enumerates to know which tabs exist.
461    pub fn titles(&self) -> Vec<&str> {
462        self.facets.iter().map(|f| f.title()).collect()
463    }
464
465    /// Make the facet titled `title` the active tab — the programmatic (headless,
466    /// robot-addressable) equivalent of clicking its tab header. Returns `true` if a
467    /// facet with that title exists (and is now active), `false` otherwise. This is
468    /// the named boundary a control channel switches tabs through (the deck analogue
469    /// of the viz's `Tab::from_name`), so a driver needn't replay a pointer click.
470    pub fn set_active_by_title(&mut self, title: &str) -> bool {
471        match self.facets.iter().position(|f| f.title() == title) {
472            Some(i) => {
473                self.active = i;
474                true
475            }
476            None => false,
477        }
478    }
479
480    /// Typed mutable access to the facet with `title`, downcast to `T` — `None` if
481    /// no such facet, or it doesn't opt into [`Facet::as_any_mut`], or the type
482    /// mismatches. Lets a host drive a concrete component's own API (e.g. a
483    /// robot-UI control forwarding a node selection to a `SystemChart`).
484    pub fn facet_mut<T: std::any::Any>(&mut self, title: &str) -> Option<&mut T> {
485        self.facets
486            .iter_mut()
487            .find(|f| f.title() == title)
488            .and_then(|f| f.as_any_mut())
489            .and_then(|a| a.downcast_mut::<T>())
490    }
491
492    /// Replace the facet whose `title` matches with `facet` (the box's own title is
493    /// what the deck enumerates afterwards). Returns `true` if a facet was replaced.
494    /// Used by hosts that **reload** a tab's data in place — e.g. the OSM region
495    /// picker rebuilds the `OSM 2D` / `OSM 3D` views from a freshly clipped region
496    /// and swaps them in, keeping the same tab slots (and the active selection).
497    pub fn replace_facet(&mut self, title: &str, facet: Box<dyn Facet>) -> bool {
498        if let Some(slot) = self.facets.iter_mut().find(|f| f.title() == title) {
499            *slot = facet;
500            true
501        } else {
502            false
503        }
504    }
505
506    // ── opt-in effects + theming (see deckfx.rs) ─────────────────────────────
507
508    /// Enable deck effects up front (builder form of [`fx_mut`](Self::fx_mut)).
509    pub fn with_fx(mut self, fx: DeckFx) -> Self {
510        self.fx = fx;
511        self
512    }
513    /// The current deck-effects config (read-only).
514    pub fn fx(&self) -> &DeckFx {
515        &self.fx
516    }
517    /// Mutate the deck-effects config (toggle glow, pin a palette, …).
518    pub fn fx_mut(&mut self) -> &mut DeckFx {
519        &mut self.fx
520    }
521    /// Override the deck theme with palette index `i` (wraps); enables the
522    /// override. Convenience over `fx_mut().set_palette(i)`.
523    pub fn set_palette(&mut self, i: usize) {
524        self.fx.set_palette(i);
525    }
526    /// Advance to the next palette in [`Theme::ALL`] (wrapping); returns the new
527    /// index. Convenience over `fx_mut().cycle_palette()`.
528    pub fn cycle_palette(&mut self) -> usize {
529        self.fx.cycle_palette()
530    }
531
532    /// **Summon the raven** to perch on `target` — any rect a facet/host hands us
533    /// (a table row, a node, a header). Replaces any raven already in flight. The
534    /// body is tinted from the deck's current palette (or the host theme). Logs an
535    /// activity trail entry. Drive/paint happens automatically inside
536    /// [`ui`](Self::ui).
537    pub fn send_raven(&mut self, target: Rect) {
538        let theme = self.effective_theme();
539        self.raven = Some(DeckRaven::new(target, &theme));
540        harness::trail(
541            harness::Kind::Render,
542            format!("raven launched → perch ({:.0},{:.0})", target.center().x, target.top()),
543        );
544    }
545    /// True while a raven is present (flying or perched).
546    pub fn has_raven(&self) -> bool {
547        self.raven.is_some()
548    }
549    /// True once the summoned raven has landed (false if none).
550    pub fn raven_perched(&self) -> bool {
551        self.raven.as_ref().map(|r| r.is_perched()).unwrap_or(false)
552    }
553    /// Dismiss any raven.
554    pub fn clear_raven(&mut self) {
555        self.raven = None;
556    }
557
558    /// The theme the deck paints with: the fx palette override if set, else
559    /// [`Theme::default`] (the host's own `set_theme` still applies its visuals;
560    /// this is just the colour source for deck-owned effects/picker).
561    fn effective_theme(&self) -> Theme {
562        self.fx.theme().unwrap_or_default()
563    }
564
565    /// Draw a one-line **palette picker** — a switcher over [`Theme::ALL`] the
566    /// host can place anywhere (toolbar, menu). Selecting a palette pins the fx
567    /// override; `ui()` then applies it each frame. Returns the chosen index if it
568    /// changed this frame.
569    ///
570    /// The override stays **off until the user actually clicks** a palette: merely
571    /// drawing the picker must not pin index 0, otherwise a host that drives its
572    /// own theme (e.g. the rich [`crate::look::Theme`]) would be silently clobbered
573    /// every frame by the legacy `set_theme` in [`ui`](Self::ui) — size still
574    /// changing (spacing) but colour frozen on `Theme::ALL[0]`. So we only pin when
575    /// the selection genuinely changed this frame.
576    pub fn palette_picker(&mut self, ui: &mut Ui) -> Option<usize> {
577        let mut sel = self.fx.palette().unwrap_or(0);
578        let before = sel;
579        ui.horizontal_wrapped(|ui| {
580            ui.label("Palette:");
581            for (i, ctor) in Theme::ALL.iter().enumerate() {
582                ui.selectable_value(&mut sel, i, ctor().name);
583            }
584        });
585        if sel != before {
586            self.fx.set_palette(sel);
587        }
588        (sel != before).then_some(sel)
589    }
590
591    /// The capabilities of the currently-active facet (or `NONE` if empty).
592    pub fn active_caps(&self) -> FacetCaps {
593        self.facets.get(self.active).map(|f| f.caps()).unwrap_or(FacetCaps::NONE)
594    }
595
596    /// Number of facets in the deck.
597    pub fn len(&self) -> usize {
598        self.facets.len()
599    }
600    /// True when the deck holds no facets.
601    pub fn is_empty(&self) -> bool {
602        self.facets.is_empty()
603    }
604
605    /// **Wall layout** — render **every** facet at once in a wrapping grid of
606    /// `cols` columns, instead of the tabbed one-at-a-time [`ui`](Self::ui). This is
607    /// the "multiple components visible simultaneously" mode a dashboard host wants
608    /// (e.g. several `Graph3D` panes side-by-side). Each cell is a titled group; the
609    /// fx palette override (if set) still applies, and every rendered facet is logged
610    /// to the runtrace ledger keyed `deck.wall:<title>` (mirrors the tab path's
611    /// `deck.render:<title>`), so `state_json`'s `trace.ran` proves each pane drew.
612    pub fn wall_ui(&mut self, ui: &mut Ui, cols: usize) {
613        if let Some(theme) = self.fx.theme() {
614            set_theme(ui.ctx(), theme);
615        }
616        if self.facets.is_empty() {
617            ui.weak("empty deck");
618            return;
619        }
620        let cols = cols.max(1);
621        let spacing = ui.spacing().item_spacing.x;
622        let total_w = ui.available_width();
623        let cell_w = ((total_w - spacing * (cols as f32 - 1.0)) / cols as f32).max(160.0);
624        // A generous cell so 3D graphs have room; the inner facet fills it.
625        let cell_h = 300.0_f32;
626        egui::ScrollArea::vertical()
627            .auto_shrink([false; 2])
628            .show(ui, |ui| {
629                ui.horizontal_wrapped(|ui| {
630                    for f in self.facets.iter_mut() {
631                        ui.allocate_ui(egui::vec2(cell_w, cell_h), |ui| {
632                            ui.group(|ui| {
633                                ui.set_min_size(egui::vec2(cell_w - 12.0, cell_h - 12.0));
634                                ui.vertical(|ui| {
635                                    ui.strong(f.title());
636                                    ui.separator();
637                                    runtrace::ran(&format!("deck.wall:{}", f.title()));
638                                    f.ui(ui);
639                                });
640                            });
641                        });
642                    }
643                });
644            });
645    }
646
647    /// The active facet's current scale (1.0 if none / not scalable).
648    fn active_scale(&self) -> f32 {
649        self.facets.get(self.active).map(|f| f.scale()).unwrap_or(1.0)
650    }
651
652    /// Multiply the active facet's scale by `k`, clamped to a sane range.
653    fn scale_active(&mut self, k: f32) {
654        if let Some(f) = self.facets.get_mut(self.active) {
655            let s = (f.scale() * k).clamp(0.25, 4.0);
656            f.set_scale(s);
657        }
658    }
659
660    /// Reset the active facet's scale to native.
661    fn reset_scale(&mut self) {
662        if let Some(f) = self.facets.get_mut(self.active) {
663            f.set_scale(1.0);
664        }
665    }
666
667    /// Draw the tab bar + capability toolbar + the active facet, and route
668    /// capability-gated shortcuts (Ctrl-+/-/0 for scale; Ctrl-C/X/V for clipboard).
669    pub fn ui(&mut self, ui: &mut Ui) {
670        // Opt-in palette override: apply the chosen Theme::ALL palette + its
671        // egui Visuals each frame so the whole deck (and every facet that reads
672        // `theme(ui)`) follows. No override → the host's own theme stays.
673        if let Some(theme) = self.fx.theme() {
674            set_theme(ui.ctx(), theme);
675        }
676
677        let titles: Vec<String> = self.facets.iter().map(|f| f.title().to_string()).collect();
678        // Wrap the tab bar: with many facets a single non-wrapping row overflows
679        // the panel width and the trailing tabs become unreachable (off-screen,
680        // unclickable for a robot driver / pointer). Wrapping keeps every tab
681        // visible + clickable no matter how many facets the deck holds.
682        ui.horizontal_wrapped(|ui| {
683            for (i, t) in titles.iter().enumerate() {
684                ui.selectable_value(&mut self.active, i, t);
685            }
686        });
687
688        let caps = self.active_caps();
689
690        // Capability-driven toolbar: only show controls the active facet honors.
691        if caps.scalable {
692            ui.horizontal(|ui| {
693                if ui.button("−").on_hover_text("Zoom out (Ctrl-−)").clicked() {
694                    self.scale_active(1.0 / 1.1);
695                }
696                ui.label(format!("{:.0}%", self.active_scale() * 100.0));
697                if ui.button("+").on_hover_text("Zoom in (Ctrl-+)").clicked() {
698                    self.scale_active(1.1);
699                }
700                if ui.button("Reset").on_hover_text("Reset zoom (Ctrl-0)").clicked() {
701                    self.reset_scale();
702                }
703            });
704        }
705
706        // Capability-gated scale shortcuts. egui has no semantic event for these,
707        // so we hand-detect the key combos (clipboard uses semantic events below).
708        if caps.scalable {
709            let (cmd, plus, minus, zero) = ui.input(|i| {
710                (
711                    i.modifiers.command,
712                    i.key_pressed(egui::Key::Plus) || i.key_pressed(egui::Key::Equals),
713                    i.key_pressed(egui::Key::Minus),
714                    i.key_pressed(egui::Key::Num0),
715                )
716            });
717            if cmd {
718                if plus {
719                    self.scale_active(1.1);
720                }
721                if minus {
722                    self.scale_active(1.0 / 1.1);
723                }
724                if zero {
725                    self.reset_scale();
726                }
727            }
728        }
729
730        // Clipboard routing: drain semantic events and dispatch to the active
731        // facet, gated by its caps. A focused TextEdit already consumed its own.
732        self.route_clipboard(ui.ctx());
733
734        // Cross-instance component clone (DISTINCT gesture): the Ctrl+Shift+C/V
735        // accelerators + any pending envelope paste, drained the same frame.
736        self.route_component_clipboard(ui);
737
738        ui.separator();
739        // Optionally wrap the active facet in the shared glass/card chrome
740        // (`chrome` module), gated by the active EffectsPolicy. Reserve a paint slot
741        // BEFORE the content so the glass fill sits behind it; the glow + border
742        // edge is painted on top afterwards.
743        let chrome_on = self.fx.chrome;
744        let chrome_slot = chrome_on.then(|| ui.painter().add(egui::Shape::Noop));
745        // Render the active facet, capturing the rect it occupied so the deck can
746        // bloom it (opt-in glow) without the facet knowing.
747        let content = ui.scope(|ui| {
748            if let Some(f) = self.facets.get_mut(self.active) {
749                // Render-trace: this facet's `ui()` RAN this frame (the wasm-safe
750                // "what ran" ledger — folded into state_json, read via the JS hook
751                // on wasm). Keyed by tab title so the ran-list maps tab → ran?.
752                runtrace::ran(&format!("deck.render:{}", f.title()));
753                f.ui(ui);
754            }
755        });
756        let content_rect = content.response.rect;
757
758        // Paint the card chrome around the facet's content rect.
759        if let Some(slot) = chrome_slot
760            && content_rect.is_positive()
761        {
762            let theme = self.effective_theme();
763            let policy = crate::look::effects_policy(ui);
764            let style = chrome::ChromeStyle::default().for_policy(policy);
765            let card = content_rect.expand(6.0);
766            ui.painter().set(slot, chrome::fill_shape(card, &theme, policy, style));
767            chrome::edge(ui.painter(), card, &theme, policy, style);
768        }
769
770        // Right-click the active facet body → the Copy/Paste-component menu (the
771        // discoverable affordance for the cross-instance clone gesture). The
772        // text clipboard's own copy/paste is unaffected (different gesture).
773        if !self.active_kind().is_empty() {
774            content.response.context_menu(|ui| self.component_menu(ui));
775        }
776
777        // Opt-in glow on the active facet's content rect, pulsing.
778        if self.fx.glow && content_rect.is_positive() {
779            let theme = self.effective_theme();
780            let time = ui.input(|i| i.time);
781            let painter = ui.painter_at(content_rect);
782            deckfx::paint_active_glow(&painter, content_rect.shrink(2.0), &theme, &self.fx, time);
783            ui.ctx().request_repaint(); // keep the pulse animating
784        }
785
786        // Drive + paint a summoned raven on a foreground layer above everything.
787        self.drive_raven(ui.ctx());
788
789        // Paint the component-clone toast (mismatch / rejection feedback) on top.
790        self.paint_component_toast(ui);
791    }
792
793    /// Advance + paint the summoned raven (if any) on a foreground layer. Pins its
794    /// launch time on the first frame and keeps repainting while it flies.
795    fn drive_raven(&mut self, ctx: &egui::Context) {
796        let Some(raven) = self.raven.as_mut() else { return };
797        raven.sprite.update(ctx);
798        let painter =
799            ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("facett_deck_raven")));
800        raven.sprite.paint(&painter);
801    }
802
803    /// Route this frame's clipboard events to the active facet, gated by caps.
804    /// The single OS-touching write (`clipboard::put`) lives here.
805    fn route_clipboard(&mut self, ctx: &egui::Context) {
806        let caps = self.active_caps();
807        if !(caps.copyable || caps.cuttable || caps.pasteable) {
808            return;
809        }
810        for action in clipboard::poll(ctx) {
811            let Some(f) = self.facets.get_mut(self.active) else { continue };
812            match action {
813                ClipAction::Copy if caps.copyable => {
814                    if let Some(t) = f.copy() {
815                        clipboard::put(ctx, t);
816                    }
817                }
818                ClipAction::Cut if caps.cuttable => {
819                    if let Some(t) = f.cut() {
820                        clipboard::put(ctx, t);
821                    }
822                }
823                ClipAction::Paste(s) if caps.pasteable => {
824                    f.paste(&s);
825                }
826                // Capability not declared → ignore (event may belong to a focused
827                // sub-widget egui already handled).
828                _ => {}
829            }
830        }
831    }
832
833    // ── cross-instance component clone (Copy/Paste component) ────────────────
834    //
835    // A DISTINCT gesture from the text clipboard above: it transfers a facet's
836    // type-tagged PORTABLE state (not a text selection) to a same-kind sibling.
837    // See `.nornir/design/copy-paste-between-instances.md`. Surfaced two ways —
838    // the context menu in `component_menu` (right-click the body) and the
839    // `Ctrl+Shift+C / Ctrl+Shift+V` accelerators routed in `ui`.
840
841    /// The active facet's [`Facet::kind`] (`""` if empty / opted out).
842    pub fn active_kind(&self) -> &'static str {
843        self.facets.get(self.active).map(|f| f.kind()).unwrap_or("")
844    }
845
846    /// **Copy component** — encode the active facet's [`Facet::portable_state`]
847    /// into the tagged clipboard envelope and place it on the OS clipboard.
848    /// Returns the envelope text on success, or `None` if the active facet opts
849    /// out (empty `kind()` or no `portable_state()`). This is the data half the
850    /// gesture handlers + tests drive; the OS write is the caller's via
851    /// [`clipboard::put`] (done for them in [`copy_component`](Self::copy_component)).
852    pub fn copy_component_envelope(&self) -> Option<String> {
853        let f = self.facets.get(self.active)?;
854        let kind = f.kind();
855        if kind.is_empty() {
856            return None;
857        }
858        let state = f.portable_state()?;
859        Some(clipboard::encode_component(kind, &state))
860    }
861
862    /// Copy the active facet's portable state to the OS clipboard (the full
863    /// gesture). Returns `true` if something was copied.
864    pub fn copy_component(&mut self, ctx: &egui::Context) -> bool {
865        match self.copy_component_envelope() {
866            Some(env) => {
867                clipboard::put(ctx, env);
868                true
869            }
870            None => false,
871        }
872    }
873
874    /// **Paste component** — decode a clipboard `text` envelope and, **only if its
875    /// kind matches the active facet's** [`Facet::kind`], hand the state to
876    /// [`Facet::load_state`]. Returns `true` if the active facet adopted it.
877    /// A kind mismatch (or a non-envelope / wrong-version text) is a no-op that
878    /// raises a themed mismatch [`toast`](Self::toast) — the type-match guard is
879    /// the whole point: a `table` envelope NEVER loads into a `graphpan`.
880    pub fn paste_component(&mut self, text: &str, now: f64) -> bool {
881        let Some((kind, state)) = clipboard::decode_component(text) else {
882            // Not a component envelope at all — leave it for the text path; no toast.
883            return false;
884        };
885        let active_kind = self.active_kind();
886        if active_kind.is_empty() {
887            self.toast = Some(("this view doesn't accept a pasted component".to_string(), now));
888            return false;
889        }
890        if kind != active_kind {
891            self.toast = Some((format!("clipboard holds a `{kind}`, not a `{active_kind}`"), now));
892            return false;
893        }
894        let Some(f) = self.facets.get_mut(self.active) else { return false };
895        let accepted = f.load_state(&state);
896        if !accepted {
897            self.toast = Some((format!("this `{active_kind}` could not adopt the clipboard state"), now));
898        }
899        accepted
900    }
901
902    /// The current component-clone toast message (if one is live), for tests /
903    /// hosts that want to surface it themselves.
904    pub fn component_toast(&self) -> Option<&str> {
905        self.toast.as_ref().map(|(m, _)| m.as_str())
906    }
907
908    /// Right-click context-menu entries for the cross-instance clone gesture —
909    /// **Copy component** / **Paste component** — themed by the active style. A
910    /// host attaches these to the facet body (or its tab) via
911    /// `response.context_menu(|ui| deck.component_menu(ui))`. Greys out when the
912    /// active facet opts out (empty `kind()`).
913    pub fn component_menu(&mut self, ui: &mut egui::Ui) {
914        let km = look::keymap(ui);
915        let kind = self.active_kind();
916        let can_clone = !kind.is_empty();
917        ui.add_enabled_ui(can_clone && self.copy_component_envelope().is_some(), |ui| {
918            let label = format!("Copy component  {}", km.label(Action::Copy, ui.ctx()));
919            if ui.button(label).clicked() {
920                self.copy_component(ui.ctx());
921                ui.close();
922            }
923        });
924        ui.add_enabled_ui(can_clone, |ui| {
925            let label = format!("Paste component  {}", km.label(Action::Paste, ui.ctx()));
926            if ui.button(label).clicked() {
927                // Pull the OS clipboard via egui's paste request; the actual text
928                // arrives next frame as an Event::Paste, routed in `route_component_clipboard`.
929                ui.ctx().send_viewport_cmd(egui::ViewportCommand::RequestPaste);
930                ui.close();
931            }
932        });
933    }
934
935    /// Route the `Ctrl+Shift+C / Ctrl+Shift+V` component-clone accelerators +
936    /// drain any pending paste envelope. Called once per frame from [`ui`](Self::ui),
937    /// AFTER the text clipboard so a focused TextEdit's plain Ctrl+C/V is untouched
938    /// (the Shift discriminates this gesture from text copy/paste).
939    fn route_component_clipboard(&mut self, ui: &mut egui::Ui) {
940        let now = ui.input(|i| i.time);
941        // Accelerators: Ctrl+Shift+C copies; Ctrl+Shift+V triggers an OS-clipboard
942        // paste request (the text lands next frame as Event::Paste, decoded below).
943        let copy_shift = egui::KeyboardShortcut::new(
944            egui::Modifiers::COMMAND | egui::Modifiers::SHIFT,
945            egui::Key::C,
946        );
947        let paste_shift = egui::KeyboardShortcut::new(
948            egui::Modifiers::COMMAND | egui::Modifiers::SHIFT,
949            egui::Key::V,
950        );
951        if ui.input_mut(|i| i.consume_shortcut(&copy_shift)) {
952            self.copy_component(ui.ctx());
953        }
954        if ui.input_mut(|i| i.consume_shortcut(&paste_shift)) {
955            ui.ctx().send_viewport_cmd(egui::ViewportCommand::RequestPaste);
956        }
957        // Drain any Paste events that look like a component envelope (a plain text
958        // paste decodes to None here and is left for the text clipboard path).
959        let pastes: Vec<String> = ui.input(|i| {
960            i.events
961                .iter()
962                .filter_map(|e| match e {
963                    egui::Event::Paste(s) if clipboard::decode_component(s).is_some() => Some(s.clone()),
964                    _ => None,
965                })
966                .collect()
967        });
968        for text in pastes {
969            self.paste_component(&text, now);
970        }
971        // Age out the toast.
972        if let Some((_, raised)) = self.toast {
973            if now - raised > TOAST_SECS {
974                self.toast = None;
975            }
976        }
977    }
978
979    /// Paint the live component-clone toast on a foreground layer (themed, spacious),
980    /// if one is set. A no-op when there is no toast.
981    fn paint_component_toast(&self, ui: &mut egui::Ui) {
982        let Some((msg, _)) = self.toast.as_ref() else { return };
983        let th = theme(ui);
984        let ctx = ui.ctx();
985        let painter =
986            ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("facett_deck_component_toast")));
987        let screen = ctx.content_rect();
988        let font = FontId::proportional(14.0);
989        let galley = painter.layout_no_wrap(msg.clone(), font.clone(), th.text);
990        let pad = vec2(14.0, 10.0); // spacious preset padding
991        let size = galley.size() + pad * 2.0;
992        // Bottom-centre, lifted off the edge.
993        let center = Pos2::new(screen.center().x, screen.max.y - size.y * 0.5 - 18.0);
994        let rect = Rect::from_center_size(center, size);
995        painter.rect_filled(rect, 8.0, th.panel_bg);
996        painter.rect_stroke(rect, 8.0, Stroke::new(1.0, th.panel_stroke), egui::StrokeKind::Inside);
997        painter.galley(rect.min + pad, galley, th.text);
998        ctx.request_repaint(); // keep ticking so the toast ages out on time
999    }
1000
1001    /// The whole-app observable state: the active facet + each facet's
1002    /// `state_json`, plus an **additive** sibling `caps` map (title → caps JSON)
1003    /// so the existing flat `facets[title]` shape is unchanged for consumers.
1004    pub fn state_json(&self) -> serde_json::Value {
1005        let mut facets = serde_json::Map::new();
1006        let mut caps = serde_json::Map::new();
1007        for f in &self.facets {
1008            facets.insert(f.title().to_string(), f.state_json());
1009            caps.insert(f.title().to_string(), f.caps().to_json());
1010        }
1011        serde_json::json!({
1012            "active": self.facets.get(self.active).map(|f| f.title()),
1013            "facets": facets,
1014            "caps": caps,
1015            // The deck's opt-in effects (DeckFx) as data: whether the shared glass/
1016            // card `chrome` wrap is on, whether the active-facet bloom glow is on, and
1017            // the active palette override. A headless driver reads this to PROVE the
1018            // T1.3 showcase rendering (glass + bloom) is actually wired, not eyeballed.
1019            "fx": {
1020                "chrome": self.fx.chrome,
1021                "glow": self.fx.glow,
1022                "glow_layers": self.fx.glow_layers,
1023                "palette": self.fx.palette(),
1024            },
1025            // The wasm-safe "what RAN" ledger — every facet render + every traced
1026            // control handler that has executed this session (the readable proof
1027            // the shipped artifact actually ran each surface). See `runtrace`.
1028            "trace": { "ran": runtrace::snapshot(), "distinct": runtrace::distinct() },
1029        })
1030    }
1031}
1032
1033/// A stable, bright-ish colour from a string (FNV-1a). Handy default node colour.
1034pub fn hash_color(s: &str) -> Color32 {
1035    let mut h: u32 = 2166136261;
1036    for b in s.bytes() {
1037        h = (h ^ b as u32).wrapping_mul(16777619);
1038    }
1039    Color32::from_rgb((h & 0xFF) as u8 | 0x60, ((h >> 8) & 0xFF) as u8 | 0x60, ((h >> 16) & 0xFF) as u8 | 0x60)
1040}
1041
1042#[cfg(test)]
1043mod tests {
1044    use super::*;
1045
1046    #[test]
1047    fn scene_builds() {
1048        let mut s = Scene::new();
1049        let a = s.node("Person", hash_color("Person"));
1050        let b = s.node("Company", hash_color("Company"));
1051        s.edge(a, b);
1052        assert_eq!(s.nodes.len(), 2);
1053        assert_eq!(s.edges.len(), 1);
1054        assert!(!s.is_empty());
1055    }
1056
1057    #[test]
1058    fn force_layout_produces_finite_bounded_positions() {
1059        let mut scene = Scene::new();
1060        for i in 0..12 { scene.node(format!("n{i}"), hash_color("n")); }
1061        for i in 0..12 { scene.edge(i, (i + 1) % 12); }
1062        let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(400.0, 400.0));
1063        let pos = positions(Layout::Force, &scene, rect);
1064        assert_eq!(pos.len(), 12);
1065        for p in &pos {
1066            assert!(p.x.is_finite() && p.y.is_finite(), "finite");
1067            assert!(rect.expand(50.0).contains(*p), "roughly within the rect");
1068        }
1069    }
1070
1071    /// **Scale (RED-when-broken):** above the Barnes–Hut threshold a large force
1072    /// graph must still lay out — every node finite + inside the rect — under a sane
1073    /// wall-clock budget. The old O(n²) all-pairs loop blows this budget (and the
1074    /// 30 000-node viewer hung); the O(n log n) Barnes–Hut path meets it. A regression
1075    /// back to quadratic repulsion trips the time bound.
1076    #[test]
1077    fn force_layout_scales_to_ten_thousand_nodes() {
1078        let n = 10_000;
1079        assert!(n >= crate::barnes_hut::BH_THRESHOLD, "n is on the Barnes–Hut path");
1080        let mut scene = Scene::new();
1081        for i in 0..n {
1082            scene.node(format!("n{i}"), hash_color("n"));
1083        }
1084        // A sparse ring + chords: O(n) edges (attraction stays O(edges)).
1085        for i in 0..n {
1086            scene.edge(i, (i + 1) % n);
1087            if i % 7 == 0 {
1088                scene.edge(i, (i + 137) % n);
1089            }
1090        }
1091        let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1024.0, 1024.0));
1092        let t0 = std::time::Instant::now();
1093        let pos = positions(Layout::Force, &scene, rect);
1094        let ms = t0.elapsed().as_secs_f64() * 1000.0;
1095        assert_eq!(pos.len(), n);
1096        for p in &pos {
1097            assert!(p.x.is_finite() && p.y.is_finite(), "every node finite at scale");
1098            assert!(rect.expand(50.0).contains(*p), "every node bounded within the rect");
1099        }
1100        // Generous ceiling for a debug build on a slow box; the O(n²) loop is far over.
1101        assert!(ms < 20_000.0, "10k-node force layout must be sub-quadratic fast; took {ms:.0} ms");
1102    }
1103
1104    #[test]
1105    fn hash_color_is_stable() {
1106        assert_eq!(hash_color("Person"), hash_color("Person"));
1107        assert_ne!(hash_color("Person"), hash_color("Company"));
1108    }
1109
1110    /// A minimal facet for deck tests.
1111    struct Stub(&'static str);
1112    impl Facet for Stub {
1113        fn title(&self) -> &str {
1114            self.0
1115        }
1116        fn ui(&mut self, ui: &mut Ui) {
1117            ui.label(self.0);
1118        }
1119        fn state_json(&self) -> serde_json::Value {
1120            serde_json::json!({ "t": self.0 })
1121        }
1122    }
1123
1124    /// A component-clone-capable stub: a `kind`, a JSON `payload` it round-trips
1125    /// through `portable_state`/`load_state`.
1126    struct CloneStub {
1127        kind: &'static str,
1128        payload: serde_json::Value,
1129    }
1130    impl Facet for CloneStub {
1131        fn title(&self) -> &str {
1132            self.kind
1133        }
1134        fn ui(&mut self, _ui: &mut Ui) {}
1135        fn state_json(&self) -> serde_json::Value {
1136            serde_json::json!({ "kind": self.kind })
1137        }
1138        fn kind(&self) -> &'static str {
1139            self.kind
1140        }
1141        fn portable_state(&self) -> Option<serde_json::Value> {
1142            Some(self.payload.clone())
1143        }
1144        fn load_state(&mut self, state: &serde_json::Value) -> bool {
1145            self.payload = state.clone();
1146            true
1147        }
1148    }
1149
1150    #[test]
1151    fn component_clone_round_trips_through_the_deck() {
1152        // Instance A (configured) → envelope → instance B adopts it.
1153        let a = CloneStub { kind: "graphpan", payload: serde_json::json!({ "zoom": 2.0, "pan": [3, 4] }) };
1154        let mut deck_a = FacetDeck::new(vec![Box::new(a)]);
1155        let env = deck_a.copy_component_envelope().expect("A copies its portable state");
1156
1157        let mut deck_b = FacetDeck::new(vec![Box::new(CloneStub {
1158            kind: "graphpan",
1159            payload: serde_json::json!({ "zoom": 1.0, "pan": [0, 0] }),
1160        })]);
1161        assert!(deck_b.paste_component(&env, 0.0), "same-kind paste is accepted");
1162        // B now equals A's portable state.
1163        assert_eq!(
1164            deck_b.copy_component_envelope(),
1165            deck_a.copy_component_envelope(),
1166            "B adopted A's portable state exactly"
1167        );
1168        assert!(deck_b.component_toast().is_none(), "a successful paste raises no toast");
1169    }
1170
1171    #[test]
1172    fn component_clone_rejects_a_type_mismatch_with_a_toast() {
1173        // A `table` envelope handed to a `graphpan` → load_state NOT called.
1174        let table_env = clipboard::encode_component("table", &serde_json::json!({ "rows": 3 }));
1175        let mut deck = FacetDeck::new(vec![Box::new(CloneStub {
1176            kind: "graphpan",
1177            payload: serde_json::json!({ "zoom": 1.0 }),
1178        })]);
1179        let before = deck.copy_component_envelope();
1180        assert!(!deck.paste_component(&table_env, 0.0), "cross-type paste returns false");
1181        assert_eq!(deck.copy_component_envelope(), before, "graphpan state untouched");
1182        let toast = deck.component_toast().expect("mismatch raises a toast");
1183        assert!(toast.contains("table") && toast.contains("graphpan"), "toast names both kinds: {toast}");
1184    }
1185
1186    #[test]
1187    fn component_clone_version_guard_rejects_unknown_v() {
1188        let bad = serde_json::json!({ "facett.kind": "graphpan", "v": 7, "state": { "zoom": 9.0 } }).to_string();
1189        let mut deck = FacetDeck::new(vec![Box::new(CloneStub {
1190            kind: "graphpan",
1191            payload: serde_json::json!({ "zoom": 1.0 }),
1192        })]);
1193        let before = deck.copy_component_envelope();
1194        // An unknown-version text decodes to None → it's a no-op (left for the text path).
1195        assert!(!deck.paste_component(&bad, 0.0), "unknown version is not adopted");
1196        assert_eq!(deck.copy_component_envelope(), before, "state untouched by a bad-version paste");
1197    }
1198
1199    #[test]
1200    fn component_clone_opt_out_floor_neither_copies_nor_accepts() {
1201        // The plain Stub does NOT implement the trio → empty kind, no copy.
1202        let mut deck = FacetDeck::new(vec![Box::new(Stub("plain"))]);
1203        assert_eq!(deck.active_kind(), "", "opted out");
1204        assert!(deck.copy_component_envelope().is_none(), "opt-out facet never copies a component");
1205        // A real envelope handed to an opt-out facet is refused (no panic, no state).
1206        let env = clipboard::encode_component("table", &serde_json::json!({ "rows": 1 }));
1207        assert!(!deck.paste_component(&env, 0.0), "opt-out facet never adopts a component");
1208        assert!(deck.component_toast().is_some(), "the refusal is surfaced");
1209    }
1210
1211    #[test]
1212    fn deck_fx_is_off_by_default() {
1213        let deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
1214        assert_eq!(*deck.fx(), DeckFx::OFF, "no effects until the host opts in");
1215        assert!(!deck.has_raven());
1216        assert!(!deck.fx().glow);
1217        assert!(deck.fx().palette().is_none());
1218    }
1219
1220    #[test]
1221    fn deck_state_json_reports_fx_as_data() {
1222        // The deck's opt-in effects must be observable as data (a robot proof reads
1223        // `fx.chrome` / `fx.glow` to know the showcase rendering is wired ON).
1224        let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
1225        let off = deck.state_json();
1226        assert_eq!(off["fx"]["chrome"].as_bool(), Some(false), "chrome off by default");
1227        assert_eq!(off["fx"]["glow"].as_bool(), Some(false), "glow off by default");
1228        assert!(off["fx"]["palette"].is_null(), "no palette override by default");
1229
1230        deck.fx_mut().chrome = true;
1231        deck.fx_mut().glow = true;
1232        deck.set_palette(2);
1233        let on = deck.state_json();
1234        assert_eq!(on["fx"]["chrome"].as_bool(), Some(true), "chrome wired ON shows in state");
1235        assert_eq!(on["fx"]["glow"].as_bool(), Some(true), "glow wired ON shows in state");
1236        assert_eq!(on["fx"]["palette"].as_u64(), Some(2), "palette override shows in state");
1237    }
1238
1239    #[test]
1240    fn deck_cycle_palette_walks_theme_all() {
1241        let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
1242        let first = deck.cycle_palette();
1243        assert_eq!(first, 0);
1244        assert_eq!(deck.fx().theme().map(|t| t.name), Some(Theme::ALL[0]().name));
1245        // walks forward and wraps
1246        for _ in 1..Theme::ALL.len() {
1247            deck.cycle_palette();
1248        }
1249        assert_eq!(deck.cycle_palette(), 0, "wraps back to the first palette");
1250    }
1251
1252    #[test]
1253    fn deck_send_raven_launches_and_perches_after_a_full_flight() {
1254        use crate::effects::RAVEN_FLIGHT_SECS;
1255        let mut deck = FacetDeck::new(vec![Box::new(Stub("rows"))]);
1256        assert!(!deck.has_raven());
1257        let target = egui::Rect::from_min_size(egui::pos2(120.0, 80.0), egui::vec2(200.0, 28.0));
1258        deck.send_raven(target);
1259        assert!(deck.has_raven(), "raven summoned");
1260        assert!(!deck.raven_perched(), "not perched at launch");
1261
1262        // Drive the sprite headlessly past the flight duration → it perches.
1263        if let Some(r) = deck.raven.as_mut() {
1264            r.sprite.advance(RAVEN_FLIGHT_SECS + 0.1);
1265        }
1266        assert!(deck.raven_perched(), "perched after the flight duration");
1267
1268        deck.clear_raven();
1269        assert!(!deck.has_raven());
1270    }
1271
1272    /// REGRESSION (inject-assert): merely *drawing* the palette picker without a
1273    /// user click must NOT pin a palette override. The bug: the picker auto-pinned
1274    /// index 0 on the first passive frame, turning the legacy `set_theme` override
1275    /// permanently on and clobbering a host's own theme (the rich `look::Theme`)
1276    /// every frame. We render one frame with no interaction and assert the override
1277    /// is still `None` (host theme wins).
1278    #[test]
1279    fn palette_picker_does_not_pin_without_a_user_click() {
1280        let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
1281        assert!(deck.fx().palette().is_none(), "starts with no override");
1282        let ctx = egui::Context::default();
1283        let mut chosen = Some(7usize);
1284        let _ = ctx.run(egui::RawInput::default(), |ctx| {
1285            egui::CentralPanel::default().show(ctx, |ui| {
1286                // No synthetic click is fed → the picker is drawn but not used.
1287                chosen = deck.palette_picker(ui);
1288            });
1289        });
1290        assert_eq!(chosen, None, "drawing the picker reports no selection without a click");
1291        assert!(
1292            deck.fx().palette().is_none(),
1293            "drawing the picker must not pin index 0 — that would clobber the host's own theme each frame"
1294        );
1295    }
1296
1297    #[test]
1298    fn deck_palette_override_applies_theme_in_a_ui_pass() {
1299        let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
1300        deck.set_palette(1); // sci-fi
1301        let ctx = egui::Context::default();
1302        let mut seen = "";
1303        let _ = ctx.run(egui::RawInput::default(), |ctx| {
1304            egui::CentralPanel::default().show(ctx, |ui| {
1305                deck.ui(ui);
1306                seen = theme(ui).name;
1307            });
1308        });
1309        assert_eq!(seen, Theme::ALL[1]().name, "deck applied its palette override");
1310    }
1311}