The top-bar TabBar component (D3b)
Design + guide for factoring the viz top-bar (workspace picker + the ~16 tab buttons in 2 rows + 🎨 palette picker + ℹ About + 🐞 log + live toggle) out of the bespoke inline chrome in
app.rsinto a reusable, themed nav/toolbar component,facett_ui::TabBar. Closes THE_BIG_PLAN D3b.Companion to
.nornir/theming-and-facett-migration.md(C8/D3 — the app-wide palette + thefacett_uiwidget module). Laws honoured: #1 inject-assert tests · #2 this doc · #3 facett-style component + theme · #6 "see what the user sees" as data · CLI parity (the--theme/$NORNIR_VIZ_THEMEparity is kept).
TL;DR
- The top-bar was bespoke inline chrome in
UrdrThreadsApp::draw_ui(src/viz/app.rs): aui.horizontalcontrol row (workspaceComboBox, source label, ↻ reload,livecheckbox, ⟳ sync, 🐞 log toggle, ℹ About toggle, 🎨 paletteComboBox) followed by twoui.horizontalrows of 16selectable_valuetab buttons. - It is now painted through
facett_ui::TabBar— a generic, palette-driven, facett-style widget that owns the tab strip + theming. The host pickers/actions are passed in as left/right slot closures so the bar stays non-nornir. - The component emits its own
state_json(the tabs, the active id, the row split, and the pickers/toggles it hosts with their values), folded intostate_json["top_bar"]— readable data, LAW #6.
The component API (src/viz/facett_ui.rs)
pub struct TabItem { pub id: String, pub label: String }
// id = stable identifier (the tab enum's debug name) → state_json + click match
// label = full header text the user reads, e.g. "🔗 Dep Graph"
pub struct BarControl { pub key: String, pub kind: String, pub value: String }
// BarControl::picker(key, value) — a dropdown reporting its selection
// BarControl::toggle(key, on) — a boolean reporting "on"/"off"
pub struct TabBar<'a> { /* … */ }
impl<'a> TabBar<'a> {
fn new(id_salt, tabs: &[TabItem], active: &str) -> Self;
fn split_at(self, split: usize) -> Self; // 2-row split (viz uses 8)
fn with_control(self, c: BarControl) -> Self; // declare a hosted picker/toggle
fn state_json(&self) -> serde_json::Value; // readable-data dump (LAW #6)
fn show(&self, ui, theme: &Theme,
left: impl FnMut(&mut Ui), // host paints its pickers/actions
right: impl FnMut(&mut Ui)) -> Option<String>; // → id of the newly-clicked tab
}
- Input:
tabs: &[TabItem](icon already baked intolabel) + theactiveid + the activeTheme(palette) + the left/right slot closures for the pickers/actions + declaredBarControls. - Output:
showreturnsSome(id)for the newly-clicked tab (Noneif unchanged);state_jsondescribes what was rendered (tabs/active/split + controls and their values). - Theming: the tabs render on two
horizontalrows, the active tab highlighted with the palette accent (theme.accent, bold) so a palette switch re-skins the highlight for free. - Generic: the bar never names a "workspace" or "palette" — those live in the host's slot closures + the
BarControldescriptors, so korp/Njord/any egui surface can host the same bar with their own controls.
How the viz wires it (UrdrThreadsApp::draw_ui)
The bespoke control row + the two selectable_value tab rows are gone. In their place:
- Build
Tab::tab_items()(id =format!("{tab:?}"), label =Tab::label()) andactive_id = format!("{:?}", self.tab). - Slot closures only accumulate intents into locals (chosen workspace, chosen palette, reload/sync/toggle flags, the
livecheckbox bool). They do not touch&mut self— that would fight the bar over the borrow. The one&muta slot needs (self.ws_panel.draw_controls) is captured as a disjoint field borrow, with the server handle precomputed before the panel. - After the panel closes, the intents are applied to
self(the same calls the old inline chrome made:switch_workspace,reload,set_palette,ensure_server_info, the toggles, andself.tab = …for a clicked tab).
Behaviour is unchanged: same tabs, same labels (so robot/AccessKit click_label("🚀 Release") still resolves), same switching, same palette/About/live actions, same action-log + ui.tab trace events.
state_json["top_bar"]
UrdrThreadsApp::top_bar_state() builds the same TabBar the render builds and returns its state_json(), so the dump can't drift from what's painted:
"top_bar": {
"tabs": [ { "id": "Timeline", "label": "🧵 Timeline", "active": false }, … ],
"active": "Test",
"split": 8,
"controls": [
{ "key": "workspace", "kind": "picker", "value": "nornir" },
{ "key": "live", "kind": "toggle", "value": "off" },
{ "key": "about", "kind": "toggle", "value": "off" },
{ "key": "action_log", "kind": "toggle", "value": "off" },
{ "key": "palette", "kind": "picker", "value": "default" }
]
}
CLI parity (kept)
The 🎨 palette picker is one of the bar's controls, but the initial palette still resolves headlessly via C8's Theme::resolve_initial($NORNIR_VIZ_THEME → --theme → default) — tests/cli_parity.rs is untouched and still green. The bar reports the active palette in top_bar.controls[palette].value, so a headless/agent run can read which palette is showing without a screen.
facett re-home (the egui-version blocker)
Like the rest of facett_ui.rs, TabBar is vendored in the viz rather than imported from facett, because the viz pins egui 0.33 (the highest egui-snarl 0.9, the Funnel node-graph, supports) while facett is on egui 0.34, and egui's Ui/Color32/Painter types do not interoperate across versions. When egui-snarl ships on 0.34 (or the Funnel leaves snarl), this collapses to a thin re-export:
pub use facett::TabBar; // once egui 0.33 ↔ 0.34 align
It is already Theme-driven and slot-generic, so the move is a re-home, not a rewrite (a doc note to that effect lives on the TabBar doc comment).
Tests (LAW #1 — inject + assert)
src/viz/facett_ui.rs::tests::tabbar_state_json_reports_tabs_active_and_controls— builds aTabBar, asserts thestate_jsonreports the tabs (id/label/which active), the split, and the declared pickers/toggle with their values.tests/robot_viz.rs::tabbar_component_drives_active_and_exposes_pickers— drives the real viz headless (egui-kittest harness), asserts the bar dump lists all 16 tabs with the id→label contract + the palette picker + live toggle, then clicks🚀 Releaseand asserts bothstate_json["tab"]ANDstate_json["top_bar"].active/tabs[].activeflip to Release.- The pre-existing
tests/robot_viz.rstab-click tests (robot_drives_viz_tabs_and_state_reflects_each_click,scenario_drives_every_tab_and_fills_the_matrix) still pass — proving the click path goes through the new component unchanged.