From 16edd144e941f876220deb205348806ba4d5d1d4 Mon Sep 17 00:00:00 2001 From: Arda Aydin Date: Tue, 22 Sep 2026 21:06:56 +0300 Subject: [PATCH] Add OxPage WYSIWYG editor panel for ontrolOx HMI pages New ox_page_view.py with OxPageViewPanel: canvas rendering at device resolution (800x480 Orion, 320x240 Rion), RGB565 color conversion, widget tree, selection handles, drag-move, device frame image support. Wired into main.py with context menu, history, panel lifecycle. Co-Authored-By: Claude Opus 4.6 --- app_state.py | 2 +- main.py | 61 +++++- ox_page_view.py | 530 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 589 insertions(+), 4 deletions(-) create mode 100644 ox_page_view.py diff --git a/app_state.py b/app_state.py index f9c26f2..dbcf315 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.118" +VERSION = "0.0.0.119" 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/main.py b/main.py index d4b31bb..e60036f 100644 --- a/main.py +++ b/main.py @@ -18,6 +18,7 @@ import properties import sax_file import schema import undo +import ox_page_view import user_service_view import wiresheet from sax_dom import (find_component_by_path, find_parent_element, get_component_path, @@ -79,6 +80,7 @@ HISTORY_LIMIT = 50 VIEW_SHEET = "sheet" VIEW_PROPERTIES = "properties" VIEW_LINKS = "links" +VIEW_PAGE = "page" VIEW_USERS = "users" # Tk class names of the widgets that own the keyboard while they have focus. Del and @@ -124,6 +126,7 @@ show_left_column_var = None properties_panel = None link_panel = None kits_panel = None +ox_page_panel = None user_service_panel = None # The sheet to put the tree back on when a sheet change is refused because of unsaved @@ -192,6 +195,12 @@ def refresh_all_views(): else: close_editor_panel(force=True) + if ox_page_panel is not None: + if _component_in_document(ox_page_panel.comp): + ox_page_panel.refresh() + else: + close_editor_panel(force=True) + if user_service_panel is not None: if _component_in_document(user_service_panel.comp): user_service_panel.refresh() @@ -219,7 +228,7 @@ def delete_component(comp): than going through refresh_all_views, so the panel would otherwise be left describing a component the document no longer has. """ - panel = properties_panel or link_panel or user_service_panel + panel = properties_panel or link_panel or ox_page_panel or user_service_panel showing = panel is not None and panel.comp is comp if sax_file.execute_cascading_deletion(comp) and showing: close_editor_panel(force=True) @@ -228,7 +237,8 @@ def delete_component(comp): def editor_panel_showing(): """Whether something other than the wiresheet has the editor pane.""" return (properties_panel is not None or link_panel is not None - or kits_panel is not None or user_service_panel is not None) + or kits_panel is not None or ox_page_panel is not None + or user_service_panel is not None) def _take_editor_pane(): @@ -340,6 +350,38 @@ def dismiss_link_view(restore_canvas=True): _give_editor_pane_back() +def show_ox_page_view(comp): + """Puts the WYSIWYG page editor for an OxPage in the editor pane.""" + global ox_page_panel + if comp is None: + return + + if not close_editor_panel(): + return + + _take_editor_pane() + ox_page_panel = ox_page_view.build_ox_page_view_panel( + frame_editor, comp, on_change=refresh_all_views, + on_close=dismiss_ox_page_view, + ) + ox_page_panel.frame.pack(fill=tk.BOTH, expand=True) + record_visit(VIEW_PAGE, comp) + app_state.set_status(f"Page Editor: {comp.get('name', '?')}") + + +def dismiss_ox_page_view(restore_canvas=True): + """Takes the OxPage editor down and gives the pane back.""" + global ox_page_panel + if ox_page_panel is None: + return + + ox_page_panel.destroy() + ox_page_panel = None + + if restore_canvas: + _give_editor_pane_back() + + def show_user_service(comp): """Puts the User Manager for a sys::UserService in the editor pane.""" global user_service_panel @@ -420,6 +462,9 @@ def close_editor_panel(force=False): if kits_panel is not None: dismiss_kits() return True + if ox_page_panel is not None: + dismiss_ox_page_view() + return True if user_service_panel is not None: dismiss_user_service() return True @@ -987,6 +1032,9 @@ def on_tree_right_click(event): def _open_special_view(comp): """Opens the type-specific view for a component, or Properties as fallback.""" + if ox_page_view.is_ox_page(comp): + show_ox_page_view(comp) + return if user_service_view.is_user_service(comp): show_user_service(comp) return @@ -1288,6 +1336,11 @@ def fill_component_popup(menu, comp): label="Link View", command=lambda: show_link_view(comp), ) + if ox_page_view.is_ox_page(comp): + menu.add_command( + label="Page Editor", + command=lambda: show_ox_page_view(comp), + ) if user_service_view.is_user_service(comp): menu.add_command( label="User Manager", @@ -1730,6 +1783,8 @@ def _show_history_entry(position): _show_sheet_entry(element) elif kind == VIEW_PROPERTIES: show_properties(element) + elif kind == VIEW_PAGE: + show_ox_page_view(element) elif kind == VIEW_USERS: show_user_service(element) else: @@ -1795,7 +1850,7 @@ def on_tree_filled(): # A different file has no such component, and a rebuild after a delete may have taken # it. Neither is a moment to ask about edits that now have nowhere to go. - panel = properties_panel or link_panel or user_service_panel + panel = properties_panel or link_panel or ox_page_panel or user_service_panel if panel is not None and not _component_in_document(panel.comp): close_editor_panel(force=True) diff --git a/ox_page_view.py b/ox_page_view.py new file mode 100644 index 0000000..7c92afc --- /dev/null +++ b/ox_page_view.py @@ -0,0 +1,530 @@ +"""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)