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>
This commit is contained in:
2026-09-24 15:16:51 +03:00
co-authored by Claude Opus 5.5
parent 43d4849533
commit e5cb04b8cf
4 changed files with 662 additions and 65 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 os
VERSION = "0.0.0.129"
VERSION = "0.0.0.130"
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True
+87 -63
View File
@@ -26,6 +26,7 @@ import app_state
import live_hooks
import undo
from ox_widget_properties import OxWidgetPropertiesPanel
from ox_widget_render import WidgetPainter
from sax_dom import set_prop_value
HEADER_BG = "#f2f2f2"
@@ -53,13 +54,11 @@ ZOOM_LEVELS = (0.25, 0.33, 0.5, 0.67, 0.8, 1.0, 1.25, 1.5, 2.0, 3.0)
CANVAS_PADDING = 16
WIDGET_OUTLINE = "#7f9db9"
WIDGET_OUTLINE_WIDTH = 1
SELECTED_OUTLINE = "#1f4e79"
SELECTED_OUTLINE_WIDTH = 2
HANDLE_SIZE = 6
HANDLE_COLOUR = "#00b050"
WIDGET_MIN_SIZE = 4
_SHORT_TYPE_NAMES = {
"NumericLabel": "NL",
@@ -143,6 +142,22 @@ def _get_widget_children(page_comp):
CANVAS_BG = "#e0e0e0"
GRID_STEP = 10 # device pixels
GRID_MIN_SPACING = 4 # canvas pixels; a denser grid is left out rather than drawn solid
GRID_SHADE = 0.82 # grid line = page colour scaled by this (lifted on dark pages)
# Whether the 10x10 grid is drawn, held for the session like the Sedona sheet's toggles.
show_grid = True
def _grid_colour(page_hex):
r, g, b = (int(page_hex[i:i + 2], 16) for i in (1, 3, 5))
if (r * 299 + g * 587 + b * 114) / 1000 < 60:
r, g, b = (min(255, v + 45) for v in (r, g, b))
else:
r, g, b = (int(v * GRID_SHADE) for v in (r, g, b))
return f"#{r:02x}{g:02x}{b:02x}"
class OxPageViewPanel:
@@ -179,6 +194,7 @@ class OxPageViewPanel:
self._selected_widget = None
self._widget_items = {}
self._tag_to_comp = {}
self._widget_boxes = {}
self._drag = {"tag": None, "x": 0, "y": 0}
self._build_header()
@@ -217,6 +233,11 @@ class OxPageViewPanel:
tk.Label(header, textvariable=self._widget_count_var, font=plain_font,
fg="#555555", bg=HEADER_BG).pack(side=tk.RIGHT)
self._grid_var = tk.BooleanVar(value=show_grid)
tk.Checkbutton(header, text="Grid", variable=self._grid_var, font=plain_font,
bg=HEADER_BG, activebackground=HEADER_BG,
command=self._on_grid_toggled).pack(side=tk.RIGHT, padx=(0, 12))
def _build_body(self):
body = ttk.PanedWindow(self.frame, orient=tk.HORIZONTAL)
body.pack(fill=tk.BOTH, expand=True)
@@ -283,6 +304,7 @@ class OxPageViewPanel:
c.delete("all")
self._widget_items.clear()
self._tag_to_comp.clear()
self._widget_boxes.clear()
z = self._zoom
sx = self._screen_canvas_x * z
@@ -294,13 +316,22 @@ class OxPageViewPanel:
fill=bg_colour, outline="",
tags=("screen_bg",))
self._painter = WidgetPainter(c, sx, sy, z, bg_colour)
self._painter.page_image(_get_prop(self.comp, "imageFileName", ""),
self._screen_w, self._screen_h)
if self._grid_var.get():
self._draw_grid(sx, sy, _grid_colour(bg_colour))
if self._frame_image:
if z != 1.0 and self._frame_pil is not None:
orig_w, orig_h = self._frame_pil.size
zw = max(1, int(orig_w * z))
zh = max(1, int(orig_h * z))
resized = self._frame_pil.resize((zw, zh), Image.LANCZOS)
self._scaled_frame = ImageTk.PhotoImage(resized)
# Rescaled once per zoom level; every edit redraws the page.
if getattr(self, "_scaled_frame_zoom", None) != z:
orig_w, orig_h = self._frame_pil.size
zw = max(1, int(orig_w * z))
zh = max(1, int(orig_h * z))
resized = self._frame_pil.resize((zw, zh), Image.LANCZOS)
self._scaled_frame = ImageTk.PhotoImage(resized)
self._scaled_frame_zoom = z
c.create_image(self._frame_ox * z, self._frame_oy * z, anchor="nw",
image=self._scaled_frame, tags=("device_frame",))
else:
@@ -320,49 +351,55 @@ class OxPageViewPanel:
self._populate_tree()
def _draw_widget(self, widget):
def _draw_grid(self, sx, sy, colour):
"""The 10x10 device-pixel grid over the page background, under the widgets."""
c = self._canvas
z = self._zoom
sx = self._screen_canvas_x * z
sy = self._screen_canvas_y * z
if GRID_STEP * z < GRID_MIN_SPACING:
return
right = sx + self._screen_w * z
bottom = sy + self._screen_h * z
for gx in range(GRID_STEP, self._screen_w, GRID_STEP):
c.create_line(sx + gx * z, sy, sx + gx * z, bottom, fill=colour, tags=("grid",))
for gy in range(GRID_STEP, self._screen_h, GRID_STEP):
c.create_line(sx, sy + gy * z, right, sy + gy * z, fill=colour, tags=("grid",))
x = int(_get_prop(widget, "x", "0"))
y = int(_get_prop(widget, "y", "0"))
w = int(_get_prop(widget, "width", "80"))
h = int(_get_prop(widget, "height", "80"))
def _on_grid_toggled(self):
global show_grid
show_grid = self._grid_var.get()
self.refresh()
w = max(WIDGET_MIN_SIZE, w)
h = max(WIDGET_MIN_SIZE, h)
cx = sx + x * z
cy = sy + y * z
cw = w * z
ch = h * z
def _draw_widget(self, widget):
"""Paints the widget as the device would, then lays an empty box over it.
The box is what selection outlines and what a drag's bbox is read from, so a
transparent label with no text can still be found and moved.
"""
tag = f"ox_widget_{id(widget)}"
comp_type = widget.get("type", "")
short = _short_type(comp_type)
name = widget.get("name", "?")
tags = (tag, "ox_widget")
x, y, w, h = self._painter.draw(widget, tags, _short_type(widget.get("type", "")))
bg_val = _get_prop(widget, "backgroundColor")
fill = _rgb565_to_hex(bg_val) if bg_val is not None else "#dce6f2"
text_val = _get_prop(widget, "textColor")
text_colour = _rgb565_to_hex(text_val) if text_val is not None else "#000000"
c.create_rectangle(cx, cy, cx + cw, cy + ch,
fill=fill, outline=WIDGET_OUTLINE,
width=WIDGET_OUTLINE_WIDTH,
tags=(tag, "ox_widget", "ox_widget_rect"))
label = f"{name}\n({short})"
font_size = max(7, min(10, int(min(cw, ch) // 8)))
c.create_text(cx + cw / 2, cy + ch / 2, text=label,
fill=text_colour, font=("Segoe UI", font_size),
anchor="center", tags=(tag, "ox_widget", "ox_widget_text"))
z = self._zoom
cx = (self._screen_canvas_x + x) * z
cy = (self._screen_canvas_y + y) * z
self._canvas.create_rectangle(cx, cy, cx + w * z, cy + h * z, fill="", outline="",
width=WIDGET_OUTLINE_WIDTH,
tags=(tag, "ox_widget", "ox_widget_rect"))
self._widget_items[widget] = tag
self._tag_to_comp[tag] = widget
self._widget_boxes[widget] = (x, y, w, h)
def _widget_at(self, cx, cy):
"""The topmost widget whose box holds canvas point (cx, cy): the last one drawn."""
z = self._zoom
px = cx / z - self._screen_canvas_x
py = cy / z - self._screen_canvas_y
for widget in reversed(_get_widget_children(self.comp)):
box = self._widget_boxes.get(widget)
if box and box[0] <= px <= box[0] + box[2] and box[1] <= py <= box[1] + box[3]:
return widget
return None
def _populate_tree(self):
for item in self._tree.get_children():
@@ -383,8 +420,7 @@ class OxPageViewPanel:
def _select_widget(self, widget):
c = self._canvas
c.itemconfig("ox_widget_rect", outline=WIDGET_OUTLINE,
width=WIDGET_OUTLINE_WIDTH)
c.itemconfig("ox_widget_rect", outline="", width=WIDGET_OUTLINE_WIDTH)
c.delete("selection_handle")
self._selected_widget = widget
@@ -397,9 +433,11 @@ class OxPageViewPanel:
if tag is None:
return
# Only the outline comes forward: raising the widget itself would paint it over
# the ones the device draws later.
c.itemconfig(f"{tag} && ox_widget_rect", outline=SELECTED_OUTLINE,
width=SELECTED_OUTLINE_WIDTH)
c.tag_raise(tag)
c.tag_raise(f"{tag} && ox_widget_rect")
bbox = c.bbox(f"{tag} && ox_widget_rect")
if bbox:
@@ -484,18 +522,11 @@ class OxPageViewPanel:
cx = c.canvasx(event.x)
cy = c.canvasy(event.y)
clicked = c.find_closest(cx, cy, halo=3)
if not clicked:
self._select_widget(None)
return
tags = c.gettags(clicked[0])
widget_tag = next((t for t in tags if t.startswith("ox_widget_")), None)
if widget_tag and widget_tag in self._tag_to_comp:
widget = self._tag_to_comp[widget_tag]
# Hit-tested by box, not by canvas item: a transparent label is mostly grid.
widget = self._widget_at(cx, cy)
if widget is not None:
self._select_widget(widget)
self._drag["tag"] = widget_tag
self._drag["tag"] = self._widget_items[widget]
self._drag["x"] = cx
self._drag["y"] = cy
else:
@@ -554,16 +585,9 @@ class OxPageViewPanel:
cx = c.canvasx(event.x)
cy = c.canvasy(event.y)
clicked = c.find_closest(cx, cy, halo=3)
if not clicked:
widget = self._widget_at(cx, cy)
if widget is None:
return
tags = c.gettags(clicked[0])
widget_tag = next((t for t in tags if t.startswith("ox_widget_")), None)
if not widget_tag or widget_tag not in self._tag_to_comp:
return
widget = self._tag_to_comp[widget_tag]
self._select_widget(widget)
menu = tk.Menu(self._canvas, tearoff=0)
+43 -1
View File
@@ -356,7 +356,7 @@ def build_props(comp):
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)))
str(m.int(n)) for n in LAYOUT_SLOTS), _layout_writer(m), dialog="layout"))
if m.has(*FONT_SLOTS):
consumed.update(FONT_SLOTS)
@@ -728,6 +728,9 @@ class OxWidgetPropertiesPanel:
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)
@@ -852,6 +855,45 @@ class OxWidgetPropertiesPanel:
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:
+531
View File
@@ -0,0 +1,531 @@
"""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)