Files
sedona_editor/ox_widget_render.py
T
ArdaandClaude Opus 5.5 e5cb04b8cf Render Ox widgets on the Page Editor canvas as the device draws them
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>
2026-09-24 15:16:51 +03:00

532 lines
22 KiB
Python

"""Draws Ox widgets on the Page Editor canvas the way the device's firmware draws them.
The rules are the R-ION firmware's (graphics.c, ontrolXx.c) and each widget's draw():
text boxes drawn with the device's own bitmap fonts from Sedona home's fontBank
(a Windows font of the same size when the file is not there). The
line is centred vertically, aligned by attributes bits 4-5 when the
box is wider than the text, and cut at the character that would cross
the right edge. A box whose draw-background bit (3) is clear is
transparent. The last `subscriptDigits` characters are drawn half size.
images centred in a box larger than the image, cropped to a smaller one; a
file the editor cannot find is drawn as the device draws a missing
one, a red and white checker.
values each widget shows the text its `in` currently selects, so a live
page reads as the device does and an offline one shows the defaults.
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
try:
from PIL import Image, ImageTk
_HAS_PIL = True
except ImportError:
_HAS_PIL = False
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,
parse_lookup, parse_numeric_format, rgb565_to_hex)
PLACEHOLDER_FILL = "#dce6f2"
PLACEHOLDER_OUTLINE = "#7f9db9"
HIDDEN_OUTLINE = "#9a9a9a"
CHECKER_COLOURS = ((232, 60, 60), (255, 255, 255)) # the firmware's RED, WHITE
CHECKER_CELL = 4
SEPARATORS = {0: "/", 1: ".", 2: "-", 3: " "}
FALLBACK_FAMILY = "Segoe UI"
_font_cache = {}
_families = None
_text_cache = {} # rendered lines, so a redraw does not re-decode every glyph
TEXT_CACHE_SIZE = 400
def _font(family, pixels, style):
"""A Tk font `pixels` tall; style is fontAttributes' 0 normal, 1 bold, 2 italic."""
global _families
if _families is None:
_families = {f.lower() for f in tkfont.families()}
if family.lower() not in _families:
family = FALLBACK_FAMILY
key = (family, pixels, style)
if key not in _font_cache:
_font_cache[key] = tkfont.Font(
family=family, size=-max(1, pixels),
weight="bold" if style == 1 else "normal",
slant="italic" if style == 2 else "roman")
return _font_cache[key]
# --- the device's own bitmap fonts ----------------------------------------------------
class BitmapFont:
"""A device font file: fontBank/'32pt Tahoma.bin', or fontBankXL/'.oxfl' (same layout).
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
data 4-bit grey, two pixels a byte, high nibble first, row by row
"""
def __init__(self, path):
data = open(path, "rb").read()
self.data = data
self.height = data[34]
count = data[32] | data[33] << 8
self.glyphs = {}
for index in range(count):
e = data[64 + index * 10:74 + index * 10]
if len(e) < 10:
break
code = e[0] if e[0] < 192 and e[1] == 0 else e[0] | e[1] << 8
self.glyphs[code] = (e[2] | e[3] << 8 | e[4] << 16, e[5], e[6],
e[7] - 256 if e[7] > 127 else e[7], e[8], e[9])
def metrics(self, code):
"""(printWidth, xOffset) the way Calculate_Text_Width works them out."""
_, width, _, x_off, _, advance = self.glyphs[code]
x_offset = 1 if x_off < 0 else x_off + 1
print_width = max(advance, width, x_offset + width)
return print_width, x_offset
def glyph_image(self, code):
offset, width, height, _, _, _ = self.glyphs[code]
img = Image.new("L", (max(1, width), max(1, height)))
if width and height:
raw = self.data[offset:offset + (width * height + 1) // 2]
pixels = bytearray(width * height)
for i in range(width * height):
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
_bitmap_fonts = {}
def find_bitmap_font(family, size, style):
"""The device font for fontName/fontSize/fontAttributes, or None when none is local.
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
find nothing better.
"""
home = getattr(app_state, "SEDONA_HOME", None)
if not home or 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")]
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")]
for path in candidates:
if path in _bitmap_fonts:
return _bitmap_fonts[path]
if os.path.isfile(path):
try:
_bitmap_fonts[path] = BitmapFont(path)
except (OSError, IndexError):
_bitmap_fonts[path] = None
return _bitmap_fonts[path]
return None
def render_bitmap_text(font, text, box_width, subscript):
"""(mask, text width): the line as Draw_Text_Box lays it out, in device pixels.
Characters the font lacks are skipped, the last `subscript` drawn ones are halved
in both directions and sit on the lower half of the line (Stretch_Char), and the
line stops at the first character that would cross the box's right edge.
"""
codes = [ord(ch) for ch in text if ord(ch) in font.glyphs]
stretch_from = len(codes) - subscript if 0 < subscript <= len(codes) else len(codes)
layout, width = [], 0
for n, code in enumerate(codes):
print_width, x_offset = font.metrics(code)
stretched = n >= stretch_from
if stretched:
print_width, x_offset = print_width >> 1, x_offset >> 1
if width + print_width > box_width:
break
layout.append((code, width + x_offset, stretched))
width += print_width
mask = Image.new("L", (max(1, width), max(1, font.height)))
for code, x, stretched in layout:
glyph = font.glyph_image(code)
_, _, height, _, y_offset, _ = font.glyphs[code]
if stretched:
glyph = glyph.resize((max(1, glyph.width >> 1), max(1, glyph.height >> 1)),
Image.BOX)
y_offset += height >> 1
mask.paste(glyph, (x, y_offset), glyph)
return mask, width
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 ext in IMAGE_EXTENSIONS:
path = os.path.join(folder, name + ext)
if os.path.isfile(path):
return path
return None
def _format_value(fmt, value):
"""sprintf(fmt, value) for the %f formats NumericLabel carries."""
parts = parse_numeric_format(fmt)
if parts is not None:
prefix, show, precision, suffix = parts
return f"{prefix}{value:.{precision}f}{suffix}" if show else prefix
try:
return fmt % value
except (TypeError, ValueError):
return fmt
def _lookup_text(text, index):
"""Process_Text: prefix + the index'th item + suffix, or '<n>:Error'."""
prefix, items, suffix = parse_lookup(text)
if 0 <= index < len(items):
return f"{prefix}{items[index]}{suffix}"
return f"{index}:Error"
def _date_text(options, separator):
"""Today's date as DateLabelSet.prepareDateString lays it out."""
today = datetime.date.today()
no_zero = bool(options & DATE_LEADING_ZERO) # the firmware drops the zero when set
day = str(today.day) if no_zero else f"{today.day:02d}"
month = str(today.month) if no_zero else f"{today.month:02d}"
sep = SEPARATORS.get(separator, "/")
first, second = (month, day) if options & DATE_MONTH_FIRST else (day, month)
text = f"{first}{sep}{second}"
if not options & DATE_HIDE_YEAR:
year = today.year % 100 if options & DATE_TWO_DIGIT_YEAR else today.year
text += f"{sep}{year:02d}"
return text
def _time_text(prefix, minutes, am_pm):
"""drawTimeTextBox with digits 0: prefix + HH:MM, optionally 12-hour."""
minutes = int(minutes) % 1440
hour, minute = divmod(minutes, 60)
if not am_pm:
return f"{prefix}{hour:02d}:{minute:02d}"
suffix = " AM" if hour < 12 else " PM"
hour = 12 if hour == 0 else hour - 12 if hour > 12 else hour
return f"{prefix}{hour:02d}:{minute:02d}{suffix}"
def _as_float(raw, fallback=0.0):
try:
return float(raw)
except (TypeError, ValueError):
return fallback
def _bool_state(raw):
return properties.bool_state(raw)
class WidgetPainter:
"""Paints one page's widgets; holds the PhotoImages the canvas needs kept alive."""
def __init__(self, canvas, origin_x, origin_y, zoom, page_colour):
self.c = canvas
self.ox = origin_x
self.oy = origin_y
self.z = zoom
self.page_colour = page_colour
self.images = []
# ── primitives ────────────────────────────────────────────────
def _xy(self, x, y):
return self.ox + x * self.z, self.oy + y * self.z
def _shape(self, x, y, w, h, fill, shape, tags):
x1, y1 = self._xy(x, y)
x2, y2 = self._xy(x + w, y + h)
maker = self.c.create_oval if shape else self.c.create_rectangle
maker(x1, y1, x2, y2, fill=fill, outline="", tags=tags)
def text_box(self, m, text, x, y, w, h, text_colour, box_colour, tags,
attributes=None, shape=None):
"""A label's box and text.
The line box (the font's full height) is centred on the widget box even when
it is the taller of the two, so the glyphs sit in the box as Workbench shows
them rather than hanging below a short one.
"""
attributes = m.int("attributes", 0x08) if attributes is None else attributes
shape = m.int("shape") if shape is None else shape
names = font_names(m) if "fontName" in m.slots else ("Tahoma",)
family_index = m.int("fontName")
family = names[family_index] if 0 <= family_index < len(names) else names[0]
size = m.int("fontSize", 16)
style = m.int("fontAttributes")
if attributes & BACKGROUND_BIT:
self._shape(x, y, w, h, rgb565_to_hex(box_colour), shape, tags)
if not text:
return
digits = attributes & SUBSCRIPT_MASK
bitmap = find_bitmap_font(family, size, style)
if bitmap is not None:
self._bitmap_text(bitmap, text, x, y, w, h, attributes, digits, text_colour, tags)
else:
self._tk_text(family, size, style, text, x, y, w, h, attributes, digits,
text_colour, tags)
@staticmethod
def _left_space(attributes, box_width, text_width):
if box_width <= text_width:
return 0
alignment = attributes & ALIGN_MASK
if alignment == 0x20:
return box_width - text_width
if alignment == 0x10:
return (box_width - text_width) / 2
return 0
def _bitmap_text(self, font, text, x, y, w, h, attributes, digits, text_colour, tags):
key = (id(font), text, w, digits, text_colour, self.z)
cached = _text_cache.get(key)
if cached is None:
mask, width = render_bitmap_text(font, text, w, digits)
if width == 0:
return
size = (max(1, round(mask.width * self.z)), max(1, round(mask.height * self.z)))
if size != mask.size:
# Up close the device's pixels are shown as pixels; below 1:1 they blend.
mask = mask.resize(size, Image.NEAREST if self.z >= 2 else Image.LANCZOS)
r, g, b = (int(rgb565_to_hex(text_colour)[i:i + 2], 16) for i in (1, 3, 5))
img = Image.new("RGBA", mask.size, (r, g, b, 0))
img.putalpha(mask)
if len(_text_cache) > TEXT_CACHE_SIZE:
_text_cache.clear()
cached = _text_cache[key] = (ImageTk.PhotoImage(img), width)
photo, width = cached
self.images.append(photo)
left = self._left_space(attributes, w, width)
top = (h - font.height) / 2
self.c.create_image(*self._xy(x + left, y + top), anchor="nw", image=photo, tags=tags)
def _tk_text(self, family, size, style, text, x, y, w, h, attributes, digits,
text_colour, tags):
"""The fallback when no device font file is found: a Windows font of the size."""
z = self.z
main_font = _font(family, round(size * z), style)
half_font = _font(family, round(size * z / 2), style)
split = len(text) - digits if 0 < digits < len(text) else len(text)
chars = [(ch, main_font if i < split else half_font) for i, ch in enumerate(text)]
limit = w * z
drawn, width = [], 0.0
for ch, fnt in chars:
advance = fnt.measure(ch)
if width + advance > limit + 0.5:
break
drawn.append((ch, fnt))
width += advance
bx, by = self._xy(x, y)
cursor = bx + self._left_space(attributes, limit, width)
top = by + (h * z - main_font.metrics("linespace")) / 2
colour = rgb565_to_hex(text_colour)
main_text = "".join(ch for ch, fnt in drawn if fnt is main_font)
tail_text = "".join(ch for ch, fnt in drawn if fnt is half_font)
if main_text:
self.c.create_text(cursor, top, text=main_text, anchor="nw", fill=colour,
font=main_font, tags=tags)
cursor += main_font.measure(main_text)
if tail_text:
baseline = top + main_font.metrics("ascent") - half_font.metrics("ascent")
self.c.create_text(cursor, baseline, text=tail_text, anchor="nw", fill=colour,
font=half_font, tags=tags)
def image_box(self, name, x, y, w, h, tags):
path = find_image_file(name)
if not _HAS_PIL:
self._placeholder(x, y, w, h, name or "image", tags)
return
if path is None:
self._checker(x, y, w, h, tags)
return
try:
img = Image.open(path).convert("RGBA")
except Exception:
self._checker(x, y, w, h, tags)
return
iw, ih = img.size
crop_w, crop_h = min(iw, w), min(ih, h)
img = img.crop((0, 0, crop_w, crop_h))
left = (w - iw) // 2 if w > iw else 0
top = (h - ih) // 2 if h > ih else 0
size = (max(1, round(crop_w * self.z)), max(1, round(crop_h * self.z)))
if size != img.size:
img = img.resize(size, Image.LANCZOS)
photo = ImageTk.PhotoImage(img)
self.images.append(photo)
self.c.create_image(*self._xy(x + left, y + top), anchor="nw", image=photo, tags=tags)
def _checker(self, x, y, w, h, tags):
cw, ch = max(1, round(w * self.z)), max(1, round(h * self.z))
cell = max(1, round(CHECKER_CELL * self.z))
img = Image.new("RGB", (cw, ch))
pixels = img.load()
for py in range(ch):
row = (py // cell) & 1
for px in range(cw):
pixels[px, py] = CHECKER_COLOURS[((px // cell) & 1) ^ row]
photo = ImageTk.PhotoImage(img)
self.images.append(photo)
self.c.create_image(*self._xy(x, y), anchor="nw", image=photo, tags=tags)
def _placeholder(self, x, y, w, h, label, tags):
x1, y1 = self._xy(x, y)
x2, y2 = self._xy(x + w, y + h)
self.c.create_rectangle(x1, y1, x2, y2, fill=PLACEHOLDER_FILL,
outline=PLACEHOLDER_OUTLINE, tags=tags)
self.c.create_text((x1 + x2) / 2, (y1 + y2) / 2, text=label, fill="#000000",
font=(FALLBACK_FAMILY, max(7, min(10, int(min(x2 - x1, y2 - y1) // 8)))),
tags=tags)
def page_image(self, name, width, height):
"""OxPage.imageFileName, drawn over the page colour from the top-left corner."""
if not name or find_image_file(name) is None:
return
self.image_box(name, 0, 0, width, height, ("page_image",))
# ── widgets ───────────────────────────────────────────────────
def draw(self, widget, tags, short_label):
"""Paints `widget`; returns its box (x, y, w, h) in device pixels."""
m = _Model(widget)
x, y = m.int("x"), m.int("y")
w, h = max(1, m.int("width", 80)), max(1, m.int("height", 80))
kind = widget.get("type", "").rsplit("::", 1)[-1]
if _bool_state(m.raw("visibility")) == "false":
x1, y1 = self._xy(x, y)
x2, y2 = self._xy(x + w, y + h)
self.c.create_rectangle(x1, y1, x2, y2, outline=HIDDEN_OUTLINE, dash=(3, 3),
tags=tags)
return x, y, w, h
painter = getattr(self, f"_draw_{kind}", None)
if painter is None:
self._placeholder(x, y, w, h, f"{widget.get('name', '?')}\n({short_label})", tags)
else:
painter(m, x, y, w, h, tags)
return x, y, w, h
def _draw_PushButtonLabel(self, m, x, y, w, h, tags):
self.text_box(m, m.text("text"), x, y, w, h, m.int("textColor"),
m.int("backgroundColor", 0xFF00), tags)
def _draw_NumericLabel(self, m, x, y, w, h, tags):
fmt = m.text("text")
value = _as_float(m.raw("in"))
tab = m.int("valueTabPosition")
split = fmt.find("%")
if tab == 0 or split <= 0:
self.text_box(m, _format_value(fmt, value), x, y, w, h, m.int("textColor"),
m.int("backgroundColor", 0xFF00), tags)
return
attributes = m.int("attributes", 0x08)
self.text_box(m, fmt[:split].replace("%%", "%"), x, y, tab, h, m.int("textColor"),
m.int("backgroundColor", 0xFF00), tags, shape=0)
self.text_box(m, _format_value(": " + fmt[split:], value), x + tab, y, w - tab, h,
m.int("textColor"), m.int("backgroundColor", 0xFF00), tags,
attributes=attributes & 0xCF, shape=0)
_draw_NumericLabelSet = _draw_NumericLabel
def _draw_NumericSelectLabel(self, m, x, y, w, h, tags):
text = m.text("text")
if not text:
return
self.text_box(m, _lookup_text(text, m.int("in")), x, y, w, h, m.int("textColor"),
m.int("backgroundColor", 0xFF00), tags)
def _draw_BooleanLabel(self, m, x, y, w, h, tags):
state = _bool_state(m.raw("in"))
if state == "true":
text, fg, bg = m.text("trueText"), m.int("trueTextColor"), m.int("trueBackgroundColor")
else:
text = m.text("falseText") if state == "false" else "Null"
fg, bg = m.int("falseTextColor"), m.int("falseBackgroundColor")
attributes = m.int("attributes", 0x08)
if not text:
attributes &= 0xF7
self.text_box(m, text, x, y, w, h, fg, bg, tags, attributes=attributes)
def _draw_ScheduleLabelSet(self, m, x, y, w, h, tags):
state = _bool_state(m.raw("in"))
if state == "true":
img, fg, bg, index = (m.text("trueImageFileName"), m.int("trueTextColor"),
m.int("trueBackgroundColor"), 1)
else:
img = m.text("falseImageFileName") if state == "false" else ""
fg, bg = m.int("falseTextColor"), m.int("falseBackgroundColor")
index = 0 if state == "false" else 2
if img:
self.image_box(img, x, y, w, h, tags)
elif m.text("text"):
self.text_box(m, _lookup_text(m.text("text"), index), x, y, w, h, fg, bg, tags)
def _draw_DateLabelSet(self, m, x, y, w, h, tags):
text = _date_text(m.int("dateFormatOptions"), m.int("dateSeparatorCharacter"))
self.text_box(m, text, x, y, w, h, m.int("textColor"),
m.int("backgroundColor", 0xFF00), tags)
def _draw_TimeLabelSet(self, m, x, y, w, h, tags):
am_pm = _bool_state(m.raw("amPmFormat")) == "true"
text = _time_text(m.text("text"), m.int("in"), am_pm)
self.text_box(m, text, x, y, w, h, m.int("textColor"),
m.int("backgroundColor", 0xFF00), tags,
attributes=m.int("attributes", 0x08) & 0xF8)
def _draw_Geometry(self, m, x, y, w, h, tags):
if _bool_state(m.raw("transparent")) != "true":
self._shape(x, y, w, h, rgb565_to_hex(m.int("backgroundColor", 0xFF00)),
m.int("shape"), tags)
def _draw_CalibrationButton(self, m, x, y, w, h, tags):
self._shape(x, y, w, h, rgb565_to_hex(m.int("backgroundColor", 0xFF00)),
m.int("shape"), tags)
def _draw_PushButtonImage(self, m, x, y, w, h, tags):
self.image_box(m.text("imageFileName"), x, y, w, h, tags)
def _draw_BooleanImage(self, m, x, y, w, h, tags):
state = _bool_state(m.raw("in"))
if state == "null":
return
name = m.text("trueImageFileName" if state == "true" else "falseImageFileName")
self.image_box(name, x, y, w, h, tags)
def _draw_NumericSelectImage(self, m, x, y, w, h, tags):
self.image_box(f"{m.text('imageFileNamePrefix')}{m.int('in')}", x, y, w, h, tags)