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

nordisk/facett

facett-tabbar/src/lib.rs

//! **facett-tabbar** — the generic, themed **nav toolbar**: a control strip
//! (workspace/palette/About/live-style pickers + toggles, host-painted) plus a
//! two-row strip of tab headers, the active tab carrying the palette accent.
//! Adopted from nornir's `src/viz/facett_ui.rs` (`TabBar` / `TabItem` /
//! `BarControl`) — the header of that file explicitly says it "should live in
//! the upstream `facett` crate"; the only thing that kept it in nornir is the
//! egui 0.33↔0.34 skew (egui-snarl pins 0.33). facett is on egui 0.34, so this
//! is the re-home; nornir re-points to `facett::tabbar::TabBar` once its viz
//! bumps to 0.34 (still gated on egui-snarl shipping 0.34 — see the cut policy
//! note in the batch report).
//!
//! Two surfaces:
//!  * [`TabBar`] — the borrowed, zero-alloc widget (`&[TabItem]` + an `active`
//!    id), the verbatim port of nornir's bar. `show()` returns the id of the
//!    newly-clicked tab; `state_json()` reports the tabs/active/split/controls.
//!  * [`TabBarView`] — an **owning** canonical [`Facet`] (`local()`/`remote()`,
//!    `state_json()`, `caps()`) so a `FacetDeck` mounts the bar as a tab and a
//!    headless driver reads + drives the selection. It tracks its own `active`
//!    and clicking a header re-keys it (and records the click count).

use facett_core::clip::{ClipKind, ClipPayload, CopySource};
use facett_core::{Elm, FacetCaps, Semantics};
use serde::{Deserialize, Serialize};

/// One tab in a [`TabBar`]: a stable `id` (emitted in `state_json` + matched on
/// click) plus the human `label` egui paints on the header (icon + words, e.g.
/// `"🔗 Dep Graph"`), so an AccessKit/robot click resolves by the same string
/// the user reads. Keeping `id` and `label` separate lets a host rename a label
/// without breaking click-by-id wiring or the `state_json` contract.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TabItem {
    /// Stable identifier — emitted in `state_json["tabs"][].id` / `["active"]`
    /// and returned from [`TabBar::show`] on click.
    pub id: String,
    /// The full header text egui paints (icon glyph + label).
    pub label: String,
}

impl TabItem {
    /// Build a tab from an `id` and the full `label` (icon already prefixed).
    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
        Self { id: id.into(), label: label.into() }
    }
}

/// A declarative descriptor of one **picker / toggle / button** the bar hosts in
/// its left/right slots (workspace combo, palette combo, About toggle, live
/// checkbox, …). The bar does NOT paint these — the host paints them through a
/// slot closure — but the descriptor lets `state_json` report *which* controls
/// are present and their values (the "components expose readable data" LAW)
/// without the bar knowing anything domain-specific.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BarControl {
    /// Stable key, e.g. `"workspace"`, `"palette"`, `"about"`, `"live"`.
    pub key: String,
    /// Control kind, e.g. `"picker"`, `"toggle"`, `"button"` — for the dump.
    pub kind: String,
    /// The current value as a string (selected option / `"on"`/`"off"`), or
    /// empty when valueless.
    pub value: String,
}

impl BarControl {
    /// A dropdown/combo picker reporting its selected value.
    pub fn picker(key: impl Into<String>, value: impl Into<String>) -> Self {
        Self { key: key.into(), kind: "picker".into(), value: value.into() }
    }
    /// A boolean toggle reporting `"on"`/`"off"`.
    pub fn toggle(key: impl Into<String>, on: bool) -> Self {
        Self { key: key.into(), kind: "toggle".into(), value: if on { "on" } else { "off" }.into() }
    }
    /// A valueless action button.
    pub fn button(key: impl Into<String>) -> Self {
        Self { key: key.into(), kind: "button".into(), value: String::new() }
    }
}

/// The generic, themed **nav/toolbar** widget: a control strip plus a two-row
/// strip of tab headers, all painted in the active [`Theme`](facett_core::theme::Theme)
/// (the active tab carries the palette accent). Deliberately NOT domain-specific:
/// tabs are `&[TabItem]`, the left/right slots are host-supplied closures, and the
/// controls they host are declared as [`BarControl`] descriptors.
///
/// * [`TabBar::show`] returns `Option<String>` — the id of the newly-clicked tab
///   (`None` if the active tab was unchanged this frame).
/// * [`TabBar::state_json`] describes what was rendered: tab ids+labels, the
///   active id, the row split, and the present controls + values.
pub struct TabBar<'a> {
    id_salt: &'a str,
    tabs: &'a [TabItem],
    active: &'a str,
    /// Index at which the second row begins (tabs `[0, split)` on row 1, rest on
    /// row 2). Clamped to `tabs.len()`.
    split: usize,
    /// Declared controls hosted in the slots — for `state_json` only.
    controls: Vec<BarControl>,
}

