Skip to main content

gpui_kit/canvas/
graph.rs

1//! The canvas a run is drawn on.
2//!
3//! The graph owns no layout algorithm. Where a node sits is a product
4//! question — a plan graph, a dependency graph and a retry graph want
5//! different answers, and none of them belong in a component library — so the
6//! caller places every node and this draws what it was given. What the graph
7//! does own is the part that is the same every time: the backdrop, the
8//! stacking of edges beneath nodes, and the five states a canvas can be in.
9
10use std::{cell::Cell, collections::HashMap, rc::Rc};
11
12use gpui::{
13    AnyElement, App, Bounds, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels,
14    Point, RenderOnce, ScrollDelta, SharedString, Styled, Window, canvas, div, point, px, size,
15};
16use gpui_kit_semantics::{NodeSpec, Role, Semantic};
17use gpui_kit_theme::{ActiveTheme, Radius, Space, Surface, TypeScale};
18use web_time::Instant;
19
20use crate::display::empty::EmptyState;
21use crate::foundation::{Ident, StyledExt};
22use crate::layout::measure;
23use crate::motion::keyed;
24use crate::strings::{ActiveStrings, StringKey};
25
26use super::edge::{
27    Anchor, Axis, GraphEdge, GraphEndpoint, OrthogonalRoute, PortSide, RouteTransform, paint_route,
28    paint_route_stroke, route_orthogonal, route_preview,
29};
30use super::node::{GraphNode, GraphPort, NodeState, PortDirection};
31
32/// The spacing of the dot grid behind the canvas, in pixels.
33const GRID_STEP: f32 = 24.0;
34const GRID_DOT: f32 = 1.0;
35
36/// Caller-owned pan and zoom values for a [`NodeGraph`].
37#[derive(Debug, Clone, Copy, PartialEq)]
38pub struct GraphViewport {
39    pub offset: Point<f32>,
40    pub zoom: f32,
41}
42
43impl GraphViewport {
44    /// Creates a viewport with a screen-space offset and world scale.
45    pub fn new(offset: Point<f32>, zoom: f32) -> Self {
46        Self { offset, zoom }
47    }
48}
49
50impl Default for GraphViewport {
51    fn default() -> Self {
52        Self {
53            offset: point(0.0, 0.0),
54            zoom: 1.0,
55        }
56    }
57}
58
59/// A proposed controlled graph change.
60#[derive(Debug, Clone, PartialEq)]
61pub enum NodeGraphEvent {
62    /// Proposes a pan or zoom change.
63    ViewportChanged(GraphViewport),
64    /// Proposes a new world-space position for a node.
65    NodeMoved {
66        id: SharedString,
67        position: Point<f32>,
68    },
69    /// Proposes a new output-to-input connection.
70    ConnectionRequested {
71        from: GraphEndpoint,
72        to: GraphEndpoint,
73    },
74}
75
76type EventHandler = Rc<dyn Fn(&NodeGraphEvent, &mut Window, &mut App)>;
77
78#[derive(Debug, Clone)]
79enum Gesture {
80    Pan {
81        at: Point<Pixels>,
82        viewport: GraphViewport,
83    },
84    Node {
85        at: Point<Pixels>,
86        id: SharedString,
87        position: Point<f32>,
88        moved: bool,
89    },
90    Connect {
91        from: GraphEndpoint,
92    },
93}
94
95#[derive(Debug, Default)]
96struct GestureState {
97    gesture: Option<Gesture>,
98    pointer: Option<Point<Pixels>>,
99    animation_started: Option<Instant>,
100}
101
102fn world_to_screen(world: Point<f32>, viewport: GraphViewport) -> Point<f32> {
103    point(
104        viewport.offset.x + world.x * viewport.zoom,
105        viewport.offset.y + world.y * viewport.zoom,
106    )
107}
108fn screen_to_world(screen: Point<f32>, viewport: GraphViewport) -> Point<f32> {
109    point(
110        (screen.x - viewport.offset.x) / viewport.zoom,
111        (screen.y - viewport.offset.y) / viewport.zoom,
112    )
113}
114fn zoom_at(viewport: GraphViewport, screen: Point<f32>, zoom: f32) -> GraphViewport {
115    let world = screen_to_world(screen, viewport);
116    GraphViewport {
117        offset: point(screen.x - world.x * zoom, screen.y - world.y * zoom),
118        zoom,
119    }
120}
121
122fn viewport_value(state: &str, viewport: GraphViewport) -> String {
123    format!(
124        "state:{state};offset:{:.3},{:.3};zoom:{:.3}",
125        viewport.offset.x, viewport.offset.y, viewport.zoom
126    )
127}
128
129fn composite_id(prefix: &str, parts: &[&str]) -> SharedString {
130    let mut id = prefix.to_string();
131    for part in parts {
132        id.push(':');
133        id.push_str(&part.len().to_string());
134        id.push(':');
135        id.push_str(part);
136    }
137    id.into()
138}
139
140#[derive(Debug, Clone)]
141struct PortGeometry {
142    id: SharedString,
143    anchor: Anchor,
144    direction: super::node::PortDirection,
145}
146#[derive(Debug, Clone)]
147struct NodeGeometry {
148    id: SharedString,
149    bounds: Bounds<f32>,
150    ports: Vec<PortGeometry>,
151}
152#[derive(Debug, Clone)]
153struct RoutedEdge {
154    edge: GraphEdge,
155    route: OrthogonalRoute,
156}
157
158#[derive(Debug, Clone)]
159struct ConnectionPreview {
160    route: OrthogonalRoute,
161    target: Option<(GraphEndpoint, bool)>,
162}
163
164/// A node and where its top left corner sits, in canvas coordinates.
165pub struct Placed {
166    node: GraphNode,
167    x: f32,
168    y: f32,
169    /// A height the caller declared, for a card whose content the node cannot
170    /// measure. Left unset, the node measures itself.
171    height: Option<f32>,
172}
173
174impl Placed {
175    pub fn new(node: GraphNode, x: f32, y: f32) -> Self {
176        Self {
177            node,
178            x,
179            y,
180            height: None,
181        }
182    }
183
184    /// Sets the card's actual logical height. Routing and card layout use this
185    /// same value. Non-finite and non-positive values leave the height
186    /// automatic rather than creating geometry the card cannot render.
187    pub fn height(mut self, height: f32) -> Self {
188        self.height = (height.is_finite() && height > 0.0).then_some(height);
189        self
190    }
191
192    fn bounds(&self, theme: &gpui_kit_theme::Theme, measured_height: Option<f32>) -> Bounds<f32> {
193        let height = self
194            .height
195            .or(measured_height.filter(|height| height.is_finite() && *height > 0.0))
196            .unwrap_or_else(|| self.node.measured_height(theme));
197        Bounds::new(point(self.x, self.y), size(self.node.node_width(), height))
198    }
199}
200
201impl std::fmt::Debug for Placed {
202    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203        formatter
204            .debug_struct("Placed")
205            .field("node", self.node.ident())
206            .field("x", &self.x)
207            .field("y", &self.y)
208            .finish()
209    }
210}
211
212/// What the canvas can currently say about itself.
213///
214/// These are the same five distinct states the rest of the library keeps
215/// apart, and for the same reason: a canvas that is still loading, a run with
216/// no steps, a graph the host would not produce, and a graph that failed to
217/// load are four different things, and drawing any of them as an empty canvas
218/// would be a lie a reader cannot detect.
219#[derive(Debug, Clone, PartialEq, Eq, Default)]
220pub enum GraphState {
221    #[default]
222    Ready,
223    Loading,
224    /// The host declined to produce the graph, in its own words.
225    Refused(SharedString),
226    /// The graph could not be loaded, in the host's own words.
227    Failed(SharedString),
228}
229
230/// A run drawn as connected steps.
231#[derive(IntoElement)]
232pub struct NodeGraph {
233    ident: Ident,
234    nodes: Vec<Placed>,
235    edges: Vec<GraphEdge>,
236    state: GraphState,
237    empty: Option<EmptyState>,
238    grid: bool,
239    viewport: GraphViewport,
240    zoom_range: (f32, f32),
241    on_event: Option<EventHandler>,
242}
243
244impl std::fmt::Debug for NodeGraph {
245    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246        formatter
247            .debug_struct("NodeGraph")
248            .field("ident", &self.ident)
249            .field("nodes", &self.nodes.len())
250            .field("edges", &self.edges.len())
251            .field("state", &self.state)
252            .finish()
253    }
254}
255
256impl NodeGraph {
257    pub fn new(ident: impl Into<Ident>) -> Self {
258        Self {
259            ident: ident.into(),
260            nodes: Vec::new(),
261            edges: Vec::new(),
262            state: GraphState::Ready,
263            empty: None,
264            grid: true,
265            viewport: GraphViewport::default(),
266            zoom_range: (0.5, 2.0),
267            on_event: None,
268        }
269    }
270
271    pub fn node(mut self, node: GraphNode, x: f32, y: f32) -> Self {
272        self.nodes.push(Placed::new(node, x, y));
273        self
274    }
275
276    pub fn placed(mut self, placed: Placed) -> Self {
277        self.nodes.push(placed);
278        self
279    }
280
281    pub fn edge(mut self, edge: GraphEdge) -> Self {
282        self.edges.push(edge);
283        self
284    }
285
286    pub fn edges(mut self, edges: impl IntoIterator<Item = GraphEdge>) -> Self {
287        self.edges.extend(edges);
288        self
289    }
290
291    pub fn state(mut self, state: GraphState) -> Self {
292        self.state = state;
293        self
294    }
295
296    /// What to draw when the run has no steps at all. Without one, an empty
297    /// run draws as an empty canvas, which is only honest when the caller has
298    /// confirmed that is what it is.
299    pub fn empty(mut self, empty: EmptyState) -> Self {
300        self.empty = Some(empty);
301        self
302    }
303
304    /// Turns off the dot grid, for a canvas embedded somewhere that already
305    /// has a texture of its own.
306    pub fn grid(mut self, grid: bool) -> Self {
307        self.grid = grid;
308        self
309    }
310
311    /// Scrolls the canvas. The caller owns the offset, because panning is a
312    /// gesture the host binds and a position the host may want to keep.
313    pub fn offset(mut self, x: f32, y: f32) -> Self {
314        if x.is_finite() && y.is_finite() {
315            self.viewport.offset = point(x, y);
316        }
317        self
318    }
319
320    pub fn viewport(mut self, viewport: GraphViewport) -> Self {
321        if viewport.offset.x.is_finite() && viewport.offset.y.is_finite() {
322            self.viewport.offset = viewport.offset;
323        }
324        if viewport.zoom.is_finite() && viewport.zoom > 0.0 {
325            self.viewport.zoom = viewport.zoom;
326        }
327        self
328    }
329    pub fn zoom(mut self, zoom: f32) -> Self {
330        if zoom.is_finite() && zoom > 0.0 {
331            self.viewport.zoom = zoom;
332        }
333        self
334    }
335    pub fn zoom_range(mut self, min: f32, max: f32) -> Self {
336        if min.is_finite() && max.is_finite() && min > 0.0 && min <= max {
337            self.zoom_range = (min, max);
338        }
339        self
340    }
341    pub fn on_event(
342        mut self,
343        handler: impl Fn(&NodeGraphEvent, &mut Window, &mut App) + 'static,
344    ) -> Self {
345        self.on_event = Some(Rc::new(handler));
346        self
347    }
348
349    /// The box of every node, by identity, for the edge painter.
350    #[cfg(test)]
351    fn geometry(&self, theme: &gpui_kit_theme::Theme) -> Vec<NodeGeometry> {
352        self.geometry_with_heights(theme, &HashMap::new())
353    }
354
355    fn geometry_with_heights(
356        &self,
357        theme: &gpui_kit_theme::Theme,
358        measured_heights: &HashMap<SharedString, f32>,
359    ) -> Vec<NodeGeometry> {
360        let node_counts = self
361            .nodes
362            .iter()
363            .fold(HashMap::new(), |mut counts, placed| {
364                *counts
365                    .entry(placed.node.ident().semantic_id())
366                    .or_insert(0usize) += 1;
367                counts
368            });
369        self.nodes
370            .iter()
371            .filter(|placed| node_counts.get(&placed.node.ident().semantic_id()) == Some(&1))
372            .map(|placed| {
373                let id = placed.node.ident().semantic_id();
374                let bounds = placed.bounds(theme, measured_heights.get(&id).copied());
375                let port_counts =
376                    placed
377                        .node
378                        .graph_ports()
379                        .iter()
380                        .fold(HashMap::new(), |mut counts, port| {
381                            *counts.entry(port.id()).or_insert(0usize) += 1;
382                            counts
383                        });
384                let ports = placed
385                    .node
386                    .graph_ports()
387                    .iter()
388                    .filter(|port| port_counts.get(port.id()) == Some(&1))
389                    .map(|port| {
390                        let same: Vec<&GraphPort> = placed
391                            .node
392                            .graph_ports()
393                            .iter()
394                            .filter(|p| p.port_side() == port.port_side())
395                            .collect();
396                        let index = same.iter().position(|p| p.id() == port.id()).unwrap_or(0);
397                        let fraction = (index + 1) as f32 / (same.len() + 1) as f32;
398                        let anchor = match port.port_side() {
399                            PortSide::Top => {
400                                point(bounds.left() + bounds.size.width * fraction, bounds.top())
401                            }
402                            PortSide::Right => {
403                                point(bounds.right(), bounds.top() + bounds.size.height * fraction)
404                            }
405                            PortSide::Bottom => point(
406                                bounds.left() + bounds.size.width * fraction,
407                                bounds.bottom(),
408                            ),
409                            PortSide::Left => {
410                                point(bounds.left(), bounds.top() + bounds.size.height * fraction)
411                            }
412                        };
413                        PortGeometry {
414                            id: port.id().clone(),
415                            anchor: Anchor {
416                                point: anchor,
417                                side: port.port_side(),
418                            },
419                            direction: port.direction(),
420                        }
421                    })
422                    .collect();
423                NodeGeometry { id, bounds, ports }
424            })
425            .collect()
426    }
427
428    /// The edges that name two nodes this graph actually has.
429    ///
430    /// An edge to a node that is not here is dropped rather than guessed at:
431    /// a line drawn to the wrong box would report a connection the run does
432    /// not have, and there is no correct place to put a line whose end is
433    /// missing.
434    #[cfg(test)]
435    fn routable(&self, theme: &gpui_kit_theme::Theme) -> Vec<RoutedEdge> {
436        let nodes = self.geometry(theme);
437        self.routable_geometry(&nodes)
438    }
439
440    fn routable_geometry(&self, nodes: &[NodeGeometry]) -> Vec<RoutedEdge> {
441        let counts = self.edges.iter().fold(HashMap::new(), |mut m, e| {
442            *m.entry(e.identity()).or_insert(0usize) += 1;
443            m
444        });
445        self.edges
446            .iter()
447            .filter(|edge| counts.get(&edge.identity()) == Some(&1))
448            .filter_map(|edge| {
449                let from = nodes.iter().find(|n| &n.id == edge.from())?;
450                let to = nodes.iter().find(|n| &n.id == edge.to())?;
451                let (a, b) = match (edge.source_port(), edge.target_port()) {
452                    (Some(a), Some(b)) => {
453                        let a = from.ports.iter().find(|p| &p.id == a)?;
454                        let b = to.ports.iter().find(|p| &p.id == b)?;
455                        if a.direction != super::node::PortDirection::Output
456                            || b.direction != super::node::PortDirection::Input
457                        {
458                            return None;
459                        }
460                        (a.anchor, b.anchor)
461                    }
462                    (None, None) => auto_anchors(from.bounds, to.bounds, edge.kind()),
463                    _ => return None,
464                };
465                let route =
466                    route_orthogonal(a, b, from.bounds, to.bounds, edge.kind(), edge.edge_lane())?;
467                Some(RoutedEdge {
468                    edge: edge.clone(),
469                    route,
470                })
471            })
472            .collect()
473    }
474}
475
476fn auto_anchors(
477    from: Bounds<f32>,
478    to: Bounds<f32>,
479    kind: super::edge::EdgeKind,
480) -> (Anchor, Anchor) {
481    let fc = from.center();
482    let tc = to.center();
483    let (fs, ts) = if kind == super::edge::EdgeKind::Feedback {
484        (PortSide::Bottom, PortSide::Bottom)
485    } else if (tc.x - fc.x).abs() >= (tc.y - fc.y).abs() {
486        if tc.x >= fc.x {
487            (PortSide::Right, PortSide::Left)
488        } else {
489            (PortSide::Left, PortSide::Right)
490        }
491    } else if tc.y >= fc.y {
492        (PortSide::Bottom, PortSide::Top)
493    } else {
494        (PortSide::Top, PortSide::Bottom)
495    };
496    let at = |b: Bounds<f32>, side| Anchor {
497        point: match side {
498            PortSide::Top => point(b.center().x, b.top()),
499            PortSide::Right => point(b.right(), b.center().y),
500            PortSide::Bottom => point(b.center().x, b.bottom()),
501            PortSide::Left => point(b.left(), b.center().y),
502        },
503        side,
504    };
505    (at(from, fs), at(to, ts))
506}
507
508fn connection_target(
509    nodes: &[NodeGeometry],
510    from: &GraphEndpoint,
511    pointer: Point<f32>,
512    viewport: GraphViewport,
513    radius: f32,
514) -> Option<(GraphEndpoint, bool)> {
515    nodes
516        .iter()
517        .flat_map(|node| node.ports.iter().map(move |port| (node, port)))
518        .filter_map(|(node, port)| {
519            let at = world_to_screen(port.anchor.point, viewport);
520            let distance = (at.x - pointer.x).powi(2) + (at.y - pointer.y).powi(2);
521            (distance <= radius.powi(2)).then(|| {
522                let endpoint = GraphEndpoint::new(node.id.clone(), port.id.clone());
523                let valid = port.direction == PortDirection::Input && &endpoint != from;
524                (distance, endpoint, valid)
525            })
526        })
527        .min_by(|left, right| {
528            left.0
529                .partial_cmp(&right.0)
530                .unwrap_or(std::cmp::Ordering::Equal)
531        })
532        .map(|(_, endpoint, valid)| (endpoint, valid))
533}
534
535impl RenderOnce for NodeGraph {
536    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
537        let theme = cx.theme().clone();
538        let mut viewport = self.viewport;
539        viewport.zoom = viewport.zoom.clamp(self.zoom_range.0, self.zoom_range.1);
540        let moving_effects = matches!(self.state, GraphState::Ready)
541            && (self.edges.iter().any(GraphEdge::is_active)
542                || self
543                    .nodes
544                    .iter()
545                    .any(|placed| placed.node.node_state() == NodeState::Running));
546        let graph_busy = matches!(self.state, GraphState::Loading) || moving_effects;
547        let gesture = keyed::slot::<GestureState>(&self.ident.semantic_id(), cx);
548        let animation_phase = if moving_effects && !cx.reduce_motion() {
549            let now = cx.background_executor().now();
550            let mut state = gesture.borrow_mut();
551            let started = *state.animation_started.get_or_insert(now);
552            Some((now.duration_since(started).as_secs_f32() / 1.8).rem_euclid(1.0))
553        } else {
554            gesture.borrow_mut().animation_started = None;
555            None
556        };
557        if animation_phase.is_some() {
558            window.request_animation_frame();
559        }
560        let spec = NodeSpec::new(self.ident.semantic_id(), Role::Group).busy(graph_busy);
561
562        let measured = measure::cell(&self.ident.child("viewport").semantic_id(), cx);
563        let record = Rc::clone(&measured);
564        let mut frame = div()
565            .on_children_prepainted(move |bounds, window, _| {
566                if let Some(first) = bounds.first() {
567                    measure::record(&record, *first, window);
568                }
569            })
570            .id(self.ident.element_id())
571            .relative()
572            .size_full()
573            .overflow_hidden()
574            .surface(&theme, Surface::Canvas);
575
576        if let Some(report) = self
577            .on_event
578            .as_ref()
579            .cloned()
580            .filter(|_| matches!(self.state, GraphState::Ready) && !self.nodes.is_empty())
581        {
582            let down = Rc::clone(&gesture);
583            frame =
584                frame.on_mouse_down_with_pointer_capture(MouseButton::Left, move |event, _, cx| {
585                    down.borrow_mut().gesture = Some(Gesture::Pan {
586                        at: event.position,
587                        viewport,
588                    });
589                    cx.stop_propagation();
590                });
591            let moving = Rc::clone(&gesture);
592            let move_report = Rc::clone(&report);
593            frame = frame.on_mouse_move(move |event, window, cx| {
594                let mut state = moving.borrow_mut();
595                state.pointer = Some(event.position);
596                if let Some(Gesture::Pan { at, viewport }) = state.gesture {
597                    if event.pressed_button != Some(MouseButton::Left) {
598                        state.gesture = None;
599                        return;
600                    }
601                    let delta = point(
602                        f32::from(event.position.x - at.x),
603                        f32::from(event.position.y - at.y),
604                    );
605                    move_report(
606                        &NodeGraphEvent::ViewportChanged(GraphViewport {
607                            offset: point(viewport.offset.x + delta.x, viewport.offset.y + delta.y),
608                            zoom: viewport.zoom,
609                        }),
610                        window,
611                        cx,
612                    );
613                }
614            });
615            let up = Rc::clone(&gesture);
616            frame = frame.on_mouse_up(MouseButton::Left, move |_, _, _| {
617                up.borrow_mut().gesture = None
618            });
619            let wheel_report = Rc::clone(&report);
620            let wheel_bounds = Rc::clone(&measured);
621            let wheel_gesture = Rc::clone(&gesture);
622            let (min_zoom, max_zoom) = self.zoom_range;
623            frame = frame.on_scroll_wheel(move |event, window, cx| {
624                if wheel_gesture.borrow().gesture.is_some() {
625                    cx.stop_propagation();
626                    return;
627                }
628                let delta = match event.delta {
629                    ScrollDelta::Lines(delta) => delta.y * 40.0,
630                    ScrollDelta::Pixels(delta) => f32::from(delta.y),
631                };
632                if !delta.is_finite() || delta == 0.0 {
633                    return;
634                }
635                let next = (viewport.zoom * (delta / 400.0).exp()).clamp(min_zoom, max_zoom);
636                if (next - viewport.zoom).abs() < f32::EPSILON {
637                    return;
638                }
639                let bounds = wheel_bounds.get();
640                if bounds.size.width <= px(0.0) || bounds.size.height <= px(0.0) {
641                    return;
642                }
643                let at = point(
644                    f32::from(event.position.x - bounds.origin.x),
645                    f32::from(event.position.y - bounds.origin.y),
646                );
647                wheel_report(
648                    &NodeGraphEvent::ViewportChanged(zoom_at(viewport, at, next)),
649                    window,
650                    cx,
651                );
652            });
653        }
654
655        // A canvas that is not ready draws its reason and nothing else. Steps
656        // left underneath a failure would read as a run that is still going.
657        let message = match &self.state {
658            GraphState::Loading => Some((
659                theme.colors.text_muted,
660                cx.strings().text(StringKey::Loading),
661            )),
662            GraphState::Refused(reason) => Some((theme.colors.warning, reason.clone())),
663            GraphState::Failed(reason) => Some((theme.colors.danger, reason.clone())),
664            GraphState::Ready => None,
665        };
666        if let Some((color, text)) = message {
667            return frame
668                .child(
669                    div()
670                        .absolute()
671                        .inset_0()
672                        .flex()
673                        .items_center()
674                        .justify_center()
675                        .p_token(&theme, Space::Lg)
676                        .type_scale(&theme, TypeScale::Label)
677                        .text_color(color)
678                        .child(text),
679                )
680                .semantic_in(
681                    cx,
682                    spec.value(viewport_value(
683                        match self.state {
684                            GraphState::Loading => "loading",
685                            GraphState::Refused(_) => "refused",
686                            _ => "failed",
687                        },
688                        viewport,
689                    )),
690                )
691                .into_any_element();
692        }
693
694        if self.nodes.is_empty() {
695            let empty = self.empty.map(|empty| {
696                div()
697                    .absolute()
698                    .inset_0()
699                    .flex()
700                    .items_center()
701                    .justify_center()
702                    .child(empty)
703            });
704            return frame
705                .children(empty)
706                .semantic_in(cx, spec.value(viewport_value("empty", viewport)))
707                .into_any_element();
708        }
709
710        let node_measurements: HashMap<SharedString, Rc<Cell<Bounds<Pixels>>>> = self
711            .nodes
712            .iter()
713            .map(|placed| {
714                let id = placed.node.ident().semantic_id();
715                let measurement_id = composite_id("node-measure", &[id.as_ref()]);
716                (id, measure::cell(&measurement_id, cx))
717            })
718            .collect();
719        let measured_heights: HashMap<SharedString, f32> = node_measurements
720            .iter()
721            .filter_map(|(id, measured)| {
722                let height = f32::from(measured.get().size.height);
723                (height > 0.0 && height.is_finite()).then(|| (id.clone(), height))
724            })
725            .collect();
726        let geometry = self.geometry_with_heights(&theme, &measured_heights);
727        let routes = self.routable_geometry(&geometry);
728        let preview = {
729            let state = gesture.borrow();
730            match (&state.gesture, state.pointer) {
731                (Some(Gesture::Connect { from }), Some(pointer)) => {
732                    let source = geometry
733                        .iter()
734                        .find(|node| node.id == from.node)
735                        .and_then(|node| node.ports.iter().find(|port| port.id == from.port));
736                    source.map(|source| {
737                        let bounds = measured.get();
738                        let pointer = point(
739                            f32::from(pointer.x - bounds.origin.x),
740                            f32::from(pointer.y - bounds.origin.y),
741                        );
742                        let world = screen_to_world(pointer, viewport);
743                        ConnectionPreview {
744                            route: route_preview(source.anchor, world),
745                            target: connection_target(
746                                &geometry,
747                                from,
748                                pointer,
749                                viewport,
750                                (14.0 * viewport.zoom).max(10.0),
751                            ),
752                        }
753                    })
754                }
755                _ => None,
756            }
757        };
758        let stroke = theme.borders.hairline;
759        let grid_color = theme.colors.hairline;
760        let draw_grid = self.grid;
761        let edge_theme = theme.clone();
762        let painted_routes = routes.clone();
763        let painted_preview = preview.clone();
764
765        // Edges and the grid are one painted layer beneath the nodes, so a
766        // connection never intercepts a click meant for a card and adding one
767        // moves nothing.
768        let beneath = canvas(
769            |_, _, _| {},
770            move |bounds, _, window, _| {
771                if draw_grid {
772                    paint_grid(
773                        window,
774                        bounds,
775                        viewport.offset.x,
776                        viewport.offset.y,
777                        GRID_STEP * viewport.zoom,
778                        grid_color,
779                    );
780                }
781                for routed in painted_routes {
782                    let transform =
783                        RouteTransform::new(bounds.origin, viewport.offset, viewport.zoom);
784                    paint_route(
785                        window,
786                        &edge_theme,
787                        &routed.edge,
788                        &routed.route,
789                        transform,
790                        stroke,
791                        animation_phase,
792                    );
793                }
794                if let Some(preview) = painted_preview {
795                    let color = match preview.target {
796                        Some((_, true)) => edge_theme.colors.success,
797                        Some((_, false)) => edge_theme.colors.danger,
798                        None => edge_theme.colors.accent,
799                    };
800                    paint_route_stroke(
801                        window,
802                        &preview.route,
803                        RouteTransform::new(bounds.origin, viewport.offset, viewport.zoom),
804                        stroke * 1.5,
805                        color.opacity(0.88),
806                        None,
807                    );
808                }
809            },
810        )
811        .absolute()
812        .inset_0();
813
814        let edge_labels: Vec<AnyElement> = routes
815            .iter()
816            .filter_map(|routed| {
817                let label = routed.edge.edge_label()?.clone();
818                let at = world_to_screen(routed.route.midpoint(), viewport);
819                let from = geometry
820                    .iter()
821                    .find(|node| &node.id == routed.edge.from())?;
822                let to = geometry.iter().find(|node| &node.id == routed.edge.to())?;
823                let center = world_to_screen(
824                    point(
825                        (from.bounds.center().x + to.bounds.center().x) * 0.5,
826                        (from.bounds.center().y + to.bounds.center().y) * 0.5,
827                    ),
828                    viewport,
829                );
830                let left_edge = world_to_screen(
831                    point(
832                        from.bounds.left().max(to.bounds.left()),
833                        from.bounds.top().max(to.bounds.top()),
834                    ),
835                    viewport,
836                );
837                let gap = 6.0 * viewport.zoom;
838                let label = div()
839                    .absolute()
840                    .whitespace_nowrap()
841                    .px(px(theme.spacing.xs * viewport.zoom))
842                    .rounded_sm()
843                    .bg(theme.colors.canvas)
844                    .text_size(px(theme.typography.caption.size * viewport.zoom))
845                    .text_color(theme.colors.text_muted)
846                    .child(label);
847                let label = match routed.route.midpoint_axis() {
848                    Axis::Vertical if at.x <= left_edge.x => label.right(px(gap)).top(px(-theme
849                        .typography
850                        .caption
851                        .line_height
852                        * viewport.zoom
853                        * 0.5)),
854                    Axis::Vertical => label.left(px(gap)).top(px(-theme
855                        .typography
856                        .caption
857                        .line_height
858                        * viewport.zoom
859                        * 0.5)),
860                    Axis::Horizontal if at.y <= center.y && at.x >= center.x => {
861                        label.right(px(gap)).bottom(px(gap))
862                    }
863                    Axis::Horizontal if at.y <= center.y => label.left(px(gap)).bottom(px(gap)),
864                    Axis::Horizontal if at.x >= center.x => label.right(px(gap)).top(px(gap)),
865                    Axis::Horizontal => label.left(px(gap)).top(px(gap)),
866                };
867                Some(
868                    div()
869                        .absolute()
870                        .left(px(at.x))
871                        .top(px(at.y))
872                        .w(px(0.0))
873                        .h(px(0.0))
874                        .child(label)
875                        .into_any_element(),
876                )
877            })
878            .collect();
879
880        let mut ports = Vec::new();
881        for node in &geometry {
882            let Some(placed) = self
883                .nodes
884                .iter()
885                .find(|placed| placed.node.ident().semantic_id() == node.id)
886            else {
887                continue;
888            };
889            for port_geometry in &node.ports {
890                let Some(port) = placed
891                    .node
892                    .graph_ports()
893                    .iter()
894                    .find(|port| port.id() == &port_geometry.id)
895                else {
896                    continue;
897                };
898                let at = world_to_screen(port_geometry.anchor.point, viewport);
899                let endpoint = GraphEndpoint::new(node.id.clone(), port.id().clone());
900                let semantic_id =
901                    composite_id("graph-port", &[node.id.as_ref(), port.id().as_ref()]);
902                let spec = NodeSpec::new(semantic_id.clone(), Role::Button)
903                    .text(port.label().clone())
904                    .value(port.direction().name());
905                let diameter = 12.0 * viewport.zoom;
906                let label_gap = 4.0 * viewport.zoom;
907                let target = preview
908                    .as_ref()
909                    .and_then(|preview| preview.target.as_ref())
910                    .filter(|(target, _)| target == &endpoint)
911                    .map(|(_, valid)| *valid);
912                let color = match target {
913                    Some(true) => theme.colors.success,
914                    Some(false) => theme.colors.danger,
915                    None if port.direction() == PortDirection::Output => theme.colors.accent,
916                    None => theme.colors.hairline_strong,
917                };
918                let label = div()
919                    .absolute()
920                    .whitespace_nowrap()
921                    .px(px(2.0 * viewport.zoom))
922                    .rounded_sm()
923                    .bg(theme.colors.canvas)
924                    .text_size(px(theme.typography.caption.size * viewport.zoom))
925                    .text_color(theme.colors.text_muted)
926                    .child(port.label().clone());
927                let label = match (port.port_side(), port.direction()) {
928                    (PortSide::Left, PortDirection::Input) => label
929                        .right(px(diameter + label_gap))
930                        .top(px(diameter + label_gap)),
931                    (PortSide::Left, PortDirection::Output) => label
932                        .right(px(diameter + label_gap))
933                        .bottom(px(diameter + label_gap)),
934                    (PortSide::Right, PortDirection::Input) => label
935                        .left(px(diameter + label_gap))
936                        .top(px(diameter + label_gap)),
937                    (PortSide::Right, PortDirection::Output) => label
938                        .left(px(diameter + label_gap))
939                        .bottom(px(diameter + label_gap)),
940                    (PortSide::Top, _) => label
941                        .left(px(diameter + label_gap))
942                        .bottom(px(diameter / 2.0)),
943                    (PortSide::Bottom, _) => {
944                        label.left(px(diameter + label_gap)).top(px(diameter / 2.0))
945                    }
946                };
947                let mut view = div()
948                    .id(semantic_id)
949                    .absolute()
950                    .left(px(at.x - diameter / 2.0))
951                    .top(px(at.y - diameter / 2.0))
952                    .w(px(diameter))
953                    .h(px(diameter))
954                    .rounded_full()
955                    .border_1()
956                    .border_color(theme.colors.canvas)
957                    .bg(color)
958                    .cursor_pointer()
959                    .child(label);
960                if port.direction() == PortDirection::Output {
961                    let down = Rc::clone(&gesture);
962                    let from = endpoint.clone();
963                    view = view.on_mouse_down_with_pointer_capture(
964                        MouseButton::Left,
965                        move |event, window, cx| {
966                            let mut state = down.borrow_mut();
967                            state.pointer = Some(event.position);
968                            state.gesture = Some(Gesture::Connect { from: from.clone() });
969                            window.refresh();
970                            cx.stop_propagation();
971                        },
972                    );
973                    let moving = Rc::clone(&gesture);
974                    view = view.on_mouse_move(move |event, window, cx| {
975                        if event.pressed_button != Some(MouseButton::Left) {
976                            moving.borrow_mut().gesture = None;
977                            return;
978                        }
979                        moving.borrow_mut().pointer = Some(event.position);
980                        window.refresh();
981                        cx.stop_propagation();
982                    });
983                    let up = Rc::clone(&gesture);
984                    let candidates = geometry.clone();
985                    let report = self.on_event.clone();
986                    let target_bounds = Rc::clone(&measured);
987                    view = view.on_mouse_up(MouseButton::Left, move |event, window, cx| {
988                        let mut state = up.borrow_mut();
989                        if let Some(Gesture::Connect { from }) = state.gesture.take() {
990                            let bounds = target_bounds.get();
991                            let pointer = point(
992                                f32::from(event.position.x - bounds.origin.x),
993                                f32::from(event.position.y - bounds.origin.y),
994                            );
995                            if let (Some(report), Some((to, true))) = (
996                                &report,
997                                connection_target(
998                                    &candidates,
999                                    &from,
1000                                    pointer,
1001                                    viewport,
1002                                    (14.0 * viewport.zoom).max(10.0),
1003                                ),
1004                            ) {
1005                                report(
1006                                    &NodeGraphEvent::ConnectionRequested { from, to },
1007                                    window,
1008                                    cx,
1009                                );
1010                            }
1011                        }
1012                        state.pointer = None;
1013                        window.refresh();
1014                        cx.stop_propagation();
1015                    });
1016                } else {
1017                    view = view.on_mouse_down(MouseButton::Left, |_, _, cx| {
1018                        // Input ports are connection targets, not blank canvas.
1019                        cx.stop_propagation();
1020                    });
1021                }
1022                ports.push(view.semantic_in(cx, spec).into_any_element());
1023            }
1024        }
1025
1026        let mut shockwaves = Vec::new();
1027        for placed in self
1028            .nodes
1029            .iter()
1030            .filter(|placed| placed.node.node_state() == NodeState::Running)
1031            .filter(|placed| {
1032                geometry
1033                    .iter()
1034                    .any(|node| node.id == placed.node.ident().semantic_id())
1035            })
1036        {
1037            let Some(bounds) = geometry
1038                .iter()
1039                .find(|node| node.id == placed.node.ident().semantic_id())
1040                .map(|node| node.bounds)
1041            else {
1042                continue;
1043            };
1044            let screen = world_to_screen(bounds.origin, viewport);
1045            let width = bounds.size.width * viewport.zoom;
1046            let height = bounds.size.height * viewport.zoom;
1047            let phases: Vec<f32> = animation_phase
1048                .map(|phase| vec![phase, (phase + 0.5).rem_euclid(1.0)])
1049                .unwrap_or_else(|| vec![0.35]);
1050            for phase in phases {
1051                let reach = (8.0 + phase * 30.0) * viewport.zoom;
1052                let opacity = if animation_phase.is_some() {
1053                    0.36 * (1.0 - phase).powf(1.7)
1054                } else {
1055                    0.18
1056                };
1057                shockwaves.push(
1058                    div()
1059                        .absolute()
1060                        .left(px(screen.x - reach))
1061                        .top(px(screen.y - reach))
1062                        .w(px(width + reach * 2.0))
1063                        .h(px(height + reach * 2.0))
1064                        .rounded(px(theme.radius(Radius::Card) * viewport.zoom + reach))
1065                        .border_1()
1066                        .border_color(theme.colors.accent.opacity(opacity))
1067                        .into_any_element(),
1068                );
1069            }
1070        }
1071
1072        let report = self.on_event.clone();
1073        let cards: Vec<AnyElement> = self
1074            .nodes
1075            .into_iter()
1076            .filter(|placed| {
1077                geometry
1078                    .iter()
1079                    .any(|node| node.id == placed.node.ident().semantic_id())
1080            })
1081            .map(|placed| {
1082                let screen = world_to_screen(point(placed.x, placed.y), viewport);
1083                let height = placed.height;
1084                let id = placed.node.ident().semantic_id();
1085                let measurement = node_measurements.get(&id).cloned();
1086                let click = placed.node.click_handler();
1087                let pointer_click = report.is_none();
1088                let mut card = div()
1089                    .absolute()
1090                    .left(px(screen.x))
1091                    .top(px(screen.y))
1092                    .w(px(placed.node.node_width() * viewport.zoom))
1093                    .child(
1094                        placed
1095                            .node
1096                            .display_at(viewport.zoom, height)
1097                            .pointer_click(pointer_click),
1098                    );
1099                if let Some(measurement) = measurement {
1100                    card = card.on_children_prepainted(move |bounds, window, _| {
1101                        let Some(first) = bounds.first() else {
1102                            return;
1103                        };
1104                        let logical = Bounds::new(
1105                            point(px(0.0), px(0.0)),
1106                            size(
1107                                px(f32::from(first.size.width) / viewport.zoom),
1108                                px(f32::from(first.size.height) / viewport.zoom),
1109                            ),
1110                        );
1111                        measure::record(&measurement, logical, window);
1112                    });
1113                }
1114                let mut card = card.id(composite_id("node-drag", &[id.as_ref()]));
1115                if let Some(report) = report.as_ref().cloned() {
1116                    let down = Rc::clone(&gesture);
1117                    let start = point(placed.x, placed.y);
1118                    let drag_id = id.clone();
1119                    card = card.on_mouse_down_with_pointer_capture(
1120                        MouseButton::Left,
1121                        move |event, _, cx| {
1122                            down.borrow_mut().gesture = Some(Gesture::Node {
1123                                at: event.position,
1124                                id: drag_id.clone(),
1125                                position: start,
1126                                moved: false,
1127                            });
1128                            cx.stop_propagation();
1129                        },
1130                    );
1131                    let moving = Rc::clone(&gesture);
1132                    card = card.on_mouse_move(move |event, window, cx| {
1133                        let mut state = moving.borrow_mut();
1134                        if event.pressed_button != Some(MouseButton::Left) {
1135                            state.gesture = None;
1136                            return;
1137                        }
1138                        let (id, position) = match state.gesture.as_mut() {
1139                            Some(Gesture::Node {
1140                                at,
1141                                id,
1142                                position,
1143                                moved,
1144                            }) => {
1145                                let screen_delta = point(
1146                                    f32::from(event.position.x - at.x),
1147                                    f32::from(event.position.y - at.y),
1148                                );
1149                                *moved |= screen_delta.x.abs().max(screen_delta.y.abs()) >= 4.0;
1150                                (
1151                                    id.clone(),
1152                                    point(
1153                                        position.x + screen_delta.x / viewport.zoom,
1154                                        position.y + screen_delta.y / viewport.zoom,
1155                                    ),
1156                                )
1157                            }
1158                            _ => return,
1159                        };
1160                        drop(state);
1161                        report(&NodeGraphEvent::NodeMoved { id, position }, window, cx);
1162                        cx.stop_propagation();
1163                    });
1164                    let up = Rc::clone(&gesture);
1165                    card = card.on_mouse_up(MouseButton::Left, move |_, window, cx| {
1166                        let gesture = up.borrow_mut().gesture.take();
1167                        if matches!(gesture, Some(Gesture::Node { moved: false, .. }))
1168                            && let Some(click) = &click
1169                        {
1170                            click(window, cx);
1171                        }
1172                        cx.stop_propagation();
1173                    });
1174                }
1175                card.into_any_element()
1176            })
1177            .collect();
1178
1179        frame
1180            .child(beneath)
1181            .children(shockwaves)
1182            .children(edge_labels)
1183            .children(cards)
1184            .children(ports)
1185            .semantic_in(cx, spec.value(viewport_value("ready", viewport)))
1186            .into_any_element()
1187    }
1188}
1189
1190/// Paints the dot grid the canvas sits on.
1191///
1192/// The grid is anchored to the pan offset rather than to the viewport, so it
1193/// travels with the graph and reports that the canvas moved. A grid pinned to
1194/// the viewport would sit still under a graph that was moving, which reads as
1195/// the graph having stayed where it was.
1196fn paint_grid(
1197    window: &mut Window,
1198    bounds: Bounds<Pixels>,
1199    pan_x: f32,
1200    pan_y: f32,
1201    step: f32,
1202    color: gpui::Hsla,
1203) {
1204    let first = |pan: f32| pan.rem_euclid(step);
1205    let mut y = first(pan_y);
1206    while y < f32::from(bounds.size.height) {
1207        let mut x = first(pan_x);
1208        while x < f32::from(bounds.size.width) {
1209            window.paint_quad(gpui::fill(
1210                Bounds::new(
1211                    point(bounds.origin.x + px(x), bounds.origin.y + px(y)),
1212                    size(px(GRID_DOT), px(GRID_DOT)),
1213                ),
1214                color,
1215            ));
1216            x += step;
1217        }
1218        y += step;
1219    }
1220}
1221
1222#[cfg(test)]
1223mod tests {
1224    use super::*;
1225    use crate::canvas::edge::EdgeKind;
1226    use crate::canvas::node::NodeState;
1227
1228    fn graph() -> NodeGraph {
1229        NodeGraph::new("run")
1230            .node(GraphNode::new("run.plan", "Plan"), 0.0, 0.0)
1231            .node(
1232                GraphNode::new("run.apply", "Apply").state(NodeState::Failed),
1233                300.0,
1234                0.0,
1235            )
1236    }
1237
1238    #[test]
1239    fn a_node_box_follows_the_width_the_node_carries() {
1240        let placed = Placed::new(GraphNode::new("a", "A").width(150.0), 10.0, 20.0);
1241        let bounds = placed.bounds(&gpui_kit_theme::Theme::studio_dark(), None);
1242        assert_eq!(bounds.origin.x, 10.0);
1243        assert_eq!(bounds.size.width, 150.0);
1244        assert!(bounds.size.height > 0.0);
1245    }
1246
1247    #[test]
1248    fn a_declared_height_positions_the_edges() {
1249        let placed = Placed::new(GraphNode::new("a", "A"), 0.0, 0.0).height(200.0);
1250        assert_eq!(
1251            placed
1252                .bounds(&gpui_kit_theme::Theme::studio_dark(), None)
1253                .size
1254                .height,
1255            200.0
1256        );
1257    }
1258
1259    #[test]
1260    fn an_invalid_declared_height_keeps_rendering_and_routing_automatic() {
1261        let theme = gpui_kit_theme::Theme::studio_dark();
1262        let automatic = Placed::new(GraphNode::new("a", "A"), 0.0, 0.0)
1263            .bounds(&theme, None)
1264            .size
1265            .height;
1266        for invalid in [0.0, -1.0, f32::NAN, f32::INFINITY] {
1267            assert_eq!(
1268                Placed::new(GraphNode::new("a", "A"), 0.0, 0.0)
1269                    .height(invalid)
1270                    .bounds(&theme, None)
1271                    .size
1272                    .height,
1273                automatic
1274            );
1275        }
1276    }
1277
1278    #[test]
1279    fn edges_route_between_the_nodes_that_are_present() {
1280        let routes = graph()
1281            .edge(GraphEdge::new("run.plan", "run.apply"))
1282            .routable(&gpui_kit_theme::Theme::studio_dark());
1283        assert_eq!(routes.len(), 1);
1284        assert_eq!(routes[0].edge.kind(), EdgeKind::Flow);
1285    }
1286
1287    /// A line to a node that is not on the canvas has no correct place to go,
1288    /// so it goes nowhere rather than somewhere wrong.
1289    #[test]
1290    fn an_edge_to_a_missing_node_is_dropped_rather_than_guessed() {
1291        let routes = graph()
1292            .edge(GraphEdge::new("run.plan", "run.publish"))
1293            .edge(GraphEdge::new("run.nowhere", "run.apply"))
1294            .routable(&gpui_kit_theme::Theme::studio_dark());
1295        assert!(routes.is_empty());
1296    }
1297
1298    #[test]
1299    fn a_feedback_edge_keeps_its_kind_through_routing() {
1300        let routes = graph()
1301            .edge(GraphEdge::new("run.apply", "run.plan").feedback())
1302            .routable(&gpui_kit_theme::Theme::studio_dark());
1303        assert_eq!(routes[0].edge.kind(), EdgeKind::Feedback);
1304    }
1305
1306    #[test]
1307    fn explicit_ports_are_strict_and_duplicate_identities_are_rejected() {
1308        let theme = gpui_kit_theme::Theme::studio_dark();
1309        let valid = NodeGraph::new("run")
1310            .node(
1311                GraphNode::new("a", "A").port(GraphPort::output("out", "Out")),
1312                0.0,
1313                0.0,
1314            )
1315            .node(
1316                GraphNode::new("b", "B").port(GraphPort::input("in", "In")),
1317                300.0,
1318                0.0,
1319            )
1320            .edge(GraphEdge::new("a", "b").ports("out", "in"));
1321        assert_eq!(valid.routable(&theme).len(), 1);
1322
1323        let invalid_direction = NodeGraph::new("run")
1324            .node(
1325                GraphNode::new("a", "A").port(GraphPort::input("in", "In")),
1326                0.0,
1327                0.0,
1328            )
1329            .node(
1330                GraphNode::new("b", "B").port(GraphPort::output("out", "Out")),
1331                300.0,
1332                0.0,
1333            )
1334            .edge(GraphEdge::new("a", "b").ports("in", "out"));
1335        assert!(invalid_direction.routable(&theme).is_empty());
1336
1337        let duplicate = NodeGraph::new("run")
1338            .node(GraphNode::new("a", "A"), 0.0, 0.0)
1339            .node(GraphNode::new("a", "A again"), 10.0, 0.0)
1340            .node(GraphNode::new("b", "B"), 300.0, 0.0)
1341            .edge(GraphEdge::new("a", "b"));
1342        assert!(duplicate.routable(&theme).is_empty());
1343    }
1344
1345    #[test]
1346    fn pointer_centered_zoom_preserves_the_world_point() {
1347        let viewport = GraphViewport::new(point(30.0, -20.0), 1.25);
1348        let pointer = point(240.0, 130.0);
1349        let world = screen_to_world(pointer, viewport);
1350        let zoomed = zoom_at(viewport, pointer, 1.8);
1351        assert_eq!(world_to_screen(world, zoomed), pointer);
1352    }
1353
1354    #[test]
1355    fn a_new_graph_is_ready_and_carries_its_grid() {
1356        let graph = NodeGraph::new("run");
1357        assert_eq!(graph.state, GraphState::Ready);
1358        assert!(graph.grid);
1359        assert_eq!(graph.viewport, GraphViewport::default());
1360    }
1361
1362    /// The four not-ready states are separate answers and none of them may
1363    /// collapse into another.
1364    #[test]
1365    fn the_canvas_states_stay_distinct() {
1366        assert_ne!(
1367            GraphState::Refused("no".into()),
1368            GraphState::Failed("no".into())
1369        );
1370        assert_ne!(GraphState::Loading, GraphState::Ready);
1371    }
1372}