Files
sedona_editor/main.py
T
ArdaandClaude Opus 5.5 7ff2479736 Preferences: lazy loading on connect, on by default
Off reads the whole app from app.sab when connecting. Read at each connect, so a
change takes effect on the next one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:13:41 +03:00

3796 lines
143 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 threading
import app_backup
import app_state
import connect_dialog
import device_session
import link_drag
import link_view
import live_hooks
import manage_kits
import palette
import properties
import put_kits_dialog
import sax_file
import schema
import sox
import undo
import ox_page_view
import user_service_view
import wiresheet
from sax_dom import (find_component_by_path, find_parent_element, get_component_path,
read_meta_property, remove_prop, set_prop_value, 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
class _ToolTip:
"""Minimal hover tooltip for toolbar widgets."""
def __init__(self, widget, text):
self._widget = widget
self._text = text
self._tw = None
widget.bind("<Enter>", self._show, add="+")
widget.bind("<Leave>", self._hide, add="+")
def _show(self, _event=None):
x = self._widget.winfo_rootx() + self._widget.winfo_width() // 2
y = self._widget.winfo_rooty() + self._widget.winfo_height() + 4
tw = tk.Toplevel(self._widget)
tw.wm_overrideredirect(True)
tw.wm_geometry(f"+{x}+{y}")
label = tk.Label(
tw, text=self._text, background="#ffffe0", foreground="#000000",
relief="solid", borderwidth=1, padx=4, pady=2,
font=("Segoe UI", 8),
)
label.pack()
self._tw = tw
def _hide(self, _event=None):
if self._tw:
self._tw.destroy()
self._tw = None
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
# The editor itself, plus room for the Page Editor's widgets/properties pane.
DEFAULT_WINDOW_WIDTH = 880 + ox_page_view.RIGHT_PANE_WIDTH
DEFAULT_WINDOW_HEIGHT = 600
# How many visited views Back and Forward can walk through. A session touches few of
# them; the cap only stops an unbounded list.
HISTORY_LIMIT = 50
# What a history entry can be showing. The editor pane holds one of these five at a
# time, so the history that walks it has to be able to name each of them.
VIEW_SHEET = "sheet"
VIEW_PROPERTIES = "properties"
VIEW_LINKS = "links"
VIEW_PAGE = "page"
VIEW_USERS = "users"
# Tk class names of the widgets that own the keyboard while they have focus. Del and
# Backspace mean "edit this text" in each of them, never "delete the selected component".
TEXT_ENTRY_CLASSES = ("Entry", "TEntry", "TCombobox", "Text", "Spinbox", "TSpinbox")
MENU_BACK_LABEL = "Back"
MENU_FORWARD_LABEL = "Forward"
# --- Editor pane history ---
# Each entry is (kind, element), naming what the pane was showing:
#
# (VIEW_SHEET, element) the sheet whose children are painted, which is what
# app_state.current_sheet_parent holds; None is the
# file's root node
# (VIEW_PROPERTIES, comp) the Properties view of that component
# (VIEW_LINKS, comp) the Link View of that component
# (VIEW_PAGE, comp) the oxPage view of that page component
# (VIEW_USERS, comp) the User Manager of that UserService component
#
# Opening any of these views is a move like opening a sheet, so Back from one
# lands on what the pane showed before it rather than skipping past to the sheet before
# that, and Forward brings it back.
#
# 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.
view_history = []
history_index = -1 # position in view_history that is on screen
_suppress_history = False # set while Back or Forward restores an entry
_history_file_path = None # the file view_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
btn_toggle_pane = None
zoom_label = None
menu_view = None
show_left_column_var = None
# The Properties, Link and Kits views each take the editor pane over from the canvas
# rather than floating above it, so at most one of the four is on screen and these three
# say which. All None means the sheet is showing.
properties_panel = None
link_panel = None
kits_panel = None
ox_page_panel = None
user_service_panel = None
# The sheet to put the tree back on when a sheet change is refused because of unsaved
# edits, and the flag that stops the restoring selection from being read as a fresh
# choice. The element is held rather than the tree item id, for the same reason the
# history holds elements: a rebuild of the tree hands out fresh ids.
panel_return_sheet = None
_restoring_selection = False
# The link a jump has asked to be picked out, held until the sheet it is on has been
# painted. Selecting a tree node paints its sheet, and painting a sheet clears whatever
# link was selected, so the pick cannot be made at the point the node is selected --
# see go_to_sheet_with_link.
_pending_link_selection = None
def anything_unwritten():
"""Whether there is a change no one has written yet, in the document or in a field.
The Properties view counts before its Save: a typed value is as unwritten as a
dragged box, and a file name that only went red on the second of those would be
telling half the story.
"""
if app_state.is_dirty:
return True
return properties_panel is not None and properties_panel.dirty
def refresh_dirty_display():
"""Puts the file name on the tree's root row in red, or back to its usual colour.
The tag is added to or taken off the row rather than having its colour reconfigured.
Reconfiguring a tag does not reliably repaint rows already on screen, so the name
only turned red on the paths that happened to rebuild the tree -- a Properties Save
did, dragging a box did not.
"""
if ox_page_panel is not None:
ox_page_panel.refresh_dirty_tint()
tree = app_state.tree
if tree is None:
return
rows = tree.get_children()
if not rows:
return
root_row = rows[0]
wanted = (app_state.DIRTY_NAME_TAG,) if anything_unwritten() else ()
if tuple(tree.item(root_row, "tags")) != wanted:
tree.item(root_row, tags=wanted)
def refresh_all_views():
"""Rebuilds tree and canvas from the DOM. Registered as undo's view refresh."""
sax_file.refresh_treeview_from_dom(app_state.xml_root_element)
wiresheet.render_current_sheet()
# An undo or a cascading delete can take the component a panel is showing out of the
# document underneath it. Nothing it could display then would be true.
if properties_panel is not None and not _component_in_document(properties_panel.comp):
close_editor_panel(force=True)
# The Link View reads the document and writes nothing, so a change elsewhere is
# simply redrawn rather than being a reason to take the view down.
if link_panel is not None:
if _component_in_document(link_panel.comp):
link_panel.draw()
else:
close_editor_panel(force=True)
if ox_page_panel is not None:
if _component_in_document(ox_page_panel.comp):
ox_page_panel.refresh()
else:
close_editor_panel(force=True)
if user_service_panel is not None:
if _component_in_document(user_service_panel.comp):
user_service_panel.refresh()
else:
close_editor_panel(force=True)
def current_element(comp):
"""The element standing for `comp` in the document now.
On a device, selecting a tree row opens its sheet, which reads the DOM afresh after
a right-click has already picked the row's element. The same component is then a
new element with the same id; acting on the old one would leave the document, and
undo, holding something other than what the device just did.
"""
root = app_state.xml_root_element
if root is None or not app_state.live_mode:
return comp
if any(element is comp for element in root.iter()):
return comp
comp_id = comp.get("id")
if comp_id is not None:
for element in root.iter():
if element.tag in ("app", "comp") and element.get("id") == comp_id:
return element
return comp
def _component_in_document(comp):
root = app_state.xml_root_element
if root is None:
return False
if any(element is comp for element in root.iter()):
return True
if app_state.live_mode:
comp_id = comp.get("id")
if comp_id is not None:
return any(e.get("id") == comp_id for e in root.iter())
return False
def delete_component(comp):
"""Deletes a component, taking a view of it down with it if one is showing.
Every delete in the UI comes through here. sax_file does its own view refresh rather
than going through refresh_all_views, so the panel would otherwise be left describing
a component the document no longer has.
"""
panel = properties_panel or link_panel or ox_page_panel or user_service_panel
showing = panel is not None and panel.comp is comp
if sax_file.execute_cascading_deletion(comp) and showing:
close_editor_panel(force=True)
def editor_panel_showing():
"""Whether something other than the wiresheet has the editor pane."""
return (properties_panel is not None or link_panel is not None
or kits_panel is not None or ox_page_panel is not None
or user_service_panel is not None)
def _take_editor_pane():
"""Hides the sheet so a panel can have the pane.
The canvas is only unpacked, never destroyed, so the sheet keeps its items and its
scroll position and comes back exactly as it was. It is still painted while it is
hidden -- a Properties Save repaints it -- which costs nothing and means there is no
catching up to do when it returns.
"""
global panel_return_sheet
panel_return_sheet = app_state.current_sheet_parent
canvas_vscroll.pack_forget()
canvas_hscroll.pack_forget()
app_state.canvas.pack_forget()
def _give_editor_pane_back():
"""Puts the sheet back, in the order the pane was built in.
The scrollbars are repacked before the canvas because pack honours the order it was
given: packed after it, they would take their space from the middle of the pane.
"""
canvas_vscroll.pack(side=tk.RIGHT, fill=tk.Y)
canvas_hscroll.pack(side=tk.BOTTOM, fill=tk.X)
app_state.canvas.pack(fill=tk.BOTH, expand=True)
app_state.clear_status()
def show_properties(comp):
"""Puts one component's slots in the editor pane, in place of the wiresheet."""
global properties_panel
if comp is None:
return
# Right-clicking a second component is a way of leaving the first, so it asks about
# unsaved edits exactly as Close does, and a refusal leaves the first one showing.
if not close_editor_panel():
return
_take_editor_pane()
properties_panel = properties.build_properties_panel(
frame_editor, comp, on_change=refresh_all_views, on_close=dismiss_properties,
on_dirty_change=refresh_dirty_display,
)
properties_panel.frame.pack(fill=tk.BOTH, expand=True)
record_visit(VIEW_PROPERTIES, comp)
default_name = "App" if comp.tag == "app" else "?"
app_state.set_status(f"Properties: {comp.get('name', default_name)}")
def dismiss_properties(restore_canvas=True):
"""Takes the Properties view down and gives the pane back, asking nothing.
The panel's own Close is what asks; by the time it calls this the question has been
put and answered. Anything that has made the view moot -- the component deleted, the
file closed -- comes straight here too.
"""
global properties_panel
if properties_panel is None:
return
properties_panel.destroy()
properties_panel = None
refresh_dirty_display()
if restore_canvas:
_give_editor_pane_back()
def show_link_view(comp):
"""Puts one component's links in the editor pane, in place of the wiresheet.
The same trade the Properties view makes, and for the same reason: the sheet and a
picture of one component's wiring are two answers to the same question, so only one
of them should be on screen at a time.
"""
global link_panel
if comp is None:
return
if not close_editor_panel():
return
_take_editor_pane()
link_panel = link_view.build_link_view_panel(
frame_editor, comp, on_open_link=open_link_from_link_view,
on_close=dismiss_link_view,
)
link_panel.frame.pack(fill=tk.BOTH, expand=True)
record_visit(VIEW_LINKS, comp)
app_state.set_status(f"Link View: {comp.get('name', '?')}")
def dismiss_link_view(restore_canvas=True):
"""Takes the Link View down and gives the pane back.
Nothing is ever asked here: the view has changed nothing, so there is nothing to
lose by leaving it.
"""
global link_panel
if link_panel is None:
return
link_panel.destroy()
link_panel = None
if restore_canvas:
_give_editor_pane_back()
def show_ox_page_view(comp):
"""Puts the WYSIWYG page editor for an OxPage in the editor pane."""
global ox_page_panel
if comp is None:
return
if not close_editor_panel():
return
_take_editor_pane()
ox_page_panel = ox_page_view.build_ox_page_view_panel(
frame_editor, comp, on_change=refresh_all_views,
on_close=dismiss_ox_page_view,
)
ox_page_panel.frame.pack(fill=tk.BOTH, expand=True)
_show_page_editor_tools(ox_page_panel)
record_visit(VIEW_PAGE, comp)
update_zoom_label()
app_state.set_status(f"Page Editor: {comp.get('name', '?')}")
def _show_page_editor_tools(panel):
"""Lays the Page Editor's toggles out on the toolbar, after a separator of their own."""
_hide_page_editor_tools()
ttk.Separator(page_editor_tools, orient=tk.VERTICAL).pack(
side=tk.LEFT, fill=tk.Y, padx=8, pady=2)
for index, (icon, tip, variable, command) in enumerate(panel.toolbar_tools()):
if variable is not None:
button = ttk.Checkbutton(page_editor_tools, text=icon, width=3,
style="Toolbutton", variable=variable, command=command)
else:
button = ttk.Button(page_editor_tools, text=icon, width=3, command=command)
button.pack(side=tk.LEFT, padx=(3 if index else 0, 0))
_ToolTip(button, tip)
page_editor_tools.pack(side=tk.LEFT)
def _hide_page_editor_tools():
for child in page_editor_tools.winfo_children():
child.destroy()
page_editor_tools.pack_forget()
def dismiss_ox_page_view(restore_canvas=True):
"""Takes the OxPage editor down and gives the pane back."""
global ox_page_panel
if ox_page_panel is None:
return
ox_page_panel.destroy()
ox_page_panel = None
_hide_page_editor_tools()
update_zoom_label()
if restore_canvas:
_give_editor_pane_back()
def show_user_service(comp):
"""Puts the User Manager for a sys::UserService in the editor pane."""
global user_service_panel
if comp is None:
return
if not close_editor_panel():
return
_take_editor_pane()
user_service_panel = user_service_view.build_user_service_panel(
frame_editor, comp, on_change=refresh_all_views,
on_close=dismiss_user_service,
)
user_service_panel.frame.pack(fill=tk.BOTH, expand=True)
record_visit(VIEW_USERS, comp)
app_state.set_status(f"User Manager: {comp.get('name', '?')}")
def dismiss_user_service(restore_canvas=True):
"""Takes the User Manager down and gives the pane back."""
global user_service_panel
if user_service_panel is None:
return
user_service_panel.destroy()
user_service_panel = None
if restore_canvas:
_give_editor_pane_back()
def show_kits():
"""Puts the kit table in the editor pane, in place of the wiresheet."""
global kits_panel
if app_state.xml_root_element is None:
messagebox.showinfo("Manage Kits", "Open a SAX file first.")
return
if app_state.xml_root_element.find("schema") is None:
messagebox.showwarning("Manage Kits", "This file has no <schema> block to manage.")
return
if not close_editor_panel():
return
_take_editor_pane()
kits_panel = manage_kits.build_kits_panel(
frame_editor, on_applied=refresh_palette_kits, on_close=dismiss_kits,
)
kits_panel.frame.pack(fill=tk.BOTH, expand=True)
app_state.set_status("Manage Kits")
def dismiss_kits(restore_canvas=True):
"""Takes the Kits view down and gives the pane back."""
global kits_panel
if kits_panel is None:
return
kits_panel.destroy()
kits_panel = None
if restore_canvas:
_give_editor_pane_back()
def close_editor_panel(force=False):
"""Frees the editor pane, asking about unsaved edits first. True if it is now free.
Every way of leaving a panel goes through this, so "you have not saved that" is
asked once, in one place, whether the user pressed Close, picked another sheet,
walked the history or opened a different component. Only the Properties view has
anything to ask about; other views simply stand down.
"""
if link_panel is not None:
dismiss_link_view()
return True
if kits_panel is not None:
dismiss_kits()
return True
if ox_page_panel is not None:
dismiss_ox_page_view()
return True
if user_service_panel is not None:
dismiss_user_service()
return True
if properties_panel is None:
return True
if force:
dismiss_properties()
return True
return bool(properties_panel.close())
def _restore_tree_selection():
"""Puts the tree back on the sheet that is actually showing, after a refused change."""
global _restoring_selection
item_id = _tree_item_for_element(panel_return_sheet)
if item_id is None:
return
_restoring_selection = True
app_state.tree.selection_set(item_id)
# ttk queues its selection event rather than dispatching it, so the flag has to
# outlive this call or the restoring selection is read as a fresh choice.
app_state.window.after_idle(_clear_restoring_flag)
def _clear_restoring_flag():
global _restoring_selection
_restoring_selection = False
def do_undo(event=None):
label = undo.undo()
if label is None:
return "break"
app_state.window.title(f"{_base_title()} — undid {label}")
return "break"
def _base_title():
if app_state.live_mode:
return app_state.base_window_title_live()
return app_state.base_window_title()
def do_save(event=None):
if app_state.live_mode:
do_download_to_sax()
return "break"
sax_file.execute_direct_overwrite_save()
return "break"
def do_download_to_sax():
"""In live mode, saves the current device DOM snapshot as a .sax file."""
if app_state.xml_root_element is None:
return
path = filedialog.asksaveasfilename(
title="Download to SAX",
defaultextension=".sax",
filetypes=[("Sedona SAX", "*.sax"), ("All files", "*.*")],
initialfile=f"{app_state.device_host}.sax"
)
if not path:
return
try:
sax_file.write_sax_to_path(path)
app_state.set_status(f"Downloaded to {os.path.basename(path)}")
except Exception as e:
messagebox.showerror("Download Failed", str(e))
def _invoke_app_action(slot_name):
"""Invokes an action slot on the app component (comp_id 0) of the connected device.
Returns whether the device took it; why not is on the status bar.
"""
ds = app_state.device_session
if ds is None or not ds.is_connected:
app_state.set_status("Not connected to a device", warn=True)
return False
comp = ds.sox_client.cache_get(0)
if comp is None:
app_state.set_status("App component not loaded", warn=True)
return False
slots = ds.sox_client.slot_registry.get((comp.kit_id, comp.type_id))
if slots is None:
app_state.set_status("No slot registry for the app component", warn=True)
return False
slot = next((s for s in slots if s["name"] == slot_name and s.get("is_action")), None)
if slot is None:
app_state.set_status(f"Action slot '{slot_name}' not found on app", warn=True)
return False
goes_down = slot_name in _GOES_DOWN
try:
# A device going down often does so before it answers: give the request a few
# seconds (time for DASP to resend it once), then disconnect either way rather
# than wait for the session to drop.
ds.sox_client.invoke(0, slot["id"], timeout=_GOES_DOWN_WAIT if goes_down else 30.0)
except sox.SoxException as e:
refused = "timeout" not in str(e).lower() and "closed" not in str(e).lower()
if not goes_down or refused:
app_state.set_status(f"Invoke '{slot_name}' failed: {e}", warn=True)
return False
except Exception as e:
app_state.set_status(f"Invoke '{slot_name}' failed: {e}", warn=True)
return False
if slot_name == "save":
app_state.mark_clean()
app_state.set_status(f"Invoked '{slot_name}' on device")
elif goes_down:
host = app_state.device_host
do_device_disconnect(device_down=True)
app_state.set_status(f"Sent '{slot_name}' to {host} and disconnected")
else:
app_state.set_status(f"Invoked '{slot_name}' on device")
return True
_APP_ACTIONS = ["save", "hibernate", "quit", "restart", "reboot"]
_GOES_DOWN = ("hibernate", "quit", "restart", "reboot") # the device stops answering
_GOES_DOWN_WAIT = 3.0 # seconds to wait for its reply before disconnecting
def do_get_with_file():
"""App > Get with Files: backs the device's app up to a zip in <Sedona home>/store/apps.
The app is saved on the device before its app.sab is read, as Ontrol's Niagara
driver does, so unsaved changes are announced first and the backup can be called
off. The transfer runs off the main thread behind a small progress window.
"""
ds = app_state.device_session
if not app_state.live_mode or ds is None or not ds.is_connected:
messagebox.showinfo("Get with Files", "Connect to a device first.")
return
if not app_state.SEDONA_HOME:
messagebox.showerror("Get with Files", "The Sedona home folder is not set (Preferences).")
return
if app_state.is_dirty and not messagebox.askokcancel(
"Unsaved Changes",
"The app has changes that are not saved on the device yet." + NL + NL +
"Get with Files saves the app on the device first, so they will be saved."):
return
path = _ask_backup_path(ds)
if not path:
return
def finish(result, error):
if error is not None:
app_state.set_status(f"Get with Files failed: {error}", warn=True)
messagebox.showerror("Get with Files", f"The backup failed:{NL}{NL}{error}")
return
app_state.mark_clean() # the app was saved on the device first
_report_backup(result)
_run_with_progress("Get with Files",
lambda status: app_backup.get_with_file(ds.sox_client, path, status),
finish)
def _run_with_progress(title, work, finish):
"""Runs work(status) off the main thread behind a small progress window.
status(text, fraction) updates the window; finish(result, error) then runs on the
main thread, error being the exception work raised, or None.
"""
window = app_state.window
dlg = tk.Toplevel(window)
dlg.title(title)
dlg.resizable(False, False)
dlg.transient(window)
dlg.protocol("WM_DELETE_WINDOW", lambda: None) # a transfer cannot be stopped midway
frame = ttk.Frame(dlg, padding=15)
frame.pack(fill=tk.BOTH, expand=True)
label = ttk.Label(frame, text="Starting ...", width=50)
label.pack(anchor="w")
bar = ttk.Progressbar(frame, length=360, maximum=1.0)
bar.pack(fill=tk.X, pady=(8, 0))
dlg.update_idletasks()
dlg.geometry(f"+{window.winfo_x() + (window.winfo_width() - dlg.winfo_width()) // 2}"
f"+{window.winfo_y() + (window.winfo_height() - dlg.winfo_height()) // 2}")
dlg.grab_set()
def status(text, fraction):
def show():
if dlg.winfo_exists():
label.config(text=text)
if fraction is not None:
bar.config(value=fraction)
window.after(0, show)
def done(result, error):
dlg.grab_release()
dlg.destroy()
finish(result, error)
def run():
try:
result = work(status)
except Exception as e:
window.after(0, lambda e=e: done(None, e))
else:
window.after(0, lambda: done(result, None))
threading.Thread(target=run, daemon=True).start()
def _device_app_names(ds):
"""(deviceName, appName) of the device's app, or None for each it does not give.
Read off the device now -- the app component's config is not loaded until
something asks for it.
"""
try:
comp = ds.sox_client.cache_get(0)
if comp is not None:
ds.sox_client.update([comp], device_session.CONFIG)
return comp.values.get("deviceName") or None, comp.values.get("appName") or None
except Exception as e:
app_state.set_status(f"Could not read the app's names: {e}", warn=True)
return None, None
def _ask_backup_path(ds):
"""The save dialog for the zip: <Sedona home>/store/apps, named after the app."""
device_name, app_name = _device_app_names(ds)
device_name, app_name = device_name or app_state.device_host, app_name or "app"
folder = app_backup.backup_folder()
try:
os.makedirs(folder, exist_ok=True)
except OSError:
folder = app_state.SEDONA_HOME
return filedialog.asksaveasfilename(
title="Get with Files",
initialdir=folder,
initialfile=app_backup.backup_file_name(device_name, app_name),
defaultextension=".zip",
filetypes=[("Zip archive", "*.zip"), ("All files", "*.*")],
)
def _report_backup(result):
"""Says where the zip went, and warns of anything the app names but could not get."""
lines = [f"Saved {os.path.basename(result.path)}", f"in {os.path.dirname(result.path)}",
"", f"{len(result.entries)} files in the zip."]
warn = False
if result.sax_error:
warn = True
lines += ["", f"app.sax could not be written: {result.sax_error}"]
if result.missing_on_device:
warn = True
lines += ["", "The app names these files, but the device does not have them:"]
lines += [f" {uri}" for uri in result.missing_on_device]
if result.missing_in_bank:
lines += ["", "No source for these in Sedona home's banks; only the device copy is in the zip:"]
lines += [f" {name}" for name in result.missing_in_bank]
app_state.set_status(f"Got {os.path.basename(result.path)}", warn=warn)
(messagebox.showwarning if warn else messagebox.showinfo)("Get with Files", NL.join(lines))
def do_put_with_files():
"""App > Put with Files: puts a Get with Files zip back onto the connected device.
As Ontrol's tool does: the kits the app needs are listed to choose builds for (the
device's by default), the backup's app.sax is encoded for them, and kits.scode is
rebuilt with sedonac when they are not the device's. The zip is checked against the
device on the way. The app goes staged, so the device takes it up when restarted,
which is offered once the files are in.
"""
ds = app_state.device_session
if not app_state.live_mode or ds is None or not ds.is_connected:
messagebox.showinfo("Put with Files", "Connect to a device first.")
return
if not app_state.SEDONA_HOME:
messagebox.showerror("Put with Files", "The Sedona home folder is not set (Preferences).")
return
folder = app_backup.backup_folder()
path = filedialog.askopenfilename(
title="Put with Files",
initialdir=folder if os.path.isdir(folder) else app_state.SEDONA_HOME,
filetypes=[("Sedona backup files", "*.zip"), ("All files", "*.*")],
)
if not path:
return
try:
target = app_backup.device_target(ds)
except Exception as e:
messagebox.showerror("Put with Files", f"Could not read the device:{NL}{NL}{e}")
return
try:
rows = app_backup.kit_rows(path, target)
except Exception as e:
messagebox.showerror("Put with Files", f"{os.path.basename(path)}:{NL}{NL}{e}")
return
backup_platform = app_backup.platform_of_kits(r.name for r in rows if r.backup or r.used)
if backup_platform is None or backup_platform is not target.platform:
messagebox.showerror(
"Put with Files",
f"{os.path.basename(path)} cannot be put on this device:{NL}{NL}"
f"The backup is from {app_backup.platform_label(backup_platform)}; the device "
f"is {app_backup.platform_label(target.platform)}.")
return
rebuild = put_kits_dialog.ask(app_state.window, rows)
if rebuild is None:
return
def planned(plan, error):
if error is not None:
messagebox.showerror("Put with Files", f"Preparing the put failed:{NL}{NL}{error}")
return
if not plan.ok:
app_state.set_status(f"{os.path.basename(path)} cannot be put on the device",
warn=True)
messagebox.showerror(
"Put with Files",
f"{os.path.basename(path)} cannot be put on this device:{NL}{NL}" +
NL.join(plan.problems))
return
if messagebox.askokcancel("Put with Files", _describe_put(plan),
icon=messagebox.WARNING, default=messagebox.CANCEL):
_put(plan)
def prepare(status):
status("Preparing the app" + (" and building kits.scode" if rebuild or any(
map(app_backup.kit_change, rows)) else "") + " ...", None)
return app_backup.plan_put(path, target, rows, rebuild)
def _put(plan):
_run_with_progress("Put with Files",
lambda status: app_backup.put_with_files(ds.sox_client, plan,
target, status),
lambda result, error: finish(plan, result, error))
def finish(plan, _result, error):
if error is not None:
app_state.set_status(f"Put with Files failed: {error}", warn=True)
messagebox.showerror(
"Put with Files",
f"The put failed:{NL}{NL}{error}{NL}{NL}" +
("The new app is staged, but not all of its fonts and images are on the "
"device. Put the backup again before restarting it."
if plan.staged else
"app.sab.stage was not written; the device keeps its current app."))
return
app_state.set_status(f"Put {os.path.basename(path)}; restart the device to take it up")
if messagebox.askyesno(
"Put with Files",
f"{len(plan.app_files) + len(plan.media)} files written to the device."
f"{NL}{NL}Restart the device now to complete provisioning?"):
if _invoke_app_action("restart"):
app_state.set_status(f"Put {os.path.basename(path)}; the device is "
"restarting, disconnected")
_run_with_progress("Put with Files", prepare, planned)
def _describe_put(plan):
"""The confirmation for a put: what goes to the device, and what that replaces."""
images = sum(f.device_uri.startswith("images/") for f in plan.media)
fonts = [f for f in plan.media if f.device_uri.startswith("fonts/")]
lines = [f"Put {os.path.basename(plan.path)} on {app_state.device_host}?", "",
"The device's app is replaced; anything not saved on it is lost." +
(" Its fonts and images are deleted and replaced by the backup's."
if plan.has_screen else ""), "",
f" app.sab, from {plan.app_source}"]
if plan.scode_source:
lines.append(f" kits.scode, {plan.scode_source}")
lines += [f" {line}" for line in plan.kit_changes]
if not plan.has_screen:
return NL.join(lines + ([""] + plan.warnings if plan.warnings else []))
lines.append(f" {images} images" +
(f" ({plan.converted} made from the backup's image bank)"
if plan.converted else ""))
lines.append(f" {len(fonts)} fonts" +
(f" ({len(plan.fonts_from_zip)} from the backup, the rest from Sedona home)"
if plan.fonts_from_zip else " from Sedona home" if fonts else ""))
if any(f.device_uri.startswith("system/") for f in plan.media):
lines.append(" boot logo")
for title, items in (("The backup has no usable copy of these images:",
plan.images_missing),
("Neither Sedona home nor the backup has these fonts:",
plan.fonts_missing)):
if items:
lines += ["", title] + [f" {item}" for item in items]
if plan.warnings:
lines += [""] + plan.warnings
return NL.join(lines)
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()
if moved is None:
return "break"
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"
# --- Zoom ---
def update_zoom_label():
"""Puts the painted scale on the toolbar as a percentage."""
if zoom_label is None:
return
if ox_page_panel is not None:
zoom_label.config(text=f"{round(ox_page_panel.zoom * 100)}%")
else:
zoom_label.config(text=f"{round(app_state.zoom * 100)}%")
def zoom_to(level, anchor=None):
"""Repaints the sheet at this zoom, holding one point of it still on screen.
``anchor`` is a canvas-relative (x, y) to keep under the same pixel -- the pointer,
for a wheel zoom -- and defaults to the middle of the visible area, so a menu or
keyboard zoom closes in on what is already being looked at rather than on the
top-left corner.
"""
canvas = app_state.canvas
before = app_state.zoom
if anchor is None:
anchor = (canvas.winfo_width() / 2, canvas.winfo_height() / 2)
# Where the held point sits on the unscaled sheet.
sheet_x = canvas.canvasx(anchor[0]) / before
sheet_y = canvas.canvasy(anchor[1]) / before
after = wiresheet.set_zoom(level)
update_zoom_label()
if after == before:
return
# Scroll the repainted sheet back under the held point. The scrollregion starts at
# the origin, so its far corner is the span the view fractions are measured against.
region = str(canvas.cget("scrollregion")).split()
if len(region) == 4:
span_x = float(region[2]) or 1.0
span_y = float(region[3]) or 1.0
canvas.xview_moveto(max(0.0, (sheet_x * after - anchor[0]) / span_x))
canvas.yview_moveto(max(0.0, (sheet_y * after - anchor[1]) / span_y))
def do_zoom_in(event=None):
if ox_page_panel is not None:
ox_page_panel.set_zoom(ox_page_panel.zoom_step(1))
update_zoom_label()
return "break"
zoom_to(wiresheet.zoom_step(1))
return "break"
def do_zoom_out(event=None):
if ox_page_panel is not None:
ox_page_panel.set_zoom(ox_page_panel.zoom_step(-1))
update_zoom_label()
return "break"
zoom_to(wiresheet.zoom_step(-1))
return "break"
def do_zoom_reset(event=None):
if ox_page_panel is not None:
ox_page_panel.set_zoom(1.0)
update_zoom_label()
return "break"
zoom_to(1.0)
return "break"
def do_zoom_fit(event=None):
"""Zoom so the whole sheet is visible, and scroll it into view.
The fitted scale is not one of the ladder's, so the percentage on the toolbar is
where it is read off; stepping in or out from there rejoins the ladder.
"""
if ox_page_panel is not None:
level = ox_page_panel.fit_zoom_level()
if level is None:
app_state.set_status("Cannot fit — page editor canvas not ready.")
return "break"
ox_page_panel.set_zoom(level)
update_zoom_label()
return "break"
canvas = app_state.canvas
level = wiresheet.fit_zoom_level()
bounds = wiresheet.sheet_content_bounds()
if level is None or bounds is None:
app_state.set_status("Nothing to fit — open a sheet with components first.")
return "break"
applied = wiresheet.set_zoom(level)
update_zoom_label()
region = str(canvas.cget("scrollregion")).split()
if len(region) == 4:
span_x = float(region[2]) or 1.0
span_y = float(region[3]) or 1.0
canvas.xview_moveto(max(0.0, (bounds[0] * applied - wiresheet.FIT_PADDING) / span_x))
canvas.yview_moveto(max(0.0, (bounds[1] * applied - wiresheet.FIT_PADDING) / span_y))
return "break"
def on_canvas_wheel_zoom(event):
"""Ctrl + wheel over the sheet, the gesture every other editor uses for zoom."""
if not event.delta:
return "break"
step = 1 if event.delta > 0 else -1
zoom_to(wiresheet.zoom_step(step), anchor=(event.x, event.y))
return "break"
def on_canvas_wheel_scroll(event):
"""Plain wheel pans the sheet up and down, Shift + wheel pans it sideways."""
if not event.delta:
return "break"
units = -1 if event.delta > 0 else 1
if event.state & 0x0001:
canvas.xview_scroll(units, "units")
else:
canvas.yview_scroll(units, "units")
return "break"
# --- Event handlers ---
def on_tree_select(event):
"""Triggered when a branch node is clicked in the Navigation view."""
global _pending_link_selection, _restoring_selection
# Read and cleared first thing, so a link a jump asked for is only ever picked out
# by the very selection that jump made. A selection that turns out not to paint a
# sheet -- one being put back after a refusal, one of nothing at all -- drops the
# request rather than leaving it to be applied to some later sheet.
pending_link = _pending_link_selection
_pending_link_selection = None
if _restoring_selection:
return
# Ctrl or Shift has picked out several rows to act on together, not a sheet to
# open, so the sheet already showing stays.
if len(app_state.tree.selection()) > 1:
return
# Choosing a node means "show me that sheet", so whatever has the pane stands down
# rather than leaving the selection looking like it did nothing. Refusing to leave
# has to put the tree back on the sheet that is still showing.
if not close_editor_panel():
_restore_tree_selection()
return
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 and tidy can repaint this same sheet from the DOM.
app_state.current_sheet_parent = element
record_visit(VIEW_SHEET, element)
if app_state.live_mode and app_state.device_session and element is not None:
comp_id_str = element.get("id")
if comp_id_str is not None:
ds = app_state.device_session
ds.close_wiresheet()
try:
ds.open_wiresheet(int(comp_id_str))
sax_root = ds.build_dom_tree(ds.sox_client.cache_get(0))
ds.build_links_elements(sax_root)
app_state.xml_root_element = sax_root
sax_file.refresh_treeview_from_dom(sax_root)
# re-find element by comp id after DOM rebuild (tree item IDs changed)
element = None
for tid, elem in app_state.tree_element_map.items():
if elem.get("id") == comp_id_str:
element = elem
selected_id = tid
break
if element is not None:
_restoring_selection = True
tree.selection_set(selected_id)
tree.see(selected_id)
tree.after_idle(_clear_restoring_flag)
app_state.current_sheet_parent = element
except Exception:
import traceback; traceback.print_exc()
# The DOM rebuild replaced every element object, so a pending link from the
# old tree can no longer be found by identity. Re-resolve it by from/to.
if pending_link is not None:
old_from = pending_link.get("from", "")
old_to = pending_link.get("to", "")
links_container = app_state.xml_root_element.find("links") if app_state.xml_root_element is not None else None
if links_container is not None:
for lnk in links_container:
if lnk.get("from") == old_from and lnk.get("to") == old_to:
pending_link = lnk
break
if element is not None:
components_to_render = [child for child in element if child.tag == "comp"]
elif tree.exists(selected_id):
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)
# The sheet is painted now, so a link the jump asked for has wires to be found among.
if pending_link is not None:
select_link_on_open_sheet(pending_link)
def delete_selected_link():
"""Removes every selected link from the DOM, as one undoable step, and repaints."""
link_elems = []
for wire_tag in app_state.selected_link_tags:
link_elem = app_state.canvas_link_map.get(wire_tag)
if link_elem is not None and link_elem not in link_elems:
link_elems.append(link_elem)
if not link_elems:
return
if len(link_elems) == 1:
from_ref = link_elems[0].get("from", "")
to_ref = link_elems[0].get("to", "")
question = f"Delete link?\n\n from: {from_ref}\n to: {to_ref}"
else:
shown = link_elems[:15]
lines = [f" {e.get('from', '')} → {e.get('to', '')}" for e in shown]
if len(link_elems) > len(shown):
lines.append(f" … and {len(link_elems) - len(shown)} more")
question = f"Delete {len(link_elems)} links?\n\n" + "\n".join(lines)
if not messagebox.askyesno("Delete Link", question):
return
sax_file.remove_links(link_elems)
def link_endpoint_components(link_elem):
"""The two components a link joins, resolved from its from/to references."""
root = app_state.xml_root_element
ends = []
for attribute in ("from", "to"):
reference = link_elem.get(attribute, "")
path = reference.rsplit(".", 1)[0] if "." in reference else ""
ends.append(find_component_by_path(root, path))
return ends
def link_target_sheet(link_elem):
"""Where a link leads: (sheet element, the component at that end), or None.
The far end is the one that is not on the open sheet, which is exactly the end a
knob stands in for. A link with both ends on this sheet has nowhere to go, and
neither has one whose references name a component this document does not have.
"""
root = app_state.xml_root_element
if root is None or link_elem is None:
return None
here = sax_file.current_sheet_container()
for component in link_endpoint_components(link_elem):
if component is None:
continue
sheet = find_parent_element(root, component)
if sheet is not None and sheet is not here:
return sheet, component
return None
def tree_item_for_sheet(sheet_element):
"""The navigation tree item that opens this sheet, or None if it has no node.
The app element is the file's root node, which the tree holds without an entry in
the element map, and which the history records as None for the same reason.
"""
root = app_state.xml_root_element
app_element = root.find("app") if root is not None else None
if sheet_element is app_element or sheet_element is root:
return _tree_item_for_element(None)
return _tree_item_for_element(sheet_element)
def scroll_canvas_to(tag):
"""Scrolls the sheet so the tagged items sit in the middle of the view."""
canvas = app_state.canvas
box = canvas.bbox(tag)
region = str(canvas.cget("scrollregion")).split()
if box is None or len(region) != 4:
return
span_x = float(region[2]) or 1.0
span_y = float(region[3]) or 1.0
middle_x = (box[0] + box[2]) / 2 - canvas.winfo_width() / 2
middle_y = (box[1] + box[3]) / 2 - canvas.winfo_height() / 2
canvas.xview_moveto(max(0.0, middle_x / span_x))
canvas.yview_moveto(max(0.0, middle_y / span_y))
def select_link_element(link_elem):
"""Selects whichever wire or knob now stands for this link, and scrolls it into view.
A repaint hands out fresh wire tags, so the link is found again by its element
rather than by the tag it had on the sheet it was picked on.
"""
for wire_tag, painted in app_state.canvas_link_map.items():
if painted is link_elem:
wiresheet.select_link(wire_tag)
scroll_canvas_to(wire_tag)
return True
return False
def go_to_link_sheet():
"""Link popup: open the sheet the far end of this link is on, with the link selected."""
link_elem = app_state.canvas_link_map.get(app_state.selected_link_tag)
destination = link_target_sheet(link_elem) if link_elem is not None else None
if destination is None:
app_state.set_status("This link has no other sheet to go to.", warn=True)
return
go_to_sheet_with_link(destination[0], link_elem)
def go_to_sheet_with_link(sheet_element, link_elem):
"""Opens a sheet and picks out one link on it.
Shared by the link popup's jump and the Link View, so following a link lands the
same way whichever of them was used to follow it.
"""
global _pending_link_selection
item_id = tree_item_for_sheet(sheet_element)
if item_id is None:
app_state.set_status(
f"'{link_view.sheet_display_name(sheet_element)}' is not in the "
"navigation tree.",
warn=True,
)
return
# Selecting the node is what paints the sheet, and painting a sheet clears whatever
# link was selected -- so the link is left as a request for the selection handler to
# honour once it has painted, rather than picked out here. An idle callback is not
# good enough: ttk dispatches its own selection event later than one of those, so a
# link selected from one is wiped by the repaint that follows it.
_pending_link_selection = link_elem
tree = app_state.tree
# This is also what records the visit, so Back returns to the sheet the link was
# followed from.
tree.selection_set(item_id)
tree.focus(item_id)
tree.see(item_id)
def open_link_from_link_view(link_elem):
"""Link View: leave for the wiresheet this link leads to, with the link selected.
Where it leads is the sheet of the end that is not the component the view is about:
a link that stays on one sheet goes back to that sheet, where both ends are drawn,
and one that crosses to another sheet opens that other sheet -- which is the end the
view could only show as a knob. A reference the document cannot resolve has no sheet
of its own, so it falls back to the one the component itself is on.
"""
focus = link_panel.comp if link_panel is not None else None
root = app_state.xml_root_element
if focus is None or root is None:
return
peer = next((component for component in link_endpoint_components(link_elem)
if component is not None and component is not focus), focus)
sheet_element = find_parent_element(root, peer)
# The sheet needs the pane back before it can be scrolled to the link: a hidden
# canvas has no width to centre anything in.
dismiss_link_view()
go_to_sheet_with_link(sheet_element, link_elem)
def select_link_on_open_sheet(link_elem):
"""Selects a link on the sheet that has just been painted, and says which it is."""
if select_link_element(link_elem):
app_state.set_status(
f"Link {link_elem.get('from', '?')} → {link_elem.get('to', '?')}"
)
else:
app_state.set_status("That link is not painted on this sheet.", warn=True)
def delete_selected_component(event=None):
"""Deletes the highlighted canvas component (or selected link) and all its descendants recursively."""
# Del is bound application-wide, so it also reaches a Properties field being typed in
# and every other window. A keystroke aimed at a text field must not delete a
# component, and neither must one pressed while the sheet is not even on screen.
if editor_panel_showing():
return
focused = app_state.window.focus_get()
if focused is not None:
if focused.winfo_toplevel() is not app_state.window:
return
if focused.winfo_class() in TEXT_ENTRY_CLASSES:
return
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:
delete_component(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
selected_rows = tree.selection()
keep_selection = item_id in selected_rows and len(selected_rows) > 1
if not keep_selection:
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.delete(0, tk.END)
# The root row names the <app> itself, which has slots of its own to show.
app_element = app_state.tree_element_map.get(item_id)
if app_element is not None:
tree_root_context_menu.add_command(
label="Properties", command=lambda: show_properties(app_element))
if _has_reorderable_children(app_element):
tree_root_context_menu.add_command(
label="Reorder...",
command=lambda: open_reorder_dialog(current_element(app_element)))
tree_root_context_menu.add_separator()
if app_state.live_mode and app_state.device_session and app_state.device_session.is_connected:
for action_name in _APP_ACTIONS:
tree_root_context_menu.add_command(
label=action_name.capitalize(),
command=lambda a=action_name: _invoke_app_action(a))
else:
tree_root_context_menu.add_command(
label="Save", command=sax_file.execute_direct_overwrite_save)
tree_root_context_menu.post(event.x_root, event.y_root)
return
comp_element = app_state.tree_element_map.get(item_id)
if comp_element is None:
return
# The same actions the canvas offers, on the component the tree row names: which
# list a component was picked from should not change what can be done with it.
selection = None
if keep_selection:
selection = [app_state.tree_element_map[row] for row in selected_rows
if row in app_state.tree_element_map]
fill_component_popup(tree_comp_context_menu, comp_element, selection)
if _has_reorderable_children(comp_element):
# After Rename..., with the other entries that change the component itself.
rename_index = tree_comp_context_menu.index("Rename...")
tree_comp_context_menu.insert_command(
rename_index + 1, label="Reorder...",
command=lambda: open_reorder_dialog(current_element(comp_element)))
tree_comp_context_menu.post(event.x_root, event.y_root)
def _open_special_view(comp):
"""Opens the type-specific view for a component, or Properties as fallback."""
if ox_page_view.is_ox_page(comp):
show_ox_page_view(comp)
return
if user_service_view.is_user_service(comp):
show_user_service(comp)
return
show_properties(comp)
def on_tree_double_click(event):
"""Double-click on a tree node opens its special view (or Properties).
Returns "break" so ttk's own double-click does not also toggle the row: only the
+/- sign opens or closes a node.
"""
tree = app_state.tree
item_id = tree.identify_row(event.y)
if not item_id:
return "break"
comp = app_state.tree_element_map.get(item_id)
if comp is None:
return "break"
if tree.parent(item_id) == "":
show_properties(comp)
else:
_open_special_view(comp)
return "break"
def on_canvas_double_click(event):
"""Double-click on a canvas component opens its special view (or Properties)."""
canvas = app_state.canvas
cx = canvas.canvasx(event.x)
cy = canvas.canvasy(event.y)
clicked = canvas.find_closest(cx, cy, halo=3)
if not clicked:
return
tags = canvas.gettags(clicked[0])
group_tag = next((t for t in tags if t.startswith("comp_group_")), None)
if group_tag is None:
return
comp = app_state.canvas_comp_map.get(group_tag)
if comp is not None:
_open_special_view(comp)
CTRL_MASK = 0x0004
def paint_canvas_selection():
"""Outlines every selected box heavier than the rest."""
canvas = app_state.canvas
canvas.itemconfig("block_outline", outline="#7f9db9", width=2)
for tag in app_state.selected_canvas_tags:
canvas.itemconfig(f"{tag} && block_outline", outline="#1f4e79", width=3)
def select_canvas_boxes(tags):
"""Makes exactly these boxes the selection; the last is the one Del acts on."""
tags = [t for t in tags if t]
app_state.selected_canvas_tags = set(tags)
app_state.selected_canvas_tag = tags[-1] if tags else None
paint_canvas_selection()
def _toggle_canvas_box(group_tag):
"""Ctrl+click: adds a box to the selection, or takes it back out."""
selected = app_state.selected_canvas_tags
if group_tag in selected:
selected.discard(group_tag)
if app_state.selected_canvas_tag == group_tag:
app_state.selected_canvas_tag = next(iter(selected), None)
else:
selected.add(group_tag)
app_state.selected_canvas_tag = group_tag
paint_canvas_selection()
def on_canvas_click(event):
canvas = app_state.canvas
app_state.drag_data["tag"] = None
cx = canvas.canvasx(event.x)
cy = canvas.canvasy(event.y)
clicked_item = canvas.find_closest(cx, cy, halo=3)
tags = canvas.gettags(clicked_item[0]) if clicked_item else ()
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 event.state & CTRL_MASK and wire_tag:
# Picking links out one by one. Links and boxes are not selected together, so
# Del always knows which of the two it is deleting.
select_canvas_boxes([])
wiresheet.toggle_link(wire_tag)
return
wiresheet.deselect_link()
if event.state & CTRL_MASK and group_tag and not wire_tag:
# Picking boxes out one by one, so nothing is picked up to be moved.
_toggle_canvas_box(group_tag)
return
select_canvas_boxes([])
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
select_canvas_boxes([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
# Bring the clicked box clear of any neighbour it happens to sit under.
canvas.tag_raise(group_tag)
wiresheet.redraw_links()
def set_link_mark(comp):
"""Marks a component as one end of a link still to be made."""
if comp is None:
return
app_state.link_mark_element = comp
app_state.set_status(
f"Marked '{comp.get('name', '?')}' — right-click the other component to link it."
)
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, source_slot=None, target_slot=None):
"""Opens a dialog to select source and target slots, validates types, and creates a link.
source_slot / target_slot pre-select a slot on that side, as when a link dragged out
of a slot is dropped on a component's title bar rather than on one of its slots.
"""
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()
for var, slot, info, is_target in ((src_var, source_slot, src_slot_info, False),
(tgt_var, target_slot, tgt_slot_info, True)):
if slot in info and _slot_linkable(info[slot][1], info[slot][0], info[slot][2],
is_target):
var.set(slot)
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 open_rename_dialog(comp):
"""Asks for a new name, checking Sedona's naming rules as it is typed."""
window = app_state.window
root = app_state.xml_root_element
if root is None or comp.tag != "comp":
return
parent = find_parent_element(root, comp)
old_name = comp.get("name", "")
dialog = tk.Toplevel(window)
dialog.title("Rename Component")
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)
tk.Label(main_frame, text=f"New name for '{old_name}' [id: {comp.get('id', '?')}]:",
anchor="w").grid(row=0, column=0, sticky="w")
name_var = tk.StringVar(value=old_name)
entry = ttk.Entry(main_frame, textvariable=name_var, width=24)
entry.grid(row=1, column=0, sticky="we", pady=(4, 0))
tk.Label(main_frame, text="1-7 characters: a letter, then letters, digits or '_'.",
anchor="w", fg="#666666").grid(row=2, column=0, sticky="w", pady=(4, 0))
error_var = tk.StringVar(value="")
tk.Label(main_frame, textvariable=error_var, anchor="w", fg="#b00020",
wraplength=320, justify="left").grid(row=3, column=0, sticky="w")
btn_frame = tk.Frame(main_frame)
btn_frame.grid(row=4, column=0, pady=(10, 0))
ok_button = ttk.Button(btn_frame, text="OK", width=10)
ok_button.pack(side=tk.LEFT, padx=5)
ttk.Button(btn_frame, text="Cancel", width=10,
command=dialog.destroy).pack(side=tk.LEFT, padx=5)
def current_error():
name = name_var.get()
if name == old_name:
return None
return sax_file.check_component_name(name, parent, comp)
def on_change(*_):
err = current_error()
error_var.set(err or "")
ok_button.state(["disabled"] if err else ["!disabled"])
def on_ok(*_):
if current_error() is not None:
return
new_name = name_var.get()
dialog.destroy()
# An open panel may show the old name, so it is refreshed along with the sheet.
if new_name != old_name and sax_file.rename_component(comp, new_name):
refresh_all_views()
ok_button.configure(command=on_ok)
name_var.trace_add("write", on_change)
entry.bind("<Return>", on_ok)
dialog.bind("<Escape>", lambda _: dialog.destroy())
entry.focus_set()
entry.select_range(0, tk.END)
def _child_display_names(parent, children):
"""Names for a reorder list. On a device a child not yet read is a "..." row, so
its name is read from the device rather than shown as dots."""
names = [child.get("name", "?") for child in children]
if not (app_state.live_mode and "..." in names):
return names
ds = app_state.device_session
try:
ds.load_children(int(parent.get("id")))
except Exception:
return names
for i, child in enumerate(children):
if names[i] == "...":
cached = ds.sox_client.cache_get(int(child.get("id", -1)))
if cached is not None:
names[i] = cached.name
return names
def open_reorder_dialog(parent):
"""Lists a component's children to move up and down; OK applies the new order."""
window = app_state.window
original = [child for child in parent if child.tag == "comp"]
if len(original) < 2:
return
names = dict(zip(map(id, original), _child_display_names(parent, original)))
order = list(original)
dialog = tk.Toplevel(window)
dialog.title(f"Reorder {parent.get('name', 'app')}")
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)
main_frame.columnconfigure(0, weight=1)
main_frame.rowconfigure(0, weight=1)
list_frame = tk.Frame(main_frame)
list_frame.grid(row=0, column=0, sticky="nsew")
listbox = tk.Listbox(list_frame, width=28, height=min(max(len(order), 10), 20),
exportselection=False, activestyle="none")
scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=listbox.yview)
listbox.configure(yscrollcommand=scrollbar.set)
listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
side = tk.Frame(main_frame, padx=10)
side.grid(row=0, column=1, sticky="n")
up_button = ttk.Button(side, text="Move Up", width=14)
up_button.pack(fill=tk.X)
down_button = ttk.Button(side, text="Move Down", width=14)
down_button.pack(fill=tk.X, pady=(6, 0))
reset_button = ttk.Button(side, text="Reset", width=14)
reset_button.pack(fill=tk.X, pady=(40, 0))
btn_frame = tk.Frame(main_frame)
btn_frame.grid(row=1, column=0, columnspan=2, pady=(10, 0))
ok_button = ttk.Button(btn_frame, text="OK", width=10)
ok_button.pack(side=tk.LEFT, padx=5)
ttk.Button(btn_frame, text="Cancel", width=10,
command=dialog.destroy).pack(side=tk.LEFT, padx=5)
def selected_index():
sel = listbox.curselection()
return sel[0] if sel else None
def update_buttons(*_):
index = selected_index()
up_button.state(["!disabled"] if index not in (None, 0) else ["disabled"])
down_button.state(["!disabled"] if index not in (None, len(order) - 1)
else ["disabled"])
reset_button.state(["!disabled"] if order != original else ["disabled"])
def fill(select=None):
listbox.delete(0, tk.END)
for child in order:
listbox.insert(tk.END, names[id(child)])
if select is not None:
listbox.selection_set(select)
listbox.activate(select)
listbox.see(select)
update_buttons()
def move(step):
index = selected_index()
if index is None or not 0 <= index + step < len(order):
return
order[index], order[index + step] = order[index + step], order[index]
fill(index + step)
def reset():
# Back to the order the dialog opened with, keeping the same row selected.
index = selected_index()
picked = order[index] if index is not None else None
order[:] = original
fill(order.index(picked) if picked is not None else None)
def on_ok(*_):
dialog.destroy()
if sax_file.reorder_children(parent, order):
refresh_all_views()
up_button.configure(command=lambda: move(-1))
down_button.configure(command=lambda: move(1))
reset_button.configure(command=reset)
ok_button.configure(command=on_ok)
listbox.bind("<<ListboxSelect>>", update_buttons)
dialog.bind("<Alt-Up>", lambda _: move(-1))
dialog.bind("<Alt-Down>", lambda _: move(1))
dialog.bind("<Return>", on_ok)
dialog.bind("<Escape>", lambda _: dialog.destroy())
fill(0)
listbox.focus_set()
def _has_reorderable_children(element):
return sum(1 for child in element if child.tag == "comp") >= 2
def fill_component_popup(menu, comp, selection=None):
"""Fills a popup with the actions that apply to one component.
Shared by the canvas and the navigation tree, so a component offers the same
actions wherever it was right-clicked. The entries close over the component rather
than reading a selection back, which is what lets the two popups act on the thing
that was actually clicked. `selection` is everything selected along with it, which
Duplicate copies as a group; the other entries act on `comp` alone.
"""
selection = [e for e in (selection or [comp]) if e.tag == "comp"] or [comp]
menu.delete(0, tk.END)
menu.add_command(
label="Properties",
command=lambda: show_properties(current_element(comp)),
)
menu.add_command(
label="Link View",
command=lambda: show_link_view(current_element(comp)),
)
if ox_page_view.is_ox_page(comp):
menu.add_command(
label="Page Editor",
command=lambda: show_ox_page_view(current_element(comp)),
)
if user_service_view.is_user_service(comp):
menu.add_command(
label="User Manager",
command=lambda: show_user_service(current_element(comp)),
)
menu.add_separator()
if comp.tag == "comp":
# The app itself is named by its appName property, not by a component name.
menu.add_command(
label="Rename...",
command=lambda: open_rename_dialog(current_element(comp)),
)
if comp.tag == "comp":
menu.add_command(
label=("Duplicate" if len(selection) == 1
else f"Duplicate {len(selection)} Components"),
command=lambda: duplicate_components(selection),
)
menu.add_command(
label="Delete Component",
command=lambda: delete_component(current_element(comp)),
)
menu.add_separator()
menu.add_command(label="Link Mark", command=lambda: set_link_mark(current_element(comp)))
marked = app_state.link_mark_element
if marked is not None:
mark_name = marked.get("name", "?")
menu.add_command(
label=f'Link From "{mark_name}"',
command=lambda: open_link_dialog(current_element(marked), current_element(comp)),
)
menu.add_command(
label=f'Link To "{mark_name}"',
command=lambda: open_link_dialog(current_element(comp), current_element(marked)),
)
def duplicate_components(elements):
"""Duplicates components, and leaves the copies selected if they are on the sheet."""
copies = sax_file.duplicate_components([current_element(e) for e in elements])
select_canvas_boxes([app_state.canvas_tag_by_element.get(c) for c in copies])
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:
# Right-clicking one of several selected links acts on all of them; any
# other link becomes the selection on its own.
if wire_tag not in app_state.selected_link_tags:
select_canvas_boxes([])
wiresheet.select_link(wire_tag)
_rebuild_link_context_menu()
link_context_menu.post(event.x_root, event.y_root)
return
if group_tag:
wiresheet.deselect_link()
# Right-clicking one of several selected boxes acts on all of them; any
# other box becomes the selection on its own, as a left click would.
if group_tag not in app_state.selected_canvas_tags:
select_canvas_boxes([group_tag])
comp_element = app_state.canvas_comp_map.get(group_tag)
if comp_element is not None:
selection = [app_state.canvas_comp_map[t]
for t in app_state.selected_canvas_tags
if t in app_state.canvas_comp_map]
fill_component_popup(canvas_context_menu, comp_element, selection)
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.redraw_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.
# Canvas coordinates carry the sheet's zoom, so the step and the margin do too.
step = app_state.meta_scale * app_state.zoom
margin = wiresheet.layout_margin(app_state.meta_scale) * app_state.zoom
# 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.redraw_links()
def on_canvas_release(event):
if not app_state.drag_data["tag"]:
return
canvas = app_state.canvas
step = app_state.meta_scale * app_state.zoom
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(app_state.meta_scale) * app_state.zoom
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)
if app_state.live_mode:
new_meta = comp_element.find("prop[@name='meta']")
if new_meta is None or not live_hooks.live_set_prop_value(
comp_element, "meta", new_meta.get("val", "0")):
# Back where the device still has it, so the sheet tells the truth.
if meta_str is None:
remove_prop(comp_element, "meta")
else:
set_prop_value(comp_element, "meta", meta_str)
app_state.drag_data["tag"] = None
wiresheet.render_current_sheet()
live_hooks.warn_failure(
"Move", f"{comp_element.get('name', 'The component')} was not "
"moved on the device")
return
command_class = (undo.LiveMetaMoveCommand if app_state.live_mode
else undo.MetaMoveCommand)
undo.push(command_class(
[(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.redraw_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="+")
def _on_tree_open(event):
tree.after_idle(fit_tree_column, tree)
if _restoring_selection:
return
if app_state.live_mode:
opened = tree.focus()
if opened:
elem = app_state.tree_element_map.get(opened)
if elem is not None:
cid = elem.get("id")
if cid is not None:
_live_tree_expand(int(cid), opened)
tree.bind("<<TreeviewOpen>>", _on_tree_open, 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)
_sync_pane_toggle_button()
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"
def _sync_pane_toggle_button():
"""Update the toggle button icon to reflect the pane state."""
if btn_toggle_pane is not None and show_left_column_var is not None:
btn_toggle_pane.configure(text="◧" if show_left_column_var.get() else "▯")
# --- Sheet view history ---
def _same_element(a, b):
"""Whether two history elements name the same component.
In a file that is identity. On a device every sheet opened rebuilds the DOM from the
device, handing out fresh elements for the same components, so there it is the id.
"""
if a is b:
return True
if a is None or b is None or not app_state.live_mode:
return False
a_id = a.get("id")
return a_id is not None and a_id == b.get("id")
def _current_element(element):
"""The element in the DOM now that names the same component as this one.
Only a device swaps elements out from under the history; in a file, or when nothing
by that id is left, the element comes back as it was.
"""
if element is None or not app_state.live_mode:
return element
root = app_state.xml_root_element
if root is None:
return element
for candidate in root.iter():
if _same_element(candidate, element):
return candidate
return element
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 _same_element(mapped, element):
return item_id
return None
def _entry_is_reachable(entry):
"""Whether a history entry still names something that could be shown.
A sheet entry needs a tree node to select, which is what paints it. A panel entry
needs only its component, since a panel takes the whole pane and is built straight
from the document.
"""
kind, element = entry
if kind == VIEW_SHEET:
return _tree_item_for_element(element) is not None
return _component_in_document(element)
def _prune_view_history():
"""Drops entries that can no longer be shown, keeping the position on screen."""
global history_index
kept = []
new_index = -1
for position, entry in enumerate(view_history):
if not _entry_is_reachable(entry):
continue
# Dropping an entry can leave the two either side of it naming the same view,
# and a Back that visibly does nothing is worse than a shorter trail. The
# position still lands on whichever of the two it was on, since they are the
# same view either way.
if kept and kept[-1][0] == entry[0] and _same_element(kept[-1][1], entry[1]):
if position <= history_index:
new_index = len(kept) - 1
continue
kept.append(entry)
if position <= history_index:
new_index = len(kept) - 1
view_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(view_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_view_history():
"""Starts the history over, for a newly opened file."""
global history_index
view_history.clear()
history_index = -1
update_history_controls()
def record_visit(kind, element):
"""Adds a freshly opened view to the history, dropping anything ahead of it.
Every way of filling the editor pane comes through here: the tree selection handler
for a sheet, and the two panels as they are put up. Back and Forward suppress it,
because walking the history must not rewrite the trail it is following.
"""
global history_index, _history_file_path
# On a device, undo only reaches back as far as the view that is open. Opening a
# sheet reads the DOM afresh from the device, so steps taken before it name elements
# that are no longer there; Back and Forward are view changes too.
if app_state.live_mode:
undo.reset()
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
view_history.clear()
history_index = -1
# Opening what is already on screen is not a move. Compared by identity in a file,
# where two sheets are the same sheet only when they are the same element, and by id
# on a device, where each sheet opened hands out fresh elements.
if 0 <= history_index < len(view_history):
current_kind, current_element = view_history[history_index]
if current_kind == kind and _same_element(current_element, element):
return
del view_history[history_index + 1:]
view_history.append((kind, element))
if len(view_history) > HISTORY_LIMIT:
del view_history[0]
history_index = len(view_history) - 1
update_history_controls()
def _show_history_entry(position):
"""Puts back what the pane held at this position, without recording the move.
The pane is free by the time this runs: both ways in ask about unsaved edits and
take whatever panel is up back down first.
"""
global history_index, _suppress_history
kind, element = view_history[position]
if not _entry_is_reachable((kind, element)):
return
# On a device the entry may hold an element from an earlier rebuild; a panel built on
# that would show the values of that moment, not the device's.
element = _current_element(element)
view_history[position] = (kind, element)
_suppress_history = True
try:
if kind == VIEW_SHEET:
_show_sheet_entry(element)
elif kind == VIEW_PROPERTIES:
show_properties(element)
elif kind == VIEW_PAGE:
show_ox_page_view(element)
elif kind == VIEW_USERS:
show_user_service(element)
else:
show_link_view(element)
finally:
_suppress_history = False
history_index = position
update_history_controls()
def _show_sheet_entry(element):
"""Selects the tree node whose children are this sheet, which is what paints it."""
tree = app_state.tree
item_id = _tree_item_for_element(element)
was_selected = tree.selection() == (item_id,)
tree.selection_set(item_id)
tree.focus(item_id)
tree.see(item_id)
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. Leaving a
# panel lands here every time: closing it puts the tree's own sheet back on
# screen without the selection ever having moved.
app_state.current_sheet_parent = element
wiresheet.render_current_sheet()
def go_back(event=None):
"""View menu and toolbar: step to whatever the editor pane held before this."""
if not close_editor_panel():
return "break"
_prune_view_history()
if history_index > 0:
_show_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 view."""
if not close_editor_panel():
return "break"
_prune_view_history()
if 0 <= history_index < len(view_history) - 1:
_show_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
# A different file has no such component, and a rebuild after a delete may have taken
# it. Neither is a moment to ask about edits that now have nowhere to go.
panel = properties_panel or link_panel or ox_page_panel or user_service_panel
if panel is not None and not _component_in_document(panel.comp):
close_editor_panel(force=True)
# The root row is new after every rebuild, so its colour has to be reapplied.
refresh_dirty_display()
fit_tree_column(app_state.tree)
refresh_palette_kits()
if app_state.current_file_path != _history_file_path:
_history_file_path = app_state.current_file_path
reset_view_history()
if _schema_has_incomplete_checksums():
show_kits()
else:
_prune_view_history()
update_history_controls()
def _schema_has_incomplete_checksums():
"""Whether any declared kit is missing a checksum."""
if not app_state.active_schema_kits:
return False
return any(cs is None for cs in app_state.active_schema_kits.values())
# --- 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("470x330")
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, 10))
lbl_trace = ttk.Label(frame_padded, text="Trace DASP to terminal?", anchor="w")
lbl_trace.grid(row=5, column=0, sticky="w", pady=(0, 15), padx=(0, 10))
var_trace_dasp = tk.BooleanVar(value=app_state.TRACE_DASP)
chk_trace_dasp = ttk.Checkbutton(
frame_padded,
text="Yes",
variable=var_trace_dasp
)
chk_trace_dasp.grid(row=5, column=1, sticky="w", pady=(0, 15))
lbl_lazy = ttk.Label(frame_padded, text="Lazy loading on connect?", anchor="w")
lbl_lazy.grid(row=6, column=0, sticky="w", pady=(0, 15), padx=(0, 10))
# Off: the whole app is read from app.sab when connecting. Takes effect on the next.
var_lazy_loading = tk.BooleanVar(value=app_state.LAZY_LOADING)
chk_lazy_loading = ttk.Checkbutton(
frame_padded,
text="Yes",
variable=var_lazy_loading
)
chk_lazy_loading.grid(row=6, 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.TRACE_DASP = var_trace_dasp.get()
app_state.LAZY_LOADING = var_lazy_loading.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=7, 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)
# ── Live device connection ──────────────────────────────────────
_cov_poll_id = None
def do_device_connect():
"""Opens the connect dialog and initiates a device connection."""
if app_state.current_file_path and not app_state.live_mode:
if app_state.is_dirty:
answer = messagebox.askyesnocancel(
"Unsaved Changes",
"The current file has unsaved changes.\n\n"
"Save before connecting to a device?",
)
if answer is None:
return
if answer:
sax_file.execute_direct_overwrite_save()
sax_file.reset_to_startup_state()
sax_file.refresh_treeview_from_dom(None)
wiresheet.render_components([])
app_state.window.title(app_state.base_window_title())
connect_dialog.ConnectDialog(app_state.window, _connect_to_device,
on_before_connect=_drop_device_before_connect)
def do_device_discover():
"""Opens the discover dialog to scan for devices on the network."""
if app_state.current_file_path and not app_state.live_mode:
if app_state.is_dirty:
answer = messagebox.askyesnocancel(
"Unsaved Changes",
"The current file has unsaved changes.\n\n"
"Save before connecting to a device?",
)
if answer is None:
return
if answer:
sax_file.execute_direct_overwrite_save()
sax_file.reset_to_startup_state()
sax_file.refresh_treeview_from_dom(None)
wiresheet.render_components([])
app_state.window.title(app_state.base_window_title())
connect_dialog.DiscoverDialog(app_state.window, _connect_to_device,
on_before_connect=_drop_device_before_connect)
def _drop_device_before_connect():
"""One device at a time: the one already connected goes before the next is tried.
Called when Connect is pressed, not when the dialog opens, so backing out of the
dialog leaves the current connection alone.
"""
if app_state.device_session is not None:
do_device_disconnect()
def _connect_to_device(host, port, user, password):
"""Called from the connect dialog's background thread."""
ds = device_session.DeviceSession()
root_comp = ds.connect(host, port, user, password)
app_state.device_session = ds
app_state.device_host = host
app_state.device_port = port
app_state.live_mode = True
app_state.connection_status = "connected"
app_state.window.after(0, lambda: _on_connected(ds, root_comp))
return root_comp
def _on_connected(ds, root_comp):
"""Main-thread callback after successful connect."""
app_state.window.title(app_state.base_window_title_live())
menu_device.entryconfig("Disconnect", state="normal")
menu_device.entryconfig("Download to SAX...", state="normal")
menu_app_info.entryconfig("Get with Files...", state="normal")
menu_app_info.entryconfig("Put with Files...", state="normal")
app_state.set_status(f"Connected to {app_state.device_host}:{app_state.device_port}"
f" ({ds.seed_info})")
sax_root = ds.build_dom_tree(root_comp)
app_state.xml_root_element = sax_root
app_state.current_file_path = f"device://{app_state.device_host}"
_load_live_schema(ds)
sax_file.refresh_treeview_from_dom(sax_root)
undo.reset()
app_state.mark_clean()
_start_cov_polling()
def _load_live_schema(ds):
"""Loads schema kits from the device's schema parts into the editor registry."""
app_state.active_schema_kits.clear()
app_state.final_components_registry.clear()
if not (ds.sox_client and ds.sox_client.schema_parts):
return
for part in ds.sox_client.schema_parts:
app_state.active_schema_kits[part.name] = f"{part.checksum & 0xFFFFFFFF:08x}"
# Collect manifests we have, skip missing kits (device may use kits
# not installed on this computer — load what we can)
manifest_paths = []
missing = []
for name, checksum in app_state.active_schema_kits.items():
path = schema.find_manifest_path(name, checksum)
if path:
manifest_paths.append(path)
else:
missing.append(name)
if manifest_paths:
schema.compile_schema_registry(manifest_paths)
if missing:
app_state.set_status(
f"Missing kit manifests: {', '.join(missing)}", warn=True)
def do_device_disconnect(device_down=False):
"""Disconnects from the live device. device_down: it was just told to restart (or
stop), so nothing is asked of it on the way out -- it would not answer."""
global _cov_poll_id
if _cov_poll_id is not None:
app_state.window.after_cancel(_cov_poll_id)
_cov_poll_id = None
ds = app_state.device_session
if ds is not None:
ds.disconnect(unsubscribe=not device_down)
app_state.device_session = None
app_state.live_mode = False
app_state.connection_status = "offline"
app_state.device_host = ""
app_state.device_port = 1876
app_state.window.title(app_state.base_window_title())
menu_device.entryconfig("Connect...", state="normal")
menu_device.entryconfig("Discover...", state="normal")
menu_device.entryconfig("Disconnect", state="disabled")
menu_device.entryconfig("Download to SAX...", state="disabled")
menu_app_info.entryconfig("Get with Files...", state="disabled")
menu_app_info.entryconfig("Put with Files...", state="disabled")
app_state.set_status("Disconnected")
sax_file.reset_to_startup_state()
sax_file.refresh_treeview_from_dom(None)
wiresheet.render_components([])
def quit_app():
"""File > Exit and the window's close box: let go of the device before going.
Leaving without a proper close keeps the device's Sox session and subscriptions
alive until it times them out, so the next connect may be turned away.
"""
global _cov_poll_id
if _cov_poll_id is not None:
app_state.window.after_cancel(_cov_poll_id)
_cov_poll_id = None
ds = app_state.device_session
app_state.device_session = None
app_state.live_mode = False
if ds is not None:
try:
ds.disconnect()
except Exception:
pass
app_state.window.destroy()
def _start_cov_polling():
"""Starts the 100ms COV event polling loop on the main thread."""
global _cov_poll_id
def poll():
global _cov_poll_id
ds = app_state.device_session
if ds is None or not ds.is_connected:
_cov_poll_id = None
if app_state.live_mode:
do_device_disconnect()
app_state.set_status("Connection lost", warn=True)
return
events = ds.poll_events()
if events and app_state.xml_root_element is not None:
needs_repaint = False
for comp_id, what_char in events:
elem = ds.update_element_from_cache(comp_id, app_state.xml_root_element)
if elem is not None:
needs_repaint = True
if needs_repaint:
wiresheet.render_current_sheet()
if properties_panel is not None:
try:
properties_panel.draw()
except Exception:
pass
_cov_poll_id = app_state.window.after(100, poll)
_cov_poll_id = app_state.window.after(100, poll)
def _live_tree_expand(comp_id, tree_item_id, select=True, on_loaded=None):
"""Called when user expands a tree node in live mode to lazy-load children.
select=False loads without moving the selection or the open sheet, as a palette drag
hovering over a branch does. on_loaded gets the rebuilt row of the expanded node.
"""
ds = app_state.device_session
if ds is None or not ds.is_connected:
return
# Every child already in the tree (app.sab gave the whole app, or it was opened
# before): nothing to load, and rebuilding the DOM would only cost the undo steps.
elem = app_state.tree_element_map.get(tree_item_id)
if elem is not None and all(c.get("name") != "..." for c in elem.findall("comp")):
if on_loaded is not None:
on_loaded(tree_item_id)
return
app_state.set_status("Loading children...")
def _bg():
try:
children = ds.load_children(comp_id)
if not children:
app_state.window.after(0, lambda: app_state.set_status("No children"))
return
sax_root = ds.build_dom_tree(ds.sox_client.cache_get(0))
ds.build_links_elements(sax_root)
def _ui():
global _restoring_selection
app_state.xml_root_element = sax_root
# A fresh DOM: undo steps name elements of the one it replaces.
undo.reset()
sax_file.refresh_treeview_from_dom(sax_root)
# Re-open and re-select the node without triggering a wiresheet
# re-render — the sheet is already showing the right content and
# a redundant close_wiresheet + open_wiresheet cycle can lose links.
if not select:
_rebind_open_sheet()
for iid, elem in app_state.tree_element_map.items():
if elem.get("id") == str(comp_id):
tree.item(iid, open=True)
if select:
app_state.current_sheet_parent = elem
_restoring_selection = True
tree.selection_set(iid)
tree.see(iid)
tree.after_idle(_clear_restoring_flag)
tree.after_idle(fit_tree_column, tree)
if on_loaded is not None:
on_loaded(iid)
break
app_state.set_status(f"Loaded {len(children)} children")
app_state.window.after(0, _ui)
except Exception as exc:
app_state.window.after(
0, lambda: app_state.set_status(f"Load error: {exc}", warn=True)
)
threading.Thread(target=_bg, daemon=True).start()
def _rebind_open_sheet():
"""Points the open sheet at its element in a freshly built DOM and repaints it.
A lazy load replaces the whole DOM. The sheet on screen still holds elements of the
old one, and edits made through them would land in a document nobody saves.
"""
old_parent = app_state.current_sheet_parent
if old_parent is None:
wiresheet.render_current_sheet()
return
old_id = old_parent.get("id")
app_element = app_state.xml_root_element.find("app")
app_state.current_sheet_parent = next(
(elem for elem in app_state.xml_root_element.iter()
if elem.tag in ("app", "comp") and old_id is not None and elem.get("id") == old_id),
app_element)
wiresheet.render_current_sheet()
# --- UI Setup ---
window = tk.Tk()
app_state.window = window
window.title(app_state.base_window_title())
window.geometry(f"{DEFAULT_WINDOW_WIDTH}x{DEFAULT_WINDOW_HEIGHT}")
# 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)
def _rebuild_link_context_menu():
"""Rebuilds the link popup, naming the sheet the far end of the link is on.
A link with both ends on the open sheet is already fully in view, so the jump is
left off rather than greyed out.
"""
link_context_menu.delete(0, tk.END)
count = len(app_state.selected_link_tags)
if count > 1:
# Several links have no single far end, so there is nowhere to jump to.
link_context_menu.add_command(label=f"Delete {count} Links",
command=delete_selected_link)
return
link_context_menu.add_command(label="Delete Link", command=delete_selected_link)
link_elem = app_state.canvas_link_map.get(app_state.selected_link_tag)
destination = link_target_sheet(link_elem) if link_elem is not None else None
if destination is None:
return
sheet_name = link_view.sheet_display_name(destination[0])
link_context_menu.add_separator()
link_context_menu.add_command(
label=f'Go to Link Wire Sheet "{sheet_name}"', command=go_to_link_sheet
)
tree_comp_context_menu = tk.Menu(window, tearoff=0)
tree_root_context_menu = tk.Menu(window, tearoff=0)
# --- 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),
("b", toggle_left_column),
# Zoom, in every spelling the key can arrive as: with and without Shift, and on the
# numeric keypad.
("plus", do_zoom_in),
("equal", do_zoom_in),
("KP_Add", do_zoom_in),
("minus", do_zoom_out),
("underscore", do_zoom_out),
("KP_Subtract", do_zoom_out),
("0", do_zoom_reset),
("KP_0", do_zoom_reset),
("9", do_zoom_fit),
("KP_9", do_zoom_fit),
("p", wiresheet.print_sheet),
]
for key, handler in SHORTCUTS:
window.bind_all(f"<Command-{key}>", handler)
window.bind_all(f"<Control-{key}>", handler)
# 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_device = tk.Menu(menu_bar, tearoff=0)
menu_device.add_command(label="Connect...", command=do_device_connect)
menu_device.add_command(label="Discover...", command=do_device_discover)
menu_device.add_command(label="Disconnect", command=do_device_disconnect, state="disabled")
menu_device.add_separator()
menu_device.add_command(label="Download to SAX...", command=do_download_to_sax, state="disabled")
menu_bar.add_cascade(label="Device", menu=menu_device)
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="Print...", command=wiresheet.print_sheet, accelerator=f"{accel_mod}+P")
menu_file.add_separator()
menu_file.add_command(label="Exit", command=quit_app)
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_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="Zoom In", command=do_zoom_in, accelerator=f"{accel_mod}++")
menu_view.add_command(label="Zoom Out", command=do_zoom_out, accelerator=f"{accel_mod}+-")
menu_view.add_command(label="Zoom 100%", command=do_zoom_reset, accelerator=f"{accel_mod}+0")
menu_view.add_command(label="Zoom to Fit", command=do_zoom_fit, accelerator=f"{accel_mod}+9")
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=show_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_app_info.add_separator()
menu_app_info.add_command(label="Get with Files...", command=do_get_with_file, state="disabled")
menu_app_info.add_command(label="Put with Files...", command=do_put_with_files, state="disabled")
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))
# U+25E7: a square with left half filled — suggests a visible side panel.
btn_toggle_pane = ttk.Button(toolbar, text="◧", width=3, command=toggle_left_column)
btn_toggle_pane.pack(side=tk.LEFT)
_ToolTip(btn_toggle_pane, "Tree / Palette (Ctrl+B)")
ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=8, pady=2)
btn_back = ttk.Button(toolbar, text="◀", width=3, command=go_back, state="disabled")
btn_back.pack(side=tk.LEFT)
_ToolTip(btn_back, "Back (Alt+Left)")
btn_forward = ttk.Button(toolbar, text="▶", width=3, command=go_forward, state="disabled")
btn_forward.pack(side=tk.LEFT, padx=(3, 0))
_ToolTip(btn_forward, "Forward (Alt+Right)")
ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=8, pady=2)
_btn_zoom_out = ttk.Button(toolbar, text="−", width=3, command=do_zoom_out)
_btn_zoom_out.pack(side=tk.LEFT)
_ToolTip(_btn_zoom_out, "Zoom Out (Ctrl+−)")
zoom_label = ttk.Label(toolbar, text="100%", width=6, anchor="center")
zoom_label.pack(side=tk.LEFT, padx=1)
_btn_zoom_in = ttk.Button(toolbar, text="+", width=3, command=do_zoom_in)
_btn_zoom_in.pack(side=tk.LEFT)
_ToolTip(_btn_zoom_in, "Zoom In (Ctrl++)")
_btn_zoom_fit = ttk.Button(toolbar, text="⛶", width=3, command=do_zoom_fit)
_btn_zoom_fit.pack(side=tk.LEFT, padx=(3, 0))
_ToolTip(_btn_zoom_fit, "Zoom to Fit (Ctrl+9)")
_btn_tidy = ttk.Button(toolbar, text="⊞", width=3, command=do_tidy_layout)
_btn_tidy.pack(side=tk.LEFT, padx=(3, 0))
_ToolTip(_btn_tidy, "Tidy Layout (Ctrl+W)")
# U+21B6: anticlockwise arrow, the usual undo glyph.
_btn_undo = ttk.Button(toolbar, text="↶", width=3, command=do_undo)
_btn_undo.pack(side=tk.LEFT, padx=(3, 0))
_ToolTip(_btn_undo, f"Undo ({accel_mod}+Z)")
ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=8, pady=2)
_btn_print = ttk.Button(toolbar, text="🖨", width=3, command=wiresheet.print_sheet)
_btn_print.pack(side=tk.LEFT)
_ToolTip(_btn_print, "Print Wiresheet (Ctrl+P)")
# Filled with the Page Editor's own tools while it is open, and empty (unpacked) otherwise.
page_editor_tools = ttk.Frame(toolbar)
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)
_nav_style = ttk.Style()
_nav_style.map(
app_state.NAV_TREE_STYLE,
# No ("selected", ...) entry: without one the row keeps whatever foreground its tag
# gives it, which is the whole point.
foreground=[("disabled", "SystemGrayText")],
background=[("disabled", "SystemButtonFace"),
("selected", app_state.NAV_SELECTION_BG)],
)
tree = ttk.Treeview(
frame_tree,
show="tree",
selectmode="extended",
height=TREE_REQUESTED_ROWS,
style=app_state.NAV_TREE_STYLE,
)
# Configured once, for the life of the tree. Only the row's tags change after this.
tree.tag_configure(app_state.DIRTY_NAME_TAG, foreground=app_state.DIRTY_NAME_COLOUR)
tree.tag_configure(app_state.TREE_DROP_TAG, background=app_state.NAV_SELECTION_BG)
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)
tree.bind("<Double-1>", on_tree_double_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]
if event is not None and kit_name != app_state.LAST_PALETTE_KIT:
app_state.save_last_palette_kit(kit_name)
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, showing only kits declared in the app's schema."""
global palette_kit_names
palette.invalidate()
if _schema_has_incomplete_checksums():
palette_kit_names = []
else:
# A kit with nothing placeable (all abstract or @palette=false) is left out.
palette_kit_names = [
kit_name for kit_name in sorted(app_state.active_schema_kits.keys())
if palette.list_component_types(kit_name)
]
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:
if app_state.xml_root_element is None:
palette_status.config(text="")
elif not app_state.SEDONA_HOME:
palette_status.config(text="Set Sedona home in Preferences")
else:
palette_status.config(text="No kits found — check Sedona home")
palette_kit_var.set("")
return
# Reopen the kit chosen last so the palette is never left empty; a file without that
# kit opens on its first kit instead, without forgetting the remembered one.
if app_state.LAST_PALETTE_KIT in palette_kit_names:
palette_kit_combo.current(palette_kit_names.index(app_state.LAST_PALETTE_KIT))
else:
palette_kit_combo.current(0)
populate_palette_types()
# --- 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
# and the navigation tree. A drop on a tree row puts the component inside that row's
# component, which need not be the sheet that is open. Resting on a collapsed branch
# opens it (loading it from the device when live), so a drop can reach any depth.
DRAG_THRESHOLD = 6
HOVER_EXPAND_MS = 700
palette_drag = {"kit": None, "type": None, "origin": None, "active": False, "ghost": None,
"tree_row": None, "expand_job": None}
def _destroy_drag_ghost():
if palette_drag["ghost"] is not None:
palette_drag["ghost"].destroy()
palette_drag["ghost"] = None
def _mark_tree_drop_row(row):
"""Highlights the tree row a drop would land in; None clears the highlight.
Done with a tag rather than the selection, since selecting a row opens its sheet.
"""
previous = palette_drag["tree_row"]
if previous == row:
return
if previous is not None and tree.exists(previous):
tags = [t for t in tree.item(previous, "tags") if t != app_state.TREE_DROP_TAG]
tree.item(previous, tags=tags)
if row is not None:
tree.item(row, tags=list(tree.item(row, "tags")) + [app_state.TREE_DROP_TAG])
palette_drag["tree_row"] = row
_cancel_hover_expand()
if row is not None and tree.get_children(row) and not tree.item(row, "open"):
palette_drag["expand_job"] = window.after(HOVER_EXPAND_MS, _hover_expand, row)
def _cancel_hover_expand():
if palette_drag["expand_job"] is not None:
window.after_cancel(palette_drag["expand_job"])
palette_drag["expand_job"] = None
def _hover_expand(row):
"""Opens the branch a palette drag has rested on, loading it first on a device."""
palette_drag["expand_job"] = None
if not palette_drag["active"] or palette_drag["tree_row"] != row or not tree.exists(row):
return
tree.item(row, open=True) # in code, so no <<TreeviewOpen>>; loaded here instead
tree.after_idle(fit_tree_column, tree)
elem = app_state.tree_element_map.get(row)
comp_id = elem.get("id") if elem is not None else None
if app_state.live_mode and comp_id is not None:
# The load rebuilds the tree, so the highlight goes back on the new row for it.
def relight(new_row):
if palette_drag["active"] and palette_drag["tree_row"] == row:
_mark_tree_drop_row(new_row)
_live_tree_expand(int(comp_id), row, select=False, on_loaded=relight)
def _reset_palette_drag():
_destroy_drag_ghost()
_mark_tree_drop_row(None)
_cancel_hover_expand()
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}")
_mark_tree_drop_row(_tree_row_under_pointer(event.x_root, event.y_root))
def _pointer_over_widget(widget, x_root, y_root):
left = widget.winfo_rootx()
top = widget.winfo_rooty()
return (left <= x_root < left + widget.winfo_width()
and top <= y_root < top + widget.winfo_height())
def _pointer_over_canvas(x_root, y_root):
return _pointer_over_widget(canvas, x_root, y_root)
def _tree_row_under_pointer(x_root, y_root):
"""The navigation tree row under the pointer, or None off the tree or between rows."""
if app_state.xml_root_element is None or not _pointer_over_widget(tree, x_root, y_root):
return None
row = tree.identify_row(y_root - tree.winfo_rooty())
if not row:
return None
elem = app_state.tree_element_map.get(row)
if elem is not None and elem.get("type") == "":
return None # a "..." stand-in for a child not loaded from the device
return row
def _drop_on_tree_row(kit_name, type_name, row):
"""Adds kit::type inside the component a tree row stands for."""
parent = app_state.tree_element_map.get(row)
if parent is None and tree.parent(row) == "":
parent = app_state.xml_root_element.find("app") # the root row is the app
if parent is None:
return
# Opened first, so the rebuild after the add keeps it open and the new row shows.
tree.item(row, open=True)
grid_x, grid_y = sax_file.grid_position_below_children(parent)
sax_file.add_component(kit_name, type_name, grid_x, grid_y, parent=parent)
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
tree_row = _tree_row_under_pointer(event.x_root, event.y_root)
if tree_row is not None:
_drop_on_tree_row(kit_name, type_name, tree_row)
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 * app_state.zoom
min_step = wiresheet.layout_margin(app_state.meta_scale) // app_state.meta_scale
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("<Configure>", lambda e: wiresheet.draw_grid())
# Either button pressed on a slot row drags out a new link. Elsewhere the left button
# selects and moves boxes and a plain right click is the menu, as before.
link_drag.install(canvas, on_canvas_right_click,
on_canvas_click, on_canvas_drag, on_canvas_release,
link_dialog=open_link_dialog)
canvas.bind("<ButtonPress-2>", on_canvas_right_click)
canvas.bind("<Double-1>", on_canvas_double_click)
# Wheel zoom. Windows and macOS report a delta; X11 sends button 4 and 5 instead.
canvas.bind("<Control-MouseWheel>", on_canvas_wheel_zoom)
canvas.bind("<Control-Button-4>", do_zoom_in)
canvas.bind("<Control-Button-5>", do_zoom_out)
canvas.bind("<MouseWheel>", on_canvas_wheel_scroll)
canvas.bind("<Shift-MouseWheel>", on_canvas_wheel_scroll)
canvas.bind("<Button-4>", lambda e: canvas.yview_scroll(-1, "units"))
canvas.bind("<Button-5>", lambda e: canvas.yview_scroll(1, "units"))
canvas.bind("<Shift-Button-4>", lambda e: canvas.xview_scroll(-1, "units"))
canvas.bind("<Shift-Button-5>", lambda e: canvas.xview_scroll(1, "units"))
paned_window.add(frame_editor, weight=3)
app_state.dirty_display_callback = refresh_dirty_display
undo.set_refresh_callback(refresh_all_views)
sax_file.set_tree_filled_callback(on_tree_filled)
sax_file.set_disconnect_device_callback(do_device_disconnect)
window.protocol("WM_DELETE_WINDOW", quit_app)
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()