Undo and redo now work against a connected device; each step is sent there through Sox. Deleting a component first reads its whole subtree back (config, unloaded children, links, place among siblings) so undo can recreate it; recreated components take new device ids, which the DOM elements pick up so later steps still reach them. Binary Buf config (a user's cred) is read with read_prop, since the cache decodes every Buf as a Str. Moves, tidy layout, and Properties / page editor / user edits record only what the device accepted. A step that does not fully reach the device warns and clears the history. History is also cleared on every view change in live mode, as opening a sheet rebuilds the DOM from the device. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
787 lines
28 KiB
Python
787 lines
28 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, or 480×800 when its platform service's screenOrientation
|
||
is portrait (drawn in frame_orion_portrait.png)
|
||
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 ORION_KIT, OxWidgetPropertiesPanel, orion_portrait
|
||
from ox_widget_render import WidgetPainter
|
||
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,
|
||
},
|
||
}
|
||
ORION_PORTRAIT_PROFILE = {
|
||
"screen": (480, 800),
|
||
"frame_file": "frame_orion_portrait.png",
|
||
"screen_offset": (190, 170),
|
||
"default_zoom": 0.5,
|
||
}
|
||
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
|
||
# About what the widget tree / properties pane asks for (300px sheet + scrollbar).
|
||
RIGHT_PANE_WIDTH = 320
|
||
|
||
WIDGET_OUTLINE_WIDTH = 1
|
||
SELECTED_OUTLINE = "#1f4e79"
|
||
SELECTED_OUTLINE_WIDTH = 2
|
||
HANDLE_SIZE = 6
|
||
HANDLE_COLOUR = "#00b050"
|
||
|
||
_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:
|
||
if kit_name == ORION_KIT and orion_portrait():
|
||
return ORION_PORTRAIT_PROFILE
|
||
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"
|
||
|
||
GRID_STEP = 10 # device pixels
|
||
GRID_MIN_SPACING = 4 # canvas pixels; a denser grid is left out rather than drawn solid
|
||
GRID_SHADE = 0.82 # grid line = page colour scaled by this (lifted on dark pages)
|
||
|
||
# Page Editor toggles, held for the session like the Sedona sheet's: whether the page
|
||
# can be changed (edit mode, which is also when the 10x10 grid is drawn), whether a
|
||
# drag lands on the grid, and whether the tree/properties pane shows.
|
||
edit_mode = False
|
||
snap_to_grid = True
|
||
show_right_pane = True
|
||
|
||
|
||
def _grid_colour(page_hex):
|
||
r, g, b = (int(page_hex[i:i + 2], 16) for i in (1, 3, 5))
|
||
if (r * 299 + g * 587 + b * 114) / 1000 < 60:
|
||
r, g, b = (min(255, v + 45) for v in (r, g, b))
|
||
else:
|
||
r, g, b = (int(v * GRID_SHADE) for v in (r, g, b))
|
||
return f"#{r:02x}{g:02x}{b:02x}"
|
||
|
||
|
||
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._widget_boxes = {}
|
||
self._drag = {"tag": None, "x": 0, "y": 0}
|
||
|
||
self._build_header()
|
||
self._build_body()
|
||
self._build_footer()
|
||
self._apply_right_pane()
|
||
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)
|
||
|
||
self._edit_var = tk.BooleanVar(value=edit_mode)
|
||
self._snap_var = tk.BooleanVar(value=snap_to_grid)
|
||
self._right_pane_var = tk.BooleanVar(value=show_right_pane)
|
||
|
||
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)
|
||
self._body = body
|
||
self._side = side
|
||
|
||
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)
|
||
self._props.set_editable(self._edit_var.get())
|
||
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()
|
||
self._widget_boxes.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",))
|
||
|
||
self._painter = WidgetPainter(c, sx, sy, z, bg_colour)
|
||
self._painter.page_image(_get_prop(self.comp, "imageFileName", ""),
|
||
self._screen_w, self._screen_h)
|
||
if self._edit_var.get():
|
||
self._draw_grid(sx, sy, _grid_colour(bg_colour))
|
||
|
||
if self._frame_image:
|
||
if z != 1.0 and self._frame_pil is not None:
|
||
# Rescaled once per zoom level; every edit redraws the page.
|
||
if getattr(self, "_scaled_frame_zoom", None) != z:
|
||
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)
|
||
self._scaled_frame_zoom = z
|
||
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_grid(self, sx, sy, colour):
|
||
"""The 10x10 device-pixel grid over the page background, under the widgets."""
|
||
c = self._canvas
|
||
z = self._zoom
|
||
if GRID_STEP * z < GRID_MIN_SPACING:
|
||
return
|
||
right = sx + self._screen_w * z
|
||
bottom = sy + self._screen_h * z
|
||
for gx in range(GRID_STEP, self._screen_w, GRID_STEP):
|
||
c.create_line(sx + gx * z, sy, sx + gx * z, bottom, fill=colour, tags=("grid",))
|
||
for gy in range(GRID_STEP, self._screen_h, GRID_STEP):
|
||
c.create_line(sx, sy + gy * z, right, sy + gy * z, fill=colour, tags=("grid",))
|
||
|
||
def _on_edit_toggled(self):
|
||
global edit_mode
|
||
edit_mode = self._edit_var.get()
|
||
if not edit_mode:
|
||
self._props.flush_edits()
|
||
self._props.set_editable(edit_mode)
|
||
self.refresh()
|
||
app_state.set_status("Page Editor: edit mode — the page can be changed."
|
||
if edit_mode else "Page Editor: view mode — nothing can be changed.")
|
||
|
||
def _on_snap_toggled(self):
|
||
global snap_to_grid
|
||
snap_to_grid = self._snap_var.get()
|
||
|
||
def _on_right_pane_toggled(self):
|
||
global show_right_pane
|
||
show_right_pane = self._right_pane_var.get()
|
||
self._apply_right_pane()
|
||
|
||
def _apply_right_pane(self):
|
||
"""Takes the widget tree and properties sheet out of the split, or puts them back."""
|
||
showing = str(self._side) in self._body.panes()
|
||
if self._right_pane_var.get() and not showing:
|
||
self._body.add(self._side, weight=0)
|
||
elif not self._right_pane_var.get() and showing:
|
||
self._props.flush_edits()
|
||
self._body.forget(self._side)
|
||
|
||
# ── main toolbar ──────────────────────────────────────────────
|
||
|
||
def toolbar_tools(self):
|
||
"""The Page Editor's own toolbar buttons: (icon, tooltip, variable, command).
|
||
|
||
`main` lays them out on its toolbar while this editor is open and takes them
|
||
down with it. A button with a variable is a toggle; add new tools here.
|
||
"""
|
||
return [
|
||
("✏", "Edit Mode", self._edit_var, self._on_edit_toggled),
|
||
("⌗", "Snap to Grid", self._snap_var, self._on_snap_toggled),
|
||
("◨", "Widgets / Properties Pane", self._right_pane_var,
|
||
self._on_right_pane_toggled),
|
||
]
|
||
|
||
def _draw_widget(self, widget):
|
||
"""Paints the widget as the device would, then lays an empty box over it.
|
||
|
||
The box is what selection outlines and what a drag's bbox is read from, so a
|
||
transparent label with no text can still be found and moved.
|
||
"""
|
||
tag = f"ox_widget_{id(widget)}"
|
||
tags = (tag, "ox_widget")
|
||
x, y, w, h = self._painter.draw(widget, tags, _short_type(widget.get("type", "")))
|
||
|
||
z = self._zoom
|
||
cx = (self._screen_canvas_x + x) * z
|
||
cy = (self._screen_canvas_y + y) * z
|
||
self._canvas.create_rectangle(cx, cy, cx + w * z, cy + h * z, fill="", outline="",
|
||
width=WIDGET_OUTLINE_WIDTH,
|
||
tags=(tag, "ox_widget", "ox_widget_rect"))
|
||
|
||
self._widget_items[widget] = tag
|
||
self._tag_to_comp[tag] = widget
|
||
self._widget_boxes[widget] = (x, y, w, h)
|
||
|
||
def _widget_at(self, cx, cy):
|
||
"""The topmost widget whose box holds canvas point (cx, cy): the last one drawn."""
|
||
z = self._zoom
|
||
px = cx / z - self._screen_canvas_x
|
||
py = cy / z - self._screen_canvas_y
|
||
for widget in reversed(_get_widget_children(self.comp)):
|
||
box = self._widget_boxes.get(widget)
|
||
if box and box[0] <= px <= box[0] + box[2] and box[1] <= py <= box[1] + box[3]:
|
||
return widget
|
||
return None
|
||
|
||
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="", 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
|
||
|
||
# Only the outline comes forward: raising the widget itself would paint it over
|
||
# the ones the device draws later.
|
||
c.itemconfig(f"{tag} && ox_widget_rect", outline=SELECTED_OUTLINE,
|
||
width=SELECTED_OUTLINE_WIDTH)
|
||
c.tag_raise(f"{tag} && ox_widget_rect")
|
||
|
||
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: the device first, and the page only shows what the device accepted.
|
||
live_failures = []
|
||
applied = []
|
||
for change in changes:
|
||
element, name, _before, after = change
|
||
if app_state.live_mode and not live_hooks.live_set_prop_value(element, name, after):
|
||
live_failures.append(f"{name}: {live_hooks.last_error}")
|
||
continue
|
||
set_prop_value(element, name, after)
|
||
applied.append(change)
|
||
|
||
if app_state.live_mode:
|
||
if applied:
|
||
undo.push(undo.LiveSlotValueCommand(applied))
|
||
else:
|
||
undo.push(undo.SlotValueCommand(changes))
|
||
app_state.mark_dirty()
|
||
|
||
self._repaint_all()
|
||
|
||
who = comp.get("name", "?")
|
||
if live_failures:
|
||
live_hooks.warn_failure(
|
||
"Page Editor",
|
||
f"{len(live_failures)} of {len(changes)} change(s) to {who} did not reach "
|
||
"the device and were not applied",
|
||
"\n".join(f" • {failure}" for failure in live_failures),
|
||
)
|
||
return len(live_failures) < len(changes)
|
||
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)
|
||
|
||
# Hit-tested by box, not by canvas item: a transparent label is mostly grid.
|
||
widget = self._widget_at(cx, cy)
|
||
if widget is not None:
|
||
self._select_widget(widget)
|
||
if not self._edit_var.get():
|
||
self._drag["tag"] = None
|
||
return
|
||
box = self._widget_boxes[widget]
|
||
self._drag.update(tag=self._widget_items[widget], x=cx, y=cy,
|
||
origin=(box[0], box[1]), pos=(box[0], box[1]))
|
||
else:
|
||
self._select_widget(None)
|
||
self._drag["tag"] = None
|
||
|
||
def _on_canvas_drag(self, event):
|
||
if not self._drag["tag"]:
|
||
return
|
||
|
||
c = self._canvas
|
||
z = self._zoom
|
||
cx = c.canvasx(event.x)
|
||
cy = c.canvasy(event.y)
|
||
|
||
# Worked out from where the drag began rather than step by step, so snapping
|
||
# never accumulates rounding and the widget lands exactly where it is drawn.
|
||
ox, oy = self._drag["origin"]
|
||
new_x = ox + (cx - self._drag["x"]) / z
|
||
new_y = oy + (cy - self._drag["y"]) / z
|
||
if self._snap_var.get():
|
||
new_x = round(new_x / GRID_STEP) * GRID_STEP
|
||
new_y = round(new_y / GRID_STEP) * GRID_STEP
|
||
new_x = int(round(max(0, min(new_x, self._screen_w))))
|
||
new_y = int(round(max(0, min(new_y, self._screen_h))))
|
||
|
||
px, py = self._drag["pos"]
|
||
if (new_x, new_y) != (px, py):
|
||
c.move(self._drag["tag"], (new_x - px) * z, (new_y - py) * z)
|
||
self._drag["pos"] = (new_x, new_y)
|
||
c.delete("selection_handle")
|
||
|
||
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
|
||
moved = False
|
||
new_x, new_y = self._drag["pos"]
|
||
if (new_x, new_y) != self._drag["origin"]:
|
||
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)
|
||
|
||
widget = self._widget_at(cx, cy)
|
||
if widget is None:
|
||
return
|
||
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)
|