From 43d48495337379c718f384797e169bcdf2cb9f48 Mon Sep 17 00:00:00 2001 From: Arda Aydin Date: Thu, 24 Sep 2026 13:52:01 +0300 Subject: [PATCH] Add Workbench-style property sheet to the Page Editor The Page Editor's right pane now shows the selected widget's properties under the widget tree, laid out like Workbench's Ox editor rather than as raw Sedona slots: - layout (x, y, width, height), font (name, size, style) - alignment / transparent / subscriptDigits from the attributes bits - shape, RGB565 colours with a swatch and picker - DateLabelSet date options split out of dateFormatOptions - lookup labelText editor (prefix, items, suffix) and NumericLabel printf-format editor, min/max as one row - image name picker, slot link chooser for in/blink/visibility/touch - Buf text decoded from base64 (Latin-1), plain text in live mode Edits commit on Enter/focus-out/selection as one undo step and are written to the device in live mode. Canvas drag-moves now use the same path, so they are undoable and reach the device too. Co-Authored-By: Claude Opus 5.5 --- app_state.py | 2 +- ox_page_view.py | 98 +++- ox_widget_properties.py | 1159 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 1232 insertions(+), 27 deletions(-) create mode 100644 ox_widget_properties.py 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()