The font and image rows are now dropdowns filled from <sedona home>/ontrol: fontBank/imageBank on Rion, fontBankXL/imageBankXL on Orion. Orion lists .oxfl fonts in landscape and .oxfp fonts in portrait, read from the platform service's screenOrientation (loaded from the device in live mode when the tree has not reached it). Images show their file extension while the slot keeps the bare name. The font dialog is gone. In portrait the Page Editor draws a 480x800 screen in frame_orion_portrait.png, and the canvas renders text with the .oxfp fonts, whose glyphs are stored a quarter turn anticlockwise. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
774 lines
28 KiB
Python
774 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_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)
|
||
|
||
# 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)
|