Skip to main content

facett_core/
lib.rs

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