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>
This commit is contained in:
+1
-1
@@ -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.119"
|
||||||
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
|
||||||
|
|||||||
@@ -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,38 @@ 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)
|
||||||
|
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):
|
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 +462,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
|
||||||
@@ -987,6 +1032,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 +1336,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 +1783,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 +1850,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)
|
||||||
|
|
||||||
|
|||||||
+530
@@ -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("<<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)
|
||||||
Reference in New Issue
Block a user