3808 lines
143 KiB
Python
3808 lines
143 KiB
Python
"""Sedona SAX Tree View Viewer & Program Editor — UI wiring, dialogs and event handlers."""
|
||
|
||
import os
|
||
import sys
|
||
import tkinter as tk
|
||
from tkinter import filedialog, font as tkfont, messagebox, ttk
|
||
|
||
import 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))
|
||
|
||
lbl_slot_numbers = ttk.Label(frame_padded, text="Slot numbers on wiresheet?", anchor="w")
|
||
lbl_slot_numbers.grid(row=7, column=0, sticky="w", pady=(0, 15), padx=(0, 10))
|
||
|
||
var_slot_numbers = tk.BooleanVar(value=app_state.SHOW_SLOT_NUMBERS)
|
||
chk_slot_numbers = ttk.Checkbutton(
|
||
frame_padded,
|
||
text="Yes",
|
||
variable=var_slot_numbers
|
||
)
|
||
chk_slot_numbers.grid(row=7, 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.SHOW_SLOT_NUMBERS = var_slot_numbers.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=8, 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()
|