1use graph_explorer_core::{NavState, NodeId};
2
3mod focus;
4pub use focus::{Candidate, DescendOutcome, FocusController, SelectionInfo, SelectionKind};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum Mode { Normal, Edit, Insert, Command }
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Direction { Left, Down, Up, Right }
11
12impl Direction {
13 fn vec(self) -> [f32; 2] {
15 match self {
16 Direction::Left => [-1.0, 0.0],
17 Direction::Right => [1.0, 0.0],
18 Direction::Up => [0.0, 1.0],
19 Direction::Down => [0.0, -1.0],
20 }
21 }
22}
23
24#[derive(Debug, Clone, PartialEq)]
26pub enum Command {
27 Leap(Direction),
28 Sibling(i32),
29 Back,
30 ZoomIn,
31 ZoomOut,
32 Pan(f32, f32),
33}
34
35fn candidates_in_dir(current: [f32; 2], neighbors: &[(NodeId, [f32; 2])], dir: Direction) -> Vec<NodeId> {
37 let d = dir.vec();
38 let mut scored: Vec<(f32, f32, NodeId)> = neighbors
39 .iter()
40 .filter_map(|(id, p)| {
41 let vx = p[0] - current[0];
42 let vy = p[1] - current[1];
43 let len = (vx * vx + vy * vy).sqrt();
44 if len < 1e-6 { return None; }
45 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()))
49 })
50 .collect();
51 scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap().then(a.1.partial_cmp(&b.1).unwrap()));
52 scored.into_iter().map(|(_, _, id)| id).collect()
53}
54
55pub fn leap_target(current: [f32; 2], neighbors: &[(NodeId, [f32; 2])], dir: Direction, cycle: usize) -> Option<NodeId> {
57 let c = candidates_in_dir(current, neighbors, dir);
58 if c.is_empty() { return None; }
59 Some(c[cycle % c.len()].clone())
60}
61
62struct LeapContext {
64 candidates: Vec<NodeId>,
65 index: usize,
66}
67
68pub struct Navigator {
69 nav: NavState,
70 pending: Option<LeapContext>,
71 pub mode: Mode,
72}
73
74impl Navigator {
75 pub fn new(start: NodeId) -> Self {
76 let nav = NavState { current: Some(start), ..Default::default() };
77 Self { nav, pending: None, mode: Mode::Normal }
78 }
79
80 pub fn current(&self) -> &str {
81 self.nav.current.as_deref().unwrap_or("")
82 }
83
84 pub fn leap(
87 &mut self,
88 dir: Direction,
89 positions: &impl Fn(&str) -> [f32; 2],
90 neighbors: &impl Fn(&str) -> Vec<(NodeId, [f32; 2])>,
91 ) {
92 let cur_id = self.current().to_string();
93 let cur_pos = positions(&cur_id);
94 let ns = neighbors(&cur_id);
95 let candidates = candidates_in_dir(cur_pos, &ns, dir);
96 let Some(target) = candidates.first().cloned() else { return };
97 self.nav.focus(target);
98 self.pending = Some(LeapContext { candidates, index: 0 });
99 }
100
101 pub fn sibling(
103 &mut self,
104 delta: i32,
105 positions: &impl Fn(&str) -> [f32; 2],
106 neighbors: &impl Fn(&str) -> Vec<(NodeId, [f32; 2])>,
107 ) {
108 let Some(ctx) = self.pending.take() else { return };
109 let len = ctx.candidates.len();
110 if len == 0 { return; }
111 let new_index = ((ctx.index as i32 + delta).rem_euclid(len as i32)) as usize;
112 let _ = positions; let _ = neighbors;
114 self.nav.current = Some(ctx.candidates[new_index].clone());
115 self.pending = Some(LeapContext { index: new_index, ..ctx });
116 }
117
118 pub fn clear_pending(&mut self) { self.pending = None; }
120
121 pub fn set_current(&mut self, id: NodeId) {
124 self.nav.current = Some(id);
125 self.clear_pending();
126 }
127
128 pub fn back(&mut self) { self.clear_pending(); self.nav.back(); }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum ViewMode {
133 Traditional,
134 Focus,
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum FocusCommand {
139 SelectPrev,
140 SelectNext,
141 Descend,
142 Back,
143 Overview,
144 ZoomIn,
145 ZoomOut,
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 fn n(id: &str, x: f32, y: f32) -> (String, [f32; 2]) { (id.to_string(), [x, y]) }
153
154 #[test]
155 fn picks_the_neighbor_in_the_pressed_direction() {
156 let cur = [0.0, 0.0];
157 let ns = vec![n("right", 10.0, 0.0), n("up", 0.0, 10.0), n("left", -10.0, 0.0)];
158 assert_eq!(leap_target(cur, &ns, Direction::Right, 0), Some("right".into()));
159 assert_eq!(leap_target(cur, &ns, Direction::Up, 0), Some("up".into()));
160 assert_eq!(leap_target(cur, &ns, Direction::Left, 0), Some("left".into()));
161 }
162
163 #[test]
164 fn closest_to_axis_wins_then_cycle_reaches_the_sibling() {
165 let cur = [0.0, 0.0];
166 let ns = vec![n("up_right", 10.0, 6.0), n("dead_right", 10.0, 0.0)];
168 assert_eq!(leap_target(cur, &ns, Direction::Right, 0), Some("dead_right".into()));
169 assert_eq!(leap_target(cur, &ns, Direction::Right, 1), Some("up_right".into()));
170 assert_eq!(leap_target(cur, &ns, Direction::Right, 2), Some("dead_right".into()));
172 }
173
174 #[test]
175 fn ignores_neighbors_outside_the_direction() {
176 let cur = [0.0, 0.0];
177 let ns = vec![n("left", -10.0, 0.0)];
178 assert_eq!(leap_target(cur, &ns, Direction::Right, 0), None);
179 }
180
181 #[test]
182 fn navigator_commits_and_tracks_context_for_siblings() {
183 let mut nav = Navigator::new("a".into());
185 let neighbors = |_id: &str| vec![n("b", 10.0, 1.0), n("c", 10.0, 8.0)];
186 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] };
187
188 nav.leap(Direction::Right, &positions, &neighbors);
190 assert_eq!(nav.current(), "b");
191 nav.sibling(1, &positions, &neighbors);
193 assert_eq!(nav.current(), "c");
194 nav.sibling(-1, &positions, &neighbors);
196 assert_eq!(nav.current(), "b");
197 }
198
199}