Initial public release

This commit is contained in:
2026-07-17 15:29:53 -04:00
commit 2d71ce77a1
81 changed files with 32056 additions and 0 deletions
View File
File diff suppressed because it is too large Load Diff
+465
View File
@@ -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
+562
View File
@@ -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()
+2933
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -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})"