First Commit

Sedona sax editor AI yardimi ile yaziliyor
This commit is contained in:
arda.aydin@ontrol.com.tr 2026-08-13 17:59:08 +03:00
commit 9c283c7eea
13 changed files with 5142 additions and 0 deletions

173
README.md Normal file
View File

@ -0,0 +1,173 @@
# 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 |
| `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.
### Testing without a window
`app_state`, `sax_dom`, `schema` 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 directory is currently **fatal** — the app reports it and calls
`sys.exit(1)`. See [Known gaps](#known-gaps).
### 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
- A kit missing from `<sedona home>/manifests/` exits the whole app rather than warning
and declining to open that file.
- 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.
- No undo, and no keyboard shortcuts for save.
- `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.

114
app_state.py Normal file
View File

@ -0,0 +1,114 @@
"""Shared configuration, runtime state and widget handles.
Every other module reaches this state as ``app_state.<name>`` rather than importing
the names directly. A plain ``from app_state import is_dirty`` would copy the value,
so later rebinding would only ever be seen by the module that did it.
"""
import configparser
import os
VERSION = "0.0.0.028"
PROPERTIES_FILE = "editor.properties"
DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True
# Purely a display choice, so it lives in editor.properties. The SAX file has nowhere to
# record it: meta stores a position and nothing about box size.
DEFAULT_BOX_WIDTH = 100
MIN_ALLOWED_BOX_WIDTH = 60
MAX_ALLOWED_BOX_WIDTH = 400
# Widget handles, filled in by main once the UI exists
window = None
canvas = None
tree = None
# Maps to hold tracking states
tree_element_map = {} # Treeview item ID -> ET.Element
canvas_comp_map = {} # Canvas item tag -> ET.Element
canvas_tag_by_element = {} # ET.Element -> Canvas unique tag string
component_slot_offsets = {} # Canvas unique tag -> {slot name: y offset from box top}
# File modification tracking state
is_dirty = False
current_file_path = None
active_components_list = [] # Tracks what components are actively painted
selected_canvas_tag = None # Tracks the currently clicked/highlighted canvas component tag
selected_tree_item_id = None # Tracks the currently right-clicked Treeview item
xml_root_element = None # Keep a persistent root reference to redraw tree layouts cleanly
current_sheet_parent = None # Element whose children are painted; None means the app root
# Drag-and-drop state variables
drag_data = {"x": 0, "y": 0, "tag": None}
# Global component slot registries compiled dynamically per file load
final_components_registry = {}
active_schema_kits = {}
def load_preferences():
"""Loads application configurations from editor.properties or initializes defaults."""
config = configparser.ConfigParser()
grid_size = DEFAULT_GRID_SIZE
add_timestamp = DEFAULT_ADD_TIMESTAMP
box_width = DEFAULT_BOX_WIDTH
sedona_home = ""
if os.path.exists(PROPERTIES_FILE):
try:
config.read(PROPERTIES_FILE)
grid_size = config.getint("Editor", "grid_size", fallback=DEFAULT_GRID_SIZE)
add_timestamp = config.getboolean("Editor", "add_timestamp", fallback=DEFAULT_ADD_TIMESTAMP)
box_width = config.getint("Editor", "box_width", fallback=DEFAULT_BOX_WIDTH)
sedona_home = config.get("Editor", "sedona.home", fallback="").strip()
except Exception:
pass
else:
config["Editor"] = {
"sedona.home": "",
"grid_size": str(DEFAULT_GRID_SIZE),
"add_timestamp": str(DEFAULT_ADD_TIMESTAMP),
"box_width": str(DEFAULT_BOX_WIDTH)
}
try:
with open(PROPERTIES_FILE, "w") as configfile:
config.write(configfile)
except Exception:
pass
box_width = max(MIN_ALLOWED_BOX_WIDTH, min(MAX_ALLOWED_BOX_WIDTH, box_width))
return sedona_home, grid_size, add_timestamp, box_width
def save_preferences():
"""Writes the current preference values back to editor.properties."""
config = configparser.ConfigParser()
config["Editor"] = {
"sedona.home": SEDONA_HOME,
"grid_size": str(GRID_SIZE),
"add_timestamp": str(ADD_TIMESTAMP),
"box_width": str(BOX_WIDTH)
}
with open(PROPERTIES_FILE, "w") as configfile:
config.write(configfile)
def mark_dirty():
"""Single funnel for 'the document changed', including the unsaved-state tint."""
global is_dirty
if not is_dirty:
is_dirty = True
if canvas is not None:
canvas.config(bg="#ffe6e6")
def mark_clean():
"""Clears the unsaved-state marker after a successful save or a fresh load."""
global is_dirty
is_dirty = False
if canvas is not None:
canvas.config(bg="white")
SEDONA_HOME, GRID_SIZE, ADD_TIMESTAMP, BOX_WIDTH = load_preferences()

6
editor.properties Normal file
View File

@ -0,0 +1,6 @@
[Editor]
sedona.home = C:\Users\aa\OneDrive - Ontrol\Documents\_is\NiagaraAXSedona\sedona
grid_size = 10
add_timestamp = True
box_width = 250

759
main.py Normal file
View File

