Manage Kits as embedded panel, fix checksum zero-padding mismatch

Manage Kits now opens inside the editor pane (like Properties and Link
View) instead of a separate Toplevel window. Also normalize hex
checksums by stripping leading zeros so .kit and manifest filenames
that disagree on padding (e.g. 462d43e vs 0462d43e) still resolve to
a version.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 21:08:59 +03:00
co-authored by Claude Opus 4.6
parent da0e18a9c3
commit a77edead25
4 changed files with 197 additions and 158 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
import configparser import configparser
import os import os
VERSION = "0.0.0.058" VERSION = "0.0.0.060"
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties") PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
DEFAULT_GRID_SIZE = 10 DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True DEFAULT_ADD_TIMESTAMP = True
+44 -6
View File
@@ -111,11 +111,12 @@ zoom_label = None
menu_view = None menu_view = None
show_left_column_var = None show_left_column_var = None
# The Properties and Link views each take the editor pane over from the canvas rather # The Properties, Link and Kits views each take the editor pane over from the canvas
# than floating above it, so at most one of the three is on screen and these two say # rather than floating above it, so at most one of the four is on screen and these three
# which. Both None means the sheet is showing. # say which. All None means the sheet is showing.
properties_panel = None properties_panel = None
link_panel = None link_panel = None
kits_panel = None
# The sheet to put the tree back on when a sheet change is refused because of unsaved # The sheet to put the tree back on when a sheet change is refused because of unsaved
# edits, and the flag that stops the restoring selection from being read as a fresh # edits, and the flag that stops the restoring selection from being read as a fresh
@@ -204,7 +205,7 @@ def delete_component(comp):
def editor_panel_showing(): def editor_panel_showing():
"""Whether something other than the wiresheet has the editor pane.""" """Whether something other than the wiresheet has the editor pane."""
return properties_panel is not None or link_panel is not None return properties_panel is not None or link_panel is not None or kits_panel is not None
def _take_editor_pane(): def _take_editor_pane():
@@ -316,17 +317,54 @@ def dismiss_link_view(restore_canvas=True):
_give_editor_pane_back() _give_editor_pane_back()
def show_kits():
"""Puts the kit table in the editor pane, in place of the wiresheet."""
global kits_panel
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
if not close_editor_panel():
return
_take_editor_pane()
kits_panel = manage_kits.build_kits_panel(
frame_editor, on_applied=refresh_palette_kits, on_close=dismiss_kits,
)
kits_panel.frame.pack(fill=tk.BOTH, expand=True)
app_state.set_status("Manage Kits")
def dismiss_kits(restore_canvas=True):
"""Takes the Kits view down and gives the pane back."""
global kits_panel
if kits_panel is None:
return
kits_panel.destroy()
kits_panel = None
if restore_canvas:
_give_editor_pane_back()
def close_editor_panel(force=False): def close_editor_panel(force=False):
"""Frees the editor pane, asking about unsaved edits first. True if it is now free. """Frees the editor pane, asking about unsaved edits first. True if it is now free.
Every way of leaving a panel goes through this, so "you have not saved that" is Every way of leaving a panel goes through this, so "you have not saved that" is
asked once, in one place, whether the user pressed Close, picked another sheet, asked once, in one place, whether the user pressed Close, picked another sheet,
walked the history or opened a different component. Only the Properties view has walked the history or opened a different component. Only the Properties view has
anything to ask about; the Link View simply stands down. anything to ask about; other views simply stand down.
""" """
if link_panel is not None: if link_panel is not None:
dismiss_link_view() dismiss_link_view()
return True return True
if kits_panel is not None:
dismiss_kits()
return True
if properties_panel is None: if properties_panel is None:
return True return True
if force: if force:
@@ -1919,7 +1957,7 @@ menu_bar.add_cascade(label="View", menu=menu_view)
menu_kits = tk.Menu(menu_bar, tearoff=0) menu_kits = tk.Menu(menu_bar, tearoff=0)
menu_kits.add_command( menu_kits.add_command(
label="Manage Kits...", label="Manage Kits...",
command=lambda: manage_kits.open_dialog(on_applied=refresh_palette_kits) command=show_kits
) )
menu_bar.add_cascade(label="Kits", menu=menu_kits) menu_bar.add_cascade(label="Kits", menu=menu_kits)
+114 -123
View File
@@ -203,58 +203,45 @@ def describe_changes(changes):
return "\n".join(lines) return "\n".join(lines)
def open_dialog(on_applied=None): class KitsPanel:
"""Opens the kit table. ``on_applied`` is called after the schema really changed.""" """The kit table, built as an embedded panel in the editor pane."""
window = app_state.window
if app_state.xml_root_element is None: def __init__(self, parent, on_applied, on_close):
messagebox.showinfo("Manage Kits", "Open a SAX file first.") self.on_applied = on_applied
return self.on_close = on_close
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) self.frame = ttk.Frame(parent, padding=10)
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") COLUMNS = ("name", "installed", "latest", "action")
# 'tree headings' keeps the icon column, which is the only place a real checkbox image self.table = ttk.Treeview(
# can live; the data columns follow it. self.frame, columns=COLUMNS, show="tree headings", selectmode="browse",
table = ttk.Treeview(frame, columns=COLUMNS, show="tree headings", selectmode="browse") )
table.heading("#0", text="") self.table.heading("#0", text="")
table.column("#0", width=32, minwidth=32, stretch=False, anchor="center") self.table.column("#0", width=32, minwidth=32, stretch=False, anchor="center")
for column, heading, width, anchor in [ for column, heading, width, anchor in [
("name", "Kit name", 200, "w"), ("name", "Kit name", 200, "w"),
("installed", "Installed", 105, "w"), ("installed", "Installed", 105, "w"),
("latest", "Latest", 105, "w"), ("latest", "Latest", 105, "w"),
("action", "Action", 300, "w"), ("action", "Action", 300, "w"),
]: ]:
table.heading(column, text=heading, anchor=anchor) self.table.heading(column, text=heading, anchor=anchor)
table.column(column, width=width, anchor=anchor, stretch=(column == "action")) self.table.column(column, width=width, anchor=anchor, stretch=(column == "action"))
scrollbar = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=table.yview) scrollbar = ttk.Scrollbar(self.frame, orient=tk.VERTICAL, command=self.table.yview)
table.configure(yscrollcommand=scrollbar.set) self.table.configure(yscrollcommand=scrollbar.set)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y) scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
table.pack(fill=tk.BOTH, expand=True) self.table.pack(fill=tk.BOTH, expand=True)
table.tag_configure(SECTION_TAG, background="#dcdcdc") self.table.tag_configure(SECTION_TAG, background="#dcdcdc")
# Held on the dialog: a PhotoImage is dropped as soon as nothing references it. self.boxes = {
boxes = {
(True, True): make_checkbox_image(True, True), (True, True): make_checkbox_image(True, True),
(False, True): make_checkbox_image(False, True), (False, True): make_checkbox_image(False, True),
(True, False): make_checkbox_image(True, False), (True, False): make_checkbox_image(True, False),
} }
hint = ttk.Label( hint = ttk.Label(
frame, self.frame,
text="A tick means the schema declares the kit. Grey ticks are needed by the app " 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 " "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.", "choose a build. Nothing reaches the file until you save it.",
@@ -263,68 +250,87 @@ def open_dialog(on_applied=None):
) )
hint.pack(anchor="w", pady=(8, 0)) hint.pack(anchor="w", pady=(8, 0))
button_row = ttk.Frame(frame) button_row = ttk.Frame(self.frame)
button_row.pack(fill=tk.X, pady=(10, 0)) button_row.pack(fill=tk.X, pady=(10, 0))
rows = [] # row dicts, table order ttk.Button(
row_by_item = {} # Treeview item id -> row dict button_row, text="Upgrade all kits to latest version", command=self._upgrade_all,
editor = {"widget": None} ).pack(side=tk.LEFT)
ttk.Button(
button_row, text="Update kits", command=self._update_marked,
).pack(side=tk.LEFT, padx=6)
ttk.Button(button_row, text="Close", command=self.close).pack(side=tk.RIGHT)
def close_editor(_event=None): self.rows = []
if editor["widget"] is not None: self.row_by_item = {}
editor["widget"].destroy() self._editor_widget = None
editor["widget"] = None
def action_text(row): self.table.bind("<Button-1>", self._on_click)
"""What the Action cell says, which an untick overrides with REMOVE.""" self.table.bind("<MouseWheel>", lambda _e: self._close_editor(), add="+")
self.table.bind("<Configure>", lambda _e: self._close_editor(), add="+")
self._rebuild_table()
def close(self):
if self.on_close is not None:
self.on_close()
def destroy(self):
self._close_editor()
self.frame.destroy()
def _close_editor(self):
if self._editor_widget is not None:
self._editor_widget.destroy()
self._editor_widget = None
def _action_text(self, row):
if row["declared"] and not row["checked"]: if row["declared"] and not row["checked"]:
return REMOVE_ACTION return REMOVE_ACTION
return row["options"][row["choice"]][0] return row["options"][row["choice"]][0]
def row_values(row): def _row_values(self, row):
return (row["kit"], row["installed_text"], row["latest_text"], action_text(row)) return (row["kit"], row["installed_text"], row["latest_text"], self._action_text(row))
def row_image(row): def _row_image(self, row):
return boxes[(row["checked"], not row["required"])] return self.boxes[(row["checked"], not row["required"])]
def rebuild_table(): def _rebuild_table(self):
close_editor() self._close_editor()
for item in table.get_children(): for item in self.table.get_children():
table.delete(item) self.table.delete(item)
row_by_item.clear() self.row_by_item.clear()
rows[:] = collect_rows() self.rows[:] = collect_rows()
for row in rows: for row in self.rows:
if "section" in row: if "section" in row:
table.insert("", "end", values=(row["section"], "", "", ""), tags=(SECTION_TAG,)) self.table.insert(
"", "end", values=(row["section"], "", "", ""), tags=(SECTION_TAG,),
)
continue continue
item = table.insert("", "end", image=row_image(row), values=row_values(row)) item = self.table.insert(
row_by_item[item] = row "", "end", image=self._row_image(row), values=self._row_values(row),
)
self.row_by_item[item] = row
def refresh_row(item): def _refresh_row(self, item):
row = row_by_item[item] row = self.row_by_item[item]
table.item(item, image=row_image(row), values=row_values(row)) self.table.item(item, image=self._row_image(row), values=self._row_values(row))
def set_choice(item, index): def _set_choice(self, item, index):
row = row_by_item[item] row = self.row_by_item[item]
row["choice"] = index 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: if row["declared"] or row["options"][index][1] is not None:
row["checked"] = True row["checked"] = True
refresh_row(item) self._refresh_row(item)
def toggle_check(item): def _toggle_check(self, item):
row = row_by_item[item] row = self.row_by_item[item]
if row["required"]: if row["required"]:
# Needed by the app: the tick is fixed, and the box is drawn greyed to say so.
return return
row["checked"] = not row["checked"] row["checked"] = not row["checked"]
if not row["declared"]: 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 row["choice"] = 1 if (row["checked"] and len(row["options"]) > 1) else 0
if row["checked"] and len(row["options"]) < 2: if row["checked"] and len(row["options"]) < 2:
row["checked"] = False row["checked"] = False
@@ -332,76 +338,68 @@ def open_dialog(on_applied=None):
"Manage Kits", "Manage Kits",
f"Sedona home holds no manifest for '{row['kit']}', so there is no " f"Sedona home holds no manifest for '{row['kit']}', so there is no "
"build to add it at.", "build to add it at.",
parent=dialog
) )
refresh_row(item) self._refresh_row(item)
def edit_action(item): def _edit_action(self, item):
row = row_by_item[item] row = self.row_by_item[item]
cell = table.bbox(item, "action") cell = self.table.bbox(item, "action")
if not cell: if not cell:
return return
labels = [label for label, _ in row["options"]] labels = [label for label, _ in row["options"]]
current = action_text(row) current = self._action_text(row)
variable = tk.StringVar(value=current if current in labels else labels[row["choice"]]) variable = tk.StringVar(value=current if current in labels else labels[row["choice"]])
combo = ttk.Combobox(table, textvariable=variable, values=labels, state="readonly") combo = ttk.Combobox(self.table, textvariable=variable, values=labels, state="readonly")
combo.place(x=cell[0], y=cell[1], width=cell[2], height=cell[3]) combo.place(x=cell[0], y=cell[1], width=cell[2], height=cell[3])
editor["widget"] = combo self._editor_widget = combo
def commit(_event=None): def commit(_event=None):
if variable.get() in labels: if variable.get() in labels:
set_choice(item, labels.index(variable.get())) self._set_choice(item, labels.index(variable.get()))
close_editor() self._close_editor()
combo.bind("<<ComboboxSelected>>", commit) combo.bind("<<ComboboxSelected>>", commit)
combo.bind("<Escape>", lambda event: close_editor()) combo.bind("<Escape>", lambda _e: self._close_editor())
combo.focus_set() 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>")) combo.after_idle(lambda: combo.event_generate("<Down>"))
def on_click(event): def _on_click(self, event):
close_editor() self._close_editor()
if table.identify_region(event.x, event.y) not in ("tree", "cell"): if self.table.identify_region(event.x, event.y) not in ("tree", "cell"):
return return
item = table.identify_row(event.y) item = self.table.identify_row(event.y)
if item not in row_by_item: if item not in self.row_by_item:
return return
column = table.identify_column(event.x) column = self.table.identify_column(event.x)
if column == "#0": if column == "#0":
toggle_check(item) self._toggle_check(item)
elif column == "#4": elif column == "#4":
edit_action(item) self._edit_action(item)
table.bind("<Button-1>", on_click) def _apply_changes(self, selections, removals, label, nothing_message, note=""):
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: if not selections and not removals:
messagebox.showinfo("Manage Kits", nothing_message, parent=dialog) messagebox.showinfo("Manage Kits", nothing_message)
return return
changes, error = sax_file.apply_kit_selections(selections, removals, label=label) changes, error = sax_file.apply_kit_selections(selections, removals, label=label)
if error: if error:
messagebox.showerror("Kits unchanged", error, parent=dialog) messagebox.showerror("Kits unchanged", error)
rebuild_table() self._rebuild_table()
return return
if not changes: if not changes:
messagebox.showinfo( messagebox.showinfo("Manage Kits", "The schema already reads that way.")
"Manage Kits", "The schema already reads that way.", parent=dialog
)
return return
rebuild_table() self._rebuild_table()
if on_applied is not None: if self.on_applied is not None:
on_applied() self.on_applied()
app_state.set_status( app_state.set_status(
f"schema kits changed ({len(changes)}) — save the file to keep it.", f"schema kits changed ({len(changes)}) — save the file to keep it.",
warn=True warn=True,
) )
messagebox.showinfo( messagebox.showinfo(
"Kits updated", "Kits updated",
@@ -409,13 +407,11 @@ def open_dialog(on_applied=None):
+ (note + "\n\n" if note else "") + (note + "\n\n" if note else "")
+ "Save the file to write this to disk. The device's scode must hold these " + "Save the file to write this to disk. The device's scode must hold these "
"builds or the application will not run.", "builds or the application will not run.",
parent=dialog
) )
def upgrade_all(): def _upgrade_all(self):
"""Every kit the schema declares, moved to the newest build Sedona home holds."""
selections = [] selections = []
for row in rows: for row in self.rows:
if "section" in row or not row["declared"] or not row["latest"]: if "section" in row or not row["declared"] or not row["latest"]:
continue continue
latest_checksum = row["latest"][1] latest_checksum = row["latest"][1]
@@ -432,22 +428,20 @@ def open_dialog(on_applied=None):
f"{len(selections)} kit{'' if len(selections) == 1 else 's'} in this app's " 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?", f"schema will move to their latest installed build:\n\n{preview}\n\nContinue?",
icon=messagebox.WARNING, icon=messagebox.WARNING,
parent=dialog
) )
if not proceed: if not proceed:
return return
apply_changes( self._apply_changes(
selections, [], selections, [],
"upgrade all kits", "upgrade all kits",
"Every kit in the schema is already at its latest installed build." "Every kit in the schema is already at its latest installed build.",
) )
def update_marked(): def _update_marked(self):
"""The ticks and the Action column, applied: repins, additions and removals."""
selections = [] selections = []
removals = [] removals = []
for row in row_by_item.values(): for row in self.row_by_item.values():
if row["declared"] and not row["checked"]: if row["declared"] and not row["checked"]:
removals.append(row["kit"]) removals.append(row["kit"])
continue continue
@@ -464,17 +458,14 @@ def open_dialog(on_applied=None):
"installed in Sedona home can be added back here." "installed in Sedona home can be added back here."
) )
apply_changes( self._apply_changes(
selections, removals, selections, removals,
"update kits", "update kits",
"Nothing is marked for a change. Pick a build in a kit's Action column, tick " "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." "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() def build_kits_panel(parent, on_applied, on_close):
"""Factory matching the pattern of properties.build_properties_panel."""
return KitsPanel(parent, on_applied=on_applied, on_close=on_close)
+18 -8
View File
@@ -46,8 +46,17 @@ def version_sort_key(version_string):
return numbers or [0] return numbers or [0]
def _norm_checksum(checksum):
"""Strips leading zeros so differently-padded hex checksums compare equal."""
return checksum.lstrip("0") if checksum else checksum
def kit_versions(kit_name): def kit_versions(kit_name):
"""Maps checksum -> version string, read from the .kit filenames under kits/.""" """Maps checksum -> version string, read from the .kit filenames under kits/.
Keys are stored with leading zeros stripped because .kit files and manifest
filenames sometimes disagree on padding (e.g. 462d43e vs 0462d43e).
"""
if kit_name in _versions_cache: if kit_name in _versions_cache:
return _versions_cache[kit_name] return _versions_cache[kit_name]
@@ -58,7 +67,7 @@ def kit_versions(kit_name):
for entry in os.listdir(kit_dir): for entry in os.listdir(kit_dir):
match = pattern.match(entry) match = pattern.match(entry)
if match: if match:
versions[match.group(1)] = match.group(2) versions[_norm_checksum(match.group(1))] = match.group(2)
_versions_cache[kit_name] = versions _versions_cache[kit_name] = versions
return versions return versions
@@ -89,13 +98,14 @@ def resolve_manifest_path(kit_name):
if pinned: if pinned:
candidate = f"{kit_name}-{pinned}.xml" candidate = f"{kit_name}-{pinned}.xml"
if candidate in manifests: if candidate in manifests:
return os.path.join(kit_dir, candidate), versions.get(pinned, pinned) return os.path.join(kit_dir, candidate), versions.get(_norm_checksum(pinned), pinned)
available = [] available = []
for entry in manifests: for entry in manifests:
checksum = entry[len(kit_name) + 1:-4] checksum = entry[len(kit_name) + 1:-4]
if checksum in versions: norm = _norm_checksum(checksum)
available.append((version_sort_key(versions[checksum]), entry, versions[checksum])) if norm in versions:
available.append((version_sort_key(versions[norm]), entry, versions[norm]))
if available: if available:
available.sort() available.sort()
@@ -104,7 +114,7 @@ def resolve_manifest_path(kit_name):
entry = manifests[-1] entry = manifests[-1]
checksum = entry[len(kit_name) + 1:-4] checksum = entry[len(kit_name) + 1:-4]
return os.path.join(kit_dir, entry), versions.get(checksum, "") return os.path.join(kit_dir, entry), versions.get(_norm_checksum(checksum), "")
def kit_builds(kit_name): def kit_builds(kit_name):
@@ -125,7 +135,7 @@ def kit_builds(kit_name):
if not (entry.startswith(f"{kit_name}-") and entry.endswith(".xml")): if not (entry.startswith(f"{kit_name}-") and entry.endswith(".xml")):
continue continue
checksum = entry[len(kit_name) + 1:-4] checksum = entry[len(kit_name) + 1:-4]
builds.append((versions.get(checksum, ""), checksum)) builds.append((versions.get(_norm_checksum(checksum), ""), checksum))
builds.sort( builds.sort(
key=lambda build: (bool(build[0]), version_sort_key(build[0])), key=lambda build: (bool(build[0]), version_sort_key(build[0])),
@@ -184,7 +194,7 @@ def version_for_checksum(kit_name, checksum):
""" """
if not checksum: if not checksum:
return "" return ""
return kit_versions(kit_name).get(checksum, "") return kit_versions(kit_name).get(_norm_checksum(checksum), "")
def _find_type(qualified_name): def _find_type(qualified_name):