Right-clicking a component now offers Link View alongside Properties, from the wiresheet and from the navigation tree alike. It takes over the editor pane the same way and answers one question -- what is wired to this component, and where does it go. The chosen component sits in the middle wearing the stroke a selected box wears on the sheet, with what links into it on the left and what it links out to on the right, so every arrow points the way the signal travels. Pale yellow ground and no grid: nothing here can be positioned, because the layout is derived from the links every time it is drawn and no part of it is stored in the file. Read-only throughout -- no drag, no add, no delete -- so a component's wiring can be read without the risk of rearranging it. Every line is grey, since this is not a wiresheet and a wire-blue line would invite the reader to treat it as one. A link on the same sheet is solid; one that crosses is dashed, keeps the grey knob that sheet paints on the slot, and has the far component drawn in with its sheet named beneath it. A reference the document cannot resolve gets a placeholder naming the path, because a link the view silently dropped would be the one thing a reader could not find out from here. Clicking a wire or a component follows the link: it opens the sheet of the end that is not the component the view is about, with the link selected in red. A link that stays on one sheet therefore goes back to that sheet where both ends are drawn, and one that crosses opens the other sheet -- the end the view could only show as a knob. Supporting changes: - draw_component_box is factored out of render_components, so the sheet and this view paint a component with the same code and a box cannot read differently between them. draw_knob takes its canvas for the same reason. - The editor pane's hand-over is now shared: _take_editor_pane, _give_editor_pane_back and close_editor_panel replace the Properties-only versions, so "you have not saved that" is still asked once, in one place, whichever panel has the pane. - Fixes the jump to a link's other sheet, which arrived with the link not selected. An after_idle callback is not late enough -- ttk dispatches its own selection event later than one of those, so the repaint wiped the selection. The link is left in _pending_link_selection for on_tree_select to honour once it has painted. - Each gap is sized to hold a lane per wire crossing it, and lanes are handed out nearest-target-first, so a fan of a dozen links nests instead of crowding a dozen verticals into a 30px band. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2136 lines
78 KiB
Python
2136 lines
78 KiB
Python
"""Sedona SAX Tree View Viewer & Program Editor — UI wiring, dialogs and event handlers."""
|
||
|
||
import os
|
||
import sys
|
||
import tkinter as tk
|
||
from tkinter import filedialog, font as tkfont, messagebox, ttk
|
||
|
||
import app_state
|
||
import link_view
|
||
import manage_kits
|
||
import palette
|
||
import properties
|
||
import sax_file
|
||
import schema
|
||
import undo
|
||
import wiresheet
|
||
from sax_dom import (find_component_by_path, find_parent_element, get_component_path,
|
||
read_meta_property, update_meta_property)
|
||
|
||
# Tree column measurements, in pixels. The padding covers the expander arrow and the
|
||
# cell's own inset, which sit ahead of the text ttk lets us measure.
|
||
TREE_INDENT = 20
|
||
TREE_TEXT_PADDING = 28
|
||
TREE_MIN_COLUMN_WIDTH = 40
|
||
|
||
# Rows each list asks for. This is the only real floor ttk offers on the left column's own
|
||
# split: a pane keeps its requested height, and a short window takes the shortfall from the
|
||
# panes in proportion to their weights. Left at the ten-row default the palette asked for
|
||
# so much that the tree, carrying the larger weight, gave up all of its height first.
|
||
TREE_REQUESTED_ROWS = 6
|
||
PALETTE_REQUESTED_ROWS = 5
|
||
|
||
# Below this the shortfall is deep enough to shrink the tree past a usable few rows, and
|
||
# ttk has no way to hold a pane at a floor once the room is simply not there.
|
||
MIN_WINDOW_HEIGHT = 300
|
||
|
||
# How many visited sheets Back and Forward can walk through. A sheet is one tree node's
|
||
# children, so a session touches few of them; the cap only stops an unbounded list.
|
||
HISTORY_LIMIT = 50
|
||
|
||
# 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"
|
||
|
||
# --- Sheet view history ---
|
||
# Each entry is the element whose children are painted, which is what
|
||
# app_state.current_sheet_parent holds; None is the file's root node. Elements are kept
|
||
# rather than tree item ids because a rebuild of the tree hands out fresh ids, while the
|
||
# elements themselves survive anything short of a delete or a new file.
|
||
sheet_history = []
|
||
history_index = -1 # position in sheet_history that is on screen
|
||
_suppress_history = False # set while Back or Forward drives the tree selection
|
||
_history_file_path = None # the file sheet_history was recorded against
|
||
|
||
# Filled in once the widgets exist. History can be recorded before then, so every
|
||
# function that touches them checks first.
|
||
btn_back = None
|
||
btn_forward = None
|
||
zoom_label = None
|
||
menu_view = None
|
||
show_left_column_var = None
|
||
|
||
# The Properties and Link views each take the editor pane over from the canvas rather
|
||
# than floating above it, so at most one of the three is on screen and these two say
|
||
# which. Both None means the sheet is showing.
|
||
properties_panel = None
|
||
link_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.
|
||
"""
|
||
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/redo'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)
|
||
|
||
|
||
def _component_in_document(comp):
|
||
root = app_state.xml_root_element
|
||
return root is not None and any(element is comp for element in root.iter())
|
||
|
||
|
||
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
|
||
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
|
||
|
||
|
||
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)
|
||
app_state.set_status(f"Properties: {comp.get('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)
|
||
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 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; the Link View simply stands down.
|
||
"""
|
||
if link_panel is not None:
|
||
dismiss_link_view()
|
||
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"{app_state.base_window_title()} — undid {label}")
|
||
return "break"
|
||
|
||
|
||
def do_redo(event=None):
|
||
label = undo.redo()
|
||
if label is None:
|
||
return "break"
|
||
app_state.window.title(f"{app_state.base_window_title()} — redid {label}")
|
||
return "break"
|
||
|
||
|
||
def do_save(event=None):
|
||
sax_file.execute_direct_overwrite_save()
|
||
return "break"
|
||
|
||
|
||
NL = chr(10) # newline, for the multi-line message boxes
|
||
|
||
|
||
def do_tidy_layout(event=None):
|
||
"""Menu command: re-flow the open sheet so nothing overlaps."""
|
||
if not app_state.canvas_comp_map:
|
||
messagebox.showinfo("Tidy Layout", "Open a sheet with components first.")
|
||
return "break"
|
||
|
||
moved, clamped = wiresheet.tidy_layout()
|
||
multiplier = app_state.meta_scale // app_state.GRID_SIZE
|
||
|
||
if moved == 0:
|
||
messagebox.showinfo("Tidy Layout", "Nothing to move — no boxes overlap.")
|
||
elif clamped:
|
||
messagebox.showwarning(
|
||
"Tidy Layout",
|
||
f"Repositioned {moved} component(s).\n\n"
|
||
f"{clamped} still hit the coordinate limit of {wiresheet.META_COORD_MAX} "
|
||
"steps, because meta stores x and y in one byte each. This sheet is already "
|
||
f"laid out at {multiplier} grid squares per step, the widest meta can reach, "
|
||
"so it does not fit however it is spaced. Split it across sub-sheets."
|
||
)
|
||
elif multiplier > 1:
|
||
messagebox.showinfo(
|
||
"Tidy Layout",
|
||
f"Repositioned {moved} component(s)." + NL + NL +
|
||
f"This sheet needs more width than meta's {wiresheet.META_COORD_MAX} "
|
||
f"coordinate steps allow at one grid square each, so it is laid out at "
|
||
f"{multiplier} grid squares per step ({app_state.meta_scale} px). Boxes "
|
||
f"settle on every {multiplier} grid lines rather than every one. Your grid "
|
||
"size in Preferences is unchanged."
|
||
)
|
||
return "break"
|
||
|
||
|
||
# --- Zoom ---
|
||
|
||
def update_zoom_label():
|
||
"""Puts the painted scale on the toolbar as a percentage."""
|
||
if zoom_label is not None:
|
||
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):
|
||
zoom_to(wiresheet.zoom_step(1))
|
||
return "break"
|
||
|
||
|
||
def do_zoom_out(event=None):
|
||
zoom_to(wiresheet.zoom_step(-1))
|
||
return "break"
|
||
|
||
|
||
def do_zoom_reset(event=None):
|
||
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.
|
||
"""
|
||
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()
|
||
|
||
# Bring the sheet's own top-left corner to the top-left of the view, since a fitted
|
||
# sheet is measured from there rather than from the canvas origin.
|
||
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"
|
||
|
||
|
||
# --- Event handlers ---
|
||
|
||
def on_tree_select(event):
|
||
"""Triggered when a branch node is clicked in the Navigation view."""
|
||
global _pending_link_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
|
||
|
||
# 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/redo and tidy can repaint this same sheet from the DOM.
|
||
app_state.current_sheet_parent = element
|
||
record_sheet_visit(element)
|
||
|
||
if element is not None:
|
||
components_to_render = [child for child in element if child.tag == "comp"]
|
||
else:
|
||
top_level_ids = tree.get_children(selected_id)
|
||
for t_id in top_level_ids:
|
||
comp_elem = app_state.tree_element_map.get(t_id)
|
||
if comp_elem is not None:
|
||
components_to_render.append(comp_elem)
|
||
|
||
wiresheet.render_components(components_to_render)
|
||
|
||
# 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 the currently selected link from the DOM and repaints."""
|
||
if not app_state.selected_link_tag:
|
||
return
|
||
link_elem = app_state.canvas_link_map.get(app_state.selected_link_tag)
|
||
if link_elem is None:
|
||
return
|
||
|
||
from_ref = link_elem.get("from", "")
|
||
to_ref = link_elem.get("to", "")
|
||
confirm = messagebox.askyesno(
|
||
"Delete Link",
|
||
f"Delete link?\n\n from: {from_ref}\n to: {to_ref}"
|
||
)
|
||
if not confirm:
|
||
return
|
||
|
||
sax_file.remove_link(link_elem)
|
||
|
||
|
||
def 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
|
||
|
||
tree.selection_set(item_id)
|
||
app_state.selected_tree_item_id = item_id
|
||
parent_id = tree.parent(item_id)
|
||
|
||
if parent_id == "":
|
||
tree_root_context_menu.post(event.x_root, event.y_root)
|
||
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.
|
||
fill_component_popup(tree_comp_context_menu, comp_element)
|
||
tree_comp_context_menu.post(event.x_root, event.y_root)
|
||
|
||
|
||
def on_canvas_click(event):
|
||
canvas = app_state.canvas
|
||
canvas.itemconfig("block_outline", outline="#7f9db9", width=2)
|
||
wiresheet.deselect_link()
|
||
app_state.drag_data["tag"] = None
|
||
app_state.selected_canvas_tag = None
|
||
|
||
cx = canvas.canvasx(event.x)
|
||
cy = canvas.canvasy(event.y)
|
||
clicked_item = canvas.find_closest(cx, cy, halo=3)
|
||
|
||
if not clicked_item:
|
||
return
|
||
|
||
tags = canvas.gettags(clicked_item[0])
|
||
group_tag = next((t for t in tags if t.startswith("comp_group_")), None)
|
||
wire_tag = next((t for t in tags if t.startswith("link_wire_")), None)
|
||
|
||
if wire_tag:
|
||
wiresheet.select_link(wire_tag)
|
||
return
|
||
|
||
if group_tag:
|
||
app_state.drag_data["tag"] = group_tag
|
||
app_state.drag_data["x"] = cx
|
||
app_state.drag_data["y"] = cy
|
||
app_state.selected_canvas_tag = group_tag
|
||
|
||
# Where the box starts out. The drag is measured as an offset from this pair
|
||
# rather than accumulated per motion event, so repeated snapping cannot drift.
|
||
origin = wiresheet.get_box_origin(group_tag)
|
||
app_state.drag_data["box_x"] = origin[0] if origin else None
|
||
app_state.drag_data["box_y"] = origin[1] if origin else None
|
||
|
||
# Snapshot the pre-drag meta so the move can be pushed as one undoable step.
|
||
dragged = app_state.canvas_comp_map.get(group_tag)
|
||
meta_prop = dragged.find("prop[@name='meta']") if dragged is not None else None
|
||
app_state.drag_data["meta_before"] = meta_prop.get("val") if meta_prop is not None else None
|
||
canvas.itemconfig(f"{group_tag} && block_outline", outline="#1f4e79", width=3)
|
||
|
||
# Bring the clicked box clear of any neighbour it happens to sit under.
|
||
canvas.tag_raise(group_tag)
|
||
wiresheet.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):
|
||
"""Opens a dialog to select source and target slots, validates types, and creates a link."""
|
||
window = app_state.window
|
||
|
||
sides = [source_element, target_element]
|
||
|
||
dialog = tk.Toplevel(window)
|
||
dialog.title("Link")
|
||
dialog.resizable(False, False)
|
||
dialog.transient(window)
|
||
dialog.grab_set()
|
||
dialog.geometry(f"+{window.winfo_x() + 100}+{window.winfo_y() + 60}")
|
||
|
||
main_frame = tk.Frame(dialog, padx=10, pady=10)
|
||
main_frame.pack(fill=tk.BOTH, expand=True)
|
||
|
||
src_frame = tk.LabelFrame(main_frame, padx=5, pady=5)
|
||
src_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 5))
|
||
|
||
mid_frame = tk.Frame(main_frame)
|
||
mid_frame.grid(row=0, column=1, padx=5)
|
||
|
||
tgt_frame = tk.LabelFrame(main_frame, padx=5, pady=5)
|
||
tgt_frame.grid(row=0, column=2, sticky="nsew", padx=(5, 0))
|
||
|
||
src_var = tk.StringVar(value="")
|
||
tgt_var = tk.StringVar(value="")
|
||
|
||
src_widgets = []
|
||
tgt_widgets = []
|
||
src_slot_info = {}
|
||
tgt_slot_info = {}
|
||
|
||
status_var = tk.StringVar(value="")
|
||
|
||
def _update_peer_states(selected_var, selected_info, peer_widgets, peer_info, peer_is_target):
|
||
"""Grey out incompatible slots on the peer side after a selection."""
|
||
chosen = selected_var.get()
|
||
if not chosen or chosen not in selected_info:
|
||
for rb in peer_widgets:
|
||
name = rb.cget("value")
|
||
info = peer_info.get(name)
|
||
if info and _slot_linkable(info[1], info[0], info[2], peer_is_target):
|
||
rb.config(state=tk.NORMAL, fg="black")
|
||
else:
|
||
rb.config(state=tk.DISABLED, fg="#999999")
|
||
return
|
||
|
||
sel_type = selected_info[chosen][0]
|
||
|
||
for rb in peer_widgets:
|
||
name = rb.cget("value")
|
||
info = peer_info.get(name)
|
||
if not info:
|
||
rb.config(state=tk.DISABLED, fg="#999999")
|
||
continue
|
||
|
||
peer_type, peer_flags, peer_ro = info
|
||
if not _slot_linkable(peer_flags, peer_type, peer_ro, peer_is_target):
|
||
rb.config(state=tk.DISABLED, fg="#999999")
|
||
elif peer_type != sel_type:
|
||
rb.config(state=tk.DISABLED, fg="#999999")
|
||
else:
|
||
rb.config(state=tk.NORMAL, fg="black")
|
||
|
||
def _on_src_change(*_args):
|
||
_update_peer_states(src_var, src_slot_info, tgt_widgets, tgt_slot_info, True)
|
||
_update_status_label()
|
||
|
||
def _on_tgt_change(*_args):
|
||
_update_peer_states(tgt_var, tgt_slot_info, src_widgets, src_slot_info, False)
|
||
_update_status_label()
|
||
|
||
def _update_status_label():
|
||
s = src_var.get()
|
||
t = tgt_var.get()
|
||
left_name = sides[0].get("name", "?")
|
||
right_name = sides[1].get("name", "?")
|
||
if s and t:
|
||
status_var.set(f"Link {left_name}.{s} -> {right_name}.{t}")
|
||
elif s:
|
||
status_var.set(f"Link {left_name}.{s} -> ?")
|
||
elif t:
|
||
status_var.set(f"Link ? -> {right_name}.{t}")
|
||
else:
|
||
status_var.set("")
|
||
|
||
def populate_panel(parent_frame, slots, var, widgets_list, info_dict, is_target):
|
||
for child in parent_frame.winfo_children():
|
||
child.destroy()
|
||
widgets_list.clear()
|
||
info_dict.clear()
|
||
|
||
panel_canvas = tk.Canvas(parent_frame, width=160, height=300, highlightthickness=0)
|
||
panel_scroll = ttk.Scrollbar(parent_frame, orient=tk.VERTICAL, command=panel_canvas.yview)
|
||
inner = tk.Frame(panel_canvas)
|
||
panel_canvas.create_window((0, 0), window=inner, anchor="nw")
|
||
panel_canvas.config(yscrollcommand=panel_scroll.set)
|
||
panel_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||
panel_scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||
|
||
for slot_name, slot_type, slot_flags, slot_ro in slots:
|
||
info_dict[slot_name] = (slot_type, slot_flags, slot_ro)
|
||
linkable = _slot_linkable(slot_flags, slot_type, slot_ro, is_target)
|
||
rb = tk.Radiobutton(inner, text=slot_name, variable=var, value=slot_name,
|
||
anchor="w", bg="#c8d4e8", relief="flat", padx=4, pady=1,
|
||
state=tk.NORMAL if linkable else tk.DISABLED,
|
||
fg="black" if linkable else "#999999",
|
||
disabledforeground="#999999")
|
||
rb.pack(fill=tk.X)
|
||
widgets_list.append(rb)
|
||
|
||
inner.update_idletasks()
|
||
panel_canvas.config(scrollregion=panel_canvas.bbox("all"))
|
||
|
||
def refresh_panels():
|
||
left = sides[0]
|
||
right = sides[1]
|
||
left_name = left.get("name", "?")
|
||
right_name = right.get("name", "?")
|
||
left_type = left.get("type", "")
|
||
right_type = right.get("type", "")
|
||
|
||
src_frame.config(text=f"{left_name} [Source]")
|
||
tgt_frame.config(text=f"{right_name} [Target]")
|
||
|
||
left_slots = schema.get_all_slots_for_type(left_type)
|
||
right_slots = schema.get_all_slots_for_type(right_type)
|
||
|
||
src_var.set("")
|
||
tgt_var.set("")
|
||
populate_panel(src_frame, left_slots, src_var, src_widgets, src_slot_info, False)
|
||
populate_panel(tgt_frame, right_slots, tgt_var, tgt_widgets, tgt_slot_info, True)
|
||
_update_status_label()
|
||
|
||
src_var.trace_add("write", _on_src_change)
|
||
tgt_var.trace_add("write", _on_tgt_change)
|
||
|
||
def on_swap():
|
||
sides[0], sides[1] = sides[1], sides[0]
|
||
refresh_panels()
|
||
|
||
ttk.Button(mid_frame, text="⇄", width=3, command=on_swap).pack(pady=5)
|
||
|
||
refresh_panels()
|
||
|
||
status_label = tk.Label(main_frame, textvariable=status_var, anchor="w", fg="#333333")
|
||
status_label.grid(row=1, column=0, columnspan=3, sticky="w", pady=(8, 0))
|
||
|
||
def on_ok():
|
||
src_slot = src_var.get()
|
||
tgt_slot = tgt_var.get()
|
||
|
||
if not src_slot or not tgt_slot:
|
||
messagebox.showwarning("Link", "Select a slot from both source and target.", parent=dialog)
|
||
return
|
||
|
||
st = src_slot_info.get(src_slot, ("?", ""))[0]
|
||
tt = tgt_slot_info.get(tgt_slot, ("?", ""))[0]
|
||
|
||
if st != tt:
|
||
messagebox.showwarning(
|
||
"Slot type mismatch",
|
||
f"Cannot link slots of different types.\n\n"
|
||
f"Source: {src_slot} ({st})\n"
|
||
f"Target: {tgt_slot} ({tt})",
|
||
parent=dialog
|
||
)
|
||
return
|
||
|
||
src_path = get_component_path(sides[0], app_state.xml_root_element)
|
||
tgt_path = get_component_path(sides[1], app_state.xml_root_element)
|
||
|
||
from_ref = f"{src_path}.{src_slot}"
|
||
to_ref = f"{tgt_path}.{tgt_slot}"
|
||
|
||
dialog.destroy()
|
||
sax_file.add_link(from_ref, to_ref)
|
||
|
||
btn_frame = tk.Frame(main_frame)
|
||
btn_frame.grid(row=2, column=0, columnspan=3, pady=(15, 0))
|
||
|
||
ttk.Button(btn_frame, text="OK", width=10, command=on_ok).pack(side=tk.LEFT, padx=5)
|
||
ttk.Button(btn_frame, text="Cancel", width=10, command=dialog.destroy).pack(side=tk.LEFT, padx=5)
|
||
|
||
|
||
def fill_component_popup(menu, comp):
|
||
"""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.
|
||
"""
|
||
menu.delete(0, tk.END)
|
||
menu.add_command(
|
||
label="Properties",
|
||
command=lambda: show_properties(comp),
|
||
)
|
||
menu.add_command(
|
||
label="Link View",
|
||
command=lambda: show_link_view(comp),
|
||
)
|
||
menu.add_separator()
|
||
menu.add_command(
|
||
label="Delete Component",
|
||
command=lambda: delete_component(comp),
|
||
)
|
||
menu.add_separator()
|
||
menu.add_command(label="Link Mark", command=lambda: set_link_mark(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(marked, comp),
|
||
)
|
||
menu.add_command(
|
||
label=f'Link To "{mark_name}"',
|
||
command=lambda: open_link_dialog(comp, marked),
|
||
)
|
||
|
||
|
||
def on_canvas_right_click(event):
|
||
"""Detects right-clicks on nodes to instantly show context-sensitive action overlays."""
|
||
canvas = app_state.canvas
|
||
cx = canvas.canvasx(event.x)
|
||
cy = canvas.canvasy(event.y)
|
||
clicked_item = canvas.find_closest(cx, cy, halo=3)
|
||
|
||
if clicked_item:
|
||
tags = canvas.gettags(clicked_item[0])
|
||
group_tag = next((t for t in tags if t.startswith("comp_group_")), None)
|
||
wire_tag = next((t for t in tags if t.startswith("link_wire_")), None)
|
||
|
||
if wire_tag:
|
||
wiresheet.select_link(wire_tag)
|
||
_rebuild_link_context_menu()
|
||
link_context_menu.post(event.x_root, event.y_root)
|
||
return
|
||
|
||
if group_tag:
|
||
wiresheet.deselect_link()
|
||
canvas.itemconfig("block_outline", outline="#7f9db9", width=2)
|
||
app_state.selected_canvas_tag = group_tag
|
||
canvas.itemconfig(f"{group_tag} && block_outline", outline="#1f4e79", width=3)
|
||
|
||
comp_element = app_state.canvas_comp_map.get(group_tag)
|
||
if comp_element is not None:
|
||
fill_component_popup(canvas_context_menu, comp_element)
|
||
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)
|
||
undo.push(undo.MetaMoveCommand(
|
||
[(comp_element, meta_before, raw_x, raw_y)],
|
||
label=f"move {comp_element.get('name', 'component')}"
|
||
))
|
||
app_state.mark_dirty()
|
||
|
||
app_state.drag_data["tag"] = None
|
||
wiresheet.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="+")
|
||
tree.bind("<<TreeviewOpen>>", lambda event: tree.after_idle(fit_tree_column, tree), add="+")
|
||
tree.bind("<<TreeviewClose>>", lambda event: tree.after_idle(fit_tree_column, tree), add="+")
|
||
|
||
|
||
def clamp_left_pane_split(event=None):
|
||
"""Keeps both halves of the left column visible, whatever the sash or the window does.
|
||
|
||
ttk lets a sash drag run to either end, and it remembers that position when the window
|
||
is made shorter afterwards, so either list can be left with no height at all. Each
|
||
pane's requested height is the line. Once the column is too short to seat both, the
|
||
floors are dropped and ttk's own proportional squeeze takes over.
|
||
"""
|
||
total = left_pane.winfo_height()
|
||
tree_floor = frame_tree.winfo_reqheight()
|
||
palette_floor = frame_palette.winfo_reqheight()
|
||
if total <= tree_floor + palette_floor:
|
||
return
|
||
|
||
position = left_pane.sashpos(0)
|
||
clamped = min(max(position, tree_floor), total - palette_floor)
|
||
if clamped != position:
|
||
left_pane.sashpos(0, clamped)
|
||
|
||
|
||
def apply_left_pane_width():
|
||
"""Pins the tree/palette column to the configured width.
|
||
|
||
Setting the sash before the paned window has been laid out collapses the column
|
||
instead of sizing it, so the startup call retries until there is a width to divide.
|
||
This is also how a changed preference reaches an already open window.
|
||
"""
|
||
app_state.window.update_idletasks()
|
||
if paned_window.winfo_width() <= 1:
|
||
paned_window.after(50, apply_left_pane_width)
|
||
return
|
||
paned_window.sashpos(0, app_state.LEFT_PANE_WIDTH)
|
||
|
||
|
||
def apply_left_column_visibility():
|
||
"""Shows or hides the tree/palette column to match the View menu's checkbutton.
|
||
|
||
ttk has no hide flag for a pane: the child is dropped from the paned window and put
|
||
back at the front, which is why the configured width has to be re-applied afterwards.
|
||
"""
|
||
if show_left_column_var is None:
|
||
return
|
||
|
||
panes = paned_window.panes()
|
||
is_shown = str(left_pane) in [str(pane) for pane in panes]
|
||
|
||
if show_left_column_var.get():
|
||
if not is_shown:
|
||
paned_window.insert(0, left_pane, weight=0)
|
||
apply_left_pane_width()
|
||
elif is_shown:
|
||
paned_window.forget(left_pane)
|
||
|
||
|
||
def toggle_left_column(event=None):
|
||
"""Keyboard route to the same toggle. The menu flips the variable for itself."""
|
||
if show_left_column_var is not None:
|
||
show_left_column_var.set(not show_left_column_var.get())
|
||
apply_left_column_visibility()
|
||
return "break"
|
||
|
||
|
||
# --- Sheet view history ---
|
||
|
||
def _tree_item_for_element(element):
|
||
"""The tree item whose sheet is this element's children, or None if it is gone.
|
||
|
||
A history entry recorded before a delete, or before another file was opened, no
|
||
longer has a node to select; that is what None reports.
|
||
"""
|
||
tree = app_state.tree
|
||
if tree is None:
|
||
return None
|
||
if element is None:
|
||
# The file's root node, which parse never puts in the element map.
|
||
children = tree.get_children()
|
||
return children[0] if children else None
|
||
for item_id, mapped in app_state.tree_element_map.items():
|
||
if mapped is element:
|
||
return item_id
|
||
return None
|
||
|
||
|
||
def _prune_sheet_history():
|
||
"""Drops entries whose sheet no longer exists, keeping the position on screen."""
|
||
global history_index
|
||
|
||
kept = []
|
||
new_index = -1
|
||
for position, element in enumerate(sheet_history):
|
||
if _tree_item_for_element(element) is None:
|
||
continue
|
||
kept.append(element)
|
||
if position <= history_index:
|
||
new_index = len(kept) - 1
|
||
|
||
sheet_history[:] = kept
|
||
history_index = min(new_index, len(kept) - 1)
|
||
|
||
|
||
def update_history_controls():
|
||
"""Greys out Back and Forward when there is nothing to walk to in that direction."""
|
||
can_back = history_index > 0
|
||
can_forward = 0 <= history_index < len(sheet_history) - 1
|
||
|
||
for button, enabled in ((btn_back, can_back), (btn_forward, can_forward)):
|
||
if button is not None:
|
||
button.config(state="normal" if enabled else "disabled")
|
||
|
||
if menu_view is not None:
|
||
menu_view.entryconfig(
|
||
MENU_BACK_LABEL, state="normal" if can_back else "disabled"
|
||
)
|
||
menu_view.entryconfig(
|
||
MENU_FORWARD_LABEL, state="normal" if can_forward else "disabled"
|
||
)
|
||
|
||
|
||
def reset_sheet_history():
|
||
"""Starts the history over, for a newly opened file."""
|
||
global history_index
|
||
sheet_history.clear()
|
||
history_index = -1
|
||
update_history_controls()
|
||
|
||
|
||
def record_sheet_visit(element):
|
||
"""Adds a freshly opened sheet to the history, dropping anything ahead of it.
|
||
|
||
Called from the tree selection handler, so it sees every way a sheet is opened.
|
||
Back and Forward suppress it: walking the history must not rewrite it.
|
||
"""
|
||
global history_index, _history_file_path
|
||
|
||
if _suppress_history:
|
||
return
|
||
|
||
# Entries name elements of one document, so a different file starts a fresh history
|
||
# whether or not the tree rebuild that came with it was announced.
|
||
if app_state.current_file_path != _history_file_path:
|
||
_history_file_path = app_state.current_file_path
|
||
sheet_history.clear()
|
||
history_index = -1
|
||
|
||
if 0 <= history_index < len(sheet_history) and sheet_history[history_index] is element:
|
||
return
|
||
|
||
del sheet_history[history_index + 1:]
|
||
sheet_history.append(element)
|
||
if len(sheet_history) > HISTORY_LIMIT:
|
||
del sheet_history[0]
|
||
history_index = len(sheet_history) - 1
|
||
update_history_controls()
|
||
|
||
|
||
def _show_sheet_history_entry(position):
|
||
"""Opens the sheet the history holds at this position, without recording the move."""
|
||
global history_index, _suppress_history
|
||
|
||
element = sheet_history[position]
|
||
item_id = _tree_item_for_element(element)
|
||
if item_id is None:
|
||
return
|
||
|
||
tree = app_state.tree
|
||
history_index = position
|
||
was_selected = tree.selection() == (item_id,)
|
||
|
||
_suppress_history = True
|
||
try:
|
||
# Selecting the node is what paints the sheet, via on_tree_select.
|
||
tree.selection_set(item_id)
|
||
tree.focus(item_id)
|
||
tree.see(item_id)
|
||
finally:
|
||
_suppress_history = False
|
||
|
||
if was_selected:
|
||
# ttk fires no selection event when the node is already the selected one, so
|
||
# the repaint this entry is asking for has to be made here instead.
|
||
app_state.current_sheet_parent = element
|
||
wiresheet.render_current_sheet()
|
||
|
||
update_history_controls()
|
||
|
||
|
||
def go_back(event=None):
|
||
"""View menu and toolbar: step to the previously opened sheet."""
|
||
if not close_editor_panel():
|
||
return "break"
|
||
_prune_sheet_history()
|
||
if history_index > 0:
|
||
_show_sheet_history_entry(history_index - 1)
|
||
else:
|
||
update_history_controls()
|
||
return "break"
|
||
|
||
|
||
def go_forward(event=None):
|
||
"""View menu and toolbar: step back towards the most recently opened sheet."""
|
||
if not close_editor_panel():
|
||
return "break"
|
||
_prune_sheet_history()
|
||
if 0 <= history_index < len(sheet_history) - 1:
|
||
_show_sheet_history_entry(history_index + 1)
|
||
else:
|
||
update_history_controls()
|
||
return "break"
|
||
|
||
|
||
def on_tree_filled():
|
||
"""Runs after every rebuild of the navigation tree.
|
||
|
||
A rebuild is either a new file, which retires the history, or a repaint after an
|
||
undo or a delete, which can leave entries pointing at elements that are no longer
|
||
in the document.
|
||
"""
|
||
global _history_file_path
|
||
|
||
# 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
|
||
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)
|
||
|
||
if app_state.current_file_path != _history_file_path:
|
||
_history_file_path = app_state.current_file_path
|
||
reset_sheet_history()
|
||
else:
|
||
_prune_sheet_history()
|
||
update_history_controls()
|
||
|
||
|
||
# --- Dialogs ---
|
||
|
||
def open_preferences_dialog():
|
||
"""Pops open a tabular modal dialog to edit configuration attributes."""
|
||
window = app_state.window
|
||
|
||
dialog = tk.Toplevel(window)
|
||
dialog.title("Preferences")
|
||
dialog.geometry("470x290")
|
||
dialog.resizable(False, False)
|
||
dialog.transient(window)
|
||
dialog.grab_set()
|
||
|
||
dialog.geometry(f"+{window.winfo_x() + 100}+{window.winfo_y() + 100}")
|
||
|
||
frame_padded = ttk.Frame(dialog, padding=15)
|
||
frame_padded.pack(fill=tk.BOTH, expand=True)
|
||
|
||
frame_padded.columnconfigure(0, weight=1, minsize=160)
|
||
frame_padded.columnconfigure(1, weight=2)
|
||
|
||
lbl_home = ttk.Label(frame_padded, text="Sedona home folder:", anchor="w")
|
||
lbl_home.grid(row=0, column=0, sticky="w", pady=(0, 10), padx=(0, 10))
|
||
|
||
frame_home_picker = ttk.Frame(frame_padded)
|
||
frame_home_picker.grid(row=0, column=1, sticky="ew", pady=(0, 10))
|
||
frame_home_picker.columnconfigure(0, weight=1)
|
||
|
||
entry_home = ttk.Entry(frame_home_picker)
|
||
entry_home.insert(0, app_state.SEDONA_HOME)
|
||
entry_home.grid(row=0, column=0, sticky="ew", padx=(0, 5))
|
||
|
||
def browse_home_folder():
|
||
chosen_dir = filedialog.askdirectory(title="Select Sedona Home Folder", initialdir=entry_home.get() or None)
|
||
if chosen_dir:
|
||
entry_home.delete(0, tk.END)
|
||
entry_home.insert(0, os.path.normpath(chosen_dir))
|
||
|
||
btn_home_browse = ttk.Button(frame_home_picker, text="...", width=3, command=browse_home_folder)
|
||
btn_home_browse.grid(row=0, column=1, sticky="e")
|
||
|
||
lbl_size = ttk.Label(frame_padded, text="Grid size (pixels):", anchor="w")
|
||
lbl_size.grid(row=1, column=0, sticky="w", pady=(0, 10), padx=(0, 10))
|
||
|
||
entry_grid = ttk.Entry(frame_padded)
|
||
entry_grid.insert(0, str(app_state.GRID_SIZE))
|
||
entry_grid.grid(row=1, column=1, sticky="ew", pady=(0, 10))
|
||
|
||
lbl_box = ttk.Label(frame_padded, text="Component box width (pixels):", anchor="w")
|
||
lbl_box.grid(row=2, column=0, sticky="w", pady=(0, 10), padx=(0, 10))
|
||
|
||
entry_box_width = ttk.Entry(frame_padded)
|
||
entry_box_width.insert(0, str(app_state.BOX_WIDTH))
|
||
entry_box_width.grid(row=2, column=1, sticky="ew", pady=(0, 10))
|
||
|
||
lbl_left_pane = ttk.Label(frame_padded, text="Tree/palette width (pixels):", anchor="w")
|
||
lbl_left_pane.grid(row=3, column=0, sticky="w", pady=(0, 10), padx=(0, 10))
|
||
|
||
entry_left_pane = ttk.Entry(frame_padded)
|
||
entry_left_pane.insert(0, str(app_state.LEFT_PANE_WIDTH))
|
||
entry_left_pane.grid(row=3, column=1, sticky="ew", pady=(0, 10))
|
||
|
||
lbl_timestamp = ttk.Label(frame_padded, text="Save with Date-Time stamp?", anchor="w")
|
||
lbl_timestamp.grid(row=4, column=0, sticky="w", pady=(0, 15), padx=(0, 10))
|
||
|
||
var_timestamp = tk.BooleanVar(value=app_state.ADD_TIMESTAMP)
|
||
chk_timestamp = ttk.Checkbutton(
|
||
frame_padded,
|
||
text="Yes",
|
||
variable=var_timestamp
|
||
)
|
||
chk_timestamp.grid(row=4, column=1, sticky="w", pady=(0, 15))
|
||
|
||
def apply_preferences():
|
||
try:
|
||
val = int(entry_grid.get().strip())
|
||
if val < 2 or val > 200:
|
||
raise ValueError("Grid size must be a whole number between 2 and 200.")
|
||
|
||
try:
|
||
width = int(entry_box_width.get().strip())
|
||
except ValueError:
|
||
raise ValueError(
|
||
f"Box width must be a whole number between "
|
||
f"{app_state.MIN_ALLOWED_BOX_WIDTH} and {app_state.MAX_ALLOWED_BOX_WIDTH}."
|
||
)
|
||
if not (app_state.MIN_ALLOWED_BOX_WIDTH <= width <= app_state.MAX_ALLOWED_BOX_WIDTH):
|
||
raise ValueError(
|
||
f"Box width must be between {app_state.MIN_ALLOWED_BOX_WIDTH} and "
|
||
f"{app_state.MAX_ALLOWED_BOX_WIDTH} pixels."
|
||
)
|
||
|
||
try:
|
||
pane_width = int(entry_left_pane.get().strip())
|
||
except ValueError:
|
||
raise ValueError(
|
||
f"Tree/palette width must be a whole number between "
|
||
f"{app_state.MIN_ALLOWED_LEFT_PANE_WIDTH} and "
|
||
f"{app_state.MAX_ALLOWED_LEFT_PANE_WIDTH}."
|
||
)
|
||
if not (app_state.MIN_ALLOWED_LEFT_PANE_WIDTH <= pane_width
|
||
<= app_state.MAX_ALLOWED_LEFT_PANE_WIDTH):
|
||
raise ValueError(
|
||
f"Tree/palette width must be between "
|
||
f"{app_state.MIN_ALLOWED_LEFT_PANE_WIDTH} and "
|
||
f"{app_state.MAX_ALLOWED_LEFT_PANE_WIDTH} pixels."
|
||
)
|
||
|
||
# The width cap is applied after each box is fitted to its text, so a cap
|
||
# that is not a multiple of the grid leaves the widest boxes off-grid.
|
||
proposed = wiresheet.suggest_box_width_for_grid(width, val)
|
||
if proposed != width:
|
||
if not messagebox.askyesno(
|
||
"Box Width Off Grid",
|
||
f"A box width of {width} px is not a multiple of the {val} px grid, "
|
||
f"so the widest boxes would not line up with it.\n\n"
|
||
f"Change the box width to {proposed} px?",
|
||
parent=dialog,
|
||
):
|
||
return
|
||
width = proposed
|
||
entry_box_width.delete(0, tk.END)
|
||
entry_box_width.insert(0, str(width))
|
||
|
||
app_state.BOX_WIDTH = width
|
||
app_state.LEFT_PANE_WIDTH = pane_width
|
||
app_state.GRID_SIZE = val
|
||
app_state.ADD_TIMESTAMP = var_timestamp.get()
|
||
app_state.SEDONA_HOME = entry_home.get().strip()
|
||
|
||
app_state.save_preferences()
|
||
|
||
apply_left_pane_width()
|
||
wiresheet.draw_grid()
|
||
if app_state.active_components_list:
|
||
wiresheet.render_components(app_state.active_components_list)
|
||
|
||
# Sedona home may have moved, so the palette's kit list is rebuilt.
|
||
refresh_palette_kits()
|
||
|
||
dialog.destroy()
|
||
except ValueError as error:
|
||
message = str(error) or "Please enter a valid integer size between 2 and 200."
|
||
messagebox.showerror("Invalid Input", message, parent=dialog)
|
||
|
||
btn_save_pref = ttk.Button(frame_padded, text="Apply Changes", command=apply_preferences)
|
||
btn_save_pref.grid(row=5, column=1, sticky="e")
|
||
|
||
if not app_state.SEDONA_HOME:
|
||
entry_home.focus_set()
|
||
else:
|
||
entry_grid.focus_set()
|
||
|
||
|
||
def show_app_info():
|
||
"""Displays information tracking details for the currently active schema file in standard columns."""
|
||
window = app_state.window
|
||
|
||
if app_state.current_file_path:
|
||
file_name = os.path.basename(app_state.current_file_path)
|
||
folder_path = os.path.dirname(app_state.current_file_path)
|
||
else:
|
||
file_name = "No profile loaded"
|
||
folder_path = "None"
|
||
|
||
lastReachedID = 0
|
||
all_found_ids = set()
|
||
|
||
if app_state.xml_root_element is not None:
|
||
for elem in app_state.xml_root_element.iter():
|
||
if elem.tag == "comp":
|
||
id_str = elem.get("id")
|
||
if id_str is not None:
|
||
try:
|
||
val = int(id_str)
|
||
all_found_ids.add(val)
|
||
if val > lastReachedID:
|
||
lastReachedID = val
|
||
except ValueError:
|
||
pass
|
||
|
||
not_used_ids = [i for i in range(1, lastReachedID + 1) if i not in all_found_ids]
|
||
usedComponentNumber = lastReachedID - len(not_used_ids)
|
||
|
||
not_used_str = ", ".join(map(str, not_used_ids)) if not_used_ids else "None"
|
||
if len(not_used_str) > 40:
|
||
not_used_str = not_used_str[:37] + "..."
|
||
|
||
info_win = tk.Toplevel(window)
|
||
info_win.title("App Info")
|
||
info_win.resizable(False, False)
|
||
info_win.transient(window)
|
||
info_win.grab_set()
|
||
|
||
frame = ttk.Frame(info_win, padding=15)
|
||
frame.pack(fill=tk.BOTH, expand=True)
|
||
|
||
rows_data = [
|
||
("App File Name:", file_name),
|
||
("File Location:", folder_path),
|
||
("Last Reached ID:", str(lastReachedID)),
|
||
("Used Component Count:", str(max(0, usedComponentNumber))),
|
||
("Unused IDs:", not_used_str)
|
||
]
|
||
|
||
for idx, (lbl_txt, val_txt) in enumerate(rows_data):
|
||
lbl = tk.Label(frame, text=lbl_txt, font=("TkDefaultFont", 9, "bold"), anchor="w")
|
||
lbl.grid(row=idx, column=0, sticky="w", padx=(0, 15), pady=4)
|
||
|
||
val = tk.Label(frame, text=val_txt, anchor="w", justify="left")
|
||
val.grid(row=idx, column=1, sticky="w", pady=4)
|
||
|
||
info_win.update_idletasks()
|
||
x = window.winfo_x() + (window.winfo_width() // 2) - (info_win.winfo_width() // 2)
|
||
y = window.winfo_y() + (window.winfo_height() // 2) - (info_win.winfo_height() // 2)
|
||
info_win.geometry(f"+{x}+{y}")
|
||
|
||
|
||
def verify_startup_configurations():
|
||
"""Checks for configuration gaps, triggers an alert, and opens setup if empty."""
|
||
if not app_state.SEDONA_HOME:
|
||
messagebox.showwarning(
|
||
"Configuration Required",
|
||
"The 'Sedona home folder' path is not configured.\n\n"
|
||
"Please specify the directory path in the configuration options "
|
||
"before trying to view or load profile source files."
|
||
)
|
||
open_preferences_dialog()
|
||
return
|
||
|
||
# editor.properties can be hand-edited, so the pair is reconciled here too.
|
||
proposed = wiresheet.suggest_box_width_for_grid(app_state.BOX_WIDTH, app_state.GRID_SIZE)
|
||
if proposed != app_state.BOX_WIDTH and messagebox.askyesno(
|
||
"Box Width Off Grid",
|
||
f"The saved box width of {app_state.BOX_WIDTH} px is not a multiple of the "
|
||
f"{app_state.GRID_SIZE} px grid, so the widest boxes will not line up with it.\n\n"
|
||
f"Change the box width to {proposed} px?",
|
||
):
|
||
app_state.BOX_WIDTH = proposed
|
||
app_state.save_preferences()
|
||
wiresheet.draw_grid()
|
||
if app_state.active_components_list:
|
||
wiresheet.render_components(app_state.active_components_list)
|
||
|
||
|
||
# --- UI Setup ---
|
||
window = tk.Tk()
|
||
app_state.window = window
|
||
window.title(app_state.base_window_title())
|
||
window.geometry("950x600")
|
||
# Width is left alone; only the height needs a floor, to keep the tree pane off zero.
|
||
window.minsize(1, MIN_WINDOW_HEIGHT)
|
||
|
||
# --- Context Popups Structures ---
|
||
canvas_context_menu = tk.Menu(window, tearoff=0)
|
||
link_context_menu = tk.Menu(window, tearoff=0)
|
||
|
||
|
||
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)
|
||
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)
|
||
tree_root_context_menu.add_command(label="Save", command=sax_file.execute_direct_overwrite_save)
|
||
|
||
# --- Keyboard shortcuts ---
|
||
# Command and Control are both bound on every platform, so muscle memory from either
|
||
# side works and Windows users are not asked to learn a Mac chord.
|
||
SHORTCUTS = [
|
||
("s", do_save),
|
||
("z", do_undo),
|
||
("y", do_redo),
|
||
("b", toggle_left_column),
|
||
# 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),
|
||
]
|
||
for key, handler in SHORTCUTS:
|
||
window.bind_all(f"<Command-{key}>", handler)
|
||
window.bind_all(f"<Control-{key}>", handler)
|
||
|
||
# Redo also as Shift-Z, the usual mac chord.
|
||
window.bind_all("<Command-Shift-Z>", do_redo)
|
||
window.bind_all("<Control-Shift-Z>", do_redo)
|
||
|
||
# Delete: the mac Delete key reports as BackSpace, the Windows one as Delete.
|
||
window.bind_all("<Delete>", delete_selected_component)
|
||
window.bind_all("<BackSpace>", delete_selected_component)
|
||
|
||
# Tidy Layout is the one shortcut bound on Control alone: Cmd+W closes the window on
|
||
# macOS, and shadowing that would be a poor trade for a second way to re-flow a sheet.
|
||
window.bind_all("<Control-w>", do_tidy_layout)
|
||
|
||
# Sheet history: the browser chords, in both the Alt and the mac bracket spelling.
|
||
window.bind_all("<Alt-Left>", go_back)
|
||
window.bind_all("<Alt-Right>", go_forward)
|
||
window.bind_all("<Command-bracketleft>", go_back)
|
||
window.bind_all("<Command-bracketright>", go_forward)
|
||
|
||
accel_mod = "Cmd" if sys.platform == "darwin" else "Ctrl"
|
||
|
||
# --- Native Application Menu Configurations ---
|
||
menu_bar = tk.Menu(window)
|
||
|
||
menu_file = tk.Menu(menu_bar, tearoff=0)
|
||
menu_file.add_command(label="Open", command=sax_file.open_file)
|
||
menu_file.add_command(label="Save", command=do_save, accelerator=f"{accel_mod}+S")
|
||
menu_file.add_command(label="Save As...", command=sax_file.save_file_as)
|
||
menu_file.add_separator()
|
||
menu_file.add_command(label="Exit", command=window.quit)
|
||
menu_bar.add_cascade(label="File", menu=menu_file)
|
||
|
||
menu_edit = tk.Menu(menu_bar, tearoff=0)
|
||
menu_edit.add_command(label="Undo", command=do_undo, accelerator=f"{accel_mod}+Z")
|
||
menu_edit.add_command(label="Redo", command=do_redo, accelerator=f"{accel_mod}+Y")
|
||
menu_edit.add_separator()
|
||
menu_edit.add_command(label="Delete Component", command=delete_selected_component, accelerator="Del")
|
||
menu_bar.add_cascade(label="Edit", menu=menu_edit)
|
||
|
||
menu_view = tk.Menu(menu_bar, tearoff=0)
|
||
show_left_column_var = tk.BooleanVar(value=True)
|
||
menu_view.add_checkbutton(
|
||
label="Tree / Palette",
|
||
variable=show_left_column_var,
|
||
command=apply_left_column_visibility,
|
||
accelerator=f"{accel_mod}+B",
|
||
)
|
||
menu_view.add_separator()
|
||
menu_view.add_command(
|
||
label=MENU_BACK_LABEL, command=go_back, accelerator="Alt+Left", state="disabled"
|
||
)
|
||
menu_view.add_command(
|
||
label=MENU_FORWARD_LABEL, command=go_forward, accelerator="Alt+Right", state="disabled"
|
||
)
|
||
menu_view.add_separator()
|
||
menu_view.add_command(label="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=lambda: manage_kits.open_dialog(on_applied=refresh_palette_kits)
|
||
)
|
||
menu_bar.add_cascade(label="Kits", menu=menu_kits)
|
||
|
||
menu_app_info = tk.Menu(menu_bar, tearoff=0)
|
||
menu_app_info.add_command(label="App Info", command=show_app_info)
|
||
menu_bar.add_cascade(label="App", menu=menu_app_info)
|
||
|
||
menu_prefs = tk.Menu(menu_bar, tearoff=0)
|
||
menu_prefs.add_command(label="Preferences", command=open_preferences_dialog)
|
||
menu_bar.add_cascade(label="Preferences", menu=menu_prefs)
|
||
|
||
window.config(menu=menu_bar)
|
||
|
||
# Packed before the paned window: pack hands out the cavity in order, and the paned
|
||
# window expands into whatever is left, so a status bar added afterwards would get no room.
|
||
status_label = ttk.Label(window, text="", anchor="w", foreground=app_state.STATUS_INFO_COLOUR)
|
||
status_label.pack(side=tk.BOTTOM, fill=tk.X, padx=8, pady=(0, 4))
|
||
app_state.status_label = status_label
|
||
|
||
# Packed ahead of the paned window for the same reason the status bar is.
|
||
toolbar = ttk.Frame(window)
|
||
toolbar.pack(side=tk.TOP, fill=tk.X, padx=8, pady=(4, 0))
|
||
|
||
btn_back = ttk.Button(toolbar, text="◀", width=3, command=go_back, state="disabled")
|
||
btn_back.pack(side=tk.LEFT)
|
||
|
||
btn_forward = ttk.Button(toolbar, text="▶", width=3, command=go_forward, state="disabled")
|
||
btn_forward.pack(side=tk.LEFT, padx=(3, 0))
|
||
|
||
ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=8, pady=2)
|
||
|
||
ttk.Button(toolbar, text="−", width=3, command=do_zoom_out).pack(side=tk.LEFT)
|
||
zoom_label = ttk.Label(toolbar, text="100%", width=6, anchor="center")
|
||
zoom_label.pack(side=tk.LEFT, padx=1)
|
||
ttk.Button(toolbar, text="+", width=3, command=do_zoom_in).pack(side=tk.LEFT)
|
||
|
||
# U+26F6, the four-corners frame every other editor marks "fit to window" with.
|
||
ttk.Button(toolbar, text="⛶", width=3, command=do_zoom_fit).pack(side=tk.LEFT, padx=(3, 0))
|
||
|
||
paned_window = ttk.PanedWindow(window, orient=tk.HORIZONTAL)
|
||
paned_window.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
||
|
||
# Left column splits into the navigation tree above and the component palette below.
|
||
left_pane = ttk.PanedWindow(paned_window, orient=tk.VERTICAL)
|
||
|
||
frame_tree = ttk.Frame(left_pane)
|
||
|
||
_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="browse",
|
||
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)
|
||
app_state.tree = tree
|
||
attach_tree_scrollbars(frame_tree, tree)
|
||
tree.bind("<<TreeviewSelect>>", on_tree_select)
|
||
|
||
tree.bind("<ButtonPress-3>", on_tree_right_click)
|
||
tree.bind("<ButtonPress-2>", on_tree_right_click)
|
||
|
||
left_pane.add(frame_tree, weight=3)
|
||
|
||
# --- Component palette ---
|
||
frame_palette = ttk.Frame(left_pane)
|
||
|
||
ttk.Label(frame_palette, text="Palette", font=("TkDefaultFont", 9, "bold")).pack(
|
||
anchor="w", padx=4, pady=(4, 2)
|
||
)
|
||
|
||
palette_kit_var = tk.StringVar()
|
||
palette_kit_combo = ttk.Combobox(frame_palette, textvariable=palette_kit_var, state="readonly")
|
||
palette_kit_combo.pack(fill=tk.X, padx=4)
|
||
|
||
frame_palette_list = ttk.Frame(frame_palette)
|
||
frame_palette_list.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)
|
||
|
||
palette_tree = ttk.Treeview(
|
||
frame_palette_list,
|
||
show="tree",
|
||
selectmode="browse",
|
||
height=PALETTE_REQUESTED_ROWS,
|
||
)
|
||
attach_tree_scrollbars(frame_palette_list, palette_tree)
|
||
|
||
palette_status = ttk.Label(frame_palette, text="", foreground="#666666")
|
||
palette_status.pack(anchor="w", padx=4, pady=(0, 4))
|
||
|
||
|
||
def populate_palette_types(event=None):
|
||
"""Lists the component types of the kit chosen in the palette dropdown."""
|
||
for item in palette_tree.get_children():
|
||
palette_tree.delete(item)
|
||
|
||
index = palette_kit_combo.current()
|
||
if index < 0 or index >= len(palette_kit_names):
|
||
palette_status.config(text="")
|
||
return
|
||
|
||
kit_name = palette_kit_names[index]
|
||
types = palette.list_component_types(kit_name)
|
||
|
||
for type_name, base in types:
|
||
palette_tree.insert("", "end", text=type_name, values=(f"{kit_name}::{type_name}", base))
|
||
|
||
palette_status.config(text=f"{len(types)} component{'' if len(types) == 1 else 's'}")
|
||
fit_tree_column(palette_tree)
|
||
|
||
|
||
def refresh_palette_kits():
|
||
"""Reloads the kit list from Sedona home. Also called after Preferences changes it."""
|
||
global palette_kit_names
|
||
|
||
palette.invalidate()
|
||
palette_kit_names = palette.list_kits()
|
||
|
||
labels = []
|
||
for kit_name in palette_kit_names:
|
||
version = palette.kit_version_label(kit_name)
|
||
labels.append(f"{kit_name} ({version})" if version else kit_name)
|
||
|
||
palette_kit_combo.config(values=labels)
|
||
|
||
for item in palette_tree.get_children():
|
||
palette_tree.delete(item)
|
||
|
||
if not palette_kit_names:
|
||
palette_status.config(
|
||
text="No kits found — check Sedona home" if app_state.SEDONA_HOME
|
||
else "Set Sedona home in Preferences"
|
||
)
|
||
palette_kit_var.set("")
|
||
return
|
||
|
||
palette_status.config(text=f"{len(palette_kit_names)} kits")
|
||
palette_kit_var.set("")
|
||
fit_tree_column(palette_tree)
|
||
|
||
|
||
|
||
# --- Palette drag and drop ---
|
||
# Tk has no drag-and-drop between widgets, so the gesture is assembled by hand:
|
||
# remember what was pressed, start dragging past a small threshold, follow the pointer
|
||
# with a borderless ghost window, and on release test the pointer against the canvas.
|
||
DRAG_THRESHOLD = 6
|
||
|
||
palette_drag = {"kit": None, "type": None, "origin": None, "active": False, "ghost": None}
|
||
|
||
|
||
def _destroy_drag_ghost():
|
||
if palette_drag["ghost"] is not None:
|
||
palette_drag["ghost"].destroy()
|
||
palette_drag["ghost"] = None
|
||
|
||
|
||
def _reset_palette_drag():
|
||
_destroy_drag_ghost()
|
||
palette_drag.update(kit=None, type=None, origin=None, active=False)
|
||
|
||
|
||
def on_palette_press(event):
|
||
item = palette_tree.identify_row(event.y)
|
||
if not item:
|
||
_reset_palette_drag()
|
||
return
|
||
|
||
palette_tree.selection_set(item)
|
||
index = palette_kit_combo.current()
|
||
if index < 0 or index >= len(palette_kit_names):
|
||
return
|
||
|
||
palette_drag.update(
|
||
kit=palette_kit_names[index],
|
||
type=palette_tree.item(item, "text"),
|
||
origin=(event.x_root, event.y_root),
|
||
active=False,
|
||
)
|
||
|
||
|
||
def on_palette_motion(event):
|
||
if palette_drag["type"] is None or palette_drag["origin"] is None:
|
||
return
|
||
|
||
origin_x, origin_y = palette_drag["origin"]
|
||
moved = abs(event.x_root - origin_x) + abs(event.y_root - origin_y)
|
||
|
||
if not palette_drag["active"]:
|
||
if moved < DRAG_THRESHOLD:
|
||
return
|
||
palette_drag["active"] = True
|
||
|
||
ghost = tk.Toplevel(window)
|
||
ghost.overrideredirect(True)
|
||
ghost.attributes("-topmost", True)
|
||
tk.Label(
|
||
ghost, text=f"{palette_drag['type']}",
|
||
bg="#dce6f2", fg="black", relief="solid", borderwidth=1,
|
||
padx=6, pady=2, font=("TkDefaultFont", 9),
|
||
).pack()
|
||
palette_drag["ghost"] = ghost
|
||
|
||
if palette_drag["ghost"] is not None:
|
||
palette_drag["ghost"].geometry(f"+{event.x_root + 12}+{event.y_root + 10}")
|
||
|
||
|
||
def _pointer_over_canvas(x_root, y_root):
|
||
left = canvas.winfo_rootx()
|
||
top = canvas.winfo_rooty()
|
||
return (left <= x_root < left + canvas.winfo_width()
|
||
and top <= y_root < top + canvas.winfo_height())
|
||
|
||
|
||
def on_palette_release(event):
|
||
was_dragging = palette_drag["active"]
|
||
kit_name = palette_drag["kit"]
|
||
type_name = palette_drag["type"]
|
||
_reset_palette_drag()
|
||
|
||
if not was_dragging or not kit_name or not type_name:
|
||
return
|
||
|
||
if not _pointer_over_canvas(event.x_root, event.y_root):
|
||
return
|
||
|
||
if app_state.xml_root_element is None:
|
||
messagebox.showwarning("Add Component", "Open a SAX file first.")
|
||
return
|
||
|
||
# Pointer position -> canvas coordinates -> grid units, snapped like a dragged box.
|
||
drop_x = canvas.canvasx(event.x_root - canvas.winfo_rootx())
|
||
drop_y = canvas.canvasy(event.y_root - canvas.winfo_rooty())
|
||
|
||
step = app_state.meta_scale * 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("<ButtonPress-1>", on_canvas_click)
|
||
canvas.bind("<B1-Motion>", on_canvas_drag)
|
||
canvas.bind("<ButtonRelease-1>", on_canvas_release)
|
||
canvas.bind("<Configure>", lambda e: wiresheet.draw_grid())
|
||
|
||
canvas.bind("<ButtonPress-3>", on_canvas_right_click)
|
||
canvas.bind("<ButtonPress-2>", on_canvas_right_click)
|
||
|
||
# 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)
|
||
|
||
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)
|
||
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()
|