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

nordisk/facett

facett-core/src/navgraph.rs

//! **navgraph** — one numbered navigation graph over the whole application, down to the
//! fields, drawn in dev mode. Phase 1 of `.nornir/navgraph-design.md`.
//!
//! Owner, 2026-09-28: *"show graph of navigation with unique numbering at dev mode"*.
//!
//! # What it is
//!
//! A tree of nodes, one per addressable thing on screen (`App` → `Surface` → `Pane` →
//! `Group` → `Field`). Each node has a stable dotted id (`facett-demo.deck.tabs.Map`)
//! and a number, the path of 1-based ranks from the root: `1`, `1.2`, `1.2.3`.
//!
//! * **Declared order wins.** A step is a plain number when the parent's child order is
//!   declared: by the host's [`NavOrder`], by a container whose draw order *is* its
//!   declaration (a tab strip, [`declare_draw_order`]), or by a list the container hands
//!   in for this frame ([`declare_children`], e.g. a [`FocusGroup`](crate::focus::FocusGroup)).
//! * **`~n`** — the parent declared nothing, so `n` is the draw order this frame. A guess
//!   is marked on the step that is guessed and can never pass for a declaration.
//! * **`?n`** — the parent declared an order and this child is not in it. `n` is its draw
//!   rank, so two undeclared siblings are still tellable apart. The `?` is a to-do list.
//!
//! # How it is collected
//!
//! A per-frame arena ([`NavGraph`]) kept in the egui `Context` memory. It is allocated
//! once and cleared every frame; nodes are slots in a `Vec` whose capacity and whose
//! `String`s are reused, so a steady-state frame allocates nothing in the arena.
//! Registration points:
//!
//! * [`crate::a11y::node`] — every FC-4 painted atom registers as a `Field`;
//! * [`crate::devid::badge`] — every deck pane registers as a `Pane`;
//! * [`crate::focus::FocusGroup::nav_register`] — a form group and its fields;
//! * [`scope`] / [`begin`]+[`end`] — a parent (app, surface, group) around a closure;
//! * [`field`] — a plain egui widget (`Button`, `TextEdit`) from its `Response`.
//!
//! An egui [`Plugin`](egui::Plugin) ([`install`]) clears the arena at the start of each
//! pass and, at the end of the pass, numbers the nodes, paints the overlay on the top
//! layer (so dev mode never moves layout) and publishes one AccessKit node per shown
//! chip: `nav: 1.2.3 tabs.Map [focused]`. The chips publish AccessKit nodes WITHOUT a
//! hit-test rect, so the overlay can never take a click from the widget under it.
//!
//! # The gate
//!
//! Every entry point returns at once unless dev mode is on — the same gate as the dev-id
//! badge ([`crate::devid::enabled`]: `debug_assertions`, or `FACETT_DEVID=1` in a
//! release build). Inside dev mode nothing is collected while the overlay is
//! [`NavMode::Off`] and no chord is being typed, so an idle dev build pays one atomic
//! load per registration point.
//!
//! # Keys (dev mode)
//!
//! * `Ctrl+Shift+G` cycles the overlay: off → labels → labels + next → labels + all edges.
//! * `Ctrl+G 1 . 2 . 3 Enter` focuses node `1.2.3` (Escape cancels, Backspace edits).
//!   `~` and `?` are not typed: `1.2` matches `~1.~2` too.
//!
//! `FACETT_NAVGRAPH=labels|next|all` sets the starting mode.

use std::fmt::Write as _;
use std::sync::atomic::{AtomicUsize, Ordering};

use egui::{Color32, Context, FontId, Id, IdMap, Key, LayerId, Modifiers, Order, Pos2, Rect, Response, Stroke, Ui, pos2, vec2};
use serde::{Deserialize, Serialize};

use crate::edges::{point_at_frac, spline_points_into};
use crate::look::NavOverlayTokens;
use crate::nav::{Dir4, nearest_in_direction};

/// Ordinary egui: every navigable widget egui laid out, read at the end of the pass.
pub mod harvest;

/// The prefix of every navgraph atom in the AccessKit tree.
pub const NAV_ATOM: &str = "nav:";
/// The suffix a chip's atom carries when its node has focus (or a chord selected it).
pub const FOCUSED_MARK: &str = crate::devid::FOCUSED_MARK;
/// Levels shown without hovering (owner, 2026-09-25: app, surface, pane).
pub const DEFAULT_DEPTH: u8 = 3;
/// In a [`NavOrder`] list, the slot for the one child that is not named — the active
/// pane of a tabbed deck, whose key changes with the tab.
pub const WILDCARD: &str = "*";

const GRAPH_ID: &str = "facett_navgraph";
const CHIP_FONT: f32 = 10.0;
const SPLINE_SAMPLES: usize = 16;

/// How many contexts are collecting right now. A fast path only: zero means every
/// registration point returns before touching the context.
static COLLECTING: AtomicUsize = AtomicUsize::new(0);

/// What kind of thing a node is (design §1).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NavKind {
    App,
    Surface,
    Pane,
    Group,
    Field,
}

impl NavKind {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::App => "app",
            Self::Surface => "surface",
            Self::Pane => "pane",
            Self::Group => "group",
            Self::Field => "field",
        }
    }
}

/// What the overlay draws. `Ctrl+Shift+G` steps through them in this order.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NavMode {
    #[default]
    Off,
    Labels,
    LabelsNext,
    All,
}

impl NavMode {
    /// The next mode in the cycle (wraps to `Off`).
    pub fn next(self) -> Self {
        match self {
            Self::Off => Self::Labels,
            Self::Labels => Self::LabelsNext,
            Self::LabelsNext => Self::All,
            Self::All => Self::Off,
        }
    }
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Off => "off",
            Self::Labels => "labels",
            Self::LabelsNext => "next",
            Self::All => "all",
        }
    }
    /// Parse `off|labels|next|all` (also `0..=3`). `None` for anything else.
    pub fn parse(s: &str) -> Option<Self> {
        match s.trim() {
            "off" | "0" => Some(Self::Off),
            "labels" | "on" | "1" => Some(Self::Labels),
            "next" | "labels+next" | "2" => Some(Self::LabelsNext),
            "all" | "3" => Some(Self::All),
            _ => None,
        }
    }
    fn shows_next(self) -> bool {
        matches!(self, Self::LabelsNext | Self::All)
    }
    fn shows_all(self) -> bool {
        self == Self::All
    }
}

/// One step of a number.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Step {
    /// Declared rank `n`, written `n`.
    Declared(u32),
    /// Draw-order rank under an undeclared parent, written `~n`.
    Guessed(u32),
    /// Missing from a declared parent's list; draw rank `n`, written `?n`.
    Undeclared(u32),
}

impl Step {
    pub fn is_declared(self) -> bool {
        matches!(self, Self::Declared(_))
    }
    fn rank(self) -> u32 {
        match self {
            Self::Declared(n) | Self::Guessed(n) | Self::Undeclared(n) => n,
        }
    }
    fn write_to(self, out: &mut String) {
        let _ = match self {
            Self::Declared(n) => write!(out, "{n}"),
            Self::Guessed(n) => write!(out, "~{n}"),
            Self::Undeclared(n) => write!(out, "?{n}"),
        };
    }
    fn as_str(self) -> &'static str {
        match self {
            Self::Declared(_) => "declared",
            Self::Guessed(_) => "guessed",
            Self::Undeclared(_) => "undeclared",
        }
    }
}

/// **The declared navigation order** — parent id → its ordered child keys. Handed in by
/// the host once ([`set_order`]); it is not rebuilt every frame.
///
/// Parent ids are full dotted ids (`facett-demo.deck`); the root level is the empty
/// string. Child entries are the child's own key (the last segment). [`WILDCARD`]
/// declares a slot for the one child that is not named.
///
/// It is `serde`, so it can be the child order of the future layout document (design
/// §5.1, ron).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct NavOrder {
    pub parents: std::collections::BTreeMap<String, Vec<String>>,
}

impl NavOrder {
    pub fn new() -> Self {
        Self::default()
    }
    /// Declare `parent`'s children, in order.
    pub fn children<I, S>(mut self, parent: &str, kids: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        self.parents.insert(parent.to_string(), kids.into_iter().map(Into::into).collect());
        self
    }
    /// Declare the root level (the app nodes), in order.
    pub fn roots<I, S>(self, kids: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        self.children("", kids)
    }
    pub fn get(&self, parent: &str) -> Option<&[String]> {
        self.parents.get(parent).map(Vec::as_slice)
    }
    pub fn is_empty(&self) -> bool {
        self.parents.is_empty()
    }
}

