Files
sedona_editor/README.md
T
ArdaandClaude Opus 5 2db230dec1 Properties view: every slot of a component, edited in the editor pane
Right-click a component on the wiresheet or in the tree and Properties takes
over the editor pane, listing every slot the manifest declares rather than the
handful a box paints. Config slots that are not @readonly are white and
editable; everything else is grey.

Rows are built from the slot's facets, read against SedonaDoc/niagara.html:
@trueText/@falseText, @range, @precision, @unit, and @min/@max, which bound
what Save accepts. Three facets it had wrong:

  - null is a value, not an absence. control::WriteFloat pairs 'in' with a
    setNull action and the sample app stores val="null" on four of them, so
    null now shows, parses back, and reads as @nullText where declared.
  - @min/@max on a Buf count characters. TcpDevice.ipAddr is a Buf(17) with
    @max 16; a longer string is refused rather than overrunning the device.
  - an int's implicit range is noise. [-2147483648 - 2147483647] said no more
    than "a whole number", so only byte and short print a range they did not
    declare.

meta gets a row of its own: the '>>' opens its user groups as checkboxes and
its position as x and y, since typing 822411269 into a field sets neither.

Save writes only the rows that moved, as one undoable SlotValueCommand, and
refuses the lot if any row fails its range check. An edited field paints the
view in the canvas's own dirty tint, and every way of leaving asks before
discarding.

The file name on the tree's root row goes red while anything is unwritten,
counting both a dirty document and unsaved fields. Two things this needed:
the nav tree runs on its own style, because a themed Treeview's selected
foreground beats a row tag's; and the tag is added to or removed from the row
rather than recoloured, because reconfiguring a tag does not repaint rows that
are already drawn.

Also fixes a latent hazard the panel exposed: Del and Backspace are bound
application-wide, so they now stand down while a text field has focus rather
than deleting the component behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:38:03 +03:00

