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