/// One node of this frame's graph.
#[derive(Clone, Debug)]
pub struct NavNode {
    /// The full dotted id, e.g. `facett-demo.deck.tabs.Map`.
    pub id: String,
    local: usize,
    /// Human text.
    pub label: String,
    pub kind: NavKind,
    /// This frame's rectangle.
    pub rect: Rect,
    /// Arena index of the parent (`None` for a root).
    pub parent: Option<u32>,
    /// 1 for a root.
    pub depth: u8,
    /// The egui widget this node focuses, when it is one.
    pub widget: Option<Id>,
    /// This node's own step (the last part of [`number`](Self::number)).
    pub step: Step,
    /// The number, e.g. `1.2.~3`.
    pub number: String,
    /// Shown this frame (depth limit, hover, focus).
    pub visible: bool,
    /// The focused widget is this node or under it.
    pub focus_in: bool,
    /// The focused widget is this node.
    pub focused: bool,
    /// Found by the end-of-pass harvest of egui's own widget record (ordinary egui),
    /// not registered by facett.
    pub harvested: bool,
    draw_rank: u32,
    kids: u32,
    wild_used: bool,
    decl_draw: bool,
    decl: String,
    hash: Id,
    chip: Rect,
}

impl NavNode {
    fn empty() -> Self {
        Self {
            id: String::new(),
            local: 0,
            label: String::new(),
            kind: NavKind::Field,
            rect: Rect::NOTHING,
            parent: None,
            depth: 1,
            widget: None,
            step: Step::Guessed(0),
            number: String::new(),
            visible: false,
            focus_in: false,
            focused: false,
            harvested: false,
            draw_rank: 0,
            kids: 0,
            wild_used: false,
            decl_draw: false,
            decl: String::new(),
            hash: Id::NULL,
            chip: Rect::NOTHING,
        }
    }
    /// The node's own key (the last id segment).
    pub fn local(&self) -> &str {
        &self.id[self.local..]
    }
    /// The last two id segments, e.g. `tabs.Map` — the short id a chip shows on hover.
    pub fn short(&self) -> &str {
        let s = self.id.as_str();
        match s.rfind('.') {
            Some(i) => match s[..i].rfind('.') {
                Some(j) => &s[j + 1..],
                None => s,
            },
            None => s,
        }
    }
    /// Does this node's number read `want`, ignoring the `~`/`?` marks?
    pub fn number_is(&self, want: &str) -> bool {
        let mut a = self.number.chars().filter(|c| *c != '~' && *c != '?');
        let mut b = want.chars().filter(|c| !c.is_whitespace());
        loop {
            match (a.next(), b.next()) {
                (None, None) => return true,
                (x, y) if x == y => {}
                _ => return false,
            }
        }
    }
}

