#![forbid(unsafe_code)]
use super::graph::{FocusGraph, FocusId, NavDirection};
use ftui_core::geometry::Rect;
#[must_use]
pub fn spatial_navigate(graph: &FocusGraph, origin: FocusId, dir: NavDirection) -> Option<FocusId> {
if let Some(target) = graph.navigate(origin, dir)
&& graph.get(target).is_some_and(|n| n.is_focusable)
{
return Some(target);
}
if !matches!(
dir,
NavDirection::Up | NavDirection::Down | NavDirection::Left | NavDirection::Right
) {
return None;
}
let origin_node = graph.get(origin)?;
let oc = center_i32(&origin_node.bounds);
let mut best: Option<(FocusId, i64)> = None;
for candidate_id in graph.node_ids() {
if candidate_id == origin {
continue;
}
let Some(candidate) = graph.get(candidate_id) else {
continue;
};
if !candidate.is_focusable {
continue;
}
let cc = center_i32(&candidate.bounds);
if !in_quadrant_i32(oc, cc, dir) {
continue;
}
let score = distance_score_i32(oc, cc, dir);
if best.is_none_or(|(best_id, best_score)| {
score < best_score || (score == best_score && candidate_id < best_id)
}) {
best = Some((candidate_id, score));
}
}
best.map(|(id, _)| id)
}
pub fn build_spatial_edges(graph: &mut FocusGraph) {
let ids: Vec<FocusId> = graph.node_ids().collect();
let dirs = [
NavDirection::Up,
NavDirection::Down,
NavDirection::Left,
NavDirection::Right,
];
let mut edges_to_add = Vec::new();
for &id in &ids {
for dir in dirs {
if graph.navigate(id, dir).is_some() {
continue;
}
if let Some(target) = spatial_navigate(graph, id, dir) {
edges_to_add.push((id, dir, target));
}
}
}
for (from, dir, to) in edges_to_add {
graph.connect(from, dir, to);
}
}
fn center_i32(r: &Rect) -> (i32, i32) {
(
2 * r.x as i32 + r.width as i32,
2 * r.y as i32 + r.height as i32,
)
}
fn in_quadrant_i32(origin: (i32, i32), candidate: (i32, i32), dir: NavDirection) -> bool {
let (ox, oy) = origin;
let (cx, cy) = candidate;
match dir {
NavDirection::Up => cy < oy,
NavDirection::Down => cy > oy,
NavDirection::Left => cx < ox,
NavDirection::Right => cx > ox,
_ => false,
}
}
fn distance_score_i32(origin: (i32, i32), candidate: (i32, i32), dir: NavDirection) -> i64 {
let (ox, oy) = origin;
let (cx, cy) = candidate;
let (primary, ortho) = match dir {
NavDirection::Up => (i64::from(oy - cy), i64::from((ox - cx).abs())),
NavDirection::Down => (i64::from(cy - oy), i64::from((ox - cx).abs())),
NavDirection::Left => (i64::from(ox - cx), i64::from((oy - cy).abs())),
NavDirection::Right => (i64::from(cx - ox), i64::from((oy - cy).abs())),
_ => (i64::MAX / 2, 0),
};
10 * primary + 3 * ortho
}
#[cfg(test)]
mod tests {
use super::*;
use crate::focus::FocusNode;
fn rect(x: u16, y: u16, w: u16, h: u16) -> Rect {
Rect::new(x, y, w, h)
}
fn node_at(id: FocusId, x: u16, y: u16, w: u16, h: u16) -> FocusNode {
FocusNode::new(id, rect(x, y, w, h))
}
fn is_coverage_run() -> bool {
std::env::var("LLVM_PROFILE_FILE").is_ok() || std::env::var("CARGO_LLVM_COV").is_ok()
}
fn coverage_budget_us(base: u128) -> u128 {
if is_coverage_run() {
base.saturating_mul(2)
} else {
base
}
}
fn grid_3x3() -> FocusGraph {
let mut g = FocusGraph::new();
for row in 0..3u16 {
for col in 0..3u16 {
let id = (row * 3 + col + 1) as FocusId;
g.insert(node_at(id, col * 12, row * 4, 10, 3));
}
}
g
}
#[test]
fn navigate_right_in_row() {
let g = grid_3x3();
let target = spatial_navigate(&g, 1, NavDirection::Right);
assert_eq!(target, Some(2));
}
#[test]
fn navigate_left_in_row() {
let g = grid_3x3();
let target = spatial_navigate(&g, 3, NavDirection::Left);
assert_eq!(target, Some(2));
}
#[test]
fn navigate_down_in_column() {
let g = grid_3x3();
let target = spatial_navigate(&g, 1, NavDirection::Down);
assert_eq!(target, Some(4));
}
#[test]
fn navigate_up_in_column() {
let g = grid_3x3();
let target = spatial_navigate(&g, 7, NavDirection::Up);
assert_eq!(target, Some(4));
}
#[test]
fn no_target_left_at_edge() {
let g = grid_3x3();
let target = spatial_navigate(&g, 1, NavDirection::Left);
assert_eq!(target, None);
}
#[test]
fn no_target_up_at_edge() {
let g = grid_3x3();
let target = spatial_navigate(&g, 1, NavDirection::Up);
assert_eq!(target, None);
}
#[test]
fn no_target_right_at_edge() {
let g = grid_3x3();
let target = spatial_navigate(&g, 3, NavDirection::Right);
assert_eq!(target, None);
}
#[test]
fn no_target_down_at_edge() {
let g = grid_3x3();
let target = spatial_navigate(&g, 9, NavDirection::Down);
assert_eq!(target, None);
}
#[test]
fn prefers_aligned_over_diagonal() {
let g = grid_3x3();
let target = spatial_navigate(&g, 5, NavDirection::Right);
assert_eq!(target, Some(6));
}
#[test]
fn tie_break_prefers_lower_focus_id() {
let mut g = FocusGraph::new();
g.insert(node_at(10, 10, 10, 4, 2)); g.insert(node_at(42, 20, 6, 4, 2)); g.insert(node_at(7, 20, 14, 4, 2));
let target = spatial_navigate(&g, 10, NavDirection::Right);
assert_eq!(target, Some(7));
}
#[test]
fn explicit_edge_takes_precedence() {
let mut g = grid_3x3();
g.connect(1, NavDirection::Right, 9);
let target = spatial_navigate(&g, 1, NavDirection::Right);
assert_eq!(target, Some(9));
}
#[test]
fn skips_unfocusable_candidates() {
let mut g = FocusGraph::new();
g.insert(node_at(1, 0, 0, 10, 3));
g.insert(node_at(2, 12, 0, 10, 3).with_focusable(false));
g.insert(node_at(3, 24, 0, 10, 3));
let target = spatial_navigate(&g, 1, NavDirection::Right);
assert_eq!(target, Some(3)); }
#[test]
fn next_prev_not_spatial() {
let g = grid_3x3();
assert_eq!(spatial_navigate(&g, 1, NavDirection::Next), None);
assert_eq!(spatial_navigate(&g, 1, NavDirection::Prev), None);
}
#[test]
fn build_spatial_edges_populates_grid() {
let mut g = grid_3x3();
build_spatial_edges(&mut g);
assert_eq!(g.navigate(1, NavDirection::Right), Some(2));
assert_eq!(g.navigate(1, NavDirection::Down), Some(4));
assert!(g.navigate(5, NavDirection::Up).is_some());
assert!(g.navigate(5, NavDirection::Down).is_some());
assert!(g.navigate(5, NavDirection::Left).is_some());
assert!(g.navigate(5, NavDirection::Right).is_some());
}
#[test]
fn build_spatial_preserves_explicit_edges() {
let mut g = grid_3x3();
g.connect(1, NavDirection::Right, 9); build_spatial_edges(&mut g);
assert_eq!(g.navigate(1, NavDirection::Right), Some(9));
}
#[test]
fn navigate_irregular_layout() {
let mut g = FocusGraph::new();
g.insert(node_at(1, 0, 0, 10, 3));
g.insert(node_at(2, 12, 0, 10, 3));
g.insert(node_at(3, 0, 4, 22, 3));
let target = spatial_navigate(&g, 1, NavDirection::Down);
assert_eq!(target, Some(3));
let target = spatial_navigate(&g, 2, NavDirection::Down);
assert_eq!(target, Some(3));
}
#[test]
fn navigate_overlapping_widgets() {
let mut g = FocusGraph::new();
g.insert(node_at(1, 0, 0, 10, 5));
g.insert(node_at(2, 5, 3, 10, 5));
let target = spatial_navigate(&g, 1, NavDirection::Right);
assert_eq!(target, Some(2));
}
#[test]
fn single_node_no_target() {
let mut g = FocusGraph::new();
g.insert(node_at(1, 0, 0, 10, 3));
for dir in NavDirection::ALL {
assert_eq!(spatial_navigate(&g, 1, dir), None);
}
}
#[test]
fn empty_graph_returns_none() {
let g = FocusGraph::new();
assert_eq!(spatial_navigate(&g, 1, NavDirection::Right), None);
}
#[test]
fn closer_target_wins() {
let mut g = FocusGraph::new();
g.insert(node_at(1, 0, 0, 10, 3));
g.insert(node_at(2, 12, 0, 10, 3)); g.insert(node_at(3, 50, 0, 10, 3));
let target = spatial_navigate(&g, 1, NavDirection::Right);
assert_eq!(target, Some(2));
}
#[test]
fn property_deterministic() {
let g = grid_3x3();
let dirs = [
NavDirection::Up,
NavDirection::Down,
NavDirection::Left,
NavDirection::Right,
];
for _ in 0..100 {
for id in 1..=9 {
for dir in dirs {
let a = spatial_navigate(&g, id, dir);
let b = spatial_navigate(&g, id, dir);
assert_eq!(a, b, "Non-deterministic for id={id}, dir={dir:?}");
}
}
}
}
#[test]
fn perf_spatial_navigate_100_nodes() {
let mut g = FocusGraph::new();
for row in 0..10u16 {
for col in 0..10u16 {
let id = (row * 10 + col + 1) as FocusId;
g.insert(node_at(id, col * 12, row * 4, 10, 3));
}
}
let start = std::time::Instant::now();
for id in 1..=100 {
for dir in [
NavDirection::Up,
NavDirection::Down,
NavDirection::Left,
NavDirection::Right,
] {
let _ = spatial_navigate(&g, id, dir);
}
}
let elapsed = start.elapsed();
let budget_us = coverage_budget_us(25_000);
assert!(
elapsed.as_micros() < budget_us,
"400 spatial navigations took {}μs (budget: {}μs)",
elapsed.as_micros(),
budget_us
);
}
#[test]
fn perf_build_spatial_edges_100() {
let mut g = FocusGraph::new();
for row in 0..10u16 {
for col in 0..10u16 {
let id = (row * 10 + col + 1) as FocusId;
g.insert(node_at(id, col * 12, row * 4, 10, 3));
}
}
let start = std::time::Instant::now();
build_spatial_edges(&mut g);
let elapsed = start.elapsed();
let budget_us = coverage_budget_us(50_000);
assert!(
elapsed.as_micros() < budget_us,
"build_spatial_edges(100 nodes) took {}μs (budget: {}μs)",
elapsed.as_micros(),
budget_us
);
}
}