Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9a6428200 | ||
|
|
01dadea610 |
+1
-1
@@ -110,7 +110,7 @@ def save_preferences():
|
||||
|
||||
def base_window_title():
|
||||
"""The window title with no file context, which handlers extend with their own suffix."""
|
||||
return f"Sedona SAX Tree View Viewer & Program Editor [v{VERSION}]"
|
||||
return f"Sedona SAX Offline Program Editor [v{VERSION}]"
|
||||
|
||||
|
||||
def set_status(message, warn=False):
|
||||
|
||||
@@ -6,6 +6,7 @@ import tkinter as tk
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
import app_state
|
||||
import manage_kits
|
||||
import palette
|
||||
import sax_file
|
||||
import schema
|
||||
@@ -768,6 +769,13 @@ menu_edit.add_separator()
|
||||
menu_edit.add_command(label="Delete Component", command=delete_selected_component, accelerator="Del")
|
||||
menu_bar.add_cascade(label="Edit", menu=menu_edit)
|
||||
|
||||
menu_kits = tk.Menu(menu_bar, tearoff=0)
|
||||
menu_kits.add_command(
|
||||
label="Manage Kits...",
|
||||
command=lambda: manage_kits.open_dialog(on_applied=refresh_palette_kits)
|
||||
)
|
||||
menu_bar.add_cascade(label="Kits", menu=menu_kits)
|
||||
|
||||
menu_layout = tk.Menu(menu_bar, tearoff=0)
|
||||
menu_layout.add_command(label="Tidy Layout", command=do_tidy_layout)
|
||||
menu_bar.add_cascade(label="Layout", menu=menu_layout)
|
||||
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
"""The Manage Kits dialog: what the app pins, what Sedona home offers, and repinning.
|
||||
|
||||
The table answers two questions per kit — which build the app is pinned to, and which
|
||||
builds exist — and the Action column is where a different build is chosen. Nothing is
|
||||
written to the file until one of the buttons is pressed, so the whole table can be set up
|
||||
first and reviewed as a batch.
|
||||
|
||||
Rows come in three groups, in this order: kits the app needs — named by a component or
|
||||
depended on by a kit that is — then kits the schema declares without needing, then
|
||||
everything else installed in Sedona home.
|
||||
|
||||
The tick in the first column means "the schema declares this kit". A kit the app needs is
|
||||
ticked and greyed, because removing it would stop the app running; one that is merely
|
||||
declared can be unticked, which turns its Action into REMOVE. An installed kit that is not
|
||||
declared starts unticked, and ticking it adds it at its newest build.
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
import app_state
|
||||
import palette
|
||||
import sax_file
|
||||
|
||||
NOT_DECLARED = "—" # em dash, for a column with nothing to report
|
||||
NO_CHECKSUM = "(no checksum)"
|
||||
REMOVE_ACTION = "REMOVE from app"
|
||||
|
||||
SECTION_TAG = "section"
|
||||
|
||||
CHECKBOX_SIZE = 13
|
||||
|
||||
# The tick, as filled 2x2 blocks: a short down-stroke into a long up-stroke.
|
||||
TICK_BLOCKS = [
|
||||
(2, 5), (3, 6), (4, 7), (5, 6), (6, 5), (7, 4), (8, 3), (9, 2),
|
||||
]
|
||||
|
||||
|
||||
def make_checkbox_image(checked, enabled):
|
||||
"""A checkbox drawn pixel by pixel, so a locked tick can be greyed out.
|
||||
|
||||
Tk has no disabled state for a cell, and a row tag would grey the whole row including
|
||||
an Action the user can still change — so the box itself carries the distinction.
|
||||
"""
|
||||
image = tk.PhotoImage(width=CHECKBOX_SIZE, height=CHECKBOX_SIZE)
|
||||
|
||||
face = "#ffffff" if enabled else "#f0f0f0"
|
||||
border = "#555555" if enabled else "#a8a8a8"
|
||||
ink = "#1a1a1a" if enabled else "#909090"
|
||||
|
||||
image.put(face, to=(0, 0, CHECKBOX_SIZE, CHECKBOX_SIZE))
|
||||
image.put(border, to=(0, 0, CHECKBOX_SIZE, 1))
|
||||
image.put(border, to=(0, CHECKBOX_SIZE - 1, CHECKBOX_SIZE, CHECKBOX_SIZE))
|
||||
image.put(border, to=(0, 0, 1, CHECKBOX_SIZE))
|
||||
image.put(border, to=(CHECKBOX_SIZE - 1, 0, CHECKBOX_SIZE, CHECKBOX_SIZE))
|
||||
|
||||
if checked:
|
||||
for x, y in TICK_BLOCKS:
|
||||
image.put(ink, to=(x, y, x + 2, y + 2))
|
||||
|
||||
return image
|
||||
|
||||
|
||||
def build_label(version, checksum):
|
||||
"""How a build is named in a sentence: its version, or its checksum when it has none."""
|
||||
return version if version else f"checksum {checksum}"
|
||||
|
||||
|
||||
def column_label(version, checksum):
|
||||
"""How a build is named in the Installed and Latest columns, which are narrow."""
|
||||
return version if version else checksum
|
||||
|
||||
|
||||
def action_options(kit_name, current_checksum, is_declared, builds):
|
||||
"""The Action dropdown for one kit: (label, checksum to apply) pairs, no-op first.
|
||||
|
||||
The no-op comes first so a row does nothing until the user picks something, and the
|
||||
remaining entries read as upgrade or downgrade relative to the pinned build — which is
|
||||
what makes the column scannable when a kit ships thirty builds.
|
||||
"""
|
||||
installed_version = None
|
||||
for version, checksum in builds:
|
||||
if checksum == current_checksum:
|
||||
installed_version = version
|
||||
break
|
||||
|
||||
if not is_declared:
|
||||
options = [("Not in schema", None)]
|
||||
elif current_checksum is None:
|
||||
options = [("Keep with no checksum", None)]
|
||||
elif installed_version is None:
|
||||
# The file pins a build this machine does not hold, so there is nothing to name it
|
||||
# by and no way to tell whether the other builds are newer or older than it.
|
||||
options = [(f"Keep at checksum {current_checksum}", None)]
|
||||
else:
|
||||
options = [(f"Keep at {build_label(installed_version, current_checksum)}", None)]
|
||||
|
||||
installed_rank = palette.version_sort_key(installed_version) if installed_version else None
|
||||
|
||||
for version, checksum in builds:
|
||||
if is_declared and checksum == current_checksum:
|
||||
continue
|
||||
|
||||
label = build_label(version, checksum)
|
||||
if not is_declared:
|
||||
verb = "Add at"
|
||||
elif installed_rank is None or not version:
|
||||
verb = "Set to"
|
||||
elif palette.version_sort_key(version) > installed_rank:
|
||||
verb = "Upgrade to"
|
||||
elif palette.version_sort_key(version) < installed_rank:
|
||||
verb = "Downgrade to"
|
||||
else:
|
||||
verb = "Set to"
|
||||
options.append((f"{verb} {label}", checksum))
|
||||
|
||||
return options
|
||||
|
||||
|
||||
def collect_rows():
|
||||
"""Every kit worth listing, as row dicts in the order they should appear.
|
||||
|
||||
A row carries what the table paints and what the buttons act on, so the dialog never
|
||||
has to re-derive a kit's state from the DOM while it is open.
|
||||
"""
|
||||
declared = sax_file.schema_kit_checksums()
|
||||
declared_names = [name for name, _ in declared]
|
||||
required = sax_file.required_kit_names()
|
||||
|
||||
installed = palette.list_kits()
|
||||
# A kit the schema declares but Sedona home does not hold still gets a row: it is the
|
||||
# clearest place to see that the file points at something missing.
|
||||
others = [name for name in installed if name not in declared_names]
|
||||
|
||||
groups = [
|
||||
("Installed kits", [
|
||||
(name, checksum) for name, checksum in declared if name in required
|
||||
]),
|
||||
("Installed but not used kits", [
|
||||
(name, checksum) for name, checksum in declared if name not in required
|
||||
]),
|
||||
("Kits to be installed", [(name, None) for name in others]),
|
||||
]
|
||||
|
||||
rows = []
|
||||
for title, entries in groups:
|
||||
if not entries:
|
||||
continue
|
||||
rows.append({"section": title})
|
||||
for name, checksum in entries:
|
||||
builds = palette.kit_builds(name)
|
||||
latest = builds[0] if builds else None
|
||||
is_declared = name in declared_names
|
||||
options = action_options(name, checksum, is_declared, builds)
|
||||
|
||||
if not is_declared:
|
||||
installed_text = NOT_DECLARED
|
||||
elif checksum is None:
|
||||
installed_text = NO_CHECKSUM
|
||||
else:
|
||||
installed_text = column_label(
|
||||
next((v for v, c in builds if c == checksum), ""), checksum
|
||||
)
|
||||
|
||||
rows.append({
|
||||
"kit": name,
|
||||
"declared": is_declared,
|
||||
"required": name in required,
|
||||
"checksum": checksum,
|
||||
"builds": builds,
|
||||
"latest": latest,
|
||||
"installed_text": installed_text,
|
||||
"latest_text": column_label(*latest) if latest else NOT_DECLARED,
|
||||
"options": options,
|
||||
"choice": 0,
|
||||
# The tick reflects the file as it stands: declared kits start ticked.
|
||||
"checked": is_declared,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def describe_changes(changes):
|
||||
"""The applied-changes report, one kit per line, old build to new."""
|
||||
lines = []
|
||||
for kit_name, old_checksum, new_checksum in changes:
|
||||
old_label = (
|
||||
build_label(palette.version_for_checksum(kit_name, old_checksum), old_checksum)
|
||||
if old_checksum else None
|
||||
)
|
||||
|
||||
if new_checksum is None:
|
||||
was = f" (was {old_label})" if old_label else ""
|
||||
lines.append(f" • {kit_name}: removed from the schema{was}")
|
||||
continue
|
||||
|
||||
new_label = build_label(
|
||||
palette.version_for_checksum(kit_name, new_checksum), new_checksum
|
||||
)
|
||||
if old_checksum is None:
|
||||
lines.append(f" • {kit_name}: added at {new_label}")
|
||||
else:
|
||||
lines.append(f" • {kit_name}: {old_label} → {new_label}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def open_dialog(on_applied=None):
|
||||
"""Opens the kit table. ``on_applied`` is called after the schema really changed."""
|
||||
window = app_state.window
|
||||
|
||||
if app_state.xml_root_element is None:
|
||||
messagebox.showinfo("Manage Kits", "Open a SAX file first.")
|
||||
return
|
||||
if app_state.xml_root_element.find("schema") is None:
|
||||
messagebox.showwarning("Manage Kits", "This file has no <schema> block to manage.")
|
||||
return
|
||||
|
||||
dialog = tk.Toplevel(window)
|
||||
dialog.title("Kits")
|
||||
dialog.transient(window)
|
||||
dialog.grab_set()
|
||||
dialog.geometry(f"820x520+{window.winfo_x() + 60}+{window.winfo_y() + 40}")
|
||||
dialog.minsize(640, 320)
|
||||
|
||||
frame = ttk.Frame(dialog, padding=10)
|
||||
frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
COLUMNS = ("name", "installed", "latest", "action")
|
||||
# 'tree headings' keeps the icon column, which is the only place a real checkbox image
|
||||
# can live; the data columns follow it.
|
||||
table = ttk.Treeview(frame, columns=COLUMNS, show="tree headings", selectmode="browse")
|
||||
table.heading("#0", text="")
|
||||
table.column("#0", width=32, minwidth=32, stretch=False, anchor="center")
|
||||
for column, heading, width, anchor in [
|
||||
("name", "Kit name", 200, "w"),
|
||||
("installed", "Installed", 105, "w"),
|
||||
("latest", "Latest", 105, "w"),
|
||||
("action", "Action", 300, "w"),
|
||||
]:
|
||||
table.heading(column, text=heading, anchor=anchor)
|
||||
table.column(column, width=width, anchor=anchor, stretch=(column == "action"))
|
||||
|
||||
scrollbar = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=table.yview)
|
||||
table.configure(yscrollcommand=scrollbar.set)
|
||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
table.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
table.tag_configure(SECTION_TAG, background="#dcdcdc")
|
||||
|
||||
# Held on the dialog: a PhotoImage is dropped as soon as nothing references it.
|
||||
boxes = {
|
||||
(True, True): make_checkbox_image(True, True),
|
||||
(False, True): make_checkbox_image(False, True),
|
||||
(True, False): make_checkbox_image(True, False),
|
||||
}
|
||||
|
||||
hint = ttk.Label(
|
||||
frame,
|
||||
text="A tick means the schema declares the kit. Grey ticks are needed by the app "
|
||||
"and cannot be removed; untick another to remove it. Click the Action cell to "
|
||||
"choose a build. Nothing reaches the file until you save it.",
|
||||
foreground="#666666",
|
||||
wraplength=780,
|
||||
)
|
||||
hint.pack(anchor="w", pady=(8, 0))
|
||||
|
||||
button_row = ttk.Frame(frame)
|
||||
button_row.pack(fill=tk.X, pady=(10, 0))
|
||||
|
||||
rows = [] # row dicts, table order
|
||||
row_by_item = {} # Treeview item id -> row dict
|
||||
editor = {"widget": None}
|
||||
|
||||
def close_editor(_event=None):
|
||||
if editor["widget"] is not None:
|
||||
editor["widget"].destroy()
|
||||
editor["widget"] = None
|
||||
|
||||
def action_text(row):
|
||||
"""What the Action cell says, which an untick overrides with REMOVE."""
|
||||
if row["declared"] and not row["checked"]:
|
||||
return REMOVE_ACTION
|
||||
return row["options"][row["choice"]][0]
|
||||
|
||||
def row_values(row):
|
||||
return (row["kit"], row["installed_text"], row["latest_text"], action_text(row))
|
||||
|
||||
def row_image(row):
|
||||
return boxes[(row["checked"], not row["required"])]
|
||||
|
||||
def rebuild_table():
|
||||
close_editor()
|
||||
for item in table.get_children():
|
||||
table.delete(item)
|
||||
row_by_item.clear()
|
||||
|
||||
rows[:] = collect_rows()
|
||||
for row in rows:
|
||||
if "section" in row:
|
||||
table.insert("", "end", values=(row["section"], "", "", ""), tags=(SECTION_TAG,))
|
||||
continue
|
||||
item = table.insert("", "end", image=row_image(row), values=row_values(row))
|
||||
row_by_item[item] = row
|
||||
|
||||
def refresh_row(item):
|
||||
row = row_by_item[item]
|
||||
table.item(item, image=row_image(row), values=row_values(row))
|
||||
|
||||
def set_choice(item, index):
|
||||
row = row_by_item[item]
|
||||
row["choice"] = index
|
||||
|
||||
# Naming a build is the user saying they want the kit at it, so the row ticks
|
||||
# itself — and for a declared row that also cancels a pending removal.
|
||||
if row["declared"] or row["options"][index][1] is not None:
|
||||
row["checked"] = True
|
||||
refresh_row(item)
|
||||
|
||||
def toggle_check(item):
|
||||
row = row_by_item[item]
|
||||
if row["required"]:
|
||||
# Needed by the app: the tick is fixed, and the box is drawn greyed to say so.
|
||||
return
|
||||
|
||||
row["checked"] = not row["checked"]
|
||||
if not row["declared"]:
|
||||
# Ticking a kit that is not in the schema means adding it, at its newest build
|
||||
# unless the user picks another; unticking puts the row back to doing nothing.
|
||||
row["choice"] = 1 if (row["checked"] and len(row["options"]) > 1) else 0
|
||||
if row["checked"] and len(row["options"]) < 2:
|
||||
row["checked"] = False
|
||||
messagebox.showwarning(
|
||||
"Manage Kits",
|
||||
f"Sedona home holds no manifest for '{row['kit']}', so there is no "
|
||||
"build to add it at.",
|
||||
parent=dialog
|
||||
)
|
||||
refresh_row(item)
|
||||
|
||||
def edit_action(item):
|
||||
row = row_by_item[item]
|
||||
cell = table.bbox(item, "action")
|
||||
if not cell:
|
||||
return
|
||||
|
||||
labels = [label for label, _ in row["options"]]
|
||||
current = action_text(row)
|
||||
variable = tk.StringVar(value=current if current in labels else labels[row["choice"]])
|
||||
combo = ttk.Combobox(table, textvariable=variable, values=labels, state="readonly")
|
||||
combo.place(x=cell[0], y=cell[1], width=cell[2], height=cell[3])
|
||||
editor["widget"] = combo
|
||||
|
||||
def commit(_event=None):
|
||||
if variable.get() in labels:
|
||||
set_choice(item, labels.index(variable.get()))
|
||||
close_editor()
|
||||
|
||||
combo.bind("<<ComboboxSelected>>", commit)
|
||||
combo.bind("<Escape>", lambda event: close_editor())
|
||||
combo.focus_set()
|
||||
# A single click on the cell should show the builds, not just a collapsed box.
|
||||
combo.after_idle(lambda: combo.event_generate("<Down>"))
|
||||
|
||||
def on_click(event):
|
||||
close_editor()
|
||||
if table.identify_region(event.x, event.y) not in ("tree", "cell"):
|
||||
return
|
||||
item = table.identify_row(event.y)
|
||||
if item not in row_by_item:
|
||||
return
|
||||
|
||||
column = table.identify_column(event.x)
|
||||
if column == "#0":
|
||||
toggle_check(item)
|
||||
elif column == "#4":
|
||||
edit_action(item)
|
||||
|
||||
table.bind("<Button-1>", on_click)
|
||||
table.bind("<MouseWheel>", close_editor, add="+")
|
||||
table.bind("<Configure>", close_editor, add="+")
|
||||
|
||||
def apply_changes(selections, removals, label, nothing_message, note=""):
|
||||
if not selections and not removals:
|
||||
messagebox.showinfo("Manage Kits", nothing_message, parent=dialog)
|
||||
return
|
||||
|
||||
changes, error = sax_file.apply_kit_selections(selections, removals, label=label)
|
||||
if error:
|
||||
messagebox.showerror("Kits unchanged", error, parent=dialog)
|
||||
rebuild_table()
|
||||
return
|
||||
|
||||
if not changes:
|
||||
messagebox.showinfo(
|
||||
"Manage Kits", "The schema already reads that way.", parent=dialog
|
||||
)
|
||||
return
|
||||
|
||||
rebuild_table()
|
||||
if on_applied is not None:
|
||||
on_applied()
|
||||
|
||||
app_state.set_status(
|
||||
f"schema kits changed ({len(changes)}) — save the file to keep it.",
|
||||
warn=True
|
||||
)
|
||||
messagebox.showinfo(
|
||||
"Kits updated",
|
||||
f"The schema block now reads:\n\n{describe_changes(changes)}\n\n"
|
||||
+ (note + "\n\n" if note else "")
|
||||
+ "Save the file to write this to disk. The device's scode must hold these "
|
||||
"builds or the application will not run.",
|
||||
parent=dialog
|
||||
)
|
||||
|
||||
def upgrade_all():
|
||||
"""Every kit the schema declares, moved to the newest build Sedona home holds."""
|
||||
selections = []
|
||||
for row in rows:
|
||||
if "section" in row or not row["declared"] or not row["latest"]:
|
||||
continue
|
||||
latest_checksum = row["latest"][1]
|
||||
if latest_checksum != row["checksum"]:
|
||||
selections.append((row["kit"], latest_checksum))
|
||||
|
||||
if selections:
|
||||
preview = "\n".join(
|
||||
f" • {kit}: {build_label(palette.version_for_checksum(kit, checksum), checksum)}"
|
||||
for kit, checksum in selections
|
||||
)
|
||||
proceed = messagebox.askokcancel(
|
||||
"Upgrade all kits",
|
||||
f"{len(selections)} kit{'' if len(selections) == 1 else 's'} in this app's "
|
||||
f"schema will move to their latest installed build:\n\n{preview}\n\nContinue?",
|
||||
icon=messagebox.WARNING,
|
||||
parent=dialog
|
||||
)
|
||||
if not proceed:
|
||||
return
|
||||
|
||||
apply_changes(
|
||||
selections, [],
|
||||
"upgrade all kits",
|
||||
"Every kit in the schema is already at its latest installed build."
|
||||
)
|
||||
|
||||
def update_marked():
|
||||
"""The ticks and the Action column, applied: repins, additions and removals."""
|
||||
selections = []
|
||||
removals = []
|
||||
for row in row_by_item.values():
|
||||
if row["declared"] and not row["checked"]:
|
||||
removals.append(row["kit"])
|
||||
continue
|
||||
if not row["checked"]:
|
||||
continue
|
||||
checksum = row["options"][row["choice"]][1]
|
||||
if checksum:
|
||||
selections.append((row["kit"], checksum))
|
||||
|
||||
note = ""
|
||||
if removals:
|
||||
note = (
|
||||
"Removed kits are gone from the schema only. Any build of them still "
|
||||
"installed in Sedona home can be added back here."
|
||||
)
|
||||
|
||||
apply_changes(
|
||||
selections, removals,
|
||||
"update kits",
|
||||
"Nothing is marked for a change. Pick a build in a kit's Action column, tick "
|
||||
"a kit to add it, or untick one to remove it."
|
||||
)
|
||||
|
||||
ttk.Button(
|
||||
button_row, text="Upgrade all kits to latest version", command=upgrade_all
|
||||
).pack(side=tk.LEFT)
|
||||
ttk.Button(button_row, text="Update kits", command=update_marked).pack(side=tk.LEFT, padx=6)
|
||||
ttk.Button(button_row, text="Close", command=dialog.destroy).pack(side=tk.RIGHT)
|
||||
|
||||
rebuild_table()
|
||||
+40
-3
@@ -19,6 +19,7 @@ import app_state
|
||||
_versions_cache = {} # kit name -> {checksum: version string}
|
||||
_manifest_cache = {} # kit name -> (version string, {type name: element})
|
||||
_component_cache = {} # 'kit::Type' -> bool
|
||||
_builds_cache = {} # kit name -> [(version string, checksum)], newest first
|
||||
|
||||
|
||||
def _manifests_dir():
|
||||
@@ -40,7 +41,7 @@ def list_kits():
|
||||
)
|
||||
|
||||
|
||||
def _version_sort_key(version_string):
|
||||
def version_sort_key(version_string):
|
||||
numbers = [int(part) for part in re.findall(r"\d+", version_string)]
|
||||
return numbers or [0]
|
||||
|
||||
@@ -94,7 +95,7 @@ def resolve_manifest_path(kit_name):
|
||||
for entry in manifests:
|
||||
checksum = entry[len(kit_name) + 1:-4]
|
||||
if checksum in versions:
|
||||
available.append((_version_sort_key(versions[checksum]), entry, versions[checksum]))
|
||||
available.append((version_sort_key(versions[checksum]), entry, versions[checksum]))
|
||||
|
||||
if available:
|
||||
available.sort()
|
||||
@@ -106,6 +107,41 @@ def resolve_manifest_path(kit_name):
|
||||
return os.path.join(kit_dir, entry), versions.get(checksum, "")
|
||||
|
||||
|
||||
def kit_builds(kit_name):
|
||||
"""Every build of a kit this Sedona home can describe, newest version first.
|
||||
|
||||
One entry per manifest under manifests/<kit>, as (version string, checksum). A
|
||||
manifest with no matching .kit file has no version to report, so its version is empty
|
||||
and such builds sort last — there is nothing to order them by.
|
||||
"""
|
||||
if kit_name in _builds_cache:
|
||||
return _builds_cache[kit_name]
|
||||
|
||||
kit_dir = os.path.join(_manifests_dir(), kit_name)
|
||||
builds = []
|
||||
if os.path.isdir(kit_dir):
|
||||
versions = kit_versions(kit_name)
|
||||
for entry in sorted(os.listdir(kit_dir)):
|
||||
if not (entry.startswith(f"{kit_name}-") and entry.endswith(".xml")):
|
||||
continue
|
||||
checksum = entry[len(kit_name) + 1:-4]
|
||||
builds.append((versions.get(checksum, ""), checksum))
|
||||
|
||||
builds.sort(
|
||||
key=lambda build: (bool(build[0]), version_sort_key(build[0])),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
_builds_cache[kit_name] = builds
|
||||
return builds
|
||||
|
||||
|
||||
def latest_build(kit_name):
|
||||
"""The newest build of a kit, as (version string, checksum), or None if it has none."""
|
||||
builds = kit_builds(kit_name)
|
||||
return builds[0] if builds else None
|
||||
|
||||
|
||||
def _load_kit(kit_name):
|
||||
"""Parses a kit's chosen manifest into (version label, {type name: element})."""
|
||||
if kit_name in _manifest_cache:
|
||||
@@ -201,7 +237,8 @@ def list_component_types(kit_name):
|
||||
|
||||
|
||||
def invalidate():
|
||||
"""Clears every cache, for when Sedona home changes in Preferences."""
|
||||
"""Clears every cache, for when Sedona home changes or a kit is repinned."""
|
||||
_versions_cache.clear()
|
||||
_manifest_cache.clear()
|
||||
_component_cache.clear()
|
||||
_builds_cache.clear()
|
||||
|
||||
+20
@@ -3,9 +3,29 @@
|
||||
Pure XML and bit work, deliberately free of Tk so the rules stay testable headless.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
def snapshot_children(element):
|
||||
"""Copies an element's children and leading text, so it can be put back wholesale.
|
||||
|
||||
Used for the <schema> block, which is short enough that copying it whole is easier to
|
||||
trust than tracking which attribute moved and which entry was inserted where.
|
||||
"""
|
||||
return [copy.deepcopy(child) for child in element], element.text
|
||||
|
||||
|
||||
def restore_children(element, snapshot):
|
||||
"""Puts back what snapshot_children captured, whitespace and all."""
|
||||
children, text = snapshot
|
||||
for child in list(element):
|
||||
element.remove(child)
|
||||
element.text = text
|
||||
for child in children:
|
||||
element.append(copy.deepcopy(child))
|
||||
|
||||
|
||||
def read_meta_property(meta_str):
|
||||
"""Parses 32-bit meta property integer fields via structured byte extraction rules."""
|
||||
if meta_str is None or not meta_str:
|
||||
|
||||
+228
-40
@@ -10,7 +10,13 @@ import palette
|
||||
import schema
|
||||
import undo
|
||||
import wiresheet
|
||||
from sax_dom import find_parent_element, get_component_path, update_meta_property
|
||||
from sax_dom import (
|
||||
find_parent_element,
|
||||
get_component_path,
|
||||
restore_children,
|
||||
snapshot_children,
|
||||
update_meta_property,
|
||||
)
|
||||
|
||||
|
||||
def parse_xml_to_tree(treeview, parent_node, xml_element):
|
||||
@@ -361,53 +367,62 @@ def used_kit_names():
|
||||
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 used kit the <schema> block does not list.
|
||||
"""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 (added elements in the order written, whitespace to put back if the caller
|
||||
rolls the insertion out).
|
||||
Returns the <kit> elements that were added, in the order they were written.
|
||||
"""
|
||||
if app_state.xml_root_element is None:
|
||||
return [], []
|
||||
return []
|
||||
|
||||
schema_elem = app_state.xml_root_element.find("schema")
|
||||
if schema_elem is None:
|
||||
return [], []
|
||||
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)
|
||||
|
||||
for name in sorted(required_kit_names() - declared):
|
||||
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)
|
||||
kit_element = insert_kit_element(schema_elem, name, checksum)
|
||||
|
||||
app_state.active_schema_kits[name] = checksum
|
||||
added.append(kit_element)
|
||||
|
||||
return added, tail_restores
|
||||
return added
|
||||
|
||||
|
||||
def indent_kit_entry(schema_elem, kit_element):
|
||||
@@ -418,9 +433,8 @@ def indent_kit_entry(schema_elem, kit_element):
|
||||
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.
|
||||
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]
|
||||
|
||||
@@ -430,13 +444,50 @@ def indent_kit_entry(schema_elem, kit_element):
|
||||
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
|
||||
|
||||
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 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):
|
||||
@@ -456,8 +507,8 @@ def describe_schema_kits(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:",
|
||||
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(
|
||||
@@ -484,13 +535,155 @@ def describe_schema_kits(added_elements):
|
||||
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.
|
||||
"""
|
||||
added, tail_restores = declare_used_kits()
|
||||
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
|
||||
|
||||
@@ -501,14 +694,9 @@ def reconcile_schema_kits_before_save():
|
||||
)
|
||||
|
||||
if not proceed:
|
||||
schema_elem = app_state.xml_root_element.find("schema")
|
||||
restore_children(schema_elem, before)
|
||||
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.
|
||||
|
||||
@@ -5,6 +5,8 @@ import xml.etree.ElementTree as ET
|
||||
|
||||
import app_state
|
||||
|
||||
_dependency_cache = {} # manifest path -> tuple of kit names it depends on
|
||||
|
||||
|
||||
class MissingManifestError(Exception):
|
||||
"""A kit the file needs has no manifest under the Sedona home folder.
|
||||
@@ -42,6 +44,38 @@ def describe_missing_manifests(error):
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def read_kit_dependencies(kit_name, checksum=None):
|
||||
"""The kits a manifest declares its kit was built against, by name only.
|
||||
|
||||
A manifest lists them as <depend on="sys 1+"/>; the version bound is dropped here,
|
||||
since the schema block pins a build by checksum and has nowhere to record a range.
|
||||
|
||||
An unreadable or absent manifest yields no dependencies rather than an error: the
|
||||
callers use this to widen a list, and a kit whose manifest is missing is already
|
||||
reported by whatever needed its types.
|
||||
"""
|
||||
manifest_path = find_manifest_path(kit_name, checksum)
|
||||
if not manifest_path:
|
||||
return ()
|
||||
if manifest_path in _dependency_cache:
|
||||
return _dependency_cache[manifest_path]
|
||||
|
||||
dependencies = []
|
||||
try:
|
||||
root = ET.parse(manifest_path).getroot()
|
||||
depends_element = root.find("depends")
|
||||
if depends_element is not None:
|
||||
for depend in depends_element.findall("depend"):
|
||||
on = (depend.get("on") or "").strip()
|
||||
if on:
|
||||
dependencies.append(on.split()[0])
|
||||
except Exception as error:
|
||||
print(f"Cannot read dependencies of {manifest_path}: {error}")
|
||||
|
||||
_dependency_cache[manifest_path] = tuple(dependencies)
|
||||
return _dependency_cache[manifest_path]
|
||||
|
||||
|
||||
def read_summary_facet(slot_element):
|
||||
"""Reads a slot's '@summary' facet. Absent means summary, matching manifest convention."""
|
||||
facets = slot_element.find("facets")
|
||||
|
||||
+19
-17
@@ -1,22 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sedonaApp>
|
||||
<schema>
|
||||
<kit name="sys" />
|
||||
<kit name="control" />
|
||||
<kit name="datetime" />
|
||||
<kit name="driver" />
|
||||
<kit name="inet" />
|
||||
<kit name="ontrolCalc" />
|
||||
<kit name="ontrolControl" />
|
||||
<kit name="ontrolDatetime" />
|
||||
<kit name="ontrolDatetimeStd" />
|
||||
<kit name="ontrolModbusMaster" />
|
||||
<kit name="ontrolPion" />
|
||||
<kit name="ontrolPlatform" />
|
||||
<kit name="ontrolSchedule" />
|
||||
<kit name="serial" />
|
||||
<kit name="sox" />
|
||||
<kit name="web" />
|
||||
<kit name="sys" checksum="d3984c51" />
|
||||
<kit name="control" checksum="808b7db3" />
|
||||
<kit name="datetime" checksum="3a280dce" />
|
||||
<kit name="driver" checksum="b4cc82ce" />
|
||||
<kit name="inet" checksum="25648ba7" />
|
||||
<kit name="ontrolCalc" checksum="ba6a7e02" />
|
||||
<kit name="ontrolControl" checksum="254170a5" />
|
||||
<kit name="ontrolDatetime" checksum="948972be" />
|
||||
<kit name="ontrolDatetimeStd" checksum="190a44b4" />
|
||||
<kit name="ontrolModbusMaster" checksum="45d95f96" />
|
||||
<kit name="ontrolPion" checksum="931bda2d" />
|
||||
<kit name="ontrolPlatform" checksum="5c12ff9d" />
|
||||
<kit name="ontrolSchedule" checksum="d9c85cf5" />
|
||||
<kit name="serial" checksum="87314b6e" />
|
||||
<kit name="sox" checksum="397a84dd" />
|
||||
<kit name="web" checksum="0462d43e" />
|
||||
<kit name="ontrolModbusSlaveSmart" checksum="929218bf" />
|
||||
</schema>
|
||||
<app>
|
||||
<prop name="deviceName" val="DDC_8" />
|
||||
@@ -279,7 +280,8 @@
|
||||
</comp>
|
||||
</comp>
|
||||
</comp>
|
||||
<comp name="ModbusS" id="27" type="ontrolModbusSlaveSmart::ModbusSlaveSmart"><prop name="meta" val="438042625" /></comp></comp>
|
||||
<comp name="ModbusS" id="27" type="ontrolModbusSlaveSmart::ModbusSlaveSmart"><prop name="meta" val="438042625" />
|
||||
</comp></comp>
|
||||
|
||||
<comp name="AHU20_1" id="70" type="sys::Folder">
|
||||
<prop name="meta" val="17367041" />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,9 @@ import graph free of cycles (sax_file and wiresheet both push commands here).
|
||||
"""
|
||||
|
||||
import app_state
|
||||
from sax_dom import update_meta_property
|
||||
import palette
|
||||
import schema
|
||||
from sax_dom import restore_children, update_meta_property
|
||||
|
||||
_undo_stack = []
|
||||
_redo_stack = []
|
||||
@@ -79,6 +81,43 @@ def reset():
|
||||
_saved_depth = 0
|
||||
|
||||
|
||||
def reload_schema_registry():
|
||||
"""Recompiles the slot registry after the <schema> block changed.
|
||||
|
||||
A failure is swallowed: undo and redo only ever land on a state that resolved once
|
||||
already, so a missing manifest here means Sedona home moved underneath the session,
|
||||
which the next action reports in its own words.
|
||||
"""
|
||||
palette.invalidate()
|
||||
try:
|
||||
schema.load_schema_kits_and_manifests()
|
||||
except schema.MissingManifestError:
|
||||
pass
|
||||
|
||||
|
||||
class SchemaBlockCommand:
|
||||
"""A change to the file's <schema> block, held as a before/after snapshot.
|
||||
|
||||
Kit entries decide which manifest describes which type, so the slot registry is
|
||||
recompiled in both directions — a repinned kit can change a component's slots.
|
||||
"""
|
||||
|
||||
def __init__(self, schema_element, before, after, label="update kits"):
|
||||
# before/after: snapshots from sax_dom.snapshot_children
|
||||
self.schema_element = schema_element
|
||||
self.before = before
|
||||
self.after = after
|
||||
self.label = label
|
||||
|
||||
def undo(self):
|
||||
restore_children(self.schema_element, self.before)
|
||||
reload_schema_registry()
|
||||
|
||||
def redo(self):
|
||||
restore_children(self.schema_element, self.after)
|
||||
reload_schema_registry()
|
||||
|
||||
|
||||
class MetaMoveCommand:
|
||||
"""One or many components changing position, i.e. their packed meta x/y."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user