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>
691 lines
23 KiB
Python
691 lines
23 KiB
Python
"""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)
|