diff --git a/app_state.py b/app_state.py index f3e1ecd..6693bb5 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.131" +VERSION = "0.0.0.132" 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/device_session.py b/device_session.py index 7788b9d..e268bb8 100644 --- a/device_session.py +++ b/device_session.py @@ -175,6 +175,33 @@ class DeviceSession: for cid in parent.children 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 ────────────────────────────────── def open_wiresheet(self, folder_comp_id): diff --git a/ox_page_view.py b/ox_page_view.py index 857eeb0..4d07f44 100644 --- a/ox_page_view.py +++ b/ox_page_view.py @@ -5,7 +5,8 @@ widgets (ontrolOx::Widget subclasses) by their x, y, width, height slots. Supports selection, drag-move, and a synced widget tree. 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 A device frame image (the physical bezel) sits behind the screen area when the @@ -25,7 +26,7 @@ except ImportError: import app_state import live_hooks 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 sax_dom import set_prop_value @@ -47,6 +48,12 @@ DEVICE_PROFILES = { "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_ZOOM = 0.5 @@ -105,6 +112,8 @@ def _short_type(full_type): def _detect_device_profile(): for kit_name, profile in DEVICE_PROFILES.items(): if kit_name in app_state.active_schema_kits: + if kit_name == ORION_KIT and orion_portrait(): + return ORION_PORTRAIT_PROFILE return profile return None diff --git a/ox_widget_properties.py b/ox_widget_properties.py index c580c6e..b51ae6b 100644 --- a/ox_widget_properties.py +++ b/ox_widget_properties.py @@ -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 /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("<>", 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("<>", 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("", 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 diff --git a/ox_widget_render.py b/ox_widget_render.py index 71e5f4d..17e25ac 100644 --- a/ox_widget_render.py +++ b/ox_widget_render.py @@ -18,7 +18,6 @@ Everything is drawn in canvas units: device pixels times the zoom. """ import datetime -import glob import os import tkinter as tk from tkinter import font as tkfont @@ -33,7 +32,8 @@ import app_state import properties from ox_widget_properties import (_Model, BACKGROUND_BIT, ALIGN_MASK, SUBSCRIPT_MASK, 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) PLACEHOLDER_FILL = "#dce6f2" @@ -71,6 +71,10 @@ def _font(family, pixels, style): class BitmapFont: """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 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 @@ -80,6 +84,7 @@ class BitmapFont: def __init__(self, path): data = open(path, "rb").read() self.data = data + self.rotated = path.lower().endswith(PORTRAIT_FONT_EXTENSION) self.height = data[34] count = data[32] | data[33] << 8 self.glyphs = {} @@ -100,7 +105,8 @@ class BitmapFont: def glyph_image(self, 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: raw = self.data[offset:offset + (width * height + 1) // 2] pixels = bytearray(width * height) @@ -108,7 +114,7 @@ class BitmapFont: byte = raw[i >> 1] if (i >> 1) < len(raw) else 0 pixels[i] = ((byte >> 4) if i % 2 == 0 else (byte & 0x0F)) * 17 img.putdata(pixels) - return img + return img.transpose(Image.ROTATE_270) if self.rotated else img _bitmap_fonts = {} @@ -119,18 +125,16 @@ def find_bitmap_font(family, size, style): get_Font_File_Name's naming: 'pt ', prefixed 'bold ' for bold. There is 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 home or not _HAS_PIL: + if not _HAS_PIL: return None - stem = 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")] + stems = [f"{'bold ' if style == 1 else ''}{size}pt {family}"] if style == 1: - plain = f"{size}pt {family}" - candidates += [os.path.join(home, "ontrol", "fontBank", plain + ".bin"), - os.path.join(home, "ontrol", "fontBankXL", plain + ".oxfl")] + stems.append(f"{size}pt {family}") + candidates = [os.path.join(folder, stem + ext) for stem in stems + for folder in bank_folders("font") for ext in font_extensions()] for path in candidates: if path in _bitmap_fonts: 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.""" if not name: return None - home = getattr(app_state, "SEDONA_HOME", None) - if not home: - return None - for folder in sorted(glob.glob(os.path.join(home, "ontrol", "imageBank*"))): + for folder in bank_folders("image"): for ext in IMAGE_EXTENSIONS: path = os.path.join(folder, name + ext) if os.path.isfile(path):