diff --git a/app_state.py b/app_state.py index cb9ca31..8628dd6 100644 --- a/app_state.py +++ b/app_state.py @@ -36,6 +36,9 @@ current_file_path = None active_components_list = [] # Tracks what components are actively painted selected_canvas_tag = None # Tracks the currently clicked/highlighted canvas component tag 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 () 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 diff --git a/main.py b/main.py index b7ffae6..9adb83c 100644 --- a/main.py +++ b/main.py @@ -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) diff --git a/sax_file.py b/sax_file.py index ae2cbc6..626f5c8 100644 --- a/sax_file.py +++ b/sax_file.py @@ -65,12 +65,15 @@ def reset_to_startup_state(): app_state.current_sheet_parent = None app_state.selected_canvas_tag = 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.tree_element_map.clear() app_state.canvas_comp_map.clear() app_state.canvas_tag_by_element.clear() app_state.component_slot_offsets.clear() + app_state.canvas_link_map.clear() app_state.final_components_registry.clear() app_state.active_schema_kits.clear() @@ -423,6 +426,59 @@ def add_component(kit_name, type_name, grid_x, grid_y): return element +def remove_link(link_element): + """Removes a element from the 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 element to the 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): """Shared application logic to wipe an element, its children, and related links from DOM and runtime lists.""" comp_name = comp_element.get("name", "Unknown") diff --git a/schema.py b/schema.py index cc16f01..7cb157e 100644 --- a/schema.py +++ b/schema.py @@ -161,6 +161,17 @@ def load_schema_kits_and_manifests(): 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=()): """ Returns the slots worth painting for a given type, prefixed with double-digit diff --git a/undo.py b/undo.py index 4e36969..96f3c3d 100644 --- a/undo.py +++ b/undo.py @@ -124,6 +124,41 @@ class AddComponentCommand: self.parent.insert(self.index, self.element) +class AddLinkCommand: + """Adding a single link to the 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 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: """A cascading delete: the component subtree plus every link that referenced it.""" diff --git a/wiresheet.py b/wiresheet.py index 149616c..09a14a3 100644 --- a/wiresheet.py +++ b/wiresheet.py @@ -180,10 +180,16 @@ def draw_knob(x, y): ) +LINK_SELECT_COLOR = "#e00000" +LINK_SELECT_WIDTH = 2 + + def draw_links(): """Paints wires between slots on this sheet and knobs for links leaving it.""" canvas = app_state.canvas canvas.delete("link") + app_state.canvas_link_map.clear() + app_state.selected_link_tag = None if app_state.xml_root_element is None: 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] + link_index = 0 for link in links_elem.findall("link"): from_ref = link.get("from", "") to_ref = link.get("to", "") @@ -218,19 +225,21 @@ def draw_links(): if src_box is None or dst_box is None: 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) flat = [coord for point in points for coord in point] canvas.create_line( *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: - # Source is here, target is on another sheet. src_box, src_y = resolve_slot_anchor(src_tag, from_slot) if src_box is not None: draw_knob(src_box[2] + KNOB_RADIUS, src_y) elif dst_tag: - # Target is here, source is on another sheet. dst_box, dst_y = resolve_slot_anchor(dst_tag, to_slot) if dst_box is not None: draw_knob(dst_box[0] - KNOB_RADIUS, dst_y) @@ -240,6 +249,27 @@ def draw_links(): 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 --- def render_components(components_to_render):