Files
sedona_editor/ox_page_view.py
T
ArdaandClaude Opus 4.6 16edd144e9 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 <noreply@anthropic.com>
2026-09-22 21:06:56 +03:00

531 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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("<<TreeviewSelect>>", 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("<ButtonPress-1>", self._on_canvas_click)
self._canvas.bind("<B1-Motion>", self._on_canvas_drag)
self._canvas.bind("<ButtonRelease-1>", self._on_canvas_release)
self._canvas.bind("<ButtonPress-3>", 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)