Skip to main content

gpui_graph/view/
mod.rs

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