diff --git a/README.md b/README.md index c560c95..74ac8be 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ not inside the window. Tk always hands it to the system menu bar. | `app_state.py` | Preferences, runtime state, widget handles, `mark_dirty`/`mark_clean` | no | | `sax_dom.py` | `meta` bit-packing codec, component path resolution | no | | `schema.py` | Kit manifest loading, slot inheritance, slot visibility rules | no | +| `undo.py` | Undo/redo stack of reversible DOM commands | no | | `wiresheet.py` | Grid, component boxes, link routing and painting | yes | | `sax_file.py` | Open/save, navigation tree, cascading delete | yes | | `main.py` | Widgets, menus, event handlers, dialogs, main loop | yes | @@ -63,10 +64,32 @@ Modules reach shared state as **`app_state.`**, always qualified. A bare invisible to every other module. Every mutation of "the document changed" goes through `app_state.mark_dirty()`, which also applies the unsaved-state pink canvas tint. +### Undo and keyboard shortcuts + +`undo.py` holds a stack of commands that own their DOM mutation in both directions: +`MetaMoveCommand` (a box drag, or a whole Tidy Layout as one step), `AddComponentCommand` +(which also removes the `` entry placing it may have added) and +`DeleteComponentCommand` (which restores the removed `` entries at their original +indices, reinserting in ascending order so the indices stay valid). Nothing in +`undo.py` touches Tk; `main` registers a refresh callback at startup, which is what keeps +`sax_file` and `wiresheet` free to push commands without an import cycle. + +Undoing back to the depth of the last save clears the dirty tint rather than leaving it lit. + +| Shortcut | Action | +|---|---| +| `Ctrl`/`Cmd` + `S` | Save (direct overwrite) | +| `Ctrl`/`Cmd` + `Z` | Undo | +| `Ctrl`/`Cmd` + `Y` or `Ctrl`/`Cmd` + `Shift` + `Z` | Redo | +| `Del` / `Backspace` | Delete selected component | + +Both `Command-` and `Control-` are bound for each, so the same build works on macOS and +Windows; menu accelerators render as `Cmd` or `Ctrl` per platform. + ### Testing without a window -`app_state`, `sax_dom`, `schema` and `wiresheet` import with no display, so the rules -can be exercised directly: +`app_state`, `sax_dom`, `schema`, `undo` and `wiresheet` import with no display, so the +rules can be exercised directly: ```python import xml.etree.ElementTree as ET @@ -90,8 +113,14 @@ Each `` in the file's `` block is resolved to highest-sorting file in that directory when the checksum is absent. Slots are flattened along the `base` chain, so inherited slots keep their declaration order. -A kit with no manifest directory is currently **fatal** — the app reports it and calls -`sys.exit(1)`. See [Known gaps](#known-gaps). +A kit with no manifest aborts **that load only**. `schema.load_schema_kits_and_manifests()` +raises `schema.MissingManifestError` naming every unresolved kit at once, the caller shows a +dialog asking the user to check the Sedona home folder, and `sax_file.reset_to_startup_state()` +returns the app to its just-launched state — no file, empty tree, empty sheet. The app stays +open, so the folder can be corrected in Preferences and the file opened again. + +The same error raised while placing a palette component only abandons that placement: the +`` entry just added to `` is removed and the previous registry is rebuilt. ### Slot visibility @@ -163,11 +192,10 @@ step, since they are not part of the dragged canvas group. ## Known gaps -- A kit missing from `/manifests/` exits the whole app rather than warning - and declining to open that file. - The yellow square in each box header is drawn but wired to nothing; it is the natural place for a per-box collapse/expand toggle. -- No undo, and no keyboard shortcuts for save. +- Undo covers box moves, adds and deletes. Nothing else pushes a command, so a change made + outside those three is not reversible. - `on_canvas_release` snaps using the rounded-rectangle polygon's first coordinate, which is `x1 + radius` rather than `x1`, so snapping is offset by the corner radius. - Slot values are display-only; there is no editing of a slot from the wiresheet. diff --git a/app_state.py b/app_state.py index 29b6f80..dac6362 100644 --- a/app_state.py +++ b/app_state.py @@ -94,6 +94,11 @@ def save_preferences(): config.write(configfile) +def base_window_title(): + """The window title with no file context, which handlers extend with their own suffix.""" + return f"Sedona SAX Tree View Viewer & Program Editor [v{VERSION}]" + + def mark_dirty(): """Single funnel for 'the document changed', including the unsaved-state tint.""" global is_dirty diff --git a/main.py b/main.py index 2caeea2..b7ffae6 100644 --- a/main.py +++ b/main.py @@ -23,9 +23,7 @@ def do_undo(event=None): label = undo.undo() if label is None: return "break" - app_state.window.title( - f"Sedona SAX Tree View Viewer & Program Editor [v{app_state.VERSION}] — undid {label}" - ) + app_state.window.title(f"{app_state.base_window_title()} — undid {label}") return "break" @@ -33,9 +31,7 @@ def do_redo(event=None): label = undo.redo() if label is None: return "break" - app_state.window.title( - f"Sedona SAX Tree View Viewer & Program Editor [v{app_state.VERSION}] — redid {label}" - ) + app_state.window.title(f"{app_state.base_window_title()} — redid {label}") return "break" @@ -440,7 +436,7 @@ def verify_startup_configurations(): # --- UI Setup --- window = tk.Tk() app_state.window = window -window.title(f"Sedona SAX Tree View Viewer & Program Editor [v{app_state.VERSION}]") +window.title(app_state.base_window_title()) window.geometry("950x600") # --- Context Popups Structures --- diff --git a/sax_file.py b/sax_file.py index 1c3d78b..ae2cbc6 100644 --- a/sax_file.py +++ b/sax_file.py @@ -53,6 +53,42 @@ def refresh_treeview_from_dom(xml_root): parse_xml_to_tree(tree, root_node, xml_root) +def reset_to_startup_state(): + """Returns the app to its just-launched state: no file, empty tree, empty sheet. + + A load that fails part way has already cleared the previous file, so leaving the + half-built state on screen would show a tree and a DOM that no longer agree. This + is the one place that unwinds all of it. + """ + app_state.current_file_path = None + app_state.xml_root_element = None + app_state.current_sheet_parent = None + app_state.selected_canvas_tag = None + app_state.selected_tree_item_id = None + + app_state.active_components_list.clear() + app_state.tree_element_map.clear() + app_state.canvas_comp_map.clear() + app_state.canvas_tag_by_element.clear() + app_state.component_slot_offsets.clear() + app_state.final_components_registry.clear() + app_state.active_schema_kits.clear() + + undo.reset() + app_state.mark_clean() + + if app_state.tree is not None: + for item in app_state.tree.get_children(): + app_state.tree.delete(item) + + if app_state.canvas is not None: + app_state.canvas.delete("all") + wiresheet.draw_grid() + + if app_state.window is not None: + app_state.window.title(app_state.base_window_title()) + + def open_file(): if not app_state.SEDONA_HOME: messagebox.showwarning("Action Blocked", "Cannot load SAX profiles because 'Sedona home folder' configuration is missing or empty.") @@ -98,8 +134,17 @@ def open_file(): else: parse_xml_to_tree(tree, root_node, app_state.xml_root_element) + except schema.MissingManifestError as missing: + messagebox.showerror( + "Kit manifest missing", + f"'{os.path.basename(file_path)}' was not opened.\n\n" + + schema.describe_missing_manifests(missing) + + "\n\nFix the Sedona home folder, then open the file again." + ) + reset_to_startup_state() except Exception as e: messagebox.showerror("Error", f"Failed to parse the file:\n{str(e)}") + reset_to_startup_state() def sync_live_metadata_to_dom(): @@ -330,7 +375,24 @@ def add_component(kit_name, type_name, grid_x, grid_y): return None # New kit means new types to resolve before the box can be painted. - schema.load_schema_kits_and_manifests() + try: + schema.load_schema_kits_and_manifests() + except schema.MissingManifestError as missing: + messagebox.showerror( + "Kit manifest missing", + f"'{kit_name}' was not added.\n\n" + + schema.describe_missing_manifests(missing) + ) + # The kit entry goes back out and the registry is rebuilt from the kits that + # were resolvable a moment ago, so only this placement is abandoned. + schema_elem = app_state.xml_root_element.find("schema") + if schema_elem is not None: + schema_elem.remove(kit_added) + try: + schema.load_schema_kits_and_manifests() + except schema.MissingManifestError: + reset_to_startup_state() + return None comp_id = allocate_component_id() comp_name = unique_child_name(parent, type_name) diff --git a/schema.py b/schema.py index 95b2758..cc16f01 100644 --- a/schema.py +++ b/schema.py @@ -1,13 +1,47 @@ """Kit manifest loading, slot inheritance, and which slots are worth showing.""" import os -import sys -from tkinter import messagebox import xml.etree.ElementTree as ET import app_state +class MissingManifestError(Exception): + """A kit the file needs has no manifest under the Sedona home folder. + + Raised rather than handled here, so the caller decides what to abandon: opening a + file abandons the whole load, placing a palette component only abandons that + component. Carrying the kit names lets either one name them in its own wording. + """ + + def __init__(self, kit_names, unresolved_type=None): + self.kit_names = sorted(set(kit_names)) + self.unresolved_type = unresolved_type + super().__init__(", ".join(self.kit_names)) + + +def describe_missing_manifests(error): + """The body of the dialog both callers show, pointing at the folder to check.""" + kit_list = "\n".join(f" • {name}" for name in error.kit_names) + home = app_state.SEDONA_HOME or "(not set)" + + lines = ["No manifest was found for:", "", kit_list, "", f"Sedona home folder:\n {home}", ""] + + if error.unresolved_type: + lines.append( + f"Type '{error.unresolved_type}' inherits from a kit that is not in the " + "file's schema block, so its slots cannot be resolved." + ) + lines.append("") + + lines.append( + "Check that the Sedona home folder is the right one and that it holds " + "manifests//-.xml for each kit listed above. " + "Preferences → Sedona home folder points it somewhere else." + ) + return "\n".join(lines) + + def read_summary_facet(slot_element): """Reads a slot's '@summary' facet. Absent means summary, matching manifest convention.""" facets = slot_element.find("facets") @@ -23,8 +57,7 @@ def get_slots_with_inheritance(type_name, kit_schemas): """Recursively resolves the slots for a given type by traversing its base chain.""" if type_name not in kit_schemas: kit_prefix = type_name.split("::")[0] if "::" in type_name else type_name - messagebox.showerror("Schema Warning", f"Kit {kit_prefix} is not in Schema selection!") - sys.exit(1) + raise MissingManifestError([kit_prefix], unresolved_type=type_name) type_element = kit_schemas[type_name] base_type = type_element.get("base") @@ -93,6 +126,7 @@ def load_schema_kits_and_manifests(): return manifest_paths = [] + missing_kits = [] for kit in schema_elem.findall("kit"): name = kit.get("name") checksum = kit.get("checksum") @@ -117,8 +151,12 @@ def load_schema_kits_and_manifests(): if manifest_path: manifest_paths.append(manifest_path) else: - messagebox.showerror("Schema Warning", f"Kit {name} is not in Schema selection!") - sys.exit(1) + # Every missing kit is collected before reporting, so one dialog names them + # all rather than the user fixing them one reopen at a time. + missing_kits.append(name) + + if missing_kits: + raise MissingManifestError(missing_kits) compile_schema_registry(manifest_paths)