From d036ee206d4007b9020f3e5927f5a9b42ed17f08 Mon Sep 17 00:00:00 2001 From: Arda Aydin Date: Tue, 6 Oct 2026 22:00:49 +0300 Subject: [PATCH] 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 --- app_state.py | 3 +- live_hooks.py | 74 +++++++++++++++ main.py | 97 +++++++++++++++---- sax_file.py | 254 ++++++++++++++++++++++++++++++++++++++++++++++++++ undo.py | 19 ++++ wiresheet.py | 1 + 6 files changed, 429 insertions(+), 19 deletions(-) diff --git a/app_state.py b/app_state.py index 8a717c0..352bc9f 100644 --- a/app_state.py +++ b/app_state.py @@ -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 diff --git a/live_hooks.py b/live_hooks.py index 1cc00be..381e83c 100644 --- a/live_hooks.py +++ b/live_hooks.py @@ -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: diff --git a/main.py b/main.py index 2b89ef1..f224b3b 100644 --- a/main.py +++ b/main.py @@ -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, ) diff --git a/sax_file.py b/sax_file.py index cbc59fc..d55b435 100644 --- a/sax_file.py +++ b/sax_file.py @@ -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) diff --git a/undo.py b/undo.py index ce258ec..78acff8 100644 --- a/undo.py +++ b/undo.py @@ -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 section.""" diff --git a/wiresheet.py b/wiresheet.py index 98a6fe1..3b6e0f4 100644 --- a/wiresheet.py +++ b/wiresheet.py @@ -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")