/// A registration key: a plain string, or an a11y atom's `Debug` key.
#[derive(Clone, Copy, Debug)]
enum Key0<'a> {
    Str(&'a str),
    Debug(&'a dyn std::fmt::Debug),
}

/// **The per-frame arena** plus the few things that persist across frames (mode, order,
/// chord). Lives in the egui `Context` memory under one id.
#[derive(Clone)]
pub struct NavGraph {
    nodes: Vec<NavNode>,
    len: usize,
    stack: Vec<u32>,
    index: IdMap<u32>,
    root_kids: u32,
    root_wild_used: bool,
    scratch: String,
    // declared order (persistent, compiled once in `set_order`)
    order: NavOrder,
    order_decl: IdMap<()>,
    order_rank: IdMap<u32>,
    order_wild: IdMap<u32>,
    // persistent state
    mode: NavMode,
    depth_limit: u8,
    keep: bool,
    collect: bool,
    counted: bool,
    installed: bool,
    chord_on: bool,
    chord: String,
    selected: String,
    // derived per frame
    edges_contains: Vec<(u32, u32)>,
    edges_next: Vec<(u32, u32)>,
    edges_spatial: Vec<(u32, u32)>,
    sort_buf: Vec<(u32, u32, u32)>,
    rect_buf: Vec<Rect>,
    idx_buf: Vec<u32>,
    pts: Vec<Pos2>,
    text: String,
    guessed: usize,
    undeclared: usize,
    harvest: harvest::HarvestBuf,
}

impl Default for NavGraph {
    fn default() -> Self {
        Self {
            nodes: Vec::new(),
            len: 0,
            stack: Vec::new(),
            index: IdMap::default(),
            root_kids: 0,
            root_wild_used: false,
            scratch: String::new(),
            order: NavOrder::default(),
            order_decl: IdMap::default(),
            order_rank: IdMap::default(),
            order_wild: IdMap::default(),
            mode: NavMode::Off,
            depth_limit: DEFAULT_DEPTH,
            keep: false,
            collect: false,
            counted: false,
            installed: false,
            chord_on: false,
            chord: String::new(),
            selected: String::new(),
            edges_contains: Vec::new(),
            edges_next: Vec::new(),
            edges_spatial: Vec::new(),
            sort_buf: Vec::new(),
            rect_buf: Vec::new(),
            idx_buf: Vec::new(),
            pts: Vec::new(),
            text: String::new(),
            guessed: 0,
            undeclared: 0,
            harvest: harvest::HarvestBuf::default(),
        }
    }
}

fn root_hash() -> Id {
    Id::new("")
}

impl NavGraph {
    /// An empty graph (collection on, so the pure API can be driven without a context).
    pub fn new() -> Self {
        Self { collect: true, ..Self::default() }
    }

    // ── declared order ────────────────────────────────────────────────────────────

    /// Replace the declared order. Compiled once into hash maps keyed by egui `Id`, so
    /// a per-frame lookup hashes and allocates nothing.
    pub fn set_order(&mut self, order: NavOrder) {
        if order == self.order {
            return;
        }
        self.order_decl.clear();
        self.order_rank.clear();
        self.order_wild.clear();
        for (parent, kids) in &order.parents {
            let ph = Id::new(parent.as_str());
            self.order_decl.insert(ph, ());
            for (i, k) in kids.iter().enumerate() {
                let r = i as u32 + 1;
                if k == WILDCARD {
                    self.order_wild.insert(ph, r);
                } else {
                    self.order_rank.insert(ph.with(k.as_str()), r);
                }
            }
        }
        self.order = order;
    }
    pub fn order(&self) -> &NavOrder {
        &self.order
    }

    // ── the arena ─────────────────────────────────────────────────────────────────

    /// Clear this frame's nodes, keeping every capacity.
    pub fn clear(&mut self) {
        self.len = 0;
        self.stack.clear();
        self.index.clear();
        self.root_kids = 0;
        self.root_wild_used = false;
        self.edges_contains.clear();
        self.edges_next.clear();
        self.edges_spatial.clear();
        self.guessed = 0;
        self.undeclared = 0;
    }

    /// This frame's nodes, in registration (draw) order.
    pub fn nodes(&self) -> &[NavNode] {
        &self.nodes[..self.len]
    }
    pub fn len(&self) -> usize {
        self.len
    }
    pub fn is_empty(&self) -> bool {
        self.len == 0
    }
    /// The node whose number reads `number` (marks ignored); first in draw order.
    pub fn find_number(&self, number: &str) -> Option<&NavNode> {
        self.nodes().iter().find(|n| n.number_is(number))
    }
    /// The node with full id `id`.
    pub fn find_id(&self, id: &str) -> Option<&NavNode> {
        self.index.get(&Id::new(id)).map(|&i| &self.nodes[i as usize])
    }
    /// Nodes whose own step is `~` or `?` (after [`finish`](Self::finish)).
    pub fn not_declared(&self) -> impl Iterator<Item = &NavNode> {
        self.nodes().iter().filter(|n| !n.step.is_declared())
    }
    pub fn mode(&self) -> NavMode {
        self.mode
    }
    pub fn depth_limit(&self) -> u8 {
        self.depth_limit
    }
    /// The full id of the node the last chord selected (empty when none).
    pub fn selected(&self) -> &str {
        &self.selected
    }
    pub fn edges_contains(&self) -> &[(u32, u32)] {
        &self.edges_contains
    }
    pub fn edges_next(&self) -> &[(u32, u32)] {
        &self.edges_next
    }
    pub fn edges_spatial(&self) -> &[(u32, u32)] {
        &self.edges_spatial
    }

    /// Register a node under `parent` (or under the scope stack's top when `None`).
    /// A second registration of the same full id in one frame merges into the first
    /// (rects unite, a widget id is kept), so a pane that a scope opened and a badge
    /// later names is one node.
    fn register(
        &mut self,
        key: Key0<'_>,
        label: &str,
        kind: NavKind,
        rect: Rect,
        widget: Option<Id>,
        parent: Option<u32>,
    ) -> Option<u32> {
        if !self.collect {
            return None;
        }
        let parent = parent.or_else(|| self.stack.last().copied());
        self.scratch.clear();
        if let Some(p) = parent {
            self.scratch.push_str(&self.nodes[p as usize].id);
            self.scratch.push('.');
        }
        let local = self.scratch.len();
        match key {
            Key0::Str(s) => push_key(&mut self.scratch, s),
            Key0::Debug(d) => {
                let _ = write!(self.scratch, "{d:?}");
                // `"node-7"` → `node-7`; keep the quotes' content only.
                let raw: &str = &self.scratch[local..];
                if raw.len() >= 2 && raw.starts_with('"') && raw.ends_with('"') {
                    self.scratch.pop();
                    self.scratch.remove(local);
                }
                sanitize_from(&mut self.scratch, local);
            }
        }
        let hash = Id::new(self.scratch.as_str());
        if let Some(&i) = self.index.get(&hash) {
            let n = &mut self.nodes[i as usize];
            n.rect = match (n.rect.is_positive(), rect.is_positive()) {
                (true, true) => n.rect.union(rect),
                (false, true) => rect,
                _ => n.rect,
            };
            if n.widget.is_none() {
                n.widget = widget;
            }
            if n.label.is_empty() {
                n.label.push_str(label);
            }
            return Some(i);
        }
        let idx = self.len;
        if idx == self.nodes.len() {
            self.nodes.push(NavNode::empty());
        }
        self.len += 1;
        let (draw_rank, depth) = match parent {
            Some(p) => {
                let pn = &mut self.nodes[p as usize];
                pn.kids += 1;
                (pn.kids, pn.depth.saturating_add(1))
            }
            None => {
                self.root_kids += 1;
                (self.root_kids, 1)
            }
        };
        let n = &mut self.nodes[idx];
        std::mem::swap(&mut n.id, &mut self.scratch);
        n.local = local;
        n.label.clear();
        n.label.push_str(label);
        n.kind = kind;
        n.rect = rect;
        n.parent = parent;
        n.depth = depth;
        n.widget = widget;
        n.step = Step::Guessed(draw_rank);
        n.number.clear();
        n.visible = false;
        n.focus_in = false;
        n.focused = false;
        n.harvested = false;
        n.draw_rank = draw_rank;
        n.kids = 0;
        n.wild_used = false;
        n.decl_draw = false;
        n.decl.clear();
        n.hash = hash;
        n.chip = Rect::NOTHING;
        self.index.insert(hash, idx as u32);
        Some(idx as u32)
    }

    /// Open a scope: register a node and push it, so later registrations hang under it.
    pub fn open(&mut self, key: &str, kind: NavKind, label: &str, rect: Rect) -> Option<u32> {
        let i = self.register(Key0::Str(key), label, kind, rect, None, None)?;
        self.stack.push(i);
        Some(i)
    }
    /// Close scope `token` (and anything left open above it), setting its final rect.
    pub fn close(&mut self, token: u32, rect: Rect) {
        while let Some(top) = self.stack.pop() {
            if top == token {
                break;
            }
        }
        if let Some(n) = self.nodes.get_mut(token as usize)
            && rect.is_positive()
        {
            n.rect = rect;
        }
    }
    /// Register a leaf (or any node) under the current scope.
    pub fn leaf(&mut self, key: &str, kind: NavKind, label: &str, rect: Rect, widget: Option<Id>) -> Option<u32> {
        self.register(Key0::Str(key), label, kind, rect, widget, None)
    }
    fn leaf_debug(&mut self, key: &dyn std::fmt::Debug, label: &str, rect: Rect, widget: Option<Id>) -> Option<u32> {
        self.register(Key0::Debug(key), label, NavKind::Field, rect, widget, None)
    }
    /// Register a leaf under an explicit parent.
    pub fn leaf_under(&mut self, parent: u32, key: &str, kind: NavKind, label: &str, rect: Rect) -> Option<u32> {
        self.register(Key0::Str(key), label, kind, rect, None, Some(parent))
    }
    /// Declare that `token`'s children are in their declared order when drawn in order
    /// (a tab strip, a toolbar whose code order is its design order).
    pub fn declare_draw_order(&mut self, token: u32) {
        if let Some(n) = self.nodes.get_mut(token as usize) {
            n.decl_draw = true;
        }
    }
    /// Declare the innermost OPEN scope's children in draw order (see
    /// [`super::declare_own_draw_order`]). No open scope: nothing is declared.
    pub fn declare_current_draw_order(&mut self) {
        if let Some(&top) = self.stack.last() {
            self.declare_draw_order(top);
        }
    }
    /// Declare `token`'s children for this frame, by key, in order.
    pub fn declare_children<'a>(&mut self, token: u32, keys: impl IntoIterator<Item = &'a str>) {
        if let Some(n) = self.nodes.get_mut(token as usize) {
            n.decl.clear();
            for k in keys {
                if !n.decl.is_empty() {
                    n.decl.push('\n');
                }
                push_key(&mut n.decl, k);
            }
            if n.decl.is_empty() {
                // An empty list is still a declaration ("no children expected").
                n.decl.push('\n');
            }
        }
    }

    // ── numbering, visibility, edges ──────────────────────────────────────────────

    /// **Number every node** and derive visibility and edges. Pure: a function of the
    /// registrations, the declared order, the focused widget and the pointer.
    pub fn finish(&mut self, focused: Option<Id>, pointer: Option<Pos2>) {
        self.guessed = 0;
        self.undeclared = 0;
        self.edges_contains.clear();
        self.edges_next.clear();
        self.edges_spatial.clear();
        self.root_wild_used = false;
        for n in &mut self.nodes[..self.len] {
            n.wild_used = false;
        }
        let root = root_hash();
        let root_declared = self.order_decl.contains_key(&root);
        for i in 0..self.len {
            let (head, tail) = self.nodes.split_at_mut(i);
            let n = &mut tail[0];
            let local = &n.id[n.local..];
            let step = match n.parent {
                Some(p) => {
                    let pn = &mut head[p as usize];
                    if pn.decl_draw {
                        Step::Declared(n.draw_rank)
                    } else if !pn.decl.is_empty() {
                        match pn.decl.split('\n').position(|k| k == local) {
                            Some(pos) => Step::Declared(pos as u32 + 1),
                            // The wildcard slot of a per-frame declaration (a scene tab set:
                            // `[tabs, *]`) takes the one unnamed child — never a harvested
                            // plain-egui widget, which is chrome, not the slot's occupant.
                            None => match pn.decl.split('\n').position(|k| k == WILDCARD) {
                                Some(pos) if !pn.wild_used && !n.harvested => {
                                    pn.wild_used = true;
                                    Step::Declared(pos as u32 + 1)
                                }
                                _ => Step::Undeclared(n.draw_rank),
                            },
                        }
                    } else if self.order_decl.contains_key(&pn.hash) {
                        lookup(&self.order_rank, &self.order_wild, pn.hash, local, &mut pn.wild_used, n.draw_rank)
                    } else {
                        Step::Guessed(n.draw_rank)
                    }
                }
                None if root_declared => {
                    lookup(&self.order_rank, &self.order_wild, root, local, &mut self.root_wild_used, n.draw_rank)
                }
                None => Step::Guessed(n.draw_rank),
            };
            n.step = step;
            n.number.clear();
            if let Some(p) = n.parent {
                n.number.push_str(&head[p as usize].number);
                n.number.push('.');
            }
            step.write_to(&mut n.number);
            n.focus_in = false;
            n.focused = false;
            match step {
                Step::Declared(_) => {}
                Step::Guessed(_) => self.guessed += 1,
                Step::Undeclared(_) => self.undeclared += 1,
            }
        }
        // Focus: the focused widget's node and every ancestor.
        if let Some(f) = focused {
            for i in 0..self.len {
                if self.nodes[i].widget == Some(f) {
                    self.nodes[i].focused = true;
                    let mut at = Some(i as u32);
                    while let Some(a) = at {
                        let n = &mut self.nodes[a as usize];
                        n.focus_in = true;
                        at = n.parent;
                    }
                }
            }
        }
        // Visibility: the first `depth_limit` levels, and below that the children of a
        // shown node the pointer is in or focus is under.
        for i in 0..self.len {
            let (head, tail) = self.nodes.split_at_mut(i);
            let n = &mut tail[0];
            n.visible = n.rect.is_positive()
                && (n.depth <= self.depth_limit
                    || n.parent.is_some_and(|p| {
                        let pn = &head[p as usize];
                        pn.visible && (pn.focus_in || pointer.is_some_and(|q| pn.rect.contains(q)))
                    }));
        }
        // Contains: parent → child.
        for i in 0..self.len {
            if let Some(p) = self.nodes[i].parent {
                self.edges_contains.push((p, i as u32));
            }
        }
        // Next: siblings in rank order, `?` excluded (it has no place in the order).
        self.sort_buf.clear();
        for (i, n) in self.nodes[..self.len].iter().enumerate() {
            if !matches!(n.step, Step::Undeclared(_)) {
                self.sort_buf.push((n.parent.map_or(0, |p| p + 1), n.step.rank(), i as u32));
            }
        }
        self.sort_buf.sort_unstable();
        for w in self.sort_buf.windows(2) {
            if w[0].0 == w[1].0 {
                self.edges_next.push((w[0].2, w[1].2));
            }
        }
        // Spatial: the arrow-key neighbour (right, down) among shown siblings. Shown
        // nodes only — a pane's thousands of hidden atoms would make this quadratic.
        if self.mode.shows_all() {
            for i in 0..self.len {
                let n = &self.nodes[i];
                if !n.visible {
                    continue;
                }
                self.rect_buf.clear();
                self.idx_buf.clear();
                for (j, m) in self.nodes[..self.len].iter().enumerate() {
                    if j != i && m.visible && m.parent == n.parent {
                        self.rect_buf.push(m.rect);
                        self.idx_buf.push(j as u32);
                    }
                }
                for dir in [Dir4::Right, Dir4::Down] {
                    if let Some(k) = nearest_in_direction(n.rect, &self.rect_buf, dir) {
                        self.edges_spatial.push((i as u32, self.idx_buf[k]));
                    }
                }
            }
        }
    }

    /// Place every shown node's chip (`size_of(node)` gives the chip size). Anchored at
    /// the node's top-left; a chip that collides with an earlier one is pushed down and
    /// right, one pass in draw order, so the layout is deterministic.
    pub fn place_chips(&mut self, mut size_of: impl FnMut(&NavNode) -> egui::Vec2) {
        self.rect_buf.clear();
        for i in 0..self.len {
            let n = &self.nodes[i];
            if !n.visible {
                self.nodes[i].chip = Rect::NOTHING;
                continue;
            }
            let size = size_of(n);
            let mut chip = Rect::from_min_size(n.rect.left_top() + vec2(1.0, 1.0), size);
            for prev in &self.rect_buf {
                if chip.intersects(*prev) {
                    chip = Rect::from_min_size(pos2(chip.left() + 4.0, prev.bottom() + 1.0), size);
                }
            }
            self.rect_buf.push(chip);
            self.nodes[i].chip = chip;
        }
    }

    /// The chip rect placed for node `i` this frame (`Rect::NOTHING` when hidden).
    pub fn chip(&self, i: usize) -> Rect {
        self.nodes.get(i).map_or(Rect::NOTHING, |n| n.chip)
    }

    /// **The whole graph as data** — ids, numbers, kinds, rects, edges. The test
    /// artefact, and what the future editor (design §5) reads.
    pub fn to_json(&self) -> serde_json::Value {
        let r = |r: Rect| {
            let q = |v: f32| (v * 10.0).round() / 10.0;
            serde_json::json!([q(r.min.x), q(r.min.y), q(r.max.x), q(r.max.y)])
        };
        let nodes: Vec<serde_json::Value> = self
            .nodes()
            .iter()
            .map(|n| {
                serde_json::json!({
                    "id": n.id,
                    "number": n.number,
                    "kind": n.kind.as_str(),
                    "label": n.label,
                    "depth": n.depth,
                    "step": n.step.as_str(),
                    "parent": n.parent,
                    "rect": r(n.rect),
                    "visible": n.visible,
                    "focusable": n.widget.is_some(),
                    "focus_in": n.focus_in,
                    "focused": n.focused,
                    "harvested": n.harvested,
                })
            })
            .collect();
        let pairs = |v: &[(u32, u32)]| v.iter().map(|(a, b)| serde_json::json!([a, b])).collect::<Vec<_>>();
        serde_json::json!({
            "mode": self.mode.as_str(),
            "depth_limit": self.depth_limit,
            "declared_order": !self.order.is_empty(),
            "count": self.len,
            "guessed": self.guessed,
            "undeclared": self.undeclared,
            "selected": if self.selected.is_empty() { serde_json::Value::Null } else { self.selected.clone().into() },
            "chord": if self.chord_on { serde_json::Value::from(self.chord.clone()) } else { serde_json::Value::Null },
            "nodes": nodes,
            "edges": {
                "contains": pairs(&self.edges_contains),
                "next": pairs(&self.edges_next),
                "spatial": pairs(&self.edges_spatial),
            },
        })
    }
}

