1use 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
32const GRID_STEP: f32 = 24.0;
34const GRID_DOT: f32 = 1.0;
35
36#[derive(Debug, Clone, Copy, PartialEq)]
38pub struct GraphViewport {
39 pub offset: Point<f32>,
40 pub zoom: f32,
41}
42
43impl GraphViewport {
44 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#[derive(Debug, Clone, PartialEq)]
61pub enum NodeGraphEvent {
62 ViewportChanged(GraphViewport),
64 NodeMoved {
66 id: SharedString,
67 position: Point<f32>,
68 },
69 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
164pub struct Placed {
166 node: GraphNode,
167 x: f32,
168 y: f32,
169 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 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
220pub enum GraphState {
221 #[default]
222 Ready,
223 Loading,
224 Refused(SharedString),
226 Failed(SharedString),
228}
229
230#[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 pub fn empty(mut self, empty: EmptyState) -> Self {
300 self.empty = Some(empty);
301 self
302 }
303
304 pub fn grid(mut self, grid: bool) -> Self {
307 self.grid = grid;
308 self
309 }
310
311 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 #[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 #[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 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 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 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
1190fn 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 #[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 #[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}