Duplicate components from the sheet and tree popups, with links between them

Ctrl+click picks out several boxes on the sheet, and Ctrl/Shift+click several
rows in the tree. Copies are named on from their original (Add1 -> Add2), sit
one step right and down, and keep the links that ran between the copied
components. One undo step; on a device the originals are read back in full
and the copies made there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 22:00:49 +03:00
co-authored by Claude Opus 5.5
parent 4ab4b4d588
commit d036ee206d
6 changed files with 429 additions and 19 deletions
+79 -18
View File
@@ -919,6 +919,11 @@ def on_tree_select(event):
if _restoring_selection:
return
# Ctrl or Shift has picked out several rows to act on together, not a sheet to
# open, so the sheet already showing stays.
if len(app_state.tree.selection()) > 1:
return
# Choosing a node means "show me that sheet", so whatever has the pane stands down
# rather than leaving the selection looking like it did nothing. Refusing to leave
# has to put the tree back on the sheet that is still showing.
@@ -1200,7 +1205,10 @@ def on_tree_right_click(event):
if not item_id:
return
tree.selection_set(item_id)
selected_rows = tree.selection()
keep_selection = item_id in selected_rows and len(selected_rows) > 1
if not keep_selection:
tree.selection_set(item_id)
app_state.selected_tree_item_id = item_id
parent_id = tree.parent(item_id)
@@ -1229,7 +1237,11 @@ def on_tree_right_click(event):
# The same actions the canvas offers, on the component the tree row names: which
# list a component was picked from should not change what can be done with it.
fill_component_popup(tree_comp_context_menu, comp_element)
selection = None
if keep_selection:
selection = [app_state.tree_element_map[row] for row in selected_rows
if row in app_state.tree_element_map]
fill_component_popup(tree_comp_context_menu, comp_element, selection)
tree_comp_context_menu.post(event.x_root, event.y_root)
@@ -1281,24 +1293,56 @@ def on_canvas_double_click(event):
_open_special_view(comp)
def on_canvas_click(event):
CTRL_MASK = 0x0004
def paint_canvas_selection():
"""Outlines every selected box heavier than the rest."""
canvas = app_state.canvas
canvas.itemconfig("block_outline", outline="#7f9db9", width=2)
for tag in app_state.selected_canvas_tags:
canvas.itemconfig(f"{tag} && block_outline", outline="#1f4e79", width=3)
def select_canvas_boxes(tags):
"""Makes exactly these boxes the selection; the last is the one Del acts on."""
tags = [t for t in tags if t]
app_state.selected_canvas_tags = set(tags)
app_state.selected_canvas_tag = tags[-1] if tags else None
paint_canvas_selection()
def _toggle_canvas_box(group_tag):
"""Ctrl+click: adds a box to the selection, or takes it back out."""
selected = app_state.selected_canvas_tags
if group_tag in selected:
selected.discard(group_tag)
if app_state.selected_canvas_tag == group_tag:
app_state.selected_canvas_tag = next(iter(selected), None)
else:
selected.add(group_tag)
app_state.selected_canvas_tag = group_tag
paint_canvas_selection()
def on_canvas_click(event):
canvas = app_state.canvas
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, halo=3)
if not clicked_item:
return
tags = canvas.gettags(clicked_item[0])
tags = canvas.gettags(clicked_item[0]) if clicked_item else ()
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 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)
return
select_canvas_boxes([])
if wire_tag:
wiresheet.select_link(wire_tag)
return
@@ -1307,7 +1351,7 @@ def on_canvas_click(event):
app_state.drag_data["tag"] = group_tag
app_state.drag_data["x"] = cx
app_state.drag_data["y"] = cy
app_state.selected_canvas_tag = group_tag
select_canvas_boxes([group_tag])
# Where the box starts out. The drag is measured as an offset from this pair
# rather than accumulated per motion event, so repeated snapping cannot drift.
@@ -1319,7 +1363,6 @@ def on_canvas_click(event):
dragged = app_state.canvas_comp_map.get(group_tag)
meta_prop = dragged.find("prop[@name='meta']") if dragged is not None else None
app_state.drag_data["meta_before"] = meta_prop.get("val") if meta_prop is not None else None
canvas.itemconfig(f"{group_tag} && block_outline", outline="#1f4e79", width=3)
# Bring the clicked box clear of any neighbour it happens to sit under.
canvas.tag_raise(group_tag)
@@ -1605,14 +1648,16 @@ def open_rename_dialog(comp):
entry.select_range(0, tk.END)
def fill_component_popup(menu, comp):
def fill_component_popup(menu, comp, selection=None):
"""Fills a popup with the actions that apply to one component.
Shared by the canvas and the navigation tree, so a component offers the same
actions wherever it was right-clicked. The entries close over the component rather
than reading a selection back, which is what lets the two popups act on the thing
that was actually clicked.
that was actually clicked. `selection` is everything selected along with it, which
Duplicate copies as a group; the other entries act on `comp` alone.
"""
selection = [e for e in (selection or [comp]) if e.tag == "comp"] or [comp]
menu.delete(0, tk.END)
menu.add_command(
label="Properties",
@@ -1639,6 +1684,12 @@ def fill_component_popup(menu, comp):
label="Rename...",
command=lambda: open_rename_dialog(current_element(comp)),
)
if comp.tag == "comp":
menu.add_command(
label=("Duplicate" if len(selection) == 1
else f"Duplicate {len(selection)} Components"),
command=lambda: duplicate_components(selection),
)
menu.add_command(
label="Delete Component",
command=lambda: delete_component(current_element(comp)),
@@ -1659,6 +1710,12 @@ def fill_component_popup(menu, comp):
)
def duplicate_components(elements):
"""Duplicates components, and leaves the copies selected if they are on the sheet."""
copies = sax_file.duplicate_components([current_element(e) for e in elements])
select_canvas_boxes([app_state.canvas_tag_by_element.get(c) for c in copies])
def on_canvas_right_click(event):
"""Detects right-clicks on nodes to instantly show context-sensitive action overlays."""
canvas = app_state.canvas
@@ -1679,13 +1736,17 @@ def on_canvas_right_click(event):
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)
# Right-clicking one of several selected boxes acts on all of them; any
# other box becomes the selection on its own, as a left click would.
if group_tag not in app_state.selected_canvas_tags:
select_canvas_boxes([group_tag])
comp_element = app_state.canvas_comp_map.get(group_tag)
if comp_element is not None:
fill_component_popup(canvas_context_menu, comp_element)
selection = [app_state.canvas_comp_map[t]
for t in app_state.selected_canvas_tags
if t in app_state.canvas_comp_map]
fill_component_popup(canvas_context_menu, comp_element, selection)
canvas_context_menu.post(event.x_root, event.y_root)
@@ -2956,7 +3017,7 @@ _nav_style.map(
tree = ttk.Treeview(
frame_tree,
show="tree",
selectmode="browse",
selectmode="extended",
height=TREE_REQUESTED_ROWS,
style=app_state.NAV_TREE_STYLE,
)