commit 9c283c7eea567eb346a2011a07ee396a755f2516 Author: arda.aydin@ontrol.com.tr Date: Thu Aug 13 17:59:08 2026 +0300 First Commit Sedona sax editor AI yardimi ile yaziliyor diff --git a/README.md b/README.md new file mode 100644 index 0000000..c560c95 --- /dev/null +++ b/README.md @@ -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.`**, 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 `` in the file's `` block is resolved to +`/manifests//-.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 `` (either end) | **Show** — overrides everything below | +| 2 | `` 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 `` in the file and the renderer substitutes the string `null`. That is +correct for an offline app dump, not a fault. + +### The `meta` property + +A packed 32-bit integer, decoded in `sax_dom.py`: + +``` +bits 31-24 x position, in grid units +bits 23-16 y position, in grid units +bits 15-8 reserved (preserved verbatim on write) +bits 7-0 user group bits 1-4 in the low nibble, reserved high nibble +``` + +Writes re-encode only x and y and preserve every other bit, so dragging a box never +disturbs the rest of the value. + +### Links + +`` sits at the document root, with `from`/`to` references of the form +`/path/to/Component.slotName`. + +- Both endpoints on the current sheet → an orthogonal wire is routed **around** the + boxes. Routing tries a clear vertical channel between the two boxes first, then a + lane above or below everything (which is what feedback links right-to-left need), + and only falls back to a direct dog-leg if nothing is clear. +- One endpoint off-sheet → a **knob** is drawn at the slot's edge, marking a connection + that leaves this sheet. In the sample app, 137 of 217 links are same-sheet, so knobs + are not a rare case. + +Wires and knobs are painted behind the component boxes and re-routed on every drag +step, since they are not part of the dragged canvas group. + +## Versioning + +`VERSION` lives in `app_state.py` and is shown in the window title. + +- **Feature** → bump the version. +- **Bug fix** → no bump. +- **Refactor with no behaviour change** → no bump. + +## Known gaps + +- A kit missing from `/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. diff --git a/app_state.py b/app_state.py new file mode 100644 index 0000000..29b6f80 --- /dev/null +++ b/app_state.py @@ -0,0 +1,114 @@ +"""Shared configuration, runtime state and widget handles. + +Every other module reaches this state as ``app_state.`` 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() diff --git a/editor.properties b/editor.properties new file mode 100644 index 0000000..099f963 --- /dev/null +++ b/editor.properties @@ -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 + diff --git a/main.py b/main.py new file mode 100644 index 0000000..2caeea2 --- /dev/null +++ b/main.py @@ -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"", handler) + window.bind_all(f"", handler) + +# Redo also as Shift-Z, the usual mac chord. +window.bind_all("", do_redo) +window.bind_all("", do_redo) + +# Delete: the mac Delete key reports as BackSpace, the Windows one as Delete. +window.bind_all("", delete_selected_component) +window.bind_all("", 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("<>", on_tree_select) + +tree.bind("", on_tree_right_click) +tree.bind("", 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("<>", populate_palette_types) +palette_tree.bind("", on_palette_press) +palette_tree.bind("", on_palette_motion) +palette_tree.bind("", 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("", on_canvas_click) +canvas.bind("", on_canvas_drag) +canvas.bind("", on_canvas_release) +canvas.bind("", lambda e: wiresheet.draw_grid()) + +canvas.bind("", on_canvas_right_click) +canvas.bind("", 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() diff --git a/palette.py b/palette.py new file mode 100644 index 0000000..0c7c01b --- /dev/null +++ b/palette.py @@ -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 , 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', 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 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() diff --git a/run.py b/run.py new file mode 100644 index 0000000..e526141 --- /dev/null +++ b/run.py @@ -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) diff --git a/sax_dom.py b/sax_dom.py new file mode 100644 index 0000000..b8873d9 --- /dev/null +++ b/sax_dom.py @@ -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) diff --git a/sax_file.py b/sax_file.py new file mode 100644 index 0000000..1c3d78b --- /dev/null +++ b/sax_file.py @@ -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 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'\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'\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 entry to 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 diff --git a/schema.py b/schema.py new file mode 100644 index 0000000..95b2758 --- /dev/null +++ b/schema.py @@ -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 diff --git a/testSax/checksumYok.sax b/testSax/checksumYok.sax new file mode 100644 index 0000000..8adcfdf --- /dev/null +++ b/testSax/checksumYok.sax @@ -0,0 +1,1315 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/testSax/normal.sax b/testSax/normal.sax new file mode 100644 index 0000000..c25127c --- /dev/null +++ b/testSax/normal.sax @@ -0,0 +1,1266 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/undo.py b/undo.py new file mode 100644 index 0000000..4e36969 --- /dev/null +++ b/undo.py @@ -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 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) diff --git a/wiresheet.py b/wiresheet.py new file mode 100644 index 0000000..149616c --- /dev/null +++ b/wiresheet.py @@ -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