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 <noreply@anthropic.com>
This commit is contained in:
+3
-1
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
|
|||||||
import configparser
|
import configparser
|
||||||
import os
|
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")
|
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
|
||||||
DEFAULT_GRID_SIZE = 10
|
DEFAULT_GRID_SIZE = 10
|
||||||
DEFAULT_ADD_TIMESTAMP = True
|
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
|
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"
|
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_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
|
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
|
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
|
zoom = 1.0 # Scale the open sheet is painted at; 1.0 is one pixel per pixel
|
||||||
|
|||||||
@@ -1014,23 +1014,30 @@ def on_tree_select(event):
|
|||||||
|
|
||||||
|
|
||||||
def delete_selected_link():
|
def delete_selected_link():
|
||||||
"""Removes the currently selected link from the DOM and repaints."""
|
"""Removes every selected link from the DOM, as one undoable step, and repaints."""
|
||||||
if not app_state.selected_link_tag:
|
link_elems = []
|
||||||
return
|
for wire_tag in app_state.selected_link_tags:
|
||||||
link_elem = app_state.canvas_link_map.get(app_state.selected_link_tag)
|
link_elem = app_state.canvas_link_map.get(wire_tag)
|
||||||
if link_elem is None:
|
if link_elem is not None and link_elem not in link_elems:
|
||||||
|
link_elems.append(link_elem)
|
||||||
|
if not link_elems:
|
||||||
return
|
return
|
||||||
|
|
||||||
from_ref = link_elem.get("from", "")
|
if len(link_elems) == 1:
|
||||||
to_ref = link_elem.get("to", "")
|
from_ref = link_elems[0].get("from", "")
|
||||||
confirm = messagebox.askyesno(
|
to_ref = link_elems[0].get("to", "")
|
||||||
"Delete Link",
|
question = f"Delete link?\n\n from: {from_ref}\n to: {to_ref}"
|
||||||
f"Delete link?\n\n from: {from_ref}\n to: {to_ref}"
|
else:
|
||||||
)
|
shown = link_elems[:15]
|
||||||
if not confirm:
|
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
|
return
|
||||||
|
|
||||||
sax_file.remove_link(link_elem)
|
sax_file.remove_links(link_elems)
|
||||||
|
|
||||||
|
|
||||||
def link_endpoint_components(link_elem):
|
def link_endpoint_components(link_elem):
|
||||||
@@ -1341,7 +1348,6 @@ def _toggle_canvas_box(group_tag):
|
|||||||
|
|
||||||
def on_canvas_click(event):
|
def on_canvas_click(event):
|
||||||
canvas = app_state.canvas
|
canvas = app_state.canvas
|
||||||
wiresheet.deselect_link()
|
|
||||||
app_state.drag_data["tag"] = None
|
app_state.drag_data["tag"] = None
|
||||||
|
|
||||||
cx = canvas.canvasx(event.x)
|
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)
|
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)
|
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:
|
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.
|
# Picking boxes out one by one, so nothing is picked up to be moved.
|
||||||
_toggle_canvas_box(group_tag)
|
_toggle_canvas_box(group_tag)
|
||||||
@@ -1743,6 +1757,10 @@ def on_canvas_right_click(event):
|
|||||||
wire_tag = next((t for t in tags if t.startswith("link_wire_")), None)
|
wire_tag = next((t for t in tags if t.startswith("link_wire_")), None)
|
||||||
|
|
||||||
if wire_tag:
|
if 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)
|
wiresheet.select_link(wire_tag)
|
||||||
_rebuild_link_context_menu()
|
_rebuild_link_context_menu()
|
||||||
link_context_menu.post(event.x_root, event.y_root)
|
link_context_menu.post(event.x_root, event.y_root)
|
||||||
@@ -2862,6 +2880,12 @@ def _rebuild_link_context_menu():
|
|||||||
left off rather than greyed out.
|
left off rather than greyed out.
|
||||||
"""
|
"""
|
||||||
link_context_menu.delete(0, tk.END)
|
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_context_menu.add_command(label="Delete Link", command=delete_selected_link)
|
||||||
|
|
||||||
link_elem = app_state.canvas_link_map.get(app_state.selected_link_tag)
|
link_elem = app_state.canvas_link_map.get(app_state.selected_link_tag)
|
||||||
|
|||||||
+40
-14
@@ -155,6 +155,7 @@ def reset_to_startup_state():
|
|||||||
app_state.selected_tree_item_id = None
|
app_state.selected_tree_item_id = None
|
||||||
app_state.link_mark_element = None
|
app_state.link_mark_element = None
|
||||||
app_state.selected_link_tag = None
|
app_state.selected_link_tag = None
|
||||||
|
app_state.selected_link_tags.clear()
|
||||||
|
|
||||||
app_state.active_components_list.clear()
|
app_state.active_components_list.clear()
|
||||||
app_state.tree_element_map.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):
|
def remove_link(link_element):
|
||||||
"""Removes a <link> element from the <links> section and repaints."""
|
"""Removes a <link> element from the <links> section and repaints."""
|
||||||
|
remove_links([link_element])
|
||||||
|
|
||||||
|
|
||||||
|
def remove_links(link_elements):
|
||||||
|
"""Removes several <link> 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:
|
if app_state.xml_root_element is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1115,35 +1125,51 @@ def remove_link(link_element):
|
|||||||
if links_elem is None:
|
if links_elem is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
commands = []
|
||||||
|
failures = []
|
||||||
|
for link_element in link_elements:
|
||||||
|
if link_element not in list(links_elem):
|
||||||
|
continue
|
||||||
from_ref = link_element.get("from", "")
|
from_ref = link_element.get("from", "")
|
||||||
to_ref = link_element.get("to", "")
|
to_ref = link_element.get("to", "")
|
||||||
|
|
||||||
if app_state.live_mode:
|
if app_state.live_mode:
|
||||||
if not live_hooks.live_delete_link(*link_ends(from_ref, to_ref)):
|
if not live_hooks.live_delete_link(*link_ends(from_ref, to_ref)):
|
||||||
live_hooks.warn_failure("Delete Link",
|
failures.append(f"{from_ref} → {to_ref}")
|
||||||
f"The link {from_ref} → {to_ref} was not removed from the device")
|
continue
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
index = list(links_elem).index(link_element)
|
index = list(links_elem).index(link_element)
|
||||||
links_elem.remove(link_element)
|
links_elem.remove(link_element)
|
||||||
except ValueError:
|
|
||||||
return
|
|
||||||
|
|
||||||
from_slot = from_ref.rsplit(".", 1)[-1] if "." in from_ref else from_ref
|
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
|
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:
|
if app_state.live_mode:
|
||||||
undo.push(LiveLinkCommand(
|
commands.append(LiveLinkCommand(links_elem, index, link_element,
|
||||||
links_elem, index, link_element, present=False,
|
present=False, label=label))
|
||||||
label=f"delete link {from_slot}→{to_slot}"
|
|
||||||
))
|
|
||||||
else:
|
else:
|
||||||
undo.push(undo.DeleteLinkCommand(
|
commands.append(undo.DeleteLinkCommand(links_elem, index, link_element,
|
||||||
links_elem, index, link_element,
|
label=label))
|
||||||
label=f"delete link {from_slot}→{to_slot}"
|
|
||||||
))
|
# 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
|
||||||
|
|
||||||
app_state.selected_link_tag = None
|
app_state.selected_link_tag = None
|
||||||
|
app_state.selected_link_tags.clear()
|
||||||
app_state.mark_dirty()
|
app_state.mark_dirty()
|
||||||
wiresheet.render_current_sheet()
|
wiresheet.render_current_sheet()
|
||||||
|
|
||||||
|
|||||||
+63
-17
@@ -843,6 +843,8 @@ def draw_links():
|
|||||||
app_state.canvas_link_map.clear()
|
app_state.canvas_link_map.clear()
|
||||||
app_state.knob_link_ends.clear()
|
app_state.knob_link_ends.clear()
|
||||||
app_state.selected_link_tag = None
|
app_state.selected_link_tag = None
|
||||||
|
app_state.selected_link_tags.clear()
|
||||||
|
_selected_link_restore.clear()
|
||||||
app_state.unroutable_link_count = 0
|
app_state.unroutable_link_count = 0
|
||||||
if app_state.xml_root_element is None:
|
if app_state.xml_root_element is None:
|
||||||
return
|
return
|
||||||
@@ -920,7 +922,8 @@ def draw_links():
|
|||||||
app_state.unroutable_link_count = len(jobs) - len(routed)
|
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):
|
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)
|
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.
|
"""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
|
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
|
may be grey, or half grey, and blanket-restoring would repaint it as an ordinary
|
||||||
on-sheet marker.
|
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
|
canvas = app_state.canvas
|
||||||
|
if not add:
|
||||||
deselect_link()
|
deselect_link()
|
||||||
|
elif wire_tag in app_state.selected_link_tags:
|
||||||
|
return
|
||||||
if wire_tag and wire_tag in app_state.canvas_link_map:
|
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):
|
for item in canvas.find_withtag(wire_tag):
|
||||||
_selected_link_restore.append((
|
restore.append(captured.get(item) or (
|
||||||
item,
|
item,
|
||||||
canvas.itemcget(item, "fill"),
|
canvas.itemcget(item, "fill"),
|
||||||
canvas.itemcget(item, "outline") if canvas.type(item) != "line" else "",
|
canvas.itemcget(item, "outline") if canvas.type(item) != "line" else "",
|
||||||
canvas.itemcget(item, "width"),
|
canvas.itemcget(item, "width"),
|
||||||
))
|
))
|
||||||
if canvas.type(item) == "line":
|
_paint_link_selected(wire_tag)
|
||||||
canvas.itemconfig(item, fill=LINK_SELECT_COLOR,
|
app_state.selected_link_tags.append(wire_tag)
|
||||||
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)
|
|
||||||
app_state.selected_link_tag = 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
|
# A link with both ends on this sheet but no drawable route is two knobs with
|
||||||
@@ -975,18 +983,47 @@ def select_link(wire_tag):
|
|||||||
*trim_to_knobs(*ends), fill=LINK_SELECT_COLOR,
|
*trim_to_knobs(*ends), fill=LINK_SELECT_COLOR,
|
||||||
width=LINK_SELECT_WIDTH, dash=LINK_HINT_DASH,
|
width=LINK_SELECT_WIDTH, dash=LINK_HINT_DASH,
|
||||||
arrow=tk.LAST, arrowshape=(8, 9, 3),
|
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_select_hint")
|
||||||
canvas.tag_raise("link_knob")
|
canvas.tag_raise("link_knob")
|
||||||
|
|
||||||
|
|
||||||
def deselect_link():
|
def _paint_link_selected(wire_tag):
|
||||||
"""Puts the selected link back exactly as it was drawn."""
|
"""Paints one link's wire and knobs in the selection red."""
|
||||||
canvas = app_state.canvas
|
canvas = app_state.canvas
|
||||||
canvas.delete("link_select_hint")
|
for item in canvas.find_withtag(wire_tag):
|
||||||
if app_state.selected_link_tag:
|
if canvas.type(item) == "line":
|
||||||
for item, fill, outline, width in _selected_link_restore:
|
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:
|
try:
|
||||||
if canvas.type(item) == "line":
|
if canvas.type(item) == "line":
|
||||||
canvas.itemconfig(item, fill=fill, width=width)
|
canvas.itemconfig(item, fill=fill, width=width)
|
||||||
@@ -994,6 +1031,15 @@ def deselect_link():
|
|||||||
canvas.itemconfig(item, fill=fill, outline=outline, width=width)
|
canvas.itemconfig(item, fill=fill, outline=outline, width=width)
|
||||||
except tk.TclError:
|
except tk.TclError:
|
||||||
pass # the item went away with a repaint
|
pass # the item went away with a repaint
|
||||||
|
|
||||||
|
|
||||||
|
def deselect_link():
|
||||||
|
"""Puts every selected link back exactly as it was drawn."""
|
||||||
|
canvas = app_state.canvas
|
||||||
|
canvas.delete("link_select_hint")
|
||||||
|
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
|
app_state.selected_link_tag = None
|
||||||
_selected_link_restore.clear()
|
_selected_link_restore.clear()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user