Initial public release
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,465 @@
|
||||
"""Wire/connection drawing and logic for node editor.
|
||||
|
||||
Each Connection renders as a stack of short line segments, each colored with
|
||||
an interpolation between the two connected nodes' header colors and blended
|
||||
at 50% opacity over the canvas background (simulated alpha since Tkinter's
|
||||
Canvas has no native alpha channel).
|
||||
|
||||
Routing (smart — picks a curve shape based on the geometry):
|
||||
|
||||
1. ``straight`` — ports almost perfectly aligned horizontally with a clear
|
||||
forward path; a nearly-straight line with a tiny bulge.
|
||||
2. ``s_curve`` — ordinary forward connection (target ahead, ports face
|
||||
each other); cubic S-curve with tangent length scaled by
|
||||
the dominant axis (handles both horizontal-dominant and
|
||||
vertical-dominant cases naturally).
|
||||
3. ``vertical`` — target is mostly above/below the source with little
|
||||
horizontal distance; pulls the tangent much further
|
||||
vertically so the curve doesn't "bow out" awkwardly.
|
||||
4. ``detour`` — forward-facing ports but the target is behind the source
|
||||
exit direction (i.e. wire would cross back through its
|
||||
own node body). Routes out-then-down-then-back like a
|
||||
squared-off hook.
|
||||
5. ``horseshoe`` — typical loop-back case (ports facing the same direction
|
||||
or pointing away from each other). Chooses above vs
|
||||
below the nodes based on which side has more clearance
|
||||
so the wire doesn't cross node bodies when possible.
|
||||
"""
|
||||
|
||||
from math import comb
|
||||
|
||||
from utils.constants import (
|
||||
WIRE_COLOR, WIRE_SELECTED_COLOR, CANVAS_BG, NODE_TYPES,
|
||||
NODE_UPSTREAM_BORDER, NODE_DOWNSTREAM_BORDER,
|
||||
)
|
||||
|
||||
|
||||
# Higher = smoother gradient/curve, but more canvas items. 22 keeps redraws
|
||||
# responsive even on macros with 150+ connections.
|
||||
_SEGMENT_COUNT = 22
|
||||
|
||||
# Simulated alpha (Tkinter has no native alpha) for blending wire colors
|
||||
# with the canvas background.
|
||||
_WIRE_ALPHA = 0.5
|
||||
|
||||
# Highlight colors mirror the node-border palette:
|
||||
# green = incoming (feeds the selected node's input)
|
||||
# red = outgoing (driven from the selected node's output)
|
||||
_WIRE_INPUT_COLOR = NODE_UPSTREAM_BORDER # green
|
||||
_WIRE_OUTPUT_COLOR = NODE_DOWNSTREAM_BORDER # red
|
||||
|
||||
# Separate tag so canvas.py can stack highlighted wires above normal ones
|
||||
# (but still below nodes).
|
||||
TAG_WIRE_NORMAL = "wire"
|
||||
TAG_WIRE_HIGHLIGHT = "wire_hl"
|
||||
|
||||
|
||||
class Connection:
|
||||
"""Visual wire connecting two ports."""
|
||||
|
||||
def __init__(self, canvas, from_node, from_port, to_node, to_port, conn_data):
|
||||
self.canvas = canvas
|
||||
self.from_node = from_node
|
||||
self.from_port = from_port
|
||||
self.to_node = to_node
|
||||
self.to_port = to_port
|
||||
self.data = conn_data
|
||||
self._line_ids: list[int] = []
|
||||
self.selected = False
|
||||
# Selection-adjacency highlight; one of:
|
||||
# None — default faded node-color gradient
|
||||
# "input" — green (fully opaque); wire enters a selected node
|
||||
# "output" — red (fully opaque); wire leaves a selected node
|
||||
# "fade" — red→green gradient; wire connects two selected nodes
|
||||
# Highlighted wires are also raised above normal wires.
|
||||
self.highlight_kind: str | None = None
|
||||
self._draw()
|
||||
|
||||
def update(self):
|
||||
self._draw()
|
||||
|
||||
def set_selected(self, selected: bool):
|
||||
self.selected = selected
|
||||
self._draw()
|
||||
|
||||
def set_highlight(self, kind: str | None):
|
||||
"""Set the selection-adjacency highlight.
|
||||
|
||||
No-op if unchanged so bulk selection updates don't thrash the canvas.
|
||||
"""
|
||||
if self.highlight_kind == kind:
|
||||
return
|
||||
self.highlight_kind = kind
|
||||
self._draw()
|
||||
|
||||
def destroy(self):
|
||||
for cid in self._line_ids:
|
||||
self.canvas.delete(cid)
|
||||
self._line_ids = []
|
||||
|
||||
def hit_test(self, x: int, y: int, threshold: int = 8) -> bool:
|
||||
"""Check if (x, y) is within ``threshold`` pixels of the wire."""
|
||||
for lid in self._line_ids:
|
||||
coords = self.canvas.coords(lid)
|
||||
if len(coords) < 4:
|
||||
continue
|
||||
for i in range(0, len(coords) - 2, 2):
|
||||
x1, y1 = coords[i], coords[i + 1]
|
||||
x2, y2 = coords[i + 2], coords[i + 3]
|
||||
if self._point_line_dist(x, y, x1, y1, x2, y2) < threshold:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _draw(self):
|
||||
for cid in self._line_ids:
|
||||
self.canvas.delete(cid)
|
||||
self._line_ids = []
|
||||
|
||||
x1, y1 = self.from_port.x, self.from_port.y
|
||||
x2, y2 = self.to_port.x, self.to_port.y
|
||||
|
||||
zoom = self._get_zoom()
|
||||
points = self._curve_points(x1, y1, x2, y2, zoom, _SEGMENT_COUNT)
|
||||
|
||||
highlighted = self.highlight_kind is not None
|
||||
|
||||
if self.selected:
|
||||
colors = [WIRE_SELECTED_COLOR] * len(points)
|
||||
base_w = 5
|
||||
elif self.highlight_kind == "input":
|
||||
colors = [_WIRE_INPUT_COLOR] * len(points)
|
||||
base_w = 5
|
||||
elif self.highlight_kind == "output":
|
||||
colors = [_WIRE_OUTPUT_COLOR] * len(points)
|
||||
base_w = 5
|
||||
elif self.highlight_kind == "fade":
|
||||
# Wire between two selected nodes — fade red → green so each
|
||||
# end matches the port color it terminates at.
|
||||
colors = []
|
||||
n = max(1, len(points) - 1)
|
||||
for i in range(len(points)):
|
||||
t = i / n
|
||||
colors.append(self._lerp_color(_WIRE_OUTPUT_COLOR, _WIRE_INPUT_COLOR, t))
|
||||
base_w = 5
|
||||
else:
|
||||
# Faded gradient between the two nodes' header colors.
|
||||
from_c = self._node_color(self.from_node)
|
||||
to_c = self._node_color(self.to_node)
|
||||
colors = []
|
||||
n = max(1, len(points) - 1)
|
||||
for i in range(len(points)):
|
||||
t = i / n
|
||||
rgb = self._lerp_color(from_c, to_c, t)
|
||||
rgb = self._blend_with_bg(rgb, _WIRE_ALPHA)
|
||||
colors.append(rgb)
|
||||
base_w = 4
|
||||
|
||||
width = max(1, int(round(base_w * zoom)))
|
||||
|
||||
tag = TAG_WIRE_HIGHLIGHT if highlighted else TAG_WIRE_NORMAL
|
||||
|
||||
# Each segment uses the color at its starting endpoint, producing
|
||||
# the visual gradient along the wire.
|
||||
for i in range(len(points) - 1):
|
||||
ax, ay = points[i]
|
||||
bx, by = points[i + 1]
|
||||
color = colors[i]
|
||||
lid = self.canvas.create_line(
|
||||
ax, ay, bx, by,
|
||||
fill=color, width=width,
|
||||
capstyle="round",
|
||||
tags=(tag,),
|
||||
)
|
||||
self._line_ids.append(lid)
|
||||
|
||||
# Both kinds of wires stay below nodes; canvas._reorder_wire_layers
|
||||
# handles the finer "highlighted on top of normal" layering.
|
||||
try:
|
||||
self.canvas.tag_lower(tag, "node")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _curve_points(self, x1, y1, x2, y2, zoom, n_samples):
|
||||
"""Sample points along a smart-routed curve between the two ports.
|
||||
|
||||
Picks a curve style based on the relative geometry of the two ports
|
||||
(see module docstring). Takes into account each port's ``side``
|
||||
('L' or 'R') so wires always exit/enter in the direction away from
|
||||
the node body, even when a node is flipped.
|
||||
"""
|
||||
# Exit direction: +1 = right of node, -1 = left of node
|
||||
from_side = getattr(self.from_port, "side", "R")
|
||||
to_side = getattr(self.to_port, "side", "L")
|
||||
from_dir = 1 if from_side == "R" else -1
|
||||
to_dir = 1 if to_side == "R" else -1
|
||||
|
||||
dx = x2 - x1
|
||||
dy = y2 - y1
|
||||
adx = abs(dx)
|
||||
ady = abs(dy)
|
||||
|
||||
facing = (from_dir != to_dir)
|
||||
# Target lies ahead of the source exit side
|
||||
in_exit_direction = (dx * from_dir) > 0 if dx != 0 else True
|
||||
|
||||
if not facing:
|
||||
# Same-side ports → horseshoe loop
|
||||
return self._bezier_samples(
|
||||
self._horseshoe_ctrl(x1, y1, x2, y2, from_dir, to_dir, zoom),
|
||||
n_samples,
|
||||
)
|
||||
|
||||
if not in_exit_direction:
|
||||
# Naive cubic would loop through the source node body; use a tall hook
|
||||
return self._bezier_samples(
|
||||
self._detour_ctrl(x1, y1, x2, y2, from_dir, to_dir, zoom),
|
||||
n_samples,
|
||||
)
|
||||
|
||||
# Perfectly (or almost) aligned: pure straight line
|
||||
if ady <= 4 * zoom:
|
||||
# Tiny offset preserves smooth port joins
|
||||
off = max(10 * zoom, adx * 0.08)
|
||||
return self._bezier_samples([
|
||||
(x1, y1),
|
||||
(x1 + off * from_dir, y1),
|
||||
(x2 + off * to_dir, y2),
|
||||
(x2, y2),
|
||||
], n_samples)
|
||||
|
||||
# Vertical-dominant: kick in early (ratio 1.3) so stacked-node layouts
|
||||
# use this shape instead of the generic S-curve.
|
||||
if ady > adx * 1.3 and ady > 60 * zoom:
|
||||
return self._bezier_samples(
|
||||
self._vertical_dominant_ctrl(x1, y1, x2, y2, from_dir, to_dir, zoom, adx, ady),
|
||||
n_samples,
|
||||
)
|
||||
|
||||
# Nearly-aligned horizontal: minimal bulge
|
||||
if ady < 40 * zoom and adx > 40 * zoom:
|
||||
return self._bezier_samples(
|
||||
self._straight_ctrl(x1, y1, x2, y2, from_dir, to_dir, zoom),
|
||||
n_samples,
|
||||
)
|
||||
|
||||
# Very short hop: tight tangents so the wire doesn't overshoot
|
||||
if adx + ady < 80 * zoom:
|
||||
off = max(12 * zoom, (adx + ady) * 0.25)
|
||||
return self._bezier_samples([
|
||||
(x1, y1),
|
||||
(x1 + off * from_dir, y1),
|
||||
(x2 + off * to_dir, y2),
|
||||
(x2, y2),
|
||||
], n_samples)
|
||||
|
||||
# Default horizontal-dominant S-curve. Tangent length grows with the
|
||||
# horizontal gap but is capped so huge horizontal separations still
|
||||
# produce a tidy curve rather than a sagging one.
|
||||
offset = max(40 * zoom, 0.55 * adx + 0.15 * ady)
|
||||
offset = min(offset, 300 * zoom + 0.25 * adx)
|
||||
ctrl = [
|
||||
(x1, y1),
|
||||
(x1 + offset * from_dir, y1),
|
||||
(x2 + offset * to_dir, y2),
|
||||
(x2, y2),
|
||||
]
|
||||
return self._bezier_samples(ctrl, n_samples)
|
||||
|
||||
# ---------- Individual routing styles ----------
|
||||
|
||||
@staticmethod
|
||||
def _straight_ctrl(x1, y1, x2, y2, from_dir, to_dir, zoom):
|
||||
"""Nearly-aligned horizontal pair — very small tangent so the line
|
||||
reads as essentially straight with gentle port blending."""
|
||||
off = max(14 * zoom, abs(x2 - x1) * 0.10)
|
||||
return [
|
||||
(x1, y1),
|
||||
(x1 + off * from_dir, y1),
|
||||
(x2 + off * to_dir, y2),
|
||||
(x2, y2),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _vertical_dominant_ctrl(x1, y1, x2, y2, from_dir, to_dir, zoom, adx, ady):
|
||||
"""Target mostly above/below. Quintic Bezier with middle control points
|
||||
along the vertical line between the two ports — produces a vertical
|
||||
"S on its side" instead of the horizontal bow a plain cubic would draw.
|
||||
"""
|
||||
# Short horizontal escape off each port; then the curve runs vertical
|
||||
escape = max(18 * zoom, 20 * zoom + adx * 0.15)
|
||||
# Strong vertical stretch so the S leans vertical rather than diagonal
|
||||
vstretch = max(50 * zoom, ady * 0.55)
|
||||
vdir = 1 if y2 > y1 else -1
|
||||
return [
|
||||
(x1, y1),
|
||||
(x1 + escape * from_dir, y1),
|
||||
(x1 + escape * from_dir, y1 + vstretch * vdir),
|
||||
(x2 + escape * to_dir, y2 - vstretch * vdir),
|
||||
(x2 + escape * to_dir, y2),
|
||||
(x2, y2),
|
||||
]
|
||||
|
||||
# Vertical gap (canvas px at zoom=1) below which two vertically-offset
|
||||
# node bodies overlap, so we route around instead of through. Node body
|
||||
# is roughly 54 px + padding.
|
||||
_CORRIDOR_MIN_CLEARANCE = 90
|
||||
|
||||
@classmethod
|
||||
def _detour_ctrl(cls, x1, y1, x2, y2, from_dir, to_dir, zoom):
|
||||
"""Facing ports but target is behind source's exit side.
|
||||
|
||||
Picks the shortest viable route:
|
||||
|
||||
1. **Corridor** — when the two ports are vertically separated by
|
||||
more than a node's height, there's a clear horizontal strip
|
||||
between them. Route through that corridor (mid_y between the
|
||||
two ports). This is dramatically shorter than arcing under or
|
||||
over both nodes and is the common case for "output up-right of
|
||||
input" wiring.
|
||||
|
||||
2. **Arc above / below** — when ports are too close vertically to
|
||||
have a corridor, loop around the side that matches the natural
|
||||
direction of travel. Target below source → arc under target.
|
||||
Target above → arc over source. Arc size is just big enough
|
||||
to clear one node, not both.
|
||||
"""
|
||||
ady = abs(y2 - y1)
|
||||
h_off = max(55 * zoom, abs(x2 - x1) * 0.18 + 50 * zoom)
|
||||
|
||||
# Corridor routing (preferred when there's room)
|
||||
if ady > cls._CORRIDOR_MIN_CLEARANCE * zoom:
|
||||
mid_y = (y1 + y2) / 2
|
||||
return [
|
||||
(x1, y1),
|
||||
(x1 + h_off * from_dir, y1),
|
||||
(x1 + h_off * from_dir, mid_y),
|
||||
(x2 + h_off * to_dir, mid_y),
|
||||
(x2 + h_off * to_dir, y2),
|
||||
(x2, y2),
|
||||
]
|
||||
|
||||
# No corridor — arc around one side. v_off only needs to clear one
|
||||
# node body's height, not two.
|
||||
v_off = max(60 * zoom, ady * 0.5 + 45 * zoom)
|
||||
if y2 < y1:
|
||||
mid_y = min(y1, y2) - v_off
|
||||
else:
|
||||
mid_y = max(y1, y2) + v_off
|
||||
return [
|
||||
(x1, y1),
|
||||
(x1 + h_off * from_dir, y1),
|
||||
(x1 + h_off * from_dir, mid_y),
|
||||
(x2 + h_off * to_dir, mid_y),
|
||||
(x2 + h_off * to_dir, y2),
|
||||
(x2, y2),
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _horseshoe_ctrl(cls, x1, y1, x2, y2, from_dir, to_dir, zoom):
|
||||
"""Same-side ports (both exiting right, or both left).
|
||||
|
||||
Prefers the horizontal corridor between the two ports when there's
|
||||
vertical clearance, falls back to arcing around one side. Arc
|
||||
direction follows natural vertical travel to avoid bouncing backwards.
|
||||
"""
|
||||
ady = abs(y2 - y1)
|
||||
h_off = max(55 * zoom, abs(x2 - x1) * 0.18 + 50 * zoom)
|
||||
|
||||
if ady > cls._CORRIDOR_MIN_CLEARANCE * zoom:
|
||||
mid_y = (y1 + y2) / 2
|
||||
return [
|
||||
(x1, y1),
|
||||
(x1 + h_off * from_dir, y1),
|
||||
(x1 + h_off * from_dir, mid_y),
|
||||
(x2 + h_off * to_dir, mid_y),
|
||||
(x2 + h_off * to_dir, y2),
|
||||
(x2, y2),
|
||||
]
|
||||
|
||||
v_off = max(55 * zoom, ady * 0.5 + 40 * zoom)
|
||||
if y2 < y1:
|
||||
mid_y = min(y1, y2) - v_off
|
||||
else:
|
||||
mid_y = max(y1, y2) + v_off
|
||||
return [
|
||||
(x1, y1),
|
||||
(x1 + h_off * from_dir, y1),
|
||||
(x1 + h_off * from_dir, mid_y),
|
||||
(x2 + h_off * to_dir, mid_y),
|
||||
(x2 + h_off * to_dir, y2),
|
||||
(x2, y2),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _bezier_samples(ctrl, n):
|
||||
"""Sample a Bezier of any degree at n+1 equally-spaced t values."""
|
||||
deg = len(ctrl) - 1
|
||||
if deg < 1:
|
||||
return list(ctrl)
|
||||
pts = []
|
||||
for i in range(n + 1):
|
||||
t = i / n if n else 0
|
||||
u = 1 - t
|
||||
x = y = 0.0
|
||||
for k, (cx, cy) in enumerate(ctrl):
|
||||
b = comb(deg, k) * (u ** (deg - k)) * (t ** k)
|
||||
x += cx * b
|
||||
y += cy * b
|
||||
pts.append((x, y))
|
||||
return pts
|
||||
|
||||
def _get_zoom(self) -> float:
|
||||
node = self.from_node if self.from_node else self.to_node
|
||||
if node is not None and getattr(node, "canvas_ref", None) is not None:
|
||||
return getattr(node.canvas_ref, "_zoom_level", 1.0)
|
||||
return 1.0
|
||||
|
||||
@staticmethod
|
||||
def _node_color(node_widget) -> str:
|
||||
if node_widget is not None and getattr(node_widget, "data", None):
|
||||
info = NODE_TYPES.get(node_widget.data.type, {})
|
||||
return info.get("color", WIRE_COLOR)
|
||||
return WIRE_COLOR
|
||||
|
||||
@staticmethod
|
||||
def _parse_hex(c: str):
|
||||
return int(c[1:3], 16), int(c[3:5], 16), int(c[5:7], 16)
|
||||
|
||||
@staticmethod
|
||||
def _to_hex(r, g, b) -> str:
|
||||
r = max(0, min(255, int(round(r))))
|
||||
g = max(0, min(255, int(round(g))))
|
||||
b = max(0, min(255, int(round(b))))
|
||||
return f"#{r:02X}{g:02X}{b:02X}"
|
||||
|
||||
@classmethod
|
||||
def _lerp_color(cls, c1, c2, t):
|
||||
r1, g1, b1 = cls._parse_hex(c1)
|
||||
r2, g2, b2 = cls._parse_hex(c2)
|
||||
return cls._to_hex(
|
||||
r1 + (r2 - r1) * t,
|
||||
g1 + (g2 - g1) * t,
|
||||
b1 + (b2 - b1) * t,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _blend_with_bg(cls, color, alpha):
|
||||
"""Simulate alpha by blending ``color`` at ``alpha`` over the canvas bg."""
|
||||
r1, g1, b1 = cls._parse_hex(color)
|
||||
r2, g2, b2 = cls._parse_hex(CANVAS_BG)
|
||||
return cls._to_hex(
|
||||
r1 * alpha + r2 * (1 - alpha),
|
||||
g1 * alpha + g2 * (1 - alpha),
|
||||
b1 * alpha + b2 * (1 - alpha),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _point_line_dist(px, py, x1, y1, x2, y2) -> float:
|
||||
dx, dy = x2 - x1, y2 - y1
|
||||
if dx == 0 and dy == 0:
|
||||
return ((px - x1) ** 2 + (py - y1) ** 2) ** 0.5
|
||||
t = max(0, min(1, ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy)))
|
||||
proj_x = x1 + t * dx
|
||||
proj_y = y1 + t * dy
|
||||
return ((px - proj_x) ** 2 + (py - proj_y) ** 2) ** 0.5
|
||||
@@ -0,0 +1,562 @@
|
||||
"""Base node widget rendered on the canvas."""
|
||||
|
||||
from utils.constants import (
|
||||
NODE_TYPES, NODE_HEADER_HEIGHT, NODE_MIN_WIDTH, NODE_PORT_RADIUS,
|
||||
NODE_BODY_COLOR, NODE_TEXT_COLOR, NODE_PORT_IN_COLOR, NODE_PORT_OUT_COLOR,
|
||||
NODE_SELECTED_BORDER, NODE_UPSTREAM_BORDER, NODE_DOWNSTREAM_BORDER,
|
||||
)
|
||||
from .port import Port
|
||||
|
||||
|
||||
class NodeWidget:
|
||||
"""Visual representation of a node on the canvas.
|
||||
|
||||
Coordinates:
|
||||
- ``self.data.x``, ``self.data.y`` are WORLD coordinates (independent of zoom).
|
||||
- All drawing is done in CANVAS coordinates = world * zoom.
|
||||
- ``port.x``, ``port.y`` are CANVAS coordinates, kept up to date on redraw/move.
|
||||
- ``self.width``, ``self.height`` are CANVAS pixel dimensions (scaled by zoom).
|
||||
"""
|
||||
|
||||
TAG_PREFIX = "node_"
|
||||
|
||||
def __init__(self, canvas, node_data, on_select=None, on_move=None, canvas_ref=None):
|
||||
self.canvas = canvas
|
||||
self.canvas_ref = canvas_ref
|
||||
self.data = node_data
|
||||
self.on_select = on_select
|
||||
self.on_move = on_move
|
||||
self.selected = False
|
||||
# Directional connectivity highlight. One of:
|
||||
# None — no highlight
|
||||
# "upstream" — green: a neighbor wires INTO our input
|
||||
# "downstream" — red: a neighbor receives FROM our output
|
||||
# "both" — node is both an upstream and downstream neighbor
|
||||
# of the current selection (double border)
|
||||
self.highlight_kind: str | None = None
|
||||
self.canvas_items = []
|
||||
self.ports: list[Port] = []
|
||||
self.input_ports: list[Port] = []
|
||||
self.output_ports: list[Port] = []
|
||||
self.tag = f"{self.TAG_PREFIX}{self.data.id}"
|
||||
|
||||
self._setup_ports()
|
||||
self._draw()
|
||||
|
||||
def _zoom(self) -> float:
|
||||
if self.canvas_ref is not None:
|
||||
return getattr(self.canvas_ref, "_zoom_level", 1.0)
|
||||
return 1.0
|
||||
|
||||
def _setup_ports(self):
|
||||
node_type = self.data.type
|
||||
|
||||
# Note nodes are annotation-only — no ports, no connections
|
||||
if node_type == "note":
|
||||
self.input_ports = []
|
||||
self.output_ports = []
|
||||
self.ports = []
|
||||
return
|
||||
|
||||
if node_type == "start":
|
||||
self.input_ports = []
|
||||
elif node_type == "repeat":
|
||||
self.input_ports = [
|
||||
Port("in", "input", "Start"),
|
||||
Port("loop_back", "input", "Loop Back"),
|
||||
]
|
||||
elif node_type == "pc_alive_check":
|
||||
self.input_ports = [
|
||||
Port("in", "input", "In"),
|
||||
]
|
||||
elif node_type == "aggregator":
|
||||
count = max(1, int(self.data.data.get("input_count", 2)))
|
||||
self.input_ports = [
|
||||
Port(f"in_{i}", "input", f"In {i+1}") for i in range(count)
|
||||
]
|
||||
else:
|
||||
self.input_ports = [Port("in", "input", "In")]
|
||||
|
||||
if node_type == "branch" or node_type == "iteration_branch":
|
||||
choices = self.data.data.get("choices", [])
|
||||
self.output_ports = [
|
||||
Port(f"out_{i}", "output", choice.get("label", f"Out {i+1}"))
|
||||
for i, choice in enumerate(choices)
|
||||
]
|
||||
elif node_type == "repeat":
|
||||
self.output_ports = [
|
||||
Port("loop_body", "output", "Loop Body"),
|
||||
Port("done", "output", "Done"),
|
||||
]
|
||||
elif node_type == "pc_alive_check":
|
||||
self.output_ports = [
|
||||
Port("true", "output", "True"),
|
||||
Port("false", "output", "False"),
|
||||
]
|
||||
elif node_type == "bluetooth" and self.data.data.get("mode") == "get_local":
|
||||
# Get Variables uses Num Lock probing; routes to Pass on a matched
|
||||
# outcome and Fail on no match / timeout.
|
||||
self.output_ports = [
|
||||
Port("pass", "output", "Pass"),
|
||||
Port("fail", "output", "Fail"),
|
||||
]
|
||||
else:
|
||||
self.output_ports = [Port("out", "output", "Out")]
|
||||
|
||||
self.ports = self.input_ports + self.output_ports
|
||||
|
||||
def _draw(self):
|
||||
self._clear()
|
||||
|
||||
zoom = self._zoom()
|
||||
|
||||
# World → canvas
|
||||
x = self.data.x * zoom
|
||||
y = self.data.y * zoom
|
||||
|
||||
if self.data.type == "note":
|
||||
self._draw_note(x, y, zoom)
|
||||
return
|
||||
|
||||
type_info = NODE_TYPES.get(self.data.type, {"label": "Unknown", "color": "#555555"})
|
||||
header_color = type_info["color"]
|
||||
label = type_info["label"]
|
||||
|
||||
w = NODE_MIN_WIDTH * zoom
|
||||
header_h = NODE_HEADER_HEIGHT * zoom
|
||||
port_spacing = 24 * zoom
|
||||
|
||||
port_count = max(len(self.input_ports), len(self.output_ports))
|
||||
body_h = max(30 * zoom, port_count * port_spacing + 8 * zoom)
|
||||
total_h = header_h + body_h
|
||||
port_radius = max(2, NODE_PORT_RADIUS * zoom)
|
||||
|
||||
# Clamp minimum font size for legibility
|
||||
header_font_size = max(5, int(round(9 * zoom)))
|
||||
subtitle_font_size = max(5, int(round(8 * zoom)))
|
||||
port_label_font_size = max(5, int(round(7 * zoom)))
|
||||
|
||||
body = self.canvas.create_rectangle(
|
||||
x, y, x + w, y + total_h,
|
||||
fill=NODE_BODY_COLOR, outline="#555555", width=1,
|
||||
tags=(self.tag, "node")
|
||||
)
|
||||
self.canvas_items.append(body)
|
||||
|
||||
header = self.canvas.create_rectangle(
|
||||
x, y, x + w, y + header_h,
|
||||
fill=header_color, outline=header_color,
|
||||
tags=(self.tag, "node", "header")
|
||||
)
|
||||
self.canvas_items.append(header)
|
||||
|
||||
header_text = self.canvas.create_text(
|
||||
x + w / 2, y + header_h / 2,
|
||||
text=label, fill="white", font=("Segoe UI", header_font_size, "bold"),
|
||||
tags=(self.tag, "node", "header")
|
||||
)
|
||||
self.canvas_items.append(header_text)
|
||||
|
||||
subtitle = self._get_subtitle()
|
||||
if subtitle:
|
||||
sub_text = self.canvas.create_text(
|
||||
x + w / 2, y + header_h + 14 * zoom,
|
||||
text=subtitle, fill="#AAAAAA", font=("Segoe UI", subtitle_font_size),
|
||||
width=max(1, int(w - 16 * zoom)),
|
||||
tags=(self.tag, "node")
|
||||
)
|
||||
self.canvas_items.append(sub_text)
|
||||
|
||||
port_start_y = y + header_h + 8 * zoom
|
||||
show_port_labels = self.data.type in (
|
||||
"branch", "repeat", "pc_alive_check", "iteration_branch",
|
||||
) or (self.data.type == "bluetooth" and self.data.data.get("mode") == "get_local")
|
||||
label_inset = 12 * zoom
|
||||
|
||||
flipped = bool(getattr(self.data, "flipped", False))
|
||||
# When flipped: inputs go on the right, outputs on the left
|
||||
in_on_right = flipped
|
||||
out_on_right = not flipped
|
||||
in_x = (x + w) if in_on_right else x
|
||||
out_x = (x + w) if out_on_right else x
|
||||
|
||||
for i, port in enumerate(self.input_ports):
|
||||
py = port_start_y + i * port_spacing + 12 * zoom
|
||||
px = in_x
|
||||
port.x = px
|
||||
port.y = py
|
||||
port.side = "R" if in_on_right else "L"
|
||||
cid = self.canvas.create_oval(
|
||||
px - port_radius, py - port_radius,
|
||||
px + port_radius, py + port_radius,
|
||||
fill=NODE_PORT_IN_COLOR, outline="#222222",
|
||||
tags=(self.tag, "port", f"port_{self.data.id}_{port.name}")
|
||||
)
|
||||
port.canvas_id = cid
|
||||
self.canvas_items.append(cid)
|
||||
|
||||
if show_port_labels and len(self.input_ports) > 1:
|
||||
# Label goes toward node interior
|
||||
if in_on_right:
|
||||
label_x = px - label_inset
|
||||
anchor = "e"
|
||||
else:
|
||||
label_x = px + label_inset
|
||||
anchor = "w"
|
||||
plabel = self.canvas.create_text(
|
||||
label_x, py,
|
||||
text=port.label, fill="#CCCCCC",
|
||||
font=("Segoe UI", port_label_font_size),
|
||||
anchor=anchor,
|
||||
tags=(self.tag, "node")
|
||||
)
|
||||
self.canvas_items.append(plabel)
|
||||
|
||||
for i, port in enumerate(self.output_ports):
|
||||
py = port_start_y + i * port_spacing + 12 * zoom
|
||||
px = out_x
|
||||
port.x = px
|
||||
port.y = py
|
||||
port.side = "R" if out_on_right else "L"
|
||||
cid = self.canvas.create_oval(
|
||||
px - port_radius, py - port_radius,
|
||||
px + port_radius, py + port_radius,
|
||||
fill=NODE_PORT_OUT_COLOR, outline="#222222",
|
||||
tags=(self.tag, "port", f"port_{self.data.id}_{port.name}")
|
||||
)
|
||||
port.canvas_id = cid
|
||||
self.canvas_items.append(cid)
|
||||
|
||||
if show_port_labels:
|
||||
# Label goes toward node interior
|
||||
if out_on_right:
|
||||
label_x = px - label_inset
|
||||
anchor = "e"
|
||||
else:
|
||||
label_x = px + label_inset
|
||||
anchor = "w"
|
||||
plabel = self.canvas.create_text(
|
||||
label_x, py,
|
||||
text=port.label, fill="#CCCCCC",
|
||||
font=("Segoe UI", port_label_font_size),
|
||||
anchor=anchor,
|
||||
tags=(self.tag, "node")
|
||||
)
|
||||
self.canvas_items.append(plabel)
|
||||
|
||||
self.width = w
|
||||
self.height = total_h
|
||||
|
||||
if self.selected:
|
||||
self._draw_selection()
|
||||
elif self.highlight_kind:
|
||||
self._draw_highlight()
|
||||
|
||||
def _get_subtitle(self) -> str:
|
||||
d = self.data.data
|
||||
t = self.data.type
|
||||
if t == "text":
|
||||
text = d.get("text", "")
|
||||
return f'"{text[:20]}..."' if len(text) > 20 else f'"{text}"' if text else "(empty)"
|
||||
elif t == "combo":
|
||||
mods = "+".join(d.get("mods", []))
|
||||
key = d.get("key", "")
|
||||
# Legacy "fast" or new "custom_timings" both surface as a lightning bolt
|
||||
uses_custom = bool(d.get("custom_timings", False)) or bool(d.get("fast", False))
|
||||
prefix = "\u26a1 " if uses_custom else ""
|
||||
if mods and key:
|
||||
return f"{prefix}{mods}+{key}"
|
||||
elif mods:
|
||||
return f"{prefix}{mods}"
|
||||
elif key:
|
||||
return f"{prefix}{key}"
|
||||
return "(empty)"
|
||||
elif t == "delay":
|
||||
return f'{d.get("ms", 0)}ms'
|
||||
elif t == "pause":
|
||||
wait = d.get("wait", "click")
|
||||
return f"Wait: {wait}"
|
||||
elif t == "mouse":
|
||||
return f'{d.get("action", "click")} {d.get("button", "left")}'
|
||||
elif t == "media":
|
||||
return d.get("action", "?")
|
||||
elif t == "repeat":
|
||||
if d.get("use_selector", False):
|
||||
return "Count from selector"
|
||||
return f'{d.get("count", 1)}x'
|
||||
elif t == "loop_selector":
|
||||
mn = d.get("min", 1)
|
||||
mx = d.get("max", 10)
|
||||
step = d.get("step", 1)
|
||||
if step != 1:
|
||||
return f"{mn}..{mx} (step {step})"
|
||||
return f"{mn}..{mx}"
|
||||
elif t == "iteration_branch":
|
||||
n_choices = len(d.get("choices", []))
|
||||
tied = d.get("loop_node_id", "")
|
||||
if not tied:
|
||||
return f"{n_choices} paths (untied)"
|
||||
return f"{n_choices} paths"
|
||||
elif t == "aggregator":
|
||||
n = d.get("input_count", 2)
|
||||
return f"{n} inputs"
|
||||
elif t == "subroutine":
|
||||
name = d.get("name", "")
|
||||
return f'Call: {name}' if name else "(not set)"
|
||||
elif t == "rs232":
|
||||
msg = d.get("message", "")
|
||||
baud = d.get("baud", 9600)
|
||||
preview = f'{msg[:15]}...' if len(msg) > 15 else msg
|
||||
return f'{baud}bps: "{preview}"' if preview else f'{baud}bps'
|
||||
elif t == "pc_alive_check":
|
||||
cond = d.get("condition", "pc_response")
|
||||
labels = {"numlock_on": "NumLock ON", "numlock_off": "NumLock OFF", "pc_response": "PC Response"}
|
||||
loop = d.get("loop", True)
|
||||
label = labels.get(cond, cond)
|
||||
return f"{label}" + (" (loop)" if loop else "")
|
||||
elif t == "start":
|
||||
return "Execution begins here"
|
||||
elif t == "note":
|
||||
return "" # Notes render their own body; never show subtitle
|
||||
elif t == "macro":
|
||||
n = len(d.get("events", []))
|
||||
name = d.get("name", "").strip()
|
||||
if n == 0:
|
||||
return f"{name} (empty)" if name else "(not recorded)"
|
||||
last_t = d["events"][-1][0] if d["events"] else 0
|
||||
secs = last_t / 1000.0
|
||||
dur = f"{secs:.1f}s" if secs < 60 else f"{int(secs // 60)}m{int(secs % 60)}s"
|
||||
return f'{name} \u25b6 {n} evt, {dur}' if name else f"\u25b6 {n} evt, {dur}"
|
||||
elif t == "bluetooth":
|
||||
mode_labels = {
|
||||
"pull_ble": "Pull BLE Variables",
|
||||
"push_ble": "Push BLE Variables",
|
||||
"request_ble": "Request BLE Variable(s)",
|
||||
"set_local": "Set Variables",
|
||||
"get_local": "Get Variables",
|
||||
}
|
||||
return mode_labels.get(d.get("mode", "pull_ble"), "")
|
||||
return ""
|
||||
|
||||
def _draw_note(self, x, y, zoom):
|
||||
"""Draw a Note node — GUI-only annotation with no header or ports."""
|
||||
d = self.data.data
|
||||
text = d.get("text", "") or "(empty note)"
|
||||
font_size = int(d.get("font_size", 14))
|
||||
color_name = d.get("color", "white")
|
||||
width_world = int(d.get("width", 220))
|
||||
|
||||
font_px = max(5, int(round(font_size * zoom)))
|
||||
w = max(60, width_world * zoom)
|
||||
pad = max(4, 8 * zoom)
|
||||
|
||||
from utils.constants import DISPLAY_COLORS
|
||||
color_map = dict(DISPLAY_COLORS)
|
||||
text_color = color_map.get(color_name, color_name)
|
||||
|
||||
# Muted text if this is actually an empty placeholder
|
||||
show_placeholder = not d.get("text")
|
||||
if show_placeholder:
|
||||
text_color = "#888888"
|
||||
|
||||
# Create the text item first so we can measure its bbox,
|
||||
# then back-size the card rectangle around it.
|
||||
text_id = self.canvas.create_text(
|
||||
x + pad, y + pad,
|
||||
text=text,
|
||||
fill=text_color,
|
||||
font=("Segoe UI", font_px),
|
||||
anchor="nw",
|
||||
width=max(1, int(w - 2 * pad)),
|
||||
tags=(self.tag, "node", "note")
|
||||
)
|
||||
|
||||
bbox = self.canvas.bbox(text_id)
|
||||
if bbox:
|
||||
min_h = font_px + 2 * pad
|
||||
total_h = max(min_h, (bbox[3] - y) + pad)
|
||||
else:
|
||||
total_h = max(40, font_px + 2 * pad)
|
||||
|
||||
# Subtle dashed border distinguishes notes from regular nodes
|
||||
body = self.canvas.create_rectangle(
|
||||
x, y, x + w, y + total_h,
|
||||
fill="#25252F", outline="#5A5A7A", width=1, dash=(3, 3),
|
||||
tags=(self.tag, "node", "note")
|
||||
)
|
||||
self.canvas.tag_lower(body, text_id)
|
||||
|
||||
self.canvas_items.append(body)
|
||||
self.canvas_items.append(text_id)
|
||||
|
||||
self.width = w
|
||||
self.height = total_h
|
||||
|
||||
if self.selected:
|
||||
self._draw_selection()
|
||||
elif self.highlight_kind:
|
||||
self._draw_highlight()
|
||||
|
||||
def _draw_selection(self):
|
||||
zoom = self._zoom()
|
||||
x = self.data.x * zoom
|
||||
y = self.data.y * zoom
|
||||
sel = self.canvas.create_rectangle(
|
||||
x - 2, y - 2, x + self.width + 2, y + self.height + 2,
|
||||
outline=NODE_SELECTED_BORDER, width=2, dash=(4, 2),
|
||||
tags=(self.tag, "selection")
|
||||
)
|
||||
self.canvas_items.append(sel)
|
||||
|
||||
def _draw_highlight(self):
|
||||
"""Directional connectivity border.
|
||||
|
||||
- "upstream" → solid green border (matches the green input-port
|
||||
color on the selected node — this neighbor is what
|
||||
feeds INTO the selection).
|
||||
- "downstream" → solid red border (matches the red output-port color
|
||||
— this neighbor receives from the selection's
|
||||
output).
|
||||
- "both" → an alternating green/red dotted border. Tkinter
|
||||
can't multi-color a single outline, so we draw the
|
||||
perimeter as a chain of short segments that cycle
|
||||
through the two colors dash-by-dash.
|
||||
"""
|
||||
zoom = self._zoom()
|
||||
x = self.data.x * zoom
|
||||
y = self.data.y * zoom
|
||||
kind = self.highlight_kind
|
||||
|
||||
if kind == "both":
|
||||
self._draw_alternating_border(x, y, self.width, self.height, zoom)
|
||||
return
|
||||
|
||||
color = NODE_UPSTREAM_BORDER if kind == "upstream" else NODE_DOWNSTREAM_BORDER
|
||||
hl = self.canvas.create_rectangle(
|
||||
x - 2, y - 2, x + self.width + 2, y + self.height + 2,
|
||||
outline=color, width=2,
|
||||
tags=(self.tag, "highlight")
|
||||
)
|
||||
self.canvas_items.append(hl)
|
||||
|
||||
def _draw_alternating_border(self, x, y, w, h, zoom):
|
||||
"""Draw the node border as alternating green/red dashes.
|
||||
|
||||
Tkinter can't multi-color a single outline, so we walk the perimeter
|
||||
clockwise and emit one short line per dash, alternating colors.
|
||||
"""
|
||||
# Slight outset so the dashes don't overlap the node body
|
||||
pad = 2
|
||||
x1 = x - pad
|
||||
y1 = y - pad
|
||||
x2 = x + w + pad
|
||||
y2 = y + h + pad
|
||||
|
||||
seg_len = max(5, 8 * zoom)
|
||||
gap_len = max(3, 4 * zoom)
|
||||
stride = seg_len + gap_len
|
||||
width = max(1, int(round(2 * zoom)))
|
||||
|
||||
colors = (NODE_UPSTREAM_BORDER, NODE_DOWNSTREAM_BORDER)
|
||||
|
||||
# Clockwise: top → right → bottom → left
|
||||
edges = [
|
||||
(x1, y1, x2, y1),
|
||||
(x2, y1, x2, y2),
|
||||
(x2, y2, x1, y2),
|
||||
(x1, y2, x1, y1),
|
||||
]
|
||||
|
||||
color_idx = 0
|
||||
for ax, ay, bx, by in edges:
|
||||
length = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5
|
||||
if length <= 0:
|
||||
continue
|
||||
ux = (bx - ax) / length
|
||||
uy = (by - ay) / length
|
||||
pos = 0.0
|
||||
while pos < length:
|
||||
end = min(pos + seg_len, length)
|
||||
sx = ax + ux * pos
|
||||
sy = ay + uy * pos
|
||||
ex = ax + ux * end
|
||||
ey = ay + uy * end
|
||||
item = self.canvas.create_line(
|
||||
sx, sy, ex, ey,
|
||||
fill=colors[color_idx % 2],
|
||||
width=width,
|
||||
capstyle="round",
|
||||
tags=(self.tag, "highlight")
|
||||
)
|
||||
self.canvas_items.append(item)
|
||||
color_idx += 1
|
||||
pos += stride
|
||||
|
||||
def move_by(self, dx_canvas, dy_canvas):
|
||||
"""Move the node. Deltas are in CANVAS coordinates (screen pixels)."""
|
||||
for item in self.canvas_items:
|
||||
self.canvas.move(item, dx_canvas, dy_canvas)
|
||||
|
||||
# Convert canvas delta → world delta before updating data
|
||||
zoom = self._zoom()
|
||||
if zoom == 0:
|
||||
zoom = 1.0
|
||||
self.data.x += dx_canvas / zoom
|
||||
self.data.y += dy_canvas / zoom
|
||||
|
||||
# Port coords are in canvas space — update by canvas delta
|
||||
for port in self.ports:
|
||||
port.x += dx_canvas
|
||||
port.y += dy_canvas
|
||||
|
||||
if self.on_move:
|
||||
self.on_move(self)
|
||||
|
||||
def set_selected(self, selected: bool):
|
||||
self.selected = selected
|
||||
self.redraw()
|
||||
|
||||
def set_highlight_kind(self, kind: str | None):
|
||||
"""Set the directional connectivity highlight.
|
||||
|
||||
``kind`` is one of None / "upstream" / "downstream" / "both".
|
||||
No-op if unchanged so bulk selection updates don't thrash the canvas.
|
||||
"""
|
||||
if self.highlight_kind == kind:
|
||||
return
|
||||
self.highlight_kind = kind
|
||||
self.redraw()
|
||||
|
||||
def redraw(self):
|
||||
self._draw()
|
||||
|
||||
def _clear(self):
|
||||
for item in self.canvas_items:
|
||||
self.canvas.delete(item)
|
||||
self.canvas_items.clear()
|
||||
|
||||
def destroy(self):
|
||||
self._clear()
|
||||
|
||||
def get_port(self, port_name: str) -> Port | None:
|
||||
for p in self.ports:
|
||||
if p.name == port_name:
|
||||
return p
|
||||
return None
|
||||
|
||||
def get_port_at(self, x: int, y: int) -> Port | None:
|
||||
"""Find a port near the given canvas coords."""
|
||||
zoom = self._zoom()
|
||||
port_radius = max(2, NODE_PORT_RADIUS * zoom)
|
||||
tol2 = (port_radius + 4) ** 2
|
||||
for port in self.ports:
|
||||
dx = x - port.x
|
||||
dy = y - port.y
|
||||
if dx * dx + dy * dy <= tol2:
|
||||
return port
|
||||
return None
|
||||
|
||||
def update_branch_ports(self):
|
||||
if self.data.type in ("branch", "iteration_branch", "aggregator", "bluetooth"):
|
||||
self._setup_ports()
|
||||
self.redraw()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
"""Port definitions for node editor."""
|
||||
|
||||
|
||||
class Port:
|
||||
"""Represents an input or output port on a node."""
|
||||
|
||||
def __init__(self, name: str, port_type: str, label: str = ""):
|
||||
self.name = name # e.g. "in", "out", "out_0", "out_1"
|
||||
self.port_type = port_type # "input" or "output"
|
||||
self.label = label or name
|
||||
self.canvas_id = None
|
||||
self.x = 0
|
||||
self.y = 0
|
||||
# "L" or "R" — set by NodeWidget during draw based on the node's
|
||||
# flipped state. Wire routing uses it to pick curve control points.
|
||||
self.side = "L" if port_type == "input" else "R"
|
||||
|
||||
def __repr__(self):
|
||||
return f"Port({self.name}, {self.port_type})"
|
||||
Reference in New Issue
Block a user