The tree and palette column took a share of every extra pixel the window gained. It is now pinned to a width set in Preferences, defaulting to 230 px, so widening the window only ever feeds the wiresheet. That fixed width brought two existing weaknesses into view: - Long paths and type names had nowhere to go. Both lists now size their tree column to the widest visible row - ttk derives the horizontal scroll range from the column width, so a column left stretching can never scroll - and carry vertical and horizontal scrollbars that leave the grid when they have nothing to scroll. - A short window squeezed the navigation tree away entirely. Both lists asked for ten rows, so the palette requested the most while the tree, carrying the larger weight, gave up its height first. The requested rows are now 6 and 5, a 300 px minimum window height keeps the shortfall shallow, and clamp_left_pane_split holds a dragged sash between the two floors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
909 lines
32 KiB
Python
909 lines
32 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,
|
|
restore_children,
|
|
snapshot_children,
|
|
update_meta_property,
|
|
)
|
|
|
|
|
|
_tree_filled = None
|
|
|
|
|
|
def set_tree_filled_callback(callback):
|
|
"""Registers what should run once the navigation tree has been rebuilt."""
|
|
global _tree_filled
|
|
_tree_filled = callback
|
|
|
|
|
|
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:
|
|
if _tree_filled is not None:
|
|
_tree_filled()
|
|
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)
|
|
|
|
if _tree_filled is not None:
|
|
_tree_filled()
|
|
|
|
|
|
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 required_kit_names():
|
|
"""Every kit this app cannot run without: those its components name, plus their
|
|
dependencies, and the dependencies of those, all the way down.
|
|
|
|
A kit's manifest records the kits it was built against, and the device's scode needs
|
|
those just as much as the one a component names — so as far as the schema block is
|
|
concerned, being depended on by a used kit is the same as being used.
|
|
"""
|
|
unvisited = list(used_kit_names())
|
|
required = set(unvisited)
|
|
|
|
while unvisited:
|
|
kit_name = unvisited.pop()
|
|
checksum = (app_state.active_schema_kits.get(kit_name)
|
|
or app_state.pending_schema_kits.get(kit_name))
|
|
for dependency in schema.read_kit_dependencies(kit_name, checksum):
|
|
if dependency not in required:
|
|
required.add(dependency)
|
|
unvisited.append(dependency)
|
|
|
|
return required
|
|
|
|
|
|
def declare_used_kits():
|
|
"""Adds a <kit> entry for every kit the app needs that 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.
|
|
|
|
A kit that a used kit depends on counts as needed too, so placing one component can
|
|
bring in more than one entry.
|
|
|
|
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 the <kit> elements that were added, in the order they were written.
|
|
"""
|
|
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 = []
|
|
for name in sorted(required_kit_names() - declared):
|
|
checksum = app_state.pending_schema_kits.get(name) or palette.resolve_checksum(name)
|
|
kit_element = insert_kit_element(schema_elem, name, checksum)
|
|
|
|
app_state.active_schema_kits[name] = checksum
|
|
added.append(kit_element)
|
|
|
|
return added
|
|
|
|
|
|
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.
|
|
|
|
Callers that may have to undo the insertion snapshot the whole block instead of
|
|
tracking these tails, so nothing is reported back.
|
|
"""
|
|
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
|
|
|
|
previous = kits[-1]
|
|
kit_element.tail = previous.tail
|
|
previous.tail = kits[-2].tail if len(kits) >= 2 else "\n "
|
|
|
|
|
|
def insert_kit_element(schema_elem, kit_name, checksum):
|
|
"""Builds a <kit> entry, places it after the last existing one, and returns it.
|
|
|
|
The block is not alphabetical — sys comes first by convention — so appending is safer
|
|
than guessing a sort position.
|
|
"""
|
|
kit_element = ET.Element("kit")
|
|
kit_element.set("name", kit_name)
|
|
if checksum:
|
|
kit_element.set("checksum", checksum)
|
|
|
|
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)
|
|
indent_kit_entry(schema_elem, kit_element)
|
|
return kit_element
|
|
|
|
|
|
def remove_kit_element(schema_elem, kit_element):
|
|
"""Takes a <kit> entry out of the block, leaving its line breaks tidy.
|
|
|
|
Whitespace lives in the tail of the element before it, so the entry that closes the
|
|
block is the one carrying the break that puts </schema> on its own line: when that is
|
|
the entry going away, its tail passes back to whatever now ends the block.
|
|
"""
|
|
kits = [child for child in schema_elem if child.tag == "kit"]
|
|
|
|
if kits and kits[-1] is kit_element:
|
|
if len(kits) >= 2:
|
|
kits[-2].tail = kit_element.tail
|
|
else:
|
|
schema_elem.text = kit_element.tail
|
|
|
|
schema_elem.remove(kit_element)
|
|
|
|
|
|
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} needed by this app — by a component, or by a kit one "
|
|
"of them depends on — but not declared in its 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 schema_kit_checksums():
|
|
"""The file's declared kits as an ordered list of (name, checksum), checksum may be None."""
|
|
if app_state.xml_root_element is None:
|
|
return []
|
|
|
|
schema_elem = app_state.xml_root_element.find("schema")
|
|
if schema_elem is None:
|
|
return []
|
|
|
|
return [
|
|
(kit.get("name"), kit.get("checksum"))
|
|
for kit in schema_elem.findall("kit")
|
|
if kit.get("name")
|
|
]
|
|
|
|
|
|
def unresolved_component_types():
|
|
"""Types placed in the app that the current registry cannot describe, sorted.
|
|
|
|
Repinning a kit to another build can drop a type the app still uses, which would leave
|
|
those components on the sheet with no slots. Checked after every schema change so the
|
|
change can be refused rather than silently emptying a box.
|
|
"""
|
|
if app_state.xml_root_element is None:
|
|
return []
|
|
|
|
missing = set()
|
|
for comp in app_state.xml_root_element.iter("comp"):
|
|
type_str = comp.get("type")
|
|
if type_str and type_str not in app_state.final_components_registry:
|
|
missing.add(type_str)
|
|
return sorted(missing)
|
|
|
|
|
|
def apply_kit_selections(selections, removals=(), label="update kits"):
|
|
"""Repins, adds and drops <kit> entries in one go.
|
|
|
|
``selections`` is an iterable of (kit name, checksum) the block should end up
|
|
declaring; ``removals`` is kit names it should stop declaring. Entries that already
|
|
match are skipped, so the return value describes only what really moved.
|
|
|
|
The whole set is applied, then the registry is recompiled once and checked: if a
|
|
manifest is missing, or a type the app uses no longer resolves, the block goes back to
|
|
how it was and the reason is returned instead. Nothing is half-applied.
|
|
|
|
Returns (changes, error) where changes is a list of (kit name, old checksum, new
|
|
checksum) — a new checksum of None meaning the entry was dropped — and error is None
|
|
on success.
|
|
"""
|
|
if app_state.xml_root_element is None:
|
|
return [], "No file is open."
|
|
|
|
schema_elem = app_state.xml_root_element.find("schema")
|
|
if schema_elem is None:
|
|
return [], "This file has no <schema> block to record kits in."
|
|
|
|
before = snapshot_children(schema_elem)
|
|
# Whatever the file could not describe already is not this change's doing, and holding
|
|
# it against the change would lock the kit dialog on the very file that needs it.
|
|
before_unresolved = set(unresolved_component_types())
|
|
|
|
# A kit some component needs, directly or through another kit's dependencies, cannot
|
|
# be dropped whatever the caller asks: the app would not run without it.
|
|
protected = sorted(set(removals) & required_kit_names())
|
|
if protected:
|
|
return [], (
|
|
"No kit was changed.\n\nThese kits cannot be removed, because this app "
|
|
"needs them:\n\n"
|
|
+ "\n".join(f" • {name}" for name in protected)
|
|
)
|
|
|
|
changes = []
|
|
for kit_name in removals:
|
|
for kit in schema_elem.findall("kit"):
|
|
if kit.get("name") == kit_name:
|
|
changes.append((kit_name, kit.get("checksum"), None))
|
|
remove_kit_element(schema_elem, kit)
|
|
app_state.active_schema_kits.pop(kit_name, None)
|
|
break
|
|
|
|
for kit_name, checksum in selections:
|
|
if not kit_name or not checksum:
|
|
continue
|
|
|
|
existing = None
|
|
for kit in schema_elem.findall("kit"):
|
|
if kit.get("name") == kit_name:
|
|
existing = kit
|
|
break
|
|
|
|
if existing is None:
|
|
insert_kit_element(schema_elem, kit_name, checksum)
|
|
changes.append((kit_name, None, checksum))
|
|
elif existing.get("checksum") != checksum:
|
|
old_checksum = existing.get("checksum")
|
|
existing.set("checksum", checksum)
|
|
changes.append((kit_name, old_checksum, checksum))
|
|
|
|
if not changes:
|
|
return [], None
|
|
|
|
def abandon(reason):
|
|
restore_children(schema_elem, before)
|
|
undo.reload_schema_registry()
|
|
return [], reason
|
|
|
|
# The palette resolves a kit through the checksum the schema pins, so its cached
|
|
# manifests describe the builds that were pinned a moment ago.
|
|
palette.invalidate()
|
|
try:
|
|
schema.load_schema_kits_and_manifests()
|
|
except schema.MissingManifestError as missing:
|
|
return abandon(
|
|
"No kit was changed.\n\n" + schema.describe_missing_manifests(missing)
|
|
)
|
|
|
|
unresolved = sorted(set(unresolved_component_types()) - before_unresolved)
|
|
if unresolved:
|
|
type_list = "\n".join(f" • {name}" for name in unresolved)
|
|
return abandon(
|
|
"No kit was changed.\n\nIllegal components:\n\n"
|
|
f"{type_list}\n\nFor correcting:\n\n- Delete those components first\nor\n"
|
|
"- Add related kits."
|
|
)
|
|
|
|
for kit_name, _, checksum in changes:
|
|
app_state.pending_schema_kits.pop(kit_name, None)
|
|
|
|
undo.push(undo.SchemaBlockCommand(
|
|
schema_elem, before, snapshot_children(schema_elem), label=label
|
|
))
|
|
|
|
app_state.mark_dirty()
|
|
refresh_treeview_from_dom(app_state.xml_root_element)
|
|
wiresheet.render_current_sheet()
|
|
|
|
return changes, None
|
|
|
|
|
|
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.
|
|
"""
|
|
schema_elem = app_state.xml_root_element.find("schema")
|
|
before = snapshot_children(schema_elem) if schema_elem is not None else None
|
|
|
|
added = 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:
|
|
restore_children(schema_elem, before)
|
|
for kit_element in added:
|
|
app_state.active_schema_kits.pop(kit_element.get("name"), None)
|
|
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
|