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/nav.rs

//! **Shared spatial navigation + focus** (§13 FOC-2, §14 NAV-1) — the *one*
//! pan/zoom model (`Navigable`) reused by map/graph/plot/canvas, and the *one*
//! spatial focus rule (`nearest_in_direction`) reused by pane and form
//! navigation (COH-3). Pan/zoom is expressed as an `egui::emath::TSTransform`
//! (scale + offset), so a host can also use it to transform an overlaid guest
//! layer (§5 CMP-4).

use egui::{Pos2, Rect, Vec2, emath::TSTransform, vec2};
use serde::{Deserialize, Serialize};

/// A compass direction for directional focus / pane moves.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Dir4 {
    Left,
    Right,
    Up,
    Down,
}

/// The shared pan/zoom model (NAV-1). Stores a scale + translation as a
/// `TSTransform` mapping **scene → screen**. Map, graph and plot all drive this
/// with the same gestures, so they feel identical (NAV-2).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Navigable {
    /// Uniform zoom (scene units → screen px).
    pub scale: f32,
    /// Scene-origin offset in screen px.
    pub offset: [f32; 2],
    /// Zoom clamp.
    pub min_scale: f32,
    pub max_scale: f32,
}

impl Default for Navigable {
    fn default() -> Self {
        Self { scale: 1.0, offset: [0.0, 0.0], min_scale: 0.05, max_scale: 64.0 }
    }
}

impl Navigable {
    /// The scene→screen transform.
    pub fn transform(&self) -> TSTransform {
        TSTransform::new(Vec2::new(self.offset[0], self.offset[1]), self.scale)
    }

    /// Pan by a screen-space delta (drag / arrows).
    pub fn pan(&mut self, delta: Vec2) {
        self.offset[0] += delta.x;
        self.offset[1] += delta.y;
    }

    /// **Zoom toward a screen point** by factor `k` (>1 zooms in), keeping the
    /// scene point under the cursor fixed (the natural map/plot feel, NAV-1).
    pub fn zoom_to(&mut self, k: f32, screen_pivot: Pos2) {
        let new_scale = (self.scale * k).clamp(self.min_scale, self.max_scale);
        let actual_k = new_scale / self.scale;
        // Keep `screen_pivot` fixed: offset' = pivot - (pivot - offset) * actual_k
        self.offset[0] = screen_pivot.x - (screen_pivot.x - self.offset[0]) * actual_k;
        self.offset[1] = screen_pivot.y - (screen_pivot.y - self.offset[1]) * actual_k;
        self.scale = new_scale;
    }

    /// **Fit** a scene bounding box into a screen viewport with a margin
    /// fraction (FitToView). Centres + scales so the whole bbox is visible.
    pub fn fit(&mut self, scene_bbox: Rect, viewport: Rect, margin: f32) {
        if scene_bbox.width() <= 0.0 || scene_bbox.height() <= 0.0 {
            return;
        }
        let m = margin.clamp(0.0, 0.45);
        let avail = viewport.size() * (1.0 - 2.0 * m);
        let sx = avail.x / scene_bbox.width();
        let sy = avail.y / scene_bbox.height();
        let s = sx.min(sy).clamp(self.min_scale, self.max_scale);
        self.scale = s;
        // Centre the bbox in the viewport.
        let bbox_center_scaled = scene_bbox.center().to_vec2() * s;
        let vp_center = viewport.center().to_vec2();
        self.offset = [vp_center.x - bbox_center_scaled.x, vp_center.y - bbox_center_scaled.y];
    }

    /// Map a scene point to screen.
    pub fn to_screen(&self, scene: Pos2) -> Pos2 {
        self.transform().mul_pos(scene)
    }

    /// This camera as a [`crate::fly`] state over the `seen` screen rect: the scene
    /// point at its centre and the scene width it shows.
    #[must_use]
    pub fn fly_state(&self, seen: Rect) -> ([f64; 3], f64) {
        let s = f64::from(self.scale.max(1e-9));
        let c = seen.center();
        (
            [(f64::from(c.x) - f64::from(self.offset[0])) / s, (f64::from(c.y) - f64::from(self.offset[1])) / s, 0.0],
            f64::from(seen.width().max(1.0)) / s,
        )
    }