impl<'a> TabBar<'a> {
    /// Start a bar for `tabs`, with `active` the currently-selected id, salted by
    /// `id_salt` so multiple bars don't collide. Default split is the midpoint.
    pub fn new(id_salt: &'a str, tabs: &'a [TabItem], active: &'a str) -> Self {
        let split = tabs.len().div_ceil(2);
        Self { id_salt, tabs, active, split, controls: Vec::new() }
    }

    /// Override the row-split index (tabs before it on row 1, rest on row 2).
    pub fn split_at(mut self, split: usize) -> Self {
        self.split = split.min(self.tabs.len());
        self
    }

    /// Declare a control the host paints in a slot, so it shows up in
    /// `state_json`. Call once per picker/toggle/button (order = report order).
    pub fn with_control(mut self, c: BarControl) -> Self {
        self.controls.push(c);
        self
    }

    /// What this bar will render, as readable data. Stable whether or not
    /// [`show`](Self::show) is called (headless introspection).
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "tabs": self.tabs.iter().map(|t| serde_json::json!({
                "id": t.id,
                "label": t.label,
                "active": t.id == self.active,
            })).collect::<Vec<_>>(),
            "active": self.active,
            "split": self.split.min(self.tabs.len()),
            "controls": self.controls.iter().map(|c| serde_json::json!({
                "key": c.key,
                "kind": c.kind,
                "value": c.value,
            })).collect::<Vec<_>>(),
        })
    }

    /// Render the bar: the host's `left` slot, then the two tab rows, with the
    /// `right` slot painted on the same control row after the `left` preamble.
    /// Returns the id of the newly-clicked tab (`None` if unchanged). The active
    /// tab is highlighted with the palette accent.
    pub fn show(
        &self,
        ui: &mut egui::Ui,
        left: impl FnMut(&mut egui::Ui),
        right: impl FnMut(&mut egui::Ui),
    ) -> Option<String> {
        self.show_impl(ui, left, right, None)
    }

    /// Like [`show`](Self::show) but paints an **active-tab underline that slides**
    /// to the active tab via the caller-owned [`UnderlineTween`] (injected-clock,
    /// FC-7 — the eased rect lives in `underline`, not egui memory). Pass
    /// `ui.input(|i| i.stable_dt)` as `dt`.
    pub fn show_anim(
        &self,
        ui: &mut egui::Ui,
        left: impl FnMut(&mut egui::Ui),
        right: impl FnMut(&mut egui::Ui),
        underline: &mut UnderlineTween,
        dt: f32,
    ) -> Option<String> {
        self.show_impl(ui, left, right, Some((underline, dt)))
    }

    fn show_impl(
        &self,
        ui: &mut egui::Ui,
        left: impl FnMut(&mut egui::Ui),
        right: impl FnMut(&mut egui::Ui),
        underline: Option<(&mut UnderlineTween, f32)>,
    ) -> Option<String> {
        let th = facett_core::theme(ui);
        let (clicked, active_rect) = self.paint_body(ui, left, right);

        // Slide the active-tab underline toward the active tab (FC-7).
        if let (Some((underline, dt)), Some(target)) = (underline, active_rect) {
            let (bar, moving) = underline.advance_to(target, dt, UNDERLINE_SECS);
            let line = egui::Rect::from_min_max(
                egui::pos2(bar.left(), bar.bottom() - 2.0),
                egui::pos2(bar.right(), bar.bottom()),
            );
            ui.painter().rect_filled(line, 1.0, th.accent);
            if moving {
                ui.ctx().request_repaint();
            }
        }

        clicked
    }

    /// The shared, side-effect-free-on-`self` paint of the control row + the two
    /// themed tab rows. Returns the id of the newly-clicked tab (`None` if
    /// unchanged) and the active tab's rect (the underline target). Both
    /// [`show_impl`](Self::show_impl) (mutating tween) and
    /// [`show_pure`](Self::show_pure) (read-only tween) build on it, so the pixels
    /// are identical whichever underline path runs.
    fn paint_body(
        &self,
        ui: &mut egui::Ui,
        mut left: impl FnMut(&mut egui::Ui),
        mut right: impl FnMut(&mut egui::Ui),
    ) -> (Option<String>, Option<egui::Rect>) {
        let th = facett_core::theme(ui);
        let split = self.split.min(self.tabs.len());
        let mut clicked: Option<String> = None;
        // The active tab's rect, captured as the rows paint — the target the
        // underline slides toward.
        let mut active_rect: Option<egui::Rect> = None;

        // ── control row: host pickers/actions ─────────────────────────────────
        ui.horizontal(|ui| {
            left(ui);
            right(ui);
        });

        // ── tabs on TWO rows, themed: the active tab carries the palette accent ─
        let active = self.active;
        let base = ui.id().with(self.id_salt);
        let mut paint_row = |ui: &mut egui::Ui, items: &[TabItem]| {
            ui.horizontal(|ui| {
                for t in items {
                    let is_active = t.id == active;
                    let mut rt = egui::RichText::new(&t.label);
                    if is_active {
                        rt = rt.color(th.accent).strong();
                    }
                    // Stable id keyed on the tab id so a robot click resolves it.
                    let resp = ui
                        .push_id(facett_core::stable_id(base, &t.id), |ui| {
                            ui.selectable_label(is_active, rt)
                        })
                        .inner;
                    // Native-feel cues (mac/windows parity): the Windows reveal glow
                    // as the pointer nears a tab + the platform focus ring/rect on the
                    // active tab. Read the active Theme's NativeFeel via the shared
                    // `look::feel` helpers (no-op on Neutral/Device).
                    facett_core::look::reveal_on_hover(ui, resp.rect, 4.0);
                    facett_core::look::apply_focus_ring(ui, resp.rect, is_active, 4.0);
                    if is_active {
                        active_rect = Some(resp.rect);
                    }
                    let label = t.label.clone();
                    resp.widget_info(move || Semantics::list_item(label.clone(), is_active).widget_info());
                    if resp.clicked() && !is_active {
                        clicked = Some(t.id.clone());
                    }
                }
            });
        };
        paint_row(ui, &self.tabs[..split]);
        paint_row(ui, &self.tabs[split..]);

        (clicked, active_rect)
    }

    /// **Pure** paint (FC-9): renders the bar exactly like [`show`](Self::show) and
    /// paints the active-tab underline at `underline_current` (snapping to the
    /// active tab's rect on the first frame it is seen) **without advancing any
    /// tween** — the caller advances the tween through a `Msg`/`update` instead.
    /// Returns the newly-clicked tab id and the active tab's rect (the next
    /// underline target).
    fn show_pure(
        &self,
        ui: &mut egui::Ui,
        left: impl FnMut(&mut egui::Ui),
        right: impl FnMut(&mut egui::Ui),
        underline_current: Option<egui::Rect>,
    ) -> (Option<String>, Option<egui::Rect>) {
        let th = facett_core::theme(ui);
        let (clicked, active_rect) = self.paint_body(ui, left, right);
        if let Some(target) = active_rect {
            // Read-only: paint the underline where the tween currently sits (or snap
            // to the target the first frame, matching `UnderlineTween::advance_to`).
            let rect = underline_current.unwrap_or(target);
            let line = egui::Rect::from_min_max(
                egui::pos2(rect.left(), rect.bottom() - 2.0),
                egui::pos2(rect.right(), rect.bottom()),
            );
            ui.painter().rect_filled(line, 1.0, th.accent);
        }
        (clicked, active_rect)
    }
}

