473 lines
17 KiB
Python
473 lines
17 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 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)
|
|
|
|
|
|
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")
|
|
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, stretch, anchor in [
|
|
("name", "Kit name", 200, True, "w"),
|
|
("installed", "Installed", 105, False, "w"),
|
|
("latest", "Latest", 105, False, "w"),
|
|
("action", "Action", 300, False, "w"),
|
|
]:
|
|
self.table.heading(column, text=heading, anchor=anchor)
|
|
self.table.column(column, width=width, anchor=anchor, stretch=stretch)
|
|
|
|
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)
|
|
|
|
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),
|
|
}
|
|
|
|
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)
|
|
|
|
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 _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):
|
|
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"]:
|
|
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]
|
|
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 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)
|