    /// Put the camera at a [`crate::fly`] state over `seen` (the inverse of
    /// [`fly_state`](Self::fly_state)).
    pub fn set_fly_state(&mut self, c: [f64; 3], w: f64, seen: Rect) {
        let s = f64::from(seen.width().max(1.0)) / w.max(1e-12);
        let ctr = seen.center();
        self.scale = s as f32;
        self.offset = [(f64::from(ctr.x) - c[0] * s) as f32, (f64::from(ctr.y) - c[1] * s) as f32];
    }
}

/// **The size a graph canvas takes**: what the layout has left, but never laid out past
/// the visible clip. A canvas inside a scroll area or an unbounded layout used to take
/// the whole available width — 10 604 px on loki — and every camera centres on its
/// canvas: the graph landed "10 cm to the right, outside the window", and a GPU
/// offscreen that wide failed. When the cursor is already outside the clip (a canvas
/// scrolled off), the layout's own size is used.
#[must_use]
pub fn canvas_size(ui: &egui::Ui) -> Vec2 {
    let avail = ui.available_size();
    let room = ui.clip_rect().max - ui.cursor().min;
    let side = |a: f32, r: f32| if r >= 64.0 && a.is_finite() { a.min(r) } else if a.is_finite() { a } else { r.max(64.0) };
    vec2(side(avail.x, room.x), side(avail.y, room.y))
}

/// **A screen-space camera's canvas, tracked** — for a [`Navigable`] pane whose camera
/// lives in screen px: the part of the canvas the viewer SAW (canvas ∩ clip; a fit
/// centres on it), and how far the canvas moved since the last frame (the picture
/// follows it — a camera pinned to where the canvas used to be is the "graph opens
/// off to the right" class).
#[derive(Debug, Default)]
pub struct CanvasTrack {
    canvas: std::cell::Cell<Option<Rect>>,
    seen: std::cell::Cell<Option<Rect>>,
}

impl Clone for CanvasTrack {
    fn clone(&self) -> Self {
        Self { canvas: std::cell::Cell::new(self.canvas.get()), seen: std::cell::Cell::new(self.seen.get()) }
    }
}

impl CanvasTrack {
    /// Record this frame's canvas `rect`: `(seen, moved)` — the visible part, and the
    /// canvas's move since the last frame (zero on the first).
    pub fn frame(&self, ui: &egui::Ui, rect: Rect) -> (Rect, Vec2) {
        let seen = rect.intersect(ui.clip_rect());
        let seen = if seen.width() > 1.0 && seen.height() > 1.0 { seen } else { rect };
        let moved = self.canvas.get().map_or(Vec2::ZERO, |prev| rect.min - prev.min);
        self.canvas.set(Some(rect));
        self.seen.set(Some(seen));
        (seen, moved)
    }

    /// The visible part of the last canvas.
    pub fn seen(&self) -> Option<Rect> {
        self.seen.get()
    }
}

/// **🎯 Focus / ⊙ fit as a flight for a [`Navigable`]**: the shared [`crate::fly`]
/// path over the `seen` rect it started in. A pane keeps one in its state (`None` when
/// still), starts it with [`NavFlight::to`] and advances it each frame with
/// [`NavFlight::tick`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct NavFlight {
    pub flight: crate::fly::Flight,
    pub seen: Rect,
}

impl NavFlight {
    /// Fly `nav` (as it is, or from where `current` has it mid-flight) so scene point
    /// `target` lands at the centre of `seen` at `scale` (scene → px), from `now`.
    #[must_use]
    pub fn to(nav: &Navigable, current: Option<NavFlight>, target: Pos2, scale: f32, seen: Rect, now: f64) -> Self {
        let mut from = *nav;
        if let Some(f) = current {
            let (c, w) = f.flight.at(now);
            from.set_fly_state(c, w, f.seen);
        }
        let (c0, w0) = from.fly_state(seen);
        let w1 = f64::from(seen.width().max(1.0)) / f64::from(scale.max(1e-6));
        Self { flight: crate::fly::Flight::new(now, c0, w0, [f64::from(target.x), f64::from(target.y), 0.0], w1), seen }
    }