fn lookup(rank: &IdMap<u32>, wild: &IdMap<u32>, parent: Id, local: &str, wild_used: &mut bool, draw_rank: u32) -> Step {
    if let Some(&r) = rank.get(&parent.with(local)) {
        return Step::Declared(r);
    }
    if let Some(&r) = wild.get(&parent)
        && !*wild_used
    {
        *wild_used = true;
        return Step::Declared(r);
    }
    Step::Undeclared(draw_rank)
}

/// Append a key with the id separator (`.`) and the list separator (newline) replaced,
/// so a key can never forge an extra path segment.
fn push_key(out: &mut String, key: &str) {
    for c in key.chars() {
        out.push(match c {
            '.' => '_',
            '\n' | '\r' => ' ',
            c => c,
        });
    }
}

fn sanitize_from(s: &mut String, start: usize) {
    if s[start..].contains(['.', '\n', '\r']) {
        // Rare (a Debug key with a dot, e.g. a float); rebuild the tail once.
        let tail: String = s[start..].to_string();
        s.truncate(start);
        push_key(s, &tail);
    }
}

// ── context API ───────────────────────────────────────────────────────────────────

fn gate_open() -> bool {
    crate::devid::enabled()
}

fn fast_off() -> bool {
    COLLECTING.load(Ordering::Relaxed) == 0 || !gate_open()
}

fn with_mut<R>(ctx: &Context, f: impl FnOnce(&mut NavGraph) -> R) -> R {
    ctx.data_mut(|d| f(d.get_temp_mut_or_default::<NavGraph>(Id::new(GRAPH_ID))))
}

/// Read this context's graph (the last finished frame, when called between frames).
pub fn with<R>(ctx: &Context, f: impl FnOnce(&NavGraph) -> R) -> R {
    with_mut(ctx, |g| f(g))
}

/// Is this context collecting right now? A host that has to build a key (a `format!`)
/// asks this first.
pub fn collecting(ctx: &Context) -> bool {
    !fast_off() && with_mut(ctx, |g| g.collect)
}

struct NavPlugin;

impl egui::Plugin for NavPlugin {
    fn debug_name(&self) -> &'static str {
        "facett-navgraph"
    }
    fn on_begin_pass(&mut self, ui: &mut Ui) {
        begin_pass(ui.ctx());
    }
    fn on_end_pass(&mut self, ui: &mut Ui) {
        end_pass(ui.ctx());
    }
}

/// **Install the navgraph on a context** (idempotent). A no-op unless dev mode is on.
/// [`crate::devid::badge`] calls this, so every deck host gets it without a line of
/// code; a host with no deck calls it once. Reads `FACETT_NAVGRAPH` for the start mode.
pub fn install(ctx: &Context) {
    if !gate_open() {
        return;
    }
    let first = with_mut(ctx, |g| {
        let first = !g.installed;
        if first {
            g.installed = true;
            if let Some(m) = std::env::var("FACETT_NAVGRAPH").ok().as_deref().and_then(NavMode::parse) {
                g.mode = m;
            }
        }
        first
    });
    if first {
        ctx.add_plugin(NavPlugin);
        // The plugin runs from the NEXT pass; ask for it.
        ctx.request_repaint();
    }
}

