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;
10pub mod caps;
11pub mod clip;
12pub mod clipboard;
13pub mod deckfx;
14pub mod edges;
15pub mod effects;
16pub mod focus;
17pub mod imgscan; // image-analysis oracle (SCAN-THE-PIXELS law): spoke/high-freq/
18 // coverage/centroid features computed FROM the rendered pixels.
19pub mod labels3d;
20pub mod harness;
21pub mod look;
22pub mod nav;
23pub mod overlay;
24pub mod rabbit;
25/// The L0 shared render kernel (CONS-CORE) — shared `Camera`, z-ordered
26/// `LayerStack`, the CPU rect scissor, and (feature `wgpu`) the extracted GPU
27/// scaffold. Map skins + `facett-graphview` draw through this.
28pub mod render;
29pub mod runtrace; // in-memory, wasm-safe "what RAN" ledger (no FS) — folded into
30 // state_json["trace"]["ran"], read via the JS hook on wasm.
31pub mod scroll_engine;
32pub mod testmatrix; // functional-status → nornir test-matrix bridge (feature
33 // `testmatrix`); no-op in release.
34pub mod theme;
35pub mod trace; // structured IN/OUT/END event stream ($FACETT_TRACE) — the
36 // machine-readable data a facet actually rendered.
37pub use a11y::{Semantics, node as a11y_node, stable_id};
38pub use caps::FacetCaps;
39pub use clip::{ArrowColumnRef, ClipKind, ClipPayload, CopySource, PasteTarget};
40pub use clipboard::ClipAction;
41pub use deckfx::{DeckFx, DeckRaven};
42pub use imgscan::{BBox, Rgba, ScanReport, coverage, high_freq_ratio, painted_centroid_and_bbox, scan, spoke_score};
43pub use look::{Action, KeyMap, Palette};
44pub use nav::{Dir4, Navigable, nearest_in_direction};
45pub use rabbit::{Rabbit, RabbitMesh, rabbit_mesh, rabbit_outline};
46pub use scroll_engine::SmoothScroll;
47pub use theme::{Theme, set_theme, theme};
48
49// The rich look-&-feel `Theme` (the work-order architecture) is re-exported under
50// an unambiguous alias so it coexists with the legacy flat palette `Theme` above.
51pub use look::Theme as LookTheme;
52
53/// A node: a label + a colour (the *consumer* picks the colour policy — hash by
54/// label, by status, …).
55#[derive(Clone)]
56pub struct Node {
57 pub label: String,
58 pub color: Color32,
59}
60
61/// A directed edge between node indices.
62#[derive(Clone, Copy)]
63pub struct Edge {
64 pub src: usize,
65 pub dst: usize,
66}
67
68/// A drawable graph: nodes + edges (edges index into `nodes`).
69#[derive(Default, Clone)]
70pub struct Scene {
71 pub nodes: Vec<Node>,
72 pub edges: Vec<Edge>,
73}
74
75impl Scene {
76 pub fn new() -> Self {
77 Self::default()
78 }
79 /// Push a node, returning its index.
80 pub fn node(&mut self, label: impl Into<String>, color: Color32) -> usize {
81 self.nodes.push(Node { label: label.into(), color });
82 self.nodes.len() - 1
83 }
84 pub fn edge(&mut self, src: usize, dst: usize) {
85 self.edges.push(Edge { src, dst });
86 }
87 pub fn is_empty(&self) -> bool {
88 self.nodes.is_empty()
89 }
90}
91
92/// Node placement strategy.
93#[derive(Clone, Copy, PartialEq, Eq, Default)]
94pub enum Layout {
95 #[default]
96 Circular,
97 /// Deterministic Fruchterman–Reingold (edges pull, all nodes repel). O(n²)
98 /// per iteration — best for small/medium graphs.
99 Force,
100}
101
102/// Draw a `Scene` into `ui` — the reusable render primitive. Empty scenes show
103/// `empty_hint`. Labels render when the node count is small enough to read.
104pub fn draw(ui: &mut Ui, scene: &Scene, layout: Layout, empty_hint: &str) {
105 let (rect, _) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
106 let th = theme(ui);
107 let painter = ui.painter_at(rect);
108 let n = scene.nodes.len();
109 if n == 0 {
110 painter.text(rect.center(), Align2::CENTER_CENTER, empty_hint, FontId::proportional(13.0), th.text_dim);
111 return;
112 }
113 let pos = positions(layout, scene, rect);
114 for e in &scene.edges {
115 if e.src < n && e.dst < n {
116 painter.line_segment([pos[e.src], pos[e.dst]], Stroke::new(0.6, th.edge));
117 }
118 }
119 for (i, node) in scene.nodes.iter().enumerate() {
120 painter.circle_filled(pos[i], 5.0, node.color);
121 }
122 if n <= 60 {
123 for (i, node) in scene.nodes.iter().enumerate() {
124 painter.text(pos[i] + vec2(7.0, 0.0), Align2::LEFT_CENTER, &node.label, FontId::proportional(10.0), th.text);
125 }
126 }
127}
128
129/// **Test/host hook (additive).** The public, return-asserted view of the
130/// private [`positions`] layout node — the exact node centres [`draw`] paints for
131/// `scene` under `layout` inside `rect`. Exposed so the graph-skin call-chain
132/// matrix can assert the *layout* stage (finite, in-rect, count == nodes,
133/// circular radius, force-fit normalisation) without a painter. Calls the **same**
134/// private fn `draw` uses, so it IS the layout the pixels come from — additive,
135/// no behaviour change.
136pub fn layout_positions(layout: Layout, scene: &Scene, rect: Rect) -> Vec<Pos2> {
137 positions(layout, scene, rect)
138}
139
140fn positions(layout: Layout, scene: &Scene, rect: Rect) -> Vec<Pos2> {
141 let n = scene.nodes.len();
142 let center = rect.center();
143 let radius = rect.size().min_elem() * 0.42;
144 let circular = |i: usize| {
145 let a = std::f32::consts::TAU * (i as f32) / (n as f32);
146 vec2(a.cos(), a.sin())
147 };
148 match layout {
149 Layout::Circular => (0..n).map(|i| center + radius * circular(i)).collect(),
150 Layout::Force => {
151 // Deterministic Fruchterman–Reingold from a circular seed (unit space).
152 let mut p: Vec<egui::Vec2> = (0..n).map(circular).collect();
153 let k = (1.0 / (n.max(1) as f32).sqrt()).clamp(0.05, 1.0);
154 for _ in 0..120 {
155 let mut disp = vec![egui::Vec2::ZERO; n];
156 for i in 0..n {
157 for j in (i + 1)..n {
158 let d = p[i] - p[j];
159 let dist = d.length().max(1e-3);
160 let f = k * k / dist;
161 let dir = d / dist;
162 disp[i] += dir * f;
163 disp[j] -= dir * f;
164 }
165 }
166 for e in &scene.edges {
167 if e.src < n && e.dst < n {
168 let d = p[e.src] - p[e.dst];
169 let dist = d.length().max(1e-3);
170 let f = dist * dist / k;
171 let dir = d / dist;
172 disp[e.src] -= dir * f;
173 disp[e.dst] += dir * f;
174 }
175 }
176 for i in 0..n {
177 let dl = disp[i].length().max(1e-3);
178 p[i] += disp[i] / dl * dl.min(0.04); // capped step (cooling-free, deterministic)
179 }
180 }
181 // Normalise to fit the rect.
182 let (mut mn, mut mx) = (egui::vec2(f32::MAX, f32::MAX), egui::vec2(f32::MIN, f32::MIN));
183 for v in &p {
184 mn.x = mn.x.min(v.x);
185 mn.y = mn.y.min(v.y);
186 mx.x = mx.x.max(v.x);
187 mx.y = mx.y.max(v.y);
188 }
189 let span = (mx - mn).max(egui::vec2(1e-3, 1e-3));
190 p.iter()
191 .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))
192 .collect()
193 }
194 }
195}
196
197/// The facett **component contract**. Every facet — graph, map, pipeline, table,
198/// the ported nornir viewers — implements this, so consumers (korp, nornir, …)
199/// compose them uniformly *and* get headless robot-testing for free.
200///
201/// The three things a component owes its host:
202/// 1. a **title** (tab label / panel heading),
203/// 2. how to **draw** itself into egui,
204/// 3. its **observable state** as JSON — dumped to `$APP_STATE` for headless
205/// assertions. **Rule:** every visible list/status/count goes in `state_json`.
206pub trait Facet {
207 fn title(&self) -> &str;
208 fn ui(&mut self, ui: &mut Ui);
209 fn state_json(&self) -> serde_json::Value;
210
211 // --- uniform capability surface (all defaulted; see caps.rs / clipboard.rs) ---
212
213 /// What this facet can do. Override to opt into capabilities.
214 fn caps(&self) -> FacetCaps {
215 FacetCaps::NONE
216 }
217
218 /// Current uniform scale (1.0 = native). Override if `caps().scalable`.
219 fn scale(&self) -> f32 {
220 1.0
221 }
222 /// Set the uniform scale; clamp internally. Default no-op (not scalable).
223 fn set_scale(&mut self, _scale: f32) {}
224
225 /// The current selection as JSON (also folded into `state_json` by
226 /// convention). `Null` when nothing/none selectable.
227 fn selection_json(&self) -> serde_json::Value {
228 serde_json::Value::Null
229 }
230
231 /// Clipboard hooks — see clipboard.rs. Defaults: nothing to give/take.
232 /// Returns the text to place on the clipboard (None = nothing copyable now).
233 fn copy(&mut self) -> Option<String> {
234 None
235 }
236 /// Like `copy`, but also removes the selection. Default delegates to `copy`.
237 fn cut(&mut self) -> Option<String> {
238 self.copy()
239 }
240 /// Accept pasted text. Returns true if consumed.
241 fn paste(&mut self, _text: &str) -> bool {
242 false
243 }
244
245 /// Optional downcast handle for hosts that need typed access to a specific
246 /// facet living inside a [`FacetDeck`] (e.g. a robot-UI driver clicking an
247 /// app-level control that must forward to a concrete component's own API).
248 /// Defaulted to `None` so no existing facet has to change; a component opts in
249 /// by returning `Some(self)`.
250 fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
251 None
252 }
253
254 // --- cross-instance state clone (copy/paste BETWEEN same-component instances) ---
255 // See `.nornir/design/copy-paste-between-instances.md` + `clipboard.rs`. The trio
256 // below is the type-tagged STATE layer on top of the text clipboard: two
257 // instances with the SAME `kind()` exchange `portable_state()` via the OS
258 // clipboard envelope (`clipboard::encode_component`/`decode_component`). Each is
259 // defaulted to the opt-OUT floor — a component that doesn't implement all three
260 // neither copies nor accepts cross-instance state, and nothing panics.
261
262 /// Stable component-type id (e.g. `"jobview"`, `"graphpan"`, `"table"`). Two
263 /// instances with the **same** kind can exchange portable state; the empty
264 /// default `""` means **opted out** of cross-instance clone.
265 fn kind(&self) -> &'static str {
266 ""
267 }
268
269 /// The **portable** subset of this facet's state — the fields a same-kind
270 /// sibling can adopt. `None` = not cloneable. Kept SEPARATE from
271 /// [`state_json`](Self::state_json) (the introspection dump, which may carry
272 /// derived / render-only data) so this stays round-trippable through
273 /// [`load_state`](Self::load_state).
274 fn portable_state(&self) -> Option<serde_json::Value> {
275 None
276 }
277
278 /// Adopt a portable state produced by [`portable_state`](Self::portable_state)
279 /// on a same-kind sibling. Returns `true` if accepted. Default `false`
280 /// (opt-in per component).
281 fn load_state(&mut self, _state: &serde_json::Value) -> bool {
282 false
283 }
284}
285
286/// A tabbed set of [`Facet`]s — the reusable multi-component shell. Draws a tab
287/// bar + the active facet, and composes **every** facet's `state_json` under its
288/// title, so the whole-app introspection contract is free. korp/nornir can build
289/// their window from a `FacetDeck` instead of hand-rolling tabs + the state dump.
290pub struct FacetDeck {
291 facets: Vec<Box<dyn Facet>>,
292 active: usize,
293 /// Opt-in deck effects (palette override + glow). `Default` = all off, so a
294 /// deck that never opts in is unchanged and pays nothing.
295 fx: DeckFx,
296 /// A raven summoned through the deck, in flight or perched (or `None`).
297 raven: Option<DeckRaven>,
298 /// A transient, themed component-clone toast (message + the `ctx.input.time`
299 /// it was raised at), shown briefly after a Copy-/Paste-component gesture —
300 /// chiefly the type-mismatch rejection ("clipboard holds a `table`, not a
301 /// `graphpan`"). `None` = nothing to show. See [`Self::component_toast`].
302 toast: Option<(String, f64)>,
303}
304
305/// How long a component-clone [`toast`](FacetDeck::toast) stays on screen.
306const TOAST_SECS: f64 = 2.6;
307
308impl FacetDeck {
309 pub fn new(facets: Vec<Box<dyn Facet>>) -> Self {
310 Self { facets, active: 0, fx: DeckFx::OFF, raven: None, toast: None }
311 }
312 pub fn active(&self) -> usize {
313 self.active
314 }
315
316 /// The title of the currently-active facet (the deck's `state_json["active"]`),
317 /// or `None` if the deck is empty.
318 pub fn active_title(&self) -> Option<&str> {
319 self.facets.get(self.active).map(|f| f.title())
320 }
321
322 /// The titles of every tabbed facet, in tab order — the discoverable surface a
323 /// host (or a robot-UI control channel) enumerates to know which tabs exist.
324 pub fn titles(&self) -> Vec<&str> {
325 self.facets.iter().map(|f| f.title()).collect()
326 }
327
328 /// Make the facet titled `title` the active tab — the programmatic (headless,
329 /// robot-addressable) equivalent of clicking its tab header. Returns `true` if a
330 /// facet with that title exists (and is now active), `false` otherwise. This is
331 /// the named boundary a control channel switches tabs through (the deck analogue
332 /// of the viz's `Tab::from_name`), so a driver needn't replay a pointer click.
333 pub fn set_active_by_title(&mut self, title: &str) -> bool {
334 match self.facets.iter().position(|f| f.title() == title) {
335 Some(i) => {
336 self.active = i;
337 true
338 }
339 None => false,
340 }
341 }
342
343 /// Typed mutable access to the facet with `title`, downcast to `T` — `None` if
344 /// no such facet, or it doesn't opt into [`Facet::as_any_mut`], or the type
345 /// mismatches. Lets a host drive a concrete component's own API (e.g. a
346 /// robot-UI control forwarding a node selection to a `SystemChart`).
347 pub fn facet_mut<T: std::any::Any>(&mut self, title: &str) -> Option<&mut T> {
348 self.facets
349 .iter_mut()
350 .find(|f| f.title() == title)
351 .and_then(|f| f.as_any_mut())
352 .and_then(|a| a.downcast_mut::<T>())
353 }
354
355 /// Replace the facet whose `title` matches with `facet` (the box's own title is
356 /// what the deck enumerates afterwards). Returns `true` if a facet was replaced.
357 /// Used by hosts that **reload** a tab's data in place — e.g. the OSM region
358 /// picker rebuilds the `OSM 2D` / `OSM 3D` views from a freshly clipped region
359 /// and swaps them in, keeping the same tab slots (and the active selection).
360 pub fn replace_facet(&mut self, title: &str, facet: Box<dyn Facet>) -> bool {
361 if let Some(slot) = self.facets.iter_mut().find(|f| f.title() == title) {
362 *slot = facet;
363 true
364 } else {
365 false
366 }
367 }
368
369 // ── opt-in effects + theming (see deckfx.rs) ─────────────────────────────
370
371 /// Enable deck effects up front (builder form of [`fx_mut`](Self::fx_mut)).
372 pub fn with_fx(mut self, fx: DeckFx) -> Self {
373 self.fx = fx;
374 self
375 }
376 /// The current deck-effects config (read-only).
377 pub fn fx(&self) -> &DeckFx {
378 &self.fx
379 }
380 /// Mutate the deck-effects config (toggle glow, pin a palette, …).
381 pub fn fx_mut(&mut self) -> &mut DeckFx {
382 &mut self.fx
383 }
384 /// Override the deck theme with palette index `i` (wraps); enables the
385 /// override. Convenience over `fx_mut().set_palette(i)`.
386 pub fn set_palette(&mut self, i: usize) {
387 self.fx.set_palette(i);
388 }
389 /// Advance to the next palette in [`Theme::ALL`] (wrapping); returns the new
390 /// index. Convenience over `fx_mut().cycle_palette()`.
391 pub fn cycle_palette(&mut self) -> usize {
392 self.fx.cycle_palette()
393 }
394
395 /// **Summon the raven** to perch on `target` — any rect a facet/host hands us
396 /// (a table row, a node, a header). Replaces any raven already in flight. The
397 /// body is tinted from the deck's current palette (or the host theme). Logs an
398 /// activity trail entry. Drive/paint happens automatically inside
399 /// [`ui`](Self::ui).
400 pub fn send_raven(&mut self, target: Rect) {
401 let theme = self.effective_theme();
402 self.raven = Some(DeckRaven::new(target, &theme));
403 harness::trail(
404 harness::Kind::Render,
405 format!("raven launched → perch ({:.0},{:.0})", target.center().x, target.top()),
406 );
407 }
408 /// True while a raven is present (flying or perched).
409 pub fn has_raven(&self) -> bool {
410 self.raven.is_some()
411 }
412 /// True once the summoned raven has landed (false if none).
413 pub fn raven_perched(&self) -> bool {
414 self.raven.as_ref().map(|r| r.is_perched()).unwrap_or(false)
415 }
416 /// Dismiss any raven.
417 pub fn clear_raven(&mut self) {
418 self.raven = None;
419 }
420
421 /// The theme the deck paints with: the fx palette override if set, else
422 /// [`Theme::default`] (the host's own `set_theme` still applies its visuals;
423 /// this is just the colour source for deck-owned effects/picker).
424 fn effective_theme(&self) -> Theme {
425 self.fx.theme().unwrap_or_default()
426 }
427
428 /// Draw a one-line **palette picker** — a switcher over [`Theme::ALL`] the
429 /// host can place anywhere (toolbar, menu). Selecting a palette pins the fx
430 /// override; `ui()` then applies it each frame. Returns the chosen index if it
431 /// changed this frame.
432 ///
433 /// The override stays **off until the user actually clicks** a palette: merely
434 /// drawing the picker must not pin index 0, otherwise a host that drives its
435 /// own theme (e.g. the rich [`crate::look::Theme`]) would be silently clobbered
436 /// every frame by the legacy `set_theme` in [`ui`](Self::ui) — size still
437 /// changing (spacing) but colour frozen on `Theme::ALL[0]`. So we only pin when
438 /// the selection genuinely changed this frame.
439 pub fn palette_picker(&mut self, ui: &mut Ui) -> Option<usize> {
440 let mut sel = self.fx.palette().unwrap_or(0);
441 let before = sel;
442 ui.horizontal_wrapped(|ui| {
443 ui.label("Palette:");
444 for (i, ctor) in Theme::ALL.iter().enumerate() {
445 ui.selectable_value(&mut sel, i, ctor().name);
446 }
447 });
448 if sel != before {
449 self.fx.set_palette(sel);
450 }
451 (sel != before).then_some(sel)
452 }
453
454 /// The capabilities of the currently-active facet (or `NONE` if empty).
455 pub fn active_caps(&self) -> FacetCaps {
456 self.facets.get(self.active).map(|f| f.caps()).unwrap_or(FacetCaps::NONE)
457 }
458
459 /// The active facet's current scale (1.0 if none / not scalable).
460 fn active_scale(&self) -> f32 {
461 self.facets.get(self.active).map(|f| f.scale()).unwrap_or(1.0)
462 }
463
464 /// Multiply the active facet's scale by `k`, clamped to a sane range.
465 fn scale_active(&mut self, k: f32) {
466 if let Some(f) = self.facets.get_mut(self.active) {
467 let s = (f.scale() * k).clamp(0.25, 4.0);
468 f.set_scale(s);
469 }
470 }
471
472 /// Reset the active facet's scale to native.
473 fn reset_scale(&mut self) {
474 if let Some(f) = self.facets.get_mut(self.active) {
475 f.set_scale(1.0);
476 }
477 }
478
479 /// Draw the tab bar + capability toolbar + the active facet, and route
480 /// capability-gated shortcuts (Ctrl-+/-/0 for scale; Ctrl-C/X/V for clipboard).
481 pub fn ui(&mut self, ui: &mut Ui) {
482 // Opt-in palette override: apply the chosen Theme::ALL palette + its
483 // egui Visuals each frame so the whole deck (and every facet that reads
484 // `theme(ui)`) follows. No override → the host's own theme stays.
485 if let Some(theme) = self.fx.theme() {
486 set_theme(ui.ctx(), theme);
487 }
488
489 let titles: Vec<String> = self.facets.iter().map(|f| f.title().to_string()).collect();
490 // Wrap the tab bar: with many facets a single non-wrapping row overflows
491 // the panel width and the trailing tabs become unreachable (off-screen,
492 // unclickable for a robot driver / pointer). Wrapping keeps every tab
493 // visible + clickable no matter how many facets the deck holds.
494 ui.horizontal_wrapped(|ui| {
495 for (i, t) in titles.iter().enumerate() {
496 ui.selectable_value(&mut self.active, i, t);
497 }
498 });
499
500 let caps = self.active_caps();
501
502 // Capability-driven toolbar: only show controls the active facet honors.
503 if caps.scalable {
504 ui.horizontal(|ui| {
505 if ui.button("−").on_hover_text("Zoom out (Ctrl-−)").clicked() {
506 self.scale_active(1.0 / 1.1);
507 }
508 ui.label(format!("{:.0}%", self.active_scale() * 100.0));
509 if ui.button("+").on_hover_text("Zoom in (Ctrl-+)").clicked() {
510 self.scale_active(1.1);
511 }
512 if ui.button("Reset").on_hover_text("Reset zoom (Ctrl-0)").clicked() {
513 self.reset_scale();
514 }
515 });
516 }
517
518 // Capability-gated scale shortcuts. egui has no semantic event for these,
519 // so we hand-detect the key combos (clipboard uses semantic events below).
520 if caps.scalable {
521 let (cmd, plus, minus, zero) = ui.input(|i| {
522 (
523 i.modifiers.command,
524 i.key_pressed(egui::Key::Plus) || i.key_pressed(egui::Key::Equals),
525 i.key_pressed(egui::Key::Minus),
526 i.key_pressed(egui::Key::Num0),
527 )
528 });
529 if cmd {
530 if plus {
531 self.scale_active(1.1);
532 }
533 if minus {
534 self.scale_active(1.0 / 1.1);
535 }
536 if zero {
537 self.reset_scale();
538 }
539 }
540 }
541
542 // Clipboard routing: drain semantic events and dispatch to the active
543 // facet, gated by its caps. A focused TextEdit already consumed its own.
544 self.route_clipboard(ui.ctx());
545
546 // Cross-instance component clone (DISTINCT gesture): the Ctrl+Shift+C/V
547 // accelerators + any pending envelope paste, drained the same frame.
548 self.route_component_clipboard(ui);
549
550 ui.separator();
551 // Render the active facet, capturing the rect it occupied so the deck can
552 // bloom it (opt-in glow) without the facet knowing.
553 let content = ui.scope(|ui| {
554 if let Some(f) = self.facets.get_mut(self.active) {
555 // Render-trace: this facet's `ui()` RAN this frame (the wasm-safe
556 // "what ran" ledger — folded into state_json, read via the JS hook
557 // on wasm). Keyed by tab title so the ran-list maps tab → ran?.
558 runtrace::ran(&format!("deck.render:{}", f.title()));
559 f.ui(ui);
560 }
561 });
562 let content_rect = content.response.rect;
563
564 // Right-click the active facet body → the Copy/Paste-component menu (the
565 // discoverable affordance for the cross-instance clone gesture). The
566 // text clipboard's own copy/paste is unaffected (different gesture).
567 if !self.active_kind().is_empty() {
568 content.response.context_menu(|ui| self.component_menu(ui));
569 }
570
571 // Opt-in glow on the active facet's content rect, pulsing.
572 if self.fx.glow && content_rect.is_positive() {
573 let theme = self.effective_theme();
574 let time = ui.input(|i| i.time);
575 let painter = ui.painter_at(content_rect);
576 deckfx::paint_active_glow(&painter, content_rect.shrink(2.0), &theme, &self.fx, time);
577 ui.ctx().request_repaint(); // keep the pulse animating
578 }
579
580 // Drive + paint a summoned raven on a foreground layer above everything.
581 self.drive_raven(ui.ctx());
582
583 // Paint the component-clone toast (mismatch / rejection feedback) on top.
584 self.paint_component_toast(ui);
585 }
586
587 /// Advance + paint the summoned raven (if any) on a foreground layer. Pins its
588 /// launch time on the first frame and keeps repainting while it flies.
589 fn drive_raven(&mut self, ctx: &egui::Context) {
590 let Some(raven) = self.raven.as_mut() else { return };
591 raven.sprite.update(ctx);
592 let painter =
593 ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("facett_deck_raven")));
594 raven.sprite.paint(&painter);
595 }
596
597 /// Route this frame's clipboard events to the active facet, gated by caps.
598 /// The single OS-touching write (`clipboard::put`) lives here.
599 fn route_clipboard(&mut self, ctx: &egui::Context) {
600 let caps = self.active_caps();
601 if !(caps.copyable || caps.cuttable || caps.pasteable) {
602 return;
603 }
604 for action in clipboard::poll(ctx) {
605 let Some(f) = self.facets.get_mut(self.active) else { continue };
606 match action {
607 ClipAction::Copy if caps.copyable => {
608 if let Some(t) = f.copy() {
609 clipboard::put(ctx, t);
610 }
611 }
612 ClipAction::Cut if caps.cuttable => {
613 if let Some(t) = f.cut() {
614 clipboard::put(ctx, t);
615 }
616 }
617 ClipAction::Paste(s) if caps.pasteable => {
618 f.paste(&s);
619 }
620 // Capability not declared → ignore (event may belong to a focused
621 // sub-widget egui already handled).
622 _ => {}
623 }
624 }
625 }
626
627 // ── cross-instance component clone (Copy/Paste component) ────────────────
628 //
629 // A DISTINCT gesture from the text clipboard above: it transfers a facet's
630 // type-tagged PORTABLE state (not a text selection) to a same-kind sibling.
631 // See `.nornir/design/copy-paste-between-instances.md`. Surfaced two ways —
632 // the context menu in `component_menu` (right-click the body) and the
633 // `Ctrl+Shift+C / Ctrl+Shift+V` accelerators routed in `ui`.
634
635 /// The active facet's [`Facet::kind`] (`""` if empty / opted out).
636 pub fn active_kind(&self) -> &'static str {
637 self.facets.get(self.active).map(|f| f.kind()).unwrap_or("")
638 }
639
640 /// **Copy component** — encode the active facet's [`Facet::portable_state`]
641 /// into the tagged clipboard envelope and place it on the OS clipboard.
642 /// Returns the envelope text on success, or `None` if the active facet opts
643 /// out (empty `kind()` or no `portable_state()`). This is the data half the
644 /// gesture handlers + tests drive; the OS write is the caller's via
645 /// [`clipboard::put`] (done for them in [`copy_component`](Self::copy_component)).
646 pub fn copy_component_envelope(&self) -> Option<String> {
647 let f = self.facets.get(self.active)?;
648 let kind = f.kind();
649 if kind.is_empty() {
650 return None;
651 }
652 let state = f.portable_state()?;
653 Some(clipboard::encode_component(kind, &state))
654 }
655
656 /// Copy the active facet's portable state to the OS clipboard (the full
657 /// gesture). Returns `true` if something was copied.
658 pub fn copy_component(&mut self, ctx: &egui::Context) -> bool {
659 match self.copy_component_envelope() {
660 Some(env) => {
661 clipboard::put(ctx, env);
662 true
663 }
664 None => false,
665 }
666 }
667
668 /// **Paste component** — decode a clipboard `text` envelope and, **only if its
669 /// kind matches the active facet's** [`Facet::kind`], hand the state to
670 /// [`Facet::load_state`]. Returns `true` if the active facet adopted it.
671 /// A kind mismatch (or a non-envelope / wrong-version text) is a no-op that
672 /// raises a themed mismatch [`toast`](Self::toast) — the type-match guard is
673 /// the whole point: a `table` envelope NEVER loads into a `graphpan`.
674 pub fn paste_component(&mut self, text: &str, now: f64) -> bool {
675 let Some((kind, state)) = clipboard::decode_component(text) else {
676 // Not a component envelope at all — leave it for the text path; no toast.
677 return false;
678 };
679 let active_kind = self.active_kind();
680 if active_kind.is_empty() {
681 self.toast = Some(("this view doesn't accept a pasted component".to_string(), now));
682 return false;
683 }
684 if kind != active_kind {
685 self.toast = Some((format!("clipboard holds a `{kind}`, not a `{active_kind}`"), now));
686 return false;
687 }
688 let Some(f) = self.facets.get_mut(self.active) else { return false };
689 let accepted = f.load_state(&state);
690 if !accepted {
691 self.toast = Some((format!("this `{active_kind}` could not adopt the clipboard state"), now));
692 }
693 accepted
694 }
695
696 /// The current component-clone toast message (if one is live), for tests /
697 /// hosts that want to surface it themselves.
698 pub fn component_toast(&self) -> Option<&str> {
699 self.toast.as_ref().map(|(m, _)| m.as_str())
700 }
701
702 /// Right-click context-menu entries for the cross-instance clone gesture —
703 /// **Copy component** / **Paste component** — themed by the active style. A
704 /// host attaches these to the facet body (or its tab) via
705 /// `response.context_menu(|ui| deck.component_menu(ui))`. Greys out when the
706 /// active facet opts out (empty `kind()`).
707 pub fn component_menu(&mut self, ui: &mut egui::Ui) {
708 let km = look::keymap(ui);
709 let kind = self.active_kind();
710 let can_clone = !kind.is_empty();
711 ui.add_enabled_ui(can_clone && self.copy_component_envelope().is_some(), |ui| {
712 let label = format!("Copy component {}", km.label(Action::Copy, ui.ctx()));
713 if ui.button(label).clicked() {
714 self.copy_component(ui.ctx());
715 ui.close();
716 }
717 });
718 ui.add_enabled_ui(can_clone, |ui| {
719 let label = format!("Paste component {}", km.label(Action::Paste, ui.ctx()));
720 if ui.button(label).clicked() {
721 // Pull the OS clipboard via egui's paste request; the actual text
722 // arrives next frame as an Event::Paste, routed in `route_component_clipboard`.
723 ui.ctx().send_viewport_cmd(egui::ViewportCommand::RequestPaste);
724 ui.close();
725 }
726 });
727 }
728
729 /// Route the `Ctrl+Shift+C / Ctrl+Shift+V` component-clone accelerators +
730 /// drain any pending paste envelope. Called once per frame from [`ui`](Self::ui),
731 /// AFTER the text clipboard so a focused TextEdit's plain Ctrl+C/V is untouched
732 /// (the Shift discriminates this gesture from text copy/paste).
733 fn route_component_clipboard(&mut self, ui: &mut egui::Ui) {
734 let now = ui.input(|i| i.time);
735 // Accelerators: Ctrl+Shift+C copies; Ctrl+Shift+V triggers an OS-clipboard
736 // paste request (the text lands next frame as Event::Paste, decoded below).
737 let copy_shift = egui::KeyboardShortcut::new(
738 egui::Modifiers::COMMAND | egui::Modifiers::SHIFT,
739 egui::Key::C,
740 );
741 let paste_shift = egui::KeyboardShortcut::new(
742 egui::Modifiers::COMMAND | egui::Modifiers::SHIFT,
743 egui::Key::V,
744 );
745 if ui.input_mut(|i| i.consume_shortcut(©_shift)) {
746 self.copy_component(ui.ctx());
747 }
748 if ui.input_mut(|i| i.consume_shortcut(&paste_shift)) {
749 ui.ctx().send_viewport_cmd(egui::ViewportCommand::RequestPaste);
750 }
751 // Drain any Paste events that look like a component envelope (a plain text
752 // paste decodes to None here and is left for the text clipboard path).
753 let pastes: Vec<String> = ui.input(|i| {
754 i.events
755 .iter()
756 .filter_map(|e| match e {
757 egui::Event::Paste(s) if clipboard::decode_component(s).is_some() => Some(s.clone()),
758 _ => None,
759 })
760 .collect()
761 });
762 for text in pastes {
763 self.paste_component(&text, now);
764 }
765 // Age out the toast.
766 if let Some((_, raised)) = self.toast {
767 if now - raised > TOAST_SECS {
768 self.toast = None;
769 }
770 }
771 }
772
773 /// Paint the live component-clone toast on a foreground layer (themed, spacious),
774 /// if one is set. A no-op when there is no toast.
775 fn paint_component_toast(&self, ui: &mut egui::Ui) {
776 let Some((msg, _)) = self.toast.as_ref() else { return };
777 let th = theme(ui);
778 let ctx = ui.ctx();
779 let painter =
780 ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("facett_deck_component_toast")));
781 let screen = ctx.content_rect();
782 let font = FontId::proportional(14.0);
783 let galley = painter.layout_no_wrap(msg.clone(), font.clone(), th.text);
784 let pad = vec2(14.0, 10.0); // spacious preset padding
785 let size = galley.size() + pad * 2.0;
786 // Bottom-centre, lifted off the edge.
787 let center = Pos2::new(screen.center().x, screen.max.y - size.y * 0.5 - 18.0);
788 let rect = Rect::from_center_size(center, size);
789 painter.rect_filled(rect, 8.0, th.panel_bg);
790 painter.rect_stroke(rect, 8.0, Stroke::new(1.0, th.panel_stroke), egui::StrokeKind::Inside);
791 painter.galley(rect.min + pad, galley, th.text);
792 ctx.request_repaint(); // keep ticking so the toast ages out on time
793 }
794
795 /// The whole-app observable state: the active facet + each facet's
796 /// `state_json`, plus an **additive** sibling `caps` map (title → caps JSON)
797 /// so the existing flat `facets[title]` shape is unchanged for consumers.
798 pub fn state_json(&self) -> serde_json::Value {
799 let mut facets = serde_json::Map::new();
800 let mut caps = serde_json::Map::new();
801 for f in &self.facets {
802 facets.insert(f.title().to_string(), f.state_json());
803 caps.insert(f.title().to_string(), f.caps().to_json());
804 }
805 serde_json::json!({
806 "active": self.facets.get(self.active).map(|f| f.title()),
807 "facets": facets,
808 "caps": caps,
809 // The wasm-safe "what RAN" ledger — every facet render + every traced
810 // control handler that has executed this session (the readable proof
811 // the shipped artifact actually ran each surface). See `runtrace`.
812 "trace": { "ran": runtrace::snapshot(), "distinct": runtrace::distinct() },
813 })
814 }
815}
816
817/// A stable, bright-ish colour from a string (FNV-1a). Handy default node colour.
818pub fn hash_color(s: &str) -> Color32 {
819 let mut h: u32 = 2166136261;
820 for b in s.bytes() {
821 h = (h ^ b as u32).wrapping_mul(16777619);
822 }
823 Color32::from_rgb((h & 0xFF) as u8 | 0x60, ((h >> 8) & 0xFF) as u8 | 0x60, ((h >> 16) & 0xFF) as u8 | 0x60)
824}
825
826#[cfg(test)]
827mod tests {
828 use super::*;
829
830 #[test]
831 fn scene_builds() {
832 let mut s = Scene::new();
833 let a = s.node("Person", hash_color("Person"));
834 let b = s.node("Company", hash_color("Company"));
835 s.edge(a, b);
836 assert_eq!(s.nodes.len(), 2);
837 assert_eq!(s.edges.len(), 1);
838 assert!(!s.is_empty());
839 }
840
841 #[test]
842 fn force_layout_produces_finite_bounded_positions() {
843 let mut scene = Scene::new();
844 for i in 0..12 { scene.node(format!("n{i}"), hash_color("n")); }
845 for i in 0..12 { scene.edge(i, (i + 1) % 12); }
846 let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(400.0, 400.0));
847 let pos = positions(Layout::Force, &scene, rect);
848 assert_eq!(pos.len(), 12);
849 for p in &pos {
850 assert!(p.x.is_finite() && p.y.is_finite(), "finite");
851 assert!(rect.expand(50.0).contains(*p), "roughly within the rect");
852 }
853 }
854
855 #[test]
856 fn hash_color_is_stable() {
857 assert_eq!(hash_color("Person"), hash_color("Person"));
858 assert_ne!(hash_color("Person"), hash_color("Company"));
859 }
860
861 /// A minimal facet for deck tests.
862 struct Stub(&'static str);
863 impl Facet for Stub {
864 fn title(&self) -> &str {
865 self.0
866 }
867 fn ui(&mut self, ui: &mut Ui) {
868 ui.label(self.0);
869 }
870 fn state_json(&self) -> serde_json::Value {
871 serde_json::json!({ "t": self.0 })
872 }
873 }
874
875 /// A component-clone-capable stub: a `kind`, a JSON `payload` it round-trips
876 /// through `portable_state`/`load_state`.
877 struct CloneStub {
878 kind: &'static str,
879 payload: serde_json::Value,
880 }
881 impl Facet for CloneStub {
882 fn title(&self) -> &str {
883 self.kind
884 }
885 fn ui(&mut self, _ui: &mut Ui) {}
886 fn state_json(&self) -> serde_json::Value {
887 serde_json::json!({ "kind": self.kind })
888 }
889 fn kind(&self) -> &'static str {
890 self.kind
891 }
892 fn portable_state(&self) -> Option<serde_json::Value> {
893 Some(self.payload.clone())
894 }
895 fn load_state(&mut self, state: &serde_json::Value) -> bool {
896 self.payload = state.clone();
897 true
898 }
899 }
900
901 #[test]
902 fn component_clone_round_trips_through_the_deck() {
903 // Instance A (configured) → envelope → instance B adopts it.
904 let a = CloneStub { kind: "graphpan", payload: serde_json::json!({ "zoom": 2.0, "pan": [3, 4] }) };
905 let mut deck_a = FacetDeck::new(vec![Box::new(a)]);
906 let env = deck_a.copy_component_envelope().expect("A copies its portable state");
907
908 let mut deck_b = FacetDeck::new(vec![Box::new(CloneStub {
909 kind: "graphpan",
910 payload: serde_json::json!({ "zoom": 1.0, "pan": [0, 0] }),
911 })]);
912 assert!(deck_b.paste_component(&env, 0.0), "same-kind paste is accepted");
913 // B now equals A's portable state.
914 assert_eq!(
915 deck_b.copy_component_envelope(),
916 deck_a.copy_component_envelope(),
917 "B adopted A's portable state exactly"
918 );
919 assert!(deck_b.component_toast().is_none(), "a successful paste raises no toast");
920 }
921
922 #[test]
923 fn component_clone_rejects_a_type_mismatch_with_a_toast() {
924 // A `table` envelope handed to a `graphpan` → load_state NOT called.
925 let table_env = clipboard::encode_component("table", &serde_json::json!({ "rows": 3 }));
926 let mut deck = FacetDeck::new(vec![Box::new(CloneStub {
927 kind: "graphpan",
928 payload: serde_json::json!({ "zoom": 1.0 }),
929 })]);
930 let before = deck.copy_component_envelope();
931 assert!(!deck.paste_component(&table_env, 0.0), "cross-type paste returns false");
932 assert_eq!(deck.copy_component_envelope(), before, "graphpan state untouched");
933 let toast = deck.component_toast().expect("mismatch raises a toast");
934 assert!(toast.contains("table") && toast.contains("graphpan"), "toast names both kinds: {toast}");
935 }
936
937 #[test]
938 fn component_clone_version_guard_rejects_unknown_v() {
939 let bad = serde_json::json!({ "facett.kind": "graphpan", "v": 7, "state": { "zoom": 9.0 } }).to_string();
940 let mut deck = FacetDeck::new(vec![Box::new(CloneStub {
941 kind: "graphpan",
942 payload: serde_json::json!({ "zoom": 1.0 }),
943 })]);
944 let before = deck.copy_component_envelope();
945 // An unknown-version text decodes to None → it's a no-op (left for the text path).
946 assert!(!deck.paste_component(&bad, 0.0), "unknown version is not adopted");
947 assert_eq!(deck.copy_component_envelope(), before, "state untouched by a bad-version paste");
948 }
949
950 #[test]
951 fn component_clone_opt_out_floor_neither_copies_nor_accepts() {
952 // The plain Stub does NOT implement the trio → empty kind, no copy.
953 let mut deck = FacetDeck::new(vec![Box::new(Stub("plain"))]);
954 assert_eq!(deck.active_kind(), "", "opted out");
955 assert!(deck.copy_component_envelope().is_none(), "opt-out facet never copies a component");
956 // A real envelope handed to an opt-out facet is refused (no panic, no state).
957 let env = clipboard::encode_component("table", &serde_json::json!({ "rows": 1 }));
958 assert!(!deck.paste_component(&env, 0.0), "opt-out facet never adopts a component");
959 assert!(deck.component_toast().is_some(), "the refusal is surfaced");
960 }
961
962 #[test]
963 fn deck_fx_is_off_by_default() {
964 let deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
965 assert_eq!(*deck.fx(), DeckFx::OFF, "no effects until the host opts in");
966 assert!(!deck.has_raven());
967 assert!(!deck.fx().glow);
968 assert!(deck.fx().palette().is_none());
969 }
970
971 #[test]
972 fn deck_cycle_palette_walks_theme_all() {
973 let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
974 let first = deck.cycle_palette();
975 assert_eq!(first, 0);
976 assert_eq!(deck.fx().theme().map(|t| t.name), Some(Theme::ALL[0]().name));
977 // walks forward and wraps
978 for _ in 1..Theme::ALL.len() {
979 deck.cycle_palette();
980 }
981 assert_eq!(deck.cycle_palette(), 0, "wraps back to the first palette");
982 }
983
984 #[test]
985 fn deck_send_raven_launches_and_perches_after_a_full_flight() {
986 use crate::effects::RAVEN_FLIGHT_SECS;
987 let mut deck = FacetDeck::new(vec![Box::new(Stub("rows"))]);
988 assert!(!deck.has_raven());
989 let target = egui::Rect::from_min_size(egui::pos2(120.0, 80.0), egui::vec2(200.0, 28.0));
990 deck.send_raven(target);
991 assert!(deck.has_raven(), "raven summoned");
992 assert!(!deck.raven_perched(), "not perched at launch");
993
994 // Drive the sprite headlessly past the flight duration → it perches.
995 if let Some(r) = deck.raven.as_mut() {
996 r.sprite.advance(RAVEN_FLIGHT_SECS + 0.1);
997 }
998 assert!(deck.raven_perched(), "perched after the flight duration");
999
1000 deck.clear_raven();
1001 assert!(!deck.has_raven());
1002 }
1003
1004 /// REGRESSION (inject-assert): merely *drawing* the palette picker without a
1005 /// user click must NOT pin a palette override. The bug: the picker auto-pinned
1006 /// index 0 on the first passive frame, turning the legacy `set_theme` override
1007 /// permanently on and clobbering a host's own theme (the rich `look::Theme`)
1008 /// every frame. We render one frame with no interaction and assert the override
1009 /// is still `None` (host theme wins).
1010 #[test]
1011 fn palette_picker_does_not_pin_without_a_user_click() {
1012 let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
1013 assert!(deck.fx().palette().is_none(), "starts with no override");
1014 let ctx = egui::Context::default();
1015 let mut chosen = Some(7usize);
1016 let _ = ctx.run(egui::RawInput::default(), |ctx| {
1017 egui::CentralPanel::default().show(ctx, |ui| {
1018 // No synthetic click is fed → the picker is drawn but not used.
1019 chosen = deck.palette_picker(ui);
1020 });
1021 });
1022 assert_eq!(chosen, None, "drawing the picker reports no selection without a click");
1023 assert!(
1024 deck.fx().palette().is_none(),
1025 "drawing the picker must not pin index 0 — that would clobber the host's own theme each frame"
1026 );
1027 }
1028
1029 #[test]
1030 fn deck_palette_override_applies_theme_in_a_ui_pass() {
1031 let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
1032 deck.set_palette(1); // sci-fi
1033 let ctx = egui::Context::default();
1034 let mut seen = "";
1035 let _ = ctx.run(egui::RawInput::default(), |ctx| {
1036 egui::CentralPanel::default().show(ctx, |ui| {
1037 deck.ui(ui);
1038 seen = theme(ui).name;
1039 });
1040 });
1041 assert_eq!(seen, Theme::ALL[1]().name, "deck applied its palette override");
1042 }
1043}