diff --git a/app_state.py b/app_state.py index b66257c..632e798 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.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 diff --git a/ox_page_view.py b/ox_page_view.py index d294e5f..9713b4d 100644 --- a/ox_page_view.py +++ b/ox_page_view.py @@ -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) diff --git a/ox_widget_properties.py b/ox_widget_properties.py index b9f76ef..fb1d16f 100644 --- a/ox_widget_properties.py +++ b/ox_widget_properties.py @@ -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("", 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: diff --git a/ox_widget_render.py b/ox_widget_render.py new file mode 100644 index 0000000..71e5f4d --- /dev/null +++ b/ox_widget_render.py @@ -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: '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. + """ + 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 ':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)