"""Shared configuration, runtime state and widget handles. Every other module reaches this state as ``app_state.`` 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.102" 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 () 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 declares them # Kits a placed component needs that the file's 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 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.""" 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), "trace_dasp": str(TRACE_DASP), } 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() # ── 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()