Files
sedona_editor/properties.py
T
ArdaandClaude Opus 5 2db230dec1 Properties view: every slot of a component, edited in the editor pane
Right-click a component on the wiresheet or in the tree and Properties takes
over the editor pane, listing every slot the manifest declares rather than the
handful a box paints. Config slots that are not @readonly are white and
editable; everything else is grey.

Rows are built from the slot's facets, read against SedonaDoc/niagara.html:
@trueText/@falseText, @range, @precision, @unit, and @min/@max, which bound
what Save accepts. Three facets it had wrong:

  - null is a value, not an absence. control::WriteFloat pairs 'in' with a
    setNull action and the sample app stores val="null" on four of them, so
    null now shows, parses back, and reads as @nullText where declared.
  - @min/@max on a Buf count characters. TcpDevice.ipAddr is a Buf(17) with
    @max 16; a longer string is refused rather than overrunning the device.
  - an int's implicit range is noise. [-2147483648 - 2147483647] said no more
    than "a whole number", so only byte and short print a range they did not
    declare.

meta gets a row of its own: the '>>' opens its user groups as checkboxes and
its position as x and y, since typing 822411269 into a field sets neither.

Save writes only the rows that moved, as one undoable SlotValueCommand, and
refuses the lot if any row fails its range check. An edited field paints the
view in the canvas's own dirty tint, and every way of leaving asks before
discarding.

The file name on the tree's root row goes red while anything is unwritten,
counting both a dirty document and unsaved fields. Two things this needed:
the nav tree runs on its own style, because a themed Treeview's selected
foreground beats a row tag's; and the tag is added to or removed from the row
rather than recoloured, because reconfiguring a tag does not repaint rows that
are already drawn.

Also fixes a latent hazard the panel exposed: Del and Backspace are bound
application-wide, so they now stand down while a text field has focus rather
than deleting the component behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:38:03 +03:00

1076 lines
44 KiB
Python

"""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 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"
READONLY_TEXT = "#444444"
ROW_BG = "#ffffff"
HEADER_BG = "#f2f2f2"
TYPE_COLOUR = "#555555"
PATH_COLOUR = "#7a7a7a"
DOT_TRUE = "#3fa34d"
DOT_FALSE = "#d9534f"
DOT_NULL = "#4a90d9"
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 <prop name="in" val="null"/> on four of them.
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 <prop> 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_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 <prop val=...> 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
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._build_rows_area()
self._load_path()
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 (row["widget"].get() 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."""
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, widget, kind):
"""Makes a field repaint the view as soon as its value moves."""
if kind == "text":
widget.bind("<KeyRelease>", lambda _event: self._update_tint(), add="+")
widget.bind("<<Paste>>", lambda _event: self._update_tint(), add="+")
widget.bind("<FocusOut>", lambda _event: self._update_tint(), add="+")
else:
widget.bind("<<ComboboxSelected>>",
lambda _event: self._update_tint(), add="+")
# -- 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 <link> 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(
"<Configure>",
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("<MouseWheel>", self._on_wheel)
self.frame.bind("<Shift-MouseWheel>", self._on_shift_wheel)
for grid_row, slot in enumerate(self.slots):
if slot["name"] == "meta":
self._build_meta_row(grid_row, slot)
else:
self._build_slot_row(grid_row, slot)
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 _build_slot_row(self, grid_row, slot):
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))
editable = is_editable(slot)
row = {"slot": slot, "editable": editable, "kind": None, "widget": None,
"state_dot": None, "loaded_text": ""}
names = enum_range(slot)
if slot["type"] == "bool":
row["kind"] = "bool"
state_dot = self._tinted(tk.Canvas(field_frame, width=14, height=16,
highlightthickness=0, bg=ROW_BG), ROW_BG)
state_dot.grid(row=0, column=0)
row["state_dot"] = state_dot
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))
widget = ttk.Combobox(field_frame, values=choices,
width=FIELD_WIDTH - 3, state="readonly")
widget.grid(row=0, column=1)
elif names and slot["type"] in INTEGER_TYPES:
row["kind"] = "enum"
widget = ttk.Combobox(field_frame, values=list(names),
width=FIELD_WIDTH - 1, state="readonly")
widget.grid(row=0, column=1)
else:
row["kind"] = "text"
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(widget, row["kind"])
suffix = " ".join(part for part in (unit_symbol(slot["facets"].get("unit")),
range_text(slot)) if part)
self._tinted(tk.Label(self.rows_frame, text=suffix, anchor="w", bg=ROW_BG,
fg="#555555"),
ROW_BG).grid(row=grid_row, column=4, sticky="w", padx=(6, 6))
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"))
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)
# -- loading --
def _set_state_dot(self, canvas_widget, colour):
canvas_widget.delete("all")
canvas_widget.create_oval(3, 4, 12, 13, fill=colour, outline="#666666")
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"]
if row["kind"] == "text":
widget.configure(state="normal")
widget.delete(0, tk.END)
widget.insert(0, text)
if row["editable"]:
widget.configure(state="normal", fg=LABEL_COLOUR, bg=EDITABLE_FIELD_BG)
else:
widget.configure(state="readonly", fg=READONLY_TEXT)
else:
widget.configure(state="readonly")
widget.set(text)
if not row["editable"]:
widget.configure(state="disabled")
if row["state_dot"] is not None:
if is_null(raw):
colour = DOT_NULL
elif str(raw).strip().lower() in ("true", "1"):
colour = DOT_TRUE
else:
colour = DOT_FALSE
self._set_state_dot(row["state_dot"], colour)
row["loaded_text"] = text
def _load_path(self):
"""The component's absolute path, as <link> 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("<Return>", lambda _event: apply_meta())
editor.bind("<Escape>", lambda _event: editor.destroy())
# -- the two buttons --
def refresh(self):
"""Re-reads every value from the DOM, discarding edits that were not saved."""
for row in self.rows:
self._load_row(row)
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 = (row["widget"].get() 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)