1use 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; pub use caps::FacetCaps;
18pub use clipboard::ClipAction;
19pub use deckfx::{DeckFx, DeckRaven};
20pub use theme::{Theme, set_theme, theme};
21
22#[derive(Clone)]
25pub struct Node {
26 pub label: String,
27 pub color: Color32,
28}
29
30#[derive(Clone, Copy)]
32pub struct Edge {
33 pub src: usize,
34 pub dst: usize,
35}
36
37#[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 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#[derive(Clone, Copy, PartialEq, Eq, Default)]
63pub enum Layout {
64 #[default]
65 Circular,
66 Force,
69}
70
71pub 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 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); }
138 }
139 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
155pub 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 fn caps(&self) -> FacetCaps {
173 FacetCaps::NONE
174 }
175
176 fn scale(&self) -> f32 {
178 1.0
179 }
180 fn set_scale(&mut self, _scale: f32) {}
182
183 fn selection_json(&self) -> serde_json::Value {
186 serde_json::Value::Null
187 }
188
189 fn copy(&mut self) -> Option<String> {
192 None
193 }
194 fn cut(&mut self) -> Option<String> {
196 self.copy()
197 }
198 fn paste(&mut self, _text: &str) -> bool {
200 false
201 }
202
203 fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
209 None
210 }
211}
212
213pub struct FacetDeck {
218 facets: Vec<Box<dyn Facet>>,
219 active: usize,
220 fx: DeckFx,
223 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 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 pub fn with_fx(mut self, fx: DeckFx) -> Self {
251 self.fx = fx;
252 self
253 }
254 pub fn fx(&self) -> &DeckFx {
256 &self.fx
257 }
258 pub fn fx_mut(&mut self) -> &mut DeckFx {
260 &mut self.fx
261 }
262 pub fn set_palette(&mut self, i: usize) {
265 self.fx.set_palette(i);
266 }
267 pub fn cycle_palette(&mut self) -> usize {
270 self.fx.cycle_palette()
271 }
272
273 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 pub fn has_raven(&self) -> bool {
288 self.raven.is_some()
289 }
290 pub fn raven_perched(&self) -> bool {
292 self.raven.as_ref().map(|r| r.is_perched()).unwrap_or(false)
293 }
294 pub fn clear_raven(&mut self) {
296 self.raven = None;
297 }
298
299 fn effective_theme(&self) -> Theme {
303 self.fx.theme().unwrap_or_default()
304 }
305
306 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 pub fn active_caps(&self) -> FacetCaps {
327 self.facets.get(self.active).map(|f| f.caps()).unwrap_or(FacetCaps::NONE)
328 }
329
330 fn active_scale(&self) -> f32 {
332 self.facets.get(self.active).map(|f| f.scale()).unwrap_or(1.0)
333 }
334
335 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 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 pub fn ui(&mut self, ui: &mut Ui) {
353 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 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 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 self.route_clipboard(ui.ctx());
412
413 ui.separator();
414 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 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(); }
431
432 self.drive_raven(ui.ctx());
434 }
435
436 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 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 _ => {}
472 }
473 }
474 }
475
476 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
494pub 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 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 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 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); 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}