Files
sedona_editor/main.py
T
ArdaandClaude Opus 5 e2cd13b9a0 Wiresheet: grid-snapped layout, visible wire routing, knob legend
Layout rules for tidy and for dragging:
- Boxes snap to whole meta steps live while dragged, not only on release.
- The left-most column and top row clear the canvas edge by at least 30px,
  and neighbours by at least 30px, each rounded up to the next step.
- Box widths round up to the grid so a right edge lands on a grid line.
- Column gaps widen only where wires need lanes, so most stay at exactly 30px.
- A sheet too wide for meta's 0..255 range is laid out at 2, then 3, then 4
  grid squares per step. The multiplier is derived from the sheet's contents,
  so painting and tidying agree on it without recording anything in the file.
  The grid size in Preferences is untouched.

Wire routing:
- No wire may pass under a component. A link with no clear route is shown as
  knobs on both slots instead of a line hidden behind a box.
- Vertical runs hug a box within 15px, never closer than 10px, and two runs
  sharing a gap stand 10px apart.
- Horizontal runs of different links stay 5px apart. Runs from one output pin
  may share, since a fan-out is one signal.
- Straight routes claim their rows first, as their segments are pinned to
  their slots and cannot move later.

Knobs:
- Wire colour with a small white centre when the far end is on this sheet,
  grey when it is on another, half and half when a slot carries both.
- Selectable and deletable like a wire, and raised above the boxes so a click
  reaches them.
- Selecting a same-sheet pair draws a red dashed line between the two knobs.

Also:
- get_box_bounds returns the rectangle actually drawn rather than canvas.bbox,
  which was inflated by the outline stroke and by selection.
- Preferences offers to round the box width to a multiple of the grid.
- The tidy coordinate-limit warning said to lower the grid size; raising it is
  what helps, since the reach is 255 * grid size.
- Tidy Layout moves from its own menu into Edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 19:05:58 +03:00

1123 lines
40 KiB
Python

