Compare commits

...
5 Commits
Author SHA1 Message Date
ArdaandClaude Opus 4.6 b0eb859f32 Add zoom support to OxPage editor with 50% default
Toolbar zoom buttons (in/out/reset/fit) and Ctrl+MouseWheel now
control the page editor canvas when it is active. Frame image is
scaled via PIL for smooth rendering at any zoom level.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-22 23:19:13 +03:00
ArdaandClaude Opus 4.6 74c169a401 Move widget tree pane to the right side of Page Editor
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-22 21:35:57 +03:00
ArdaandClaude Opus 4.6 cbf1010c16 Rename frame_rion_portrait.png to frame_orion_portrait.png
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-22 21:12:01 +03:00
ArdaandClaude Opus 4.6 9e2c063880 Add device frame images for Orion, Rion and Rion portrait
Three PNG frames in assets/ with transparent screen cutouts:
- frame_orion.png (1144x849, screen at 191,169 = 800x480)
- frame_rion.png (433x396, screen centered at 76,62 = 320x240)
- frame_rion_portrait.png (856x1134, screen at 190,170 = 480x800)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-22 21:10:37 +03:00
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
6 changed files with 715 additions and 7 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
import configparser import configparser
import os import os
VERSION = "0.0.0.118" VERSION = "0.0.0.122"
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties") PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
DEFAULT_GRID_SIZE = 10 DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True DEFAULT_ADD_TIMESTAMP = True
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+86 -6
View File
@@ -18,6 +18,7 @@ import properties
import sax_file import sax_file
import schema import schema
import undo import undo
import ox_page_view
import user_service_view import user_service_view
import wiresheet import wiresheet
from sax_dom import (find_component_by_path, find_parent_element, get_component_path, 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_SHEET = "sheet"
VIEW_PROPERTIES = "properties" VIEW_PROPERTIES = "properties"
VIEW_LINKS = "links" VIEW_LINKS = "links"
VIEW_PAGE = "page"
VIEW_USERS = "users" VIEW_USERS = "users"
# Tk class names of the widgets that own the keyboard while they have focus. Del and # 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 properties_panel = None
link_panel = None link_panel = None
kits_panel = None kits_panel = None
ox_page_panel = None
user_service_panel = None user_service_panel = None
# The sheet to put the tree back on when a sheet change is refused because of unsaved # 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: else:
close_editor_panel(force=True) 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 user_service_panel is not None:
if _component_in_document(user_service_panel.comp): if _component_in_document(user_service_panel.comp):
user_service_panel.refresh() 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 than going through refresh_all_views, so the panel would otherwise be left describing
a component the document no longer has. 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 showing = panel is not None and panel.comp is comp
if sax_file.execute_cascading_deletion(comp) and showing: if sax_file.execute_cascading_deletion(comp) and showing:
close_editor_panel(force=True) close_editor_panel(force=True)
@@ -228,7 +237,8 @@ def delete_component(comp):
def editor_panel_showing(): def editor_panel_showing():
"""Whether something other than the wiresheet has the editor pane.""" """Whether something other than the wiresheet has the editor pane."""
return (properties_panel is not None or link_panel is not None 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(): def _take_editor_pane():
@@ -340,6 +350,40 @@ def dismiss_link_view(restore_canvas=True):
_give_editor_pane_back() _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)
update_zoom_label()
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
update_zoom_label()
if restore_canvas:
_give_editor_pane_back()
def show_user_service(comp): def show_user_service(comp):
"""Puts the User Manager for a sys::UserService in the editor pane.""" """Puts the User Manager for a sys::UserService in the editor pane."""
global user_service_panel global user_service_panel
@@ -420,6 +464,9 @@ def close_editor_panel(force=False):
if kits_panel is not None: if kits_panel is not None:
dismiss_kits() dismiss_kits()
return True return True
if ox_page_panel is not None:
dismiss_ox_page_view()
return True
if user_service_panel is not None: if user_service_panel is not None:
dismiss_user_service() dismiss_user_service()
return True return True
@@ -572,7 +619,11 @@ def do_tidy_layout(event=None):
def update_zoom_label(): def update_zoom_label():
"""Puts the painted scale on the toolbar as a percentage.""" """Puts the painted scale on the toolbar as a percentage."""
if zoom_label is not None: if zoom_label is None:
return
if ox_page_panel is not None:
zoom_label.config(text=f"{round(ox_page_panel.zoom * 100)}%")
else:
zoom_label.config(text=f"{round(app_state.zoom * 100)}%") zoom_label.config(text=f"{round(app_state.zoom * 100)}%")
@@ -610,16 +661,28 @@ def zoom_to(level, anchor=None):
def do_zoom_in(event=None): def do_zoom_in(event=None):
if ox_page_panel is not None:
ox_page_panel.set_zoom(ox_page_panel.zoom_step(1))
update_zoom_label()
return "break"
zoom_to(wiresheet.zoom_step(1)) zoom_to(wiresheet.zoom_step(1))
return "break" return "break"
def do_zoom_out(event=None): def do_zoom_out(event=None):
if ox_page_panel is not None:
ox_page_panel.set_zoom(ox_page_panel.zoom_step(-1))
update_zoom_label()
return "break"
zoom_to(wiresheet.zoom_step(-1)) zoom_to(wiresheet.zoom_step(-1))
return "break" return "break"
def do_zoom_reset(event=None): def do_zoom_reset(event=None):
if ox_page_panel is not None:
ox_page_panel.set_zoom(1.0)
update_zoom_label()
return "break"
zoom_to(1.0) zoom_to(1.0)
return "break" return "break"
@@ -630,6 +693,15 @@ def do_zoom_fit(event=None):
The fitted scale is not one of the ladder's, so the percentage on the toolbar is The fitted scale is not one of the ladder's, so the percentage on the toolbar is
where it is read off; stepping in or out from there rejoins the ladder. where it is read off; stepping in or out from there rejoins the ladder.
""" """
if ox_page_panel is not None:
level = ox_page_panel.fit_zoom_level()
if level is None:
app_state.set_status("Cannot fit — page editor canvas not ready.")
return "break"
ox_page_panel.set_zoom(level)
update_zoom_label()
return "break"
canvas = app_state.canvas canvas = app_state.canvas
level = wiresheet.fit_zoom_level() level = wiresheet.fit_zoom_level()
bounds = wiresheet.sheet_content_bounds() bounds = wiresheet.sheet_content_bounds()
@@ -641,8 +713,6 @@ def do_zoom_fit(event=None):
applied = wiresheet.set_zoom(level) applied = wiresheet.set_zoom(level)
update_zoom_label() update_zoom_label()
# Bring the sheet's own top-left corner to the top-left of the view, since a fitted
# sheet is measured from there rather than from the canvas origin.
region = str(canvas.cget("scrollregion")).split() region = str(canvas.cget("scrollregion")).split()
if len(region) == 4: if len(region) == 4:
span_x = float(region[2]) or 1.0 span_x = float(region[2]) or 1.0
@@ -987,6 +1057,9 @@ def on_tree_right_click(event):
def _open_special_view(comp): def _open_special_view(comp):
"""Opens the type-specific view for a component, or Properties as fallback.""" """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): if user_service_view.is_user_service(comp):
show_user_service(comp) show_user_service(comp)
return return
@@ -1288,6 +1361,11 @@ def fill_component_popup(menu, comp):
label="Link View", label="Link View",
command=lambda: show_link_view(comp), 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): if user_service_view.is_user_service(comp):
menu.add_command( menu.add_command(
label="User Manager", label="User Manager",
@@ -1730,6 +1808,8 @@ def _show_history_entry(position):
_show_sheet_entry(element) _show_sheet_entry(element)
elif kind == VIEW_PROPERTIES: elif kind == VIEW_PROPERTIES:
show_properties(element) show_properties(element)
elif kind == VIEW_PAGE:
show_ox_page_view(element)
elif kind == VIEW_USERS: elif kind == VIEW_USERS:
show_user_service(element) show_user_service(element)
else: else:
@@ -1795,7 +1875,7 @@ def on_tree_filled():
# A different file has no such component, and a rebuild after a delete may have taken # 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. # 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): if panel is not None and not _component_in_document(panel.comp):
close_editor_panel(force=True) close_editor_panel(force=True)
+628
View File
@@ -0,0 +1,628 @@
"""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
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)
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 _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, 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)]
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 = 0.5
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)
canvas_frame = ttk.Frame(body)
self._build_canvas(canvas_frame)
body.add(canvas_frame, weight=1)
tree_frame = ttk.Frame(body)
self._build_widget_tree(tree_frame)
body.add(tree_frame, weight=0)
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, 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
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
z = self._zoom
bbox = c.bbox(f"{tag} && ox_widget_rect")
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))
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 _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._render_page()
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)