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 clip;
12pub mod clipboard;
13pub mod deckfx;
14pub mod edges;
15pub mod effects;
16pub mod focus;
17pub mod labels3d;
18pub mod harness;
19pub mod look;
20pub mod nav;
21pub mod overlay;
22pub mod rabbit;
23/// The L0 shared render kernel (CONS-CORE) — shared `Camera`, z-ordered
24/// `LayerStack`, the CPU rect scissor, and (feature `wgpu`) the extracted GPU
25/// scaffold. Map skins + `facett-graphview` draw through this.
26pub mod render;
27pub mod scroll_engine;
28pub mod testmatrix; // functional-status → nornir test-matrix bridge (feature
29                    // `testmatrix`); no-op in release.
30pub mod theme;
31pub mod trace; // structured IN/OUT/END event stream ($FACETT_TRACE) — the
32               // machine-readable data a facet actually rendered.
33pub use a11y::{Semantics, node as a11y_node, stable_id};
34pub use caps::FacetCaps;
35pub use clip::{ArrowColumnRef, ClipKind, ClipPayload, CopySource, PasteTarget};
36pub use clipboard::ClipAction;
37pub use deckfx::{DeckFx, DeckRaven};
38pub use look::{Action, KeyMap, Palette};
39pub use nav::{Dir4, Navigable, nearest_in_direction};
40pub use rabbit::{Rabbit, RabbitMesh, rabbit_mesh, rabbit_outline};
41pub use scroll_engine::SmoothScroll;
42pub use theme::{Theme, set_theme, theme};
43
44// The rich look-&-feel `Theme` (the work-order architecture) is re-exported under
45// an unambiguous alias so it coexists with the legacy flat palette `Theme` above.
46pub use look::Theme as LookTheme;
47
48/// A node: a label + a colour (the *consumer* picks the colour policy — hash by
49/// label, by status, …).
50#[derive(Clone)]
51pub struct Node {
52    pub label: String,
53    pub color: Color32,
54}
55
56/// A directed edge between node indices.
57#[derive(Clone, Copy)]
58pub struct Edge {
59    pub src: usize,
60    pub dst: usize,
61}
62
63/// A drawable graph: nodes + edges (edges index into `nodes`).
64#[derive(Default, Clone)]
65pub struct Scene {
66    pub nodes: Vec<Node>,
67    pub edges: Vec<Edge>,
68}
69
70impl Scene {
71    pub fn new() -> Self {
72        Self::default()
73    }
74    /// Push a node, returning its index.
75    pub fn node(&mut self, label: impl Into<String>, color: Color32) -> usize {
76        self.nodes.push(Node { label: label.into(), color });
77        self.nodes.len() - 1
78    }
79    pub fn edge(&mut self, src: usize, dst: usize) {
80        self.edges.push(Edge { src, dst });
81    }
82    pub fn is_empty(&self) -> bool {
83        self.nodes.is_empty()
84    }
85}
86
87/// Node placement strategy.
88#[derive(Clone, Copy, PartialEq, Eq, Default)]
89pub enum Layout {
90    #[default]
91    Circular,
92    /// Deterministic Fruchterman–Reingold (edges pull, all nodes repel). O(n²)
93    /// per iteration — best for small/medium graphs.
94    Force,
95}
96
97/// Draw a `Scene` into `ui` — the reusable render primitive. Empty scenes show
98/// `empty_hint`. Labels render when the node count is small enough to read.
99pub fn draw(ui: &mut Ui, scene: &Scene, layout: Layout, empty_hint: &str) {
100    let (rect, _) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
101    let th = theme(ui);
102    let painter = ui.painter_at(rect);
103    let n = scene.nodes.len();
104    if n == 0 {
105        painter.text(rect.center(), Align2::CENTER_CENTER, empty_hint, FontId::proportional(13.0), th.text_dim);
106        return;
107    }
108    let pos = positions(layout, scene, rect);
109    for e in &scene.edges {
110        if e.src < n && e.dst < n {
111            painter.line_segment([pos[e.src], pos[e.dst]], Stroke::new(0.6, th.edge));
112        }
113    }
114    for (i, node) in scene.nodes.iter().enumerate() {
115        painter.circle_filled(pos[i], 5.0, node.color);
116    }
117    if n <= 60 {
118        for (i, node) in scene.nodes.iter().enumerate() {
119            painter.text(pos[i] + vec2(7.0, 0.0), Align2::LEFT_CENTER, &node.label, FontId::proportional(10.0), th.text);
120        }
121    }
122}
123
124fn positions(layout: Layout, scene: &Scene, rect: Rect) -> Vec<Pos2> {
125    let n = scene.nodes.len();
126    let center = rect.center();
127    let radius = rect.size().min_elem() * 0.42;
128    let circular = |i: usize| {
129        let a = std::f32::consts::TAU * (i as f32) / (n as f32);
130        vec2(a.cos(), a.sin())
131    };
132    match layout {
133        Layout::Circular => (0..n).map(|i| center + radius * circular(i)).collect(),
134        Layout::Force => {
135            // Deterministic Fruchterman–Reingold from a circular seed (unit space).
136            let mut p: Vec<egui::Vec2> = (0..n).map(circular).collect();
137            let k = (1.0 / (n.max(1) as f32).sqrt()).clamp(0.05, 1.0);
138            for _ in 0..120 {
139                let mut disp = vec![egui::Vec2::ZERO; n];
140                for i in 0..n {
141                    for j in (i + 1)..n {
142                        let d = p[i] - p[j];
143                        let dist = d.length().max(1e-3);
144                        let f = k * k / dist;
145                        let dir = d / dist;
146                        disp[i] += dir * f;
147                        disp[j] -= dir * f;
148                    }
149                }
150                for e in &scene.edges {
151                    if e.src < n && e.dst < n {
152                        let d = p[e.src] - p[e.dst];
153                        let dist = d.length().max(1e-3);
154                        let f = dist * dist / k;
155                        let dir = d / dist;
156                        disp[e.src] -= dir * f;
157                        disp[e.dst] += dir * f;
158                    }
159                }
160                for i in 0..n {
161                    let dl = disp[i].length().max(1e-3);
162                    p[i] += disp[i] / dl * dl.min(0.04); // capped step (cooling-free, deterministic)
163                }
164            }
165            // Normalise to fit the rect.
166            let (mut mn, mut mx) = (egui::vec2(f32::MAX, f32::MAX), egui::vec2(f32::MIN, f32::MIN));
167            for v in &p {
168                mn.x = mn.x.min(v.x);
169                mn.y = mn.y.min(v.y);
170                mx.x = mx.x.max(v.x);
171                mx.y = mx.y.max(v.y);
172            }
173            let span = (mx - mn).max(egui::vec2(1e-3, 1e-3));
174            p.iter()
175                .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))
176                .collect()
177        }
178    }
179}
180
181/// The facett **component contract**. Every facet — graph, map, pipeline, table,
182/// the ported nornir viewers — implements this, so consumers (korp, nornir, …)
183/// compose them uniformly *and* get headless robot-testing for free.
184///
185/// The three things a component owes its host:
186/// 1. a **title** (tab label / panel heading),
187/// 2. how to **draw** itself into egui,
188/// 3. its **observable state** as JSON — dumped to `$APP_STATE` for headless
189///    assertions. **Rule:** every visible list/status/count goes in `state_json`.
190pub trait Facet {
191    fn title(&self) -> &str;
192    fn ui(&mut self, ui: &mut Ui);
193    fn state_json(&self) -> serde_json::Value;
194
195    // --- uniform capability surface (all defaulted; see caps.rs / clipboard.rs) ---
196
197    /// What this facet can do. Override to opt into capabilities.
198    fn caps(&self) -> FacetCaps {
199        FacetCaps::NONE
200    }
201
202    /// Current uniform scale (1.0 = native). Override if `caps().scalable`.
203    fn scale(&self) -> f32 {
204        1.0
205    }
206    /// Set the uniform scale; clamp internally. Default no-op (not scalable).
207    fn set_scale(&mut self, _scale: f32) {}
208
209    /// The current selection as JSON (also folded into `state_json` by
210    /// convention). `Null` when nothing/none selectable.
211    fn selection_json(&self) -> serde_json::Value {
212        serde_json::Value::Null
213    }
214
215    /// Clipboard hooks — see clipboard.rs. Defaults: nothing to give/take.
216    /// Returns the text to place on the clipboard (None = nothing copyable now).
217    fn copy(&mut self) -> Option<String> {
218        None
219    }
220    /// Like `copy`, but also removes the selection. Default delegates to `copy`.
221    fn cut(&mut self) -> Option<String> {
222        self.copy()
223    }
224    /// Accept pasted text. Returns true if consumed.
225    fn paste(&mut self, _text: &str) -> bool {
226        false
227    }
228
229    /// Optional downcast handle for hosts that need typed access to a specific
230    /// facet living inside a [`FacetDeck`] (e.g. a robot-UI driver clicking an
231    /// app-level control that must forward to a concrete component's own API).
232    /// Defaulted to `None` so no existing facet has to change; a component opts in
233    /// by returning `Some(self)`.
234    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
235        None
236    }
237}
238
239/// A tabbed set of [`Facet`]s — the reusable multi-component shell. Draws a tab
240/// bar + the active facet, and composes **every** facet's `state_json` under its
241/// title, so the whole-app introspection contract is free. korp/nornir can build
242/// their window from a `FacetDeck` instead of hand-rolling tabs + the state dump.
243pub struct FacetDeck {
244    facets: Vec<Box<dyn Facet>>,
245    active: usize,
246    /// Opt-in deck effects (palette override + glow). `Default` = all off, so a
247    /// deck that never opts in is unchanged and pays nothing.
248    fx: DeckFx,
249    /// A raven summoned through the deck, in flight or perched (or `None`).
250    raven: Option<DeckRaven>,
251}
252
253impl FacetDeck {
254    pub fn new(facets: Vec<Box<dyn Facet>>) -> Self {
255        Self { facets, active: 0, fx: DeckFx::OFF, raven: None }
256    }
257    pub fn active(&self) -> usize {
258        self.active
259    }
260
261    /// The title of the currently-active facet (the deck's `state_json["active"]`),
262    /// or `None` if the deck is empty.
263    pub fn active_title(&self) -> Option<&str> {
264        self.facets.get(self.active).map(|f| f.title())
265    }
266
267    /// The titles of every tabbed facet, in tab order — the discoverable surface a
268    /// host (or a robot-UI control channel) enumerates to know which tabs exist.
269    pub fn titles(&self) -> Vec<&str> {
270        self.facets.iter().map(|f| f.title()).collect()
271    }
272
273    /// Make the facet titled `title` the active tab — the programmatic (headless,
274    /// robot-addressable) equivalent of clicking its tab header. Returns `true` if a
275    /// facet with that title exists (and is now active), `false` otherwise. This is
276    /// the named boundary a control channel switches tabs through (the deck analogue
277    /// of the viz's `Tab::from_name`), so a driver needn't replay a pointer click.
278    pub fn set_active_by_title(&mut self, title: &str) -> bool {
279        match self.facets.iter().position(|f| f.title() == title) {
280            Some(i) => {
281                self.active = i;
282                true
283            }
284            None => false,
285        }
286    }
287
288    /// Typed mutable access to the facet with `title`, downcast to `T` — `None` if
289    /// no such facet, or it doesn't opt into [`Facet::as_any_mut`], or the type
290    /// mismatches. Lets a host drive a concrete component's own API (e.g. a
291    /// robot-UI control forwarding a node selection to a `SystemChart`).
292    pub fn facet_mut<T: std::any::Any>(&mut self, title: &str) -> Option<&mut T> {
293        self.facets
294            .iter_mut()
295            .find(|f| f.title() == title)
296            .and_then(|f| f.as_any_mut())
297            .and_then(|a| a.downcast_mut::<T>())
298    }
299
300    /// Replace the facet whose `title` matches with `facet` (the box's own title is
301    /// what the deck enumerates afterwards). Returns `true` if a facet was replaced.
302    /// Used by hosts that **reload** a tab's data in place — e.g. the OSM region
303    /// picker rebuilds the `OSM 2D` / `OSM 3D` views from a freshly clipped region
304    /// and swaps them in, keeping the same tab slots (and the active selection).
305    pub fn replace_facet(&mut self, title: &str, facet: Box<dyn Facet>) -> bool {
306        if let Some(slot) = self.facets.iter_mut().find(|f| f.title() == title) {
307            *slot = facet;
308            true
309        } else {
310            false
311        }
312    }
313
314    // ── opt-in effects + theming (see deckfx.rs) ─────────────────────────────
315
316    /// Enable deck effects up front (builder form of [`fx_mut`](Self::fx_mut)).
317    pub fn with_fx(mut self, fx: DeckFx) -> Self {
318        self.fx = fx;
319        self
320    }
321    /// The current deck-effects config (read-only).
322    pub fn fx(&self) -> &DeckFx {
323        &self.fx
324    }
325    /// Mutate the deck-effects config (toggle glow, pin a palette, …).
326    pub fn fx_mut(&mut self) -> &mut DeckFx {
327        &mut self.fx
328    }
329    /// Override the deck theme with palette index `i` (wraps); enables the
330    /// override. Convenience over `fx_mut().set_palette(i)`.
331    pub fn set_palette(&mut self, i: usize) {
332        self.fx.set_palette(i);
333    }
334    /// Advance to the next palette in [`Theme::ALL`] (wrapping); returns the new
335    /// index. Convenience over `fx_mut().cycle_palette()`.
336    pub fn cycle_palette(&mut self) -> usize {
337        self.fx.cycle_palette()
338    }
339
340    /// **Summon the raven** to perch on `target` — any rect a facet/host hands us
341    /// (a table row, a node, a header). Replaces any raven already in flight. The
342    /// body is tinted from the deck's current palette (or the host theme). Logs an
343    /// activity trail entry. Drive/paint happens automatically inside
344    /// [`ui`](Self::ui).
345    pub fn send_raven(&mut self, target: Rect) {
346        let theme = self.effective_theme();
347        self.raven = Some(DeckRaven::new(target, &theme));
348        harness::trail(
349            harness::Kind::Render,
350            format!("raven launched → perch ({:.0},{:.0})", target.center().x, target.top()),
351        );
352    }
353    /// True while a raven is present (flying or perched).
354    pub fn has_raven(&self) -> bool {
355        self.raven.is_some()
356    }
357    /// True once the summoned raven has landed (false if none).
358    pub fn raven_perched(&self) -> bool {
359        self.raven.as_ref().map(|r| r.is_perched()).unwrap_or(false)
360    }
361    /// Dismiss any raven.
362    pub fn clear_raven(&mut self) {
363        self.raven = None;
364    }
365
366    /// The theme the deck paints with: the fx palette override if set, else
367    /// [`Theme::default`] (the host's own `set_theme` still applies its visuals;
368    /// this is just the colour source for deck-owned effects/picker).
369    fn effective_theme(&self) -> Theme {
370        self.fx.theme().unwrap_or_default()
371    }
372
373    /// Draw a one-line **palette picker** — a switcher over [`Theme::ALL`] the
374    /// host can place anywhere (toolbar, menu). Selecting a palette pins the fx
375    /// override; `ui()` then applies it each frame. Returns the chosen index if it
376    /// changed this frame.
377    ///
378    /// The override stays **off until the user actually clicks** a palette: merely
379    /// drawing the picker must not pin index 0, otherwise a host that drives its
380    /// own theme (e.g. the rich [`crate::look::Theme`]) would be silently clobbered
381    /// every frame by the legacy `set_theme` in [`ui`](Self::ui) — size still
382    /// changing (spacing) but colour frozen on `Theme::ALL[0]`. So we only pin when
383    /// the selection genuinely changed this frame.
384    pub fn palette_picker(&mut self, ui: &mut Ui) -> Option<usize> {
385        let mut sel = self.fx.palette().unwrap_or(0);
386        let before = sel;
387        ui.horizontal_wrapped(|ui| {
388            ui.label("Palette:");
389            for (i, ctor) in Theme::ALL.iter().enumerate() {
390                ui.selectable_value(&mut sel, i, ctor().name);
391            }
392        });
393        if sel != before {
394            self.fx.set_palette(sel);
395        }
396        (sel != before).then_some(sel)
397    }
398
399    /// The capabilities of the currently-active facet (or `NONE` if empty).
400    pub fn active_caps(&self) -> FacetCaps {
401        self.facets.get(self.active).map(|f| f.caps()).unwrap_or(FacetCaps::NONE)
402    }
403
404    /// The active facet's current scale (1.0 if none / not scalable).
405    fn active_scale(&self) -> f32 {
406        self.facets.get(self.active).map(|f| f.scale()).unwrap_or(1.0)
407    }
408
409    /// Multiply the active facet's scale by `k`, clamped to a sane range.
410    fn scale_active(&mut self, k: f32) {
411        if let Some(f) = self.facets.get_mut(self.active) {
412            let s = (f.scale() * k).clamp(0.25, 4.0);
413            f.set_scale(s);
414        }
415    }
416
417    /// Reset the active facet's scale to native.
418    fn reset_scale(&mut self) {
419        if let Some(f) = self.facets.get_mut(self.active) {
420            f.set_scale(1.0);
421        }
422    }
423
424    /// Draw the tab bar + capability toolbar + the active facet, and route
425    /// capability-gated shortcuts (Ctrl-+/-/0 for scale; Ctrl-C/X/V for clipboard).
426    pub fn ui(&mut self, ui: &mut Ui) {
427        // Opt-in palette override: apply the chosen Theme::ALL palette + its
428        // egui Visuals each frame so the whole deck (and every facet that reads
429        // `theme(ui)`) follows. No override → the host's own theme stays.
430        if let Some(theme) = self.fx.theme() {
431            set_theme(ui.ctx(), theme);
432        }
433
434        let titles: Vec<String> = self.facets.iter().map(|f| f.title().to_string()).collect();
435        // Wrap the tab bar: with many facets a single non-wrapping row overflows
436        // the panel width and the trailing tabs become unreachable (off-screen,
437        // unclickable for a robot driver / pointer). Wrapping keeps every tab
438        // visible + clickable no matter how many facets the deck holds.
439        ui.horizontal_wrapped(|ui| {
440            for (i, t) in titles.iter().enumerate() {
441                ui.selectable_value(&mut self.active, i, t);
442            }
443        });
444
445        let caps = self.active_caps();
446
447        // Capability-driven toolbar: only show controls the active facet honors.
448        if caps.scalable {
449            ui.horizontal(|ui| {
450                if ui.button("−").on_hover_text("Zoom out (Ctrl-−)").clicked() {
451                    self.scale_active(1.0 / 1.1);
452                }
453                ui.label(format!("{:.0}%", self.active_scale() * 100.0));
454                if ui.button("+").on_hover_text("Zoom in (Ctrl-+)").clicked() {
455                    self.scale_active(1.1);
456                }
457                if ui.button("Reset").on_hover_text("Reset zoom (Ctrl-0)").clicked() {
458                    self.reset_scale();
459                }
460            });
461        }
462
463        // Capability-gated scale shortcuts. egui has no semantic event for these,
464        // so we hand-detect the key combos (clipboard uses semantic events below).
465        if caps.scalable {
466            let (cmd, plus, minus, zero) = ui.input(|i| {
467                (
468                    i.modifiers.command,
469                    i.key_pressed(egui::Key::Plus) || i.key_pressed(egui::Key::Equals),
470                    i.key_pressed(egui::Key::Minus),
471                    i.key_pressed(egui::Key::Num0),
472                )
473            });
474            if cmd {
475                if plus {
476                    self.scale_active(1.1);
477                }
478                if minus {
479                    self.scale_active(1.0 / 1.1);
480                }
481                if zero {
482                    self.reset_scale();
483                }
484            }
485        }
486
487        // Clipboard routing: drain semantic events and dispatch to the active
488        // facet, gated by its caps. A focused TextEdit already consumed its own.
489        self.route_clipboard(ui.ctx());
490
491        ui.separator();
492        // Render the active facet, capturing the rect it occupied so the deck can
493        // bloom it (opt-in glow) without the facet knowing.
494        let content = ui.scope(|ui| {
495            if let Some(f) = self.facets.get_mut(self.active) {
496                f.ui(ui);
497            }
498        });
499        let content_rect = content.response.rect;
500
501        // Opt-in glow on the active facet's content rect, pulsing.
502        if self.fx.glow && content_rect.is_positive() {
503            let theme = self.effective_theme();
504            let time = ui.input(|i| i.time);
505            let painter = ui.painter_at(content_rect);
506            deckfx::paint_active_glow(&painter, content_rect.shrink(2.0), &theme, &self.fx, time);
507            ui.ctx().request_repaint(); // keep the pulse animating
508        }
509
510        // Drive + paint a summoned raven on a foreground layer above everything.
511        self.drive_raven(ui.ctx());
512    }
513
514    /// Advance + paint the summoned raven (if any) on a foreground layer. Pins its
515    /// launch time on the first frame and keeps repainting while it flies.
516    fn drive_raven(&mut self, ctx: &egui::Context) {
517        let Some(raven) = self.raven.as_mut() else { return };
518        raven.sprite.update(ctx);
519        let painter =
520            ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("facett_deck_raven")));
521        raven.sprite.paint(&painter);
522    }
523
524    /// Route this frame's clipboard events to the active facet, gated by caps.
525    /// The single OS-touching write (`clipboard::put`) lives here.
526    fn route_clipboard(&mut self, ctx: &egui::Context) {
527        let caps = self.active_caps();
528        if !(caps.copyable || caps.cuttable || caps.pasteable) {
529            return;
530        }
531        for action in clipboard::poll(ctx) {
532            let Some(f) = self.facets.get_mut(self.active) else { continue };
533            match action {
534                ClipAction::Copy if caps.copyable => {
535                    if let Some(t) = f.copy() {
536                        clipboard::put(ctx, t);
537                    }
538                }
539                ClipAction::Cut if caps.cuttable => {
540                    if let Some(t) = f.cut() {
541                        clipboard::put(ctx, t);
542                    }
543                }
544                ClipAction::Paste(s) if caps.pasteable => {
545                    f.paste(&s);
546                }
547                // Capability not declared → ignore (event may belong to a focused
548                // sub-widget egui already handled).
549                _ => {}
550            }
551        }
552    }
553
554    /// The whole-app observable state: the active facet + each facet's
555    /// `state_json`, plus an **additive** sibling `caps` map (title → caps JSON)
556    /// so the existing flat `facets[title]` shape is unchanged for consumers.
557    pub fn state_json(&self) -> serde_json::Value {
558        let mut facets = serde_json::Map::new();
559        let mut caps = serde_json::Map::new();
560        for f in &self.facets {
561            facets.insert(f.title().to_string(), f.state_json());
562            caps.insert(f.title().to_string(), f.caps().to_json());
563        }
564        serde_json::json!({
565            "active": self.facets.get(self.active).map(|f| f.title()),
566            "facets": facets,
567            "caps": caps,
568        })
569    }
570}
571
572/// A stable, bright-ish colour from a string (FNV-1a). Handy default node colour.
573pub fn hash_color(s: &str) -> Color32 {
574    let mut h: u32 = 2166136261;
575    for b in s.bytes() {
576        h = (h ^ b as u32).wrapping_mul(16777619);
577    }
578    Color32::from_rgb((h & 0xFF) as u8 | 0x60, ((h >> 8) & 0xFF) as u8 | 0x60, ((h >> 16) & 0xFF) as u8 | 0x60)
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    #[test]
586    fn scene_builds() {
587        let mut s = Scene::new();
588        let a = s.node("Person", hash_color("Person"));
589        let b = s.node("Company", hash_color("Company"));
590        s.edge(a, b);
591        assert_eq!(s.nodes.len(), 2);
592        assert_eq!(s.edges.len(), 1);
593        assert!(!s.is_empty());
594    }
595
596    #[test]
597    fn force_layout_produces_finite_bounded_positions() {
598        let mut scene = Scene::new();
599        for i in 0..12 { scene.node(format!("n{i}"), hash_color("n")); }
600        for i in 0..12 { scene.edge(i, (i + 1) % 12); }
601        let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(400.0, 400.0));
602        let pos = positions(Layout::Force, &scene, rect);
603        assert_eq!(pos.len(), 12);
604        for p in &pos {
605            assert!(p.x.is_finite() && p.y.is_finite(), "finite");
606            assert!(rect.expand(50.0).contains(*p), "roughly within the rect");
607        }
608    }
609
610    #[test]
611    fn hash_color_is_stable() {
612        assert_eq!(hash_color("Person"), hash_color("Person"));
613        assert_ne!(hash_color("Person"), hash_color("Company"));
614    }
615
616    /// A minimal facet for deck tests.
617    struct Stub(&'static str);
618    impl Facet for Stub {
619        fn title(&self) -> &str {
620            self.0
621        }
622        fn ui(&mut self, ui: &mut Ui) {
623            ui.label(self.0);
624        }
625        fn state_json(&self) -> serde_json::Value {
626            serde_json::json!({ "t": self.0 })
627        }
628    }
629
630    #[test]
631    fn deck_fx_is_off_by_default() {
632        let deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
633        assert_eq!(*deck.fx(), DeckFx::OFF, "no effects until the host opts in");
634        assert!(!deck.has_raven());
635        assert!(!deck.fx().glow);
636        assert!(deck.fx().palette().is_none());
637    }
638
639    #[test]
640    fn deck_cycle_palette_walks_theme_all() {
641        let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
642        let first = deck.cycle_palette();
643        assert_eq!(first, 0);
644        assert_eq!(deck.fx().theme().map(|t| t.name), Some(Theme::ALL[0]().name));
645        // walks forward and wraps
646        for _ in 1..Theme::ALL.len() {
647            deck.cycle_palette();
648        }
649        assert_eq!(deck.cycle_palette(), 0, "wraps back to the first palette");
650    }
651
652    #[test]
653    fn deck_send_raven_launches_and_perches_after_a_full_flight() {
654        use crate::effects::RAVEN_FLIGHT_SECS;
655        let mut deck = FacetDeck::new(vec![Box::new(Stub("rows"))]);
656        assert!(!deck.has_raven());
657        let target = egui::Rect::from_min_size(egui::pos2(120.0, 80.0), egui::vec2(200.0, 28.0));
658        deck.send_raven(target);
659        assert!(deck.has_raven(), "raven summoned");
660        assert!(!deck.raven_perched(), "not perched at launch");
661
662        // Drive the sprite headlessly past the flight duration → it perches.
663        if let Some(r) = deck.raven.as_mut() {
664            r.sprite.advance(RAVEN_FLIGHT_SECS + 0.1);
665        }
666        assert!(deck.raven_perched(), "perched after the flight duration");
667
668        deck.clear_raven();
669        assert!(!deck.has_raven());
670    }
671
672    /// REGRESSION (inject-assert): merely *drawing* the palette picker without a
673    /// user click must NOT pin a palette override. The bug: the picker auto-pinned
674    /// index 0 on the first passive frame, turning the legacy `set_theme` override
675    /// permanently on and clobbering a host's own theme (the rich `look::Theme`)
676    /// every frame. We render one frame with no interaction and assert the override
677    /// is still `None` (host theme wins).
678    #[test]
679    fn palette_picker_does_not_pin_without_a_user_click() {
680        let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
681        assert!(deck.fx().palette().is_none(), "starts with no override");
682        let ctx = egui::Context::default();
683        let mut chosen = Some(7usize);
684        let _ = ctx.run(egui::RawInput::default(), |ctx| {
685            egui::CentralPanel::default().show(ctx, |ui| {
686                // No synthetic click is fed → the picker is drawn but not used.
687                chosen = deck.palette_picker(ui);
688            });
689        });
690        assert_eq!(chosen, None, "drawing the picker reports no selection without a click");
691        assert!(
692            deck.fx().palette().is_none(),
693            "drawing the picker must not pin index 0 — that would clobber the host's own theme each frame"
694        );
695    }
696
697    #[test]
698    fn deck_palette_override_applies_theme_in_a_ui_pass() {
699        let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
700        deck.set_palette(1); // sci-fi
701        let ctx = egui::Context::default();
702        let mut seen = "";
703        let _ = ctx.run(egui::RawInput::default(), |ctx| {
704            egui::CentralPanel::default().show(ctx, |ui| {
705                deck.ui(ui);
706                seen = theme(ui).name;
707            });
708        });
709        assert_eq!(seen, Theme::ALL[1]().name, "deck applied its palette override");
710    }
711}