1use std::rc::Rc;
10
11use gpui::{
12 AnyElement, App, FontWeight, Hsla, InteractiveElement, IntoElement, ParentElement, RenderOnce,
13 SharedString, Styled, Window, div, prelude::FluentBuilder, px,
14};
15use gpui_kit_assets::{Icon, icon};
16use gpui_kit_semantics::{NodeSpec, Role, Semantic};
17use gpui_kit_theme::{ActiveTheme, Elevation, Radius, Surface};
18
19use crate::foundation::{FocusRing, Ident, Pressable, Selectable, StyledExt};
20use crate::motion;
21
22use super::edge::PortSide;
23
24pub const NODE_WIDTH: f32 = 216.0;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum PortDirection {
33 #[default]
34 Input,
35 Output,
36}
37
38impl PortDirection {
39 pub fn name(self) -> &'static str {
40 match self {
41 Self::Input => "input",
42 Self::Output => "output",
43 }
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct GraphPort {
53 id: SharedString,
54 label: SharedString,
55 direction: PortDirection,
56 side: PortSide,
57}
58
59impl GraphPort {
60 pub fn input(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
61 Self {
62 id: id.into(),
63 label: label.into(),
64 direction: PortDirection::Input,
65 side: PortSide::Left,
66 }
67 }
68
69 pub fn output(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
70 Self {
71 id: id.into(),
72 label: label.into(),
73 direction: PortDirection::Output,
74 side: PortSide::Right,
75 }
76 }
77
78 pub fn side(mut self, side: PortSide) -> Self {
79 self.side = side;
80 self
81 }
82
83 pub fn id(&self) -> &SharedString {
84 &self.id
85 }
86
87 pub fn label(&self) -> &SharedString {
88 &self.label
89 }
90
91 pub fn direction(&self) -> PortDirection {
92 self.direction
93 }
94
95 pub fn port_side(&self) -> PortSide {
100 self.side
101 }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq)]
106struct NodeMetrics {
107 width: f32,
108 height: Option<f32>,
109 padding: f32,
110 gap: f32,
111 figure_gap: f32,
112 label_size: f32,
113 label_height: f32,
114 caption_size: f32,
115 caption_height: f32,
116 icon_size: f32,
117 radius: f32,
118}
119
120impl NodeMetrics {
121 fn new(theme: &gpui_kit_theme::Theme, width: f32, zoom: f32, height: Option<f32>) -> Self {
122 let scale = if zoom.is_finite() && zoom > 0.0 {
123 zoom
124 } else {
125 1.0
126 };
127 let scaled = |value: f32| value * scale;
128 Self {
129 width: scaled(width),
130 height: height.map(scaled),
131 padding: scaled(theme.spacing.sm),
132 gap: scaled(theme.spacing.xs),
133 figure_gap: scaled(theme.spacing.sm),
134 label_size: scaled(theme.typography.label.size),
135 label_height: scaled(theme.typography.label.line_height),
136 caption_size: scaled(theme.typography.caption.size),
137 caption_height: scaled(theme.typography.caption.line_height),
138 icon_size: scaled(theme.control.sm.icon_size),
139 radius: scaled(theme.radius(Radius::Card)),
140 }
141 }
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
150pub enum NodeState {
151 #[default]
153 Pending,
154 Running,
155 Succeeded,
156 Failed,
157 Refused,
160}
161
162impl NodeState {
163 pub fn color(self, theme: &gpui_kit_theme::Theme) -> Hsla {
164 match self {
165 Self::Pending => theme.colors.text_faint,
166 Self::Running => theme.colors.accent,
167 Self::Succeeded => theme.colors.success,
168 Self::Failed => theme.colors.danger,
169 Self::Refused => theme.colors.warning,
170 }
171 }
172
173 fn glyph(self) -> Option<Icon> {
174 match self {
175 Self::Pending => None,
176 Self::Running => Some(Icon::Refresh),
177 Self::Succeeded => Some(Icon::Check),
178 Self::Failed => Some(Icon::Close),
179 Self::Refused => Some(Icon::Danger),
180 }
181 }
182
183 fn value(self) -> &'static str {
185 match self {
186 Self::Pending => "pending",
187 Self::Running => "running",
188 Self::Succeeded => "succeeded",
189 Self::Failed => "failed",
190 Self::Refused => "refused",
191 }
192 }
193
194 fn is_notable(self) -> bool {
200 matches!(self, Self::Running | Self::Failed | Self::Refused)
201 }
202}
203
204#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct NodeMetric {
208 pub label: SharedString,
209 pub value: SharedString,
210}
211
212impl NodeMetric {
213 pub fn new(label: impl Into<SharedString>, value: impl Into<SharedString>) -> Self {
214 Self {
215 label: label.into(),
216 value: value.into(),
217 }
218 }
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
227pub struct Diff {
228 pub added: usize,
229 pub removed: usize,
230}
231
232impl Diff {
233 pub fn new(added: usize, removed: usize) -> Self {
234 Self { added, removed }
235 }
236
237 pub fn is_empty(self) -> bool {
238 self.added == 0 && self.removed == 0
239 }
240}
241
242type ClickHandler = Rc<dyn Fn(&mut Window, &mut App)>;
243
244#[derive(IntoElement)]
246pub struct GraphNode {
247 ident: Ident,
248 title: SharedString,
249 action: Option<SharedString>,
251 state: NodeState,
252 metrics: Vec<NodeMetric>,
253 ports: Vec<GraphPort>,
254 diff: Option<Diff>,
255 selected: bool,
256 width: f32,
257 display_zoom: f32,
258 declared_height: Option<f32>,
259 pointer_click: bool,
260 on_click: Option<ClickHandler>,
261}
262
263impl std::fmt::Debug for GraphNode {
264 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265 formatter
266 .debug_struct("GraphNode")
267 .field("ident", &self.ident)
268 .field("title", &self.title)
269 .field("state", &self.state)
270 .field("metrics", &self.metrics.len())
271 .finish_non_exhaustive()
272 }
273}
274
275impl GraphNode {
276 pub fn new(ident: impl Into<Ident>, title: impl Into<SharedString>) -> Self {
277 Self {
278 ident: ident.into(),
279 title: title.into(),
280 action: None,
281 state: NodeState::default(),
282 metrics: Vec::new(),
283 ports: Vec::new(),
284 diff: None,
285 selected: false,
286 width: NODE_WIDTH,
287 display_zoom: 1.0,
288 declared_height: None,
289 pointer_click: true,
290 on_click: None,
291 }
292 }
293
294 pub fn action(mut self, action: impl Into<SharedString>) -> Self {
296 self.action = Some(action.into());
297 self
298 }
299
300 pub fn state(mut self, state: NodeState) -> Self {
301 self.state = state;
302 self
303 }
304
305 pub fn metric(
306 mut self,
307 label: impl Into<SharedString>,
308 value: impl Into<SharedString>,
309 ) -> Self {
310 self.metrics.push(NodeMetric::new(label, value));
311 self
312 }
313
314 pub fn metrics(mut self, metrics: impl IntoIterator<Item = NodeMetric>) -> Self {
315 self.metrics.extend(metrics);
316 self
317 }
318
319 pub fn port(mut self, port: GraphPort) -> Self {
320 self.ports.push(port);
321 self
322 }
323
324 pub fn ports(mut self, ports: impl IntoIterator<Item = GraphPort>) -> Self {
325 self.ports.extend(ports);
326 self
327 }
328
329 pub fn diff(mut self, diff: Diff) -> Self {
333 self.diff = Some(diff);
334 self
335 }
336
337 pub fn width(mut self, width: f32) -> Self {
338 self.width = width;
339 self
340 }
341
342 pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
343 self.on_click = Some(Rc::new(handler));
344 self
345 }
346
347 pub(crate) fn ident(&self) -> &Ident {
348 &self.ident
349 }
350
351 pub(crate) fn node_width(&self) -> f32 {
352 self.width
353 }
354
355 pub(crate) fn node_state(&self) -> NodeState {
356 self.state
357 }
358
359 pub(crate) fn graph_ports(&self) -> &[GraphPort] {
360 &self.ports
361 }
362
363 pub(crate) fn click_handler(&self) -> Option<ClickHandler> {
364 self.on_click.clone()
365 }
366
367 pub(crate) fn display_at(mut self, zoom: f32, declared_height: Option<f32>) -> Self {
370 self.display_zoom = if zoom.is_finite() && zoom > 0.0 {
371 zoom
372 } else {
373 1.0
374 };
375 self.declared_height = declared_height.filter(|height| height.is_finite() && *height > 0.0);
376 self
377 }
378
379 pub(crate) fn pointer_click(mut self, enabled: bool) -> Self {
382 self.pointer_click = enabled;
383 self
384 }
385
386 #[cfg(test)]
387 pub(crate) fn logical_height(&self, theme: &gpui_kit_theme::Theme) -> f32 {
388 self.declared_height
389 .unwrap_or_else(|| self.measured_height(theme))
390 }
391
392 pub(crate) fn measured_height(&self, theme: &gpui_kit_theme::Theme) -> f32 {
401 let mut rows = vec![theme.typography.label.line_height];
402 if self.action.is_some() {
403 rows.push(theme.typography.caption.line_height);
404 }
405 if !self.metrics.is_empty() || self.diff.is_some_and(|diff| !diff.is_empty()) {
406 rows.push(theme.typography.caption.line_height);
407 }
408 let gaps = theme.spacing.xs * (rows.len() - 1) as f32;
409 theme.spacing.sm * 2.0 + rows.iter().sum::<f32>() + gaps
410 }
411}
412
413impl Selectable for GraphNode {
414 fn selected(mut self, selected: bool) -> Self {
415 self.selected = selected;
416 self
417 }
418}
419
420impl RenderOnce for GraphNode {
421 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
422 let theme = cx.theme().clone();
423 let color = self.state.color(&theme);
424 let metrics = NodeMetrics::new(&theme, self.width, self.display_zoom, self.declared_height);
425
426 let mark = self.state.glyph().map(|glyph| {
430 let element = icon(glyph).size(px(metrics.icon_size)).text_color(color);
431 match self.state {
432 NodeState::Running => {
433 motion::spin(element, self.ident.child("mark").element_id(), &theme, cx)
434 }
435 _ => element.into_any_element(),
436 }
437 });
438
439 let header = div()
440 .row()
441 .w_full()
442 .gap(px(metrics.gap))
443 .children(mark)
444 .child(
445 div()
446 .min_w_0()
447 .flex_1()
448 .text_size(px(metrics.label_size))
449 .line_height(px(metrics.label_height))
450 .font_weight(FontWeight(theme.typography.label.weight))
451 .text_color(theme.colors.text)
452 .truncate()
453 .child(self.title.clone()),
454 );
455
456 let action = self.action.clone().map(|action| {
457 div()
458 .w_full()
459 .text_size(px(metrics.caption_size))
460 .line_height(px(metrics.caption_height))
461 .font_weight(FontWeight(theme.typography.caption.weight))
462 .text_color(theme.colors.text_muted)
463 .truncate()
464 .child(action)
465 });
466
467 let mut figures: Vec<AnyElement> = self
468 .metrics
469 .iter()
470 .map(|metric| {
471 div()
472 .row()
473 .gap(px(metrics.gap / 2.0))
474 .child(
475 div()
476 .text_color(theme.colors.text_faint)
477 .child(metric.label.clone()),
478 )
479 .child(
480 div()
481 .text_color(theme.colors.text_muted)
482 .child(metric.value.clone()),
483 )
484 .into_any_element()
485 })
486 .collect();
487
488 if let Some(diff) = self.diff.filter(|diff| !diff.is_empty()) {
489 figures.push(
490 div()
491 .row()
492 .gap(px(metrics.gap / 2.0))
493 .child(
494 div()
495 .text_color(theme.colors.success)
496 .child(format!("+{}", diff.added)),
497 )
498 .child(
499 div()
500 .text_color(theme.colors.danger)
501 .child(format!("-{}", diff.removed)),
502 )
503 .into_any_element(),
504 );
505 }
506
507 let strip = (!figures.is_empty()).then(|| {
508 div()
509 .row()
510 .w_full()
511 .flex_wrap()
512 .gap(px(metrics.figure_gap))
513 .text_size(px(metrics.caption_size))
514 .line_height(px(metrics.caption_height))
515 .font_weight(FontWeight(theme.typography.caption.weight))
516 .children(figures)
517 });
518
519 let card = div()
520 .w(px(metrics.width))
521 .when_some(metrics.height, |element, height| element.h(px(height)))
522 .column()
523 .gap(px(metrics.gap))
524 .p(px(metrics.padding))
525 .rounded(px(metrics.radius))
526 .frame(&theme, Surface::Raised, Elevation::Raised)
527 .when(self.state.is_notable(), |element| {
531 element.glow(&theme, color)
532 })
533 .when(self.selected, |element| {
534 element.shadow(theme.selected_ring())
535 })
536 .child(header)
537 .children(action)
538 .children(strip);
539
540 let role = if self.on_click.is_some() {
544 Role::Button
545 } else {
546 Role::Group
547 };
548 let spec = NodeSpec::new(self.ident.semantic_id(), role)
549 .text(self.title.clone())
550 .value(self.state.value())
551 .selected(self.selected)
552 .busy(self.state == NodeState::Running)
553 .invalid(self.state == NodeState::Failed);
554
555 let Some(handler) = self.on_click else {
556 return card.semantic_in(cx, spec).into_any_element();
557 };
558
559 let mut card = card
560 .id(self.ident.element_id())
561 .cursor_pointer()
562 .tab_index(0)
563 .focus_ring(&theme)
564 .pressable(cx);
565 if self.pointer_click {
566 let click = Rc::clone(&handler);
567 card.interactivity()
568 .on_click(move |_, window, cx| click(window, cx));
569 }
570 card.interactivity().on_key_down(move |event, window, cx| {
571 if matches!(event.keystroke.key.as_str(), "enter" | "space") {
572 handler(window, cx);
573 cx.stop_propagation();
574 }
575 });
576 card.semantic_in(cx, spec).into_any_element()
577 }
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583
584 fn theme() -> gpui_kit_theme::Theme {
585 gpui_kit_theme::Theme::studio_dark()
586 }
587
588 #[test]
591 fn every_state_is_distinguishable_from_every_other() {
592 let theme = theme();
593 let states = [
594 NodeState::Pending,
595 NodeState::Running,
596 NodeState::Succeeded,
597 NodeState::Failed,
598 NodeState::Refused,
599 ];
600 for (index, state) in states.iter().enumerate() {
601 for other in &states[index + 1..] {
602 assert_ne!(
603 state.color(&theme),
604 other.color(&theme),
605 "{state:?} {other:?}"
606 );
607 assert_ne!(state.value(), other.value(), "{state:?} {other:?}");
608 }
609 }
610 }
611
612 #[test]
615 fn a_refusal_is_not_a_failure() {
616 let theme = theme();
617 assert_ne!(
618 NodeState::Refused.color(&theme),
619 NodeState::Failed.color(&theme)
620 );
621 assert_eq!(NodeState::Refused.value(), "refused");
622 }
623
624 #[test]
625 fn only_the_states_worth_scanning_for_reach_past_the_card() {
626 assert!(NodeState::Running.is_notable());
627 assert!(NodeState::Failed.is_notable());
628 assert!(NodeState::Refused.is_notable());
629 assert!(!NodeState::Pending.is_notable());
630 assert!(!NodeState::Succeeded.is_notable());
631 }
632
633 #[test]
634 fn a_pending_step_carries_no_glyph_and_the_rest_do() {
635 assert!(NodeState::Pending.glyph().is_none());
636 for state in [
637 NodeState::Running,
638 NodeState::Succeeded,
639 NodeState::Failed,
640 NodeState::Refused,
641 ] {
642 assert!(state.glyph().is_some(), "{state:?}");
643 }
644 }
645
646 #[test]
647 fn an_empty_diff_reports_itself_as_empty() {
648 assert!(Diff::default().is_empty());
649 assert!(!Diff::new(0, 3).is_empty());
650 assert!(!Diff::new(3, 0).is_empty());
651 }
652
653 #[test]
654 fn a_node_starts_pending_and_at_the_shared_width() {
655 let node = GraphNode::new("run.plan", "Plan");
656 assert_eq!(node.state, NodeState::Pending);
657 assert_eq!(node.node_width(), NODE_WIDTH);
658 assert_eq!(node.ident().as_str(), "run.plan");
659 }
660
661 #[test]
662 fn ports_have_directional_defaults_and_allow_side_override() {
663 let input = GraphPort::input("source", "Source");
664 assert_eq!(input.direction(), PortDirection::Input);
665 assert_eq!(input.direction().name(), "input");
666 assert_eq!(input.port_side(), PortSide::Left);
667
668 let output = GraphPort::output("result", "Result").side(PortSide::Bottom);
669 assert_eq!(output.direction(), PortDirection::Output);
670 assert_eq!(output.direction().name(), "output");
671 assert_eq!(output.port_side(), PortSide::Bottom);
672 }
673
674 #[test]
675 fn node_port_builders_preserve_caller_identity_and_labels() {
676 let node = GraphNode::new("transform", "Transform")
677 .port(GraphPort::input("in", "Rows"))
678 .ports([GraphPort::output("out", "Records")]);
679 assert_eq!(node.graph_ports().len(), 2);
680 assert_eq!(node.graph_ports()[0].id().as_ref(), "in");
681 assert_eq!(node.graph_ports()[0].label().as_ref(), "Rows");
682 assert_eq!(node.graph_ports()[1].id().as_ref(), "out");
683 }
684
685 #[test]
686 fn declared_height_is_the_logical_geometry_contract() {
687 let theme = theme();
688 let node = GraphNode::new("step", "Step").display_at(2.0, Some(140.0));
689 assert_eq!(node.logical_height(&theme), 140.0);
690 let metrics = NodeMetrics::new(
691 &theme,
692 node.node_width(),
693 node.display_zoom,
694 node.declared_height,
695 );
696 assert_eq!(metrics.height, Some(280.0));
697 }
698
699 #[test]
700 fn scale_is_normalized_and_applied_to_all_layout_metrics() {
701 let theme = theme();
702 let normal = NodeMetrics::new(&theme, NODE_WIDTH, f32::NAN, Some(100.0));
703 assert_eq!(normal.width, NODE_WIDTH);
704 assert_eq!(normal.height, Some(100.0));
705
706 let doubled = NodeMetrics::new(&theme, NODE_WIDTH, 2.0, Some(100.0));
707 assert_eq!(doubled.width, NODE_WIDTH * 2.0);
708 assert_eq!(doubled.height, Some(200.0));
709 assert_eq!(doubled.padding, theme.spacing.sm * 2.0);
710 assert_eq!(doubled.caption_size, theme.typography.caption.size * 2.0);
711 assert_eq!(doubled.icon_size, theme.control.sm.icon_size * 2.0);
712 assert_eq!(doubled.radius, theme.radius(Radius::Card) * 2.0);
713 }
714}