Files
sedona_editor/ox_widget_properties.py
T
ArdaandClaude Opus 5.5 6a52603951 Add a "No image" entry to the Page Editor's image dropdown
It heads the list and empties imageFileName; a widget with no image shows
it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:10:18 +03:00

1361 lines
53 KiB
Python

"""The Page Editor's right-hand property sheet, in the shape Workbench's Ox editor uses.
The Sedona slot sheet lists a widget's raw slots; this one lists what a screen designer
thinks in. Several slots fold into one property and one slot splits into several:
layout x, y, width, height "0, 20, 320, 40"
font fontName, fontSize, "32pt Tahoma", "bold 16pt Lato"
fontAttributes
alignment attributes bits 4-5 Left / Center / Right
transparent attributes bit 3 (inverted) bit 3 set means "draw background"
subscriptDigits attributes bits 0-2 trailing characters drawn half size
shape shape Rectangle / Ellipse
*Color RGB565 shorts a swatch and a colour chooser
text NumericLabel's printf format prefix, value, decimals, suffix
labelText a lookup Buf prefix, one item per index, suffix
minmax min, max "0, 100"
displayYear .. DateLabelSet's four settings from one bit field
dateFormatOptions
in, blink .. runtime slots the slot linked to them, and a chooser
The `attributes` bits are the ones ontrolOx's Label.sedona documents; the date bits and
the lookup format ("^prefix;item0;item1;suffix^") are read the way DateLabelSet.sedona
and the R-ION firmware's Process_Text read them. Every other config slot is listed by
its own name with an editor chosen from its type.
Edits commit as soon as a field is left, Return is pressed, a choice is picked or a
dialog is OKed, the way Workbench's sheet behaves; the panel hands the new <prop> values
to the `commit` callback it was built with and never writes a prop itself.
"""
import base64
import binascii
import glob
import os
import re
import tkinter as tk
from tkinter import colorchooser, font as tkfont, messagebox, ttk
import app_state
import properties
import schema
from sax_dom import get_component_path
SHEET_BG = "#ffffff"
GRID_LINE = "#d4d4d4"
NAME_BG = "#f4f4f4"
READONLY_FG = "#707070"
LINK_FG = "#1f4e79"
HEADER_BG = "#dfe8f6"
ALIGNMENTS = ("Left", "Center", "Right")
SHAPES = ("Rectangle", "Ellipse")
YEAR_DIGITS = ("Four", "Two")
DAY_MONTH_ORDERS = ("DD_MM_YYYY", "MM_DD_YYYY")
ALIGN_SHIFT = 4
ALIGN_MASK = 0x30
BACKGROUND_BIT = 0x08
SUBSCRIPT_MASK = 0x07
# DateLabelSet.dateFormatOptions, as DateLabelSet.draw() tests it.
DATE_HIDE_YEAR = 0x01
DATE_TWO_DIGIT_YEAR = 0x02
DATE_LEADING_ZERO = 0x04 # the firmware's `dayMonthLeadingZero`
DATE_MONTH_FIRST = 0x08
LAYOUT_SLOTS = ("x", "y", "width", "height")
FONT_SLOTS = ("fontName", "fontSize", "fontAttributes")
# Runtime slots Workbench lists as "linked to" rather than as a value. `touch` is the
# widget's output; the rest are inputs something else drives.
LINK_SLOTS = ("in", "inInt", "blink", "visibility", "touch")
OUTPUT_LINK_SLOTS = ("touch",)
# The lookup Bufs, and the labels Workbench gives their items when the count is fixed.
LOOKUP_ITEM_LABELS = {
"ontrolOx::ScheduleLabelSet": ("False text", "True text", "Null text"),
}
LOOKUP_TYPES = ("ontrolOx::NumericSelectLabel", "ontrolOx::ScheduleLabelSet")
NUMERIC_FORMAT_TYPES = ("ontrolOx::NumericLabel", "ontrolOx::NumericLabelSet")
# The firmware draws single-byte text, so a Buf's bytes are Latin-1: 'm³' is 6D B3.
BUF_ENCODING = "latin-1"
IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".bmp", ".bin")
NO_IMAGE = "No image" # the image list's entry for an empty imageFileName
# --- value conversions ----------------------------------------------------------------
def rgb565_to_hex(value):
try:
v = int(value) & 0xFFFF
except (TypeError, ValueError):
return "#ffffff"
r = ((v >> 11) & 0x1F) * 255 // 31
g = ((v >> 5) & 0x3F) * 255 // 63
b = (v & 0x1F) * 255 // 31
return f"#{r:02x}{g:02x}{b:02x}"
def hex_to_rgb565(colour):
colour = colour.lstrip("#")
r, g, b = int(colour[0:2], 16), int(colour[2:4], 16), int(colour[4:6], 16)
return ((r * 31 + 127) // 255) << 11 | ((g * 63 + 127) // 255) << 5 | ((b * 31 + 127) // 255)
def _as_int(raw, fallback=0):
try:
return int(float(raw))
except (TypeError, ValueError):
return fallback
def _is_colour_slot(slot):
return slot["type"] in ("short", "int") and slot["name"].lower().endswith("color")
def _is_image_slot(slot):
return slot["type"] == "sys::Buf" and "imagefilename" in slot["name"].lower()
def _is_binary_buf(slot):
"""A Buf the .sax stores as base64 of its bytes, NUL included: 'QWxhcm0A' is 'Alarm'."""
return slot["type"] == "sys::Buf" and slot["facets"].get("asStr") is not True
def decode_buf(raw):
"""The text a base64 Buf holds, or None when the value is not base64 at all."""
if not raw:
return ""
try:
data = base64.b64decode(raw, validate=True)
except (binascii.Error, ValueError):
return None
return data.split(b"\0", 1)[0].decode(BUF_ENCODING)
def encode_buf(text):
return base64.b64encode(text.encode(BUF_ENCODING) + b"\0").decode("ascii") if text else ""
def check_buf_text(text, limit):
"""None when `text` fits a Buf of `limit` bytes (its NUL included), else the reason."""
try:
size = len(text.encode(BUF_ENCODING)) + 1 if text else 0
except UnicodeEncodeError as error:
return f"'{text[error.start]}' cannot be shown on the device"
if isinstance(limit, (int, float)) and size > limit:
return f"longer than {int(limit) - 1} characters"
return None
def humanise_type(comp_type):
"""'ontrolOx::PushButtonLabel' -> 'Push Button Label'."""
short = comp_type.rsplit("::", 1)[-1]
return properties.humanise(short)
# --- NumericLabel's printf format -----------------------------------------------------
_FLOAT_SPEC = re.compile(r"%(?:\.(\d*))?f")
def parse_numeric_format(text):
"""'T1: %.1f C' -> ('T1: ', True, 1, ' C'); None for a format this cannot split.
drawNumericTextBox hands the Buf to sprintf with the value, so '%%' is a literal
percent sign and a text with no conversion at all is a plain caption.
"""
i = 0
while i < len(text):
if text[i] == "%":
if text[i + 1:i + 2] == "%":
i += 2
continue
match = _FLOAT_SPEC.match(text, i)
if not match:
return None
digits = match.group(1)
precision = 6 if digits is None else int(digits or 0)
suffix = text[match.end():]
if "%" in suffix.replace("%%", ""):
return None
return (text[:i].replace("%%", "%"), True, precision,
suffix.replace("%%", "%"))
i += 1
return text.replace("%%", "%"), False, 1, ""
def compose_numeric_format(prefix, show_value, precision, suffix):
text = prefix.replace("%", "%%")
if show_value:
text += f"%.{precision}f" + suffix.replace("%", "%%")
return text
def numeric_format_preview(text):
parts = parse_numeric_format(text)
if parts is None:
return text
prefix, show_value, precision, suffix = parts
if not show_value:
return prefix
placeholder = "#" + ("." + "#" * precision if precision else "")
return f"{prefix}{placeholder}{suffix}"
# --- lookup text ----------------------------------------------------------------------
def parse_lookup(text):
"""'^TEST: ;Off;On;' -> ('TEST: ', ['Off', 'On'], '')."""
prefix = suffix = ""
body = text or ""
if body.startswith("^"):
end = body.find(";")
if end > 0:
prefix, body = body[1:end], body[end + 1:]
if body.endswith("^"):
end = body.rfind(";", 0, len(body) - 1)
suffix, body = body[end + 1:-1], body[:end + 1]
items = body.split(";")
if items and items[-1] == "":
items.pop()
return prefix, items, suffix
def compose_lookup(prefix, items, suffix):
text = f"^{prefix};" if prefix else ""
text += "".join(f"{item};" for item in items)
if suffix:
text += f"{suffix}^"
return text
def lookup_preview(text):
prefix, items, suffix = parse_lookup(text)
return f"{prefix}[{' | '.join(items)}]{suffix}"
# --- the device's font and image banks ------------------------------------------------
# Orion's 800x480 screen takes the XL banks; Rion's 320x240 one the plain ones.
ORION_KIT = "ontrolOrion"
RION_KIT = "ontrolRion"
FONT_EXTENSIONS = (".bin", ".oxfl", ".oxfp")
# Orion keeps a landscape (.oxfl) and a portrait (.oxfp) cut of every font, and draws
# with the one its platform service's screenOrientation (false = landscape) picks.
ORION_PLATFORM_TYPE = "ontrolOrion::OrionPlatformService"
LANDSCAPE_FONT_EXTENSION = ".oxfl"
PORTRAIT_FONT_EXTENSION = ".oxfp"
_FONT_STEM = re.compile(r"^(bold\s+)?(\d+)pt\s+(.+)$", re.IGNORECASE)
def bank_folders(kind):
"""The <sedona home>/ontrol folders holding the open platform's `kind` bank.
`kind` is "font" or "image". Orion reads fontBankXL/imageBankXL and Rion
fontBank/imageBank; a document of neither platform is offered both.
"""
home = getattr(app_state, "SEDONA_HOME", None)
if not home:
return []
kits = app_state.active_schema_kits
if ORION_KIT in kits:
names = (f"{kind}BankXL",)
elif RION_KIT in kits:
names = (f"{kind}Bank",)
else:
names = (f"{kind}Bank", f"{kind}BankXL")
return [os.path.join(home, "ontrol", name) for name in names]
def _bank_files(kind, extensions):
"""The bank's file names, extension included, of the kinds in `extensions`."""
files = set()
for folder in bank_folders(kind):
for path in glob.glob(os.path.join(folder, "*")):
if os.path.splitext(path)[1].lower() in extensions and os.path.isfile(path):
files.add(os.path.basename(path))
return files
def _bank_stems(kind, extensions):
return {os.path.splitext(name)[0] for name in _bank_files(kind, extensions)}
def image_names():
"""The image bank's files as the list shows them: 'trt.jpg', 'logo.png'."""
return sorted(_bank_files("image", IMAGE_EXTENSIONS), key=str.lower)
def image_file_text(name):
"""An imageFileName value with its bank file's extension: 'trt' -> 'trt.jpg'.
A name no bank file matches is shown as it is, and an empty one as NO_IMAGE.
"""
if not name:
return NO_IMAGE
for file_name in image_names():
if os.path.splitext(file_name)[0] == name:
return file_name
return name
def image_file_value(text):
"""The imageFileName value for a listed file: 'trt.jpg' -> 'trt'.
The slot holds the bare name, as the device looks the file up by it; NO_IMAGE
empties it.
"""
if text == NO_IMAGE:
return ""
stem, ext = os.path.splitext(text.strip())
return stem if ext.lower() in IMAGE_EXTENSIONS else text.strip()
def parse_font_stem(stem):
"""'bold 12pt Tahoma' -> ('Tahoma', 12, True); None for a name not in that form."""
match = _FONT_STEM.match(stem.strip())
if not match:
return None
return match.group(3).strip(), int(match.group(2)), bool(match.group(1))
def orion_portrait():
"""True when the Orion platform service is set to a portrait screen.
In live mode the tree holds only the folders opened so far, so a platform service
the document lacks is read from the device instead.
"""
root = app_state.xml_root_element
for comp in root.iter("comp") if root is not None else ():
if comp.get("type") == ORION_PLATFORM_TYPE:
slot = next((s for s in schema.get_full_slots_for_type(ORION_PLATFORM_TYPE)
if s["name"] == "screenOrientation"), None)
raw = properties.effective_value(comp, slot) if slot else None
return str(raw).lower() == "true"
session = getattr(app_state, "device_session", None)
if app_state.live_mode and session is not None:
try:
comp = session.find_component_of_type(ORION_PLATFORM_TYPE, "screenOrientation")
except Exception: # a dropped connection reads as the default, landscape
comp = None
if comp is not None:
return str(comp.values.get("screenOrientation")).lower() == "true"
return False
def font_extensions():
"""The font file extensions the open platform draws with."""
if ORION_KIT in app_state.active_schema_kits:
return (PORTRAIT_FONT_EXTENSION if orion_portrait() else LANDSCAPE_FONT_EXTENSION,)
return FONT_EXTENSIONS
def font_bank_names():
"""The font bank's fonts, smallest first and plain before bold at each size."""
fonts = [(parse_font_stem(stem), stem) for stem in _bank_stems("font", font_extensions())]
fonts = [(parsed, stem) for parsed, stem in fonts if parsed]
fonts.sort(key=lambda f: (f[0][1], f[0][2], f[0][0].lower()))
return [stem for _, stem in fonts]
# --- links ----------------------------------------------------------------------------
def slot_ref(comp, slot_name):
root = app_state.xml_root_element
return f"{get_component_path(comp, root)}.{slot_name}" if root is not None else ""
def slot_links(comp, slot_name, output):
"""The <link> elements that feed this slot, or that it feeds when `output`."""
root = app_state.xml_root_element
links = root.find("links") if root is not None else None
if links is None:
return []
ref = slot_ref(comp, slot_name)
end = "from" if output else "to"
return [link for link in links if link.tag == "link" and link.get(end) == ref]
# --- one property ---------------------------------------------------------------------
class Prop:
"""A row of the sheet: how to read its text from the component and how to write it.
`read()` gives the text shown; `write(text)` gives ({prop_name: raw}, error). A row
whose `write` is None is read-only. `dialog` names the panel's editor for rows
edited through one, and `unit` is the symbol shown after the value.
"""
def __init__(self, label, kind, read, write=None, choices=(), dialog=None, unit="",
slot=None, output=False):
self.label = label
self.kind = kind # text | choice | colour | dialog | image | font | link | readonly
self.read = read
self.write = write
self.choices = choices
self.dialog = dialog
self.unit = unit
self.slot = slot
self.output = output
class _Model:
"""Reads and packs one component's slots for the rows built on it."""
def __init__(self, comp):
self.comp = comp
self.slots = {s["name"]: s for s in schema.get_full_slots_for_type(comp.get("type", ""))}
def raw(self, name):
slot = self.slots.get(name)
if slot is None:
return None
return properties.effective_value(self.comp, slot)
def int(self, name, fallback=0):
return _as_int(self.raw(name), fallback)
def has(self, *names):
return all(n in self.slots for n in names)
def check(self, name, raw_text):
"""parse_text against the slot's own facets, so ranges are the manifest's."""
return properties.parse_text(self.slots[name], raw_text)
def text(self, name):
"""A Buf's text, in whichever form the document holds it.
A .sax carries a binary Buf as base64. In live mode the document is filled from
the device, which reads the Buf over Sox as a Str, so the prop already holds
plain text and a write goes out as a Str too -- the same bytes on the wire, NUL
included. Base64 there would reach the device as the literal characters.
"""
raw = self.raw(name) or ""
if app_state.live_mode or not _is_binary_buf(self.slots[name]):
return raw
text = decode_buf(raw)
return raw if text is None else text
def text_values(self, name, text):
"""({name: raw}, error) for putting `text` into Buf `name`."""
slot = self.slots[name]
error = check_buf_text(text, properties.value_bounds(slot)[1])
if error:
return None, error
if app_state.live_mode or not _is_binary_buf(slot):
return {name: text}, None
return {name: encode_buf(text)}, None
def build_props(comp):
"""Every row for `comp`, sorted by name the way Workbench sorts its sheet."""
m = _Model(comp)
comp_type = comp.get("type", "")
rows = [Prop("_name_", "readonly", lambda: comp.get("name", ""))]
consumed = {"meta"}
if m.has(*LAYOUT_SLOTS):
consumed.update(LAYOUT_SLOTS)
rows.append(Prop("layout", "text", lambda: ", ".join(
str(m.int(n)) for n in LAYOUT_SLOTS), _layout_writer(m), dialog="layout"))
if m.has(*FONT_SLOTS):
consumed.update(FONT_SLOTS)
rows.append(Prop("font", "font", lambda: font_text(m), _font_writer(m)))
if m.has("attributes"):
consumed.add("attributes")
rows.extend(_attribute_props(m))
if m.has("shape"):
consumed.add("shape")
rows.append(_enum_prop(m, "shape", "shape", SHAPES))
if m.has("dateFormatOptions"):
consumed.add("dateFormatOptions")
rows.extend(_date_props(m))
if m.has("dateSeparatorCharacter"):
consumed.add("dateSeparatorCharacter")
slot = m.slots["dateSeparatorCharacter"]
rows.append(_enum_prop(m, "separatorCharacter", "dateSeparatorCharacter",
properties.enum_range(slot) or ("slash", "dot", "dash", "space")))
if m.has("min", "max") and comp_type == "ontrolOx::NumericLabelSet":
consumed.update(("min", "max"))
rows.append(_minmax_prop(m))
if m.has("text") and comp_type in LOOKUP_TYPES:
consumed.add("text")
rows.append(Prop("labelText", "dialog", lambda: lookup_preview(m.text("text")),
dialog="lookup"))
elif m.has("text") and comp_type in NUMERIC_FORMAT_TYPES:
consumed.add("text")
rows.append(Prop("text", "dialog", lambda: numeric_format_preview(m.text("text")),
dialog="numeric"))
rows.append(Prop("meta", "readonly", lambda: properties.meta_summary(comp)[0]))
for name, slot in m.slots.items():
if name in consumed or properties.is_hidden(slot) or slot["type"] == "void":
continue
if name in LINK_SLOTS:
rows.append(_link_prop(m, slot))
continue
if not properties.is_editable(slot):
continue
rows.append(_slot_prop(m, slot))
rows.sort(key=lambda p: p.label.lower())
return rows
def _enum_prop(m, label, name, names):
def read():
value = m.int(name)
return names[value] if 0 <= value < len(names) else str(value)
def write(text):
if text in names:
return {name: str(names.index(text))}, None
return None, f"expected one of: {', '.join(names)}"
return Prop(label, "choice", read, write, names)
def _layout_writer(m):
def write(text):
parts = [p.strip() for p in text.replace(";", ",").split(",") if p.strip()]
if len(parts) != 4:
return None, "expected x, y, width, height"
values = {}
for name, part in zip(LAYOUT_SLOTS, parts):
try:
number = str(int(round(float(part))))
except ValueError:
return None, f"{name}: expected a whole number"
raw, error = m.check(name, number)
if error:
return None, f"{name}: {error}"
values[name] = raw
return values, None
return write
def font_names(m):
return properties.enum_range(m.slots["fontName"]) or ("Tahoma",)
def font_styles(m):
return properties.enum_range(m.slots["fontAttributes"]) or ("Normal", "Bold", "Italic")
def font_text(m):
"""The font as the font bank names its file: '32pt Tahoma', 'bold 16pt Lato'."""
names, styles = font_names(m), font_styles(m)
name_i, style_i = m.int("fontName"), m.int("fontAttributes")
name = names[name_i] if 0 <= name_i < len(names) else f"font {name_i}"
style = styles[style_i] if 0 <= style_i < len(styles) else ""
prefix = "" if style_i == 0 or not style else f"{style.lower()} "
return f"{prefix}{m.int('fontSize')}pt {name}"
def _font_writer(m):
"""A font bank name back into fontName, fontSize and fontAttributes."""
def write(text):
parsed = parse_font_stem(text)
if parsed is None:
return None, "expected a font such as '12pt Tahoma' or 'bold 12pt Tahoma'"
family, size, bold = parsed
names = [n.lower() for n in font_names(m)]
if family.lower() not in names:
return None, f"'{family}' is not one of: {', '.join(font_names(m))}"
size_raw, error = m.check("fontSize", str(size))
if error:
return None, f"size: {error}"
styles = [s.lower() for s in font_styles(m)]
style = styles.index("bold") if bold and "bold" in styles else (1 if bold else 0)
return {"fontName": str(names.index(family.lower())), "fontSize": size_raw,
"fontAttributes": str(style)}, None
return write
def _bit_props(m, name, fallback, specs):
"""Rows that each own some bits of one byte slot: (label, mask, choices, values)."""
def current():
return m.int(name, fallback) & 0xFF
def row(label, mask, choices, values):
def read():
bits = current() & mask
return choices[values.index(bits)] if bits in values else str(bits)
def write(text):
if text not in choices:
return None, f"expected one of: {', '.join(choices)}"
bits = values[choices.index(text)]
return {name: str((current() & ~mask & 0xFF) | bits)}, None
return Prop(label, "choice", read, write, choices)
return [row(*spec) for spec in specs]
def _attribute_props(m):
return _bit_props(m, "attributes", 0x08, [
("alignment", ALIGN_MASK, ALIGNMENTS, (0x00, 0x10, 0x20)),
("transparent", BACKGROUND_BIT, ("true", "false"), (0, BACKGROUND_BIT)),
("subscriptDigits", SUBSCRIPT_MASK, tuple(str(n) for n in range(8)), tuple(range(8))),
])
def _date_props(m):
return _bit_props(m, "dateFormatOptions", 0, [
("displayYear", DATE_HIDE_YEAR, ("true", "false"), (0, DATE_HIDE_YEAR)),
("yearDigits", DATE_TWO_DIGIT_YEAR, YEAR_DIGITS, (0, DATE_TWO_DIGIT_YEAR)),
("dayMonthLeadingZero", DATE_LEADING_ZERO, ("true", "false"), (DATE_LEADING_ZERO, 0)),
("dayMonthOrder", DATE_MONTH_FIRST, DAY_MONTH_ORDERS, (0, DATE_MONTH_FIRST)),
])
def _minmax_prop(m):
def read():
return ", ".join(properties.display_text(m.slots[n], m.raw(n)) for n in ("min", "max"))
def write(text):
parts = [p.strip() for p in text.replace(";", ",").split(",")]
if len(parts) != 2:
return None, "expected min, max"
values = {}
for name, part in zip(("min", "max"), parts):
raw, error = m.check(name, part or "null")
if error:
return None, f"{name}: {error}"
values[name] = raw
low, high = values["min"], values["max"]
if "null" not in (low, high) and float(low) > float(high):
return None, "min is above max"
return values, None
return Prop("minmax", "text", read, write)
def _link_prop(m, slot):
name = slot["name"]
return Prop(name, "link", lambda: properties.display_text(slot, m.raw(name)),
slot=name, output=name in OUTPUT_LINK_SLOTS)
def _slot_prop(m, slot):
name = slot["name"]
unit = properties.unit_symbol(slot["facets"].get("unit"))
def read():
return properties.display_text(slot, m.raw(name))
def write(text):
raw, error = properties.parse_text(slot, text)
return ({name: raw}, None) if error is None else (None, error)
if _is_colour_slot(slot):
return Prop(name, "colour", lambda: str(m.int(name)),
lambda text: ({name: text}, None))
if _is_image_slot(slot):
return Prop(name, "image", lambda: image_file_text(m.text(name)),
lambda text: m.text_values(name, image_file_value(text)))
if slot["type"] == "sys::Buf":
return Prop(name, "text", lambda: m.text(name), lambda text: m.text_values(name, text))
if slot["type"] == "bool":
false_text, true_text = properties.bool_texts(slot)
choices = (true_text, false_text)
if properties.allows_null(slot):
choices += (properties.null_text(slot),)
return Prop(name, "choice", read, write, choices)
names = properties.enum_range(slot)
if names and slot["type"] in properties.INTEGER_TYPES:
return Prop(name, "choice", read, write, names)
return Prop(name, "text", read, write, unit=unit)
# --- the sheet ------------------------------------------------------------------------
class OxWidgetPropertiesPanel:
"""A scrolling two-column sheet for one component at a time.
`commit(comp, {prop_name: raw})` writes the values and is expected to call back into
`reload()` (via the page's refresh) once the document holds them. `repaint()` is
called after links are added or removed here, which go through sax_file instead.
"""
def __init__(self, parent, commit, repaint):
self.commit = commit
self.repaint = repaint
self.comp = None
self.rows = []
# Off until the Page Editor's edit mode is switched on: every field is then shown
# but none of them takes input, and no write reaches `commit`.
self.editable = False
self._controls = [] # (widget, state when editable) for every input on the sheet
self.frame = ttk.Frame(parent)
self._name_font = tkfont.Font(family="Segoe UI", size=9)
self._bold_font = tkfont.Font(family="Segoe UI", size=9, weight="bold")
header = tk.Frame(self.frame, bg=HEADER_BG, padx=6, pady=4)
header.pack(fill=tk.X)
tk.Label(header, text="Properties", font=self._bold_font,
bg=HEADER_BG).pack(anchor="w")
self._title_var = tk.StringVar()
self._type_var = tk.StringVar()
info = tk.Frame(self.frame, padx=6, pady=3)
info.pack(fill=tk.X)
tk.Label(info, textvariable=self._title_var, font=self._bold_font,
anchor="w").pack(fill=tk.X)
tk.Label(info, textvariable=self._type_var, font=self._name_font,
fg="#555555", anchor="w").pack(fill=tk.X)
body = ttk.Frame(self.frame)
body.pack(fill=tk.BOTH, expand=True)
self._canvas = tk.Canvas(body, bg=SHEET_BG, highlightthickness=0, width=300)
vscroll = ttk.Scrollbar(body, orient=tk.VERTICAL, command=self._canvas.yview)
self._canvas.configure(yscrollcommand=vscroll.set)
vscroll.pack(side=tk.RIGHT, fill=tk.Y)
self._canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
self._sheet = tk.Frame(self._canvas, bg=GRID_LINE)
self._sheet_window = self._canvas.create_window(0, 0, anchor="nw", window=self._sheet)
self._sheet.columnconfigure(1, weight=1)
self._sheet.bind("<Configure>", lambda e: self._canvas.configure(
scrollregion=self._canvas.bbox("all")))
self._canvas.bind("<Configure>", lambda e: self._canvas.itemconfigure(
self._sheet_window, width=e.width))
self._canvas.bind("<Enter>", lambda e: self._canvas.bind_all(
"<MouseWheel>", self._on_wheel))
self._canvas.bind("<Leave>", lambda e: self._canvas.unbind_all("<MouseWheel>"))
self._status_var = tk.StringVar()
tk.Label(self.frame, textvariable=self._status_var, font=self._name_font,
fg="#a00000", anchor="w", padx=6, wraplength=300,
justify=tk.LEFT).pack(fill=tk.X)
def _on_wheel(self, event):
if event.delta:
self._canvas.yview_scroll(-1 if event.delta > 0 else 1, "units")
# ── showing a component ───────────────────────────────────────
def show(self, comp):
"""Lists `comp`'s properties; the same component again only re-reads values."""
if comp is not self.comp:
self.flush_edits()
if comp is self.comp and self.rows:
self.reload()
return
self.comp = comp
self._rebuild()
def _rebuild(self):
for child in self._sheet.winfo_children():
child.destroy()
self.rows = []
self._controls = []
self._status_var.set("")
if self.comp is None:
self._title_var.set("")
self._type_var.set("Select a widget to see its properties.")
return
self._title_var.set(self.comp.get("name", "?"))
self._type_var.set(humanise_type(self.comp.get("type", "")))
for grid_row, prop in enumerate(build_props(self.comp)):
self.rows.append(self._build_row(grid_row, prop))
self._apply_editable()
self.reload()
self._canvas.yview_moveto(0)
def set_editable(self, editable):
"""Edit mode on or off: the fields take input, or only show their values."""
self.editable = editable
self._apply_editable()
def _apply_editable(self):
for widget, editable_state in self._controls:
state = editable_state if self.editable else "disabled"
# Combobox first: it is an Entry too, and "readonly" would still let it pick.
if isinstance(widget, ttk.Combobox):
widget.configure(state=state)
elif isinstance(widget, tk.Entry):
widget.configure(state="normal" if self.editable else "readonly")
else:
widget.state(["!disabled"] if self.editable else ["disabled"])
if self.comp is not None:
self._status_var.set("" if self.editable else
"View mode — switch on edit mode (✏) to change values.")
def _guarded(self, command):
"""`command`, run only while the sheet is editable."""
def run(*args):
if self.editable:
return command(*args)
return run
def flush_edits(self):
"""Commits a typed value still sitting in its field.
Picking another widget on the canvas takes no focus from the field, so without
this its <FocusOut> would never come and the edit would be rebuilt away.
"""
for row in list(self.rows):
flush = row.get("flush")
if flush is not None:
flush()
def reload(self):
"""Re-reads every row from the document, keeping the rows themselves."""
if self.comp is not None:
self._title_var.set(self.comp.get("name", "?"))
for row in self.rows:
row["load"]()
# ── rows ──────────────────────────────────────────────────────
def _cell(self, grid_row, column, bg):
cell = tk.Frame(self._sheet, bg=bg)
cell.grid(row=grid_row, column=column, sticky="nsew",
padx=(0 if column else 1, 1), pady=(0, 1))
return cell
def _build_row(self, grid_row, prop):
name_cell = self._cell(grid_row, 0, NAME_BG)
tk.Label(name_cell, text=prop.label, font=self._name_font, bg=NAME_BG,
anchor="w", padx=4).pack(fill=tk.BOTH, expand=True)
cell = self._cell(grid_row, 1, SHEET_BG)
row = {"prop": prop, "var": tk.StringVar()}
getattr(self, f"_build_{prop.kind}")(cell, row)
return row
def _value_label(self, cell, row, fg="#000000"):
label = tk.Label(cell, textvariable=row["var"], font=self._name_font, bg=SHEET_BG,
fg=fg, anchor="w", padx=4)
label.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
return label
def _dots(self, cell, command):
button = ttk.Button(cell, text="…", width=3, command=self._guarded(command))
button.pack(side=tk.RIGHT, padx=1)
self._controls.append((button, None))
def _build_readonly(self, cell, row):
self._value_label(cell, row, READONLY_FG)
row["load"] = lambda: row["var"].set(row["prop"].read())
def _bind_commit(self, widget, row):
"""Return, leaving the field and Escape, shared by the typed-in rows."""
var = row["var"]
row["loaded"] = ""
def load():
row["loaded"] = row["prop"].read()
var.set(row["loaded"])
def commit(_event=None):
text = var.get()
if not self.editable:
if text != row["loaded"]:
var.set(row["loaded"])
return
if text != row["loaded"]:
# Taken as loaded first: the write repaints, and the repaint must not
# find this same edit still pending and commit it a second time.
row["loaded"] = text
self._write(row, text)
widget.bind("<Return>", commit)
widget.bind("<KP_Enter>", commit)
widget.bind("<FocusOut>", commit)
widget.bind("<Escape>", lambda e: var.set(row["loaded"]))
row["load"] = load
row["flush"] = commit
return commit
def _build_text(self, cell, row):
prop = row["prop"]
if prop.dialog:
# Typed in place or edited field by field, as Workbench offers both.
self._dots(cell, getattr(self, f"_open_{prop.dialog}_dialog"))
if prop.unit:
tk.Label(cell, text=prop.unit, font=self._name_font, bg=SHEET_BG,
fg=READONLY_FG, padx=4).pack(side=tk.RIGHT)
entry = tk.Entry(cell, textvariable=row["var"], font=self._name_font, relief=tk.FLAT,
bg=SHEET_BG, highlightthickness=1, highlightcolor="#7f9db9",
highlightbackground=SHEET_BG)
entry.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=2, pady=1)
self._bind_commit(entry, row)
self._controls.append((entry, None))
def _bank_combo(self, cell, row, bank_values, first=None):
"""A pick-only list filled from a bank each time it opens, the value kept listed.
`first`, when given, heads the list: the entry for a slot naming nothing.
"""
var = row["var"]
def fill():
values = ([first] if first else []) + bank_values()
current = var.get()
if current and current not in values:
values.insert(1 if first else 0, current)
combo.configure(values=values)
combo = ttk.Combobox(cell, textvariable=var, state="readonly", font=self._name_font,
postcommand=fill)
combo.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=1)
def selected(_event=None):
combo.selection_clear()
if var.get() != row["prop"].read():
self._write(row, var.get())
combo.bind("<<ComboboxSelected>>", selected)
row["load"] = lambda: var.set(row["prop"].read())
self._controls.append((combo, "readonly"))
return combo
def _build_image(self, cell, row):
self._bank_combo(cell, row, image_names, first=NO_IMAGE)
def _build_font(self, cell, row):
self._bank_combo(cell, row, font_bank_names)
def _build_choice(self, cell, row):
prop, var = row["prop"], row["var"]
combo = ttk.Combobox(cell, textvariable=var, values=list(prop.choices),
state="readonly", font=self._name_font)
combo.pack(fill=tk.BOTH, expand=True, padx=1)
def selected(_event=None):
combo.selection_clear()
self._write(row, var.get())
combo.bind("<<ComboboxSelected>>", selected)
row["load"] = lambda: var.set(prop.read())
self._controls.append((combo, "readonly"))
def _build_colour(self, cell, row):
prop, var = row["prop"], row["var"]
swatch = tk.Frame(cell, width=28, height=12, bg="#ffffff",
highlightthickness=1, highlightbackground="#000000")
swatch.pack(side=tk.LEFT, padx=(4, 6), pady=4)
def load():
value = _as_int(prop.read())
colour = rgb565_to_hex(value)
swatch.config(bg=colour)
var.set(f"{colour} ({value})")
def choose(_event=None):
current = rgb565_to_hex(_as_int(prop.read()))
picked = colorchooser.askcolor(color=current, parent=self.frame,
title=prop.label)[1]
if picked:
self._write(row, str(hex_to_rgb565(picked)))
self._dots(cell, choose)
self._value_label(cell, row, READONLY_FG)
swatch.bind("<Button-1>", self._guarded(choose))
row["load"] = load
def _build_dialog(self, cell, row):
opener = getattr(self, f"_open_{row['prop'].dialog}_dialog")
self._dots(cell, opener)
label = self._value_label(cell, row)
label.bind("<Double-Button-1>", self._guarded(lambda e: opener()))
row["load"] = lambda: row["var"].set(row["prop"].read())
def _build_link(self, cell, row):
prop, var = row["prop"], row["var"]
self._dots(cell, lambda: self._open_link_dialog(prop))
label = self._value_label(cell, row)
def load():
links = slot_links(self.comp, prop.slot, prop.output)
if not links:
var.set(prop.read())
label.config(fg=READONLY_FG)
return
end = "to" if prop.output else "from"
arrow = "→" if prop.output else "←"
text = f"{arrow} {links[0].get(end, '')}"
if len(links) > 1:
text += f" (+{len(links) - 1})"
var.set(text)
label.config(fg=LINK_FG)
row["load"] = load
# ── writing ───────────────────────────────────────────────────
def _write(self, row, text):
if not self.editable:
row["load"]()
return
values, error = row["prop"].write(text)
if error:
self._status_var.set(f"{row['prop'].label}: {error}")
row["load"]()
return
self._status_var.set("")
self.commit(self.comp, values)
# ── dialogs ───────────────────────────────────────────────────
def _dialog(self, title):
"""A modal Toplevel with a padded body and an OK/Cancel row still to be added."""
dialog = tk.Toplevel(self.frame)
dialog.title(title)
dialog.transient(self.frame.winfo_toplevel())
dialog.resizable(False, False)
body = ttk.Frame(dialog, padding=10)
body.pack(fill=tk.BOTH, expand=True)
return dialog, body
def _finish_dialog(self, dialog, body, row, apply):
buttons = ttk.Frame(body)
buttons.grid(row=row, column=0, columnspan=3, sticky="e", pady=(10, 0))
ttk.Button(buttons, text="OK", width=9, command=apply).pack(side=tk.LEFT, padx=(0, 4))
ttk.Button(buttons, text="Cancel", width=9,
command=dialog.destroy).pack(side=tk.LEFT)
dialog.bind("<Escape>", lambda e: dialog.destroy())
dialog.update_idletasks()
top = self.frame.winfo_toplevel()
x = top.winfo_rootx() + max(0, top.winfo_width() - dialog.winfo_width() - 340)
dialog.geometry(f"+{x}+{top.winfo_rooty() + 120}")
dialog.grab_set()
def _apply_values(self, dialog, comp, values, error):
if error:
messagebox.showerror(dialog.title(), error, parent=dialog)
return
dialog.destroy()
self.commit(comp, values)
def _open_layout_dialog(self):
"""X, Y, Width and Height, one field each, as Workbench's layout editor has them."""
comp = self.comp
if comp is None:
return
self.flush_edits()
m = _Model(comp)
dialog, body = self._dialog("layout")
table = tk.Frame(body, bg=GRID_LINE)
table.grid(row=0, column=0, sticky="nsew")
tk.Label(table, text="", bg=NAME_BG, width=8).grid(row=0, column=0, sticky="nsew",
padx=(1, 1), pady=1)
tk.Label(table, text="Value", bg=NAME_BG, anchor="w", padx=4, width=16,
font=self._name_font).grid(row=0, column=1, sticky="nsew", padx=(0, 1), pady=1)
variables = {}
entries = []
for index, (name, label) in enumerate(zip(LAYOUT_SLOTS, ("X", "Y", "Width", "Height"))):
tk.Label(table, text=label, bg=SHEET_BG, anchor="w", padx=4,
font=self._name_font).grid(row=index + 1, column=0, sticky="nsew",
padx=(1, 1), pady=(0, 1))
var = tk.StringVar(value=str(m.int(name)))
entry = tk.Entry(table, textvariable=var, font=self._name_font, relief=tk.FLAT,
bg=SHEET_BG, highlightthickness=1, highlightcolor="#7f9db9",
highlightbackground=SHEET_BG, width=16)
entry.grid(row=index + 1, column=1, sticky="nsew", padx=(0, 1), pady=(0, 1))
variables[name] = var
entries.append(entry)
def apply():
text = ", ".join(variables[name].get().strip() or "?" for name in LAYOUT_SLOTS)
self._apply_values(dialog, comp, *_layout_writer(m)(text))
dialog.bind("<Return>", lambda e: apply())
self._finish_dialog(dialog, body, 1, apply)
entries[0].focus_set()
entries[0].select_range(0, tk.END)
def _open_numeric_dialog(self):
"""NumericLabel's text: the caption around the value and how it is formatted."""
comp = self.comp
if comp is None:
return
m = _Model(comp)
current = m.text("text")
parts = parse_numeric_format(current)
dialog, body = self._dialog("Text")
prefix_var = tk.StringVar()
suffix_var = tk.StringVar()
show_var = tk.BooleanVar()
precision_var = tk.StringVar()
raw_var = tk.StringVar(value=current)
preview_var = tk.StringVar()
if parts is not None:
prefix_var.set(parts[0])
show_var.set(parts[1])
precision_var.set(str(parts[2]))
suffix_var.set(parts[3])
ttk.Label(body, text="Prefix").grid(row=0, column=0, sticky="w", pady=2)
ttk.Entry(body, textvariable=prefix_var, width=24).grid(row=0, column=1, sticky="ew")
ttk.Checkbutton(body, text="Show value", variable=show_var).grid(
row=1, column=1, sticky="w", pady=2)
ttk.Label(body, text="Decimals").grid(row=2, column=0, sticky="w", pady=2)
precision_box = ttk.Spinbox(body, textvariable=precision_var, from_=0, to=6, width=6)
precision_box.grid(row=2, column=1, sticky="w")
ttk.Label(body, text="Suffix").grid(row=3, column=0, sticky="w", pady=2)
suffix_entry = ttk.Entry(body, textvariable=suffix_var, width=24)
suffix_entry.grid(row=3, column=1, sticky="ew")
ttk.Label(body, text="Format").grid(row=4, column=0, sticky="w", pady=(8, 2))
raw_entry = ttk.Entry(body, textvariable=raw_var, width=24)
raw_entry.grid(row=4, column=1, sticky="ew", pady=(8, 2))
ttk.Label(body, text="Preview").grid(row=5, column=0, sticky="w", pady=2)
ttk.Label(body, textvariable=preview_var, foreground=LINK_FG).grid(
row=5, column=1, sticky="w")
syncing = []
def from_parts(*_):
if syncing:
return
state = "normal" if show_var.get() else "disabled"
precision_box.configure(state=state)
suffix_entry.configure(state=state)
precision = _as_int(precision_var.get(), 1)
syncing.append(True)
raw_var.set(compose_numeric_format(prefix_var.get(), show_var.get(),
max(0, min(6, precision)), suffix_var.get()))
syncing.pop()
def from_raw(*_):
preview_var.set(numeric_format_preview(raw_var.get()))
if syncing:
return
parsed = parse_numeric_format(raw_var.get())
if parsed is None:
return
syncing.append(True)
prefix_var.set(parsed[0])
show_var.set(parsed[1])
precision_var.set(str(parsed[2]))
suffix_var.set(parsed[3])
syncing.pop()
for var in (prefix_var, suffix_var, show_var, precision_var):
var.trace_add("write", from_parts)
raw_var.trace_add("write", from_raw)
from_raw()
if parts is not None:
from_parts()
def apply():
text = raw_var.get()
if parse_numeric_format(text) is None:
self._apply_values(dialog, comp, None,
"The format can hold one %f conversion; write % as %%.")
return
self._apply_values(dialog, comp, *m.text_values("text", text))
dialog.bind("<Return>", lambda e: apply())
self._finish_dialog(dialog, body, 6, apply)
def _open_lookup_dialog(self):
"""A lookup Buf: the text drawn for each value of `in`, with a prefix and suffix."""
comp = self.comp
if comp is None:
return
m = _Model(comp)
prefix, items, suffix = parse_lookup(m.text("text"))
fixed_labels = LOOKUP_ITEM_LABELS.get(comp.get("type", ""))
dialog, body = self._dialog("Label Text")
prefix_var = tk.StringVar(value=prefix)
suffix_var = tk.StringVar(value=suffix)
ttk.Label(body, text="Prefix").grid(row=0, column=0, sticky="w", pady=2)
ttk.Entry(body, textvariable=prefix_var, width=30).grid(row=0, column=1, sticky="ew")
if fixed_labels:
item_vars = []
for index, label in enumerate(fixed_labels):
var = tk.StringVar(value=items[index] if index < len(items) else "")
item_vars.append(var)
ttk.Label(body, text=label).grid(row=1 + index, column=0, sticky="w", pady=2)
ttk.Entry(body, textvariable=var, width=30).grid(
row=1 + index, column=1, sticky="ew")
next_row = 1 + len(fixed_labels)
def read_items():
values = [v.get() for v in item_vars]
while values and values[-1] == "":
values.pop()
return values
else:
ttk.Label(body, text="Items\n(one per line,\nfirst is value 0)",
justify=tk.LEFT).grid(row=1, column=0, sticky="nw", pady=2)
box = ttk.Frame(body)
box.grid(row=1, column=1, sticky="nsew", pady=2)
gutter = tk.Text(box, width=3, height=10, font=self._name_font, bg=NAME_BG,
relief=tk.FLAT, state="disabled", takefocus=0)
gutter.pack(side=tk.LEFT, fill=tk.Y)
text = tk.Text(box, width=30, height=10, font=self._name_font, undo=True)
text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
text.insert("1.0", "\n".join(items))
def renumber(_event=None):
lines = int(text.index("end-1c").split(".")[0])
gutter.configure(state="normal")
gutter.delete("1.0", tk.END)
gutter.insert("1.0", "\n".join(str(i) for i in range(lines)))
gutter.configure(state="disabled")
gutter.yview_moveto(text.yview()[0])
text.bind("<KeyRelease>", renumber)
text.configure(yscrollcommand=lambda first, last: gutter.yview_moveto(first))
renumber()
next_row = 2
def read_items():
values = text.get("1.0", "end-1c").split("\n")
while values and values[-1] == "":
values.pop()
return values
ttk.Label(body, text="Suffix").grid(row=next_row, column=0, sticky="w", pady=2)
ttk.Entry(body, textvariable=suffix_var, width=30).grid(
row=next_row, column=1, sticky="ew")
def apply():
parts = [prefix_var.get(), suffix_var.get(), *read_items()]
if any(ch in part for part in parts for ch in ";^"):
self._apply_values(dialog, comp, None,
"';' and '^' separate the parts, so no text can hold them.")
return
composed = compose_lookup(prefix_var.get(), read_items(), suffix_var.get())
self._apply_values(dialog, comp, *m.text_values("text", composed))
self._finish_dialog(dialog, body, next_row + 1, apply)
def _open_link_dialog(self, prop):
"""Links a slot of any component to this runtime slot, or unlinks it."""
comp = self.comp
root = app_state.xml_root_element
if comp is None or root is None:
return
import sax_file # sax_file imports the views; resolved late to keep that one-way
own_slot = _Model(comp).slots.get(prop.slot, {})
own_type = own_slot.get("type")
direction = "Link to" if prop.output else "Link from"
dialog, body = self._dialog(f"{direction} — {comp.get('name', '?')}.{prop.slot}")
dialog.resizable(True, True)
existing = slot_links(comp, prop.slot, prop.output)
end = "to" if prop.output else "from"
ttk.Label(body, text="Linked: " + (", ".join(l.get(end, "") for l in existing)
if existing else "nothing"),
foreground=LINK_FG).grid(row=0, column=0, columnspan=2, sticky="w")
comps = ttk.Treeview(body, show="tree", height=14, selectmode="browse")
comps.grid(row=1, column=0, sticky="nsew", pady=6, padx=(0, 6))
slots = ttk.Treeview(body, columns=("type",), show="tree headings", height=14,
selectmode="browse")
slots.heading("#0", text="Slot")
slots.heading("type", text="Type")
slots.column("#0", width=150)
slots.column("type", width=80)
slots.grid(row=1, column=1, sticky="nsew", pady=6)
body.columnconfigure(0, weight=1, minsize=220)
body.columnconfigure(1, weight=1)
body.rowconfigure(1, weight=1)
same_type = tk.BooleanVar(value=True)
ttk.Checkbutton(body, text=f"Only {own_type} slots", variable=same_type,
command=lambda: fill_slots()).grid(row=2, column=1, sticky="w")
by_iid = {}
def add_comps(parent_iid, element):
for child in element:
if child.tag != "comp":
continue
iid = comps.insert(parent_iid, tk.END, text=child.get("name", "?"),
open=False)
by_iid[iid] = child
add_comps(iid, child)
app = root.find("app")
add_comps("", app if app is not None else root)
def fill_slots(_event=None):
slots.delete(*slots.get_children())
sel = comps.selection()
if not sel:
return
element = by_iid[sel[0]]
for slot in schema.get_full_slots_for_type(element.get("type", "")):
if element is comp and slot["name"] == prop.slot:
continue
if slot["type"] == "void" and not prop.output:
continue
if same_type.get() and slot["type"] != own_type and slot["type"] != "void":
continue
slots.insert("", tk.END, iid=slot["name"], text=slot["name"],
values=(slot["type"],))
comps.bind("<<TreeviewSelect>>", fill_slots)
def apply():
sel, slot_sel = comps.selection(), slots.selection()
if not sel or not slot_sel:
messagebox.showerror(dialog.title(), "Pick a component and one of its slots.",
parent=dialog)
return
other = f"{get_component_path(by_iid[sel[0]], root)}.{slot_sel[0]}"
mine = slot_ref(comp, prop.slot)
dialog.destroy()
if prop.output:
sax_file.add_link(mine, other)
else:
# An input takes one source: the new link replaces what fed it.
for link in slot_links(comp, prop.slot, False):
sax_file.remove_link(link)
sax_file.add_link(other, mine)
self.repaint()
def unlink():
dialog.destroy()
for link in slot_links(comp, prop.slot, prop.output):
sax_file.remove_link(link)
self.repaint()
slots.bind("<Double-Button-1>", lambda e: apply())
buttons = ttk.Frame(body)
buttons.grid(row=3, column=0, columnspan=2, sticky="e", pady=(10, 0))
unlink_button = ttk.Button(buttons, text="Unlink", width=9, command=unlink)
unlink_button.pack(side=tk.LEFT, padx=(0, 16))
if not existing:
unlink_button.state(["disabled"])
ttk.Button(buttons, text="Link", width=9, command=apply).pack(side=tk.LEFT, padx=(0, 4))
ttk.Button(buttons, text="Cancel", width=9, command=dialog.destroy).pack(side=tk.LEFT)
dialog.bind("<Escape>", lambda e: dialog.destroy())
dialog.update_idletasks()
top = self.frame.winfo_toplevel()
dialog.geometry(f"+{top.winfo_rootx() + 200}+{top.winfo_rooty() + 80}")
dialog.grab_set()