Right-clicking a component in the navigation tree offered Delete Component alone, while the same component on the wiresheet also offered Link Mark and Link From / Link To. Which list a component was picked from should not change what can be done with it, so both popups are now built by fill_component_popup. Its entries close over the component instead of reading a selection back, which is what lets each popup act on the row or box that was actually clicked -- the canvas commands went through app_state.selected_canvas_tag, which a tree row never sets. do_link_mark, do_link_from_mark, do_link_to_mark and delete_tree_selected_component fall out with it, and Link Mark now says in the status bar what it marked, since nothing else on screen shows that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
270 lines
13 KiB
Markdown
270 lines
13 KiB
Markdown
# 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.<name>`**, 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 `<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 |
|
|
| `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.
|
|
|
|
### The component popup
|
|
|
|
Right-clicking a component offers the same actions from the wiresheet and from the
|
|
navigation tree -- Delete Component, Link Mark, and Link From / Link To naming the marked
|
|
component once one is marked. `fill_component_popup` builds both, and its entries close
|
|
over the component rather than reading a selection back, which is what lets each popup act
|
|
on the row or box that was actually clicked. The tree's root row keeps its own popup.
|
|
|
|
### Following a link to its other sheet
|
|
|
|
Right-clicking a wire or a knob offers **Go to Link Wire Sheet "<name>"** 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 `<link>`'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 `<app>` 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 `<kit name="..."/>` in the file's `<schema>` block is resolved to
|
|
`<sedona home>/manifests/<kit>/<kit>-<checksum>.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
|
|
`<kit>` entry just added to `<schema>` 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 `<links>` (either end) | **Show** — overrides everything below |
|
|
| 2 | `<bool name="summary" val="false"/>` 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 `<prop>` 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
|
|
|
|
`<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.
|