Cmd+W closes the window on macOS, so the Command half of the pair is left alone; this is the one shortcut that is not bound on both modifiers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1528 lines
55 KiB
Python
1528 lines
55 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, font as tkfont, 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
|
|
|
|
# Tree column measurements, in pixels. The padding covers the expander arrow and the
|
|
# cell's own inset, which sit ahead of the text ttk lets us measure.
|
|
TREE_INDENT = 20
|
|
TREE_TEXT_PADDING = 28
|
|
TREE_MIN_COLUMN_WIDTH = 40
|
|
|
|
# Rows each list asks for. This is the only real floor ttk offers on the left column's own
|
|
# split: a pane keeps its requested height, and a short window takes the shortfall from the
|
|
# panes in proportion to their weights. Left at the ten-row default the palette asked for
|
|
# so much that the tree, carrying the larger weight, gave up all of its height first.
|
|
TREE_REQUESTED_ROWS = 6
|
|
PALETTE_REQUESTED_ROWS = 5
|
|
|
|
# Below this the shortfall is deep enough to shrink the tree past a usable few rows, and
|
|
# ttk has no way to hold a pane at a floor once the room is simply not there.
|
|
MIN_WINDOW_HEIGHT = 300
|
|
|
|
# How many visited sheets Back and Forward can walk through. A sheet is one tree node's
|
|
# children, so a session touches few of them; the cap only stops an unbounded list.
|
|
HISTORY_LIMIT = 50
|
|
|
|
MENU_BACK_LABEL = "Back"
|
|
MENU_FORWARD_LABEL = "Forward"
|
|
|
|
# --- Sheet view history ---
|
|
# Each entry is the element whose children are painted, which is what
|
|
# app_state.current_sheet_parent holds; None is the file's root node. Elements are kept
|
|
# rather than tree item ids because a rebuild of the tree hands out fresh ids, while the
|
|
# elements themselves survive anything short of a delete or a new file.
|
|
sheet_history = []
|
|
history_index = -1 # position in sheet_history that is on screen
|
|
_suppress_history = False # set while Back or Forward drives the tree selection
|
|
_history_file_path = None # the file sheet_history was recorded against
|
|
|
|
# Filled in once the widgets exist. History can be recorded before then, so every
|
|
# function that touches them checks first.
|
|
btn_back = None
|
|
btn_forward = None
|
|
menu_view = None
|
|
show_left_column_var = None
|
|
|
|
|
|
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
|
|
record_sheet_visit(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()
|
|
|
|
|
|
def fit_tree_column(tree):
|
|
"""Sizes a Treeview's tree column to its widest visible row.
|
|
|
|
ttk derives the horizontal scroll range from the column width, so a column left to
|
|
stretch with the widget can never scroll - long text is simply clipped. Sizing it to
|
|
the content is what gives the horizontal scrollbar something to move, and the max()
|
|
keeps a short list filling the width it has instead of leaving a bare strip.
|
|
"""
|
|
if tree is None or not tree.winfo_exists():
|
|
return
|
|
|
|
style = ttk.Style()
|
|
text_font = tkfont.nametofont(style.lookup("Treeview", "font") or "TkDefaultFont")
|
|
try:
|
|
indent = int(style.lookup("Treeview", "indent") or TREE_INDENT)
|
|
except (ValueError, tk.TclError):
|
|
indent = TREE_INDENT
|
|
|
|
widest = 0
|
|
|
|
def measure(node, depth):
|
|
nonlocal widest
|
|
for child in tree.get_children(node):
|
|
width = depth * indent + text_font.measure(tree.item(child, "text"))
|
|
widest = max(widest, width)
|
|
# A closed branch hides its children, so they are not part of the width.
|
|
if tree.item(child, "open"):
|
|
measure(child, depth + 1)
|
|
|
|
measure("", 1)
|
|
tree.column("#0", width=max(widest + TREE_TEXT_PADDING, tree.winfo_width() - 2),
|
|
minwidth=TREE_MIN_COLUMN_WIDTH, stretch=False)
|
|
|
|
|
|
def attach_tree_scrollbars(container, tree):
|
|
"""Puts a Treeview in its container with scrollbars that show only when they help.
|
|
|
|
The tree/palette column is a fixed width now, so a deep path or a long type name has
|
|
to be reachable by scrolling sideways rather than by widening the window. A bar that
|
|
has nothing to scroll is taken out of the grid so it costs no room.
|
|
"""
|
|
vertical = ttk.Scrollbar(container, orient=tk.VERTICAL, command=tree.yview)
|
|
horizontal = ttk.Scrollbar(container, orient=tk.HORIZONTAL, command=tree.xview)
|
|
vertical_cell = {"row": 0, "column": 1, "sticky": "ns"}
|
|
horizontal_cell = {"row": 1, "column": 0, "sticky": "ew"}
|
|
|
|
def show_when_needed(bar, cell, first, last):
|
|
if float(first) <= 0.0 and float(last) >= 1.0:
|
|
bar.grid_remove()
|
|
else:
|
|
bar.grid(**cell)
|
|
bar.set(first, last)
|
|
|
|
tree.configure(
|
|
yscrollcommand=lambda first, last: show_when_needed(vertical, vertical_cell, first, last),
|
|
xscrollcommand=lambda first, last: show_when_needed(horizontal, horizontal_cell, first, last),
|
|
)
|
|
|
|
container.rowconfigure(0, weight=1)
|
|
container.columnconfigure(0, weight=1)
|
|
tree.grid(row=0, column=0, sticky="nsew")
|
|
|
|
# Resizing, and opening or closing a branch, both change what the widest row is.
|
|
tree.bind("<Configure>", lambda event: fit_tree_column(tree), add="+")
|
|
tree.bind("<<TreeviewOpen>>", lambda event: tree.after_idle(fit_tree_column, tree), add="+")
|
|
tree.bind("<<TreeviewClose>>", lambda event: tree.after_idle(fit_tree_column, tree), add="+")
|
|
|
|
|
|
def clamp_left_pane_split(event=None):
|
|
"""Keeps both halves of the left column visible, whatever the sash or the window does.
|
|
|
|
ttk lets a sash drag run to either end, and it remembers that position when the window
|
|
is made shorter afterwards, so either list can be left with no height at all. Each
|
|
pane's requested height is the line. Once the column is too short to seat both, the
|
|
floors are dropped and ttk's own proportional squeeze takes over.
|
|
"""
|
|
total = left_pane.winfo_height()
|
|
tree_floor = frame_tree.winfo_reqheight()
|
|
palette_floor = frame_palette.winfo_reqheight()
|
|
if total <= tree_floor + palette_floor:
|
|
return
|
|
|
|
position = left_pane.sashpos(0)
|
|
clamped = min(max(position, tree_floor), total - palette_floor)
|
|
if clamped != position:
|
|
left_pane.sashpos(0, clamped)
|
|
|
|
|
|
def apply_left_pane_width():
|
|
"""Pins the tree/palette column to the configured width.
|
|
|
|
Setting the sash before the paned window has been laid out collapses the column
|
|
instead of sizing it, so the startup call retries until there is a width to divide.
|
|
This is also how a changed preference reaches an already open window.
|
|
"""
|
|
app_state.window.update_idletasks()
|
|
if paned_window.winfo_width() <= 1:
|
|
paned_window.after(50, apply_left_pane_width)
|
|
return
|
|
paned_window.sashpos(0, app_state.LEFT_PANE_WIDTH)
|
|
|
|
|
|
def apply_left_column_visibility():
|
|
"""Shows or hides the tree/palette column to match the View menu's checkbutton.
|
|
|
|
ttk has no hide flag for a pane: the child is dropped from the paned window and put
|
|
back at the front, which is why the configured width has to be re-applied afterwards.
|
|
"""
|
|
if show_left_column_var is None:
|
|
return
|
|
|
|
panes = paned_window.panes()
|
|
is_shown = str(left_pane) in [str(pane) for pane in panes]
|
|
|
|
if show_left_column_var.get():
|
|
if not is_shown:
|
|
paned_window.insert(0, left_pane, weight=0)
|
|
apply_left_pane_width()
|
|
elif is_shown:
|
|
paned_window.forget(left_pane)
|
|
|
|
|
|
def toggle_left_column(event=None):
|
|
"""Keyboard route to the same toggle. The menu flips the variable for itself."""
|
|
if show_left_column_var is not None:
|
|
show_left_column_var.set(not show_left_column_var.get())
|
|
apply_left_column_visibility()
|
|
return "break"
|
|
|
|
|
|
# --- Sheet view history ---
|
|
|
|
def _tree_item_for_element(element):
|
|
"""The tree item whose sheet is this element's children, or None if it is gone.
|
|
|
|
A history entry recorded before a delete, or before another file was opened, no
|
|
longer has a node to select; that is what None reports.
|
|
"""
|
|
tree = app_state.tree
|
|
if tree is None:
|
|
return None
|
|
if element is None:
|
|
# The file's root node, which parse never puts in the element map.
|
|
children = tree.get_children()
|
|
return children[0] if children else None
|
|
for item_id, mapped in app_state.tree_element_map.items():
|
|
if mapped is element:
|
|
return item_id
|
|
return None
|
|
|
|
|
|
def _prune_sheet_history():
|
|
"""Drops entries whose sheet no longer exists, keeping the position on screen."""
|
|
global history_index
|
|
|
|
kept = []
|
|
new_index = -1
|
|
for position, element in enumerate(sheet_history):
|
|
if _tree_item_for_element(element) is None:
|
|
continue
|
|
kept.append(element)
|
|
if position <= history_index:
|
|
new_index = len(kept) - 1
|
|
|
|
sheet_history[:] = kept
|
|
history_index = min(new_index, len(kept) - 1)
|
|
|
|
|
|
def update_history_controls():
|
|
"""Greys out Back and Forward when there is nothing to walk to in that direction."""
|
|
can_back = history_index > 0
|
|
can_forward = 0 <= history_index < len(sheet_history) - 1
|
|
|
|
for button, enabled in ((btn_back, can_back), (btn_forward, can_forward)):
|
|
if button is not None:
|
|
button.config(state="normal" if enabled else "disabled")
|
|
|
|
if menu_view is not None:
|
|
menu_view.entryconfig(
|
|
MENU_BACK_LABEL, state="normal" if can_back else "disabled"
|
|
)
|
|
menu_view.entryconfig(
|
|
MENU_FORWARD_LABEL, state="normal" if can_forward else "disabled"
|
|
)
|
|
|
|
|
|
def reset_sheet_history():
|
|
"""Starts the history over, for a newly opened file."""
|
|
global history_index
|
|
sheet_history.clear()
|
|
history_index = -1
|
|
update_history_controls()
|
|
|
|
|
|
def record_sheet_visit(element):
|
|
"""Adds a freshly opened sheet to the history, dropping anything ahead of it.
|
|
|
|
Called from the tree selection handler, so it sees every way a sheet is opened.
|
|
Back and Forward suppress it: walking the history must not rewrite it.
|
|
"""
|
|
global history_index, _history_file_path
|
|
|
|
if _suppress_history:
|
|
return
|
|
|
|
# Entries name elements of one document, so a different file starts a fresh history
|
|
# whether or not the tree rebuild that came with it was announced.
|
|
if app_state.current_file_path != _history_file_path:
|
|
_history_file_path = app_state.current_file_path
|
|
sheet_history.clear()
|
|
history_index = -1
|
|
|
|
if 0 <= history_index < len(sheet_history) and sheet_history[history_index] is element:
|
|
return
|
|
|
|
del sheet_history[history_index + 1:]
|
|
sheet_history.append(element)
|
|
if len(sheet_history) > HISTORY_LIMIT:
|
|
del sheet_history[0]
|
|
history_index = len(sheet_history) - 1
|
|
update_history_controls()
|
|
|
|
|
|
def _show_sheet_history_entry(position):
|
|
"""Opens the sheet the history holds at this position, without recording the move."""
|
|
global history_index, _suppress_history
|
|
|
|
element = sheet_history[position]
|
|
item_id = _tree_item_for_element(element)
|
|
if item_id is None:
|
|
return
|
|
|
|
tree = app_state.tree
|
|
history_index = position
|
|
was_selected = tree.selection() == (item_id,)
|
|
|
|
_suppress_history = True
|
|
try:
|
|
# Selecting the node is what paints the sheet, via on_tree_select.
|
|
tree.selection_set(item_id)
|
|
tree.focus(item_id)
|
|
tree.see(item_id)
|
|
finally:
|
|
_suppress_history = False
|
|
|
|
if was_selected:
|
|
# ttk fires no selection event when the node is already the selected one, so
|
|
# the repaint this entry is asking for has to be made here instead.
|
|
app_state.current_sheet_parent = element
|
|
wiresheet.render_current_sheet()
|
|
|
|
update_history_controls()
|
|
|
|
|
|
def go_back(event=None):
|
|
"""View menu and toolbar: step to the previously opened sheet."""
|
|
_prune_sheet_history()
|
|
if history_index > 0:
|
|
_show_sheet_history_entry(history_index - 1)
|
|
else:
|
|
update_history_controls()
|
|
return "break"
|
|
|
|
|
|
def go_forward(event=None):
|
|
"""View menu and toolbar: step back towards the most recently opened sheet."""
|
|
_prune_sheet_history()
|
|
if 0 <= history_index < len(sheet_history) - 1:
|
|
_show_sheet_history_entry(history_index + 1)
|
|
else:
|
|
update_history_controls()
|
|
return "break"
|
|
|
|
|
|
def on_tree_filled():
|
|
"""Runs after every rebuild of the navigation tree.
|
|
|
|
A rebuild is either a new file, which retires the history, or a repaint after an
|
|
undo or a delete, which can leave entries pointing at elements that are no longer
|
|
in the document.
|
|
"""
|
|
global _history_file_path
|
|
|
|
fit_tree_column(app_state.tree)
|
|
|
|
if app_state.current_file_path != _history_file_path:
|
|
_history_file_path = app_state.current_file_path
|
|
reset_sheet_history()
|
|
else:
|
|
_prune_sheet_history()
|
|
update_history_controls()
|
|
|
|
|
|
# --- 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("470x290")
|
|
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_left_pane = ttk.Label(frame_padded, text="Tree/palette width (pixels):", anchor="w")
|
|
lbl_left_pane.grid(row=3, column=0, sticky="w", pady=(0, 10), padx=(0, 10))
|
|
|
|
entry_left_pane = ttk.Entry(frame_padded)
|
|
entry_left_pane.insert(0, str(app_state.LEFT_PANE_WIDTH))
|
|
entry_left_pane.grid(row=3, 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=4, 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=4, 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."
|
|
)
|
|
|
|
try:
|
|
pane_width = int(entry_left_pane.get().strip())
|
|
except ValueError:
|
|
raise ValueError(
|
|
f"Tree/palette width must be a whole number between "
|
|
f"{app_state.MIN_ALLOWED_LEFT_PANE_WIDTH} and "
|
|
f"{app_state.MAX_ALLOWED_LEFT_PANE_WIDTH}."
|
|
)
|
|
if not (app_state.MIN_ALLOWED_LEFT_PANE_WIDTH <= pane_width
|
|
<= app_state.MAX_ALLOWED_LEFT_PANE_WIDTH):
|
|
raise ValueError(
|
|
f"Tree/palette width must be between "
|
|
f"{app_state.MIN_ALLOWED_LEFT_PANE_WIDTH} and "
|
|
f"{app_state.MAX_ALLOWED_LEFT_PANE_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.LEFT_PANE_WIDTH = pane_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()
|
|
|
|
apply_left_pane_width()
|
|
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=5, 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")
|
|
# Width is left alone; only the height needs a floor, to keep the tree pane off zero.
|
|
window.minsize(1, MIN_WINDOW_HEIGHT)
|
|
|
|
# --- 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),
|
|
("b", toggle_left_column),
|
|
]
|
|
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)
|
|
|
|
# Tidy Layout is the one shortcut bound on Control alone: Cmd+W closes the window on
|
|
# macOS, and shadowing that would be a poor trade for a second way to re-flow a sheet.
|
|
window.bind_all("<Control-w>", do_tidy_layout)
|
|
|
|
# Sheet history: the browser chords, in both the Alt and the mac bracket spelling.
|
|
window.bind_all("<Alt-Left>", go_back)
|
|
window.bind_all("<Alt-Right>", go_forward)
|
|
window.bind_all("<Command-bracketleft>", go_back)
|
|
window.bind_all("<Command-bracketright>", go_forward)
|
|
|
|
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_view = tk.Menu(menu_bar, tearoff=0)
|
|
show_left_column_var = tk.BooleanVar(value=True)
|
|
menu_view.add_checkbutton(
|
|
label="Tree / Palette",
|
|
variable=show_left_column_var,
|
|
command=apply_left_column_visibility,
|
|
accelerator=f"{accel_mod}+B",
|
|
)
|
|
menu_view.add_separator()
|
|
menu_view.add_command(
|
|
label=MENU_BACK_LABEL, command=go_back, accelerator="Alt+Left", state="disabled"
|
|
)
|
|
menu_view.add_command(
|
|
label=MENU_FORWARD_LABEL, command=go_forward, accelerator="Alt+Right", state="disabled"
|
|
)
|
|
menu_view.add_separator()
|
|
menu_view.add_command(label="Tidy Layout", command=do_tidy_layout, accelerator="Ctrl+W")
|
|
menu_bar.add_cascade(label="View", menu=menu_view)
|
|
|
|
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
|
|
|
|
# Packed ahead of the paned window for the same reason the status bar is.
|
|
toolbar = ttk.Frame(window)
|
|
toolbar.pack(side=tk.TOP, fill=tk.X, padx=8, pady=(4, 0))
|
|
|
|
btn_back = ttk.Button(toolbar, text="◀", width=3, command=go_back, state="disabled")
|
|
btn_back.pack(side=tk.LEFT)
|
|
|
|
btn_forward = ttk.Button(toolbar, text="▶", width=3, command=go_forward, state="disabled")
|
|
btn_forward.pack(side=tk.LEFT, padx=(3, 0))
|
|
|
|
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 = ttk.Treeview(
|
|
frame_tree,
|
|
show="tree",
|
|
selectmode="browse",
|
|
height=TREE_REQUESTED_ROWS,
|
|
)
|
|
app_state.tree = tree
|
|
attach_tree_scrollbars(frame_tree, tree)
|
|
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_tree = ttk.Treeview(
|
|
frame_palette_list,
|
|
show="tree",
|
|
selectmode="browse",
|
|
height=PALETTE_REQUESTED_ROWS,
|
|
)
|
|
attach_tree_scrollbars(frame_palette_list, palette_tree)
|
|
|
|
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'}")
|
|
fit_tree_column(palette_tree)
|
|
|
|
|
|
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("")
|
|
fit_tree_column(palette_tree)
|
|
|
|
|
|
|
|
# --- 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)
|
|
|
|
# weight=0 keeps the tree/palette column out of the share-out of spare width, so growing
|
|
# the window feeds the wiresheet alone and the column stays at its configured width.
|
|
paned_window.add(left_pane, weight=0)
|
|
|
|
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)
|
|
sax_file.set_tree_filled_callback(on_tree_filled)
|
|
refresh_palette_kits()
|
|
apply_left_pane_width()
|
|
|
|
# Moving the sash changes the split directly; resizing the window changes what fits.
|
|
left_pane.bind("<B1-Motion>", clamp_left_pane_split, add="+")
|
|
left_pane.bind("<ButtonRelease-1>", clamp_left_pane_split, add="+")
|
|
left_pane.bind("<Configure>", clamp_left_pane_split, add="+")
|
|
|
|
window.after(100, verify_startup_configurations)
|
|
window.mainloop()
|