    /// Fly to `bbox` framed and centred in `seen` with a `margin` fraction (⊙ fit).
    #[must_use]
    pub fn fit(nav: &Navigable, current: Option<NavFlight>, bbox: Rect, seen: Rect, margin: f32, now: f64) -> Self {
        let mut t = *nav;
        t.fit(bbox, seen, margin);
        Self::to(nav, current, bbox.center(), t.scale, seen, now)
    }

    /// Advance `nav` to `now`; `true` while still flying.
    pub fn tick(&self, nav: &mut Navigable, now: f64) -> bool {
        let (c, w) = self.flight.at(now);
        nav.set_fly_state(c, w, self.seen);
        !self.flight.done(now)
    }
}

/// A camera gesture, independent of any component's message type.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum NavGesture {
    Pan(Vec2),
    Zoom { factor: f32, anchor: Pos2 },
    Fit,
}

/// Read shared drag, wheel, pinch and keyboard camera gestures without changing
/// the camera. Keyboard shortcuts belong only to the hovered or focused pane,
/// and do not steal typing from text inputs. The caller applies returned messages.
pub fn gestures(ui: &mut egui::Ui, rect: Rect, resp: &egui::Response) -> Vec<NavGesture> {
    use crate::look::{Action, keymap};
    let mut messages = Vec::new();
    if resp.dragged() {
        messages.push(NavGesture::Pan(resp.drag_delta()));
    }
    let pointer = ui.input(|i| i.pointer.hover_pos()).unwrap_or(rect.center());
    let anchor = if rect.contains(pointer) { pointer } else { rect.center() };
    let (scroll, zoom, modifiers) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers));
    if resp.hovered() {
        if (zoom - 1.0).abs() > 1e-4 {
            messages.push(NavGesture::Zoom { factor: zoom, anchor });
        } else if scroll.y != 0.0 && !modifiers.ctrl {
            messages.push(NavGesture::Zoom { factor: (1.0 + scroll.y * 0.0015).clamp(0.5, 2.0), anchor });
        }
        if scroll.x != 0.0 {
            messages.push(NavGesture::Pan(vec2(scroll.x, 0.0)));
        }
    }
    if !(resp.hovered() || resp.has_focus()) || (ui.ctx().egui_wants_keyboard_input() && !resp.has_focus()) {
        return messages;
    }
    let km = keymap(ui);
    let mut delta = Vec2::ZERO;
    for (action, movement) in [
        (Action::Left, vec2(60.0, 0.0)),
        (Action::Right, vec2(-60.0, 0.0)),
        (Action::Up, vec2(0.0, 60.0)),
        (Action::Down, vec2(0.0, -60.0)),
    ] {
        if km.consume(action, ui) {
            delta += movement;
        }
    }
    if delta != Vec2::ZERO {
        messages.push(NavGesture::Pan(delta));
    }
    if km.consume(Action::ZoomIn, ui) || km.consume(Action::PageUp, ui) {
        messages.push(NavGesture::Zoom { factor: 1.2, anchor: rect.center() });
    }
    if km.consume(Action::ZoomOut, ui) || km.consume(Action::PageDown, ui) {
        messages.push(NavGesture::Zoom { factor: 1.0 / 1.2, anchor: rect.center() });
    }
    if km.consume(Action::FitToView, ui)
        || ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::F))
    {
        messages.push(NavGesture::Fit);
    }
    messages
}

