"""WYSIWYG HMI page editor for ontrolOx::OxPage components. Renders the device screen canvas at its native resolution and places the child widgets (ontrolOx::Widget subclasses) by their x, y, width, height slots. Supports selection, drag-move, and a synced widget tree. Device resolution is detected from the schema kits: ontrolOrion → 800×480 ontrolRion → 320×240 A device frame image (the physical bezel) sits behind the screen area when the corresponding PNG is found in the assets/ directory next to this module. """ import os import tkinter as tk from tkinter import font as tkfont, ttk import app_state HEADER_BG = "#f2f2f2" _ASSETS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets") DEVICE_PROFILES = { "ontrolOrion": { "screen": (800, 480), "frame_file": "frame_orion.png", "screen_offset": (191, 169), }, "ontrolRion": { "screen": (320, 240), "frame_file": "frame_rion.png", "screen_offset": (76, 62), }, } DEFAULT_SCREEN = (800, 480) 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", "NumericLabelSet": "NLS", "PushButtonLabel": "PBL", "PushButtonImage": "PBI", "BooleanLabel": "BL", "BooleanImage": "BI", "Geometry": "Geo", "CalibrationButton": "Cal", "NumericSelectImage": "NSI", "NumericSelectLabel": "NSL", "DateLabelSet": "DLS", "TimeLabelSet": "TLS", "ScheduleLabelSet": "ScL", } def _rgb565_to_hex(val): try: v = int(val) except (TypeError, ValueError): return "#ffffff" if v < 0: v &= 0xFFFF r = ((v >> 11) & 0x1F) * 255 // 31 g = ((v >> 5) & 0x3F) * 255 // 63 b = (v & 0x1F) * 255 // 31 return f"#{r:02x}{g:02x}{b:02x}" def _get_prop(comp, name, default=None): prop = comp.find(f"prop[@name='{name}']") return prop.get("val", default) if prop is not None else default def _set_prop(comp, name, value): prop = comp.find(f"prop[@name='{name}']") if prop is not None: prop.set("val", value) else: import xml.etree.ElementTree as ET ET.SubElement(comp, "prop", name=name, val=value) def _short_type(full_type): short = full_type.rsplit("::", 1)[-1] if "::" in full_type else full_type return _SHORT_TYPE_NAMES.get(short, short) def _detect_device_profile(): for kit_name, profile in DEVICE_PROFILES.items(): if kit_name in app_state.active_schema_kits: return profile return None def _detect_screen_size(): profile = _detect_device_profile() return profile["screen"] if profile else DEFAULT_SCREEN def _load_frame_image(profile): if profile is None: return None path = os.path.join(_ASSETS_DIR, profile["frame_file"]) if not os.path.isfile(path): return None try: return tk.PhotoImage(file=path) except tk.TclError: return None def _is_ox_widget(comp): comp_type = comp.get("type", "") return comp_type.startswith("ontrolOx::") and comp_type != "ontrolOx::OxPage" def _get_widget_children(page_comp): return [c for c in page_comp if c.tag == "comp" and _is_ox_widget(c)] class OxPageViewPanel: def __init__(self, parent, comp, on_change=None, on_close=None): self.comp = comp self.on_change = on_change self.on_close = on_close self.dirty = False self.frame = tk.Frame(parent) profile = _detect_device_profile() self._screen_w, self._screen_h = _detect_screen_size() self._frame_image = _load_frame_image(profile) if self._frame_image and profile: self._screen_ox = profile["screen_offset"][0] self._screen_oy = profile["screen_offset"][1] frame_w = self._frame_image.width() frame_h = self._frame_image.height() self._frame_ox = CANVAS_PADDING self._frame_oy = CANVAS_PADDING self._canvas_w = frame_w + CANVAS_PADDING * 2 self._canvas_h = frame_h + CANVAS_PADDING * 2 else: self._screen_ox = 0 self._screen_oy = 0 self._frame_ox = 0 self._frame_oy = 0 self._canvas_w = self._screen_w + CANVAS_PADDING * 2 self._canvas_h = self._screen_h + CANVAS_PADDING * 2 self._selected_widget = None self._widget_items = {} self._tag_to_comp = {} self._drag = {"tag": None, "x": 0, "y": 0} self._build_header() self._build_body() self._build_footer() self._render_page() @property def _screen_canvas_x(self): if self._frame_image: return self._frame_ox + self._screen_ox return CANVAS_PADDING @property def _screen_canvas_y(self): if self._frame_image: return self._frame_oy + self._screen_oy return CANVAS_PADDING def _build_header(self): header = tk.Frame(self.frame, padx=8, pady=6, bg=HEADER_BG) header.pack(fill=tk.X) name_font = tkfont.Font(family="Segoe UI", size=9, weight="bold") plain_font = tkfont.Font(family="Segoe UI", size=9) tk.Label(header, text="Page Editor", font=name_font, bg=HEADER_BG).pack(side=tk.LEFT) page_name = self.comp.get("name", "?") tk.Label(header, text=f"— {page_name} ({self._screen_w}×{self._screen_h})", font=plain_font, fg="#555555", bg=HEADER_BG).pack(side=tk.LEFT, padx=(8, 0)) self._widget_count_var = tk.StringVar(value="") tk.Label(header, textvariable=self._widget_count_var, font=plain_font, fg="#555555", bg=HEADER_BG).pack(side=tk.RIGHT) def _build_body(self): body = ttk.PanedWindow(self.frame, orient=tk.HORIZONTAL) body.pack(fill=tk.BOTH, expand=True) tree_frame = ttk.Frame(body) self._build_widget_tree(tree_frame) body.add(tree_frame, weight=0) canvas_frame = ttk.Frame(body) self._build_canvas(canvas_frame) body.add(canvas_frame, weight=1) def _build_widget_tree(self, parent): ttk.Label(parent, text="Widgets", font=("TkDefaultFont", 9, "bold")).pack( anchor="w", padx=4, pady=(4, 2)) tree_container = ttk.Frame(parent) tree_container.pack(fill=tk.BOTH, expand=True, padx=4, pady=4) self._tree = ttk.Treeview(tree_container, show="tree", selectmode="browse", height=12) vscroll = ttk.Scrollbar(tree_container, orient=tk.VERTICAL, command=self._tree.yview) self._tree.configure(yscrollcommand=vscroll.set) tree_container.columnconfigure(0, weight=1) tree_container.rowconfigure(0, weight=1) self._tree.grid(row=0, column=0, sticky="nsew") vscroll.grid(row=0, column=1, sticky="ns") self._tree.bind("<>", self._on_tree_select) self._tree_items = {} def _build_canvas(self, parent): vscroll = ttk.Scrollbar(parent, orient=tk.VERTICAL) vscroll.pack(side=tk.RIGHT, fill=tk.Y) hscroll = ttk.Scrollbar(parent, orient=tk.HORIZONTAL) hscroll.pack(side=tk.BOTTOM, fill=tk.X) self._canvas = tk.Canvas( parent, bg="#e0e0e0", highlightthickness=0, xscrollcommand=hscroll.set, yscrollcommand=vscroll.set, scrollregion=(0, 0, self._canvas_w, self._canvas_h), ) self._canvas.pack(fill=tk.BOTH, expand=True) vscroll.config(command=self._canvas.yview) hscroll.config(command=self._canvas.xview) self._canvas.bind("", self._on_canvas_click) self._canvas.bind("", self._on_canvas_drag) self._canvas.bind("", self._on_canvas_release) self._canvas.bind("", self._on_canvas_right_click) def _render_page(self): c = self._canvas c.delete("all") self._widget_items.clear() self._tag_to_comp.clear() sx = self._screen_canvas_x sy = self._screen_canvas_y bg_val = _get_prop(self.comp, "backgroundColor", "65535") bg_colour = _rgb565_to_hex(bg_val) c.create_rectangle(sx, sy, sx + self._screen_w, sy + self._screen_h, fill=bg_colour, outline="", tags=("screen_bg",)) if self._frame_image: c.create_image(self._frame_ox, self._frame_oy, anchor="nw", image=self._frame_image, tags=("device_frame",)) else: c.create_rectangle(sx, sy, sx + self._screen_w, sy + self._screen_h, fill="", outline="#333333", width=2, tags=("screen_border",)) widgets = _get_widget_children(self.comp) for widget in widgets: self._draw_widget(widget) self._widget_count_var.set( f"{len(widgets)} widget{'' if len(widgets) == 1 else 's'}") self._populate_tree() def _draw_widget(self, widget): c = self._canvas sx = self._screen_canvas_x sy = self._screen_canvas_y 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")) w = max(WIDGET_MIN_SIZE, w) h = max(WIDGET_MIN_SIZE, h) cx = sx + x cy = sy + y tag = f"ox_widget_{id(widget)}" comp_type = widget.get("type", "") short = _short_type(comp_type) name = widget.get("name", "?") 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 + w, cy + h, 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, min(w, h) // 8)) c.create_text(cx + w / 2, cy + h / 2, text=label, fill=text_colour, font=("Segoe UI", font_size), anchor="center", tags=(tag, "ox_widget", "ox_widget_text")) self._widget_items[widget] = tag self._tag_to_comp[tag] = widget def _populate_tree(self): for item in self._tree.get_children(): self._tree.delete(item) self._tree_items.clear() page_name = self.comp.get("name", "?") page_iid = self._tree.insert("", tk.END, text=f"\U0001f4c4 {page_name}", open=True) widgets = _get_widget_children(self.comp) for widget in widgets: name = widget.get("name", "?") short = _short_type(widget.get("type", "")) iid = self._tree.insert(page_iid, tk.END, text=f"{name} [{short}]") self._tree_items[iid] = widget def _select_widget(self, widget): c = self._canvas c.itemconfig("ox_widget_rect", outline=WIDGET_OUTLINE, width=WIDGET_OUTLINE_WIDTH) c.delete("selection_handle") self._selected_widget = widget if widget is None: return tag = self._widget_items.get(widget) if tag is None: return c.itemconfig(f"{tag} && ox_widget_rect", outline=SELECTED_OUTLINE, width=SELECTED_OUTLINE_WIDTH) c.tag_raise(tag) bbox = c.bbox(f"{tag} && ox_widget_rect") if bbox: self._draw_handles(bbox) for iid, comp in self._tree_items.items(): if comp is widget: self._tree.selection_set(iid) self._tree.see(iid) break name = widget.get("name", "?") comp_type = widget.get("type", "") x = _get_prop(widget, "x", "0") y = _get_prop(widget, "y", "0") w = _get_prop(widget, "width", "0") h = _get_prop(widget, "height", "0") app_state.set_status( f"{name} ({comp_type}) x={x} y={y} w={w} h={h}") def _draw_handles(self, bbox): c = self._canvas x1, y1, x2, y2 = bbox hs = HANDLE_SIZE // 2 points = [ (x1, y1), ((x1 + x2) / 2, y1), (x2, y1), (x1, (y1 + y2) / 2), (x2, (y1 + y2) / 2), (x1, y2), ((x1 + x2) / 2, y2), (x2, y2), ] for px, py in points: c.create_rectangle(px - hs, py - hs, px + hs, py + hs, fill=HANDLE_COLOUR, outline=HANDLE_COLOUR, tags=("selection_handle",)) def _on_canvas_click(self, event): c = self._canvas 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] self._select_widget(widget) self._drag["tag"] = widget_tag self._drag["x"] = cx self._drag["y"] = cy else: self._select_widget(None) self._drag["tag"] = None def _on_canvas_drag(self, event): if not self._drag["tag"]: return c = self._canvas cx = c.canvasx(event.x) cy = c.canvasy(event.y) dx = cx - self._drag["x"] dy = cy - self._drag["y"] tag = self._drag["tag"] c.move(tag, dx, dy) c.delete("selection_handle") self._drag["x"] = cx self._drag["y"] = cy def _on_canvas_release(self, event): tag = self._drag["tag"] if not tag: return widget = self._tag_to_comp.get(tag) if widget is None: self._drag["tag"] = None return c = self._canvas bbox = c.bbox(f"{tag} && ox_widget_rect") if bbox: new_x = bbox[0] - self._screen_canvas_x new_y = bbox[1] - self._screen_canvas_y new_x = max(0, min(new_x, self._screen_w)) new_y = max(0, min(new_y, self._screen_h)) old_x = _get_prop(widget, "x", "0") old_y = _get_prop(widget, "y", "0") if str(new_x) != old_x or str(new_y) != old_y: _set_prop(widget, "x", str(new_x)) _set_prop(widget, "y", str(new_y)) app_state.mark_dirty() self._drag["tag"] = None self._render_page() if widget is not None: self._select_widget(widget) def _on_canvas_right_click(self, event): c = self._canvas cx = c.canvasx(event.x) cy = c.canvasy(event.y) clicked = c.find_closest(cx, cy, halo=3) if not clicked: 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) menu.add_command(label="Properties", command=lambda: self._open_properties(widget)) menu.post(event.x_root, event.y_root) def _on_tree_select(self, event): sel = self._tree.selection() if not sel: return widget = self._tree_items.get(sel[0]) if widget is not None: self._select_widget(widget) def _open_properties(self, widget): pass def refresh(self): self._render_page() def close(self): if self.on_close is not None: self.on_close() return True def destroy(self): if self.frame.winfo_exists(): self.frame.destroy() def _build_footer(self): footer = tk.Frame(self.frame, padx=8, pady=6) footer.pack(side=tk.BOTTOM, fill=tk.X) ttk.Button(footer, text="Close", width=9, command=self.close).pack(side=tk.RIGHT) def is_ox_page(comp): return comp is not None and comp.get("type", "") == "ontrolOx::OxPage" def build_ox_page_view_panel(parent, comp, on_change=None, on_close=None): if comp is None or parent is None: return None return OxPageViewPanel(parent, comp, on_change, on_close)