/// Active-tab **underline slide** state (injected-clock, FC-7): holds the current
/// eased underline rect, which each frame eases toward the active tab's rect. Held
/// by the owning [`TabBarView`] (or any host using [`TabBar::show_anim`]) — never in
/// egui memory — so snapshots reproduce.
#[derive(Clone, Debug, Default)]
pub struct UnderlineTween {
    /// Current eased rect (`None` until the first active tab is seen → snaps to it).
    current: Option<egui::Rect>,
}

/// Active-tab underline slide duration (seconds).
const UNDERLINE_SECS: f32 = 0.14;

impl UnderlineTween {
    /// Ease the underline rect toward `target` by `dt` over `duration` seconds
    /// (exponential follow); returns the rect to paint + whether it is still moving
    /// (request a repaint while so). `duration <= 0` snaps. Deterministic.
    fn advance_to(&mut self, target: egui::Rect, dt: f32, duration: f32) -> (egui::Rect, bool) {
        let cur = self.current.unwrap_or(target);
        let t = if duration <= 0.0 { 1.0 } else { (dt.max(0.0) / duration).clamp(0.0, 1.0) };
        let next = facett_core::effects::tween_rect(cur, target, t);
        self.current = Some(next);
        let moving =
            (next.left() - target.left()).abs() > 0.5 || (next.right() - target.right()).abs() > 0.5;
        (next, moving)
    }

    /// Whether the eased rect has effectively reached `target` (within 0.5 px on
    /// both edges). `false` before the first active tab is seen (`current` unset),
    /// so the pure view knows to request another frame to snap/slide.
    fn settled_at(&self, target: egui::Rect) -> bool {
        match self.current {
            Some(c) => {
                (c.left() - target.left()).abs() <= 0.5 && (c.right() - target.right()).abs() <= 0.5
            }
            None => false,
        }
    }
}