/// **Spatial focus** (FOC-2): among `candidates` (their rects), pick the nearest
/// focusable in direction `dir` from `current`. Picks by directional projection +
/// perpendicular penalty — the standard "nearest in that direction" rule, not tab
/// order. Returns the index into `candidates`, or `None` if nothing lies that way.
pub fn nearest_in_direction(current: Rect, candidates: &[Rect], dir: Dir4) -> Option<usize> {
    let from = current.center();
    let mut best: Option<(usize, f32)> = None;
    for (i, &r) in candidates.iter().enumerate() {
        let to = r.center();
        let d = to - from;
        // Must lie predominantly in the requested direction.
        let (along, across) = match dir {
            Dir4::Left => (-d.x, d.y.abs()),
            Dir4::Right => (d.x, d.y.abs()),
            Dir4::Up => (-d.y, d.x.abs()),
            Dir4::Down => (d.y, d.x.abs()),
        };
        if along <= 0.5 {
            continue; // not in this direction (or same rect)
        }
        // Cost: distance along + a penalty for being off-axis. Lower is better.
        let cost = along + across * 2.0;
        if best.map(|(_, c)| cost < c).unwrap_or(true) {
            best = Some((i, cost));
        }
    }
    best.map(|(i, _)| i)
}

/// Lay out hint badge anchor points for a set of focusable rects (FOC-3): the
/// top-left inset of each rect, where a which-key label is painted. Pure so a
/// snapshot test can assert positions.
pub fn hint_anchors(rects: &[Rect]) -> Vec<Pos2> {
    rects.iter().map(|r| r.left_top() + vec2(4.0, 4.0)).collect()
}

// ─────────────────────────────────────────────────────────────────────────────
// Hierarchical drill-down navigation: LEVELS + a history stack (back) + in-place
// progressive UNFOLD. Shared by the 2D (facett-graphview) and 3D (facett-graph3d
// Code Vault) warehouse navigators so both feel identical (NAV-2) and a level
// change is a DATA edit, not code (COH-3). Distinct from `Navigable` (that is the
// spatial pan/zoom); this is the semantic drill hierarchy.
// ─────────────────────────────────────────────────────────────────────────────

use std::collections::BTreeSet;

/// One **level** in the drill-down hierarchy (constellation → repo → crate →
/// module → code, …). Data-driven: adding a level (e.g. a `type`/`class` level)
/// is a `NavLevel` in the list, never new match arms.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NavLevel {
    /// Stable id (`"constellation"`, `"repo"`, `"crate"`, `"module"`, `"code"`).
    pub id: String,
    /// Human label for the breadcrumb.
    pub label: String,
}

impl NavLevel {
    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
        Self { id: id.into(), label: label.into() }
    }
}

/// One entry on the drill path: the level reached and the node focused to get there.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NavFocus {
    /// Index into [`DrillNav::levels`].
    pub level: usize,
    /// The focused node id at that level (`""` for the root focus).
    pub node: String,
}

/// **The shared drill-down navigator model.** Holds a data-driven ordered list of
/// [`NavLevel`]s, a **history stack** of [`NavFocus`] drill-downs (right-click =
/// `back` = pop), and an **`expanded`** set for progressive **in-place unfold** —
/// unfolding a node NEVER removes its parent/ancestors (the "travel and unfold
/// everything without closing the parent" invariant). Domain-agnostic: it does not
/// hold the graph; the consumer supplies the child lookup to [`visible`](Self::visible).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct DrillNav {
    levels: Vec<NavLevel>,
    /// The drill path; always ≥1 entry (`history[0]` = the root focus at level 0).
    history: Vec<NavFocus>,
    /// Nodes unfolded in place (their children are shown). A superset never shrinks
    /// on unfold — only [`fold`](Self::fold) removes.
    expanded: BTreeSet<String>,
}

impl DrillNav {
    /// Build a navigator over an ordered level list, positioned at the root
    /// (level 0). An empty level list still yields a valid single-level navigator.
    pub fn new(levels: impl IntoIterator<Item = NavLevel>) -> Self {
        let levels: Vec<NavLevel> = levels.into_iter().collect();
        Self { levels, history: vec![NavFocus { level: 0, node: String::new() }], expanded: BTreeSet::new() }
    }

