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:
2026-09-26 21:15:43 +03:00
co-authored by Claude Opus 5.5
parent 138d9927be
commit c01a192365
5 changed files with 228 additions and 84 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
import configparser import configparser
import os import os
VERSION = "0.0.0.131" VERSION = "0.0.0.132"
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties") PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
DEFAULT_GRID_SIZE = 10 DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True DEFAULT_ADD_TIMESTAMP = True
+27
View File
@@ -175,6 +175,33 @@ class DeviceSession:
for cid in parent.children for cid in parent.children
if self.sox_client.cache_get(cid) is not None] if self.sox_client.cache_get(cid) is not None]
def find_component_of_type(self, type_name, slot_name):
"""A component of `type_name` with `slot_name` read, loading where services live.
Nothing is loaded until a folder is opened, so a platform service the user never
expanded to is not cached yet. It sits one or two levels down (/service/plat), and
those are loaded to find it rather than the whole app.
"""
client = self.sox_client
if client is None:
return None
def cached():
return next((c for c in client.cache
if c is not None and c.type_name == type_name), None)
comp = cached()
if comp is None:
for top in self.load_children(0):
if top.name == "service" or top.type_name == type_name:
for child in self.load_children(top.id):
if child.children and child.type_name != type_name:
self.load_children(child.id)
comp = cached()
if comp is not None and slot_name not in comp.values:
client.update([comp], CONFIG)
return comp
# ── Wiresheet Open / Close ────────────────────────────────── # ── Wiresheet Open / Close ──────────────────────────────────
def open_wiresheet(self, folder_comp_id): def open_wiresheet(self, folder_comp_id):
+11 -2
View File
@@ -5,7 +5,8 @@ widgets (ontrolOx::Widget subclasses) by their x, y, width, height slots.
Supports selection, drag-move, and a synced widget tree. Supports selection, drag-move, and a synced widget tree.
Device resolution is detected from the schema kits: Device resolution is detected from the schema kits:
ontrolOrion → 800×480 ontrolOrion → 800×480, or 480×800 when its platform service's screenOrientation
is portrait (drawn in frame_orion_portrait.png)
ontrolRion → 320×240 ontrolRion → 320×240
A device frame image (the physical bezel) sits behind the screen area when the A device frame image (the physical bezel) sits behind the screen area when the
@@ -25,7 +26,7 @@ except ImportError:
import app_state import app_state
import live_hooks import live_hooks
import undo import undo
from ox_widget_properties import OxWidgetPropertiesPanel from ox_widget_properties import ORION_KIT, OxWidgetPropertiesPanel, orion_portrait
from ox_widget_render import WidgetPainter from ox_widget_render import WidgetPainter
from sax_dom import set_prop_value from sax_dom import set_prop_value
@@ -47,6 +48,12 @@ DEVICE_PROFILES = {
"default_zoom": 1.0, "default_zoom": 1.0,
}, },
} }
ORION_PORTRAIT_PROFILE = {
"screen": (480, 800),
"frame_file": "frame_orion_portrait.png",
"screen_offset": (190, 170),
"default_zoom": 0.5,
}
DEFAULT_SCREEN = (800, 480) DEFAULT_SCREEN = (800, 480)
DEFAULT_ZOOM = 0.5 DEFAULT_ZOOM = 0.5
@@ -105,6 +112,8 @@ def _short_type(full_type):
def _detect_device_profile(): def _detect_device_profile():
for kit_name, profile in DEVICE_PROFILES.items(): for kit_name, profile in DEVICE_PROFILES.items():
if kit_name in app_state.active_schema_kits: if kit_name in app_state.active_schema_kits:
if kit_name == ORION_KIT and orion_portrait():
return ORION_PORTRAIT_PROFILE
return profile return profile
return None return None
+171 -64
View File
@@ -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: thinks in. Several slots fold into one property and one slot splits into several:
layout x, y, width, height "0, 20, 320, 40" 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 fontAttributes
alignment attributes bits 4-5 Left / Center / Right alignment attributes bits 4-5 Left / Center / Right
transparent attributes bit 3 (inverted) bit 3 set means "draw background" 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}" 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(): def image_names():
"""Image names worth offering: the local image banks' files and the document's own.""" """The image bank's files as the list shows them: 'trt.jpg', 'logo.png'."""
names = set() return sorted(_bank_files("image", IMAGE_EXTENSIONS), key=str.lower)
home = getattr(app_state, "SEDONA_HOME", None)
if home:
for folder in glob.glob(os.path.join(home, "ontrol", "imageBank*")): def image_file_text(name):
for path in glob.glob(os.path.join(folder, "*")): """An imageFileName value with its bank file's extension: 'trt' -> 'trt.jpg'.
stem, ext = os.path.splitext(os.path.basename(path))
if ext.lower() in IMAGE_EXTENSIONS: A name no bank file matches is shown as it is.
names.add(stem) """
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 root = app_state.xml_root_element
if root is not None: for comp in root.iter("comp") if root is not None else ():
for prop in root.iter("prop"): if comp.get("type") == ORION_PLATFORM_TYPE:
if "imagefilename" in (prop.get("name") or "").lower() and prop.get("val"): slot = next((s for s in schema.get_full_slots_for_type(ORION_PLATFORM_TYPE)
names.add(prop.get("val")) if s["name"] == "screenOrientation"), None)
return sorted(names, key=str.lower) 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 ---------------------------------------------------------------------------- # --- links ----------------------------------------------------------------------------
@@ -288,7 +388,7 @@ class Prop:
def __init__(self, label, kind, read, write=None, choices=(), dialog=None, unit="", def __init__(self, label, kind, read, write=None, choices=(), dialog=None, unit="",
slot=None, output=False): slot=None, output=False):
self.label = label 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.read = read
self.write = write self.write = write
self.choices = choices self.choices = choices
@@ -360,7 +460,7 @@ def build_props(comp):
if m.has(*FONT_SLOTS): if m.has(*FONT_SLOTS):
consumed.update(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"): if m.has("attributes"):
consumed.add("attributes") consumed.add("attributes")
@@ -449,14 +549,35 @@ def font_styles(m):
def font_text(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) names, styles = font_names(m), font_styles(m)
name_i, style_i = m.int("fontName"), m.int("fontAttributes") 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}" 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 "" 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}" 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): def _bit_props(m, name, fallback, specs):
"""Rows that each own some bits of one byte slot: (label, mask, choices, values).""" """Rows that each own some bits of one byte slot: (label, mask, choices, values)."""
def current(): def current():
@@ -539,8 +660,8 @@ def _slot_prop(m, slot):
lambda text: ({name: text}, None)) lambda text: ({name: text}, None))
if _is_image_slot(slot): if _is_image_slot(slot):
return Prop(name, "image", lambda: m.text(name), return Prop(name, "image", lambda: image_file_text(m.text(name)),
lambda text: m.text_values(name, text.strip())) lambda text: m.text_values(name, image_file_value(text)))
if slot["type"] == "sys::Buf": if slot["type"] == "sys::Buf":
return Prop(name, "text", lambda: m.text(name), lambda text: m.text_values(name, text)) 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._bind_commit(entry, row)
self._controls.append((entry, None)) 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): def _build_image(self, cell, row):
combo = ttk.Combobox(cell, textvariable=row["var"], font=self._name_font, self._bank_combo(cell, row, image_names)
postcommand=lambda: combo.configure(values=image_names()))
combo.pack(fill=tk.BOTH, expand=True, padx=1) def _build_font(self, cell, row):
commit = self._bind_commit(combo, row) self._bank_combo(cell, row, font_bank_names)
combo.bind("<<ComboboxSelected>>", commit)
self._controls.append((combo, "normal"))
def _build_choice(self, cell, row): def _build_choice(self, cell, row):
prop, var = row["prop"], row["var"] prop, var = row["prop"], row["var"]
@@ -938,43 +1082,6 @@ class OxWidgetPropertiesPanel:
entries[0].focus_set() entries[0].focus_set()
entries[0].select_range(0, tk.END) 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): def _open_numeric_dialog(self):
"""NumericLabel's text: the caption around the value and how it is formatted.""" """NumericLabel's text: the caption around the value and how it is formatted."""
comp = self.comp comp = self.comp
+18 -17
View File
@@ -18,7 +18,6 @@ Everything is drawn in canvas units: device pixels times the zoom.
""" """
import datetime import datetime
import glob
import os import os
import tkinter as tk import tkinter as tk
from tkinter import font as tkfont from tkinter import font as tkfont
@@ -33,7 +32,8 @@ import app_state
import properties import properties
from ox_widget_properties import (_Model, BACKGROUND_BIT, ALIGN_MASK, SUBSCRIPT_MASK, from ox_widget_properties import (_Model, BACKGROUND_BIT, ALIGN_MASK, SUBSCRIPT_MASK,
DATE_HIDE_YEAR, DATE_TWO_DIGIT_YEAR, DATE_LEADING_ZERO, DATE_HIDE_YEAR, DATE_TWO_DIGIT_YEAR, DATE_LEADING_ZERO,
DATE_MONTH_FIRST, IMAGE_EXTENSIONS, font_names, DATE_MONTH_FIRST, IMAGE_EXTENSIONS, PORTRAIT_FONT_EXTENSION,
bank_folders, font_extensions, font_names,
parse_lookup, parse_numeric_format, rgb565_to_hex) parse_lookup, parse_numeric_format, rgb565_to_hex)
PLACEHOLDER_FILL = "#dce6f2" PLACEHOLDER_FILL = "#dce6f2"
@@ -71,6 +71,10 @@ def _font(family, pixels, style):
class BitmapFont: class BitmapFont:
"""A device font file: fontBank/'32pt Tahoma.bin', or fontBankXL/'.oxfl' (same layout). """A device font file: fontBank/'32pt Tahoma.bin', or fontBankXL/'.oxfl' (same layout).
Orion's portrait cut, fontBankXL/'.oxfp', has the same header and table but stores
each glyph turned a quarter turn anticlockwise: `height` pixels to a row, `width`
rows. glyph_image turns it back, so every caller sees the upright glyph.
0..63 header; u16 character count at 32, line height at 34 0..63 header; u16 character count at 32, line height at 34
64.. 10 bytes per character: code (u8, or u16 above 191), data offset (u24 64.. 10 bytes per character: code (u8, or u16 above 191), data offset (u24
at 2), width, height, x offset (signed), y offset, x advance at 2), width, height, x offset (signed), y offset, x advance
@@ -80,6 +84,7 @@ class BitmapFont:
def __init__(self, path): def __init__(self, path):
data = open(path, "rb").read() data = open(path, "rb").read()
self.data = data self.data = data
self.rotated = path.lower().endswith(PORTRAIT_FONT_EXTENSION)
self.height = data[34] self.height = data[34]
count = data[32] | data[33] << 8 count = data[32] | data[33] << 8
self.glyphs = {} self.glyphs = {}
@@ -100,7 +105,8 @@ class BitmapFont:
def glyph_image(self, code): def glyph_image(self, code):
offset, width, height, _, _, _ = self.glyphs[code] offset, width, height, _, _, _ = self.glyphs[code]
img = Image.new("L", (max(1, width), max(1, height))) stored = (height, width) if self.rotated else (width, height)
img = Image.new("L", (max(1, stored[0]), max(1, stored[1])))
if width and height: if width and height:
raw = self.data[offset:offset + (width * height + 1) // 2] raw = self.data[offset:offset + (width * height + 1) // 2]
pixels = bytearray(width * height) pixels = bytearray(width * height)
@@ -108,7 +114,7 @@ class BitmapFont:
byte = raw[i >> 1] if (i >> 1) < len(raw) else 0 byte = raw[i >> 1] if (i >> 1) < len(raw) else 0
pixels[i] = ((byte >> 4) if i % 2 == 0 else (byte & 0x0F)) * 17 pixels[i] = ((byte >> 4) if i % 2 == 0 else (byte & 0x0F)) * 17
img.putdata(pixels) img.putdata(pixels)
return img return img.transpose(Image.ROTATE_270) if self.rotated else img
_bitmap_fonts = {} _bitmap_fonts = {}
@@ -119,18 +125,16 @@ def find_bitmap_font(family, size, style):
get_Font_File_Name's naming: '<size>pt <Name>', prefixed 'bold ' for bold. There is get_Font_File_Name's naming: '<size>pt <Name>', prefixed 'bold ' for bold. There is
no italic cut, so italic falls back to the regular one as the device's lookup would no italic cut, so italic falls back to the regular one as the device's lookup would
find nothing better. find nothing better. The file comes from the platform's own bank, and on Orion in
the cut its screen orientation draws with.
""" """
home = getattr(app_state, "SEDONA_HOME", None) if not _HAS_PIL:
if not home or not _HAS_PIL:
return None return None
stem = f"{'bold ' if style == 1 else ''}{size}pt {family}" stems = [f"{'bold ' if style == 1 else ''}{size}pt {family}"]
candidates = [os.path.join(home, "ontrol", "fontBank", stem + ".bin"),
os.path.join(home, "ontrol", "fontBankXL", stem + ".oxfl")]
if style == 1: if style == 1:
plain = f"{size}pt {family}" stems.append(f"{size}pt {family}")
candidates += [os.path.join(home, "ontrol", "fontBank", plain + ".bin"), candidates = [os.path.join(folder, stem + ext) for stem in stems
os.path.join(home, "ontrol", "fontBankXL", plain + ".oxfl")] for folder in bank_folders("font") for ext in font_extensions()]
for path in candidates: for path in candidates:
if path in _bitmap_fonts: if path in _bitmap_fonts:
return _bitmap_fonts[path] return _bitmap_fonts[path]
@@ -179,10 +183,7 @@ def find_image_file(name):
"""The local file for a device image name, looked for in Sedona home's image banks.""" """The local file for a device image name, looked for in Sedona home's image banks."""
if not name: if not name:
return None return None
home = getattr(app_state, "SEDONA_HOME", None) for folder in bank_folders("image"):
if not home:
return None
for folder in sorted(glob.glob(os.path.join(home, "ontrol", "imageBank*"))):
for ext in IMAGE_EXTENSIONS: for ext in IMAGE_EXTENSIONS:
path = os.path.join(folder, name + ext) path = os.path.join(folder, name + ext)
if os.path.isfile(path): if os.path.isfile(path):