diff --git a/app_state.py b/app_state.py index dda9367..b66257c 100644 --- a/app_state.py +++ b/app_state.py @@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it. import configparser import os -VERSION = "0.0.0.128" +VERSION = "0.0.0.129" PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties") DEFAULT_GRID_SIZE = 10 DEFAULT_ADD_TIMESTAMP = True diff --git a/ox_page_view.py b/ox_page_view.py index 072cd13..d294e5f 100644 --- a/ox_page_view.py +++ b/ox_page_view.py @@ -23,6 +23,10 @@ except ImportError: _HAS_PIL = False import app_state +import live_hooks +import undo +from ox_widget_properties import OxWidgetPropertiesPanel +from sax_dom import set_prop_value HEADER_BG = "#f2f2f2" @@ -92,15 +96,6 @@ def _get_prop(comp, name, default=None): return prop.get("val", default) if prop is not None else default -def _set_prop(comp, name, value): - prop = comp.find(f"prop[@name='{name}']") - if prop is not None: - prop.set("val", value) - else: - import xml.etree.ElementTree as ET - ET.SubElement(comp, "prop", name=name, val=value) - - def _short_type(full_type): short = full_type.rsplit("::", 1)[-1] if "::" in full_type else full_type return _SHORT_TYPE_NAMES.get(short, short) @@ -189,7 +184,7 @@ class OxPageViewPanel: self._build_header() self._build_body() self._build_footer() - self._render_page() + self.refresh() @property def _screen_canvas_x(self): @@ -230,9 +225,16 @@ class OxPageViewPanel: self._build_canvas(canvas_frame) body.add(canvas_frame, weight=1) - tree_frame = ttk.Frame(body) + # Workbench's layout: the widget tree above, the selected widget's sheet below. + side = ttk.PanedWindow(body, orient=tk.VERTICAL) + body.add(side, weight=0) + + tree_frame = ttk.Frame(side) self._build_widget_tree(tree_frame) - body.add(tree_frame, weight=0) + side.add(tree_frame, weight=1) + + self._props = OxWidgetPropertiesPanel(side, self._commit_props, self._repaint_all) + side.add(self._props.frame, weight=3) def _build_widget_tree(self, parent): ttk.Label(parent, text="Widgets", font=("TkDefaultFont", 9, "bold")).pack( @@ -242,7 +244,7 @@ class OxPageViewPanel: tree_container.pack(fill=tk.BOTH, expand=True, padx=4, pady=4) self._tree = ttk.Treeview(tree_container, show="tree", selectmode="browse", - height=12) + height=8) vscroll = ttk.Scrollbar(tree_container, orient=tk.VERTICAL, command=self._tree.yview) self._tree.configure(yscrollcommand=vscroll.set) @@ -386,6 +388,8 @@ class OxPageViewPanel: c.delete("selection_handle") self._selected_widget = widget + # With nothing selected the sheet shows the page itself, as Workbench does. + self._props.show(widget if widget is not None else self.comp) if widget is None: return @@ -433,6 +437,48 @@ class OxPageViewPanel: fill=HANDLE_COLOUR, outline=HANDLE_COLOUR, tags=("selection_handle",)) + def _repaint_all(self): + """Every view redrawn from the document, this one included.""" + if self.on_change is not None: + self.on_change() + else: + self.refresh() + + def _commit_props(self, comp, values): + """Writes {prop: raw} to `comp` as one undoable step, and to the device when live. + + Returns whether anything changed; a change repaints every view, this one included. + """ + changes = [] + for name, after in values.items(): + prop = comp.find(f"prop[@name='{name}']") + before = prop.get("val") if prop is not None else None + if before != after: + changes.append((comp, name, before, after)) + if not changes: + return False + + live_failures = [] + for element, name, _before, after in changes: + set_prop_value(element, name, after) + if live_hooks.is_live() and not live_hooks.live_set_prop_value(element, name, after): + live_failures.append(name) + + if not app_state.live_mode: + undo.push(undo.SlotValueCommand(changes)) + app_state.mark_dirty() + + self._repaint_all() + + who = comp.get("name", "?") + if live_failures: + app_state.set_status( + f"{who}: written locally but failed on device: {', '.join(live_failures)}") + else: + app_state.set_status( + f"{who}: {', '.join(name for _, name, _, _ in changes)} changed.") + return True + def _on_canvas_click(self, event): c = self._canvas cx = c.canvasx(event.x) @@ -484,9 +530,11 @@ class OxPageViewPanel: self._drag["tag"] = None return + self._drag["tag"] = None c = self._canvas z = self._zoom bbox = c.bbox(f"{tag} && ox_widget_rect") + moved = False if bbox: new_x = int((bbox[0] - self._screen_canvas_x * z) / z) new_y = int((bbox[1] - self._screen_canvas_y * z) / z) @@ -494,19 +542,12 @@ class OxPageViewPanel: new_x = max(0, min(new_x, self._screen_w)) new_y = max(0, min(new_y, self._screen_h)) - old_x = _get_prop(widget, "x", "0") - old_y = _get_prop(widget, "y", "0") + moved = self._commit_props(widget, {"x": str(new_x), "y": str(new_y)}) - if str(new_x) != old_x or str(new_y) != old_y: - _set_prop(widget, "x", str(new_x)) - _set_prop(widget, "y", str(new_y)) - app_state.mark_dirty() - - self._drag["tag"] = None - - self._render_page() - if widget is not None: - self._select_widget(widget) + # A commit re-renders through refresh(); a drag that went nowhere still has to + # put back the handles the motion took down. + if not moved: + self.refresh() def _on_canvas_right_click(self, event): c = self._canvas @@ -605,10 +646,15 @@ class OxPageViewPanel: zw = int(self._canvas_w * z) zh = int(self._canvas_h * z) self._canvas.config(scrollregion=(0, 0, zw, zh)) - self._render_page() + self.refresh() def refresh(self): + """Redraws from the document, keeping the selection when the widget survives.""" + selected = self._selected_widget self._render_page() + if selected is not None and selected not in _get_widget_children(self.comp): + selected = None + self._select_widget(selected) def _background(self): return app_state.DIRTY_TINT if app_state.is_dirty else CANVAS_BG diff --git a/ox_widget_properties.py b/ox_widget_properties.py new file mode 100644 index 0000000..b9f76ef --- /dev/null +++ b/ox_widget_properties.py @@ -0,0 +1,1159 @@ +"""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))) + + 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.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_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()