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 scroll_engine;
24pub mod testmatrix; pub mod theme;
27pub mod trace; pub use a11y::{Semantics, node as a11y_node, stable_id};
30pub use caps::FacetCaps;
31pub use clip::{ArrowColumnRef, ClipKind, ClipPayload, CopySource, PasteTarget};
32pub use clipboard::ClipAction;
33pub use deckfx::{DeckFx, DeckRaven};
34pub use look::{Action, KeyMap, Palette};
35pub use nav::{Dir4, Navigable, nearest_in_direction};
36pub use rabbit::{Rabbit, RabbitMesh, rabbit_mesh, rabbit_outline};
37pub use scroll_engine::SmoothScroll;
38pub use theme::{Theme, set_theme, theme};
39
40pub use look::Theme as LookTheme;
43
44#[derive(Clone)]
47pub struct Node {
48 pub label: String,
49 pub color: Color32,
50}
51
52#[derive(Clone, Copy)]
54pub struct Edge {
55 pub src: usize,
56 pub dst: usize,
57}
58
59#[derive(Default, Clone)]
61pub struct Scene {
62 pub nodes: Vec<Node>,
63 pub edges: Vec<Edge>,
64}
65
66impl Scene {
67 pub fn new() -> Self {
68 Self::default()
69 }
70 pub fn node(&mut self, label: impl Into<String>, color: Color32) -> usize {
72 self.nodes.push(Node { label: label.into(), color });
73 self.nodes.len() - 1
74 }
75 pub fn edge(&mut self, src: usize, dst: usize) {
76 self.edges.push(Edge { src, dst });
77 }
78 pub fn is_empty(&self) -> bool {
79 self.nodes.is_empty()
80 }
81}
82
83#[derive(Clone, Copy, PartialEq, Eq, Default)]
85pub enum Layout {
86 #[default]
87 Circular,
88 Force,
91}
92
93pub fn draw(ui: &mut Ui, scene: &Scene, layout: Layout, empty_hint: &str) {
96 let (rect, _) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
97 let th = theme(ui);
98 let painter = ui.painter_at(rect);
99 let n = scene.nodes.len();
100 if n == 0 {
101 painter.text(rect.center(), Align2::CENTER_CENTER, empty_hint, FontId::proportional(13.0), th.text_dim);
102 return;
103 }
104 let pos = positions(layout, scene, rect);
105 for e in &scene.edges {
106 if e.src < n && e.dst < n {
107 painter.line_segment([pos[e.src], pos[e.dst]], Stroke::new(0.6, th.edge));
108 }
109 }
110 for (i, node) in scene.nodes.iter().enumerate() {
111 painter.circle_filled(pos[i], 5.0, node.color);
112 }
113 if n <= 60 {
114 for (i, node) in scene.nodes.iter().enumerate() {
115 painter.text(pos[i] + vec2(7.0, 0.0), Align2::LEFT_CENTER, &node.label, FontId::proportional(10.0), th.text);
116 }
117 }
118}
119
120fn positions(layout: Layout, scene: &Scene, rect: Rect) -> Vec<Pos2> {
121 let n = scene.nodes.len();
122 let center = rect.center();
123 let radius = rect.size().min_elem() * 0.42;
124 let circular = |i: usize| {
125 let a = std::f32::consts::TAU * (i as f32) / (n as f32);
126 vec2(a.cos(), a.sin())
127 };
128 match layout {
129 Layout::Circular => (0..n).map(|i| center + radius * circular(i)).collect(),
130 Layout::Force => {
131 let mut p: Vec<egui::Vec2> = (0..n).map(circular).collect();
133 let k = (1.0 / (n.max(1) as f32).sqrt()).clamp(0.05, 1.0);
134 for _ in 0..120 {
135 let mut disp = vec![egui::Vec2::ZERO; n];
136 for i in 0..n {
137 for j in (i + 1)..n {
138 let d = p[i] - p[j];
139 let dist = d.length().max(1e-3);
140 let f = k * k / dist;
141 let dir = d / dist;
142 disp[i] += dir * f;
143 disp[j] -= dir * f;
144 }
145 }
146 for e in &scene.edges {
147 if e.src < n && e.dst < n {
148 let d = p[e.src] - p[e.dst];
149 let dist = d.length().max(1e-3);
150 let f = dist * dist / k;
151 let dir = d / dist;
152 disp[e.src] -= dir * f;
153 disp[e.dst] += dir * f;
154 }
155 }
156 for i in 0..n {
157 let dl = disp[i].length().max(1e-3);
158 p[i] += disp[i] / dl * dl.min(0.04); }
160 }
161 let (mut mn, mut mx) = (egui::vec2(f32::MAX, f32::MAX), egui::vec2(f32::MIN, f32::MIN));
163 for v in &p {
164 mn.x = mn.x.min(v.x);
165 mn.y = mn.y.min(v.y);
166 mx.x = mx.x.max(v.x);
167 mx.y = mx.y.max(v.y);
168 }
169 let span = (mx - mn).max(egui::vec2(1e-3, 1e-3));
170 p.iter()
171 .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))
172 .collect()
173 }
174 }
175}
176
177pub trait Facet {
187 fn title(&self) -> &str;
188 fn ui(&mut self, ui: &mut Ui);
189 fn state_json(&self) -> serde_json::Value;
190
191 fn caps(&self) -> FacetCaps {
195 FacetCaps::NONE
196 }
197
198 fn scale(&self) -> f32 {
200 1.0
201 }
202 fn set_scale(&mut self, _scale: f32) {}
204
205 fn selection_json(&self) -> serde_json::Value {
208 serde_json::Value::Null
209 }
210
211 fn copy(&mut self) -> Option<String> {
214 None
215 }
216 fn cut(&mut self) -> Option<String> {
218 self.copy()
219 }
220 fn paste(&mut self, _text: &str) -> bool {
222 false
223 }
224
225 fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
231 None
232 }
233}
234
235pub struct FacetDeck {
240 facets: Vec<Box<dyn Facet>>,
241 active: usize,
242 fx: DeckFx,
245 raven: Option<DeckRaven>,
247}
248
249impl FacetDeck {
250 pub fn new(facets: Vec<Box<dyn Facet>>) -> Self {
251 Self { facets, active: 0, fx: DeckFx::OFF, raven: None }
252 }
253 pub fn active(&self) -> usize {
254 self.active
255 }
256
257 pub fn facet_mut<T: std::any::Any>(&mut self, title: &str) -> Option<&mut T> {
262 self.facets
263 .iter_mut()
264 .find(|f| f.title() == title)
265 .and_then(|f| f.as_any_mut())
266 .and_then(|a| a.downcast_mut::<T>())
267 }
268
269 pub fn replace_facet(&mut self, title: &str, facet: Box<dyn Facet>) -> bool {
275 if let Some(slot) = self.facets.iter_mut().find(|f| f.title() == title) {
276 *slot = facet;
277 true
278 } else {
279 false
280 }
281 }
282
283 pub fn with_fx(mut self, fx: DeckFx) -> Self {
287 self.fx = fx;
288 self
289 }
290 pub fn fx(&self) -> &DeckFx {
292 &self.fx
293 }
294 pub fn fx_mut(&mut self) -> &mut DeckFx {
296 &mut self.fx
297 }
298 pub fn set_palette(&mut self, i: usize) {
301 self.fx.set_palette(i);
302 }
303 pub fn cycle_palette(&mut self) -> usize {
306 self.fx.cycle_palette()
307 }
308
309 pub fn send_raven(&mut self, target: Rect) {
315 let theme = self.effective_theme();
316 self.raven = Some(DeckRaven::new(target, &theme));
317 harness::trail(
318 harness::Kind::Render,
319 format!("raven launched → perch ({:.0},{:.0})", target.center().x, target.top()),
320 );
321 }
322 pub fn has_raven(&self) -> bool {
324 self.raven.is_some()
325 }
326 pub fn raven_perched(&self) -> bool {
328 self.raven.as_ref().map(|r| r.is_perched()).unwrap_or(false)
329 }
330 pub fn clear_raven(&mut self) {
332 self.raven = None;
333 }
334
335 fn effective_theme(&self) -> Theme {
339 self.fx.theme().unwrap_or_default()
340 }
341
342 pub fn palette_picker(&mut self, ui: &mut Ui) -> Option<usize> {
354 let mut sel = self.fx.palette().unwrap_or(0);
355 let before = sel;
356 ui.horizontal_wrapped(|ui| {
357 ui.label("Palette:");
358 for (i, ctor) in Theme::ALL.iter().enumerate() {
359 ui.selectable_value(&mut sel, i, ctor().name);
360 }
361 });
362 if sel != before {
363 self.fx.set_palette(sel);
364 }
365 (sel != before).then_some(sel)
366 }
367
368 pub fn active_caps(&self) -> FacetCaps {
370 self.facets.get(self.active).map(|f| f.caps()).unwrap_or(FacetCaps::NONE)
371 }
372
373 fn active_scale(&self) -> f32 {
375 self.facets.get(self.active).map(|f| f.scale()).unwrap_or(1.0)
376 }
377
378 fn scale_active(&mut self, k: f32) {
380 if let Some(f) = self.facets.get_mut(self.active) {
381 let s = (f.scale() * k).clamp(0.25, 4.0);
382 f.set_scale(s);
383 }
384 }
385
386 fn reset_scale(&mut self) {
388 if let Some(f) = self.facets.get_mut(self.active) {
389 f.set_scale(1.0);
390 }
391 }
392
393 pub fn ui(&mut self, ui: &mut Ui) {
396 if let Some(theme) = self.fx.theme() {
400 set_theme(ui.ctx(), theme);
401 }
402
403 let titles: Vec<String> = self.facets.iter().map(|f| f.title().to_string()).collect();
404 ui.horizontal_wrapped(|ui| {
409 for (i, t) in titles.iter().enumerate() {
410 ui.selectable_value(&mut self.active, i, t);
411 }
412 });
413
414 let caps = self.active_caps();
415
416 if caps.scalable {
418 ui.horizontal(|ui| {
419 if ui.button("−").on_hover_text("Zoom out (Ctrl-−)").clicked() {
420 self.scale_active(1.0 / 1.1);
421 }
422 ui.label(format!("{:.0}%", self.active_scale() * 100.0));
423 if ui.button("+").on_hover_text("Zoom in (Ctrl-+)").clicked() {
424 self.scale_active(1.1);
425 }
426 if ui.button("Reset").on_hover_text("Reset zoom (Ctrl-0)").clicked() {
427 self.reset_scale();
428 }
429 });
430 }
431
432 if caps.scalable {
435 let (cmd, plus, minus, zero) = ui.input(|i| {
436 (
437 i.modifiers.command,
438 i.key_pressed(egui::Key::Plus) || i.key_pressed(egui::Key::Equals),
439 i.key_pressed(egui::Key::Minus),
440 i.key_pressed(egui::Key::Num0),
441 )
442 });
443 if cmd {
444 if plus {
445 self.scale_active(1.1);
446 }
447 if minus {
448 self.scale_active(1.0 / 1.1);
449 }
450 if zero {
451 self.reset_scale();
452 }
453 }
454 }
455
456 self.route_clipboard(ui.ctx());
459
460 ui.separator();
461 let content = ui.scope(|ui| {
464 if let Some(f) = self.facets.get_mut(self.active) {
465 f.ui(ui);
466 }
467 });
468 let content_rect = content.response.rect;
469
470 if self.fx.glow && content_rect.is_positive() {
472 let theme = self.effective_theme();
473 let time = ui.input(|i| i.time);
474 let painter = ui.painter_at(content_rect);
475 deckfx::paint_active_glow(&painter, content_rect.shrink(2.0), &theme, &self.fx, time);
476 ui.ctx().request_repaint(); }
478
479 self.drive_raven(ui.ctx());
481 }
482
483 fn drive_raven(&mut self, ctx: &egui::Context) {
486 let Some(raven) = self.raven.as_mut() else { return };
487 raven.sprite.update(ctx);
488 let painter =
489 ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("facett_deck_raven")));
490 raven.sprite.paint(&painter);
491 }
492
493 fn route_clipboard(&mut self, ctx: &egui::Context) {
496 let caps = self.active_caps();
497 if !(caps.copyable || caps.cuttable || caps.pasteable) {
498 return;
499 }
500 for action in clipboard::poll(ctx) {
501 let Some(f) = self.facets.get_mut(self.active) else { continue };
502 match action {
503 ClipAction::Copy if caps.copyable => {
504 if let Some(t) = f.copy() {
505 clipboard::put(ctx, t);
506 }
507 }
508 ClipAction::Cut if caps.cuttable => {
509 if let Some(t) = f.cut() {
510 clipboard::put(ctx, t);
511 }
512 }
513 ClipAction::Paste(s) if caps.pasteable => {
514 f.paste(&s);
515 }
516 _ => {}
519 }
520 }
521 }
522
523 pub fn state_json(&self) -> serde_json::Value {
527 let mut facets = serde_json::Map::new();
528 let mut caps = serde_json::Map::new();
529 for f in &self.facets {
530 facets.insert(f.title().to_string(), f.state_json());
531 caps.insert(f.title().to_string(), f.caps().to_json());
532 }
533 serde_json::json!({
534 "active": self.facets.get(self.active).map(|f| f.title()),
535 "facets": facets,
536 "caps": caps,
537 })
538 }
539}
540
541pub fn hash_color(s: &str) -> Color32 {
543 let mut h: u32 = 2166136261;
544 for b in s.bytes() {
545 h = (h ^ b as u32).wrapping_mul(16777619);
546 }
547 Color32::from_rgb((h & 0xFF) as u8 | 0x60, ((h >> 8) & 0xFF) as u8 | 0x60, ((h >> 16) & 0xFF) as u8 | 0x60)
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553
554 #[test]
555 fn scene_builds() {
556 let mut s = Scene::new();
557 let a = s.node("Person", hash_color("Person"));
558 let b = s.node("Company", hash_color("Company"));
559 s.edge(a, b);
560 assert_eq!(s.nodes.len(), 2);
561 assert_eq!(s.edges.len(), 1);
562 assert!(!s.is_empty());
563 }
564
565 #[test]
566 fn force_layout_produces_finite_bounded_positions() {
567 let mut scene = Scene::new();
568 for i in 0..12 { scene.node(format!("n{i}"), hash_color("n")); }
569 for i in 0..12 { scene.edge(i, (i + 1) % 12); }
570 let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(400.0, 400.0));
571 let pos = positions(Layout::Force, &scene, rect);
572 assert_eq!(pos.len(), 12);
573 for p in &pos {
574 assert!(p.x.is_finite() && p.y.is_finite(), "finite");
575 assert!(rect.expand(50.0).contains(*p), "roughly within the rect");
576 }
577 }
578
579 #[test]
580 fn hash_color_is_stable() {
581 assert_eq!(hash_color("Person"), hash_color("Person"));
582 assert_ne!(hash_color("Person"), hash_color("Company"));
583 }
584
585 struct Stub(&'static str);
587 impl Facet for Stub {
588 fn title(&self) -> &str {
589 self.0
590 }
591 fn ui(&mut self, ui: &mut Ui) {
592 ui.label(self.0);
593 }
594 fn state_json(&self) -> serde_json::Value {
595 serde_json::json!({ "t": self.0 })
596 }
597 }
598
599 #[test]
600 fn deck_fx_is_off_by_default() {
601 let deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
602 assert_eq!(*deck.fx(), DeckFx::OFF, "no effects until the host opts in");
603 assert!(!deck.has_raven());
604 assert!(!deck.fx().glow);
605 assert!(deck.fx().palette().is_none());
606 }
607
608 #[test]
609 fn deck_cycle_palette_walks_theme_all() {
610 let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
611 let first = deck.cycle_palette();
612 assert_eq!(first, 0);
613 assert_eq!(deck.fx().theme().map(|t| t.name), Some(Theme::ALL[0]().name));
614 for _ in 1..Theme::ALL.len() {
616 deck.cycle_palette();
617 }
618 assert_eq!(deck.cycle_palette(), 0, "wraps back to the first palette");
619 }
620
621 #[test]
622 fn deck_send_raven_launches_and_perches_after_a_full_flight() {
623 use crate::effects::RAVEN_FLIGHT_SECS;
624 let mut deck = FacetDeck::new(vec![Box::new(Stub("rows"))]);
625 assert!(!deck.has_raven());
626 let target = egui::Rect::from_min_size(egui::pos2(120.0, 80.0), egui::vec2(200.0, 28.0));
627 deck.send_raven(target);
628 assert!(deck.has_raven(), "raven summoned");
629 assert!(!deck.raven_perched(), "not perched at launch");
630
631 if let Some(r) = deck.raven.as_mut() {
633 r.sprite.advance(RAVEN_FLIGHT_SECS + 0.1);
634 }
635 assert!(deck.raven_perched(), "perched after the flight duration");
636
637 deck.clear_raven();
638 assert!(!deck.has_raven());
639 }
640
641 #[test]
648 fn palette_picker_does_not_pin_without_a_user_click() {
649 let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
650 assert!(deck.fx().palette().is_none(), "starts with no override");
651 let ctx = egui::Context::default();
652 let mut chosen = Some(7usize);
653 let _ = ctx.run(egui::RawInput::default(), |ctx| {
654 egui::CentralPanel::default().show(ctx, |ui| {
655 chosen = deck.palette_picker(ui);
657 });
658 });
659 assert_eq!(chosen, None, "drawing the picker reports no selection without a click");
660 assert!(
661 deck.fx().palette().is_none(),
662 "drawing the picker must not pin index 0 — that would clobber the host's own theme each frame"
663 );
664 }
665
666 #[test]
667 fn deck_palette_override_applies_theme_in_a_ui_pass() {
668 let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
669 deck.set_palette(1); let ctx = egui::Context::default();
671 let mut seen = "";
672 let _ = ctx.run(egui::RawInput::default(), |ctx| {
673 egui::CentralPanel::default().show(ctx, |ui| {
674 deck.ui(ui);
675 seen = theme(ui).name;
676 });
677 });
678 assert_eq!(seen, Theme::ALL[1]().name, "deck applied its palette override");
679 }
680}