/// **FC-2 / FC-9** — the robot-/CLI-addressable input surface of a [`TabBarView`].
/// The bar has exactly one user interaction — clicking a header — so the only
/// domain [`Msg`] is [`SelectTab`](Msg::SelectTab); [`AdvanceUnderline`](Msg::AdvanceUnderline)
/// is the injected-clock (FC-7) tick the **pure** view emits so the sliding
/// underline advances through [`update`](TabBarView::update) instead of a
/// `&mut self` paint. Applied only by [`TabBarView::update`].
///
/// Serde-tagged (`snake_case`) so the robot `update_json` seam parses one message
/// straight off the wire. Only the real user interaction — [`SelectTab`](Msg::SelectTab)
/// — is on the wire; [`AdvanceUnderline`](Msg::AdvanceUnderline) is the internal
/// FC-7 clock tick the pure view emits to itself (it carries an `egui::Rect`, which
/// is not serde-able, and a robot never drives it), so it is `#[serde(skip)]`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Msg {
    /// Switch the active tab to `id` (records a switch; no-op for an unknown id or
    /// the already-active tab).
    SelectTab(String),
    /// FC-7 injected-clock tick: ease the active-tab underline toward `target` by
    /// `dt` seconds. Emitted by the pure view; touches only the ephemeral tween,
    /// never the observable [`Model`](TabBarState). Not on the robot wire (carries
    /// a non-serde `egui::Rect`; a headless driver has no clock to tick it).
    #[serde(skip)]
    AdvanceUnderline {
        /// The active tab's rect this frame (the slide target).
        target: egui::Rect,
        /// Seconds since the last frame (`ui.input(|i| i.stable_dt)`).
        dt: f32,
    },
}

/// Side work as data (FC-8). Uninhabited on purpose: the bar renders a host-fed
/// tab set and does **no** I/O, so it has no effect to emit.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}

/// **FC-3** — the complete observable state of a [`TabBarView`] in one
/// serializable, round-trippable struct: the tab set, the active id, the row
/// split, the declared controls, and the session switch tally. The **only** thing
/// that mutates it is [`TabBarView::update`]. (The FC-7 underline tween is *not*
/// here: it is ephemeral, non-observable animation state, absent from
/// `state_json` and reproduced deterministically from `dt`.)
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TabBarState {
    /// The bar's tabs (id + header label), in row order.
    pub tabs: Vec<TabItem>,
    /// The currently-selected tab id.
    pub active: String,
    /// Index at which the second row begins (clamped to `tabs.len()`).
    pub split: usize,
    /// The declared controls hosted in the slots (reported, not interactive here).
    pub controls: Vec<BarControl>,
    /// How many tab switches happened this session (a real, readable signal).
    pub switches: u64,
}

/// An **owning** canonical [`Facet`] wrapping a [`TabBar`]: it holds its own
/// `tabs` + `active` + `controls` (in one serializable [`TabBarState`]), paints
/// the bar (no host slots — the controls are reported, not interactive here),
/// tracks the live selection across frames, and exposes the whole rendered bar
/// through [`Facet::state_json`] so a deck mounts it and a headless driver reads +
/// asserts the selection. It is a canonical [`Elm`] component: mutation flows only
/// through [`update`](Self::update) and the [`view`](Self::view) is pure.
pub struct TabBarView {
    /// The deck keys facets off this (host config, not observable Model state).
    title: String,
    /// All observable state (FC-3).
    state: TabBarState,
    /// The sliding active-tab underline (injected-clock, FC-7). Ephemeral: not part
    /// of the [`Model`](Elm::Model), advanced only via [`Msg::AdvanceUnderline`].
    underline: UnderlineTween,
}

impl TabBarView {
    /// An empty bar titled `title`.
    pub fn new(title: impl Into<String>) -> Self {
        Self {
            title: title.into(),
            state: TabBarState {
                tabs: Vec::new(),
                active: String::new(),
                split: 0,
                controls: Vec::new(),
                switches: 0,
            },
            underline: UnderlineTween::default(),
        }
    }

    /// Set the tabs; the first becomes active if none is set yet, and the split
    /// defaults to the midpoint.
    pub fn with_tabs(mut self, tabs: Vec<TabItem>) -> Self {
        if self.state.active.is_empty() && let Some(first) = tabs.first() {
            self.state.active = first.id.clone();
        }
        self.state.split = tabs.len().div_ceil(2);
        self.state.tabs = tabs;
        self
    }

    /// Override the active tab id.
    pub fn with_active(mut self, id: impl Into<String>) -> Self {
        self.state.active = id.into();
        self
    }

