From a77edead2526171768dff5879020a87497ae3248 Mon Sep 17 00:00:00 2001 From: Arda Aydin Date: Tue, 8 Sep 2026 21:08:59 +0300 Subject: [PATCH] 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 --- app_state.py | 2 +- main.py | 50 +++++++-- manage_kits.py | 277 ++++++++++++++++++++++++------------------------- palette.py | 26 +++-- 4 files changed, 197 insertions(+), 158 deletions(-) diff --git a/app_state.py b/app_state.py index faf5306..f897b09 100644 --- a/app_state.py +++ b/app_state.py @@ -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 diff --git a/main.py b/main.py index 4e12362..233bb87 100644 --- a/main.py +++ b/main.py @@ -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 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) diff --git a/manage_kits.py b/manage_kits.py index ea69961..2a3152e 100644 --- a/manage_kits.py +++ b/manage_kits.py @@ -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 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("", self._on_click) + self.table.bind("", lambda _e: self._close_editor(), add="+") + self.table.bind("", 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("<>", commit) - combo.bind("", lambda event: close_editor()) + combo.bind("", 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("")) - 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("", on_click) - table.bind("", close_editor, add="+") - table.bind("", 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) diff --git a/palette.py b/palette.py index 8ca17b3..796b440 100644 --- a/palette.py +++ b/palette.py @@ -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):