use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
pub mod a11y;
pub mod adapter_retry;
pub mod asset;
pub mod interface;
pub mod layered;
pub mod barnes_hut;
pub mod action_bus;
pub mod caps;
pub mod chrome;
pub mod clip;
pub mod clipboard;
pub mod deckfx;
pub mod dragdrop;
pub mod edges;
pub mod effects;
pub mod engine;
pub mod devid;
pub mod empty;
pub mod errcode;
pub mod elm;
pub mod fly;
pub mod focus;
pub mod graph_split;
#[cfg(feature = "golden")]
pub mod golden;
pub mod imgscan; pub mod labels3d;
pub mod law;
pub mod label_grid;
pub mod legibility;
pub mod harness;
pub mod look;
pub mod nav;
pub mod navgraph; pub mod overlay;
pub mod panel;
pub mod rabbit;
pub mod render;
pub mod runtrace; pub mod scroll_engine;
pub mod severity; pub mod testmatrix; pub mod theme;
pub mod time_axis;
pub mod trace; pub use a11y::{Semantics, node as a11y_node, stable_id};
pub use errcode::{FacetError, MountedFacetError};
pub use action_bus::{ActionBus, BusAction, BusMsg};
pub use caps::FacetCaps;
pub use dragdrop::{DragDrop, DragEffect, DragMsg, Move as DragMove};
pub use clip::{ArrowColumnRef, ClipKind, ClipPayload, CopySource, PasteTarget};
pub use clipboard::ClipAction;
pub use deckfx::{DeckFx, DeckRaven};
pub use elm::Elm;
pub use imgscan::{BBox, Rgba, ScanReport, coverage, high_freq_ratio, painted_centroid_and_bbox, scan, spoke_score};
pub use look::{Action, KeyMap, Palette};
pub use nav::{Dir4, Navigable, nearest_in_direction};
pub use panel::Panel;
pub use rabbit::{Rabbit, RabbitMesh, rabbit_mesh, rabbit_outline};
pub use scroll_engine::SmoothScroll;
pub use severity::{Severity, worst as worst_severity};
pub use theme::{Theme, set_theme, theme};
pub use time_axis::{TimeAxis, TimeEffect, TimeMsg};
pub use look::Theme as LookTheme;
#[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 n = scene.nodes.len();
if n == 0 {
let th = theme(ui);
ui.painter_at(rect).text(rect.center(), Align2::CENTER_CENTER, empty_hint, FontId::proportional(13.0), th.text_dim);
return;
}
let pos = positions(layout, scene, rect);
draw_positions(ui, scene, &pos, rect, empty_hint);
}
pub fn draw_positions(ui: &mut Ui, scene: &Scene, pos: &[Pos2], rect: Rect, empty_hint: &str) {
let th = theme(ui);
let painter = ui.painter_at(rect);
let n = scene.nodes.len();
if n == 0 || pos.len() != n {
painter.text(rect.center(), Align2::CENTER_CENTER, empty_hint, FontId::proportional(13.0), th.text_dim);
return;
}
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_f32, th.edge));
}
}
for (i, node) in scene.nodes.iter().enumerate() {
painter.circle_filled(pos[i], 5.0, node.color);
}
draw_position_labels(ui, scene, pos, rect);
}
pub fn draw_position_labels(ui: &Ui, scene: &Scene, pos: &[Pos2], rect: Rect) {
let th = theme(ui);
let painter = ui.painter_at(rect);
let n = scene.nodes.len();
if n <= 60 && pos.len() == n {
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);
}
}
}
#[derive(Default, Clone)]
pub struct ForceCache {
sig: u64,
rect: [u32; 4],
pos: Vec<Pos2>,
}
impl ForceCache {
pub fn positions(&mut self, layout: Layout, scene: &Scene, rect: Rect) -> &[Pos2] {
let n = scene.nodes.len();
let key = self.structure_key(layout, scene);
let rb = [rect.min.x.to_bits(), rect.min.y.to_bits(), rect.max.x.to_bits(), rect.max.y.to_bits()];
if self.sig != key || self.rect != rb || self.pos.len() != n {
self.pos = positions(layout, scene, rect);
self.sig = key;
self.rect = rb;
}
&self.pos
}
#[must_use]
pub fn is_settled(&self, layout: Layout, scene: &Scene, rect: Rect) -> bool {
let rb = [rect.min.x.to_bits(), rect.min.y.to_bits(), rect.max.x.to_bits(), rect.max.y.to_bits()];
self.sig == self.structure_key(layout, scene) && self.rect == rb && self.pos.len() == scene.nodes.len()
}
pub fn invalidate(&mut self) {
self.sig = 0;
self.rect = [0; 4];
self.pos.clear();
}
fn structure_key(&self, layout: Layout, scene: &Scene) -> u64 {
let edges: Vec<(usize, usize)> = scene.edges.iter().map(|e| (e.src, e.dst)).collect();
let key = match layout {
Layout::Circular => "circular",
Layout::Force => "force",
};
crate::barnes_hut::structure_sig(key, scene.nodes.len(), &edges)
}
}
pub fn layout_positions(layout: Layout, scene: &Scene, rect: Rect) -> Vec<Pos2> {
positions(layout, scene, rect)
}
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);
let use_bh = n >= crate::barnes_hut::BH_THRESHOLD;
for _ in 0..120 {
let mut disp = vec![egui::Vec2::ZERO; n];
if use_bh {
let pts: Vec<[f32; 2]> = p.iter().map(|v| [v.x, v.y]).collect();
let rep = crate::barnes_hut::repulsion_forces::<2>(&pts, k, crate::barnes_hut::BH_THETA);
for i in 0..n {
disp[i] = egui::vec2(rep[i][0], rep[i][1]);
}
} else {
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 update_json(&mut self, _msg_json: &str) {}
fn component(&self) -> &'static str {
""
}
fn severity(&self) -> crate::Severity {
crate::Severity::Info
}
fn caps(&self) -> FacetCaps {
FacetCaps::NONE
}
fn scale(&self) -> f32 {
1.0
}
fn set_scale(&mut self, _scale: f32) {}
fn scale_range(&self) -> (f32, f32) {
(0.25, 4.0)
}
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
}
fn kind(&self) -> &'static str {
""
}
fn portable_state(&self) -> Option<serde_json::Value> {
None
}
fn load_state(&mut self, _state: &serde_json::Value) -> bool {
false
}
}
#[derive(Default, Debug)]
pub struct DeckStateCache {
facets: serde_json::Map<String, serde_json::Value>,
text: std::collections::HashMap<String, String>,
next: usize,
pub built: u64,
}
pub struct FacetDeck {
facets: Vec<Box<dyn Facet>>,
active: usize,
fx: DeckFx,
raven: Option<DeckRaven>,
toast: Option<(String, f64)>,
menu_collapsed: bool,
menu_open: bool,
menu_bar_rect: Rect,
content_rect: Rect,
groups: Vec<TabGroup>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TabGroup {
pub name: String,
pub members: Vec<usize>,
last: usize,
}
impl TabGroup {
pub fn entry(&self) -> usize {
if self.members.contains(&self.last) { self.last } else { self.members.first().copied().unwrap_or(0) }
}
}
const TOAST_SECS: f64 = 2.6;
impl FacetDeck {
pub fn new(facets: Vec<Box<dyn Facet>>) -> Self {
Self {
facets,
active: 0,
fx: DeckFx::OFF,
raven: None,
toast: None,
menu_collapsed: false,
menu_open: false,
menu_bar_rect: Rect::ZERO,
content_rect: Rect::ZERO,
groups: Vec::new(),
}
}
#[must_use]
pub fn with_groups(mut self, groups: &[(&str, &[&str])], rest: &str) -> Self {
self.set_groups(groups, rest);
self
}
pub fn set_groups(&mut self, groups: &[(&str, &[&str])], rest: &str) {
let mut owner: Vec<Option<usize>> = vec![None; self.facets.len()];
for (g, (_, titles)) in groups.iter().enumerate() {
for t in titles.iter() {
if let Some(i) = self.facets.iter().position(|f| f.title() == *t)
&& owner[i].is_none()
{
owner[i] = Some(g);
}
}
}
let mut out: Vec<TabGroup> = groups
.iter()
.enumerate()
.map(|(g, (name, _))| TabGroup {
name: (*name).to_string(),
members: (0..self.facets.len()).filter(|&i| owner[i] == Some(g)).collect(),
last: usize::MAX,
})
.collect();
let orphans: Vec<usize> = (0..self.facets.len()).filter(|&i| owner[i].is_none()).collect();
if !orphans.is_empty() {
match out.iter_mut().find(|g| g.name == rest) {
Some(g) => {
g.members.extend(orphans);
g.members.sort_unstable();
}
None => out.push(TabGroup { name: rest.to_string(), members: orphans, last: usize::MAX }),
}
}
out.retain(|g| !g.members.is_empty());
self.groups = out;
}
pub fn groups(&self) -> &[TabGroup] {
&self.groups
}
pub fn active_group(&self) -> Option<usize> {
self.groups.iter().position(|g| g.members.contains(&self.active))
}
pub fn active_group_name(&self) -> Option<&str> {
self.active_group().map(|g| self.groups[g].name.as_str())
}
pub fn set_active_group(&mut self, name: &str) -> bool {
match self.groups.iter().find(|g| g.name == name) {
Some(g) => {
self.active = g.entry();
true
}
None => false,
}
}
fn note_group_entry(&mut self) {
let active = self.active;
if let Some(g) = self.groups.iter_mut().find(|g| g.members.contains(&active)) {
g.last = active;
}
}
fn draw_group_row(&mut self, ui: &mut Ui) -> bool {
let active_group = self.active_group();
let mut pick: Option<usize> = None;
let nav = navgraph::begin(ui, "groups", navgraph::NavKind::Group, "groups");
navgraph::declare_draw_order(ui, nav);
let row = ui.horizontal_wrapped(|ui| {
for (gi, g) in self.groups.iter().enumerate() {
let r = ui
.selectable_label(active_group == Some(gi), g.name.as_str())
.on_hover_text(format!("{} tabs", g.members.len()));
navgraph::field(ui, &g.name, &r);
if r.clicked() {
pick = Some(gi);
}
}
});
navgraph::end(ui, nav, row.response.rect);
if let Some(gi) = pick {
self.active = self.groups[gi].entry();
}
pick.is_some()
}
pub fn push(&mut self, facet: Box<dyn Facet>) {
self.facets.push(facet);
}
pub fn active(&self) -> usize {
self.active
}
pub fn active_title(&self) -> Option<&str> {
self.facets.get(self.active).map(|f| f.title())
}
pub fn titles(&self) -> Vec<&str> {
self.facets.iter().map(|f| f.title()).collect()
}
pub fn set_active_by_title(&mut self, title: &str) -> bool {
match self.facets.iter().position(|f| f.title() == title) {
Some(i) => {
self.active = i;
true
}
None => false,
}
}
pub fn set_menu_collapsed(&mut self, collapsed: bool) {
if !collapsed {
self.menu_open = false;
}
self.menu_collapsed = collapsed;
}
pub fn menu_collapsed(&self) -> bool {
self.menu_collapsed
}
pub fn menu_open(&self) -> bool {
self.menu_open
}
pub fn set_menu_open(&mut self, open: bool) {
self.menu_open = open;
}
pub fn toggle_menu(&mut self) -> bool {
self.menu_open = !self.menu_open;
self.menu_open
}
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 update_json_for(&mut self, title: &str, msg_json: &str) -> bool {
match self.facets.iter_mut().find(|f| f.title() == title) {
Some(f) => {
f.update_json(msg_json);
true
}
None => false,
}
}
pub fn replace_facet(&mut self, title: &str, facet: Box<dyn Facet>) -> bool {
if let Some(slot) = self.facets.iter_mut().find(|f| f.title() == title) {
*slot = facet;
true
} else {
false
}
}
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.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)
}
pub fn len(&self) -> usize {
self.facets.len()
}
pub fn is_empty(&self) -> bool {
self.facets.is_empty()
}
pub fn wall_ui(&mut self, ui: &mut Ui, cols: usize) {
if let Some(theme) = self.fx.theme() {
set_theme(ui.ctx(), theme);
}
if self.facets.is_empty() {
ui.weak("empty deck");
return;
}
let cols = cols.max(1);
let spacing = ui.spacing().item_spacing.x;
let total_w = ui.available_width();
let cell_w = ((total_w - spacing * (cols as f32 - 1.0)) / cols as f32).max(160.0);
let cell_h = 300.0_f32;
egui::ScrollArea::vertical()
.auto_shrink([false; 2])
.show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
for f in self.facets.iter_mut() {
ui.allocate_ui(egui::vec2(cell_w, cell_h), |ui| {
let cell = ui.group(|ui| {
ui.set_min_size(egui::vec2(cell_w - 12.0, cell_h - 12.0));
ui.vertical(|ui| {
ui.strong(f.title());
ui.separator();
runtrace::ran(&format!("deck.wall:{}", f.title()));
f.ui(ui);
});
});
devid::badge(ui, cell.response.rect, f.component(), f.title());
});
}
});
});
}
pub fn pane_ui(&mut self, idx: usize, ui: &mut Ui) -> bool {
if let Some(theme) = self.fx.theme() {
set_theme(ui.ctx(), theme);
}
let Some(f) = self.facets.get_mut(idx) else {
return false;
};
runtrace::ran(&format!("deck.pane:{}", f.title()));
let rect = ui.max_rect();
let (component, title) = (f.component(), f.title().to_string());
let nav = navgraph::collecting(ui.ctx())
.then(|| devid::badge_text(component, &title).0)
.and_then(|key| navgraph::begin(ui, &key, navgraph::NavKind::Pane, &title));
f.ui(ui);
navgraph::end(ui, nav, rect);
devid::badge(ui, rect, component, &title);
true
}
fn nav_pane_begin(&self, ui: &Ui) -> Option<navgraph::Token> {
if !navgraph::collecting(ui.ctx()) {
return None;
}
let f = self.facets.get(self.active)?;
let (key, _) = devid::badge_text(f.component(), f.title());
navgraph::begin(ui, &key, navgraph::NavKind::Pane, f.title())
}
pub fn pane_title(&self, idx: usize) -> Option<&str> {
self.facets.get(idx).map(|f| f.title())
}
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 (lo, hi) = f.scale_range();
let s = (f.scale() * k).clamp(lo, hi);
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();
let bar = ui.scope(|ui| {
if self.menu_collapsed {
self.draw_menu_drawer(&titles, ui);
} else {
let shown: Vec<usize> = match self.active_group() {
Some(g) => {
self.draw_group_row(ui);
let g = self.active_group().unwrap_or(g);
self.groups[g].members.clone()
}
None => (0..titles.len()).collect(),
};
let nav = navgraph::begin(ui, "tabs", navgraph::NavKind::Group, "tabs");
navgraph::declare_draw_order(ui, nav);
let strip = ui.horizontal_wrapped(|ui| {
for &i in &shown {
let t = &titles[i];
let r = ui.selectable_value(&mut self.active, i, t);
navgraph::field(ui, t, &r);
}
});
navgraph::end(ui, nav, strip.response.rect);
self.note_group_entry();
}
});
self.menu_bar_rect = bar.response.rect;
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());
self.route_component_clipboard(ui);
ui.separator();
let chrome_on = self.fx.chrome;
let chrome_slot = chrome_on.then(|| ui.painter().add(egui::Shape::Noop));
let nav = self.nav_pane_begin(ui);
let content = ui.scope(|ui| {
if let Some(f) = self.facets.get_mut(self.active) {
runtrace::ran(&format!("deck.render:{}", f.title()));
f.ui(ui);
}
});
let content_rect = content.response.rect;
navgraph::end(ui, nav, content_rect);
if let Some(f) = self.facets.get(self.active) {
let (component, title) = (f.component(), f.title().to_string());
devid::badge(ui, content_rect, component, &title);
}
self.content_rect = content_rect;
if let Some(slot) = chrome_slot
&& content_rect.is_positive()
{
let theme = self.effective_theme();
let policy = crate::look::effects_policy(ui);
let style = chrome::ChromeStyle::default().for_policy(policy);
let card = content_rect.expand(6.0);
ui.painter().set(slot, chrome::fill_shape(card, &theme, policy, style));
chrome::edge(ui.painter(), card, &theme, policy, style);
}
if !self.active_kind().is_empty() {
content.response.context_menu(|ui| self.component_menu(ui));
}
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());
self.paint_component_toast(ui);
}
fn draw_menu_drawer(&mut self, titles: &[String], ui: &mut Ui) {
if self.active_group().is_some() && self.draw_group_row(ui) {
self.menu_open = true;
}
let active_label = titles.get(self.active).cloned().unwrap_or_default();
ui.horizontal(|ui| {
let glyph = if self.menu_open { "✕" } else { "≡" };
let hint = if self.menu_open { "close the menu — give the canvas the screen" } else { "choose a view" };
if ui.selectable_label(self.menu_open, glyph).on_hover_text(hint).clicked() {
self.menu_open = !self.menu_open;
}
ui.label(&active_label);
});
if self.menu_open {
let max_h = (ui.ctx().content_rect().height() * 0.6).clamp(160.0, 420.0);
egui::Frame::group(ui.style()).show(ui, |ui| {
egui::ScrollArea::vertical().max_height(max_h).auto_shrink([false, true]).show(ui, |ui| {
let members: Vec<usize> = match self.active_group() {
Some(g) => self.groups[g].members.clone(),
None => (0..titles.len()).collect(),
};
for i in members {
let Some(t) = titles.get(i) else { continue };
if ui.selectable_label(self.active == i, t.as_str()).clicked() {
self.active = i;
self.menu_open = false;
}
}
});
});
}
}
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 active_kind(&self) -> &'static str {
self.facets.get(self.active).map(|f| f.kind()).unwrap_or("")
}
pub fn copy_component_envelope(&self) -> Option<String> {
let f = self.facets.get(self.active)?;
let kind = f.kind();
if kind.is_empty() {
return None;
}
let state = f.portable_state()?;
Some(clipboard::encode_component(kind, &state))
}
pub fn copy_component(&mut self, ctx: &egui::Context) -> bool {
match self.copy_component_envelope() {
Some(env) => {
clipboard::put(ctx, env);
true
}
None => false,
}
}
pub fn paste_component(&mut self, text: &str, now: f64) -> bool {
let Some((kind, state)) = clipboard::decode_component(text) else {
return false;
};
let active_kind = self.active_kind();
if active_kind.is_empty() {
self.toast = Some(("this view doesn't accept a pasted component".to_string(), now));
return false;
}
if kind != active_kind {
self.toast = Some((format!("clipboard holds a `{kind}`, not a `{active_kind}`"), now));
return false;
}
let Some(f) = self.facets.get_mut(self.active) else { return false };
let accepted = f.load_state(&state);
if !accepted {
self.toast = Some((format!("this `{active_kind}` could not adopt the clipboard state"), now));
}
accepted
}
pub fn component_toast(&self) -> Option<&str> {
self.toast.as_ref().map(|(m, _)| m.as_str())
}
pub fn component_menu(&mut self, ui: &mut egui::Ui) {
let km = look::keymap(ui);
let kind = self.active_kind();
let can_clone = !kind.is_empty();
ui.add_enabled_ui(can_clone && self.copy_component_envelope().is_some(), |ui| {
let label = format!("Copy component {}", km.label(Action::Copy, ui.ctx()));
if ui.button(label).clicked() {
self.copy_component(ui.ctx());
ui.close();
}
});
ui.add_enabled_ui(can_clone, |ui| {
let label = format!("Paste component {}", km.label(Action::Paste, ui.ctx()));
if ui.button(label).clicked() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::RequestPaste);
ui.close();
}
});
}
fn route_component_clipboard(&mut self, ui: &mut egui::Ui) {
let now = ui.input(|i| i.time);
let copy_shift = egui::KeyboardShortcut::new(
egui::Modifiers::COMMAND | egui::Modifiers::SHIFT,
egui::Key::C,
);
let paste_shift = egui::KeyboardShortcut::new(
egui::Modifiers::COMMAND | egui::Modifiers::SHIFT,
egui::Key::V,
);
if ui.input_mut(|i| i.consume_shortcut(©_shift)) {
self.copy_component(ui.ctx());
}
if ui.input_mut(|i| i.consume_shortcut(&paste_shift)) {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::RequestPaste);
}
let pastes: Vec<String> = ui.input(|i| {
i.events
.iter()
.filter_map(|e| match e {
egui::Event::Paste(s) if clipboard::decode_component(s).is_some() => Some(s.clone()),
_ => None,
})
.collect()
});
for text in pastes {
self.paste_component(&text, now);
}
if let Some((_, raised)) = self.toast && now - raised > TOAST_SECS {
self.toast = None;
}
}
fn paint_component_toast(&self, ui: &mut egui::Ui) {
let Some((msg, _)) = self.toast.as_ref() else { return };
let th = theme(ui);
let ctx = ui.ctx();
let painter =
ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("facett_deck_component_toast")));
let screen = ctx.content_rect();
let font = FontId::proportional(14.0);
let galley = painter.layout_no_wrap(msg.clone(), font.clone(), th.text);
let pad = vec2(14.0, 10.0); let size = galley.size() + pad * 2.0;
let center = Pos2::new(screen.center().x, screen.max.y - size.y * 0.5 - 18.0);
let rect = Rect::from_center_size(center, size);
painter.rect_filled(rect, 8.0, th.panel_bg);
painter.rect_stroke(rect, 8.0, Stroke::new(1.0_f32, th.panel_stroke), egui::StrokeKind::Inside);
painter.galley(rect.min + pad, galley, th.text);
ctx.request_repaint(); }
pub fn state_json(&self) -> serde_json::Value {
self.state_json_inner(None)
}
pub fn state_json_rolling(&self, cache: &mut DeckStateCache) -> serde_json::Value {
self.state_json_inner(Some(cache))
}
pub fn return_rolling(cache: &mut DeckStateCache, mut deck: serde_json::Value) {
if let Some(serde_json::Value::Object(m)) = deck.get_mut("facets").map(serde_json::Value::take) {
cache.facets = m;
}
}
pub fn serialize_rolling(cache: &mut DeckStateCache, mut state: serde_json::Value, deck_pointer: &str) -> String {
const MARK: &str = "\u{0}facett-rolling-facets\u{0}";
let slot = state.pointer_mut(&format!("{deck_pointer}/facets"));
let Some(slot) = slot else { return serde_json::to_string(&state).unwrap_or_default() };
let serde_json::Value::Object(facets) = std::mem::replace(slot, serde_json::Value::String(MARK.into())) else {
return serde_json::to_string(&state).unwrap_or_default();
};
let head = serde_json::to_string(&state).unwrap_or_default();
cache.text.retain(|k, _| facets.contains_key(k));
let mut body = String::with_capacity(cache.text.values().map(|t| t.len() + 32).sum::<usize>() + 2);
body.push('{');
for (i, (k, v)) in facets.iter().enumerate() {
if i > 0 {
body.push(',');
}
body.push_str(&serde_json::to_string(k).unwrap_or_default());
body.push(':');
match cache.text.get(k) {
Some(t) => body.push_str(t),
None => {
let t = serde_json::to_string(v).unwrap_or_default();
body.push_str(&t);
cache.text.insert(k.clone(), t);
}
}
}
body.push('}');
cache.facets = facets;
let mark = serde_json::to_string(MARK).unwrap_or_default();
head.replacen(&mark, &body, 1)
}
fn state_json_inner(&self, cache: Option<&mut DeckStateCache>) -> serde_json::Value {
let mut caps = serde_json::Map::new();
for f in &self.facets {
caps.insert(f.title().to_string(), f.caps().to_json());
}
let facets = match cache {
None => self.facets.iter().map(|f| (f.title().to_string(), f.state_json())).collect(),
Some(c) => {
let n = self.facets.len();
if c.facets.len() != n || self.facets.iter().any(|f| !c.facets.contains_key(f.title())) {
c.facets.clear();
c.text.clear();
}
let turn = (0..n).map(|k| (c.next + k) % n.max(1)).find(|&i| i != self.active);
c.next = turn.map_or(0, |i| i + 1);
for (i, f) in self.facets.iter().enumerate() {
if i == self.active || Some(i) == turn || !c.facets.contains_key(f.title()) {
c.built += 1;
c.facets.insert(f.title().to_string(), f.state_json());
c.text.remove(f.title());
}
}
std::mem::take(&mut c.facets)
}
};
let mr = self.menu_bar_rect;
let cr = self.content_rect;
serde_json::json!({
"active": self.facets.get(self.active).map(|f| f.title()),
"facets": facets,
"caps": caps,
"menu": {
"collapsed": self.menu_collapsed,
"open": self.menu_open,
"active": self.facets.get(self.active).map(|f| f.title()),
"count": self.facets.len(),
"group": self.active_group_name(),
"groups": self.groups.iter().map(|g| serde_json::json!({
"name": g.name,
"tabs": g.members.len(),
})).collect::<Vec<_>>(),
"bar_rect": [mr.min.x, mr.min.y, mr.width(), mr.height()],
"content_rect": [cr.min.x, cr.min.y, cr.width(), cr.height()],
},
"fx": {
"chrome": self.fx.chrome,
"glow": self.fx.glow,
"glow_layers": self.fx.glow_layers,
"palette": self.fx.palette(),
},
"trace": { "ran": runtrace::snapshot(), "distinct": runtrace::distinct() },
"devid": devid::gate_json(),
})
}
}
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 force_layout_repulsion_is_sub_quadratic() {
const QUADRATIC_GATE: f64 = 8.0;
fn ring_scene(n: usize) -> Scene {
let mut scene = Scene::new();
for i in 0..n {
scene.node(format!("n{i}"), hash_color("n"));
}
for i in 0..n {
scene.edge(i, (i + 1) % n);
if i % 7 == 0 {
scene.edge(i, (i + 137) % n);
}
}
scene
}
let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1024.0, 1024.0));
let run = |n: usize| -> f64 {
let scene = ring_scene(n);
let mut best = f64::INFINITY;
for _ in 0..2 {
let t0 = std::time::Instant::now();
let pos = positions(Layout::Force, &scene, rect);
let ms = t0.elapsed().as_secs_f64() * 1000.0;
assert_eq!(pos.len(), n);
for p in &pos {
assert!(p.x.is_finite() && p.y.is_finite(), "every node finite at n={n}");
assert!(rect.expand(50.0).contains(*p), "every node bounded at n={n}");
}
best = best.min(ms);
}
best
};
const QUAD_N: usize = 250;
const _: () = assert!(
4 * QUAD_N < crate::barnes_hut::BH_THRESHOLD,
"arm 1 must stay entirely on the exact all-pairs path"
);
let q1 = run(QUAD_N);
let q4 = run(4 * QUAD_N);
let quad_ratio = q4 / q1;
assert!(
quad_ratio >= QUADRATIC_GATE,
"RED PROBE FAILED: the exact all-pairs repulsion below BH_THRESHOLD grew only \
{quad_ratio:.3}x from n={QUAD_N} ({q1:.1} ms) to n={} ({q4:.1} ms), against the \
{QUADRATIC_GATE} this test calls quadratic (measured 18.651 on oden). This box cannot \
currently tell quadratic from n log n, so the Barnes-Hut arm below would be blind — \
re-run it somewhere less contended rather than trusting a green.",
4 * QUAD_N
);
const BH_N: usize = 2_000;
const _: () = assert!(BH_N >= crate::barnes_hut::BH_THRESHOLD, "arm 2 must be on the Barnes-Hut path");
let b1 = run(BH_N);
let b4 = run(4 * BH_N);
let bh_ratio = b4 / b1;
assert!(
bh_ratio < QUADRATIC_GATE,
"the force layout is not sub-quadratic: t(4n)/t(n) = {bh_ratio:.3} \
({b1:.1} ms at n={BH_N} -> {b4:.1} ms at n={}), against {quad_ratio:.3} for the \
all-pairs loop the same instrument measured seconds ago. A regression to quadratic \
repulsion above BH_THRESHOLD lands exactly here.",
4 * BH_N
);
}
#[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 })
}
}
struct Counted(&'static str, std::rc::Rc<std::cell::Cell<u32>>);
impl Facet for Counted {
fn title(&self) -> &str {
self.0
}
fn ui(&mut self, _ui: &mut Ui) {}
fn state_json(&self) -> serde_json::Value {
self.1.set(self.1.get() + 1);
serde_json::json!({ "t": self.0, "n": self.1.get() })
}
}
#[test]
fn rolling_state_builds_the_active_tab_and_one_other_per_frame() {
let counts: Vec<_> = (0..5).map(|_| std::rc::Rc::new(std::cell::Cell::new(0u32))).collect();
let names = ["a", "b", "c", "d", "e"];
let mut deck = FacetDeck::new(
names.iter().zip(&counts).map(|(n, c)| Box::new(Counted(n, c.clone())) as Box<dyn Facet>).collect(),
);
let mut cache = DeckStateCache::default();
let first = deck.state_json_rolling(&mut cache);
assert_eq!(cache.built, 5, "the first call builds everything");
let full = deck.state_json();
let keys = |v: &serde_json::Value| v.as_object().unwrap().keys().cloned().collect::<Vec<_>>();
assert_eq!(keys(&first), keys(&full), "same shape as state_json");
assert_eq!(keys(&first["facets"]), keys(&full["facets"]));
FacetDeck::return_rolling(&mut cache, first);
for _ in 0..8 {
let v = deck.state_json_rolling(&mut cache);
FacetDeck::return_rolling(&mut cache, v);
}
assert_eq!(cache.built, 5 + 8 * 2);
let after: Vec<u32> = counts.iter().map(|c| c.get()).collect();
assert_eq!(after, vec![1 + 1 + 8, 1 + 1 + 2, 1 + 1 + 2, 1 + 1 + 2, 1 + 1 + 2]);
assert!(deck.set_active_by_title("d"));
let before = counts[3].get();
let v = deck.state_json_rolling(&mut cache);
assert_eq!(counts[3].get(), before + 1);
assert_eq!(v["facets"]["d"]["n"], before + 1, "the active tab's state is this frame's");
assert_eq!(v["active"], "d");
let v = deck.state_json_rolling(&mut cache);
let plain = serde_json::to_string(&v).unwrap();
let spliced = FacetDeck::serialize_rolling(&mut cache, serde_json::json!({ "deck": v, "x": 1 }), "/deck");
assert_eq!(spliced, format!("{{\"deck\":{plain},\"x\":1}}"));
let v = deck.state_json_rolling(&mut cache);
let plain = serde_json::to_string(&v).unwrap();
assert_eq!(FacetDeck::serialize_rolling(&mut cache, v, ""), plain, "the deck state itself");
drop(deck.state_json_rolling(&mut cache));
let built = cache.built;
let w = deck.state_json_rolling(&mut cache);
assert_eq!(cache.built, built + 5, "nothing to serve from: every tab rebuilt");
assert_eq!(keys(&w["facets"]), keys(&full["facets"]));
}
struct CloneStub {
kind: &'static str,
payload: serde_json::Value,
}
impl Facet for CloneStub {
fn title(&self) -> &str {
self.kind
}
fn ui(&mut self, _ui: &mut Ui) {}
fn state_json(&self) -> serde_json::Value {
serde_json::json!({ "kind": self.kind })
}
fn kind(&self) -> &'static str {
self.kind
}
fn portable_state(&self) -> Option<serde_json::Value> {
Some(self.payload.clone())
}
fn load_state(&mut self, state: &serde_json::Value) -> bool {
self.payload = state.clone();
true
}
}
#[test]
fn the_deck_state_reports_the_devid_gate() {
let deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
let gate = deck.state_json()["devid"].clone();
assert!(!gate.is_null(), "the deck's state_json carries no `devid` gate");
assert_eq!(gate["enabled"], devid::enabled(), "deck reports a gate badge() disobeys");
assert_eq!(gate["debug_assertions"], cfg!(debug_assertions), "wrong profile: {gate}");
}
#[test]
fn component_clone_round_trips_through_the_deck() {
let a = CloneStub { kind: "graphpan", payload: serde_json::json!({ "zoom": 2.0, "pan": [3, 4] }) };
let deck_a = FacetDeck::new(vec![Box::new(a)]);
let env = deck_a.copy_component_envelope().expect("A copies its portable state");
let mut deck_b = FacetDeck::new(vec![Box::new(CloneStub {
kind: "graphpan",
payload: serde_json::json!({ "zoom": 1.0, "pan": [0, 0] }),
})]);
assert!(deck_b.paste_component(&env, 0.0), "same-kind paste is accepted");
assert_eq!(
deck_b.copy_component_envelope(),
deck_a.copy_component_envelope(),
"B adopted A's portable state exactly"
);
assert!(deck_b.component_toast().is_none(), "a successful paste raises no toast");
}
#[test]
fn component_clone_rejects_a_type_mismatch_with_a_toast() {
let table_env = clipboard::encode_component("table", &serde_json::json!({ "rows": 3 }));
let mut deck = FacetDeck::new(vec![Box::new(CloneStub {
kind: "graphpan",
payload: serde_json::json!({ "zoom": 1.0 }),
})]);
let before = deck.copy_component_envelope();
assert!(!deck.paste_component(&table_env, 0.0), "cross-type paste returns false");
assert_eq!(deck.copy_component_envelope(), before, "graphpan state untouched");
let toast = deck.component_toast().expect("mismatch raises a toast");
assert!(toast.contains("table") && toast.contains("graphpan"), "toast names both kinds: {toast}");
}
#[test]
fn component_clone_version_guard_rejects_unknown_v() {
let bad = serde_json::json!({ "facett.kind": "graphpan", "v": 7, "state": { "zoom": 9.0 } }).to_string();
let mut deck = FacetDeck::new(vec![Box::new(CloneStub {
kind: "graphpan",
payload: serde_json::json!({ "zoom": 1.0 }),
})]);
let before = deck.copy_component_envelope();
assert!(!deck.paste_component(&bad, 0.0), "unknown version is not adopted");
assert_eq!(deck.copy_component_envelope(), before, "state untouched by a bad-version paste");
}
#[test]
fn component_clone_opt_out_floor_neither_copies_nor_accepts() {
let mut deck = FacetDeck::new(vec![Box::new(Stub("plain"))]);
assert_eq!(deck.active_kind(), "", "opted out");
assert!(deck.copy_component_envelope().is_none(), "opt-out facet never copies a component");
let env = clipboard::encode_component("table", &serde_json::json!({ "rows": 1 }));
assert!(!deck.paste_component(&env, 0.0), "opt-out facet never adopts a component");
assert!(deck.component_toast().is_some(), "the refusal is surfaced");
}
#[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_state_json_reports_fx_as_data() {
let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
let off = deck.state_json();
assert_eq!(off["fx"]["chrome"].as_bool(), Some(false), "chrome off by default");
assert_eq!(off["fx"]["glow"].as_bool(), Some(false), "glow off by default");
assert!(off["fx"]["palette"].is_null(), "no palette override by default");
deck.fx_mut().chrome = true;
deck.fx_mut().glow = true;
deck.set_palette(2);
let on = deck.state_json();
assert_eq!(on["fx"]["chrome"].as_bool(), Some(true), "chrome wired ON shows in state");
assert_eq!(on["fx"]["glow"].as_bool(), Some(true), "glow wired ON shows in state");
assert_eq!(on["fx"]["palette"].as_u64(), Some(2), "palette override shows in state");
}
#[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 palette_picker_does_not_pin_without_a_user_click() {
let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
assert!(deck.fx().palette().is_none(), "starts with no override");
let ctx = egui::Context::default();
let mut chosen = Some(7usize);
ctx.run_ui(egui::RawInput::default(), |ui| {
egui::CentralPanel::default().show(ui, |ui| {
chosen = deck.palette_picker(ui);
});
}).textures_delta.clear();
assert_eq!(chosen, None, "drawing the picker reports no selection without a click");
assert!(
deck.fx().palette().is_none(),
"drawing the picker must not pin index 0 — that would clobber the host's own theme each frame"
);
}
#[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 = "";
ctx.run_ui(egui::RawInput::default(), |ui| {
egui::CentralPanel::default().show(ui, |ui| {
deck.ui(ui);
seen = theme(ui).name;
});
}).textures_delta.clear();
assert_eq!(seen, Theme::ALL[1]().name, "deck applied its palette override");
}
fn grouped_deck() -> FacetDeck {
FacetDeck::new(vec![
Box::new(Stub("graph")),
Box::new(Stub("grid")),
Box::new(Stub("Graphs")),
Box::new(Stub("map")),
Box::new(Stub("loose")),
])
.with_groups(&[("Grafer", &["Graphs", "graph", "not-here"]), ("Kartor", &["map"]), ("Tom", &[])], "Övrigt")
}
#[test]
fn every_facet_lands_in_exactly_one_group_in_deck_order() {
let deck = grouped_deck();
let names: Vec<&str> = deck.groups().iter().map(|g| g.name.as_str()).collect();
assert_eq!(names, ["Grafer", "Kartor", "Övrigt"]);
assert_eq!(deck.groups()[0].members, vec![0, 2], "deck order, not list order; unknown titles skipped");
assert_eq!(deck.groups()[2].members, vec![1, 4], "grid + loose are orphans");
let mut all: Vec<usize> = deck.groups().iter().flat_map(|g| g.members.clone()).collect();
all.sort_unstable();
assert_eq!(all, (0..deck.len()).collect::<Vec<_>>(), "each facet once");
}
#[test]
fn the_active_group_follows_the_active_tab_and_remembers_its_entry() {
let mut deck = grouped_deck();
assert_eq!(deck.active_group_name(), Some("Grafer"));
assert!(deck.set_active_by_title("map"));
assert_eq!(deck.active_group_name(), Some("Kartor"));
assert!(deck.set_active_group("Övrigt"));
assert_eq!(deck.active_title(), Some("grid"));
assert!(deck.set_active_by_title("loose"));
let ctx = egui::Context::default();
ctx.run_ui(egui::RawInput::default(), |ui| {
egui::CentralPanel::default().show(ui, |ui| deck.ui(ui));
}).textures_delta.clear();
assert!(deck.set_active_group("Grafer"));
assert!(deck.set_active_group("Övrigt"));
assert_eq!(deck.active_title(), Some("loose"));
assert!(!deck.set_active_group("nope"));
let j = deck.state_json();
assert_eq!(j["menu"]["group"], "Övrigt");
assert_eq!(j["menu"]["groups"].as_array().unwrap().len(), 3);
}
#[test]
fn an_ungrouped_deck_keeps_the_flat_strip() {
let deck = FacetDeck::new(vec![Box::new(Stub("a")), Box::new(Stub("b"))]);
assert!(deck.groups().is_empty());
assert_eq!(deck.active_group_name(), None);
assert_eq!(deck.state_json()["menu"]["groups"], serde_json::json!([]));
}
}