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

nordisk/facett

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