Files
sedona_editor/ox_page_view.py
T
ArdaandClaude Opus 5.5 43d4849533 Add Workbench-style property sheet to the Page Editor
The Page Editor's right pane now shows the selected widget's properties
under the widget tree, laid out like Workbench's Ox editor rather than
as raw Sedona slots:

- layout (x, y, width, height), font (name, size, style)
- alignment / transparent / subscriptDigits from the attributes bits
- shape, RGB565 colours with a swatch and picker
- DateLabelSet date options split out of dateFormatOptions
- lookup labelText editor (prefix, items, suffix) and NumericLabel
  printf-format editor, min/max as one row
- image name picker, slot link chooser for in/blink/visibility/touch
- Buf text decoded from base64 (Latin-1), plain text in live mode

Edits commit on Enter/focus-out/selection as one undo step and are
written to the device in live mode. Canvas drag-moves now use the same
path, so they are undoable and reach the device too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:52:01 +03:00

691 lines
23 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
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 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 = "#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 _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"
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._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)
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("<<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=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("<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)
self._canvas.bind("<Control-MouseWheel>", self._on_wheel_zoom)
def _render_page(self):
c = self._canvas
c.delete("all")
self._widget_items.clear()
self._tag_to_comp.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",))
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)
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_widget(self, widget):
c = self._canvas
z = self._zoom
sx = self._screen_canvas_x * z
sy = self._screen_canvas_y * z
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 * z
cy = sy + y * z
cw = w * z
ch = h * z
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 + 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"))
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
# 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
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:
# selection_set always fires <<TreeviewSelect>>, 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)
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
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)
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 _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)