"""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 try: from PIL import Image, ImageTk _HAS_PIL = True except ImportError: _HAS_PIL = False 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" _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), "default_zoom": 0.5, }, "ontrolRion": { "screen": (320, 240), "frame_file": "frame_rion.png", "screen_offset": (76, 62), "default_zoom": 1.0, }, } DEFAULT_SCREEN = (800, 480) DEFAULT_ZOOM = 0.5 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_WIDTH = 1 SELECTED_OUTLINE = "#1f4e79" SELECTED_OUTLINE_WIDTH = 2 HANDLE_SIZE = 6 HANDLE_COLOUR = "#00b050" _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 _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, None path = os.path.join(_ASSETS_DIR, profile["frame_file"]) if not os.path.isfile(path): return None, None if _HAS_PIL: try: pil_img = Image.open(path).convert("RGBA") tk_img = ImageTk.PhotoImage(pil_img) return pil_img, tk_img except Exception: return None, None try: return None, tk.PhotoImage(file=path) except tk.TclError: return None, 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)] 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: 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_pil, self._frame_image = _load_frame_image(profile) self._scaled_frame = None 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._zoom = profile.get("default_zoom", DEFAULT_ZOOM) if profile else DEFAULT_ZOOM 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() self._build_body() self._build_footer() self.refresh() @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) 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) canvas_frame = ttk.Frame(body) self._build_canvas(canvas_frame) body.add(canvas_frame, weight=1) # Workbench's layout: the widget tree above, the selected widget's sheet below. side = ttk.PanedWindow(body, orient=tk.VERTICAL) body.add(side, weight=0) tree_frame = ttk.Frame(side) self._build_widget_tree(tree_frame) side.add(tree_frame, weight=1) self._props = OxWidgetPropertiesPanel(side, self._commit_props, self._repaint_all) side.add(self._props.frame, weight=3) 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=8) 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=self._background(), highlightthickness=0, xscrollcommand=hscroll.set, yscrollcommand=vscroll.set, scrollregion=(0, 0, int(self._canvas_w * self._zoom), int(self._canvas_h * self._zoom)), ) 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) self._canvas.bind("", self._on_wheel_zoom) def _render_page(self): c = self._canvas 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 sy = self._screen_canvas_y * z bg_val = _get_prop(self.comp, "backgroundColor", "65535") bg_colour = _rgb565_to_hex(bg_val) c.create_rectangle(sx, sy, sx + self._screen_w * z, sy + self._screen_h * z, 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: # 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: 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 * z, sy + self._screen_h * z, 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_grid(self, sx, sy, colour): """The 10x10 device-pixel grid over the page background, under the widgets.""" c = self._canvas z = self._zoom 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",)) def _on_grid_toggled(self): global show_grid show_grid = self._grid_var.get() self.refresh() 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)}" tags = (tag, "ox_widget") x, y, w, h = self._painter.draw(widget, tags, _short_type(widget.get("type", ""))) 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(): 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="", width=WIDGET_OUTLINE_WIDTH) c.delete("selection_handle") self._selected_widget = widget # With nothing selected the sheet shows the page itself, as Workbench does. self._props.show(widget if widget is not None else self.comp) if widget is None: return tag = self._widget_items.get(widget) 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(f"{tag} && ox_widget_rect") 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: # selection_set always fires <>, which calls # back into _select_widget — only set it when it changes. if self._tree.selection() != (iid,): 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 _repaint_all(self): """Every view redrawn from the document, this one included.""" if self.on_change is not None: self.on_change() else: self.refresh() def _commit_props(self, comp, values): """Writes {prop: raw} to `comp` as one undoable step, and to the device when live. Returns whether anything changed; a change repaints every view, this one included. """ changes = [] for name, after in values.items(): prop = comp.find(f"prop[@name='{name}']") before = prop.get("val") if prop is not None else None if before != after: changes.append((comp, name, before, after)) if not changes: return False live_failures = [] for element, name, _before, after in changes: set_prop_value(element, name, after) if live_hooks.is_live() and not live_hooks.live_set_prop_value(element, name, after): live_failures.append(name) if not app_state.live_mode: undo.push(undo.SlotValueCommand(changes)) app_state.mark_dirty() self._repaint_all() who = comp.get("name", "?") if live_failures: app_state.set_status( f"{who}: written locally but failed on device: {', '.join(live_failures)}") else: app_state.set_status( f"{who}: {', '.join(name for _, name, _, _ in changes)} changed.") return True def _on_canvas_click(self, event): c = self._canvas cx = c.canvasx(event.x) cy = c.canvasy(event.y) # 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"] = self._widget_items[widget] 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 self._drag["tag"] = None c = self._canvas z = self._zoom bbox = c.bbox(f"{tag} && ox_widget_rect") moved = False if bbox: new_x = int((bbox[0] - self._screen_canvas_x * z) / z) new_y = int((bbox[1] - self._screen_canvas_y * z) / z) new_x = max(0, min(new_x, self._screen_w)) new_y = max(0, min(new_y, self._screen_h)) moved = self._commit_props(widget, {"x": str(new_x), "y": str(new_y)}) # A commit re-renders through refresh(); a drag that went nowhere still has to # put back the handles the motion took down. if not moved: self.refresh() def _on_canvas_right_click(self, event): c = self._canvas cx = c.canvasx(event.x) cy = c.canvasy(event.y) widget = self._widget_at(cx, cy) if widget is None: return 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 _on_wheel_zoom(self, event): if not event.delta: return "break" step = 1 if event.delta > 0 else -1 level = self.zoom_step(step) anchor = (event.x, event.y) self._zoom_with_anchor(level, anchor) from main import update_zoom_label update_zoom_label() return "break" def _zoom_with_anchor(self, level, anchor): c = self._canvas before = self._zoom sheet_x = c.canvasx(anchor[0]) / before sheet_y = c.canvasy(anchor[1]) / before after = self.set_zoom(level) if after == before: return region = str(c.cget("scrollregion")).split() if len(region) == 4: span_x = float(region[2]) or 1.0 span_y = float(region[3]) or 1.0 c.xview_moveto(max(0.0, (sheet_x * after - anchor[0]) / span_x)) c.yview_moveto(max(0.0, (sheet_y * after - anchor[1]) / span_y)) # ── Zoom ────────────────────────────────────────────────────── @property def zoom(self): return self._zoom def zoom_step(self, direction): idx = 0 for i, lvl in enumerate(ZOOM_LEVELS): if lvl <= self._zoom: idx = i target = idx + direction target = max(0, min(len(ZOOM_LEVELS) - 1, target)) return ZOOM_LEVELS[target] def set_zoom(self, level): level = max(ZOOM_LEVELS[0], min(ZOOM_LEVELS[-1], level)) self._zoom = level self._apply_zoom() return level def fit_zoom_level(self): c = self._canvas view_w = c.winfo_width() - 4 view_h = c.winfo_height() - 4 if view_w <= 0 or view_h <= 0: return None fit_x = view_w / self._canvas_w fit_y = view_h / self._canvas_h return max(ZOOM_LEVELS[0], min(ZOOM_LEVELS[-1], min(fit_x, fit_y))) def _apply_zoom(self): z = self._zoom zw = int(self._canvas_w * z) zh = int(self._canvas_h * z) self._canvas.config(scrollregion=(0, 0, zw, zh)) self.refresh() def refresh(self): """Redraws from the document, keeping the selection when the widget survives.""" selected = self._selected_widget self._render_page() if selected is not None and selected not in _get_widget_children(self.comp): selected = None self._select_widget(selected) def _background(self): return app_state.DIRTY_TINT if app_state.is_dirty else CANVAS_BG def refresh_dirty_tint(self): """Wears the wiresheet's unsaved-state tint while the document has unwritten changes.""" self._canvas.config(bg=self._background()) 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)