facett-app/src/tabview.rs
//! **`TabView` — nested in-content tabs.** A lightweight tab strip *inside* a
//! pane (distinct from the deck's top-level tabs): headers + an active id, with
//! the caller drawing content for `active()`. State in the struct (FC-1),
//! headless (`select`), observable (`state_json`).
use serde_json::{Value, json};
use std::collections::BTreeMap;
/// The narrowest row [`TabView::show`] will ever wrap against. A parent that reports a
/// width at or below this is not a layout to respect — it is a pane mid-collapse, and
/// wrapping every header onto its own row there would be worse than one that overflows.
const MIN_ROW_WIDTH: f32 = 64.0;
/// An in-content tab strip.
pub struct TabView {
tabs: Vec<(String, String)>,
active: String,
switches: u64,
/// Optional AccessKit label prefix — `""` (the default) keeps every existing tab
/// strip's tree byte-identical; a prefix makes each header uniquely addressable
/// (`"workspace layout"`) where a bare label would collide with a pane title.
label_prefix: String,
/// Optional per-tab hover tooltip (`id → text`), rendered as `on_hover_text` on the
/// header button — a "what does this view do" hint. Empty (the default) leaves the
/// strip byte-identical.
tooltips: BTreeMap<String, String>,
/// The rect each header occupied in the LAST [`show`](Self::show) — published so a
/// robot can click a tab by position, the way it clicks a chrome control or a split
/// divider. Empty until the strip has been drawn once.
rects: BTreeMap<String, [f32; 4]>,
}
impl TabView {
/// Build from `(id, label)` tabs; the first is active.
pub fn new(tabs: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>) -> Self {
let tabs: Vec<(String, String)> = tabs.into_iter().map(|(i, l)| (i.into(), l.into())).collect();
let active = tabs.first().map(|(i, _)| i.clone()).unwrap_or_default();
Self {
tabs,
active,
switches: 0,
label_prefix: String::new(),
tooltips: BTreeMap::new(),
rects: BTreeMap::new(),
}
}
/// Attach per-tab hover tooltips (`id → text`). Ids not in the map get no tooltip;
/// the default (no call) keeps the strip byte-identical.
pub fn tooltips(mut self, tips: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>) -> Self {
self.tooltips = tips.into_iter().map(|(i, t)| (i.into(), t.into())).collect();
self
}
/// Give every header a UNIQUE AccessKit label `"<prefix> <id>"`. Without it the
/// header keeps its plain visible label (the pre-existing behaviour).
pub fn label_prefix(mut self, prefix: impl Into<String>) -> Self {
self.label_prefix = prefix.into();
self
}
/// The rect `[x, y, w, h]` each header occupied in the last [`show`](Self::show).
pub fn rects(&self) -> &BTreeMap<String, [f32; 4]> {
&self.rects
}
pub fn active(&self) -> &str {
&self.active
}
/// Switch by id (headless). No-op for unknown/already-active.
pub fn select(&mut self, id: &str) -> bool {
if id != self.active && self.tabs.iter().any(|(i, _)| i == id) {
self.active = id.to_string();
self.switches += 1;
true
} else {
false
}
}
/// Draw the header row; returns the newly-selected id if it changed.
pub fn show(&mut self, ui: &mut egui::Ui) -> Option<String> {
let mut changed = None;
let tabs = self.tabs.clone();
let active = self.active.clone();
let prefix = self.label_prefix.clone();
let tooltips = self.tooltips.clone();
self.rects.clear();
let mut rects: Vec<(String, [f32; 4])> = Vec::new();
// **The strip WRAPS.** A tab strip that neither wraps nor scrolls lays its last
// headers out past the container's right edge, where they fall out of the
// AccessKit tree and become genuinely unreachable — dwarves lost its 18th tab
// exactly this way. MEASURED at 24 tabs in a 900pt canvas: SEVEN escaped.
//
// `ScrollArea::horizontal` was the first cure (2026-07-22) and it made every tab
// REACHABLE. It did not make them VISIBLE: the operator still had to drag a strip
// and read as they went, and a tab nobody knows exists is one nobody scrolls to.
// That is not hypothetical — a Kernel tab, 10th of 24, was reported missing on
// 2026-08-25 by someone looking straight at the strip that contained it.
//
// ⚠ THE TWO ARE MUTUALLY EXCLUSIVE, and the trap is silent. A `horizontal_wrapped`
// row INSIDE a horizontal `ScrollArea` never wraps: the scroll area hands the row
// unbounded width, so the wrap point is never reached. Swapping the call while
// leaving the scroll area looks exactly like a fix and changes nothing. The scroll
// area is therefore GONE, not nested — see `tests/tabstrip_wraps.rs`, which pins
// both halves: nothing escapes the canvas, AND the row really did break.
//
// Wrapping costs vertical space only when it fires; the same test asserts a wide
// canvas still keeps one row, because a strip that wraps when there is room has
// stolen a row from the pane below it.
//
// ⚠ AND THE WRAP POINT MUST BE A WIDTH THAT EXISTS ON SCREEN. `horizontal_wrapped`
// breaks the row at the width its parent `Ui` reports, and a parent is free to
// report more than the window has: `ui.max_rect()` is whatever the caller built the
// `Ui` with, up to and including `f32::INFINITY`. Against an unbounded parent the
// wrap point is never reached, the row stays flat, and the last headers land past
// the right edge — the ORIGINAL defect, unchanged, behind a call that looks fixed.
// MEASURED at 13 columns in a 1400pt canvas whose parent reported 2100pt: ONE row,
// the last column at x=1370.8 running to 1414.8 — off-surface, click centre and
// all. And the 24-tab shell above, re-run at 900pt under a parent reporting 1350pt,
// put back the ORIGINAL SEVEN escapees (t17..t23, 901.6 → 1239.0) with the wrap
// fully in place: removing the scroll area only ever helped where the parent
// happened to be honest. dwarves' `kernel_scene.rs` records the same cause on its
// own canvas (~2250pt reported inside a 1500pt window); it clamps a WIDTH to the
// window's width (`avail.x.min(screen.x)`), which still overruns by whatever the
// pane's own left offset is. This clamps the right EDGE, so it holds for a strip
// that does not start at x=0 — every strip inside a split.
//
// The clamp is a NARROWING ONLY, to two bounds that are both real: this `Ui`'s clip
// rect (the region actually painted — a split child's is already its own cell, so
// its own narrower `max_rect` wins untouched) and the viewport. Neither can widen
// the row, so a strip that already fits lays out byte-identically.
let mut row = ui.available_rect_before_wrap();
let limit = ui
.clip_rect()
.right()
.min(ui.ctx().viewport_rect().right())
// The floor goes on the LIMIT, never on the row: applying it to `row.max.x`
// would WIDEN a genuinely narrow pane, and this clamp is only ever allowed to
// narrow. Here it just stops a pane scrolled off the right edge from asking
// for an inverted rect.
.max(row.min.x + MIN_ROW_WIDTH);
row.max.x = row.max.x.min(limit);
// navgraph (§6 phase 2): the strip is one `tabs` group whose headers are declared
// in draw order (the strip's order IS the declared order), each header a `Field`
// keyed by its tab id, so `Ctrl+G 1.2.1.3 Enter` focuses the third header.
let nav_token = facett_core::navgraph::begin(ui, "tabs", facett_core::navgraph::NavKind::Group, "tabs");
facett_core::navgraph::declare_draw_order(ui, nav_token);
let strip = ui.scope_builder(egui::UiBuilder::new().max_rect(row), |ui| {
ui.horizontal_wrapped(|ui| {
for (id, label) in &tabs {
let mut resp = ui.add(egui::Button::selectable(*id == active, label));
// A hover hint on the tab header — "what does this view do" — when the
// caller supplied one for this id. No-op (byte-identical) otherwise.
if let Some(tip) = tooltips.get(id) {
resp = resp.on_hover_text(tip);
}
if !prefix.is_empty() {
// Built INSIDE the closure: egui only calls it when a consumer
// needs the node, so a release frame allocates nothing here.
resp.widget_info(|| {
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{prefix} {id}"))
});
}
facett_core::navgraph::field(ui, id, &resp);
let r = resp.rect;
rects.push((id.clone(), [r.min.x, r.min.y, r.width(), r.height()]));
if resp.clicked() && self.select(id) {
changed = Some(id.clone());
}
}
});
});
facett_core::navgraph::end(ui, nav_token, strip.response.rect);
self.rects = rects.into_iter().collect();
changed
}
pub fn state_json(&self) -> Value {
json!({
"active": self.active,
"tabs": self.tabs.iter().map(|(i, _)| i).collect::<Vec<_>>(),
"switches": self.switches,
"rects": self.rects,
})
}
}