466 lines
26 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.048** (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 |
| `properties.py` | The Properties view: every slot of one component, read and written | 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, `properties` builds on `schema` and `undo`, and `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) and `SlotValueCommand`
(one Save from the Properties view, however many slots it changed). 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 -- Properties, 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.
### The Properties view
**Properties**, from either component popup, **takes over the editor pane** -- the sheet
and the slot table are the same piece of screen, so only one of them is ever the thing
being looked at. The canvas is unpacked, never destroyed, so it keeps its items and its
scroll position and **Close** puts it back exactly as it was. It is still painted while it
is hidden, which costs nothing and means there is no catching up to do when it returns.
Choosing a node in the tree means "show me that sheet", so it stands the view down rather
than leaving the selection looking like it did nothing. So does deleting the component it
is showing, from anywhere -- every delete in the UI goes through `delete_component`,
because `sax_file` does its own view refresh rather than going through `refresh_all_views`.
It lists **every** slot the manifest declares,
in manifest order, each row numbered with the same slot id the wiresheet box uses. The
box's visibility rules do not apply here: they are right for a diagram and wrong for the
one place a value is meant to be changed, so `HvacControl` shows all 47 rows rather than
the 8 its box paints.
The header names the component, its type and its **absolute path** -- the same path a
`<link>` writes, and the one thing on screen that separates this `Cascade` from the three
others in the file. It is read from the document rather than held, so a renamed parent
shows up on the next Refresh.
Each row carries the slot's declared type between its name and its value, written the way
a slot sheet writes it: the manifest stores the primitives bare and everything else already
qualified, so `int` is shown as `sys::int` and `sys::Buf` is left as it stands.
Which rows can be typed into is the slot's own business, not the view's:
| Slot | Field |
|---|---|
| config (`c` flag) and not `@readonly` | white, editable |
| everything else | grey, read-only |
A runtime slot holds a live value that no offline file carries, so nothing here could
usefully write one; a `@readonly` config slot is the manifest saying the same about a
value that *is* persisted. Both read the same way on screen.
Each row is drawn from the slot's facets, so it reads the way the kit author meant it to:
- `@trueText` / `@falseText` turn a bool into its own two words -- `Disabled` / `Enabled`
rather than false / true -- in a dropdown, with a red or green dot beside it.
- **Null is a value, not an absence.** Sedona models it as NaN for a float and a sentinel
for the integer types, and a config slot can genuinely hold it: `control::WriteFloat`
pairs an `in` property with a `setNull` action, and the sample app carries
`<prop name="in" val="null"/>` on four of them. So `null` shows against a blue dot,
types back in as `null`, and reads as `@nullText` where a kit declared one --
`ontrolBaseDriver::DeviceNetwork.status` calls it `Disabled`. `@allowNull false` is the
one case that refuses it, and takes it out of a bool's dropdown.
- `@range` turns an integer slot into a named dropdown; the file still stores the index.
- `@precision` fixes how many decimals a float shows, `@unit` puts the symbol after the
field (`celsius` reads `°C`).
- `@min` / `@max` bound what Save will accept, and are shown as `[0 - 1000]`. A row prints
a range when the manifest declared one, and otherwise only where the **type** supplies a
narrow one: `byte` reads `[0 - 255]` and `short` reads `[0 - 65535]` unfaceted, because
those bounds are worth knowing. An `int`, a `long` or a float prints nothing, since its
full span means no more than "a whole number" and crowds the row to say it -- and an end
those types leave undeclared prints as `-inf` / `+inf` rather than reaching for the
type. This is display only: Save still holds every integer to its type, so an `int` of
`9999999999` is rejected whether or not its row advertised the limit.
- On a **`Buf`** those same two facets count *characters*, not magnitude, and the count is
what the buffer can hold: `TcpDevice.ipAddr` is a `Buf(17)` declaring `@max 16`, the
seventeenth byte being the null terminator. The row reads `[max 16 chars]` and Save
refuses a longer string -- overrunning a Buf is not a display problem, the device gets a
string it has no room for.
The value shown is the file's `<prop>`, or the manifest default when the file carries
none, or the type's zero when the manifest declares none either. Runtime slots reach the
view through that second and third step, which is why they read `0.0` here and `null` on
the wiresheet: the box is reporting what the file says, and this is reporting what the
device would start from.
`meta` gets a row of its own rather than an editable integer: typing 822411269 into a
field is no way to set either of the two unrelated things it packs. The `»` beside it
opens them apart -- the four user group bits as checkboxes, and the position as x and y,
which a drag would otherwise be the only way to set. Both are in grid units, the figure
`meta_scale` multiplies to reach pixels, and each is one byte, so the editor holds them to
0-255. The row shows the groups it summarises and the position beside them.
Saving a changed meta writes the position first and the groups second, because each pass
preserves what the other owns: the position pass keeps the group bits it reads, and the
group pass keeps the position it was just handed.
The table is laid out at its natural width and never stretched. Every widget is gridded
straight into one frame, so grid's own column sizing is what lines the four columns up
across the rows, and no column carries a weight -- widening the view shows more white to
the right of the table instead of walking the fields across it, and narrowing it scrolls
sideways rather than squeezing them. Each scrollbar appears only while it has somewhere to
scroll; `Shift` + wheel scrolls sideways.
### The file name goes red
While anything is unwritten, the file name on the tree's **root row** turns
`DIRTY_NAME_COLOUR`. That row is the only place a file name appears on screen -- a Tk
window title cannot be coloured.
The tag's colour is configured **once**, when the tree is built, and never changed; what
moves is whether the row carries the tag. Reconfiguring a tag does not reliably repaint
rows that are already drawn, so a version that recoloured the tag went red only on the
paths that happened to rebuild the tree -- a Properties Save did, dragging a box did not,
and the two disagreed in a way that looked like the drag was not marking anything dirty at
all. Setting a row's tags repaints that row, so the colour cannot fall behind the state.
`anything_unwritten()` is the whole rule, and it counts **both**: the document being dirty,
and the Properties view holding edits its Save has not been pressed for. A typed value is
as unwritten as a dragged box, and a name that only went red for the second of those would
be telling half the story. So it goes red the moment a field is edited, before anything has
reached the document, and comes back when the edit is typed away, discarded, or saved and
written.
The navigation tree runs on a style of its own, `NAV_TREE_STYLE`, for one reason: a themed
Treeview maps the selected state to `SystemHighlightText`, and **that mapping beats a row
tag's own foreground**. The name turned white-on-blue the moment its row was selected --
which is exactly when the root sheet is the one being looked at, so the red was invisible
in the most ordinary case. The style drops the selected foreground entry altogether and
takes a light selection background instead, so a tag's colour shows in every state and
plain black still reads on the highlight. Scoped to this tree, so the palette keeps the
system selection.
`app_state.refresh_dirty_display()` is the single funnel, called from `mark_dirty` and
`mark_clean` through a callback `main` registers -- the same shape `undo` uses for its view
refresh, and what keeps `app_state` free of any idea that a Properties view exists.
### Unsaved edits in the Properties view
An edited field paints the whole view in **`app_state.DIRTY_TINT`** -- the same colour
the canvas wears when the file is dirty, now one constant rather than two spellings of
`#ffe6e6`, so "there is something here you have not written yet" looks the same wherever
the change was made. The fields themselves keep their own white or grey: whether a value
*can* be typed into is a different thing from whether it *has* been.
Dirtiness is read from the widgets rather than tracked as a flag, so typing a value and
then typing it back leaves the view clean -- it is exactly the set of rows Save would
write. The tint follows the fields live, and comes off on Save and on Refresh.
Every way of leaving the view goes through `close_properties`, so the question is asked
once, in one place: **Close**, picking another sheet in the tree, walking **Back** or
**Forward**, or right-clicking a different component. The box has three answers rather
than two, because "I did not mean to leave" and "leave, I did not want those edits" are
different things and a two-way box makes the user guess which one Cancel is. A Save that
fails its range checks keeps the view open, since leaving then would discard exactly the
values being complained about.
Refusing to leave has to put the tree back on the sheet that is still showing. The
**element** is held for that, not the tree item id, for the same reason the history holds
elements: a rebuild of the tree hands out fresh ids. ttk queues its selection event rather
than dispatching it, so the flag that stops the restoring selection being read as a fresh
choice is cleared from an `after_idle` rather than inline.
Nothing asks when the question has nowhere to go -- the component deleted, a different
file opened. Those go straight to `dismiss_properties`, which is also what the panel's own
Close calls once the question has been put and answered.
Discarded edits are discarded: the panel is destroyed, and the next one is built from the
document, so it opens on the file's values with no tint.
**Save** writes the edited rows into the DOM and pushes them as **one** undoable step, so
a dozen changed slots undo together. Only rows whose text actually moved are written, so a
component keeps exactly the props the file gave it plus the ones edited here -- and if any
edited row fails its range check, *nothing* is written and the view names every offending
row at once. Save changes the document, not the file; `Ctrl`/`Cmd` + `S` still writes the
`.sax`. **Refresh** re-reads every row from the DOM, discarding pending edits, which is
also how the view catches up after an undo.
`Del` and `Backspace` are bound application-wide, so they also reach a field being typed
in. `delete_selected_component` therefore ignores the key while the Properties view is up,
while focus is in another window, and while it is in any widget that owns the keyboard --
an `Entry`, a `TCombobox`, a `Text`. A backspace aimed at a text field must never delete a
component. Save still checks the component is in the document before writing, since an
undo can take it out from under the view.
### 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.
### Facets this does not act on
Read against `SedonaDoc/niagara.html`, which is what tells Niagara -- and so this view
-- how to model a slot. These are recognised there and deliberately not acted on here:
| Facet | Where it is used | Why not |
|---|---|---|
| `hidden` | 129 slots, every one a `void` action such as `HdwAI.checkType` | The doc says user interfaces should rarely display hidden slots, and the reference tool appears not to. This view shows every slot because that is what it is for; say the word and it will skip them. |
| `fieldWidth` | 3082 slots | A per-slot column count for the text field. Applying it would make the value column ragged; a table reads better with one width. |
| `radix`, `showSeparators`, `multiLine`, `fieldEditor` | unused by any kit under Sedona home | Nothing to render. |
And two things worth knowing about the manifests themselves rather than the code:
- 24 slots declare **`readOnly`** with a capital O. Niagara matches flag names exactly, so
it is not the `readonly` flag and does nothing there either -- it is stored as an
ordinary facet. Every one of them is a runtime slot, so they are already grey here for
the other reason, and honouring the typo would change nothing on screen.
- 118 **config `Buf`** slots omit `@asStr`, so by the letter of `components.html` they
serialise as base64 rather than text. Their names (`text`, `trueText`, `buttonText`) say
otherwise, so they are edited as text here.
## 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 edited from the Properties view only; a wiresheet box row is still
display-only.