    /// Override the row split.
    pub fn split_at(mut self, split: usize) -> Self {
        self.state.split = split.min(self.state.tabs.len());
        self
    }

    /// Declare a hosted control (reported in `state_json`).
    pub fn with_control(mut self, c: BarControl) -> Self {
        self.state.controls.push(c);
        self
    }

    /// **FC-3** — read the complete observable state at any frame boundary.
    pub fn state(&self) -> &TabBarState {
        &self.state
    }

    /// Re-title (the deck keys facets off [`Facet::title`]). Host config — the title
    /// is not part of the observable [`Model`](TabBarState), so this is a plain
    /// setter, not a [`Msg`].
    pub fn set_title(&mut self, title: impl Into<String>) {
        self.title = title.into();
    }

    /// The current active tab id.
    pub fn active(&self) -> &str {
        &self.state.active
    }

    /// Switch the active tab by id (records the switch); no-op for an unknown id
    /// or the already-active tab. Returns `true` if it actually switched. The core
    /// mutator behind [`Msg::SelectTab`] — in the FC-9 loop it is reached only
    /// through [`update`](Self::update); exposed inherently as a host convenience.
    pub fn select(&mut self, id: &str) -> bool {
        if id != self.state.active && self.state.tabs.iter().any(|t| t.id == id) {
            self.state.active = id.to_string();
            self.state.switches += 1;
            true
        } else {
            false
        }
    }

    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
    /// (always empty) [`Effect`]s the host should run.
    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
        match msg {
            Msg::SelectTab(id) => {
                self.select(&id);
            }
            Msg::AdvanceUnderline { target, dt } => {
                // Ephemeral FC-7 tween only — no observable state changes.
                let _ = self.underline.advance_to(target, dt, UNDERLINE_SECS);
            }
        }
        Vec::new()
    }

    /// Tab switches recorded this session.
    pub fn switches(&self) -> u64 {
        self.state.switches
    }

    /// **Discovery-contract `local()`** — a realistic 6-tab nav bar (the nornir
    /// top-bar vocabulary) split 3/3, with a workspace picker, a palette picker,
    /// and a live toggle declared, so the bar renders real headers + controls
    /// with no host attached.
    pub fn local() -> Self {
        Self::new("TabBar")
            .with_tabs(vec![
                TabItem::new("Timeline", "🧵 Timeline"),
                TabItem::new("DepGraph", "🔗 Dep Graph"),
                TabItem::new("Release", "🚀 Release"),
                TabItem::new("Test", "🧪 Test"),
                TabItem::new("Bench", "📊 Bench"),
                TabItem::new("Arch", "🏛 Arch"),
            ])
            .with_active("DepGraph")
            .split_at(3)
            .with_control(BarControl::picker("workspace", "nordisk"))
            .with_control(BarControl::picker("palette", "sci-fi"))
            .with_control(BarControl::toggle("live", true))
    }

    /// **Discovery-contract `remote()`** — same surface, the variant a host builds
    /// from a server-fed tab set. No transport here, so identical to `local()`
    /// but re-titled, kept so the matrix sees both ctors.
    pub fn remote() -> Self {
        let mut v = Self::local();
        v.title = "TabBar (remote)".into();
        v
    }
}

impl TabBarView {
    /// The label of the active tab (falls back to the active id if the id isn't in
    /// the tab set) — the textual copy of the current selection.
    pub fn active_label(&self) -> Option<String> {
        if self.state.tabs.is_empty() {
            return None;
        }
        Some(
            self.state
                .tabs
                .iter()
                .find(|t| t.id == self.state.active)
                .map(|t| t.label.clone())
                .unwrap_or_else(|| self.state.active.clone()),
        )
    }
}

// ── typed copy (§16) — the active tab's label is the selection ────────────────
impl CopySource for TabBarView {
    fn copy_kinds(&self) -> &[ClipKind] {
        &[ClipKind::Text]
    }

    fn copy_payload(&self) -> Option<ClipPayload> {
        self.active_label().map(ClipPayload::Text)
    }
}

impl TabBarView {
    /// **FC-9 view** — a **pure** function of `&self`: it paints the control row +
    /// the two themed tab rows + the (read-only) active-tab underline, and
    /// *returns* the [`Msg`]s the frame produced. It MUST NOT mutate `self`; the
    /// [`Facet::ui`] bridge (written by [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm))
    /// applies them through [`update`](Self::update). This is the same body the old
    /// `&mut self` `ui()` ran, with the click `self.select(..)` turned into a
    /// returned `Msg::SelectTab` and the tween advance turned into a returned
    /// `Msg::AdvanceUnderline` — the only changes that make it pure.
    pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        let dt = ui.input(|i| i.stable_dt);
        let bar = TabBar::new("tabbarview", &self.state.tabs, &self.state.active)
            .split_at(self.state.split)
            .with_control_vec(self.state.controls.clone());
        // The slots are empty here (the owning view does not host live pickers — it
        // only reports the declared controls); a deck wanting interactive pickers
        // uses the borrowed `TabBar` directly. The underline is painted read-only at
        // the tween's current position; it advances via `Msg::AdvanceUnderline`.
        let (clicked, active_rect) = bar.show_pure(ui, |_| {}, |_| {}, self.underline.current);