/// Set the overlay mode (installs the navgraph).
pub fn set_mode(ctx: &Context, mode: NavMode) {
    install(ctx);
    with_mut(ctx, |g| g.mode = mode);
    // A canvas with nothing animating paints only on input, so without this the
    // overlay would wait for the next pointer event (measured: an `osm2d` canvas in the
    // browser had drawn once and never collected).
    ctx.request_repaint();
}
pub fn mode(ctx: &Context) -> NavMode {
    with_mut(ctx, |g| g.mode)
}
/// Hand in the declared order (installs the navgraph). Cheap when unchanged.
pub fn set_order(ctx: &Context, order: NavOrder) {
    install(ctx);
    with_mut(ctx, |g| g.set_order(order));
}
/// Is an order declared? A host that builds its order lazily asks this first.
pub fn has_order(ctx: &Context) -> bool {
    with_mut(ctx, |g| !g.order.is_empty())
}
/// Levels shown without hovering (default [`DEFAULT_DEPTH`]).
pub fn set_depth_limit(ctx: &Context, n: u8) {
    with_mut(ctx, |g| g.depth_limit = n.max(1));
}
/// Keep collecting while the overlay is off — for a host or test that reads
/// [`to_json`] without showing anything.
pub fn keep_collecting(ctx: &Context, on: bool) {
    install(ctx);
    with_mut(ctx, |g| g.keep = on);
}
/// The last finished frame's graph as JSON ([`NavGraph::to_json`]).
pub fn to_json(ctx: &Context) -> serde_json::Value {
    with(ctx, NavGraph::to_json)
}

/// A scope token from [`begin`], closed by [`end`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Token(u32);

/// Open a parent node under the current scope. `None` when not collecting.
pub fn begin(ui: &Ui, key: &str, kind: NavKind, label: &str) -> Option<Token> {
    if fast_off() {
        return None;
    }
    let rect = ui.max_rect();
    with_mut(ui.ctx(), |g| g.open(key, kind, label, rect)).map(Token)
}
/// Close a scope from [`begin`] with the rect it finally occupied.
pub fn end(ui: &Ui, token: Option<Token>, rect: Rect) {
    if let Some(Token(t)) = token {
        with_mut(ui.ctx(), |g| g.close(t, rect));
    }
}
/// Declare that the open scope's children, drawn in order, are in declared order.
pub fn declare_draw_order(ui: &Ui, token: Option<Token>) {
    if let Some(Token(t)) = token {
        with_mut(ui.ctx(), |g| g.declare_draw_order(t));
    }
}
/// **A component declares its own atoms** (navgraph-design §9 "Left", phase 3): the
/// children of the scope the component is drawn in — its pane — are in declared order when
/// drawn in order. For a component whose painted atoms follow its DATA order (graph nodes
/// in scene order, heatmap cells row-major, a pipeline's stages), that order is the
/// declaration, and the atoms stop reading `~`. Needs no token and touches no layout, so
/// a component calls it once in its `view` without knowing who hosts it (a `FacetDeck`
/// pane, a scene slot, a wrapped canvas). Outside dev mode it is one atomic load.
pub fn declare_own_draw_order(ui: &Ui) {
    if fast_off() {
        return;
    }
    with_mut(ui.ctx(), |g| g.declare_current_draw_order());
}

/// Declare the open scope's children for this frame, by key, in order.
pub fn declare_children<'a>(ui: &Ui, token: Option<Token>, keys: impl IntoIterator<Item = &'a str>) {
    if let Some(Token(t)) = token {
        with_mut(ui.ctx(), |g| g.declare_children(t, keys));
    }
}

/// **A parent around a closure.** Always wraps `add` in `ui.scope`, in dev mode or not,
/// so turning the overlay on never changes layout.
pub fn scope<R>(ui: &mut Ui, key: &str, kind: NavKind, label: &str, add: impl FnOnce(&mut Ui) -> R) -> R {
    let token = begin(ui, key, kind, label);
    let inner = ui.scope(add);
    end(ui, token, inner.response.rect);
    inner.inner
}

/// [`scope`] whose children's draw order is their declared order (a tab strip).
pub fn scope_ordered<R>(ui: &mut Ui, key: &str, kind: NavKind, label: &str, add: impl FnOnce(&mut Ui) -> R) -> R {
    let token = begin(ui, key, kind, label);
    declare_draw_order(ui, token);
    let inner = ui.scope(add);
    end(ui, token, inner.response.rect);
    inner.inner
}

/// **Register a plain egui widget** (a `Button`, a `TextEdit`) as a `Field` of the
/// current scope. The response's id is what `Ctrl+G <number>` focuses.
pub fn field(ui: &Ui, key: &str, response: &Response) {
    if fast_off() {
        return;
    }
    let (rect, id) = (response.rect, response.id);
    with_mut(ui.ctx(), |g| {
        g.leaf(key, NavKind::Field, key, rect, Some(id));
    });
}

/// The [`crate::a11y::node`] hook: an FC-4 atom becomes a `Field`.
pub(crate) fn atom(ui: &Ui, key: &dyn std::fmt::Debug, label: &str, response: &Response) {
    if fast_off() {
        return;
    }
    let (rect, id) = (response.rect, response.id);
    with_mut(ui.ctx(), |g| {
        g.leaf_debug(key, label, rect, Some(id));
    });
}

/// The [`crate::devid::badge`] hook: a deck pane. Merges into the node a host scope
/// opened under the same key, so the pane's children stay under it.
pub(crate) fn pane(ui: &Ui, key: &str, label: &str, rect: Rect) {
    if fast_off() {
        return;
    }
    with_mut(ui.ctx(), |g| {
        g.leaf(key, NavKind::Pane, label, rect, None);
    });
}

/// Register a [`FocusGroup`](crate::focus::FocusGroup): a `Group` whose declared
/// children are its fields, in order.
pub fn focus_group(ui: &Ui, group: &crate::focus::FocusGroup) {
    if fast_off() {
        return;
    }
    let rect = group.fields.iter().fold(Rect::NOTHING, |a, f| a.union(f.rect));
    with_mut(ui.ctx(), |g| {
        let Some(t) = g.open(&group.id, NavKind::Group, &group.id, rect) else { return };
        g.declare_children(t, group.fields.iter().map(|f| f.id.as_str()));
        for f in &group.fields {
            g.leaf(&f.id, NavKind::Field, &f.id, f.rect, None);
        }
        g.close(t, rect);
    });
}

/// **The browser half** (wasm only). A web host calls [`web::install`] once after the
/// runner starts.
#[cfg(target_arch = "wasm32")]
pub mod web {
    use wasm_bindgen::JsCast as _;
    use wasm_bindgen::closure::Closure;

    /// `?nav=labels|next|all` (or `#…&nav=…`) in the page URL, when present.
    pub fn mode_from_url() -> Option<super::NavMode> {
        let loc = web_sys::window()?.location();
        let s = format!("{}&{}", loc.search().ok()?, loc.hash().ok().unwrap_or_default());
        s.split(['?', '&', '#'])
            .find_map(|kv| kv.strip_prefix("nav="))
            .and_then(super::NavMode::parse)
    }

    /// Stop the browser's own Ctrl+G (Chrome: find next, which opens the find bar and
    /// takes the digits of the chord). eframe's web runner prevents the default only
    /// for Ctrl+O/P/S/comma, so the chord could not be typed in a browser without this.
    /// Capture phase on `window`, so it runs before the page sees the key. Idempotent.
    pub fn prevent_browser_ctrl_g() {
        use std::sync::atomic::{AtomicBool, Ordering};
        static DONE: AtomicBool = AtomicBool::new(false);
        if DONE.swap(true, Ordering::Relaxed) {
            return;
        }
        let Some(w) = web_sys::window() else { return };
        let cb = Closure::<dyn FnMut(web_sys::KeyboardEvent)>::new(|e: web_sys::KeyboardEvent| {
            if (e.ctrl_key() || e.meta_key()) && e.key().eq_ignore_ascii_case("g") {
                e.prevent_default();
            }
        });
        let _ = w.add_event_listener_with_callback_and_bool("keydown", cb.as_ref().unchecked_ref(), true);
        cb.forget();
    }

    /// Both of the above, plus [`super::install`] on `ctx` with the URL's mode.
    pub fn install(ctx: &egui::Context) {
        prevent_browser_ctrl_g();
        super::install(ctx);
        if let Some(m) = mode_from_url() {
            super::set_mode(ctx, m);
        }
    }
}

// ── the pass hooks ────────────────────────────────────────────────────────────────

