From 67fbfe03ddfb1363adf206168f69e3e5d78aef6e Mon Sep 17 00:00:00 2001 From: Arda Aydin Date: Wed, 7 Oct 2026 09:55:18 +0300 Subject: [PATCH] Wiresheet: Ctrl+click to select several links, delete them together Ctrl+click toggles a link in the selection; Del or the link popup deletes every selected link after one confirmation, as a single undoable step. On a device, links the device refuses stay and are named in one warning. Co-Authored-By: Claude Opus 5.5 --- app_state.py | 4 ++- main.py | 54 +++++++++++++++++++++--------- sax_file.py | 70 ++++++++++++++++++++++++++------------ wiresheet.py | 94 ++++++++++++++++++++++++++++++++++++++-------------- 4 files changed, 160 insertions(+), 62 deletions(-) diff --git a/app_state.py b/app_state.py index cfd860b..6a5b38a 100644 --- a/app_state.py +++ b/app_state.py @@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it. import configparser import os -VERSION = "0.0.0.162" +VERSION = "0.0.0.163" PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties") DEFAULT_GRID_SIZE = 10 DEFAULT_ADD_TIMESTAMP = True @@ -86,6 +86,8 @@ selected_canvas_tags = set() # Every selected box, the one above included; Ctrl 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 +selected_link_tags = [] # Every selected link wire, in picking order; Ctrl+click adds. + # selected_link_tag is the last of these. unroutable_link_count = 0 # Links drawn as knobs because no wire could avoid a component meta_scale = DEFAULT_GRID_SIZE # Pixels per meta coordinate step on the painted sheet zoom = 1.0 # Scale the open sheet is painted at; 1.0 is one pixel per pixel diff --git a/main.py b/main.py index 69dcd34..5a4d8b7 100644 --- a/main.py +++ b/main.py @@ -1014,23 +1014,30 @@ def on_tree_select(event): 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: + """Removes every selected link from the DOM, as one undoable step, and repaints.""" + link_elems = [] + for wire_tag in app_state.selected_link_tags: + link_elem = app_state.canvas_link_map.get(wire_tag) + if link_elem is not None and link_elem not in link_elems: + link_elems.append(link_elem) + if not link_elems: 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: + if len(link_elems) == 1: + from_ref = link_elems[0].get("from", "") + to_ref = link_elems[0].get("to", "") + question = f"Delete link?\n\n from: {from_ref}\n to: {to_ref}" + else: + shown = link_elems[:15] + lines = [f" {e.get('from', '')} → {e.get('to', '')}" for e in shown] + if len(link_elems) > len(shown): + lines.append(f" … and {len(link_elems) - len(shown)} more") + question = f"Delete {len(link_elems)} links?\n\n" + "\n".join(lines) + + if not messagebox.askyesno("Delete Link", question): return - sax_file.remove_link(link_elem) + sax_file.remove_links(link_elems) def link_endpoint_components(link_elem): @@ -1341,7 +1348,6 @@ def _toggle_canvas_box(group_tag): def on_canvas_click(event): canvas = app_state.canvas - wiresheet.deselect_link() app_state.drag_data["tag"] = None cx = canvas.canvasx(event.x) @@ -1351,6 +1357,14 @@ def on_canvas_click(event): 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 event.state & CTRL_MASK and wire_tag: + # Picking links out one by one. Links and boxes are not selected together, so + # Del always knows which of the two it is deleting. + select_canvas_boxes([]) + wiresheet.toggle_link(wire_tag) + return + + wiresheet.deselect_link() if event.state & CTRL_MASK and group_tag and not wire_tag: # Picking boxes out one by one, so nothing is picked up to be moved. _toggle_canvas_box(group_tag) @@ -1743,7 +1757,11 @@ def on_canvas_right_click(event): wire_tag = next((t for t in tags if t.startswith("link_wire_")), None) if wire_tag: - wiresheet.select_link(wire_tag) + # Right-clicking one of several selected links acts on all of them; any + # other link becomes the selection on its own. + if wire_tag not in app_state.selected_link_tags: + select_canvas_boxes([]) + wiresheet.select_link(wire_tag) _rebuild_link_context_menu() link_context_menu.post(event.x_root, event.y_root) return @@ -2862,6 +2880,12 @@ def _rebuild_link_context_menu(): left off rather than greyed out. """ link_context_menu.delete(0, tk.END) + count = len(app_state.selected_link_tags) + if count > 1: + # Several links have no single far end, so there is nowhere to jump to. + link_context_menu.add_command(label=f"Delete {count} Links", + command=delete_selected_link) + return link_context_menu.add_command(label="Delete Link", command=delete_selected_link) link_elem = app_state.canvas_link_map.get(app_state.selected_link_tag) diff --git a/sax_file.py b/sax_file.py index d55b435..acb554a 100644 --- a/sax_file.py +++ b/sax_file.py @@ -155,6 +155,7 @@ def reset_to_startup_state(): app_state.selected_tree_item_id = None app_state.link_mark_element = None app_state.selected_link_tag = None + app_state.selected_link_tags.clear() app_state.active_components_list.clear() app_state.tree_element_map.clear() @@ -1108,6 +1109,15 @@ def add_component(kit_name, type_name, grid_x, grid_y, parent=None): def remove_link(link_element): """Removes a element from the section and repaints.""" + remove_links([link_element]) + + +def remove_links(link_elements): + """Removes several elements as one undoable step, and repaints. + + On a device each link is unlinked there first; one the device refuses stays in the + DOM, and the rest still go, with the refusals named in a single warning. + """ if app_state.xml_root_element is None: return @@ -1115,35 +1125,51 @@ def remove_link(link_element): if links_elem is None: return - from_ref = link_element.get("from", "") - to_ref = link_element.get("to", "") + commands = [] + failures = [] + for link_element in link_elements: + if link_element not in list(links_elem): + continue + from_ref = link_element.get("from", "") + to_ref = link_element.get("to", "") - if app_state.live_mode: - if not live_hooks.live_delete_link(*link_ends(from_ref, to_ref)): - live_hooks.warn_failure("Delete Link", - f"The link {from_ref} → {to_ref} was not removed from the device") - return + if app_state.live_mode: + if not live_hooks.live_delete_link(*link_ends(from_ref, to_ref)): + failures.append(f"{from_ref} → {to_ref}") + continue - try: index = list(links_elem).index(link_element) links_elem.remove(link_element) - except ValueError: + + 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 + label = f"delete link {from_slot}→{to_slot}" + if app_state.live_mode: + commands.append(LiveLinkCommand(links_elem, index, link_element, + present=False, label=label)) + else: + commands.append(undo.DeleteLinkCommand(links_elem, index, link_element, + label=label)) + + # Removed in order, each index taken after the ones before it went, so the composite + # puts them back last-first and every one lands where it was. + if len(commands) == 1: + undo.push(commands[0]) + elif commands: + undo.push(undo.CompositeCommand(commands, label=f"delete {len(commands)} links")) + + if failures: + noun = "link" if len(failures) == 1 else "links" + verb = "was" if len(failures) == 1 else "were" + live_hooks.warn_failure( + "Delete Link", + f"The {noun} {', '.join(failures)} {verb} not removed from the device") + + if not commands: return - 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 - if app_state.live_mode: - undo.push(LiveLinkCommand( - links_elem, index, link_element, present=False, - label=f"delete link {from_slot}→{to_slot}" - )) - else: - 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.selected_link_tags.clear() app_state.mark_dirty() wiresheet.render_current_sheet() diff --git a/wiresheet.py b/wiresheet.py index da6b0c8..b763581 100644 --- a/wiresheet.py +++ b/wiresheet.py @@ -843,6 +843,8 @@ def draw_links(): app_state.canvas_link_map.clear() app_state.knob_link_ends.clear() app_state.selected_link_tag = None + app_state.selected_link_tags.clear() + _selected_link_restore.clear() app_state.unroutable_link_count = 0 if app_state.xml_root_element is None: return @@ -920,7 +922,8 @@ def draw_links(): app_state.unroutable_link_count = len(jobs) - len(routed) -_selected_link_restore = [] # [(item, fill, outline, width)] captured when a link is selected +# wire tag -> [(item, fill, outline, width)], captured when that link is selected +_selected_link_restore = {} def trim_to_knobs(x1, y1, x2, y2): @@ -938,31 +941,36 @@ def trim_to_knobs(x1, y1, x2, y2): x2 - ux * inset, y2 - uy * inset) -def select_link(wire_tag): +def select_link(wire_tag, add=False): """Highlights a link in red, remembering what each of its items looked like. The colours cannot simply be reset to LINK_COLOR afterwards: a link shown as knobs may be grey, or half grey, and blanket-restoring would repaint it as an ordinary on-sheet marker. + + With `add` the link joins whatever links are already selected (Ctrl+click); + otherwise it becomes the only one. """ canvas = app_state.canvas - deselect_link() + if not add: + deselect_link() + elif wire_tag in app_state.selected_link_tags: + return if wire_tag and wire_tag in app_state.canvas_link_map: + # A knob can stand for several links. One already selected through another link + # is red by now, so its true colours are taken from that link's record. + captured = {entry[0]: entry for entries in _selected_link_restore.values() + for entry in entries} + restore = _selected_link_restore.setdefault(wire_tag, []) for item in canvas.find_withtag(wire_tag): - _selected_link_restore.append(( + restore.append(captured.get(item) or ( item, canvas.itemcget(item, "fill"), canvas.itemcget(item, "outline") if canvas.type(item) != "line" else "", canvas.itemcget(item, "width"), )) - if canvas.type(item) == "line": - canvas.itemconfig(item, fill=LINK_SELECT_COLOR, - width=LINK_SELECT_WIDTH) - elif "link_knob_core" in canvas.gettags(item): - pass # the white centre stays white while selected - else: - canvas.itemconfig(item, fill=LINK_SELECT_COLOR) - canvas.tag_raise(wire_tag) + _paint_link_selected(wire_tag) + app_state.selected_link_tags.append(wire_tag) app_state.selected_link_tag = wire_tag # A link with both ends on this sheet but no drawable route is two knobs with @@ -975,26 +983,64 @@ def select_link(wire_tag): *trim_to_knobs(*ends), fill=LINK_SELECT_COLOR, width=LINK_SELECT_WIDTH, dash=LINK_HINT_DASH, arrow=tk.LAST, arrowshape=(8, 9, 3), - tags=("link", "link_select_hint") + tags=("link", "link_select_hint", f"link_select_hint_{wire_tag}") ) canvas.tag_raise("link_select_hint") canvas.tag_raise("link_knob") +def _paint_link_selected(wire_tag): + """Paints one link's wire and knobs in the selection red.""" + canvas = app_state.canvas + for item in canvas.find_withtag(wire_tag): + if canvas.type(item) == "line": + canvas.itemconfig(item, fill=LINK_SELECT_COLOR, width=LINK_SELECT_WIDTH) + elif "link_knob_core" in canvas.gettags(item): + pass # the white centre stays white while selected + else: + canvas.itemconfig(item, fill=LINK_SELECT_COLOR) + canvas.tag_raise(wire_tag) + + +def toggle_link(wire_tag): + """Ctrl+click: adds a link to the selection, or takes it back out.""" + if wire_tag in app_state.selected_link_tags: + _restore_link(wire_tag) + app_state.selected_link_tags.remove(wire_tag) + app_state.selected_link_tag = (app_state.selected_link_tags[-1] + if app_state.selected_link_tags else None) + # A knob shared with a link still selected went back to its own colours above. + for still_selected in app_state.selected_link_tags: + _paint_link_selected(still_selected) + if app_state.canvas.find_withtag("link_select_hint"): + app_state.canvas.tag_raise("link_select_hint") + app_state.canvas.tag_raise("link_knob") + else: + select_link(wire_tag, add=True) + + +def _restore_link(wire_tag): + """Puts one selected link back exactly as it was drawn.""" + canvas = app_state.canvas + canvas.delete(f"link_select_hint_{wire_tag}") + for item, fill, outline, width in _selected_link_restore.pop(wire_tag, ()): + try: + if canvas.type(item) == "line": + canvas.itemconfig(item, fill=fill, width=width) + else: + canvas.itemconfig(item, fill=fill, outline=outline, width=width) + except tk.TclError: + pass # the item went away with a repaint + + def deselect_link(): - """Puts the selected link back exactly as it was drawn.""" + """Puts every selected link back exactly as it was drawn.""" canvas = app_state.canvas canvas.delete("link_select_hint") - if app_state.selected_link_tag: - for item, fill, outline, width in _selected_link_restore: - try: - if canvas.type(item) == "line": - canvas.itemconfig(item, fill=fill, width=width) - else: - canvas.itemconfig(item, fill=fill, outline=outline, width=width) - except tk.TclError: - pass # the item went away with a repaint - app_state.selected_link_tag = None + for wire_tag in app_state.selected_link_tags: + _restore_link(wire_tag) + app_state.selected_link_tags.clear() + app_state.selected_link_tag = None _selected_link_restore.clear()