# Sedona SAX Tree View Viewer & Program Editor A Tkinter desktop editor for Sedona Framework application files (`.sax`). It shows the component hierarchy as a navigation tree, paints the selected folder's children as a wiresheet of draggable component boxes, draws the links between them, and writes box positions back into each component's `meta` property on save. Current version: **0.0.0.024** (see [Versioning](#versioning)). ## Requirements Python **3.13 from Homebrew**, which brings Tk 9: ```bash brew install python-tk@3.13 ``` Do **not** run this with `/usr/bin/python3`. Apple's system Python ships Tk 8.5.9, a 2010 build that is broken on current macOS: windows open at the right size with the right titles, but no widget contents ever paint. A blank white panel is the symptom, and it looks exactly like a layout bug in this code. It isn't. Verify which Tk an interpreter has: ```bash /opt/homebrew/bin/python3.13 -c "import tkinter; r=tkinter.Tk(); print(r.tk.call('info','patchlevel'))" ``` ## Running ```bash cd sedona_editor /opt/homebrew/bin/python3.13 run.py ``` On first launch, set **Sedona home folder** in the Preferences menu — the directory containing `manifests/` and `kits/`. Nothing will load until it is set. Preferences are stored in `editor.properties`, read from the current working directory. On macOS the menu bar (File / App / Preferences) appears at the **top of the screen**, not inside the window. Tk always hands it to the system menu bar. ## Module layout | Module | Responsibility | Needs a display | |---|---|---| | `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 | | `run.py` | Entry point | yes | Imports form a DAG — `sax_dom`/`schema` depend on nothing UI-shaped, `wiresheet` and `sax_file` build on them, `main` wires it together. Event handlers live in `main`, which is what keeps the graph acyclic. ### Shared state Modules reach shared state as **`app_state.`**, always qualified. A bare `from app_state import is_dirty` would bind a copy, so a later rebinding would be 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 | | `Ctrl`/`Cmd` + `B` | Show or hide the tree/palette column | | `Ctrl` + `W` | Tidy Layout: re-flow the open sheet | | `Alt` + `Left` / `Alt` + `Right` | Back and forward through the sheets opened this session | | `Ctrl`/`Cmd` + `+` / `-` | Zoom the sheet in and out | | `Ctrl`/`Cmd` + `0` | Zoom back to 100% | | `Ctrl`/`Cmd` + `9` | Zoom to Fit: show the whole sheet | | `Ctrl` + wheel | Zoom around the pointer | 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. Tidy Layout is the exception, on `Control-` alone: `Cmd` + `W` closes the window on macOS. ### The View menu **Tree / Palette** hides the left column so the wiresheet has the whole window, and puts it back at its configured width. ttk has no hide flag for a pane, so the column is dropped from the paned window and re-inserted at the front. **Zoom In**, **Zoom Out** and **Zoom 100%** step a ladder of scales from 25% to 300%, with the zoom buttons, the percentage and the fit icon sitting beside the navigation arrows in the toolbar. **Zoom to Fit** is the one thing that lands between the ladder's rungs -- a sheet fits at whatever scale it fits at -- and stepping in or out from there rejoins the ladder. It measures the sheet from item coordinates rather than `canvas.bbox()`, which is inflated by the outline stroke: a stroke is the same two pixels at every zoom, so a bbox divided back to 1:1 reports a slightly smaller sheet the further in it is zoomed, and fitting an already fitted sheet would creep instead of standing still. Anything painted left of or above the canvas origin is measured from the origin instead, since no amount of scrolling can bring it into view. The sheet is painted and routed at 1:1 -- box metrics, wire stubs, lane gaps, clearances and knob radii are all tuned in those pixels -- and `apply_sheet_zoom` scales the finished picture, so a zoomed sheet keeps exactly the proportions of a 1:1 one and the router makes the same decisions at every scale. Text scales because the three box fonts are resized after the boxes were measured with them. `redraw_links()` is what a drag or a drop calls: it puts the boxes back to 1:1 for the routing pass and scales the result again, since the router reads box positions straight off the canvas. Canvas coordinates therefore carry the zoom, which is why the drag, release and drop handlers snap against `meta_scale * zoom`. **Back** and **Forward** walk the sheets opened this session, mirrored by the two arrow buttons in the toolbar. The history records the element whose children are painted -- the same thing `app_state.current_sheet_parent` holds -- rather than a tree item id, because rebuilding the tree hands out fresh ids while the elements themselves survive. Opening a different file retires the history; a delete or an undo drops only the entries whose element has left the document, keeping the position on screen. Walking the history drives the tree selection, which is what paints the sheet, and is suppressed from recording so a walk cannot rewrite the trail it is following. ### Following a link to its other sheet Right-clicking a wire or a knob offers **Go to Link Wire Sheet ""** alongside Delete Link, naming the sheet the far end of that link is on -- the end a grey knob stands in for. The entry is left off a link with both ends on the open sheet, which has nowhere to go. `find_component_by_path` in `sax_dom` turns a ``'s from/to reference back into the component it points at, the inverse of `get_component_path`, and the sheet is that component's parent; a parent of `` is the file's root node, which the popup names after the file. The jump selects the same link again once it arrives. It has to be found by its element rather than by the tag it was picked on, because a repaint hands out fresh wire tags, and it has to wait for that repaint: ttk queues its selection event rather than dispatching it, so the sheet is not painted at the point the tree node is selected -- and painting a sheet clears whatever link was selected. The selection is therefore made from an `after_idle` callback, and the link is scrolled to the middle of the view. Arriving is an ordinary sheet opening, so **Back** returns to the sheet the link was picked on. ### Testing without a window `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 import app_state, schema, wiresheet app_state.SEDONA_HOME = "../sedona" app_state.xml_root_element = ET.parse("../DDC_8-ATP_PION_17.sax").getroot() schema.load_schema_kits_and_manifests() idx = wiresheet.build_linked_slot_index() print(schema.get_slots_for_type("ontrolControl::HvacControl", idx.get("/AHU20_1/TmpCont/HvacCon", ()))) ``` ## How it reads a Sedona app ### Manifests Each `` in the file's `` block is resolved to `/manifests//-.xml`, falling back to the 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 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 A component type can declare far more slots than are worth seeing; `HvacControl` has 47. Rows are filtered by these rules, in precedence order: | Precedence | Condition | Result | |---|---|---| | 1 | Slot appears in `` (either end) | **Show** — overrides everything below | | 2 | `` facet | Hide | | 3 | Runtime slot (no `c` in `flags`) | **Show** | | 4 | Config slot (`c`), unlinked | Hide | | 5 | `o` flag (operator) | Hide | Rule 1 outranks rule 2 deliberately: a linked slot with `@summary false` still needs a row for its wire to terminate on, otherwise the wire points at the box's centre. Row numbers are the manifest slot ids, so hidden rows leave **gaps** in the numbering (`01, 02, 03, 04, 05, 08, 11...`) rather than renumbering. The numbers stay meaningful against the manifest. Consequence worth knowing: `meta` is a config slot that nothing links to, so it is hidden from every box. It holds the packed position, not process data. ### Why outputs read `null` Config slots carry `flags="c"` and are persisted in the `.sax`. Runtime slots carry no flag and are **never** persisted — they only exist on a live device. So `out` and `in` have no `` in the file and the renderer substitutes the string `null`. That is correct for an offline app dump, not a fault. ### The `meta` property A packed 32-bit integer, decoded in `sax_dom.py`: ``` bits 31-24 x position, in grid units bits 23-16 y position, in grid units bits 15-8 reserved (preserved verbatim on write) bits 7-0 user group bits 1-4 in the low nibble, reserved high nibble ``` Writes re-encode only x and y and preserve every other bit, so dragging a box never disturbs the rest of the value. ### Links `` sits at the document root, with `from`/`to` references of the form `/path/to/Component.slotName`. - Both endpoints on the current sheet → an orthogonal wire is routed **around** the boxes. Routing tries a clear vertical channel between the two boxes first, then a lane above or below everything (which is what feedback links right-to-left need), and only falls back to a direct dog-leg if nothing is clear. - One endpoint off-sheet → a **knob** is drawn at the slot's edge, marking a connection that leaves this sheet. In the sample app, 137 of 217 links are same-sheet, so knobs are not a rare case. Wires and knobs are painted behind the component boxes and re-routed on every drag step, since they are not part of the dragged canvas group. ## Versioning `VERSION` lives in `app_state.py` and is shown in the window title. - **Feature** → bump the version. - **Bug fix** → no bump. - **Refactor with no behaviour change** → no bump. ## Known gaps - 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. - 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.