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