    /// The canonical constellation→repo→crate→module→code hierarchy (the nornir
    /// NavigatorModel levels). A convenience default; a host may pass its own.
    pub fn warehouse() -> Self {
        Self::new([
            NavLevel::new("constellation", "Constellation"),
            NavLevel::new("repo", "Repo"),
            NavLevel::new("crate", "Crate"),
            NavLevel::new("module", "Module"),
            NavLevel::new("code", "Code"),
        ])
    }

    /// The level list (breadcrumb source).
    pub fn levels(&self) -> &[NavLevel] {
        &self.levels
    }

    /// The current drill focus (never `None`; the root at minimum).
    pub fn focus(&self) -> &NavFocus {
        self.history.last().expect("history is never empty")
    }

    /// The current level index.
    pub fn current_level(&self) -> usize {
        self.focus().level
    }

    /// The current level's id (`""` if there are no levels).
    pub fn current_level_id(&self) -> &str {
        self.levels.get(self.current_level()).map(|l| l.id.as_str()).unwrap_or("")
    }

    /// How deep the drill path is (1 = at the root).
    pub fn depth(&self) -> usize {
        self.history.len()
    }

    /// **Drill down** into `node` — push a focus at the next level (clamped to the
    /// last level), and unfold `node` so its children are visible in place. This is
    /// the click-to-descend gesture.
    pub fn drill(&mut self, node: impl Into<String>) {
        let node = node.into();
        let next = (self.current_level() + 1).min(self.levels.len().saturating_sub(1));
        self.expanded.insert(node.clone());
        self.history.push(NavFocus { level: next, node });
    }

    /// **Navigate back** up one level — pop the drill history (the right-click
    /// gesture). Returns `true` if it moved (false at the root). Does NOT fold; the
    /// unfolded children stay on screen (in-place travel), only the focus/level moves.
    pub fn back(&mut self) -> bool {
        if self.history.len() > 1 {
            self.history.pop();
            true
        } else {
            false
        }
    }

    /// **Unfold `node` in place** — show its children WITHOUT closing the parent or
    /// any ancestor. Idempotent. The key "class-diagram progressive expand" gesture.
    pub fn unfold(&mut self, node: impl Into<String>) {
        self.expanded.insert(node.into());
    }

    /// **Fold `node`** — hide its children again (the parent itself stays visible).
    pub fn fold(&mut self, node: &str) {
        self.expanded.remove(node);
    }

    /// Is `node` currently unfolded?
    pub fn is_expanded(&self, node: &str) -> bool {
        self.expanded.contains(node)
    }

    /// The unfolded set.
    pub fn expanded(&self) -> &BTreeSet<String> {
        &self.expanded
    }

    /// Unfold every id in `all` — the fully-open initial state (so a view that starts
    /// "everything visible" is one call, and folding is the opt-in restriction).
    pub fn unfold_all<'a>(&mut self, all: impl IntoIterator<Item = &'a str>) {
        for id in all {
            self.expanded.insert(id.to_string());
        }
    }

    /// **The visible set under progressive unfold**: every `root`, plus the children
    /// (via `children`) of every EXPANDED node, transitively. A node is NEVER removed
    /// because a descendant unfolded — ancestors always remain (the in-place-unfold
    /// invariant). `children` returns a node's direct child ids.
    pub fn visible<F>(&self, roots: impl IntoIterator<Item = String>, children: F) -> BTreeSet<String>
    where
        F: Fn(&str) -> Vec<String>,
    {
        let mut vis = BTreeSet::new();
        let mut stack: Vec<String> = roots.into_iter().collect();
        while let Some(n) = stack.pop() {
            if !vis.insert(n.clone()) {
                continue;
            }
            if self.expanded.contains(&n) {
                for c in children(&n) {
                    if !vis.contains(&c) {
                        stack.push(c);
                    }
                }
            }
        }
        vis
    }

    /// The drill history as a breadcrumb of `(level_id, node)` — for `state_json`.
    pub fn breadcrumb(&self) -> Vec<(String, String)> {
        self.history
            .iter()
            .map(|f| (self.levels.get(f.level).map(|l| l.id.clone()).unwrap_or_default(), f.node.clone()))
            .collect()
    }

    /// The observable nav state (folded into a host's `state_json`).
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "levels": self.levels.iter().map(|l| l.id.clone()).collect::<Vec<_>>(),
            "current_level": self.current_level(),
            "current_level_id": self.current_level_id(),
            "depth": self.depth(),
            "focus": self.focus().node,
            "breadcrumb": self.breadcrumb().into_iter().map(|(l, n)| serde_json::json!([l, n])).collect::<Vec<_>>(),
            "expanded": self.expanded.iter().cloned().collect::<Vec<_>>(),
            "expanded_count": self.expanded.len(),
        })
    }
}

