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 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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,6 +1205,9 @@ def on_tree_right_click(event):
|
|||||||
if not item_id:
|
if not item_id:
|
||||||
return
|
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)
|
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
@@ -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)
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user