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.
415 lines
16 KiB
Rust
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);
|
|
}
|