Warn and reset instead of exiting when a kit manifest is missing
A kit with no manifest under the Sedona home folder called sys.exit(1), tearing down the whole app over one unopenable file. schema now raises MissingManifestError instead, collecting every unresolved kit first so one dialog names them all rather than the user fixing them one reopen at a time. describe_missing_manifests builds the body, pointing at the configured Sedona home and at Preferences. open_file catches it, reports it, and calls the new reset_to_startup_state to return to the just-launched state: no file, empty tree, empty sheet, cleared registries and undo stack. The generic except now resets too, since a half-parsed file previously left current_file_path and a stale root behind. Placing a palette component hits the same loader, where a full reset would be too destructive: it rolls back the <kit> entry it just added, rebuilds the previous registry, and abandons only that placement. schema no longer imports sys or tkinter, so it reports nothing itself and stays testable without a display. base_window_title moves to app_state so the reset can restore the title without duplicating it. README: document the new behaviour, drop the fixed gap, and correct the stale "no undo, no keyboard shortcuts for save" claim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
9c283c7eea
commit
c8026f00db
42
README.md
42
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.<name>`**, 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 `<kit>` entry placing it may have added) and
|
||||
`DeleteComponentCommand` (which restores the removed `<link>` 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 `<kit name="..."/>` in the file's `<schema>` 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
|
||||
`<kit>` entry just added to `<schema>` 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 `<sedona home>/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.
|
||||
|
||||
@ -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
|
||||
|
||||
10
main.py
10
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 ---
|
||||
|
||||
62
sax_file.py
62
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.
|
||||
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)
|
||||
|
||||
50
schema.py
50
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/<kit>/<kit>-<checksum>.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)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user