"""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") # --- 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 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), dialog="layout")) 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("", 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._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 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("", 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) 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("<>", 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("<>", 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("", 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("", 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("", 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_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("", 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("", 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()