"""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 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 /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 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("", lambda e: self._canvas.configure( scrollregion=self._canvas.bbox("all"))) self._canvas.bind("", lambda e: self._canvas.itemconfigure( self._sheet_window, width=e.width)) self._canvas.bind("", lambda e: self._canvas.bind_all( "", self._on_wheel)) self._canvas.bind("", lambda e: self._canvas.unbind_all("")) 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 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("", commit) widget.bind("", commit) widget.bind("", commit) widget.bind("", 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("<>", 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("<>", 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("", 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("", 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("", 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("", 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("", 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("", 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("<>", 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("", 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("", 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()