Pick Page Editor fonts and images from the platform's banks
The font and image rows are now dropdowns filled from <sedona home>/ontrol: fontBank/imageBank on Rion, fontBankXL/imageBankXL on Orion. Orion lists .oxfl fonts in landscape and .oxfp fonts in portrait, read from the platform service's screenOrientation (loaded from the device in live mode when the tree has not reached it). Images show their file extension while the slot keeps the bare name. The font dialog is gone. In portrait the Page Editor draws a 480x800 screen in frame_orion_portrait.png, and the canvas renders text with the .oxfp fonts, whose glyphs are stored a quarter turn anticlockwise. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+171
-64
@@ -4,7 +4,7 @@ The Sedona slot sheet lists a widget's raw slots; this one lists what a screen d
|
||||
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"
|
||||
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"
|
||||
@@ -237,24 +237,124 @@ def lookup_preview(text):
|
||||
return f"{prefix}[{' | '.join(items)}]{suffix}"
|
||||
|
||||
|
||||
# --- images ---------------------------------------------------------------------------
|
||||
# --- the device's font and image banks ------------------------------------------------
|
||||
|
||||
# Orion's 800x480 screen takes the XL banks; Rion's 320x240 one the plain ones.
|
||||
ORION_KIT = "ontrolOrion"
|
||||
RION_KIT = "ontrolRion"
|
||||
FONT_EXTENSIONS = (".bin", ".oxfl", ".oxfp")
|
||||
# Orion keeps a landscape (.oxfl) and a portrait (.oxfp) cut of every font, and draws
|
||||
# with the one its platform service's screenOrientation (false = landscape) picks.
|
||||
ORION_PLATFORM_TYPE = "ontrolOrion::OrionPlatformService"
|
||||
LANDSCAPE_FONT_EXTENSION = ".oxfl"
|
||||
PORTRAIT_FONT_EXTENSION = ".oxfp"
|
||||
_FONT_STEM = re.compile(r"^(bold\s+)?(\d+)pt\s+(.+)$", re.IGNORECASE)
|
||||
|
||||
|
||||
def bank_folders(kind):
|
||||
"""The <sedona home>/ontrol folders holding the open platform's `kind` bank.
|
||||
|
||||
`kind` is "font" or "image". Orion reads fontBankXL/imageBankXL and Rion
|
||||
fontBank/imageBank; a document of neither platform is offered both.
|
||||
"""
|
||||
home = getattr(app_state, "SEDONA_HOME", None)
|
||||
if not home:
|
||||
return []
|
||||
kits = app_state.active_schema_kits
|
||||
if ORION_KIT in kits:
|
||||
names = (f"{kind}BankXL",)
|
||||
elif RION_KIT in kits:
|
||||
names = (f"{kind}Bank",)
|
||||
else:
|
||||
names = (f"{kind}Bank", f"{kind}BankXL")
|
||||
return [os.path.join(home, "ontrol", name) for name in names]
|
||||
|
||||
|
||||
def _bank_files(kind, extensions):
|
||||
"""The bank's file names, extension included, of the kinds in `extensions`."""
|
||||
files = set()
|
||||
for folder in bank_folders(kind):
|
||||
for path in glob.glob(os.path.join(folder, "*")):
|
||||
if os.path.splitext(path)[1].lower() in extensions and os.path.isfile(path):
|
||||
files.add(os.path.basename(path))
|
||||
return files
|
||||
|
||||
|
||||
def _bank_stems(kind, extensions):
|
||||
return {os.path.splitext(name)[0] for name in _bank_files(kind, extensions)}
|
||||
|
||||
|
||||
def image_names():
|
||||
"""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)
|
||||
"""The image bank's files as the list shows them: 'trt.jpg', 'logo.png'."""
|
||||
return sorted(_bank_files("image", IMAGE_EXTENSIONS), key=str.lower)
|
||||
|
||||
|
||||
def image_file_text(name):
|
||||
"""An imageFileName value with its bank file's extension: 'trt' -> 'trt.jpg'.
|
||||
|
||||
A name no bank file matches is shown as it is.
|
||||
"""
|
||||
if name:
|
||||
for file_name in image_names():
|
||||
if os.path.splitext(file_name)[0] == name:
|
||||
return file_name
|
||||
return name
|
||||
|
||||
|
||||
def image_file_value(text):
|
||||
"""The imageFileName value for a listed file: 'trt.jpg' -> 'trt'.
|
||||
|
||||
The slot holds the bare name, as the device looks the file up by it.
|
||||
"""
|
||||
stem, ext = os.path.splitext(text.strip())
|
||||
return stem if ext.lower() in IMAGE_EXTENSIONS else text.strip()
|
||||
|
||||
|
||||
def parse_font_stem(stem):
|
||||
"""'bold 12pt Tahoma' -> ('Tahoma', 12, True); None for a name not in that form."""
|
||||
match = _FONT_STEM.match(stem.strip())
|
||||
if not match:
|
||||
return None
|
||||
return match.group(3).strip(), int(match.group(2)), bool(match.group(1))
|
||||
|
||||
|
||||
def orion_portrait():
|
||||
"""True when the Orion platform service is set to a portrait screen.
|
||||
|
||||
In live mode the tree holds only the folders opened so far, so a platform service
|
||||
the document lacks is read from the device instead.
|
||||
"""
|
||||
root = app_state.xml_root_element
|
||||
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)
|
||||
for comp in root.iter("comp") if root is not None else ():
|
||||
if comp.get("type") == ORION_PLATFORM_TYPE:
|
||||
slot = next((s for s in schema.get_full_slots_for_type(ORION_PLATFORM_TYPE)
|
||||
if s["name"] == "screenOrientation"), None)
|
||||
raw = properties.effective_value(comp, slot) if slot else None
|
||||
return str(raw).lower() == "true"
|
||||
session = getattr(app_state, "device_session", None)
|
||||
if app_state.live_mode and session is not None:
|
||||
try:
|
||||
comp = session.find_component_of_type(ORION_PLATFORM_TYPE, "screenOrientation")
|
||||
except Exception: # a dropped connection reads as the default, landscape
|
||||
comp = None
|
||||
if comp is not None:
|
||||
return str(comp.values.get("screenOrientation")).lower() == "true"
|
||||
return False
|
||||
|
||||
|
||||
def font_extensions():
|
||||
"""The font file extensions the open platform draws with."""
|
||||
if ORION_KIT in app_state.active_schema_kits:
|
||||
return (PORTRAIT_FONT_EXTENSION if orion_portrait() else LANDSCAPE_FONT_EXTENSION,)
|
||||
return FONT_EXTENSIONS
|
||||
|
||||
|
||||
def font_bank_names():
|
||||
"""The font bank's fonts, smallest first and plain before bold at each size."""
|
||||
fonts = [(parse_font_stem(stem), stem) for stem in _bank_stems("font", font_extensions())]
|
||||
fonts = [(parsed, stem) for parsed, stem in fonts if parsed]
|
||||
fonts.sort(key=lambda f: (f[0][1], f[0][2], f[0][0].lower()))
|
||||
return [stem for _, stem in fonts]
|
||||
|
||||
|
||||
# --- links ----------------------------------------------------------------------------
|
||||
@@ -288,7 +388,7 @@ class Prop:
|
||||
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.kind = kind # text | choice | colour | dialog | image | font | link | readonly
|
||||
self.read = read
|
||||
self.write = write
|
||||
self.choices = choices
|
||||
@@ -360,7 +460,7 @@ def build_props(comp):
|
||||
|
||||
if m.has(*FONT_SLOTS):
|
||||
consumed.update(FONT_SLOTS)
|
||||
rows.append(Prop("font", "dialog", lambda: font_text(m), dialog="font"))
|
||||
rows.append(Prop("font", "font", lambda: font_text(m), _font_writer(m)))
|
||||
|
||||
if m.has("attributes"):
|
||||
consumed.add("attributes")
|
||||
@@ -449,14 +549,35 @@ def font_styles(m):
|
||||
|
||||
|
||||
def font_text(m):
|
||||
"""The font as the font bank names its file: '32pt Tahoma', 'bold 16pt Lato'."""
|
||||
names, styles = font_names(m), font_styles(m)
|
||||
name_i, style_i = m.int("fontName"), m.int("fontAttributes")
|
||||
name = names[name_i] if 0 <= name_i < len(names) else f"font {name_i}"
|
||||
style = styles[style_i] if 0 <= style_i < len(styles) else ""
|
||||
prefix = "" if style_i == 0 or not style else f"{style} "
|
||||
prefix = "" if style_i == 0 or not style else f"{style.lower()} "
|
||||
return f"{prefix}{m.int('fontSize')}pt {name}"
|
||||
|
||||
|
||||
def _font_writer(m):
|
||||
"""A font bank name back into fontName, fontSize and fontAttributes."""
|
||||
def write(text):
|
||||
parsed = parse_font_stem(text)
|
||||
if parsed is None:
|
||||
return None, "expected a font such as '12pt Tahoma' or 'bold 12pt Tahoma'"
|
||||
family, size, bold = parsed
|
||||
names = [n.lower() for n in font_names(m)]
|
||||
if family.lower() not in names:
|
||||
return None, f"'{family}' is not one of: {', '.join(font_names(m))}"
|
||||
size_raw, error = m.check("fontSize", str(size))
|
||||
if error:
|
||||
return None, f"size: {error}"
|
||||
styles = [s.lower() for s in font_styles(m)]
|
||||
style = styles.index("bold") if bold and "bold" in styles else (1 if bold else 0)
|
||||
return {"fontName": str(names.index(family.lower())), "fontSize": size_raw,
|
||||
"fontAttributes": str(style)}, None
|
||||
return write
|
||||
|
||||
|
||||
def _bit_props(m, name, fallback, specs):
|
||||
"""Rows that each own some bits of one byte slot: (label, mask, choices, values)."""
|
||||
def current():
|
||||
@@ -539,8 +660,8 @@ def _slot_prop(m, slot):
|
||||
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()))
|
||||
return Prop(name, "image", lambda: image_file_text(m.text(name)),
|
||||
lambda text: m.text_values(name, image_file_value(text)))
|
||||
|
||||
if slot["type"] == "sys::Buf":
|
||||
return Prop(name, "text", lambda: m.text(name), lambda text: m.text_values(name, text))
|
||||
@@ -779,13 +900,36 @@ class OxWidgetPropertiesPanel:
|
||||
self._bind_commit(entry, row)
|
||||
self._controls.append((entry, None))
|
||||
|
||||
def _bank_combo(self, cell, row, bank_values):
|
||||
"""A pick-only list filled from a bank each time it opens, the value kept listed."""
|
||||
var = row["var"]
|
||||
|
||||
def fill():
|
||||
values = bank_values()
|
||||
current = var.get()
|
||||
if current and current not in values:
|
||||
values = [current] + values
|
||||
combo.configure(values=values)
|
||||
|
||||
combo = ttk.Combobox(cell, textvariable=var, state="readonly", font=self._name_font,
|
||||
postcommand=fill)
|
||||
combo.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=1)
|
||||
|
||||
def selected(_event=None):
|
||||
combo.selection_clear()
|
||||
if var.get() != row["prop"].read():
|
||||
self._write(row, var.get())
|
||||
|
||||
combo.bind("<<ComboboxSelected>>", selected)
|
||||
row["load"] = lambda: var.set(row["prop"].read())
|
||||
self._controls.append((combo, "readonly"))
|
||||
return combo
|
||||
|
||||
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)
|
||||
self._controls.append((combo, "normal"))
|
||||
self._bank_combo(cell, row, image_names)
|
||||
|
||||
def _build_font(self, cell, row):
|
||||
self._bank_combo(cell, row, font_bank_names)
|
||||
|
||||
def _build_choice(self, cell, row):
|
||||
prop, var = row["prop"], row["var"]
|
||||
@@ -938,43 +1082,6 @@ class OxWidgetPropertiesPanel:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user