Skip to main content

gpui_graph/view/
mod.rs

1mod paint;
2mod visual;
3
4use std::{
5    cell::{Cell, RefCell},
6    collections::{HashMap, HashSet},
7    rc::Rc,
8};
9
10use std::time::{Duration, Instant};
11
12use gpui::{
13    AnyElement, App, Bounds, ContentMask, Context, CursorStyle, DispatchPhase, Entity,
14    EventEmitter, FocusHandle, Focusable, Hitbox, HitboxBehavior, Hsla, InteractiveElement as _,
15    IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _,
16    Pixels, Point, Render, ScrollWheelEvent, SharedString, Styled as _, Window, canvas, div, point,
17    px,
18};
19
20use crate::actions::{
21    Activate, ClearSelection, FitView, KEY_CONTEXT, OpenContextMenu, SelectDown, SelectLeft,
22    SelectRight, SelectUp, ZoomIn, ZoomOut,
23};
24
25use crate::geometry::EdgeGeometry;
26use crate::layout::{Body, ForceConfig, Simulation, Vec2};
27use crate::model::{Edge, GraphData, Node};
28use crate::style::{ColorBy, GraphColors, GraphMetrics, LabelMode, NodeSizing, Palette};
29use visual::{GroupInput, Vertex, Visual};
30
31const MIN_ZOOM: f32 = 0.05;
32const MAX_ZOOM: f32 = 6.;
33/// Pointer travel (px) before a press becomes a drag instead of a click.
34const DRAG_THRESHOLD: f32 = 3.;
35/// Pointer distance (px) within which an edge counts as hit.
36const EDGE_HIT_SLOP: f32 = 4.;
37/// With reduced motion, the layout is computed within this budget per frame
38/// instead of being animated.
39const REDUCED_MOTION_BUDGET: Duration = Duration::from_millis(120);
40
41/// What the user has selected or is pointing at.
42#[derive(Clone, Debug, Default)]
43#[non_exhaustive]
44pub enum Selection {
45    #[default]
46    None,
47    Node(Node),
48    Edge(Edge),
49    /// Several edges drawn as one, e.g. from a node into a collapsed group.
50    EdgeBundle(Vec<Edge>),
51    /// A collapsed group's super node.
52    Group(GroupInfo),
53}
54
55#[derive(Clone, Debug)]
56#[non_exhaustive]
57pub enum NetworkGraphEvent {
58    SelectionChanged(Selection),
59    /// Double-click on a node: the usual "expand neighbors" gesture in graph explorers.
60    NodeActivated(Node),
61    /// A group was collapsed or expanded, by the user or through the API.
62    GroupToggled {
63        group: SharedString,
64        collapsed: bool,
65    },
66    /// Right-click. `position` is in window coordinates, ready for a popover anchor.
67    ContextMenu {
68        target: Selection,
69        position: Point<Pixels>,
70    },
71}
72
73/// A category present in the graph, for legends.
74#[derive(Clone, Debug)]
75#[non_exhaustive]
76pub struct CategoryInfo {
77    pub name: SharedString,
78    pub color: Hsla,
79    pub count: usize,
80}
81
82/// A group (community) present in the graph.
83#[derive(Clone, Debug)]
84#[non_exhaustive]
85pub struct GroupInfo {
86    pub id: SharedString,
87    pub label: SharedString,
88    pub color: Hsla,
89    /// Number of member nodes.
90    pub size: usize,
91    pub collapsed: bool,
92}
93
94/// Builds tooltip content for the hovered item; `None` hides the tooltip.
95pub type TooltipBuilder =
96    Rc<dyn Fn(&Selection, &GraphColors, &mut Window, &mut App) -> Option<AnyElement>>;
97
98/// A hovered / hit item, by index into the visual graph. Transient.
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100enum Item {
101    Vertex(usize),
102    Edge(usize),
103}
104
105/// A selected item by stable identity, so it survives collapse/expand and merges.
106#[derive(Clone, Debug, PartialEq, Eq)]
107enum SelectionKey {
108    Node(SharedString),
109    Group(SharedString),
110    /// Model edge indices of the selected visual edge.
111    Edges(Vec<usize>),
112}
113
114struct ModelEdge {
115    data: Edge,
116    source: usize,
117    target: usize,
118}
119
120struct GroupState {
121    id: SharedString,
122    label: SharedString,
123    members: Vec<usize>,
124    collapsed: bool,
125    /// Super node body while collapsed.
126    body: Body,
127    /// Members' centroid when collapsed; expanding moves them by the super node's travel.
128    anchor: Vec2,
129}
130
131enum DragKind {
132    Pan { start_pan: Point<Pixels> },
133    Vertex { ix: usize, was_pinned: bool },
134}
135
136struct Drag {
137    kind: DragKind,
138    start: Point<Pixels>,
139    moved: bool,
140}
141
142/// An interactive, force-directed network graph.
143///
144/// ```ignore
145/// let graph = cx.new(|cx| {
146///     NetworkGraph::new(cx)
147///         .with_data(GraphData::new(nodes, edges))
148///         .with_label_mode(LabelMode::Auto)
149/// });
150/// cx.subscribe(&graph, |this, _, event: &NetworkGraphEvent, cx| { /* ... */ }).detach();
151/// ```
152///
153/// Place it in a sized container: it fills its parent.
154pub struct NetworkGraph {
155    // Model: every node and edge, with persisted positions.
156    nodes: Vec<Node>,
157    node_bodies: Vec<Body>,
158    edges: Vec<ModelEdge>,
159    index: HashMap<SharedString, usize>,
160    degrees: Vec<usize>,
161    neighbors: Vec<Vec<usize>>,
162    categories: Vec<SharedString>,
163    groups: Vec<GroupState>,
164    group_index: HashMap<SharedString, usize>,
165    node_group: Vec<Option<usize>>,
166
167    // What is drawn and simulated.
168    vis: Visual,
169    sim: Simulation,
170
171    /// Screen position = bounds.center + pan + world * zoom.
172    pan: Point<Pixels>,
173    zoom: f32,
174    /// Keep fitting the view while the layout settles, until the user moves it.
175    auto_fit: bool,
176    bounds: Rc<Cell<Bounds<Pixels>>>,
177
178    hovered: Option<Item>,
179    selected: Option<SelectionKey>,
180    drag: Option<Drag>,
181    pointer: Option<Point<Pixels>>,
182    focus_handle: FocusHandle,
183    /// The last interaction came from the keyboard: show focus indicators.
184    keyboard_nav: bool,
185    /// Vertices whose label was drawn last frame; they win placement ties.
186    shown_labels: RefCell<HashSet<usize>>,
187
188    label_mode: LabelMode,
189    color_by: ColorBy,
190    arrows: bool,
191    edge_labels: bool,
192    group_hulls: bool,
193    /// Dragged nodes stay where they are dropped.
194    sticky_drag: bool,
195    palette: Palette,
196    sizing: NodeSizing,
197    colors: Option<GraphColors>,
198    tooltip: Option<TooltipBuilder>,
199    tooltips: bool,
200}
201
202impl EventEmitter<NetworkGraphEvent> for NetworkGraph {}
203
204impl Focusable for NetworkGraph {
205    fn focus_handle(&self, _: &App) -> FocusHandle {
206        self.focus_handle.clone()
207    }
208}
209
210impl NetworkGraph {
211    pub fn new(cx: &mut Context<Self>) -> Self {
212        Self {
213            nodes: Vec::new(),
214            node_bodies: Vec::new(),
215            edges: Vec::new(),
216            index: HashMap::new(),
217            degrees: Vec::new(),
218            neighbors: Vec::new(),
219            categories: Vec::new(),
220            groups: Vec::new(),
221            group_index: HashMap::new(),
222            node_group: Vec::new(),
223            vis: Visual::default(),
224            sim: Simulation::new(ForceConfig::default()),
225            pan: Point::default(),
226            zoom: 1.,
227            auto_fit: true,
228            bounds: Rc::new(Cell::new(Bounds::default())),
229            hovered: None,
230            selected: None,
231            drag: None,
232            pointer: None,
233            focus_handle: cx.focus_handle().tab_stop(true),
234            keyboard_nav: false,
235            shown_labels: RefCell::default(),
236            label_mode: LabelMode::default(),
237            color_by: ColorBy::default(),
238            arrows: true,
239            edge_labels: true,
240            group_hulls: true,
241            sticky_drag: false,
242            palette: Palette::default(),
243            sizing: NodeSizing::default(),
244            colors: None,
245            tooltip: None,
246            tooltips: true,
247        }
248    }
249
250    // ---- construction options -----------------------------------------------
251
252    pub fn with_data(mut self, data: GraphData) -> Self {
253        self.load(data);
254        self
255    }
256
257    pub fn with_force_config(mut self, config: ForceConfig) -> Self {
258        self.sim.config = config;
259        self
260    }
261
262    pub fn with_label_mode(mut self, mode: LabelMode) -> Self {
263        self.label_mode = mode;
264        self
265    }
266
267    pub fn with_color_by(mut self, color_by: ColorBy) -> Self {
268        self.color_by = color_by;
269        self
270    }
271
272    /// Draw direction arrows on edges (default on).
273    pub fn arrows(mut self, arrows: bool) -> Self {
274        self.arrows = arrows;
275        self
276    }
277
278    /// Draw relationship labels on edges (default on).
279    pub fn edge_labels(mut self, edge_labels: bool) -> Self {
280        self.edge_labels = edge_labels;
281        self
282    }
283
284    /// Outline each expanded group with a rounded hull (default on).
285    pub fn group_hulls(mut self, group_hulls: bool) -> Self {
286        self.group_hulls = group_hulls;
287        self
288    }
289
290    /// Keep dragged nodes pinned where they are dropped (default off).
291    pub fn sticky_drag(mut self, sticky: bool) -> Self {
292        self.sticky_drag = sticky;
293        self
294    }
295
296    pub fn with_palette(mut self, palette: Palette) -> Self {
297        self.palette = palette;
298        self
299    }
300
301    pub fn with_node_sizing(mut self, sizing: NodeSizing) -> Self {
302        self.sizing = sizing;
303        self
304    }
305
306    /// Fixed colors instead of following the gpui-base theme.
307    pub fn with_colors(mut self, colors: GraphColors) -> Self {
308        self.colors = Some(colors);
309        self
310    }
311
312    /// Replace the built-in tooltip. Return `None` from the builder to hide it.
313    pub fn with_tooltip(
314        mut self,
315        builder: impl Fn(&Selection, &GraphColors, &mut Window, &mut App) -> Option<AnyElement>
316        + 'static,
317    ) -> Self {
318        self.tooltip = Some(Rc::new(builder));
319        self
320    }
321
322    /// Show tooltips for the hovered item (default on).
323    pub fn tooltips(mut self, tooltips: bool) -> Self {
324        self.tooltips = tooltips;
325        self
326    }
327
328    // ---- runtime settings ---------------------------------------------------
329
330    pub fn label_mode(&self) -> LabelMode {
331        self.label_mode
332    }
333
334    pub fn set_label_mode(&mut self, mode: LabelMode, cx: &mut Context<Self>) {
335        self.label_mode = mode;
336        cx.notify();
337    }
338
339    pub fn color_by(&self) -> ColorBy {
340        self.color_by
341    }
342
343    pub fn set_color_by(&mut self, color_by: ColorBy, cx: &mut Context<Self>) {
344        self.color_by = color_by;
345        cx.notify();
346    }
347
348    pub fn has_arrows(&self) -> bool {
349        self.arrows
350    }
351
352    pub fn set_arrows(&mut self, arrows: bool, cx: &mut Context<Self>) {
353        self.arrows = arrows;
354        cx.notify();
355    }
356
357    pub fn has_edge_labels(&self) -> bool {
358        self.edge_labels
359    }
360
361    pub fn set_edge_labels(&mut self, edge_labels: bool, cx: &mut Context<Self>) {
362        self.edge_labels = edge_labels;
363        cx.notify();
364    }
365
366    pub fn has_group_hulls(&self) -> bool {
367        self.group_hulls
368    }
369
370    pub fn set_group_hulls(&mut self, group_hulls: bool, cx: &mut Context<Self>) {
371        self.group_hulls = group_hulls;
372        cx.notify();
373    }
374
375    pub fn is_sticky_drag(&self) -> bool {
376        self.sticky_drag
377    }
378
379    pub fn set_sticky_drag(&mut self, sticky: bool) {
380        self.sticky_drag = sticky;
381    }
382
383    pub fn has_tooltips(&self) -> bool {
384        self.tooltips
385    }
386
387    pub fn set_tooltips(&mut self, tooltips: bool, cx: &mut Context<Self>) {
388        self.tooltips = tooltips;
389        cx.notify();
390    }
391
392    pub fn set_force_config(&mut self, config: ForceConfig, cx: &mut Context<Self>) {
393        self.sim.config = config;
394        self.sim.reheat(0.5);
395        cx.notify();
396    }
397
398    pub fn set_colors(&mut self, colors: Option<GraphColors>, cx: &mut Context<Self>) {
399        self.colors = colors;
400        cx.notify();
401    }
402
403    // ---- data -------------------------------------------------------------
404
405    /// Replace the whole graph (a new query result) and reset the viewport.
406    pub fn set_data(&mut self, data: GraphData, cx: &mut Context<Self>) {
407        let had_selection = self.selected.is_some();
408        self.load(data);
409        if had_selection {
410            cx.emit(NetworkGraphEvent::SelectionChanged(Selection::None));
411        }
412        cx.notify();
413    }
414
415    /// Add nodes and edges to the current graph, e.g. expanding a node. Existing
416    /// ids are kept; new nodes start around `anchor` so the layout stays stable.
417    /// New members of a collapsed group join it without expanding it.
418    pub fn merge(&mut self, data: GraphData, anchor: Option<&str>, cx: &mut Context<Self>) {
419        self.sync_back();
420        self.insert(data, anchor);
421        cx.notify();
422    }
423
424    /// Remove nodes and every edge touching them.
425    pub fn remove_nodes(&mut self, ids: &[&str], cx: &mut Context<Self>) {
426        let doomed: Vec<bool> = self
427            .nodes
428            .iter()
429            .map(|n| ids.contains(&n.id.as_ref()))
430            .collect();
431        if !doomed.contains(&true) {
432            return;
433        }
434        self.sync_back();
435        let mut remap = vec![usize::MAX; self.nodes.len()];
436        let mut kept = 0;
437        for (i, &d) in doomed.iter().enumerate() {
438            if !d {
439                remap[i] = kept;
440                kept += 1;
441            }
442        }
443        let mut i = 0;
444        self.nodes.retain(|_| (!doomed[i], i += 1).0);
445        let mut i = 0;
446        self.node_bodies.retain(|_| (!doomed[i], i += 1).0);
447        self.edges.retain_mut(|e| {
448            let keep = !doomed[e.source] && !doomed[e.target];
449            e.source = remap[e.source];
450            e.target = remap[e.target];
451            keep
452        });
453        self.index = self
454            .nodes
455            .iter()
456            .enumerate()
457            .map(|(i, n)| (n.id.clone(), i))
458            .collect();
459        self.categories
460            .retain(|c| self.nodes.iter().any(|n| &n.category == c));
461        // Model edge indices shifted; an edge selection cannot be carried over.
462        if matches!(self.selected, Some(SelectionKey::Edges(_))) {
463            self.selected = None;
464        }
465        self.rebuild();
466        self.sim.reheat(0.3);
467        self.validate_selection(cx);
468        cx.notify();
469    }
470
471    pub fn clear(&mut self, cx: &mut Context<Self>) {
472        self.set_data(GraphData::default(), cx);
473    }
474
475    /// The current graph contents (including nodes inside collapsed groups).
476    pub fn data(&self) -> GraphData {
477        GraphData {
478            nodes: self.nodes.clone(),
479            edges: self.edges.iter().map(|e| e.data.clone()).collect(),
480        }
481    }
482
483    fn load(&mut self, data: GraphData) {
484        self.nodes.clear();
485        self.node_bodies.clear();
486        self.edges.clear();
487        self.index.clear();
488        self.categories.clear();
489        self.groups.clear();
490        self.group_index.clear();
491        self.vis = Visual::default();
492        self.hovered = None;
493        self.selected = None;
494        self.drag = None;
495        self.insert(data, None);
496        self.pan = Point::default();
497        self.zoom = 1.;
498        self.auto_fit = true;
499    }
500
501    /// Requires the model to be in sync with the visual graph (see `sync_back`).
502    fn insert(&mut self, data: GraphData, anchor: Option<&str>) {
503        let anchor_pos = anchor
504            .and_then(|id| self.index.get(id))
505            .map(|&i| self.vis.bodies[self.vis.node_slot[i]].pos);
506        let first_new = self.nodes.len();
507        for node in data.nodes {
508            if self.index.contains_key(&node.id) {
509                continue;
510            }
511            if !self.categories.contains(&node.category) {
512                self.categories.push(node.category.clone());
513            }
514            self.index.insert(node.id.clone(), self.nodes.len());
515            self.nodes.push(node);
516            self.node_bodies.push(Body::default());
517        }
518        match anchor_pos {
519            Some(center) => {
520                let added = self.nodes.len() - first_new;
521                for (k, body) in self.node_bodies[first_new..].iter_mut().enumerate() {
522                    let a = k as f32 / added.max(1) as f32 * std::f32::consts::TAU;
523                    body.pos = Vec2::new(center.x + 30. * a.cos(), center.y + 30. * a.sin());
524                }
525            }
526            None => Simulation::initialize_positions(&mut self.node_bodies, first_new),
527        }
528
529        for edge in data.edges {
530            let (Some(&s), Some(&t)) = (self.index.get(&edge.source), self.index.get(&edge.target))
531            else {
532                continue;
533            };
534            let duplicate = self
535                .edges
536                .iter()
537                .any(|e| e.source == s && e.target == t && e.data.label == edge.label);
538            if !duplicate {
539                self.edges.push(ModelEdge {
540                    data: edge,
541                    source: s,
542                    target: t,
543                });
544            }
545        }
546        self.rebuild();
547        self.sim.reheat(if anchor_pos.is_some() { 0.5 } else { 1. });
548    }
549
550    /// Persist simulated positions from the visual graph into the model.
551    fn sync_back(&mut self) {
552        for (v, vertex) in self.vis.vertices.iter().enumerate() {
553            let body = self.vis.bodies[v].clone();
554            match *vertex {
555                Vertex::Node(i) => self.node_bodies[i] = body,
556                Vertex::Group(g) => self.groups[g].body = body,
557            }
558        }
559    }
560
561    /// Recompute model topology and groups, then the visual graph.
562    fn rebuild(&mut self) {
563        let n = self.nodes.len();
564        self.degrees = vec![0; n];
565        self.neighbors = vec![Vec::new(); n];
566        for e in &self.edges {
567            self.degrees[e.source] += 1;
568            self.degrees[e.target] += 1;
569            if e.source != e.target {
570                self.neighbors[e.source].push(e.target);
571                self.neighbors[e.target].push(e.source);
572            }
573        }
574        for list in &mut self.neighbors {
575            list.sort_unstable();
576            list.dedup();
577        }
578        for (i, body) in self.node_bodies.iter_mut().enumerate() {
579            body.radius = self.nodes[i]
580                .size
581                .unwrap_or_else(|| self.sizing.radius(self.degrees[i]));
582        }
583
584        // Groups keep their state (collapsed, position, label) across rebuilds.
585        for g in &mut self.groups {
586            g.members.clear();
587        }
588        self.node_group = vec![None; n];
589        for (i, node) in self.nodes.iter().enumerate() {
590            let Some(id) = &node.group else { continue };
591            let g = *self.group_index.entry(id.clone()).or_insert_with(|| {
592                self.groups.push(GroupState {
593                    id: id.clone(),
594                    label: id.clone(),
595                    members: Vec::new(),
596                    collapsed: false,
597                    body: Body::default(),
598                    anchor: Vec2::default(),
599                });
600                self.groups.len() - 1
601            });
602            self.groups[g].members.push(i);
603            self.node_group[i] = Some(g);
604        }
605        if self.groups.iter().any(|g| g.members.is_empty()) {
606            self.groups.retain(|g| !g.members.is_empty());
607            self.group_index = self
608                .groups
609                .iter()
610                .enumerate()
611                .map(|(g, s)| (s.id.clone(), g))
612                .collect();
613            for (i, node) in self.nodes.iter().enumerate() {
614                self.node_group[i] = node.group.as_ref().map(|id| self.group_index[id]);
615            }
616        }
617        for g in &mut self.groups {
618            g.body.radius = (12. + (g.members.len() as f32).sqrt() * 3.).min(48.);
619        }
620
621        let groups: Vec<GroupInput> = self
622            .groups
623            .iter()
624            .map(|g| GroupInput {
625                collapsed: g.collapsed,
626                body: &g.body,
627            })
628            .collect();
629        let links: Vec<(usize, usize)> = self.edges.iter().map(|e| (e.source, e.target)).collect();
630        self.vis = Visual::build(&self.node_bodies, &self.node_group, &groups, &links);
631        self.hovered = None;
632        self.drag = None;
633        // Vertex indices changed.
634        self.shown_labels.take();
635    }
636
637    // ---- queries ------------------------------------------------------------
638
639    pub fn node_count(&self) -> usize {
640        self.nodes.len()
641    }
642
643    pub fn edge_count(&self) -> usize {
644        self.edges.len()
645    }
646
647    pub fn node(&self, id: &str) -> Option<&Node> {
648        self.index.get(id).map(|&i| &self.nodes[i])
649    }
650
651    pub fn degree(&self, id: &str) -> usize {
652        self.index.get(id).map_or(0, |&i| self.degrees[i])
653    }
654
655    pub fn neighbors_of(&self, id: &str) -> Vec<Node> {
656        self.index
657            .get(id)
658            .map(|&i| {
659                self.neighbors[i]
660                    .iter()
661                    .map(|&j| self.nodes[j].clone())
662                    .collect()
663            })
664            .unwrap_or_default()
665    }
666
667    /// Edges touching a node, in either direction.
668    pub fn edges_of(&self, id: &str) -> Vec<Edge> {
669        let Some(&i) = self.index.get(id) else {
670            return Vec::new();
671        };
672        self.edges
673            .iter()
674            .filter(|e| e.source == i || e.target == i)
675            .map(|e| e.data.clone())
676            .collect()
677    }
678
679    pub fn categories(&self) -> Vec<CategoryInfo> {
680        self.categories
681            .iter()
682            .enumerate()
683            .map(|(i, c)| CategoryInfo {
684                name: c.clone(),
685                color: self.palette.get(i),
686                count: self.nodes.iter().filter(|n| &n.category == c).count(),
687            })
688            .collect()
689    }
690
691    pub fn groups(&self) -> Vec<GroupInfo> {
692        (0..self.groups.len()).map(|g| self.group_info(g)).collect()
693    }
694
695    pub fn group(&self, id: &str) -> Option<GroupInfo> {
696        self.group_index.get(id).map(|&g| self.group_info(g))
697    }
698
699    /// Member nodes of a group.
700    pub fn group_members(&self, id: &str) -> Vec<Node> {
701        self.group_index
702            .get(id)
703            .map(|&g| {
704                self.groups[g]
705                    .members
706                    .iter()
707                    .map(|&i| self.nodes[i].clone())
708                    .collect()
709            })
710            .unwrap_or_default()
711    }
712
713    /// The colors in effect: the override, or the current theme.
714    pub fn resolved_colors(&self, cx: &App) -> GraphColors {
715        self.colors
716            .clone()
717            .unwrap_or_else(|| GraphColors::from_theme(cx))
718    }
719
720    /// The color a node is painted with.
721    pub fn color_of(&self, node: &Node, cx: &App) -> Hsla {
722        if let Some(c) = node.color {
723            return c;
724        }
725        match self.color_by {
726            ColorBy::Category => {
727                let ix = self
728                    .categories
729                    .iter()
730                    .position(|c| c == &node.category)
731                    .unwrap_or(0);
732                self.palette.get(ix)
733            }
734            ColorBy::Group => match node.group.as_ref().and_then(|g| self.group_index.get(g)) {
735                Some(&g) => self.group_color(g),
736                None => self.resolved_colors(cx).ungrouped,
737            },
738        }
739    }
740
741    pub fn selection(&self) -> Selection {
742        match &self.selected {
743            None => Selection::None,
744            Some(SelectionKey::Node(id)) => self
745                .node(id)
746                .cloned()
747                .map_or(Selection::None, Selection::Node),
748            Some(SelectionKey::Group(id)) => {
749                self.group(id).map_or(Selection::None, Selection::Group)
750            }
751            Some(SelectionKey::Edges(edges)) => self.edge_selection(edges),
752        }
753    }
754
755    pub fn zoom(&self) -> f32 {
756        self.zoom
757    }
758
759    /// Whether the layout is still moving.
760    pub fn is_simulating(&self) -> bool {
761        self.sim.is_active()
762    }
763
764    fn group_info(&self, g: usize) -> GroupInfo {
765        let s = &self.groups[g];
766        GroupInfo {
767            id: s.id.clone(),
768            label: s.label.clone(),
769            color: self.group_color(g),
770            size: s.members.len(),
771            collapsed: s.collapsed,
772        }
773    }
774
775    fn group_color(&self, g: usize) -> Hsla {
776        self.palette.get(g)
777    }
778
779    fn edge_selection(&self, edges: &[usize]) -> Selection {
780        match edges {
781            [] => Selection::None,
782            [e] => Selection::Edge(self.edges[*e].data.clone()),
783            many => {
784                Selection::EdgeBundle(many.iter().map(|&e| self.edges[e].data.clone()).collect())
785            }
786        }
787    }
788
789    fn key_of(&self, item: Item) -> SelectionKey {
790        match item {
791            Item::Vertex(v) => match self.vis.vertices[v] {
792                Vertex::Node(i) => SelectionKey::Node(self.nodes[i].id.clone()),
793                Vertex::Group(g) => SelectionKey::Group(self.groups[g].id.clone()),
794            },
795            Item::Edge(e) => SelectionKey::Edges(self.vis.edges[e].edges.clone()),
796        }
797    }
798
799    fn item_selection(&self, item: Item) -> Selection {
800        match item {
801            Item::Vertex(v) => match self.vis.vertices[v] {
802                Vertex::Node(i) => Selection::Node(self.nodes[i].clone()),
803                Vertex::Group(g) => Selection::Group(self.group_info(g)),
804            },
805            Item::Edge(e) => self.edge_selection(&self.vis.edges[e].edges),
806        }
807    }
808
809    /// The selection as a visual item, if it is currently drawn.
810    fn selected_item(&self) -> Option<Item> {
811        match self.selected.as_ref()? {
812            SelectionKey::Node(id) => {
813                let slot = self.vis.node_slot[*self.index.get(id)?];
814                matches!(self.vis.vertices[slot], Vertex::Node(_)).then_some(Item::Vertex(slot))
815            }
816            SelectionKey::Group(id) => {
817                self.vis.group_slot[*self.group_index.get(id)?].map(Item::Vertex)
818            }
819            SelectionKey::Edges(edges) => self
820                .vis
821                .edges
822                .iter()
823                .position(|e| &e.edges == edges)
824                .map(Item::Edge),
825        }
826    }
827
828    /// Drop a selection whose target no longer exists, notifying listeners.
829    fn validate_selection(&mut self, cx: &mut Context<Self>) {
830        if self.selected.is_some() && matches!(self.selection(), Selection::None) {
831            self.selected = None;
832            cx.emit(NetworkGraphEvent::SelectionChanged(Selection::None));
833        }
834    }
835
836    // ---- selection & viewport ---------------------------------------------
837
838    /// Select a node by id (`None` clears), optionally centering it.
839    pub fn select_node(&mut self, id: Option<&str>, center: bool, cx: &mut Context<Self>) {
840        let key = id
841            .filter(|id| self.index.contains_key(*id))
842            .map(|id| SelectionKey::Node(id.to_string().into()));
843        if let (Some(id), true) = (id, center)
844            && let Some(&i) = self.index.get(id)
845        {
846            let p = self.vis.bodies[self.vis.node_slot[i]].pos;
847            self.auto_fit = false;
848            self.pan = point(px(-p.x * self.zoom), px(-p.y * self.zoom));
849            cx.notify();
850        }
851        self.set_selected(key, cx);
852    }
853
854    fn set_selected(&mut self, key: Option<SelectionKey>, cx: &mut Context<Self>) {
855        if key == self.selected {
856            return;
857        }
858        self.selected = key;
859        cx.emit(NetworkGraphEvent::SelectionChanged(self.selection()));
860        cx.notify();
861    }
862
863    /// Pin or release a node at its current position.
864    pub fn set_pinned(&mut self, id: &str, pinned: bool, cx: &mut Context<Self>) {
865        let Some(&i) = self.index.get(id) else { return };
866        let v = self.vis.node_slot[i];
867        if matches!(self.vis.vertices[v], Vertex::Node(_)) {
868            let body = &mut self.vis.bodies[v];
869            body.fixed = pinned.then_some(body.pos);
870        } else {
871            let body = &mut self.node_bodies[i];
872            body.fixed = pinned.then_some(body.pos);
873        }
874        self.sim.reheat(0.1);
875        cx.notify();
876    }
877
878    pub fn is_pinned(&self, id: &str) -> bool {
879        self.index.get(id).is_some_and(|&i| {
880            let v = self.vis.node_slot[i];
881            match self.vis.vertices[v] {
882                Vertex::Node(_) => self.vis.bodies[v].fixed.is_some(),
883                Vertex::Group(_) => self.node_bodies[i].fixed.is_some(),
884            }
885        })
886    }
887
888    pub fn unpin_all(&mut self, cx: &mut Context<Self>) {
889        for b in self.vis.bodies.iter_mut().chain(&mut self.node_bodies) {
890            b.fixed = None;
891        }
892        self.sim.reheat(0.3);
893        cx.notify();
894    }
895
896    // ---- groups -------------------------------------------------------------
897
898    /// Collapse a group into a single super node.
899    pub fn collapse_group(&mut self, id: &str, cx: &mut Context<Self>) {
900        self.set_groups_collapsed(|g| g.id.as_ref() == id, true, cx);
901    }
902
903    pub fn expand_group(&mut self, id: &str, cx: &mut Context<Self>) {
904        self.set_groups_collapsed(|g| g.id.as_ref() == id, false, cx);
905    }
906
907    pub fn toggle_group(&mut self, id: &str, cx: &mut Context<Self>) {
908        if let Some(&g) = self.group_index.get(id) {
909            let collapse = !self.groups[g].collapsed;
910            self.set_groups_collapsed(|s| s.id.as_ref() == id, collapse, cx);
911        }
912    }
913
914    /// Community overview: every group becomes one node.
915    pub fn collapse_all_groups(&mut self, cx: &mut Context<Self>) {
916        self.set_groups_collapsed(|_| true, true, cx);
917    }
918
919    pub fn expand_all_groups(&mut self, cx: &mut Context<Self>) {
920        self.set_groups_collapsed(|_| true, false, cx);
921    }
922
923    pub fn set_group_label(
924        &mut self,
925        id: &str,
926        label: impl Into<SharedString>,
927        cx: &mut Context<Self>,
928    ) {
929        if let Some(&g) = self.group_index.get(id) {
930            self.groups[g].label = label.into();
931            cx.notify();
932        }
933    }
934
935    fn set_groups_collapsed(
936        &mut self,
937        filter: impl Fn(&GroupState) -> bool,
938        collapse: bool,
939        cx: &mut Context<Self>,
940    ) {
941        let targets: Vec<usize> = (0..self.groups.len())
942            .filter(|&g| filter(&self.groups[g]) && self.groups[g].collapsed != collapse)
943            .collect();
944        if targets.is_empty() {
945            return;
946        }
947        self.sync_back();
948        for &g in &targets {
949            let members = self.groups[g].members.clone();
950            if collapse {
951                let n = members.len().max(1) as f32;
952                let (sx, sy) = members.iter().fold((0., 0.), |(x, y), &i| {
953                    (x + self.node_bodies[i].pos.x, y + self.node_bodies[i].pos.y)
954                });
955                let centroid = Vec2::new(sx / n, sy / n);
956                let group = &mut self.groups[g];
957                group.anchor = centroid;
958                group.body.pos = centroid;
959                group.body.vel = Vec2::default();
960                group.body.fixed = None;
961            } else {
962                let group = &self.groups[g];
963                let (dx, dy) = (
964                    group.body.pos.x - group.anchor.x,
965                    group.body.pos.y - group.anchor.y,
966                );
967                for i in members {
968                    let b = &mut self.node_bodies[i];
969                    b.pos = Vec2::new(b.pos.x + dx, b.pos.y + dy);
970                    b.vel = Vec2::default();
971                    if let Some(f) = b.fixed {
972                        b.fixed = Some(Vec2::new(f.x + dx, f.y + dy));
973                    }
974                }
975            }
976            self.groups[g].collapsed = collapse;
977        }
978        // A selected super node no longer exists once expanded.
979        if let (false, Some(SelectionKey::Group(id))) = (collapse, &self.selected)
980            && targets.iter().any(|&g| &self.groups[g].id == id)
981        {
982            self.selected = None;
983            cx.emit(NetworkGraphEvent::SelectionChanged(Selection::None));
984        }
985        self.rebuild();
986        self.sim.reheat(0.4);
987        for g in targets {
988            cx.emit(NetworkGraphEvent::GroupToggled {
989                group: self.groups[g].id.clone(),
990                collapsed: collapse,
991            });
992        }
993        cx.notify();
994    }
995
996    // ---- viewport -----------------------------------------------------------
997
998    /// Fit everything into view and keep fitting while the layout settles.
999    pub fn fit_view(&mut self, cx: &mut Context<Self>) {
1000        self.auto_fit = true;
1001        self.apply_fit(1.);
1002        cx.notify();
1003    }
1004
1005    pub fn zoom_by(&mut self, factor: f32, cx: &mut Context<Self>) {
1006        self.auto_fit = false;
1007        let center = self.bounds.get().center();
1008        self.zoom_around(center, factor);
1009        cx.notify();
1010    }
1011
1012    /// Restart the layout from scratch (d3 `simulation.alpha(1).restart()`).
1013    pub fn relayout(&mut self, cx: &mut Context<Self>) {
1014        for b in &mut self.vis.bodies {
1015            b.fixed = None;
1016        }
1017        Simulation::initialize_positions(&mut self.vis.bodies, 0);
1018        self.sim.reheat(1.);
1019        self.auto_fit = true;
1020        cx.notify();
1021    }
1022
1023    fn apply_fit(&mut self, blend: f32) {
1024        let bounds = self.bounds.get();
1025        if self.vis.bodies.is_empty() || bounds.size.width <= px(0.) {
1026            return;
1027        }
1028        let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
1029        for b in &self.vis.bodies {
1030            x0 = x0.min(b.pos.x - b.radius);
1031            y0 = y0.min(b.pos.y - b.radius);
1032            x1 = x1.max(b.pos.x + b.radius);
1033            y1 = y1.max(b.pos.y + b.radius);
1034        }
1035        let padding = 48.;
1036        let w = (bounds.size.width.as_f32() - padding * 2.).max(1.);
1037        let h = (bounds.size.height.as_f32() - padding * 2.).max(1.);
1038        let zoom = (w / (x1 - x0).max(1.))
1039            .min(h / (y1 - y0).max(1.))
1040            .clamp(MIN_ZOOM, 2.);
1041        let (cx, cy) = ((x0 + x1) / 2., (y0 + y1) / 2.);
1042        let target = point(px(-cx * zoom), px(-cy * zoom));
1043        self.zoom += (zoom - self.zoom) * blend;
1044        self.pan = self.pan + (target - self.pan) * blend;
1045    }
1046
1047    fn zoom_around(&mut self, anchor: Point<Pixels>, factor: f32) {
1048        let world = self.to_world(anchor);
1049        self.zoom = (self.zoom * factor).clamp(MIN_ZOOM, MAX_ZOOM);
1050        let center = self.bounds.get().center();
1051        self.pan = anchor - center - point(px(world.x * self.zoom), px(world.y * self.zoom));
1052    }
1053
1054    fn to_screen(&self, p: Vec2) -> Point<Pixels> {
1055        self.bounds.get().center() + self.pan + point(px(p.x * self.zoom), px(p.y * self.zoom))
1056    }
1057
1058    fn to_world(&self, p: Point<Pixels>) -> Vec2 {
1059        let local = p - self.bounds.get().center() - self.pan;
1060        Vec2::new(local.x.as_f32() / self.zoom, local.y.as_f32() / self.zoom)
1061    }
1062
1063    fn edge_geometry(&self, e: usize) -> EdgeGeometry {
1064        let e = &self.vis.edges[e];
1065        let a = self.to_screen(self.vis.bodies[e.source].pos);
1066        let b = self.to_screen(self.vis.bodies[e.target].pos);
1067        EdgeGeometry::new(a, b, e.curvature * 28. * self.zoom.min(1.5))
1068    }
1069
1070    fn hit_test(&self, p: Point<Pixels>) -> Option<Item> {
1071        let w = self.to_world(p);
1072        let slop = 3. / self.zoom;
1073        // Topmost first: vertices are painted in index order, above edges.
1074        let vertex = self.vis.bodies.iter().enumerate().rev().find_map(|(i, b)| {
1075            let (dx, dy) = (b.pos.x - w.x, b.pos.y - w.y);
1076            let r = b.radius + slop;
1077            (dx * dx + dy * dy <= r * r).then_some(i)
1078        });
1079        if let Some(v) = vertex {
1080            return Some(Item::Vertex(v));
1081        }
1082        let mut best = (EDGE_HIT_SLOP, None);
1083        for (i, e) in self.vis.edges.iter().enumerate() {
1084            let d = if e.source == e.target {
1085                let c = self.to_screen(self.vis.bodies[e.source].pos);
1086                let s = (self.vis.bodies[e.source].radius * self.zoom).max(6.);
1087                let center = point(c.x + px(s * 1.3), c.y - px(s * 1.3));
1088                let r = (p.x - center.x).as_f32().hypot((p.y - center.y).as_f32());
1089                (r - s * 0.9).abs()
1090            } else {
1091                self.edge_geometry(i).distance_to(p)
1092            };
1093            if d < best.0 {
1094                best = (d, Some(i));
1095            }
1096        }
1097        best.1.map(Item::Edge)
1098    }
1099
1100    // ---- keyboard -----------------------------------------------------------
1101
1102    fn on_zoom_in(&mut self, _: &ZoomIn, _: &mut Window, cx: &mut Context<Self>) {
1103        self.keyboard_nav = true;
1104        self.zoom_by(1.25, cx);
1105    }
1106
1107    fn on_zoom_out(&mut self, _: &ZoomOut, _: &mut Window, cx: &mut Context<Self>) {
1108        self.keyboard_nav = true;
1109        self.zoom_by(0.8, cx);
1110    }
1111
1112    fn on_fit_view(&mut self, _: &FitView, _: &mut Window, cx: &mut Context<Self>) {
1113        self.keyboard_nav = true;
1114        self.fit_view(cx);
1115    }
1116
1117    fn on_clear_selection(&mut self, _: &ClearSelection, _: &mut Window, cx: &mut Context<Self>) {
1118        self.keyboard_nav = true;
1119        if self.selected.is_some() {
1120            self.set_selected(None, cx);
1121        } else {
1122            cx.propagate();
1123        }
1124    }
1125
1126    fn on_activate(&mut self, _: &Activate, _: &mut Window, cx: &mut Context<Self>) {
1127        self.keyboard_nav = true;
1128        match self.selected.clone() {
1129            Some(SelectionKey::Node(id)) => {
1130                if let Some(node) = self.node(&id).cloned() {
1131                    cx.emit(NetworkGraphEvent::NodeActivated(node));
1132                }
1133            }
1134            Some(SelectionKey::Group(id)) => self.toggle_group(&id, cx),
1135            _ => cx.propagate(),
1136        }
1137    }
1138
1139    fn on_open_context_menu(
1140        &mut self,
1141        _: &OpenContextMenu,
1142        _: &mut Window,
1143        cx: &mut Context<Self>,
1144    ) {
1145        self.keyboard_nav = true;
1146        let position = match self.selected_item() {
1147            Some(Item::Vertex(v)) => self.to_screen(self.vis.bodies[v].pos),
1148            Some(Item::Edge(e)) => self.edge_geometry(e).midpoint(),
1149            None => self.bounds.get().center(),
1150        };
1151        cx.emit(NetworkGraphEvent::ContextMenu {
1152            target: self.selection(),
1153            position,
1154        });
1155    }
1156
1157    /// Move the selection to the nearest vertex in a direction (spatial navigation).
1158    /// With nothing selected, start from the vertex nearest the viewport center.
1159    fn select_toward(&mut self, dx: f32, dy: f32, cx: &mut Context<Self>) {
1160        self.keyboard_nav = true;
1161        let bodies = &self.vis.bodies;
1162        if bodies.is_empty() {
1163            return;
1164        }
1165        let from = match self.selected_item() {
1166            Some(Item::Vertex(v)) => Some(v),
1167            Some(Item::Edge(e)) => Some(self.vis.edges[e].source),
1168            None => None,
1169        };
1170        let target = match from {
1171            None => {
1172                let c = self.to_world(self.bounds.get().center());
1173                (0..bodies.len()).min_by(|&a, &b| {
1174                    let d = |i: usize| (bodies[i].pos.x - c.x).hypot(bodies[i].pos.y - c.y);
1175                    d(a).total_cmp(&d(b))
1176                })
1177            }
1178            Some(f) => {
1179                let p = bodies[f].pos;
1180                (0..bodies.len())
1181                    .filter(|&i| i != f)
1182                    .filter_map(|i| {
1183                        let (x, y) = (bodies[i].pos.x - p.x, bodies[i].pos.y - p.y);
1184                        let dist = x.hypot(y).max(1e-3);
1185                        let cos = (x * dx + y * dy) / dist;
1186                        // Within ±60° of the direction; prefer straight ahead.
1187                        (cos > 0.5).then_some((i, dist * (2. - cos)))
1188                    })
1189                    .min_by(|a, b| a.1.total_cmp(&b.1))
1190                    .map(|(i, _)| i)
1191            }
1192        };
1193        let Some(v) = target else { return };
1194        self.reveal(v);
1195        let key = self.key_of(Item::Vertex(v));
1196        self.set_selected(Some(key), cx);
1197        cx.notify();
1198    }
1199
1200    /// Pan just enough to bring a vertex into view.
1201    fn reveal(&mut self, v: usize) {
1202        let bounds = self.bounds.get();
1203        let margin = px(48.);
1204        let p = self.to_screen(self.vis.bodies[v].pos);
1205        let inner = Bounds::from_corners(
1206            bounds.origin + point(margin, margin),
1207            bounds.bottom_right() - point(margin, margin),
1208        );
1209        if !inner.contains(&p) {
1210            self.auto_fit = false;
1211            let clamped = point(
1212                p.x.clamp(inner.left(), inner.right()),
1213                p.y.clamp(inner.top(), inner.bottom()),
1214            );
1215            self.pan += clamped - p;
1216        }
1217    }
1218
1219    // ---- input --------------------------------------------------------------
1220
1221    fn on_mouse_down(&mut self, ev: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
1222        window.focus(&self.focus_handle, cx);
1223        self.keyboard_nav = false;
1224        let hit = self.hit_test(ev.position);
1225        if ev.button == MouseButton::Right {
1226            if let Some(item) = hit {
1227                self.set_selected(Some(self.key_of(item)), cx);
1228            }
1229            let target = hit.map_or(Selection::None, |item| self.item_selection(item));
1230            cx.emit(NetworkGraphEvent::ContextMenu {
1231                target,
1232                position: ev.position,
1233            });
1234            return;
1235        }
1236        if ev.click_count >= 2 {
1237            if let Some(Item::Vertex(v)) = hit {
1238                match self.vis.vertices[v] {
1239                    Vertex::Node(i) => {
1240                        cx.emit(NetworkGraphEvent::NodeActivated(self.nodes[i].clone()))
1241                    }
1242                    Vertex::Group(g) => {
1243                        let id = self.groups[g].id.clone();
1244                        self.expand_group(&id, cx);
1245                    }
1246                }
1247            }
1248            return;
1249        }
1250        let kind = match hit {
1251            Some(Item::Vertex(ix)) => DragKind::Vertex {
1252                ix,
1253                was_pinned: self.vis.bodies[ix].fixed.is_some(),
1254            },
1255            _ => DragKind::Pan {
1256                start_pan: self.pan,
1257            },
1258        };
1259        self.drag = Some(Drag {
1260            kind,
1261            start: ev.position,
1262            moved: false,
1263        });
1264    }
1265
1266    fn on_mouse_move(&mut self, ev: &MouseMoveEvent, inside: bool, cx: &mut Context<Self>) {
1267        self.pointer = inside.then_some(ev.position);
1268        if let Some(drag) = &mut self.drag {
1269            let delta = ev.position - drag.start;
1270            if !drag.moved {
1271                if delta.x.as_f32().hypot(delta.y.as_f32()) < DRAG_THRESHOLD {
1272                    return;
1273                }
1274                drag.moved = true;
1275            }
1276            match drag.kind {
1277                DragKind::Pan { start_pan } => {
1278                    self.auto_fit = false;
1279                    self.pan = start_pan + delta;
1280                }
1281                DragKind::Vertex { ix, .. } => {
1282                    let w = self.to_world(ev.position);
1283                    self.vis.bodies[ix].fixed = Some(w);
1284                    self.vis.bodies[ix].pos = w;
1285                    self.sim.alpha_target = 0.3;
1286                    self.sim.reheat(0.3);
1287                    self.auto_fit = false;
1288                }
1289            }
1290            cx.notify();
1291            return;
1292        }
1293        let hovered = if inside {
1294            self.hit_test(ev.position)
1295        } else {
1296            None
1297        };
1298        if hovered != self.hovered || hovered.is_some() {
1299            // Also re-render while hovering so the tooltip follows the pointer.
1300            self.hovered = hovered;
1301            cx.notify();
1302        }
1303    }
1304
1305    fn on_mouse_up(&mut self, ev: &MouseUpEvent, cx: &mut Context<Self>) {
1306        let Some(drag) = self.drag.take() else { return };
1307        match drag.kind {
1308            DragKind::Vertex { ix, was_pinned } => {
1309                self.sim.alpha_target = 0.;
1310                if !drag.moved {
1311                    let key = self.key_of(Item::Vertex(ix));
1312                    self.set_selected(Some(key), cx);
1313                } else if !(self.sticky_drag || was_pinned) {
1314                    self.vis.bodies[ix].fixed = None;
1315                }
1316            }
1317            DragKind::Pan { .. } => {
1318                if !drag.moved {
1319                    let key = self.hit_test(ev.position).map(|item| self.key_of(item));
1320                    self.set_selected(key, cx);
1321                }
1322            }
1323        }
1324        cx.notify();
1325    }
1326
1327    fn on_scroll(&mut self, ev: &ScrollWheelEvent, cx: &mut Context<Self>) {
1328        let delta = ev.delta.pixel_delta(px(20.));
1329        let factor = (-delta.y.as_f32() * 0.0025).exp();
1330        self.auto_fit = false;
1331        self.zoom_around(ev.position, factor);
1332        cx.notify();
1333    }
1334}
1335
1336impl Render for NetworkGraph {
1337    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1338        if self.sim.is_active() && !self.vis.bodies.is_empty() {
1339            let reduce_motion = cx.reduce_motion();
1340            let vis = &mut self.vis;
1341            if reduce_motion {
1342                // Settle off-screen instead of animating the layout.
1343                let deadline = Instant::now() + REDUCED_MOTION_BUDGET;
1344                while self.sim.is_active()
1345                    && self.sim.alpha_target == 0.
1346                    && Instant::now() < deadline
1347                {
1348                    self.sim
1349                        .tick(&mut vis.bodies, &vis.links, &vis.degrees, &vis.clusters);
1350                }
1351                if self.sim.alpha_target != 0. {
1352                    self.sim
1353                        .tick(&mut vis.bodies, &vis.links, &vis.degrees, &vis.clusters);
1354                }
1355            } else {
1356                // Small graphs can afford two ticks per frame and settle twice as fast.
1357                let ticks = if vis.bodies.len() > 1500 { 1 } else { 2 };
1358                for _ in 0..ticks {
1359                    self.sim
1360                        .tick(&mut vis.bodies, &vis.links, &vis.degrees, &vis.clusters);
1361                }
1362            }
1363            if self.auto_fit {
1364                self.apply_fit(if reduce_motion { 1. } else { 0.25 });
1365            }
1366            if self.sim.is_active() {
1367                window.request_animation_frame();
1368            }
1369        }
1370
1371        let colors = self.resolved_colors(cx);
1372        let metrics = GraphMetrics::from_theme(cx);
1373        let tooltip = self.tooltip_element(&colors, &metrics, window, cx);
1374        let entity = cx.entity();
1375        let bounds_cell = self.bounds.clone();
1376        let background = colors.background;
1377        let show_focus = self.keyboard_nav && self.focus_handle.is_focused(window);
1378
1379        div()
1380            .id("network-graph")
1381            .track_focus(&self.focus_handle)
1382            .key_context(KEY_CONTEXT)
1383            .on_action(cx.listener(Self::on_zoom_in))
1384            .on_action(cx.listener(Self::on_zoom_out))
1385            .on_action(cx.listener(Self::on_fit_view))
1386            .on_action(cx.listener(Self::on_clear_selection))
1387            .on_action(cx.listener(Self::on_activate))
1388            .on_action(cx.listener(Self::on_open_context_menu))
1389            .on_action(cx.listener(|this, _: &SelectLeft, _, cx| this.select_toward(-1., 0., cx)))
1390            .on_action(cx.listener(|this, _: &SelectRight, _, cx| this.select_toward(1., 0., cx)))
1391            .on_action(cx.listener(|this, _: &SelectUp, _, cx| this.select_toward(0., -1., cx)))
1392            .on_action(cx.listener(|this, _: &SelectDown, _, cx| this.select_toward(0., 1., cx)))
1393            .size_full()
1394            .relative()
1395            .overflow_hidden()
1396            .bg(background)
1397            .child(
1398                canvas(
1399                    move |bounds, window, _| {
1400                        bounds_cell.set(bounds);
1401                        window.insert_hitbox(bounds, HitboxBehavior::Normal)
1402                    },
1403                    move |bounds, hitbox: Hitbox, window, cx| {
1404                        register_input(&entity, &hitbox, window, cx);
1405                        window.with_content_mask(Some(ContentMask { bounds }), |window| {
1406                            entity.update(cx, |this, cx| {
1407                                this.paint_graph(bounds, &colors, &metrics, show_focus, window, cx)
1408                            });
1409                        });
1410                    },
1411                )
1412                .size_full(),
1413            )
1414            .children(tooltip)
1415    }
1416}
1417
1418/// Mouse handling is registered at the window level during paint so drags keep
1419/// tracking outside the graph's bounds.
1420fn register_input(
1421    entity: &Entity<NetworkGraph>,
1422    hitbox: &Hitbox,
1423    window: &mut Window,
1424    cx: &mut App,
1425) {
1426    let graph = entity.read(cx);
1427    // Pointing hand is reserved for links; hover state is shown by the halo.
1428    let cursor = match &graph.drag {
1429        Some(d) if d.moved => CursorStyle::ClosedHand,
1430        _ => CursorStyle::Arrow,
1431    };
1432    window.set_cursor_style(cursor, hitbox);
1433
1434    let (e, h) = (entity.clone(), hitbox.clone());
1435    window.on_mouse_event(move |ev: &MouseDownEvent, phase, window, cx| {
1436        let button_ok = matches!(ev.button, MouseButton::Left | MouseButton::Right);
1437        if phase == DispatchPhase::Bubble && button_ok && h.is_hovered(window) {
1438            e.update(cx, |this, cx| this.on_mouse_down(ev, window, cx));
1439        }
1440    });
1441    let (e, h) = (entity.clone(), hitbox.clone());
1442    window.on_mouse_event(move |ev: &MouseMoveEvent, phase, window, cx| {
1443        if phase != DispatchPhase::Bubble {
1444            return;
1445        }
1446        let inside = h.is_hovered(window);
1447        let graph = e.read(cx);
1448        if inside || graph.drag.is_some() || graph.hovered.is_some() {
1449            e.update(cx, |this, cx| this.on_mouse_move(ev, inside, cx));
1450        }
1451    });
1452    let e = entity.clone();
1453    window.on_mouse_event(move |ev: &MouseUpEvent, phase, _, cx| {
1454        if phase == DispatchPhase::Bubble && ev.button == MouseButton::Left {
1455            e.update(cx, |this, cx| this.on_mouse_up(ev, cx));
1456        }
1457    });
1458    let (e, h) = (entity.clone(), hitbox.clone());
1459    window.on_mouse_event(move |ev: &ScrollWheelEvent, phase, window, cx| {
1460        if phase == DispatchPhase::Bubble && h.should_handle_scroll(window) {
1461            e.update(cx, |this, cx| this.on_scroll(ev, cx));
1462        }
1463    });
1464}