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