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
+254
View File
@@ -1,5 +1,6 @@
"""Opening, saving and structural editing of a Sedona app file, plus the navigation tree."""
import copy
from datetime import datetime
import os
from tkinter import filedialog, messagebox
@@ -150,6 +151,7 @@ def reset_to_startup_state():
app_state.xml_root_element = None
app_state.current_sheet_parent = None
app_state.selected_canvas_tag = None
app_state.selected_canvas_tags.clear()
app_state.selected_tree_item_id = None
app_state.link_mark_element = None
app_state.selected_link_tag = None
@@ -209,6 +211,7 @@ def open_file():
app_state.pending_schema_kits.clear()
app_state.clear_status()
app_state.selected_canvas_tag = None
app_state.selected_canvas_tags.clear()
app_state.current_sheet_parent = None
undo.reset()
app_state.mark_clean()
@@ -513,6 +516,23 @@ def unique_child_name(parent, base_name):
return base_name[:MAX_NAME_LENGTH]
def duplicate_name(name, taken):
"""A name for a copy of `name`: its stem with the next free number, Add1 -> Add2.
Counting on from a trailing number, rather than appending another, keeps a run of
copies reading Add1, Add2, Add3 instead of Add1, Add11, Add111.
"""
stem = name.rstrip("0123456789") or "c"
digits = name[len(stem):]
counter = int(digits) + 1 if digits else 1
while True:
suffix = str(counter)
candidate = stem[:max(1, MAX_NAME_LENGTH - len(suffix))] + suffix
if candidate not in taken:
return candidate
counter += 1
def current_sheet_container():
"""The element a newly placed component belongs to: the open folder, or the app."""
if app_state.xml_root_element is None:
@@ -1164,6 +1184,239 @@ def add_link(from_path_slot, to_path_slot):
wiresheet.render_current_sheet()
# Meta steps a copy sits right of and below its original, (x, y).
DUPLICATE_OFFSET = (1, 1)
def _duplicate_tops(elements):
"""The components to copy, in document order, without any held by another of them.
A component inside one that is copied already comes along with it; copying it again
would put a second copy next to the original.
"""
root = app_state.xml_root_element
order = {element: i for i, element in enumerate(root.iter())}
chosen = []
for element in elements:
# The app cannot be copied, and a "..." stand-in is not a component at all.
if element.tag != "comp" or not element.get("type") or element not in order:
continue
if element not in chosen:
chosen.append(element)
tops = [e for e in chosen
if not any(other is not e and e in other.iter() for other in chosen)]
return sorted(tops, key=order.get)
def _meta_val(element):
meta_prop = element.find("prop[@name='meta']")
return meta_prop.get("val") if meta_prop is not None else None
def _moved_meta(meta_str, dx, dy):
"""meta moved by (dx, dy) steps, clamped to the sheet, as an unsigned int."""
x, y = read_meta_property(meta_str)[:2]
scratch = ET.Element("comp")
update_meta_property(scratch, meta_str,
min(wiresheet.META_COORD_MAX, x + dx),
min(wiresheet.META_COORD_MAX, y + dy))
return int(scratch.find("prop").get("val"))
def _ref_inside(ref, path):
return ref.startswith(path + ".") or ref.startswith(path + "/")
def duplicate_components(elements):
"""Copies components, with whatever they hold, next to the originals.
Links running between the copied components are copied too, so a group of linked
blocks comes out as a second working group. Links to anything left behind are not:
the copy would otherwise drive, or be driven by, the original's neighbours. Each
copy is named after its original with the next free number, and the whole duplicate
is one undo step. Returns the new top elements.
"""
root = app_state.xml_root_element
if root is None:
return []
tops = _duplicate_tops(elements)
if not tops:
return []
parents = [find_parent_element(root, e) for e in tops]
taken = {}
names = []
for parent, element in zip(parents, tops):
if id(parent) not in taken:
taken[id(parent)] = {c.get("name") for c in parent if c.tag == "comp"}
name = duplicate_name(element.get("name", "c"), taken[id(parent)])
taken[id(parent)].add(name)
names.append(name)
dx, dy = DUPLICATE_OFFSET
if app_state.live_mode:
copies = _duplicate_on_device(tops, parents, names, dx, dy)
else:
copies = _duplicate_in_file(tops, parents, names, dx, dy)
if not copies:
return []
app_state.mark_dirty()
refresh_treeview_from_dom(root)
wiresheet.render_current_sheet()
return copies
def _duplicate_label(tops):
if len(tops) == 1:
return f"duplicate {tops[0].get('name', '?')}"
return f"duplicate {len(tops)} components"
def _duplicate_in_file(tops, parents, names, dx, dy):
root = app_state.xml_root_element
path_map = []
commands = []
copies = []
for parent, element, name in zip(parents, tops, names):
old_path = get_component_path(element, root)
copy_elem = copy.deepcopy(element)
copy_elem.set("name", name)
x, y = read_meta_property(_meta_val(element))[:2]
update_meta_property(copy_elem, _meta_val(copy_elem),
min(wiresheet.META_COORD_MAX, x + dx),
min(wiresheet.META_COORD_MAX, y + dy))
index = len(list(parent))
parent.append(copy_elem)
# In the document first, so each new id is checked against the ones before it.
# The id a copy still carries is its original's, which stays taken either way.
for comp in copy_elem.iter("comp"):
comp.set("id", str(allocate_component_id()))
path_map.append((old_path, get_component_path(copy_elem, root)))
commands.append(undo.AddComponentCommand(parent, index, copy_elem))
copies.append(copy_elem)
def mapped(ref):
for old_path, new_path in path_map:
if _ref_inside(ref, old_path):
return new_path + ref[len(old_path):]
return None
links_elem = root.find("links")
if links_elem is not None:
for link in list(links_elem):
if link.tag != "link":
continue
new_from = mapped(link.get("from", ""))
new_to = mapped(link.get("to", ""))
if new_from is None or new_to is None:
continue
new_link = ET.SubElement(links_elem, "link")
new_link.set("from", new_from)
new_link.set("to", new_to)
commands.append(undo.AddLinkCommand(links_elem, new_link))
undo.push(undo.CompositeCommand(commands, label=_duplicate_label(tops)))
link_count = len(commands) - len(copies)
app_state.set_status(f"Duplicated as {', '.join(names)}"
+ (f", with {link_count} link(s)." if link_count else "."))
return copies
def _duplicate_on_device(tops, parents, names, dx, dy):
"""The device makes the copies from what it holds, not from the DOM.
The DOM only knows what has been opened: a folder's children may be "..." stand-ins
with no config of their own. Each original is read back in full, as a delete reads
it to be undone, and the copies are added from that.
"""
snapshots = []
for element in tops:
snapshot = live_hooks.read_subtree(element)
if snapshot is None:
live_hooks.warn_failure("Duplicate",
f"{element.get('name', '?')} could not be read from the device")
return []
snapshots.append(snapshot)
copied_ids = set()
def collect(node):
copied_ids.add(node["id"])
for child in node["children"]:
collect(child)
for snapshot in snapshots:
collect(snapshot["tree"])
links = []
for snapshot in snapshots:
for from_end, from_slot, to_end, to_slot in snapshot["links"]:
ends = (live_hooks._link_end_id(from_end), from_slot,
live_hooks._link_end_id(to_end), to_slot)
if ends[0] in copied_ids and ends[2] in copied_ids and ends not in links:
links.append(ends)
plans = []
for parent, snapshot, name in zip(parents, snapshots, names):
meta = snapshot["tree"]["values"].get("meta")
new_meta = None
if meta is not None:
new_meta = _moved_meta(str(meta), dx, dy)
if new_meta >= 1 << 31:
new_meta -= 1 << 32 # an int slot, as the device sent it: signed
plans.append((parent, snapshot["tree"], name, new_meta))
created, new_ids, complete = live_hooks.duplicate_subtrees(plans, links,
build_config_values)
if not created:
live_hooks.warn_failure("Duplicate", "Nothing was duplicated on the device")
return []
ds = app_state.device_session
client = ds.sox_client
commands = []
copies = []
for parent, new_id in created:
comp = client.cache_get(new_id)
if comp is None:
continue
copy_elem = ds._build_comp_element(parent, comp)
commands.append(LiveComponentCommand(
parent, list(parent).index(copy_elem), copy_elem, present=True))
copies.append(copy_elem)
root = app_state.xml_root_element
links_elem = root.find("links")
if links_elem is None:
links_elem = ET.SubElement(root, "links")
if complete:
for from_id, from_slot, to_id, to_slot in links:
from_comp = client.cache_get(new_ids[from_id])
to_comp = client.cache_get(new_ids[to_id])
if from_comp is None or to_comp is None:
continue
from_name = ds._slot_name_by_id(from_comp.kit_id, from_comp.type_id, from_slot)
to_name = ds._slot_name_by_id(to_comp.kit_id, to_comp.type_id, to_slot)
from_path = ds._comp_path(from_comp.id)
to_path = ds._comp_path(to_comp.id)
if from_name and to_name and from_path and to_path:
ET.SubElement(links_elem, "link",
**{"from": f"{from_path}.{from_name}",
"to": f"{to_path}.{to_name}"})
# Deleting a copy from the device takes its links with it, so the copies alone
# undo the whole duplicate.
undo.push(undo.CompositeCommand(commands, label=_duplicate_label(tops)))
if not complete:
live_hooks.warn_failure("Duplicate", "The duplicate did not fully reach the device")
else:
app_state.set_status(f"Duplicated as {', '.join(names)} on the device"
+ (f", with {len(links)} link(s)." if links else "."))
return copies
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")
@@ -1224,6 +1477,7 @@ def execute_cascading_deletion(comp_element):
target_canvas_tag = app_state.canvas_tag_by_element.get(comp_element)
if target_canvas_tag and target_canvas_tag == app_state.selected_canvas_tag:
app_state.selected_canvas_tag = None
app_state.selected_canvas_tags.discard(target_canvas_tag)
if target_canvas_tag:
app_state.canvas.delete(target_canvas_tag)