"""Node editor canvas - the main visual editing area. Coordinate system: - ``NodeData.x``, ``NodeData.y`` are WORLD coordinates (always in zoom=1.0 space). - The Tkinter canvas renders at CANVAS coordinates = world * _zoom_level. - Mouse events return canvas coordinates (via canvasx/canvasy); to store new node positions in the macro, convert: ``world = canvas / zoom``. """ import copy as _copy import tkinter as tk from utils.constants import ( CANVAS_BG, NODE_TYPES, GRID_SIZE, GRID_COLOR, NODE_PORT_IN_COLOR, NODE_PORT_OUT_COLOR, ) from models.macro import NodeData, ConnectionData from .node import NodeWidget from .connection import Connection # Default world bounds (zoom=1.0). The scrollregion never shrinks below this rectangle. DEFAULT_WORLD_LEFT = -2000 DEFAULT_WORLD_TOP = -2000 DEFAULT_WORLD_RIGHT = 4000 DEFAULT_WORLD_BOTTOM = 4000 # Auto-grow parameters (world units, zoom=1.0): # - GROW_MARGIN: bbox edge proximity that triggers expansion # - GROW_CHUNK: quantization step; also the hysteresis band so a node near # a threshold doesn't oscillate the scrollregion # - NODE_FOOTPRINT_*: rough node extent used when computing the bbox GROW_MARGIN = 400 GROW_CHUNK = 1000 NODE_FOOTPRINT_W = 200 NODE_FOOTPRINT_H = 200 def _floor_chunk_to(value: float, anchor: int) -> int: """Round ``value`` toward -inf to the nearest ``anchor + k * GROW_CHUNK``.""" delta = value - anchor k = int(delta // GROW_CHUNK) return anchor + k * GROW_CHUNK def _ceil_chunk_to(value: float, anchor: int) -> int: """Round ``value`` toward +inf to the nearest ``anchor + k * GROW_CHUNK``.""" delta = value - anchor k = -int(-delta // GROW_CHUNK) return anchor + k * GROW_CHUNK class NodeCanvas(tk.Frame): def __init__(self, parent, on_node_select=None, on_change=None): super().__init__(parent) self.on_node_select = on_node_select self.on_change = on_change self.nodes: dict[str, NodeWidget] = {} self.connections: list[Connection] = [] self.selected_nodes: set[NodeWidget] = set() self.macro = None self._zoom_level = 1.0 # Grow outward in GROW_CHUNK steps as nodes approach an edge; snap # back to defaults when the canvas empties. See _recompute_world_bounds. self._world_left = DEFAULT_WORLD_LEFT self._world_top = DEFAULT_WORLD_TOP self._world_right = DEFAULT_WORLD_RIGHT self._world_bottom = DEFAULT_WORLD_BOTTOM # Grid cache: while the viewport stays inside _grid_drawn_region the # existing lines remain valid and _draw_grid is a no-op. The pending # flag coalesces rapid scroll/resize callbacks into one redraw. self._grid_drawn_region: tuple | None = None self._grid_redraw_pending = False self._drag_node: NodeWidget | None = None self._drag_start_x = 0 self._drag_start_y = 0 self._is_dragging = False self._wire_drag = False self._wire_from_node = None self._wire_from_port = None self._wire_temp_line = None self._pan_origin = None self._is_panning = False self._rubberband_active = False self._rubberband_start = None self._rubberband_rect_id = None self._clipboard = None self._last_paste_anchor = None # world (x, y) of most recent paste top-left # "Pick a loop" mode — used by iteration_branch self._picking_loop_for = None self._pick_done_callback = None self._pick_banner_items = [] self._setup_canvas() self._bind_events() @property def selected_node(self) -> NodeWidget | None: if len(self.selected_nodes) == 1: return next(iter(self.selected_nodes)) return None @selected_node.setter def selected_node(self, value): if value is None: self._deselect_all() def _setup_canvas(self): self.canvas = tk.Canvas( self, bg=CANVAS_BG, highlightthickness=0, scrollregion=self._scrollregion_for_zoom(), ) h_scroll = tk.Scrollbar(self, orient=tk.HORIZONTAL, command=self.canvas.xview) v_scroll = tk.Scrollbar(self, orient=tk.VERTICAL, command=self.canvas.yview) # Wrap scroll commands so the grid lazily extends when the user # scrolls past the currently-drawn region self.canvas.configure( xscrollcommand=lambda *a: (h_scroll.set(*a), self._schedule_grid_redraw()), yscrollcommand=lambda *a: (v_scroll.set(*a), self._schedule_grid_redraw()), ) self.canvas.grid(row=0, column=0, sticky="nsew") v_scroll.grid(row=0, column=1, sticky="ns") h_scroll.grid(row=1, column=0, sticky="ew") self.grid_rowconfigure(0, weight=1) self.grid_columnconfigure(0, weight=1) self.canvas.bind("", self._on_canvas_configure, add="+") self._draw_grid() def _scrollregion_for_zoom(self): z = self._zoom_level return (self._world_left * z, self._world_top * z, self._world_right * z, self._world_bottom * z) # ---- Bounds expand / contract ---- def _node_bbox_world(self): """Bounding box of all nodes in world coords, or None if empty.""" if not self.nodes: return None min_x = min_y = float("inf") max_x = max_y = float("-inf") for w in self.nodes.values(): d = w.data if d.x < min_x: min_x = d.x if d.y < min_y: min_y = d.y # Account for the rendered footprint, not just the top-left # anchor — otherwise a node planted right at the right edge # would extend past the scrollregion until you drag it once. ex = d.x + NODE_FOOTPRINT_W ey = d.y + NODE_FOOTPRINT_H if ex > max_x: max_x = ex if ey > max_y: max_y = ey return (min_x, min_y, max_x, max_y) def _recompute_world_bounds(self, can_shrink: bool) -> bool: """Recompute scrollregion bounds from node positions. Quantized to GROW_CHUNK steps relative to the defaults so we don't thrash the scrollregion every drag pixel: a node has to move a full chunk past a threshold before bounds change. ``can_shrink=False`` is used during an active drag so the scrollregion never collapses underneath the cursor mid-motion. Shrinks (and the resulting viewport snap, if any) are deferred to drag release / add / delete / paste / load. Returns True if any edge changed. """ bbox = self._node_bbox_world() if bbox is None: new_left = DEFAULT_WORLD_LEFT new_top = DEFAULT_WORLD_TOP new_right = DEFAULT_WORLD_RIGHT new_bottom = DEFAULT_WORLD_BOTTOM else: min_x, min_y, max_x, max_y = bbox # The "ideal" bounds: node bbox padded by GROW_MARGIN, then # quantized outward to chunk boundaries anchored at the # default edges. Floor toward -inf on the negative sides; # ceil toward +inf on the positive sides. ideal_left = _floor_chunk_to(min_x - GROW_MARGIN, DEFAULT_WORLD_LEFT) ideal_top = _floor_chunk_to(min_y - GROW_MARGIN, DEFAULT_WORLD_TOP) ideal_right = _ceil_chunk_to(max_x + GROW_MARGIN, DEFAULT_WORLD_RIGHT) ideal_bottom = _ceil_chunk_to(max_y + GROW_MARGIN, DEFAULT_WORLD_BOTTOM) # Defaults are the floor/ceiling — never shrink below them. new_left = min(DEFAULT_WORLD_LEFT, ideal_left) new_top = min(DEFAULT_WORLD_TOP, ideal_top) new_right = max(DEFAULT_WORLD_RIGHT, ideal_right) new_bottom = max(DEFAULT_WORLD_BOTTOM, ideal_bottom) if not can_shrink: # Keep current bounds if they were larger — only ever grow # during a drag. if new_left > self._world_left: new_left = self._world_left if new_top > self._world_top: new_top = self._world_top if new_right < self._world_right: new_right = self._world_right if new_bottom < self._world_bottom: new_bottom = self._world_bottom if (new_left == self._world_left and new_top == self._world_top and new_right == self._world_right and new_bottom == self._world_bottom): return False self._world_left = new_left self._world_top = new_top self._world_right = new_right self._world_bottom = new_bottom return True def _ensure_bounds_for_nodes(self, can_shrink: bool = False) -> None: """Recompute bounds; if changed, push to scrollregion + refresh grid.""" if not self._recompute_world_bounds(can_shrink): return self.canvas.configure(scrollregion=self._scrollregion_for_zoom()) # Bounds changed → grid clamping changed → invalidate cache so the # next redraw re-clips to the new scrollregion. self._invalidate_grid() self._schedule_grid_redraw() # ---- Viewport-based grid ---- def _invalidate_grid(self) -> None: self._grid_drawn_region = None def _schedule_grid_redraw(self) -> None: """Coalesce many scroll/resize ticks into one idle-time redraw.""" if self._grid_redraw_pending: return self._grid_redraw_pending = True try: self.after_idle(self._do_grid_redraw) except Exception: self._grid_redraw_pending = False def _do_grid_redraw(self) -> None: self._grid_redraw_pending = False self._draw_grid() def _on_canvas_configure(self, _event) -> None: # Window/canvas resized → existing grid may not cover the new # viewport. Drop the cache so the next idle redraw re-extends. self._invalidate_grid() self._schedule_grid_redraw() def _draw_grid(self) -> None: z = self._zoom_level spacing = GRID_SIZE * z if spacing < 6: # Too dense to be useful at very low zoom self.canvas.delete("grid") self._grid_drawn_region = None return # Visible viewport in canvas coords. winfo_width/height can be 1 # before the widget has been mapped — fall back to a safe default # so the very first draw still produces a usable grid. try: vw = self.canvas.winfo_width() vh = self.canvas.winfo_height() except Exception: vw = vh = 0 if vw < 2: vw = 800 if vh < 2: vh = 600 try: vleft = float(self.canvas.canvasx(0)) vtop = float(self.canvas.canvasy(0)) except Exception: vleft = vtop = 0.0 vright = vleft + vw vbottom = vtop + vh # If the existing grid still covers the visible viewport, skip # the redraw entirely — this is the steady state for normal # mousewheel scrolling within a viewport. if self._grid_drawn_region is not None: gl, gt, gr, gb = self._grid_drawn_region if vleft >= gl and vtop >= gt and vright <= gr and vbottom <= gb: return # Draw with one viewport's worth of overdraw on every side. This # means a single redraw covers ~3x viewport in each dimension, so # the user can scroll a long way before triggering another redraw. left = vleft - vw top = vtop - vh right = vright + vw bottom = vbottom + vh # Clamp to scrollregion — no point drawing grid where the user # can never scroll to. sr = self._scrollregion_for_zoom() left = max(left, sr[0]) top = max(top, sr[1]) right = min(right, sr[2]) bottom = min(bottom, sr[3]) if right <= left or bottom <= top: self.canvas.delete("grid") self._grid_drawn_region = None return self.canvas.delete("grid") # Align grid lines to world-grid positions (the modulo math also # works for negative coords because Python's % returns a # non-negative remainder). x = left - (left % spacing) while x <= right: self.canvas.create_line(x, top, x, bottom, fill=GRID_COLOR, tags=("grid",)) x += spacing y = top - (top % spacing) while y <= bottom: self.canvas.create_line(left, y, right, y, fill=GRID_COLOR, tags=("grid",)) y += spacing self.canvas.tag_lower("grid") self._grid_drawn_region = (left, top, right, bottom) def _bind_events(self): self.canvas.bind("", self._on_click) self.canvas.bind("", self._on_drag) self.canvas.bind("", self._on_release) self.canvas.bind("", self._on_delete) self.canvas.bind("", self._on_mousewheel) self.canvas.bind("", self._on_select_all) self.canvas.bind("", self._on_copy) self.canvas.bind("", self._on_paste) self.canvas.bind("", self._on_escape) self.canvas.bind("", self._on_flip_selected) self.canvas.bind("", self._on_flip_selected) self.canvas.bind("", self._on_right_press) self.canvas.bind("", self._on_right_drag) self.canvas.bind("", self._on_right_release) self.canvas.bind("", self._on_pan_start) self.canvas.bind("", self._on_pan_move) self.canvas.focus_set() # ===================================================================== # Selection helpers # ===================================================================== def _deselect_all(self): for node in self.selected_nodes: node.set_selected(False) self.selected_nodes.clear() self._refresh_connected_highlights() def _select_single(self, node_widget: NodeWidget): self._deselect_all() self.selected_nodes.add(node_widget) node_widget.set_selected(True) self._refresh_connected_highlights() if self.on_node_select: self.on_node_select(node_widget) def _toggle_selection(self, node_widget: NodeWidget): if node_widget in self.selected_nodes: self.selected_nodes.discard(node_widget) node_widget.set_selected(False) else: self.selected_nodes.add(node_widget) node_widget.set_selected(True) self._refresh_connected_highlights() if self.on_node_select: self.on_node_select(self.selected_node) def select_all(self): for widget in self.nodes.values(): self.selected_nodes.add(widget) widget.set_selected(True) self._refresh_connected_highlights() if self.on_node_select: self.on_node_select(self.selected_node) def _refresh_connected_highlights(self): """Apply selection-adjacency highlights to both nodes and wires. Node borders: - green ("upstream") — wires into our input come from this node - red ("downstream") — wires from our output go to this node - mixed ("both") — node is both upstream and downstream of the current selection - none — everything else Wires: - green ("input") — wire feeds a selected node's input - red ("output") — wire leaves a selected node's output - fade ("fade") — wire connects two selected nodes (red at the from-port end, green at the to-port end, so each end's color matches the port color it terminates at) - none — default faded node-color gradient Highlighted wires also get raised above the other wires (but still below nodes) via ``_reorder_wire_layers`` so they read as on-top. The colors mirror the port colors: green input ports, red output ports. So at a glance you can see which neighbors feed the current selection vs. which neighbors the selection feeds. """ selected_ids = {w.data.id for w in self.selected_nodes} upstream_ids: set[str] = set() downstream_ids: set[str] = set() for conn in self.connections: fid = conn.from_node.data.id tid = conn.to_node.data.id from_sel = fid in selected_ids to_sel = tid in selected_ids if from_sel and to_sel: conn.set_highlight("fade") elif to_sel: conn.set_highlight("input") upstream_ids.add(fid) elif from_sel: conn.set_highlight("output") downstream_ids.add(tid) else: conn.set_highlight(None) for widget in self.nodes.values(): if widget.selected: widget.set_highlight_kind(None) continue nid = widget.data.id is_up = nid in upstream_ids is_down = nid in downstream_ids if is_up and is_down: widget.set_highlight_kind("both") elif is_up: widget.set_highlight_kind("upstream") elif is_down: widget.set_highlight_kind("downstream") else: widget.set_highlight_kind(None) # Restack: highlighted wires above normal wires, both below nodes. self._reorder_wire_layers() def _reorder_wire_layers(self): """Enforce stacking order: grid < wire < wire_hl < node. Each tag_raise is guarded because tags may be empty (e.g. no wire currently highlighted) and Tkinter raises TclError in that case on some platforms. """ for args in (("wire", "grid"), ("wire_hl", "wire"), ("node", "wire_hl"), ("node", "wire")): try: self.canvas.tag_raise(*args) except Exception: pass # ===================================================================== # Macro loading # ===================================================================== def load_macro(self, macro): self.clear() self.macro = macro if not macro: return for node_data in macro.nodes: self._create_node_widget(node_data) for conn_data in macro.connections: from_widget = self.nodes.get(conn_data.from_id) to_widget = self.nodes.get(conn_data.to_id) if from_widget and to_widget: from_port = from_widget.get_port(conn_data.from_port) to_port = to_widget.get_port(conn_data.to_port) if from_port and to_port: conn = Connection(self.canvas, from_widget, from_port, to_widget, to_port, conn_data) self.connections.append(conn) # Existing macros may already have nodes outside the default # bounds (legacy projects, paste-from-elsewhere, hand-edited # JSON); make sure the scrollregion grows to fit on first show. self._ensure_bounds_for_nodes(can_shrink=True) def clear(self): for conn in self.connections: conn.destroy() self.connections.clear() for node in self.nodes.values(): node.destroy() self.nodes.clear() self.selected_nodes.clear() self._drag_node = None # Reset zoom AND world bounds so the next macro starts fresh. # _ensure_bounds_for_nodes() with no nodes collapses to defaults, # which is also what we want after a clear with zoom unchanged. zoom_changed = self._zoom_level != 1.0 if zoom_changed: self._zoom_level = 1.0 bounds_changed = self._recompute_world_bounds(can_shrink=True) if zoom_changed or bounds_changed: self.canvas.configure(scrollregion=self._scrollregion_for_zoom()) self._invalidate_grid() self._schedule_grid_redraw() def _create_node_widget(self, node_data: NodeData) -> NodeWidget: widget = NodeWidget( self.canvas, node_data, on_select=self._on_node_click_select, on_move=self._on_node_moved, canvas_ref=self, ) self.nodes[node_data.id] = widget return widget def add_node(self, node_type: str, x: int = None, y: int = None): """Add a new node of the given type. ``x``, ``y`` are CANVAS (screen) coords from the context menu; they are converted to world coords by dividing by zoom. """ if not self.macro: return if x is None or y is None: wx = 200 if x is None else x / self._zoom_level wy = 200 if y is None else y / self._zoom_level else: wx = x / self._zoom_level wy = y / self._zoom_level wx = round(wx / GRID_SIZE) * GRID_SIZE wy = round(wy / GRID_SIZE) * GRID_SIZE node_data = NodeData(node_type, x=wx, y=wy) self.macro.add_node(node_data) widget = self._create_node_widget(node_data) self._ensure_bounds_for_nodes(can_shrink=True) self._notify_change() # For iteration_branch: prompt the user to click a Loop to tie to. if node_type == "iteration_branch": self._select_single(widget) self.after(50, lambda w=widget: self.start_picking_loop_for(w)) def delete_selected(self): if not self.selected_nodes or not self.macro: return nodes_to_delete = list(self.selected_nodes) for node_widget in nodes_to_delete: node_id = node_widget.data.id to_remove = [c for c in self.connections if c.from_node.data.id == node_id or c.to_node.data.id == node_id] for conn in to_remove: conn.destroy() self.connections.remove(conn) self.macro.remove_node(node_id) node_widget.destroy() del self.nodes[node_id] self.selected_nodes.clear() self._refresh_connected_highlights() if self.on_node_select: self.on_node_select(None) self._ensure_bounds_for_nodes(can_shrink=True) self._notify_change() # ===================================================================== # Pick-a-Loop mode (used by iteration_branch) # ===================================================================== def start_picking_loop_for(self, iteration_branch_widget, on_done=None): """Enter 'pick a Loop node' mode. The next click on a repeat node will bind that loop to the given iteration_branch widget. Press Escape (or right-click) to cancel. """ has_loop = any(w.data.type == "repeat" for w in self.nodes.values()) self._picking_loop_for = iteration_branch_widget self._pick_done_callback = on_done if not has_loop: self._show_pick_banner("No Loop nodes yet — add one first, then " "use 'Pick Loop on Canvas' in the properties panel.") else: self._show_pick_banner("Click a Loop node to tie this Iteration " "Branch to it (Esc to cancel)") def _cancel_picking_loop(self): self._picking_loop_for = None self._pick_done_callback = None self._clear_pick_banner() def _show_pick_banner(self, text): self._clear_pick_banner() # Place at the top-left of the currently visible viewport. vw = self.canvas.winfo_width() or 600 x0 = self.canvas.canvasx(0) y0 = self.canvas.canvasy(0) + 8 bg = self.canvas.create_rectangle( x0 + 6, y0, x0 + vw - 6, y0 + 34, fill="#F1C40F", outline="#F39C12", width=2, tags=("pick_banner",) ) txt = self.canvas.create_text( x0 + vw // 2, y0 + 17, text=text, fill="#1E1E2E", font=("Segoe UI", 10, "bold"), tags=("pick_banner",) ) self._pick_banner_items = [bg, txt] self.canvas.tag_raise("pick_banner") def _clear_pick_banner(self): for item in self._pick_banner_items: try: self.canvas.delete(item) except Exception: pass self._pick_banner_items.clear() def _on_escape(self, event): if self._picking_loop_for is not None: self._cancel_picking_loop() return "break" # ===================================================================== # Copy / Paste # ===================================================================== def copy_selected(self): """Snapshot the current selection into an internal clipboard.""" if not self.selected_nodes: return False copied_ids = set() copied_nodes = [] for widget in self.selected_nodes: copied_ids.add(widget.data.id) copied_nodes.append({ "orig_id": widget.data.id, "type": widget.data.type, "x": widget.data.x, "y": widget.data.y, "data": _copy.deepcopy(widget.data.data), }) # Keep only connections where both endpoints are in the selection copied_conns = [] if self.macro: for c in self.macro.connections: if c.from_id in copied_ids and c.to_id in copied_ids: copied_conns.append({ "from": c.from_id, "from_port": c.from_port, "to": c.to_id, "to_port": c.to_port, }) min_x = min(n["x"] for n in copied_nodes) min_y = min(n["y"] for n in copied_nodes) self._clipboard = { "nodes": copied_nodes, "connections": copied_conns, "anchor_x": min_x, "anchor_y": min_y, } # Reset paste anchor so the next paste lands one-offset from the source self._last_paste_anchor = (min_x, min_y) return True def paste(self): """Paste the clipboard contents into the current macro.""" if not self._clipboard or not self.macro: return False PASTE_OFFSET = 40 # world units, down-and-right from anchor clip = self._clipboard orig_anchor = (clip["anchor_x"], clip["anchor_y"]) src_anchor = self._last_paste_anchor if self._last_paste_anchor else orig_anchor target_x = src_anchor[0] + PASTE_OFFSET target_y = src_anchor[1] + PASTE_OFFSET target_x = round(target_x / GRID_SIZE) * GRID_SIZE target_y = round(target_y / GRID_SIZE) * GRID_SIZE delta_x = target_x - orig_anchor[0] delta_y = target_y - orig_anchor[1] id_map = {} # orig_id -> new_id pasted_widgets = [] for n in clip["nodes"]: new_x = n["x"] + delta_x new_y = n["y"] + delta_y new_x = round(new_x / GRID_SIZE) * GRID_SIZE new_y = round(new_y / GRID_SIZE) * GRID_SIZE node_data = NodeData( n["type"], x=new_x, y=new_y, data=_copy.deepcopy(n["data"]), ) id_map[n["orig_id"]] = node_data.id self.macro.add_node(node_data) widget = self._create_node_widget(node_data) pasted_widgets.append(widget) for c in clip["connections"]: new_from = id_map.get(c["from"]) new_to = id_map.get(c["to"]) if not (new_from and new_to): continue conn_data = ConnectionData(new_from, c["from_port"], new_to, c["to_port"]) self.macro.add_connection(conn_data) from_widget = self.nodes.get(new_from) to_widget = self.nodes.get(new_to) if from_widget and to_widget: from_port = from_widget.get_port(c["from_port"]) to_port = to_widget.get_port(c["to_port"]) if from_port and to_port: conn = Connection(self.canvas, from_widget, from_port, to_widget, to_port, conn_data) self.connections.append(conn) self._deselect_all() for w in pasted_widgets: self.selected_nodes.add(w) w.set_selected(True) self._refresh_connected_highlights() if self.on_node_select: self.on_node_select(self.selected_node) # Cascade further on the next Ctrl+V self._last_paste_anchor = (target_x, target_y) self._ensure_bounds_for_nodes(can_shrink=True) self._notify_change() return True def _on_copy(self, event): self.copy_selected() return "break" def _on_paste(self, event): self.paste() return "break" # ===================================================================== # Node finding / selection callback # ===================================================================== def _find_node_at(self, cx, cy) -> NodeWidget | None: items = self.canvas.find_overlapping(cx - 2, cy - 2, cx + 2, cy + 2) for item in items: tags = self.canvas.gettags(item) for tag in tags: if tag.startswith(NodeWidget.TAG_PREFIX): node_id = tag[len(NodeWidget.TAG_PREFIX):] if node_id in self.nodes: return self.nodes[node_id] return None def _on_node_click_select(self, node_widget: NodeWidget): self._select_single(node_widget) def _on_node_moved(self, node_widget: NodeWidget): for conn in self.connections: if conn.from_node == node_widget or conn.to_node == node_widget: conn.update() # Grow the scrollregion if a node has been pushed near an edge. # Shrinking is held off until drag-release (in _on_release) so the # viewport doesn't snap underneath the cursor mid-drag. self._ensure_bounds_for_nodes(can_shrink=False) self._notify_change() # ===================================================================== # Left-click / drag / release # ===================================================================== def _on_click(self, event): cx = self.canvas.canvasx(event.x) cy = self.canvas.canvasy(event.y) ctrl_held = bool(event.state & 0x4) # Pick-loop mode: only Loop (repeat) nodes count; ignore other clicks. if self._picking_loop_for is not None: node = self._find_node_at(cx, cy) if node is not None and node.data.type == "repeat": target_widget = self._picking_loop_for target_widget.data.data["loop_node_id"] = node.data.id target_widget.redraw() cb = self._pick_done_callback self._cancel_picking_loop() if cb: try: cb() except Exception: pass self._notify_change() return for node_widget in self.nodes.values(): port = node_widget.get_port_at(cx, cy) if port: if port.port_type == "input": self._delete_connections_at_port(node_widget, port) else: self._wire_drag = True self._wire_from_node = node_widget self._wire_from_port = port self._wire_temp_line = None return node = self._find_node_at(cx, cy) if node: if ctrl_held: self._toggle_selection(node) else: if node not in self.selected_nodes: self._select_single(node) self._drag_node = node self._drag_start_x = cx self._drag_start_y = cy self._is_dragging = False return # Background — potential pan self._pan_origin = (event.x, event.y) self._is_panning = False self.canvas.scan_mark(event.x, event.y) self.canvas.focus_set() def _on_drag(self, event): cx = self.canvas.canvasx(event.x) cy = self.canvas.canvasy(event.y) if self._wire_drag: if self._wire_temp_line: self.canvas.delete(self._wire_temp_line) width = max(1, int(round(2 * self._zoom_level))) self._wire_temp_line = self.canvas.create_line( self._wire_from_port.x, self._wire_from_port.y, cx, cy, fill=NODE_PORT_OUT_COLOR, width=width, dash=(4, 2), ) return if self._drag_node: dx = cx - self._drag_start_x dy = cy - self._drag_start_y if not self._is_dragging: if abs(dx) > 3 or abs(dy) > 3: self._is_dragging = True else: return nodes_to_move = self.selected_nodes if self._drag_node in self.selected_nodes else {self._drag_node} affected_conns = set() for nw in nodes_to_move: nw.move_by(dx, dy) for conn in self.connections: if conn.from_node == nw or conn.to_node == nw: affected_conns.add(conn) for conn in affected_conns: conn.update() self._drag_start_x = cx self._drag_start_y = cy return if self._pan_origin is not None: self._is_panning = True self.canvas.scan_dragto(event.x, event.y, gain=1) def _on_release(self, event): cx = self.canvas.canvasx(event.x) cy = self.canvas.canvasy(event.y) if self._wire_drag: from_node = self._wire_from_node from_port = self._wire_from_port was_dragging = self._wire_temp_line is not None if self._wire_temp_line: self.canvas.delete(self._wire_temp_line) self._wire_temp_line = None self._wire_drag = False self._wire_from_node = None self._wire_from_port = None if was_dragging: for node_widget in self.nodes.values(): if node_widget == from_node: continue port = node_widget.get_port_at(cx, cy) if port and port.port_type == "input": self._create_connection(from_node, from_port, node_widget, port) break else: self._delete_connections_at_port(from_node, from_port) return if self._drag_node: node = self._drag_node was_dragging = self._is_dragging self._drag_node = None self._is_dragging = False if was_dragging: if node not in self.selected_nodes: self._select_single(node) # Drag finished — safe to let bounds shrink back if the # node has moved well inside the previous edges. self._ensure_bounds_for_nodes(can_shrink=True) self._notify_change() else: if len(self.selected_nodes) > 1: self._select_single(node) return if self._pan_origin is not None: was_panning = self._is_panning self._pan_origin = None self._is_panning = False if not was_panning: self._deselect_all() if self.on_node_select: self.on_node_select(None) # ===================================================================== # Right-click: rubber-band + context menu # ===================================================================== def _on_right_press(self, event): # Cancel pick-loop mode if active if self._picking_loop_for is not None: self._cancel_picking_loop() return cx = self.canvas.canvasx(event.x) cy = self.canvas.canvasy(event.y) self._rubberband_start = (cx, cy) self._rubberband_active = False self._rubberband_rect_id = None def _on_right_drag(self, event): if self._rubberband_start is None: return cx = self.canvas.canvasx(event.x) cy = self.canvas.canvasy(event.y) sx, sy = self._rubberband_start if not self._rubberband_active: if abs(cx - sx) > 5 or abs(cy - sy) > 5: self._rubberband_active = True else: return if self._rubberband_rect_id: self.canvas.delete(self._rubberband_rect_id) self._rubberband_rect_id = self.canvas.create_rectangle( sx, sy, cx, cy, outline="#4A90D9", width=2, dash=(4, 2), fill="", tags=("rubberband",) ) def _on_right_release(self, event): cx = self.canvas.canvasx(event.x) cy = self.canvas.canvasy(event.y) if self._rubberband_rect_id: self.canvas.delete(self._rubberband_rect_id) self._rubberband_rect_id = None if self._rubberband_active and self._rubberband_start: sx, sy = self._rubberband_start x1, y1 = min(sx, cx), min(sy, cy) x2, y2 = max(sx, cx), max(sy, cy) ctrl_held = bool(event.state & 0x4) if not ctrl_held: self._deselect_all() z = self._zoom_level for widget in self.nodes.values(): nx = widget.data.x * z ny = widget.data.y * z nw = getattr(widget, 'width', 160) nh = getattr(widget, 'height', 54) if nx + nw >= x1 and nx <= x2 and ny + nh >= y1 and ny <= y2: self.selected_nodes.add(widget) widget.set_selected(True) self._refresh_connected_highlights() if self.on_node_select: self.on_node_select(self.selected_node) else: self._show_context_menu(event) self._rubberband_start = None self._rubberband_active = False def _show_context_menu(self, event): menu = tk.Menu(self, tearoff=0) cx = self.canvas.canvasx(event.x) cy = self.canvas.canvasy(event.y) menu.add_command(label="Select All", command=self.select_all) if self.selected_nodes: menu.add_command(label="Copy Selected", command=self.copy_selected) if self._clipboard: menu.add_command(label="Paste", command=self.paste) menu.add_separator() for ntype, info in NODE_TYPES.items(): menu.add_command( label=f"{info['label']} - {info['desc']}", command=lambda t=ntype, x=cx, y=cy: self.add_node(t, x, y), ) for conn in self.connections: if conn.hit_test(cx, cy): menu.add_separator() menu.add_command( label="Delete Wire", command=lambda c=conn: self._delete_connection(c), ) break menu.tk_popup(event.x_root, event.y_root) # ===================================================================== # Connections # ===================================================================== def _create_connection(self, from_node, from_port, to_node, to_port): if not self.macro: return existing = [c for c in self.connections if c.to_node.data.id == to_node.data.id and c.to_port.name == to_port.name] for c in existing: c.destroy() self.connections.remove(c) self.macro.remove_connection(c.data.from_id, c.data.from_port, c.data.to_id, c.data.to_port) conn_data = ConnectionData( from_node.data.id, from_port.name, to_node.data.id, to_port.name, ) self.macro.add_connection(conn_data) conn = Connection(self.canvas, from_node, from_port, to_node, to_port, conn_data) self.connections.append(conn) self._refresh_connected_highlights() self._notify_change() def _delete_connections_at_port(self, node_widget, port): if port.port_type == "input": to_remove = [c for c in self.connections if c.to_node == node_widget and c.to_port == port] else: to_remove = [c for c in self.connections if c.from_node == node_widget and c.from_port == port] for conn in to_remove: self._delete_connection(conn) def _delete_connection(self, conn: Connection): if self.macro: self.macro.remove_connection( conn.data.from_id, conn.data.from_port, conn.data.to_id, conn.data.to_port, ) conn.destroy() self.connections.remove(conn) self._refresh_connected_highlights() self._notify_change() # ===================================================================== # Keyboard shortcuts # ===================================================================== def _on_delete(self, event): self.delete_selected() def _on_select_all(self, event): self.select_all() return "break" def _on_flip_selected(self, event): """Flip the input/output sides of all selected nodes.""" if not self.selected_nodes: return "break" flipped_widgets = list(self.selected_nodes) for widget in flipped_widgets: widget.data.flipped = not bool(getattr(widget.data, "flipped", False)) widget.redraw() # Port positions (wire endpoints) changed, so redraw affected wires flipped_set = set(flipped_widgets) for conn in self.connections: if conn.from_node in flipped_set or conn.to_node in flipped_set: conn.update() self._notify_change() return "break" # ===================================================================== # Zoom and Pan # ===================================================================== def _on_mousewheel(self, event): """Route the wheel event based on modifier keys: - Ctrl → zoom (cursor-anchored) - Shift → horizontal scroll - (none) → vertical scroll Uses fractional scaling derived from ``event.delta`` so precision touchpads — which send many small delta values instead of the ±120 per notch that a physical wheel emits — produce smooth, continuous motion rather than discrete chunks. """ if event.state & 0x4: # Ctrl self._zoom(event) elif event.state & 0x1: # Shift self._smooth_scroll("x", event.delta) else: self._smooth_scroll("y", event.delta) def _smooth_scroll(self, axis: str, delta: int): """Pixel-accurate scroll that respects touchpad precision deltas. On Windows a physical wheel notch emits ``delta`` of ±120, while touchpad swipes send much smaller values. We translate delta into pixels (0.5 px per delta-unit feels natural) and move via ``xview_moveto`` / ``yview_moveto`` (fractional position) to avoid the jerky "one unit at a time" look of ``yview_scroll``. """ # Positive delta = wheel up / finger down-to-up → show content above pixels = -delta * 0.5 region = self._scrollregion_for_zoom() if axis == "y": span = region[3] - region[1] if span <= 0: return cur = self.canvas.canvasy(0) new = cur + pixels self.canvas.yview_moveto(max(0.0, min(1.0, (new - region[1]) / span))) else: span = region[2] - region[0] if span <= 0: return cur = self.canvas.canvasx(0) new = cur + pixels self.canvas.xview_moveto(max(0.0, min(1.0, (new - region[0]) / span))) def _zoom(self, event): """Cursor-anchored zoom with a fractional scale factor. Factor is derived from ``event.delta``: a wheel notch (delta = 120) produces ~10% step, while touchpad pinches stream many small deltas for continuous-feeling zoom. """ # 1200 tuned so delta=120 → 1.10x (matches legacy feel on a real wheel) factor = 1.0 + (event.delta / 1200.0) # Safety clamp against huge deltas from odd devices factor = max(0.5, min(2.0, factor)) new_zoom = self._zoom_level * factor if new_zoom < 0.3 or new_zoom > 3.0: return # Sub-pixel zoom changes aren't worth a full redraw if abs(new_zoom - self._zoom_level) < 0.001: return old_zoom = self._zoom_level # World coords under the mouse (stay the same after zoom) cx = self.canvas.canvasx(event.x) cy = self.canvas.canvasy(event.y) world_x = cx / old_zoom world_y = cy / old_zoom self._zoom_level = new_zoom self.canvas.configure(scrollregion=self._scrollregion_for_zoom()) # Spacing changed → cached grid lines are at the wrong stride. self._invalidate_grid() self._draw_grid() for widget in self.nodes.values(): widget.redraw() # Connections read zoom from nodes' canvas_ref for conn in self.connections: conn.update() # Scroll so the cursor stays over the same world point new_cx = world_x * new_zoom new_cy = world_y * new_zoom delta_cx = new_cx - cx delta_cy = new_cy - cy x1, y1, x2, y2 = [float(v) for v in self._scrollregion_for_zoom()] region_w = x2 - x1 region_h = y2 - y1 if region_w > 0 and region_h > 0: cur_left = self.canvas.canvasx(0) cur_top = self.canvas.canvasy(0) new_left = cur_left + delta_cx new_top = cur_top + delta_cy fx = (new_left - x1) / region_w fy = (new_top - y1) / region_h self.canvas.xview_moveto(max(0.0, min(1.0, fx))) self.canvas.yview_moveto(max(0.0, min(1.0, fy))) def _on_pan_start(self, event): self.canvas.scan_mark(event.x, event.y) def _on_pan_move(self, event): self.canvas.scan_dragto(event.x, event.y, gain=1) # ===================================================================== # Utility # ===================================================================== def _notify_change(self): if self.on_change: self.on_change() def find_start_node(self) -> str | None: for nid, widget in self.nodes.items(): if widget.data.type == "start": return nid return None def scroll_to_node(self, node_id: str): widget = self.nodes.get(node_id) if not widget: return self.canvas.update_idletasks() z = self._zoom_level nx = widget.data.x * z + getattr(widget, 'width', 160) / 2 ny = widget.data.y * z + getattr(widget, 'height', 54) / 2 x1, y1, x2, y2 = self._scrollregion_for_zoom() region_w = x2 - x1 region_h = y2 - y1 vw = self.canvas.winfo_width() vh = self.canvas.winfo_height() target_left = nx - vw / 2 target_top = ny - vh / 2 fx = (target_left - x1) / region_w if region_w > 0 else 0 fy = (target_top - y1) / region_h if region_h > 0 else 0 self.canvas.xview_moveto(max(0.0, min(1.0, fx))) self.canvas.yview_moveto(max(0.0, min(1.0, fy))) def refresh_node(self, node_id: str): widget = self.nodes.get(node_id) if widget: widget.redraw() for conn in self.connections: if conn.from_node == widget or conn.to_node == widget: conn.update() def rebuild_node_ports(self, node_id: str): """Rebuild ports for a node whose port count or labels may have changed (branch choices added/removed/renamed, aggregator inputs, bluetooth get_local pass/fail, etc.). Drops any connections whose port no longer exists, re-links the survivors to the fresh Port objects, redraws the node so the new ports/labels actually appear on the canvas, and finally re-routes the connections through the now-positioned ports. Callers can rely on this for the FULL refresh — no need to also call ``refresh_node`` afterward. """ widget = self.nodes.get(node_id) if not widget: return widget.update_branch_ports() stale = [] for conn in self.connections: if conn.from_node == widget: new_port = widget.get_port(conn.data.from_port) if new_port is None: stale.append(conn) else: conn.from_port = new_port elif conn.to_node == widget: new_port = widget.get_port(conn.data.to_port) if new_port is None: stale.append(conn) else: conn.to_port = new_port for conn in stale: self._delete_connection(conn) # Redraw the node itself so the new ports/labels paint, then route # wires off the freshly-positioned ports (port.x / port.y are set # inside redraw()). widget.redraw() for conn in self.connections: if conn.from_node == widget or conn.to_node == widget: conn.update()