#[cfg(test)]
mod tests {
    use egui::pos2;

    use super::*;

    // ── DrillNav: levels + history/back + in-place unfold ──────────────────────

    fn kids(n: &str) -> Vec<String> {
        // A tiny tree: root → {a, b}; a → {a1, a2}; b → {b1}.
        match n {
            "root" => vec!["a".into(), "b".into()],
            "a" => vec!["a1".into(), "a2".into()],
            "b" => vec!["b1".into()],
            _ => vec![],
        }
    }

    #[test]
    fn drill_advances_the_level_and_back_returns_to_the_parent() {
        let mut nav = DrillNav::warehouse();
        assert_eq!(nav.current_level_id(), "constellation");
        assert_eq!(nav.depth(), 1);
        nav.drill("nornir"); // → repo
        assert_eq!(nav.current_level_id(), "repo");
        nav.drill("nornir-warehouse"); // → crate
        assert_eq!(nav.current_level_id(), "crate");
        assert_eq!(nav.depth(), 3);
        // Right-click = back: returns to the PARENT level.
        assert!(nav.back());
        assert_eq!(nav.current_level_id(), "repo", "back returns to the parent level");
        assert_eq!(nav.focus().node, "nornir");
        // Back to the root, then back is a no-op (nothing above the constellation).
        assert!(nav.back());
        assert_eq!(nav.current_level_id(), "constellation");
        assert!(!nav.back(), "back at the root does nothing");
        assert_eq!(nav.depth(), 1);
    }

    #[test]
    fn unfold_keeps_the_parent_and_all_ancestors_visible() {
        // RED-when-broken: if unfolding a child dropped its parent from the visible
        // set, this fails. Start collapsed (only root expanded), unfold progressively,
        // and assert the parent + ancestors persist as children appear.
        let mut nav = DrillNav::warehouse();
        nav.unfold("root");
        let roots = || ["root".to_string()];
        // Only root expanded → root + its direct children visible; grandchildren not.
        let v = nav.visible(roots(), kids);
        assert!(v.contains("root") && v.contains("a") && v.contains("b"));
        assert!(!v.contains("a1"), "a's children hidden until a is unfolded");
        // Unfold `a` IN PLACE → a1/a2 appear AND root + a + b all remain.
        nav.unfold("a");
        let v = nav.visible(roots(), kids);
        assert!(v.contains("a1") && v.contains("a2"), "a unfolded its children");
        assert!(v.contains("root"), "the ROOT (ancestor) is still visible after unfold");
        assert!(v.contains("a"), "the PARENT is still visible after unfold");
        assert!(v.contains("b"), "the sibling stays too");
        // Fold `a` again → its children vanish, but `a` itself stays.
        nav.fold("a");
        let v = nav.visible(roots(), kids);
        assert!(!v.contains("a1") && v.contains("a"), "fold hides children, keeps the node");
    }

