ArdaandClaude Opus 5 ed79e9af72 Tree rows get the same right-click actions as canvas boxes
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>
2026-09-04 23:41:30 +03:00
2026-08-13 23:27:45 +03:00
2026-08-13 17:59:08 +03:00
2026-08-13 17:59:08 +03:00

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).

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/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.

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 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:

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> 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.
S
Description
No description provided
Readme
6.2 MiB
Languages
Python 50.7%
HTML 49.3%