        let mut msgs: Vec<Msg> = Vec::new();
        if let Some(target) = active_rect {
            // Keep the FC-7 slide alive: request another frame until the tween settles.
            if !self.underline.settled_at(target) {
                ui.ctx().request_repaint();
            }
            msgs.push(Msg::AdvanceUnderline { target, dt });
        }
        if let Some(id) = clicked {
            msgs.push(Msg::SelectTab(id));
        }

        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-tabbar::TabBarView::view",
            "ui_render",
            !self.state.tabs.is_empty(),
            &format!("tabs={} active={}", self.state.tabs.len(), self.state.active),
        );

        msgs
    }

    /// The rich `state_json` this view publishes (the rendered bar + the live
    /// view-only signals) — shared by the [`Facet`] impl below. Kept as an inherent
    /// method so tests can read it without the `Facet` trait in scope.
    fn state_json_impl(&self) -> serde_json::Value {
        let mut j = TabBar::new("tabbarview", &self.state.tabs, &self.state.active)
            .split_at(self.state.split)
            .with_control_vec(self.state.controls.clone())
            .state_json();
        // fold in the live view-only signals.
        if let Some(obj) = j.as_object_mut() {
            obj.insert("title".into(), serde_json::json!(self.title));
            obj.insert("switches".into(), serde_json::json!(self.state.switches));
        }
        j
    }
}

// ── FC-2/FC-9 canonical trait: TabBarView IS an `Elm` component ───────────────
//
// The Model/Msg/Effect/pure-view split lives on the inherent methods; the `Elm`
// impl delegates to them verbatim.
impl Elm for TabBarView {
    type Model = TabBarState;
    type Msg = Msg;
    type Effect = Effect;

    fn title(&self) -> &str {
        &self.title
    }
    fn state(&self) -> &TabBarState {
        &self.state
    }
    fn update(&mut self, msg: Msg) -> Vec<Effect> {
        TabBarView::update(self, msg)
    }
    fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        TabBarView::view(self, ui)
    }
}

// The bridge macro writes `impl Facet for TabBarView` from the `Elm` impl: `title`,
// the FC-9 `ui` loop (`for m in view(ui) { update(m) }`), and — because this view
// publishes a **richer** `state_json` than plain `serde(state())` (the rendered
// bar with each tab's `active` flag folded in, plus the out-of-Model `title`) —
// **form 3** (`custom_state_json`) so we supply our own `state_json` (and the
// typed copy / selection / caps / downcast overrides) with no duplicate-method clash.
facett_core::impl_facet_via_elm!(TabBarView, custom_state_json, {
    fn copy(&mut self) -> Option<String> {
        use facett_core::clip::CopySource as _;
        self.copy_payload().map(|p| p.as_text())
    }

    fn state_json(&self) -> serde_json::Value {
        self.state_json_impl()
    }

    /// 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 tab-select messages mutate nothing). Malformed / unknown
    /// JSON is ignored (never a panic); the internal `AdvanceUnderline` tick is off
    /// the wire (`#[serde(skip)]`), so only a real tab switch is drivable.
    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);
        }
    }

    fn selection_json(&self) -> serde_json::Value {
        serde_json::json!({ "active": self.state.active })
    }

    /// Themed (the active tab follows the palette accent) + selectable (a tab is
    /// the selection) + resizable (the rows wrap to the host width) + copyable.
    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.themeable().resizable().selectable().copyable()
    }

    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
        Some(self)
    }
});

