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
+204 -5
View File
@@ -8,9 +8,10 @@ from tkinter import filedialog, messagebox, ttk
import app_state
import palette
import sax_file
import schema
import undo
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():
@@ -88,8 +89,32 @@ def on_tree_select(event):
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):
"""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:
return
@@ -128,18 +153,24 @@ def on_tree_right_click(event):
def on_canvas_click(event):
canvas = app_state.canvas
canvas.itemconfig("block_outline", outline="#7f9db9", width=2)
wiresheet.deselect_link()
app_state.drag_data["tag"] = None
app_state.selected_canvas_tag = None
cx = canvas.canvasx(event.x)
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:
return
tags = canvas.gettags(clicked_item[0])
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:
app_state.drag_data["tag"] = group_tag
@@ -158,22 +189,172 @@ def on_canvas_click(event):
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):
"""Detects right-clicks on nodes to instantly show context-sensitive action overlays."""
canvas = app_state.canvas
cx = canvas.canvasx(event.x)
cy = canvas.canvasy(event.y)
clicked_item = canvas.find_closest(cx, cy)
clicked_item = canvas.find_closest(cx, cy, halo=3)
if clicked_item:
tags = canvas.gettags(clicked_item[0])
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:
wiresheet.deselect_link()
canvas.itemconfig("block_outline", outline="#7f9db9", width=2)
app_state.selected_canvas_tag = group_tag
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)
@@ -441,7 +622,25 @@ window.geometry("950x600")
# --- Context Popups Structures ---
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.add_command(label="Delete Component", command=delete_tree_selected_component)