Right-clicking a wire or a knob now offers Go to Link Wire Sheet "<name>", naming the sheet the far end is on -- the end a grey knob stands in for. A link with both ends on the open sheet has nowhere to go, so the entry is left off rather than greyed out. find_component_by_path turns a link's from/to reference back into the component it points at, the inverse of get_component_path, and the sheet is that component's parent; a parent of <app> is the file's root node, which the popup names after the file. Arriving selects the same link and scrolls it to the middle of the view. It is found by its element rather than by the tag it was picked on, since a repaint hands out fresh wire tags, and it waits for that repaint from an after_idle callback: ttk queues its selection event rather than dispatching it, so the sheet is not painted when the tree node is selected -- and painting a sheet clears the selected link, which was wiping the selection this feature had just made. The jump is an ordinary sheet opening, so Back returns to where the link was picked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
169 lines
6.6 KiB
Python
169 lines
6.6 KiB
Python
"""Shared configuration, runtime state and widget handles.
|
|
|
|
Every other module reaches this state as ``app_state.<name>`` rather than importing
|
|
the names directly. A plain ``from app_state import is_dirty`` would copy the value,
|
|
so later rebinding would only ever be seen by the module that did it.
|
|
"""
|
|
|
|
import configparser
|
|
import os
|
|
|
|
VERSION = "0.0.0.035"
|
|
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
|
|
DEFAULT_GRID_SIZE = 10
|
|
DEFAULT_ADD_TIMESTAMP = True
|
|
|
|
# Purely a display choice, so it lives in editor.properties. The SAX file has nowhere to
|
|
# record it: meta stores a position and nothing about box size.
|
|
DEFAULT_BOX_WIDTH = 100
|
|
MIN_ALLOWED_BOX_WIDTH = 60
|
|
MAX_ALLOWED_BOX_WIDTH = 400
|
|
|
|
# The tree and palette column is held at this width whatever the window does, so a wider
|
|
# window only ever gives the wiresheet more room. The sash can still be dragged; this is
|
|
# the width the column is set to at startup and whenever the preference changes.
|
|
DEFAULT_LEFT_PANE_WIDTH = 230
|
|
MIN_ALLOWED_LEFT_PANE_WIDTH = 120
|
|
MAX_ALLOWED_LEFT_PANE_WIDTH = 600
|
|
|
|
# Widget handles, filled in by main once the UI exists
|
|
window = None
|
|
canvas = None
|
|
tree = None
|
|
status_label = None # the window's bottom status bar
|
|
|
|
# Status bar tones. A hint that something needs attention later is worth a colour, so a
|
|
# placement that leaves the schema out of step is not read as a plain confirmation.
|
|
STATUS_INFO_COLOUR = "#333333"
|
|
STATUS_WARN_COLOUR = "#a04b00"
|
|
|
|
# Maps to hold tracking states
|
|
tree_element_map = {} # Treeview item ID -> ET.Element
|
|
canvas_comp_map = {} # Canvas item tag -> ET.Element
|
|
canvas_tag_by_element = {} # ET.Element -> Canvas unique tag string
|
|
component_slot_offsets = {} # Canvas unique tag -> {slot name: y offset from box top}
|
|
|
|
# File modification tracking state
|
|
is_dirty = False
|
|
current_file_path = None
|
|
active_components_list = [] # Tracks what components are actively painted
|
|
selected_canvas_tag = None # Tracks the currently clicked/highlighted canvas component tag
|
|
selected_tree_item_id = None # Tracks the currently right-clicked Treeview item
|
|
link_mark_element = None # Component marked as a link endpoint via "Link Mark"
|
|
selected_link_tag = None # Canvas tag of the currently selected (red-highlighted) link wire
|
|
unroutable_link_count = 0 # Links drawn as knobs because no wire could avoid a component
|
|
meta_scale = DEFAULT_GRID_SIZE # Pixels per meta coordinate step on the painted sheet
|
|
zoom = 1.0 # Scale the open sheet is painted at; 1.0 is one pixel per pixel
|
|
knob_link_ends = {} # wire tag -> (x1, y1, x2, y2) for a link shown as two knobs
|
|
canvas_link_map = {} # Canvas link tag -> ET.Element (<link>)
|
|
xml_root_element = None # Keep a persistent root reference to redraw tree layouts cleanly
|
|
current_sheet_parent = None # Element whose children are painted; None means the app root
|
|
|
|
# Drag-and-drop state variables
|
|
drag_data = {"x": 0, "y": 0, "tag": None}
|
|
|
|
# Global component slot registries compiled dynamically per file load
|
|
final_components_registry = {}
|
|
active_schema_kits = {} # kit name -> checksum, as the open file's <schema> declares them
|
|
|
|
# Kits a placed component needs that the file's <schema> does not declare yet, as
|
|
# kit name -> checksum resolved from Sedona home. Filled in when a palette component is
|
|
# placed and read back when saving, which is where the <schema> block is reconciled.
|
|
pending_schema_kits = {}
|
|
|
|
|
|
def load_preferences():
|
|
"""Loads application configurations from editor.properties or initializes defaults."""
|
|
config = configparser.ConfigParser()
|
|
grid_size = DEFAULT_GRID_SIZE
|
|
add_timestamp = DEFAULT_ADD_TIMESTAMP
|
|
box_width = DEFAULT_BOX_WIDTH
|
|
left_pane_width = DEFAULT_LEFT_PANE_WIDTH
|
|
sedona_home = ""
|
|
|
|
if os.path.exists(PROPERTIES_FILE):
|
|
try:
|
|
config.read(PROPERTIES_FILE)
|
|
grid_size = config.getint("Editor", "grid_size", fallback=DEFAULT_GRID_SIZE)
|
|
add_timestamp = config.getboolean("Editor", "add_timestamp", fallback=DEFAULT_ADD_TIMESTAMP)
|
|
box_width = config.getint("Editor", "box_width", fallback=DEFAULT_BOX_WIDTH)
|
|
left_pane_width = config.getint(
|
|
"Editor", "left_pane_width", fallback=DEFAULT_LEFT_PANE_WIDTH
|
|
)
|
|
sedona_home = config.get("Editor", "sedona.home", fallback="").strip()
|
|
except Exception:
|
|
pass
|
|
else:
|
|
config["Editor"] = {
|
|
"sedona.home": "",
|
|
"grid_size": str(DEFAULT_GRID_SIZE),
|
|
"add_timestamp": str(DEFAULT_ADD_TIMESTAMP),
|
|
"box_width": str(DEFAULT_BOX_WIDTH),
|
|
"left_pane_width": str(DEFAULT_LEFT_PANE_WIDTH)
|
|
}
|
|
try:
|
|
with open(PROPERTIES_FILE, "w") as configfile:
|
|
config.write(configfile)
|
|
except Exception:
|
|
pass
|
|
|
|
box_width = max(MIN_ALLOWED_BOX_WIDTH, min(MAX_ALLOWED_BOX_WIDTH, box_width))
|
|
left_pane_width = max(
|
|
MIN_ALLOWED_LEFT_PANE_WIDTH, min(MAX_ALLOWED_LEFT_PANE_WIDTH, left_pane_width)
|
|
)
|
|
return sedona_home, grid_size, add_timestamp, box_width, left_pane_width
|
|
|
|
|
|
def save_preferences():
|
|
"""Writes the current preference values back to editor.properties."""
|
|
config = configparser.ConfigParser()
|
|
config["Editor"] = {
|
|
"sedona.home": SEDONA_HOME,
|
|
"grid_size": str(GRID_SIZE),
|
|
"add_timestamp": str(ADD_TIMESTAMP),
|
|
"box_width": str(BOX_WIDTH),
|
|
"left_pane_width": str(LEFT_PANE_WIDTH)
|
|
}
|
|
with open(PROPERTIES_FILE, "w") as configfile:
|
|
config.write(configfile)
|
|
|
|
|
|
def base_window_title():
|
|
"""The window title with no file context, which handlers extend with their own suffix."""
|
|
return f"Sedona SAX Offline Program Editor [v{VERSION}]"
|
|
|
|
|
|
def set_status(message, warn=False):
|
|
"""Posts a one-line note to the status bar. A no-op before the UI exists."""
|
|
if status_label is None:
|
|
return
|
|
status_label.config(
|
|
text=message,
|
|
foreground=STATUS_WARN_COLOUR if warn else STATUS_INFO_COLOUR
|
|
)
|
|
|
|
|
|
def clear_status():
|
|
"""Empties the status bar, for when the note no longer describes anything on screen."""
|
|
set_status("")
|
|
|
|
|
|
def mark_dirty():
|
|
"""Single funnel for 'the document changed', including the unsaved-state tint."""
|
|
global is_dirty
|
|
if not is_dirty:
|
|
is_dirty = True
|
|
if canvas is not None:
|
|
canvas.config(bg="#ffe6e6")
|
|
|
|
|
|
def mark_clean():
|
|
"""Clears the unsaved-state marker after a successful save or a fresh load."""
|
|
global is_dirty
|
|
is_dirty = False
|
|
if canvas is not None:
|
|
canvas.config(bg="white")
|
|
|
|
|
|
SEDONA_HOME, GRID_SIZE, ADD_TIMESTAMP, BOX_WIDTH, LEFT_PANE_WIDTH = load_preferences()
|