impl<'a> TabBar<'a> {
    /// Bulk-declare controls (used by the owning [`TabBarView`]).
    fn with_control_vec(mut self, controls: Vec<BarControl>) -> Self {
        self.controls = controls;
        self
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    // `Facet` is exercised only by the tests (the lib reaches it via the macro's
    // `$crate::Facet`), so it is imported here rather than crate-wide.
    use facett_core::Facet;

    #[test]
    fn tabbar_state_json_reports_tabs_active_and_controls() {
        let tabs = vec![
            TabItem::new("Timeline", "🧵 Timeline"),
            TabItem::new("DepGraph", "🔗 Dep Graph"),
            TabItem::new("Release", "🚀 Release"),
        ];
        let bar = TabBar::new("top", &tabs, "DepGraph")
            .split_at(2)
            .with_control(BarControl::picker("workspace", "nornir"))
            .with_control(BarControl::picker("palette", "sci-fi"))
            .with_control(BarControl::toggle("live", true));
        let s = bar.state_json();
        assert_eq!(s["tabs"].as_array().unwrap().len(), 3);
        assert_eq!(s["tabs"][1]["id"], "DepGraph");
        assert_eq!(s["tabs"][1]["label"], "🔗 Dep Graph");
        assert_eq!(s["tabs"][1]["active"], true);
        assert_eq!(s["tabs"][0]["active"], false);
        assert_eq!(s["active"], "DepGraph");
        assert_eq!(s["split"], 2);
        let ctrls = s["controls"].as_array().unwrap();
        assert_eq!(ctrls.len(), 3);
        assert_eq!(ctrls[0]["key"], "workspace");
        assert_eq!(ctrls[0]["kind"], "picker");
        assert_eq!(ctrls[0]["value"], "nornir");
        assert_eq!(ctrls[2]["key"], "live");
        assert_eq!(ctrls[2]["kind"], "toggle");
        assert_eq!(ctrls[2]["value"], "on");
    }

    #[test]
    fn split_is_clamped_to_tab_count() {
        let tabs = vec![TabItem::new("a", "A"), TabItem::new("b", "B")];
        let bar = TabBar::new("x", &tabs, "a").split_at(99);
        assert_eq!(bar.state_json()["split"], 2, "split clamped to tab count");
    }

    /// inject-assert (LAW 6): drive a real selection through the owning view and
    /// assert `state_json`/`selection_json` report the exact active tab + the
    /// switch tally a driver would read.
    #[test]
    fn view_select_switches_active_and_counts() {
        let mut v = TabBarView::local();
        assert_eq!(v.active(), "DepGraph");
        let j0 = v.state_json();
        assert_eq!(j0["active"], "DepGraph");
        assert_eq!(j0["switches"], 0);
        assert_eq!(j0["tabs"].as_array().unwrap().len(), 6);
        assert_eq!(j0["split"], 3);
        // exactly the active tab is flagged active.
        let active_count = j0["tabs"].as_array().unwrap().iter().filter(|t| t["active"] == true).count();
        assert_eq!(active_count, 1, "exactly one active tab");

        // a real switch by id moves the selection + bumps the counter.
        assert!(v.select("Bench"), "switching to a known tab succeeds");
        assert_eq!(v.active(), "Bench");
        let j1 = v.state_json();
        assert_eq!(j1["active"], "Bench");
        assert_eq!(j1["switches"], 1);
        assert_eq!(v.selection_json()["active"], "Bench");
        // the Bench tab is now the active one, DepGraph is not.
        let tabs = j1["tabs"].as_array().unwrap();
        assert!(tabs.iter().any(|t| t["id"] == "Bench" && t["active"] == true));
        assert!(tabs.iter().any(|t| t["id"] == "DepGraph" && t["active"] == false));

        // an unknown id and the already-active id are both no-ops (no extra switch).
        assert!(!v.select("Nope"));
        assert!(!v.select("Bench"));
        assert_eq!(v.switches(), 1, "unknown / no-change selects do not bump the counter");
    }

    #[test]
    fn local_and_remote_ctors_seed_a_real_bar() {
        let v = TabBarView::local();
        let j = v.state_json();
        assert!(j.as_object().map(|o| !o.is_empty()).unwrap_or(false));
        assert!(v.state().tabs.len() >= 6, "local seeds a multi-tab bar");
        assert_eq!(j["controls"].as_array().unwrap().len(), 3, "workspace + palette + live");
        assert_eq!(<TabBarView as Facet>::title(&v), "TabBar");
        assert_eq!(<TabBarView as Facet>::title(&TabBarView::remote()), "TabBar (remote)");
    }
    #[test]
    fn copy_payload_is_the_active_tab_label() {
        let mut v = TabBarView::new("t").with_tabs(vec![
            TabItem::new("a", "Alpha"),
            TabItem::new("b", "Beta"),
        ]);
        v.select("b");
        let p = v.copy_payload().expect("a bar with tabs copies its active label");
        assert_eq!(p.kind(), facett_core::clip::ClipKind::Text);
        assert_eq!(p.as_text(), "Beta");
        assert_eq!(<TabBarView as Facet>::copy(&mut v), Some("Beta".to_string()));
        assert!(TabBarView::new("empty").copy_payload().is_none());
    }

    // ── FC-2 → FC-3 as a tested property: drive Msg sequences through the headless
    //    `facett_core::harness` (no egui, no GPU) and assert the resulting Model. ──
    //
    // The bar has exactly one user interaction (click a header), so the domain Msg
    // vocabulary is a single `SelectTab`; `AdvanceUnderline` is the FC-7 tween tick
    // the pure view emits and is proven not to touch the observable Model.

    #[test]
    fn harness_select_transitions_the_active_tab_and_counts_switches() {
        use facett_core::harness;
        let mut v = TabBarView::local();
        // A real switch by id moves the selection + bumps the tally.
        let snap = harness::snapshot(&mut v, [Msg::SelectTab("Bench".into())]);
        assert_eq!(snap.active, "Bench");
        assert_eq!(snap.switches, 1);
        // An unknown id and the already-active id are both no-ops (no extra switch).
        let snap = harness::snapshot(
            &mut v,
            [Msg::SelectTab("Nope".into()), Msg::SelectTab("Bench".into())],
        );
        assert_eq!(snap.active, "Bench");
        assert_eq!(snap.switches, 1, "unknown / no-change selects do not bump the tally");
        // A second distinct switch moves + counts again.
        let snap = harness::snapshot(&mut v, [Msg::SelectTab("Arch".into())]);
        assert_eq!(snap.active, "Arch");
        assert_eq!(snap.switches, 2);
    }

    #[test]
    fn harness_advance_underline_is_a_no_op_on_the_model() {
        use facett_core::harness;
        let mut v = TabBarView::local();
        let before = v.state().clone();
        // The FC-7 tween tick carries egui geometry but never mutates observable state.
        let target = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(40.0, 12.0));
        let snap = harness::snapshot(&mut v, [Msg::AdvanceUnderline { target, dt: 0.016 }]);
        assert_eq!(snap, before, "AdvanceUnderline touches only the ephemeral tween");
    }

