Files
m5stack-automation-tool/node_editor/node.py
T
2026-07-17 15:29:53 -04:00

563 lines
20 KiB
Python

"""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()