Placing a palette component no longer edits <schema> straight away. The kit is noted as pending, its manifest is compiled so the box can be painted, and the status bar says the kit will be declared on save. The <schema> block is then reconciled when the file is written, in the one place that can report the whole kit list the file will carry, with each kit's version and checksum. What a kit's manifest depends on counts as used too, transitively, so placing a component can bring in more than one entry: hvac pulls in types, ontrolPion pulls in ontrolPlatform. New Kits > Manage Kits... dialog lists every kit in three groups -- needed by the app, declared but unused, and merely installed -- with the pinned build, the newest installed build, and an Action dropdown of every build a kit ships. The tick means the schema declares the kit: grey and fixed for kits the app needs, unticked to remove, ticked to add at the newest build. "Upgrade all kits to latest version" moves every declared kit; "Update kits" applies the ticks and the Action column, additions and removals included. Every schema change is applied as a batch, then verified: a missing manifest or a component type that stops resolving reverts the whole block and reports why, and a kit the app needs cannot be removed. Changes are one undo step, which recompiles the slot registry in both directions since a repin can change a component's slots. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1049 lines
36 KiB
Python
1049 lines
36 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"
|
|
|
|
|
|
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_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
|
|
|
|
# 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
|
|
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(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_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_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)
|
|
|
|
# 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())
|
|
|
|
grid_size = app_state.GRID_SIZE
|
|
grid_x = max(0, min(wiresheet.META_COORD_MAX, round(drop_x / grid_size)))
|
|
grid_y = max(0, min(wiresheet.META_COORD_MAX, round(drop_y / grid_size)))
|
|
|
|
sax_file.add_component(kit_name, type_name, grid_x, grid_y)
|
|
|
|
|
|
palette_kit_names = []
|
|
palette_kit_combo.bind("<<ComboboxSelected>>", populate_palette_types)
|
|
palette_tree.bind("<ButtonPress-1>", on_palette_press)
|
|
palette_tree.bind("<B1-Motion>", on_palette_motion)
|
|
palette_tree.bind("<ButtonRelease-1>", on_palette_release)
|
|
|
|
left_pane.add(frame_palette, weight=2)
|
|
paned_window.add(left_pane, weight=1)
|
|
|
|
frame_editor = ttk.Frame(paned_window)
|
|
|
|
canvas_vscroll = ttk.Scrollbar(frame_editor, orient=tk.VERTICAL)
|
|
canvas_vscroll.pack(side=tk.RIGHT, fill=tk.Y)
|
|
|
|
canvas_hscroll = ttk.Scrollbar(frame_editor, orient=tk.HORIZONTAL)
|
|
canvas_hscroll.pack(side=tk.BOTTOM, fill=tk.X)
|
|
|
|
canvas = tk.Canvas(
|
|
frame_editor,
|
|
bg="white",
|
|
highlightthickness=0,
|
|
xscrollcommand=canvas_hscroll.set,
|
|
yscrollcommand=canvas_vscroll.set,
|
|
scrollregion=(0, 0, 2000, 2000),
|
|
)
|
|
app_state.canvas = canvas
|
|
canvas.pack(fill=tk.BOTH, expand=True)
|
|
|
|
canvas_vscroll.config(command=canvas.yview)
|
|
canvas_hscroll.config(command=canvas.xview)
|
|
|
|
canvas.bind("<ButtonPress-1>", on_canvas_click)
|
|
canvas.bind("<B1-Motion>", on_canvas_drag)
|
|
canvas.bind("<ButtonRelease-1>", on_canvas_release)
|
|
canvas.bind("<Configure>", lambda e: wiresheet.draw_grid())
|
|
|
|
canvas.bind("<ButtonPress-3>", on_canvas_right_click)
|
|
canvas.bind("<ButtonPress-2>", on_canvas_right_click)
|
|
|
|
paned_window.add(frame_editor, weight=3)
|
|
|
|
undo.set_refresh_callback(refresh_all_views)
|
|
refresh_palette_kits()
|
|
|
|
window.after(100, verify_startup_configurations)
|
|
window.mainloop()
|