    #[test]
    fn harness_drive_yields_no_effects_fc8() {
        use facett_core::harness;
        let mut v = TabBarView::local();
        let target = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(40.0, 12.0));
        let effects = harness::drive(
            &mut v,
            [Msg::SelectTab("Bench".into()), Msg::AdvanceUnderline { target, dt: 0.016 }, Msg::SelectTab("Arch".into())],
        );
        assert!(effects.is_empty(), "tabbar performs no I/O — `Effect` is uninhabited");
    }

    #[test]
    fn model_round_trips_through_serde_fc3() {
        use facett_core::harness;
        let mut v = TabBarView::local();
        let model = harness::snapshot(&mut v, [Msg::SelectTab("Test".into())]);
        let json = serde_json::to_string(&model).unwrap();
        let back: TabBarState = serde_json::from_str(&json).unwrap();
        assert_eq!(back, model, "the observable Model round-trips through serde");
        // state() and the serde snapshot agree.
        assert_eq!(*v.state(), model);
    }

    #[test]
    fn macro_ui_bridge_renders_headlessly_and_preserves_state_json() {
        use facett_core::harness;
        // Exercise the macro-generated `Facet::ui` (the FC-9 view+update loop over a
        // real egui pass, incl. the read-only underline paint) and assert it draws +
        // keeps the observable `state_json` the deck/matrix read.
        let mut v = TabBarView::local();
        v.select("Release");
        let r = harness::headless_render(&mut v);
        assert_eq!(r.title, "TabBar");
        assert!(r.drew(), "the themed two-row bar tessellates to vertices");
        assert_eq!(r.state["active"], "Release");
        assert_eq!(r.state["tabs"].as_array().unwrap().len(), 6);
        assert_eq!(r.state["controls"].as_array().unwrap().len(), 3);
        // A second frame keeps the model stable (the AdvanceUnderline tick the pure
        // view emits never perturbs observable state).
        let r2 = harness::headless_render(&mut v);
        assert_eq!(r2.state["active"], "Release");
        assert_eq!(r2.state["switches"], r.state["switches"]);
    }

    #[test]
    fn state_json_still_reports_the_full_rendered_bar() {
        use facett_core::harness;
        // The FC-9 `view`/`update` bridge preserves the observable `state_json`.
        let mut v = TabBarView::local();
        harness::drive(&mut v, [Msg::SelectTab("Release".into())]);
        let s = v.state_json();
        assert_eq!(s["active"], "Release");
        assert_eq!(s["title"], "TabBar");
        assert_eq!(s["switches"], 1);
        assert_eq!(s["split"], 3);
        assert_eq!(s["tabs"].as_array().unwrap().len(), 6);
        assert_eq!(s["controls"].as_array().unwrap().len(), 3);
        // Exactly the active tab is flagged, and it is Release.
        let tabs = s["tabs"].as_array().unwrap();
        assert_eq!(tabs.iter().filter(|t| t["active"] == true).count(), 1);
        assert!(tabs.iter().any(|t| t["id"] == "Release" && t["active"] == true));
    }
}