facett-warehousedeck/src/lib.rs
//! **facett-warehousedeck** — a multi-pane **dashboard over a lakehouse
//! warehouse**. It views the warehouse as **many components at once**: several
//! **3D graphs** (call graph, dependency graph, world-delta, release lineage,
//! SBOM/vuln) **plus** grids and charts, all visible simultaneously in a wall.
//!
//! It is built **entirely from existing facett widgets** — it never reimplements
//! a viewer. Each pane is one of:
//! [`facett_graph3d::Graph3D`] · [`facett_grid::Grid`] / [`facett_table::Table`] ·
//! [`facett_plot::PlotView`], composed inside a [`facett_core::FacetDeck`]. The
//! deck is rendered in **Wall** mode ([`FacetDeck::wall_ui`]) so every pane (and
//! thus several `Graph3D` instances) draws at once; a **Focus** mode falls back to
//! the deck's tabbed [`FacetDeck::ui`].
//!
//! ## Engine-agnostic
//! Like the rest of facett, this crate takes **data, not a catalog**: a host
//! (nornir's viz) opens its Iceberg warehouse, reads each table, maps the rows to
//! a [`PaneData`] view-model, and hands them in. This crate never depends on
//! iceberg/skade/nornir.
//!
//! ## The registry (table → view-kind)
//! [`WarehouseRegistry`] maps a **table name** to the [`ViewKind`] it should
//! render as, so adding a table is one `register(...)` line. The host queries the
//! registry to decide which [`PaneData`] to build for a table; explicit join-panes
//! (e.g. the call graph from `call_edges`×`symbol_facts`) are added directly.
//!
//! ## FC-2 / FC-9 contract
//! Built on [`facett_core::Elm`]: the complete **observable** control state lives
//! in one serializable [`WarehouseModel`] ([`state`](WarehouseDeck::state)) —
//! title, layout, wall columns, the per-pane metadata, and the focused pane's
//! stable id (FC-5). Every input is a [`Msg`] applied through the single mutation
//! path [`update`](WarehouseDeck::update) (FC-2: layout toggle, column stepper,
//! pane select / drill-in / back-to-wall, focus cycling). [`view`](WarehouseDeck::view)
//! is a **pure** function of `&self` that paints and *returns* the [`Msg`]s the
//! frame produced (FC-9). The composed [`FacetDeck`] of boxed child widgets is
//! **not** serializable, so — like `facett-docview`'s render engine — it lives
//! outside the Model behind a [`RefCell`], and `view` renders it through interior
//! mutability (the child `Facet::ui` calls need `&mut`, which the `&self` view
//! cannot otherwise supply). Its derived introspection is folded back into the
//! richer `state_json` (Form 3 of the bridge macro).
//!
//! ## Robot-testable
//! [`WarehouseDeck`] is itself a [`Facet`], so it drops into nornir's viz and is
//! headless-assertable. [`WarehouseDeck::state_json`] exposes `pane_count`,
//! per-kind counts (`graph3d_count`, `grid_count`, …), the focused pane
//! (`selected`), and a `panes` array carrying each pane's `nodes`/`edges`/`rows`/
//! `series`, plus the composed deck state (whose `trace.ran` lists
//! `deck.wall:<title>` for every pane that drew).
use arrow_array::RecordBatch;
use egui::Ui;
use facett_core::{FacetCaps, FacetDeck};
use facett_graph3d::{DecorPolicy, Graph3D, Layout3D};
use facett_grid::Grid;
use facett_plot::{ChartKind, PlotView};
use facett_table::Table;
use serde::{Deserialize, Serialize};
use std::cell::RefCell;
use std::collections::BTreeMap;
// Re-export the widget-facing types a host needs so it can build view-models +
// render the deck without depending on each leaf crate directly.
pub use egui::Color32;
pub use facett_core::{hash_color, Facet};
pub use facett_graph3d::Layout3D as GraphLayout;
pub use facett_plot::ChartKind as PlotKind;
/// How a warehouse table is rendered. The registry maps each table to one of
/// these; [`PaneData`] carries the matching view-model.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub enum ViewKind {
/// A [`Graph3D`] — nodes + edges (call graph, dependency graph, …).
Graph3D,
/// A [`Grid`] (rich, Arrow-backed) over the table's rows.
Grid,
/// A [`PlotView`] line/bars chart (timelines, throughput, …).
Chart,
/// A simple text/blob viewer (rendered as a one-column [`Table`]).
BlobViewer,
}
impl ViewKind {
pub fn as_str(self) -> &'static str {
match self {
ViewKind::Graph3D => "graph3d",
ViewKind::Grid => "grid",
ViewKind::Chart => "chart",
ViewKind::BlobViewer => "blob",
}
}
}
/// A 3D-graph view-model: `(label, color)` nodes + index edges + a layout. Maps
/// directly onto [`Graph3D::new`].
#[derive(Clone)]
pub struct GraphSpec {
pub nodes: Vec<(String, Color32)>,
pub edges: Vec<(usize, usize)>,
pub layout: Layout3D,
}
impl GraphSpec {
pub fn new(nodes: Vec<(String, Color32)>, edges: Vec<(usize, usize)>) -> Self {
Self { nodes, edges, layout: Layout3D::Force }
}
pub fn with_layout(mut self, layout: Layout3D) -> Self {
self.layout = layout;
self
}
}
/// A chart view-model: named `(x, y)` series + the chart kind. Maps onto
/// [`PlotView`].
#[derive(Clone)]
pub struct ChartSpec {
pub kind: ChartKind,
pub series: Vec<(String, Vec<(f64, f64)>)>,
}
impl ChartSpec {
pub fn line(series: Vec<(String, Vec<(f64, f64)>)>) -> Self {
Self { kind: ChartKind::Line, series }
}
pub fn bars(series: Vec<(String, Vec<(f64, f64)>)>) -> Self {
Self { kind: ChartKind::Bars, series }
}
}
/// The data a host hands per pane. The variant determines the widget; its
/// [`ViewKind`] is [`PaneData::kind`].
pub enum PaneData {
/// A 3D graph.
Graph(GraphSpec),
/// Raw Arrow frames straight from a table scan (any number of batches; they
/// are concatenated into one [`Grid`]).
Grid(Vec<RecordBatch>),
/// A chart.
Chart(ChartSpec),
/// Free text / a blob column.
Blob { text: String },
}
impl PaneData {
pub fn kind(&self) -> ViewKind {
match self {
PaneData::Graph(_) => ViewKind::Graph3D,
PaneData::Grid(_) => ViewKind::Grid,
PaneData::Chart(_) => ViewKind::Chart,
PaneData::Blob { .. } => ViewKind::BlobViewer,
}
}
}
/// **table name → [`ViewKind`]** — the registry that makes adding a table trivial.
/// The host queries [`kind_for`](Self::kind_for) to decide how to render a table
/// it found in the catalog; explicit join-panes are added directly to the deck.
#[derive(Clone, Debug, Default)]
pub struct WarehouseRegistry {
kinds: BTreeMap<String, ViewKind>,
}
impl WarehouseRegistry {
/// An empty registry.
pub fn new() -> Self {
Self::default()
}
/// Register / override the view-kind for a table.
pub fn register(&mut self, table: impl Into<String>, kind: ViewKind) -> &mut Self {
self.kinds.insert(table.into(), kind);
self
}
/// The view-kind for `table`, if registered.
pub fn kind_for(&self, table: &str) -> Option<ViewKind> {
self.kinds.get(table).copied()
}
/// Every registered table name (sorted).
pub fn tables(&self) -> Vec<&str> {
self.kinds.keys().map(String::as_str).collect()
}
pub fn len(&self) -> usize {
self.kinds.len()
}
pub fn is_empty(&self) -> bool {
self.kinds.is_empty()
}
/// The default mapping for the **nornir / modgunn** warehouse (the single
/// `nornir` namespace). Covers the initial slice and is extended by one
/// `register(...)` line per new table. Join-driven graphs (call graph, dep
/// graph, world-delta, release lineage, SBOM/vuln) are added explicitly by the
/// host; this map is the per-table default for everything else.
pub fn default_nordisk() -> Self {
let mut r = Self::new();
use ViewKind::*;
// ── graph-shaped tables (edges / relationships) ──────────────────────
for t in [
"call_edges",
"dep_graph_edges",
"dep_edges",
"world_snapshots",
"behavior_deltas",
"architecture_wiring",
"scip_call_edges",
] {
r.register(t, Graph3D);
}
// ── tabular tables (grids) ───────────────────────────────────────────
for t in [
"bench_runs",
"bench_results",
"test_outcomes",
"test_results",
"vuln_findings",
"cve_advisories",
"tripwire_events",
"sbom_components",
"symbol_facts",
"scip_occurrences",
"scip_symbols",
"release_lineage",
"release_commits",
"knowledge_scans",
"mcp_requests",
"agent_model_runs",
"surface_coverage",
"coverage",
"coverage_fn",
"saga_events",
"viz_actions",
"ui_snapshots",
"embeddings",
] {
r.register(t, Grid);
}
// ── time-series / event tables (charts) ──────────────────────────────
for t in [
"release_events",
"airgap_events",
"funnel_events",
"bench_telemetry",
"git_heat_facts",
] {
r.register(t, Chart);
}
r
}
}
/// Deck render layout.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub enum DeckLayout {
/// Every pane visible at once, in a grid of `cols` columns (the default;
/// this is what makes several `Graph3D` show simultaneously).
Wall,
/// One pane at a time, tabbed (the deck's native [`FacetDeck::ui`]).
Focus,
}
impl DeckLayout {
fn as_str(self) -> &'static str {
match self {
DeckLayout::Wall => "wall",
DeckLayout::Focus => "focus",
}
}
}
/// Per-pane metadata, part of the observable [`WarehouseModel`] and mirrored into
/// `state_json` for headless assertions.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PaneInfo {
/// The source table (or join label).
pub table: String,
pub kind: ViewKind,
/// The pane title (== the `FacetDeck` key; unique). This is the **stable id**
/// selection / drill-in keys off (FC-5).
pub title: String,
/// Graph panes: node / edge counts.
pub nodes: usize,
pub edges: usize,
/// Grid / blob panes: row count.
pub rows: usize,
/// Chart panes: number of series.
pub series: usize,
}
/// **The complete observable, serializable Model (FC-1 / FC-3)** — the control
/// state of a [`WarehouseDeck`], round-trippable through serde. The composed
/// [`FacetDeck`] of boxed child widgets is **not** serializable, so it lives
/// outside this struct (in an interior-mutable cell on [`WarehouseDeck`]); the
/// richer `state_json` folds its derived signals back in. The **only** thing that
/// mutates this is [`WarehouseDeck::update`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct WarehouseModel {
/// Deck title (the tab/panel key).
pub title: String,
/// Wall (all panes at once) vs Focus (tabbed, one at a time).
pub layout: DeckLayout,
/// Wall column count (clamped `1..=6`).
pub cols: usize,
/// Per-pane metadata, in deck order.
pub panes: Vec<PaneInfo>,
/// Stable id ([`PaneInfo::title`]) of the focused pane — drives the Focus tab
/// and the pane-navigator highlight. Keyed on the domain id, not an index
/// (FC-5), so add/reorder preserves the focus.
pub selected: Option<String>,
}
/// A robot-/CLI-addressable control message — the named boundary a headless
/// driver (or the toolbar) drives the deck through, the same effect the toolbar
/// gestures produce. Applied by [`WarehouseDeck::update`]; the pure
/// [`view`](WarehouseDeck::view) *returns* these (it never mutates the Model).
/// Serde-tagged (`snake_case`) so the robot `update_json` seam parses one message
/// straight off the wire.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Msg {
/// Switch the deck layout (Wall = all panes at once, Focus = tabbed).
SetLayout(DeckLayout),
/// Set the wall column count (clamped `1..=6`).
SetCols(usize),
/// Bump the wall column count up one (the `+` stepper), capped at 6.
IncCols,
/// Drop the wall column count down one (the `−` stepper), floored at 1.
DecCols,
/// Focus/select the pane with this stable id ([`PaneInfo::title`]); in Focus
/// mode this is the active tab. Unknown id = no-op (FC-5).
SelectPane(String),
/// Clear the pane focus (no active selection).
ClearSelection,
/// **Drill in**: focus the pane with this stable id **and** switch to Focus
/// layout (the wall→focus gesture). Unknown id = no-op.
DrillInto(String),
/// Return from a drill-in to the Wall overview (Focus→Wall; keeps the focus).
BackToWall,
/// Move the focus to the next pane (wraps; keyboard/scroll-through). No-op on
/// an empty deck.
FocusNext,
/// Move the focus to the previous pane (wraps). No-op on an empty deck.
FocusPrev,
}
/// Side work as data (FC-8). The deck does no host I/O — every [`Msg`] mutates
/// only the in-memory [`WarehouseModel`] (or the interior-mutable child deck via
/// the pure view) — so this is uninhabited on purpose: the type-checked statement
/// that [`WarehouseDeck::update`] never asks the host to do anything.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}
/// A [`FacetDeck`] of N warehouse panes, rendered as a wall (all at once) or
/// focused (tabbed). Implements [`Facet`], so a host drops it straight into its
/// viz and gets headless robot-testing for free.
///
/// The observable, serializable control state lives in [`state`](Self::state) (a
/// [`WarehouseModel`]); the composed [`FacetDeck`] of boxed child widgets is the
/// non-serializable **render engine**, kept in an interior-mutable [`RefCell`] so
/// the FC-9 pure [`view`](Self::view) (which takes `&self`) can render the child
/// facets (whose `ui` needs `&mut`). The child deck is the honest **residual**
/// beyond the Model: not observable Model state, but reachable read-only via
/// [`deck`](Self::deck) and mirrored into `state_json`'s `deck` key.
pub struct WarehouseDeck {
/// The observable, serializable Model (FC-3).
state: WarehouseModel,
/// The composed child widgets — not serializable; interior-mutable so the pure
/// view can drive each pane's `&mut Facet::ui`.
deck: RefCell<FacetDeck>,
}
impl WarehouseDeck {
/// An empty deck; add panes with [`add`](Self::add).
pub fn new(title: impl Into<String>) -> Self {
Self {
state: WarehouseModel {
title: title.into(),
layout: DeckLayout::Wall,
cols: 2,
panes: Vec::new(),
selected: None,
},
deck: RefCell::new(FacetDeck::new(Vec::new())),
}
}
/// Builder: set the initial layout.
pub fn with_layout(mut self, layout: DeckLayout) -> Self {
self.state.layout = layout;
self
}
/// Builder: set the wall column count (clamped to 1..=6).
pub fn with_cols(mut self, cols: usize) -> Self {
self.state.cols = cols.clamp(1, 6);
self
}
/// Set the layout (routes through the single mutation path).
pub fn set_layout(&mut self, layout: DeckLayout) {
let _ = self.update(Msg::SetLayout(layout));
}
pub fn layout(&self) -> DeckLayout {
self.state.layout
}
/// **FC-3** — read the complete observable Model at any frame boundary.
pub fn state(&self) -> &WarehouseModel {
&self.state
}
/// Add a pane titled `title`, building the matching widget from `data`. The
/// title is made unique within the deck (`FacetDeck` keys on it).
pub fn add(&mut self, title: impl Into<String>, data: PaneData) -> &mut Self {
let title = self.unique_title(title.into());
let kind = data.kind();
let mut info =
PaneInfo { table: title.clone(), kind, title: title.clone(), nodes: 0, edges: 0, rows: 0, series: 0 };
let facet: Box<dyn Facet> = match data {
PaneData::Graph(spec) => {
info.nodes = spec.nodes.len();
info.edges = spec.edges.len();
Box::new(
Graph3D::new(spec.nodes, spec.edges, spec.layout)
.with_title(title.clone())
.with_decor(DecorPolicy::Full),
)
}
PaneData::Grid(batches) => {
info.rows = batches.iter().map(|b| b.num_rows()).sum();
build_grid(&title, batches)
}
PaneData::Chart(spec) => {
info.series = spec.series.len();
info.rows = spec.series.iter().map(|(_, pts)| pts.len()).sum();
Box::new(match spec.kind {
ChartKind::Line => PlotView::line(title.clone(), spec.series),
ChartKind::Bars => PlotView::bars(title.clone(), spec.series),
})
}
PaneData::Blob { text } => {
let mut t = Table::new(title.clone(), vec!["blob".to_string()]);
let mut n = 0;
for line in text.lines() {
t.push_row(vec![line.to_string()]);
n += 1;
}
info.rows = n;
Box::new(t)
}
};
self.deck.get_mut().push(facet);
self.state.panes.push(info);
self
}
/// Add a pane, recording `table` as the source provenance while titling the
/// pane `label` (e.g. a join: label `"call graph"`, table `"call_edges"`).
pub fn add_for(&mut self, label: impl Into<String>, table: impl Into<String>, data: PaneData) -> &mut Self {
self.add(label, data);
if let Some(p) = self.state.panes.last_mut() {
p.table = table.into();
}
self
}
fn unique_title(&self, base: String) -> String {
if !self.state.panes.iter().any(|p| p.title == base) {
return base;
}
let mut i = 2;
loop {
let cand = format!("{base} #{i}");
if !self.state.panes.iter().any(|p| p.title == cand) {
return cand;
}
i += 1;
}
}
fn has_pane(&self, id: &str) -> bool {
self.state.panes.iter().any(|p| p.title == id)
}
/// Index of a pane by its stable id ([`PaneInfo::title`]).
fn index_of(&self, id: &str) -> Option<usize> {
self.state.panes.iter().position(|p| p.title == id)
}
// ── introspection ────────────────────────────────────────────────────────
pub fn pane_count(&self) -> usize {
self.state.panes.len()
}
pub fn count_of(&self, kind: ViewKind) -> usize {
self.state.panes.iter().filter(|p| p.kind == kind).count()
}
pub fn graph3d_count(&self) -> usize {
self.count_of(ViewKind::Graph3D)
}
pub fn grid_count(&self) -> usize {
self.count_of(ViewKind::Grid)
}
pub fn chart_count(&self) -> usize {
self.count_of(ViewKind::Chart)
}
pub fn panes(&self) -> &[PaneInfo] {
&self.state.panes
}
/// The pane titles, in deck order.
pub fn titles(&self) -> Vec<&str> {
self.state.panes.iter().map(|p| p.title.as_str()).collect()
}
/// The focused pane's stable id ([`PaneInfo::title`]), if any.
pub fn selected(&self) -> Option<&str> {
self.state.selected.as_deref()
}
/// Borrow the underlying deck read-only (e.g. to inspect a focused pane).
pub fn deck(&self) -> std::cell::Ref<'_, FacetDeck> {
self.deck.borrow()
}
/// Mutable access to the underlying deck (e.g. to replace a pane's data).
pub fn deck_mut(&mut self) -> &mut FacetDeck {
self.deck.get_mut()
}
/// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
/// (always empty) [`Effect`]s the host should run. The toolbar routes its
/// [`Msg`]s here too, so live == headless.
pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
match msg {
Msg::SetLayout(l) => self.state.layout = l,
Msg::SetCols(c) => self.state.cols = c.clamp(1, 6),
Msg::IncCols => self.state.cols = (self.state.cols + 1).min(6),
Msg::DecCols => self.state.cols = self.state.cols.saturating_sub(1).max(1),
Msg::SelectPane(id) => {
if self.has_pane(&id) {
self.state.selected = Some(id);
}
}
Msg::ClearSelection => self.state.selected = None,
Msg::DrillInto(id) => {
if self.has_pane(&id) {
self.state.selected = Some(id);
self.state.layout = DeckLayout::Focus;
}
}
Msg::BackToWall => self.state.layout = DeckLayout::Wall,
Msg::FocusNext => self.step_focus(1),
Msg::FocusPrev => self.step_focus(-1),
}
Vec::new()
}
/// Move the focus `delta` panes along (wrapping). Starts at the current focus
/// (or the first/last pane if none). No-op on an empty deck.
fn step_focus(&mut self, delta: isize) {
let n = self.state.panes.len();
if n == 0 {
return;
}
let cur = self.state.selected.as_deref().and_then(|id| self.index_of(id));
let next = match cur {
Some(i) => (i as isize + delta).rem_euclid(n as isize) as usize,
// No focus yet: FocusNext lands on the first pane, FocusPrev the last.
None if delta >= 0 => 0,
None => n - 1,
};
self.state.selected = Some(self.state.panes[next].title.clone());
}
/// The toolbar (title · pane count · layout toggle · column stepper) + a pane
/// navigator (select / drill-in / focus-cycle). **Pure**: it reads the Model
/// and *returns* the [`Msg`]s the frame produced — it never mutates `self`.
fn toolbar(&self, ui: &mut Ui) -> Vec<Msg> {
let mut msgs = Vec::new();
ui.horizontal_wrapped(|ui| {
ui.strong(&self.state.title);
ui.separator();
ui.weak(format!(
"{} panes · {} graphs · {} grids · {} charts",
self.pane_count(),
self.graph3d_count(),
self.grid_count(),
self.chart_count(),
));
ui.separator();
let wall = self.state.layout == DeckLayout::Wall;
if ui.selectable_label(wall, "▦ Wall").clicked() {
msgs.push(Msg::SetLayout(DeckLayout::Wall));
}
if ui.selectable_label(!wall, "❏ Focus").clicked() {
msgs.push(Msg::SetLayout(DeckLayout::Focus));
}
if wall {
ui.separator();
ui.label("cols:");
if ui.small_button("−").clicked() {
msgs.push(Msg::DecCols);
}
ui.monospace(self.state.cols.to_string());
if ui.small_button("+").clicked() {
msgs.push(Msg::IncCols);
}
}
});
// ── pane navigator: select / drill-in / cycle, keyed on the stable id ──
if !self.state.panes.is_empty() {
ui.horizontal_wrapped(|ui| {
if self.state.layout == DeckLayout::Focus && ui.small_button("◧ Overview").clicked() {
msgs.push(Msg::BackToWall);
}
if ui.small_button("‹").on_hover_text("focus previous pane").clicked() {
msgs.push(Msg::FocusPrev);
}
if ui.small_button("›").on_hover_text("focus next pane").clicked() {
msgs.push(Msg::FocusNext);
}
ui.separator();
let sel = self.state.selected.as_deref();
for p in &self.state.panes {
let on = sel == Some(p.title.as_str());
// Clicking a pane drills in: it focuses the pane AND switches to
// Focus layout — the "click a pane in the wall to open it" gesture.
if ui.selectable_label(on, &p.title).clicked() {
msgs.push(Msg::DrillInto(p.title.clone()));
}
}
});
}
msgs
}
/// **FC-9 pure view** — a function of `&self`: it paints the toolbar + the
/// composed deck and *returns* the [`Msg`]s the frame produced (toolbar
/// gestures, plus a tab click in Focus mode reconciled into a
/// [`Msg::SelectPane`]). It does **not** mutate the Model; the child deck it
/// renders through the [`RefCell`] is the render engine, not Model state, so a
/// headless driver feeding the same [`Msg`]s reproduces the live selection.
/// **Render ONE pane of the deck into the caller's `Ui`** — the seam that lets a
/// `facett_app::scene` host make the deck wall out of REAL panes.
///
/// [`view`](Self::view) draws a toolbar and then the whole wall inside one `Ui`.
/// A scene host mounting that gets N panes' worth of content sharing ONE pane's
/// allotment, with one chrome bar and one published rect for the lot — the deck
/// emulating a scene inside a scene. nornir's WarehouseDeck tab is exactly that
/// today, which is why it cannot declare a `Layout::Grid` of `Content::Slot`s over
/// its own panes.
///
/// With this, a host declares one scene pane per [`panes`](Self::panes) entry and
/// calls `view_pane(i, ui)` from its `SceneHost::slot`. Each pane then gets its own
/// rect, its own chrome (fold / minimise / maximise) and its own address — and one
/// pane's content can no longer displace another's, because they no longer share a
/// flow.
///
/// Returns no `Msg` today: selection and layout are toolbar concerns, and a host
/// driving panes directly owns its own arrangement. The signature returns
/// `Vec<Msg>` anyway so a future per-pane interaction is additive.
///
/// An out-of-range `idx` draws a visible marker rather than nothing — a host whose
/// pane tree has drifted from the deck's contents should SEE the drift, not a
/// blank rect that reads as "this pane has no data".
pub fn view_pane(&self, idx: usize, ui: &mut Ui) -> Vec<Msg> {
let drew = self.deck.borrow_mut().pane_ui(idx, ui);
if !drew {
ui.weak(format!("no deck pane at index {idx} (deck holds {})", self.pane_count()));
}
Vec::new()
}
pub fn view(&self, ui: &mut Ui) -> Vec<Msg> {
facett_core::navgraph::declare_own_draw_order(ui); // atoms follow the data order (navgraph §9)
let mut msgs = self.toolbar(ui);
ui.separator();
let mut deck = self.deck.borrow_mut();
match self.state.layout {
DeckLayout::Wall => deck.wall_ui(ui, self.state.cols),
DeckLayout::Focus => {
// Sync the child deck's active tab to OUR focused id before it draws
// its tab bar, so the Model owns the selection (FC-5). After it draws,
// reconcile a tab click back into a `Msg` so live == headless.
if let Some(id) = self.state.selected.as_deref() {
deck.set_active_by_title(id);
}
deck.ui(ui);
let active = deck.active_title().map(str::to_string);
if active.as_deref() != self.state.selected.as_deref() && let Some(a) = active {
// A tab click (or the empty→first default) changed the active
// pane — record it through the single mutation path.
msgs.push(Msg::SelectPane(a));
}
}
}
drop(deck);
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-warehousedeck::WarehouseDeck::view",
"ui_render",
// OK = the deck holds exactly the panes we recorded.
self.deck.borrow().len() == self.state.panes.len(),
&format!(
"layout={} panes={} graphs={} grids={} charts={}",
self.state.layout.as_str(),
self.pane_count(),
self.graph3d_count(),
self.grid_count(),
self.chart_count(),
),
);
msgs
}
}
/// Build a [`Grid`] over one or more scan batches (concatenated). Empty scan → an
/// empty one-line placeholder table.
fn build_grid(title: &str, batches: Vec<RecordBatch>) -> Box<dyn Facet> {
if batches.is_empty() {
return Box::new(Table::new(title.to_string(), Vec::new()));
}
let schema = batches[0].schema();
let one = arrow_select::concat::concat_batches(&schema, &batches).unwrap_or_else(|_| batches[0].clone());
Box::new(Grid::from_record_batch(title.to_string(), &one))
}
// ── the Elm contract (FC-2 / FC-9) ─────────────────────────────────────────────
impl facett_core::Elm for WarehouseDeck {
type Model = WarehouseModel;
type Msg = Msg;
type Effect = Effect;
fn title(&self) -> &str {
&self.state.title
}
fn state(&self) -> &WarehouseModel {
&self.state
}
fn update(&mut self, msg: Msg) -> Vec<Effect> {
WarehouseDeck::update(self, msg)
}
fn view(&self, ui: &mut Ui) -> Vec<Msg> {
WarehouseDeck::view(self, ui)
}
}
// The bridge macro writes `impl Facet for WarehouseDeck` from the `Elm` impl:
// `title`, the FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the extra
// overrides below. **Form 3** (`custom_state_json`) because the deck publishes a
// RICHER `state_json` than plain `serde(state())` — it folds in the derived
// per-kind counts AND the non-Model child deck's own `state_json` (whose
// `trace.ran` proves each pane drew), exactly the observable shape the crate's own
// tests (and a host's mega-matrix) assert. The Model's serde-serialization
// (title/layout/cols/panes/selected) is preserved for the headless round-trip.
facett_core::impl_facet_via_elm!(WarehouseDeck, custom_state_json, {
fn state_json(&self) -> serde_json::Value {
let s = &self.state;
serde_json::json!({
"title": s.title,
"layout": s.layout.as_str(),
"cols": s.cols,
"pane_count": self.pane_count(),
"graph3d_count": self.graph3d_count(),
"grid_count": self.grid_count(),
"chart_count": self.chart_count(),
"blob_count": self.count_of(ViewKind::BlobViewer),
"selected": s.selected,
"panes": s.panes.iter().map(|p| serde_json::json!({
"table": p.table,
"title": p.title,
"kind": p.kind.as_str(),
"nodes": p.nodes,
"edges": p.edges,
"rows": p.rows,
"series": p.series,
})).collect::<Vec<_>>(),
// The composed per-pane state (each widget's own `state_json`) + the
// `trace.ran` ledger (lists `deck.wall:<title>` for every pane drawn).
"deck": self.deck.borrow().state_json(),
})
}
/// The robot / headless-drive seam (FC-2): parse one serde-tagged [`Msg`] and
/// apply it through the single [`Elm::update`](facett_core::Elm::update)
/// mutation path. The `impl_facet_via_elm!` bridge does NOT generate an
/// `update_json`, so without this override the JSON drive seam is a dead no-op
/// (a headless robot's layout / focus messages mutate nothing). Malformed /
/// unknown JSON is ignored (never a panic).
fn update_json(&mut self, msg_json: &str) {
if let Ok(msg) = serde_json::from_str::<Msg>(msg_json) {
let _ = <Self as facett_core::Elm>::update(self, msg);
}
}
/// The focused pane's stable id (FC-5), or null. Mirrors the `selectable` cap.
fn selection_json(&self) -> serde_json::Value {
match self.selected() {
Some(id) => serde_json::json!(id),
None => serde_json::Value::Null,
}
}
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().selectable().resizable()
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
});
#[cfg(test)]
mod tests {
use super::*;
// `state()`, `state_json`, `selection_json` resolve through the crate's own
// inherent methods + the `Facet` re-export in `super::*`; only `harness` is new.
use facett_core::harness;
fn n(label: &str) -> (String, Color32) {
(label.to_string(), Color32::from_rgb(120, 200, 255))
}
/// A deck with two 3D graphs + one chart (no Arrow needed) — the headline
/// "multiple 3D graphs simultaneously" shape.
fn graph_heavy_deck() -> WarehouseDeck {
let mut d = WarehouseDeck::new("🏢 Warehouse");
d.add_for(
"call graph",
"call_edges",
PaneData::Graph(GraphSpec::new(
vec![n("main"), n("run"), n("scan"), n("write")],
vec![(0, 1), (1, 2), (2, 3)],
)),
);
d.add_for(
"dependency graph",
"dep_graph_edges",
PaneData::Graph(GraphSpec::new(vec![n("nornir"), n("facett"), n("skade")], vec![(0, 1), (0, 2)])),
);
d.add_for(
"release timeline",
"release_events",
PaneData::Chart(ChartSpec::line(vec![("commits".into(), vec![(0.0, 1.0), (1.0, 4.0), (2.0, 9.0)])])),
);
d
}
#[test]
fn builds_multiple_graph3d_panes_simultaneously() {
let d = graph_heavy_deck();
assert_eq!(d.pane_count(), 3);
assert!(d.graph3d_count() >= 2, "must hold >=2 Graph3D panes");
assert_eq!(d.chart_count(), 1);
// The deck owns exactly the panes we built.
assert_eq!(d.deck().len(), 3);
}
#[test]
fn wall_render_draws_every_pane() {
let mut d = graph_heavy_deck();
let r = harness::headless_render(&mut d);
assert!(r.drew(), "the wall should tessellate to vertices");
assert_eq!(r.state["pane_count"], 3);
assert_eq!(r.state["graph3d_count"], 2);
assert_eq!(r.state["layout"], "wall");
// Every pane drew → its runtrace entry is present (`ran` is a name→count map).
let ran = r.state["deck"]["trace"]["ran"].as_object().cloned().unwrap_or_default();
assert!(ran.contains_key("deck.wall:call graph"), "call graph pane drew: {ran:?}");
assert!(ran.contains_key("deck.wall:dependency graph"));
assert!(ran.contains_key("deck.wall:release timeline"));
}
#[test]
fn focus_layout_uses_tabbed_deck() {
let mut d = graph_heavy_deck().with_layout(DeckLayout::Focus);
let r = harness::headless_render(&mut d);
assert_eq!(r.state["layout"], "focus");
assert!(r.drew());
}
#[test]
fn grid_pane_reports_rows() {
use arrow_array::{Int64Array, StringArray};
use arrow_schema::{DataType, Field, Schema};
use std::sync::Arc;
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("id", DataType::Int64, false),
Field::new("name", DataType::Utf8, false),
])),
vec![
Arc::new(Int64Array::from(vec![1, 2, 3])),
Arc::new(StringArray::from(vec!["a", "b", "c"])),
],
)
.unwrap();
let mut d = WarehouseDeck::new("wh");
d.add_for("bench", "bench_runs", PaneData::Grid(vec![batch]));
assert_eq!(d.grid_count(), 1);
let j = d.state_json();
let pane = &j["panes"][0];
assert_eq!(pane["kind"], "grid");
assert_eq!(pane["rows"], 3);
assert_eq!(pane["table"], "bench_runs");
}
#[test]
fn registry_maps_tables_to_kinds() {
let r = WarehouseRegistry::default_nordisk();
assert_eq!(r.kind_for("call_edges"), Some(ViewKind::Graph3D));
assert_eq!(r.kind_for("dep_graph_edges"), Some(ViewKind::Graph3D));
assert_eq!(r.kind_for("bench_runs"), Some(ViewKind::Grid));
assert_eq!(r.kind_for("vuln_findings"), Some(ViewKind::Grid));
assert_eq!(r.kind_for("git_heat_facts"), Some(ViewKind::Chart));
assert_eq!(r.kind_for("nonexistent"), None);
// Extensible.
let mut r2 = WarehouseRegistry::new();
r2.register("my_table", ViewKind::Grid);
assert_eq!(r2.kind_for("my_table"), Some(ViewKind::Grid));
}
#[test]
fn unique_titles_for_duplicate_panes() {
let mut d = WarehouseDeck::new("wh");
d.add("dup", PaneData::Blob { text: "x".into() });
d.add("dup", PaneData::Blob { text: "y".into() });
let titles = d.titles();
assert_eq!(titles, vec!["dup", "dup #2"]);
assert_eq!(d.deck().len(), 2);
}
// ── FC-2 / FC-9 Elm transitions ────────────────────────────────────────────
#[test]
fn layout_and_cols_msgs_mutate_only_via_update() {
let mut d = graph_heavy_deck();
assert_eq!(d.layout(), DeckLayout::Wall);
assert_eq!(d.state().cols, 2);
harness::drive(&mut d, [Msg::SetLayout(DeckLayout::Focus)]);
assert_eq!(d.layout(), DeckLayout::Focus);
harness::drive(&mut d, [Msg::BackToWall]);
assert_eq!(d.layout(), DeckLayout::Wall);
// Column stepper bounds (clamped 1..=6).
harness::drive(&mut d, [Msg::IncCols, Msg::IncCols, Msg::IncCols, Msg::IncCols, Msg::IncCols]);
assert_eq!(d.state().cols, 6, "clamped at 6");
harness::drive(&mut d, std::iter::repeat_n(Msg::DecCols, 10));
assert_eq!(d.state().cols, 1, "floored at 1");
harness::drive(&mut d, [Msg::SetCols(99)]);
assert_eq!(d.state().cols, 6);
harness::drive(&mut d, [Msg::SetCols(0)]);
assert_eq!(d.state().cols, 1);
}
#[test]
fn select_and_drill_key_on_stable_id() {
let mut d = graph_heavy_deck();
assert_eq!(d.selected(), None);
// Unknown id is a no-op (FC-5).
harness::drive(&mut d, [Msg::SelectPane("nope".into())]);
assert_eq!(d.selected(), None);
// Select by stable pane title.
harness::drive(&mut d, [Msg::SelectPane("dependency graph".into())]);
assert_eq!(d.selected(), Some("dependency graph"));
assert_eq!(d.layout(), DeckLayout::Wall, "plain select does not change layout");
// Drill-in selects AND switches to Focus.
harness::drive(&mut d, [Msg::DrillInto("release timeline".into())]);
assert_eq!(d.selected(), Some("release timeline"));
assert_eq!(d.layout(), DeckLayout::Focus);
// Unknown drill target is a no-op.
harness::drive(&mut d, [Msg::DrillInto("ghost".into())]);
assert_eq!(d.selected(), Some("release timeline"));
harness::drive(&mut d, [Msg::ClearSelection]);
assert_eq!(d.selected(), None);
}
#[test]
fn focus_next_prev_cycle_wraps() {
let mut d = graph_heavy_deck();
// From no focus, Next lands on the first pane.
harness::drive(&mut d, [Msg::FocusNext]);
assert_eq!(d.selected(), Some("call graph"));
harness::drive(&mut d, [Msg::FocusNext]);
assert_eq!(d.selected(), Some("dependency graph"));
harness::drive(&mut d, [Msg::FocusNext, Msg::FocusNext]);
assert_eq!(d.selected(), Some("call graph"), "wraps past the end");
harness::drive(&mut d, [Msg::FocusPrev]);
assert_eq!(d.selected(), Some("release timeline"), "wraps past the start");
// Empty deck: cycling is a no-op.
let mut empty = WarehouseDeck::new("empty");
harness::drive(&mut empty, [Msg::FocusNext, Msg::FocusPrev]);
assert_eq!(empty.selected(), None);
}
#[test]
fn drive_yields_no_effects() {
// Effect is uninhabited: `update` never asks the host to do anything (FC-8).
let mut d = graph_heavy_deck();
let effects = harness::drive(
&mut d,
[
Msg::SetLayout(DeckLayout::Focus),
Msg::DrillInto("call graph".into()),
Msg::IncCols,
Msg::FocusNext,
Msg::ClearSelection,
],
);
assert!(effects.is_empty());
}
#[test]
fn model_serde_round_trips() {
let mut d = graph_heavy_deck();
harness::drive(&mut d, [Msg::DrillInto("dependency graph".into()), Msg::SetCols(4)]);
let model = d.state().clone();
let json = serde_json::to_string(&model).unwrap();
let back: WarehouseModel = serde_json::from_str(&json).unwrap();
assert_eq!(model, back);
assert_eq!(back.selected.as_deref(), Some("dependency graph"));
assert_eq!(back.cols, 4);
assert_eq!(back.layout, DeckLayout::Focus);
assert_eq!(back.panes.len(), 3);
}
#[test]
fn selection_json_and_state_json_expose_focus() {
let mut d = graph_heavy_deck();
harness::drive(&mut d, [Msg::SelectPane("call graph".into())]);
assert_eq!(d.selection_json(), serde_json::json!("call graph"));
assert_eq!(d.state_json()["selected"], "call graph");
harness::drive(&mut d, [Msg::ClearSelection]);
assert_eq!(d.selection_json(), serde_json::Value::Null);
}
#[test]
fn focus_render_reconciles_active_tab_into_selection() {
// In Focus mode with no prior selection, the deck defaults to its first
// pane; the pure view reconciles that back into the Model via `Msg::SelectPane`
// (live == headless), so after a render `selected` is populated.
let mut d = graph_heavy_deck().with_layout(DeckLayout::Focus);
assert_eq!(d.selected(), None);
harness::headless_render(&mut d);
assert_eq!(d.selected(), Some("call graph"));
}
}