1use 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;
23pub mod render;
27pub mod scroll_engine;
28pub mod testmatrix; pub mod theme;
31pub mod trace; pub 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
44pub use look::Theme as LookTheme;
47
48#[derive(Clone)]
51pub struct Node {
52 pub label: String,
53 pub color: Color32,
54}
55
56#[derive(Clone, Copy)]
58pub struct Edge {
59 pub src: usize,
60 pub dst: usize,
61}
62
63#[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 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#[derive(Clone, Copy, PartialEq, Eq, Default)]
89pub enum Layout {
90 #[default]
91 Circular,
92 Force,
95}
96
97pub 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 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); }
164 }
165 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
181pub 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 fn caps(&self) -> FacetCaps {
199 FacetCaps::NONE
200 }
201
202 fn scale(&self) -> f32 {
204 1.0
205 }
206 fn set_scale(&mut self, _scale: f32) {}
208
209 fn selection_json(&self) -> serde_json::Value {
212 serde_json::Value::Null
213 }
214
215 fn copy(&mut self) -> Option<String> {
218 None
219 }
220 fn cut(&mut self) -> Option<String> {
222 self.copy()
223 }
224 fn paste(&mut self, _text: &str) -> bool {
226 false
227 }
228
229 fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
235 None
236 }
237}
238
239pub struct FacetDeck {
244 facets: Vec<Box<dyn Facet>>,
245 active: usize,
246 fx: DeckFx,
249 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 pub fn active_title(&self) -> Option<&str> {
264 self.facets.get(self.active).map(|f| f.title())
265 }
266
267 pub fn titles(&self) -> Vec<&str> {
270 self.facets.iter().map(|f| f.title()).collect()
271 }
272
273 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 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 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 pub fn with_fx(mut self, fx: DeckFx) -> Self {
318 self.fx = fx;
319 self
320 }
321 pub fn fx(&self) -> &DeckFx {
323 &self.fx
324 }
325 pub fn fx_mut(&mut self) -> &mut DeckFx {
327 &mut self.fx
328 }
329 pub fn set_palette(&mut self, i: usize) {
332 self.fx.set_palette(i);
333 }
334 pub fn cycle_palette(&mut self) -> usize {
337 self.fx.cycle_palette()
338 }
339
340 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 pub fn has_raven(&self) -> bool {
355 self.raven.is_some()
356 }
357 pub fn raven_perched(&self) -> bool {
359 self.raven.as_ref().map(|r| r.is_perched()).unwrap_or(false)
360 }
361 pub fn clear_raven(&mut self) {
363 self.raven = None;
364 }
365
366 fn effective_theme(&self) -> Theme {
370 self.fx.theme().unwrap_or_default()
371 }
372
373 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 pub fn active_caps(&self) -> FacetCaps {
401 self.facets.get(self.active).map(|f| f.caps()).unwrap_or(FacetCaps::NONE)
402 }
403
404 fn active_scale(&self) -> f32 {
406 self.facets.get(self.active).map(|f| f.scale()).unwrap_or(1.0)
407 }
408
409 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 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 pub fn ui(&mut self, ui: &mut Ui) {
427 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 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 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 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 self.route_clipboard(ui.ctx());
490
491 ui.separator();
492 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 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(); }
509
510 self.drive_raven(ui.ctx());
512 }
513
514 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 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 _ => {}
550 }
551 }
552 }
553
554 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
572pub 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 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 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 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 #[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 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); 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}