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

1337 lines
50 KiB
Python

"""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("<Configure>", 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("<Button-1>", self._on_click)
self.canvas.bind("<B1-Motion>", self._on_drag)
self.canvas.bind("<ButtonRelease-1>", self._on_release)
self.canvas.bind("<Delete>", self._on_delete)
self.canvas.bind("<MouseWheel>", self._on_mousewheel)
self.canvas.bind("<Control-a>", self._on_select_all)
self.canvas.bind("<Control-c>", self._on_copy)
self.canvas.bind("<Control-v>", self._on_paste)
self.canvas.bind("<Escape>", self._on_escape)
self.canvas.bind("<Key-f>", self._on_flip_selected)
self.canvas.bind("<Key-F>", self._on_flip_selected)
self.canvas.bind("<Button-3>", self._on_right_press)
self.canvas.bind("<B3-Motion>", self._on_right_drag)
self.canvas.bind("<ButtonRelease-3>", self._on_right_release)
self.canvas.bind("<Button-2>", self._on_pan_start)
self.canvas.bind("<B2-Motion>", 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()