// Copyright 2026 Ling71671
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
///|
/// Component style metrics descriptor for NodeEditor topology graphs.
pub struct NodeEditorStyle {
node_min_w : Double
node_header_h : Double
port_radius : Double
port_spacing : Double
card_radius : Double
wire_width : Double
grid_size : Double
} derive(Eq, Debug)
///|
/// Default style configuration adhering to Studio Light design tokens.
pub fn NodeEditorStyle::default() -> NodeEditorStyle {
{
node_min_w: 160.0,
node_header_h: 28.0,
port_radius: 5.0,
port_spacing: 22.0,
card_radius: 6.0,
wire_width: 2.0,
grid_size: 24.0,
}
}
///|
/// Directional polarity of a node connector port.
pub enum NodePortKind {
Input
Output
} derive(Eq, Debug)
///|
/// Terminal connection port on a node card.
pub struct NodePort {
id : String
name : String
kind : NodePortKind
color : @color.Color?
} derive(Eq, Debug)
///|
/// Construct an input port.
pub fn NodePort::input(
id : String,
name : String,
color? : @color.Color,
) -> NodePort {
{ id, name, kind: Input, color, }
}
///|
/// Construct an output port.
pub fn NodePort::output(
id : String,
name : String,
color? : @color.Color,
) -> NodePort {
{ id, name, kind: Output, color, }
}
///|
/// Node card item placed within the infinite or bounded canvas.
pub struct NodeItem {
id : String
title : String
mut pos : @math.Vec2
size : @math.Vec2?
inputs : Array[NodePort]
outputs : Array[NodePort]
accent_color : @color.Color?
} derive(Eq, Debug)
///|
/// Update position of a node item.
pub fn NodeItem::set_pos(self : NodeItem, pos : @math.Vec2) -> Unit {
self.pos = pos
}
///|
/// Construct a new node card item.
pub fn NodeItem::new(
id : String,
title : String,
pos : @math.Vec2,
inputs? : Array[NodePort] = [],
outputs? : Array[NodePort] = [],
accent_color? : @color.Color,
size? : @math.Vec2,
) -> NodeItem {
{ id, title, pos, size, inputs, outputs, accent_color, }
}
///|
/// Directed connection wire between two ports on different nodes.
pub struct NodeConnection {
from_node : String
from_port : String
to_node : String
to_port : String
} derive(Eq, Debug)
///|
/// Construct a connection descriptor.
pub fn NodeConnection::new(
from_node : String,
from_port : String,
to_node : String,
to_port : String,
) -> NodeConnection {
{ from_node, from_port, to_node, to_port, }
}
///|
/// Interaction and state feedback from the NodeEditor component.
pub struct NodeEditorResponse {
rect : @math.Rect
nodes : Array[NodeItem]
connections : Array[NodeConnection]
selected_node : String?
dragged_node : String?
new_connection : NodeConnection?
changed : Bool
} derive(Eq, Debug)
///|
/// Standalone NodeEditor builder supporting fluent configuration.
pub struct NodeEditor {
id_salt : String
size : @math.Vec2
nodes : Array[NodeItem]
connections : Array[NodeConnection]
custom_style : NodeEditorStyle?
selected_node : String?
} derive(Eq, Debug)
///|
/// Create a new NodeEditor builder.
pub fn NodeEditor::new(
id_salt : String,
size : @math.Vec2,
nodes : Array[NodeItem],
connections : Array[NodeConnection],
) -> NodeEditor {
{
id_salt,
size,
nodes,
connections,
custom_style: None,
selected_node: None,
}
}
///|
/// Override styling metrics.
pub fn NodeEditor::style(
self : NodeEditor,
style : NodeEditorStyle,
) -> NodeEditor {
{ ..self, custom_style: Some(style), }
}
///|
/// Set currently selected node identifier.
pub fn NodeEditor::selected_node(
self : NodeEditor,
selected_node : String?,
) -> NodeEditor {
{ ..self, selected_node, }
}
///|
/// Draws a smooth cubic Bézier curve wire connecting two endpoints.
fn draw_cubic_bezier_wire(
painter : Painter,
p0 : @math.Vec2,
p3 : @math.Vec2,
color : @color.Color,
width : Double,
steps? : Int = 16,
) -> Unit {
let dx = (p3.x - p0.x).abs() * 0.5
let curvature = if dx < 32.0 { 32.0 } else { dx }
let p1 = @math.Vec2::new(p0.x + curvature, p0.y)
let p2 = @math.Vec2::new(p3.x - curvature, p3.y)
let _ = steps
painter.add_bezier_curve(p0, p1, p2, p3, color, width)
}
///|
/// Resolves screen coordinate for a specific port on a node.
fn find_port_position(
canvas_min : @math.Vec2,
nodes : Array[NodeItem],
node_id : String,
port_id : String,
style : NodeEditorStyle,
scale : Double,
) -> @math.Vec2? {
for node in nodes {
if node.id == node_id {
let screen_x = canvas_min.x + node.pos.x
let screen_y = canvas_min.y + node.pos.y
let node_w = match node.size {
Some(sz) => sz.x * scale
None => style.node_min_w * scale
}
let header_h = style.node_header_h * scale
let port_spacing = style.port_spacing * scale
// Check inputs (left edge)
for i, p in node.inputs {
if p.id == port_id {
let py = screen_y +
header_h +
14.0 * scale +
i.to_double() * port_spacing
return Some(@math.Vec2::new(screen_x, py))
}
}
// Check outputs (right edge)
for j, p in node.outputs {
if p.id == port_id {
let py = screen_y +
header_h +
14.0 * scale +
j.to_double() * port_spacing
return Some(@math.Vec2::new(screen_x + node_w, py))
}
}
}
}
None
}
///|
/// Render the NodeEditor into UIContext.
pub fn NodeEditor::show(
self : NodeEditor,
ctx : UIContext,
) -> NodeEditorResponse {
node_editor(
ctx,
self.id_salt,
self.size,
self.nodes,
self.connections,
custom_style=self.custom_style,
selected_node=self.selected_node,
)
}
///|
/// Main functional entry point for the NodeEditor component.
pub fn node_editor(
ctx : UIContext,
id_salt : String,
size : @math.Vec2,
nodes : Array[NodeItem],
connections : Array[NodeConnection],
custom_style? : NodeEditorStyle? = None,
selected_node? : String? = None,
) -> NodeEditorResponse {
let scale = ctx.style.scale
let style = match custom_style {
Some(s) => s
None => NodeEditorStyle::default()
}
let node_min_w = style.node_min_w * scale
let header_h = style.node_header_h * scale
let port_r = style.port_radius * scale
let port_spacing = style.port_spacing * scale
let card_radius = style.card_radius * scale
let wire_width = style.wire_width * scale
let font_title = ctx.style.font_normal * scale
let font_port = ctx.style.font_small * scale
ctx.push_id(id_salt)
let (canvas_id, canvas_rect, _) = ctx.allocate_space(size)
ctx.pop_id()
let painter = ctx.painter()
// 1. Draw Canvas Background & Studio Dot Grid
painter.add_rect(canvas_rect, @color.Color::bg_subtle(), card_radius)
painter.add_rect_stroke(
canvas_rect,
@color.Color::border_default(),
1.0,
card_radius,
)
// Push scissor clip so nodes & wires cannot leak outside editor bounds
ctx.push_clip(canvas_rect)
painter.push_clip(canvas_rect)
let grid_step = style.grid_size * scale
let mut gx = canvas_rect.x + grid_step * 0.5
let canvas_right = canvas_rect.x + canvas_rect.w
let canvas_bottom = canvas_rect.y + canvas_rect.h
while gx < canvas_right {
let mut gy = canvas_rect.y + grid_step * 0.5
while gy < canvas_bottom {
painter.add_circle(
@math.Vec2::new(gx, gy),
1.0 * scale,
@color.Color::border_muted(),
)
gy = gy + grid_step
}
gx = gx + grid_step
}
// 2. Persistent State in Memory
let mut active_drag_node = ctx.memory.get_string_or_default(
canvas_id,
"",
tag="drag_node",
)
let mut active_conn_node = ctx.memory.get_string_or_default(
canvas_id,
"",
tag="conn_node",
)
let mut active_conn_port = ctx.memory.get_string_or_default(
canvas_id,
"",
tag="conn_port",
)
let mut cur_selected_node = match selected_node {
Some(sn) => sn
None => ctx.memory.get_string_or_default(canvas_id, "", tag="selected_node")
}
let mouse_pos = ctx.input.mouse_pos
let mouse_down = ctx.input.mouse_down
let mouse_pressed = ctx.input.mouse_pressed
let is_canvas_hovered = ctx.is_hovered(canvas_rect)
let mut new_conn : NodeConnection? = None
let mut changed = false
let out_conns = []
for c in connections {
out_conns.push(c)
}
// 3. Handle Mouse Release Actions (End Drag or Complete Port Connection)
if !mouse_down {
if active_conn_node != "" && active_conn_port != "" {
// Check if released over an input port on a different node
for node in nodes {
if node.id != active_conn_node {
let screen_x = canvas_rect.x + node.pos.x
let screen_y = canvas_rect.y + node.pos.y
for i, in_port in node.inputs {
let py = screen_y +
header_h +
14.0 * scale +
i.to_double() * port_spacing
let port_center = @math.Vec2::new(screen_x, py)
let dist = (mouse_pos.x - port_center.x).abs() +
(mouse_pos.y - port_center.y).abs()
if dist <= port_r * 2.5 + 4.0 * scale {
let conn = NodeConnection::new(
active_conn_node,
active_conn_port,
node.id,
in_port.id,
)
out_conns.push(conn)
new_conn = Some(conn)
changed = true
}
}
}
}
ctx.memory.set_string(canvas_id, "", tag="conn_node")
ctx.memory.set_string(canvas_id, "", tag="conn_port")
active_conn_node = ""
active_conn_port = ""
}
if active_drag_node != "" {
ctx.memory.set_string(canvas_id, "", tag="drag_node")
active_drag_node = ""
}
}
// 4. Update Node Positions During Active Dragging
if mouse_down && active_drag_node != "" {
let anchor_x = ctx.memory.get_double_or_default(
canvas_id,
0.0,
tag="drag_anchor_x",
)
let anchor_y = ctx.memory.get_double_or_default(
canvas_id,
0.0,
tag="drag_anchor_y",
)
for node in nodes {
if node.id == active_drag_node {
let new_x = mouse_pos.x - canvas_rect.x - anchor_x
let new_y = mouse_pos.y - canvas_rect.y - anchor_y
if node.pos.x != new_x || node.pos.y != new_y {
node.pos = @math.Vec2::new(new_x, new_y)
changed = true
}
ctx.set_cursor_icon("move")
}
}
}
// 5. Port Click / Header Drag Initiation (Top-to-Bottom Node Detection)
if is_canvas_hovered &&
mouse_pressed &&
active_drag_node == "" &&
active_conn_node == "" {
let mut handled = false
// Check ports first
for node in nodes {
if handled {
break
}
let screen_x = canvas_rect.x + node.pos.x
let screen_y = canvas_rect.y + node.pos.y
let node_w = match node.size {
Some(sz) => sz.x * scale
None => node_min_w
}
for j, out_port in node.outputs {
let py = screen_y +
header_h +
14.0 * scale +
j.to_double() * port_spacing
let port_center = @math.Vec2::new(screen_x + node_w, py)
let dist = (mouse_pos.x - port_center.x).abs() +
(mouse_pos.y - port_center.y).abs()
if dist <= port_r * 2.0 + 3.0 * scale {
active_conn_node = node.id
active_conn_port = out_port.id
ctx.memory.set_string(canvas_id, node.id, tag="conn_node")
ctx.memory.set_string(canvas_id, out_port.id, tag="conn_port")
handled = true
break
}
}
}
// If not clicked on port, check node headers / bodies
if !handled {
// Traverse nodes in reverse (topmost visually is last in array)
for k = nodes.length() - 1; k >= 0; k = k - 1 {
let node = nodes[k]
let screen_x = canvas_rect.x + node.pos.x
let screen_y = canvas_rect.y + node.pos.y
let node_w = match node.size {
Some(sz) => sz.x * scale
None => node_min_w
}
let max_ports = if node.inputs.length() > node.outputs.length() {
node.inputs.length()
} else {
node.outputs.length()
}
let body_h = (max_ports.to_double() * style.port_spacing + 16.0) * scale
let total_h = match node.size {
Some(sz) => sz.y * scale
None => header_h + body_h
}
let node_rect = @math.Rect::new(screen_x, screen_y, node_w, total_h)
if ctx.is_hovered(node_rect) {
cur_selected_node = node.id
active_drag_node = node.id
ctx.memory.set_string(canvas_id, node.id, tag="selected_node")
ctx.memory.set_string(canvas_id, node.id, tag="drag_node")
ctx.memory.set_double(
canvas_id,
mouse_pos.x - canvas_rect.x - node.pos.x,
tag="drag_anchor_x",
)
ctx.memory.set_double(
canvas_id,
mouse_pos.y - canvas_rect.y - node.pos.y,
tag="drag_anchor_y",
)
handled = true
break
}
}
}
}
// 6. Draw Existing Connection Wires
let canvas_min = canvas_rect.min()
for conn in out_conns {
let p_start = find_port_position(
canvas_min,
nodes,
conn.from_node,
conn.from_port,
style,
scale,
)
let p_end = find_port_position(
canvas_min,
nodes,
conn.to_node,
conn.to_port,
style,
scale,
)
match (p_start, p_end) {
(Some(p0), Some(p3)) =>
draw_cubic_bezier_wire(
painter,
p0,
p3,
@color.Color::accent_primary(),
wire_width,
)
_ => ()
}
}
// 7. Draw Active In-Progress Drag Connection Wire
if active_conn_node != "" && active_conn_port != "" {
let p_start = find_port_position(
canvas_min, nodes, active_conn_node, active_conn_port, style, scale,
)
if p_start is Some(p0) {
draw_cubic_bezier_wire(
painter,
p0,
mouse_pos,
@color.Color::accent_hover(),
wire_width + 0.5,
)
ctx.set_cursor_icon("crosshair")
}
}
// 8. Draw Node Cards & Ports
for node in nodes {
let screen_x = canvas_rect.x + node.pos.x
let screen_y = canvas_rect.y + node.pos.y
let node_w = match node.size {
Some(sz) => sz.x * scale
None => node_min_w
}
let max_ports = if node.inputs.length() > node.outputs.length() {
node.inputs.length()
} else {
node.outputs.length()
}
let body_h = (max_ports.to_double() * style.port_spacing + 16.0) * scale
let total_h = match node.size {
Some(sz) => sz.y * scale
None => header_h + body_h
}
let node_rect = @math.Rect::new(screen_x, screen_y, node_w, total_h)
let header_rect = @math.Rect::new(screen_x, screen_y, node_w, header_h)
let is_selected = cur_selected_node == node.id
// Card background fill
painter.add_rect(node_rect, @color.Color::bg_window(), card_radius)
// Header background fill
let header_color = match node.accent_color {
Some(c) => c
None => @color.Color::bg_surface()
}
painter.add_rect(header_rect, header_color, card_radius)
// Square off bottom corners of header
painter.add_rect(
@math.Rect::new(
screen_x,
screen_y + header_h - card_radius,
node_w,
card_radius,
),
header_color,
0.0,
)
// Card stroke (highlight if selected)
let (border_col, stroke_w) = if is_selected {
(@color.Color::border_focus(), 1.5 * scale)
} else {
(@color.Color::border_default(), 1.0)
}
painter.add_rect_stroke(node_rect, border_col, stroke_w, card_radius)
// Node title text
let title_color = match node.accent_color {
Some(_) => @color.Color::text_inverse()
None => @color.Color::text_strong()
}
let title_pos = @math.Vec2::new(
screen_x + 10.0 * scale,
screen_y + (header_h - font_title) * 0.5,
)
painter.add_text(title_pos, node.title, font_title, title_color)
// Cursor feedback on header hover
if is_canvas_hovered && header_rect.contains(mouse_pos) {
ctx.set_cursor_icon("move")
}
// Render Input Ports (Left)
for i, in_port in node.inputs {
let py = screen_y + header_h + 14.0 * scale + i.to_double() * port_spacing
let port_center = @math.Vec2::new(screen_x, py)
let p_col = match in_port.color {
Some(c) => c
None => @color.Color::accent_primary()
}
// Check hover
let is_port_hovered = is_canvas_hovered &&
(mouse_pos.x - port_center.x).abs() <= port_r * 2.0 &&
(mouse_pos.y - port_center.y).abs() <= port_r * 2.0
if is_port_hovered {
ctx.set_cursor_icon("crosshair")
painter.add_circle(port_center, port_r + 2.0 * scale, p_col)
} else {
painter.add_circle(port_center, port_r, p_col)
painter.add_circle_stroke(
port_center,
port_r,
@color.Color::bg_surface(),
1.5,
)
}
let label_pos = @math.Vec2::new(
screen_x + port_r + 6.0 * scale,
py - font_port * 0.5,
)
painter.add_text(
label_pos,
in_port.name,
font_port,
@color.Color::text_primary(),
)
}
// Render Output Ports (Right)
for j, out_port in node.outputs {
let py = screen_y + header_h + 14.0 * scale + j.to_double() * port_spacing
let port_center = @math.Vec2::new(screen_x + node_w, py)
let p_col = match out_port.color {
Some(c) => c
None => @color.Color::accent_primary()
}
let is_port_hovered = is_canvas_hovered &&
(mouse_pos.x - port_center.x).abs() <= port_r * 2.0 &&
(mouse_pos.y - port_center.y).abs() <= port_r * 2.0
if is_port_hovered {
ctx.set_cursor_icon("crosshair")
painter.add_circle(port_center, port_r + 2.0 * scale, p_col)
} else {
painter.add_circle(port_center, port_r, p_col)
painter.add_circle_stroke(
port_center,
port_r,
@color.Color::bg_surface(),
1.5,
)
}
let label_sz = ctx.measure_text(out_port.name, font_port)
let label_pos = @math.Vec2::new(
screen_x + node_w - port_r - 6.0 * scale - label_sz.x,
py - font_port * 0.5,
)
painter.add_text(
label_pos,
out_port.name,
font_port,
@color.Color::text_primary(),
)
}
}
// Pop scissor clip
painter.pop_clip()
ctx.pop_clip()
let active_drag = if active_drag_node != "" {
Some(active_drag_node)
} else {
None
}
let sel_node = if cur_selected_node != "" {
Some(cur_selected_node)
} else {
None
}
{
rect: canvas_rect,
nodes,
connections: out_conns,
selected_node: sel_node,
dragged_node: active_drag,
new_connection: new_conn,
changed,
}
}