use graph_explorer_core::{NavState, NodeId};
mod focus;
pub use focus::{Candidate, DescendOutcome, FocusController, SelectionInfo, SelectionKind};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode { Normal, Edit, Insert, Command }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction { Left, Down, Up, Right }
impl Direction {
fn vec(self) -> [f32; 2] {
match self {
Direction::Left => [-1.0, 0.0],
Direction::Right => [1.0, 0.0],
Direction::Up => [0.0, 1.0],
Direction::Down => [0.0, -1.0],
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Command {
Leap(Direction),
Sibling(i32),
Back,
ZoomIn,
ZoomOut,
Pan(f32, f32),
}
fn candidates_in_dir(current: [f32; 2], neighbors: &[(NodeId, [f32; 2])], dir: Direction) -> Vec<NodeId> {
let d = dir.vec();
let mut scored: Vec<(f32, f32, NodeId)> = neighbors
.iter()
.filter_map(|(id, p)| {
let vx = p[0] - current[0];
let vy = p[1] - current[1];
let len = (vx * vx + vy * vy).sqrt();
if len < 1e-6 { return None; }
let dot = (vx * d[0] + vy * d[1]) / len; if dot <= 0.0 { return None; } let deviation = 1.0 - dot; Some((deviation, len, id.clone()))
})
.collect();
scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap().then(a.1.partial_cmp(&b.1).unwrap()));
scored.into_iter().map(|(_, _, id)| id).collect()
}
pub fn leap_target(current: [f32; 2], neighbors: &[(NodeId, [f32; 2])], dir: Direction, cycle: usize) -> Option<NodeId> {
let c = candidates_in_dir(current, neighbors, dir);
if c.is_empty() { return None; }
Some(c[cycle % c.len()].clone())
}
struct LeapContext {
candidates: Vec<NodeId>,
index: usize,
}
pub struct Navigator {
nav: NavState,
pending: Option<LeapContext>,
pub mode: Mode,
}
impl Navigator {
pub fn new(start: NodeId) -> Self {
let nav = NavState { current: Some(start), ..Default::default() };
Self { nav, pending: None, mode: Mode::Normal }
}
pub fn current(&self) -> &str {
self.nav.current.as_deref().unwrap_or("")
}
pub fn leap(
&mut self,
dir: Direction,
positions: &impl Fn(&str) -> [f32; 2],
neighbors: &impl Fn(&str) -> Vec<(NodeId, [f32; 2])>,
) {
let cur_id = self.current().to_string();
let cur_pos = positions(&cur_id);
let ns = neighbors(&cur_id);
let candidates = candidates_in_dir(cur_pos, &ns, dir);
let Some(target) = candidates.first().cloned() else { return };
self.nav.focus(target);
self.pending = Some(LeapContext { candidates, index: 0 });
}
pub fn sibling(
&mut self,
delta: i32,
positions: &impl Fn(&str) -> [f32; 2],
neighbors: &impl Fn(&str) -> Vec<(NodeId, [f32; 2])>,
) {
let Some(ctx) = self.pending.take() else { return };
let len = ctx.candidates.len();
if len == 0 { return; }
let new_index = ((ctx.index as i32 + delta).rem_euclid(len as i32)) as usize;
let _ = positions; let _ = neighbors;
self.nav.current = Some(ctx.candidates[new_index].clone());
self.pending = Some(LeapContext { index: new_index, ..ctx });
}
pub fn clear_pending(&mut self) { self.pending = None; }
pub fn set_current(&mut self, id: NodeId) {
self.nav.current = Some(id);
self.clear_pending();
}
pub fn back(&mut self) { self.clear_pending(); self.nav.back(); }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViewMode {
Traditional,
Focus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FocusCommand {
SelectPrev,
SelectNext,
Descend,
Back,
Overview,
ZoomIn,
ZoomOut,
}
#[cfg(test)]
mod tests {
use super::*;
fn n(id: &str, x: f32, y: f32) -> (String, [f32; 2]) { (id.to_string(), [x, y]) }
#[test]
fn picks_the_neighbor_in_the_pressed_direction() {
let cur = [0.0, 0.0];
let ns = vec![n("right", 10.0, 0.0), n("up", 0.0, 10.0), n("left", -10.0, 0.0)];
assert_eq!(leap_target(cur, &ns, Direction::Right, 0), Some("right".into()));
assert_eq!(leap_target(cur, &ns, Direction::Up, 0), Some("up".into()));
assert_eq!(leap_target(cur, &ns, Direction::Left, 0), Some("left".into()));
}
#[test]
fn closest_to_axis_wins_then_cycle_reaches_the_sibling() {
let cur = [0.0, 0.0];
let ns = vec![n("up_right", 10.0, 6.0), n("dead_right", 10.0, 0.0)];
assert_eq!(leap_target(cur, &ns, Direction::Right, 0), Some("dead_right".into()));
assert_eq!(leap_target(cur, &ns, Direction::Right, 1), Some("up_right".into()));
assert_eq!(leap_target(cur, &ns, Direction::Right, 2), Some("dead_right".into()));
}
#[test]
fn ignores_neighbors_outside_the_direction() {
let cur = [0.0, 0.0];
let ns = vec![n("left", -10.0, 0.0)];
assert_eq!(leap_target(cur, &ns, Direction::Right, 0), None);
}
#[test]
fn navigator_commits_and_tracks_context_for_siblings() {
let mut nav = Navigator::new("a".into());
let neighbors = |_id: &str| vec![n("b", 10.0, 1.0), n("c", 10.0, 8.0)];
let positions = |id: &str| match id { "a" => [0.0,0.0], "b" => [10.0,1.0], "c" => [10.0,8.0], _ => [0.0,0.0] };
nav.leap(Direction::Right, &positions, &neighbors);
assert_eq!(nav.current(), "b");
nav.sibling(1, &positions, &neighbors);
assert_eq!(nav.current(), "c");
nav.sibling(-1, &positions, &neighbors);
assert_eq!(nav.current(), "b");
}
}