Skip to main content

gpui_kit/canvas/
node.rs

1//! One step of a run, drawn as a card on a graph canvas.
2//!
3//! A node reports four things and invents none of them: what it is, what it is
4//! doing now, how it ended, and what it cost. The cost figures are the
5//! caller's strings, because a component that formatted a token count would be
6//! deciding a product question — thousands separators, units, rounding — on
7//! behalf of every host that ever draws one.
8
9use 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
24/// The default width of a node, in pixels.
25///
26/// Nodes on one canvas share a width so the columns of a graph line up and the
27/// eye can compare two steps without measuring them.
28pub const NODE_WIDTH: f32 = 216.0;
29
30/// Whether a port receives or produces data.
31#[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/// A typed connection point on a [`GraphNode`].
48///
49/// Port ids must be unique within their node. They are caller-owned identity,
50/// while labels are the caller-owned words shown for that identity.
51#[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    /// Returns the side selected for this port.
96    ///
97    /// Named distinctly from the [`GraphPort::side`] builder because Rust
98    /// does not overload methods by argument count.
99    pub fn port_side(&self) -> PortSide {
100        self.side
101    }
102}
103
104/// Screen-space values derived from world-space theme values in one place.
105#[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/// How a step ended, or that it has not.
145///
146/// These are five separate answers and stay separate: a step that the host
147/// refused to run is not a step that failed, and a step nobody has reached yet
148/// is not a step that succeeded quietly.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
150pub enum NodeState {
151    /// Not reached yet.
152    #[default]
153    Pending,
154    Running,
155    Succeeded,
156    Failed,
157    /// The host declined to run it. Shown as a refusal, never as a failure and
158    /// never as an empty step.
159    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    /// What the node publishes as its value.
184    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    /// Whether the state is worth bleeding into the pixels around the card.
195    ///
196    /// Only the states a reader is scanning for glow. A canvas where every
197    /// node glowed would be a canvas where none of them stood out, which is
198    /// the same as no glow at all but more expensive to draw.
199    fn is_notable(self) -> bool {
200        matches!(self, Self::Running | Self::Failed | Self::Refused)
201    }
202}
203
204/// One figure a step reports about itself, such as a token count or an elapsed
205/// time. Both halves are the caller's words.
206#[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/// How much a step changed, in lines.
222///
223/// Kept apart from [`NodeMetric`] because the two halves are coloured against
224/// each other, and a reader who sees green and red beside each other is
225/// entitled to assume they mean added and removed.
226#[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/// A step of a run, as a card on the canvas.
245#[derive(IntoElement)]
246pub struct GraphNode {
247    ident: Ident,
248    title: SharedString,
249    /// What the step is doing now, for a step that is doing something.
250    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    /// What the step is doing right now, in the caller's words.
295    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    /// What the step changed. An empty diff is not shown, because "nothing
330    /// changed" and "no diff was reported" are different claims and only the
331    /// caller knows which one it has.
332    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    /// Configures this card for graph display. Dimensions remain logical world
368    /// values until render, so graph routing and card layout use one scale.
369    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    /// Leaves keyboard activation on the node while an owning canvas
380    /// arbitrates pointer click versus drag on its stable outer surface.
381    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    /// How tall the card will come out, from the rows it actually has.
393    ///
394    /// Edges are geometry and need a box before the card has been laid out,
395    /// and the node is the only thing that knows how many rows it carries. A
396    /// graph-wide constant would leave every connection to a step with no
397    /// metrics entering at a different place from every connection to a step
398    /// with three, and an edge that misses the card it joins is the one
399    /// detail a reader will read as meaningful.
400    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        // The mark is the one part of a node that moves, and it moves because
427        // the step is still running. It turns through the shared vocabulary,
428        // so a running node and a running tool call turn at one rate.
429        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            // The state bleeds out of the card rather than being drawn round
528            // it, so a running node and a failed one differ by the colour the
529            // canvas takes near them and not by which of two lines they wear.
530            .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        // A node that takes a click is a button and a node that does not is a
541        // group, so the role is decided before the spec is built rather than
542        // patched afterwards.
543        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    /// The states exist to be told apart, so no two of them may report the
589    /// same colour or the same word.
590    #[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    /// A refusal is the host declining, which is neither a failure nor an
613    /// absence of work, and it may not be reported as either.
614    #[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}