#![forbid(unsafe_code)]
use ahash::AHashMap;
use ftui_core::geometry::Rect;
pub type FocusId = u64;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum NavDirection {
Up,
Down,
Left,
Right,
Next,
Prev,
}
impl NavDirection {
pub const ALL: [NavDirection; 6] = [
NavDirection::Up,
NavDirection::Down,
NavDirection::Left,
NavDirection::Right,
NavDirection::Next,
NavDirection::Prev,
];
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FocusNode {
pub id: FocusId,
pub bounds: Rect,
pub tab_index: i32,
pub is_focusable: bool,
pub group_id: Option<u32>,
}
impl FocusNode {
#[must_use]
pub fn new(id: FocusId, bounds: Rect) -> Self {
Self {
id,
bounds,
tab_index: 0,
is_focusable: true,
group_id: None,
}
}
#[must_use]
pub fn with_tab_index(mut self, idx: i32) -> Self {
self.tab_index = idx;
self
}
#[must_use]
pub fn with_focusable(mut self, focusable: bool) -> Self {
self.is_focusable = focusable;
self
}
#[must_use]
pub fn with_group(mut self, group: u32) -> Self {
self.group_id = Some(group);
self
}
}
#[derive(Debug, Default)]
pub struct FocusGraph {
nodes: AHashMap<FocusId, FocusNode>,
edges: AHashMap<(FocusId, NavDirection), FocusId>,
}
impl FocusGraph {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, node: FocusNode) -> FocusId {
let id = node.id;
self.nodes.insert(id, node);
id
}
#[must_use = "use the removed node (if any)"]
pub fn remove(&mut self, id: FocusId) -> Option<FocusNode> {
let node = self.nodes.remove(&id)?;
for dir in NavDirection::ALL {
self.edges.remove(&(id, dir));
}
self.edges.retain(|_, target| *target != id);
Some(node)
}
pub fn connect(&mut self, from: FocusId, dir: NavDirection, to: FocusId) {
if self.nodes.contains_key(&from) && self.nodes.contains_key(&to) {
self.edges.insert((from, dir), to);
}
}
pub fn disconnect(&mut self, from: FocusId, dir: NavDirection) {
self.edges.remove(&(from, dir));
}
#[must_use = "use the returned target id (if any)"]
pub fn navigate(&self, from: FocusId, dir: NavDirection) -> Option<FocusId> {
self.edges.get(&(from, dir)).copied()
}
#[must_use = "use the returned node (if any)"]
pub fn get(&self, id: FocusId) -> Option<&FocusNode> {
self.nodes.get(&id)
}
#[must_use]
pub fn node_count(&self) -> usize {
self.nodes.len()
}
#[must_use]
pub fn edge_count(&self) -> usize {
self.edges.len()
}
#[inline]
#[must_use]
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
pub fn node_ids(&self) -> impl Iterator<Item = FocusId> + '_ {
self.nodes.keys().copied()
}
pub fn tab_order(&self) -> Vec<FocusId> {
let mut ordered: Vec<_> = self
.nodes
.values()
.filter(|n| n.is_focusable && n.tab_index >= 0)
.collect();
ordered.sort_by(|a, b| a.tab_index.cmp(&b.tab_index).then(a.id.cmp(&b.id)));
ordered.iter().map(|n| n.id).collect()
}
pub fn group_tab_order(&self, group: u32) -> Vec<FocusId> {
let mut ordered: Vec<_> = self
.nodes
.values()
.filter(|n| n.is_focusable && n.tab_index >= 0 && n.group_id == Some(group))
.collect();
ordered.sort_by(|a, b| a.tab_index.cmp(&b.tab_index).then(a.id.cmp(&b.id)));
ordered.iter().map(|n| n.id).collect()
}
#[must_use = "use the returned cycle (if any) to diagnose invalid focus graphs"]
pub fn find_cycle(&self, start: FocusId) -> Option<Vec<FocusId>> {
let mut slow = start;
let mut fast = start;
loop {
slow = self.navigate(slow, NavDirection::Next)?;
fast = self.navigate(fast, NavDirection::Next)?;
fast = self.navigate(fast, NavDirection::Next)?;
if slow == fast {
break;
}
}
let mut p1 = start;
let mut p2 = slow;
while p1 != p2 {
p1 = self.navigate(p1, NavDirection::Next)?;
p2 = self.navigate(p2, NavDirection::Next)?;
}
let cycle_start = p1;
let mut cycle = vec![cycle_start];
let mut current = self.navigate(cycle_start, NavDirection::Next)?;
while current != cycle_start {
cycle.push(current);
current = self.navigate(current, NavDirection::Next)?;
}
cycle.push(cycle_start);
Some(cycle)
}
#[must_use = "use the returned cycle (if any) to diagnose invalid focus graphs"]
pub fn find_cycle_in_direction(
&self,
start: FocusId,
dir: NavDirection,
) -> Option<Vec<FocusId>> {
let mut slow = start;
let mut fast = start;
loop {
slow = self.navigate(slow, dir)?;
fast = self.navigate(fast, dir)?;
fast = self.navigate(fast, dir)?;
if slow == fast {
break;
}
}
let mut p1 = start;
let mut p2 = slow;
while p1 != p2 {
p1 = self.navigate(p1, dir)?;
p2 = self.navigate(p2, dir)?;
}
let cycle_start = p1;
let mut cycle = vec![cycle_start];
let mut current = self.navigate(cycle_start, dir)?;
while current != cycle_start {
cycle.push(current);
current = self.navigate(current, dir)?;
}
cycle.push(cycle_start);
Some(cycle)
}
pub fn build_tab_chain(&mut self, wrap: bool) {
self.edges
.retain(|(_, dir), _| *dir != NavDirection::Next && *dir != NavDirection::Prev);
let order = self.tab_order();
if order.len() < 2 {
return;
}
for pair in order.windows(2) {
self.edges.insert((pair[0], NavDirection::Next), pair[1]);
self.edges.insert((pair[1], NavDirection::Prev), pair[0]);
}
if wrap {
let first = order[0];
let last = *order.last().unwrap();
self.edges.insert((last, NavDirection::Next), first);
self.edges.insert((first, NavDirection::Prev), last);
}
}
pub fn clear(&mut self) {
self.nodes.clear();
self.edges.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rect(x: u16, y: u16, w: u16, h: u16) -> Rect {
Rect::new(x, y, w, h)
}
fn node(id: FocusId) -> FocusNode {
FocusNode::new(id, rect(0, 0, 10, 1))
}
#[test]
fn empty_graph() {
let g = FocusGraph::new();
assert!(g.is_empty());
assert_eq!(g.node_count(), 0);
assert_eq!(g.edge_count(), 0);
}
#[test]
fn insert_node() {
let mut g = FocusGraph::new();
let id = g.insert(node(1));
assert_eq!(id, 1);
assert_eq!(g.node_count(), 1);
assert!(g.get(1).is_some());
}
#[test]
fn insert_replaces_existing() {
let mut g = FocusGraph::new();
g.insert(FocusNode::new(1, rect(0, 0, 10, 1)));
g.insert(FocusNode::new(1, rect(5, 5, 20, 2)));
assert_eq!(g.node_count(), 1);
assert_eq!(g.get(1).unwrap().bounds, rect(5, 5, 20, 2));
}
#[test]
fn remove_node() {
let mut g = FocusGraph::new();
g.insert(node(1));
let removed = g.remove(1);
assert!(removed.is_some());
assert_eq!(removed.unwrap().id, 1);
assert!(g.is_empty());
}
#[test]
fn remove_nonexistent() {
let mut g = FocusGraph::new();
assert!(g.remove(42).is_none());
}
#[test]
fn navigate_connected() {
let mut g = FocusGraph::new();
g.insert(node(1));
g.insert(node(2));
g.connect(1, NavDirection::Right, 2);
assert_eq!(g.navigate(1, NavDirection::Right), Some(2));
}
#[test]
fn navigate_unconnected() {
let mut g = FocusGraph::new();
g.insert(node(1));
assert_eq!(g.navigate(1, NavDirection::Up), None);
}
#[test]
fn navigate_nonexistent_node() {
let g = FocusGraph::new();
assert_eq!(g.navigate(99, NavDirection::Down), None);
}
#[test]
fn connect_ignores_missing_nodes() {
let mut g = FocusGraph::new();
g.insert(node(1));
g.connect(1, NavDirection::Next, 99); assert_eq!(g.navigate(1, NavDirection::Next), None);
assert_eq!(g.edge_count(), 0);
}
#[test]
fn disconnect_edge() {
let mut g = FocusGraph::new();
g.insert(node(1));
g.insert(node(2));
g.connect(1, NavDirection::Right, 2);
assert_eq!(g.navigate(1, NavDirection::Right), Some(2));
g.disconnect(1, NavDirection::Right);
assert_eq!(g.navigate(1, NavDirection::Right), None);
}
#[test]
fn remove_cleans_outgoing_edges() {
let mut g = FocusGraph::new();
g.insert(node(1));
g.insert(node(2));
g.connect(1, NavDirection::Next, 2);
let _ = g.remove(1);
assert_eq!(g.edge_count(), 0);
}
#[test]
fn remove_cleans_incoming_edges() {
let mut g = FocusGraph::new();
g.insert(node(1));
g.insert(node(2));
g.connect(1, NavDirection::Next, 2);
let _ = g.remove(2);
assert_eq!(g.edge_count(), 0);
assert_eq!(g.navigate(1, NavDirection::Next), None);
}
#[test]
fn tab_order_sorts_by_index_then_id() {
let mut g = FocusGraph::new();
g.insert(node(3).with_tab_index(1));
g.insert(node(1).with_tab_index(0));
g.insert(node(2).with_tab_index(0));
let order = g.tab_order();
assert_eq!(order, vec![1, 2, 3]);
}
#[test]
fn tab_order_skips_negative_index() {
let mut g = FocusGraph::new();
g.insert(node(1).with_tab_index(0));
g.insert(node(2).with_tab_index(-1));
g.insert(node(3).with_tab_index(1));
let order = g.tab_order();
assert_eq!(order, vec![1, 3]);
}
#[test]
fn tab_order_skips_unfocusable() {
let mut g = FocusGraph::new();
g.insert(node(1));
g.insert(node(2).with_focusable(false));
g.insert(node(3));
let order = g.tab_order();
assert_eq!(order, vec![1, 3]);
}
#[test]
fn group_tab_order_filters_by_group() {
let mut g = FocusGraph::new();
g.insert(node(1).with_group(1));
g.insert(node(2).with_group(2));
g.insert(node(3).with_group(1).with_tab_index(1));
let order = g.group_tab_order(1);
assert_eq!(order, vec![1, 3]);
}
#[test]
fn build_tab_chain_no_wrap() {
let mut g = FocusGraph::new();
g.insert(node(1).with_tab_index(0));
g.insert(node(2).with_tab_index(1));
g.insert(node(3).with_tab_index(2));
g.build_tab_chain(false);
assert_eq!(g.navigate(1, NavDirection::Next), Some(2));
assert_eq!(g.navigate(2, NavDirection::Next), Some(3));
assert_eq!(g.navigate(3, NavDirection::Next), None);
assert_eq!(g.navigate(3, NavDirection::Prev), Some(2));
assert_eq!(g.navigate(2, NavDirection::Prev), Some(1));
assert_eq!(g.navigate(1, NavDirection::Prev), None);
}
#[test]
fn build_tab_chain_wrap() {
let mut g = FocusGraph::new();
g.insert(node(1).with_tab_index(0));
g.insert(node(2).with_tab_index(1));
g.insert(node(3).with_tab_index(2));
g.build_tab_chain(true);
assert_eq!(g.navigate(3, NavDirection::Next), Some(1));
assert_eq!(g.navigate(1, NavDirection::Prev), Some(3));
}
#[test]
fn build_tab_chain_single_node_noop() {
let mut g = FocusGraph::new();
g.insert(node(1));
g.build_tab_chain(true);
assert_eq!(g.edge_count(), 0);
}
#[test]
fn no_cycle_linear() {
let mut g = FocusGraph::new();
g.insert(node(1));
g.insert(node(2));
g.insert(node(3));
g.connect(1, NavDirection::Next, 2);
g.connect(2, NavDirection::Next, 3);
assert!(g.find_cycle(1).is_none());
}
#[test]
fn simple_cycle() {
let mut g = FocusGraph::new();
g.insert(node(1));
g.insert(node(2));
g.insert(node(3));
g.connect(1, NavDirection::Next, 2);
g.connect(2, NavDirection::Next, 3);
g.connect(3, NavDirection::Next, 1);
let cycle = g.find_cycle(1);
assert!(cycle.is_some());
let c = cycle.unwrap();
assert_eq!(c.first(), c.last());
assert_eq!(c.len(), 4); }
#[test]
fn self_loop_cycle() {
let mut g = FocusGraph::new();
g.insert(node(1));
g.connect(1, NavDirection::Next, 1);
let cycle = g.find_cycle(1);
assert!(cycle.is_some());
let c = cycle.unwrap();
assert_eq!(c, vec![1, 1]);
}
#[test]
fn cycle_in_middle() {
let mut g = FocusGraph::new();
for id in 1..=4 {
g.insert(node(id));
}
g.connect(1, NavDirection::Next, 2);
g.connect(2, NavDirection::Next, 3);
g.connect(3, NavDirection::Next, 4);
g.connect(4, NavDirection::Next, 2);
let cycle = g.find_cycle(1);
assert!(cycle.is_some());
let c = cycle.unwrap();
assert_eq!(c.first(), c.last());
assert_eq!(c.len(), 4);
assert!(c.contains(&2));
assert!(c.contains(&3));
assert!(c.contains(&4));
}
#[test]
fn find_cycle_from_nonexistent_start() {
let g = FocusGraph::new();
assert!(g.find_cycle(99).is_none());
}
#[test]
fn find_cycle_in_direction_right() {
let mut g = FocusGraph::new();
g.insert(node(1));
g.insert(node(2));
g.connect(1, NavDirection::Right, 2);
g.connect(2, NavDirection::Right, 1);
let cycle = g.find_cycle_in_direction(1, NavDirection::Right);
assert!(cycle.is_some());
}
#[test]
fn clear_empties_graph() {
let mut g = FocusGraph::new();
g.insert(node(1));
g.insert(node(2));
g.connect(1, NavDirection::Next, 2);
g.clear();
assert!(g.is_empty());
assert_eq!(g.edge_count(), 0);
}
#[test]
fn node_builder_defaults() {
let n = FocusNode::new(1, rect(0, 0, 10, 1));
assert_eq!(n.tab_index, 0);
assert!(n.is_focusable);
assert!(n.group_id.is_none());
}
#[test]
fn node_builder_chain() {
let n = FocusNode::new(1, rect(0, 0, 10, 1))
.with_tab_index(5)
.with_focusable(false)
.with_group(3);
assert_eq!(n.tab_index, 5);
assert!(!n.is_focusable);
assert_eq!(n.group_id, Some(3));
}
#[test]
fn multiple_directions_same_source() {
let mut g = FocusGraph::new();
g.insert(node(1));
g.insert(node(2));
g.insert(node(3));
g.connect(1, NavDirection::Right, 2);
g.connect(1, NavDirection::Down, 3);
assert_eq!(g.navigate(1, NavDirection::Right), Some(2));
assert_eq!(g.navigate(1, NavDirection::Down), Some(3));
assert_eq!(g.edge_count(), 2);
}
#[test]
fn overwrite_edge() {
let mut g = FocusGraph::new();
g.insert(node(1));
g.insert(node(2));
g.insert(node(3));
g.connect(1, NavDirection::Next, 2);
g.connect(1, NavDirection::Next, 3);
assert_eq!(g.navigate(1, NavDirection::Next), Some(3));
assert_eq!(g.edge_count(), 1);
}
#[test]
fn node_ids_iteration() {
let mut g = FocusGraph::new();
g.insert(node(10));
g.insert(node(20));
g.insert(node(30));
let mut ids: Vec<_> = g.node_ids().collect();
ids.sort();
assert_eq!(ids, vec![10, 20, 30]);
}
#[test]
fn property_remove_insert_idempotent() {
let mut g = FocusGraph::new();
let n = node(1);
g.insert(n.clone());
let _ = g.remove(1);
g.insert(n.clone());
assert_eq!(g.node_count(), 1);
assert_eq!(g.get(1).unwrap().id, 1);
}
#[test]
fn property_tab_chain_wrap_forms_cycle() {
let mut g = FocusGraph::new();
for i in 1..=5 {
g.insert(node(i).with_tab_index(i as i32));
}
g.build_tab_chain(true);
let cycle = g.find_cycle(1);
assert!(cycle.is_some());
let c = cycle.unwrap();
assert_eq!(c.len(), 6); }
#[test]
fn property_tab_chain_no_wrap_no_cycle() {
let mut g = FocusGraph::new();
for i in 1..=5 {
g.insert(node(i).with_tab_index(i as i32));
}
g.build_tab_chain(false);
assert!(g.find_cycle(1).is_none());
}
#[test]
fn property_bidirectional_chain_consistency() {
let mut g = FocusGraph::new();
for i in 1..=4 {
g.insert(node(i).with_tab_index(i as i32));
}
g.build_tab_chain(false);
for id in 1..=3u64 {
let next = g.navigate(id, NavDirection::Next).unwrap();
let prev_of_next = g.navigate(next, NavDirection::Prev).unwrap();
assert_eq!(prev_of_next, id);
}
}
#[test]
fn stress_many_nodes() {
let mut g = FocusGraph::new();
for i in 0..1000 {
g.insert(node(i).with_tab_index(i as i32));
}
g.build_tab_chain(true);
assert_eq!(g.node_count(), 1000);
let mut current = 0;
for _ in 0..1000 {
current = g.navigate(current, NavDirection::Next).unwrap();
}
assert_eq!(current, 0); }
#[test]
fn perf_insert_1000_nodes() {
let start = std::time::Instant::now();
let mut g = FocusGraph::new();
for i in 0..1000 {
g.insert(node(i));
}
let elapsed = start.elapsed();
assert!(
elapsed.as_micros() < 5000,
"Inserting 1000 nodes took {}μs (budget: 5000μs)",
elapsed.as_micros()
);
}
#[test]
fn perf_navigate_10000() {
let mut g = FocusGraph::new();
for i in 0..100 {
g.insert(node(i).with_tab_index(i as i32));
}
g.build_tab_chain(true);
let start = std::time::Instant::now();
let mut current = 0;
for _ in 0..10_000 {
current = g.navigate(current, NavDirection::Next).unwrap();
}
let elapsed = start.elapsed();
assert!(current < 100);
let budget: u128 =
if std::env::var("CARGO_LLVM_COV").is_ok() || std::env::var("COVERAGE").is_ok() {
16_000
} else {
8_000
};
assert!(
elapsed.as_micros() < budget,
"10,000 navigations took {}μs (budget: {}μs)",
elapsed.as_micros(),
budget
);
}
#[test]
fn perf_cycle_detection_1000() {
let mut g = FocusGraph::new();
for i in 0..1000 {
g.insert(node(i).with_tab_index(i as i32));
}
g.build_tab_chain(true);
let start = std::time::Instant::now();
let cycle = g.find_cycle(0);
let elapsed = start.elapsed();
assert!(cycle.is_some());
assert!(
elapsed.as_micros() < 10_000,
"Cycle detection on 1000-node ring took {}μs (budget: 10000μs)",
elapsed.as_micros()
);
}
}