Files
ArdaandClaude Opus 5.5 f22d4852c9 Fix Manage Kits for apps read from a device
The schema built from a Sox session spelled checksums as signed decimals,
so no kit matched a manifest: Installed showed raw checksums and Action
read "Keep at checksum ...". They are now 8 hex digits like a .sax file,
and checksums compare regardless of zero-padding.

A device session only loads folders as they are opened, so only sys
counted as used. Used kits now come from the whole component tree, read
one request at a time so a small controller is not flooded into dropping
the session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:10:53 +03:00

574 lines
21 KiB
Python

"""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 os
from datetime import datetime
import tkinter as tk
from tkinter import filedialog, 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),
]
# The cross (X), as filled 2x2 blocks: two diagonal strokes.
CROSS_BLOCKS = [
(2, 2), (3, 3), (4, 4), (5, 5), (6, 6), (7, 7), (8, 8), (9, 9),
(9, 2), (8, 3), (7, 4), (6, 5), (5, 6), (4, 7), (3, 8), (2, 9),
]
def make_checkbox_image(checked, enabled, cross=False):
"""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 cross:
for x, y in CROSS_BLOCKS:
image.put("#b04040", to=(x, y, x + 2, y + 2))
elif 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 same_checksum(a, b):
"""Whether two checksums name the same build, whatever their zero-padding."""
return bool(a) and bool(b) and a.lower().lstrip("0") == b.lower().lstrip("0")
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 same_checksum(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 same_checksum(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]
bad = palette.unsupported_kits()
compatible = [name for name in others if name not in bad]
incompatible = [name for name in others if name in bad]
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 available on the computer", [(name, None) for name in compatible]),
("Not supported", [(name, None) for name in incompatible]),
]
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
is_unsupported = name in bad
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 same_checksum(c, checksum)), ""), checksum
)
rows.append({
"kit": name,
"declared": is_declared,
"required": name in required,
"unsupported": is_unsupported,
"checksum": checksum,
"builds": builds,
"latest": latest,
"installed_text": installed_text,
"latest_text": column_label(*latest) if latest else NOT_DECLARED,
"options": options,
"choice": 0,
"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)
class KitsPanel:
"""The kit table, built as an embedded panel in the editor pane."""
def __init__(self, parent, on_applied, on_close):
self.on_applied = on_applied
self.on_close = on_close
self.frame = ttk.Frame(parent, padding=10)
COLUMNS = ("name", "installed", "latest", "action")
table_frame = ttk.Frame(self.frame)
table_frame.pack(fill=tk.BOTH, expand=True)
self.table = ttk.Treeview(
table_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", 190, "w"),
("installed", "Installed", 105, "w"),
("latest", "Latest", 105, "w"),
("action", "Action", 195, "w"),
]:
self.table.heading(column, text=heading, anchor=anchor)
self.table.column(column, width=width, minwidth=width, anchor=anchor, stretch=False)
vscroll = ttk.Scrollbar(table_frame, orient=tk.VERTICAL, command=self.table.yview)
hscroll = ttk.Scrollbar(table_frame, orient=tk.HORIZONTAL, command=self.table.xview)
self.table.configure(yscrollcommand=vscroll.set, xscrollcommand=hscroll.set)
vscroll.pack(side=tk.RIGHT, fill=tk.Y)
hscroll.pack(side=tk.BOTTOM, fill=tk.X)
self.table.pack(fill=tk.BOTH, expand=True)
self.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),
(False, False): make_checkbox_image(False, False),
}
self.cross_box = make_checkbox_image(False, False, cross=True)
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))
button_row = ttk.Frame(self.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)
ttk.Button(
button_row, text="Export as XLS", command=self._export_xls,
).pack(side=tk.RIGHT, padx=6)
self.rows = []
self.row_by_item = {}
self._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="+")
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 _export_xls(self):
try:
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
except ImportError:
messagebox.showerror("Export", "openpyxl is not installed.")
return
default_name = ""
if app_state.current_file_path:
base = os.path.splitext(os.path.basename(app_state.current_file_path))[0]
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
default_name = f"{base}_kits_{stamp}.xlsx"
path = filedialog.asksaveasfilename(
title="Export Kit Table",
defaultextension=".xlsx",
initialfile=default_name,
filetypes=[("Excel Files", "*.xlsx")],
)
if not path:
return
wb = Workbook()
ws = wb.active
ws.title = "Kits"
headers = ["", "Kit name", "Installed", "Latest", "Action"]
header_font = Font(bold=True)
section_fill = PatternFill(start_color="DCDCDC", end_color="DCDCDC", fill_type="solid")
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=header)
cell.font = header_font
row_num = 2
for row in self.rows:
if "section" in row:
cell = ws.cell(row=row_num, column=2, value=row["section"])
cell.font = Font(bold=True)
for col in range(1, 6):
ws.cell(row=row_num, column=col).fill = section_fill
row_num += 1
continue
check = "✓" if row["checked"] else ""
ws.cell(row=row_num, column=1, value=check).alignment = Alignment(horizontal="center")
ws.cell(row=row_num, column=2, value=row["kit"])
ws.cell(row=row_num, column=3, value=row["installed_text"])
ws.cell(row=row_num, column=4, value=row["latest_text"])
ws.cell(row=row_num, column=5, value=self._action_text(row))
row_num += 1
ws.column_dimensions["A"].width = 4
ws.column_dimensions["B"].width = 28
ws.column_dimensions["C"].width = 16
ws.column_dimensions["D"].width = 16
ws.column_dimensions["E"].width = 30
try:
wb.save(path)
app_state.set_status(f"Kit table exported to {os.path.basename(path)}")
except Exception as e:
messagebox.showerror("Export", f"Could not save file:\n{e}")
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(self, row):
return (row["kit"], row["installed_text"], row["latest_text"], self._action_text(row))
def _row_image(self, row):
if row.get("unsupported"):
return self.cross_box
return self.boxes[(row["checked"], not row["required"])]
def _rebuild_table(self):
self._close_editor()
for item in self.table.get_children():
self.table.delete(item)
self.row_by_item.clear()
self.rows[:] = collect_rows()
for row in self.rows:
if "section" in row:
self.table.insert(
"", "end", values=(row["section"], "", "", ""), tags=(SECTION_TAG,),
)
continue
item = self.table.insert(
"", "end", image=self._row_image(row), values=self._row_values(row),
)
self.row_by_item[item] = 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(self, item, index):
row = self.row_by_item[item]
row["choice"] = index
if row["declared"] or row["options"][index][1] is not None:
row["checked"] = True
self._refresh_row(item)
def _toggle_check(self, item):
row = self.row_by_item[item]
if row["required"] or row.get("unsupported"):
return
row["checked"] = not row["checked"]
if not row["declared"]:
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.",
)
self._refresh_row(item)
def _edit_action(self, item):
row = self.row_by_item[item]
if row.get("unsupported"):
return
cell = self.table.bbox(item, "action")
if not cell:
return
labels = [label for label, _ in row["options"]]
current = self._action_text(row)
variable = tk.StringVar(value=current if current in labels else labels[row["choice"]])
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])
self._editor_widget = combo
def commit(_event=None):
if variable.get() in labels:
self._set_choice(item, labels.index(variable.get()))
self._close_editor()
combo.bind("<<ComboboxSelected>>", commit)
combo.bind("<Escape>", lambda _e: self._close_editor())
combo.focus_set()
combo.after_idle(lambda: combo.event_generate("<Down>"))
def _on_click(self, event):
self._close_editor()
if self.table.identify_region(event.x, event.y) not in ("tree", "cell"):
return
item = self.table.identify_row(event.y)
if item not in self.row_by_item:
return
column = self.table.identify_column(event.x)
if column == "#0":
self._toggle_check(item)
elif column == "#4":
self._edit_action(item)
def _apply_changes(self, selections, removals, label, nothing_message, note=""):
if not selections and not removals:
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)
self._rebuild_table()
return
if not changes:
messagebox.showinfo("Manage Kits", "The schema already reads that way.")
return
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,
)
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.",
)
def _upgrade_all(self):
selections = []
for row in self.rows:
if "section" in row or not row["declared"] or not row["latest"]:
continue
latest_checksum = row["latest"][1]
if not same_checksum(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,
)
if not proceed:
return
self._apply_changes(
selections, [],
"upgrade all kits",
"Every kit in the schema is already at its latest installed build.",
)
def _update_marked(self):
selections = []
removals = []
for row in self.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."
)
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.",
)
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)