fn begin_pass(ctx: &Context) {
    if !gate_open() {
        return;
    }
    let (mut chord_on, mut chord) = with_mut(ctx, |g| (g.chord_on, std::mem::take(&mut g.chord)));
    let mut cycle = false;
    let mut enter = false;
    ctx.input_mut(|i| {
        if i.consume_key(Modifiers::CTRL | Modifiers::SHIFT, Key::G) {
            cycle = true;
        } else if i.consume_key(Modifiers::CTRL, Key::G) {
            chord_on = true;
            chord.clear();
        }
        if !chord_on {
            return;
        }
        i.events.retain(|e| match e {
            egui::Event::Key { key, pressed: true, modifiers, .. } if !modifiers.ctrl && !modifiers.command => {
                match key {
                    Key::Enter => enter = true,
                    Key::Escape => {
                        chord_on = false;
                        chord.clear();
                    }
                    Key::Backspace => {
                        chord.pop();
                    }
                    Key::Period => chord.push('.'),
                    k => match digit(*k) {
                        Some(d) => chord.push(d),
                        None => return true,
                    },
                }
                false
            }
            egui::Event::Text(t) => !t.chars().all(|c| c.is_ascii_digit() || c == '.'),
            _ => true,
        });
    });
    let mut focus: Option<Id> = None;
    with_mut(ctx, |g| {
        if cycle {
            g.mode = g.mode.next();
        }
        if enter {
            // Resolve against the graph as it stands: the frame the human looked at.
            g.selected.clear();
            if let Some(i) = g.nodes().iter().position(|n| n.number_is(&chord)) {
                g.selected.push_str(&g.nodes[i].id);
                // A node that is not a widget focuses its first focusable descendant.
                let n = &g.nodes[i];
                focus = n.widget.or_else(|| {
                    let p = n.id.as_str();
                    g.nodes()[i + 1..]
                        .iter()
                        .find(|m| m.widget.is_some() && m.id.len() > p.len() && m.id.starts_with(p) && m.id.as_bytes()[p.len()] == b'.')
                        .and_then(|m| m.widget)
                });
            }
            chord_on = false;
            chord.clear();
        }
        g.chord = chord;
        g.chord_on = chord_on;
        g.clear();
        let want = g.mode != NavMode::Off || g.chord_on || g.keep;
        if want != g.counted {
            if want {
                COLLECTING.fetch_add(1, Ordering::Relaxed);
            } else {
                COLLECTING.fetch_sub(1, Ordering::Relaxed);
            }
            g.counted = want;
        }
        g.collect = want;
    });
    if let Some(id) = focus {
        ctx.memory_mut(|m| m.request_focus(id));
    }
    // The harvest reads labels off the AccessKit nodes egui writes, and a robot reads
    // the chips off the same tree, so dev mode with the overlay on turns AccessKit on
    // (from the next pass; egui decides it at the start of a pass).
    if collect_now(ctx) {
        ctx.enable_accesskit();
    }
}

fn collect_now(ctx: &Context) -> bool {
    with_mut(ctx, |g| g.collect)
}

/// **The navgraph on any egui app — one call.** Installs the overlay (dev mode only):
/// `Ctrl+Shift+G` cycles it, `Ctrl+G <number> Enter` focuses. Every navigable widget is
/// numbered from egui's own per-pass record; facett registrations and a declared
/// [`NavOrder`] refine that tree. Idempotent, so it can sit in the frame loop.
pub fn overlay(ctx: &Context) {
    install(ctx);
}

fn digit(k: Key) -> Option<char> {
    Some(match k {
        Key::Num0 => '0',
        Key::Num1 => '1',
        Key::Num2 => '2',
        Key::Num3 => '3',
        Key::Num4 => '4',
        Key::Num5 => '5',
        Key::Num6 => '6',
        Key::Num7 => '7',
        Key::Num8 => '8',
        Key::Num9 => '9',
        _ => return None,
    })
}

fn end_pass(ctx: &Context) {
    if !gate_open() {
        return;
    }
    // Take the graph out of memory so painting (which locks the context) never runs
    // inside the memory lock; `Default` allocates nothing, and it goes back after.
    let mut g = with_mut(ctx, std::mem::take);
    if g.collect {
        let focused = ctx.memory(|m| m.focused());
        let pointer = ctx.pointer_hover_pos();
        // Ordinary egui: every navigable widget facett did not register, from egui's
        // own per-pass record (`harvest.rs`).
        g.harvest(ctx);
        g.finish(focused, pointer);
        if g.mode != NavMode::Off || g.chord_on {
            paint(ctx, &mut g, pointer);
        }
    }
    with_mut(ctx, |slot| *slot = g);
}

fn paint(ctx: &Context, g: &mut NavGraph, pointer: Option<Pos2>) {
    let tokens = NavOverlayTokens::from_palette(&crate::look::published_palette(ctx));
    let painter = ctx.layer_painter(LayerId::new(Order::Debug, Id::new(GRAPH_ID)));
    let font = FontId::monospace(CHIP_FONT);

    if g.mode != NavMode::Off {
        // Chip sizes from the number's width; monospace, so one glyph advance times the
        // char count measures it without laying out a galley.
        let adv = ctx.fonts_mut(|f| f.glyph_width(&font, '0'));
        let h = ctx.fonts_mut(|f| f.row_height(&font));
        g.place_chips(|n| vec2(adv * n.number.chars().count() as f32 + 6.0, h + 2.0));

        // Edges under the chips. Crisp hairlines in every effects policy (the device
        // rule): no glow in a debug overlay.
        let mode = g.mode;
        if mode.shows_all() {
            for k in 0..g.edges_contains.len() {
                let (a, b) = g.edges_contains[k];
                let (na, nb) = (&g.nodes[a as usize], &g.nodes[b as usize]);
                if na.visible && nb.visible {
                    let c = tokens.contains_at_depth(nb.depth);
                    spline(&painter, &mut g.pts, na.chip.center(), nb.chip.left_center(), 0.15, c, 1.0, false);
                }
            }
        }
        if mode.shows_next() {
            for k in 0..g.edges_next.len() {
                let (a, b) = g.edges_next[k];
                let (na, nb) = (&g.nodes[a as usize], &g.nodes[b as usize]);
                if na.visible && nb.visible {
                    let (p0, p3) = (na.chip.center(), nb.chip.center());
                    spline(&painter, &mut g.pts, p0, p3, 0.25, tokens.edge_next, 1.5, false);
                    arrowhead(&painter, &g.pts, tokens.edge_next);
                }
            }
        }
        if mode.shows_all() {
            for k in 0..g.edges_spatial.len() {
                let (a, b) = g.edges_spatial[k];
                let (na, nb) = (&g.nodes[a as usize], &g.nodes[b as usize]);
                spline(&painter, &mut g.pts, na.chip.center(), nb.chip.center(), 0.0, tokens.edge_spatial, 1.0, true);
            }
        }

        // Chips + one AccessKit node each.
        let mut hovered: Option<usize> = None;
        for i in 0..g.len {
            let n = &g.nodes[i];
            if !n.visible {
                continue;
            }
            let fg = if n.number.contains('?') {
                tokens.chip_undeclared
            } else if n.number.contains('~') {
                tokens.chip_guessed
            } else {
                tokens.chip_declared
            };
            let focused = n.focused || g.selected == n.id;
            painter.rect_filled(n.chip, 2.0, tokens.chip_plate);
            if focused {
                painter.rect_stroke(n.chip, 2.0, Stroke::new(1.0, tokens.chip_focused), egui::StrokeKind::Outside);
            }
            painter.text(n.chip.center(), egui::Align2::CENTER_CENTER, &n.number, font.clone(), fg);
            if pointer.is_some_and(|p| n.chip.contains(p)) {
                hovered = Some(i);
            }
            g.text.clear();
            let _ = write!(g.text, "{NAV_ATOM} {} {}", n.number, n.short());
            // A label that says more than the id (a harvested widget's text) rides along.
            if !n.label.is_empty() && n.label != n.local() {
                let _ = write!(g.text, " · {}", n.label);
            }
            if focused {
                g.text.push(' ');
                g.text.push_str(FOCUSED_MARK);
            }
            let chip = n.chip;
            let label = g.text.as_str();
            ctx.accesskit_node_builder(Id::new(GRAPH_ID).with(n.hash), |node| {
                // AccessKit keeps a `Label`'s text in its VALUE (a consumer reads it from
                // there), so the text goes in both places.
                node.set_role(egui::accesskit::Role::Label);
                node.set_label(label);
                node.set_value(label);
                node.set_bounds(egui::accesskit::Rect {
                    x0: chip.min.x as f64,
                    y0: chip.min.y as f64,
                    x1: chip.max.x as f64,
                    y1: chip.max.y as f64,
                });
            });
        }
        // The hovered chip grows to `number · short id` (design §3).
        if let Some(i) = hovered {
            let n = &g.nodes[i];
            g.text.clear();
            let what = if n.label.is_empty() { n.short() } else { n.label.as_str() };
            let _ = write!(g.text, "{} · {}", n.number, what);
            let w = adv * g.text.chars().count() as f32 + 6.0;
            let r = Rect::from_min_size(n.chip.min, vec2(w, n.chip.height()));
            painter.rect_filled(r, 2.0, tokens.chip_plate);
            painter.rect_stroke(r, 2.0, Stroke::new(1.0, tokens.chip_focused), egui::StrokeKind::Outside);
            painter.text(r.center(), egui::Align2::CENTER_CENTER, &g.text, font.clone(), tokens.chip_declared);
        }
    }

    // The chord being typed, bottom-left, so a human sees what Enter will focus.
    if g.chord_on {
        g.text.clear();
        let _ = write!(g.text, "Ctrl+G {}_", g.chord);
        let screen = ctx.content_rect();
        let galley = painter.layout_no_wrap(g.text.clone(), FontId::monospace(12.0), tokens.chip_declared);
        let pos = screen.left_bottom() + vec2(8.0, -8.0 - galley.size().y);
        let r = Rect::from_min_size(pos, galley.size()).expand(4.0);
        painter.rect_filled(r, 3.0, tokens.chip_plate);
        painter.rect_stroke(r, 3.0, Stroke::new(1.0, tokens.chip_focused), egui::StrokeKind::Outside);
        painter.galley(pos, galley, tokens.chip_declared);
    }
}

