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.
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
//! Data-source traits and identifier types for the node-graph editor.
|
||||
//!
|
||||
//! The widget is fully data-agnostic: it reads everything it displays through
|
||||
//! the traits in this file and never mutates the underlying model. The
|
||||
//! embedding application implements these traits over its own graph (for Oak:
|
||||
//! the `oakengine` node graph) and reacts to the [`NodeGraphEvent`]s emitted
|
||||
//! by the view.
|
||||
//!
|
||||
//! [`NodeGraphEvent`]: crate::node_graph::NodeGraphEvent
|
||||
|
||||
use crate::{Hsla, Pixels, Point, SharedString};
|
||||
|
||||
/// Unique identifier of a node within the graph.
|
||||
///
|
||||
/// Typically a newtype over the host application's own node key (e.g. an
|
||||
/// engine node handle). The widget only requires that ids are cheap to copy,
|
||||
/// totally ordered (for selection sets) and hashable (for lookup maps).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct NodeId(pub u64);
|
||||
|
||||
/// Unique identifier of a port within the graph.
|
||||
///
|
||||
/// Port ids are *globally* unique, not per-node, so that a single [`PortId`]
|
||||
/// is enough to address an endpoint of a connection request. The app is free
|
||||
/// to pack a node id and a per-node port index into the `u64` however it
|
||||
/// likes.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct PortId(pub u64);
|
||||
|
||||
/// Unique identifier of an edge (a connection between two ports).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct EdgeId(pub u64);
|
||||
|
||||
/// Whether a port accepts incoming connections or produces outgoing ones.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum PortKind {
|
||||
/// A port that consumes data; conventionally drawn on the left side of a
|
||||
/// node and accepts connections *from* an [`PortKind::Output`] port.
|
||||
Input,
|
||||
/// A port that produces data; conventionally drawn on the right side of a
|
||||
/// node and connects *to* an [`PortKind::Input`] port.
|
||||
Output,
|
||||
}
|
||||
|
||||
/// A lightweight, app-defined descriptor of the data flowing through a port.
|
||||
///
|
||||
/// The widget does not interpret data types semantically — it uses the
|
||||
/// [`color`](Self::color) to tint port dots and wires, and uses
|
||||
/// [`PartialEq`] only as a convenience for *default* visual hints. The
|
||||
/// authoritative compatibility check is always
|
||||
/// [`NodeGraphDataSource::can_connect`], so an app may implement subtyping,
|
||||
/// implicit conversions (e.g. `int → float`) or direction-dependent rules
|
||||
/// there without this type needing to model them.
|
||||
///
|
||||
/// # Equality contract
|
||||
///
|
||||
/// Two [`PortDataType`] values are considered the same type when their
|
||||
/// `name`s are equal; the color is *not* part of equality. Apps that want
|
||||
/// distinct types sharing a name should disambiguate the name.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PortDataType {
|
||||
/// Human-readable type name, e.g. `"video"`, `"audio"`, `"matte"`.
|
||||
/// Also used as the identity of the type (see type-level docs).
|
||||
pub name: SharedString,
|
||||
/// Color used to tint port dots and wires carrying this type.
|
||||
pub color: Hsla,
|
||||
}
|
||||
|
||||
impl PortDataType {
|
||||
/// Creates a new data-type descriptor with the given display name and
|
||||
/// tint color.
|
||||
pub fn new(name: impl Into<SharedString>, color: Hsla) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
color,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for PortDataType {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.name == other.name
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for PortDataType {}
|
||||
|
||||
/// A single port on a node.
|
||||
///
|
||||
/// Ports are the endpoints of edges. Each port has a globally unique
|
||||
/// [`PortId`], a direction ([`PortKind`]) and a [`PortDataType`] used for
|
||||
/// tinting.
|
||||
pub trait PortData {
|
||||
/// Returns the globally unique identifier of this port.
|
||||
fn id(&self) -> PortId;
|
||||
|
||||
/// Returns whether this is an input or an output port.
|
||||
fn kind(&self) -> PortKind;
|
||||
|
||||
/// Returns the short label drawn next to the port dot (e.g. `"in"`,
|
||||
/// `"mask"`). May be empty, in which case only the dot is drawn.
|
||||
fn label(&self) -> SharedString;
|
||||
|
||||
/// Returns the data type of this port, used to tint the port dot and any
|
||||
/// wires connected to it.
|
||||
fn data_type(&self) -> PortDataType;
|
||||
|
||||
/// Returns whether this port currently has at least one edge attached.
|
||||
///
|
||||
/// Used only for rendering (connected dots are filled, unconnected dots
|
||||
/// are hollow) and for styling during wire drags; the widget does not
|
||||
/// enforce any cardinality rules from it — that is the job of
|
||||
/// [`NodeGraphDataSource::can_connect`].
|
||||
fn is_connected(&self) -> bool;
|
||||
}
|
||||
|
||||
/// A single node in the graph.
|
||||
///
|
||||
/// Nodes are rectangular cards with a header, a column of input ports on the
|
||||
/// left and a column of output ports on the right (see
|
||||
/// [`NodeElement`](crate::node_graph::NodeElement)).
|
||||
pub trait NodeData {
|
||||
/// The port type used by this node's inputs and outputs.
|
||||
type Port: PortData;
|
||||
|
||||
/// Returns the unique identifier of this node.
|
||||
fn id(&self) -> NodeId;
|
||||
|
||||
/// Returns the title drawn in the node's header.
|
||||
fn title(&self) -> SharedString;
|
||||
|
||||
/// Returns the position of the node's top-left corner in *graph space*
|
||||
/// (the document coordinate system).
|
||||
///
|
||||
/// Graph space is an unbounded, zoom-independent coordinate system: a
|
||||
/// node at `point(px(100.), px(40.))` stays attached to that document
|
||||
/// location regardless of pan and zoom. The view converts to screen
|
||||
/// coordinates with
|
||||
/// [`GraphViewState::graph_to_screen`](crate::node_graph::GraphViewState::graph_to_screen).
|
||||
fn position(&self) -> Point<Pixels>;
|
||||
|
||||
/// Returns the input ports of this node, in top-to-bottom draw order.
|
||||
fn inputs(&self) -> Vec<Self::Port>;
|
||||
|
||||
/// Returns the output ports of this node, in top-to-bottom draw order.
|
||||
fn outputs(&self) -> Vec<Self::Port>;
|
||||
|
||||
/// Returns an optional accent color for the node header, or `None` to use
|
||||
/// the theme default. Apps typically use this to group nodes by category
|
||||
/// (inputs, transforms, color management, outputs, …).
|
||||
fn header_color(&self) -> Option<Hsla>;
|
||||
|
||||
/// Returns whether the node is collapsed to just its header.
|
||||
///
|
||||
/// Collapsed nodes draw no ports and cannot be connection targets. The
|
||||
/// collapsed state itself belongs to the app's model (or view state); the
|
||||
/// widget only reflects it.
|
||||
fn is_collapsed(&self) -> bool;
|
||||
|
||||
/// Returns whether the node is enabled.
|
||||
///
|
||||
/// Disabled nodes (e.g. a bypassed effect) are drawn dimmed. This is a
|
||||
/// purely visual hint; the widget does not change interaction behavior
|
||||
/// for disabled nodes.
|
||||
fn is_enabled(&self) -> bool;
|
||||
}
|
||||
|
||||
/// A single directed connection from an output port to an input port.
|
||||
pub trait EdgeData {
|
||||
/// Returns the unique identifier of this edge.
|
||||
fn id(&self) -> EdgeId;
|
||||
|
||||
/// Returns the id of the node the connection starts at.
|
||||
fn from_node(&self) -> NodeId;
|
||||
|
||||
/// Returns the id of the output port the connection starts at.
|
||||
fn from_port(&self) -> PortId;
|
||||
|
||||
/// Returns the id of the node the connection ends at.
|
||||
fn to_node(&self) -> NodeId;
|
||||
|
||||
/// Returns the id of the input port the connection ends at.
|
||||
fn to_port(&self) -> PortId;
|
||||
}
|
||||
|
||||
/// The data source backing a [`NodeGraphView`](crate::node_graph::NodeGraphView).
|
||||
///
|
||||
/// The app implements this trait over its engine model and places it in an
|
||||
/// `Entity`. The view re-reads `nodes()` and `edges()` every frame in which
|
||||
/// the entity notifies, so implementations should be cheap snapshots or
|
||||
/// borrow from cached data.
|
||||
///
|
||||
/// All methods take `&self`; the widget never mutates the source. Edits
|
||||
/// arrive back at the app as [`NodeGraphEvent`]s.
|
||||
///
|
||||
/// [`NodeGraphEvent`]: crate::node_graph::NodeGraphEvent
|
||||
pub trait NodeGraphDataSource {
|
||||
/// The node type returned by [`nodes()`](Self::nodes).
|
||||
type Node: NodeData;
|
||||
/// The edge type returned by [`edges()`](Self::edges).
|
||||
type Edge: EdgeData;
|
||||
|
||||
/// Returns all nodes to display, in no required order (the view sorts for
|
||||
/// painting; selection order is unaffected).
|
||||
fn nodes(&self) -> Vec<Self::Node>;
|
||||
|
||||
/// Returns all edges to display. Edges referencing ports or nodes that
|
||||
/// are not part of [`nodes()`](Self::nodes) are ignored by the view.
|
||||
fn edges(&self) -> Vec<Self::Edge>;
|
||||
|
||||
/// Returns whether connecting output port `from` to input port `to`
|
||||
/// would be valid.
|
||||
///
|
||||
/// This is the single place where the app enforces its connection rules:
|
||||
/// data-type compatibility (including implicit conversions), cycle
|
||||
/// prevention, port cardinality, node enablement, and so on. The view
|
||||
/// calls this:
|
||||
///
|
||||
/// - *continuously during a wire drag* to highlight compatible target
|
||||
/// ports and to mark the ghost wire as valid/invalid, and
|
||||
/// - *once on drop* before emitting
|
||||
/// [`NodeGraphEvent::ConnectionRequested`](crate::node_graph::NodeGraphEvent::ConnectionRequested)
|
||||
/// — a drop on a port for which this returns `false` cancels the drag
|
||||
/// silently.
|
||||
///
|
||||
/// It must be cheap, pure, and consistent: the same arguments must yield
|
||||
/// the same answer within a frame. The view passes output port first,
|
||||
/// input port second, regardless of which end the user started the drag
|
||||
/// from. Returning `true` here does not commit the app to accepting the
|
||||
/// connection; the engine may still reject it when the event arrives
|
||||
/// (e.g. it raced with another edit), in which case the app simply does
|
||||
/// not apply it.
|
||||
fn can_connect(&self, from: PortId, to: PortId) -> bool;
|
||||
}
|
||||
@@ -0,0 +1,981 @@
|
||||
//! The interactive node-graph view and its event type.
|
||||
//!
|
||||
//! [`NodeGraphView`] is the top-level widget: a focusable canvas that renders
|
||||
//! nodes, wires and interaction overlays, and reports every user edit
|
||||
//! intention as a [`NodeGraphEvent`]. See the
|
||||
//! [module-level docs](crate::node_graph) for the overall architecture.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use crate::{
|
||||
App, BorderStyle, Bounds, Context, Corners, Edges, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, Hsla, IntoElement, KeyDownEvent, KeyUpEvent, MouseButton, MouseDownEvent,
|
||||
MouseMoveEvent, PaintQuad, PinchEvent, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent,
|
||||
Window, canvas, colors::DefaultColors, div, fill, hsla, point, prelude::*, px, size,
|
||||
};
|
||||
|
||||
use crate::node_graph::{
|
||||
DEFAULT_NODE_WIDTH, EdgeData, EdgeId, GhostWire, GraphViewState, NodeData, NodeElement,
|
||||
NodeGraphDataSource, NodeId, NodeVisualState, PortData, PortDataType, PortId, PortKind,
|
||||
SelectionRect, Wire, WireVisualState, paint_ghost,
|
||||
};
|
||||
|
||||
/// Spacing between grid lines, in graph-space pixels.
|
||||
const GRID_SIZE: f32 = 20.0;
|
||||
|
||||
/// How close (in screen pixels) the cursor must be to a port dot for a wire
|
||||
/// drag to snap to it.
|
||||
const PORT_GRAB_RADIUS: Pixels = px(12.0);
|
||||
|
||||
/// Minimum marquee drag distance (in screen pixels) before a background press
|
||||
/// is treated as a marquee drag rather than a plain click.
|
||||
const MARQUEE_DRAG_THRESHOLD: f32 = 3.0;
|
||||
|
||||
/// What a mouse press on the canvas hit.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum HitTarget {
|
||||
/// Empty background.
|
||||
Background,
|
||||
/// A port dot: start a wire drag.
|
||||
Port(PortId),
|
||||
/// A collapse or enable toggle in a node header: toggle selection only.
|
||||
Toggle(NodeId),
|
||||
/// A node body: select (and possibly drag) the node.
|
||||
Node(NodeId),
|
||||
}
|
||||
|
||||
/// Transient state of a node move drag.
|
||||
struct NodeDragState {
|
||||
/// The nodes being moved (the full selection at drag start).
|
||||
nodes: Vec<NodeId>,
|
||||
/// Element-local cursor position where the drag started.
|
||||
anchor: Point<Pixels>,
|
||||
/// Accumulated graph-space displacement since drag start.
|
||||
delta: Point<Pixels>,
|
||||
}
|
||||
|
||||
/// Transient state of a wire drag (a "ghost" connection in progress).
|
||||
struct WireDragState {
|
||||
/// The ghost wire, anchored at the drag source port.
|
||||
ghost: GhostWire,
|
||||
/// The port the drag started from.
|
||||
source_port: PortId,
|
||||
/// When the drag picked up an existing edge (from a connected input), the
|
||||
/// edge id; dropping in empty space disconnects it.
|
||||
picked_edge: Option<EdgeId>,
|
||||
/// Ports currently approved as drop targets by
|
||||
/// [`NodeGraphDataSource::can_connect`].
|
||||
valid_ports: BTreeSet<PortId>,
|
||||
}
|
||||
|
||||
/// Transient state of a pan drag (space-drag or middle-mouse drag).
|
||||
struct PanDragState {
|
||||
/// Window-space cursor position where the pan started.
|
||||
start_mouse: Point<Pixels>,
|
||||
/// The viewport offset when the pan started.
|
||||
start_offset: Point<Pixels>,
|
||||
}
|
||||
|
||||
/// A snapshot of everything the view paints in one frame, computed in the
|
||||
/// canvas prepaint and consumed by the paint closure.
|
||||
struct GraphDraw {
|
||||
/// Node elements in paint order (bottom-most first), positioned in window
|
||||
/// space. Dragged nodes are painted last (on top).
|
||||
nodes: Vec<(Point<Pixels>, NodeElement)>,
|
||||
/// Edge wires, in graph order, positioned in window space.
|
||||
wires: Vec<Wire>,
|
||||
/// The in-progress ghost wire, if any, in window space.
|
||||
ghost: Option<GhostSnapshot>,
|
||||
/// The in-progress marquee rectangle, in element-local space.
|
||||
marquee: Option<SelectionRect>,
|
||||
/// The pan offset used to compute this frame.
|
||||
offset: Point<Pixels>,
|
||||
/// The zoom factor used to compute this frame.
|
||||
zoom: f32,
|
||||
}
|
||||
|
||||
/// A copy of a [`GhostWire`]'s geometry, stored in the frame snapshot so the
|
||||
/// paint closure does not need to borrow the view.
|
||||
struct GhostSnapshot {
|
||||
/// Window-space anchor of the fixed end.
|
||||
from: Point<Pixels>,
|
||||
/// Window-space position of the free (cursor) end.
|
||||
to: Point<Pixels>,
|
||||
/// Data-type tint of the source port.
|
||||
color: Hsla,
|
||||
/// Whether the current drop target is valid.
|
||||
target_valid: bool,
|
||||
}
|
||||
|
||||
/// Returns whether the two axis-aligned rectangles overlap (touching counts).
|
||||
fn rects_intersect(
|
||||
min1: Point<Pixels>,
|
||||
max1: Point<Pixels>,
|
||||
min2: Point<Pixels>,
|
||||
max2: Point<Pixels>,
|
||||
) -> bool {
|
||||
min1.x <= max2.x && min2.x <= max1.x && min1.y <= max2.y && min2.y <= max1.y
|
||||
}
|
||||
|
||||
/// Events emitted by [`NodeGraphView`].
|
||||
///
|
||||
/// **Every variant is a request, not a fact.** The widget never mutates the
|
||||
/// graph itself; the app receives these events, validates them against its
|
||||
/// engine and undo stack, applies them (or not), and calls `cx.notify()` on
|
||||
/// the data-source entity. Variants named `*Requested` correspond to undoable
|
||||
/// engine operations; the others are view-state notifications the app may
|
||||
/// ignore.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum NodeGraphEvent {
|
||||
/// Continuous preview emitted while the user drags one or more nodes:
|
||||
/// reports the *accumulated* graph-space delta since the drag started.
|
||||
///
|
||||
/// Emitted on every pointer move during a node drag, before the final
|
||||
/// [`NodeMoveRequested`](Self::NodeMoveRequested). Apps may use it for
|
||||
/// live feedback (e.g. snapping guides) but must not push undo states for
|
||||
/// it. The widget draws dragged nodes at their model position plus this
|
||||
/// delta, so the app does not need to apply it for the drag to look
|
||||
/// right.
|
||||
NodeMovePreview {
|
||||
/// The nodes being dragged (the full selection at drag start).
|
||||
nodes: Vec<NodeId>,
|
||||
/// Accumulated graph-space displacement since drag start.
|
||||
delta: Point<Pixels>,
|
||||
},
|
||||
|
||||
/// Emitted exactly once when a node drag ends (pointer release).
|
||||
///
|
||||
/// This is the undoable operation: the app should move all listed nodes
|
||||
/// by `delta` in graph space as a single undo step. `delta` is the same
|
||||
/// accumulated displacement reported by the last
|
||||
/// [`NodeMovePreview`](Self::NodeMovePreview) of this drag.
|
||||
NodeMoveRequested {
|
||||
/// The nodes to move (the full selection at drag start).
|
||||
nodes: Vec<NodeId>,
|
||||
/// Total graph-space displacement to apply.
|
||||
delta: Point<Pixels>,
|
||||
},
|
||||
|
||||
/// The user dropped a wire drag on a port and
|
||||
/// [`NodeGraphDataSource::can_connect`] approved the pair.
|
||||
///
|
||||
/// `from` is always the output port, `to` the input port, regardless of
|
||||
/// which end the drag started from. The app should still re-validate
|
||||
/// before applying — the model may have changed since the drag started.
|
||||
ConnectionRequested {
|
||||
/// The output port the connection starts at.
|
||||
from: PortId,
|
||||
/// The input port the connection ends at.
|
||||
to: PortId,
|
||||
},
|
||||
|
||||
/// The user asked to remove an existing edge (e.g. by clicking a wire
|
||||
/// with the disconnect modifier, or dragging a connected input's wire
|
||||
/// off into empty space).
|
||||
DisconnectionRequested {
|
||||
/// The edge to remove.
|
||||
edge: EdgeId,
|
||||
},
|
||||
|
||||
/// The user pressed the delete/backspace key with a non-empty selection.
|
||||
///
|
||||
/// Nodes and edges are delivered together so the app can remove them as
|
||||
/// one undo step. `edges` contains both explicitly selected edges and
|
||||
/// every edge incident to a deleted node (computed by the widget, since
|
||||
/// those edges cannot outlive their endpoints).
|
||||
DeleteRequested {
|
||||
/// The nodes to delete.
|
||||
nodes: Vec<NodeId>,
|
||||
/// The edges to delete, including edges incident to `nodes`.
|
||||
edges: Vec<EdgeId>,
|
||||
},
|
||||
|
||||
/// The selection changed. The full new selection is included so listeners
|
||||
/// do not need to track deltas. Oak uses this to keep the node graph and
|
||||
/// the [`crate::effect_stack`] selections in sync.
|
||||
SelectionChanged {
|
||||
/// The complete new selection.
|
||||
nodes: BTreeSet<NodeId>,
|
||||
},
|
||||
|
||||
/// The viewport (pan offset and/or zoom) changed. Emitted after the
|
||||
/// gesture that caused it completes — for a zoom-to-cursor scroll this is
|
||||
/// per scroll tick; apps that persist the viewport should debounce.
|
||||
ViewChanged {
|
||||
/// The new pan offset (screen-space position of the graph origin).
|
||||
offset: Point<Pixels>,
|
||||
/// The new zoom factor.
|
||||
zoom: f32,
|
||||
},
|
||||
|
||||
/// The user clicked (or released a cancelled wire drag on) empty
|
||||
/// background. `position` is the click position in *graph space*, ready
|
||||
/// to be used as the position of a newly created node. Oak opens its
|
||||
/// "add node" menu from this event.
|
||||
BackgroundClicked {
|
||||
/// Click position in graph space.
|
||||
position: Point<Pixels>,
|
||||
},
|
||||
}
|
||||
|
||||
use NodeGraphEvent::*;
|
||||
|
||||
/// The interactive node-graph editor view.
|
||||
///
|
||||
/// Generic over the app's data source `D`. Construct with
|
||||
/// [`NodeGraphView::new`], place the returned `Entity<NodeGraphView<D>>` in
|
||||
/// your layout, and [`cx.subscribe`](Context::subscribe) to
|
||||
/// [`NodeGraphEvent`] to receive edit requests.
|
||||
///
|
||||
/// # Interaction summary
|
||||
///
|
||||
/// | Gesture | Effect |
|
||||
/// |---|---|
|
||||
/// | Space-drag or middle-mouse drag on background | pan ([`NodeGraphEvent::ViewChanged`]) |
|
||||
/// | Scroll wheel / trackpad pinch | zoom at cursor ([`NodeGraphEvent::ViewChanged`]) |
|
||||
/// | Left-drag on a node | move the node — and the whole selection if the node was selected ([`NodeGraphEvent::NodeMovePreview`] × N, then [`NodeGraphEvent::NodeMoveRequested`]) |
|
||||
/// | Left-drag from a port dot | wire drag: compatible target ports highlight live via [`NodeGraphDataSource::can_connect`]; drop on a port emits [`NodeGraphEvent::ConnectionRequested`], drop on empty space cancels and emits [`NodeGraphEvent::BackgroundClicked`] so the app can offer an "add node" menu |
|
||||
/// | Left-drag on background | marquee selection ([`NodeGraphEvent::SelectionChanged`]) |
|
||||
/// | Click node | select it; Shift-click toggles it in the selection |
|
||||
/// | Delete / Backspace | [`NodeGraphEvent::DeleteRequested`] for the selection |
|
||||
///
|
||||
/// All mouse positions in events are in window space; hit testing and painting
|
||||
/// convert to element-local space by subtracting the viewport origin, which is
|
||||
/// captured each frame by the canvas prepaint.
|
||||
pub struct NodeGraphView<D: NodeGraphDataSource> {
|
||||
/// The app-supplied graph model. Read every frame; never mutated.
|
||||
data: Entity<D>,
|
||||
/// Viewport and selection state.
|
||||
state: GraphViewState,
|
||||
/// Focus handle for keyboard interactions (delete, future shortcuts).
|
||||
focus_handle: FocusHandle,
|
||||
/// The view's bounds within the window, set every frame by the canvas
|
||||
/// prepaint. Its origin converts between window-space and element-local
|
||||
/// coordinates.
|
||||
viewport: Bounds<Pixels>,
|
||||
/// In-progress node move drag, if any.
|
||||
node_drag: Option<NodeDragState>,
|
||||
/// In-progress wire drag, if any.
|
||||
wire_drag: Option<WireDragState>,
|
||||
/// In-progress pan drag, if any.
|
||||
pan_drag: Option<PanDragState>,
|
||||
/// Whether the space key is currently held down (space-drag pans).
|
||||
space_down: bool,
|
||||
}
|
||||
|
||||
impl<D: NodeGraphDataSource + 'static> NodeGraphView<D> {
|
||||
/// Creates a new node-graph view over the given data-source entity.
|
||||
///
|
||||
/// The view subscribes to the entity and re-renders whenever the app
|
||||
/// calls `cx.notify()` on it after applying (or rejecting) edit requests.
|
||||
pub fn new(data: Entity<D>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let focus_handle = cx.focus_handle();
|
||||
cx.observe(&data, |_, _, cx| cx.notify()).detach();
|
||||
Self {
|
||||
data,
|
||||
state: GraphViewState::new(),
|
||||
focus_handle,
|
||||
viewport: Bounds::new(point(px(0.0), px(0.0)), size(px(0.0), px(0.0))),
|
||||
node_drag: None,
|
||||
wire_drag: None,
|
||||
pan_drag: None,
|
||||
space_down: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current viewport/selection state.
|
||||
pub fn state(&self) -> &GraphViewState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the viewport/selection state, e.g. to
|
||||
/// restore a persisted viewport or to sync selection with
|
||||
/// [`crate::effect_stack`]. Does not emit events; call `cx.notify()` on
|
||||
/// the view entity afterwards if you changed anything.
|
||||
pub fn state_mut(&mut self) -> &mut GraphViewState {
|
||||
&mut self.state
|
||||
}
|
||||
|
||||
/// Returns the data-source entity this view renders.
|
||||
pub fn data(&self) -> &Entity<D> {
|
||||
&self.data
|
||||
}
|
||||
|
||||
/// Returns what is under `position` (in window space), or
|
||||
/// [`HitTarget::Background`]. Nodes are tested in reverse paint order so
|
||||
/// the topmost (last-painted) node wins.
|
||||
fn hit_test(&self, position: Point<Pixels>, cx: &App) -> HitTarget {
|
||||
let anchor = position - self.viewport.origin;
|
||||
let data = self.data.read(cx);
|
||||
for node in data.nodes().into_iter().rev() {
|
||||
let element = NodeElement::from_node(&node, NodeVisualState::default());
|
||||
let screen_pos = self.state.graph_to_screen(node.position());
|
||||
let bounds = Bounds::new(screen_pos, size(DEFAULT_NODE_WIDTH, element.height()));
|
||||
if bounds.contains(&anchor) {
|
||||
let local = anchor - screen_pos;
|
||||
if let Some(port) = element.port_at(local) {
|
||||
return HitTarget::Port(port);
|
||||
}
|
||||
if element.collapse_toggle_hit(local) || element.enable_toggle_hit(local) {
|
||||
return HitTarget::Toggle(node.id());
|
||||
}
|
||||
return HitTarget::Node(node.id());
|
||||
}
|
||||
}
|
||||
HitTarget::Background
|
||||
}
|
||||
|
||||
/// Handles a press on a node's body: updates the selection according to
|
||||
/// modifier keys (plain click selects exclusively, Shift toggles) and
|
||||
/// begins a potential node drag. Emits
|
||||
/// [`NodeGraphEvent::SelectionChanged`] when the selection changed.
|
||||
fn on_node_mouse_down(
|
||||
&mut self,
|
||||
node: NodeId,
|
||||
position: Point<Pixels>,
|
||||
toggle: bool,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if toggle {
|
||||
let mut new_selection = self.state.selection().clone();
|
||||
if !new_selection.remove(&node) {
|
||||
new_selection.insert(node);
|
||||
}
|
||||
self.set_selection_and_emit(new_selection, cx);
|
||||
} else if !self.state.is_selected(node) {
|
||||
self.set_selection_and_emit(BTreeSet::from([node]), cx);
|
||||
}
|
||||
self.node_drag = Some(NodeDragState {
|
||||
nodes: self.state.selection().iter().copied().collect(),
|
||||
anchor: position - self.viewport.origin,
|
||||
delta: point(px(0.0), px(0.0)),
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Handles pointer movement during a node drag: updates the accumulated
|
||||
/// drag delta in graph space and emits [`NodeGraphEvent::NodeMovePreview`].
|
||||
fn on_node_drag_move(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let drag = self.node_drag.as_mut().expect("node drag in progress");
|
||||
let cursor = window.mouse_position() - self.viewport.origin;
|
||||
drag.delta = self.state.screen_to_graph(cursor) - self.state.screen_to_graph(drag.anchor);
|
||||
let (nodes, delta) = (drag.nodes.clone(), drag.delta);
|
||||
cx.emit(NodeMovePreview { nodes, delta });
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Handles pointer release at the end of a node drag: emits the final
|
||||
/// [`NodeGraphEvent::NodeMoveRequested`] with the accumulated delta and
|
||||
/// clears the transient drag state.
|
||||
fn on_node_drag_end(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(drag) = self.node_drag.take() {
|
||||
if drag.delta != point(px(0.0), px(0.0)) {
|
||||
cx.emit(NodeMoveRequested {
|
||||
nodes: drag.nodes,
|
||||
delta: drag.delta,
|
||||
});
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Begins a wire drag from the given port. If the port is a connected
|
||||
/// input, the existing edge is "picked up" instead: its other end becomes
|
||||
/// the drag source and a [`NodeGraphEvent::DisconnectionRequested`] is
|
||||
/// emitted only if the drag ends without a new connection.
|
||||
fn begin_wire_drag(&mut self, port: PortId, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
let data = self.data.read(cx);
|
||||
let mut found: Option<(Point<Pixels>, Point<Pixels>, PortKind, Option<PortDataType>)> =
|
||||
None;
|
||||
for node in data.nodes() {
|
||||
let element = NodeElement::from_node(&node, NodeVisualState::default());
|
||||
if let Some(anchor) = element.port_anchor(port) {
|
||||
let kind = if node.inputs().into_iter().any(|p| p.id() == port) {
|
||||
PortKind::Input
|
||||
} else {
|
||||
PortKind::Output
|
||||
};
|
||||
let data_type = node
|
||||
.inputs()
|
||||
.into_iter()
|
||||
.chain(node.outputs())
|
||||
.find(|p| p.id() == port)
|
||||
.map(|p| p.data_type());
|
||||
found = Some((node.position(), anchor, kind, data_type));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (node_pos, anchor, kind, data_type) = match found {
|
||||
Some((node_pos, anchor, kind, Some(data_type))) => (node_pos, anchor, kind, data_type),
|
||||
_ => return,
|
||||
};
|
||||
let screen_anchor = self.viewport.origin + self.state.graph_to_screen(node_pos + anchor);
|
||||
|
||||
// Picking up an existing edge: only a connected input drag re-roots the
|
||||
// ghost at the far (output) end; an output drag always starts fresh.
|
||||
if kind == PortKind::Input {
|
||||
if let Some(edge) = data.edges().into_iter().find(|e| e.to_port() == port) {
|
||||
if let Some(from_node) =
|
||||
data.nodes().into_iter().find(|n| n.id() == edge.from_node())
|
||||
{
|
||||
let from_element =
|
||||
NodeElement::from_node(&from_node, NodeVisualState::default());
|
||||
if let Some(far_anchor) = from_element.port_anchor(edge.from_port()) {
|
||||
let far_screen = self.viewport.origin
|
||||
+ self.state.graph_to_screen(from_node.position() + far_anchor);
|
||||
if let Some(far_type) = from_node
|
||||
.outputs()
|
||||
.into_iter()
|
||||
.find(|p| p.id() == edge.from_port())
|
||||
.map(|p| p.data_type())
|
||||
{
|
||||
self.wire_drag = Some(WireDragState {
|
||||
ghost: GhostWire::new(far_screen, &far_type, true),
|
||||
source_port: port,
|
||||
picked_edge: Some(edge.id()),
|
||||
valid_ports: BTreeSet::new(),
|
||||
});
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.wire_drag = Some(WireDragState {
|
||||
ghost: GhostWire::new(screen_anchor, &data_type, kind == PortKind::Output),
|
||||
source_port: port,
|
||||
picked_edge: None,
|
||||
valid_ports: BTreeSet::new(),
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Updates the wire drag: moves the ghost wire's free end to the cursor
|
||||
/// and recomputes which ports are valid drop targets by calling
|
||||
/// [`NodeGraphDataSource::can_connect`] for each port of the opposite
|
||||
/// kind. Ports that pass are highlighted; the ghost wire is drawn in its
|
||||
/// invalid state while hovering a port that fails.
|
||||
fn update_wire_drag(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let data = self.data.read(cx);
|
||||
let drag = self.wire_drag.as_mut().expect("wire drag in progress");
|
||||
let from_output = drag.ghost.is_from_output();
|
||||
let source_port = drag.source_port;
|
||||
let picked_edge = drag.picked_edge;
|
||||
let output_id = if from_output {
|
||||
picked_edge
|
||||
.and_then(|edge_id| data.edges().into_iter().find(|e| e.id() == edge_id))
|
||||
.map(|edge| edge.from_port())
|
||||
.unwrap_or(source_port)
|
||||
} else {
|
||||
source_port
|
||||
};
|
||||
let cursor = window.mouse_position();
|
||||
let mut valid: BTreeSet<PortId> = BTreeSet::new();
|
||||
let mut snapped: Option<Point<Pixels>> = None;
|
||||
let mut target_valid = false;
|
||||
for node in data.nodes() {
|
||||
let element = NodeElement::from_node(&node, NodeVisualState::default());
|
||||
for port in node.inputs().into_iter().chain(node.outputs()) {
|
||||
let port_id = port.id();
|
||||
let candidate = if from_output {
|
||||
port.kind() == PortKind::Input && data.can_connect(output_id, port_id)
|
||||
} else {
|
||||
port.kind() == PortKind::Output && data.can_connect(port_id, source_port)
|
||||
};
|
||||
if candidate {
|
||||
valid.insert(port_id);
|
||||
}
|
||||
if let Some(anchor) = element.port_anchor(port_id) {
|
||||
let screen =
|
||||
self.viewport.origin + self.state.graph_to_screen(node.position() + anchor);
|
||||
let dx = screen.x.0 - cursor.x.0;
|
||||
let dy = screen.y.0 - cursor.y.0;
|
||||
if dx * dx + dy * dy <= PORT_GRAB_RADIUS.0 * PORT_GRAB_RADIUS.0 {
|
||||
snapped = Some(screen);
|
||||
target_valid = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drag.valid_ports = valid;
|
||||
drag.ghost.update(cursor, snapped, target_valid);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Ends the wire drag. On a compatible port: emits
|
||||
/// [`NodeGraphEvent::ConnectionRequested`]. On empty space: cancels and
|
||||
/// emits [`NodeGraphEvent::BackgroundClicked`] at the drop position so
|
||||
/// the app may open an "add node" menu pre-wired to the dragged port. On
|
||||
/// an incompatible port (or back on the source port): cancels silently.
|
||||
fn end_wire_drag(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let drag = match self.wire_drag.take() {
|
||||
Some(drag) => drag,
|
||||
None => return,
|
||||
};
|
||||
let data = self.data.read(cx);
|
||||
let from_output = drag.ghost.is_from_output();
|
||||
let source_port = drag.source_port;
|
||||
let picked_edge = drag.picked_edge;
|
||||
let output_id = if from_output {
|
||||
picked_edge
|
||||
.and_then(|edge_id| data.edges().into_iter().find(|e| e.id() == edge_id))
|
||||
.map(|edge| edge.from_port())
|
||||
.unwrap_or(source_port)
|
||||
} else {
|
||||
source_port
|
||||
};
|
||||
let cursor = window.mouse_position();
|
||||
let mut hit: Option<(PortId, bool)> = None;
|
||||
'ports: for node in data.nodes() {
|
||||
let element = NodeElement::from_node(&node, NodeVisualState::default());
|
||||
for port in node.inputs().into_iter().chain(node.outputs()) {
|
||||
let port_id = port.id();
|
||||
let candidate = if from_output {
|
||||
port.kind() == PortKind::Input && data.can_connect(output_id, port_id)
|
||||
} else {
|
||||
port.kind() == PortKind::Output && data.can_connect(port_id, source_port)
|
||||
};
|
||||
if let Some(anchor) = element.port_anchor(port_id) {
|
||||
let screen = self.viewport.origin
|
||||
+ self.state.graph_to_screen(node.position() + anchor);
|
||||
let dx = screen.x.0 - cursor.x.0;
|
||||
let dy = screen.y.0 - cursor.y.0;
|
||||
if dx * dx + dy * dy <= PORT_GRAB_RADIUS.0 * PORT_GRAB_RADIUS.0 {
|
||||
hit = Some((port_id, candidate));
|
||||
break 'ports;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match hit {
|
||||
Some((target, true)) if target != source_port => {
|
||||
cx.emit(ConnectionRequested {
|
||||
from: output_id,
|
||||
to: target,
|
||||
});
|
||||
}
|
||||
// An incompatible port or the port the drag started from: cancel.
|
||||
Some(_) => {}
|
||||
None => {
|
||||
if let Some(edge) = picked_edge {
|
||||
cx.emit(DisconnectionRequested { edge });
|
||||
} else {
|
||||
cx.emit(BackgroundClicked {
|
||||
position: self.state.screen_to_graph(cursor - self.viewport.origin),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Handles background presses: begins panning (space/middle button) or a
|
||||
/// marquee selection (left button), or emits
|
||||
/// [`NodeGraphEvent::BackgroundClicked`] on a right click.
|
||||
fn on_background_mouse_down(
|
||||
&mut self,
|
||||
position: Point<Pixels>,
|
||||
button: MouseButton,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let anchor = position - self.viewport.origin;
|
||||
if self.space_down || button == MouseButton::Middle {
|
||||
self.pan_drag = Some(PanDragState {
|
||||
start_mouse: position,
|
||||
start_offset: self.state.offset(),
|
||||
});
|
||||
}
|
||||
if button == MouseButton::Left {
|
||||
self.state.begin_marquee(anchor);
|
||||
}
|
||||
if button == MouseButton::Right {
|
||||
cx.emit(BackgroundClicked {
|
||||
position: self.state.screen_to_graph(anchor),
|
||||
});
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Handles pointer movement during a pan drag: repositions the viewport
|
||||
/// offset and emits [`NodeGraphEvent::ViewChanged`].
|
||||
fn on_pan_drag_move(&mut self, position: Point<Pixels>, cx: &mut Context<Self>) {
|
||||
let pan = self.pan_drag.as_ref().expect("pan drag in progress");
|
||||
let (start_mouse, start_offset) = (pan.start_mouse, pan.start_offset);
|
||||
self.state.set_offset(start_offset + (position - start_mouse));
|
||||
cx.emit(ViewChanged {
|
||||
offset: self.state.offset(),
|
||||
zoom: self.state.zoom(),
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Handles scroll-wheel and pinch gestures: zooms at the cursor via
|
||||
/// [`GraphViewState::zoom_at`] and emits [`NodeGraphEvent::ViewChanged`].
|
||||
fn on_scroll_or_pinch(&mut self, position: Point<Pixels>, factor: f32, cx: &mut Context<Self>) {
|
||||
self.state.zoom_at(position - self.viewport.origin, factor);
|
||||
cx.emit(ViewChanged {
|
||||
offset: self.state.offset(),
|
||||
zoom: self.state.zoom(),
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Handles the delete/backspace key: collects the selected nodes plus all
|
||||
/// edges incident to them and emits [`NodeGraphEvent::DeleteRequested`].
|
||||
/// Does nothing with an empty selection.
|
||||
fn on_delete_key(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nodes = self.state.selection().iter().copied().collect::<Vec<_>>();
|
||||
if nodes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let data = self.data.read(cx);
|
||||
let edges = data
|
||||
.edges()
|
||||
.into_iter()
|
||||
.filter(|edge| {
|
||||
nodes
|
||||
.iter()
|
||||
.any(|node| *node == edge.from_node() || *node == edge.to_node())
|
||||
})
|
||||
.map(|edge| edge.id())
|
||||
.collect::<Vec<_>>();
|
||||
cx.emit(DeleteRequested { nodes, edges });
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Emits [`NodeGraphEvent::SelectionChanged`] if `new` differs from the
|
||||
/// current selection, and stores `new`.
|
||||
fn set_selection_and_emit(&mut self, new: BTreeSet<NodeId>, cx: &mut Context<Self>) {
|
||||
if self.state.selection() == &new {
|
||||
return;
|
||||
}
|
||||
self.state.set_selection(new.clone());
|
||||
cx.emit(SelectionChanged { nodes: new });
|
||||
}
|
||||
|
||||
/// Ends a marquee drag: selects all nodes intersecting the rectangle, or
|
||||
/// treats the press as a plain background click (clear selection + emit
|
||||
/// [`NodeGraphEvent::BackgroundClicked`]) when the drag was too small to
|
||||
/// count.
|
||||
fn end_marquee_or_click(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
let rect = match self.state.end_marquee() {
|
||||
Some(rect) => rect,
|
||||
None => return,
|
||||
};
|
||||
let (min, max) = rect.normalized();
|
||||
let dragged = (max.x - min.x).0 >= MARQUEE_DRAG_THRESHOLD
|
||||
|| (max.y - min.y).0 >= MARQUEE_DRAG_THRESHOLD;
|
||||
if dragged {
|
||||
let g_min = self.state.screen_to_graph(min);
|
||||
let g_max = self.state.screen_to_graph(max);
|
||||
let data = self.data.read(cx);
|
||||
let mut new_selection = BTreeSet::new();
|
||||
for node in data.nodes() {
|
||||
let element = NodeElement::from_node(&node, NodeVisualState::default());
|
||||
let pos = node.position();
|
||||
if rects_intersect(
|
||||
g_min,
|
||||
g_max,
|
||||
pos,
|
||||
pos + point(DEFAULT_NODE_WIDTH, element.height()),
|
||||
) {
|
||||
new_selection.insert(node.id());
|
||||
}
|
||||
}
|
||||
self.set_selection_and_emit(new_selection, cx);
|
||||
} else {
|
||||
self.set_selection_and_emit(BTreeSet::new(), cx);
|
||||
cx.emit(BackgroundClicked {
|
||||
position: self.state.screen_to_graph(min),
|
||||
});
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Snapshot of the frame the canvas is about to paint: nodes (in paint
|
||||
/// order, dragged nodes last), wires, ghost wire and marquee, all in
|
||||
/// window space where applicable.
|
||||
fn build_draw(&self, cx: &mut Context<Self>) -> GraphDraw {
|
||||
let data = self.data.read(cx);
|
||||
let selection = self.state.selection().clone();
|
||||
let drag = self.node_drag.as_ref();
|
||||
let wire = self.wire_drag.as_ref();
|
||||
let viewport_origin = self.viewport.origin;
|
||||
let fallback = PortDataType::new("", hsla(0.0, 0.0, 0.5, 1.0));
|
||||
|
||||
let mut port_types: HashMap<PortId, PortDataType> = HashMap::new();
|
||||
let mut elements: HashMap<NodeId, (Point<Pixels>, NodeElement)> = HashMap::new();
|
||||
let mut order: Vec<NodeId> = Vec::new();
|
||||
let mut top: Vec<NodeId> = Vec::new();
|
||||
|
||||
for node in data.nodes() {
|
||||
let node_id = node.id();
|
||||
for port in node.inputs().into_iter().chain(node.outputs()) {
|
||||
port_types.insert(port.id(), port.data_type());
|
||||
}
|
||||
let has_compatible_port = wire.map_or(false, |w| {
|
||||
node.inputs()
|
||||
.into_iter()
|
||||
.chain(node.outputs())
|
||||
.any(|port| w.valid_ports.contains(&port.id()))
|
||||
});
|
||||
let element = NodeElement::from_node(
|
||||
&node,
|
||||
NodeVisualState {
|
||||
selected: selection.contains(&node_id),
|
||||
has_compatible_port,
|
||||
},
|
||||
);
|
||||
let mut pos = node.position();
|
||||
if let Some(d) = drag {
|
||||
if d.nodes.contains(&node_id) {
|
||||
pos = pos + d.delta;
|
||||
top.push(node_id);
|
||||
} else {
|
||||
order.push(node_id);
|
||||
}
|
||||
} else {
|
||||
order.push(node_id);
|
||||
}
|
||||
elements.insert(
|
||||
node_id,
|
||||
(viewport_origin + self.state.graph_to_screen(pos), element),
|
||||
);
|
||||
}
|
||||
order.extend(top);
|
||||
|
||||
let mut wires = Vec::new();
|
||||
for edge in data.edges() {
|
||||
let (from_pos, from_element) = match elements.get(&edge.from_node()) {
|
||||
Some(entry) => entry,
|
||||
None => continue,
|
||||
};
|
||||
let (to_pos, to_element) = match elements.get(&edge.to_node()) {
|
||||
Some(entry) => entry,
|
||||
None => continue,
|
||||
};
|
||||
let from_anchor = match from_element.port_anchor(edge.from_port()) {
|
||||
Some(anchor) => anchor,
|
||||
None => continue,
|
||||
};
|
||||
let to_anchor = match to_element.port_anchor(edge.to_port()) {
|
||||
Some(anchor) => anchor,
|
||||
None => continue,
|
||||
};
|
||||
let data_type = port_types
|
||||
.get(&edge.from_port())
|
||||
.or_else(|| port_types.get(&edge.to_port()))
|
||||
.unwrap_or(&fallback);
|
||||
let selected =
|
||||
selection.contains(&edge.from_node()) || selection.contains(&edge.to_node());
|
||||
let wire_state = if selected {
|
||||
WireVisualState::Selected
|
||||
} else {
|
||||
WireVisualState::Normal
|
||||
};
|
||||
wires.push(Wire::new(
|
||||
edge.id(),
|
||||
*from_pos + from_anchor,
|
||||
*to_pos + to_anchor,
|
||||
data_type,
|
||||
wire_state,
|
||||
));
|
||||
}
|
||||
|
||||
let nodes = order
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
elements
|
||||
.remove(&id)
|
||||
.expect("every painted node must have an element")
|
||||
})
|
||||
.collect();
|
||||
|
||||
let ghost = wire.map(|d| GhostSnapshot {
|
||||
from: d.ghost.source(),
|
||||
to: d.ghost.free_end(),
|
||||
color: d.ghost.color(),
|
||||
target_valid: d.ghost.is_target_valid(),
|
||||
});
|
||||
|
||||
GraphDraw {
|
||||
nodes,
|
||||
wires,
|
||||
ghost,
|
||||
marquee: self.state.marquee().copied(),
|
||||
offset: self.state.offset(),
|
||||
zoom: self.state.zoom(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Paints a [`GraphDraw`] snapshot: background, grid, wires, nodes, ghost
|
||||
/// wire and marquee overlay.
|
||||
fn paint_draw(draw: &GraphDraw, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
|
||||
let colors = cx.default_colors().clone();
|
||||
window.paint_quad(fill(bounds, Hsla::from(colors.background)));
|
||||
|
||||
// Grid lines. Lines are spaced GRID_SIZE graph pixels apart; a line
|
||||
// with graph coordinate k lands at screen x = offset.x + k*GRID_SIZE*zoom.
|
||||
let zoom = draw.zoom;
|
||||
let x0 = ((-draw.offset.x.0) / (GRID_SIZE * zoom)).floor() as i64;
|
||||
let x1 = ((bounds.size.width.0 - draw.offset.x.0) / (GRID_SIZE * zoom)).ceil() as i64;
|
||||
for k in x0..=x1 {
|
||||
let x = bounds.left() + px(k as f32 * GRID_SIZE * zoom + draw.offset.x.0);
|
||||
window.paint_quad(fill(
|
||||
Bounds::new(point(x, bounds.top()), size(px(1.0), bounds.size.height)),
|
||||
Hsla::from(colors.border).opacity(0.5),
|
||||
));
|
||||
}
|
||||
let y0 = ((-draw.offset.y.0) / (GRID_SIZE * zoom)).floor() as i64;
|
||||
let y1 = ((bounds.size.height.0 - draw.offset.y.0) / (GRID_SIZE * zoom)).ceil() as i64;
|
||||
for k in y0..=y1 {
|
||||
let y = bounds.top() + px(k as f32 * GRID_SIZE * zoom + draw.offset.y.0);
|
||||
window.paint_quad(fill(
|
||||
Bounds::new(point(bounds.left(), y), size(bounds.size.width, px(1.0))),
|
||||
Hsla::from(colors.border).opacity(0.5),
|
||||
));
|
||||
}
|
||||
|
||||
for wire in &draw.wires {
|
||||
wire.paint(window, zoom);
|
||||
}
|
||||
for (origin, element) in &draw.nodes {
|
||||
element.paint(*origin, window, cx);
|
||||
}
|
||||
if let Some(ghost) = &draw.ghost {
|
||||
paint_ghost(window, ghost.from, ghost.to, ghost.color, ghost.target_valid, zoom);
|
||||
}
|
||||
if let Some(marquee) = &draw.marquee {
|
||||
let (min, max) = marquee.normalized();
|
||||
let marquee_bounds = Bounds::from_corners(bounds.origin + min, bounds.origin + max);
|
||||
window.paint_quad(fill(
|
||||
marquee_bounds,
|
||||
Hsla::from(colors.selected).opacity(0.15),
|
||||
));
|
||||
window.paint_quad(PaintQuad {
|
||||
bounds: marquee_bounds,
|
||||
corner_radii: Corners::all(px(0.0)),
|
||||
background: hsla(0.0, 0.0, 0.0, 0.0).into(),
|
||||
border_widths: Edges::all(px(1.0)),
|
||||
border_color: Hsla::from(colors.selected),
|
||||
border_style: BorderStyle::Solid,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<D: NodeGraphDataSource + 'static> EventEmitter<NodeGraphEvent> for NodeGraphView<D> {}
|
||||
|
||||
impl<D: NodeGraphDataSource + 'static> Focusable for NodeGraphView<D> {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl<D: NodeGraphDataSource + 'static> Render for NodeGraphView<D> {
|
||||
/// Renders the graph: a full-size background layer (grid + pan/zoom
|
||||
/// handlers), then wires below nodes in graph-space order, then the
|
||||
/// marquee rectangle and the ghost wire as overlays.
|
||||
///
|
||||
/// Layout/painting is done in screen space; node and wire geometry is
|
||||
/// computed by mapping graph-space model coordinates through
|
||||
/// [`GraphViewState::graph_to_screen`]. Wire anchors come from
|
||||
/// [`NodeElement::port_anchor`](crate::node_graph::NodeElement::port_anchor)
|
||||
/// so wires always land on port dots.
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
let entity = cx.entity();
|
||||
|
||||
div()
|
||||
.relative()
|
||||
.size_full()
|
||||
.track_focus(&focus_handle)
|
||||
.on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| {
|
||||
if event.keystroke.key == "space" {
|
||||
this.space_down = true;
|
||||
} else if event.keystroke.key == "delete" || event.keystroke.key == "backspace" {
|
||||
this.on_delete_key(window, cx);
|
||||
}
|
||||
}))
|
||||
.on_key_up(cx.listener(|this, event: &KeyUpEvent, _window, _cx| {
|
||||
if event.keystroke.key == "space" {
|
||||
this.space_down = false;
|
||||
}
|
||||
}))
|
||||
.on_mouse_down(MouseButton::Left, cx.listener(|this, event: &MouseDownEvent, window, cx| {
|
||||
window.focus(&this.focus_handle, cx);
|
||||
match this.hit_test(event.position, cx) {
|
||||
HitTarget::Port(port) => this.begin_wire_drag(port, window, cx),
|
||||
HitTarget::Toggle(node) => {
|
||||
this.set_selection_and_emit(BTreeSet::from([node]), cx);
|
||||
cx.notify();
|
||||
}
|
||||
HitTarget::Node(node) => {
|
||||
this.on_node_mouse_down(node, event.position, event.modifiers.shift, window, cx);
|
||||
}
|
||||
HitTarget::Background => {
|
||||
this.on_background_mouse_down(event.position, MouseButton::Left, window, cx);
|
||||
}
|
||||
}
|
||||
}))
|
||||
.on_mouse_down(MouseButton::Middle, cx.listener(|this, event: &MouseDownEvent, window, cx| {
|
||||
window.focus(&this.focus_handle, cx);
|
||||
this.on_background_mouse_down(event.position, MouseButton::Middle, window, cx);
|
||||
}))
|
||||
.on_mouse_down(MouseButton::Right, cx.listener(|this, event: &MouseDownEvent, window, cx| {
|
||||
window.focus(&this.focus_handle, cx);
|
||||
if this.hit_test(event.position, cx) == HitTarget::Background {
|
||||
this.on_background_mouse_down(event.position, MouseButton::Right, window, cx);
|
||||
}
|
||||
}))
|
||||
.on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, window, cx| {
|
||||
if this.node_drag.is_some() {
|
||||
this.on_node_drag_move(window, cx);
|
||||
} else if this.wire_drag.is_some() {
|
||||
this.update_wire_drag(window, cx);
|
||||
} else if this.pan_drag.is_some() {
|
||||
this.on_pan_drag_move(event.position, cx);
|
||||
} else if this.state.marquee().is_some() {
|
||||
this.state.update_marquee(event.position - this.viewport.origin);
|
||||
cx.notify();
|
||||
}
|
||||
}))
|
||||
.capture_any_mouse_up(cx.listener(|this, _event, window, cx| {
|
||||
if this.node_drag.is_some() {
|
||||
this.on_node_drag_end(window, cx);
|
||||
} else if this.wire_drag.is_some() {
|
||||
this.end_wire_drag(window, cx);
|
||||
} else if this.pan_drag.is_some() {
|
||||
this.pan_drag = None;
|
||||
cx.notify();
|
||||
} else {
|
||||
this.end_marquee_or_click(window, cx);
|
||||
}
|
||||
}))
|
||||
.on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _window, cx| {
|
||||
let factor = match event.delta {
|
||||
ScrollDelta::Pixels(delta) => 1.0 + delta.y.0 * 0.002,
|
||||
ScrollDelta::Lines(lines) => 1.0 + lines.y * 0.1,
|
||||
};
|
||||
this.on_scroll_or_pinch(event.position, factor, cx);
|
||||
}))
|
||||
.on_pinch(cx.listener(|this, event: &PinchEvent, _window, cx| {
|
||||
this.on_scroll_or_pinch(event.position, 1.0 + event.delta, cx);
|
||||
}))
|
||||
.child(canvas(
|
||||
move |bounds, _window, cx| {
|
||||
entity.update(cx, |this, cx| {
|
||||
this.viewport = bounds;
|
||||
this.build_draw(cx)
|
||||
})
|
||||
},
|
||||
move |bounds, draw: GraphDraw, window, cx| {
|
||||
NodeGraphView::<D>::paint_draw(&draw, bounds, window, cx);
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Optional minimap overlay for the node graph.
|
||||
//!
|
||||
//! The minimap is a small corner overlay showing a scaled-down viewport
|
||||
//! indicator: a translucent backdrop plus a rectangle marking the region of
|
||||
//! the graph currently visible, derived from the view's pan offset and zoom.
|
||||
//!
|
||||
//! **Scope note.** The minimap is intentionally minimal: it draws no node
|
||||
//! rectangles and supports no click/drag navigation, because [`render`]
|
||||
//! receives neither the data source nor a mutable view state — it only
|
||||
//! reflects the viewport. [`graph_bounds`] reserves the bounding-box
|
||||
//! computation a future content-aware minimap would need; it is not yet wired
|
||||
//! in.
|
||||
|
||||
// `graph_bounds` reserves the bounding-box computation for a future
|
||||
// content-aware minimap; it has no caller yet.
|
||||
#![allow(dead_code)]
|
||||
// The `D` parameter is part of the render contract (the data source the
|
||||
// minimap would read for node rectangles); it is unused until that feature
|
||||
// lands.
|
||||
#![allow(clippy::extra_unused_type_parameters)]
|
||||
|
||||
use crate::{Bounds, Empty, IntoElement, Pixels, Point, Window, canvas, deferred, fill, hsla, point, px, size};
|
||||
|
||||
use crate::node_graph::{
|
||||
DEFAULT_NODE_WIDTH, GraphViewState, NodeElement, NodeGraphDataSource, NodeData, NodeVisualState,
|
||||
};
|
||||
|
||||
/// Scale factor from graph-space coordinates to minimap coordinates.
|
||||
pub const MINIMAP_CONTENT_SCALE: f32 = 0.15;
|
||||
|
||||
/// A small overview map of the entire graph, drawn as a corner overlay.
|
||||
///
|
||||
/// Renders a translucent backdrop and a highlight rectangle indicating the
|
||||
/// currently visible region (derived from the view's offset and zoom). The
|
||||
/// minimap draws no node rectangles and does not react to clicks; it is a
|
||||
/// passive viewport indicator.
|
||||
pub struct GraphMinimap {
|
||||
/// Whether the minimap is shown. Toggled by the app's view menu; the
|
||||
/// minimap renders nothing and ignores input when `false`.
|
||||
visible: bool,
|
||||
}
|
||||
|
||||
impl Default for GraphMinimap {
|
||||
fn default() -> Self {
|
||||
Self { visible: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl GraphMinimap {
|
||||
/// Creates a visible minimap overlay.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Shows or hides the minimap.
|
||||
pub fn set_visible(&mut self, visible: bool) {
|
||||
self.visible = visible;
|
||||
}
|
||||
|
||||
/// Returns whether the minimap is currently shown.
|
||||
pub fn is_visible(&self) -> bool {
|
||||
self.visible
|
||||
}
|
||||
|
||||
/// Computes the axis-aligned bounding box of all nodes in graph space,
|
||||
/// used as the minimap's content rect. Returns `None` for an empty
|
||||
/// graph (the minimap then renders only its backdrop).
|
||||
///
|
||||
/// Node extents are derived from each node's position plus its rendered
|
||||
/// card size ([`DEFAULT_NODE_WIDTH`] × [`NodeElement::height`]).
|
||||
fn graph_bounds<D: NodeGraphDataSource>(data: &D) -> Option<(Point<Pixels>, Point<Pixels>)> {
|
||||
let mut nodes = data.nodes().into_iter();
|
||||
let first = nodes.next()?;
|
||||
let extent = |node: &D::Node| {
|
||||
let pos = node.position();
|
||||
(
|
||||
pos,
|
||||
pos + point(
|
||||
DEFAULT_NODE_WIDTH,
|
||||
NodeElement::from_node(node, NodeVisualState::default()).height(),
|
||||
),
|
||||
)
|
||||
};
|
||||
let (mut min, mut max) = extent(&first);
|
||||
for node in nodes {
|
||||
let (node_min, node_max) = extent(&node);
|
||||
min = min.min(&node_min);
|
||||
max = max.max(&node_max);
|
||||
}
|
||||
Some((min, max))
|
||||
}
|
||||
|
||||
/// Renders the minimap: a translucent backdrop with a viewport indicator
|
||||
/// rectangle, laid out over `viewport_bounds` (the main view's
|
||||
/// screen-space bounds).
|
||||
///
|
||||
/// The viewport rectangle is derived from `state` (offset + zoom): the
|
||||
/// screen-space viewport is mapped back into graph space (`-offset / zoom`
|
||||
/// plus `viewport size / zoom`) and then down to minimap scale.
|
||||
pub fn render<D: NodeGraphDataSource>(
|
||||
&mut self,
|
||||
state: &GraphViewState,
|
||||
viewport_bounds: Bounds<Pixels>,
|
||||
_window: &mut Window,
|
||||
) -> impl IntoElement {
|
||||
if !self.visible {
|
||||
return deferred(Empty);
|
||||
}
|
||||
let offset = state.offset();
|
||||
let zoom = state.zoom();
|
||||
deferred(canvas(
|
||||
move |_bounds, _window, _cx| MinimapDraw {
|
||||
offset,
|
||||
zoom,
|
||||
viewport_bounds,
|
||||
},
|
||||
move |bounds, draw, window, _cx| {
|
||||
// Backdrop.
|
||||
window.paint_quad(fill(bounds, hsla(0.0, 0.0, 0.0, 0.6)));
|
||||
|
||||
// Viewport indicator: the graph-space viewport rect (screen
|
||||
// size scaled back through `zoom`) mapped down to minimap
|
||||
// scale, positioned at `-offset / zoom`.
|
||||
let scale = MINIMAP_CONTENT_SCALE;
|
||||
let origin = bounds.origin
|
||||
+ point(
|
||||
px(-(draw.offset.x.0 / draw.zoom) * scale),
|
||||
px(-(draw.offset.y.0 / draw.zoom) * scale),
|
||||
);
|
||||
let vp_size = size(
|
||||
px(draw.viewport_bounds.size.width.0 / draw.zoom * scale),
|
||||
px(draw.viewport_bounds.size.height.0 / draw.zoom * scale),
|
||||
);
|
||||
window.paint_quad(fill(
|
||||
Bounds::new(origin, vp_size),
|
||||
hsla(0.63, 0.55, 0.55, 0.5),
|
||||
));
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame snapshot passed from the canvas prepaint to its paint closure.
|
||||
#[derive(Clone, Copy)]
|
||||
struct MinimapDraw {
|
||||
/// Pan offset (screen-space position of the graph origin).
|
||||
offset: Point<Pixels>,
|
||||
/// Zoom factor.
|
||||
zoom: f32,
|
||||
/// The main view's screen-space bounds.
|
||||
viewport_bounds: Bounds<Pixels>,
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Node-graph editor widget.
|
||||
//!
|
||||
//! This module provides a data-agnostic, interactive node-graph editor in the
|
||||
//! style of compositing / video-editing tools (Nuke, Blender, DaVinci Fusion).
|
||||
//! It is built for the Oak video editor but knows nothing about Oak's engine:
|
||||
//! all graph data is supplied by the embedding application through traits, and
|
||||
//! every user edit is surfaced as a *request* event rather than being applied
|
||||
//! directly.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! - **Trait-driven data source.** The widget never owns graph data. The app
|
||||
//! implements [`NodeGraphDataSource`], [`NodeData`], [`PortData`] and
|
||||
//! [`EdgeData`] (see [`data`](crate::node_graph::data)) over its own model and hands the view an
|
||||
//! `Entity<D>`. After the app mutates its model it calls `cx.notify()` on
|
||||
//! the data entity and the view re-reads everything on the next frame.
|
||||
//! - **Canvas with pan/zoom.** [`GraphViewState`] (see [`state`](crate::node_graph::state)) holds the
|
||||
//! viewport (`offset`, `zoom`) and the current selection, plus the pure
|
||||
//! coordinate transforms between *graph space* (the document coordinate
|
||||
//! system node positions live in) and *screen space*.
|
||||
//! - **Edits as requests.** Moving nodes, connecting ports, deleting items —
|
||||
//! none of these mutate the graph directly. The view emits
|
||||
//! [`NodeGraphEvent`]s (see [`graph_view`](crate::node_graph::graph_view)); the app validates them against
|
||||
//! its engine and its undo stack, applies them, and notifies. This keeps the
|
||||
//! app's engine the single source of truth and makes undo/redo trivial.
|
||||
//! - **App-supplied connection rules.** Type compatibility, cycle prevention
|
||||
//! and port cardinality are enforced by the app via
|
||||
//! [`NodeGraphDataSource::can_connect`]. The widget calls it live during
|
||||
//! wire drags to highlight valid drop targets, and again on drop before
|
||||
//! emitting [`NodeGraphEvent::ConnectionRequested`].
|
||||
//!
|
||||
//! # Submodules
|
||||
//!
|
||||
//! - [`data`](crate::node_graph::data) — identifier newtypes and the data-source traits.
|
||||
//! - [`state`](crate::node_graph::state) — viewport/selection state and coordinate math.
|
||||
//! - [`graph_view`](crate::node_graph::graph_view) — the [`NodeGraphView`] view and [`NodeGraphEvent`].
|
||||
//! - [`node_element`](crate::node_graph::node_element) — rendering of a single node card.
|
||||
//! - [`wire`](crate::node_graph::wire) — bezier wire rendering, including the drag "ghost" wire.
|
||||
//! - [`minimap`](crate::node_graph::minimap) — overview minimap (backdrop + viewport indicator).
|
||||
//!
|
||||
//! # Wiring into Oak
|
||||
//!
|
||||
//! Oak's engine (`oakengine`) owns the real node graph (media → transform →
|
||||
//! OCIO LUT → output, …). The intended integration:
|
||||
//!
|
||||
//! | Widget event | Engine operation |
|
||||
//! |---|---|
|
||||
//! | [`NodeGraphEvent::NodeMovePreview`] / [`NodeGraphEvent::NodeMoveRequested`] | transient UI feedback / `engine.move_nodes(...)` wrapped in an undo command |
|
||||
//! | [`NodeGraphEvent::ConnectionRequested`] | `engine.connect(from, to)` (engine re-validates type & cycle rules) |
|
||||
//! | [`NodeGraphEvent::DisconnectionRequested`] | `engine.disconnect(edge)` |
|
||||
//! | [`NodeGraphEvent::DeleteRequested`] | `engine.remove(nodes, edges)` as one undo step |
|
||||
//! | [`NodeGraphEvent::BackgroundClicked`] | open the "add node" menu at the given graph position |
|
||||
//!
|
||||
//! The companion [`crate::effect_stack`] module shows the *same* engine graph
|
||||
//! as a linear effect stack. The two views are exactly that — two views over
|
||||
//! one model: they share the engine's node identities ([`NodeId`] is typically
|
||||
//! a newtype over the engine's node key), so selection sync between them is a
|
||||
//! matter of storing one shared selection set in the app, not of data
|
||||
//! conversion. Edits made in either view go through the same engine ops and
|
||||
//! undo stack.
|
||||
//!
|
||||
//! [`NodeGraphDataSource`]: crate::node_graph::NodeGraphDataSource
|
||||
//! [`NodeGraphDataSource::can_connect`]: crate::node_graph::NodeGraphDataSource::can_connect
|
||||
//! [`NodeData`]: crate::node_graph::NodeData
|
||||
//! [`PortData`]: crate::node_graph::PortData
|
||||
//! [`EdgeData`]: crate::node_graph::EdgeData
|
||||
//! [`NodeId`]: crate::node_graph::NodeId
|
||||
//! [`GraphViewState`]: crate::node_graph::GraphViewState
|
||||
//! [`NodeGraphView`]: crate::node_graph::NodeGraphView
|
||||
//! [`NodeGraphEvent`]: crate::node_graph::NodeGraphEvent
|
||||
//! [`NodeGraphEvent::NodeMovePreview`]: crate::node_graph::NodeGraphEvent::NodeMovePreview
|
||||
//! [`NodeGraphEvent::NodeMoveRequested`]: crate::node_graph::NodeGraphEvent::NodeMoveRequested
|
||||
//! [`NodeGraphEvent::ConnectionRequested`]: crate::node_graph::NodeGraphEvent::ConnectionRequested
|
||||
//! [`NodeGraphEvent::DisconnectionRequested`]: crate::node_graph::NodeGraphEvent::DisconnectionRequested
|
||||
//! [`NodeGraphEvent::DeleteRequested`]: crate::node_graph::NodeGraphEvent::DeleteRequested
|
||||
//! [`NodeGraphEvent::BackgroundClicked`]: crate::node_graph::NodeGraphEvent::BackgroundClicked
|
||||
|
||||
pub mod data;
|
||||
pub mod graph_view;
|
||||
pub mod minimap;
|
||||
pub mod node_element;
|
||||
pub mod state;
|
||||
pub mod wire;
|
||||
|
||||
pub use data::*;
|
||||
pub use graph_view::*;
|
||||
pub use minimap::*;
|
||||
pub use node_element::*;
|
||||
pub use state::*;
|
||||
pub use wire::*;
|
||||
@@ -0,0 +1,414 @@
|
||||
//! 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);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Viewport and selection state for the node-graph editor.
|
||||
//!
|
||||
//! [`GraphViewState`] owns everything about *how* the graph is looked at —
|
||||
//! pan offset, zoom, selection, marquee — and nothing about the graph itself.
|
||||
//! The coordinate transforms here are pure and implemented; they are the
|
||||
//! single source of truth for the graph-space ↔ screen-space mapping used by
|
||||
//! node rendering, wire anchoring and hit testing alike.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::{Pixels, Point, point};
|
||||
|
||||
use crate::node_graph::NodeId;
|
||||
|
||||
/// Minimum zoom factor accepted by [`GraphViewState::set_zoom`] and
|
||||
/// [`GraphViewState::zoom_at`]: the graph is shown at 10% scale.
|
||||
pub const MIN_ZOOM: f32 = 0.1;
|
||||
|
||||
/// Maximum zoom factor accepted by [`GraphViewState::set_zoom`] and
|
||||
/// [`GraphViewState::zoom_at`]: the graph is shown at 400% scale.
|
||||
pub const MAX_ZOOM: f32 = 4.0;
|
||||
|
||||
/// Pan/zoom viewport and selection state of a
|
||||
/// [`NodeGraphView`](crate::node_graph::NodeGraphView).
|
||||
///
|
||||
/// # Coordinate spaces
|
||||
///
|
||||
/// - *Graph space* is the unbounded document coordinate system that
|
||||
/// [`NodeData::position`](crate::node_graph::NodeData::position) returns.
|
||||
/// - *Screen space* is the element-local pixel coordinate system used for
|
||||
/// painting and hit testing, with the origin at the top-left corner of the
|
||||
/// graph view.
|
||||
///
|
||||
/// The mapping is an affine transform with no rotation:
|
||||
///
|
||||
/// ```text
|
||||
/// screen = graph * zoom + offset
|
||||
/// graph = (screen - offset) / zoom
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GraphViewState {
|
||||
/// Pan offset in screen space: the screen-space position of the graph
|
||||
/// origin. Positive values move the graph content down-right.
|
||||
offset: Point<Pixels>,
|
||||
/// Zoom factor, always within [`MIN_ZOOM`]..=[`MAX_ZOOM`]. `1.0` is 100%.
|
||||
zoom: f32,
|
||||
/// The currently selected nodes. Kept sorted (B-Tree) so that
|
||||
/// `SelectionChanged` events are deterministic and cheap to diff.
|
||||
selection: BTreeSet<NodeId>,
|
||||
/// An in-progress marquee (rubber-band) selection rectangle, in screen
|
||||
/// space, if the user is currently dragging one.
|
||||
marquee: Option<SelectionRect>,
|
||||
}
|
||||
|
||||
impl Default for GraphViewState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
offset: point(Pixels::ZERO, Pixels::ZERO),
|
||||
zoom: 1.0,
|
||||
selection: BTreeSet::new(),
|
||||
marquee: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GraphViewState {
|
||||
/// Creates a fresh view state: no pan, 100% zoom, empty selection.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Returns the current pan offset (the screen-space position of the
|
||||
/// graph origin).
|
||||
pub fn offset(&self) -> Point<Pixels> {
|
||||
self.offset
|
||||
}
|
||||
|
||||
/// Sets the pan offset directly. No clamping is applied — the graph is
|
||||
/// unbounded.
|
||||
pub fn set_offset(&mut self, offset: Point<Pixels>) {
|
||||
self.offset = offset;
|
||||
}
|
||||
|
||||
/// Pans the view by a screen-space delta (typically a drag delta).
|
||||
pub fn pan_by(&mut self, delta: Point<Pixels>) {
|
||||
self.offset = self.offset + delta;
|
||||
}
|
||||
|
||||
/// Returns the current zoom factor, guaranteed within
|
||||
/// [`MIN_ZOOM`]..=[`MAX_ZOOM`].
|
||||
pub fn zoom(&self) -> f32 {
|
||||
self.zoom
|
||||
}
|
||||
|
||||
/// Sets the zoom factor, clamped to [`MIN_ZOOM`]..=[`MAX_ZOOM`].
|
||||
///
|
||||
/// Unlike [`zoom_at`](Self::zoom_at) this does not preserve any anchor
|
||||
/// point; the graph origin stays put and content scales around it.
|
||||
pub fn set_zoom(&mut self, zoom: f32) {
|
||||
self.zoom = zoom.clamp(MIN_ZOOM, MAX_ZOOM);
|
||||
}
|
||||
|
||||
/// Zooms by `factor` (e.g. `1.1` per scroll step) while keeping the
|
||||
/// graph point under `anchor` (a screen-space position, usually the
|
||||
/// cursor) stationary on screen.
|
||||
///
|
||||
/// # Math contract
|
||||
///
|
||||
/// Let `z` be the old zoom and `z' = clamp(z * factor, MIN_ZOOM,
|
||||
/// MAX_ZOOM)` the new one. The offset is adjusted so that
|
||||
/// `graph_to_screen(g)` is identical before and after for the graph point
|
||||
/// `g = screen_to_graph(anchor)`:
|
||||
///
|
||||
/// ```text
|
||||
/// offset' = anchor - (anchor - offset) * (z' / z)
|
||||
/// ```
|
||||
///
|
||||
/// When the zoom is clamped (already at the min/max), `z' == z` and the
|
||||
/// offset is left untouched — the call is then a no-op.
|
||||
pub fn zoom_at(&mut self, anchor: Point<Pixels>, factor: f32) {
|
||||
let new_zoom = (self.zoom * factor).clamp(MIN_ZOOM, MAX_ZOOM);
|
||||
if new_zoom == self.zoom {
|
||||
return;
|
||||
}
|
||||
let scale = new_zoom / self.zoom;
|
||||
self.offset = point(
|
||||
anchor.x - (anchor.x - self.offset.x) * scale,
|
||||
anchor.y - (anchor.y - self.offset.y) * scale,
|
||||
);
|
||||
self.zoom = new_zoom;
|
||||
}
|
||||
|
||||
/// Maps a graph-space (document) point to screen space:
|
||||
/// `screen = graph * zoom + offset`.
|
||||
pub fn graph_to_screen(&self, graph: Point<Pixels>) -> Point<Pixels> {
|
||||
point(
|
||||
graph.x * self.zoom + self.offset.x,
|
||||
graph.y * self.zoom + self.offset.y,
|
||||
)
|
||||
}
|
||||
|
||||
/// Maps a screen-space point to graph space:
|
||||
/// `graph = (screen - offset) / zoom`. This is the exact inverse of
|
||||
/// [`graph_to_screen`](Self::graph_to_screen).
|
||||
pub fn screen_to_graph(&self, screen: Point<Pixels>) -> Point<Pixels> {
|
||||
point(
|
||||
(screen.x - self.offset.x) / self.zoom,
|
||||
(screen.y - self.offset.y) / self.zoom,
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the set of currently selected nodes.
|
||||
pub fn selection(&self) -> &BTreeSet<NodeId> {
|
||||
&self.selection
|
||||
}
|
||||
|
||||
/// Returns whether the given node is currently selected.
|
||||
pub fn is_selected(&self, node: NodeId) -> bool {
|
||||
self.selection.contains(&node)
|
||||
}
|
||||
|
||||
/// Replaces the selection with exactly the given nodes.
|
||||
///
|
||||
/// The view compares before/after and emits
|
||||
/// [`NodeGraphEvent::SelectionChanged`](crate::node_graph::NodeGraphEvent::SelectionChanged)
|
||||
/// when the set actually changed; calling this directly does not emit
|
||||
/// events on its own.
|
||||
pub fn set_selection(&mut self, nodes: impl IntoIterator<Item = NodeId>) {
|
||||
self.selection = nodes.into_iter().collect();
|
||||
}
|
||||
|
||||
/// Adds `node` to the selection (shift-click semantics).
|
||||
pub fn select(&mut self, node: NodeId) {
|
||||
self.selection.insert(node);
|
||||
}
|
||||
|
||||
/// Removes `node` from the selection; returns whether it was selected.
|
||||
pub fn deselect(&mut self, node: NodeId) -> bool {
|
||||
self.selection.remove(&node)
|
||||
}
|
||||
|
||||
/// Toggles `node` in the selection (shift-click toggle semantics).
|
||||
pub fn toggle_selection(&mut self, node: NodeId) {
|
||||
if !self.deselect(node) {
|
||||
self.select(node);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears the selection.
|
||||
pub fn clear_selection(&mut self) {
|
||||
self.selection.clear();
|
||||
}
|
||||
|
||||
/// Returns the in-progress marquee selection rectangle, if any.
|
||||
pub fn marquee(&self) -> Option<&SelectionRect> {
|
||||
self.marquee.as_ref()
|
||||
}
|
||||
|
||||
/// Begins a marquee selection anchored at the given screen-space point.
|
||||
pub fn begin_marquee(&mut self, anchor: Point<Pixels>) {
|
||||
self.marquee = Some(SelectionRect {
|
||||
anchor,
|
||||
current: anchor,
|
||||
});
|
||||
}
|
||||
|
||||
/// Updates the current corner of the in-progress marquee. Does nothing if
|
||||
/// no marquee is in progress.
|
||||
pub fn update_marquee(&mut self, current: Point<Pixels>) {
|
||||
if let Some(marquee) = &mut self.marquee {
|
||||
marquee.current = current;
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends the marquee and returns it, or `None` if none was in progress.
|
||||
///
|
||||
/// The caller (the view) converts the rect to graph space and selects all
|
||||
/// nodes intersecting it.
|
||||
pub fn end_marquee(&mut self) -> Option<SelectionRect> {
|
||||
self.marquee.take()
|
||||
}
|
||||
}
|
||||
|
||||
/// A marquee (rubber-band) selection rectangle in screen space.
|
||||
///
|
||||
/// The rectangle is defined by the point where the drag started and the
|
||||
/// current cursor position; use [`normalized`](Self::normalized) to obtain a
|
||||
/// well-ordered rect regardless of drag direction.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SelectionRect {
|
||||
/// The screen-space point where the marquee drag started.
|
||||
pub anchor: Point<Pixels>,
|
||||
/// The current screen-space corner (usually the cursor position).
|
||||
pub current: Point<Pixels>,
|
||||
}
|
||||
|
||||
impl SelectionRect {
|
||||
/// Returns the axis-aligned rectangle with `min` as the top-left and
|
||||
/// `max` as the bottom-right corner, independent of drag direction.
|
||||
pub fn normalized(&self) -> (Point<Pixels>, Point<Pixels>) {
|
||||
let min = point(self.anchor.x.min(self.current.x), self.anchor.y.min(self.current.y));
|
||||
let max = point(self.anchor.x.max(self.current.x), self.anchor.y.max(self.current.y));
|
||||
(min, max)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
//! Wire (edge) rendering for the node graph.
|
||||
//!
|
||||
//! Wires are cubic bezier curves drawn with [`PathBuilder`](crate::PathBuilder), anchored at port
|
||||
//! dot centers (see [`NodeElement::port_anchor`]). This module also covers
|
||||
//! the transient "ghost" wire shown while the user drags a connection.
|
||||
//!
|
||||
//! [`NodeElement::port_anchor`]: crate::node_graph::NodeElement::port_anchor
|
||||
|
||||
use crate::{Hsla, Path, PathBuilder, Pixels, Point, Window, hsla, point, px};
|
||||
|
||||
use crate::node_graph::{EdgeId, PortDataType};
|
||||
|
||||
/// Horizontal distance the bezier control points are pushed out from the
|
||||
/// endpoints. Larger values make wires leave ports more "horizontally" and
|
||||
/// sag less. Scaled by zoom so screen-space curvature stays constant.
|
||||
pub const WIRE_CURVATURE: Pixels = px(60.0);
|
||||
|
||||
/// The visual state of a wire, chosen by the view per frame.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum WireVisualState {
|
||||
/// A regular, idle wire.
|
||||
#[default]
|
||||
Normal,
|
||||
/// The wire is hovered (slightly brightened; click targets become
|
||||
/// discoverable).
|
||||
Hovered,
|
||||
/// The wire is part of the selection (accent color, thicker stroke).
|
||||
Selected,
|
||||
/// The ghost wire of an in-progress drag whose current hover target (if
|
||||
/// any) was rejected by
|
||||
/// [`NodeGraphDataSource::can_connect`](crate::node_graph::NodeGraphDataSource::can_connect).
|
||||
/// Drawn dashed/red to signal "dropping here will not connect".
|
||||
InvalidDrag,
|
||||
}
|
||||
|
||||
/// A fully-resolved wire ready to paint: both endpoints are already computed
|
||||
/// in screen space.
|
||||
///
|
||||
/// Built per frame by [`NodeGraphView`](crate::node_graph::NodeGraphView)
|
||||
/// from an [`EdgeData`](crate::node_graph::EdgeData) plus the port anchors of
|
||||
/// the two endpoint nodes.
|
||||
pub struct Wire {
|
||||
edge: EdgeId,
|
||||
from: Point<Pixels>,
|
||||
to: Point<Pixels>,
|
||||
color: Hsla,
|
||||
state: WireVisualState,
|
||||
}
|
||||
|
||||
impl Wire {
|
||||
/// Creates a wire between two screen-space anchor points, tinted with the
|
||||
/// connection's data-type color.
|
||||
pub fn new(
|
||||
edge: EdgeId,
|
||||
from: Point<Pixels>,
|
||||
to: Point<Pixels>,
|
||||
data_type: &PortDataType,
|
||||
state: WireVisualState,
|
||||
) -> Self {
|
||||
Self {
|
||||
edge,
|
||||
from,
|
||||
to,
|
||||
color: data_type.color,
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the edge this wire represents.
|
||||
pub fn edge(&self) -> EdgeId {
|
||||
self.edge
|
||||
}
|
||||
|
||||
/// Builds the cubic bezier [`crate::Path`] for a wire from `from` to
|
||||
/// `to`, leaving both endpoints horizontally: the control points are
|
||||
/// placed `WIRE_CURVATURE * zoom` to the right of `from` and to the left
|
||||
/// of `to`. Shared by regular wires and the ghost wire so both have
|
||||
/// identical curvature behavior.
|
||||
///
|
||||
/// Returns `None` when the path cannot be built (degenerate input); the
|
||||
/// caller simply skips painting that frame.
|
||||
pub fn build_path(
|
||||
from: Point<Pixels>,
|
||||
to: Point<Pixels>,
|
||||
zoom: f32,
|
||||
) -> Option<Path<Pixels>> {
|
||||
wire_path(from, to, zoom, px(2.0), None)
|
||||
}
|
||||
|
||||
/// Paints the wire with [`Window::paint_path`], applying the stroke width
|
||||
/// and color adjustments implied by its [`WireVisualState`].
|
||||
pub fn paint(&self, window: &mut Window, zoom: f32) {
|
||||
let (color, width, dash) = match self.state {
|
||||
WireVisualState::Normal => (self.color.opacity(0.6), px(2.0), None),
|
||||
WireVisualState::Hovered => (self.color, px(2.5), None),
|
||||
WireVisualState::Selected => (self.color, px(3.0), None),
|
||||
WireVisualState::InvalidDrag => (
|
||||
hsla(0.0, 0.85, 0.55, 1.0),
|
||||
px(2.0),
|
||||
Some([px(6.0), px(4.0)]),
|
||||
),
|
||||
};
|
||||
if let Some(path) = wire_path(
|
||||
self.from,
|
||||
self.to,
|
||||
zoom,
|
||||
width,
|
||||
dash.as_ref().map(|dash| &dash[..]),
|
||||
) {
|
||||
window.paint_path(path, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the cubic bezier path for a wire stroke with the given width and
|
||||
/// optional dash array. This is the single place the wire geometry lives;
|
||||
/// [`Wire::build_path`] and the ghost wire both delegate to it so every wire
|
||||
/// shares the same curvature behavior.
|
||||
pub(crate) fn wire_path(
|
||||
from: Point<Pixels>,
|
||||
to: Point<Pixels>,
|
||||
zoom: f32,
|
||||
width: Pixels,
|
||||
dash: Option<&[Pixels]>,
|
||||
) -> Option<Path<Pixels>> {
|
||||
let mut builder = PathBuilder::stroke(width);
|
||||
if let Some(dash) = dash {
|
||||
builder = builder.dash_array(dash);
|
||||
}
|
||||
let curvature = WIRE_CURVATURE * zoom;
|
||||
builder.move_to(from);
|
||||
builder.cubic_bezier_to(
|
||||
to,
|
||||
point(from.x + curvature, from.y),
|
||||
point(to.x - curvature, to.y),
|
||||
);
|
||||
builder.build().ok()
|
||||
}
|
||||
|
||||
/// The transient "ghost" wire shown while the user drags a connection from a
|
||||
/// port.
|
||||
///
|
||||
/// One end stays fixed at the source port's anchor; the other follows the
|
||||
/// cursor. When the cursor hovers a port, the free end snaps to that port's
|
||||
/// anchor and the ghost switches between [`WireVisualState::Hovered`] and
|
||||
/// [`WireVisualState::InvalidDrag`] depending on
|
||||
/// [`NodeGraphDataSource::can_connect`](crate::node_graph::NodeGraphDataSource::can_connect).
|
||||
pub struct GhostWire {
|
||||
/// The screen-space anchor of the port the drag started from.
|
||||
source: Point<Pixels>,
|
||||
/// The current screen-space position of the free end (cursor, or a
|
||||
/// snapped hover-target anchor).
|
||||
free_end: Point<Pixels>,
|
||||
/// Data type of the source port; tints the ghost.
|
||||
color: Hsla,
|
||||
/// Whether the current hover target is a valid drop (drives the
|
||||
/// [`WireVisualState::InvalidDrag`] styling).
|
||||
target_valid: bool,
|
||||
/// Whether the drag started from an output port. When `false` (drag
|
||||
/// started from an input), `source`/`free_end` are swapped when building
|
||||
/// the path so the bezier tangents still point the right way.
|
||||
from_output: bool,
|
||||
}
|
||||
|
||||
impl GhostWire {
|
||||
/// Creates a ghost wire anchored at `source` (screen space), tinted with
|
||||
/// the source port's data type. `from_output` records the drag direction;
|
||||
/// see the field docs.
|
||||
pub fn new(
|
||||
source: Point<Pixels>,
|
||||
data_type: &PortDataType,
|
||||
from_output: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
source,
|
||||
free_end: source,
|
||||
color: data_type.color,
|
||||
target_valid: false,
|
||||
from_output,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the screen-space anchor of the port the drag started from.
|
||||
pub(crate) fn source(&self) -> Point<Pixels> {
|
||||
self.source
|
||||
}
|
||||
|
||||
/// Returns the current screen-space position of the free end.
|
||||
pub(crate) fn free_end(&self) -> Point<Pixels> {
|
||||
self.free_end
|
||||
}
|
||||
|
||||
/// Returns the data-type color tinting the ghost.
|
||||
pub(crate) fn color(&self) -> Hsla {
|
||||
self.color
|
||||
}
|
||||
|
||||
/// Returns whether the currently hovered port is a valid drop target.
|
||||
pub(crate) fn is_target_valid(&self) -> bool {
|
||||
self.target_valid
|
||||
}
|
||||
|
||||
/// Returns whether the drag started from an output port.
|
||||
pub(crate) fn is_from_output(&self) -> bool {
|
||||
self.from_output
|
||||
}
|
||||
|
||||
/// Moves the free end to `cursor` (screen space) and records whether the
|
||||
/// currently hovered port — if any — is a valid drop target. Pass
|
||||
/// `snapped = Some(anchor)` instead of the raw cursor when the cursor is
|
||||
/// inside a port's grab radius, so the ghost visually snaps onto it.
|
||||
pub fn update(
|
||||
&mut self,
|
||||
cursor: Point<Pixels>,
|
||||
snapped: Option<Point<Pixels>>,
|
||||
target_valid: bool,
|
||||
) {
|
||||
self.free_end = snapped.unwrap_or(cursor);
|
||||
self.target_valid = target_valid;
|
||||
}
|
||||
|
||||
/// Paints the ghost wire using the same bezier shape as [`Wire`], with
|
||||
/// its state styling.
|
||||
pub fn paint(&self, window: &mut Window, zoom: f32) {
|
||||
let (from, to) = if self.from_output {
|
||||
(self.source, self.free_end)
|
||||
} else {
|
||||
(self.free_end, self.source)
|
||||
};
|
||||
paint_ghost(window, from, to, self.color, self.target_valid, zoom);
|
||||
}
|
||||
}
|
||||
|
||||
/// Paints the ghost wire between two screen-space anchors. Valid drops are
|
||||
/// drawn solid with the data-type tint; invalid drops (hovering an
|
||||
/// incompatible port) are drawn dashed/red to signal that dropping will not
|
||||
/// connect. Used both by [`GhostWire::paint`] and by the view's frame
|
||||
/// snapshot.
|
||||
pub(crate) fn paint_ghost(
|
||||
window: &mut Window,
|
||||
from: Point<Pixels>,
|
||||
to: Point<Pixels>,
|
||||
color: Hsla,
|
||||
target_valid: bool,
|
||||
zoom: f32,
|
||||
) {
|
||||
if target_valid {
|
||||
if let Some(path) = wire_path(from, to, zoom, px(2.5), None) {
|
||||
window.paint_path(path, color);
|
||||
}
|
||||
} else if let Some(path) = wire_path(from, to, zoom, px(2.0), Some(&[px(6.0), px(4.0)])) {
|
||||
window.paint_path(path, hsla(0.0, 0.85, 0.55, 1.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixels per second the [`FlowAnimation`] dash phase advances while active.
|
||||
pub const FLOW_SPEED: f32 = 60.0;
|
||||
|
||||
/// Optional signal-flow animation hook.
|
||||
///
|
||||
/// A subtle animated dash offset travelling along each wire from output to
|
||||
/// input while playback is running, to visualize which connections are
|
||||
/// "live". The view calls [`FlowAnimation::advance`] each frame during
|
||||
/// playback and passes the resulting offset to the wire stroke's dash phase.
|
||||
///
|
||||
/// The phase advances at [`FLOW_SPEED`] pixels per second; wires fall back to
|
||||
/// their static style while inactive.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct FlowAnimation {
|
||||
/// Current dash phase in pixels, monotonically increasing while active.
|
||||
phase: Pixels,
|
||||
/// Whether the animation is currently running (e.g. during playback).
|
||||
active: bool,
|
||||
}
|
||||
|
||||
impl FlowAnimation {
|
||||
/// Starts the flow animation (e.g. when playback begins), resetting the
|
||||
/// phase to zero.
|
||||
pub fn start(&mut self) {
|
||||
self.active = true;
|
||||
self.phase = px(0.0);
|
||||
}
|
||||
|
||||
/// Stops the flow animation; wires fall back to their static style.
|
||||
pub fn stop(&mut self) {
|
||||
self.active = false;
|
||||
}
|
||||
|
||||
/// Advances the phase by one frame. `dt` is the elapsed frame time in
|
||||
/// seconds; flow speed is a fixed px/s constant. No-op while inactive.
|
||||
pub fn advance(&mut self, dt: f32) {
|
||||
if self.active {
|
||||
self.phase += px(FLOW_SPEED * dt);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current dash phase to apply to wire strokes, or `None`
|
||||
/// while inactive.
|
||||
pub fn phase(&self) -> Option<Pixels> {
|
||||
self.active.then_some(self.phase)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user