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
+134 -143
View File
@@ -203,128 +203,134 @@ def describe_changes(changes):
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
class KitsPanel:
"""The kit table, built as an embedded panel in the editor pane."""
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
def __init__(self, parent, on_applied, on_close):
self.on_applied = on_applied
self.on_close = on_close
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)
self.frame = ttk.Frame(parent, padding=10)
frame = ttk.Frame(dialog, padding=10)
frame.pack(fill=tk.BOTH, expand=True)
COLUMNS = ("name", "installed", "latest", "action")
self.table = ttk.Treeview(
self.frame, columns=COLUMNS, show="tree headings", selectmode="browse",
)
self.table.heading("#0", text="")
self.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"),
]:
self.table.heading(column, text=heading, anchor=anchor)
self.table.column(column, width=width, anchor=anchor, stretch=(column == "action"))
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(self.frame, orient=tk.VERTICAL, command=self.table.yview)
self.table.configure(yscrollcommand=scrollbar.set)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.table.pack(fill=tk.BOTH, expand=True)
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)
self.table.tag_configure(SECTION_TAG, background="#dcdcdc")
table.tag_configure(SECTION_TAG, background="#dcdcdc")
self.boxes = {
(True, True): make_checkbox_image(True, True),
(False, True): make_checkbox_image(False, True),
(True, False): make_checkbox_image(True, False),
}
# 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(
self.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))
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(self.frame)
button_row.pack(fill=tk.X, pady=(10, 0))
button_row = ttk.Frame(frame)
button_row.pack(fill=tk.X, pady=(10, 0))
ttk.Button(
button_row, text="Upgrade all kits to latest version", command=self._upgrade_all,
).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)
rows = [] # row dicts, table order
row_by_item = {} # Treeview item id -> row dict
editor = {"widget": None}
self.rows = []
self.row_by_item = {}
self._editor_widget = None
def close_editor(_event=None):
if editor["widget"] is not None:
editor["widget"].destroy()
editor["widget"] = None
self.table.bind("<Button-1>", self._on_click)
self.table.bind("<MouseWheel>", lambda _e: self._close_editor(), add="+")
self.table.bind("<Configure>", lambda _e: self._close_editor(), add="+")
def action_text(row):
"""What the Action cell says, which an untick overrides with REMOVE."""
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"]:
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_values(self, row):
return (row["kit"], row["installed_text"], row["latest_text"], self._action_text(row))
def row_image(row):
return boxes[(row["checked"], not row["required"])]
def _row_image(self, row):
return self.boxes[(row["checked"], not row["required"])]
def rebuild_table():
close_editor()
for item in table.get_children():
table.delete(item)
row_by_item.clear()
def _rebuild_table(self):
self._close_editor()
for item in self.table.get_children():
self.table.delete(item)
self.row_by_item.clear()
rows[:] = collect_rows()
for row in rows:
self.rows[:] = collect_rows()
for row in self.rows:
if "section" in row:
table.insert("", "end", values=(row["section"], "", "", ""), tags=(SECTION_TAG,))
self.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
item = self.table.insert(
"", "end", image=self._row_image(row), values=self._row_values(row),
)
self.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 _refresh_row(self, item):
row = self.row_by_item[item]
self.table.item(item, image=self._row_image(row), values=self._row_values(row))
def set_choice(item, index):
row = row_by_item[item]
def _set_choice(self, item, index):
row = self.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)
self._refresh_row(item)
def toggle_check(item):
row = row_by_item[item]
def _toggle_check(self, item):
row = self.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
@@ -332,76 +338,68 @@ def open_dialog(on_applied=None):
"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)
self._refresh_row(item)
def edit_action(item):
row = row_by_item[item]
cell = table.bbox(item, "action")
def _edit_action(self, item):
row = self.row_by_item[item]
cell = self.table.bbox(item, "action")
if not cell:
return
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"]])
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])
editor["widget"] = combo
self._editor_widget = combo
def commit(_event=None):
if variable.get() in labels:
set_choice(item, labels.index(variable.get()))
close_editor()
self._set_choice(item, labels.index(variable.get()))
self._close_editor()
combo.bind("<<ComboboxSelected>>", commit)
combo.bind("<Escape>", lambda event: close_editor())
combo.bind("<Escape>", lambda _e: self._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"):
def _on_click(self, event):
self._close_editor()
if self.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:
item = self.table.identify_row(event.y)
if item not in self.row_by_item:
return
column = table.identify_column(event.x)
column = self.table.identify_column(event.x)
if column == "#0":
toggle_check(item)
self._toggle_check(item)
elif column == "#4":
edit_action(item)
self._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=""):
def _apply_changes(self, selections, removals, label, nothing_message, note=""):
if not selections and not removals:
messagebox.showinfo("Manage Kits", nothing_message, parent=dialog)
messagebox.showinfo("Manage Kits", nothing_message)
return
changes, error = sax_file.apply_kit_selections(selections, removals, label=label)
if error:
messagebox.showerror("Kits unchanged", error, parent=dialog)
rebuild_table()
messagebox.showerror("Kits unchanged", error)
self._rebuild_table()
return
if not changes:
messagebox.showinfo(
"Manage Kits", "The schema already reads that way.", parent=dialog
)
messagebox.showinfo("Manage Kits", "The schema already reads that way.")
return
rebuild_table()
if on_applied is not None:
on_applied()
self._rebuild_table()
if self.on_applied is not None:
self.on_applied()
app_state.set_status(
f"schema kits changed ({len(changes)}) — save the file to keep it.",
warn=True
warn=True,
)
messagebox.showinfo(
"Kits updated",
@@ -409,13 +407,11 @@ def open_dialog(on_applied=None):
+ (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."""
def _upgrade_all(self):
selections = []
for row in rows:
for row in self.rows:
if "section" in row or not row["declared"] or not row["latest"]:
continue
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"schema will move to their latest installed build:\n\n{preview}\n\nContinue?",
icon=messagebox.WARNING,
parent=dialog
)
if not proceed:
return
apply_changes(
self._apply_changes(
selections, [],
"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():
"""The ticks and the Action column, applied: repins, additions and removals."""
def _update_marked(self):
selections = []
removals = []
for row in row_by_item.values():
for row in self.row_by_item.values():
if row["declared"] and not row["checked"]:
removals.append(row["kit"])
continue
@@ -464,17 +458,14 @@ def open_dialog(on_applied=None):
"installed in Sedona home can be added back here."
)
apply_changes(
self._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."
"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)