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:
+2
-1
@@ -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.156"
|
||||
VERSION = "0.0.0.157"
|
||||
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
|
||||
DEFAULT_GRID_SIZE = 10
|
||||
DEFAULT_ADD_TIMESTAMP = True
|
||||
@@ -82,6 +82,7 @@ is_dirty = False
|
||||
current_file_path = None
|
||||
active_components_list = [] # Tracks what components are actively painted
|
||||
selected_canvas_tag = None # Tracks the currently clicked/highlighted canvas component tag
|
||||
selected_canvas_tags = set() # Every selected box, the one above included; Ctrl+click adds
|
||||
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
|
||||
|
||||
@@ -403,6 +403,80 @@ def recreate_subtree(parent_element, snapshot, build_config):
|
||||
return True, complete
|
||||
|
||||
|
||||
def duplicate_subtrees(plans, links, build_config):
|
||||
"""Adds copies of read_subtree trees to the device, then the links among them.
|
||||
|
||||
`plans` is a list of (parent_element, node, new_name, new_meta): the node is a
|
||||
read_subtree tree, copied under `parent_element` with its top renamed and moved to
|
||||
`new_meta` (None keeps it where it was). Children keep their names, which stay
|
||||
unique because their parent is new. `links` holds (from_id, from_slot, to_id,
|
||||
to_slot) between components in the trees, by their original ids.
|
||||
|
||||
Nothing in a node is changed; the device hands out new ids for the copies.
|
||||
Returns (created, new_ids, complete): `created` lists (parent_element, new_top_id)
|
||||
for every top the device took, `new_ids` maps each original id to its copy's, and
|
||||
when not complete, last_error says what is missing.
|
||||
"""
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return [], {}, False
|
||||
client = ds.sox_client
|
||||
new_ids = {}
|
||||
created = []
|
||||
|
||||
def create(node, under_id, name, meta):
|
||||
values = dict(node["values"])
|
||||
if meta is not None:
|
||||
values["meta"] = meta
|
||||
config = build_config(node["type_name"], device_values=values)
|
||||
comp = client.add(under_id, node["kit_id"], node["type_id"], name, config)
|
||||
new_ids[node["id"]] = comp.id
|
||||
for child in node["children"]:
|
||||
create(child, comp.id, child["name"], None)
|
||||
|
||||
complete = True
|
||||
for parent_element, node, new_name, new_meta in plans:
|
||||
parent_id = _comp_id(parent_element)
|
||||
if parent_id is None:
|
||||
_fail(f"The parent '{parent_element.get('name', '?')}' has no component id.")
|
||||
complete = False
|
||||
break
|
||||
try:
|
||||
create(node, parent_id, new_name, new_meta)
|
||||
except Exception as e:
|
||||
if node["id"] in new_ids:
|
||||
created.append((parent_element, new_ids[node["id"]]))
|
||||
_fail(f"'{new_name}' was added to the device, but not all of it: {e}")
|
||||
else:
|
||||
_fail(f"The device rejected the add of '{new_name}': {e}")
|
||||
complete = False
|
||||
break
|
||||
created.append((parent_element, new_ids[node["id"]]))
|
||||
|
||||
if complete:
|
||||
for from_id, from_slot, to_id, to_slot in links:
|
||||
try:
|
||||
client.link(new_ids[from_id], from_slot, new_ids[to_id], to_slot)
|
||||
except Exception as e:
|
||||
_fail(f"The copies are on the device, but not every link between them: {e}")
|
||||
complete = False
|
||||
break
|
||||
|
||||
top_ids = {top_id for _, top_id in created}
|
||||
for top_id in top_ids:
|
||||
ds.after_add(top_id)
|
||||
# after_add reads only the top; the children's values are read too, so the DOM
|
||||
# built from the cache carries their slots.
|
||||
children = [client.cache_get(i) for i in new_ids.values() if i not in top_ids]
|
||||
children = [c for c in children if c is not None]
|
||||
if children:
|
||||
try:
|
||||
client.update(children, CONFIG)
|
||||
except Exception:
|
||||
pass
|
||||
return created, new_ids, complete
|
||||
|
||||
|
||||
def wire_value(type_id, raw):
|
||||
"""A value as the device sent it, in the form encode_value wants for `type_id`."""
|
||||
if type_id == BUF_ID:
|
||||
|
||||
@@ -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,6 +1205,9 @@ def on_tree_right_click(event):
|
||||
if not item_id:
|
||||
return
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
+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)
|
||||
|
||||
@@ -263,6 +263,25 @@ class AddComponentCommand:
|
||||
self.parent.insert(self.index, self.element)
|
||||
|
||||
|
||||
class CompositeCommand:
|
||||
"""Several commands taken as one step, such as a duplicate: its copies and its links.
|
||||
|
||||
Undone last-first, so links go before the components they run between.
|
||||
"""
|
||||
|
||||
def __init__(self, commands, label="edit"):
|
||||
self.commands = list(commands)
|
||||
self.label = label
|
||||
|
||||
def undo(self):
|
||||
for command in reversed(self.commands):
|
||||
command.undo()
|
||||
|
||||
def redo(self):
|
||||
for command in self.commands:
|
||||
command.redo()
|
||||
|
||||
|
||||
class AddLinkCommand:
|
||||
"""Adding a single link to the <links> section."""
|
||||
|
||||
|
||||
@@ -960,6 +960,7 @@ def render_components(components_to_render):
|
||||
canvas = app_state.canvas
|
||||
app_state.active_components_list = components_to_render
|
||||
app_state.selected_canvas_tag = None
|
||||
app_state.selected_canvas_tags.clear()
|
||||
|
||||
canvas.delete("component")
|
||||
canvas.delete("link")
|
||||
|
||||
Reference in New Issue
Block a user