A kits step after the zip is chosen, laid out as the Manage Kits panel: installed kits (what the app needs, greyed), installed but not used, kits available on the computer, not supported; the Action cell offers Keep at / Upgrade to / Downgrade to / Add at / REMOVE; Upgrade all kits to latest version. Kits not in the backup can be added, with their dependencies. The ticked kits make app.sab's schema (encode_sax takes the kit list, as Ontrol's PutApp rewrites it). When they are not the device's kits, or Rebuild kits.scode is ticked, kits.scode is built for them with Ontrol's sedonac from the Niagara install, after checking the natives the kits call against the device's platform manifest (PutApp.checkNativeCompatibility). - kit_build.py: platform manifest lookup, native check, sedonac run - palette.kit_versions: a checksum released more than once stands for its highest version (ontrolPlatform 1.0.1, not 1.0.0) - palette.unsupported_kits takes the kit list to judge against - sab_app: a type from a kit the app does not list is a clear error Tested on a P-ION: a rebuild for its own kits is byte-identical to the one it runs; adding and then removing the logic kit both come back running. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.053 (see Versioning).
Requirements
Python 3.13 from Homebrew, which brings Tk 9:
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:
/opt/homebrew/bin/python3.13 -c "import tkinter; r=tkinter.Tk(); print(r.tk.call('info','patchlevel'))"
Running
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 stack of reversible commands (DOM, and the device in live mode) | 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 |
link_view.py |
The Link View: every link touching one component, drawn around it | 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, link_view builds
on wiresheet, 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). There is no redo.
On a device the same steps are undoable, and each undo is sent there: undo.py has
live subclasses for moves, slot edits and renames, and sax_file has
LiveComponentCommand and LiveLinkCommand. Deleting a component first reads its
whole subtree back from the device (config, children, links, place among its
siblings), so undo can recreate it; the device hands out new ids, which the DOM
elements take on. An undo that does not fully reach the device warns and clears the
history, and so does every view change, since opening a sheet rebuilds the DOM from the
device. 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 |
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 everything the editor pane has held this session,
mirrored by the two arrow buttons in the toolbar -- sheets and the two panels alike. Each
entry is a (kind, element) pair naming one of the three:
| Entry | What Back or Forward puts back |
|---|---|
(VIEW_SHEET, element) |
selects that element's tree node, which is what paints its children |
(VIEW_PROPERTIES, comp) |
re-opens the Properties view of that component |
(VIEW_LINKS, comp) |
re-opens the Link View of that component |
So opening a panel is a move like opening a sheet: Back from a Properties view lands on whatever the pane showed before it rather than skipping past to the sheet before that, and Forward brings the panel back. Following a link out of the Link View is an ordinary sheet opening, so Back from where it lands returns to the picture the link was followed from, and a second Back to the sheet that was open before that.
The panel's own Close is not recorded. It means "leave this view", and recording it would put a sheet entry after the panel's -- so Back would re-open the panel that had just been closed. Leaving the position where it is instead means Back still steps to what came before the panel, and Forward re-opens it, which is what the two arrows are for.
Entries hold elements rather than tree item ids, 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 the entries that can no longer be shown -- a sheet
with no node left, a component out of the document -- keeping the position on screen, and
collapses any pair that pruning has left naming the same view, since a Back that visibly
does nothing is worse than a shorter trail. Restoring an entry is suppressed from
recording, so a walk cannot rewrite the trail it is following, and both arrows go through
close_editor_panel first -- so unsaved Properties edits are still asked about, and a
refusal leaves both the view and the position where they were.
The component popup
Right-clicking a component offers the same actions from the wiresheet and from the
navigation tree -- Properties, Link View, 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 editor pane
The pane to the right of the tree holds one of three things: the wiresheet, the Properties view or the Link View. The latter two take the pane over rather than floating above it, so a sheet and a table of one component's slots are never both half in view. 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.
_take_editor_pane and _give_editor_pane_back in main are the hand-over, and
close_editor_panel is the single way out of whichever panel has the pane: "you have not
saved that" is therefore asked once, in one place, whether the user pressed Close, picked
another sheet, walked the history or opened a different component. Only the Properties
view has anything to ask about.
Choosing a node in the tree means "show me that sheet", so it stands a panel down rather
than leaving the selection looking like it did nothing. So does deleting the component one
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.
All three are recorded in one history, so Back and Forward walk them together -- see The View menu above.
The Properties view
Properties, from either component popup, is the one place a slot's value is meant to be changed, and the only view here that writes to the document.
It lists every slot the manifest declares -- bar one kind, see Hidden slots below --
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.
A column of raw flags= letters was tried here and taken out again: c, a, s and o
are the manifest's own shorthand, and a column of them told the reader nothing the row was
not already saying -- c is what the white field means, a is what an action value
means. What the flags decide is still decided by them; it is just not spelled out twice.
The header row stays. It is deliberately quiet -- row-sized, grey, over a hairline rule -- so it labels the table without competing with it.
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.
A dropdown is offered only where there is a choice to make. A read-only bool or enum gets a plain field instead, since a disabled dropdown is an arrow that does nothing -- and a read-only bool is still filled by its state, because a runtime bool has a state too.
The dropdown is a tk.OptionMenu rather than a ttk.Combobox, for one reason: on the
Windows themes a Combobox draws its field with a native element and ignores
fieldbackground outright, so a bool could never be filled with its colour. The plain Tk
widget takes the colour it is given. It reports a change through its variable rather than
an event, which also catches a value set from anywhere else -- and means loading a row
looks exactly like choosing one, so the load is flagged and the tint left alone until every
row is in.
Each row is drawn from the slot's facets, so it reads the way the kit author meant it to:
@trueText/@falseTextturn a bool into its own two words --Disabled/Enabledrather than false / true -- and the field is filled by the state: green for true, red for false, blue for null. It began as a coloured dot beside the field, which meant learning what the dot stood for, then as coloured text; a block of colour carries down a long list of slots where a letterform does not, and the words stay black so they read as easily as every other value. Dropping the dot also let a bool's field line up with the numbers above and below 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::WriteFloatpairs aninproperty with asetNullaction, and the sample app carries<prop name="in" val="null"/>on four of them. Sonullshows against a blue dot, types back in asnull, and reads as@nullTextwhere a kit declared one --ontrolBaseDriver::DeviceNetwork.statuscalls itDisabled.@allowNull falseis the one case that refuses it, and takes it out of a bool's dropdown. @rangeturns an integer slot into a named dropdown; the file still stores the index.@precisionfixes how many decimals a float shows,@unitputs the symbol after the field (celsiusreads°C).@min/@maxbound 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:bytereads[0 - 255]andshortreads[0 - 65535]unfaceted, because those bounds are worth knowing. Anint, alongor 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/+infrather than reaching for the type. This is display only: Save still holds every integer to its type, so anintof9999999999is rejected whether or not its row advertised the limit.- On a
Bufthose same two facets count characters, not magnitude, and the count is what the buffer can hold:TcpDevice.ipAddris aBuf(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.
Hidden slots
@hidden is the one facet the view filters on, and only by default. niagara.html says
hidden slots "are designed to be invisible to the user" and that interfaces should rarely
display them; every one under Sedona home is a void action a kit uses internally, such
as HdwAI.checkType. A Hidden slots (n) checkbox in the footer brings them back, and
the answer sticks for the session rather than being asked again for every component.
The rows are always built -- the checkbox grid_removes and re-grids them, which keeps
each widget's options so putting a row back needs no arithmetic, and an emptied grid row
collapses rather than leaving a gap. The checkbox itself only appears on a type that has
something to reveal, so most components carry no control for a thing they do not have.
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.
The Link View
Link View, from either component popup, answers one question -- what is wired to this component, and where does it go -- and takes over the editor pane to do it. It is read-only: no drag, no add, no delete, nothing that writes to the document, so a component's wiring can be read without the risk of rearranging it.
The chosen component is drawn in the middle, with the components linking into it on
the left and the ones it links out to on the right, so every arrow points the way the
signal travels and the picture reads left to right the way a wiresheet does. The chosen
box wears the same dark stroke a selected box wears on the sheet. The ground is pale
yellow and carries no grid: nothing here can be positioned, because the layout is
derived from the links every time it is drawn and no part of it is stored in the file
(meta records a component's place on its own sheet, which says nothing about where it
belongs relative to another one).
Each column is stacked in the order of the chosen component's own slots: whatever
feeds its first slot sits at the top, whatever feeds its fifth sits below that. Nothing
is read from meta, so the view is free to put the peers wherever they read best, and
this is the order that makes the wires readable -- see Keeping the wires apart below.
Every line is grey, because this is not a wiresheet and a wire-blue line would invite the reader to treat it as one:
| Link | Drawn as |
|---|---|
| link on the same wiresheet | solid grey line |
| link to another wiresheet | dashed grey line, plus the grey knob that sheet paints on the slot, with the far component drawn in and its sheet named beneath it |
Boxes come from wiresheet.draw_component_box -- the same function the sheet paints
with -- so a component reads exactly as it does on its own sheet and a wire lands on the
same slot row. A component wired twice on one side is one box with two wires, as it would
be on a sheet; a reference the document cannot resolve gets a dashed placeholder naming
the path, because a link the view silently dropped would be the one thing a reader could
not find out from here.
Hovering a wire darkens it and names the link in the footer; clicking a wire or a
component follows it. collect_links, sheet_display_name and the whole of the routing
geometry -- segments_meet, routes_meet, column_gap, _slot_order_key -- are pure
functions, so both what the view reads out of a document and how it decides where a wire
goes can be exercised without a window.
Keeping the wires apart
Ordering each column by the chosen component's slots turns the set of wires on one side
into a ladder: both ends run the same way down the page, so no wire has any reason to
cross another. What is left is choosing which lane each one turns in, and column_gap
reserves the room for that -- a lane per wire, so a dozen links widen the gap rather than
crowding a dozen verticals into a 30px band.
For a ladder there is a lane order that cannot produce a crossing, and read off the slots rather than off the direction the line happens to be drawn in, it is the same on both sides -- two fans, each nesting outwards from the chosen component's own row:
| Wire | Turns nearer the focus |
|---|---|
| reaching a peer slot above its focus slot | the higher up the focus it lands |
| reaching a peer slot below its focus slot | the lower down the focus it lands |
Take two wires of the upper fan, the higher one nested inside the lower: the lower one's run into the focus passes below where the higher one's vertical ends, and its run out to its peer passes above where that vertical begins, so it goes round it and never through it. The lower fan is the same argument upside down. A wire of one fan can never share a stretch of the page with a wire of the other -- ordering the ladder is what rules that out -- so the two fans reuse each other's lanes and the gap stays as narrow as the picture allows.
Two special cases sit on top of that rule. Where several wires leave the same slot no order of focus heights can separate them, so the longest branch turns first, against the focus, and the shorter ones carry on along the shared run beside the pin and drop away outside it -- which is how a wiresheet draws a fan-out too. And where two slots already line up, the wire is one straight line with no vertical run to place at all; those are settled first, because such a wire crosses the whole gap and everything else has to be fitted past it. The layout is rounded to whole pixels so that a pair that ought to line up does, rather than being drawn with a 1px jog in it.
Every wire is still checked against the ones already routed before it takes a lane
(routes_meet), and takes the next lane out if it clashes. The argument above rests on
the ladder being ordered, and one thing breaks that: a peer whose own slots run
counter to the focus's -- linking its row 4 to the focus's row 3 and its row 3 to the
focus's row 4. Two links with their ends inverted like that have to cross wherever they
are drawn, and no reordering can help, because the order inside each box is the
component's own. The check is what stops that one forced crossing from being made worse
by the ones around it.
Measured over 461 linked components in four real files -- 1113 wires -- this leaves no wire passing through a box at all, and ten crossings in six components, every one of them an inverted pair of the kind above.
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 <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
link_view.sheet_display_name names after the file.
Clicking a link in the Link View follows it the same way, through the same
go_to_sheet_with_link: it opens the sheet of the end that is not the component the
view is about, so a link that stays on one sheet goes back to that sheet where both ends
are drawn, and one that crosses opens the other sheet -- the end the view could only show
as a knob.
Either way the link is selected again once the sheet 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 be picked out after that repaint, since painting a sheet clears
whatever link was selected. An after_idle callback is not late enough: ttk dispatches
its own selection event later than one of those, so a link selected from an idle callback
was wiped by the repaint that followed it. The link is therefore left in
_pending_link_selection for on_tree_select to honour once it has painted, and that
handler reads and clears the request first thing -- so a selection that turns out not to
paint a sheet drops it rather than leaving it to be applied to some later one. The link is
then scrolled to the middle of the view. Arriving is an ordinary sheet opening, so
Back returns to whatever the pane held before it -- the sheet the wire was
right-clicked on, or the Link View the link was clicked in.
Testing without a window
app_state, sax_dom, schema, undo and wiresheet import with no display, so the
rules can be exercised directly. link_view.collect_links is pure DOM work too, and reads
a component's links without one:
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 |
|---|---|---|
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
readOnlywith a capital O. Niagara matches flag names exactly, so it is not thereadonlyflag 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
Bufslots omit@asStr, so by the letter ofcomponents.htmlthey 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 a type-icon placeholder that is drawn but not yet functional; no icon set has been wired to it.
- Slot values are edited from the Properties view only; a wiresheet box row is still display-only.
- The Link View stacks each side in one column, so a component with a dozen peers makes a picture taller than the pane. It scrolls, but it does not wrap into a second column.
- Two links between the same pair of components whose slot rows are inverted have to cross, since neither box's slot order is the view's to change. Six components in the four files measured do this.
column_gapbudgets one lane per wire, but the router reuses lanes wherever it can, so a side that needs few of them is drawn with a gap wider than its wires require.