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");
}
}