Files
sedona_editor/app_state.py
T
ArdaandClaude Opus 5 b119e02b9e Link View: order the peers so the wires stop crossing
The picture was right and unreadable: eleven links round one component
drew twelve wire crossings, because each column was stacked in the order
the file happens to list its links and the lanes were handed out by how
far a wire had to travel.

Both columns are now stacked in the order of the chosen component's own
slots -- what feeds its first slot at the top, what feeds its fifth below
that. Nothing here is read from meta, so the view was always free to put
the peers where they read best, and this is the order that turns the set
of wires on one side into a ladder: both ends run the same way down the
page, so no wire has any reason to cross another.

That leaves which lane each wire turns in. For a ladder there is an order
that cannot produce a crossing, and read off the slots rather than off the
direction the line is drawn in, it is the same on both sides -- two fans,
each nesting outwards from the component's own row:

    reaching a peer slot above its focus slot -> turns nearer the focus
    the higher up the focus it lands

    reaching a peer slot below its focus slot -> turns nearer the focus
    the lower down the focus it lands

Two wires of one fan, the outer going round the inner, pass above and
below its vertical but never through it; and a wire of one fan can never
share a stretch of the page with a wire of the other, so the two fans
reuse each other's lanes and the gap stays as narrow as the picture
allows. Where several wires leave the same slot no order of focus heights
separates them, so the longest branch turns first and the rest carry on
along the shared run beside the pin -- a wiresheet's own fan-out. Where
two slots already line up the wire is one straight line and is settled
before anything has to be fitted past it; the layout is rounded to whole
pixels so that a pair that ought to line up does.

The reasoning is not trusted on its own: every wire is checked against the
ones already routed (routes_meet) and takes the next lane out if it
clashes. One thing genuinely breaks the ladder -- a peer whose own slots
run counter to the focus's, linking its row 4 to row 3 and its row 3 to
row 4. Ends inverted like that have to cross wherever they are drawn,
since neither box's slot order is this view's to change.

Measured over 461 linked components in four real files, 1113 wires: no
wire passes through a box at all, and ten crossings remain in six
components, every one of them an inverted pair of that kind. Before this
the same four files drew 45.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 19:17:20 +03:00

208 lines
8.5 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.056"
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
# Called whenever "is there anything unwritten" may have changed, so the file name on
# the tree can follow it. main registers it; anything before that is a no-op.
dirty_display_callback = None
# 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
# The unsaved-state tint. The wiresheet paints it on the canvas and the Properties view
# paints it on itself, so "there is something here you have not written yet" is one
# colour wherever the change was made.
DIRTY_TINT = "#ffe6e6"
CLEAN_TINT = "white"
# The file name on the tree's root row turns this colour while anything is unwritten,
# which is the one place a file name appears on screen -- a Tk window title cannot be
# coloured.
#
# The tag's colour is configured once and never changed; what moves is whether the row
# carries the tag. Reconfiguring a tag does not reliably repaint rows that are already
# drawn -- which is why the name only ever went red on the paths that happened to rebuild
# the tree, and stayed black after a box was dragged. Setting a row's tags repaints that
# row, so the two states cannot drift apart.
DIRTY_NAME_COLOUR = "#c00000"
DIRTY_NAME_TAG = "unsaved_file"
# A themed Treeview maps the selected state to SystemHighlightText, and that mapping beats
# a row tag's own foreground -- so the file name turned white-on-blue the moment its row
# was selected, which is exactly when the root sheet is the one being looked at. The
# navigation tree therefore runs on a style of its own that drops the selected foreground
# and picks a light selection background instead, so a tag's colour shows in every state
# and black text still reads on the highlight.
NAV_TREE_STYLE = "Nav.Treeview"
NAV_SELECTION_BG = "#cce4f7"
# 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 refresh_dirty_display():
"""Nudges whatever shows 'there is something unwritten'. A no-op before the UI exists."""
if dirty_display_callback is not None:
dirty_display_callback()
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=DIRTY_TINT)
refresh_dirty_display()
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=CLEAN_TINT)
refresh_dirty_display()
SEDONA_HOME, GRID_SIZE, ADD_TIMESTAMP, BOX_WIDTH, LEFT_PANE_WIDTH = load_preferences()