Add link management: create, select, delete with undo/redo

Implement full link workflow between component slots on the wiresheet:
- Link Mark / Link From / Link To context menu items for creating links
- Link dialog with slot type validation and source/target swap button
- Selectable link wires (click to highlight in red) with right-click delete
- Undo/redo support for both add and delete link operations
- Component deletion already cascades to remove related links

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 16:34:17 +03:00
co-authored by Claude Opus 4.6
parent e43c5e17a4
commit 424caf777c
6 changed files with 342 additions and 8 deletions
+3
View File
@@ -36,6 +36,9 @@ current_file_path = None
active_components_list = [] # Tracks what components are actively painted active_components_list = [] # Tracks what components are actively painted
selected_canvas_tag = None # Tracks the currently clicked/highlighted canvas component tag selected_canvas_tag = None # Tracks the currently clicked/highlighted canvas component tag
selected_tree_item_id = None # Tracks the currently right-clicked Treeview item selected_tree_item_id = None # Tracks the currently right-clicked Treeview item
link_mark_element = None # Component marked as a link endpoint via "Link Mark"
selected_link_tag = None # Canvas tag of the currently selected (red-highlighted) link wire
canvas_link_map = {} # Canvas link tag -> ET.Element (<link>)
xml_root_element = None # Keep a persistent root reference to redraw tree layouts cleanly xml_root_element = None # Keep a persistent root reference to redraw tree layouts cleanly
current_sheet_parent = None # Element whose children are painted; None means the app root current_sheet_parent = None # Element whose children are painted; None means the app root
+204 -5
View File
@@ -8,9 +8,10 @@ from tkinter import filedialog, messagebox, ttk
import app_state import app_state
import palette import palette
import sax_file import sax_file
import schema
import undo import undo
import wiresheet import wiresheet
from sax_dom import read_meta_property, update_meta_property from sax_dom import get_component_path, read_meta_property, update_meta_property
def refresh_all_views(): def refresh_all_views():
@@ -88,8 +89,32 @@ def on_tree_select(event):
wiresheet.render_components(components_to_render) wiresheet.render_components(components_to_render)
def delete_selected_link():
"""Removes the currently selected link from the DOM and repaints."""
if not app_state.selected_link_tag:
return
link_elem = app_state.canvas_link_map.get(app_state.selected_link_tag)
if link_elem is None:
return
from_ref = link_elem.get("from", "")
to_ref = link_elem.get("to", "")
confirm = messagebox.askyesno(
"Delete Link",
f"Delete link?\n\n from: {from_ref}\n to: {to_ref}"
)
if not confirm:
return
sax_file.remove_link(link_elem)
def delete_selected_component(event=None): def delete_selected_component(event=None):
"""Deletes the highlighted canvas component and all its descendants recursively.""" """Deletes the highlighted canvas component (or selected link) and all its descendants recursively."""
if app_state.selected_link_tag:
delete_selected_link()
return
if not app_state.selected_canvas_tag: if not app_state.selected_canvas_tag:
return return
@@ -128,18 +153,24 @@ def on_tree_right_click(event):
def on_canvas_click(event): def on_canvas_click(event):
canvas = app_state.canvas canvas = app_state.canvas
canvas.itemconfig("block_outline", outline="#7f9db9", width=2) canvas.itemconfig("block_outline", outline="#7f9db9", width=2)
wiresheet.deselect_link()
app_state.drag_data["tag"] = None app_state.drag_data["tag"] = None
app_state.selected_canvas_tag = None app_state.selected_canvas_tag = None
cx = canvas.canvasx(event.x) cx = canvas.canvasx(event.x)
cy = canvas.canvasy(event.y) cy = canvas.canvasy(event.y)
clicked_item = canvas.find_closest(cx, cy) clicked_item = canvas.find_closest(cx, cy, halo=3)
if not clicked_item: if not clicked_item:
return return
tags = canvas.gettags(clicked_item[0]) tags = canvas.gettags(clicked_item[0])
group_tag = next((t for t in tags if t.startswith("comp_group_")), None) group_tag = next((t for t in tags if t.startswith("comp_group_")), None)
wire_tag = next((t for t in tags if t.startswith("link_wire_")), None)
if wire_tag:
wiresheet.select_link(wire_tag)
return
if group_tag: if group_tag:
app_state.drag_data["tag"] = group_tag app_state.drag_data["tag"] = group_tag
@@ -158,22 +189,172 @@ def on_canvas_click(event):
wiresheet.draw_links() wiresheet.draw_links()
def do_link_mark():
"""Marks the currently selected canvas component as a link endpoint."""
if not app_state.selected_canvas_tag:
return
comp = app_state.canvas_comp_map.get(app_state.selected_canvas_tag)
if comp is not None:
app_state.link_mark_element = comp
def open_link_dialog(source_element, target_element):
"""Opens a dialog to select source and target slots, validates types, and creates a link."""
window = app_state.window
# Mutable direction state: [left_element, right_element]
sides = [source_element, target_element]
dialog = tk.Toplevel(window)
dialog.title("Link")
dialog.resizable(False, False)
dialog.transient(window)
dialog.grab_set()
dialog.geometry(f"+{window.winfo_x() + 100}+{window.winfo_y() + 60}")
main_frame = tk.Frame(dialog, padx=10, pady=10)
main_frame.pack(fill=tk.BOTH, expand=True)
src_frame = tk.LabelFrame(main_frame, padx=5, pady=5)
src_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 5))
mid_frame = tk.Frame(main_frame)
mid_frame.grid(row=0, column=1, padx=5)
tgt_frame = tk.LabelFrame(main_frame, padx=5, pady=5)
tgt_frame.grid(row=0, column=2, sticky="nsew", padx=(5, 0))
src_var = tk.StringVar(value="")
tgt_var = tk.StringVar(value="")
def populate_panel(parent_frame, slots, var):
for child in parent_frame.winfo_children():
child.destroy()
panel_canvas = tk.Canvas(parent_frame, width=160, height=300, highlightthickness=0)
panel_scroll = ttk.Scrollbar(parent_frame, orient=tk.VERTICAL, command=panel_canvas.yview)
inner = tk.Frame(panel_canvas)
panel_canvas.create_window((0, 0), window=inner, anchor="nw")
panel_canvas.config(yscrollcommand=panel_scroll.set)
panel_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
panel_scroll.pack(side=tk.RIGHT, fill=tk.Y)
for slot_name, slot_type in slots:
rb = tk.Radiobutton(inner, text=slot_name, variable=var, value=slot_name,
anchor="w", bg="#c8d4e8", relief="flat", padx=4, pady=1)
rb.pack(fill=tk.X)
inner.update_idletasks()
panel_canvas.config(scrollregion=panel_canvas.bbox("all"))
def refresh_panels():
left = sides[0]
right = sides[1]
left_name = left.get("name", "?")
right_name = right.get("name", "?")
left_type = left.get("type", "")
right_type = right.get("type", "")
src_frame.config(text=f"{left_name} [Source]")
tgt_frame.config(text=f"{right_name} [Target]")
left_slots = schema.get_all_slots_for_type(left_type)
right_slots = schema.get_all_slots_for_type(right_type)
src_var.set("")
tgt_var.set("")
populate_panel(src_frame, left_slots, src_var)
populate_panel(tgt_frame, right_slots, tgt_var)
def on_swap():
sides[0], sides[1] = sides[1], sides[0]
refresh_panels()
ttk.Button(mid_frame, text="⇄", width=3, command=on_swap).pack(pady=5)
refresh_panels()
def on_ok():
src_slot = src_var.get()
tgt_slot = tgt_var.get()
if not src_slot or not tgt_slot:
messagebox.showwarning("Link", "Select a slot from both source and target.", parent=dialog)
return
left_type = sides[0].get("type", "")
right_type = sides[1].get("type", "")
left_type_map = {s[0]: s[1] for s in schema.get_all_slots_for_type(left_type)}
right_type_map = {s[0]: s[1] for s in schema.get_all_slots_for_type(right_type)}
st = left_type_map.get(src_slot, "?")
tt = right_type_map.get(tgt_slot, "?")
if st != tt:
messagebox.showwarning(
"Slot type mismatch",
f"Cannot link slots of different types.\n\n"
f"Source: {src_slot} ({st})\n"
f"Target: {tgt_slot} ({tt})",
parent=dialog
)
return
src_path = get_component_path(sides[0], app_state.xml_root_element)
tgt_path = get_component_path(sides[1], app_state.xml_root_element)
from_ref = f"{src_path}.{src_slot}"
to_ref = f"{tgt_path}.{tgt_slot}"
dialog.destroy()
sax_file.add_link(from_ref, to_ref)
btn_frame = tk.Frame(main_frame)
btn_frame.grid(row=1, column=0, columnspan=3, pady=(15, 0))
ttk.Button(btn_frame, text="OK", width=10, command=on_ok).pack(side=tk.LEFT, padx=5)
ttk.Button(btn_frame, text="Cancel", width=10, command=dialog.destroy).pack(side=tk.LEFT, padx=5)
def do_link_from_mark():
"""Link From: marked component is source, right-clicked component is target."""
if app_state.link_mark_element is None or not app_state.selected_canvas_tag:
return
target = app_state.canvas_comp_map.get(app_state.selected_canvas_tag)
if target is not None:
open_link_dialog(app_state.link_mark_element, target)
def do_link_to_mark():
"""Link To: right-clicked component is source, marked component is target."""
if app_state.link_mark_element is None or not app_state.selected_canvas_tag:
return
source = app_state.canvas_comp_map.get(app_state.selected_canvas_tag)
if source is not None:
open_link_dialog(source, app_state.link_mark_element)
def on_canvas_right_click(event): def on_canvas_right_click(event):
"""Detects right-clicks on nodes to instantly show context-sensitive action overlays.""" """Detects right-clicks on nodes to instantly show context-sensitive action overlays."""
canvas = app_state.canvas canvas = app_state.canvas
cx = canvas.canvasx(event.x) cx = canvas.canvasx(event.x)
cy = canvas.canvasy(event.y) cy = canvas.canvasy(event.y)
clicked_item = canvas.find_closest(cx, cy) clicked_item = canvas.find_closest(cx, cy, halo=3)
if clicked_item: if clicked_item:
tags = canvas.gettags(clicked_item[0]) tags = canvas.gettags(clicked_item[0])
group_tag = next((t for t in tags if t.startswith("comp_group_")), None) group_tag = next((t for t in tags if t.startswith("comp_group_")), None)
wire_tag = next((t for t in tags if t.startswith("link_wire_")), None)
if wire_tag:
wiresheet.select_link(wire_tag)
link_context_menu.post(event.x_root, event.y_root)
return
if group_tag: if group_tag:
wiresheet.deselect_link()
canvas.itemconfig("block_outline", outline="#7f9db9", width=2) canvas.itemconfig("block_outline", outline="#7f9db9", width=2)
app_state.selected_canvas_tag = group_tag app_state.selected_canvas_tag = group_tag
canvas.itemconfig(f"{group_tag} && block_outline", outline="#1f4e79", width=3) canvas.itemconfig(f"{group_tag} && block_outline", outline="#1f4e79", width=3)
_rebuild_canvas_context_menu()
canvas_context_menu.post(event.x_root, event.y_root) canvas_context_menu.post(event.x_root, event.y_root)
@@ -441,7 +622,25 @@ window.geometry("950x600")
# --- Context Popups Structures --- # --- Context Popups Structures ---
canvas_context_menu = tk.Menu(window, tearoff=0) canvas_context_menu = tk.Menu(window, tearoff=0)
canvas_context_menu.add_command(label="Delete Component", command=delete_selected_component) link_context_menu = tk.Menu(window, tearoff=0)
link_context_menu.add_command(label="Delete Link", command=delete_selected_link)
def _rebuild_canvas_context_menu():
"""Rebuilds the canvas context menu with dynamic Link From/Link To labels."""
canvas_context_menu.delete(0, tk.END)
canvas_context_menu.add_command(label="Delete Component", command=delete_selected_component)
canvas_context_menu.add_separator()
canvas_context_menu.add_command(label="Link Mark", command=do_link_mark)
if app_state.link_mark_element is not None:
mark_name = app_state.link_mark_element.get("name", "?")
canvas_context_menu.add_command(
label=f'Link From "{mark_name}"', command=do_link_from_mark
)
canvas_context_menu.add_command(
label=f'Link To "{mark_name}"', command=do_link_to_mark
)
tree_comp_context_menu = tk.Menu(window, tearoff=0) tree_comp_context_menu = tk.Menu(window, tearoff=0)
tree_comp_context_menu.add_command(label="Delete Component", command=delete_tree_selected_component) tree_comp_context_menu.add_command(label="Delete Component", command=delete_tree_selected_component)
+56
View File
@@ -65,12 +65,15 @@ def reset_to_startup_state():
app_state.current_sheet_parent = None app_state.current_sheet_parent = None
app_state.selected_canvas_tag = None app_state.selected_canvas_tag = None
app_state.selected_tree_item_id = None app_state.selected_tree_item_id = None
app_state.link_mark_element = None
app_state.selected_link_tag = None
app_state.active_components_list.clear() app_state.active_components_list.clear()
app_state.tree_element_map.clear() app_state.tree_element_map.clear()
app_state.canvas_comp_map.clear() app_state.canvas_comp_map.clear()
app_state.canvas_tag_by_element.clear() app_state.canvas_tag_by_element.clear()
app_state.component_slot_offsets.clear() app_state.component_slot_offsets.clear()
app_state.canvas_link_map.clear()
app_state.final_components_registry.clear() app_state.final_components_registry.clear()
app_state.active_schema_kits.clear() app_state.active_schema_kits.clear()
@@ -423,6 +426,59 @@ def add_component(kit_name, type_name, grid_x, grid_y):
return element return element
def remove_link(link_element):
"""Removes a <link> element from the <links> section and repaints."""
if app_state.xml_root_element is None:
return
links_elem = app_state.xml_root_element.find("links")
if links_elem is None:
return
try:
index = list(links_elem).index(link_element)
links_elem.remove(link_element)
except ValueError:
return
from_ref = link_element.get("from", "")
to_ref = link_element.get("to", "")
from_slot = from_ref.rsplit(".", 1)[-1] if "." in from_ref else from_ref
to_slot = to_ref.rsplit(".", 1)[-1] if "." in to_ref else to_ref
undo.push(undo.DeleteLinkCommand(
links_elem, index, link_element,
label=f"delete link {from_slot}→{to_slot}"
))
app_state.selected_link_tag = None
app_state.mark_dirty()
wiresheet.render_current_sheet()
def add_link(from_path_slot, to_path_slot):
"""Adds a <link> element to the <links> section of the SAX DOM."""
if app_state.xml_root_element is None:
return
links_elem = app_state.xml_root_element.find("links")
if links_elem is None:
links_elem = ET.SubElement(app_state.xml_root_element, "links")
link_elem = ET.SubElement(links_elem, "link")
link_elem.set("from", from_path_slot)
link_elem.set("to", to_path_slot)
from_slot = from_path_slot.rsplit(".", 1)[-1] if "." in from_path_slot else from_path_slot
to_slot = to_path_slot.rsplit(".", 1)[-1] if "." in to_path_slot else to_path_slot
undo.push(undo.AddLinkCommand(
links_elem, link_elem,
label=f"add link {from_slot}→{to_slot}"
))
app_state.mark_dirty()
wiresheet.render_current_sheet()
def execute_cascading_deletion(comp_element): def execute_cascading_deletion(comp_element):
"""Shared application logic to wipe an element, its children, and related links from DOM and runtime lists.""" """Shared application logic to wipe an element, its children, and related links from DOM and runtime lists."""
comp_name = comp_element.get("name", "Unknown") comp_name = comp_element.get("name", "Unknown")
+11
View File
@@ -161,6 +161,17 @@ def load_schema_kits_and_manifests():
compile_schema_registry(manifest_paths) compile_schema_registry(manifest_paths)
def get_all_slots_for_type(type_str):
"""Returns every slot for a type as (name, type_string) tuples, for the link dialog."""
if type_str not in app_state.final_components_registry:
return []
return [
(slot["name"], slot["type"])
for slot in app_state.final_components_registry[type_str]
if slot["name"]
]
def get_slots_for_type(type_str, linked_slots=()): def get_slots_for_type(type_str, linked_slots=()):
""" """
Returns the slots worth painting for a given type, prefixed with double-digit Returns the slots worth painting for a given type, prefixed with double-digit
+35
View File
@@ -124,6 +124,41 @@ class AddComponentCommand:
self.parent.insert(self.index, self.element) self.parent.insert(self.index, self.element)
class AddLinkCommand:
"""Adding a single link to the <links> section."""
def __init__(self, links_parent, link_element, label="add link"):
self.links_parent = links_parent
self.link_element = link_element
self.label = label
def undo(self):
if self.link_element in list(self.links_parent):
self.links_parent.remove(self.link_element)
def redo(self):
if self.link_element not in list(self.links_parent):
self.links_parent.append(self.link_element)
class DeleteLinkCommand:
"""Removing a single link from the <links> section."""
def __init__(self, links_parent, index, link_element, label="delete link"):
self.links_parent = links_parent
self.index = index
self.link_element = link_element
self.label = label
def undo(self):
if self.link_element not in list(self.links_parent):
self.links_parent.insert(self.index, self.link_element)
def redo(self):
if self.link_element in list(self.links_parent):
self.links_parent.remove(self.link_element)
class DeleteComponentCommand: class DeleteComponentCommand:
"""A cascading delete: the component subtree plus every link that referenced it.""" """A cascading delete: the component subtree plus every link that referenced it."""
+33 -3
View File
@@ -180,10 +180,16 @@ def draw_knob(x, y):
) )
LINK_SELECT_COLOR = "#e00000"
LINK_SELECT_WIDTH = 2
def draw_links(): def draw_links():
"""Paints wires between slots on this sheet and knobs for links leaving it.""" """Paints wires between slots on this sheet and knobs for links leaving it."""
canvas = app_state.canvas canvas = app_state.canvas
canvas.delete("link") canvas.delete("link")
app_state.canvas_link_map.clear()
app_state.selected_link_tag = None
if app_state.xml_root_element is None: if app_state.xml_root_element is None:
return return
@@ -200,6 +206,7 @@ def draw_links():
boxes = [b for b in (get_box_bounds(t) for t in path_to_tag.values()) if b] boxes = [b for b in (get_box_bounds(t) for t in path_to_tag.values()) if b]
link_index = 0
for link in links_elem.findall("link"): for link in links_elem.findall("link"):
from_ref = link.get("from", "") from_ref = link.get("from", "")
to_ref = link.get("to", "") to_ref = link.get("to", "")
@@ -218,19 +225,21 @@ def draw_links():
if src_box is None or dst_box is None: if src_box is None or dst_box is None:
continue continue
wire_tag = f"link_wire_{link_index}"
link_index += 1
app_state.canvas_link_map[wire_tag] = link
points = route_link_path(src_box, src_y, dst_box, dst_y, boxes) points = route_link_path(src_box, src_y, dst_box, dst_y, boxes)
flat = [coord for point in points for coord in point] flat = [coord for point in points for coord in point]
canvas.create_line( canvas.create_line(
*flat, fill=LINK_COLOR, width=1, *flat, fill=LINK_COLOR, width=1,
arrow=tk.LAST, arrowshape=(8, 9, 3), tags=("link",) arrow=tk.LAST, arrowshape=(8, 9, 3), tags=("link", wire_tag)
) )
elif src_tag: elif src_tag:
# Source is here, target is on another sheet.
src_box, src_y = resolve_slot_anchor(src_tag, from_slot) src_box, src_y = resolve_slot_anchor(src_tag, from_slot)
if src_box is not None: if src_box is not None:
draw_knob(src_box[2] + KNOB_RADIUS, src_y) draw_knob(src_box[2] + KNOB_RADIUS, src_y)
elif dst_tag: elif dst_tag:
# Target is here, source is on another sheet.
dst_box, dst_y = resolve_slot_anchor(dst_tag, to_slot) dst_box, dst_y = resolve_slot_anchor(dst_tag, to_slot)
if dst_box is not None: if dst_box is not None:
draw_knob(dst_box[0] - KNOB_RADIUS, dst_y) draw_knob(dst_box[0] - KNOB_RADIUS, dst_y)
@@ -240,6 +249,27 @@ def draw_links():
canvas.tag_lower("link", "component") canvas.tag_lower("link", "component")
def select_link(wire_tag):
"""Highlights a link wire in red."""
canvas = app_state.canvas
deselect_link()
if wire_tag and wire_tag in app_state.canvas_link_map:
canvas.itemconfig(wire_tag, fill=LINK_SELECT_COLOR, width=LINK_SELECT_WIDTH)
canvas.tag_raise(wire_tag)
app_state.selected_link_tag = wire_tag
def deselect_link():
"""Resets the selected link wire back to its default colour."""
canvas = app_state.canvas
if app_state.selected_link_tag:
try:
canvas.itemconfig(app_state.selected_link_tag, fill=LINK_COLOR, width=1)
except tk.TclError:
pass
app_state.selected_link_tag = None
# --- Component painting --- # --- Component painting ---
def render_components(components_to_render): def render_components(components_to_render):