707 lines
26 KiB
Python
707 lines
26 KiB
Python
"""Opening, saving and structural editing of a Sedona app file, plus the navigation tree."""
|
|
|
|
from datetime import datetime
|
|
import os
|
|
from tkinter import filedialog, messagebox
|
|
import xml.etree.ElementTree as ET
|
|
|
|
import app_state
|
|
import palette
|
|
import schema
|
|
import undo
|
|
import wiresheet
|
|
from sax_dom import find_parent_element, get_component_path, update_meta_property
|
|
|
|
|
|
def parse_xml_to_tree(treeview, parent_node, xml_element):
|
|
"""Recursively traverses XML elements and adds <comp> tags to the Treeview."""
|
|
for child in xml_element:
|
|
if child.tag == "comp":
|
|
comp_name = child.get("name", "Unknown")
|
|
comp_id = child.get("id")
|
|
|
|
if comp_id is not None:
|
|
display_text = f"{comp_name} [id: {comp_id}]"
|
|
else:
|
|
display_text = comp_name
|
|
|
|
node_id = treeview.insert(
|
|
parent_node, "end", text=display_text, open=False
|
|
)
|
|
app_state.tree_element_map[node_id] = child
|
|
parse_xml_to_tree(treeview, node_id, child)
|
|
|
|
|
|
def refresh_treeview_from_dom(xml_root):
|
|
"""Rebuilds navigation tree structures using current XML DOM trees."""
|
|
tree = app_state.tree
|
|
app_state.tree_element_map.clear()
|
|
|
|
for item in tree.get_children():
|
|
tree.delete(item)
|
|
|
|
if not app_state.current_file_path or xml_root is None:
|
|
return
|
|
|
|
file_name = os.path.basename(app_state.current_file_path)
|
|
root_node = tree.insert("", "end", text=file_name, open=True)
|
|
|
|
app_element = xml_root.find("app")
|
|
if app_element is not None:
|
|
parse_xml_to_tree(tree, root_node, app_element)
|
|
else:
|
|
parse_xml_to_tree(tree, root_node, xml_root)
|
|
|
|
|
|
def reset_to_startup_state():
|
|
"""Returns the app to its just-launched state: no file, empty tree, empty sheet.
|
|
|
|
A load that fails part way has already cleared the previous file, so leaving the
|
|
half-built state on screen would show a tree and a DOM that no longer agree. This
|
|
is the one place that unwinds all of it.
|
|
"""
|
|
app_state.current_file_path = None
|
|
app_state.xml_root_element = None
|
|
app_state.current_sheet_parent = None
|
|
app_state.selected_canvas_tag = None
|
|
app_state.selected_tree_item_id = None
|
|
app_state.link_mark_element = None
|
|
app_state.selected_link_tag = None
|
|
|
|
app_state.active_components_list.clear()
|
|
app_state.tree_element_map.clear()
|
|
app_state.canvas_comp_map.clear()
|
|
app_state.canvas_tag_by_element.clear()
|
|
app_state.component_slot_offsets.clear()
|
|
app_state.canvas_link_map.clear()
|
|
app_state.final_components_registry.clear()
|
|
app_state.active_schema_kits.clear()
|
|
app_state.pending_schema_kits.clear()
|
|
|
|
undo.reset()
|
|
app_state.mark_clean()
|
|
app_state.clear_status()
|
|
|
|
if app_state.tree is not None:
|
|
for item in app_state.tree.get_children():
|
|
app_state.tree.delete(item)
|
|
|
|
if app_state.canvas is not None:
|
|
app_state.canvas.delete("all")
|
|
wiresheet.draw_grid()
|
|
|
|
if app_state.window is not None:
|
|
app_state.window.title(app_state.base_window_title())
|
|
|
|
|
|
def open_file():
|
|
if not app_state.SEDONA_HOME:
|
|
messagebox.showwarning("Action Blocked", "Cannot load SAX profiles because 'Sedona home folder' configuration is missing or empty.")
|
|
return
|
|
|
|
file_path = filedialog.askopenfilename(
|
|
title="Select SAX File",
|
|
filetypes=[("SAX Files", "*.sax"), ("XML Files", "*.xml"), ("All Files", "*.*")],
|
|
)
|
|
if not file_path:
|
|
return
|
|
|
|
canvas = app_state.canvas
|
|
tree = app_state.tree
|
|
|
|
try:
|
|
app_state.current_file_path = file_path
|
|
app_state.active_components_list.clear()
|
|
app_state.pending_schema_kits.clear()
|
|
app_state.clear_status()
|
|
app_state.selected_canvas_tag = None
|
|
app_state.current_sheet_parent = None
|
|
undo.reset()
|
|
app_state.mark_clean()
|
|
|
|
app_state.tree_element_map.clear()
|
|
app_state.canvas_comp_map.clear()
|
|
app_state.canvas_tag_by_element.clear()
|
|
for item in tree.get_children():
|
|
tree.delete(item)
|
|
canvas.delete("all")
|
|
wiresheet.draw_grid()
|
|
|
|
xml_tree = ET.parse(file_path)
|
|
app_state.xml_root_element = xml_tree.getroot()
|
|
|
|
schema.load_schema_kits_and_manifests()
|
|
|
|
file_name = os.path.basename(file_path)
|
|
root_node = tree.insert("", "end", text=file_name, open=True)
|
|
|
|
app_element = app_state.xml_root_element.find("app")
|
|
if app_element is not None:
|
|
parse_xml_to_tree(tree, root_node, app_element)
|
|
else:
|
|
parse_xml_to_tree(tree, root_node, app_state.xml_root_element)
|
|
|
|
except schema.MissingManifestError as missing:
|
|
messagebox.showerror(
|
|
"Kit manifest missing",
|
|
f"'{os.path.basename(file_path)}' was not opened.\n\n"
|
|
+ schema.describe_missing_manifests(missing)
|
|
+ "\n\nFix the Sedona home folder, then open the file again."
|
|
)
|
|
reset_to_startup_state()
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"Failed to parse the file:\n{str(e)}")
|
|
reset_to_startup_state()
|
|
|
|
|
|
def sync_live_metadata_to_dom():
|
|
"""Internal helper to clean up deleted elements and sync coordinates into the root XML object."""
|
|
if app_state.xml_root_element is None:
|
|
return
|
|
|
|
live_elements_meta = {}
|
|
for cached_tag, cached_element in app_state.canvas_comp_map.items():
|
|
comp_id = cached_element.get("id")
|
|
comp_name = cached_element.get("name")
|
|
meta_prop = cached_element.find("prop[@name='meta']")
|
|
if meta_prop is not None:
|
|
live_elements_meta[(comp_id, comp_name)] = meta_prop.get("val")
|
|
|
|
def sync_and_prune_tree(target_xml_parent):
|
|
children_to_examine = list(target_xml_parent)
|
|
for child in children_to_examine:
|
|
if child.tag == "comp":
|
|
comp_id = child.get("id")
|
|
comp_name = child.get("name")
|
|
|
|
is_element_alive = False
|
|
for c_elem in app_state.active_components_list:
|
|
if c_elem.get("id") == comp_id and c_elem.get("name") == comp_name:
|
|
is_element_alive = True
|
|
break
|
|
|
|
is_previously_rendered = any(
|
|
elem.get("id") == comp_id and elem.get("name") == comp_name
|
|
for elem in app_state.canvas_comp_map.values()
|
|
) or (comp_id, comp_name) in live_elements_meta
|
|
|
|
if is_previously_rendered and not is_element_alive:
|
|
target_xml_parent.remove(child)
|
|
continue
|
|
|
|
if (comp_id, comp_name) in live_elements_meta:
|
|
tgt_meta = child.find("prop[@name='meta']")
|
|
if tgt_meta is None:
|
|
tgt_meta = ET.SubElement(child, "prop", name="meta")
|
|
tgt_meta.set("val", live_elements_meta[(comp_id, comp_name)])
|
|
|
|
sync_and_prune_tree(child)
|
|
|
|
sync_and_prune_tree(app_state.xml_root_element)
|
|
|
|
|
|
def execute_direct_overwrite_save():
|
|
"""Overwrites the existing file directly without adding timestamp formats or prompts."""
|
|
if not app_state.current_file_path or app_state.xml_root_element is None:
|
|
return
|
|
|
|
try:
|
|
sync_live_metadata_to_dom()
|
|
|
|
if not reconcile_schema_kits_before_save():
|
|
return
|
|
|
|
with open(app_state.current_file_path, "wb") as f:
|
|
f.write(b'<?xml version="1.0" encoding="UTF-8"?>\n')
|
|
ET.ElementTree(app_state.xml_root_element).write(f, encoding="utf-8", xml_declaration=False)
|
|
|
|
app_state.mark_clean()
|
|
undo.mark_saved()
|
|
refresh_treeview_from_dom(app_state.xml_root_element)
|
|
app_state.set_status(f"Saved {os.path.basename(app_state.current_file_path)}.")
|
|
|
|
messagebox.showinfo("Success", f"Direct changes saved onto:\n{os.path.basename(app_state.current_file_path)}")
|
|
except Exception as e:
|
|
messagebox.showerror("Save Error", f"Failed to write direct update stream:\n{str(e)}")
|
|
|
|
|
|
def save_file_as():
|
|
"""Saves the tracking tree document hierarchy targeting an explicit location choice dialog."""
|
|
if not app_state.current_file_path or app_state.xml_root_element is None:
|
|
messagebox.showinfo("Save Info", "No active profile has been opened yet.")
|
|
return
|
|
|
|
try:
|
|
sync_live_metadata_to_dom()
|
|
|
|
if app_state.ADD_TIMESTAMP:
|
|
base_dir = os.path.dirname(app_state.current_file_path)
|
|
orig_filename = os.path.basename(app_state.current_file_path)
|
|
name_part, ext_part = os.path.splitext(orig_filename)
|
|
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
suggested_name = f"{name_part}_{timestamp}{ext_part}"
|
|
|
|
target_path = filedialog.asksaveasfilename(
|
|
initialdir=base_dir,
|
|
initialfile=suggested_name,
|
|
title="Save Timestamps Layout As",
|
|
filetypes=[
|
|
("SAX Files", "*.sax"),
|
|
("XML Files", "*.xml"),
|
|
("All Files", "*.*"),
|
|
]
|
|
)
|
|
|
|
if not target_path:
|
|
return
|
|
else:
|
|
target_path = app_state.current_file_path
|
|
|
|
if not reconcile_schema_kits_before_save():
|
|
return
|
|
|
|
with open(target_path, "wb") as f:
|
|
f.write(b'<?xml version="1.0" encoding="UTF-8"?>\n')
|
|
ET.ElementTree(app_state.xml_root_element).write(f, encoding="utf-8", xml_declaration=False)
|
|
|
|
app_state.mark_clean()
|
|
undo.mark_saved()
|
|
|
|
refresh_treeview_from_dom(app_state.xml_root_element)
|
|
|
|
folder_name = os.path.basename(os.path.dirname(target_path))
|
|
file_name = os.path.basename(target_path)
|
|
app_state.set_status(f"Saved {file_name}.")
|
|
|
|
messagebox.showinfo("Success", f"File Saved\nFolder: {folder_name}\nFile: {file_name}")
|
|
|
|
except Exception as e:
|
|
messagebox.showerror("Save Error", f"An anomaly prevented saving the XML file stream:\n{str(e)}")
|
|
|
|
|
|
MAX_NAME_LENGTH = 7 # every component name in a Sedona app observes this
|
|
|
|
|
|
def allocate_component_id():
|
|
"""Lowest unused id, matching how this app's ids are dense with no gaps."""
|
|
used = set()
|
|
if app_state.xml_root_element is not None:
|
|
for element in app_state.xml_root_element.iter("comp"):
|
|
try:
|
|
used.add(int(element.get("id")))
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
candidate = 1
|
|
while candidate in used:
|
|
candidate += 1
|
|
return candidate
|
|
|
|
|
|
def unique_child_name(parent, base_name):
|
|
"""Trims to the 7-character limit and disambiguates against existing siblings."""
|
|
existing = {child.get("name") for child in parent if child.tag == "comp"}
|
|
|
|
candidate = base_name[:MAX_NAME_LENGTH]
|
|
if candidate and candidate not in existing:
|
|
return candidate
|
|
|
|
for counter in range(1, 10000):
|
|
suffix = str(counter)
|
|
stem = base_name[:max(1, MAX_NAME_LENGTH - len(suffix))]
|
|
candidate = f"{stem}{suffix}"[:MAX_NAME_LENGTH]
|
|
if candidate not in existing:
|
|
return candidate
|
|
|
|
return base_name[:MAX_NAME_LENGTH]
|
|
|
|
|
|
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:
|
|
return None
|
|
if app_state.current_sheet_parent is not None:
|
|
return app_state.current_sheet_parent
|
|
|
|
app_element = app_state.xml_root_element.find("app")
|
|
return app_element if app_element is not None else app_state.xml_root_element
|
|
|
|
|
|
def note_kit_used(kit_name):
|
|
"""Records that a placed component needs a kit the file's <schema> does not declare.
|
|
|
|
Nothing is written to the DOM here. The <schema> block is reconciled when the file is
|
|
saved, which is the one moment that can report the complete kit list the file will
|
|
carry rather than interrupting every placement with a partial one.
|
|
|
|
Returns True when this call is what put the kit on the pending list.
|
|
"""
|
|
if app_state.xml_root_element is None:
|
|
return False
|
|
if kit_name in app_state.active_schema_kits:
|
|
return False
|
|
if kit_name in app_state.pending_schema_kits:
|
|
return False
|
|
|
|
app_state.pending_schema_kits[kit_name] = palette.resolve_checksum(kit_name)
|
|
return True
|
|
|
|
|
|
def used_kit_names():
|
|
"""Every kit the app's components need, taken from the type of each <comp>."""
|
|
names = set()
|
|
if app_state.xml_root_element is None:
|
|
return names
|
|
|
|
for comp in app_state.xml_root_element.iter("comp"):
|
|
type_str = comp.get("type") or ""
|
|
if "::" in type_str:
|
|
names.add(type_str.split("::", 1)[0])
|
|
return names
|
|
|
|
|
|
def declare_used_kits():
|
|
"""Adds a <kit> entry for every used kit the <schema> block does not list.
|
|
|
|
The DOM is the authority on what is used, not the pending list, so a placement that
|
|
was later undone cannot leave a kit declared for nothing. Pending entries only supply
|
|
the checksum of the manifest the palette resolved when the component was placed.
|
|
|
|
Entries already in the file are left as they are — a missing checksum there is a
|
|
deliberate tolerance on load, not something to backfill.
|
|
|
|
Returns (added elements in the order written, whitespace to put back if the caller
|
|
rolls the insertion out).
|
|
"""
|
|
if app_state.xml_root_element is None:
|
|
return [], []
|
|
|
|
schema_elem = app_state.xml_root_element.find("schema")
|
|
if schema_elem is None:
|
|
return [], []
|
|
|
|
declared = {kit.get("name") for kit in schema_elem.findall("kit")}
|
|
|
|
added = []
|
|
tail_restores = []
|
|
for name in sorted(used_kit_names() - declared):
|
|
kit_element = ET.Element("kit")
|
|
kit_element.set("name", name)
|
|
|
|
checksum = app_state.pending_schema_kits.get(name) or palette.resolve_checksum(name)
|
|
if checksum:
|
|
kit_element.set("checksum", checksum)
|
|
|
|
# Added after the last existing kit. The block is not alphabetical — sys comes
|
|
# first by convention — so appending is safer than guessing a sort position.
|
|
insert_at = len(list(schema_elem))
|
|
for index, existing in enumerate(list(schema_elem)):
|
|
if existing.tag == "kit":
|
|
insert_at = index + 1
|
|
schema_elem.insert(insert_at, kit_element)
|
|
restore = indent_kit_entry(schema_elem, kit_element)
|
|
if restore is not None:
|
|
tail_restores.append(restore)
|
|
|
|
app_state.active_schema_kits[name] = checksum
|
|
added.append(kit_element)
|
|
|
|
return added, tail_restores
|
|
|
|
|
|
def indent_kit_entry(schema_elem, kit_element):
|
|
"""Gives a freshly inserted <kit> the surrounding block's line break and indent.
|
|
|
|
ElementTree keeps whitespace in each element's ``tail``, so an inserted element lands
|
|
flush against its neighbour unless the tails are handed on: the new entry takes over
|
|
the tail that used to close the block, and the entry before it takes the tail that
|
|
separates two kits.
|
|
|
|
Handing a tail on means writing to a neighbour, so the pair needed to put that
|
|
neighbour back is returned for a caller that may have to undo the insertion. It is
|
|
(element, tail) for a block that already held kits, and None for one that did not.
|
|
"""
|
|
kits = [child for child in schema_elem if child.tag == "kit" and child is not kit_element]
|
|
|
|
if not kits:
|
|
# A blank block keeps whatever whitespace it had as the new entry's closer, so an
|
|
# all-on-one-line <schema></schema> still gains a line break rather than losing one.
|
|
existing_text = schema_elem.text or ""
|
|
kit_element.tail = existing_text if existing_text.strip() == "" and existing_text else "\n"
|
|
schema_elem.text = "\n "
|
|
return None
|
|
|
|
previous = kits[-1]
|
|
restore = (previous, previous.tail)
|
|
kit_element.tail = previous.tail
|
|
previous.tail = kits[-2].tail if len(kits) >= 2 else "\n "
|
|
return restore
|
|
|
|
|
|
def format_kit_row(marker, kit_name, checksum):
|
|
"""One line of the save-time kit report: name, version and checksum.
|
|
|
|
Version comes from the .kit filenames in Sedona home rather than from the file, which
|
|
records only a checksum; '(unknown)' means this machine holds no .kit for that build.
|
|
"""
|
|
version = palette.version_for_checksum(kit_name, checksum) or "(unknown)"
|
|
return f" {marker} {kit_name} version {version} checksum {checksum or '(none)'}"
|
|
|
|
|
|
def describe_schema_kits(added_elements):
|
|
"""The save-time report: the kits just added, then every kit the file will declare."""
|
|
schema_elem = app_state.xml_root_element.find("schema")
|
|
added_names = [kit.get("name") for kit in added_elements]
|
|
|
|
kit_word = "kit is" if len(added_names) == 1 else "kits are"
|
|
lines = [
|
|
f"The following {kit_word} used by a component but not declared in this app's "
|
|
"schema, and has been added to it:",
|
|
"",
|
|
]
|
|
lines.extend(
|
|
format_kit_row("+", name, app_state.active_schema_kits.get(name))
|
|
for name in added_names
|
|
)
|
|
lines += [
|
|
"",
|
|
"The device's scode must also contain "
|
|
+ ", ".join(added_names)
|
|
+ " or the application will not run.",
|
|
"",
|
|
"Schema kits after this save:",
|
|
"",
|
|
]
|
|
|
|
for kit in schema_elem.findall("kit"):
|
|
name = kit.get("name")
|
|
if not name:
|
|
continue
|
|
marker = "+" if name in added_names else "•"
|
|
lines.append(format_kit_row(marker, name, kit.get("checksum")))
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def reconcile_schema_kits_before_save():
|
|
"""Brings <schema> in step with the components, warning before anything is written.
|
|
|
|
Returns False when the user backs out, in which case the added entries are taken back
|
|
out and the save is abandoned.
|
|
"""
|
|
added, tail_restores = declare_used_kits()
|
|
if not added:
|
|
return True
|
|
|
|
proceed = messagebox.askokcancel(
|
|
"Kits added to schema",
|
|
describe_schema_kits(added) + "\n\nSave with these kits?",
|
|
icon=messagebox.WARNING
|
|
)
|
|
|
|
if not proceed:
|
|
schema_elem = app_state.xml_root_element.find("schema")
|
|
for kit_element in added:
|
|
if schema_elem is not None and kit_element in list(schema_elem):
|
|
schema_elem.remove(kit_element)
|
|
app_state.active_schema_kits.pop(kit_element.get("name"), None)
|
|
# Oldest first, so the entry that ends the block gets the last word on its tail.
|
|
for element, tail in reversed(tail_restores):
|
|
element.tail = tail
|
|
return False
|
|
|
|
# Declared in the file now, so nothing about them is pending any more.
|
|
for kit_element in added:
|
|
app_state.pending_schema_kits.pop(kit_element.get("name"), None)
|
|
return True
|
|
|
|
|
|
def add_component(kit_name, type_name, grid_x, grid_y):
|
|
"""Places a new component of kit::type at a grid position. Returns the element."""
|
|
parent = current_sheet_container()
|
|
if parent is None:
|
|
messagebox.showwarning("Add Component", "Open a SAX file first.")
|
|
return None
|
|
|
|
if note_kit_used(kit_name):
|
|
# An undeclared kit brings types the registry has never resolved, and the box
|
|
# cannot be painted until it has them.
|
|
try:
|
|
schema.load_schema_kits_and_manifests()
|
|
except schema.MissingManifestError as missing:
|
|
messagebox.showerror(
|
|
"Kit manifest missing",
|
|
f"'{kit_name}' was not added.\n\n"
|
|
+ schema.describe_missing_manifests(missing)
|
|
)
|
|
# The kit comes back off the pending list and the registry is rebuilt from
|
|
# what was resolvable a moment ago, so only this placement is abandoned.
|
|
app_state.pending_schema_kits.pop(kit_name, None)
|
|
try:
|
|
schema.load_schema_kits_and_manifests()
|
|
except schema.MissingManifestError:
|
|
reset_to_startup_state()
|
|
return None
|
|
|
|
comp_id = allocate_component_id()
|
|
comp_name = unique_child_name(parent, type_name)
|
|
|
|
element = ET.Element("comp")
|
|
element.set("name", comp_name)
|
|
element.set("id", str(comp_id))
|
|
element.set("type", f"{kit_name}::{type_name}")
|
|
|
|
meta_prop = ET.SubElement(element, "prop")
|
|
meta_prop.set("name", "meta")
|
|
update_meta_property(element, None, grid_x, grid_y)
|
|
|
|
index = len(list(parent))
|
|
parent.append(element)
|
|
|
|
undo.push(undo.AddComponentCommand(
|
|
parent, index, element,
|
|
label=f"add {comp_name}"
|
|
))
|
|
|
|
app_state.mark_dirty()
|
|
refresh_treeview_from_dom(app_state.xml_root_element)
|
|
wiresheet.render_current_sheet()
|
|
|
|
# The schema is only reconciled on save, so the placement says so here rather than
|
|
# letting the save dialog be the first the user hears of it.
|
|
if kit_name in app_state.pending_schema_kits:
|
|
app_state.set_status(
|
|
f"{comp_name} ({kit_name}::{type_name}) added — '{kit_name}' is not in this "
|
|
"app's schema and will be declared when you save.",
|
|
warn=True
|
|
)
|
|
else:
|
|
app_state.set_status(f"{comp_name} ({kit_name}::{type_name}) added.")
|
|
|
|
return element
|
|
|
|
|
|
def remove_link(link_element):
|
|
"""Removes a <link> element from the <links> section and repaints."""
|
|
if app_state.xml_root_element is None:
|
|
return
|
|
|
|
links_elem = app_state.xml_root_element.find("links")
|
|
if links_elem is None:
|
|
return
|
|
|
|
try:
|
|
index = list(links_elem).index(link_element)
|
|
links_elem.remove(link_element)
|
|
except ValueError:
|
|
return
|
|
|
|
from_ref = link_element.get("from", "")
|
|
to_ref = link_element.get("to", "")
|
|
from_slot = from_ref.rsplit(".", 1)[-1] if "." in from_ref else from_ref
|
|
to_slot = to_ref.rsplit(".", 1)[-1] if "." in to_ref else to_ref
|
|
undo.push(undo.DeleteLinkCommand(
|
|
links_elem, index, link_element,
|
|
label=f"delete link {from_slot}→{to_slot}"
|
|
))
|
|
|
|
app_state.selected_link_tag = None
|
|
app_state.mark_dirty()
|
|
wiresheet.render_current_sheet()
|
|
|
|
|
|
def add_link(from_path_slot, to_path_slot):
|
|
"""Adds a <link> element to the <links> section of the SAX DOM."""
|
|
if app_state.xml_root_element is None:
|
|
return
|
|
|
|
links_elem = app_state.xml_root_element.find("links")
|
|
if links_elem is None:
|
|
links_elem = ET.SubElement(app_state.xml_root_element, "links")
|
|
|
|
link_elem = ET.SubElement(links_elem, "link")
|
|
link_elem.set("from", from_path_slot)
|
|
link_elem.set("to", to_path_slot)
|
|
|
|
from_slot = from_path_slot.rsplit(".", 1)[-1] if "." in from_path_slot else from_path_slot
|
|
to_slot = to_path_slot.rsplit(".", 1)[-1] if "." in to_path_slot else to_path_slot
|
|
undo.push(undo.AddLinkCommand(
|
|
links_elem, link_elem,
|
|
label=f"add link {from_slot}→{to_slot}"
|
|
))
|
|
|
|
app_state.mark_dirty()
|
|
wiresheet.render_current_sheet()
|
|
|
|
|
|
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")
|
|
comp_id = comp_element.get("id", "??")
|
|
|
|
descendant_comps = [c for c in comp_element.iter() if c.tag == "comp" and c != comp_element]
|
|
descendant_count = len(descendant_comps)
|
|
|
|
warning_msg = f"Are you sure you want to remove component '{comp_name}' [id: {comp_id}]?"
|
|
if descendant_count > 0:
|
|
warning_msg += f"\n\n⚠️ WARNING: This component contains {descendant_count} nested sub-component(s) under it. Deleting it will purge ALL of them recursively."
|
|
|
|
confirm = messagebox.askyesno("Confirm Cascading Delete", warning_msg)
|
|
if not confirm:
|
|
return False
|
|
|
|
comp_path = get_component_path(comp_element, app_state.xml_root_element)
|
|
|
|
all_elements_to_prune = [comp_element] + descendant_comps
|
|
for elem in all_elements_to_prune:
|
|
if elem in app_state.active_components_list:
|
|
app_state.active_components_list.remove(elem)
|
|
|
|
# Positions are recorded before anything is removed, so the delete can be reversed.
|
|
link_records = []
|
|
if app_state.xml_root_element is not None:
|
|
links_elem = app_state.xml_root_element.find("links")
|
|
if links_elem is not None:
|
|
for index, link in enumerate(list(links_elem)):
|
|
if link.tag != "link":
|
|
continue
|
|
l_from = link.get("from", "")
|
|
l_to = link.get("to", "")
|
|
|
|
from_match = l_from.startswith(comp_path + ".") or l_from.startswith(comp_path + "/")
|
|
to_match = l_to.startswith(comp_path + ".") or l_to.startswith(comp_path + "/")
|
|
|
|
if from_match or to_match:
|
|
link_records.append((links_elem, index, link))
|
|
|
|
for links_parent, _, link in link_records:
|
|
links_parent.remove(link)
|
|
|
|
xml_parent = None
|
|
comp_index = 0
|
|
if app_state.xml_root_element is not None:
|
|
xml_parent = find_parent_element(app_state.xml_root_element, comp_element)
|
|
if xml_parent is not None:
|
|
comp_index = list(xml_parent).index(comp_element)
|
|
xml_parent.remove(comp_element)
|
|
|
|
if xml_parent is not None:
|
|
undo.push(undo.DeleteComponentCommand(
|
|
xml_parent, comp_index, comp_element, link_records,
|
|
label=f"delete {comp_name}"
|
|
))
|
|
|
|
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
|
|
|
|
if target_canvas_tag:
|
|
app_state.canvas.delete(target_canvas_tag)
|
|
|
|
app_state.mark_dirty()
|
|
|
|
refresh_treeview_from_dom(app_state.xml_root_element)
|
|
wiresheet.render_components(app_state.active_components_list)
|
|
return True
|