Files
sedona_editor/ox_page_view.py
ArdaandClaude Opus 5.5 b98fe828da Live undo/redo: add/delete component and link, move, property edits
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>
2026-10-06 20:03:26 +03:00

787 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)