A kits step after the zip is chosen, laid out as the Manage Kits panel: installed kits (what the app needs, greyed), installed but not used, kits available on the computer, not supported; the Action cell offers Keep at / Upgrade to / Downgrade to / Add at / REMOVE; Upgrade all kits to latest version. Kits not in the backup can be added, with their dependencies. The ticked kits make app.sab's schema (encode_sax takes the kit list, as Ontrol's PutApp rewrites it). When they are not the device's kits, or Rebuild kits.scode is ticked, kits.scode is built for them with Ontrol's sedonac from the Niagara install, after checking the natives the kits call against the device's platform manifest (PutApp.checkNativeCompatibility). - kit_build.py: platform manifest lookup, native check, sedonac run - palette.kit_versions: a checksum released more than once stands for its highest version (ontrolPlatform 1.0.1, not 1.0.0) - palette.unsupported_kits takes the kit list to judge against - sab_app: a type from a kit the app does not list is a clear error Tested on a P-ION: a rebuild for its own kits is byte-identical to the one it runs; adding and then removing the logic kit both come back running. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
264 lines
10 KiB
Python
264 lines
10 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.167"
|
|
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"
|
|
|
|
# Marks the tree row a palette drag would drop into.
|
|
TREE_DROP_TAG = "palette_drop_target"
|
|
|
|
# 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_canvas_tags = set() # Every selected box, the one above included; Ctrl+click adds
|
|
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
|
|
selected_link_tags = [] # Every selected link wire, in picking order; Ctrl+click adds.
|
|
# selected_link_tag is the last of these.
|
|
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 = ""
|
|
trace_dasp = False
|
|
|
|
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()
|
|
trace_dasp = config.getboolean("Editor", "trace_dasp", fallback=False)
|
|
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, trace_dasp
|
|
|
|
|
|
def save_preferences():
|
|
"""Writes the current preference values back to editor.properties.
|
|
|
|
The file is read first so the sections other code owns ([Device], [Palette]) survive.
|
|
"""
|
|
config = configparser.ConfigParser()
|
|
try:
|
|
config.read(PROPERTIES_FILE)
|
|
except Exception:
|
|
pass
|
|
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),
|
|
"trace_dasp": str(TRACE_DASP),
|
|
}
|
|
with open(PROPERTIES_FILE, "w") as configfile:
|
|
config.write(configfile)
|
|
|
|
|
|
def load_last_palette_kit():
|
|
"""The kit the palette dropdown showed last, or "" when none was ever chosen."""
|
|
config = configparser.ConfigParser()
|
|
try:
|
|
config.read(PROPERTIES_FILE)
|
|
return config.get("Palette", "kit", fallback="").strip()
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def save_last_palette_kit(kit_name):
|
|
"""Remembers the palette's kit so the next start, or a kit refresh, reopens it."""
|
|
global LAST_PALETTE_KIT
|
|
LAST_PALETTE_KIT = kit_name
|
|
config = configparser.ConfigParser()
|
|
try:
|
|
config.read(PROPERTIES_FILE)
|
|
if "Palette" not in config:
|
|
config["Palette"] = {}
|
|
config["Palette"]["kit"] = kit_name
|
|
with open(PROPERTIES_FILE, "w") as configfile:
|
|
config.write(configfile)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
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()
|
|
|
|
|
|
# ── Live device connection state ────────────────────────────────
|
|
live_mode = False
|
|
device_session = None # device_session.DeviceSession instance
|
|
device_host = ""
|
|
device_port = 1876
|
|
connection_status = "offline" # offline | connecting | connected | error
|
|
|
|
|
|
def base_window_title_live():
|
|
"""Window title for live mode."""
|
|
return f"Sedona Live Editor [v{VERSION}] — {device_host}:{device_port}"
|
|
|
|
|
|
SEDONA_HOME, GRID_SIZE, ADD_TIMESTAMP, BOX_WIDTH, LEFT_PANE_WIDTH, TRACE_DASP = load_preferences()
|
|
LAST_PALETTE_KIT = load_last_palette_kit()
|