Skip to main content

graph_explorer_interaction/
focus.rs

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    /// Placeholder shown while a fetch for this neighborhood is in flight.
8    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/// A description of what is currently selected, for a host that wants to render
33/// an affordance ("Enter to expand 32 Sorcery") without mirroring engine state
34/// through a callback. Deliberately plain data — `graph-explorer-wasm` serializes it.
35#[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            // Neighbors are undirected, so after descending A->B, A comes
65            // back as one of B's neighbors. Skip the current focus and
66            // anything already in history so we don't re-offer nodes the
67            // user just came from (redundant UX, and it clobbers the
68            // wasm `scene_positions` map since the same id would appear
69            // both as history and as a fresh candidate).
70            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            // Derived from the node we queried; never trusted from the wire.
77            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            // One marker, not `limit` of them — the real count isn't known yet.
89            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    /// Whether the candidate list is currently just a pending placeholder — the
103    /// neighborhood was requested but hadn't arrived when it was last fetched.
104    pub fn is_pending(&self) -> bool {
105        self.candidates.iter().any(|c| matches!(c, Candidate::Loading))
106    }
107
108    /// Re-run the fetch for the current focus without disturbing focus/history.
109    /// Called after an async cache fill resolves a neighborhood that was pending
110    /// when this controller first fetched it; the provider now returns real data.
111    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    /// Move the selection to candidate `i`. Returns `false` — changing
124    /// nothing — when `i` is out of range or the candidate is a `Loading`
125    /// placeholder (inert by design, same as hit-testing). The id→index
126    /// resolution lives in graph-explorer-wasm, which mints the scene ids; this
127    /// method is deliberately just the bounds-checked cursor move.
128    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    /// Read-only view for hosts that resolve ids/indices — graph-explorer-wasm's select_id.
139    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    /// What is selected right now, or `None` when there are no candidates.
167    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    /// Fake provider: focus "a" has 3 real neighbors paged 2-at-a-time;
210    /// focus "b" returns one aggregate; everything else returns empty.
211    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); // 3 candidates
262        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); // [x, y, <more>]
272        c.selection = 2; // the More
273        let out = c.descend(&Fake);
274        assert!(matches!(out, DescendOutcome::LoadedMore));
275        assert_eq!(ids(&c), vec!["x", "y", "z"]); // z appended, no more More
276    }
277
278    #[test]
279    fn descend_real_refocuses_and_pushes_history() {
280        let mut c = FocusController::new("a".into(), &Fake, 10); // [x, y, z]
281        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        // "x" has no neighbors in Fake
287        assert!(c.candidates.is_empty());
288        // back returns to a
289        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); // [<agg:grp>]
297        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    // focus "f": page 1 has one real node + a `next`; page 2 (cursor "1") is empty.
305    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); // [Real(only), More("1")]
321        assert_eq!(c.candidates.len(), 2);
322        c.selection = 1; // the More
323        let out = c.descend(&FlakyProvider);
324        assert!(matches!(out, DescendOutcome::LoadedMore));
325        assert_eq!(c.candidates.len(), 1); // More removed, empty page appended nothing
326        assert!(c.selection < c.candidates.len(), "selection must stay in bounds");
327        // the previously-panicking follow-up descend must be safe now
328        let _ = c.descend(&FlakyProvider); // selection 0 -> Real("only"), refocuses, no panic
329    }
330
331    /// focus "a" has neighbor "b"; focus "b" has neighbors ["a", "c"].
332    /// Neighbors are undirected, so after descending a->b, "a" would
333    /// naively reappear as one of b's candidates. It must be filtered out
334    /// because it's now in history.
335    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); // [b]
351        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        // "a" is excluded (it's now history); only "c" remains.
358        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); // Fake returns empty for unknown focus
364        assert!(c.candidates.is_empty());
365        assert!(matches!(c.descend(&Fake), DescendOutcome::NoOp));
366    }
367
368    /// Provider that always reports "still loading" (nothing cached yet).
369    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    /// Provider that reports "pending" once, then real data — models an async
393    /// cache that fills between the initial fetch and a later refetch.
394    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); // cache has now 'filled'
421        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); // [x, y, z]
465        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); // [x, y, z]
473        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); // [Loading]
482        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        // The deferral is the whole point: the engine hands the aggregate back
491        // and lets the host decide what expansion means.
492        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}