"""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 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, PORTRAIT_FONT_EXTENSION, bank_folders, font_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). 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 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.rotated = path.lower().endswith(PORTRAIT_FONT_EXTENSION) 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] 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) 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.transpose(Image.ROTATE_270) if self.rotated else 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: '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. The file comes from the platform's own bank, and on Orion in the cut its screen orientation draws with. """ if not _HAS_PIL: return None stems = [f"{'bold ' if style == 1 else ''}{size}pt {family}"] if style == 1: 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] 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 for folder in bank_folders("image"): 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 ':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)