@ -0,0 +1,759 @@
"""Sedona SAX Tree View Viewer & Program Editor — UI wiring, dialogs and event handlers."""
import os
import sys
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
import app_state
import palette
import sax_file
import undo
import wiresheet
from sax_dom import read_meta_property, update_meta_property
def refresh_all_views():
"""Rebuilds tree and canvas from the DOM. Registered as undo/redo's view refresh."""
sax_file.refresh_treeview_from_dom(app_state.xml_root_element)
wiresheet.render_current_sheet()
def do_undo(event=None):
label = undo.undo()
if label is None:
return "break"
app_state.window.title(
f"Sedona SAX Tree View Viewer & Program Editor [v{app_state.VERSION}] — undid {label}"
)
return "break"
def do_redo(event=None):
label = undo.redo()
if label is None:
return "break"
app_state.window.title(
f"Sedona SAX Tree View Viewer & Program Editor [v{app_state.VERSION}] — redid {label}"
)
return "break"
def do_save(event=None):
sax_file.execute_direct_overwrite_save()
return "break"
def do_tidy_layout(event=None):
"""Menu command: re-flow the open sheet so nothing overlaps."""
if not app_state.canvas_comp_map:
messagebox.showinfo("Tidy Layout", "Open a sheet with components first.")
return "break"
moved, clamped = wiresheet.tidy_layout()
if moved == 0:
messagebox.showinfo("Tidy Layout", "Nothing to move — no boxes overlap.")
elif clamped:
messagebox.showwarning(
"Tidy Layout",
f"Repositioned {moved} component(s).\n\n"
f"{clamped} hit the coordinate limit of {wiresheet.META_COORD_MAX} grid units "
"and were clamped, because meta stores x and y in one byte each. "
"Lower the grid size in Preferences to fit more in."
)
return "break"
# --- Event handlers ---
def on_tree_select(event):
"""Triggered when a branch node is clicked in the Navigation view."""
tree = app_state.tree
selected_items = tree.selection()
if not selected_items:
return
selected_id = selected_items[0]
element = app_state.tree_element_map.get(selected_id)
components_to_render = []
# Remembered so undo/redo and tidy can repaint this same sheet from the DOM.
app_state.current_sheet_parent = element
if element is not None:
components_to_render = [child for child in element if child.tag == "comp"]
else:
top_level_ids = tree.get_children(selected_id)
for t_id in top_level_ids:
comp_elem = app_state.tree_element_map.get(t_id)
if comp_elem is not None:
components_to_render.append(comp_elem)
wiresheet.render_components(components_to_render)
def delete_selected_component(event=None):
"""Deletes the highlighted canvas component and all its descendants recursively."""
if not app_state.selected_canvas_tag:
return
comp_element = app_state.canvas_comp_map.get(app_state.selected_canvas_tag)
if comp_element is not None:
sax_file.execute_cascading_deletion(comp_element)
def delete_tree_selected_component():
"""Triggered by the context popup menu to delete an item selected in the Treeview architecture."""
if not app_state.selected_tree_item_id:
return
comp_element = app_state.tree_element_map.get(app_state.selected_tree_item_id)
if comp_element is not None:
sax_file.execute_cascading_deletion(comp_element)
def on_tree_right_click(event):
"""Intercepts right-clicks on the treeview architecture to render branch context panels."""
tree = app_state.tree
item_id = tree.identify_row(event.y)
if not item_id:
return
tree.selection_set(item_id)
app_state.selected_tree_item_id = item_id
parent_id = tree.parent(item_id)
if parent_id == "":
tree_root_context_menu.post(event.x_root, event.y_root)
else:
tree_comp_context_menu.post(event.x_root, event.y_root)
def on_canvas_click(event):
canvas = app_state.canvas
canvas.itemconfig("block_outline", outline="#7f9db9", width=2)
app_state.drag_data["tag"] = None
app_state.selected_canvas_tag = None
cx = canvas.canvasx(event.x)
cy = canvas.canvasy(event.y)
clicked_item = canvas.find_closest(cx, cy)
if not clicked_item:
return
tags = canvas.gettags(clicked_item[0])
group_tag = next((t for t in tags if t.startswith("comp_group_")), None)
if group_tag:
app_state.drag_data["tag"] = group_tag
app_state.drag_data["x"] = cx
app_state.drag_data["y"] = cy
app_state.selected_canvas_tag = group_tag
# Snapshot the pre-drag meta so the move can be pushed as one undoable step.
dragged = app_state.canvas_comp_map.get(group_tag)
meta_prop = dragged.find("prop[@name='meta']") if dragged is not None else None
app_state.drag_data["meta_before"] = meta_prop.get("val") if meta_prop is not None else None
canvas.itemconfig(f"{group_tag} && block_outline", outline="#1f4e79", width=3)
# Bring the clicked box clear of any neighbour it happens to sit under.
canvas.tag_raise(group_tag)
wiresheet.draw_links()
def on_canvas_right_click(event):
"""Detects right-clicks on nodes to instantly show context-sensitive action overlays."""
canvas = app_state.canvas
cx = canvas.canvasx(event.x)
cy = canvas.canvasy(event.y)
clicked_item = canvas.find_closest(cx, cy)
if clicked_item:
tags = canvas.gettags(clicked_item[0])
group_tag = next((t for t in tags if t.startswith("comp_group_")), None)
if group_tag:
canvas.itemconfig("block_outline", outline="#7f9db9", width=2)
app_state.selected_canvas_tag = group_tag
canvas.itemconfig(f"{group_tag} && block_outline", outline="#1f4e79", width=3)
canvas_context_menu.post(event.x_root, event.y_root)
def on_canvas_drag(event):
if not app_state.drag_data["tag"]:
return
canvas = app_state.canvas
cx = canvas.canvasx(event.x)
cy = canvas.canvasy(event.y)
delta_x = cx - app_state.drag_data["x"]
delta_y = cy - app_state.drag_data["y"]
canvas.move(app_state.drag_data["tag"], delta_x, delta_y)
app_state.drag_data["x"] = cx
app_state.drag_data["y"] = cy
# Wires are not part of the dragged group, so they are re-routed against the new positions.
wiresheet.draw_links()
def on_canvas_release(event):
if not app_state.drag_data["tag"]:
return
canvas = app_state.canvas
grid_size = app_state.GRID_SIZE
group_tag = app_state.drag_data["tag"]
outline_item = canvas.find_withtag(f"{group_tag} && block_outline")
if outline_item:
coords = canvas.coords(outline_item[0])
current_x, current_y = coords[0], coords[1]
snapped_x = round(current_x / grid_size) * grid_size
snapped_y = round(current_y / grid_size) * grid_size
shift_x = snapped_x - current_x
shift_y = snapped_y - current_y
if shift_x != 0 or shift_y != 0:
canvas.move(group_tag, shift_x, shift_y)
app_state.mark_dirty()
raw_x = int(snapped_x / grid_size)
raw_y = int(snapped_y / grid_size)
comp_element = app_state.canvas_comp_map.get(group_tag)
if comp_element is not None:
meta_str = None
orig_prop = comp_element.find("prop[@name='meta']")
if orig_prop is not None:
meta_str = orig_prop.get("val")
orig_x, orig_y, _, _, _, _, _, _ = read_meta_property(meta_str)
if orig_x != raw_x or orig_y != raw_y:
meta_before = app_state.drag_data.get("meta_before", meta_str)
update_meta_property(comp_element, meta_str, raw_x, raw_y)
undo.push(undo.MetaMoveCommand(
[(comp_element, meta_before, raw_x, raw_y)],
label=f"move {comp_element.get('name', 'component')}"
))
app_state.mark_dirty()
app_state.drag_data["tag"] = None
wiresheet.draw_links()
# --- Dialogs ---
def open_preferences_dialog():
"""Pops open a tabular modal dialog to edit configuration attributes."""
window = app_state.window
dialog = tk.Toplevel(window)
dialog.title("Preferences")
dialog.geometry("470x250")
dialog.resizable(False, False)
dialog.transient(window)
dialog.grab_set()
dialog.geometry(f"+{window.winfo_x() + 100}+{window.winfo_y() + 100}")
frame_padded = ttk.Frame(dialog, padding=15)
frame_padded.pack(fill=tk.BOTH, expand=True)
frame_padded.columnconfigure(0, weight=1, minsize=160)
frame_padded.columnconfigure(1, weight=2)
lbl_home = ttk.Label(frame_padded, text="Sedona home folder:", anchor="w")
lbl_home.grid(row=0, column=0, sticky="w", pady=(0, 10), padx=(0, 10))
frame_home_picker = ttk.Frame(frame_padded)
frame_home_picker.grid(row=0, column=1, sticky="ew", pady=(0, 10))
frame_home_picker.columnconfigure(0, weight=1)
entry_home = ttk.Entry(frame_home_picker)
entry_home.insert(0, app_state.SEDONA_HOME)
entry_home.grid(row=0, column=0, sticky="ew", padx=(0, 5))
def browse_home_folder():
chosen_dir = filedialog.askdirectory(title="Select Sedona Home Folder", initialdir=entry_home.get() or None)
if chosen_dir:
entry_home.delete(0, tk.END)
entry_home.insert(0, os.path.normpath(chosen_dir))
btn_home_browse = ttk.Button(frame_home_picker, text="...", width=3, command=browse_home_folder)
btn_home_browse.grid(row=0, column=1, sticky="e")
lbl_size = ttk.Label(frame_padded, text="Grid size (pixels):", anchor="w")
lbl_size.grid(row=1, column=0, sticky="w", pady=(0, 10), padx=(0, 10))
entry_grid = ttk.Entry(frame_padded)
entry_grid.insert(0, str(app_state.GRID_SIZE))
entry_grid.grid(row=1, column=1, sticky="ew", pady=(0, 10))
lbl_box = ttk.Label(frame_padded, text="Component box width (pixels):", anchor="w")
lbl_box.grid(row=2, column=0, sticky="w", pady=(0, 10), padx=(0, 10))
entry_box_width = ttk.Entry(frame_padded)
entry_box_width.insert(0, str(app_state.BOX_WIDTH))
entry_box_width.grid(row=2, column=1, sticky="ew", pady=(0, 10))
lbl_timestamp = ttk.Label(frame_padded, text="Save with Date-Time stamp?", anchor="w")
lbl_timestamp.grid(row=3, column=0, sticky="w", pady=(0, 15), padx=(0, 10))
var_timestamp = tk.BooleanVar(value=app_state.ADD_TIMESTAMP)
chk_timestamp = ttk.Checkbutton(
frame_padded,
text="Yes",
variable=var_timestamp
)
chk_timestamp.grid(row=3, column=1, sticky="w", pady=(0, 15))
def apply_preferences():
try:
val = int(entry_grid.get().strip())
if val < 2 or val > 200:
raise ValueError("Grid size must be a whole number between 2 and 200.")
try:
width = int(entry_box_width.get().strip())
except ValueError:
raise ValueError(
f"Box width must be a whole number between "
f"{app_state.MIN_ALLOWED_BOX_WIDTH} and {app_state.MAX_ALLOWED_BOX_WIDTH}."
)
if not (app_state.MIN_ALLOWED_BOX_WIDTH <= width <= app_state.MAX_ALLOWED_BOX_WIDTH):
raise ValueError(
f"Box width must be between {app_state.MIN_ALLOWED_BOX_WIDTH} and "
f"{app_state.MAX_ALLOWED_BOX_WIDTH} pixels."
)
app_state.BOX_WIDTH = width
app_state.GRID_SIZE = val
app_state.ADD_TIMESTAMP = var_timestamp.get()
app_state.SEDONA_HOME = entry_home.get().strip()
app_state.save_preferences()
wiresheet.draw_grid()
if app_state.active_components_list:
wiresheet.render_components(app_state.active_components_list)
# Sedona home may have moved, so the palette's kit list is rebuilt.
refresh_palette_kits()
dialog.destroy()
except ValueError as error:
message = str(error) or "Please enter a valid integer size between 2 and 200."
messagebox.showerror("Invalid Input", message, parent=dialog)
btn_save_pref = ttk.Button(frame_padded, text="Apply Changes", command=apply_preferences)
btn_save_pref.grid(row=4, column=1, sticky="e")
if not app_state.SEDONA_HOME:
entry_home.focus_set()
else:
entry_grid.focus_set()
def show_app_info():
"""Displays information tracking details for the currently active schema file in standard columns."""
window = app_state.window
if app_state.current_file_path:
file_name = os.path.basename(app_state.current_file_path)
folder_path = os.path.dirname(app_state.current_file_path)
else:
file_name = "No profile loaded"
folder_path = "None"
lastReachedID = 0
all_found_ids = set()
if app_state.xml_root_element is not None:
for elem in app_state.xml_root_element.iter():
if elem.tag == "comp":
id_str = elem.get("id")
if id_str is not None:
try:
val = int(id_str)
all_found_ids.add(val)
if val > lastReachedID:
lastReachedID = val
except ValueError:
pass
not_used_ids = [i for i in range(1, lastReachedID + 1) if i not in all_found_ids]
usedComponentNumber = lastReachedID - len(not_used_ids)
not_used_str = ", ".join(map(str, not_used_ids)) if not_used_ids else "None"
if len(not_used_str) > 40:
not_used_str = not_used_str[:37] + "..."
info_win = tk.Toplevel(window)
info_win.title("App Info")
info_win.resizable(False, False)
info_win.transient(window)
info_win.grab_set()
frame = ttk.Frame(info_win, padding=15)
frame.pack(fill=tk.BOTH, expand=True)
rows_data = [
("App File Name:", file_name),
("File Location:", folder_path),
("Last Reached ID:", str(lastReachedID)),
("Used Component Count:", str(max(0, usedComponentNumber))),
("Unused IDs:", not_used_str)
]
for idx, (lbl_txt, val_txt) in enumerate(rows_data):
lbl = tk.Label(frame, text=lbl_txt, font=("TkDefaultFont", 9, "bold"), anchor="w")
lbl.grid(row=idx, column=0, sticky="w", padx=(0, 15), pady=4)
val = tk.Label(frame, text=val_txt, anchor="w", justify="left")
val.grid(row=idx, column=1, sticky="w", pady=4)
info_win.update_idletasks()
x = window.winfo_x() + (window.winfo_width() // 2) - (info_win.winfo_width() // 2)
y = window.winfo_y() + (window.winfo_height() // 2) - (info_win.winfo_height() // 2)
info_win.geometry(f"+{x}+{y}")
def verify_startup_configurations():
"""Checks for configuration gaps, triggers an alert, and opens setup if empty."""
if not app_state.SEDONA_HOME:
messagebox.showwarning(
"Configuration Required",
"The 'Sedona home folder' path is not configured.\n\n"
"Please specify the directory path in the configuration options "
"before trying to view or load profile source files."
)
open_preferences_dialog()
# --- UI Setup ---
window = tk.Tk()
app_state.window = window
window.title(f"Sedona SAX Tree View Viewer & Program Editor [v{app_state.VERSION}]")
window.geometry("950x600")
# --- Context Popups Structures ---
canvas_context_menu = tk.Menu(window, tearoff=0)
canvas_context_menu.add_command(label="Delete Component", command=delete_selected_component)
tree_comp_context_menu = tk.Menu(window, tearoff=0)
tree_comp_context_menu.add_command(label="Delete Component", command=delete_tree_selected_component)
tree_root_context_menu = tk.Menu(window, tearoff=0)
tree_root_context_menu.add_command(label="Save", command=sax_file.execute_direct_overwrite_save)
# --- Keyboard shortcuts ---
# Command and Control are both bound on every platform, so muscle memory from either
# side works and Windows users are not asked to learn a Mac chord.
SHORTCUTS = [
("s", do_save),
("z", do_undo),
("y", do_redo),
]
for key, handler in SHORTCUTS:
window.bind_all(f"<Command-{key}>", handler)
window.bind_all(f"<Control-{key}>", handler)
# Redo also as Shift-Z, the usual mac chord.
window.bind_all("<Command-Shift-Z>", do_redo)
window.bind_all("<Control-Shift-Z>", do_redo)
# Delete: the mac Delete key reports as BackSpace, the Windows one as Delete.
window.bind_all("<Delete>", delete_selected_component)
window.bind_all("<BackSpace>", delete_selected_component)
accel_mod = "Cmd" if sys.platform == "darwin" else "Ctrl"
# --- Native Application Menu Configurations ---
menu_bar = tk.Menu(window)
menu_file = tk.Menu(menu_bar, tearoff=0)
menu_file.add_command(label="Open", command=sax_file.open_file)
menu_file.add_command(label="Save", command=do_save, accelerator=f"{accel_mod}+S")
menu_file.add_command(label="Save As...", command=sax_file.save_file_as)
menu_file.add_separator()
menu_file.add_command(label="Exit", command=window.quit)
menu_bar.add_cascade(label="File", menu=menu_file)
menu_edit = tk.Menu(menu_bar, tearoff=0)
menu_edit.add_command(label="Undo", command=do_undo, accelerator=f"{accel_mod}+Z")
menu_edit.add_command(label="Redo", command=do_redo, accelerator=f"{accel_mod}+Y")
menu_edit.add_separator()
menu_edit.add_command(label="Delete Component", command=delete_selected_component, accelerator="Del")
menu_bar.add_cascade(label="Edit", menu=menu_edit)
menu_layout = tk.Menu(menu_bar, tearoff=0)
menu_layout.add_command(label="Tidy Layout", command=do_tidy_layout)
menu_bar.add_cascade(label="Layout", menu=menu_layout)
menu_app_info = tk.Menu(menu_bar, tearoff=0)
menu_app_info.add_command(label="App Info", command=show_app_info)
menu_bar.add_cascade(label="App", menu=menu_app_info)
menu_prefs = tk.Menu(menu_bar, tearoff=0)
menu_prefs.add_command(label="Preferences", command=open_preferences_dialog)
menu_bar.add_cascade(label="Preferences", menu=menu_prefs)
window.config(menu=menu_bar)
paned_window = ttk.PanedWindow(window, orient=tk.HORIZONTAL)
paned_window.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
# Left column splits into the navigation tree above and the component palette below.
left_pane = ttk.PanedWindow(paned_window, orient=tk.VERTICAL)
frame_tree = ttk.Frame(left_pane)
tree_scrollbar = ttk.Scrollbar(frame_tree)
tree_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
tree = ttk.Treeview(
frame_tree,
yscrollcommand=tree_scrollbar.set,
show="tree",
selectmode="browse",
)
app_state.tree = tree
tree.pack(fill=tk.BOTH, expand=True)
tree_scrollbar.config(command=tree.yview)
tree.bind("<<TreeviewSelect>>", on_tree_select)
tree.bind("<ButtonPress-3>", on_tree_right_click)
tree.bind("<ButtonPress-2>", on_tree_right_click)
left_pane.add(frame_tree, weight=3)
# --- Component palette ---
frame_palette = ttk.Frame(left_pane)
ttk.Label(frame_palette, text="Palette", font=("TkDefaultFont", 9, "bold")).pack(
anchor="w", padx=4, pady=(4, 2)
)
palette_kit_var = tk.StringVar()
palette_kit_combo = ttk.Combobox(frame_palette, textvariable=palette_kit_var, state="readonly")
palette_kit_combo.pack(fill=tk.X, padx=4)
frame_palette_list = ttk.Frame(frame_palette)
frame_palette_list.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)
palette_scrollbar = ttk.Scrollbar(frame_palette_list)
palette_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
palette_tree = ttk.Treeview(
frame_palette_list,
yscrollcommand=palette_scrollbar.set,
show="tree",
selectmode="browse",
)
palette_tree.pack(fill=tk.BOTH, expand=True)
palette_scrollbar.config(command=palette_tree.yview)
palette_status = ttk.Label(frame_palette, text="", foreground="#666666")
palette_status.pack(anchor="w", padx=4, pady=(0, 4))
def populate_palette_types(event=None):
"""Lists the component types of the kit chosen in the palette dropdown."""
for item in palette_tree.get_children():
palette_tree.delete(item)
index = palette_kit_combo.current()
if index < 0 or index >= len(palette_kit_names):
palette_status.config(text="")
return
kit_name = palette_kit_names[index]
types = palette.list_component_types(kit_name)
for type_name, base in types:
palette_tree.insert("", "end", text=type_name, values=(f"{kit_name}::{type_name}", base))
palette_status.config(text=f"{len(types)} component{'' if len(types) == 1 else 's'}")
def refresh_palette_kits():
"""Reloads the kit list from Sedona home. Also called after Preferences changes it."""
global palette_kit_names
palette.invalidate()
palette_kit_names = palette.list_kits()
labels = []
for kit_name in palette_kit_names:
version = palette.kit_version_label(kit_name)
labels.append(f"{kit_name} ({version})" if version else kit_name)
palette_kit_combo.config(values=labels)
for item in palette_tree.get_children():
palette_tree.delete(item)
if not palette_kit_names:
palette_status.config(
text="No kits found — check Sedona home" if app_state.SEDONA_HOME
else "Set Sedona home in Preferences"
)
palette_kit_var.set("")
return
palette_status.config(text=f"{len(palette_kit_names)} kits")
palette_kit_var.set("")
# --- Palette drag and drop ---
# Tk has no drag-and-drop between widgets, so the gesture is assembled by hand:
# remember what was pressed, start dragging past a small threshold, follow the pointer
# with a borderless ghost window, and on release test the pointer against the canvas.
DRAG_THRESHOLD = 6
palette_drag = {"kit": None, "type": None, "origin": None, "active": False, "ghost": None}
def _destroy_drag_ghost():
if palette_drag["ghost"] is not None:
palette_drag["ghost"].destroy()
palette_drag["ghost"] = None
def _reset_palette_drag():
_destroy_drag_ghost()
palette_drag.update(kit=None, type=None, origin=None, active=False)
def on_palette_press(event):
item = palette_tree.identify_row(event.y)
if not item:
_reset_palette_drag()
return
palette_tree.selection_set(item)
index = palette_kit_combo.current()
if index < 0 or index >= len(palette_kit_names):
return
palette_drag.update(
kit=palette_kit_names[index],
type=palette_tree.item(item, "text"),
origin=(event.x_root, event.y_root),
active=False,
)
def on_palette_motion(event):
if palette_drag["type"] is None or palette_drag["origin"] is None:
return
origin_x, origin_y = palette_drag["origin"]
moved = abs(event.x_root - origin_x) + abs(event.y_root - origin_y)
if not palette_drag["active"]:
if moved < DRAG_THRESHOLD:
return
palette_drag["active"] = True
ghost = tk.Toplevel(window)
ghost.overrideredirect(True)
ghost.attributes("-topmost", True)
tk.Label(
ghost, text=f"{palette_drag['type']}",
bg="#dce6f2", fg="black", relief="solid", borderwidth=1,
padx=6, pady=2, font=("TkDefaultFont", 9),
).pack()
palette_drag["ghost"] = ghost
if palette_drag["ghost"] is not None:
palette_drag["ghost"].geometry(f"+{event.x_root + 12}+{event.y_root + 10}")
def _pointer_over_canvas(x_root, y_root):
left = canvas.winfo_rootx()
top = canvas.winfo_rooty()
return (left <= x_root < left + canvas.winfo_width()
and top <= y_root < top + canvas.winfo_height())
def on_palette_release(event):
was_dragging = palette_drag["active"]
kit_name = palette_drag["kit"]
type_name = palette_drag["type"]
_reset_palette_drag()
if not was_dragging or not kit_name or not type_name:
return
if not _pointer_over_canvas(event.x_root, event.y_root):
return
if app_state.xml_root_element is None:
messagebox.showwarning("Add Component", "Open a SAX file first.")
return
# Pointer position -> canvas coordinates -> grid units, snapped like a dragged box.
drop_x = canvas.canvasx(event.x_root - canvas.winfo_rootx())
drop_y = canvas.canvasy(event.y_root - canvas.winfo_rooty())
grid_size = app_state.GRID_SIZE
grid_x = max(0, min(wiresheet.META_COORD_MAX, round(drop_x / grid_size)))
grid_y = max(0, min(wiresheet.META_COORD_MAX, round(drop_y / grid_size)))
sax_file.add_component(kit_name, type_name, grid_x, grid_y)
palette_kit_names = []
palette_kit_combo.bind("<<ComboboxSelected>>", populate_palette_types)
palette_tree.bind("<ButtonPress-1>", on_palette_press)
palette_tree.bind("<B1-Motion>", on_palette_motion)
palette_tree.bind("<ButtonRelease-1>", on_palette_release)
left_pane.add(frame_palette, weight=2)
paned_window.add(left_pane, weight=1)
frame_editor = ttk.Frame(paned_window)
canvas_vscroll = ttk.Scrollbar(frame_editor, orient=tk.VERTICAL)
canvas_vscroll.pack(side=tk.RIGHT, fill=tk.Y)
canvas_hscroll = ttk.Scrollbar(frame_editor, orient=tk.HORIZONTAL)
canvas_hscroll.pack(side=tk.BOTTOM, fill=tk.X)
canvas = tk.Canvas(
frame_editor,
bg="white",
highlightthickness=0,
xscrollcommand=canvas_hscroll.set,
yscrollcommand=canvas_vscroll.set,
scrollregion=(0, 0, 2000, 2000),
)
app_state.canvas = canvas
canvas.pack(fill=tk.BOTH, expand=True)
canvas_vscroll.config(command=canvas.yview)
canvas_hscroll.config(command=canvas.xview)
canvas.bind("<ButtonPress-1>", on_canvas_click)
canvas.bind("<B1-Motion>", on_canvas_drag)
canvas.bind("<ButtonRelease-1>", on_canvas_release)
canvas.bind("<Configure>", lambda e: wiresheet.draw_grid())
canvas.bind("<ButtonPress-3>", on_canvas_right_click)
canvas.bind("<ButtonPress-2>", on_canvas_right_click)
paned_window.add(frame_editor, weight=3)
undo.set_refresh_callback(refresh_all_views)
refresh_palette_kits()
window.after(100, verify_startup_configurations)
window.mainloop()

196
palette.py Normal file
View File

@ -0,0 +1,196 @@
"""Component palette: the kits installed in Sedona home, and the components inside each.
Deliberately sourced from Sedona home rather than the open file's <schema>, so the
palette shows everything installed and not just the 17 kits this app happens to use.
Version resolution matters here. A kit directory can hold many manifests ontrolControl
ships 22 and the manifest filename carries only a checksum, which sorts arbitrarily.
The real version number lives in the .kit filename under kits/, as
'<kit>-<checksum>-<version>.kit', so that is what is used to decide which manifest is
newest.
"""
import os
import re
import xml.etree.ElementTree as ET
import app_state
_versions_cache = {} # kit name -> {checksum: version string}
_manifest_cache = {} # kit name -> (version string, {type name: element})
_component_cache = {} # 'kit::Type' -> bool
def _manifests_dir():
return os.path.join(app_state.SEDONA_HOME, "manifests")
def _kits_dir():
return os.path.join(app_state.SEDONA_HOME, "kits")
def list_kits():
"""Kit names installed in Sedona home, alphabetically."""
directory = _manifests_dir()
if not os.path.isdir(directory):
return []
return sorted(
name for name in os.listdir(directory)
if os.path.isdir(os.path.join(directory, name))
)
def _version_sort_key(version_string):
numbers = [int(part) for part in re.findall(r"\d+", version_string)]
return numbers or [0]
def kit_versions(kit_name):
"""Maps checksum -> version string, read from the .kit filenames under kits/."""
if kit_name in _versions_cache:
return _versions_cache[kit_name]
versions = {}
kit_dir = os.path.join(_kits_dir(), kit_name)
if os.path.isdir(kit_dir):
pattern = re.compile(rf"^{re.escape(kit_name)}-([0-9a-fA-F]+)-(.+)\.kit$")
for entry in os.listdir(kit_dir):
match = pattern.match(entry)
if match:
versions[match.group(1)] = match.group(2)
_versions_cache[kit_name] = versions
return versions
def resolve_manifest_path(kit_name):
"""Picks the manifest to describe a kit, returning (path, version_label).
Preference order:
1. the checksum the open file's <schema> pins, when it declares one
2. the highest real version number, via the .kit filenames
3. the last manifest by filename, which is what the app has always fallen back to
"""
kit_dir = os.path.join(_manifests_dir(), kit_name)
if not os.path.isdir(kit_dir):
return None, ""
manifests = sorted(
entry for entry in os.listdir(kit_dir)
if entry.startswith(f"{kit_name}-") and entry.endswith(".xml")
)
if not manifests:
return None, ""
versions = kit_versions(kit_name)
pinned = app_state.active_schema_kits.get(kit_name)
if pinned:
candidate = f"{kit_name}-{pinned}.xml"
if candidate in manifests:
return os.path.join(kit_dir, candidate), versions.get(pinned, pinned)
available = []
for entry in manifests:
checksum = entry[len(kit_name) + 1:-4]
if checksum in versions:
available.append((_version_sort_key(versions[checksum]), entry, versions[checksum]))
if available:
available.sort()
_, entry, version_label = available[-1]
return os.path.join(kit_dir, entry), version_label
entry = manifests[-1]
checksum = entry[len(kit_name) + 1:-4]
return os.path.join(kit_dir, entry), versions.get(checksum, "")
def _load_kit(kit_name):
"""Parses a kit's chosen manifest into (version label, {type name: element})."""
if kit_name in _manifest_cache:
return _manifest_cache[kit_name]
path, version_label = resolve_manifest_path(kit_name)
types = {}
if path:
try:
root = ET.parse(path).getroot()
for type_element in root.findall("type"):
types[type_element.get("name")] = type_element
except Exception as error:
print(f"Palette: cannot read manifest {path}: {error}")
_manifest_cache[kit_name] = (version_label, types)
return _manifest_cache[kit_name]
def kit_version_label(kit_name):
return _load_kit(kit_name)[0]
def resolve_checksum(kit_name):
"""Checksum of the manifest this palette resolved for a kit, or None."""
path, _ = resolve_manifest_path(kit_name)
if not path:
return None
filename = os.path.basename(path)
if not filename.startswith(f"{kit_name}-") or not filename.endswith(".xml"):
return None
return filename[len(kit_name) + 1:-4]
def _find_type(qualified_name):
if "::" not in qualified_name:
return None
kit_name, type_name = qualified_name.split("::", 1)
return _load_kit(kit_name)[1].get(type_name)
def _is_component(qualified_name, seen=None):
"""True when the base chain reaches sys::Component, so the type can live in an app."""
if qualified_name == "sys::Component":
return True
if qualified_name in _component_cache:
return _component_cache[qualified_name]
seen = seen or set()
if qualified_name in seen:
return False
seen.add(qualified_name)
type_element = _find_type(qualified_name)
if type_element is None:
result = False
else:
base = type_element.get("base")
result = bool(base) and _is_component(base, seen)
_component_cache[qualified_name] = result
return result
def list_component_types(kit_name):
"""Concrete component types in a kit, as (type name, base) pairs.
Primitives (no base) and abstract types (the 'a' flag) are left out neither can be
placed in an application.
"""
_, types = _load_kit(kit_name)
entries = []
for type_name, type_element in types.items():
if "a" in (type_element.get("flags") or ""):
continue
if not _is_component(f"{kit_name}::{type_name}"):
continue
entries.append((type_name, type_element.get("base") or ""))
return sorted(entries)
def invalidate():
"""Clears every cache, for when Sedona home changes in Preferences."""
_versions_cache.clear()
_manifest_cache.clear()
_component_cache.clear()

7
run.py Normal file
View File

@ -0,0 +1,7 @@
"""Entry point. Run this to start the editor:
cd sedona_editor
/opt/homebrew/bin/python3.13 run.py
"""
import main # noqa: F401 (importing main builds the UI and enters the Tk main loop)

76
sax_dom.py Normal file
View File

@ -0,0 +1,76 @@
"""Document-level helpers for a Sedona app: the packed 'meta' property and component paths.
Pure XML and bit work, deliberately free of Tk so the rules stay testable headless.
"""
import xml.etree.ElementTree as ET
def read_meta_property(meta_str):
"""Parses 32-bit meta property integer fields via structured byte extraction rules."""
if meta_str is None or not meta_str:
return 0, 0, 0, True, False, False, False, 0
try:
if meta_str.startswith("0x") or meta_str.startswith("0X"):
meta_val = int(meta_str, 16)
else:
meta_val = int(meta_str)
raw_x = (meta_val >> 24) & 0xFF
raw_y = (meta_val >> 16) & 0xFF
reserved = (meta_val >> 8) & 0xFF
user_group_byte = meta_val & 0xFF
group1 = bool(user_group_byte & 0x01)
group2 = bool(user_group_byte & 0x02)
group3 = bool(user_group_byte & 0x04)
group4 = bool(user_group_byte & 0x08)
user_group_reserved = (user_group_byte >> 4) & 0x0F
return raw_x, raw_y, reserved, group1, group2, group3, group4, user_group_reserved
except Exception:
return 0, 0, 0, True, False, False, False, 0
def update_meta_property(element, meta_str, raw_x, raw_y):
"""Re-encodes coordinates back into meta, completely preserving existing lower and consecutive bytes."""
_, _, reserved, g1, g2, g3, g4, ug_res = read_meta_property(meta_str)
user_group_byte = 0
if g1: user_group_byte |= 0x01
if g2: user_group_byte |= 0x02
if g3: user_group_byte |= 0x04
if g4: user_group_byte |= 0x08
user_group_byte |= (ug_res & 0x0F) << 4
new_val = (
((raw_x & 0xFF) << 24) |
((raw_y & 0xFF) << 16) |
((reserved & 0xFF) << 8) |
(user_group_byte & 0xFF)
)
meta_prop = element.find("prop[@name='meta']")
if meta_prop is None:
meta_prop = ET.SubElement(element, "prop", name="meta")
meta_prop.set("val", str(new_val))
def find_parent_element(root_elem, child_to_find):
"""Traverses the XML DOM tree recursively to find the parent element of a given element."""
for parent in root_elem.iter():
if child_to_find in list(parent):
return parent
return None
def get_component_path(element, root_elem):
"""Computes the absolute path of a component within the application tree hierarchy."""
path_parts = []
curr = element
while curr is not None and curr.tag == "comp":
path_parts.append(curr.get("name", ""))
curr = find_parent_element(root_elem, curr)
path_parts.reverse()
return "/" + "/".join(path_parts)

432
sax_file.py Normal file
View File

@ -0,0 +1,432 @@
"""Opening, saving and structural editing of a Sedona app file, plus the navigation tree."""
from datetime import datetime
import os
from tkinter import filedialog, messagebox
import xml.etree.ElementTree as ET
import app_state
import palette
import schema
import undo
import wiresheet
from sax_dom import find_parent_element, get_component_path, update_meta_property
def parse_xml_to_tree(treeview, parent_node, xml_element):
"""Recursively traverses XML elements and adds <comp> tags to the Treeview."""
for child in xml_element:
if child.tag == "comp":
comp_name = child.get("name", "Unknown")
comp_id = child.get("id")
if comp_id is not None:
display_text = f"{comp_name} [id: {comp_id}]"
else:
display_text = comp_name
node_id = treeview.insert(
parent_node, "end", text=display_text, open=False
)
app_state.tree_element_map[node_id] = child
parse_xml_to_tree(treeview, node_id, child)
def refresh_treeview_from_dom(xml_root):
"""Rebuilds navigation tree structures using current XML DOM trees."""
tree = app_state.tree
app_state.tree_element_map.clear()
for item in tree.get_children():
tree.delete(item)
if not app_state.current_file_path or xml_root is None:
return
file_name = os.path.basename(app_state.current_file_path)
root_node = tree.insert("", "end", text=file_name, open=True)
app_element = xml_root.find("app")
if app_element is not None:
parse_xml_to_tree(tree, root_node, app_element)
else:
parse_xml_to_tree(tree, root_node, xml_root)
def open_file():
if not app_state.SEDONA_HOME:
messagebox.showwarning("Action Blocked", "Cannot load SAX profiles because 'Sedona home folder' configuration is missing or empty.")
return
file_path = filedialog.askopenfilename(
title="Select SAX File",
filetypes=[("SAX Files", "*.sax"), ("XML Files", "*.xml"), ("All Files", "*.*")],
)
if not file_path:
return
canvas = app_state.canvas
tree = app_state.tree
try:
app_state.current_file_path = file_path
app_state.active_components_list.clear()
app_state.selected_canvas_tag = None
app_state.current_sheet_parent = None
undo.reset()
app_state.mark_clean()
app_state.tree_element_map.clear()
app_state.canvas_comp_map.clear()
app_state.canvas_tag_by_element.clear()
for item in tree.get_children():
tree.delete(item)
canvas.delete("all")
wiresheet.draw_grid()
xml_tree = ET.parse(file_path)
app_state.xml_root_element = xml_tree.getroot()
schema.load_schema_kits_and_manifests()
file_name = os.path.basename(file_path)
root_node = tree.insert("", "end", text=file_name, open=True)
app_element = app_state.xml_root_element.find("app")
if app_element is not None:
parse_xml_to_tree(tree, root_node, app_element)
else:
parse_xml_to_tree(tree, root_node, app_state.xml_root_element)
except Exception as e:
messagebox.showerror("Error", f"Failed to parse the file:\n{str(e)}")
def sync_live_metadata_to_dom():
"""Internal helper to clean up deleted elements and sync coordinates into the root XML object."""
if app_state.xml_root_element is None:
return
live_elements_meta = {}
for cached_tag, cached_element in app_state.canvas_comp_map.items():
comp_id = cached_element.get("id")
comp_name = cached_element.get("name")
meta_prop = cached_element.find("prop[@name='meta']")
if meta_prop is not None:
live_elements_meta[(comp_id, comp_name)] = meta_prop.get("val")
def sync_and_prune_tree(target_xml_parent):
children_to_examine = list(target_xml_parent)
for child in children_to_examine:
if child.tag == "comp":
comp_id = child.get("id")
comp_name = child.get("name")
is_element_alive = False
for c_elem in app_state.active_components_list:
if c_elem.get("id") == comp_id and c_elem.get("name") == comp_name:
is_element_alive = True
break
is_previously_rendered = any(
elem.get("id") == comp_id and elem.get("name") == comp_name
for elem in app_state.canvas_comp_map.values()
) or (comp_id, comp_name) in live_elements_meta
if is_previously_rendered and not is_element_alive:
target_xml_parent.remove(child)
continue
if (comp_id, comp_name) in live_elements_meta:
tgt_meta = child.find("prop[@name='meta']")
if tgt_meta is None:
tgt_meta = ET.SubElement(child, "prop", name="meta")
tgt_meta.set("val", live_elements_meta[(comp_id, comp_name)])
sync_and_prune_tree(child)
sync_and_prune_tree(app_state.xml_root_element)
def execute_direct_overwrite_save():
"""Overwrites the existing file directly without adding timestamp formats or prompts."""
if not app_state.current_file_path or app_state.xml_root_element is None:
return
try:
sync_live_metadata_to_dom()
with open(app_state.current_file_path, "wb") as f:
f.write(b'<?xml version="1.0" encoding="UTF-8"?>\n')
ET.ElementTree(app_state.xml_root_element).write(f, encoding="utf-8", xml_declaration=False)
app_state.mark_clean()
undo.mark_saved()
refresh_treeview_from_dom(app_state.xml_root_element)
messagebox.showinfo("Success", f"Direct changes saved onto:\n{os.path.basename(app_state.current_file_path)}")
except Exception as e:
messagebox.showerror("Save Error", f"Failed to write direct update stream:\n{str(e)}")
def save_file_as():
"""Saves the tracking tree document hierarchy targeting an explicit location choice dialog."""
if not app_state.current_file_path or app_state.xml_root_element is None:
messagebox.showinfo("Save Info", "No active profile has been opened yet.")
return
try:
sync_live_metadata_to_dom()
if app_state.ADD_TIMESTAMP:
base_dir = os.path.dirname(app_state.current_file_path)
orig_filename = os.path.basename(app_state.current_file_path)
name_part, ext_part = os.path.splitext(orig_filename)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
suggested_name = f"{name_part}_{timestamp}{ext_part}"
target_path = filedialog.asksaveasfilename(
initialdir=base_dir,
initialfile=suggested_name,
title="Save Timestamps Layout As",
filetypes=[
("SAX Files", "*.sax"),
("XML Files", "*.xml"),
("All Files", "*.*"),
]
)
if not target_path:
return
else:
target_path = app_state.current_file_path
with open(target_path, "wb") as f:
f.write(b'<?xml version="1.0" encoding="UTF-8"?>\n')
ET.ElementTree(app_state.xml_root_element).write(f, encoding="utf-8", xml_declaration=False)
app_state.mark_clean()
undo.mark_saved()
refresh_treeview_from_dom(app_state.xml_root_element)
folder_name = os.path.basename(os.path.dirname(target_path))
file_name = os.path.basename(target_path)
messagebox.showinfo("Success", f"File Saved\nFolder: {folder_name}\nFile: {file_name}")
except Exception as e:
messagebox.showerror("Save Error", f"An anomaly prevented saving the XML file stream:\n{str(e)}")
MAX_NAME_LENGTH = 7 # every component name in a Sedona app observes this
def allocate_component_id():
"""Lowest unused id, matching how this app's ids are dense with no gaps."""
used = set()
if app_state.xml_root_element is not None:
for element in app_state.xml_root_element.iter("comp"):
try:
used.add(int(element.get("id")))
except (TypeError, ValueError):
pass
candidate = 1
while candidate in used:
candidate += 1
return candidate
def unique_child_name(parent, base_name):
"""Trims to the 7-character limit and disambiguates against existing siblings."""
existing = {child.get("name") for child in parent if child.tag == "comp"}
candidate = base_name[:MAX_NAME_LENGTH]
if candidate and candidate not in existing:
return candidate
for counter in range(1, 10000):
suffix = str(counter)
stem = base_name[:max(1, MAX_NAME_LENGTH - len(suffix))]
candidate = f"{stem}{suffix}"[:MAX_NAME_LENGTH]
if candidate not in existing:
return candidate
return base_name[:MAX_NAME_LENGTH]
def current_sheet_container():
"""The element a newly placed component belongs to: the open folder, or the app."""
if app_state.xml_root_element is None:
return None
if app_state.current_sheet_parent is not None:
return app_state.current_sheet_parent
app_element = app_state.xml_root_element.find("app")
return app_element if app_element is not None else app_state.xml_root_element
def ensure_kit_declared(kit_name):
"""Adds a <kit> entry to <schema> when placing a component from an undeclared kit.
The new entry carries the checksum of the manifest the palette resolved, so the file
records exactly which kit build the component was authored against. Entries already in
the file are left as they are a missing checksum there is a deliberate tolerance on
load, not something to backfill.
Returns the element that was added, or None when the kit was already declared. A kit
absent from the schema also has to exist in the device's scode, so the caller warns.
"""
if app_state.xml_root_element is None:
return None
schema_elem = app_state.xml_root_element.find("schema")
if schema_elem is None:
return None
for kit in schema_elem.findall("kit"):
if kit.get("name") == kit_name:
return None
kit_element = ET.Element("kit")
kit_element.set("name", kit_name)
checksum = palette.resolve_checksum(kit_name)
if checksum:
kit_element.set("checksum", checksum)
# Added after the last existing kit. The block is not alphabetical — sys comes first
# by convention — so appending is safer than guessing a sort position.
insert_at = len(list(schema_elem))
for index, existing in enumerate(list(schema_elem)):
if existing.tag == "kit":
insert_at = index + 1
schema_elem.insert(insert_at, kit_element)
return kit_element
def add_component(kit_name, type_name, grid_x, grid_y):
"""Places a new component of kit::type at a grid position. Returns the element."""
parent = current_sheet_container()
if parent is None:
messagebox.showwarning("Add Component", "Open a SAX file first.")
return None
kit_added = ensure_kit_declared(kit_name)
if kit_added is not None:
proceed = messagebox.askokcancel(
"Kit not in schema",
f"'{kit_name}' is not declared in this app's schema.\n\n"
f"It will be added, but the device's scode must also contain {kit_name} "
"or the application will not run.\n\nContinue?"
)
if not proceed:
schema_elem = app_state.xml_root_element.find("schema")
if schema_elem is not None:
schema_elem.remove(kit_added)
return None
# New kit means new types to resolve before the box can be painted.
schema.load_schema_kits_and_manifests()
comp_id = allocate_component_id()
comp_name = unique_child_name(parent, type_name)
element = ET.Element("comp")
element.set("name", comp_name)
element.set("id", str(comp_id))
element.set("type", f"{kit_name}::{type_name}")
meta_prop = ET.SubElement(element, "prop")
meta_prop.set("name", "meta")
update_meta_property(element, None, grid_x, grid_y)
index = len(list(parent))
parent.append(element)
undo.push(undo.AddComponentCommand(
parent, index, element,
schema_parent=app_state.xml_root_element.find("schema"),
kit_element=kit_added,
label=f"add {comp_name}"
))
app_state.mark_dirty()
refresh_treeview_from_dom(app_state.xml_root_element)
wiresheet.render_current_sheet()
return element
def execute_cascading_deletion(comp_element):
"""Shared application logic to wipe an element, its children, and related links from DOM and runtime lists."""
comp_name = comp_element.get("name", "Unknown")
comp_id = comp_element.get("id", "??")
descendant_comps = [c for c in comp_element.iter() if c.tag == "comp" and c != comp_element]
descendant_count = len(descendant_comps)
warning_msg = f"Are you sure you want to remove component '{comp_name}' [id: {comp_id}]?"
if descendant_count > 0:
warning_msg += f"\n\n⚠️ WARNING: This component contains {descendant_count} nested sub-component(s) under it. Deleting it will purge ALL of them recursively."
confirm = messagebox.askyesno("Confirm Cascading Delete", warning_msg)
if not confirm:
return False
comp_path = get_component_path(comp_element, app_state.xml_root_element)
all_elements_to_prune = [comp_element] + descendant_comps
for elem in all_elements_to_prune:
if elem in app_state.active_components_list:
app_state.active_components_list.remove(elem)
# Positions are recorded before anything is removed, so the delete can be reversed.
link_records = []
if app_state.xml_root_element is not None:
links_elem = app_state.xml_root_element.find("links")
if links_elem is not None:
for index, link in enumerate(list(links_elem)):
if link.tag != "link":
continue
l_from = link.get("from", "")
l_to = link.get("to", "")
from_match = l_from.startswith(comp_path + ".") or l_from.startswith(comp_path + "/")
to_match = l_to.startswith(comp_path + ".") or l_to.startswith(comp_path + "/")
if from_match or to_match:
link_records.append((links_elem, index, link))
for links_parent, _, link in link_records:
links_parent.remove(link)
xml_parent = None
comp_index = 0
if app_state.xml_root_element is not None:
xml_parent = find_parent_element(app_state.xml_root_element, comp_element)
if xml_parent is not None:
comp_index = list(xml_parent).index(comp_element)
xml_parent.remove(comp_element)
if xml_parent is not None:
undo.push(undo.DeleteComponentCommand(
xml_parent, comp_index, comp_element, link_records,
label=f"delete {comp_name}"
))
target_canvas_tag = app_state.canvas_tag_by_element.get(comp_element)
if target_canvas_tag and target_canvas_tag == app_state.selected_canvas_tag:
app_state.selected_canvas_tag = None
if target_canvas_tag:
app_state.canvas.delete(target_canvas_tag)
app_state.mark_dirty()
refresh_treeview_from_dom(app_state.xml_root_element)
wiresheet.render_components(app_state.active_components_list)
return True

163
schema.py Normal file
View File

@ -0,0 +1,163 @@
"""Kit manifest loading, slot inheritance, and which slots are worth showing."""
import os
import sys
from tkinter import messagebox
import xml.etree.ElementTree as ET
import app_state
def read_summary_facet(slot_element):
"""Reads a slot's '@summary' facet. Absent means summary, matching manifest convention."""
facets = slot_element.find("facets")
if facets is None:
return True
for bool_facet in facets.findall("bool"):
if bool_facet.get("name") == "summary":
return (bool_facet.get("val", "true").strip().lower() != "false")
return True
def get_slots_with_inheritance(type_name, kit_schemas):
"""Recursively resolves the slots for a given type by traversing its base chain."""
if type_name not in kit_schemas:
kit_prefix = type_name.split("::")[0] if "::" in type_name else type_name
messagebox.showerror("Schema Warning", f"Kit {kit_prefix} is not in Schema selection!")
sys.exit(1)
type_element = kit_schemas[type_name]
base_type = type_element.get("base")
resolved_slots = []
if base_type:
resolved_slots = get_slots_with_inheritance(base_type, kit_schemas)
current_slots = []
for slot in type_element.findall("slot"):
current_slots.append({
"name": slot.get("name"),
"type": slot.get("type"),
"default": slot.get("default", "false"),
"flags": slot.get("flags", ""), # Capture visibility flags ('o' maps to operator/hidden)
"summary": read_summary_facet(slot),
"declared_id": int(slot.get("id"))
})
current_slots.sort(key=lambda s: s["declared_id"])
resolved_slots.extend(current_slots)
return resolved_slots
def compile_schema_registry(manifest_paths):
"""Loads and maps types across multiple kit files, ensuring all dependencies exist."""
kit_schemas = {}
for path in manifest_paths:
try:
tree = ET.parse(path)
root = tree.getroot()
kit_name = root.get("name")
for type_tag in root.findall("type"):
type_name = f"{kit_name}::{type_tag.get('name')}"
kit_schemas[type_name] = type_tag
except Exception as e:
print(f"Error parsing manifest {path}: {e}")
app_state.final_components_registry.clear()
for type_name in kit_schemas.keys():
flat_slots = get_slots_with_inheritance(type_name, kit_schemas)
resolved_type_structure = []
for sequence_id, slot_data in enumerate(flat_slots):
resolved_type_structure.append({
"slot_id": sequence_id,
"name": slot_data["name"],
"type": slot_data["type"],
"default": slot_data["default"],
"flags": slot_data["flags"],
"summary": slot_data["summary"]
})
app_state.final_components_registry[type_name] = resolved_type_structure
def load_schema_kits_and_manifests():
"""Scans the schema block of the SAX file and maps/loads corresponding manifest definitions."""
app_state.active_schema_kits.clear()
if app_state.xml_root_element is None:
return
schema_elem = app_state.xml_root_element.find("schema")
if schema_elem is None:
return
manifest_paths = []
for kit in schema_elem.findall("kit"):
name = kit.get("name")
checksum = kit.get("checksum")
if not name:
continue
app_state.active_schema_kits[name] = checksum
manifest_dir = os.path.join(app_state.SEDONA_HOME, "manifests", name)
manifest_path = None
if checksum:
target_file = os.path.join(manifest_dir, f"{name}-{checksum}.xml")
if os.path.exists(target_file):
manifest_path = target_file
if not manifest_path and os.path.exists(manifest_dir):
files = [f for f in os.listdir(manifest_dir) if f.startswith(f"{name}-") and f.endswith(".xml")]
if files:
files.sort()
manifest_path = os.path.join(manifest_dir, files[-1])
if manifest_path:
manifest_paths.append(manifest_path)
else:
messagebox.showerror("Schema Warning", f"Kit {name} is not in Schema selection!")
sys.exit(1)
compile_schema_registry(manifest_paths)
def get_slots_for_type(type_str, linked_slots=()):
"""
Returns the slots worth painting for a given type, prefixed with double-digit
index tags taken from the manifest so hidden rows leave gaps rather than renumber.
Visibility, in order of precedence:
- a linked slot is always shown, so its wire has a row to anchor to
- '@summary false' hides the slot, even when it is runtime
- runtime slots (no 'c' flag) are shown; they hold the live signal path
- config slots that nothing links to are hidden as clutter
- the 'o' (operator) flag hides a slot outright
"""
if type_str not in app_state.final_components_registry:
return []
slots_list = []
for slot_data in app_state.final_components_registry[type_str]:
slot_name = slot_data["name"]
if not slot_name:
continue
if "o" in slot_data["flags"]:
continue
is_linked = slot_name in linked_slots
if not is_linked:
if not slot_data.get("summary", True):
continue
if "c" in slot_data["flags"]:
continue
# Format to double digits (e.g., 00, 01, 02)
padded_id = f"{slot_data['slot_id']:02d}"
display_label = f"{padded_id}. {slot_name}"
# Keep structural name unchanged for XML lookup inside comp, but pass display label
slots_list.append((slot_name, display_label))
return slots_list

1315
testSax/checksumYok.sax Normal file

File diff suppressed because it is too large Load Diff

1266
testSax/normal.sax Normal file

File diff suppressed because it is too large Load Diff

149
undo.py Normal file
View File

@ -0,0 +1,149 @@
"""Undo/redo as a stack of reversible commands.
Commands own the DOM mutation in both directions. Nothing here touches Tk: the view is
refreshed through a callback that main registers at startup, which also keeps the
import graph free of cycles (sax_file and wiresheet both push commands here).
"""
import app_state
from sax_dom import update_meta_property
_undo_stack = []
_redo_stack = []
_saved_depth = 0 # len(_undo_stack) as of the last save
_refresh_view = None
def set_refresh_callback(callback):
"""Registers how the view should be rebuilt after an undo or redo."""
global _refresh_view
_refresh_view = callback
def _refresh():
if _refresh_view is not None:
_refresh_view()
# Undoing back to the last saved state should clear the dirty marker, not keep it lit.
if len(_undo_stack) == _saved_depth:
app_state.mark_clean()
else:
app_state.mark_dirty()
def push(command):
"""Records an already-applied command. Any redo history becomes unreachable."""
_undo_stack.append(command)
_redo_stack.clear()
def can_undo():
return bool(_undo_stack)
def can_redo():
return bool(_redo_stack)
def undo():
if not _undo_stack:
return None
command = _undo_stack.pop()
command.undo()
_redo_stack.append(command)
_refresh()
return command.label
def redo():
if not _redo_stack:
return None
command = _redo_stack.pop()
command.redo()
_undo_stack.append(command)
_refresh()
return command.label
def mark_saved():
"""Pins the current stack depth as the on-disk state."""
global _saved_depth
_saved_depth = len(_undo_stack)
def reset():
"""Drops all history, for when a different file is loaded."""
global _saved_depth
_undo_stack.clear()
_redo_stack.clear()
_saved_depth = 0
class MetaMoveCommand:
"""One or many components changing position, i.e. their packed meta x/y."""
def __init__(self, changes, label="move"):
# changes: list of (element, old_meta_val, new_x, new_y)
self.changes = changes
self.label = label
def undo(self):
for element, old_val, _, _ in self.changes:
meta_prop = element.find("prop[@name='meta']")
if meta_prop is not None and old_val is not None:
meta_prop.set("val", old_val)
def redo(self):
for element, old_val, new_x, new_y in self.changes:
update_meta_property(element, old_val, new_x, new_y)
class AddComponentCommand:
"""Placing a component, plus the <kit> entry that placing it may have required."""
def __init__(self, parent, index, element, schema_parent=None, kit_element=None, label="add"):
self.parent = parent
self.index = index
self.element = element
self.schema_parent = schema_parent
self.kit_element = kit_element
self.label = label
def undo(self):
if self.element in list(self.parent):
self.parent.remove(self.element)
if self.kit_element is not None and self.schema_parent is not None:
if self.kit_element in list(self.schema_parent):
self.schema_parent.remove(self.kit_element)
def redo(self):
if self.kit_element is not None and self.schema_parent is not None:
if self.kit_element not in list(self.schema_parent):
self.schema_parent.append(self.kit_element)
if self.element not in list(self.parent):
self.parent.insert(self.index, self.element)
class DeleteComponentCommand:
"""A cascading delete: the component subtree plus every link that referenced it."""
def __init__(self, parent, index, element, link_records, label="delete"):
# link_records: list of (links_parent, index, link_element)
self.parent = parent
self.index = index
self.element = element
self.link_records = link_records
self.label = label
def undo(self):
self.parent.insert(self.index, self.element)
# Ascending order so each link lands back on its original index.
for links_parent, index, link in sorted(self.link_records, key=lambda r: r[1]):
links_parent.insert(index, link)
def redo(self):
for links_parent, _, link in self.link_records:
if link in list(links_parent):
links_parent.remove(link)
if self.element in list(self.parent):
self.parent.remove(self.element)

486
wiresheet.py Normal file
View File

@ -0,0 +1,486 @@
"""Canvas painting: the grid, component boxes, and the routed link wires."""
import tkinter as tk
import tkinter.font as tkfont
import app_state
import schema
import undo
from sax_dom import get_component_path, read_meta_property, update_meta_property
LINK_STUB = 12 # straight run leaving a slot before the wire is allowed to turn
LINK_CLEAR = 8 # gap kept between a routed wire and any box it passes
LINK_EPS = 0.5 # tolerance so a wire touching its own box edge is not a collision
LINK_COLOR = "#2f6fba"
KNOB_RADIUS = 4
# Box metrics. Kept tight on purpose: stored meta positions were authored against
# compact boxes, with columns pitched about 100px apart and rows about 60px apart.
# Anything larger and boxes land on top of each other before a single one is moved.
HEADER_HEIGHT = 26
SLOT_ROW_HEIGHT = 14
FOOTER_HEIGHT = 2
MIN_BOX_WIDTH = 80 # ignored when the configured width is smaller
META_COORD_MAX = 255 # meta packs x and y into one byte each
TIDY_GAP = 20 # clear space left between boxes by the tidy command
def short_type_name(comp_type):
"""'ontrolControl::HvacControl' -> 'HvacControl'. The kit prefix alone cost ~40px."""
return comp_type.split("::")[-1] if "::" in comp_type else comp_type
def fit_text(text, font, max_px):
"""Truncates with an ellipsis so a label can never widen or overflow its box."""
if max_px <= 0:
return ""
if font.measure(text) <= max_px:
return text
ellipsis = ""
if font.measure(ellipsis) > max_px:
return ""
trimmed = text
while trimmed and font.measure(trimmed + ellipsis) > max_px:
trimmed = trimmed[:-1]
return (trimmed + ellipsis) if trimmed else ellipsis
def draw_grid():
"""Draws a grid onto the Canvas based on the scrollable region."""
canvas = app_state.canvas
canvas.delete("grid")
width = 3000
height = 3000
for x in range(0, width, app_state.GRID_SIZE):
canvas.create_line(x, 0, x, height, fill="#e0e0e0", tags="grid")
for y in range(0, height, app_state.GRID_SIZE):
canvas.create_line(0, y, width, y, fill="#e0e0e0", tags="grid")
canvas.tag_lower("grid")
def create_round_rectangle(canvas, x1, y1, x2, y2, radius=4, **kwargs):
"""Draws a visually smooth rounded rectangle using native canvas arcs and polygons."""
points = [
x1+radius, y1, x1+radius, y1, x2-radius, y1, x2-radius, y1, x2, y1,
x2, y1+radius, x2, y1+radius, x2, y2-radius, x2, y2-radius, x2, y2,
x2-radius, y2, x2-radius, y2, x1+radius, y2, x1+radius, y2, x1, y2,
x1, y2-radius, x1, y2-radius, x1, y1+radius, x1, y1+radius, x1, y1
]
return canvas.create_polygon(points, **kwargs, smooth=True)
# --- Link indexing and routing ---
def build_linked_slot_index():
"""Maps component path -> set of slot names touched by a link, either end."""
index = {}
if app_state.xml_root_element is None:
return index
links_elem = app_state.xml_root_element.find("links")
if links_elem is None:
return index
for link in links_elem.findall("link"):
for ref in (link.get("from", ""), link.get("to", "")):
if "." not in ref:
continue
comp_path, slot_name = ref.rsplit(".", 1)
index.setdefault(comp_path, set()).add(slot_name)
return index
def get_box_bounds(comp_tag):
"""Current on-canvas bounds of a component group, valid after drags."""
box = app_state.canvas.bbox(comp_tag)
return box if box else None
def segment_hits_boxes(x1, y1, x2, y2, boxes):
"""True if an axis-aligned segment overlaps any box rectangle."""
if x1 > x2:
x1, x2 = x2, x1
if y1 > y2:
y1, y2 = y2, y1
for bx1, by1, bx2, by2 in boxes:
if x2 < bx1 + LINK_EPS or x1 > bx2 - LINK_EPS:
continue
if y2 < by1 + LINK_EPS or y1 > by2 - LINK_EPS:
continue
return True
return False
def path_is_clear(points, boxes):
for idx in range(len(points) - 1):
(x1, y1), (x2, y2) = points[idx], points[idx + 1]
if segment_hits_boxes(x1, y1, x2, y2, boxes):
return False
return True
def route_link_path(src_box, src_y, dst_box, dst_y, boxes):
"""Routes an orthogonal wire from a source slot to a target slot around every box."""
start = (src_box[2], src_y)
end = (dst_box[0], dst_y)
leave = (start[0] + LINK_STUB, src_y)
arrive = (end[0] - LINK_STUB, dst_y)
preferred = (leave[0] + arrive[0]) / 2
# Preference 1: a clear vertical channel somewhere between the two boxes.
channels = {preferred}
for bx1, _, bx2, _ in boxes:
channels.add(bx1 - LINK_CLEAR)
channels.add(bx2 + LINK_CLEAR)
low, high = min(leave[0], arrive[0]), max(leave[0], arrive[0])
for cx in sorted((c for c in channels if low <= c <= high), key=lambda c: abs(c - preferred)):
candidate = [start, leave, (cx, src_y), (cx, dst_y), arrive, end]
if path_is_clear(candidate, boxes):
return candidate
# Preference 2: go over or under everything. Covers feedback links running right to left.
lanes = set()
for _, by1, _, by2 in boxes:
lanes.add(by1 - LINK_CLEAR)
lanes.add(by2 + LINK_CLEAR)
midline = (src_y + dst_y) / 2
for cy in sorted(lanes, key=lambda c: abs(c - midline)):
candidate = [start, leave, (leave[0], cy), (arrive[0], cy), arrive, end]
if path_is_clear(candidate, boxes):
return candidate
# Nothing clear: fall back to the direct dog-leg rather than dropping the wire.
return [start, leave, (preferred, src_y), (preferred, dst_y), arrive, end]
def resolve_slot_anchor(comp_tag, slot_name):
"""Returns (box_bounds, y) for a slot row, centring on the box when the row is hidden."""
box = get_box_bounds(comp_tag)
if box is None:
return None, None
offset = app_state.component_slot_offsets.get(comp_tag, {}).get(slot_name)
if offset is None:
return box, (box[1] + box[3]) / 2
return box, box[1] + offset
def draw_knob(x, y):
"""Marks a link whose far end lives on another wiresheet."""
app_state.canvas.create_oval(
x - KNOB_RADIUS, y - KNOB_RADIUS, x + KNOB_RADIUS, y + KNOB_RADIUS,
fill=LINK_COLOR, outline="white", width=1, tags=("link", "link_knob")
)
def draw_links():
"""Paints wires between slots on this sheet and knobs for links leaving it."""
canvas = app_state.canvas
canvas.delete("link")
if app_state.xml_root_element is None:
return
links_elem = app_state.xml_root_element.find("links")
if links_elem is None:
return
path_to_tag = {}
for comp_element, comp_tag in app_state.canvas_tag_by_element.items():
path_to_tag[get_component_path(comp_element, app_state.xml_root_element)] = comp_tag
if not path_to_tag:
return
boxes = [b for b in (get_box_bounds(t) for t in path_to_tag.values()) if b]
for link in links_elem.findall("link"):
from_ref = link.get("from", "")
to_ref = link.get("to", "")
if "." not in from_ref or "." not in to_ref:
continue
from_path, from_slot = from_ref.rsplit(".", 1)
to_path, to_slot = to_ref.rsplit(".", 1)
src_tag = path_to_tag.get(from_path)
dst_tag = path_to_tag.get(to_path)
if src_tag and dst_tag:
src_box, src_y = resolve_slot_anchor(src_tag, from_slot)
dst_box, dst_y = resolve_slot_anchor(dst_tag, to_slot)
if src_box is None or dst_box is None:
continue
points = route_link_path(src_box, src_y, dst_box, dst_y, boxes)
flat = [coord for point in points for coord in point]
canvas.create_line(
*flat, fill=LINK_COLOR, width=1,
arrow=tk.LAST, arrowshape=(8, 9, 3), tags=("link",)
)
elif src_tag:
# Source is here, target is on another sheet.
src_box, src_y = resolve_slot_anchor(src_tag, from_slot)
if src_box is not None:
draw_knob(src_box[2] + KNOB_RADIUS, src_y)
elif dst_tag:
# Target is here, source is on another sheet.
dst_box, dst_y = resolve_slot_anchor(dst_tag, to_slot)
if dst_box is not None:
draw_knob(dst_box[0] - KNOB_RADIUS, dst_y)
# Wires and knobs sit behind the boxes so they never paint over a neighbouring component.
if canvas.find_withtag("component"):
canvas.tag_lower("link", "component")
# --- Component painting ---
def render_components(components_to_render):
"""Handles parsing and visual painting of given component listings on canvas with slot attributes."""
canvas = app_state.canvas
app_state.active_components_list = components_to_render
app_state.selected_canvas_tag = None
canvas.delete("component")
canvas.delete("link")
app_state.canvas_comp_map.clear()
app_state.canvas_tag_by_element.clear()
app_state.component_slot_offsets.clear()
name_font = tkfont.Font(family="Segoe UI", size=9, weight="bold")
type_font = tkfont.Font(family="Segoe UI", size=7)
slot_font = tkfont.Font(family="Segoe UI", size=8)
linked_slot_index = build_linked_slot_index()
max_reached_x = 1000
max_reached_y = 1000
header_height = HEADER_HEIGHT
slot_row_height = SLOT_ROW_HEIGHT
footer_height = FOOTER_HEIGHT
for comp in components_to_render:
comp_name = comp.get("name", "Unknown")
comp_type = comp.get("type", "sys::Component")
comp_id = comp.get("id")
if comp_id is not None:
full_display_name = f"{comp_name} [id: {comp_id}]"
else:
full_display_name = comp_name
meta_str = None
meta_prop = comp.find("prop[@name='meta']")
if meta_prop is not None:
meta_str = meta_prop.get("val")
raw_x, raw_y, reserved, g1, g2, g3, g4, ug_res = read_meta_property(meta_str)
coord_x = (raw_x * app_state.GRID_SIZE)
coord_y = (raw_y * app_state.GRID_SIZE)
# Retrieve manifest slot definitions, keeping any slot a link needs to land on
if app_state.xml_root_element is not None:
comp_path = get_component_path(comp, app_state.xml_root_element)
else:
comp_path = ""
slots = schema.get_slots_for_type(comp_type, linked_slot_index.get(comp_path, ()))
slot_data_rows = []
for raw_name, display_label in slots:
prop_elem = comp.find(f"prop[@name='{raw_name}']")
val_str = prop_elem.get("val", "null") if prop_elem is not None else "null"
slot_data_rows.append((raw_name, display_label, val_str))
# Dynamically size width and height metrics, then clamp so a long slot name
# cannot push the box across its neighbour.
display_type = short_type_name(comp_type)
name_width = name_font.measure(full_display_name)
type_width = type_font.measure(display_type)
max_content_width = max(name_width, type_width) + 30
for _, display_label, val_str in slot_data_rows:
row_width = slot_font.measure(display_label) + slot_font.measure(val_str) + 24
if row_width > max_content_width:
max_content_width = row_width
box_width = min(app_state.BOX_WIDTH, max(MIN_BOX_WIDTH, max_content_width))
box_height = header_height + (len(slot_data_rows) * slot_row_height) + footer_height
if coord_x + box_width > max_reached_x:
max_reached_x = coord_x + box_width
if coord_y + box_height > max_reached_y:
max_reached_y = coord_y + box_height
comp_tag = f"comp_group_{id(comp)}"
app_state.canvas_comp_map[comp_tag] = comp
app_state.canvas_tag_by_element[comp] = comp_tag
# Filled in as rows are drawn below; keyed by slot name for link anchoring.
slot_offsets = {}
app_state.component_slot_offsets[comp_tag] = slot_offsets
create_round_rectangle(
canvas, coord_x, coord_y, coord_x + box_width, coord_y + box_height,
radius=5, fill="white", outline="#7f9db9", width=2,
tags=("component", comp_tag, "block_outline")
)
# Draw Header bar
canvas.create_rectangle(
coord_x + 1, coord_y + 1, coord_x + box_width - 1, coord_y + header_height,
fill="#dce6f2", outline="", tags=("component", comp_tag)
)
canvas.create_line(
coord_x, coord_y + header_height, coord_x + box_width, coord_y + header_height,
fill="#7f9db9", tags=("component", comp_tag)
)
# Header textual labels, ellipsised to leave room for the indicator square
header_text_width = box_width - 26
canvas.create_text(
coord_x + 5, coord_y + 8, anchor="w",
text=fit_text(full_display_name, name_font, header_text_width),
font=name_font, fill="black",
tags=("component", comp_tag)
)
canvas.create_text(
coord_x + 5, coord_y + 19, anchor="w",
text=fit_text(display_type, type_font, header_text_width),
font=type_font, fill="#555555",
tags=("component", comp_tag)
)
# Quick access indicator icon background slot placeholder
canvas.create_rectangle(
coord_x + box_width - 17, coord_y + 6, coord_x + box_width - 5, coord_y + 18,
fill="#ffe699", outline="#b4c6e7", tags=("component", comp_tag)
)
# Render dynamic slots rows
current_row_y = coord_y + header_height
for idx, (raw_name, s_name, val_str) in enumerate(slot_data_rows):
row_bg = "#eaeaea" if idx % 2 == 0 else "#dfdfdf"
slot_offsets[raw_name] = (current_row_y + (slot_row_height // 2)) - coord_y
canvas.create_rectangle(
coord_x + 1, current_row_y, coord_x + box_width - 1, current_row_y + slot_row_height,
fill=row_bg, outline="", tags=("component", comp_tag)
)
# Value keeps its full text; the name yields space to it when the row is tight
value_width = slot_font.measure(val_str)
label_space = box_width - 16 - value_width
canvas.create_text(
coord_x + 6, current_row_y + (slot_row_height // 2), anchor="w",
text=fit_text(s_name, slot_font, label_space),
font=slot_font, fill="black", tags=("component", comp_tag)
)
canvas.create_text(
coord_x + box_width - 6, current_row_y + (slot_row_height // 2), anchor="e",
text=val_str, font=slot_font, fill="#333333", tags=("component", comp_tag)
)
current_row_y += slot_row_height
canvas.create_line(
coord_x, current_row_y, coord_x + box_width, current_row_y,
fill="#b8b8b8", tags=("component", comp_tag)
)
canvas.config(scrollregion=(0, 0, max_reached_x + 200, max_reached_y + 200))
draw_links()
def render_current_sheet():
"""Repaints whichever sheet is open, re-reading children from the DOM.
Undo of a delete puts an element back, so the component list has to be rebuilt from
the document rather than reused from the previous render.
"""
if app_state.xml_root_element is None:
return
parent = app_state.current_sheet_parent
if parent is None:
app_element = app_state.xml_root_element.find("app")
if app_element is None:
app_element = app_state.xml_root_element
parent = app_element
render_components([child for child in parent if child.tag == "comp"])
def tidy_layout():
"""Re-flows the open sheet so no two boxes overlap, preserving the author's columns.
Boxes keep their column grouping and their top-to-bottom order; only the spacing is
rebuilt, from the sizes actually painted. Returns (moved_count, clamped_count).
"""
if not app_state.canvas_comp_map:
return 0, 0
placed = []
for comp_tag, comp in app_state.canvas_comp_map.items():
bounds = get_box_bounds(comp_tag)
if bounds is None:
continue
meta_prop = comp.find("prop[@name='meta']")
meta_val = meta_prop.get("val") if meta_prop is not None else None
grid_x, grid_y = read_meta_property(meta_val)[:2]
placed.append({
"comp": comp,
"meta": meta_val,
"grid_x": grid_x,
"grid_y": grid_y,
"width": bounds[2] - bounds[0],
"height": bounds[3] - bounds[1],
})
if not placed:
return 0, 0
columns = {}
for item in placed:
columns.setdefault(item["grid_x"], []).append(item)
grid_size = app_state.GRID_SIZE
changes = []
clamped = 0
cursor_x = grid_size # leave one grid square of margin at the left
for grid_x in sorted(columns):
column = sorted(columns[grid_x], key=lambda i: i["grid_y"])
cursor_y = grid_size
for item in column:
new_grid_x = min(META_COORD_MAX, round(cursor_x / grid_size))
new_grid_y = min(META_COORD_MAX, round(cursor_y / grid_size))
if new_grid_x == META_COORD_MAX or new_grid_y == META_COORD_MAX:
clamped += 1
if (new_grid_x, new_grid_y) != (item["grid_x"], item["grid_y"]):
changes.append((item["comp"], item["meta"], new_grid_x, new_grid_y))
cursor_y += item["height"] + TIDY_GAP
cursor_x += max(i["width"] for i in column) + TIDY_GAP
if not changes:
return 0, clamped
command = undo.MetaMoveCommand(changes, label="tidy layout")
command.redo() # apply it; the command is the single writer of meta
undo.push(command)
app_state.mark_dirty()
render_current_sheet()
return len(changes), clamped