1use graph_explorer_core::{Aggregate, Cursor, DataProvider, NeighborResult, Node, NodeId, QueryParams};
2
3pub enum Candidate {
4 Real(Node),
5 More(Cursor),
6 Aggregate(Aggregate),
7 Loading,
9}
10
11pub enum DescendOutcome {
12 Refocused(NodeId),
13 LoadedMore,
14 Subsearch(Aggregate),
15 NoOp,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum SelectionKind { Node, More, Aggregate, Loading }
20
21impl SelectionKind {
22 pub fn as_str(&self) -> &'static str {
23 match self {
24 SelectionKind::Node => "node",
25 SelectionKind::More => "more",
26 SelectionKind::Aggregate => "aggregate",
27 SelectionKind::Loading => "loading",
28 }
29 }
30}
31
32#[derive(Debug, Clone, PartialEq)]
36pub struct SelectionInfo {
37 pub kind: SelectionKind,
38 pub id: String,
39 pub label: Option<String>,
40 pub count: Option<u64>,
41}
42
43pub struct FocusController {
44 pub focus: NodeId,
45 pub history: Vec<NodeId>,
46 pub candidates: Vec<Candidate>,
47 pub selection: usize,
48 limit: usize,
49}
50
51impl FocusController {
52 pub fn new(focus: NodeId, provider: &dyn DataProvider, limit: usize) -> Self {
53 let mut c = Self { focus, history: Vec::new(), candidates: Vec::new(), selection: 0, limit };
54 c.fetch(provider, None, false);
55 c
56 }
57
58 fn build(&mut self, res: NeighborResult, append: bool) {
59 if !append {
60 self.candidates.clear();
61 self.selection = 0;
62 }
63 for n in res.nodes {
64 if n.id == self.focus || self.history.contains(&n.id) {
71 continue;
72 }
73 self.candidates.push(Candidate::Real(n));
74 }
75 for mut a in res.aggregates {
76 a.parent = self.focus.clone();
78 self.candidates.push(Candidate::Aggregate(a));
79 }
80 if let Some(cur) = res.next {
81 self.candidates.push(Candidate::More(cur));
82 }
83 }
84
85 fn fetch(&mut self, provider: &dyn DataProvider, cursor: Option<Cursor>, append: bool) {
86 let res = provider.neighbors(&self.focus, &QueryParams { limit: self.limit, cursor });
87 if res.pending {
88 if !append { self.candidates.clear(); }
90 self.candidates.push(Candidate::Loading);
91 self.selection = 0;
92 return;
93 }
94 self.build(res, append);
95 }
96
97 pub fn focus_on(&mut self, id: NodeId, provider: &dyn DataProvider) {
98 self.focus = id;
99 self.fetch(provider, None, false);
100 }
101
102 pub fn is_pending(&self) -> bool {
105 self.candidates.iter().any(|c| matches!(c, Candidate::Loading))
106 }
107
108 pub fn refetch(&mut self, provider: &dyn DataProvider) {
112 self.fetch(provider, None, false);
113 }
114
115 pub fn select(&mut self, delta: i32) {
116 if self.candidates.is_empty() {
117 return;
118 }
119 let len = self.candidates.len() as i32;
120 self.selection = (self.selection as i32 + delta).rem_euclid(len) as usize;
121 }
122
123 pub fn select_index(&mut self, i: usize) -> bool {
129 match self.candidates.get(i) {
130 Some(Candidate::Loading) | None => false,
131 Some(_) => {
132 self.selection = i;
133 true
134 }
135 }
136 }
137
138 pub fn candidates(&self) -> &[Candidate] {
140 &self.candidates
141 }
142
143 pub fn descend(&mut self, provider: &dyn DataProvider) -> DescendOutcome {
144 if self.candidates.is_empty() {
145 return DescendOutcome::NoOp;
146 }
147 match &self.candidates[self.selection] {
148 Candidate::Real(n) => {
149 let id = n.id.clone();
150 self.history.push(self.focus.clone());
151 self.focus_on(id.clone(), provider);
152 DescendOutcome::Refocused(id)
153 }
154 Candidate::More(cur) => {
155 let cur = cur.clone();
156 self.candidates.remove(self.selection);
157 self.fetch(provider, Some(cur), true);
158 self.selection = self.selection.min(self.candidates.len().saturating_sub(1));
159 DescendOutcome::LoadedMore
160 }
161 Candidate::Aggregate(a) => DescendOutcome::Subsearch(a.clone()),
162 Candidate::Loading => DescendOutcome::NoOp,
163 }
164 }
165
166 pub fn selected(&self) -> Option<SelectionInfo> {
168 let c = self.candidates.get(self.selection)?;
169 Some(match c {
170 Candidate::Real(n) => SelectionInfo {
171 kind: SelectionKind::Node,
172 id: n.id.clone(),
173 label: n.label.clone(),
174 count: None,
175 },
176 Candidate::More(cur) => SelectionInfo {
177 kind: SelectionKind::More,
178 id: format!("__more:{}", cur.0),
179 label: None,
180 count: None,
181 },
182 Candidate::Aggregate(a) => SelectionInfo {
183 kind: SelectionKind::Aggregate,
184 id: a.id.clone(),
185 label: Some(a.display()),
186 count: Some(a.count),
187 },
188 Candidate::Loading => SelectionInfo {
189 kind: SelectionKind::Loading,
190 id: "__loading".into(),
191 label: None,
192 count: None,
193 },
194 })
195 }
196
197 pub fn back(&mut self, provider: &dyn DataProvider) {
198 if let Some(prev) = self.history.pop() {
199 self.focus_on(prev, provider);
200 }
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use graph_explorer_core::{Aggregate, Cursor, DataProvider, GroupBy, Graph, NeighborResult, Node, NodeId, QueryParams};
208
209 struct Fake;
212 impl DataProvider for Fake {
213 fn load(&self) -> Graph { Graph::default() }
214 fn neighbors(&self, focus: &NodeId, params: &QueryParams) -> NeighborResult {
215 match focus.as_str() {
216 "a" => {
217 let all = ["x", "y", "z"];
218 let off = params.cursor.as_ref().and_then(|c| c.0.parse::<usize>().ok()).unwrap_or(0);
219 let nodes: Vec<Node> = all.iter().skip(off).take(params.limit).map(|s| Node::new(*s)).collect();
220 let consumed = off + nodes.len();
221 let next = (consumed < all.len()).then(|| Cursor(consumed.to_string()));
222 NeighborResult { nodes, edges: vec![], aggregates: vec![], next, pending: false }
223 }
224 "b" => NeighborResult {
225 nodes: vec![],
226 edges: vec![],
227 aggregates: vec![Aggregate {
228 id: "grp".into(),
229 parent: String::new(),
230 group_by: GroupBy::Label,
231 value: "items".into(),
232 relationships: vec![],
233 count: 500,
234 query: QueryParams { limit: 10, cursor: Some(Cursor("grp:items:0".into())) },
235 }],
236 next: None,
237 pending: false,
238 },
239 _ => NeighborResult { nodes: vec![], edges: vec![], aggregates: vec![], next: None, pending: false },
240 }
241 }
242 }
243
244 fn ids(c: &FocusController) -> Vec<String> {
245 c.candidates.iter().map(|cand| match cand {
246 Candidate::Real(n) => n.id.clone(),
247 Candidate::More(_) => "<more>".into(),
248 Candidate::Aggregate(a) => format!("<agg:{}>", a.id),
249 Candidate::Loading => "<loading>".into(),
250 }).collect()
251 }
252
253 #[test]
254 fn initial_candidates_have_a_more_when_paged() {
255 let c = FocusController::new("a".into(), &Fake, 2);
256 assert_eq!(ids(&c), vec!["x", "y", "<more>"]);
257 }
258
259 #[test]
260 fn select_wraps() {
261 let mut c = FocusController::new("a".into(), &Fake, 2); assert_eq!(c.selection, 0);
263 c.select(-1);
264 assert_eq!(c.selection, 2);
265 c.select(1);
266 assert_eq!(c.selection, 0);
267 }
268
269 #[test]
270 fn descend_more_appends_next_page() {
271 let mut c = FocusController::new("a".into(), &Fake, 2); c.selection = 2; let out = c.descend(&Fake);
274 assert!(matches!(out, DescendOutcome::LoadedMore));
275 assert_eq!(ids(&c), vec!["x", "y", "z"]); }
277
278 #[test]
279 fn descend_real_refocuses_and_pushes_history() {
280 let mut c = FocusController::new("a".into(), &Fake, 10); c.selection = 0;
282 let out = c.descend(&Fake);
283 assert!(matches!(out, DescendOutcome::Refocused(ref id) if id == "x"));
284 assert_eq!(c.focus, "x");
285 assert_eq!(c.history, vec!["a".to_string()]);
286 assert!(c.candidates.is_empty());
288 c.back(&Fake);
290 assert_eq!(c.focus, "a");
291 assert!(c.history.is_empty());
292 }
293
294 #[test]
295 fn descend_aggregate_emits_subsearch_without_changing_candidates() {
296 let mut c = FocusController::new("b".into(), &Fake, 10); c.selection = 0;
298 let before = ids(&c);
299 let out = c.descend(&Fake);
300 assert!(matches!(out, DescendOutcome::Subsearch(ref a) if a.id == "grp"));
301 assert_eq!(ids(&c), before, "aggregate descend is a no-op on the candidate list (deferred)");
302 }
303
304 struct FlakyProvider;
306 impl DataProvider for FlakyProvider {
307 fn load(&self) -> Graph { Graph::default() }
308 fn neighbors(&self, _focus: &NodeId, params: &QueryParams) -> NeighborResult {
309 let off = params.cursor.as_ref().and_then(|c| c.0.parse::<usize>().ok()).unwrap_or(0);
310 if off == 0 {
311 NeighborResult { nodes: vec![Node::new("only")], edges: vec![], aggregates: vec![], next: Some(Cursor("1".into())), pending: false }
312 } else {
313 NeighborResult { nodes: vec![], edges: vec![], aggregates: vec![], next: None, pending: false }
314 }
315 }
316 }
317
318 #[test]
319 fn descend_more_with_empty_next_page_keeps_selection_valid() {
320 let mut c = FocusController::new("f".into(), &FlakyProvider, 1); assert_eq!(c.candidates.len(), 2);
322 c.selection = 1; let out = c.descend(&FlakyProvider);
324 assert!(matches!(out, DescendOutcome::LoadedMore));
325 assert_eq!(c.candidates.len(), 1); assert!(c.selection < c.candidates.len(), "selection must stay in bounds");
327 let _ = c.descend(&FlakyProvider); }
330
331 struct Undirected;
336 impl DataProvider for Undirected {
337 fn load(&self) -> Graph { Graph::default() }
338 fn neighbors(&self, focus: &NodeId, _params: &QueryParams) -> NeighborResult {
339 let nodes = match focus.as_str() {
340 "a" => vec![Node::new("b")],
341 "b" => vec![Node::new("a"), Node::new("c")],
342 _ => vec![],
343 };
344 NeighborResult { nodes, edges: vec![], aggregates: vec![], next: None, pending: false }
345 }
346 }
347
348 #[test]
349 fn descend_excludes_focus_and_history_from_candidates() {
350 let mut c = FocusController::new("a".into(), &Undirected, 8); assert_eq!(ids(&c), vec!["b"]);
352 c.selection = 0;
353 let out = c.descend(&Undirected);
354 assert!(matches!(out, DescendOutcome::Refocused(ref id) if id == "b"));
355 assert_eq!(c.focus, "b");
356 assert_eq!(c.history, vec!["a".to_string()]);
357 assert_eq!(ids(&c), vec!["c"]);
359 }
360
361 #[test]
362 fn descend_on_empty_candidates_is_noop() {
363 let mut c = FocusController::new("nobody".into(), &Fake, 5); assert!(c.candidates.is_empty());
365 assert!(matches!(c.descend(&Fake), DescendOutcome::NoOp));
366 }
367
368 struct PendingProvider;
370 impl DataProvider for PendingProvider {
371 fn load(&self) -> Graph { Graph::default() }
372 fn neighbors(&self, _f: &NodeId, _p: &QueryParams) -> NeighborResult {
373 NeighborResult { nodes: vec![], edges: vec![], aggregates: vec![], next: None, pending: true }
374 }
375 }
376
377 #[test]
378 fn pending_result_yields_a_single_loading_candidate() {
379 let fc = FocusController::new("a".into(), &PendingProvider, 8);
380 assert_eq!(fc.candidates.len(), 1, "one marker, not `limit` of them");
381 assert!(matches!(fc.candidates[0], Candidate::Loading));
382 }
383
384 #[test]
385 fn descending_a_loading_candidate_does_not_move_focus() {
386 let mut fc = FocusController::new("a".into(), &PendingProvider, 8);
387 let before = fc.focus.clone();
388 let _ = fc.descend(&PendingProvider);
389 assert_eq!(fc.focus, before, "focus must not move into a placeholder");
390 }
391
392 struct FillsOnSecondCall {
395 calls: std::cell::Cell<u32>,
396 }
397 impl DataProvider for FillsOnSecondCall {
398 fn load(&self) -> Graph { Graph::default() }
399 fn neighbors(&self, _f: &NodeId, _p: &QueryParams) -> NeighborResult {
400 let n = self.calls.get();
401 self.calls.set(n + 1);
402 if n == 0 {
403 NeighborResult { nodes: vec![], edges: vec![], aggregates: vec![], next: None, pending: true }
404 } else {
405 NeighborResult {
406 nodes: vec![Node { id: "real".into(), label: None, attrs: Default::default() }],
407 edges: vec![], aggregates: vec![], next: None, pending: false,
408 }
409 }
410 }
411 }
412
413 #[test]
414 fn refetch_resolves_a_pending_neighborhood() {
415 let p = FillsOnSecondCall { calls: std::cell::Cell::new(0) };
416 let mut fc = FocusController::new("a".into(), &p, 8);
417 assert!(fc.is_pending(), "first fetch is a placeholder");
418 assert!(matches!(fc.candidates[0], Candidate::Loading));
419
420 fc.refetch(&p); assert!(!fc.is_pending(), "refetch replaces the placeholder");
422 assert_eq!(fc.candidates.len(), 1);
423 assert!(matches!(&fc.candidates[0], Candidate::Real(n) if n.id == "real"));
424 assert_eq!(fc.focus, "a", "refetch keeps the same focus");
425 }
426
427 #[test]
428 fn fetch_stamps_the_aggregate_parent_from_the_focus() {
429 let c = FocusController::new("b".into(), &Fake, 10);
430 let agg = c.candidates.iter().find_map(|x| match x {
431 Candidate::Aggregate(a) => Some(a),
432 _ => None,
433 }).expect("Fake returns one aggregate for focus b");
434 assert_eq!(agg.parent, "b", "stamped from the queried node, not the wire");
435 }
436
437 #[test]
438 fn selected_describes_a_real_candidate() {
439 let c = FocusController::new("a".into(), &Fake, 10);
440 let s = c.selected().expect("a has candidates");
441 assert_eq!(s.kind, SelectionKind::Node);
442 assert_eq!(s.id, "x");
443 assert_eq!(s.count, None);
444 }
445
446 #[test]
447 fn selected_describes_an_aggregate_with_its_count() {
448 let mut c = FocusController::new("b".into(), &Fake, 10);
449 c.selection = c.candidates.iter().position(|x| matches!(x, Candidate::Aggregate(_))).unwrap();
450 let s = c.selected().unwrap();
451 assert_eq!(s.kind, SelectionKind::Aggregate);
452 assert_eq!(s.count, Some(500));
453 assert_eq!(s.label.as_deref(), Some("500 items"), "composed by display(), not transmitted");
454 }
455
456 #[test]
457 fn selected_is_none_when_there_are_no_candidates() {
458 let c = FocusController::new("empty".into(), &Fake, 10);
459 assert!(c.selected().is_none());
460 }
461
462 #[test]
463 fn select_index_moves_to_an_in_range_candidate() {
464 let mut c = FocusController::new("a".into(), &Fake, 10); assert!(c.select_index(1));
466 let s = c.selected().expect("candidate 1 exists");
467 assert_eq!(s.id, "y");
468 }
469
470 #[test]
471 fn select_index_rejects_out_of_range_and_keeps_selection() {
472 let mut c = FocusController::new("a".into(), &Fake, 10); assert_eq!(c.selection, 0);
474 assert!(!c.select_index(999));
475 assert_eq!(c.selection, 0);
476 assert_eq!(c.selected().unwrap().id, "x", "selection unchanged");
477 }
478
479 #[test]
480 fn select_index_rejects_loading_placeholders() {
481 let mut c = FocusController::new("a".into(), &PendingProvider, 8); assert_eq!(c.selection, 0);
483 assert!(!c.select_index(0));
484 assert_eq!(c.selection, 0);
485 assert!(matches!(c.candidates[c.selection], Candidate::Loading), "selection unchanged");
486 }
487
488 #[test]
489 fn descend_on_an_aggregate_still_changes_nothing_and_reports_it() {
490 let mut c = FocusController::new("b".into(), &Fake, 10);
493 c.selection = c.candidates.iter().position(|x| matches!(x, Candidate::Aggregate(_))).unwrap();
494 let before: Vec<String> = ids(&c);
495 match c.descend(&Fake) {
496 DescendOutcome::Subsearch(a) => {
497 assert_eq!(a.parent, "b", "the outcome carries a usable parent");
498 assert!(a.query.cursor.is_some(), "and a cursor the host can fetch with");
499 }
500 _ => panic!("expected Subsearch"),
501 }
502 assert_eq!(ids(&c), before, "candidates untouched");
503 }
504}