#[allow(clippy::too_many_arguments)]
fn spline(painter: &egui::Painter, pts: &mut Vec<Pos2>, p0: Pos2, p3: Pos2, curvature: f32, color: Color32, width: f32, dashed: bool) {
    if (p3 - p0).length_sq() < 1.0 {
        pts.clear();
        return;
    }
    spline_points_into(pts, p0, p3, curvature, SPLINE_SAMPLES);
    let stroke = Stroke::new(width, color);
    for (k, w) in pts.windows(2).enumerate() {
        if dashed && k % 2 == 1 {
            continue;
        }
        painter.line_segment([w[0], w[1]], stroke);
    }
}

fn arrowhead(painter: &egui::Painter, pts: &[Pos2], color: Color32) {
    if pts.len() < 2 {
        return;
    }
    let tip = point_at_frac(pts, 0.9);
    let back = point_at_frac(pts, 0.84);
    let d = tip - back;
    if d.length_sq() < 1e-6 {
        return;
    }
    let d = d.normalized();
    let perp = vec2(-d.y, d.x);
    let stroke = Stroke::new(1.5, color);
    painter.line_segment([tip, tip - d * 6.0 + perp * 3.5], stroke);
    painter.line_segment([tip, tip - d * 6.0 - perp * 3.5], stroke);
}

#[cfg(test)]
mod tests {
    use super::*;

    fn r(x: f32, y: f32, w: f32, h: f32) -> Rect {
        Rect::from_min_size(pos2(x, y), vec2(w, h))
    }

    /// App → two surfaces → fields.
    fn build(g: &mut NavGraph) {
        g.clear();
        let app = g.open("korp", NavKind::App, "korp", r(0.0, 0.0, 800.0, 600.0)).unwrap();
        let a = g.open("explore", NavKind::Surface, "Explore", r(0.0, 0.0, 400.0, 600.0)).unwrap();
        g.leaf("name", NavKind::Field, "name", r(10.0, 10.0, 100.0, 20.0), Some(Id::new("w-name")));
        g.leaf("date", NavKind::Field, "date", r(10.0, 40.0, 100.0, 20.0), Some(Id::new("w-date")));
        g.close(a, Rect::NOTHING);
        let b = g.open("build", NavKind::Surface, "Build", r(400.0, 0.0, 400.0, 600.0)).unwrap();
        g.leaf("run", NavKind::Field, "run", r(410.0, 10.0, 60.0, 20.0), Some(Id::new("w-run")));
        g.close(b, Rect::NOTHING);
        g.close(app, Rect::NOTHING);
    }

    fn numbers(g: &NavGraph) -> Vec<(String, String)> {
        g.nodes().iter().map(|n| (n.id.clone(), n.number.clone())).collect()
    }

    #[test]
    fn with_nothing_declared_every_step_is_a_marked_guess() {
        let mut g = NavGraph::new();
        build(&mut g);
        g.finish(None, None);
        assert_eq!(
            numbers(&g),
            [
                ("korp", "~1"),
                ("korp.explore", "~1.~1"),
                ("korp.explore.name", "~1.~1.~1"),
                ("korp.explore.date", "~1.~1.~2"),
                ("korp.build", "~1.~2"),
                ("korp.build.run", "~1.~2.~1"),
            ]
            .map(|(a, b)| (a.to_string(), b.to_string()))
        );
        assert_eq!(g.not_declared().count(), 6);
    }

    #[test]
    fn declared_order_wins_and_a_missing_child_is_a_question_mark() {
        let mut g = NavGraph::new();
        g.set_order(
            NavOrder::new()
                .roots(["korp"])
                // Declared BACKWARDS relative to the draw order: the declaration wins.
                .children("korp", ["build", "explore"])
                // `date` is not listed: it gets `?` plus its draw rank.
                .children("korp.explore", ["name"])
                .children("korp.build", ["run"]),
        );
        build(&mut g);
        g.finish(None, None);
        let n = |id: &str| g.find_id(id).unwrap().number.clone();
        assert_eq!(n("korp"), "1");
        assert_eq!(n("korp.build"), "1.1");
        assert_eq!(n("korp.explore"), "1.2");
        assert_eq!(n("korp.explore.name"), "1.2.1");
        assert_eq!(n("korp.explore.date"), "1.2.?2");
        assert_eq!(n("korp.build.run"), "1.1.1");
        let bad: Vec<&str> = g.not_declared().map(|n| n.id.as_str()).collect();
        assert_eq!(bad, ["korp.explore.date"], "only the unlisted child is not declared");
        // The chord matches with the marks ignored.
        assert_eq!(g.find_number("1.2.2").unwrap().id, "korp.explore.date");
        assert_eq!(g.find_number("1.1").unwrap().id, "korp.build");
    }

    #[test]
    fn the_wildcard_slot_takes_one_unnamed_child_and_draw_order_scopes_declare() {
        let mut g = NavGraph::new();
        g.set_order(NavOrder::new().roots(["app"]).children("app", ["tabs", "*"]));
        for _ in 0..2 {
            g.clear();
            let app = g.open("app", NavKind::App, "", r(0.0, 0.0, 10.0, 10.0)).unwrap();
            let tabs = g.open("tabs", NavKind::Group, "", r(0.0, 0.0, 10.0, 10.0)).unwrap();
            g.declare_draw_order(tabs);
            for t in ["Map", "Graph", "Grid"] {
                g.leaf(t, NavKind::Field, t, r(0.0, 0.0, 1.0, 1.0), None);
            }
            g.close(tabs, Rect::NOTHING);
            g.leaf("map/2-D map", NavKind::Pane, "", r(0.0, 0.0, 5.0, 5.0), None);
            g.leaf("extra", NavKind::Pane, "", r(0.0, 0.0, 5.0, 5.0), None);
            g.close(app, Rect::NOTHING);
            g.finish(None, None);
            let n = |id: &str| g.find_id(id).unwrap().number.clone();
            assert_eq!(n("app.tabs.Graph"), "1.1.2");
            assert_eq!(n("app.map/2-D map"), "1.2", "the wildcard slot (every frame, not only the first)");
            assert_eq!(n("app.extra"), "1.?3", "only ONE child takes the wildcard");
        }
    }

    /// A component declares its own atoms without a token: the innermost open scope (its
    /// pane) takes draw order as the declaration.
    #[test]
    fn a_component_declares_its_own_atoms_in_draw_order() {
        let mut g = NavGraph::new();
        g.clear();
        let pane = g.open("pane", NavKind::Pane, "", r(0.0, 0.0, 10.0, 10.0)).unwrap();
        g.declare_current_draw_order();
        for k in ["b", "a", "c"] {
            g.leaf(k, NavKind::Field, k, r(0.0, 0.0, 1.0, 1.0), None);
        }
        g.close(pane, Rect::NOTHING);
        g.declare_current_draw_order(); // nothing open: a no-op, never a panic
        g.finish(None, None);
        assert_eq!(g.find_id("pane.a").unwrap().number, "~1.2", "declared in draw order, not guessed");
        assert!(g.find_id("pane.c").unwrap().step.is_declared());
    }

