First Commit
Sedona sax editor AI yardimi ile yaziliyor
This commit is contained in:
commit
9c283c7eea
173
README.md
Normal file
173
README.md
Normal 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
114
app_state.py
Normal 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
6
editor.properties
Normal 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
759
main.py
Normal 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
196
palette.py
Normal 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
7
run.py
Normal 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
76
sax_dom.py
Normal 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
432
sax_file.py
Normal 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
163
schema.py
Normal 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
1315
testSax/checksumYok.sax
Normal file
File diff suppressed because it is too large
Load Diff
1266
testSax/normal.sax
Normal file
1266
testSax/normal.sax
Normal file
File diff suppressed because it is too large
Load Diff
149
undo.py
Normal file
149
undo.py
Normal 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
486
wiresheet.py
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user