"""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 manage_kits
import palette
import sax_file
import schema
import undo
import wiresheet
from sax_dom import get_component_path, 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"{app_state.base_window_title()} — undid {label}")
return "break"
def do_redo(event=None):
label = undo.redo()
if label is None:
return "break"
app_state.window.title(f"{app_state.base_window_title()} — redid {label}")
return "break"
def do_save(event=None):
sax_file.execute_direct_overwrite_save()
return "break"
NL = chr(10) # newline, for the multi-line message boxes
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()
multiplier = app_state.meta_scale // app_state.GRID_SIZE
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} still hit the coordinate limit of {wiresheet.META_COORD_MAX} "
"steps, because meta stores x and y in one byte each. This sheet is already "
f"laid out at {multiplier} grid squares per step, the widest meta can reach, "
"so it does not fit however it is spaced. Split it across sub-sheets."
)
elif multiplier > 1:
messagebox.showinfo(
"Tidy Layout",
f"Repositioned {moved} component(s)." + NL + NL +
f"This sheet needs more width than meta's {wiresheet.META_COORD_MAX} "
f"coordinate steps allow at one grid square each, so it is laid out at "
f"{multiplier} grid squares per step ({app_state.meta_scale} px). Boxes "
f"settle on every {multiplier} grid lines rather than every one. Your grid "
"size in Preferences is unchanged."
)
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_link():
"""Removes the currently selected link from the DOM and repaints."""
if not app_state.selected_link_tag:
return
link_elem = app_state.canvas_link_map.get(app_state.selected_link_tag)
if link_elem is None:
return
from_ref = link_elem.get("from", "")
to_ref = link_elem.get("to", "")
confirm = messagebox.askyesno(
"Delete Link",
f"Delete link?\n\n from: {from_ref}\n to: {to_ref}"
)
if not confirm:
return
sax_file.remove_link(link_elem)
def delete_selected_component(event=None):
"""Deletes the highlighted canvas component (or selected link) and all its descendants recursively."""
if app_state.selected_link_tag:
delete_selected_link()
return
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)
wiresheet.deselect_link()
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, halo=3)
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)
wire_tag = next((t for t in tags if t.startswith("link_wire_")), None)
if wire_tag:
wiresheet.select_link(wire_tag)
return
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
# Where the box starts out. The drag is measured as an offset from this pair
# rather than accumulated per motion event, so repeated snapping cannot drift.
origin = wiresheet.get_box_origin(group_tag)
app_state.drag_data["box_x"] = origin[0] if origin else None
app_state.drag_data["box_y"] = origin[1] if origin else None
# 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 do_link_mark():
"""Marks the currently selected canvas component as a link endpoint."""
if not app_state.selected_canvas_tag:
return
comp = app_state.canvas_comp_map.get(app_state.selected_canvas_tag)
if comp is not None:
app_state.link_mark_element = comp
def _slot_linkable(slot_flags, slot_type, readonly, is_target):
"""Returns False if a slot cannot participate in a link on the given side."""
if is_target and readonly:
return False
if "a" in slot_flags or slot_type == "void":
return False
return True
def open_link_dialog(source_element, target_element):
"""Opens a dialog to select source and target slots, validates types, and creates a link."""
window = app_state.window
sides = [source_element, target_element]
dialog = tk.Toplevel(window)
dialog.title("Link")
dialog.resizable(False, False)
dialog.transient(window)
dialog.grab_set()
dialog.geometry(f"+{window.winfo_x() + 100}+{window.winfo_y() + 60}")
main_frame = tk.Frame(dialog, padx=10, pady=10)
main_frame.pack(fill=tk.BOTH, expand=True)
src_frame = tk.LabelFrame(main_frame, padx=5, pady=5)
src_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 5))
mid_frame = tk.Frame(main_frame)
mid_frame.grid(row=0, column=1, padx=5)
tgt_frame = tk.LabelFrame(main_frame, padx=5, pady=5)
tgt_frame.grid(row=0, column=2, sticky="nsew", padx=(5, 0))
src_var = tk.StringVar(value="")
tgt_var = tk.StringVar(value="")
src_widgets = []
tgt_widgets = []
src_slot_info = {}
tgt_slot_info = {}
status_var = tk.StringVar(value="")
def _update_peer_states(selected_var, selected_info, peer_widgets, peer_info, peer_is_target):
"""Grey out incompatible slots on the peer side after a selection."""
chosen = selected_var.get()
if not chosen or chosen not in selected_info:
for rb in peer_widgets:
name = rb.cget("value")
info = peer_info.get(name)
if info and _slot_linkable(info[1], info[0], info[2], peer_is_target):
rb.config(state=tk.NORMAL, fg="black")
else:
rb.config(state=tk.DISABLED, fg="#999999")
return
sel_type = selected_info[chosen][0]
for rb in peer_widgets:
name = rb.cget("value")
info = peer_info.get(name)
if not info:
rb.config(state=tk.DISABLED, fg="#999999")
continue
peer_type, peer_flags, peer_ro = info
if not _slot_linkable(peer_flags, peer_type, peer_ro, peer_is_target):
rb.config(state=tk.DISABLED, fg="#999999")
elif peer_type != sel_type:
rb.config(state=tk.DISABLED, fg="#999999")
else:
rb.config(state=tk.NORMAL, fg="black")
def _on_src_change(*_args):
_update_peer_states(src_var, src_slot_info, tgt_widgets, tgt_slot_info, True)
_update_status_label()
def _on_tgt_change(*_args):
_update_peer_states(tgt_var, tgt_slot_info, src_widgets, src_slot_info, False)
_update_status_label()
def _update_status_label():
s = src_var.get()
t = tgt_var.get()
left_name = sides[0].get("name", "?")
right_name = sides[1].get("name", "?")
if s and t:
status_var.set(f"Link {left_name}.{s} -> {right_name}.{t}")
elif s:
status_var.set(f"Link {left_name}.{s} -> ?")
elif t:
status_var.set(f"Link ? -> {right_name}.{t}")
else:
status_var.set("")
def populate_panel(parent_frame, slots, var, widgets_list, info_dict, is_target):
for child in parent_frame.winfo_children():
child.destroy()
widgets_list.clear()
info_dict.clear()
panel_canvas = tk.Canvas(parent_frame, width=160, height=300, highlightthickness=0)
panel_scroll = ttk.Scrollbar(parent_frame, orient=tk.VERTICAL, command=panel_canvas.yview)
inner = tk.Frame(panel_canvas)
panel_canvas.create_window((0, 0), window=inner, anchor="nw")
panel_canvas.config(yscrollcommand=panel_scroll.set)
panel_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
panel_scroll.pack(side=tk.RIGHT, fill=tk.Y)
for slot_name, slot_type, slot_flags, slot_ro in slots:
info_dict[slot_name] = (slot_type, slot_flags, slot_ro)
linkable = _slot_linkable(slot_flags, slot_type, slot_ro, is_target)
rb = tk.Radiobutton(inner, text=slot_name, variable=var, value=slot_name,
anchor="w", bg="#c8d4e8", relief="flat", padx=4, pady=1,
state=tk.NORMAL if linkable else tk.DISABLED,
fg="black" if linkable else "#999999",
disabledforeground="#999999")
rb.pack(fill=tk.X)
widgets_list.append(rb)
inner.update_idletasks()
panel_canvas.config(scrollregion=panel_canvas.bbox("all"))
def refresh_panels():
left = sides[0]
right = sides[1]
left_name = left.get("name", "?")
right_name = right.get("name", "?")
left_type = left.get("type", "")
right_type = right.get("type", "")
src_frame.config(text=f"{left_name} [Source]")
tgt_frame.config(text=f"{right_name} [Target]")
left_slots = schema.get_all_slots_for_type(left_type)
right_slots = schema.get_all_slots_for_type(right_type)
src_var.set("")
tgt_var.set("")
populate_panel(src_frame, left_slots, src_var, src_widgets, src_slot_info, False)
populate_panel(tgt_frame, right_slots, tgt_var, tgt_widgets, tgt_slot_info, True)
_update_status_label()
src_var.trace_add("write", _on_src_change)
tgt_var.trace_add("write", _on_tgt_change)
def on_swap():
sides[0], sides[1] = sides[1], sides[0]
refresh_panels()
ttk.Button(mid_frame, text="⇄", width=3, command=on_swap).pack(pady=5)
refresh_panels()
status_label = tk.Label(main_frame, textvariable=status_var, anchor="w", fg="#333333")
status_label.grid(row=1, column=0, columnspan=3, sticky="w", pady=(8, 0))
def on_ok():
src_slot = src_var.get()
tgt_slot = tgt_var.get()
if not src_slot or not tgt_slot:
messagebox.showwarning("Link", "Select a slot from both source and target.", parent=dialog)
return
st = src_slot_info.get(src_slot, ("?", ""))[0]
tt = tgt_slot_info.get(tgt_slot, ("?", ""))[0]
if st != tt:
messagebox.showwarning(
"Slot type mismatch",
f"Cannot link slots of different types.\n\n"
f"Source: {src_slot} ({st})\n"
f"Target: {tgt_slot} ({tt})",
parent=dialog
)
return
src_path = get_component_path(sides[0], app_state.xml_root_element)
tgt_path = get_component_path(sides[1], app_state.xml_root_element)
from_ref = f"{src_path}.{src_slot}"
to_ref = f"{tgt_path}.{tgt_slot}"
dialog.destroy()
sax_file.add_link(from_ref, to_ref)
btn_frame = tk.Frame(main_frame)
btn_frame.grid(row=2, column=0, columnspan=3, pady=(15, 0))
ttk.Button(btn_frame, text="OK", width=10, command=on_ok).pack(side=tk.LEFT, padx=5)
ttk.Button(btn_frame, text="Cancel", width=10, command=dialog.destroy).pack(side=tk.LEFT, padx=5)
def do_link_from_mark():
"""Link From: marked component is source, right-clicked component is target."""
if app_state.link_mark_element is None or not app_state.selected_canvas_tag:
return
target = app_state.canvas_comp_map.get(app_state.selected_canvas_tag)
if target is not None:
open_link_dialog(app_state.link_mark_element, target)
def do_link_to_mark():
"""Link To: right-clicked component is source, marked component is target."""
if app_state.link_mark_element is None or not app_state.selected_canvas_tag:
return
source = app_state.canvas_comp_map.get(app_state.selected_canvas_tag)
if source is not None:
open_link_dialog(source, app_state.link_mark_element)
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, halo=3)
if clicked_item:
tags = canvas.gettags(clicked_item[0])
group_tag = next((t for t in tags if t.startswith("comp_group_")), None)
wire_tag = next((t for t in tags if t.startswith("link_wire_")), None)
if wire_tag:
wiresheet.select_link(wire_tag)
link_context_menu.post(event.x_root, event.y_root)
return
if group_tag:
wiresheet.deselect_link()
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)
_rebuild_canvas_context_menu()
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
group_tag = app_state.drag_data["tag"]
cx = canvas.canvasx(event.x)
cy = canvas.canvasy(event.y)
box_x = app_state.drag_data.get("box_x")
origin = wiresheet.get_box_origin(group_tag)
if box_x is None or origin is None:
# Nothing to align against, so the box just follows the pointer one to one.
canvas.move(group_tag, cx - app_state.drag_data["x"], cy - app_state.drag_data["y"])
app_state.drag_data["x"] = cx
app_state.drag_data["y"] = cy
wiresheet.draw_links()
return
# Snap to whole meta steps, which is the grid on an ordinary sheet and a multiple of
# it on one too wide for meta's range -- a position between steps cannot be stored.
step = app_state.meta_scale
margin = wiresheet.layout_margin(step)
# Follow the pointer, but land the box on a step every step of the way, and keep it
# out of the margin strip along the top and left canvas edges.
wanted_x = box_x + (cx - app_state.drag_data["x"])
wanted_y = app_state.drag_data["box_y"] + (cy - app_state.drag_data["y"])
target_x = max(margin, round(wanted_x / step) * step)
target_y = max(margin, round(wanted_y / step) * step)
shift_x = target_x - origin[0]
shift_y = target_y - origin[1]
if shift_x or shift_y:
canvas.move(group_tag, shift_x, shift_y)
# 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
step = app_state.meta_scale
group_tag = app_state.drag_data["tag"]
origin = wiresheet.get_box_origin(group_tag)
if origin is not None:
current_x, current_y = origin
margin = wiresheet.layout_margin(step)
snapped_x = max(margin, round(current_x / step) * step)
snapped_y = max(margin, round(current_y / step) * step)
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 = min(wiresheet.META_COORD_MAX, int(round(snapped_x / step)))
raw_y = min(wiresheet.META_COORD_MAX, int(round(snapped_y / step)))
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."
)
# The width cap is applied after each box is fitted to its text, so a cap
# that is not a multiple of the grid leaves the widest boxes off-grid.
proposed = wiresheet.suggest_box_width_for_grid(width, val)
if proposed != width:
if not messagebox.askyesno(
"Box Width Off Grid",
f"A box width of {width} px is not a multiple of the {val} px grid, "
f"so the widest boxes would not line up with it.\n\n"
f"Change the box width to {proposed} px?",
parent=dialog,
):
return
width = proposed
entry_box_width.delete(0, tk.END)
entry_box_width.insert(0, str(width))
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()
return
# editor.properties can be hand-edited, so the pair is reconciled here too.
proposed = wiresheet.suggest_box_width_for_grid(app_state.BOX_WIDTH, app_state.GRID_SIZE)
if proposed != app_state.BOX_WIDTH and messagebox.askyesno(
"Box Width Off Grid",
f"The saved box width of {app_state.BOX_WIDTH} px is not a multiple of the "
f"{app_state.GRID_SIZE} px grid, so the widest boxes will not line up with it.\n\n"
f"Change the box width to {proposed} px?",
):
app_state.BOX_WIDTH = proposed
app_state.save_preferences()
wiresheet.draw_grid()
if app_state.active_components_list:
wiresheet.render_components(app_state.active_components_list)
# --- UI Setup ---
window = tk.Tk()
app_state.window = window
window.title(app_state.base_window_title())
window.geometry("950x600")
# --- Context Popups Structures ---
canvas_context_menu = tk.Menu(window, tearoff=0)
link_context_menu = tk.Menu(window, tearoff=0)
link_context_menu.add_command(label="Delete Link", command=delete_selected_link)
def _rebuild_canvas_context_menu():
"""Rebuilds the canvas context menu with dynamic Link From/Link To labels."""
canvas_context_menu.delete(0, tk.END)
canvas_context_menu.add_command(label="Delete Component", command=delete_selected_component)
canvas_context_menu.add_separator()
canvas_context_menu.add_command(label="Link Mark", command=do_link_mark)
if app_state.link_mark_element is not None:
mark_name = app_state.link_mark_element.get("name", "?")
canvas_context_menu.add_command(
label=f'Link From "{mark_name}"', command=do_link_from_mark
)
canvas_context_menu.add_command(
label=f'Link To "{mark_name}"', command=do_link_to_mark
)
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_edit.add_separator()
menu_edit.add_command(label="Tidy Layout", command=do_tidy_layout)
menu_bar.add_cascade(label="Edit", menu=menu_edit)
menu_kits = tk.Menu(menu_bar, tearoff=0)
menu_kits.add_command(
label="Manage Kits...",
command=lambda: manage_kits.open_dialog(on_applied=refresh_palette_kits)
)
menu_bar.add_cascade(label="Kits", menu=menu_kits)
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)
# Packed before the paned window: pack hands out the cavity in order, and the paned
# window expands into whatever is left, so a status bar added afterwards would get no room.
status_label = ttk.Label(window, text="", anchor="w", foreground=app_state.STATUS_INFO_COLOUR)
status_label.pack(side=tk.BOTTOM, fill=tk.X, padx=8, pady=(0, 4))
app_state.status_label = status_label
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())
step = app_state.meta_scale
min_step = wiresheet.layout_margin(step) // step
grid_x = max(min_step, min(wiresheet.META_COORD_MAX, round(drop_x / step)))
grid_y = max(min_step, min(wiresheet.META_COORD_MAX, round(drop_y / step)))
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()