1337 lines
50 KiB
Python
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()
|