563 lines
20 KiB
Python
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()
|