1use 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; pub 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
34pub use look::Theme as LookTheme;
37
38#[derive(Clone)]
41pub struct Node {
42 pub label: String,
43 pub color: Color32,
44}
45
46#[derive(Clone, Copy)]
48pub struct Edge {
49 pub src: usize,
50 pub dst: usize,
51}
52
53#[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 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#[derive(Clone, Copy, PartialEq, Eq, Default)]
79pub enum Layout {
80 #[default]
81 Circular,
82 Force,
85}
86
87pub 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 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); }
154 }
155 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
171pub 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 fn caps(&self) -> FacetCaps {
189 FacetCaps::NONE
190 }
191
192 fn scale(&self) -> f32 {
194 1.0
195 }
196 fn set_scale(&mut self, _scale: f32) {}
198
199 fn selection_json(&self) -> serde_json::Value {
202 serde_json::Value::Null
203 }
204
205 fn copy(&mut self) -> Option<String> {
208 None
209 }
210 fn cut(&mut self) -> Option<String> {
212 self.copy()
213 }
214 fn paste(&mut self, _text: &str) -> bool {
216 false
217 }
218
219 fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
225 None
226 }
227}
228
229pub struct FacetDeck {
234 facets: Vec<Box<dyn Facet>>,
235 active: usize,
236 fx: DeckFx,
239 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 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 pub fn with_fx(mut self, fx: DeckFx) -> Self {
267 self.fx = fx;
268 self
269 }
270 pub fn fx(&self) -> &DeckFx {
272 &self.fx
273 }
274 pub fn fx_mut(&mut self) -> &mut DeckFx {
276 &mut self.fx
277 }
278 pub fn set_palette(&mut self, i: usize) {
281 self.fx.set_palette(i);
282 }
283 pub fn cycle_palette(&mut self) -> usize {
286 self.fx.cycle_palette()
287 }
288
289 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 pub fn has_raven(&self) -> bool {
304 self.raven.is_some()
305 }
306 pub fn raven_perched(&self) -> bool {
308 self.raven.as_ref().map(|r| r.is_perched()).unwrap_or(false)
309 }
310 pub fn clear_raven(&mut self) {
312 self.raven = None;
313 }
314
315 fn effective_theme(&self) -> Theme {
319 self.fx.theme().unwrap_or_default()
320 }
321
322 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 pub fn active_caps(&self) -> FacetCaps {
343 self.facets.get(self.active).map(|f| f.caps()).unwrap_or(FacetCaps::NONE)
344 }
345
346 fn active_scale(&self) -> f32 {
348 self.facets.get(self.active).map(|f| f.scale()).unwrap_or(1.0)
349 }
350
351 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 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 pub fn ui(&mut self, ui: &mut Ui) {
369 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 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 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 self.route_clipboard(ui.ctx());
428
429 ui.separator();
430 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 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(); }
447
448 self.drive_raven(ui.ctx());
450 }
451
452 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 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 _ => {}
488 }
489 }
490 }
491
492 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
510pub 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 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 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 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); 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}