Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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"));
    }
}