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 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")
DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True
+44 -6
View File
@@ -111,11 +111,12 @@ zoom_label = None
menu_view = None
show_left_column_var = None
# The Properties and Link views each take the editor pane over from the canvas rather
# than floating above it, so at most one of the three is on screen and these two say
# which. Both None means the sheet is showing.
# The Properties, Link and Kits views each take the editor pane over from the canvas
# rather than floating above it, so at most one of the four is on screen and these three
# say which. All None means the sheet is showing.
properties_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
# 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():
"""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():
@@ -316,17 +317,54 @@ def dismiss_link_view(restore_canvas=True):
_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):
"""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
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
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:
dismiss_link_view()
return True
if kits_panel is not None:
dismiss_kits()
return True
if properties_panel is None:
return True
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.add_command(
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)
+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)
+18 -8
View File
@@ -46,8 +46,17 @@ def version_sort_key(version_string):
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):
"""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:
return _versions_cache[kit_name]
@@ -58,7 +67,7 @@ def kit_versions(kit_name):
for entry in os.listdir(kit_dir):
match = pattern.match(entry)
if match:
versions[match.group(1)] = match.group(2)
versions[_norm_checksum(match.group(1))] = match.group(2)
_versions_cache[kit_name] = versions
return versions
@@ -89,13 +98,14 @@ def resolve_manifest_path(kit_name):
if pinned:
candidate = f"{kit_name}-{pinned}.xml"
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 = []
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]))
norm = _norm_checksum(checksum)
if norm in versions:
available.append((version_sort_key(versions[norm]), entry, versions[norm]))
if available:
available.sort()
@@ -104,7 +114,7 @@ def resolve_manifest_path(kit_name):
entry = manifests[-1]
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):
@@ -125,7 +135,7 @@ def kit_builds(kit_name):
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.append((versions.get(_norm_checksum(checksum), ""), checksum))
builds.sort(
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:
return ""
return kit_versions(kit_name).get(checksum, "")
return kit_versions(kit_name).get(_norm_checksum(checksum), "")
def _find_type(qualified_name):