Files
sedona_editor/ox_widget_properties.py
T
ArdaandClaude Opus 5.5 43d4849533 Add Workbench-style property sheet to the Page Editor
The Page Editor's right pane now shows the selected widget's properties
under the widget tree, laid out like Workbench's Ox editor rather than
as raw Sedona slots:

- layout (x, y, width, height), font (name, size, style)
- alignment / transparent / subscriptDigits from the attributes bits
- shape, RGB565 colours with a swatch and picker
- DateLabelSet date options split out of dateFormatOptions
- lookup labelText editor (prefix, items, suffix) and NumericLabel
  printf-format editor, min/max as one row
- image name picker, slot link chooser for in/blink/visibility/touch
- Buf text decoded from base64 (Latin-1), plain text in live mode

Edits commit on Enter/focus-out/selection as one undo step and are
written to the device in live mode. Canvas drag-moves now use the same
path, so they are undoable and reach the device too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:52:01 +03:00

1160 lines
45 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")
# --- 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}"
# --- images ---------------------------------------------------------------------------
def image_names():
"""Image names worth offering: the local image banks' files and the document's own."""
names = set()
home = getattr(app_state, "SEDONA_HOME", None)
if home:
for folder in glob.glob(os.path.join(home, "ontrol", "imageBank*")):
for path in glob.glob(os.path.join(folder, "*")):
stem, ext = os.path.splitext(os.path.basename(path))
if ext.lower() in IMAGE_EXTENSIONS:
names.add(stem)
root = app_state.xml_root_element
if root is not None:
for prop in root.iter("prop"):
if "imagefilename" in (prop.get("name") or "").lower() and prop.get("val"):
names.add(prop.get("val"))
return sorted(names, key=str.lower)
# --- 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 | 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)))
if m.has(*FONT_SLOTS):
consumed.update(FONT_SLOTS)
rows.append(Prop("font", "dialog", lambda: font_text(m), dialog="font"))
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):
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} "
return f"{prefix}{m.int('fontSize')}pt {name}"
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: m.text(name),
lambda text: m.text_values(name, text.strip()))
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 = []
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._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.reload()
self._canvas.yview_moveto(0)
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):
ttk.Button(cell, text="…", width=3, command=command).pack(side=tk.RIGHT, padx=1)
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 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.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)
def _build_image(self, cell, row):
combo = ttk.Combobox(cell, textvariable=row["var"], font=self._name_font,
postcommand=lambda: combo.configure(values=image_names()))
combo.pack(fill=tk.BOTH, expand=True, padx=1)
commit = self._bind_commit(combo, row)
combo.bind("<<ComboboxSelected>>", commit)
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())
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>", 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>", 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):
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_font_dialog(self):
comp = self.comp
if comp is None:
return
m = _Model(comp)
names, styles = font_names(m), font_styles(m)
dialog, body = self._dialog("Font")
def index_text(choices, index):
return choices[index] if 0 <= index < len(choices) else choices[0]
name_var = tk.StringVar(value=index_text(names, m.int("fontName")))
size_var = tk.StringVar(value=str(m.int("fontSize")))
style_var = tk.StringVar(value=index_text(styles, m.int("fontAttributes")))
ttk.Label(body, text="Name").grid(row=0, column=0, sticky="w", pady=2)
ttk.Combobox(body, textvariable=name_var, values=list(names), state="readonly",
width=14).grid(row=0, column=1, sticky="ew", pady=2)
ttk.Label(body, text="Size (pt)").grid(row=1, column=0, sticky="w", pady=2)
ttk.Spinbox(body, textvariable=size_var, from_=1, to=255, width=14).grid(
row=1, column=1, sticky="ew", pady=2)
ttk.Label(body, text="Style").grid(row=2, column=0, sticky="w", pady=2)
ttk.Combobox(body, textvariable=style_var, values=list(styles), state="readonly",
width=14).grid(row=2, column=1, sticky="ew", pady=2)
def apply():
size_raw, error = m.check("fontSize", size_var.get())
values = None if error else {
"fontName": str(names.index(name_var.get())),
"fontSize": size_raw,
"fontAttributes": str(styles.index(style_var.get())),
}
self._apply_values(dialog, comp, values, error and f"Size: {error}")
dialog.bind("<Return>", lambda e: apply())
self._finish_dialog(dialog, body, 3, apply)
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()