    #[test]
    fn drill_unfolds_the_focused_node_so_children_show_in_place() {
        let mut nav = DrillNav::warehouse();
        nav.unfold("root");
        nav.drill("a"); // descending focuses AND unfolds `a`
        assert!(nav.is_expanded("a"));
        let v = nav.visible(["root".to_string()], kids);
        assert!(v.contains("a1") && v.contains("root"), "drilling opened a in place, root stays");
    }

    #[test]
    fn drillnav_state_json_and_serde_round_trip() {
        let mut nav = DrillNav::warehouse();
        nav.drill("nornir");
        nav.unfold("nornir");
        let j = nav.state_json();
        assert_eq!(j["current_level_id"], "repo");
        assert_eq!(j["depth"], 2);
        assert_eq!(j["expanded_count"], 1);
        // FC-3: the model round-trips serde.
        let back: DrillNav = serde_json::from_value(serde_json::to_value(&nav).unwrap()).unwrap();
        assert_eq!(back, nav);
    }

    #[test]
    fn zoom_keeps_the_pivot_point_fixed() {
        let mut nav = Navigable::default();
        let pivot = pos2(200.0, 150.0);
        // The scene point currently under the pivot.
        let before = inverse(&nav, pivot);
        nav.zoom_to(2.0, pivot);
        let after = inverse(&nav, pivot);
        assert!((before - after).length() < 1e-3, "scene point under cursor must stay put");
        assert_eq!(nav.scale, 2.0);
    }

    fn inverse(nav: &Navigable, screen: Pos2) -> Pos2 {
        // screen = offset + scene*scale → scene = (screen - offset)/scale
        pos2((screen.x - nav.offset[0]) / nav.scale, (screen.y - nav.offset[1]) / nav.scale)
    }

    #[test]
    fn zoom_clamps_to_range() {
        let mut nav = Navigable::default();
        for _ in 0..100 {
            nav.zoom_to(2.0, pos2(0.0, 0.0));
        }
        assert!(nav.scale <= nav.max_scale + 1e-3);
        for _ in 0..100 {
            nav.zoom_to(0.5, pos2(0.0, 0.0));
        }
        assert!(nav.scale >= nav.min_scale - 1e-3);
    }

    #[test]
    fn fit_centres_and_scales_a_bbox() {
        let mut nav = Navigable::default();
        let bbox = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 100.0));
        let vp = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 400.0));
        nav.fit(bbox, vp, 0.1);
        // The bbox centre maps to the viewport centre.
        let c = nav.to_screen(bbox.center());
        assert!((c - vp.center()).length() < 1.0, "bbox centre → viewport centre");
        // And it fits with margin (scale ~ (400*0.8)/100 = 3.2).
        assert!((nav.scale - 3.2).abs() < 0.1, "fit scale, got {}", nav.scale);
    }

    #[test]
    fn spatial_focus_picks_nearest_in_direction_not_tab_order() {
        // current at centre; one candidate to the right (close), one far up.
        let current = Rect::from_center_size(pos2(100.0, 100.0), vec2(40.0, 20.0));
        let right = Rect::from_center_size(pos2(180.0, 105.0), vec2(40.0, 20.0));
        let up = Rect::from_center_size(pos2(100.0, 20.0), vec2(40.0, 20.0));
        let cands = [up, right];
        assert_eq!(nearest_in_direction(current, &cands, Dir4::Right), Some(1));
        assert_eq!(nearest_in_direction(current, &cands, Dir4::Up), Some(0));
        // Nothing to the left.
        assert_eq!(nearest_in_direction(current, &cands, Dir4::Left), None);
    }

    #[test]
    fn spatial_focus_prefers_on_axis_over_diagonal() {
        let current = Rect::from_center_size(pos2(0.0, 0.0), vec2(10.0, 10.0));
        let straight = Rect::from_center_size(pos2(100.0, 0.0), vec2(10.0, 10.0));
        let diagonal = Rect::from_center_size(pos2(90.0, 90.0), vec2(10.0, 10.0));
        let cands = [diagonal, straight];
        assert_eq!(nearest_in_direction(current, &cands, Dir4::Right), Some(1), "straight beats diagonal");
    }
}