The Page Editor canvas now paints widgets WYSIWYG instead of as labelled placeholder boxes, following the ontrolOx draw() code and the R-ION firmware: - text drawn with the device's own bitmap fonts (fontBank .bin / fontBankXL .oxfl), Windows font fallback; alignment, clipping at the box edge, half-size subscript digits, transparent vs filled boxes; the line is centred on the box even when the font is taller - each widget shows the text its `in` selects: NumericLabel formats, lookup items, Boolean true/false text and colours, date and time - images from the local image banks, centred or cropped; missing ones drawn as the device's red/white checker; page background image - 10x10 pixel grid over the page colour, with a Grid toggle - widgets hit-tested by layout box so transparent labels stay clickable; hidden widgets shown as a dashed outline - layout row gets an X/Y/Width/Height dialog - scaled device frame cached per zoom (redraws ~15 ms) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1202 lines
47 KiB
Python
1202 lines
47 KiB
Python
"""The Page Editor's right-hand property sheet, in the shape Workbench's Ox editor uses.
|
|
|
|
The Sedona slot sheet lists a widget's raw slots; this one lists what a screen designer
|
|
thinks in. Several slots fold into one property and one slot splits into several:
|
|
|
|
layout x, y, width, height "0, 20, 320, 40"
|
|
font fontName, fontSize, "32pt Tahoma", "Bold 16pt Lato"
|
|
fontAttributes
|
|
alignment attributes bits 4-5 Left / Center / Right
|
|
transparent attributes bit 3 (inverted) bit 3 set means "draw background"
|
|
subscriptDigits attributes bits 0-2 trailing characters drawn half size
|
|
shape shape Rectangle / Ellipse
|
|
*Color RGB565 shorts a swatch and a colour chooser
|
|
text NumericLabel's printf format prefix, value, decimals, suffix
|
|
labelText a lookup Buf prefix, one item per index, suffix
|
|
minmax min, max "0, 100"
|
|
displayYear .. DateLabelSet's four settings from one bit field
|
|
dateFormatOptions
|
|
in, blink .. runtime slots the slot linked to them, and a chooser
|
|
|
|
The `attributes` bits are the ones ontrolOx's Label.sedona documents; the date bits and
|
|
the lookup format ("^prefix;item0;item1;suffix^") are read the way DateLabelSet.sedona
|
|
and the R-ION firmware's Process_Text read them. Every other config slot is listed by
|
|
its own name with an editor chosen from its type.
|
|
|
|
Edits commit as soon as a field is left, Return is pressed, a choice is picked or a
|
|
dialog is OKed, the way Workbench's sheet behaves; the panel hands the new <prop> values
|
|
to the `commit` callback it was built with and never writes a prop itself.
|
|
"""
|
|
|
|
import base64
|
|
import binascii
|
|
import glob
|
|
import os
|
|
import re
|
|
import tkinter as tk
|
|
from tkinter import colorchooser, font as tkfont, messagebox, ttk
|
|
|
|
import app_state
|
|
import properties
|
|
import schema
|
|
from sax_dom import get_component_path
|
|
|
|
SHEET_BG = "#ffffff"
|
|
GRID_LINE = "#d4d4d4"
|
|
NAME_BG = "#f4f4f4"
|
|
READONLY_FG = "#707070"
|
|
LINK_FG = "#1f4e79"
|
|
HEADER_BG = "#dfe8f6"
|
|
|
|
ALIGNMENTS = ("Left", "Center", "Right")
|
|
SHAPES = ("Rectangle", "Ellipse")
|
|
YEAR_DIGITS = ("Four", "Two")
|
|
DAY_MONTH_ORDERS = ("DD_MM_YYYY", "MM_DD_YYYY")
|
|
|
|
ALIGN_SHIFT = 4
|
|
ALIGN_MASK = 0x30
|
|
BACKGROUND_BIT = 0x08
|
|
SUBSCRIPT_MASK = 0x07
|
|
|
|
# DateLabelSet.dateFormatOptions, as DateLabelSet.draw() tests it.
|
|
DATE_HIDE_YEAR = 0x01
|
|
DATE_TWO_DIGIT_YEAR = 0x02
|
|
DATE_LEADING_ZERO = 0x04 # the firmware's `dayMonthLeadingZero`
|
|
DATE_MONTH_FIRST = 0x08
|
|
|
|
LAYOUT_SLOTS = ("x", "y", "width", "height")
|
|
FONT_SLOTS = ("fontName", "fontSize", "fontAttributes")
|
|
|
|
# Runtime slots Workbench lists as "linked to" rather than as a value. `touch` is the
|
|
# widget's output; the rest are inputs something else drives.
|
|
LINK_SLOTS = ("in", "inInt", "blink", "visibility", "touch")
|
|
OUTPUT_LINK_SLOTS = ("touch",)
|
|
|
|
# The lookup Bufs, and the labels Workbench gives their items when the count is fixed.
|
|
LOOKUP_ITEM_LABELS = {
|
|
"ontrolOx::ScheduleLabelSet": ("False text", "True text", "Null text"),
|
|
}
|
|
LOOKUP_TYPES = ("ontrolOx::NumericSelectLabel", "ontrolOx::ScheduleLabelSet")
|
|
NUMERIC_FORMAT_TYPES = ("ontrolOx::NumericLabel", "ontrolOx::NumericLabelSet")
|
|
|
|
# The firmware draws single-byte text, so a Buf's bytes are Latin-1: 'm³' is 6D B3.
|
|
BUF_ENCODING = "latin-1"
|
|
|
|
IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".bmp", ".bin")
|
|
|
|
|
|
# --- value conversions ----------------------------------------------------------------
|
|
|
|
def rgb565_to_hex(value):
|
|
try:
|
|
v = int(value) & 0xFFFF
|
|
except (TypeError, ValueError):
|
|
return "#ffffff"
|
|
r = ((v >> 11) & 0x1F) * 255 // 31
|
|
g = ((v >> 5) & 0x3F) * 255 // 63
|
|
b = (v & 0x1F) * 255 // 31
|
|
return f"#{r:02x}{g:02x}{b:02x}"
|
|
|
|
|
|
def hex_to_rgb565(colour):
|
|
colour = colour.lstrip("#")
|
|
r, g, b = int(colour[0:2], 16), int(colour[2:4], 16), int(colour[4:6], 16)
|
|
return ((r * 31 + 127) // 255) << 11 | ((g * 63 + 127) // 255) << 5 | ((b * 31 + 127) // 255)
|
|
|
|
|
|
def _as_int(raw, fallback=0):
|
|
try:
|
|
return int(float(raw))
|
|
except (TypeError, ValueError):
|
|
return fallback
|
|
|
|
|
|
def _is_colour_slot(slot):
|
|
return slot["type"] in ("short", "int") and slot["name"].lower().endswith("color")
|
|
|
|
|
|
def _is_image_slot(slot):
|
|
return slot["type"] == "sys::Buf" and "imagefilename" in slot["name"].lower()
|
|
|
|
|
|
def _is_binary_buf(slot):
|
|
"""A Buf the .sax stores as base64 of its bytes, NUL included: 'QWxhcm0A' is 'Alarm'."""
|
|
return slot["type"] == "sys::Buf" and slot["facets"].get("asStr") is not True
|
|
|
|
|
|
def decode_buf(raw):
|
|
"""The text a base64 Buf holds, or None when the value is not base64 at all."""
|
|
if not raw:
|
|
return ""
|
|
try:
|
|
data = base64.b64decode(raw, validate=True)
|
|
except (binascii.Error, ValueError):
|
|
return None
|
|
return data.split(b"\0", 1)[0].decode(BUF_ENCODING)
|
|
|
|
|
|
def encode_buf(text):
|
|
return base64.b64encode(text.encode(BUF_ENCODING) + b"\0").decode("ascii") if text else ""
|
|
|
|
|
|
def check_buf_text(text, limit):
|
|
"""None when `text` fits a Buf of `limit` bytes (its NUL included), else the reason."""
|
|
try:
|
|
size = len(text.encode(BUF_ENCODING)) + 1 if text else 0
|
|
except UnicodeEncodeError as error:
|
|
return f"'{text[error.start]}' cannot be shown on the device"
|
|
if isinstance(limit, (int, float)) and size > limit:
|
|
return f"longer than {int(limit) - 1} characters"
|
|
return None
|
|
|
|
|
|
def humanise_type(comp_type):
|
|
"""'ontrolOx::PushButtonLabel' -> 'Push Button Label'."""
|
|
short = comp_type.rsplit("::", 1)[-1]
|
|
return properties.humanise(short)
|
|
|
|
|
|
# --- NumericLabel's printf format -----------------------------------------------------
|
|
|
|
_FLOAT_SPEC = re.compile(r"%(?:\.(\d*))?f")
|
|
|
|
|
|
def parse_numeric_format(text):
|
|
"""'T1: %.1f C' -> ('T1: ', True, 1, ' C'); None for a format this cannot split.
|
|
|
|
drawNumericTextBox hands the Buf to sprintf with the value, so '%%' is a literal
|
|
percent sign and a text with no conversion at all is a plain caption.
|
|
"""
|
|
i = 0
|
|
while i < len(text):
|
|
if text[i] == "%":
|
|
if text[i + 1:i + 2] == "%":
|
|
i += 2
|
|
continue
|
|
match = _FLOAT_SPEC.match(text, i)
|
|
if not match:
|
|
return None
|
|
digits = match.group(1)
|
|
precision = 6 if digits is None else int(digits or 0)
|
|
suffix = text[match.end():]
|
|
if "%" in suffix.replace("%%", ""):
|
|
return None
|
|
return (text[:i].replace("%%", "%"), True, precision,
|
|
suffix.replace("%%", "%"))
|
|
i += 1
|
|
return text.replace("%%", "%"), False, 1, ""
|
|
|
|
|
|
def compose_numeric_format(prefix, show_value, precision, suffix):
|
|
text = prefix.replace("%", "%%")
|
|
if show_value:
|
|
text += f"%.{precision}f" + suffix.replace("%", "%%")
|
|
return text
|
|
|
|
|
|
def numeric_format_preview(text):
|
|
parts = parse_numeric_format(text)
|
|
if parts is None:
|
|
return text
|
|
prefix, show_value, precision, suffix = parts
|
|
if not show_value:
|
|
return prefix
|
|
placeholder = "#" + ("." + "#" * precision if precision else "")
|
|
return f"{prefix}{placeholder}{suffix}"
|
|
|
|
|
|
# --- lookup text ----------------------------------------------------------------------
|
|
|
|
def parse_lookup(text):
|
|
"""'^TEST: ;Off;On;' -> ('TEST: ', ['Off', 'On'], '')."""
|
|
prefix = suffix = ""
|
|
body = text or ""
|
|
if body.startswith("^"):
|
|
end = body.find(";")
|
|
if end > 0:
|
|
prefix, body = body[1:end], body[end + 1:]
|
|
if body.endswith("^"):
|
|
end = body.rfind(";", 0, len(body) - 1)
|
|
suffix, body = body[end + 1:-1], body[:end + 1]
|
|
items = body.split(";")
|
|
if items and items[-1] == "":
|
|
items.pop()
|
|
return prefix, items, suffix
|
|
|
|
|
|
def compose_lookup(prefix, items, suffix):
|
|
text = f"^{prefix};" if prefix else ""
|
|
text += "".join(f"{item};" for item in items)
|
|
if suffix:
|
|
text += f"{suffix}^"
|
|
return text
|
|
|
|
|
|
def lookup_preview(text):
|
|
prefix, items, suffix = parse_lookup(text)
|
|
return f"{prefix}[{' | '.join(items)}]{suffix}"
|
|
|
|
|
|
# --- images ---------------------------------------------------------------------------
|
|
|
|
def image_names():
|
|
"""Image names worth offering: the local image banks' files and the document's own."""
|
|
names = set()
|
|
home = getattr(app_state, "SEDONA_HOME", None)
|
|
if home:
|
|
for folder in glob.glob(os.path.join(home, "ontrol", "imageBank*")):
|
|
for path in glob.glob(os.path.join(folder, "*")):
|
|
stem, ext = os.path.splitext(os.path.basename(path))
|
|
if ext.lower() in IMAGE_EXTENSIONS:
|
|
names.add(stem)
|
|
root = app_state.xml_root_element
|
|
if root is not None:
|
|
for prop in root.iter("prop"):
|
|
if "imagefilename" in (prop.get("name") or "").lower() and prop.get("val"):
|
|
names.add(prop.get("val"))
|
|
return sorted(names, key=str.lower)
|
|
|
|
|
|
# --- links ----------------------------------------------------------------------------
|
|
|
|
def slot_ref(comp, slot_name):
|
|
root = app_state.xml_root_element
|
|
return f"{get_component_path(comp, root)}.{slot_name}" if root is not None else ""
|
|
|
|
|
|
def slot_links(comp, slot_name, output):
|
|
"""The <link> elements that feed this slot, or that it feeds when `output`."""
|
|
root = app_state.xml_root_element
|
|
links = root.find("links") if root is not None else None
|
|
if links is None:
|
|
return []
|
|
ref = slot_ref(comp, slot_name)
|
|
end = "from" if output else "to"
|
|
return [link for link in links if link.tag == "link" and link.get(end) == ref]
|
|
|
|
|
|
# --- one property ---------------------------------------------------------------------
|
|
|
|
class Prop:
|
|
"""A row of the sheet: how to read its text from the component and how to write it.
|
|
|
|
`read()` gives the text shown; `write(text)` gives ({prop_name: raw}, error). A row
|
|
whose `write` is None is read-only. `dialog` names the panel's editor for rows
|
|
edited through one, and `unit` is the symbol shown after the value.
|
|
"""
|
|
|
|
def __init__(self, label, kind, read, write=None, choices=(), dialog=None, unit="",
|
|
slot=None, output=False):
|
|
self.label = label
|
|
self.kind = kind # text | choice | colour | dialog | image | link | readonly
|
|
self.read = read
|
|
self.write = write
|
|
self.choices = choices
|
|
self.dialog = dialog
|
|
self.unit = unit
|
|
self.slot = slot
|
|
self.output = output
|
|
|
|
|
|
class _Model:
|
|
"""Reads and packs one component's slots for the rows built on it."""
|
|
|
|
def __init__(self, comp):
|
|
self.comp = comp
|
|
self.slots = {s["name"]: s for s in schema.get_full_slots_for_type(comp.get("type", ""))}
|
|
|
|
def raw(self, name):
|
|
slot = self.slots.get(name)
|
|
if slot is None:
|
|
return None
|
|
return properties.effective_value(self.comp, slot)
|
|
|
|
def int(self, name, fallback=0):
|
|
return _as_int(self.raw(name), fallback)
|
|
|
|
def has(self, *names):
|
|
return all(n in self.slots for n in names)
|
|
|
|
def check(self, name, raw_text):
|
|
"""parse_text against the slot's own facets, so ranges are the manifest's."""
|
|
return properties.parse_text(self.slots[name], raw_text)
|
|
|
|
def text(self, name):
|
|
"""A Buf's text, in whichever form the document holds it.
|
|
|
|
A .sax carries a binary Buf as base64. In live mode the document is filled from
|
|
the device, which reads the Buf over Sox as a Str, so the prop already holds
|
|
plain text and a write goes out as a Str too -- the same bytes on the wire, NUL
|
|
included. Base64 there would reach the device as the literal characters.
|
|
"""
|
|
raw = self.raw(name) or ""
|
|
if app_state.live_mode or not _is_binary_buf(self.slots[name]):
|
|
return raw
|
|
text = decode_buf(raw)
|
|
return raw if text is None else text
|
|
|
|
def text_values(self, name, text):
|
|
"""({name: raw}, error) for putting `text` into Buf `name`."""
|
|
slot = self.slots[name]
|
|
error = check_buf_text(text, properties.value_bounds(slot)[1])
|
|
if error:
|
|
return None, error
|
|
if app_state.live_mode or not _is_binary_buf(slot):
|
|
return {name: text}, None
|
|
return {name: encode_buf(text)}, None
|
|
|
|
|
|
def build_props(comp):
|
|
"""Every row for `comp`, sorted by name the way Workbench sorts its sheet."""
|
|
m = _Model(comp)
|
|
comp_type = comp.get("type", "")
|
|
rows = [Prop("_name_", "readonly", lambda: comp.get("name", ""))]
|
|
consumed = {"meta"}
|
|
|
|
if m.has(*LAYOUT_SLOTS):
|
|
consumed.update(LAYOUT_SLOTS)
|
|
rows.append(Prop("layout", "text", lambda: ", ".join(
|
|
str(m.int(n)) for n in LAYOUT_SLOTS), _layout_writer(m), dialog="layout"))
|
|
|
|
if m.has(*FONT_SLOTS):
|
|
consumed.update(FONT_SLOTS)
|
|
rows.append(Prop("font", "dialog", lambda: font_text(m), dialog="font"))
|
|
|
|
if m.has("attributes"):
|
|
consumed.add("attributes")
|
|
rows.extend(_attribute_props(m))
|
|
|
|
if m.has("shape"):
|
|
consumed.add("shape")
|
|
rows.append(_enum_prop(m, "shape", "shape", SHAPES))
|
|
|
|
if m.has("dateFormatOptions"):
|
|
consumed.add("dateFormatOptions")
|
|
rows.extend(_date_props(m))
|
|
if m.has("dateSeparatorCharacter"):
|
|
consumed.add("dateSeparatorCharacter")
|
|
slot = m.slots["dateSeparatorCharacter"]
|
|
rows.append(_enum_prop(m, "separatorCharacter", "dateSeparatorCharacter",
|
|
properties.enum_range(slot) or ("slash", "dot", "dash", "space")))
|
|
|
|
if m.has("min", "max") and comp_type == "ontrolOx::NumericLabelSet":
|
|
consumed.update(("min", "max"))
|
|
rows.append(_minmax_prop(m))
|
|
|
|
if m.has("text") and comp_type in LOOKUP_TYPES:
|
|
consumed.add("text")
|
|
rows.append(Prop("labelText", "dialog", lambda: lookup_preview(m.text("text")),
|
|
dialog="lookup"))
|
|
elif m.has("text") and comp_type in NUMERIC_FORMAT_TYPES:
|
|
consumed.add("text")
|
|
rows.append(Prop("text", "dialog", lambda: numeric_format_preview(m.text("text")),
|
|
dialog="numeric"))
|
|
|
|
rows.append(Prop("meta", "readonly", lambda: properties.meta_summary(comp)[0]))
|
|
|
|
for name, slot in m.slots.items():
|
|
if name in consumed or properties.is_hidden(slot) or slot["type"] == "void":
|
|
continue
|
|
if name in LINK_SLOTS:
|
|
rows.append(_link_prop(m, slot))
|
|
continue
|
|
if not properties.is_editable(slot):
|
|
continue
|
|
rows.append(_slot_prop(m, slot))
|
|
|
|
rows.sort(key=lambda p: p.label.lower())
|
|
return rows
|
|
|
|
|
|
def _enum_prop(m, label, name, names):
|
|
def read():
|
|
value = m.int(name)
|
|
return names[value] if 0 <= value < len(names) else str(value)
|
|
|
|
def write(text):
|
|
if text in names:
|
|
return {name: str(names.index(text))}, None
|
|
return None, f"expected one of: {', '.join(names)}"
|
|
|
|
return Prop(label, "choice", read, write, names)
|
|
|
|
|
|
def _layout_writer(m):
|
|
def write(text):
|
|
parts = [p.strip() for p in text.replace(";", ",").split(",") if p.strip()]
|
|
if len(parts) != 4:
|
|
return None, "expected x, y, width, height"
|
|
values = {}
|
|
for name, part in zip(LAYOUT_SLOTS, parts):
|
|
try:
|
|
number = str(int(round(float(part))))
|
|
except ValueError:
|
|
return None, f"{name}: expected a whole number"
|
|
raw, error = m.check(name, number)
|
|
if error:
|
|
return None, f"{name}: {error}"
|
|
values[name] = raw
|
|
return values, None
|
|
return write
|
|
|
|
|
|
def font_names(m):
|
|
return properties.enum_range(m.slots["fontName"]) or ("Tahoma",)
|
|
|
|
|
|
def font_styles(m):
|
|
return properties.enum_range(m.slots["fontAttributes"]) or ("Normal", "Bold", "Italic")
|
|
|
|
|
|
def font_text(m):
|
|
names, styles = font_names(m), font_styles(m)
|
|
name_i, style_i = m.int("fontName"), m.int("fontAttributes")
|
|
name = names[name_i] if 0 <= name_i < len(names) else f"font {name_i}"
|
|
style = styles[style_i] if 0 <= style_i < len(styles) else ""
|
|
prefix = "" if style_i == 0 or not style else f"{style} "
|
|
return f"{prefix}{m.int('fontSize')}pt {name}"
|
|
|
|
|
|
def _bit_props(m, name, fallback, specs):
|
|
"""Rows that each own some bits of one byte slot: (label, mask, choices, values)."""
|
|
def current():
|
|
return m.int(name, fallback) & 0xFF
|
|
|
|
def row(label, mask, choices, values):
|
|
def read():
|
|
bits = current() & mask
|
|
return choices[values.index(bits)] if bits in values else str(bits)
|
|
|
|
def write(text):
|
|
if text not in choices:
|
|
return None, f"expected one of: {', '.join(choices)}"
|
|
bits = values[choices.index(text)]
|
|
return {name: str((current() & ~mask & 0xFF) | bits)}, None
|
|
|
|
return Prop(label, "choice", read, write, choices)
|
|
|
|
return [row(*spec) for spec in specs]
|
|
|
|
|
|
def _attribute_props(m):
|
|
return _bit_props(m, "attributes", 0x08, [
|
|
("alignment", ALIGN_MASK, ALIGNMENTS, (0x00, 0x10, 0x20)),
|
|
("transparent", BACKGROUND_BIT, ("true", "false"), (0, BACKGROUND_BIT)),
|
|
("subscriptDigits", SUBSCRIPT_MASK, tuple(str(n) for n in range(8)), tuple(range(8))),
|
|
])
|
|
|
|
|
|
def _date_props(m):
|
|
return _bit_props(m, "dateFormatOptions", 0, [
|
|
("displayYear", DATE_HIDE_YEAR, ("true", "false"), (0, DATE_HIDE_YEAR)),
|
|
("yearDigits", DATE_TWO_DIGIT_YEAR, YEAR_DIGITS, (0, DATE_TWO_DIGIT_YEAR)),
|
|
("dayMonthLeadingZero", DATE_LEADING_ZERO, ("true", "false"), (DATE_LEADING_ZERO, 0)),
|
|
("dayMonthOrder", DATE_MONTH_FIRST, DAY_MONTH_ORDERS, (0, DATE_MONTH_FIRST)),
|
|
])
|
|
|
|
|
|
def _minmax_prop(m):
|
|
def read():
|
|
return ", ".join(properties.display_text(m.slots[n], m.raw(n)) for n in ("min", "max"))
|
|
|
|
def write(text):
|
|
parts = [p.strip() for p in text.replace(";", ",").split(",")]
|
|
if len(parts) != 2:
|
|
return None, "expected min, max"
|
|
values = {}
|
|
for name, part in zip(("min", "max"), parts):
|
|
raw, error = m.check(name, part or "null")
|
|
if error:
|
|
return None, f"{name}: {error}"
|
|
values[name] = raw
|
|
low, high = values["min"], values["max"]
|
|
if "null" not in (low, high) and float(low) > float(high):
|
|
return None, "min is above max"
|
|
return values, None
|
|
|
|
return Prop("minmax", "text", read, write)
|
|
|
|
|
|
def _link_prop(m, slot):
|
|
name = slot["name"]
|
|
return Prop(name, "link", lambda: properties.display_text(slot, m.raw(name)),
|
|
slot=name, output=name in OUTPUT_LINK_SLOTS)
|
|
|
|
|
|
def _slot_prop(m, slot):
|
|
name = slot["name"]
|
|
unit = properties.unit_symbol(slot["facets"].get("unit"))
|
|
|
|
def read():
|
|
return properties.display_text(slot, m.raw(name))
|
|
|
|
def write(text):
|
|
raw, error = properties.parse_text(slot, text)
|
|
return ({name: raw}, None) if error is None else (None, error)
|
|
|
|
if _is_colour_slot(slot):
|
|
return Prop(name, "colour", lambda: str(m.int(name)),
|
|
lambda text: ({name: text}, None))
|
|
|
|
if _is_image_slot(slot):
|
|
return Prop(name, "image", lambda: m.text(name),
|
|
lambda text: m.text_values(name, text.strip()))
|
|
|
|
if slot["type"] == "sys::Buf":
|
|
return Prop(name, "text", lambda: m.text(name), lambda text: m.text_values(name, text))
|
|
|
|
if slot["type"] == "bool":
|
|
false_text, true_text = properties.bool_texts(slot)
|
|
choices = (true_text, false_text)
|
|
if properties.allows_null(slot):
|
|
choices += (properties.null_text(slot),)
|
|
return Prop(name, "choice", read, write, choices)
|
|
|
|
names = properties.enum_range(slot)
|
|
if names and slot["type"] in properties.INTEGER_TYPES:
|
|
return Prop(name, "choice", read, write, names)
|
|
|
|
return Prop(name, "text", read, write, unit=unit)
|
|
|
|
|
|
# --- the sheet ------------------------------------------------------------------------
|
|
|
|
class OxWidgetPropertiesPanel:
|
|
"""A scrolling two-column sheet for one component at a time.
|
|
|
|
`commit(comp, {prop_name: raw})` writes the values and is expected to call back into
|
|
`reload()` (via the page's refresh) once the document holds them. `repaint()` is
|
|
called after links are added or removed here, which go through sax_file instead.
|
|
"""
|
|
|
|
def __init__(self, parent, commit, repaint):
|
|
self.commit = commit
|
|
self.repaint = repaint
|
|
self.comp = None
|
|
self.rows = []
|
|
self.frame = ttk.Frame(parent)
|
|
|
|
self._name_font = tkfont.Font(family="Segoe UI", size=9)
|
|
self._bold_font = tkfont.Font(family="Segoe UI", size=9, weight="bold")
|
|
|
|
header = tk.Frame(self.frame, bg=HEADER_BG, padx=6, pady=4)
|
|
header.pack(fill=tk.X)
|
|
tk.Label(header, text="Properties", font=self._bold_font,
|
|
bg=HEADER_BG).pack(anchor="w")
|
|
|
|
self._title_var = tk.StringVar()
|
|
self._type_var = tk.StringVar()
|
|
info = tk.Frame(self.frame, padx=6, pady=3)
|
|
info.pack(fill=tk.X)
|
|
tk.Label(info, textvariable=self._title_var, font=self._bold_font,
|
|
anchor="w").pack(fill=tk.X)
|
|
tk.Label(info, textvariable=self._type_var, font=self._name_font,
|
|
fg="#555555", anchor="w").pack(fill=tk.X)
|
|
|
|
body = ttk.Frame(self.frame)
|
|
body.pack(fill=tk.BOTH, expand=True)
|
|
self._canvas = tk.Canvas(body, bg=SHEET_BG, highlightthickness=0, width=300)
|
|
vscroll = ttk.Scrollbar(body, orient=tk.VERTICAL, command=self._canvas.yview)
|
|
self._canvas.configure(yscrollcommand=vscroll.set)
|
|
vscroll.pack(side=tk.RIGHT, fill=tk.Y)
|
|
self._canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
|
|
self._sheet = tk.Frame(self._canvas, bg=GRID_LINE)
|
|
self._sheet_window = self._canvas.create_window(0, 0, anchor="nw", window=self._sheet)
|
|
self._sheet.columnconfigure(1, weight=1)
|
|
self._sheet.bind("<Configure>", lambda e: self._canvas.configure(
|
|
scrollregion=self._canvas.bbox("all")))
|
|
self._canvas.bind("<Configure>", lambda e: self._canvas.itemconfigure(
|
|
self._sheet_window, width=e.width))
|
|
self._canvas.bind("<Enter>", lambda e: self._canvas.bind_all(
|
|
"<MouseWheel>", self._on_wheel))
|
|
self._canvas.bind("<Leave>", lambda e: self._canvas.unbind_all("<MouseWheel>"))
|
|
|
|
self._status_var = tk.StringVar()
|
|
tk.Label(self.frame, textvariable=self._status_var, font=self._name_font,
|
|
fg="#a00000", anchor="w", padx=6, wraplength=300,
|
|
justify=tk.LEFT).pack(fill=tk.X)
|
|
|
|
def _on_wheel(self, event):
|
|
if event.delta:
|
|
self._canvas.yview_scroll(-1 if event.delta > 0 else 1, "units")
|
|
|
|
# ── showing a component ───────────────────────────────────────
|
|
|
|
def show(self, comp):
|
|
"""Lists `comp`'s properties; the same component again only re-reads values."""
|
|
if comp is not self.comp:
|
|
self.flush_edits()
|
|
if comp is self.comp and self.rows:
|
|
self.reload()
|
|
return
|
|
self.comp = comp
|
|
self._rebuild()
|
|
|
|
def _rebuild(self):
|
|
for child in self._sheet.winfo_children():
|
|
child.destroy()
|
|
self.rows = []
|
|
self._status_var.set("")
|
|
|
|
if self.comp is None:
|
|
self._title_var.set("")
|
|
self._type_var.set("Select a widget to see its properties.")
|
|
return
|
|
|
|
self._title_var.set(self.comp.get("name", "?"))
|
|
self._type_var.set(humanise_type(self.comp.get("type", "")))
|
|
|
|
for grid_row, prop in enumerate(build_props(self.comp)):
|
|
self.rows.append(self._build_row(grid_row, prop))
|
|
self.reload()
|
|
self._canvas.yview_moveto(0)
|
|
|
|
def flush_edits(self):
|
|
"""Commits a typed value still sitting in its field.
|
|
|
|
Picking another widget on the canvas takes no focus from the field, so without
|
|
this its <FocusOut> would never come and the edit would be rebuilt away.
|
|
"""
|
|
for row in list(self.rows):
|
|
flush = row.get("flush")
|
|
if flush is not None:
|
|
flush()
|
|
|
|
def reload(self):
|
|
"""Re-reads every row from the document, keeping the rows themselves."""
|
|
if self.comp is not None:
|
|
self._title_var.set(self.comp.get("name", "?"))
|
|
for row in self.rows:
|
|
row["load"]()
|
|
|
|
# ── rows ──────────────────────────────────────────────────────
|
|
|
|
def _cell(self, grid_row, column, bg):
|
|
cell = tk.Frame(self._sheet, bg=bg)
|
|
cell.grid(row=grid_row, column=column, sticky="nsew",
|
|
padx=(0 if column else 1, 1), pady=(0, 1))
|
|
return cell
|
|
|
|
def _build_row(self, grid_row, prop):
|
|
name_cell = self._cell(grid_row, 0, NAME_BG)
|
|
tk.Label(name_cell, text=prop.label, font=self._name_font, bg=NAME_BG,
|
|
anchor="w", padx=4).pack(fill=tk.BOTH, expand=True)
|
|
|
|
cell = self._cell(grid_row, 1, SHEET_BG)
|
|
row = {"prop": prop, "var": tk.StringVar()}
|
|
getattr(self, f"_build_{prop.kind}")(cell, row)
|
|
return row
|
|
|
|
def _value_label(self, cell, row, fg="#000000"):
|
|
label = tk.Label(cell, textvariable=row["var"], font=self._name_font, bg=SHEET_BG,
|
|
fg=fg, anchor="w", padx=4)
|
|
label.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
return label
|
|
|
|
def _dots(self, cell, command):
|
|
ttk.Button(cell, text="…", width=3, command=command).pack(side=tk.RIGHT, padx=1)
|
|
|
|
def _build_readonly(self, cell, row):
|
|
self._value_label(cell, row, READONLY_FG)
|
|
row["load"] = lambda: row["var"].set(row["prop"].read())
|
|
|
|
def _bind_commit(self, widget, row):
|
|
"""Return, leaving the field and Escape, shared by the typed-in rows."""
|
|
var = row["var"]
|
|
row["loaded"] = ""
|
|
|
|
def load():
|
|
row["loaded"] = row["prop"].read()
|
|
var.set(row["loaded"])
|
|
|
|
def commit(_event=None):
|
|
text = var.get()
|
|
if text != row["loaded"]:
|
|
# Taken as loaded first: the write repaints, and the repaint must not
|
|
# find this same edit still pending and commit it a second time.
|
|
row["loaded"] = text
|
|
self._write(row, text)
|
|
|
|
widget.bind("<Return>", commit)
|
|
widget.bind("<KP_Enter>", commit)
|
|
widget.bind("<FocusOut>", commit)
|
|
widget.bind("<Escape>", lambda e: var.set(row["loaded"]))
|
|
row["load"] = load
|
|
row["flush"] = commit
|
|
return commit
|
|
|
|
def _build_text(self, cell, row):
|
|
prop = row["prop"]
|
|
if prop.dialog:
|
|
# Typed in place or edited field by field, as Workbench offers both.
|
|
self._dots(cell, getattr(self, f"_open_{prop.dialog}_dialog"))
|
|
if prop.unit:
|
|
tk.Label(cell, text=prop.unit, font=self._name_font, bg=SHEET_BG,
|
|
fg=READONLY_FG, padx=4).pack(side=tk.RIGHT)
|
|
entry = tk.Entry(cell, textvariable=row["var"], font=self._name_font, relief=tk.FLAT,
|
|
bg=SHEET_BG, highlightthickness=1, highlightcolor="#7f9db9",
|
|
highlightbackground=SHEET_BG)
|
|
entry.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=2, pady=1)
|
|
self._bind_commit(entry, row)
|
|
|
|
def _build_image(self, cell, row):
|
|
combo = ttk.Combobox(cell, textvariable=row["var"], font=self._name_font,
|
|
postcommand=lambda: combo.configure(values=image_names()))
|
|
combo.pack(fill=tk.BOTH, expand=True, padx=1)
|
|
commit = self._bind_commit(combo, row)
|
|
combo.bind("<<ComboboxSelected>>", commit)
|
|
|
|
def _build_choice(self, cell, row):
|
|
prop, var = row["prop"], row["var"]
|
|
combo = ttk.Combobox(cell, textvariable=var, values=list(prop.choices),
|
|
state="readonly", font=self._name_font)
|
|
combo.pack(fill=tk.BOTH, expand=True, padx=1)
|
|
|
|
def selected(_event=None):
|
|
combo.selection_clear()
|
|
self._write(row, var.get())
|
|
|
|
combo.bind("<<ComboboxSelected>>", selected)
|
|
row["load"] = lambda: var.set(prop.read())
|
|
|
|
def _build_colour(self, cell, row):
|
|
prop, var = row["prop"], row["var"]
|
|
swatch = tk.Frame(cell, width=28, height=12, bg="#ffffff",
|
|
highlightthickness=1, highlightbackground="#000000")
|
|
swatch.pack(side=tk.LEFT, padx=(4, 6), pady=4)
|
|
|
|
def load():
|
|
value = _as_int(prop.read())
|
|
colour = rgb565_to_hex(value)
|
|
swatch.config(bg=colour)
|
|
var.set(f"{colour} ({value})")
|
|
|
|
def choose(_event=None):
|
|
current = rgb565_to_hex(_as_int(prop.read()))
|
|
picked = colorchooser.askcolor(color=current, parent=self.frame,
|
|
title=prop.label)[1]
|
|
if picked:
|
|
self._write(row, str(hex_to_rgb565(picked)))
|
|
|
|
self._dots(cell, choose)
|
|
self._value_label(cell, row, READONLY_FG)
|
|
swatch.bind("<Button-1>", choose)
|
|
row["load"] = load
|
|
|
|
def _build_dialog(self, cell, row):
|
|
opener = getattr(self, f"_open_{row['prop'].dialog}_dialog")
|
|
self._dots(cell, opener)
|
|
label = self._value_label(cell, row)
|
|
label.bind("<Double-Button-1>", lambda e: opener())
|
|
row["load"] = lambda: row["var"].set(row["prop"].read())
|
|
|
|
def _build_link(self, cell, row):
|
|
prop, var = row["prop"], row["var"]
|
|
self._dots(cell, lambda: self._open_link_dialog(prop))
|
|
label = self._value_label(cell, row)
|
|
|
|
def load():
|
|
links = slot_links(self.comp, prop.slot, prop.output)
|
|
if not links:
|
|
var.set(prop.read())
|
|
label.config(fg=READONLY_FG)
|
|
return
|
|
end = "to" if prop.output else "from"
|
|
arrow = "→" if prop.output else "←"
|
|
text = f"{arrow} {links[0].get(end, '')}"
|
|
if len(links) > 1:
|
|
text += f" (+{len(links) - 1})"
|
|
var.set(text)
|
|
label.config(fg=LINK_FG)
|
|
|
|
row["load"] = load
|
|
|
|
# ── writing ───────────────────────────────────────────────────
|
|
|
|
def _write(self, row, text):
|
|
values, error = row["prop"].write(text)
|
|
if error:
|
|
self._status_var.set(f"{row['prop'].label}: {error}")
|
|
row["load"]()
|
|
return
|
|
self._status_var.set("")
|
|
self.commit(self.comp, values)
|
|
|
|
# ── dialogs ───────────────────────────────────────────────────
|
|
|
|
def _dialog(self, title):
|
|
"""A modal Toplevel with a padded body and an OK/Cancel row still to be added."""
|
|
dialog = tk.Toplevel(self.frame)
|
|
dialog.title(title)
|
|
dialog.transient(self.frame.winfo_toplevel())
|
|
dialog.resizable(False, False)
|
|
body = ttk.Frame(dialog, padding=10)
|
|
body.pack(fill=tk.BOTH, expand=True)
|
|
return dialog, body
|
|
|
|
def _finish_dialog(self, dialog, body, row, apply):
|
|
buttons = ttk.Frame(body)
|
|
buttons.grid(row=row, column=0, columnspan=3, sticky="e", pady=(10, 0))
|
|
ttk.Button(buttons, text="OK", width=9, command=apply).pack(side=tk.LEFT, padx=(0, 4))
|
|
ttk.Button(buttons, text="Cancel", width=9,
|
|
command=dialog.destroy).pack(side=tk.LEFT)
|
|
dialog.bind("<Escape>", lambda e: dialog.destroy())
|
|
dialog.update_idletasks()
|
|
top = self.frame.winfo_toplevel()
|
|
x = top.winfo_rootx() + max(0, top.winfo_width() - dialog.winfo_width() - 340)
|
|
dialog.geometry(f"+{x}+{top.winfo_rooty() + 120}")
|
|
dialog.grab_set()
|
|
|
|
def _apply_values(self, dialog, comp, values, error):
|
|
if error:
|
|
messagebox.showerror(dialog.title(), error, parent=dialog)
|
|
return
|
|
dialog.destroy()
|
|
self.commit(comp, values)
|
|
|
|
def _open_layout_dialog(self):
|
|
"""X, Y, Width and Height, one field each, as Workbench's layout editor has them."""
|
|
comp = self.comp
|
|
if comp is None:
|
|
return
|
|
self.flush_edits()
|
|
m = _Model(comp)
|
|
dialog, body = self._dialog("layout")
|
|
|
|
table = tk.Frame(body, bg=GRID_LINE)
|
|
table.grid(row=0, column=0, sticky="nsew")
|
|
tk.Label(table, text="", bg=NAME_BG, width=8).grid(row=0, column=0, sticky="nsew",
|
|
padx=(1, 1), pady=1)
|
|
tk.Label(table, text="Value", bg=NAME_BG, anchor="w", padx=4, width=16,
|
|
font=self._name_font).grid(row=0, column=1, sticky="nsew", padx=(0, 1), pady=1)
|
|
|
|
variables = {}
|
|
entries = []
|
|
for index, (name, label) in enumerate(zip(LAYOUT_SLOTS, ("X", "Y", "Width", "Height"))):
|
|
tk.Label(table, text=label, bg=SHEET_BG, anchor="w", padx=4,
|
|
font=self._name_font).grid(row=index + 1, column=0, sticky="nsew",
|
|
padx=(1, 1), pady=(0, 1))
|
|
var = tk.StringVar(value=str(m.int(name)))
|
|
entry = tk.Entry(table, textvariable=var, font=self._name_font, relief=tk.FLAT,
|
|
bg=SHEET_BG, highlightthickness=1, highlightcolor="#7f9db9",
|
|
highlightbackground=SHEET_BG, width=16)
|
|
entry.grid(row=index + 1, column=1, sticky="nsew", padx=(0, 1), pady=(0, 1))
|
|
variables[name] = var
|
|
entries.append(entry)
|
|
|
|
def apply():
|
|
text = ", ".join(variables[name].get().strip() or "?" for name in LAYOUT_SLOTS)
|
|
self._apply_values(dialog, comp, *_layout_writer(m)(text))
|
|
|
|
dialog.bind("<Return>", lambda e: apply())
|
|
self._finish_dialog(dialog, body, 1, apply)
|
|
entries[0].focus_set()
|
|
entries[0].select_range(0, tk.END)
|
|
|
|
def _open_font_dialog(self):
|
|
comp = self.comp
|
|
if comp is None:
|
|
return
|
|
m = _Model(comp)
|
|
names, styles = font_names(m), font_styles(m)
|
|
dialog, body = self._dialog("Font")
|
|
|
|
def index_text(choices, index):
|
|
return choices[index] if 0 <= index < len(choices) else choices[0]
|
|
|
|
name_var = tk.StringVar(value=index_text(names, m.int("fontName")))
|
|
size_var = tk.StringVar(value=str(m.int("fontSize")))
|
|
style_var = tk.StringVar(value=index_text(styles, m.int("fontAttributes")))
|
|
|
|
ttk.Label(body, text="Name").grid(row=0, column=0, sticky="w", pady=2)
|
|
ttk.Combobox(body, textvariable=name_var, values=list(names), state="readonly",
|
|
width=14).grid(row=0, column=1, sticky="ew", pady=2)
|
|
ttk.Label(body, text="Size (pt)").grid(row=1, column=0, sticky="w", pady=2)
|
|
ttk.Spinbox(body, textvariable=size_var, from_=1, to=255, width=14).grid(
|
|
row=1, column=1, sticky="ew", pady=2)
|
|
ttk.Label(body, text="Style").grid(row=2, column=0, sticky="w", pady=2)
|
|
ttk.Combobox(body, textvariable=style_var, values=list(styles), state="readonly",
|
|
width=14).grid(row=2, column=1, sticky="ew", pady=2)
|
|
|
|
def apply():
|
|
size_raw, error = m.check("fontSize", size_var.get())
|
|
values = None if error else {
|
|
"fontName": str(names.index(name_var.get())),
|
|
"fontSize": size_raw,
|
|
"fontAttributes": str(styles.index(style_var.get())),
|
|
}
|
|
self._apply_values(dialog, comp, values, error and f"Size: {error}")
|
|
|
|
dialog.bind("<Return>", lambda e: apply())
|
|
self._finish_dialog(dialog, body, 3, apply)
|
|
|
|
def _open_numeric_dialog(self):
|
|
"""NumericLabel's text: the caption around the value and how it is formatted."""
|
|
comp = self.comp
|
|
if comp is None:
|
|
return
|
|
m = _Model(comp)
|
|
current = m.text("text")
|
|
parts = parse_numeric_format(current)
|
|
dialog, body = self._dialog("Text")
|
|
|
|
prefix_var = tk.StringVar()
|
|
suffix_var = tk.StringVar()
|
|
show_var = tk.BooleanVar()
|
|
precision_var = tk.StringVar()
|
|
raw_var = tk.StringVar(value=current)
|
|
preview_var = tk.StringVar()
|
|
if parts is not None:
|
|
prefix_var.set(parts[0])
|
|
show_var.set(parts[1])
|
|
precision_var.set(str(parts[2]))
|
|
suffix_var.set(parts[3])
|
|
|
|
ttk.Label(body, text="Prefix").grid(row=0, column=0, sticky="w", pady=2)
|
|
ttk.Entry(body, textvariable=prefix_var, width=24).grid(row=0, column=1, sticky="ew")
|
|
ttk.Checkbutton(body, text="Show value", variable=show_var).grid(
|
|
row=1, column=1, sticky="w", pady=2)
|
|
ttk.Label(body, text="Decimals").grid(row=2, column=0, sticky="w", pady=2)
|
|
precision_box = ttk.Spinbox(body, textvariable=precision_var, from_=0, to=6, width=6)
|
|
precision_box.grid(row=2, column=1, sticky="w")
|
|
ttk.Label(body, text="Suffix").grid(row=3, column=0, sticky="w", pady=2)
|
|
suffix_entry = ttk.Entry(body, textvariable=suffix_var, width=24)
|
|
suffix_entry.grid(row=3, column=1, sticky="ew")
|
|
ttk.Label(body, text="Format").grid(row=4, column=0, sticky="w", pady=(8, 2))
|
|
raw_entry = ttk.Entry(body, textvariable=raw_var, width=24)
|
|
raw_entry.grid(row=4, column=1, sticky="ew", pady=(8, 2))
|
|
ttk.Label(body, text="Preview").grid(row=5, column=0, sticky="w", pady=2)
|
|
ttk.Label(body, textvariable=preview_var, foreground=LINK_FG).grid(
|
|
row=5, column=1, sticky="w")
|
|
|
|
syncing = []
|
|
|
|
def from_parts(*_):
|
|
if syncing:
|
|
return
|
|
state = "normal" if show_var.get() else "disabled"
|
|
precision_box.configure(state=state)
|
|
suffix_entry.configure(state=state)
|
|
precision = _as_int(precision_var.get(), 1)
|
|
syncing.append(True)
|
|
raw_var.set(compose_numeric_format(prefix_var.get(), show_var.get(),
|
|
max(0, min(6, precision)), suffix_var.get()))
|
|
syncing.pop()
|
|
|
|
def from_raw(*_):
|
|
preview_var.set(numeric_format_preview(raw_var.get()))
|
|
if syncing:
|
|
return
|
|
parsed = parse_numeric_format(raw_var.get())
|
|
if parsed is None:
|
|
return
|
|
syncing.append(True)
|
|
prefix_var.set(parsed[0])
|
|
show_var.set(parsed[1])
|
|
precision_var.set(str(parsed[2]))
|
|
suffix_var.set(parsed[3])
|
|
syncing.pop()
|
|
|
|
for var in (prefix_var, suffix_var, show_var, precision_var):
|
|
var.trace_add("write", from_parts)
|
|
raw_var.trace_add("write", from_raw)
|
|
from_raw()
|
|
if parts is not None:
|
|
from_parts()
|
|
|
|
def apply():
|
|
text = raw_var.get()
|
|
if parse_numeric_format(text) is None:
|
|
self._apply_values(dialog, comp, None,
|
|
"The format can hold one %f conversion; write % as %%.")
|
|
return
|
|
self._apply_values(dialog, comp, *m.text_values("text", text))
|
|
|
|
dialog.bind("<Return>", lambda e: apply())
|
|
self._finish_dialog(dialog, body, 6, apply)
|
|
|
|
def _open_lookup_dialog(self):
|
|
"""A lookup Buf: the text drawn for each value of `in`, with a prefix and suffix."""
|
|
comp = self.comp
|
|
if comp is None:
|
|
return
|
|
m = _Model(comp)
|
|
prefix, items, suffix = parse_lookup(m.text("text"))
|
|
fixed_labels = LOOKUP_ITEM_LABELS.get(comp.get("type", ""))
|
|
dialog, body = self._dialog("Label Text")
|
|
|
|
prefix_var = tk.StringVar(value=prefix)
|
|
suffix_var = tk.StringVar(value=suffix)
|
|
ttk.Label(body, text="Prefix").grid(row=0, column=0, sticky="w", pady=2)
|
|
ttk.Entry(body, textvariable=prefix_var, width=30).grid(row=0, column=1, sticky="ew")
|
|
|
|
if fixed_labels:
|
|
item_vars = []
|
|
for index, label in enumerate(fixed_labels):
|
|
var = tk.StringVar(value=items[index] if index < len(items) else "")
|
|
item_vars.append(var)
|
|
ttk.Label(body, text=label).grid(row=1 + index, column=0, sticky="w", pady=2)
|
|
ttk.Entry(body, textvariable=var, width=30).grid(
|
|
row=1 + index, column=1, sticky="ew")
|
|
next_row = 1 + len(fixed_labels)
|
|
|
|
def read_items():
|
|
values = [v.get() for v in item_vars]
|
|
while values and values[-1] == "":
|
|
values.pop()
|
|
return values
|
|
else:
|
|
ttk.Label(body, text="Items\n(one per line,\nfirst is value 0)",
|
|
justify=tk.LEFT).grid(row=1, column=0, sticky="nw", pady=2)
|
|
box = ttk.Frame(body)
|
|
box.grid(row=1, column=1, sticky="nsew", pady=2)
|
|
gutter = tk.Text(box, width=3, height=10, font=self._name_font, bg=NAME_BG,
|
|
relief=tk.FLAT, state="disabled", takefocus=0)
|
|
gutter.pack(side=tk.LEFT, fill=tk.Y)
|
|
text = tk.Text(box, width=30, height=10, font=self._name_font, undo=True)
|
|
text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
text.insert("1.0", "\n".join(items))
|
|
|
|
def renumber(_event=None):
|
|
lines = int(text.index("end-1c").split(".")[0])
|
|
gutter.configure(state="normal")
|
|
gutter.delete("1.0", tk.END)
|
|
gutter.insert("1.0", "\n".join(str(i) for i in range(lines)))
|
|
gutter.configure(state="disabled")
|
|
gutter.yview_moveto(text.yview()[0])
|
|
|
|
text.bind("<KeyRelease>", renumber)
|
|
text.configure(yscrollcommand=lambda first, last: gutter.yview_moveto(first))
|
|
renumber()
|
|
next_row = 2
|
|
|
|
def read_items():
|
|
values = text.get("1.0", "end-1c").split("\n")
|
|
while values and values[-1] == "":
|
|
values.pop()
|
|
return values
|
|
|
|
ttk.Label(body, text="Suffix").grid(row=next_row, column=0, sticky="w", pady=2)
|
|
ttk.Entry(body, textvariable=suffix_var, width=30).grid(
|
|
row=next_row, column=1, sticky="ew")
|
|
|
|
def apply():
|
|
parts = [prefix_var.get(), suffix_var.get(), *read_items()]
|
|
if any(ch in part for part in parts for ch in ";^"):
|
|
self._apply_values(dialog, comp, None,
|
|
"';' and '^' separate the parts, so no text can hold them.")
|
|
return
|
|
composed = compose_lookup(prefix_var.get(), read_items(), suffix_var.get())
|
|
self._apply_values(dialog, comp, *m.text_values("text", composed))
|
|
|
|
self._finish_dialog(dialog, body, next_row + 1, apply)
|
|
|
|
def _open_link_dialog(self, prop):
|
|
"""Links a slot of any component to this runtime slot, or unlinks it."""
|
|
comp = self.comp
|
|
root = app_state.xml_root_element
|
|
if comp is None or root is None:
|
|
return
|
|
import sax_file # sax_file imports the views; resolved late to keep that one-way
|
|
|
|
own_slot = _Model(comp).slots.get(prop.slot, {})
|
|
own_type = own_slot.get("type")
|
|
direction = "Link to" if prop.output else "Link from"
|
|
dialog, body = self._dialog(f"{direction} — {comp.get('name', '?')}.{prop.slot}")
|
|
dialog.resizable(True, True)
|
|
|
|
existing = slot_links(comp, prop.slot, prop.output)
|
|
end = "to" if prop.output else "from"
|
|
ttk.Label(body, text="Linked: " + (", ".join(l.get(end, "") for l in existing)
|
|
if existing else "nothing"),
|
|
foreground=LINK_FG).grid(row=0, column=0, columnspan=2, sticky="w")
|
|
|
|
comps = ttk.Treeview(body, show="tree", height=14, selectmode="browse")
|
|
comps.grid(row=1, column=0, sticky="nsew", pady=6, padx=(0, 6))
|
|
slots = ttk.Treeview(body, columns=("type",), show="tree headings", height=14,
|
|
selectmode="browse")
|
|
slots.heading("#0", text="Slot")
|
|
slots.heading("type", text="Type")
|
|
slots.column("#0", width=150)
|
|
slots.column("type", width=80)
|
|
slots.grid(row=1, column=1, sticky="nsew", pady=6)
|
|
body.columnconfigure(0, weight=1, minsize=220)
|
|
body.columnconfigure(1, weight=1)
|
|
body.rowconfigure(1, weight=1)
|
|
|
|
same_type = tk.BooleanVar(value=True)
|
|
ttk.Checkbutton(body, text=f"Only {own_type} slots", variable=same_type,
|
|
command=lambda: fill_slots()).grid(row=2, column=1, sticky="w")
|
|
|
|
by_iid = {}
|
|
|
|
def add_comps(parent_iid, element):
|
|
for child in element:
|
|
if child.tag != "comp":
|
|
continue
|
|
iid = comps.insert(parent_iid, tk.END, text=child.get("name", "?"),
|
|
open=False)
|
|
by_iid[iid] = child
|
|
add_comps(iid, child)
|
|
|
|
app = root.find("app")
|
|
add_comps("", app if app is not None else root)
|
|
|
|
def fill_slots(_event=None):
|
|
slots.delete(*slots.get_children())
|
|
sel = comps.selection()
|
|
if not sel:
|
|
return
|
|
element = by_iid[sel[0]]
|
|
for slot in schema.get_full_slots_for_type(element.get("type", "")):
|
|
if element is comp and slot["name"] == prop.slot:
|
|
continue
|
|
if slot["type"] == "void" and not prop.output:
|
|
continue
|
|
if same_type.get() and slot["type"] != own_type and slot["type"] != "void":
|
|
continue
|
|
slots.insert("", tk.END, iid=slot["name"], text=slot["name"],
|
|
values=(slot["type"],))
|
|
|
|
comps.bind("<<TreeviewSelect>>", fill_slots)
|
|
|
|
def apply():
|
|
sel, slot_sel = comps.selection(), slots.selection()
|
|
if not sel or not slot_sel:
|
|
messagebox.showerror(dialog.title(), "Pick a component and one of its slots.",
|
|
parent=dialog)
|
|
return
|
|
other = f"{get_component_path(by_iid[sel[0]], root)}.{slot_sel[0]}"
|
|
mine = slot_ref(comp, prop.slot)
|
|
dialog.destroy()
|
|
if prop.output:
|
|
sax_file.add_link(mine, other)
|
|
else:
|
|
# An input takes one source: the new link replaces what fed it.
|
|
for link in slot_links(comp, prop.slot, False):
|
|
sax_file.remove_link(link)
|
|
sax_file.add_link(other, mine)
|
|
self.repaint()
|
|
|
|
def unlink():
|
|
dialog.destroy()
|
|
for link in slot_links(comp, prop.slot, prop.output):
|
|
sax_file.remove_link(link)
|
|
self.repaint()
|
|
|
|
slots.bind("<Double-Button-1>", lambda e: apply())
|
|
buttons = ttk.Frame(body)
|
|
buttons.grid(row=3, column=0, columnspan=2, sticky="e", pady=(10, 0))
|
|
unlink_button = ttk.Button(buttons, text="Unlink", width=9, command=unlink)
|
|
unlink_button.pack(side=tk.LEFT, padx=(0, 16))
|
|
if not existing:
|
|
unlink_button.state(["disabled"])
|
|
ttk.Button(buttons, text="Link", width=9, command=apply).pack(side=tk.LEFT, padx=(0, 4))
|
|
ttk.Button(buttons, text="Cancel", width=9, command=dialog.destroy).pack(side=tk.LEFT)
|
|
dialog.bind("<Escape>", lambda e: dialog.destroy())
|
|
dialog.update_idletasks()
|
|
top = self.frame.winfo_toplevel()
|
|
dialog.geometry(f"+{top.winfo_rootx() + 200}+{top.winfo_rooty() + 80}")
|
|
dialog.grab_set()
|