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/// Embedded-asset integrity: the `const fn` every `include_bytes!` site in this
11/// workspace checks itself with, so a Git-LFS pointer standing in for a font fails the
12/// BUILD instead of shipping as a font that draws nothing.
13pub mod asset;
14pub mod interface;
15/// **Barnes–Hut N-body repulsion** — the shared O(n log n) force-layout kernel
16/// (dimension-generic quadtree/octree, gatling-parallel) that every facett
17/// force-directed layout consumes so a 30 000-node graph lays out without the
18/// O(n²)-per-iteration hang. See [`barnes_hut`].
19pub mod barnes_hut;
20/// The shared **action bus** (BUS-1) — a typed, deterministic outbox facets use
21/// to hand side work (a drag move, a time-scrub, a command) to the host. The
22/// common carrier [`dragdrop`] / [`time_axis`] `Effect`s flow through.
23pub mod action_bus;
24pub mod caps;
25pub mod chrome;
26pub mod clip;
27pub mod clipboard;
28pub mod deckfx;
29/// The shared **drag-and-drop** primitive (DND-1) — the one reducer behind
30/// items-dragged-onto-zones (cards→columns, bars→lanes). Engine-agnostic sibling
31/// of [`nav::Navigable`]; the caps flag [`FacetCaps::draggable`] points here.
32pub mod dragdrop;
33pub mod edges;
34pub mod effects;
35/// **THE unified view core** (ENGINE-1) — the one *cull · project · label-collide ·
36/// pick* spine that `facett-map`, `facett-map3d`, `facett-geomap`, the L1 overlay
37/// and every graph view collapse onto. Only THREE things differ between a map and
38/// a graph, and they are the three traits
39/// ([`PositionSource`](engine::PositionSource),
40/// [`ElevationSource`](engine::ElevationSource), [`Hierarchy`](engine::Hierarchy)).
41/// See [`engine`].
42pub mod engine;
43/// The **UI error-code scheme** (`facet-<component>-<n>`) — the stable, unique code
44/// every facett UI part carries so tests + consumers react to the CODE, not a matched
45/// string. See [`errcode::FacetError`], the [`facet_err!`] macro, the canonical
46/// [`errcode::REGISTRY`], and the pink [`errcode::code_color`] renderer.
47pub mod devid;
48pub mod errcode;
49/// The canonical **Elm contract** (FC-2 / FC-9) — the [`Elm`] trait +
50/// [`impl_facet_via_elm!`] bridge macro that lift `facett-security`'s hand-rolled
51/// Model/Msg/Effect/pure-view pattern into reusable infra. Pair with
52/// [`harness`] to drive an `Elm` component headlessly.
53pub mod elm;
54pub mod focus;
55/// **The golden-image guard** (feature `golden`) — [`golden::assert_golden`], the
56/// one comparison every facett screenshot proof runs against its committed PNG.
57/// Exact match, failure artifacts under `target/`, a **missing golden FAILS**, and
58/// blessing is the explicit opt-in `UPDATE_SNAPSHOTS=1`. Enabled from the
59/// `[dev-dependencies]` of the crates that own goldens; absent from release builds.
60#[cfg(feature = "golden")]
61pub mod golden;
62pub mod imgscan; // image-analysis oracle (SCAN-THE-PIXELS law): spoke/high-freq/
63 // coverage/centroid features computed FROM the rendered pixels.
64pub mod labels3d;
65/// **ROOT LAW #0 — the rayon-free law**, as machinery a test can actually fail
66/// on ([`law`]). ONE writer for korp and facett so the two repos hold the same
67/// line: our calls and our manifests are always red, a *normal* dependency edge
68/// to rayon is red, and the known dev-only third-party edge (image→ravif inside
69/// the screenshot-diff harness) is reported as context and never fails.
70pub mod law;
71/// **The label-declutter contract** — the order-independent spatial-grid collision
72/// rule shared by the CPU painter and the WGSL compute passes (GFX_V2 item 4). One
73/// writer, two executions; see the module docs for why [`legibility::place_labels`]
74/// (greedy, order-dependent, displaces into alternative slots) could not be the one.
75pub mod label_grid;
76/// **Legibility at scale** — the shared screen-space toolbox (spatial hover index,
77/// label collision avoidance, density-aware edge thinning) that makes a 100 000-node
78/// graph or a 1 000 000-pin map readable. Pure, deterministic, headless-testable; the
79/// legibility half of the story whose speed half is [`render::gpu::graphcloud`].
80pub mod legibility;
81pub mod harness;
82pub mod look;
83pub mod nav;
84pub mod overlay;
85/// The constellation-wide **`Panel` trait** (Phase 0 foundation) — the primary
86/// UI-pane seam, with backend (1) native in-process (the blanket
87/// `impl<T: Facet> Panel for T`) and backend (4) headless ([`panel::drive`]).
88/// See `.nornir/wasm-ui-panels-design.md` §1.
89pub mod panel;
90pub mod rabbit;
91/// The L0 shared render kernel (CONS-CORE) — shared `Camera`, z-ordered
92/// `LayerStack`, the CPU rect scissor, and (feature `wgpu`) the extracted GPU
93/// scaffold. Map skins + `facett-graphview` draw through this.
94pub mod render;
95pub mod runtrace; // in-memory, wasm-safe "what RAN" ledger (no FS) — folded into
96 // state_json["trace"]["ran"], read via the JS hook on wasm.
97pub mod scroll_engine;
98pub mod severity; // STRUCTURAL error signal for the Robot-UI HARD GATE — each
99 // pane/atom reports Severity{Info,Warning,Error}; the gate asserts
100 // on this instead of scanning rendered text (RESOLVED decision (a)).
101pub mod testmatrix; // functional-status → nornir test-matrix bridge (feature
102 // `testmatrix`); no-op in release.
103pub mod theme;
104/// The shared **pan/zoom-in-time axis** (TIME-1) — the one time-window model
105/// behind gantt/CFD/calendar/timeline, linkable across facets. Temporal sibling
106/// of [`nav::Navigable`]; the caps flag [`FacetCaps::time_scrollable`] points here.
107pub mod time_axis;
108pub mod trace; // structured IN/OUT/END event stream ($FACETT_TRACE) — the
109 // machine-readable data a facet actually rendered.
110pub use a11y::{Semantics, node as a11y_node, stable_id};
111pub use errcode::{FacetError, MountedFacetError};
112pub use action_bus::{ActionBus, BusAction, BusMsg};
113pub use caps::FacetCaps;
114pub use dragdrop::{DragDrop, DragEffect, DragMsg, Move as DragMove};
115pub use clip::{ArrowColumnRef, ClipKind, ClipPayload, CopySource, PasteTarget};
116pub use clipboard::ClipAction;
117pub use deckfx::{DeckFx, DeckRaven};
118pub use elm::Elm;
119pub use imgscan::{BBox, Rgba, ScanReport, coverage, high_freq_ratio, painted_centroid_and_bbox, scan, spoke_score};
120pub use look::{Action, KeyMap, Palette};
121pub use nav::{Dir4, Navigable, nearest_in_direction};
122pub use panel::Panel;
123pub use rabbit::{Rabbit, RabbitMesh, rabbit_mesh, rabbit_outline};
124pub use scroll_engine::SmoothScroll;
125pub use severity::{Severity, worst as worst_severity};
126pub use theme::{Theme, set_theme, theme};
127pub use time_axis::{TimeAxis, TimeEffect, TimeMsg};
128
129// The rich look-&-feel `Theme` (the work-order architecture) is re-exported under
130// an unambiguous alias so it coexists with the legacy flat palette `Theme` above.
131pub use look::Theme as LookTheme;
132
133/// A node: a label + a colour (the *consumer* picks the colour policy — hash by
134/// label, by status, …).
135#[derive(Clone)]
136pub struct Node {
137 pub label: String,
138 pub color: Color32,
139}
140
141/// A directed edge between node indices.
142#[derive(Clone, Copy)]
143pub struct Edge {
144 pub src: usize,
145 pub dst: usize,
146}
147
148/// A drawable graph: nodes + edges (edges index into `nodes`).
149#[derive(Default, Clone)]
150pub struct Scene {
151 pub nodes: Vec<Node>,
152 pub edges: Vec<Edge>,
153}
154
155impl Scene {
156 pub fn new() -> Self {
157 Self::default()
158 }
159 /// Push a node, returning its index.
160 pub fn node(&mut self, label: impl Into<String>, color: Color32) -> usize {
161 self.nodes.push(Node { label: label.into(), color });
162 self.nodes.len() - 1
163 }
164 pub fn edge(&mut self, src: usize, dst: usize) {
165 self.edges.push(Edge { src, dst });
166 }
167 pub fn is_empty(&self) -> bool {
168 self.nodes.is_empty()
169 }
170}
171
172/// Node placement strategy.
173#[derive(Clone, Copy, PartialEq, Eq, Default)]
174pub enum Layout {
175 #[default]
176 Circular,
177 /// Deterministic Fruchterman–Reingold (edges pull, all nodes repel). O(n²)
178 /// per iteration — best for small/medium graphs.
179 Force,
180}
181
182/// Draw a `Scene` into `ui` — the reusable render primitive. Empty scenes show
183/// `empty_hint`. Labels render when the node count is small enough to read.
184pub fn draw(ui: &mut Ui, scene: &Scene, layout: Layout, empty_hint: &str) {
185 let (rect, _) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
186 let n = scene.nodes.len();
187 if n == 0 {
188 let th = theme(ui);
189 ui.painter_at(rect).text(rect.center(), Align2::CENTER_CENTER, empty_hint, FontId::proportional(13.0), th.text_dim);
190 return;
191 }
192 let pos = positions(layout, scene, rect);
193 draw_positions(ui, scene, &pos, rect, empty_hint);
194}
195
196/// Paint a `Scene` into `rect` using **pre-computed** node positions — the drawing
197/// half of [`draw`], split out so a stateful host can **freeze** the layout: compute
198/// the O(n log n) force positions once (cached until the graph structure changes, see
199/// [`ForceCache`]) and paint them every frame WITHOUT re-running the layout. `pos`
200/// must be indexed like `scene.nodes`; short/empty falls back to the empty hint.
201pub fn draw_positions(ui: &mut Ui, scene: &Scene, pos: &[Pos2], rect: Rect, empty_hint: &str) {
202 let th = theme(ui);
203 let painter = ui.painter_at(rect);
204 let n = scene.nodes.len();
205 if n == 0 || pos.len() != n {
206 painter.text(rect.center(), Align2::CENTER_CENTER, empty_hint, FontId::proportional(13.0), th.text_dim);
207 return;
208 }
209 for e in &scene.edges {
210 if e.src < n && e.dst < n {
211 painter.line_segment([pos[e.src], pos[e.dst]], Stroke::new(0.6_f32, th.edge));
212 }
213 }
214 for (i, node) in scene.nodes.iter().enumerate() {
215 painter.circle_filled(pos[i], 5.0, node.color);
216 }
217 if n <= 60 {
218 for (i, node) in scene.nodes.iter().enumerate() {
219 painter.text(pos[i] + vec2(7.0, 0.0), Align2::LEFT_CENTER, &node.label, FontId::proportional(10.0), th.text);
220 }
221 }
222}
223
224/// **Converge-once-then-freeze** layout cache. A force layout is a pure function of
225/// the graph structure (node count + edge set) and the paint `rect`, so this holds the
226/// last-computed positions and only re-runs [`layout_positions`] when the structure or
227/// rect actually changes. Embed it on a stateful host (e.g. a `GraphView`) and call
228/// [`ForceCache::positions`] from the render path — the O(n log n) (with Barnes–Hut)
229/// or O(n²) (small graphs) layout stops running every frame, which is the other half
230/// (besides Barnes–Hut) of killing the 30 000-node hang.
231#[derive(Default, Clone)]
232pub struct ForceCache {
233 sig: u64,
234 rect: [u32; 4],
235 pos: Vec<Pos2>,
236}
237
238impl ForceCache {
239 /// The node positions for `scene` under `layout` in `rect`, computed on the first
240 /// call and on any structural/rect change, and returned from cache otherwise.
241 pub fn positions(&mut self, layout: Layout, scene: &Scene, rect: Rect) -> &[Pos2] {
242 let n = scene.nodes.len();
243 let key = self.structure_key(layout, scene);
244 let rb = [rect.min.x.to_bits(), rect.min.y.to_bits(), rect.max.x.to_bits(), rect.max.y.to_bits()];
245 if self.sig != key || self.rect != rb || self.pos.len() != n {
246 self.pos = positions(layout, scene, rect);
247 self.sig = key;
248 self.rect = rb;
249 }
250 &self.pos
251 }
252
253 /// Whether the next [`positions`](Self::positions) call for this `layout`/`scene`
254 /// in this `rect` will be a cache hit (a no-op relayout) — the *frozen/settled*
255 /// signal a `tick` can assert.
256 #[must_use]
257 pub fn is_settled(&self, layout: Layout, scene: &Scene, rect: Rect) -> bool {
258 let rb = [rect.min.x.to_bits(), rect.min.y.to_bits(), rect.max.x.to_bits(), rect.max.y.to_bits()];
259 self.sig == self.structure_key(layout, scene) && self.rect == rb && self.pos.len() == scene.nodes.len()
260 }
261
262 /// Force a recompute on the next [`positions`](Self::positions) call.
263 pub fn invalidate(&mut self) {
264 self.sig = 0;
265 self.rect = [0; 4];
266 self.pos.clear();
267 }
268
269 fn structure_key(&self, layout: Layout, scene: &Scene) -> u64 {
270 let edges: Vec<(usize, usize)> = scene.edges.iter().map(|e| (e.src, e.dst)).collect();
271 let key = match layout {
272 Layout::Circular => "circular",
273 Layout::Force => "force",
274 };
275 crate::barnes_hut::structure_sig(key, scene.nodes.len(), &edges)
276 }
277}
278
279/// **Test/host hook (additive).** The public, return-asserted view of the
280/// private [`positions`] layout node — the exact node centres [`draw`] paints for
281/// `scene` under `layout` inside `rect`. Exposed so the graph-skin call-chain
282/// matrix can assert the *layout* stage (finite, in-rect, count == nodes,
283/// circular radius, force-fit normalisation) without a painter. Calls the **same**
284/// private fn `draw` uses, so it IS the layout the pixels come from — additive,
285/// no behaviour change.
286pub fn layout_positions(layout: Layout, scene: &Scene, rect: Rect) -> Vec<Pos2> {
287 positions(layout, scene, rect)
288}
289
290fn positions(layout: Layout, scene: &Scene, rect: Rect) -> Vec<Pos2> {
291 let n = scene.nodes.len();
292 let center = rect.center();
293 let radius = rect.size().min_elem() * 0.42;
294 let circular = |i: usize| {
295 let a = std::f32::consts::TAU * (i as f32) / (n as f32);
296 vec2(a.cos(), a.sin())
297 };
298 match layout {
299 Layout::Circular => (0..n).map(|i| center + radius * circular(i)).collect(),
300 Layout::Force => {
301 // Deterministic Fruchterman–Reingold from a circular seed (unit space).
302 let mut p: Vec<egui::Vec2> = (0..n).map(circular).collect();
303 let k = (1.0 / (n.max(1) as f32).sqrt()).clamp(0.05, 1.0);
304 // Above the threshold the O(n²) all-pairs repulsion is the 30 000-node
305 // hang — swap it for the shared Barnes–Hut O(n log n) kernel (parallel,
306 // deterministic). Below the threshold the exact loop runs unchanged, so
307 // every small-graph golden is byte-for-byte identical (additive).
308 let use_bh = n >= crate::barnes_hut::BH_THRESHOLD;
309 for _ in 0..120 {
310 let mut disp = vec![egui::Vec2::ZERO; n];
311 if use_bh {
312 let pts: Vec<[f32; 2]> = p.iter().map(|v| [v.x, v.y]).collect();
313 let rep = crate::barnes_hut::repulsion_forces::<2>(&pts, k, crate::barnes_hut::BH_THETA);
314 for i in 0..n {
315 disp[i] = egui::vec2(rep[i][0], rep[i][1]);
316 }
317 } else {
318 for i in 0..n {
319 for j in (i + 1)..n {
320 let d = p[i] - p[j];
321 let dist = d.length().max(1e-3);
322 let f = k * k / dist;
323 let dir = d / dist;
324 disp[i] += dir * f;
325 disp[j] -= dir * f;
326 }
327 }
328 }
329 for e in &scene.edges {
330 if e.src < n && e.dst < n {
331 let d = p[e.src] - p[e.dst];
332 let dist = d.length().max(1e-3);
333 let f = dist * dist / k;
334 let dir = d / dist;
335 disp[e.src] -= dir * f;
336 disp[e.dst] += dir * f;
337 }
338 }
339 for i in 0..n {
340 let dl = disp[i].length().max(1e-3);
341 p[i] += disp[i] / dl * dl.min(0.04); // capped step (cooling-free, deterministic)
342 }
343 }
344 // Normalise to fit the rect.
345 let (mut mn, mut mx) = (egui::vec2(f32::MAX, f32::MAX), egui::vec2(f32::MIN, f32::MIN));
346 for v in &p {
347 mn.x = mn.x.min(v.x);
348 mn.y = mn.y.min(v.y);
349 mx.x = mx.x.max(v.x);
350 mx.y = mx.y.max(v.y);
351 }
352 let span = (mx - mn).max(egui::vec2(1e-3, 1e-3));
353 p.iter()
354 .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))
355 .collect()
356 }
357 }
358}
359
360/// The facett **component contract**. Every facet — graph, map, pipeline, table,
361/// the ported nornir viewers — implements this, so consumers (korp, nornir, …)
362/// compose them uniformly *and* get headless robot-testing for free.
363///
364/// The things a component owes its host:
365/// 1. a **title** (tab label / panel heading),
366/// 2. how to **draw** itself into egui,
367/// 3. its **observable state** as JSON — dumped to `$APP_STATE` for headless
368/// assertions. **Rule:** every visible list/status/count goes in `state_json`.
369/// 4. (**defaulted**) [`update_json`](Facet::update_json) — the Elm mutation path,
370/// a no-op by default so it costs existing facets nothing; overriding it (and
371/// the three above) makes a facet interchangeable with a [`Panel`](crate::Panel).
372pub trait Facet {
373 fn title(&self) -> &str;
374 fn ui(&mut self, ui: &mut Ui);
375 fn state_json(&self) -> serde_json::Value;
376
377 /// The **Elm mutation path** — apply one message (JSON). This is the fourth
378 /// member of the component contract (`title`/`ui`/`state_json`/`update_json`),
379 /// and it is **defaulted to a no-op** so it is purely ADDITIVE: every existing
380 /// `impl Facet` keeps compiling unchanged, while a facet that wants the writable
381 /// input surface overrides it (an Elm-backed facet routes the JSON through its
382 /// `update`). With this default in place `Facet` and [`Panel`](crate::Panel)
383 /// share the same four-method shape, which is what lets the blanket
384 /// `impl<T: Facet> Panel for T` (see [`panel`](crate::panel)) make **every**
385 /// `Facet` a `Panel` for free. Unknown/undriven messages are ignored — the
386 /// default simply does nothing.
387 fn update_json(&mut self, _msg_json: &str) {}
388
389 /// The pane's **STRUCTURAL severity** — the RESOLVED Robot-UI error signal
390 /// (decision (a)). A facet returns the WORST [`Severity`](crate::Severity) over
391 /// its currently-rendered atoms (compute it with
392 /// [`worst_severity`](crate::worst_severity)): [`Severity::Info`] when it is
393 /// showing real data, [`Severity::Warning`] when degraded, and
394 /// [`Severity::Error`] when it surfaced a failure (a load error, an unavailable
395 /// backend, an empty data-bearing surface).
396 ///
397 /// **Defaulted to [`Severity::Info`]** so it is purely ADDITIVE — every existing
398 /// `impl Facet` stays green and keeps compiling. The Robot-UI HARD GATE reads
399 /// THIS (surfaced through the [`Panel`](crate::Panel) seam the headless robot
400 /// drives), not the `nornir_robotui::error_atoms()` substring scan, which is kept
401 /// only as a MIGRATION FALLBACK for panes that have not yet overridden this.
402 /// A pane that returns [`Severity::Error`] is RED and FAILS the gate.
403 /// **This pane's stable DEV-ID** — the [`errcode`](crate::errcode) *component*
404 /// string (the `<component>` in `facet-<component>-<n>`), so the identity a pane
405 /// shows and the identity its errors carry are the SAME vocabulary rather than two
406 /// naming schemes that drift.
407 ///
408 /// Shown as a click-to-copy chip in non-release builds ([`devid::badge`](crate::devid::badge)),
409 /// because a screenshot cannot tell you which crate drew a pane and a title like
410 /// "Map" appears in four of them. Pasting the chip resolves to a crate dir and a
411 /// source file via [`devid::resolve`](crate::devid::resolve).
412 ///
413 /// **Defaulted to `""`** so it is purely ADDITIVE — every existing `impl Facet`
414 /// keeps compiling. A pane that has not overridden it renders a visibly-different
415 /// `⟨unregistered⟩` marker instead of an id, never a plausible-looking string that
416 /// resolves to nothing (the `facet-map-99` hole that
417 /// `facett-core/tests/errcode_raise_sites.rs` documents). So the badge is also the
418 /// to-do list: every marker is a pane still to declare this.
419 fn component(&self) -> &'static str {
420 ""
421 }
422
423 fn severity(&self) -> crate::Severity {
424 crate::Severity::Info
425 }
426
427 // --- uniform capability surface (all defaulted; see caps.rs / clipboard.rs) ---
428
429 /// What this facet can do. Override to opt into capabilities.
430 fn caps(&self) -> FacetCaps {
431 FacetCaps::NONE
432 }
433
434 /// Current uniform scale (1.0 = native). Override if `caps().scalable`.
435 fn scale(&self) -> f32 {
436 1.0
437 }
438 /// Set the uniform scale; clamp internally. Default no-op (not scalable).
439 fn set_scale(&mut self, _scale: f32) {}
440
441 /// **The band `[min, max]`, relative to native (`1.0`), that this facet's uniform
442 /// scale may be driven over.** [`FacetDeck::scale_active`] clamps into it.
443 ///
444 /// The default `[0.25, 4.0]` is a *document viewer's* range — a quarter size to
445 /// quadruple size, which is all a table, a form or a text pane ever wants. It was
446 /// for years the deck's only vocabulary, hardcoded in `scale_active`, and that is
447 /// a bug for any facet whose native range is wider. A MAP's is: `OsmView`'s camera
448 /// spans `[ZOOM_MIN, ZOOM_MAX]` = a 1.25-million-fold range, so a hardcoded 16-fold
449 /// clamp both **stalls** zoom-in a couple of clicks past the fit *and* — because
450 /// the wheel and pinch drive the camera directly, never through [`Self::set_scale`]
451 /// — makes the next `+` click **saturate the clamp and slam the camera backwards**,
452 /// a zoom-IN button that visibly zooms OUT. Guarded by
453 /// `facett-geomap/tests/osm2d_deck_scale.rs`.
454 ///
455 /// Override alongside [`Self::scale`]/[`Self::set_scale`] when native has no fixed
456 /// pixel size. Must contain `1.0` — [`FacetDeck::reset_scale`] writes it.
457 fn scale_range(&self) -> (f32, f32) {
458 (0.25, 4.0)
459 }
460
461 /// The current selection as JSON (also folded into `state_json` by
462 /// convention). `Null` when nothing/none selectable.
463 fn selection_json(&self) -> serde_json::Value {
464 serde_json::Value::Null
465 }
466
467 /// Clipboard hooks — see clipboard.rs. Defaults: nothing to give/take.
468 /// Returns the text to place on the clipboard (None = nothing copyable now).
469 fn copy(&mut self) -> Option<String> {
470 None
471 }
472 /// Like `copy`, but also removes the selection. Default delegates to `copy`.
473 fn cut(&mut self) -> Option<String> {
474 self.copy()
475 }
476 /// Accept pasted text. Returns true if consumed.
477 fn paste(&mut self, _text: &str) -> bool {
478 false
479 }
480
481 /// Optional downcast handle for hosts that need typed access to a specific
482 /// facet living inside a [`FacetDeck`] (e.g. a robot-UI driver clicking an
483 /// app-level control that must forward to a concrete component's own API).
484 /// Defaulted to `None` so no existing facet has to change; a component opts in
485 /// by returning `Some(self)`.
486 fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
487 None
488 }
489
490 // --- cross-instance state clone (copy/paste BETWEEN same-component instances) ---
491 // See `.nornir/design/copy-paste-between-instances.md` + `clipboard.rs`. The trio
492 // below is the type-tagged STATE layer on top of the text clipboard: two
493 // instances with the SAME `kind()` exchange `portable_state()` via the OS
494 // clipboard envelope (`clipboard::encode_component`/`decode_component`). Each is
495 // defaulted to the opt-OUT floor — a component that doesn't implement all three
496 // neither copies nor accepts cross-instance state, and nothing panics.
497
498 /// Stable component-type id (e.g. `"jobview"`, `"graphpan"`, `"table"`). Two
499 /// instances with the **same** kind can exchange portable state; the empty
500 /// default `""` means **opted out** of cross-instance clone.
501 fn kind(&self) -> &'static str {
502 ""
503 }
504
505 /// The **portable** subset of this facet's state — the fields a same-kind
506 /// sibling can adopt. `None` = not cloneable. Kept SEPARATE from
507 /// [`state_json`](Self::state_json) (the introspection dump, which may carry
508 /// derived / render-only data) so this stays round-trippable through
509 /// [`load_state`](Self::load_state).
510 fn portable_state(&self) -> Option<serde_json::Value> {
511 None
512 }
513
514 /// Adopt a portable state produced by [`portable_state`](Self::portable_state)
515 /// on a same-kind sibling. Returns `true` if accepted. Default `false`
516 /// (opt-in per component).
517 fn load_state(&mut self, _state: &serde_json::Value) -> bool {
518 false
519 }
520}
521
522/// A tabbed set of [`Facet`]s — the reusable multi-component shell. Draws a tab
523/// bar + the active facet, and composes **every** facet's `state_json` under its
524/// title, so the whole-app introspection contract is free. korp/nornir can build
525/// their window from a `FacetDeck` instead of hand-rolling tabs + the state dump.
526pub struct FacetDeck {
527 facets: Vec<Box<dyn Facet>>,
528 active: usize,
529 /// Opt-in deck effects (palette override + glow). `Default` = all off, so a
530 /// deck that never opts in is unchanged and pays nothing.
531 fx: DeckFx,
532 /// A raven summoned through the deck, in flight or perched (or `None`).
533 raven: Option<DeckRaven>,
534 /// A transient, themed component-clone toast (message + the `ctx.input.time`
535 /// it was raised at), shown briefly after a Copy-/Paste-component gesture —
536 /// chiefly the type-mismatch rejection ("clipboard holds a `table`, not a
537 /// `graphpan`"). `None` = nothing to show. See [`Self::component_toast`].
538 toast: Option<(String, f64)>,
539 /// **Responsive MENU collapse.** Host-driven: on a narrow (phone-class) viewport
540 /// the long tab bar (one selectable per facet — a deck can hold 60+) wraps into
541 /// many rows and buries the active facet below the screen fold. When
542 /// `menu_collapsed` is set (by the host, classifying the viewport — e.g.
543 /// `facett_app::scene::Device`) the tab bar renders as a compact HAMBURGER header
544 /// (`≡` + the active choice's label) with the full choice list behind a drawer
545 /// (canvas-first). On a wide viewport it stays the inline wrapped strip. See
546 /// [`set_menu_collapsed`](Self::set_menu_collapsed).
547 menu_collapsed: bool,
548 /// The hamburger drawer's open state (deck-owned). Default **closed** ⇒ the
549 /// collapsed header is a single row and the canvas gets the screen; opening lists
550 /// every choice, and picking one switches the tab AND shuts the drawer.
551 menu_open: bool,
552 /// Last frame's tab-bar (menu header) rect — the observable geometry a headless
553 /// test reads (`state_json.menu.bar_rect`) to prove the collapsed menu is ONE
554 /// compact row, not a multi-row wrap that eats the viewport.
555 menu_bar_rect: Rect,
556 /// Last frame's active-facet content rect — the "canvas" the collapse hands the
557 /// screen to (`state_json.menu.content_rect`).
558 content_rect: Rect,
559}
560
561/// How long a component-clone [`toast`](FacetDeck::toast) stays on screen.
562const TOAST_SECS: f64 = 2.6;
563
564impl FacetDeck {
565 pub fn new(facets: Vec<Box<dyn Facet>>) -> Self {
566 Self {
567 facets,
568 active: 0,
569 fx: DeckFx::OFF,
570 raven: None,
571 toast: None,
572 menu_collapsed: false,
573 menu_open: false,
574 menu_bar_rect: Rect::ZERO,
575 content_rect: Rect::ZERO,
576 }
577 }
578 /// Append a facet (the incremental form of [`new`](Self::new)). Lets a host
579 /// build a deck pane-by-pane as it discovers what to show (e.g. one pane per
580 /// warehouse table it finds).
581 pub fn push(&mut self, facet: Box<dyn Facet>) {
582 self.facets.push(facet);
583 }
584 pub fn active(&self) -> usize {
585 self.active
586 }
587
588 /// The title of the currently-active facet (the deck's `state_json["active"]`),
589 /// or `None` if the deck is empty.
590 pub fn active_title(&self) -> Option<&str> {
591 self.facets.get(self.active).map(|f| f.title())
592 }
593
594 /// The titles of every tabbed facet, in tab order — the discoverable surface a
595 /// host (or a robot-UI control channel) enumerates to know which tabs exist.
596 pub fn titles(&self) -> Vec<&str> {
597 self.facets.iter().map(|f| f.title()).collect()
598 }
599
600 /// Make the facet titled `title` the active tab — the programmatic (headless,
601 /// robot-addressable) equivalent of clicking its tab header. Returns `true` if a
602 /// facet with that title exists (and is now active), `false` otherwise. This is
603 /// the named boundary a control channel switches tabs through (the deck analogue
604 /// of the viz's `Tab::from_name`), so a driver needn't replay a pointer click.
605 pub fn set_active_by_title(&mut self, title: &str) -> bool {
606 match self.facets.iter().position(|f| f.title() == title) {
607 Some(i) => {
608 self.active = i;
609 true
610 }
611 None => false,
612 }
613 }
614
615 // ── responsive menu (hamburger) — the reusable narrow-viewport tab bar ───────
616
617 /// Host-driven: render the tab bar as a compact **hamburger menu** (`≡` + the
618 /// active choice's label, the full list behind a drawer) instead of the inline
619 /// wrapped strip. The host classifies the viewport (e.g.
620 /// `facett_app::scene::Device::from_width`) and calls this each frame; on a
621 /// narrow (phone-class) width the long tab bar would otherwise wrap into many
622 /// rows and bury the active facet below the screen fold. Setting it `false` (a
623 /// wide viewport) restores the inline bar AND shuts the drawer. This is the deck
624 /// analogue of the demo shell's `⚙` control-strip fold — the SAME canvas-first
625 /// pattern applied to the MAIN menu, living once here so every deck host
626 /// (korp/nornir/…) inherits it.
627 pub fn set_menu_collapsed(&mut self, collapsed: bool) {
628 if !collapsed {
629 self.menu_open = false;
630 }
631 self.menu_collapsed = collapsed;
632 }
633 /// Whether the tab bar is currently rendered as the collapsed hamburger menu.
634 pub fn menu_collapsed(&self) -> bool {
635 self.menu_collapsed
636 }
637 /// Whether the hamburger drawer is open (the full choice list is showing).
638 pub fn menu_open(&self) -> bool {
639 self.menu_open
640 }
641 /// Open / shut the hamburger drawer explicitly (the programmatic equivalent of
642 /// tapping `≡`). No visible effect while the menu is not collapsed.
643 pub fn set_menu_open(&mut self, open: bool) {
644 self.menu_open = open;
645 }
646 /// Toggle the hamburger drawer; returns the new open state.
647 pub fn toggle_menu(&mut self) -> bool {
648 self.menu_open = !self.menu_open;
649 self.menu_open
650 }
651
652 /// Typed mutable access to the facet with `title`, downcast to `T` — `None` if
653 /// no such facet, or it doesn't opt into [`Facet::as_any_mut`], or the type
654 /// mismatches. Lets a host drive a concrete component's own API (e.g. a
655 /// robot-UI control forwarding a node selection to a `SystemChart`).
656 pub fn facet_mut<T: std::any::Any>(&mut self, title: &str) -> Option<&mut T> {
657 self.facets
658 .iter_mut()
659 .find(|f| f.title() == title)
660 .and_then(|f| f.as_any_mut())
661 .and_then(|a| a.downcast_mut::<T>())
662 }
663
664 /// Replace the facet whose `title` matches with `facet` (the box's own title is
665 /// what the deck enumerates afterwards). Returns `true` if a facet was replaced.
666 /// Used by hosts that **reload** a tab's data in place — e.g. the OSM region
667 /// picker rebuilds the `OSM 2D` / `OSM 3D` views from a freshly clipped region
668 /// and swaps them in, keeping the same tab slots (and the active selection).
669 pub fn replace_facet(&mut self, title: &str, facet: Box<dyn Facet>) -> bool {
670 if let Some(slot) = self.facets.iter_mut().find(|f| f.title() == title) {
671 *slot = facet;
672 true
673 } else {
674 false
675 }
676 }
677
678 // ── opt-in effects + theming (see deckfx.rs) ─────────────────────────────
679
680 /// Enable deck effects up front (builder form of [`fx_mut`](Self::fx_mut)).
681 pub fn with_fx(mut self, fx: DeckFx) -> Self {
682 self.fx = fx;
683 self
684 }
685 /// The current deck-effects config (read-only).
686 pub fn fx(&self) -> &DeckFx {
687 &self.fx
688 }
689 /// Mutate the deck-effects config (toggle glow, pin a palette, …).
690 pub fn fx_mut(&mut self) -> &mut DeckFx {
691 &mut self.fx
692 }
693 /// Override the deck theme with palette index `i` (wraps); enables the
694 /// override. Convenience over `fx_mut().set_palette(i)`.
695 pub fn set_palette(&mut self, i: usize) {
696 self.fx.set_palette(i);
697 }
698 /// Advance to the next palette in [`Theme::ALL`] (wrapping); returns the new
699 /// index. Convenience over `fx_mut().cycle_palette()`.
700 pub fn cycle_palette(&mut self) -> usize {
701 self.fx.cycle_palette()
702 }
703
704 /// **Summon the raven** to perch on `target` — any rect a facet/host hands us
705 /// (a table row, a node, a header). Replaces any raven already in flight. The
706 /// body is tinted from the deck's current palette (or the host theme). Logs an
707 /// activity trail entry. Drive/paint happens automatically inside
708 /// [`ui`](Self::ui).
709 pub fn send_raven(&mut self, target: Rect) {
710 let theme = self.effective_theme();
711 self.raven = Some(DeckRaven::new(target, &theme));
712 harness::trail(
713 harness::Kind::Render,
714 format!("raven launched → perch ({:.0},{:.0})", target.center().x, target.top()),
715 );
716 }
717 /// True while a raven is present (flying or perched).
718 pub fn has_raven(&self) -> bool {
719 self.raven.is_some()
720 }
721 /// True once the summoned raven has landed (false if none).
722 pub fn raven_perched(&self) -> bool {
723 self.raven.as_ref().map(|r| r.is_perched()).unwrap_or(false)
724 }
725 /// Dismiss any raven.
726 pub fn clear_raven(&mut self) {
727 self.raven = None;
728 }
729
730 /// The theme the deck paints with: the fx palette override if set, else
731 /// [`Theme::default`] (the host's own `set_theme` still applies its visuals;
732 /// this is just the colour source for deck-owned effects/picker).
733 fn effective_theme(&self) -> Theme {
734 self.fx.theme().unwrap_or_default()
735 }
736
737 /// Draw a one-line **palette picker** — a switcher over [`Theme::ALL`] the
738 /// host can place anywhere (toolbar, menu). Selecting a palette pins the fx
739 /// override; `ui()` then applies it each frame. Returns the chosen index if it
740 /// changed this frame.
741 ///
742 /// The override stays **off until the user actually clicks** a palette: merely
743 /// drawing the picker must not pin index 0, otherwise a host that drives its
744 /// own theme (e.g. the rich [`crate::look::Theme`]) would be silently clobbered
745 /// every frame by the legacy `set_theme` in [`ui`](Self::ui) — size still
746 /// changing (spacing) but colour frozen on `Theme::ALL[0]`. So we only pin when
747 /// the selection genuinely changed this frame.
748 pub fn palette_picker(&mut self, ui: &mut Ui) -> Option<usize> {
749 let mut sel = self.fx.palette().unwrap_or(0);
750 let before = sel;
751 ui.horizontal_wrapped(|ui| {
752 ui.label("Palette:");
753 for (i, ctor) in Theme::ALL.iter().enumerate() {
754 ui.selectable_value(&mut sel, i, ctor().name);
755 }
756 });
757 if sel != before {
758 self.fx.set_palette(sel);
759 }
760 (sel != before).then_some(sel)
761 }
762
763 /// The capabilities of the currently-active facet (or `NONE` if empty).
764 pub fn active_caps(&self) -> FacetCaps {
765 self.facets.get(self.active).map(|f| f.caps()).unwrap_or(FacetCaps::NONE)
766 }
767
768 /// Number of facets in the deck.
769 pub fn len(&self) -> usize {
770 self.facets.len()
771 }
772 /// True when the deck holds no facets.
773 pub fn is_empty(&self) -> bool {
774 self.facets.is_empty()
775 }
776
777 /// **Wall layout** — render **every** facet at once in a wrapping grid of
778 /// `cols` columns, instead of the tabbed one-at-a-time [`ui`](Self::ui). This is
779 /// the "multiple components visible simultaneously" mode a dashboard host wants
780 /// (e.g. several `Graph3D` panes side-by-side). Each cell is a titled group; the
781 /// fx palette override (if set) still applies, and every rendered facet is logged
782 /// to the runtrace ledger keyed `deck.wall:<title>` (mirrors the tab path's
783 /// `deck.render:<title>`), so `state_json`'s `trace.ran` proves each pane drew.
784 pub fn wall_ui(&mut self, ui: &mut Ui, cols: usize) {
785 if let Some(theme) = self.fx.theme() {
786 set_theme(ui.ctx(), theme);
787 }
788 if self.facets.is_empty() {
789 ui.weak("empty deck");
790 return;
791 }
792 let cols = cols.max(1);
793 let spacing = ui.spacing().item_spacing.x;
794 let total_w = ui.available_width();
795 let cell_w = ((total_w - spacing * (cols as f32 - 1.0)) / cols as f32).max(160.0);
796 // A generous cell so 3D graphs have room; the inner facet fills it.
797 let cell_h = 300.0_f32;
798 egui::ScrollArea::vertical()
799 .auto_shrink([false; 2])
800 .show(ui, |ui| {
801 ui.horizontal_wrapped(|ui| {
802 for f in self.facets.iter_mut() {
803 ui.allocate_ui(egui::vec2(cell_w, cell_h), |ui| {
804 let cell = ui.group(|ui| {
805 ui.set_min_size(egui::vec2(cell_w - 12.0, cell_h - 12.0));
806 ui.vertical(|ui| {
807 ui.strong(f.title());
808 ui.separator();
809 runtrace::ran(&format!("deck.wall:{}", f.title()));
810 f.ui(ui);
811 });
812 });
813 // The wall shows every pane at once, which is exactly when
814 // "which crate drew THAT one?" is hardest to answer.
815 devid::badge(ui, cell.response.rect, f.component(), f.title());
816 });
817 }
818 });
819 });
820 }
821
822 /// **Render ONE facet into the caller's `Ui`** — the seam a scene host needs to
823 /// make a deck out of REAL panes instead of emulating one.
824 ///
825 /// [`wall_ui`](Self::wall_ui) owns its own grid: it decides the columns, the cell
826 /// size and the scrolling, and draws every facet inside one `Ui`. That is right for
827 /// a self-contained dashboard and wrong for `facett_app::scene`, which already has
828 /// a pane tree, allots each pane its own rect, and gives each one chrome and a
829 /// published address. A host with a scene could only mount the WHOLE deck into ONE
830 /// pane, so N panes' worth of content ended up sharing a single allotment — which
831 /// is precisely the shape that lets one pane's growth displace another.
832 ///
833 /// This renders facet `idx` and nothing else, into whatever rect the caller has
834 /// already decided. No grid, no scroll area, no title strip — the scene's chrome
835 /// bar carries the title, and a second one would be a second header row.
836 ///
837 /// Returns `false` when `idx` is out of range, so a host whose pane tree has
838 /// drifted from the deck's contents gets a value it can assert on rather than a
839 /// silently blank pane.
840 ///
841 /// The runtrace key is `deck.pane:<title>`, alongside `deck.wall:` and
842 /// `deck.render:`, so `state_json`'s `trace.ran` still proves the pane drew.
843 pub fn pane_ui(&mut self, idx: usize, ui: &mut Ui) -> bool {
844 if let Some(theme) = self.fx.theme() {
845 set_theme(ui.ctx(), theme);
846 }
847 let Some(f) = self.facets.get_mut(idx) else {
848 return false;
849 };
850 runtrace::ran(&format!("deck.pane:{}", f.title()));
851 // The pane's own rect, captured BEFORE it draws: a pane that consumes the whole
852 // `Ui` leaves the cursor somewhere unhelpful, so reading it afterwards would
853 // anchor the chip to wherever the content happened to end.
854 let rect = ui.max_rect();
855 let (component, title) = (f.component(), f.title().to_string());
856 f.ui(ui);
857 // The DEV-ID chip — one draw site, so EVERY deck pane gets it for free (LAW 5)
858 // rather than 70-odd panes each remembering to paint their own. No-op in release.
859 devid::badge(ui, rect, component, &title);
860 true
861 }
862
863 /// The title of facet `idx`, for a host naming its panes from the deck.
864 pub fn pane_title(&self, idx: usize) -> Option<&str> {
865 self.facets.get(idx).map(|f| f.title())
866 }
867
868 /// The active facet's current scale (1.0 if none / not scalable).
869 fn active_scale(&self) -> f32 {
870 self.facets.get(self.active).map(|f| f.scale()).unwrap_or(1.0)
871 }
872
873 /// Multiply the active facet's scale by `k`, clamped to **the facet's own**
874 /// [`Facet::scale_range`] — not to a hardcoded document-viewer band. A facet whose
875 /// native range is wider (a map: 1.25-million-fold) would otherwise saturate the
876 /// clamp on the first click and have its camera written backwards.
877 fn scale_active(&mut self, k: f32) {
878 if let Some(f) = self.facets.get_mut(self.active) {
879 let (lo, hi) = f.scale_range();
880 let s = (f.scale() * k).clamp(lo, hi);
881 f.set_scale(s);
882 }
883 }
884
885 /// Reset the active facet's scale to native.
886 fn reset_scale(&mut self) {
887 if let Some(f) = self.facets.get_mut(self.active) {
888 f.set_scale(1.0);
889 }
890 }
891
892 /// Draw the tab bar + capability toolbar + the active facet, and route
893 /// capability-gated shortcuts (Ctrl-+/-/0 for scale; Ctrl-C/X/V for clipboard).
894 pub fn ui(&mut self, ui: &mut Ui) {
895 // Opt-in palette override: apply the chosen Theme::ALL palette + its
896 // egui Visuals each frame so the whole deck (and every facet that reads
897 // `theme(ui)`) follows. No override → the host's own theme stays.
898 if let Some(theme) = self.fx.theme() {
899 set_theme(ui.ctx(), theme);
900 }
901
902 let titles: Vec<String> = self.facets.iter().map(|f| f.title().to_string()).collect();
903 // The tab bar has TWO forms (responsive, host-driven via `set_menu_collapsed`):
904 // • WIDE viewport → the inline wrapped strip (every tab visible + clickable).
905 // • NARROW (phone) → a compact `≡` HAMBURGER header + a drawer of choices,
906 // so the long list doesn't wrap into many rows and bury the canvas.
907 // Capture the bar's rect either way so a headless test can prove the collapsed
908 // menu is one compact row, not a viewport-eating wrap.
909 let bar = ui.scope(|ui| {
910 if self.menu_collapsed {
911 self.draw_menu_drawer(&titles, ui);
912 } else {
913 // Wrap the tab bar: with many facets a single non-wrapping row overflows
914 // the panel width and the trailing tabs become unreachable (off-screen,
915 // unclickable for a robot driver / pointer). Wrapping keeps every tab
916 // visible + clickable no matter how many facets the deck holds.
917 ui.horizontal_wrapped(|ui| {
918 for (i, t) in titles.iter().enumerate() {
919 ui.selectable_value(&mut self.active, i, t);
920 }
921 });
922 }
923 });
924 self.menu_bar_rect = bar.response.rect;
925
926 let caps = self.active_caps();
927
928 // Capability-driven toolbar: only show controls the active facet honors.
929 if caps.scalable {
930 ui.horizontal(|ui| {
931 if ui.button("−").on_hover_text("Zoom out (Ctrl-−)").clicked() {
932 self.scale_active(1.0 / 1.1);
933 }
934 ui.label(format!("{:.0}%", self.active_scale() * 100.0));
935 if ui.button("+").on_hover_text("Zoom in (Ctrl-+)").clicked() {
936 self.scale_active(1.1);
937 }
938 if ui.button("Reset").on_hover_text("Reset zoom (Ctrl-0)").clicked() {
939 self.reset_scale();
940 }
941 });
942 }
943
944 // Capability-gated scale shortcuts. egui has no semantic event for these,
945 // so we hand-detect the key combos (clipboard uses semantic events below).
946 if caps.scalable {
947 let (cmd, plus, minus, zero) = ui.input(|i| {
948 (
949 i.modifiers.command,
950 i.key_pressed(egui::Key::Plus) || i.key_pressed(egui::Key::Equals),
951 i.key_pressed(egui::Key::Minus),
952 i.key_pressed(egui::Key::Num0),
953 )
954 });
955 if cmd {
956 if plus {
957 self.scale_active(1.1);
958 }
959 if minus {
960 self.scale_active(1.0 / 1.1);
961 }
962 if zero {
963 self.reset_scale();
964 }
965 }
966 }
967
968 // Clipboard routing: drain semantic events and dispatch to the active
969 // facet, gated by its caps. A focused TextEdit already consumed its own.
970 self.route_clipboard(ui.ctx());
971
972 // Cross-instance component clone (DISTINCT gesture): the Ctrl+Shift+C/V
973 // accelerators + any pending envelope paste, drained the same frame.
974 self.route_component_clipboard(ui);
975
976 ui.separator();
977 // Optionally wrap the active facet in the shared glass/card chrome
978 // (`chrome` module), gated by the active EffectsPolicy. Reserve a paint slot
979 // BEFORE the content so the glass fill sits behind it; the glow + border
980 // edge is painted on top afterwards.
981 let chrome_on = self.fx.chrome;
982 let chrome_slot = chrome_on.then(|| ui.painter().add(egui::Shape::Noop));
983 // Render the active facet, capturing the rect it occupied so the deck can
984 // bloom it (opt-in glow) without the facet knowing.
985 let content = ui.scope(|ui| {
986 if let Some(f) = self.facets.get_mut(self.active) {
987 // Render-trace: this facet's `ui()` RAN this frame (the wasm-safe
988 // "what ran" ledger — folded into state_json, read via the JS hook
989 // on wasm). Keyed by tab title so the ran-list maps tab → ran?.
990 runtrace::ran(&format!("deck.render:{}", f.title()));
991 f.ui(ui);
992 }
993 });
994 let content_rect = content.response.rect;
995 // The DEV-ID chip, on the rect the facet actually occupied. This is the draw
996 // path the apps really use (`Deck::ui`); `pane_ui` below is the host-driven
997 // one. Both paint it, because a badge wired only into the path nobody calls is
998 // a feature that ships green and never appears (facett-demo goes through HERE).
999 if let Some(f) = self.facets.get(self.active) {
1000 let (component, title) = (f.component(), f.title().to_string());
1001 devid::badge(ui, content_rect, component, &title);
1002 }
1003 // Remember the "canvas" rect — the screen the collapsed menu hands to the
1004 // active facet (read back via `state_json.menu.content_rect`).
1005 self.content_rect = content_rect;
1006
1007 // Paint the card chrome around the facet's content rect.
1008 if let Some(slot) = chrome_slot
1009 && content_rect.is_positive()
1010 {
1011 let theme = self.effective_theme();
1012 let policy = crate::look::effects_policy(ui);
1013 let style = chrome::ChromeStyle::default().for_policy(policy);
1014 let card = content_rect.expand(6.0);
1015 ui.painter().set(slot, chrome::fill_shape(card, &theme, policy, style));
1016 chrome::edge(ui.painter(), card, &theme, policy, style);
1017 }
1018
1019 // Right-click the active facet body → the Copy/Paste-component menu (the
1020 // discoverable affordance for the cross-instance clone gesture). The
1021 // text clipboard's own copy/paste is unaffected (different gesture).
1022 if !self.active_kind().is_empty() {
1023 content.response.context_menu(|ui| self.component_menu(ui));
1024 }
1025
1026 // Opt-in glow on the active facet's content rect, pulsing.
1027 if self.fx.glow && content_rect.is_positive() {
1028 let theme = self.effective_theme();
1029 let time = ui.input(|i| i.time);
1030 let painter = ui.painter_at(content_rect);
1031 deckfx::paint_active_glow(&painter, content_rect.shrink(2.0), &theme, &self.fx, time);
1032 ui.ctx().request_repaint(); // keep the pulse animating
1033 }
1034
1035 // Drive + paint a summoned raven on a foreground layer above everything.
1036 self.drive_raven(ui.ctx());
1037
1038 // Paint the component-clone toast (mismatch / rejection feedback) on top.
1039 self.paint_component_toast(ui);
1040 }
1041
1042 /// Draw the collapsed **hamburger menu** for a narrow viewport: a compact header
1043 /// (`≡` / `✕` toggle + the active choice's label) and, when the drawer is open, the
1044 /// full choice list as a vertical, scrollable drawer. Picking a choice switches the
1045 /// active facet AND shuts the drawer (canvas-first again). Mirrors the demo shell's
1046 /// `⚙` control-strip fold, applied to the MAIN menu.
1047 fn draw_menu_drawer(&mut self, titles: &[String], ui: &mut Ui) {
1048 let active_label = titles.get(self.active).cloned().unwrap_or_default();
1049 ui.horizontal(|ui| {
1050 // The hamburger toggle. `≡` closed, `✕` open — a single tappable glyph.
1051 let glyph = if self.menu_open { "✕" } else { "≡" };
1052 let hint = if self.menu_open { "close the menu — give the canvas the screen" } else { "choose a view" };
1053 if ui.selectable_label(self.menu_open, glyph).on_hover_text(hint).clicked() {
1054 self.menu_open = !self.menu_open;
1055 }
1056 // The current choice's label, so the collapsed header still says WHAT is shown.
1057 ui.label(&active_label);
1058 });
1059 if self.menu_open {
1060 // The full choice list as a vertical DRAWER (bounded + scrollable so 60+
1061 // choices never blow past the viewport). Selecting one switches + closes.
1062 egui::Frame::group(ui.style()).show(ui, |ui| {
1063 egui::ScrollArea::vertical().max_height(420.0).auto_shrink([false, true]).show(ui, |ui| {
1064 for (i, t) in titles.iter().enumerate() {
1065 if ui.selectable_label(self.active == i, t.as_str()).clicked() {
1066 self.active = i;
1067 self.menu_open = false;
1068 }
1069 }
1070 });
1071 });
1072 }
1073 }
1074
1075 /// Advance + paint the summoned raven (if any) on a foreground layer. Pins its
1076 /// launch time on the first frame and keeps repainting while it flies.
1077 fn drive_raven(&mut self, ctx: &egui::Context) {
1078 let Some(raven) = self.raven.as_mut() else { return };
1079 raven.sprite.update(ctx);
1080 let painter =
1081 ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("facett_deck_raven")));
1082 raven.sprite.paint(&painter);
1083 }
1084
1085 /// Route this frame's clipboard events to the active facet, gated by caps.
1086 /// The single OS-touching write (`clipboard::put`) lives here.
1087 fn route_clipboard(&mut self, ctx: &egui::Context) {
1088 let caps = self.active_caps();
1089 if !(caps.copyable || caps.cuttable || caps.pasteable) {
1090 return;
1091 }
1092 for action in clipboard::poll(ctx) {
1093 let Some(f) = self.facets.get_mut(self.active) else { continue };
1094 match action {
1095 ClipAction::Copy if caps.copyable => {
1096 if let Some(t) = f.copy() {
1097 clipboard::put(ctx, t);
1098 }
1099 }
1100 ClipAction::Cut if caps.cuttable => {
1101 if let Some(t) = f.cut() {
1102 clipboard::put(ctx, t);
1103 }
1104 }
1105 ClipAction::Paste(s) if caps.pasteable => {
1106 f.paste(&s);
1107 }
1108 // Capability not declared → ignore (event may belong to a focused
1109 // sub-widget egui already handled).
1110 _ => {}
1111 }
1112 }
1113 }
1114
1115 // ── cross-instance component clone (Copy/Paste component) ────────────────
1116 //
1117 // A DISTINCT gesture from the text clipboard above: it transfers a facet's
1118 // type-tagged PORTABLE state (not a text selection) to a same-kind sibling.
1119 // See `.nornir/design/copy-paste-between-instances.md`. Surfaced two ways —
1120 // the context menu in `component_menu` (right-click the body) and the
1121 // `Ctrl+Shift+C / Ctrl+Shift+V` accelerators routed in `ui`.
1122
1123 /// The active facet's [`Facet::kind`] (`""` if empty / opted out).
1124 pub fn active_kind(&self) -> &'static str {
1125 self.facets.get(self.active).map(|f| f.kind()).unwrap_or("")
1126 }
1127
1128 /// **Copy component** — encode the active facet's [`Facet::portable_state`]
1129 /// into the tagged clipboard envelope and place it on the OS clipboard.
1130 /// Returns the envelope text on success, or `None` if the active facet opts
1131 /// out (empty `kind()` or no `portable_state()`). This is the data half the
1132 /// gesture handlers + tests drive; the OS write is the caller's via
1133 /// [`clipboard::put`] (done for them in [`copy_component`](Self::copy_component)).
1134 pub fn copy_component_envelope(&self) -> Option<String> {
1135 let f = self.facets.get(self.active)?;
1136 let kind = f.kind();
1137 if kind.is_empty() {
1138 return None;
1139 }
1140 let state = f.portable_state()?;
1141 Some(clipboard::encode_component(kind, &state))
1142 }
1143
1144 /// Copy the active facet's portable state to the OS clipboard (the full
1145 /// gesture). Returns `true` if something was copied.
1146 pub fn copy_component(&mut self, ctx: &egui::Context) -> bool {
1147 match self.copy_component_envelope() {
1148 Some(env) => {
1149 clipboard::put(ctx, env);
1150 true
1151 }
1152 None => false,
1153 }
1154 }
1155
1156 /// **Paste component** — decode a clipboard `text` envelope and, **only if its
1157 /// kind matches the active facet's** [`Facet::kind`], hand the state to
1158 /// [`Facet::load_state`]. Returns `true` if the active facet adopted it.
1159 /// A kind mismatch (or a non-envelope / wrong-version text) is a no-op that
1160 /// raises a themed mismatch [`toast`](Self::toast) — the type-match guard is
1161 /// the whole point: a `table` envelope NEVER loads into a `graphpan`.
1162 pub fn paste_component(&mut self, text: &str, now: f64) -> bool {
1163 let Some((kind, state)) = clipboard::decode_component(text) else {
1164 // Not a component envelope at all — leave it for the text path; no toast.
1165 return false;
1166 };
1167 let active_kind = self.active_kind();
1168 if active_kind.is_empty() {
1169 self.toast = Some(("this view doesn't accept a pasted component".to_string(), now));
1170 return false;
1171 }
1172 if kind != active_kind {
1173 self.toast = Some((format!("clipboard holds a `{kind}`, not a `{active_kind}`"), now));
1174 return false;
1175 }
1176 let Some(f) = self.facets.get_mut(self.active) else { return false };
1177 let accepted = f.load_state(&state);
1178 if !accepted {
1179 self.toast = Some((format!("this `{active_kind}` could not adopt the clipboard state"), now));
1180 }
1181 accepted
1182 }
1183
1184 /// The current component-clone toast message (if one is live), for tests /
1185 /// hosts that want to surface it themselves.
1186 pub fn component_toast(&self) -> Option<&str> {
1187 self.toast.as_ref().map(|(m, _)| m.as_str())
1188 }
1189
1190 /// Right-click context-menu entries for the cross-instance clone gesture —
1191 /// **Copy component** / **Paste component** — themed by the active style. A
1192 /// host attaches these to the facet body (or its tab) via
1193 /// `response.context_menu(|ui| deck.component_menu(ui))`. Greys out when the
1194 /// active facet opts out (empty `kind()`).
1195 pub fn component_menu(&mut self, ui: &mut egui::Ui) {
1196 let km = look::keymap(ui);
1197 let kind = self.active_kind();
1198 let can_clone = !kind.is_empty();
1199 ui.add_enabled_ui(can_clone && self.copy_component_envelope().is_some(), |ui| {
1200 let label = format!("Copy component {}", km.label(Action::Copy, ui.ctx()));
1201 if ui.button(label).clicked() {
1202 self.copy_component(ui.ctx());
1203 ui.close();
1204 }
1205 });
1206 ui.add_enabled_ui(can_clone, |ui| {
1207 let label = format!("Paste component {}", km.label(Action::Paste, ui.ctx()));
1208 if ui.button(label).clicked() {
1209 // Pull the OS clipboard via egui's paste request; the actual text
1210 // arrives next frame as an Event::Paste, routed in `route_component_clipboard`.
1211 ui.ctx().send_viewport_cmd(egui::ViewportCommand::RequestPaste);
1212 ui.close();
1213 }
1214 });
1215 }
1216
1217 /// Route the `Ctrl+Shift+C / Ctrl+Shift+V` component-clone accelerators +
1218 /// drain any pending paste envelope. Called once per frame from [`ui`](Self::ui),
1219 /// AFTER the text clipboard so a focused TextEdit's plain Ctrl+C/V is untouched
1220 /// (the Shift discriminates this gesture from text copy/paste).
1221 fn route_component_clipboard(&mut self, ui: &mut egui::Ui) {
1222 let now = ui.input(|i| i.time);
1223 // Accelerators: Ctrl+Shift+C copies; Ctrl+Shift+V triggers an OS-clipboard
1224 // paste request (the text lands next frame as Event::Paste, decoded below).
1225 let copy_shift = egui::KeyboardShortcut::new(
1226 egui::Modifiers::COMMAND | egui::Modifiers::SHIFT,
1227 egui::Key::C,
1228 );
1229 let paste_shift = egui::KeyboardShortcut::new(
1230 egui::Modifiers::COMMAND | egui::Modifiers::SHIFT,
1231 egui::Key::V,
1232 );
1233 if ui.input_mut(|i| i.consume_shortcut(©_shift)) {
1234 self.copy_component(ui.ctx());
1235 }
1236 if ui.input_mut(|i| i.consume_shortcut(&paste_shift)) {
1237 ui.ctx().send_viewport_cmd(egui::ViewportCommand::RequestPaste);
1238 }
1239 // Drain any Paste events that look like a component envelope (a plain text
1240 // paste decodes to None here and is left for the text clipboard path).
1241 let pastes: Vec<String> = ui.input(|i| {
1242 i.events
1243 .iter()
1244 .filter_map(|e| match e {
1245 egui::Event::Paste(s) if clipboard::decode_component(s).is_some() => Some(s.clone()),
1246 _ => None,
1247 })
1248 .collect()
1249 });
1250 for text in pastes {
1251 self.paste_component(&text, now);
1252 }
1253 // Age out the toast.
1254 if let Some((_, raised)) = self.toast {
1255 if now - raised > TOAST_SECS {
1256 self.toast = None;
1257 }
1258 }
1259 }
1260
1261 /// Paint the live component-clone toast on a foreground layer (themed, spacious),
1262 /// if one is set. A no-op when there is no toast.
1263 fn paint_component_toast(&self, ui: &mut egui::Ui) {
1264 let Some((msg, _)) = self.toast.as_ref() else { return };
1265 let th = theme(ui);
1266 let ctx = ui.ctx();
1267 let painter =
1268 ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("facett_deck_component_toast")));
1269 let screen = ctx.content_rect();
1270 let font = FontId::proportional(14.0);
1271 let galley = painter.layout_no_wrap(msg.clone(), font.clone(), th.text);
1272 let pad = vec2(14.0, 10.0); // spacious preset padding
1273 let size = galley.size() + pad * 2.0;
1274 // Bottom-centre, lifted off the edge.
1275 let center = Pos2::new(screen.center().x, screen.max.y - size.y * 0.5 - 18.0);
1276 let rect = Rect::from_center_size(center, size);
1277 painter.rect_filled(rect, 8.0, th.panel_bg);
1278 painter.rect_stroke(rect, 8.0, Stroke::new(1.0_f32, th.panel_stroke), egui::StrokeKind::Inside);
1279 painter.galley(rect.min + pad, galley, th.text);
1280 ctx.request_repaint(); // keep ticking so the toast ages out on time
1281 }
1282
1283 /// The whole-app observable state: the active facet + each facet's
1284 /// `state_json`, plus an **additive** sibling `caps` map (title → caps JSON)
1285 /// so the existing flat `facets[title]` shape is unchanged for consumers.
1286 pub fn state_json(&self) -> serde_json::Value {
1287 let mut facets = serde_json::Map::new();
1288 let mut caps = serde_json::Map::new();
1289 for f in &self.facets {
1290 facets.insert(f.title().to_string(), f.state_json());
1291 caps.insert(f.title().to_string(), f.caps().to_json());
1292 }
1293 let mr = self.menu_bar_rect;
1294 let cr = self.content_rect;
1295 serde_json::json!({
1296 "active": self.facets.get(self.active).map(|f| f.title()),
1297 "facets": facets,
1298 "caps": caps,
1299 // The RESPONSIVE menu (#39-follow): whether the tab bar is the collapsed
1300 // hamburger (phone-class) and, if so, whether its drawer is open — plus the
1301 // menu-header rect and the active-facet ("canvas") rect, so a headless test
1302 // proves the collapsed menu is one compact row and the canvas gets the screen.
1303 "menu": {
1304 "collapsed": self.menu_collapsed,
1305 "open": self.menu_open,
1306 "active": self.facets.get(self.active).map(|f| f.title()),
1307 "count": self.facets.len(),
1308 "bar_rect": [mr.min.x, mr.min.y, mr.width(), mr.height()],
1309 "content_rect": [cr.min.x, cr.min.y, cr.width(), cr.height()],
1310 },
1311 // The deck's opt-in effects (DeckFx) as data: whether the shared glass/
1312 // card `chrome` wrap is on, whether the active-facet bloom glow is on, and
1313 // the active palette override. A headless driver reads this to PROVE the
1314 // T1.3 showcase rendering (glass + bloom) is actually wired, not eyeballed.
1315 "fx": {
1316 "chrome": self.fx.chrome,
1317 "glow": self.fx.glow,
1318 "glow_layers": self.fx.glow_layers,
1319 "palette": self.fx.palette(),
1320 },
1321 // The wasm-safe "what RAN" ledger — every facet render + every traced
1322 // control handler that has executed this session (the readable proof
1323 // the shipped artifact actually ran each surface). See `runtrace`.
1324 "trace": { "ran": runtrace::snapshot(), "distinct": runtrace::distinct() },
1325 // The DEV-ID gate — enabled/env/profile/source. Reports whether the chips
1326 // are SWITCHED ON, not whether one is on screen; see `devid::gate_json`.
1327 "devid": devid::gate_json(),
1328 })
1329 }
1330}
1331
1332/// A stable, bright-ish colour from a string (FNV-1a). Handy default node colour.
1333pub fn hash_color(s: &str) -> Color32 {
1334 let mut h: u32 = 2166136261;
1335 for b in s.bytes() {
1336 h = (h ^ b as u32).wrapping_mul(16777619);
1337 }
1338 Color32::from_rgb((h & 0xFF) as u8 | 0x60, ((h >> 8) & 0xFF) as u8 | 0x60, ((h >> 16) & 0xFF) as u8 | 0x60)
1339}
1340
1341#[cfg(test)]
1342mod tests {
1343 use super::*;
1344
1345 #[test]
1346 fn scene_builds() {
1347 let mut s = Scene::new();
1348 let a = s.node("Person", hash_color("Person"));
1349 let b = s.node("Company", hash_color("Company"));
1350 s.edge(a, b);
1351 assert_eq!(s.nodes.len(), 2);
1352 assert_eq!(s.edges.len(), 1);
1353 assert!(!s.is_empty());
1354 }
1355
1356 #[test]
1357 fn force_layout_produces_finite_bounded_positions() {
1358 let mut scene = Scene::new();
1359 for i in 0..12 { scene.node(format!("n{i}"), hash_color("n")); }
1360 for i in 0..12 { scene.edge(i, (i + 1) % 12); }
1361 let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(400.0, 400.0));
1362 let pos = positions(Layout::Force, &scene, rect);
1363 assert_eq!(pos.len(), 12);
1364 for p in &pos {
1365 assert!(p.x.is_finite() && p.y.is_finite(), "finite");
1366 assert!(rect.expand(50.0).contains(*p), "roughly within the rect");
1367 }
1368 }
1369
1370 /// **Scale (RED-when-broken): the repulsion is sub-quadratic, measured as a
1371 /// GROWTH RATIO and not as a wall clock.**
1372 ///
1373 /// This was `assert!(ms < 20_000.0)` on one 10 000-node run, which is two defects
1374 /// in one line (stinky `mapgpu-2`): on a loaded box a *correct* implementation
1375 /// fails (MEASURED 2026-08-03: 39 616 ms at load average 120 on 32 cores), and on
1376 /// an idle box an O(n²) implementation *passes*. A wall clock cannot answer a
1377 /// complexity question — it answers a hardware question.
1378 ///
1379 /// What is measured instead is `t(4n) / t(n)`, which is ~16 for a quadratic
1380 /// repulsion and ~2–4 for `O(n log n)`. A ratio is immune to machine load because
1381 /// both halves of it contend for the same cores in the same run, and the **4×**
1382 /// spacing (rather than 2×) is deliberate: it puts the two classes an order of
1383 /// magnitude apart, so load only has to be survived, not eliminated.
1384 ///
1385 /// **ARM 1 is a red probe that runs every time.** Below
1386 /// [`BH_THRESHOLD`](crate::barnes_hut::BH_THRESHOLD) `positions` deliberately keeps
1387 /// the exact all-pairs loop, so the same instrument is pointed at a *known
1388 /// quadratic* and required to say so against the *same gate*. If arm 1 cannot see
1389 /// quadratic growth where quadratic growth demonstrably lives, arm 2's green is not
1390 /// evidence and the test fails at arm 1 instead of passing blind.
1391 ///
1392 /// MEASURED on oden 2026-08-22, debug build, load average 91 on 32 cores:
1393 /// quadratic arm `n=250 194.4 ms → n=1000 3 625.7 ms = 18.651`; Barnes–Hut arm
1394 /// `n=2000 1 541.5 ms → n=8000 3 043.7 ms = 1.975`. That is a **9.4× separation**,
1395 /// and the single gate below sits in the empty middle of it.
1396 ///
1397 /// **SEEN RED**: an earlier 2×-spaced revision of this test was run with `use_bh`
1398 /// forced to `false` in [`positions`] — the exact regression it exists to catch —
1399 /// and failed with `26 656.7 ms → 68 839.0 ms = 2.582` against a correct 1.502 on
1400 /// the same box. That run also showed *why* the spacing was widened to 4×: at load
1401 /// average 121 a 2× step compresses a true quadratic from 3.976 to 3.151 and the
1402 /// regressed Barnes–Hut arm to 2.582, which is far too little daylight.
1403 #[test]
1404 fn force_layout_repulsion_is_sub_quadratic() {
1405 /// The gate BOTH arms are judged against. Quadratic growth over a 4× step is
1406 /// ~16; `O(n log n)` is ~2–4. One number, so the red probe and the claim
1407 /// cannot drift apart.
1408 const QUADRATIC_GATE: f64 = 8.0;
1409
1410 /// A sparse ring + chords: O(n) edges, so the only super-linear term left in
1411 /// the layout is the repulsion this test is about.
1412 fn ring_scene(n: usize) -> Scene {
1413 let mut scene = Scene::new();
1414 for i in 0..n {
1415 scene.node(format!("n{i}"), hash_color("n"));
1416 }
1417 for i in 0..n {
1418 scene.edge(i, (i + 1) % n);
1419 if i % 7 == 0 {
1420 scene.edge(i, (i + 137) % n);
1421 }
1422 }
1423 scene
1424 }
1425 let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1024.0, 1024.0));
1426 // Best of two: the MINIMUM is the estimator least polluted by a scheduler
1427 // steal, and a steal can only ever make a run slower, never faster.
1428 let run = |n: usize| -> f64 {
1429 let scene = ring_scene(n);
1430 let mut best = f64::INFINITY;
1431 for _ in 0..2 {
1432 let t0 = std::time::Instant::now();
1433 let pos = positions(Layout::Force, &scene, rect);
1434 let ms = t0.elapsed().as_secs_f64() * 1000.0;
1435 assert_eq!(pos.len(), n);
1436 for p in &pos {
1437 assert!(p.x.is_finite() && p.y.is_finite(), "every node finite at n={n}");
1438 assert!(rect.expand(50.0).contains(*p), "every node bounded at n={n}");
1439 }
1440 best = best.min(ms);
1441 }
1442 best
1443 };
1444
1445 // ── ARM 1 (RED PROBE): the instrument sees a quadratic when there IS one ──
1446 const QUAD_N: usize = 250;
1447 assert!(
1448 4 * QUAD_N < crate::barnes_hut::BH_THRESHOLD,
1449 "arm 1 must stay entirely on the exact all-pairs path"
1450 );
1451 let q1 = run(QUAD_N);
1452 let q4 = run(4 * QUAD_N);
1453 let quad_ratio = q4 / q1;
1454 assert!(
1455 quad_ratio >= QUADRATIC_GATE,
1456 "RED PROBE FAILED: the exact all-pairs repulsion below BH_THRESHOLD grew only \
1457 {quad_ratio:.3}x from n={QUAD_N} ({q1:.1} ms) to n={} ({q4:.1} ms), against the \
1458 {QUADRATIC_GATE} this test calls quadratic (measured 18.651 on oden). This box cannot \
1459 currently tell quadratic from n log n, so the Barnes-Hut arm below would be blind — \
1460 re-run it somewhere less contended rather than trusting a green.",
1461 4 * QUAD_N
1462 );
1463
1464 // ── ARM 2: the claim ──────────────────────────────────────────────────
1465 const BH_N: usize = 2_000;
1466 assert!(BH_N >= crate::barnes_hut::BH_THRESHOLD, "arm 2 must be on the Barnes-Hut path");
1467 let b1 = run(BH_N);
1468 let b4 = run(4 * BH_N);
1469 let bh_ratio = b4 / b1;
1470 assert!(
1471 bh_ratio < QUADRATIC_GATE,
1472 "the force layout is not sub-quadratic: t(4n)/t(n) = {bh_ratio:.3} \
1473 ({b1:.1} ms at n={BH_N} -> {b4:.1} ms at n={}), against {quad_ratio:.3} for the \
1474 all-pairs loop the same instrument measured seconds ago. A regression to quadratic \
1475 repulsion above BH_THRESHOLD lands exactly here.",
1476 4 * BH_N
1477 );
1478 }
1479
1480 #[test]
1481 fn hash_color_is_stable() {
1482 assert_eq!(hash_color("Person"), hash_color("Person"));
1483 assert_ne!(hash_color("Person"), hash_color("Company"));
1484 }
1485
1486 /// A minimal facet for deck tests.
1487 struct Stub(&'static str);
1488 impl Facet for Stub {
1489 fn title(&self) -> &str {
1490 self.0
1491 }
1492 fn ui(&mut self, ui: &mut Ui) {
1493 ui.label(self.0);
1494 }
1495 fn state_json(&self) -> serde_json::Value {
1496 serde_json::json!({ "t": self.0 })
1497 }
1498 }
1499
1500 /// A component-clone-capable stub: a `kind`, a JSON `payload` it round-trips
1501 /// through `portable_state`/`load_state`.
1502 struct CloneStub {
1503 kind: &'static str,
1504 payload: serde_json::Value,
1505 }
1506 impl Facet for CloneStub {
1507 fn title(&self) -> &str {
1508 self.kind
1509 }
1510 fn ui(&mut self, _ui: &mut Ui) {}
1511 fn state_json(&self) -> serde_json::Value {
1512 serde_json::json!({ "kind": self.kind })
1513 }
1514 fn kind(&self) -> &'static str {
1515 self.kind
1516 }
1517 fn portable_state(&self) -> Option<serde_json::Value> {
1518 Some(self.payload.clone())
1519 }
1520 fn load_state(&mut self, state: &serde_json::Value) -> bool {
1521 self.payload = state.clone();
1522 true
1523 }
1524 }
1525
1526 /// **The deck reports the DEV-ID gate too.** `facett_app::scene` is korp's container,
1527 /// but facett-demo and the wasm surfaces go through `FacetDeck`, and an oracle asking
1528 /// "are the chips on?" must get the same answer from either. A gate readable from only
1529 /// one of the two containers is exactly the shape of miss #2.
1530 #[test]
1531 fn the_deck_state_reports_the_devid_gate() {
1532 let deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
1533 let gate = deck.state_json()["devid"].clone();
1534 assert!(!gate.is_null(), "the deck's state_json carries no `devid` gate");
1535 assert_eq!(gate["enabled"], devid::enabled(), "deck reports a gate badge() disobeys");
1536 assert_eq!(gate["debug_assertions"], cfg!(debug_assertions), "wrong profile: {gate}");
1537 }
1538
1539 #[test]
1540 fn component_clone_round_trips_through_the_deck() {
1541 // Instance A (configured) → envelope → instance B adopts it.
1542 let a = CloneStub { kind: "graphpan", payload: serde_json::json!({ "zoom": 2.0, "pan": [3, 4] }) };
1543 let mut deck_a = FacetDeck::new(vec![Box::new(a)]);
1544 let env = deck_a.copy_component_envelope().expect("A copies its portable state");
1545
1546 let mut deck_b = FacetDeck::new(vec![Box::new(CloneStub {
1547 kind: "graphpan",
1548 payload: serde_json::json!({ "zoom": 1.0, "pan": [0, 0] }),
1549 })]);
1550 assert!(deck_b.paste_component(&env, 0.0), "same-kind paste is accepted");
1551 // B now equals A's portable state.
1552 assert_eq!(
1553 deck_b.copy_component_envelope(),
1554 deck_a.copy_component_envelope(),
1555 "B adopted A's portable state exactly"
1556 );
1557 assert!(deck_b.component_toast().is_none(), "a successful paste raises no toast");
1558 }
1559
1560 #[test]
1561 fn component_clone_rejects_a_type_mismatch_with_a_toast() {
1562 // A `table` envelope handed to a `graphpan` → load_state NOT called.
1563 let table_env = clipboard::encode_component("table", &serde_json::json!({ "rows": 3 }));
1564 let mut deck = FacetDeck::new(vec![Box::new(CloneStub {
1565 kind: "graphpan",
1566 payload: serde_json::json!({ "zoom": 1.0 }),
1567 })]);
1568 let before = deck.copy_component_envelope();
1569 assert!(!deck.paste_component(&table_env, 0.0), "cross-type paste returns false");
1570 assert_eq!(deck.copy_component_envelope(), before, "graphpan state untouched");
1571 let toast = deck.component_toast().expect("mismatch raises a toast");
1572 assert!(toast.contains("table") && toast.contains("graphpan"), "toast names both kinds: {toast}");
1573 }
1574
1575 #[test]
1576 fn component_clone_version_guard_rejects_unknown_v() {
1577 let bad = serde_json::json!({ "facett.kind": "graphpan", "v": 7, "state": { "zoom": 9.0 } }).to_string();
1578 let mut deck = FacetDeck::new(vec![Box::new(CloneStub {
1579 kind: "graphpan",
1580 payload: serde_json::json!({ "zoom": 1.0 }),
1581 })]);
1582 let before = deck.copy_component_envelope();
1583 // An unknown-version text decodes to None → it's a no-op (left for the text path).
1584 assert!(!deck.paste_component(&bad, 0.0), "unknown version is not adopted");
1585 assert_eq!(deck.copy_component_envelope(), before, "state untouched by a bad-version paste");
1586 }
1587
1588 #[test]
1589 fn component_clone_opt_out_floor_neither_copies_nor_accepts() {
1590 // The plain Stub does NOT implement the trio → empty kind, no copy.
1591 let mut deck = FacetDeck::new(vec![Box::new(Stub("plain"))]);
1592 assert_eq!(deck.active_kind(), "", "opted out");
1593 assert!(deck.copy_component_envelope().is_none(), "opt-out facet never copies a component");
1594 // A real envelope handed to an opt-out facet is refused (no panic, no state).
1595 let env = clipboard::encode_component("table", &serde_json::json!({ "rows": 1 }));
1596 assert!(!deck.paste_component(&env, 0.0), "opt-out facet never adopts a component");
1597 assert!(deck.component_toast().is_some(), "the refusal is surfaced");
1598 }
1599
1600 #[test]
1601 fn deck_fx_is_off_by_default() {
1602 let deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
1603 assert_eq!(*deck.fx(), DeckFx::OFF, "no effects until the host opts in");
1604 assert!(!deck.has_raven());
1605 assert!(!deck.fx().glow);
1606 assert!(deck.fx().palette().is_none());
1607 }
1608
1609 #[test]
1610 fn deck_state_json_reports_fx_as_data() {
1611 // The deck's opt-in effects must be observable as data (a robot proof reads
1612 // `fx.chrome` / `fx.glow` to know the showcase rendering is wired ON).
1613 let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
1614 let off = deck.state_json();
1615 assert_eq!(off["fx"]["chrome"].as_bool(), Some(false), "chrome off by default");
1616 assert_eq!(off["fx"]["glow"].as_bool(), Some(false), "glow off by default");
1617 assert!(off["fx"]["palette"].is_null(), "no palette override by default");
1618
1619 deck.fx_mut().chrome = true;
1620 deck.fx_mut().glow = true;
1621 deck.set_palette(2);
1622 let on = deck.state_json();
1623 assert_eq!(on["fx"]["chrome"].as_bool(), Some(true), "chrome wired ON shows in state");
1624 assert_eq!(on["fx"]["glow"].as_bool(), Some(true), "glow wired ON shows in state");
1625 assert_eq!(on["fx"]["palette"].as_u64(), Some(2), "palette override shows in state");
1626 }
1627
1628 #[test]
1629 fn deck_cycle_palette_walks_theme_all() {
1630 let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
1631 let first = deck.cycle_palette();
1632 assert_eq!(first, 0);
1633 assert_eq!(deck.fx().theme().map(|t| t.name), Some(Theme::ALL[0]().name));
1634 // walks forward and wraps
1635 for _ in 1..Theme::ALL.len() {
1636 deck.cycle_palette();
1637 }
1638 assert_eq!(deck.cycle_palette(), 0, "wraps back to the first palette");
1639 }
1640
1641 #[test]
1642 fn deck_send_raven_launches_and_perches_after_a_full_flight() {
1643 use crate::effects::RAVEN_FLIGHT_SECS;
1644 let mut deck = FacetDeck::new(vec![Box::new(Stub("rows"))]);
1645 assert!(!deck.has_raven());
1646 let target = egui::Rect::from_min_size(egui::pos2(120.0, 80.0), egui::vec2(200.0, 28.0));
1647 deck.send_raven(target);
1648 assert!(deck.has_raven(), "raven summoned");
1649 assert!(!deck.raven_perched(), "not perched at launch");
1650
1651 // Drive the sprite headlessly past the flight duration → it perches.
1652 if let Some(r) = deck.raven.as_mut() {
1653 r.sprite.advance(RAVEN_FLIGHT_SECS + 0.1);
1654 }
1655 assert!(deck.raven_perched(), "perched after the flight duration");
1656
1657 deck.clear_raven();
1658 assert!(!deck.has_raven());
1659 }
1660
1661 /// REGRESSION (inject-assert): merely *drawing* the palette picker without a
1662 /// user click must NOT pin a palette override. The bug: the picker auto-pinned
1663 /// index 0 on the first passive frame, turning the legacy `set_theme` override
1664 /// permanently on and clobbering a host's own theme (the rich `look::Theme`)
1665 /// every frame. We render one frame with no interaction and assert the override
1666 /// is still `None` (host theme wins).
1667 #[test]
1668 fn palette_picker_does_not_pin_without_a_user_click() {
1669 let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
1670 assert!(deck.fx().palette().is_none(), "starts with no override");
1671 let ctx = egui::Context::default();
1672 let mut chosen = Some(7usize);
1673 let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
1674 egui::CentralPanel::default().show_inside(ui, |ui| {
1675 // No synthetic click is fed → the picker is drawn but not used.
1676 chosen = deck.palette_picker(ui);
1677 });
1678 });
1679 assert_eq!(chosen, None, "drawing the picker reports no selection without a click");
1680 assert!(
1681 deck.fx().palette().is_none(),
1682 "drawing the picker must not pin index 0 — that would clobber the host's own theme each frame"
1683 );
1684 }
1685
1686 #[test]
1687 fn deck_palette_override_applies_theme_in_a_ui_pass() {
1688 let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
1689 deck.set_palette(1); // sci-fi
1690 let ctx = egui::Context::default();
1691 let mut seen = "";
1692 let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
1693 egui::CentralPanel::default().show_inside(ui, |ui| {
1694 deck.ui(ui);
1695 seen = theme(ui).name;
1696 });
1697 });
1698 assert_eq!(seen, Theme::ALL[1]().name, "deck applied its palette override");
1699 }
1700}