Initial public release
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user