    /// The per-frame declaration (`declare_children`, what `facett_app::scene` hands in
    /// for a tab set) honours the wildcard too, and a harvested widget never takes it.
    #[test]
    fn a_per_frame_declaration_has_a_wildcard_slot_that_skips_harvested_widgets() {
        let mut g = NavGraph::new();
        for _ in 0..2 {
            g.clear();
            let set = g.open("work", NavKind::Group, "", r(0.0, 0.0, 10.0, 10.0)).unwrap();
            g.declare_children(set, ["tabs", WILDCARD]);
            let chrome = g.leaf("w1", NavKind::Field, "min", r(0.0, 0.0, 1.0, 1.0), None).unwrap();
            g.nodes[chrome as usize].harvested = true;
            g.leaf("tabs", NavKind::Group, "", r(0.0, 0.0, 10.0, 1.0), None);
            g.leaf("cases", NavKind::Pane, "", r(0.0, 1.0, 10.0, 10.0), None);
            g.leaf("stray", NavKind::Pane, "", r(0.0, 1.0, 10.0, 10.0), None);
            g.close(set, Rect::NOTHING);
            g.finish(None, None);
            let n = |id: &str| g.find_id(id).unwrap().number.clone();
            assert_eq!(n("work.tabs"), "~1.1");
            assert_eq!(n("work.cases"), "~1.2", "the active tab takes the wildcard, not the chrome button");
            assert!(n("work.w1").contains('?'), "the harvested chrome is undeclared: {}", n("work.w1"));
            assert!(n("work.stray").contains('?'), "one child only: {}", n("work.stray"));
        }
    }

    #[test]
    fn a_second_registration_merges_and_keys_cannot_forge_segments() {
        let mut g = NavGraph::new();
        g.clear();
        let p = g.open("deck", NavKind::Surface, "", r(0.0, 0.0, 10.0, 10.0)).unwrap();
        let a = g.open("pane", NavKind::Pane, "", r(0.0, 0.0, 5.0, 5.0)).unwrap();
        g.leaf("x", NavKind::Field, "", r(0.0, 0.0, 1.0, 1.0), None);
        g.close(a, r(0.0, 0.0, 5.0, 5.0));
        // The badge names the same pane after it drew.
        let again = g.leaf("pane", NavKind::Pane, "Pane", r(0.0, 0.0, 6.0, 6.0), None).unwrap();
        assert_eq!(again, a, "same full id, same node");
        g.leaf("v0.1", NavKind::Field, "", r(0.0, 0.0, 1.0, 1.0), None);
        assert_eq!(g.len(), 4);
        assert!(g.find_id("deck.v0_1").is_some(), "a dot in a key is not a path separator");
        // A Debug key (the a11y hook) loses its quotes.
        g.leaf_debug(&"node-7", "n7", r(0.0, 0.0, 1.0, 1.0), None);
        assert!(g.find_id("deck.node-7").is_some());
        g.leaf_debug(&1.5_f32, "f", r(0.0, 0.0, 1.0, 1.0), None);
        assert!(g.find_id("deck.1_5").is_some(), "a Debug key with a dot is sanitised");
        g.close(p, Rect::NOTHING);
    }

    #[test]
    fn the_arena_reuses_its_capacity_frame_after_frame() {
        let mut g = NavGraph::new();
        build(&mut g);
        g.finish(None, None);
        let cap = g.nodes.capacity();
        for _ in 0..10 {
            build(&mut g);
            g.finish(None, None);
        }
        assert_eq!(g.nodes.capacity(), cap, "the node vec did not grow");
        assert_eq!(g.nodes.len(), 6, "slots are reused, not appended");
    }

    #[test]
    fn depth_limit_hides_deep_levels_until_hover_or_focus() {
        let mut g = NavGraph::new();
        g.depth_limit = 2;
        build(&mut g);
        g.finish(None, None);
        let vis = |g: &NavGraph, id: &str| g.find_id(id).unwrap().visible;
        assert!(vis(&g, "korp.explore"));
        assert!(!vis(&g, "korp.explore.name"), "level 3 is below the limit");
        // Pointer in the Explore surface → its fields show; Build's do not.
        build(&mut g);
        g.finish(None, Some(pos2(50.0, 50.0)));
        assert!(vis(&g, "korp.explore.name"));
        assert!(!vis(&g, "korp.build.run"));
        // Focus inside Build → its fields show, and the chain is marked.
        build(&mut g);
        g.finish(Some(Id::new("w-run")), None);
        assert!(vis(&g, "korp.build.run"));
        assert!(g.find_id("korp.build.run").unwrap().focused);
        assert!(g.find_id("korp.build").unwrap().focus_in);
        assert!(!g.find_id("korp.build").unwrap().focused, "focused is the exact node only");
        assert!(!g.find_id("korp.explore").unwrap().focus_in);
    }

    #[test]
    fn next_edges_follow_rank_and_skip_the_undeclared() {
        let mut g = NavGraph::new();
        g.set_order(NavOrder::new().roots(["korp"]).children("korp", ["build", "explore"]).children("korp.explore", ["name"]));
        build(&mut g);
        g.finish(None, None);
        let id = |i: u32| g.nodes()[i as usize].id.as_str();
        let next: Vec<(&str, &str)> = g.edges_next().iter().map(|&(a, b)| (id(a), id(b))).collect();
        assert!(next.contains(&("korp.build", "korp.explore")), "declared order, not draw order: {next:?}");
        assert!(!next.iter().any(|(a, b)| *a == "korp.explore.date" || *b == "korp.explore.date"), "? has no place in the order");
        assert_eq!(g.edges_contains().len(), 5, "one contains edge per non-root");
    }

    #[test]
    fn colliding_chips_are_pushed_down_and_right_deterministically() {
        let mut g = NavGraph::new();
        g.clear();
        let p = g.open("a", NavKind::App, "", r(0.0, 0.0, 100.0, 100.0)).unwrap();
        // Same top-left as the parent: its chip would sit exactly on the parent's.
        g.leaf("b", NavKind::Surface, "", r(0.0, 0.0, 50.0, 50.0), None);
        g.close(p, Rect::NOTHING);
        g.finish(None, None);
        g.place_chips(|_| vec2(20.0, 12.0));
        let (a, b) = (g.chip(0), g.chip(1));
        assert!(!a.intersects(b), "{a:?} vs {b:?}");
        assert!(b.top() >= a.bottom() && b.left() > a.left(), "pushed down and right");
        let first = (a, b);
        g.place_chips(|_| vec2(20.0, 12.0));
        assert_eq!((g.chip(0), g.chip(1)), first, "same input, same layout");
    }

    #[test]
    fn spline_paths_between_chips_are_pure_and_end_on_the_anchors() {
        let (p0, p3) = (pos2(10.0, 10.0), pos2(200.0, 120.0));
        let mut a = Vec::new();
        let mut b = Vec::new();
        spline_points_into(&mut a, p0, p3, 0.25, SPLINE_SAMPLES);
        spline_points_into(&mut b, p0, p3, 0.25, SPLINE_SAMPLES);
        assert_eq!(a, b, "same rects, same path");
        assert_eq!(a, crate::edges::spline_points(p0, p3, 0.25, SPLINE_SAMPLES), "the buffered twin is the same path");
        assert!((a[0] - p0).length() < 1e-3 && (*a.last().unwrap() - p3).length() < 1e-3);
        let cap = a.capacity();
        spline_points_into(&mut a, p3, p0, 0.1, SPLINE_SAMPLES);
        assert_eq!(a.capacity(), cap, "the buffer is reused");
        // The next-edge arrowhead sits at 0.9 of the path, on the curve.
        let tip = point_at_frac(&a, 0.9);
        assert!(a.iter().any(|q| (*q - tip).length() < 20.0));
    }

    #[test]
    fn json_carries_numbers_kinds_rects_and_edges() {
        let mut g = NavGraph::new();
        g.set_order(NavOrder::new().roots(["korp"]).children("korp", ["explore", "build"]));
        build(&mut g);
        g.finish(None, None);
        let j = g.to_json();
        assert_eq!(j["count"], 6);
        assert_eq!(j["nodes"][1]["id"], "korp.explore");
        assert_eq!(j["nodes"][1]["number"], "1.1");
        assert_eq!(j["nodes"][1]["kind"], "surface");
        assert_eq!(j["nodes"][2]["step"], "guessed");
        assert_eq!(j["nodes"][2]["rect"], serde_json::json!([10.0, 10.0, 110.0, 30.0]));
        assert_eq!(j["edges"]["contains"].as_array().unwrap().len(), 5);
        assert!(j["declared_order"].as_bool().unwrap());
    }

    #[test]
    fn modes_cycle_and_parse() {
        let mut m = NavMode::Off;
        let seen: Vec<NavMode> = (0..4)
            .map(|_| {
                m = m.next();
                m
            })
            .collect();
        assert_eq!(seen, [NavMode::Labels, NavMode::LabelsNext, NavMode::All, NavMode::Off]);
        assert_eq!(NavMode::parse("next"), Some(NavMode::LabelsNext));
        assert_eq!(NavMode::parse("banana"), None);
        assert_eq!(NavOrder::new().roots(["a"]).get(""), Some(&["a".to_string()][..]));
    }
}