use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
pub mod caps;
pub mod clipboard;
pub mod deckfx;
pub mod effects;
pub mod harness;
pub mod theme;
pub mod trace; pub use caps::FacetCaps;
pub use clipboard::ClipAction;
pub use deckfx::{DeckFx, DeckRaven};
pub use theme::{Theme, set_theme, theme};
#[derive(Clone)]
pub struct Node {
pub label: String,
pub color: Color32,
}
#[derive(Clone, Copy)]
pub struct Edge {
pub src: usize,
pub dst: usize,
}
#[derive(Default, Clone)]
pub struct Scene {
pub nodes: Vec<Node>,
pub edges: Vec<Edge>,
}
impl Scene {
pub fn new() -> Self {
Self::default()
}
pub fn node(&mut self, label: impl Into<String>, color: Color32) -> usize {
self.nodes.push(Node { label: label.into(), color });
self.nodes.len() - 1
}
pub fn edge(&mut self, src: usize, dst: usize) {
self.edges.push(Edge { src, dst });
}
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
}
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum Layout {
#[default]
Circular,
Force,
}
pub fn draw(ui: &mut Ui, scene: &Scene, layout: Layout, empty_hint: &str) {
let (rect, _) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
let th = theme(ui);
let painter = ui.painter_at(rect);
let n = scene.nodes.len();
if n == 0 {
painter.text(rect.center(), Align2::CENTER_CENTER, empty_hint, FontId::proportional(13.0), th.text_dim);
return;
}
let pos = positions(layout, scene, rect);
for e in &scene.edges {
if e.src < n && e.dst < n {
painter.line_segment([pos[e.src], pos[e.dst]], Stroke::new(0.6, th.edge));
}
}
for (i, node) in scene.nodes.iter().enumerate() {
painter.circle_filled(pos[i], 5.0, node.color);
}
if n <= 60 {
for (i, node) in scene.nodes.iter().enumerate() {
painter.text(pos[i] + vec2(7.0, 0.0), Align2::LEFT_CENTER, &node.label, FontId::proportional(10.0), th.text);
}
}
}
fn positions(layout: Layout, scene: &Scene, rect: Rect) -> Vec<Pos2> {
let n = scene.nodes.len();
let center = rect.center();
let radius = rect.size().min_elem() * 0.42;
let circular = |i: usize| {
let a = std::f32::consts::TAU * (i as f32) / (n as f32);
vec2(a.cos(), a.sin())
};
match layout {
Layout::Circular => (0..n).map(|i| center + radius * circular(i)).collect(),
Layout::Force => {
let mut p: Vec<egui::Vec2> = (0..n).map(circular).collect();
let k = (1.0 / (n.max(1) as f32).sqrt()).clamp(0.05, 1.0);
for _ in 0..120 {
let mut disp = vec![egui::Vec2::ZERO; n];
for i in 0..n {
for j in (i + 1)..n {
let d = p[i] - p[j];
let dist = d.length().max(1e-3);
let f = k * k / dist;
let dir = d / dist;
disp[i] += dir * f;
disp[j] -= dir * f;
}
}
for e in &scene.edges {
if e.src < n && e.dst < n {
let d = p[e.src] - p[e.dst];
let dist = d.length().max(1e-3);
let f = dist * dist / k;
let dir = d / dist;
disp[e.src] -= dir * f;
disp[e.dst] += dir * f;
}
}
for i in 0..n {
let dl = disp[i].length().max(1e-3);
p[i] += disp[i] / dl * dl.min(0.04); }
}
let (mut mn, mut mx) = (egui::vec2(f32::MAX, f32::MAX), egui::vec2(f32::MIN, f32::MIN));
for v in &p {
mn.x = mn.x.min(v.x);
mn.y = mn.y.min(v.y);
mx.x = mx.x.max(v.x);
mx.y = mx.y.max(v.y);
}
let span = (mx - mn).max(egui::vec2(1e-3, 1e-3));
p.iter()
.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))
.collect()
}
}
}
pub trait Facet {
fn title(&self) -> &str;
fn ui(&mut self, ui: &mut Ui);
fn state_json(&self) -> serde_json::Value;
fn caps(&self) -> FacetCaps {
FacetCaps::NONE
}
fn scale(&self) -> f32 {
1.0
}
fn set_scale(&mut self, _scale: f32) {}
fn selection_json(&self) -> serde_json::Value {
serde_json::Value::Null
}
fn copy(&mut self) -> Option<String> {
None
}
fn cut(&mut self) -> Option<String> {
self.copy()
}
fn paste(&mut self, _text: &str) -> bool {
false
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
None
}
}
pub struct FacetDeck {
facets: Vec<Box<dyn Facet>>,
active: usize,
fx: DeckFx,
raven: Option<DeckRaven>,
}
impl FacetDeck {
pub fn new(facets: Vec<Box<dyn Facet>>) -> Self {
Self { facets, active: 0, fx: DeckFx::OFF, raven: None }
}
pub fn active(&self) -> usize {
self.active
}
pub fn facet_mut<T: std::any::Any>(&mut self, title: &str) -> Option<&mut T> {
self.facets
.iter_mut()
.find(|f| f.title() == title)
.and_then(|f| f.as_any_mut())
.and_then(|a| a.downcast_mut::<T>())
}
pub fn with_fx(mut self, fx: DeckFx) -> Self {
self.fx = fx;
self
}
pub fn fx(&self) -> &DeckFx {
&self.fx
}
pub fn fx_mut(&mut self) -> &mut DeckFx {
&mut self.fx
}
pub fn set_palette(&mut self, i: usize) {
self.fx.set_palette(i);
}
pub fn cycle_palette(&mut self) -> usize {
self.fx.cycle_palette()
}
pub fn send_raven(&mut self, target: Rect) {
let theme = self.effective_theme();
self.raven = Some(DeckRaven::new(target, &theme));
harness::trail(
harness::Kind::Render,
format!("raven launched → perch ({:.0},{:.0})", target.center().x, target.top()),
);
}
pub fn has_raven(&self) -> bool {
self.raven.is_some()
}
pub fn raven_perched(&self) -> bool {
self.raven.as_ref().map(|r| r.is_perched()).unwrap_or(false)
}
pub fn clear_raven(&mut self) {
self.raven = None;
}
fn effective_theme(&self) -> Theme {
self.fx.theme().unwrap_or_default()
}
pub fn palette_picker(&mut self, ui: &mut Ui) -> Option<usize> {
let mut sel = self.fx.palette().unwrap_or(0);
let before = sel;
ui.horizontal_wrapped(|ui| {
ui.label("Palette:");
for (i, ctor) in Theme::ALL.iter().enumerate() {
ui.selectable_value(&mut sel, i, ctor().name);
}
});
if sel != before || self.fx.palette().is_none() {
self.fx.set_palette(sel);
}
(sel != before).then_some(sel)
}
pub fn active_caps(&self) -> FacetCaps {
self.facets.get(self.active).map(|f| f.caps()).unwrap_or(FacetCaps::NONE)
}
fn active_scale(&self) -> f32 {
self.facets.get(self.active).map(|f| f.scale()).unwrap_or(1.0)
}
fn scale_active(&mut self, k: f32) {
if let Some(f) = self.facets.get_mut(self.active) {
let s = (f.scale() * k).clamp(0.25, 4.0);
f.set_scale(s);
}
}
fn reset_scale(&mut self) {
if let Some(f) = self.facets.get_mut(self.active) {
f.set_scale(1.0);
}
}
pub fn ui(&mut self, ui: &mut Ui) {
if let Some(theme) = self.fx.theme() {
set_theme(ui.ctx(), theme);
}
let titles: Vec<String> = self.facets.iter().map(|f| f.title().to_string()).collect();
ui.horizontal(|ui| {
for (i, t) in titles.iter().enumerate() {
ui.selectable_value(&mut self.active, i, t);
}
});
let caps = self.active_caps();
if caps.scalable {
ui.horizontal(|ui| {
if ui.button("−").on_hover_text("Zoom out (Ctrl-−)").clicked() {
self.scale_active(1.0 / 1.1);
}
ui.label(format!("{:.0}%", self.active_scale() * 100.0));
if ui.button("+").on_hover_text("Zoom in (Ctrl-+)").clicked() {
self.scale_active(1.1);
}
if ui.button("Reset").on_hover_text("Reset zoom (Ctrl-0)").clicked() {
self.reset_scale();
}
});
}
if caps.scalable {
let (cmd, plus, minus, zero) = ui.input(|i| {
(
i.modifiers.command,
i.key_pressed(egui::Key::Plus) || i.key_pressed(egui::Key::Equals),
i.key_pressed(egui::Key::Minus),
i.key_pressed(egui::Key::Num0),
)
});
if cmd {
if plus {
self.scale_active(1.1);
}
if minus {
self.scale_active(1.0 / 1.1);
}
if zero {
self.reset_scale();
}
}
}
self.route_clipboard(ui.ctx());
ui.separator();
let content = ui.scope(|ui| {
if let Some(f) = self.facets.get_mut(self.active) {
f.ui(ui);
}
});
let content_rect = content.response.rect;
if self.fx.glow && content_rect.is_positive() {
let theme = self.effective_theme();
let time = ui.input(|i| i.time);
let painter = ui.painter_at(content_rect);
deckfx::paint_active_glow(&painter, content_rect.shrink(2.0), &theme, &self.fx, time);
ui.ctx().request_repaint(); }
self.drive_raven(ui.ctx());
}
fn drive_raven(&mut self, ctx: &egui::Context) {
let Some(raven) = self.raven.as_mut() else { return };
raven.sprite.update(ctx);
let painter =
ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("facett_deck_raven")));
raven.sprite.paint(&painter);
}
fn route_clipboard(&mut self, ctx: &egui::Context) {
let caps = self.active_caps();
if !(caps.copyable || caps.cuttable || caps.pasteable) {
return;
}
for action in clipboard::poll(ctx) {
let Some(f) = self.facets.get_mut(self.active) else { continue };
match action {
ClipAction::Copy if caps.copyable => {
if let Some(t) = f.copy() {
clipboard::put(ctx, t);
}
}
ClipAction::Cut if caps.cuttable => {
if let Some(t) = f.cut() {
clipboard::put(ctx, t);
}
}
ClipAction::Paste(s) if caps.pasteable => {
f.paste(&s);
}
_ => {}
}
}
}
pub fn state_json(&self) -> serde_json::Value {
let mut facets = serde_json::Map::new();
let mut caps = serde_json::Map::new();
for f in &self.facets {
facets.insert(f.title().to_string(), f.state_json());
caps.insert(f.title().to_string(), f.caps().to_json());
}
serde_json::json!({
"active": self.facets.get(self.active).map(|f| f.title()),
"facets": facets,
"caps": caps,
})
}
}
pub fn hash_color(s: &str) -> Color32 {
let mut h: u32 = 2166136261;
for b in s.bytes() {
h = (h ^ b as u32).wrapping_mul(16777619);
}
Color32::from_rgb((h & 0xFF) as u8 | 0x60, ((h >> 8) & 0xFF) as u8 | 0x60, ((h >> 16) & 0xFF) as u8 | 0x60)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scene_builds() {
let mut s = Scene::new();
let a = s.node("Person", hash_color("Person"));
let b = s.node("Company", hash_color("Company"));
s.edge(a, b);
assert_eq!(s.nodes.len(), 2);
assert_eq!(s.edges.len(), 1);
assert!(!s.is_empty());
}
#[test]
fn force_layout_produces_finite_bounded_positions() {
let mut scene = Scene::new();
for i in 0..12 { scene.node(format!("n{i}"), hash_color("n")); }
for i in 0..12 { scene.edge(i, (i + 1) % 12); }
let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(400.0, 400.0));
let pos = positions(Layout::Force, &scene, rect);
assert_eq!(pos.len(), 12);
for p in &pos {
assert!(p.x.is_finite() && p.y.is_finite(), "finite");
assert!(rect.expand(50.0).contains(*p), "roughly within the rect");
}
}
#[test]
fn hash_color_is_stable() {
assert_eq!(hash_color("Person"), hash_color("Person"));
assert_ne!(hash_color("Person"), hash_color("Company"));
}
struct Stub(&'static str);
impl Facet for Stub {
fn title(&self) -> &str {
self.0
}
fn ui(&mut self, ui: &mut Ui) {
ui.label(self.0);
}
fn state_json(&self) -> serde_json::Value {
serde_json::json!({ "t": self.0 })
}
}
#[test]
fn deck_fx_is_off_by_default() {
let deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
assert_eq!(*deck.fx(), DeckFx::OFF, "no effects until the host opts in");
assert!(!deck.has_raven());
assert!(!deck.fx().glow);
assert!(deck.fx().palette().is_none());
}
#[test]
fn deck_cycle_palette_walks_theme_all() {
let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
let first = deck.cycle_palette();
assert_eq!(first, 0);
assert_eq!(deck.fx().theme().map(|t| t.name), Some(Theme::ALL[0]().name));
for _ in 1..Theme::ALL.len() {
deck.cycle_palette();
}
assert_eq!(deck.cycle_palette(), 0, "wraps back to the first palette");
}
#[test]
fn deck_send_raven_launches_and_perches_after_a_full_flight() {
use crate::effects::RAVEN_FLIGHT_SECS;
let mut deck = FacetDeck::new(vec![Box::new(Stub("rows"))]);
assert!(!deck.has_raven());
let target = egui::Rect::from_min_size(egui::pos2(120.0, 80.0), egui::vec2(200.0, 28.0));
deck.send_raven(target);
assert!(deck.has_raven(), "raven summoned");
assert!(!deck.raven_perched(), "not perched at launch");
if let Some(r) = deck.raven.as_mut() {
r.sprite.advance(RAVEN_FLIGHT_SECS + 0.1);
}
assert!(deck.raven_perched(), "perched after the flight duration");
deck.clear_raven();
assert!(!deck.has_raven());
}
#[test]
fn deck_palette_override_applies_theme_in_a_ui_pass() {
let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
deck.set_palette(1); let ctx = egui::Context::default();
let mut seen = "";
let _ = ctx.run(egui::RawInput::default(), |ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
deck.ui(ui);
seen = theme(ui).name;
});
});
assert_eq!(seen, Theme::ALL[1]().name, "deck applied its palette override");
}
}