"""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"", handler) window.bind_all(f"", handler) # Redo also as Shift-Z, the usual mac chord. window.bind_all("", do_redo) window.bind_all("", do_redo) # Delete: the mac Delete key reports as BackSpace, the Windows one as Delete. window.bind_all("", delete_selected_component) window.bind_all("", delete_selected_component) accel_mod = "Cmd" if sys.platform == "darwin" else "Ctrl" # --- Native Application Menu Configurations --- menu_bar = tk.Menu(window) menu_file = tk.Menu(menu_bar, tearoff=0) menu_file.add_command(label="Open", command=sax_file.open_file) menu_file.add_command(label="Save", command=do_save, accelerator=f"{accel_mod}+S") menu_file.add_command(label="Save As...", command=sax_file.save_file_as) menu_file.add_separator() menu_file.add_command(label="Exit", command=window.quit) menu_bar.add_cascade(label="File", menu=menu_file) menu_edit = tk.Menu(menu_bar, tearoff=0) menu_edit.add_command(label="Undo", command=do_undo, accelerator=f"{accel_mod}+Z") menu_edit.add_command(label="Redo", command=do_redo, accelerator=f"{accel_mod}+Y") menu_edit.add_separator() menu_edit.add_command(label="Delete Component", command=delete_selected_component, accelerator="Del") menu_bar.add_cascade(label="Edit", menu=menu_edit) menu_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("<>", on_tree_select) tree.bind("", on_tree_right_click) tree.bind("", on_tree_right_click) left_pane.add(frame_tree, weight=3) # --- Component palette --- frame_palette = ttk.Frame(left_pane) ttk.Label(frame_palette, text="Palette", font=("TkDefaultFont", 9, "bold")).pack( anchor="w", padx=4, pady=(4, 2) ) palette_kit_var = tk.StringVar() palette_kit_combo = ttk.Combobox(frame_palette, textvariable=palette_kit_var, state="readonly") palette_kit_combo.pack(fill=tk.X, padx=4) frame_palette_list = ttk.Frame(frame_palette) frame_palette_list.pack(fill=tk.BOTH, expand=True, padx=4, pady=4) palette_scrollbar = ttk.Scrollbar(frame_palette_list) palette_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) palette_tree = ttk.Treeview( frame_palette_list, yscrollcommand=palette_scrollbar.set, show="tree", selectmode="browse", ) palette_tree.pack(fill=tk.BOTH, expand=True) palette_scrollbar.config(command=palette_tree.yview) palette_status = ttk.Label(frame_palette, text="", foreground="#666666") palette_status.pack(anchor="w", padx=4, pady=(0, 4)) def populate_palette_types(event=None): """Lists the component types of the kit chosen in the palette dropdown.""" for item in palette_tree.get_children(): palette_tree.delete(item) index = palette_kit_combo.current() if index < 0 or index >= len(palette_kit_names): palette_status.config(text="") return kit_name = palette_kit_names[index] types = palette.list_component_types(kit_name) for type_name, base in types: palette_tree.insert("", "end", text=type_name, values=(f"{kit_name}::{type_name}", base)) palette_status.config(text=f"{len(types)} component{'' if len(types) == 1 else 's'}") def refresh_palette_kits(): """Reloads the kit list from Sedona home. Also called after Preferences changes it.""" global palette_kit_names palette.invalidate() palette_kit_names = palette.list_kits() labels = [] for kit_name in palette_kit_names: version = palette.kit_version_label(kit_name) labels.append(f"{kit_name} ({version})" if version else kit_name) palette_kit_combo.config(values=labels) for item in palette_tree.get_children(): palette_tree.delete(item) if not palette_kit_names: palette_status.config( text="No kits found — check Sedona home" if app_state.SEDONA_HOME else "Set Sedona home in Preferences" ) palette_kit_var.set("") return palette_status.config(text=f"{len(palette_kit_names)} kits") palette_kit_var.set("") # --- Palette drag and drop --- # Tk has no drag-and-drop between widgets, so the gesture is assembled by hand: # remember what was pressed, start dragging past a small threshold, follow the pointer # with a borderless ghost window, and on release test the pointer against the canvas. DRAG_THRESHOLD = 6 palette_drag = {"kit": None, "type": None, "origin": None, "active": False, "ghost": None} def _destroy_drag_ghost(): if palette_drag["ghost"] is not None: palette_drag["ghost"].destroy() palette_drag["ghost"] = None def _reset_palette_drag(): _destroy_drag_ghost() palette_drag.update(kit=None, type=None, origin=None, active=False) def on_palette_press(event): item = palette_tree.identify_row(event.y) if not item: _reset_palette_drag() return palette_tree.selection_set(item) index = palette_kit_combo.current() if index < 0 or index >= len(palette_kit_names): return palette_drag.update( kit=palette_kit_names[index], type=palette_tree.item(item, "text"), origin=(event.x_root, event.y_root), active=False, ) def on_palette_motion(event): if palette_drag["type"] is None or palette_drag["origin"] is None: return origin_x, origin_y = palette_drag["origin"] moved = abs(event.x_root - origin_x) + abs(event.y_root - origin_y) if not palette_drag["active"]: if moved < DRAG_THRESHOLD: return palette_drag["active"] = True ghost = tk.Toplevel(window) ghost.overrideredirect(True) ghost.attributes("-topmost", True) tk.Label( ghost, text=f"{palette_drag['type']}", bg="#dce6f2", fg="black", relief="solid", borderwidth=1, padx=6, pady=2, font=("TkDefaultFont", 9), ).pack() palette_drag["ghost"] = ghost if palette_drag["ghost"] is not None: palette_drag["ghost"].geometry(f"+{event.x_root + 12}+{event.y_root + 10}") def _pointer_over_canvas(x_root, y_root): left = canvas.winfo_rootx() top = canvas.winfo_rooty() return (left <= x_root < left + canvas.winfo_width() and top <= y_root < top + canvas.winfo_height()) def on_palette_release(event): was_dragging = palette_drag["active"] kit_name = palette_drag["kit"] type_name = palette_drag["type"] _reset_palette_drag() if not was_dragging or not kit_name or not type_name: return if not _pointer_over_canvas(event.x_root, event.y_root): return if app_state.xml_root_element is None: messagebox.showwarning("Add Component", "Open a SAX file first.") return # Pointer position -> canvas coordinates -> grid units, snapped like a dragged box. drop_x = canvas.canvasx(event.x_root - canvas.winfo_rootx()) drop_y = canvas.canvasy(event.y_root - canvas.winfo_rooty()) grid_size = app_state.GRID_SIZE grid_x = max(0, min(wiresheet.META_COORD_MAX, round(drop_x / grid_size))) grid_y = max(0, min(wiresheet.META_COORD_MAX, round(drop_y / grid_size))) sax_file.add_component(kit_name, type_name, grid_x, grid_y) palette_kit_names = [] palette_kit_combo.bind("<>", populate_palette_types) palette_tree.bind("", on_palette_press) palette_tree.bind("", on_palette_motion) palette_tree.bind("", on_palette_release) left_pane.add(frame_palette, weight=2) paned_window.add(left_pane, weight=1) frame_editor = ttk.Frame(paned_window) canvas_vscroll = ttk.Scrollbar(frame_editor, orient=tk.VERTICAL) canvas_vscroll.pack(side=tk.RIGHT, fill=tk.Y) canvas_hscroll = ttk.Scrollbar(frame_editor, orient=tk.HORIZONTAL) canvas_hscroll.pack(side=tk.BOTTOM, fill=tk.X) canvas = tk.Canvas( frame_editor, bg="white", highlightthickness=0, xscrollcommand=canvas_hscroll.set, yscrollcommand=canvas_vscroll.set, scrollregion=(0, 0, 2000, 2000), ) app_state.canvas = canvas canvas.pack(fill=tk.BOTH, expand=True) canvas_vscroll.config(command=canvas.yview) canvas_hscroll.config(command=canvas.xview) canvas.bind("", on_canvas_click) canvas.bind("", on_canvas_drag) canvas.bind("", on_canvas_release) canvas.bind("", lambda e: wiresheet.draw_grid()) canvas.bind("", on_canvas_right_click) canvas.bind("", on_canvas_right_click) paned_window.add(frame_editor, weight=3) undo.set_refresh_callback(refresh_all_views) refresh_palette_kits() window.after(100, verify_startup_configurations) window.mainloop()