"""The Properties view: every slot of one component, shown, edited and written back. It takes over the editor pane rather than floating above it, so the sheet and the slot table are the same piece of screen and only one of them is ever the thing being looked at. `main` is what hides the canvas and puts this in its place; this module knows only the frame it was handed. The one slot it does filter is `@hidden`, and only by default: niagara.html says user interfaces should rarely display hidden slots, and every one under Sedona home is a `void` action a kit uses internally. A checkbox in the footer brings them back, and the choice sticks for the session. The wiresheet box hides most of a type's slots — `HvacControl` paints 8 of its 47 — which is right for a diagram and wrong for the one place a value is meant to be changed. This view is that place, so it filters nothing: every slot the manifest declares gets a row, numbered with the same manifest slot id the box uses, so the two agree about what row 14 is. Which rows are editable is a property of the slot, not of the view: config (flags 'c') and not '@readonly' -> editable, white field everything else -> read-only, grey field Runtime slots hold live values that no offline file carries, so nothing here can usefully write one; a '@readonly' config slot is the manifest saying the same thing about a value that is persisted. Both read the same way on screen — grey means "look, do not touch". Nothing is written until Save, which pushes every changed slot as one undoable step. Refresh throws away pending edits and re-reads the DOM. """ import tkinter as tk from tkinter import font as tkfont, messagebox, ttk import app_state import schema import undo from sax_dom import (get_component_path, read_meta_property, set_prop_value, update_meta_groups, update_meta_property) # Sedona slot types, grouped by how a value of that type is parsed and bounded. INTEGER_TYPES = {"byte", "short", "int", "long"} FLOAT_TYPES = {"float", "double"} STRING_TYPES = {"sys::Buf", "str", "Str"} # What an integer type can hold. Every one of these bounds what Save will accept. TYPE_RANGES = { "byte": (0, 255), "short": (0, 65535), "int": (-2147483648, 2147483647), "long": (-9223372036854775808, 9223372036854775807), } # ...but only these two are worth *printing* as a range when the manifest declared none. # 'byte' and 'short' are narrow unsigned types, so [0 - 255] and [0 - 65535] tell a reader # something real. An undeclared 'int' or 'long' would print its full two's-complement # span, which says no more than "a whole number" and crowds the row to say it. IMPLICIT_RANGE_TYPES = {"byte", "short"} # Unit facets carry Niagara's unit names; these are the ones the kits under Sedona home # actually use. An unknown name is shown verbatim rather than dropped, so a kit adding a # unit still reads sensibly. UNIT_SYMBOLS = { "celsius": "°C", "fahrenheit": "°F", "kelvin": "K", "second": "s", "millisecond": "ms", "nanosecond": "ns", "minute": "min", "hour": "h", "percent": "%", "percent_relative_humidity": "%RH", "pascal": "Pa", "ohm": "Ω", "volt": "V", "hertz": "Hz", "lux": "lx", "degrees_angular": "°", "cubic_meters_per_hour": "m³/h", "kilojoules_per_kilogram": "kJ/kg", "grams_per_kilogram": "g/kg", "grams_per_cubic_meter": "g/m³", "per_second": "/s", "per_minute": "/min", } # Row colours. The grey field is the whole point of the read-only rule, so it is a real # fill rather than a disabled widget's platform default, which on Windows is nearly white. EDITABLE_FIELD_BG = "#ffffff" READONLY_FIELD_BG = "#e8e8dc" LABEL_COLOUR = "#000000" HEADING_COLOUR = "#6b6b6b" READONLY_TEXT = "#444444" ROW_BG = "#ffffff" HEADER_BG = "#f2f2f2" TYPE_COLOUR = "#555555" PATH_COLOUR = "#7a7a7a" # A bool says which state it is in by the colour of its field. Filled rather than # lettered: a block of colour carries down a long list of slots where coloured text does # not, and the words stay black so they read as easily as every other value. BOOL_BACKGROUNDS = { "true": "#c8e6c9", "false": "#ffcdd2", "null": "#bbdefb", } def bool_state(raw): """Which of the three a value is in: 'true', 'false' or 'null'.""" if is_null(raw): return "null" return "true" if str(raw).strip().lower() in ("true", "1") else "false" FIELD_WIDTH = 18 # characters; the entry and the combobox share it so fields line up ROW_HEIGHT_HINT = 22 # What a .sax writes for a null value, and what an editor has to accept back. Sedona # models null as NaN for a float and a sentinel for the integer types, and a config slot # can genuinely hold it: control::WriteFloat pairs an 'in' property with a 'setNull' # action, and the sample app carries on four of them. # Whether hidden slots are listed. Held for the session rather than per panel, so the # answer does not have to be given again for every component looked at. show_hidden_slots = False NULL_VALUE = "null" NULL_TEXT = "null" # --- slot reading ------------------------------------------------------------------- def unit_symbol(unit_name): """The symbol a unit facet is shown as, or the raw name when it is not a known one.""" if not unit_name: return "" return UNIT_SYMBOLS.get(unit_name, unit_name) def humanise(slot_name): """'controlSignalForMin_1' -> 'Control Signal For Min _1', the label style the rows use. camelCase becomes spaced words and a trailing '_1' is split off as its own token, so the five `_1`.. `_5` variants of a slot line up down the column. Only the first letter of a word is touched, which leaves 'in1' as 'In1' and any acronym as it was written. """ if not slot_name: return "" words = [] current = "" for char in slot_name: if char == "_": if current: words.append(current) current = "_" elif char.isupper() and current and not current.startswith("_"): words.append(current) current = char else: current += char if current: words.append(current) return " ".join(word[:1].upper() + word[1:] for word in words) def qualified_type(slot_type): """'int' -> 'sys::int', the way a slot sheet names it. A manifest writes the primitives bare and everything else already qualified, so the only kit that ever has to be supplied is the one they all come from. """ if not slot_type: return "" return slot_type if "::" in slot_type else f"sys::{slot_type}" def enum_range(slot): """The named values a '@range' facet declares, or () for a slot that is not an enum.""" raw = slot["facets"].get("range") if not isinstance(raw, str) or not raw.strip(): return () return tuple(part.strip() for part in raw.split(",")) def type_zero(slot_type): """The value a slot holds when neither the file nor the manifest names one.""" if slot_type == "bool": return "false" if slot_type in INTEGER_TYPES: return "0" if slot_type in FLOAT_TYPES: return "0.0" if slot_type in STRING_TYPES: return "" return None def is_null(raw): """Whether a value is Sedona's null, as an absent prop or as the literal it writes.""" return raw is None or str(raw).strip().lower() == NULL_VALUE def null_text(slot): """What null reads as on this slot, honouring '@nullText'. ontrolBaseDriver::DeviceNetwork.status declares '@nullText="Disabled"', which is the facet's whole purpose: a bool that is neither true nor false still has to say something, and the kit author is the one who knows what. """ declared = slot["facets"].get("nullText") return str(declared) if isinstance(declared, str) and declared else NULL_TEXT def allows_null(slot): """Whether the editor may set this slot to null, per '@allowNull'. The facet is only ever written as false in these kits, to stop a bool being cleared. Absent means allowed, which has to be the default: a file can already hold null on a slot that declares nothing, and refusing to write back what was read would be worse. """ return slot["facets"].get("allowNull") is not False def stored_value(comp, slot): """What the file says this slot holds, or None when it carries no for it.""" prop = comp.find(f"prop[@name='{slot['name']}']") return prop.get("val", "") if prop is not None else None def effective_value(comp, slot): """The value to show: the file's, else the manifest default, else the type's zero. Runtime slots are never persisted, so most of them fall through to a default here. Showing that rather than 'null' is the honest reading offline — it is what the device starts from — and the row is grey either way, so nothing invites it to be mistaken for a live reading. """ raw = stored_value(comp, slot) if raw is not None: return raw if slot["default"] is not None: return slot["default"] return type_zero(slot["type"]) def is_hidden(slot): """The '@hidden' facet: a slot a kit keeps for itself. niagara.html puts it plainly -- hidden slots "are designed to be invisible to the user", and interfaces should rarely display them. Every one under Sedona home is a void action such as `HdwAI.checkType`. """ return slot["facets"].get("hidden") is True def is_editable(slot): """Config and not read-only. The rule the whole view's greying comes from.""" return "c" in slot["flags"] and not slot["readonly"] def range_text(slot): """The '[min - max]' suffix, or '' for a slot whose bounds are not worth stating. A declared '@min' or '@max' is always printed. With neither declared, only a narrow unsigned type fills the gap from the type itself — a 'short' reads [0 - 65535], which is worth knowing. An 'int', a 'long' or a float would print a span that means "any number", so those rows print nothing at all rather than a bound nobody can hit. This is display only: value_bounds still holds every integer to its type, so Save rejects an out-of-range int whether or not its row advertised the limit. """ slot_type = slot["type"] facets = slot["facets"] declared_min = facets.get("min") declared_max = facets.get("max") has_declared = (isinstance(declared_min, (int, float)) or isinstance(declared_max, (int, float))) if slot_type in INTEGER_TYPES: if not has_declared and slot_type not in IMPLICIT_RANGE_TYPES: return "" # An end the manifest left open is filled from the type only where that bound is # narrow enough to mean something; on an int it reads as open, the way a float does. # 'IRamp.secs' declares a min and no max, and [1 - 2147483647] would be all noise # on the right. type_min, type_max = TYPE_RANGES[slot_type] if slot_type not in IMPLICIT_RANGE_TYPES: type_min, type_max = "-inf", "+inf" low = _trim_number(declared_min) if isinstance(declared_min, (int, float)) else type_min high = _trim_number(declared_max) if isinstance(declared_max, (int, float)) else type_max return f"[{_trim_number(low)} - {_trim_number(high)}]" if slot_type in FLOAT_TYPES: if not has_declared: return "" low = _trim_number(declared_min) if isinstance(declared_min, (int, float)) else "-inf" high = _trim_number(declared_max) if isinstance(declared_max, (int, float)) else "+inf" return f"[{low} - {high}]" if slot_type in STRING_TYPES and has_declared: # On a Str these two count characters, not magnitude, and the count is what the # Buf can actually hold: a Buf(17) declares @max=16, the seventeenth byte being # the null terminator. if isinstance(declared_min, (int, float)) and isinstance(declared_max, (int, float)): return f"[{_trim_number(declared_min)} - {_trim_number(declared_max)} chars]" if isinstance(declared_max, (int, float)): return f"[max {_trim_number(declared_max)} chars]" return f"[min {_trim_number(declared_min)} chars]" return "" def _trim_number(value): """'0.1' stays '0.1' and '1000.0' becomes '1000', so a range reads the way it is meant.""" if isinstance(value, float) and value.is_integer(): return str(int(value)) return str(value) def value_bounds(slot): """(low, high) a value must fall inside: magnitude for a number, length for a Str.""" facets = slot["facets"] low = facets.get("min") high = facets.get("max") if slot["type"] in INTEGER_TYPES: type_min, type_max = TYPE_RANGES[slot["type"]] low = low if isinstance(low, (int, float)) else type_min high = high if isinstance(high, (int, float)) else type_max if not isinstance(low, (int, float)): low = None if not isinstance(high, (int, float)): high = None return low, high def bool_texts(slot): """The two words a bool slot is shown as, honouring '@trueText' and '@falseText'.""" facets = slot["facets"] true_text = facets.get("trueText") or "true" false_text = facets.get("falseText") or "false" return str(false_text), str(true_text) def display_text(slot, raw): """The value as it appears in its field, formatted by the slot's own facets.""" slot_type = slot["type"] if slot_type == "void": return "action" if is_null(raw): return null_text(slot) if slot_type == "bool": false_text, true_text = bool_texts(slot) return true_text if str(raw).strip().lower() in ("true", "1") else false_text names = enum_range(slot) if names and slot_type in INTEGER_TYPES: try: index = int(raw) except (TypeError, ValueError): return str(raw) return names[index] if 0 <= index < len(names) else str(raw) if slot_type in FLOAT_TYPES: try: number = float(raw) except (TypeError, ValueError): return str(raw) precision = slot["facets"].get("precision") if isinstance(precision, int) and 0 <= precision <= 12: return f"{number:.{precision}f}" return str(number) if slot_type in INTEGER_TYPES: try: return str(int(raw)) except (TypeError, ValueError): return str(raw) return str(raw) def parse_text(slot, text): """Turns a field's text back into the string a carries. Returns (raw, error): exactly one of the two is None. Errors name the slot's own wording rather than Python's, since it is the manifest's rule that was broken. """ slot_type = slot["type"] text = text.strip() # Null is checked ahead of every type, because it is a value each of them can hold # rather than a failure to parse one. A slot that declares '@allowNull false' is the # one place it is refused. if text.lower() == NULL_VALUE or (text and text == null_text(slot)): if allows_null(slot): return NULL_VALUE, None return None, "this slot cannot be null" if slot_type == "bool": false_text, true_text = bool_texts(slot) if text == true_text: return "true", None if text == false_text: return "false", None return None, f"expected '{true_text}' or '{false_text}'" names = enum_range(slot) if names and slot_type in INTEGER_TYPES: if text in names: return str(names.index(text)), None return None, f"expected one of: {', '.join(names)}" low, high = value_bounds(slot) if slot_type in INTEGER_TYPES: try: number = int(text, 0) if text.lower().startswith(("0x", "-0x")) else int(text) except ValueError: return None, "expected a whole number" if low is not None and number < low: return None, f"below the minimum of {_trim_number(low)}" if high is not None and number > high: return None, f"above the maximum of {_trim_number(high)}" return str(number), None if slot_type in FLOAT_TYPES: try: number = float(text) except ValueError: return None, "expected a number" if low is not None and number < low: return None, f"below the minimum of {_trim_number(low)}" if high is not None and number > high: return None, f"above the maximum of {_trim_number(high)}" return str(number), None if slot_type in STRING_TYPES: # min/max count characters here. Overrunning the Buf is not a display problem: # the device gets a string it has no room for. if isinstance(high, (int, float)) and len(text) > high: return None, f"longer than {_trim_number(high)} characters" if isinstance(low, (int, float)) and len(text) < low: return None, f"shorter than {_trim_number(low)} characters" return text, None return None, "this slot holds no editable value" # Both halves of meta's position are one byte of the packed integer, so this is the # range the editor accepts — not the sheet's extent, which is this times the grid step. META_COORD_MIN = 0 META_COORD_MAX = 255 def group_label(flags): """The 'Group [1,3]' text, from the four user-group bits in order.""" active = [str(index + 1) for index, on in enumerate(flags) if on] return f"Group [{','.join(active)}]" if active else "Group []" def meta_summary(comp): """meta unpacked into what its row and its editor need: (label, groups, (x, y)).""" meta_prop = comp.find("prop[@name='meta']") meta_str = meta_prop.get("val") if meta_prop is not None else None raw_x, raw_y, _reserved, *flags_and_rest = read_meta_property(meta_str) flags = list(flags_and_rest[:4]) return group_label(flags), flags, (raw_x, raw_y) # --- the panel ---------------------------------------------------------------------- class PropertiesPanel: """One component's slots, in a scrolling list of rows, with Save, Refresh and Close. Builds into a frame the caller supplies and does nothing with it afterwards: packing that frame where the canvas was, and putting the canvas back on Close, is `main`'s business. Nothing here reaches for the canvas or the window. Rows are built once and then only ever re-read: Refresh writes into the existing widgets rather than rebuilding them, so the scroll position survives it. Each row remembers the text it was last loaded with, and Save considers only the rows whose text has moved away from that — a component keeps exactly the props the file gave it plus the ones actually edited here. """ def __init__(self, parent, comp, on_change=None, on_close=None, on_dirty_change=None): self.comp = comp self.on_change = on_change self.on_close = on_close self.on_dirty_change = on_dirty_change self.rows = [] self.meta_row = None # Widgets whose background follows the unsaved-state tint, each with the colour # it wears when there is nothing pending. The fields themselves are left out: a # value's own white or grey says whether it can be typed into, which is a # different thing from whether it has been. self._tint_targets = [] self.dirty = False # Set while rows are being filled from the document. An OptionMenu reports through # its variable, so loading a value looks exactly like choosing one; without this # the view would open already claiming unsaved edits. self._loading = False comp_type = comp.get("type", "sys::Component") self.slots = schema.get_full_slots_for_type(comp_type) self.frame = tk.Frame(parent) # The footer is packed before the rows area and against the bottom: pack hands out # leftover space in order, so an expanding area packed first leaves a later # sibling nothing at all. self._build_header(comp_type) self._build_footer() self._loading = True self._build_rows_area() self._loading = False self._apply_hidden_filter() self._load_path() self._update_tint() if not self.slots: self.status_var.set( f"No manifest slots resolved for {comp_type}. Check Sedona home folder." ) # -- the unsaved-state tint -- def _tinted(self, widget, normal_background): """Registers a widget to follow the tint, and hands it straight back.""" self._tint_targets.append((widget, normal_background)) return widget def has_unsaved_changes(self): """Whether any row has moved away from what was last loaded or saved. Read from the widgets rather than tracked as a flag, so typing a value and then typing it back leaves the view clean — exactly the rows Save would write. """ for row in self.rows: if not row["editable"]: continue if (self.row_text(row) or "").strip() != row["loaded_text"].strip(): return True if self.meta_row is not None: if self.meta_row["groups"] != self.meta_row["loaded_groups"]: return True if self.meta_row["position"] != self.meta_row["loaded_position"]: return True return False def _update_tint(self): """Repaints the view in the same colour the canvas wears when the file is dirty.""" if self._loading: return was_dirty = self.dirty self.dirty = self.has_unsaved_changes() for widget, normal_background in self._tint_targets: if not widget.winfo_exists(): continue widget.configure( bg=app_state.DIRTY_TINT if self.dirty else normal_background ) # Pending edits here are unwritten too, so the file name has to know about them # even though nothing has reached the document yet. if was_dirty != self.dirty and self.on_dirty_change is not None: self.on_dirty_change() def _watch_for_edits(self, row, widget, kind): """Makes a field repaint the view as soon as its value moves. `kind` is the widget's, not the slot's: an editable bool is a dropdown, a read-only one is a plain field, and the two report a change differently. """ if kind == "text": widget.bind("", lambda _event: self._update_tint(), add="+") widget.bind("<>", lambda _event: self._update_tint(), add="+") widget.bind("", lambda _event: self._update_tint(), add="+") return # An OptionMenu reports through its variable rather than an event, which also # catches a value set from anywhere else. row["var"].trace_add("write", lambda *_args: self._on_choice_changed(row)) def _on_choice_changed(self, row): # A bool's fill is its value, so picking the other one has to recolour the field # straight away rather than waiting for the next load. if row["kind"] == "bool": self._recolour_bool(row) self._update_tint() def _recolour_bool(self, row): """Fills a bool row's field by which of the three states it now reads as.""" slot = row["slot"] text = (self.row_text(row) or "").strip() false_text, true_text = bool_texts(slot) if text == true_text: state = "true" elif text == false_text: state = "false" else: state = "null" fill = BOOL_BACKGROUNDS[state] row["widget"].configure(bg=fill, activebackground=fill) # -- construction -- def _build_header(self, comp_type): header = self.header_frame = tk.Frame(self.frame, padx=8, pady=6, bg=HEADER_BG) header.pack(fill=tk.X) self._tinted(header, HEADER_BG) name_font = tkfont.Font(family="Segoe UI", size=9, weight="bold") type_font = tkfont.Font(family="Segoe UI", size=9) self._tinted(tk.Label(header, text=self.comp.get("name", "?"), font=name_font, bg=HEADER_BG), HEADER_BG).pack(side=tk.LEFT) self._tinted(tk.Label(header, text=f"({comp_type})", font=type_font, fg="#555555", bg=HEADER_BG), HEADER_BG).pack(side=tk.LEFT, padx=(8, 0)) # The path is what a names this component by, and the one thing on screen # that says which of four identically named boxes this is. It is read from the # document rather than held, so a renamed parent shows up on the next Refresh. self.path_var = tk.StringVar() self._tinted(tk.Label(header, textvariable=self.path_var, font=type_font, fg=PATH_COLOUR, bg=HEADER_BG, anchor="w"), HEADER_BG).pack(side=tk.LEFT, padx=(16, 0)) def _build_rows_area(self): """A canvas-scrolled frame; ttk has no scrollable container of its own. The rows are laid out at their natural width and never stretched to the window: every widget is gridded straight into one frame, so grid's own column sizing is what aligns the four columns across every row, and no column carries a weight. A wider window therefore shows more white to the right of the table rather than walking the fields across the pane; a narrower one scrolls sideways instead of squeezing them. """ area = tk.Frame(self.frame) area.pack(fill=tk.BOTH, expand=True) area.grid_rowconfigure(0, weight=1) area.grid_columnconfigure(0, weight=1) self.scroll_canvas = tk.Canvas(area, highlightthickness=0, bg=ROW_BG) self.scroll_canvas.grid(row=0, column=0, sticky="nsew") self._tinted(self.scroll_canvas, ROW_BG) self.vertical_bar = ttk.Scrollbar(area, orient="vertical", command=self.scroll_canvas.yview) self.vertical_bar.grid(row=0, column=1, sticky="ns") self.horizontal_bar = ttk.Scrollbar(area, orient="horizontal", command=self.scroll_canvas.xview) self.horizontal_bar.grid(row=1, column=0, sticky="ew") # Each bar is shown only while it has somewhere to scroll, so a pane wide enough # for its table carries neither. self.scroll_canvas.configure( yscrollcommand=lambda first, last: self._sync_bar( self.vertical_bar, first, last ), xscrollcommand=lambda first, last: self._sync_bar( self.horizontal_bar, first, last ), ) self.rows_frame = self._tinted(tk.Frame(self.scroll_canvas, bg=ROW_BG), ROW_BG) self.rows_window = self.scroll_canvas.create_window( (0, 0), window=self.rows_frame, anchor="nw" ) self.rows_frame.bind( "", lambda _e: self.scroll_canvas.configure( scrollregion=self.scroll_canvas.bbox("all") ), ) # Bound on this frame, not bind_all: the wiresheet canvas has wheel bindings of # its own, and an application-wide one here would scroll this list from there. self.frame.bind("", self._on_wheel) self.frame.bind("", self._on_shift_wheel) self._build_header_row() # Slots start below the header and its rule. for grid_row, slot in enumerate(self.slots, start=2): if slot["name"] == "meta": self._build_meta_row(grid_row, slot) else: self._build_slot_row(grid_row, slot) def _build_header_row(self): """Names the columns. Deliberately quiet -- the same size as the rows, in grey, over a hairline rule -- so it labels the table without competing with it. """ heading_font = tkfont.Font(family="Segoe UI", size=8, weight="bold") headings = ((1, "Name"), (2, "Type"), (3, "Value"), (4, "Unit")) for column, text in headings: label = self._tinted(tk.Label(self.rows_frame, text=text, anchor="w", bg=ROW_BG, fg=HEADING_COLOUR, font=heading_font), ROW_BG) label.grid(row=0, column=column, sticky="w", padx=(2, 8), pady=(4, 2)) rule = self._tinted(tk.Frame(self.rows_frame, height=1, bg="#c8c8c8"), "#c8c8c8") rule.grid(row=1, column=0, columnspan=5, sticky="ew", pady=(0, 2)) def _row_label(self, slot): """'14. Proportional Band' — the manifest slot id, so rows match the box's numbering.""" return f"{slot['slot_id']:02d}. {humanise(slot['name'])}" def _add_bullet(self, grid_row): """The small ring each row is tagged with, drawn rather than set in a glyph font.""" dot = self._tinted(tk.Canvas(self.rows_frame, width=14, height=ROW_HEIGHT_HINT, highlightthickness=0, bg=ROW_BG), ROW_BG) # Unfilled, so the ring shows whatever the canvas behind it is currently painted. dot.create_oval(3, 8, 12, 17, outline="#8a8a8a", width=1, fill="") dot.grid(row=grid_row, column=0, padx=(6, 2)) return dot def _apply_hidden_filter(self): """Takes hidden rows out of the grid, or puts them back. grid_remove keeps each widget's options, so putting a row back needs no arithmetic, and an emptied grid row collapses to nothing rather than leaving a gap where the slot was. """ for row in self.rows: if not is_hidden(row["slot"]): continue for widget in row["widgets"]: if show_hidden_slots: widget.grid() else: widget.grid_remove() self.scroll_canvas.configure(scrollregion=self.scroll_canvas.bbox("all")) def _build_slot_row(self, grid_row, slot): bullet = self._add_bullet(grid_row) name_label = self._tinted(tk.Label(self.rows_frame, text=self._row_label(slot), anchor="w", bg=ROW_BG, fg=LABEL_COLOUR), ROW_BG) name_label.grid(row=grid_row, column=1, sticky="w", padx=(2, 8)) type_label = self._tinted(tk.Label(self.rows_frame, text=qualified_type(slot["type"]), anchor="w", bg=ROW_BG, fg=TYPE_COLOUR), ROW_BG) type_label.grid(row=grid_row, column=2, sticky="w", padx=(0, 10)) field_frame = self._tinted(tk.Frame(self.rows_frame, bg=ROW_BG), ROW_BG) field_frame.grid(row=grid_row, column=3, sticky="w", padx=(0, 4)) editable = is_editable(slot) row = {"slot": slot, "editable": editable, "kind": None, "widget": None, "loaded_text": "", "widgets": [bullet, name_label, type_label, field_frame]} names = enum_range(slot) if slot["type"] == "bool": row["kind"] = "bool" elif names and slot["type"] in INTEGER_TYPES: row["kind"] = "enum" else: row["kind"] = "text" # A dropdown is offered only where there is a choice to make: a disabled one on a # runtime slot is an arrow that does nothing. row["is_choice"] = editable and row["kind"] in ("bool", "enum") if row["is_choice"]: if row["kind"] == "bool": false_text, true_text = bool_texts(slot) choices = [false_text, true_text] # '@allowNull false' is the manifest saying this bool is only ever one of # the two, so null is offered on every other bool and withheld here. if allows_null(slot): choices.append(null_text(slot)) else: choices = list(names) # An OptionMenu rather than a ttk.Combobox, for one reason: the themed # Combobox draws its field with a native element and ignores fieldbackground # outright, so a bool could never be filled with its colour. This is a plain # Tk widget and takes the colour it is given. row["var"] = tk.StringVar() widget = tk.OptionMenu(field_frame, row["var"], *choices) widget.configure(width=FIELD_WIDTH - 5, anchor="w", relief="solid", bd=1, highlightthickness=0, padx=4, pady=0, bg=EDITABLE_FIELD_BG, activebackground=EDITABLE_FIELD_BG) widget["menu"].configure(bg=EDITABLE_FIELD_BG) else: row["var"] = None widget = tk.Entry(field_frame, width=FIELD_WIDTH, relief="solid", bd=1, highlightthickness=0, justify="left", readonlybackground=READONLY_FIELD_BG) widget.configure(bg=EDITABLE_FIELD_BG if editable else READONLY_FIELD_BG) widget.grid(row=0, column=1) row["widget"] = widget if editable: self._watch_for_edits(row, widget, "choice" if row["is_choice"] else "text") suffix = " ".join(part for part in (unit_symbol(slot["facets"].get("unit")), range_text(slot)) if part) suffix_label = self._tinted(tk.Label(self.rows_frame, text=suffix, anchor="w", bg=ROW_BG, fg="#555555"), ROW_BG) suffix_label.grid(row=grid_row, column=4, sticky="w", padx=(6, 6)) row["widgets"].append(suffix_label) self.rows.append(row) self._load_row(row) def _build_meta_row(self, grid_row, slot): """meta is one integer holding a position and four group bits, so it gets its own row. Typing 822411269 into a field is no way to set either of them, so the '»' opens the two apart: the groups as checkboxes and the position as the x and y a drag would otherwise be the only way to set. """ self._add_bullet(grid_row) self._tinted(tk.Label(self.rows_frame, text=self._row_label(slot), anchor="w", bg=ROW_BG, fg=LABEL_COLOUR), ROW_BG).grid(row=grid_row, column=1, sticky="w", padx=(2, 8)) self._tinted(tk.Label(self.rows_frame, text=qualified_type(slot["type"]), anchor="w", bg=ROW_BG, fg=TYPE_COLOUR), ROW_BG).grid(row=grid_row, column=2, sticky="w", padx=(0, 10)) field_frame = self._tinted(tk.Frame(self.rows_frame, bg=ROW_BG), ROW_BG) field_frame.grid(row=grid_row, column=3, sticky="w", padx=(0, 4)) summary_var = tk.StringVar() self._tinted(tk.Label(field_frame, textvariable=summary_var, bg=ROW_BG, fg="#1f4e79", anchor="w", width=FIELD_WIDTH - 3), ROW_BG).grid(row=0, column=0, sticky="w") self._tinted(tk.Button(field_frame, text="»", width=2, relief="flat", bg=ROW_BG, fg="#1f4e79", command=self._open_meta_editor), ROW_BG).grid(row=0, column=1) self.meta_position_var = tk.StringVar() self._tinted(tk.Label(self.rows_frame, textvariable=self.meta_position_var, bg=ROW_BG, fg="#555555"), ROW_BG).grid(row=grid_row, column=4, sticky="w", padx=(6, 6)) self.meta_row = {"slot": slot, "summary_var": summary_var, "groups": [False, False, False, False], "loaded_groups": [False, False, False, False], "position": (0, 0), "loaded_position": (0, 0)} self._load_meta_row() def _build_footer(self): footer = self.footer_frame = tk.Frame(self.frame, padx=8, pady=6) footer.pack(side=tk.BOTTOM, fill=tk.X) # The footer has no colour of its own to restore, so its own default is the one # the tint has to put back. self._tinted(footer, footer.cget("bg")) # Offered only where there is something to reveal, so most components carry no # control for a thing they do not have. hidden_count = sum(1 for slot in self.slots if is_hidden(slot)) if hidden_count: self.show_hidden_var = tk.BooleanVar(value=show_hidden_slots) box = tk.Checkbutton( footer, variable=self.show_hidden_var, text=f"Hidden slots ({hidden_count})", command=self._on_show_hidden_toggled, ) self._tinted(box, box.cget("bg")) box.pack(side=tk.LEFT, padx=(0, 12)) self.status_var = tk.StringVar(value="") status = tk.Label(footer, textvariable=self.status_var, anchor="w", fg="#333333") self._tinted(status, status.cget("bg")) status.pack(side=tk.LEFT, fill=tk.X, expand=True) ttk.Button(footer, text="Close", width=9, command=self.close).pack(side=tk.RIGHT, padx=(6, 0)) ttk.Button(footer, text="Save", width=9, command=self.save).pack(side=tk.RIGHT, padx=(6, 0)) ttk.Button(footer, text="Refresh", width=9, command=self.refresh).pack(side=tk.RIGHT) def _on_show_hidden_toggled(self): """The checkbox: remember the answer for the session, then re-filter the rows.""" global show_hidden_slots show_hidden_slots = bool(self.show_hidden_var.get()) self._apply_hidden_filter() # -- loading -- def row_text(self, row): """What a row currently reads, whichever kind of field it wears.""" if row["var"] is not None: return row["var"].get() return row["widget"].get() def _set_row_text(self, row, text): if row["var"] is not None: row["var"].set(text) return widget = row["widget"] widget.configure(state="normal") widget.delete(0, tk.END) widget.insert(0, text) def _field_background(self, row, raw): """The fill a row's field takes: a bool's state, or its editability.""" if row["kind"] == "bool": return BOOL_BACKGROUNDS[bool_state(raw)] return EDITABLE_FIELD_BG if row["editable"] else READONLY_FIELD_BG def _load_row(self, row): """Puts the DOM's current value into one row's widget and remembers it as loaded.""" slot = row["slot"] raw = effective_value(self.comp, slot) text = display_text(slot, raw) widget = row["widget"] # Set before the fill, so a StringVar trace sees the value it is colouring for. self._set_row_text(row, text) fill = self._field_background(row, raw) if row["is_choice"]: widget.configure(bg=fill, activebackground=fill) widget["menu"].configure(bg=EDITABLE_FIELD_BG) else: # A bool keeps its fill whether or not it can be changed: the colour is what # says which state the slot is in, and a runtime bool has a state too. widget.configure(fg=LABEL_COLOUR if row["editable"] else READONLY_TEXT) if row["editable"]: widget.configure(state="normal", bg=fill) else: widget.configure(state="readonly", readonlybackground=fill) row["loaded_text"] = text def _load_path(self): """The component's absolute path, as references write it.""" root = app_state.xml_root_element self.path_var.set(get_component_path(self.comp, root) if root is not None else "") def _load_meta_row(self): summary, flags, position = meta_summary(self.comp) self.meta_row["summary_var"].set(summary) self.meta_row["groups"] = list(flags) self.meta_row["loaded_groups"] = list(flags) self.meta_row["position"] = position self.meta_row["loaded_position"] = position self.meta_position_var.set(f"x {position[0]} y {position[1]}") # -- the meta editor behind the row's chevron -- def _open_meta_editor(self): """The two things meta actually holds, side by side: the groups and the position. The position is in grid units, the same figure a drag writes and `meta_scale` multiplies to reach pixels — not a pixel count, which is why 49 and 5 are ordinary values here. Each fits in one byte, so 0-255 is the whole of what meta can carry. """ window = app_state.window editor = tk.Toplevel(window) editor.title("Meta") editor.transient(window) editor.resizable(False, False) editor.grab_set() editor.geometry(f"+{window.winfo_x() + 160}+{window.winfo_y() + 140}") body = tk.Frame(editor, padx=14, pady=12) body.pack(fill=tk.BOTH, expand=True) columns = tk.Frame(body) columns.pack(fill=tk.X) heading_font = tkfont.Font(family="Segoe UI", size=9, weight="bold") groups_column = tk.Frame(columns) groups_column.grid(row=0, column=0, sticky="nw") tk.Label(groups_column, text="Groups", font=heading_font).pack(anchor="w", pady=(0, 4)) group_variables = [] for index in range(4): variable = tk.BooleanVar(value=self.meta_row["groups"][index]) tk.Checkbutton(groups_column, text=f"Group {index + 1}", variable=variable, anchor="w").pack(anchor="w") group_variables.append(variable) position_column = tk.Frame(columns) position_column.grid(row=0, column=1, sticky="nw", padx=(28, 0)) tk.Label(position_column, text="Position", font=heading_font).grid( row=0, column=0, columnspan=2, sticky="w", pady=(0, 4) ) loaded_x, loaded_y = self.meta_row["position"] x_var = tk.StringVar(value=str(loaded_x)) y_var = tk.StringVar(value=str(loaded_y)) for row_index, (axis, variable) in enumerate((("x", x_var), ("y", y_var)), start=1): tk.Label(position_column, text=axis).grid(row=row_index, column=0, sticky="w", pady=2) tk.Entry(position_column, textvariable=variable, width=6, relief="solid", bd=1, highlightthickness=0).grid(row=row_index, column=1, sticky="w", padx=(6, 0), pady=2) def read_coordinate(variable, axis): """One coordinate as an int, or None with the reason named for the axis.""" text = variable.get().strip() try: value = int(text) except ValueError: return None, f"{axis} must be a whole number." if not META_COORD_MIN <= value <= META_COORD_MAX: return None, (f"{axis} must be between {META_COORD_MIN} and " f"{META_COORD_MAX}.") return value, None def apply_meta(): new_x, x_error = read_coordinate(x_var, "x") new_y, y_error = read_coordinate(y_var, "y") problems = [problem for problem in (x_error, y_error) if problem] if problems: messagebox.showerror("Meta", "\n".join(problems), parent=editor) return self.meta_row["groups"] = [variable.get() for variable in group_variables] self.meta_row["position"] = (new_x, new_y) self.meta_row["summary_var"].set(group_label(self.meta_row["groups"])) self.meta_position_var.set(f"x {new_x} y {new_y}") editor.destroy() self._update_tint() self.status_var.set("Meta changed — Save to write it to the file.") buttons = tk.Frame(body) buttons.pack(fill=tk.X, pady=(12, 0)) ttk.Button(buttons, text="OK", width=9, command=apply_meta).pack(side=tk.LEFT) ttk.Button(buttons, text="Cancel", width=9, command=editor.destroy).pack(side=tk.LEFT, padx=(10, 0)) editor.bind("", lambda _event: apply_meta()) editor.bind("", lambda _event: editor.destroy()) # -- the two buttons -- def refresh(self): """Re-reads every value from the DOM, discarding edits that were not saved.""" self._loading = True for row in self.rows: self._load_row(row) self._loading = False if self.meta_row is not None: self._load_meta_row() self._load_path() self._update_tint() self.status_var.set("Refreshed from the document.") def save(self): """Writes the edited rows back, as one undoable step. Nothing is written until every edited row parses, so a typo in one field cannot leave the component half-updated. """ if not self._still_in_document(): messagebox.showwarning( "Properties", "This component is no longer in the document, so nothing was written.", parent=app_state.window, ) self.status_var.set("Component was deleted — nothing saved.") return changes = [] problems = [] for row in self.rows: if not row["editable"]: continue slot = row["slot"] current = (self.row_text(row) or "").strip() if current == row["loaded_text"].strip(): continue raw, error = parse_text(slot, current) if error: problems.append(f"{self._row_label(slot)}: {error}") continue changes.append((self.comp, slot["name"], stored_value(self.comp, slot), raw)) if self.meta_row is not None: groups = self.meta_row["groups"] position = self.meta_row["position"] if (groups != self.meta_row["loaded_groups"] or position != self.meta_row["loaded_position"]): meta_prop = self.comp.find("prop[@name='meta']") before = meta_prop.get("val") if meta_prop is not None else None # Two writes, because each one preserves what the other owns: the # position pass keeps the group bits it reads, then the group pass keeps # the position it has just been given. update_meta_property(self.comp, before, position[0], position[1]) repositioned = self.comp.find("prop[@name='meta']").get("val") update_meta_groups(self.comp, repositioned, groups) after = self.comp.find("prop[@name='meta']").get("val") # Applied above so the packed integer is computed once; recorded here so # undo puts back exactly the string the file carried, absent included. changes.append((self.comp, "meta", before, after)) if problems: messagebox.showerror( "Properties", "These values were not accepted, so nothing was written:\n\n" + "\n".join(f" • {problem}" for problem in problems), parent=app_state.window, ) self.status_var.set("Nothing saved — some values are out of range.") return if not changes: self.status_var.set("No changes to save.") return for element, prop_name, _before, after in changes: if prop_name != "meta": set_prop_value(element, prop_name, after) undo.push(undo.SlotValueCommand(list(changes))) app_state.mark_dirty() self.refresh() if self.on_change is not None: self.on_change() count = len(changes) self.status_var.set( f"{count} slot{'' if count == 1 else 's'} changed. " "Ctrl+S writes the file." ) app_state.set_status( f"{self.comp.get('name', '?')}: {count} slot{'' if count == 1 else 's'} changed." ) # -- plumbing -- def _still_in_document(self): """An undo can take the component out of the document while this is on screen.""" root = app_state.xml_root_element if root is None: return False return any(element is self.comp for element in root.iter()) def _sync_bar(self, bar, first, last): """Moves a scrollbar's thumb, and hides the bar entirely when it spans the whole.""" bar.set(first, last) if float(first) <= 0.0 and float(last) >= 1.0: bar.grid_remove() else: bar.grid() def _on_wheel(self, event): if self.scroll_canvas.winfo_exists(): self.scroll_canvas.yview_scroll(-1 * (event.delta // 120), "units") def _on_shift_wheel(self, event): if self.scroll_canvas.winfo_exists(): self.scroll_canvas.xview_scroll(-1 * (event.delta // 120), "units") def confirm_discard(self): """Asks before pending edits are thrown away. True means it is safe to leave. Three answers rather than two, because "I did not mean to leave" and "leave, I did not want those edits" are different things and a two-way box makes the user guess which one Cancel is. A Save that fails its range checks keeps the view open, since leaving then would discard exactly the values being complained about. """ if not self.has_unsaved_changes(): return True answer = messagebox.askyesnocancel( "Unsaved changes", f"'{self.comp.get('name', '?')}' has changes that have not been saved.\n\n" "Save them before leaving?", parent=app_state.window, ) if answer is None: return False if answer: self.save() return not self.has_unsaved_changes() return True def close(self): """Hands back to whoever put this on screen, once pending edits are settled. Nothing here destroys the frame: the caller owns it, and is the only thing that knows what belongs in its place. """ if not self.confirm_discard(): return False if self.on_close is not None: self.on_close() return True def destroy(self): """Tears the panel's widgets down, for a caller that is done with it.""" if self.frame.winfo_exists(): self.frame.destroy() def build_properties_panel(parent, comp, on_change=None, on_close=None, on_dirty_change=None): """A Properties panel for one component, built into `parent` but not yet placed. `on_change` repaints the other views after a Save; `on_close` is what the Close button asks for; `on_dirty_change` fires when pending edits appear or clear. The caller packs `panel.frame` wherever it wants it. """ if comp is None or parent is None: return None return PropertiesPanel(parent, comp, on_change, on_close, on_dirty_change)