Files
oak-gpui/crates/gpui/src/node_graph/node_element.rs
T
Mike-Solar ad7c965c5a feat(gpui): add dock, effect stack, node graph, and timeline widgets
Implements four workspace widget modules with accompanying learn examples:

- dock: dockable panel layout system (tabs, splits, drag-to-dock) with
  serde-based persistence via PanelRegistry / DockLayoutState
- effect_stack: linear effect-stack inspector widget
- node_graph: node-graph editor (nodes, ports, wires, pan/zoom canvas)
- timeline: video-editing timeline (tracks, clips, ruler, playhead)

Timeline snapping prefers the earlier frame when two snap points are
equally close, with SnapKind priority breaking same-frame ties.
2026-08-09 03:31:06 +08:00

415 lines
16 KiB
Rust

//! Rendering of a single node card.
//!
//! [`NodeElement`] draws one node of the graph: header, port columns, status
//! styling. It is used internally by
//! [`NodeGraphView`](crate::node_graph::NodeGraphView) but is public so apps
//! can customize or reuse the node chrome.
//!
//! # Sizing and port anchors
//!
//! The node is a fixed-width column (`DEFAULT_NODE_WIDTH`) laid out as:
//!
//! ```text
//! ┌──────────────────────────┐
//! │ ▶ Title (on) │ header — colored, carries collapse & enable toggles
//! │ ● in out ● │ one row per max(inputs, outputs) index
//! │ ● mask │
//! └──────────────────────────┘
//! ```
//!
//! Input ports form a left-aligned column, output ports a right-aligned
//! column, and row *i* of each column shares the same y coordinate, so the
//! port dots of opposite sides on the same row are horizontally aligned.
//!
//! Wires attach at **port dot centers**. The single source of truth for a
//! port's anchor point is [`NodeElement::port_anchor`], computed as:
//!
//! ```text
//! anchor.x = node_bounds.left() + PORT_DOT_RADIUS + PORT_INSET (inputs)
//! anchor.x = node_bounds.right() - PORT_DOT_RADIUS - PORT_INSET (outputs)
//! anchor.y = node_bounds.top() + HEADER_HEIGHT + row * PORT_ROW_HEIGHT
//! + PORT_ROW_HEIGHT / 2
//! ```
//!
//! All coordinates are in the node's local space; the view adds the node's
//! screen-space origin. Wire rendering ([`crate::node_graph::wire`]) uses the
//! same function, so anchors and dots can never drift apart.
use crate::{
colors::DefaultColors, App, BorderStyle, Bounds, Corners, Edges, Font, Hsla, PaintQuad, Pixels,
Point, SharedString, TextAlign, TextRun, Window, fill, hsla, point, px, size,
};
use crate::node_graph::{data::PortData, NodeData, NodeId, PortId};
/// The default width of a node card. Node width is fixed; only the height
/// grows with the port count.
pub const DEFAULT_NODE_WIDTH: Pixels = Pixels(180.0);
/// Height of the node header bar.
pub const HEADER_HEIGHT: Pixels = Pixels(28.0);
/// Height of a single port row; both port columns share this row pitch.
pub const PORT_ROW_HEIGHT: Pixels = Pixels(22.0);
/// Radius of a port dot.
pub const PORT_DOT_RADIUS: Pixels = Pixels(5.0);
/// Horizontal distance between the node's edge and the port dot center.
pub const PORT_INSET: Pixels = Pixels(8.0);
/// Visual state of a node card, supplied by the view at render time.
#[derive(Clone, Copy, Debug, Default)]
pub struct NodeVisualState {
/// Whether the node is part of the current selection (drawn with a
/// selection outline).
pub selected: bool,
/// Whether a wire drag is in progress and this node contains at least
/// one port that [`NodeGraphDataSource::can_connect`] approved as a drop
/// target (drawn with a subtle glow).
///
/// [`NodeGraphDataSource::can_connect`]: crate::node_graph::NodeGraphDataSource::can_connect
pub has_compatible_port: bool,
}
/// A single rendered node card.
///
/// Constructed per frame by the view from a [`NodeData`] snapshot plus a
/// [`NodeVisualState`]. Carries no interaction state of its own; mouse
/// handling for drags and wire pulls is installed by
/// [`NodeGraphView`](crate::node_graph::NodeGraphView), which owns the
/// gesture state machine.
pub struct NodeElement {
node: NodeId,
title: SharedString,
header_color: Option<Hsla>,
collapsed: bool,
enabled: bool,
visual: NodeVisualState,
inputs: Vec<PortRow>,
outputs: Vec<PortRow>,
}
/// One rendered port row: everything needed to draw a port dot and label
/// without re-querying the data source.
#[derive(Clone, Debug)]
struct PortRow {
id: PortId,
label: SharedString,
color: Hsla,
connected: bool,
}
impl NodeElement {
/// Builds the element from a node snapshot and its visual state.
///
/// Reads title, header color, collapse/enable flags and both port columns
/// off `node`. Port rows are taken in the order returned by
/// [`NodeData::inputs`] / [`NodeData::outputs`], which defines their
/// top-to-bottom draw order.
pub fn from_node<N: NodeData>(node: &N, visual: NodeVisualState) -> Self {
let inputs = node
.inputs()
.into_iter()
.map(|port| PortRow {
id: port.id(),
label: port.label(),
color: port.data_type().color,
connected: port.is_connected(),
})
.collect();
let outputs = node
.outputs()
.into_iter()
.map(|port| PortRow {
id: port.id(),
label: port.label(),
color: port.data_type().color,
connected: port.is_connected(),
})
.collect();
Self {
node: node.id(),
title: node.title(),
header_color: node.header_color(),
collapsed: node.is_collapsed(),
enabled: node.is_enabled(),
visual,
inputs,
outputs,
}
}
/// Returns the id of the node this element renders.
pub fn node_id(&self) -> NodeId {
self.node
}
/// Returns the total height of the node card: the header plus
/// `max(inputs, outputs)` port rows (zero rows when collapsed).
pub fn height(&self) -> Pixels {
if self.collapsed {
HEADER_HEIGHT
} else {
HEADER_HEIGHT + PORT_ROW_HEIGHT * self.inputs.len().max(self.outputs.len()) as f32
}
}
/// Computes the node-local anchor point (port dot center) of the given
/// port, per the formula in the [module docs](crate::node_graph::node_element).
/// Wires attach here.
///
/// Returns `None` when the port is not part of this node or the node is
/// collapsed (collapsed nodes expose no anchors and cannot be
/// connection targets).
///
/// # Panics
///
/// Never panics; unknown ports yield `None`.
pub fn port_anchor(&self, port: PortId) -> Option<Point<Pixels>> {
if self.collapsed {
return None;
}
let row_y = |row: usize| HEADER_HEIGHT + PORT_ROW_HEIGHT * row as f32 + PORT_ROW_HEIGHT * 0.5;
if let Some(row) = self.inputs.iter().position(|p| p.id == port) {
return Some(point(PORT_DOT_RADIUS + PORT_INSET, row_y(row)));
}
if let Some(row) = self.outputs.iter().position(|p| p.id == port) {
return Some(point(
DEFAULT_NODE_WIDTH - PORT_DOT_RADIUS - PORT_INSET,
row_y(row),
));
}
None
}
/// Hit-tests a node-local point against port dots and returns the id of
/// the port whose dot (inflated by a small grab margin) contains it.
/// Used to start wire drags. Header and body hits return `None`.
pub fn port_at(&self, position: Point<Pixels>) -> Option<PortId> {
let hit_radius = PORT_DOT_RADIUS + px(4.0);
for port in self.inputs.iter().chain(self.outputs.iter()) {
if let Some(anchor) = self.port_anchor(port.id) {
let dx = (position.x - anchor.x).0;
let dy = (position.y - anchor.y).0;
if dx * dx + dy * dy <= hit_radius.0 * hit_radius.0 {
return Some(port.id);
}
}
}
None
}
/// Returns whether a node-local point lands on the collapse toggle in the
/// header. The view uses this to distinguish "toggle collapse" clicks
/// from drag starts.
pub fn collapse_toggle_hit(&self, position: Point<Pixels>) -> bool {
position.x.0 >= 0.0
&& position.x.0 <= HEADER_HEIGHT.0
&& position.y.0 >= 0.0
&& position.y.0 <= HEADER_HEIGHT.0
}
/// Returns whether a node-local point lands on the enable/bypass toggle
/// in the header. Toggling emits no dedicated event — it is handled like
/// any other edit: the view emits a request and the app flips the flag in
/// its model.
pub fn enable_toggle_hit(&self, position: Point<Pixels>) -> bool {
position.x.0 >= DEFAULT_NODE_WIDTH.0 - HEADER_HEIGHT.0
&& position.x.0 <= DEFAULT_NODE_WIDTH.0
&& position.y.0 >= 0.0
&& position.y.0 <= HEADER_HEIGHT.0
}
/// Paints the node card into the current window layer: header with title
/// and toggles, port dots tinted by data type (filled when connected,
/// hollow otherwise) with labels, selection outline, disabled dimming and
/// the compatible-port glow. `origin` is the card's screen-space top-left
/// corner; all geometry within the card is node-local.
pub(crate) fn paint(&self, origin: Point<Pixels>, window: &mut Window, cx: &mut App) {
let colors = cx.default_colors().clone();
let bounds = Bounds::new(origin, size(DEFAULT_NODE_WIDTH, self.height()));
// Compatible-port glow: a slightly inflated rect behind the card while
// a wire drag offers at least one valid drop target on this node.
if self.visual.has_compatible_port {
let glow = Bounds::new(
point(origin.x - px(2.0), origin.y - px(2.0)),
size(DEFAULT_NODE_WIDTH + px(4.0), self.height() + px(4.0)),
);
window.paint_quad(fill(glow, Hsla::from(colors.selected).opacity(0.2)));
}
// Card body.
window.paint_quad(fill(bounds, colors.background));
// Border quad: transparent fill, themed border (accent when selected).
window.paint_quad(PaintQuad {
bounds,
corner_radii: Corners::all(px(4.0)),
background: hsla(0.0, 0.0, 0.0, 0.0).into(),
border_widths: Edges::all(if self.visual.selected { px(1.5) } else { px(1.0) }),
border_color: if self.visual.selected {
Hsla::from(colors.selected)
} else {
Hsla::from(colors.border)
},
border_style: BorderStyle::Solid,
});
// Header bar with the node's accent color (or the theme container
// color), containing the title and the collapse/enable toggles.
let header_bounds = Bounds::new(origin, size(DEFAULT_NODE_WIDTH, HEADER_HEIGHT));
window.paint_quad(fill(
header_bounds,
self.header_color.unwrap_or(Hsla::from(colors.container)),
));
let text_y = bounds.top() + px((HEADER_HEIGHT.0 - 12.0) / 2.0);
paint_text(
window,
cx,
&self.title,
px(12.0),
point(bounds.left() + px(28.0), text_y),
px(12.0),
Hsla::from(colors.text),
TextAlign::Left,
None,
);
// Collapse toggle: "▶" when collapsed (click to expand), "▼" when
// expanded (click to collapse).
paint_text(
window,
cx,
if self.collapsed { "▶" } else { "▼" },
px(10.0),
point(bounds.left() + px(10.0), text_y),
px(12.0),
Hsla::from(colors.text),
TextAlign::Left,
None,
);
// Enable toggle glyph (power symbol) on the right edge of the header.
paint_text(
window,
cx,
"⏻",
px(12.0),
point(bounds.right() - px(20.0), text_y),
px(12.0),
Hsla::from(colors.text),
TextAlign::Left,
None,
);
// Port dots and labels, only when the node is expanded.
if !self.collapsed {
let label_font_size = px(11.0);
let label_height = px(12.0);
for port in self.inputs.iter().chain(self.outputs.iter()) {
let Some(anchor) = self.port_anchor(port.id) else {
continue;
};
let dot_bounds = Bounds::new(
point(anchor.x - PORT_DOT_RADIUS, anchor.y - PORT_DOT_RADIUS),
size(PORT_DOT_RADIUS * 2.0, PORT_DOT_RADIUS * 2.0),
);
if port.connected {
// Connected dots are solid tinted circles.
window.paint_quad(PaintQuad {
bounds: dot_bounds,
corner_radii: Corners::all(PORT_DOT_RADIUS),
background: port.color.into(),
border_widths: Edges::all(px(0.0)),
border_color: hsla(0.0, 0.0, 0.0, 0.0),
border_style: BorderStyle::Solid,
});
} else {
// Unconnected dots are hollow: a tinted ring around the
// card's background color.
window.paint_quad(fill(dot_bounds, port.color));
let inner = Bounds::new(
point(anchor.x - PORT_DOT_RADIUS + px(2.0), anchor.y - PORT_DOT_RADIUS + px(2.0)),
size(PORT_DOT_RADIUS * 2.0 - px(4.0), PORT_DOT_RADIUS * 2.0 - px(4.0)),
);
window.paint_quad(fill(inner, colors.background));
}
if !port.label.is_empty() {
if self.inputs.iter().any(|p| p.id == port.id) {
// Input labels: left-aligned, starting right of the dot.
paint_text(
window,
cx,
&port.label,
label_font_size,
point(anchor.x + PORT_DOT_RADIUS + px(6.0), anchor.y - px(6.0)),
label_height,
Hsla::from(colors.text),
TextAlign::Left,
None,
);
} else {
// Output labels: right-aligned so they end just left of
// the dot. The box origin sits `align_width` left of the
// dot; the label's right edge lands at the box right.
let align_width = px(100.0);
paint_text(
window,
cx,
&port.label,
label_font_size,
point(
anchor.x - PORT_DOT_RADIUS - px(6.0) - align_width,
anchor.y - px(6.0),
),
label_height,
Hsla::from(colors.text),
TextAlign::Right,
Some(align_width),
);
}
}
}
}
// Disabled nodes are dimmed with a dark overlay.
if !self.enabled {
window.paint_quad(fill(bounds, hsla(0.0, 0.0, 0.0, 0.5)));
}
}
}
/// Shapes and paints a single text line at `origin` (the top-left of the
/// line box) with the given font size, line height, alignment and color.
fn paint_text(
window: &mut Window,
cx: &mut App,
text: &str,
font_size: Pixels,
origin: Point<Pixels>,
line_height: Pixels,
color: Hsla,
align: TextAlign,
align_width: Option<Pixels>,
) {
let line = window.text_system().shape_line(
SharedString::from(text),
font_size,
&[TextRun {
len: text.len(),
font: Font::default(),
color,
background_color: None,
underline: None,
strikethrough: None,
letter_spacing: None,
}],
None,
);
let _ = line.paint(origin, line_height, align, align_width, window, cx);
}