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
+2 -1
View File
@@ -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.156" VERSION = "0.0.0.157"
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
@@ -82,6 +82,7 @@ is_dirty = False
current_file_path = None current_file_path = None
active_components_list = [] # Tracks what components are actively painted active_components_list = [] # Tracks what components are actively painted
selected_canvas_tag = None # Tracks the currently clicked/highlighted canvas component tag 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 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
+74
View File
@@ -403,6 +403,80 @@ def recreate_subtree(parent_element, snapshot, build_config):
return True, complete 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): def wire_value(type_id, raw):
"""A value as the device sent it, in the form encode_value wants for `type_id`.""" """A value as the device sent it, in the form encode_value wants for `type_id`."""
if type_id == BUF_ID: if type_id == BUF_ID:
+79 -18
View File
@@ -919,6 +919,11 @@ def on_tree_select(event):
if _restoring_selection: if _restoring_selection:
return 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 # 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 # 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. # 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: if not item_id:
return 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 app_state.selected_tree_item_id = item_id
parent_id = tree.parent(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 # 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. # 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) 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) _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 = app_state.canvas
canvas.itemconfig("block_outline", outline="#7f9db9", width=2) 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() wiresheet.deselect_link()
app_state.drag_data["tag"] = None app_state.drag_data["tag"] = None
app_state.selected_canvas_tag = None
cx = canvas.canvasx(event.x) cx = canvas.canvasx(event.x)
cy = canvas.canvasy(event.y) cy = canvas.canvasy(event.y)
clicked_item = canvas.find_closest(cx, cy, halo=3) clicked_item = canvas.find_closest(cx, cy, halo=3)
tags = canvas.gettags(clicked_item[0]) if clicked_item else ()
if not clicked_item:
return
tags = canvas.gettags(clicked_item[0])
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 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: if wire_tag:
wiresheet.select_link(wire_tag) wiresheet.select_link(wire_tag)
return return
@@ -1307,7 +1351,7 @@ def on_canvas_click(event):
app_state.drag_data["tag"] = group_tag app_state.drag_data["tag"] = group_tag
app_state.drag_data["x"] = cx app_state.drag_data["x"] = cx
app_state.drag_data["y"] = cy 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 # 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. # 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) dragged = app_state.canvas_comp_map.get(group_tag)
meta_prop = dragged.find("prop[@name='meta']") if dragged is not None else None 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 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. # Bring the clicked box clear of any neighbour it happens to sit under.
canvas.tag_raise(group_tag) canvas.tag_raise(group_tag)
@@ -1605,14 +1648,16 @@ def open_rename_dialog(comp):
entry.select_range(0, tk.END) 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. """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 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 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 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.delete(0, tk.END)
menu.add_command( menu.add_command(
label="Properties", label="Properties",
@@ -1639,6 +1684,12 @@ def fill_component_popup(menu, comp):
label="Rename...", label="Rename...",
command=lambda: open_rename_dialog(current_element(comp)), 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( menu.add_command(
label="Delete Component", label="Delete Component",
command=lambda: delete_component(current_element(comp)), 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): def on_canvas_right_click(event):
"""Detects right-clicks on nodes to instantly show context-sensitive action overlays.""" """Detects right-clicks on nodes to instantly show context-sensitive action overlays."""
canvas = app_state.canvas canvas = app_state.canvas
@@ -1679,13 +1736,17 @@ def on_canvas_right_click(event):
if group_tag: if group_tag:
wiresheet.deselect_link() wiresheet.deselect_link()
canvas.itemconfig("block_outline", outline="#7f9db9", width=2) # Right-clicking one of several selected boxes acts on all of them; any
app_state.selected_canvas_tag = group_tag # other box becomes the selection on its own, as a left click would.
canvas.itemconfig(f"{group_tag} && block_outline", outline="#1f4e79", width=3) 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) comp_element = app_state.canvas_comp_map.get(group_tag)
if comp_element is not None: 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) canvas_context_menu.post(event.x_root, event.y_root)
@@ -2956,7 +3017,7 @@ _nav_style.map(
tree = ttk.Treeview( tree = ttk.Treeview(
frame_tree, frame_tree,
show="tree", show="tree",
selectmode="browse", selectmode="extended",
height=TREE_REQUESTED_ROWS, height=TREE_REQUESTED_ROWS,
style=app_state.NAV_TREE_STYLE, style=app_state.NAV_TREE_STYLE,
) )
+254
View File
@@ -1,5 +1,6 @@
"""Opening, saving and structural editing of a Sedona app file, plus the navigation tree.""" """Opening, saving and structural editing of a Sedona app file, plus the navigation tree."""
import copy
from datetime import datetime from datetime import datetime
import os import os
from tkinter import filedialog, messagebox from tkinter import filedialog, messagebox
@@ -150,6 +151,7 @@ def reset_to_startup_state():
app_state.xml_root_element = None app_state.xml_root_element = None
app_state.current_sheet_parent = None app_state.current_sheet_parent = None
app_state.selected_canvas_tag = None app_state.selected_canvas_tag = None
app_state.selected_canvas_tags.clear()
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
@@ -209,6 +211,7 @@ def open_file():
app_state.pending_schema_kits.clear() app_state.pending_schema_kits.clear()
app_state.clear_status() app_state.clear_status()
app_state.selected_canvas_tag = None app_state.selected_canvas_tag = None
app_state.selected_canvas_tags.clear()
app_state.current_sheet_parent = None app_state.current_sheet_parent = None
undo.reset() undo.reset()
app_state.mark_clean() app_state.mark_clean()
@@ -513,6 +516,23 @@ def unique_child_name(parent, base_name):
return base_name[:MAX_NAME_LENGTH] 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(): def current_sheet_container():
"""The element a newly placed component belongs to: the open folder, or the app.""" """The element a newly placed component belongs to: the open folder, or the app."""
if app_state.xml_root_element is None: 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() 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): def execute_cascading_deletion(comp_element):
"""Shared application logic to wipe an element, its children, and related links from DOM and runtime lists.""" """Shared application logic to wipe an element, its children, and related links from DOM and runtime lists."""
comp_name = comp_element.get("name", "Unknown") 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) 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: if target_canvas_tag and target_canvas_tag == app_state.selected_canvas_tag:
app_state.selected_canvas_tag = None app_state.selected_canvas_tag = None
app_state.selected_canvas_tags.discard(target_canvas_tag)
if target_canvas_tag: if target_canvas_tag:
app_state.canvas.delete(target_canvas_tag) app_state.canvas.delete(target_canvas_tag)
+19
View File
@@ -263,6 +263,25 @@ class AddComponentCommand:
self.parent.insert(self.index, self.element) 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: class AddLinkCommand:
"""Adding a single link to the <links> section.""" """Adding a single link to the <links> section."""
+1
View File
@@ -960,6 +960,7 @@ def render_components(components_to_render):
canvas = app_state.canvas canvas = app_state.canvas
app_state.active_components_list = components_to_render app_state.active_components_list = components_to_render
app_state.selected_canvas_tag = None app_state.selected_canvas_tag = None
app_state.selected_canvas_tags.clear()
canvas.delete("component") canvas.delete("component")
canvas.delete("link") canvas.delete("link")