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