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:
+254
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user