Kit manager menu oncesi

This commit is contained in:
2026-08-21 09:12:46 +03:00
parent 47bfaf04e0
commit b286b208da
8 changed files with 2501 additions and 970 deletions
+27 -1
View File
@@ -23,6 +23,12 @@ MAX_ALLOWED_BOX_WIDTH = 400
window = None window = None
canvas = None canvas = None
tree = None tree = None
status_label = None # the window's bottom status bar
# Status bar tones. A hint that something needs attention later is worth a colour, so a
# placement that leaves the schema out of step is not read as a plain confirmation.
STATUS_INFO_COLOUR = "#333333"
STATUS_WARN_COLOUR = "#a04b00"
# Maps to hold tracking states # Maps to hold tracking states
tree_element_map = {} # Treeview item ID -> ET.Element tree_element_map = {} # Treeview item ID -> ET.Element
@@ -47,7 +53,12 @@ drag_data = {"x": 0, "y": 0, "tag": None}
# Global component slot registries compiled dynamically per file load # Global component slot registries compiled dynamically per file load
final_components_registry = {} final_components_registry = {}
active_schema_kits = {} active_schema_kits = {} # kit name -> checksum, as the open file's <schema> declares them
# Kits a placed component needs that the file's <schema> does not declare yet, as
# kit name -> checksum resolved from Sedona home. Filled in when a palette component is
# placed and read back when saving, which is where the <schema> block is reconciled.
pending_schema_kits = {}
def load_preferences(): def load_preferences():
@@ -102,6 +113,21 @@ def base_window_title():
return f"Sedona SAX Tree View Viewer & Program Editor [v{VERSION}]" return f"Sedona SAX Tree View Viewer & Program Editor [v{VERSION}]"
def set_status(message, warn=False):
"""Posts a one-line note to the status bar. A no-op before the UI exists."""
if status_label is None:
return
status_label.config(
text=message,
foreground=STATUS_WARN_COLOUR if warn else STATUS_INFO_COLOUR
)
def clear_status():
"""Empties the status bar, for when the note no longer describes anything on screen."""
set_status("")
def mark_dirty(): def mark_dirty():
"""Single funnel for 'the document changed', including the unsaved-state tint.""" """Single funnel for 'the document changed', including the unsaved-state tint."""
global is_dirty global is_dirty
+6
View File
@@ -782,6 +782,12 @@ menu_bar.add_cascade(label="Preferences", menu=menu_prefs)
window.config(menu=menu_bar) window.config(menu=menu_bar)
# Packed before the paned window: pack hands out the cavity in order, and the paned
# window expands into whatever is left, so a status bar added afterwards would get no room.
status_label = ttk.Label(window, text="", anchor="w", foreground=app_state.STATUS_INFO_COLOUR)
status_label.pack(side=tk.BOTTOM, fill=tk.X, padx=8, pady=(0, 4))
app_state.status_label = status_label
paned_window = ttk.PanedWindow(window, orient=tk.HORIZONTAL) paned_window = ttk.PanedWindow(window, orient=tk.HORIZONTAL)
paned_window.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) paned_window.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
+11
View File
@@ -140,6 +140,17 @@ def resolve_checksum(kit_name):
return filename[len(kit_name) + 1:-4] return filename[len(kit_name) + 1:-4]
def version_for_checksum(kit_name, checksum):
"""The version a kit's checksum stands for, read from the .kit filenames.
Returns "" when Sedona home has no .kit for that checksum, which is the honest
answer for a file pinned to a build this machine does not hold.
"""
if not checksum:
return ""
return kit_versions(kit_name).get(checksum, "")
def _find_type(qualified_name): def _find_type(qualified_name):
if "::" not in qualified_name: if "::" not in qualified_name:
return None return None
+202 -46
View File
@@ -76,9 +76,11 @@ def reset_to_startup_state():
app_state.canvas_link_map.clear() app_state.canvas_link_map.clear()
app_state.final_components_registry.clear() app_state.final_components_registry.clear()
app_state.active_schema_kits.clear() app_state.active_schema_kits.clear()
app_state.pending_schema_kits.clear()
undo.reset() undo.reset()
app_state.mark_clean() app_state.mark_clean()
app_state.clear_status()
if app_state.tree is not None: if app_state.tree is not None:
for item in app_state.tree.get_children(): for item in app_state.tree.get_children():
@@ -110,6 +112,8 @@ def open_file():
try: try:
app_state.current_file_path = file_path app_state.current_file_path = file_path
app_state.active_components_list.clear() app_state.active_components_list.clear()
app_state.pending_schema_kits.clear()
app_state.clear_status()
app_state.selected_canvas_tag = None app_state.selected_canvas_tag = None
app_state.current_sheet_parent = None app_state.current_sheet_parent = None
undo.reset() undo.reset()
@@ -204,6 +208,9 @@ def execute_direct_overwrite_save():
try: try:
sync_live_metadata_to_dom() sync_live_metadata_to_dom()
if not reconcile_schema_kits_before_save():
return
with open(app_state.current_file_path, "wb") as f: with open(app_state.current_file_path, "wb") as f:
f.write(b'<?xml version="1.0" encoding="UTF-8"?>\n') 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) ET.ElementTree(app_state.xml_root_element).write(f, encoding="utf-8", xml_declaration=False)
@@ -211,6 +218,7 @@ def execute_direct_overwrite_save():
app_state.mark_clean() app_state.mark_clean()
undo.mark_saved() undo.mark_saved()
refresh_treeview_from_dom(app_state.xml_root_element) 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)}") messagebox.showinfo("Success", f"Direct changes saved onto:\n{os.path.basename(app_state.current_file_path)}")
except Exception as e: except Exception as e:
@@ -250,6 +258,9 @@ def save_file_as():
else: else:
target_path = app_state.current_file_path target_path = app_state.current_file_path
if not reconcile_schema_kits_before_save():
return
with open(target_path, "wb") as f: with open(target_path, "wb") as f:
f.write(b'<?xml version="1.0" encoding="UTF-8"?>\n') 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) ET.ElementTree(app_state.xml_root_element).write(f, encoding="utf-8", xml_declaration=False)
@@ -261,6 +272,7 @@ def save_file_as():
folder_name = os.path.basename(os.path.dirname(target_path)) folder_name = os.path.basename(os.path.dirname(target_path))
file_name = os.path.basename(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}") messagebox.showinfo("Success", f"File Saved\nFolder: {folder_name}\nFile: {file_name}")
@@ -316,44 +328,193 @@ def current_sheet_container():
return app_element if app_element is not None else app_state.xml_root_element return app_element if app_element is not None else app_state.xml_root_element
def ensure_kit_declared(kit_name): def note_kit_used(kit_name):
"""Adds a <kit> entry to <schema> when placing a component from an undeclared kit. """Records that a placed component needs a kit the file's <schema> does not declare.
The new entry carries the checksum of the manifest the palette resolved, so the file Nothing is written to the DOM here. The <schema> block is reconciled when the file is
records exactly which kit build the component was authored against. Entries already in saved, which is the one moment that can report the complete kit list the file will
the file are left as they are — a missing checksum there is a deliberate tolerance on carry rather than interrupting every placement with a partial one.
load, not something to backfill.
Returns the element that was added, or None when the kit was already declared. A kit Returns True when this call is what put the kit on the pending list.
absent from the schema also has to exist in the device's scode, so the caller warns.
""" """
if app_state.xml_root_element is None: if app_state.xml_root_element is None:
return 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") schema_elem = app_state.xml_root_element.find("schema")
if schema_elem is None: 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 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"): for kit in schema_elem.findall("kit"):
if kit.get("name") == kit_name: name = kit.get("name")
return None if not name:
continue
marker = "+" if name in added_names else "•"
lines.append(format_kit_row(marker, name, kit.get("checksum")))
kit_element = ET.Element("kit") return "\n".join(lines)
kit_element.set("name", kit_name)
checksum = palette.resolve_checksum(kit_name)
if checksum:
kit_element.set("checksum", checksum)
# Added after the last existing kit. The block is not alphabetical — sys comes first def reconcile_schema_kits_before_save():
# by convention — so appending is safer than guessing a sort position. """Brings <schema> in step with the components, warning before anything is written.
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)
return kit_element 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): def add_component(kit_name, type_name, grid_x, grid_y):
@@ -363,21 +524,9 @@ def add_component(kit_name, type_name, grid_x, grid_y):
messagebox.showwarning("Add Component", "Open a SAX file first.") messagebox.showwarning("Add Component", "Open a SAX file first.")
return None return None
kit_added = ensure_kit_declared(kit_name) if note_kit_used(kit_name):
if kit_added is not None: # An undeclared kit brings types the registry has never resolved, and the box
proceed = messagebox.askokcancel( # cannot be painted until it has them.
"Kit not in schema",
f"'{kit_name}' is not declared in this app's schema.\n\n"
f"It will be added, but the device's scode must also contain {kit_name} "
"or the application will not run.\n\nContinue?"
)
if not proceed:
schema_elem = app_state.xml_root_element.find("schema")
if schema_elem is not None:
schema_elem.remove(kit_added)
return None
# New kit means new types to resolve before the box can be painted.
try: try:
schema.load_schema_kits_and_manifests() schema.load_schema_kits_and_manifests()
except schema.MissingManifestError as missing: except schema.MissingManifestError as missing:
@@ -386,11 +535,9 @@ def add_component(kit_name, type_name, grid_x, grid_y):
f"'{kit_name}' was not added.\n\n" f"'{kit_name}' was not added.\n\n"
+ schema.describe_missing_manifests(missing) + schema.describe_missing_manifests(missing)
) )
# The kit entry goes back out and the registry is rebuilt from the kits that # The kit comes back off the pending list and the registry is rebuilt from
# were resolvable a moment ago, so only this placement is abandoned. # what was resolvable a moment ago, so only this placement is abandoned.
schema_elem = app_state.xml_root_element.find("schema") app_state.pending_schema_kits.pop(kit_name, None)
if schema_elem is not None:
schema_elem.remove(kit_added)
try: try:
schema.load_schema_kits_and_manifests() schema.load_schema_kits_and_manifests()
except schema.MissingManifestError: except schema.MissingManifestError:
@@ -414,8 +561,6 @@ def add_component(kit_name, type_name, grid_x, grid_y):
undo.push(undo.AddComponentCommand( undo.push(undo.AddComponentCommand(
parent, index, element, parent, index, element,
schema_parent=app_state.xml_root_element.find("schema"),
kit_element=kit_added,
label=f"add {comp_name}" label=f"add {comp_name}"
)) ))
@@ -423,6 +568,17 @@ def add_component(kit_name, type_name, grid_x, grid_y):
refresh_treeview_from_dom(app_state.xml_root_element) refresh_treeview_from_dom(app_state.xml_root_element)
wiresheet.render_current_sheet() 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 return element
+41 -20
View File
@@ -128,8 +128,39 @@ def compile_schema_registry(manifest_paths):
app_state.final_components_registry[type_name] = resolved_type_structure app_state.final_components_registry[type_name] = resolved_type_structure
def find_manifest_path(kit_name, checksum):
"""The manifest file describing a kit, or None when Sedona home holds none.
A declared checksum is honoured when its file exists; otherwise the last manifest by
filename is taken, the tolerance that lets a file with no checksums still open.
"""
manifest_dir = os.path.join(app_state.SEDONA_HOME, "manifests", kit_name)
if checksum:
target_file = os.path.join(manifest_dir, f"{kit_name}-{checksum}.xml")
if os.path.exists(target_file):
return target_file
if os.path.exists(manifest_dir):
files = [
f for f in os.listdir(manifest_dir)
if f.startswith(f"{kit_name}-") and f.endswith(".xml")
]
if files:
files.sort()
return os.path.join(manifest_dir, files[-1])
return None
def load_schema_kits_and_manifests(): def load_schema_kits_and_manifests():
"""Scans the schema block of the SAX file and maps/loads corresponding manifest definitions.""" """Loads the manifests for the file's <schema> kits, plus any kit still only pending.
A component placed from the palette records its kit in ``pending_schema_kits`` rather
than writing to <schema> straight away — that reconciliation happens on save. Its
types still have to resolve now, or the box could not be painted, so pending kits are
compiled into the registry alongside the declared ones.
"""
app_state.active_schema_kits.clear() app_state.active_schema_kits.clear()
if app_state.xml_root_element is None: if app_state.xml_root_element is None:
return return
@@ -138,29 +169,19 @@ def load_schema_kits_and_manifests():
if schema_elem is None: if schema_elem is None:
return return
manifest_paths = []
missing_kits = []
for kit in schema_elem.findall("kit"): for kit in schema_elem.findall("kit"):
name = kit.get("name") name = kit.get("name")
checksum = kit.get("checksum") if name:
if not name: app_state.active_schema_kits[name] = kit.get("checksum")
continue
app_state.active_schema_kits[name] = checksum
manifest_dir = os.path.join(app_state.SEDONA_HOME, "manifests", name) kits_to_load = dict(app_state.active_schema_kits)
manifest_path = None for name, checksum in app_state.pending_schema_kits.items():
kits_to_load.setdefault(name, checksum)
if checksum:
target_file = os.path.join(manifest_dir, f"{name}-{checksum}.xml")
if os.path.exists(target_file):
manifest_path = target_file
if not manifest_path and os.path.exists(manifest_dir):
files = [f for f in os.listdir(manifest_dir) if f.startswith(f"{name}-") and f.endswith(".xml")]
if files:
files.sort()
manifest_path = os.path.join(manifest_dir, files[-1])
manifest_paths = []
missing_kits = []
for name, checksum in kits_to_load.items():
manifest_path = find_manifest_path(name, checksum)
if manifest_path: if manifest_path:
manifest_paths.append(manifest_path) manifest_paths.append(manifest_path)
else: else:
+892 -893
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+7 -10
View File
@@ -99,27 +99,24 @@ class MetaMoveCommand:
class AddComponentCommand: class AddComponentCommand:
"""Placing a component, plus the <kit> entry that placing it may have required.""" """Placing a component.
def __init__(self, parent, index, element, schema_parent=None, kit_element=None, label="add"): Nothing here touches <schema>. The kit a placement needs is declared when the file is
saved, from the component types actually in the DOM, so undoing a placement takes its
kit out of that reckoning without this command having to unwind anything.
"""
def __init__(self, parent, index, element, label="add"):
self.parent = parent self.parent = parent
self.index = index self.index = index
self.element = element self.element = element
self.schema_parent = schema_parent
self.kit_element = kit_element
self.label = label self.label = label
def undo(self): def undo(self):
if self.element in list(self.parent): if self.element in list(self.parent):
self.parent.remove(self.element) self.parent.remove(self.element)
if self.kit_element is not None and self.schema_parent is not None:
if self.kit_element in list(self.schema_parent):
self.schema_parent.remove(self.kit_element)
def redo(self): def redo(self):
if self.kit_element is not None and self.schema_parent is not None:
if self.kit_element not in list(self.schema_parent):
self.schema_parent.append(self.kit_element)
if self.element not in list(self.parent): if self.element not in list(self.parent):
self.parent.insert(self.index, self.element) self.parent.insert(self.index, self.element)