Names the four columns -- Name, Type, Value, Unit -- in grey at row size over a hairline rule, so it labels the table without competing with it. A fifth column of raw flags= letters was tried alongside it and taken out again. c, a, s and o are the manifest's own shorthand, and a column of them told the reader nothing the row was not already saying: c is what the white field means, a is what an "action" value means. What the flags decide is still decided by them; it is just not spelled out twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1233 lines
51 KiB
Python
1233 lines
51 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 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 <prop name="in" val="null"/> 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 <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_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 <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
|
|
# 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("<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="+")
|
|
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 <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)
|
|
|
|
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 <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."""
|
|
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)
|