use std::rc::Rc;
use gpui::{
AnyElement, App, FontWeight, Hsla, InteractiveElement, IntoElement, ParentElement, RenderOnce,
SharedString, Styled, Window, div, prelude::FluentBuilder, px,
};
use gpui_kit_assets::{Icon, icon};
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{ActiveTheme, Elevation, Radius, Surface};
use crate::foundation::{FocusRing, Ident, Pressable, Selectable, StyledExt};
use crate::motion;
use super::edge::PortSide;
pub const NODE_WIDTH: f32 = 216.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PortDirection {
#[default]
Input,
Output,
}
impl PortDirection {
pub fn name(self) -> &'static str {
match self {
Self::Input => "input",
Self::Output => "output",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GraphPort {
id: SharedString,
label: SharedString,
direction: PortDirection,
side: PortSide,
}
impl GraphPort {
pub fn input(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
label: label.into(),
direction: PortDirection::Input,
side: PortSide::Left,
}
}
pub fn output(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
label: label.into(),
direction: PortDirection::Output,
side: PortSide::Right,
}
}
pub fn side(mut self, side: PortSide) -> Self {
self.side = side;
self
}
pub fn id(&self) -> &SharedString {
&self.id
}
pub fn label(&self) -> &SharedString {
&self.label
}
pub fn direction(&self) -> PortDirection {
self.direction
}
pub fn port_side(&self) -> PortSide {
self.side
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct NodeMetrics {
width: f32,
height: Option<f32>,
padding: f32,
gap: f32,
figure_gap: f32,
label_size: f32,
label_height: f32,
caption_size: f32,
caption_height: f32,
icon_size: f32,
radius: f32,
}
impl NodeMetrics {
fn new(theme: &gpui_kit_theme::Theme, width: f32, zoom: f32, height: Option<f32>) -> Self {
let scale = if zoom.is_finite() && zoom > 0.0 {
zoom
} else {
1.0
};
let scaled = |value: f32| value * scale;
Self {
width: scaled(width),
height: height.map(scaled),
padding: scaled(theme.spacing.sm),
gap: scaled(theme.spacing.xs),
figure_gap: scaled(theme.spacing.sm),
label_size: scaled(theme.typography.label.size),
label_height: scaled(theme.typography.label.line_height),
caption_size: scaled(theme.typography.caption.size),
caption_height: scaled(theme.typography.caption.line_height),
icon_size: scaled(theme.control.sm.icon_size),
radius: scaled(theme.radius(Radius::Card)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NodeState {
#[default]
Pending,
Running,
Succeeded,
Failed,
Refused,
}
impl NodeState {
pub fn color(self, theme: &gpui_kit_theme::Theme) -> Hsla {
match self {
Self::Pending => theme.colors.text_faint,
Self::Running => theme.colors.accent,
Self::Succeeded => theme.colors.success,
Self::Failed => theme.colors.danger,
Self::Refused => theme.colors.warning,
}
}
fn glyph(self) -> Option<Icon> {
match self {
Self::Pending => None,
Self::Running => Some(Icon::Refresh),
Self::Succeeded => Some(Icon::Check),
Self::Failed => Some(Icon::Close),
Self::Refused => Some(Icon::Danger),
}
}
fn value(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Running => "running",
Self::Succeeded => "succeeded",
Self::Failed => "failed",
Self::Refused => "refused",
}
}
fn is_notable(self) -> bool {
matches!(self, Self::Running | Self::Failed | Self::Refused)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NodeMetric {
pub label: SharedString,
pub value: SharedString,
}
impl NodeMetric {
pub fn new(label: impl Into<SharedString>, value: impl Into<SharedString>) -> Self {
Self {
label: label.into(),
value: value.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Diff {
pub added: usize,
pub removed: usize,
}
impl Diff {
pub fn new(added: usize, removed: usize) -> Self {
Self { added, removed }
}
pub fn is_empty(self) -> bool {
self.added == 0 && self.removed == 0
}
}
type ClickHandler = Rc<dyn Fn(&mut Window, &mut App)>;
#[derive(IntoElement)]
pub struct GraphNode {
ident: Ident,
title: SharedString,
action: Option<SharedString>,
state: NodeState,
metrics: Vec<NodeMetric>,
ports: Vec<GraphPort>,
diff: Option<Diff>,
selected: bool,
width: f32,
display_zoom: f32,
declared_height: Option<f32>,
pointer_click: bool,
on_click: Option<ClickHandler>,
}
impl std::fmt::Debug for GraphNode {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("GraphNode")
.field("ident", &self.ident)
.field("title", &self.title)
.field("state", &self.state)
.field("metrics", &self.metrics.len())
.finish_non_exhaustive()
}
}
impl GraphNode {
pub fn new(ident: impl Into<Ident>, title: impl Into<SharedString>) -> Self {
Self {
ident: ident.into(),
title: title.into(),
action: None,
state: NodeState::default(),
metrics: Vec::new(),
ports: Vec::new(),
diff: None,
selected: false,
width: NODE_WIDTH,
display_zoom: 1.0,
declared_height: None,
pointer_click: true,
on_click: None,
}
}
pub fn action(mut self, action: impl Into<SharedString>) -> Self {
self.action = Some(action.into());
self
}
pub fn state(mut self, state: NodeState) -> Self {
self.state = state;
self
}
pub fn metric(
mut self,
label: impl Into<SharedString>,
value: impl Into<SharedString>,
) -> Self {
self.metrics.push(NodeMetric::new(label, value));
self
}
pub fn metrics(mut self, metrics: impl IntoIterator<Item = NodeMetric>) -> Self {
self.metrics.extend(metrics);
self
}
pub fn port(mut self, port: GraphPort) -> Self {
self.ports.push(port);
self
}
pub fn ports(mut self, ports: impl IntoIterator<Item = GraphPort>) -> Self {
self.ports.extend(ports);
self
}
pub fn diff(mut self, diff: Diff) -> Self {
self.diff = Some(diff);
self
}
pub fn width(mut self, width: f32) -> Self {
self.width = width;
self
}
pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
pub(crate) fn ident(&self) -> &Ident {
&self.ident
}
pub(crate) fn node_width(&self) -> f32 {
self.width
}
pub(crate) fn node_state(&self) -> NodeState {
self.state
}
pub(crate) fn graph_ports(&self) -> &[GraphPort] {
&self.ports
}
pub(crate) fn click_handler(&self) -> Option<ClickHandler> {
self.on_click.clone()
}
pub(crate) fn display_at(mut self, zoom: f32, declared_height: Option<f32>) -> Self {
self.display_zoom = if zoom.is_finite() && zoom > 0.0 {
zoom
} else {
1.0
};
self.declared_height = declared_height.filter(|height| height.is_finite() && *height > 0.0);
self
}
pub(crate) fn pointer_click(mut self, enabled: bool) -> Self {
self.pointer_click = enabled;
self
}
#[cfg(test)]
pub(crate) fn logical_height(&self, theme: &gpui_kit_theme::Theme) -> f32 {
self.declared_height
.unwrap_or_else(|| self.measured_height(theme))
}
pub(crate) fn measured_height(&self, theme: &gpui_kit_theme::Theme) -> f32 {
let mut rows = vec![theme.typography.label.line_height];
if self.action.is_some() {
rows.push(theme.typography.caption.line_height);
}
if !self.metrics.is_empty() || self.diff.is_some_and(|diff| !diff.is_empty()) {
rows.push(theme.typography.caption.line_height);
}
let gaps = theme.spacing.xs * (rows.len() - 1) as f32;
theme.spacing.sm * 2.0 + rows.iter().sum::<f32>() + gaps
}
}
impl Selectable for GraphNode {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
}
impl RenderOnce for GraphNode {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme().clone();
let color = self.state.color(&theme);
let metrics = NodeMetrics::new(&theme, self.width, self.display_zoom, self.declared_height);
let mark = self.state.glyph().map(|glyph| {
let element = icon(glyph).size(px(metrics.icon_size)).text_color(color);
match self.state {
NodeState::Running => {
motion::spin(element, self.ident.child("mark").element_id(), &theme, cx)
}
_ => element.into_any_element(),
}
});
let header = div()
.row()
.w_full()
.gap(px(metrics.gap))
.children(mark)
.child(
div()
.min_w_0()
.flex_1()
.text_size(px(metrics.label_size))
.line_height(px(metrics.label_height))
.font_weight(FontWeight(theme.typography.label.weight))
.text_color(theme.colors.text)
.truncate()
.child(self.title.clone()),
);
let action = self.action.clone().map(|action| {
div()
.w_full()
.text_size(px(metrics.caption_size))
.line_height(px(metrics.caption_height))
.font_weight(FontWeight(theme.typography.caption.weight))
.text_color(theme.colors.text_muted)
.truncate()
.child(action)
});
let mut figures: Vec<AnyElement> = self
.metrics
.iter()
.map(|metric| {
div()
.row()
.gap(px(metrics.gap / 2.0))
.child(
div()
.text_color(theme.colors.text_faint)
.child(metric.label.clone()),
)
.child(
div()
.text_color(theme.colors.text_muted)
.child(metric.value.clone()),
)
.into_any_element()
})
.collect();
if let Some(diff) = self.diff.filter(|diff| !diff.is_empty()) {
figures.push(
div()
.row()
.gap(px(metrics.gap / 2.0))
.child(
div()
.text_color(theme.colors.success)
.child(format!("+{}", diff.added)),
)
.child(
div()
.text_color(theme.colors.danger)
.child(format!("-{}", diff.removed)),
)
.into_any_element(),
);
}
let strip = (!figures.is_empty()).then(|| {
div()
.row()
.w_full()
.flex_wrap()
.gap(px(metrics.figure_gap))
.text_size(px(metrics.caption_size))
.line_height(px(metrics.caption_height))
.font_weight(FontWeight(theme.typography.caption.weight))
.children(figures)
});
let card = div()
.w(px(metrics.width))
.when_some(metrics.height, |element, height| element.h(px(height)))
.column()
.gap(px(metrics.gap))
.p(px(metrics.padding))
.rounded(px(metrics.radius))
.frame(&theme, Surface::Raised, Elevation::Raised)
.when(self.state.is_notable(), |element| {
element.glow(&theme, color)
})
.when(self.selected, |element| {
element.shadow(theme.selected_ring())
})
.child(header)
.children(action)
.children(strip);
let role = if self.on_click.is_some() {
Role::Button
} else {
Role::Group
};
let spec = NodeSpec::new(self.ident.semantic_id(), role)
.text(self.title.clone())
.value(self.state.value())
.selected(self.selected)
.busy(self.state == NodeState::Running)
.invalid(self.state == NodeState::Failed);
let Some(handler) = self.on_click else {
return card.semantic_in(cx, spec).into_any_element();
};
let mut card = card
.id(self.ident.element_id())
.cursor_pointer()
.tab_index(0)
.focus_ring(&theme)
.pressable(cx);
if self.pointer_click {
let click = Rc::clone(&handler);
card.interactivity()
.on_click(move |_, window, cx| click(window, cx));
}
card.interactivity().on_key_down(move |event, window, cx| {
if matches!(event.keystroke.key.as_str(), "enter" | "space") {
handler(window, cx);
cx.stop_propagation();
}
});
card.semantic_in(cx, spec).into_any_element()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn theme() -> gpui_kit_theme::Theme {
gpui_kit_theme::Theme::studio_dark()
}
#[test]
fn every_state_is_distinguishable_from_every_other() {
let theme = theme();
let states = [
NodeState::Pending,
NodeState::Running,
NodeState::Succeeded,
NodeState::Failed,
NodeState::Refused,
];
for (index, state) in states.iter().enumerate() {
for other in &states[index + 1..] {
assert_ne!(
state.color(&theme),
other.color(&theme),
"{state:?} {other:?}"
);
assert_ne!(state.value(), other.value(), "{state:?} {other:?}");
}
}
}
#[test]
fn a_refusal_is_not_a_failure() {
let theme = theme();
assert_ne!(
NodeState::Refused.color(&theme),
NodeState::Failed.color(&theme)
);
assert_eq!(NodeState::Refused.value(), "refused");
}
#[test]
fn only_the_states_worth_scanning_for_reach_past_the_card() {
assert!(NodeState::Running.is_notable());
assert!(NodeState::Failed.is_notable());
assert!(NodeState::Refused.is_notable());
assert!(!NodeState::Pending.is_notable());
assert!(!NodeState::Succeeded.is_notable());
}
#[test]
fn a_pending_step_carries_no_glyph_and_the_rest_do() {
assert!(NodeState::Pending.glyph().is_none());
for state in [
NodeState::Running,
NodeState::Succeeded,
NodeState::Failed,
NodeState::Refused,
] {
assert!(state.glyph().is_some(), "{state:?}");
}
}
#[test]
fn an_empty_diff_reports_itself_as_empty() {
assert!(Diff::default().is_empty());
assert!(!Diff::new(0, 3).is_empty());
assert!(!Diff::new(3, 0).is_empty());
}
#[test]
fn a_node_starts_pending_and_at_the_shared_width() {
let node = GraphNode::new("run.plan", "Plan");
assert_eq!(node.state, NodeState::Pending);
assert_eq!(node.node_width(), NODE_WIDTH);
assert_eq!(node.ident().as_str(), "run.plan");
}
#[test]
fn ports_have_directional_defaults_and_allow_side_override() {
let input = GraphPort::input("source", "Source");
assert_eq!(input.direction(), PortDirection::Input);
assert_eq!(input.direction().name(), "input");
assert_eq!(input.port_side(), PortSide::Left);
let output = GraphPort::output("result", "Result").side(PortSide::Bottom);
assert_eq!(output.direction(), PortDirection::Output);
assert_eq!(output.direction().name(), "output");
assert_eq!(output.port_side(), PortSide::Bottom);
}
#[test]
fn node_port_builders_preserve_caller_identity_and_labels() {
let node = GraphNode::new("transform", "Transform")
.port(GraphPort::input("in", "Rows"))
.ports([GraphPort::output("out", "Records")]);
assert_eq!(node.graph_ports().len(), 2);
assert_eq!(node.graph_ports()[0].id().as_ref(), "in");
assert_eq!(node.graph_ports()[0].label().as_ref(), "Rows");
assert_eq!(node.graph_ports()[1].id().as_ref(), "out");
}
#[test]
fn declared_height_is_the_logical_geometry_contract() {
let theme = theme();
let node = GraphNode::new("step", "Step").display_at(2.0, Some(140.0));
assert_eq!(node.logical_height(&theme), 140.0);
let metrics = NodeMetrics::new(
&theme,
node.node_width(),
node.display_zoom,
node.declared_height,
);
assert_eq!(metrics.height, Some(280.0));
}
#[test]
fn scale_is_normalized_and_applied_to_all_layout_metrics() {
let theme = theme();
let normal = NodeMetrics::new(&theme, NODE_WIDTH, f32::NAN, Some(100.0));
assert_eq!(normal.width, NODE_WIDTH);
assert_eq!(normal.height, Some(100.0));
let doubled = NodeMetrics::new(&theme, NODE_WIDTH, 2.0, Some(100.0));
assert_eq!(doubled.width, NODE_WIDTH * 2.0);
assert_eq!(doubled.height, Some(200.0));
assert_eq!(doubled.padding, theme.spacing.sm * 2.0);
assert_eq!(doubled.caption_size, theme.typography.caption.size * 2.0);
assert_eq!(doubled.icon_size, theme.control.sm.icon_size * 2.0);
assert_eq!(doubled.radius, theme.radius(Radius::Card) * 2.0);
}
}