Add zoom support to OxPage editor with 50% default

Toolbar zoom buttons (in/out/reset/fit) and Ctrl+MouseWheel now
control the page editor canvas when it is active. Frame image is
scaled via PIL for smooth rendering at any zoom level.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 23:19:13 +03:00
co-authored by Claude Opus 4.6
parent 74c169a401
commit b0eb859f32
3 changed files with 148 additions and 25 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
import configparser import configparser
import os import os
VERSION = "0.0.0.121" VERSION = "0.0.0.122"
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties") PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
DEFAULT_GRID_SIZE = 10 DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True DEFAULT_ADD_TIMESTAMP = True
+28 -3
View File
@@ -366,6 +366,7 @@ def show_ox_page_view(comp):
) )
ox_page_panel.frame.pack(fill=tk.BOTH, expand=True) ox_page_panel.frame.pack(fill=tk.BOTH, expand=True)
record_visit(VIEW_PAGE, comp) record_visit(VIEW_PAGE, comp)
update_zoom_label()
app_state.set_status(f"Page Editor: {comp.get('name', '?')}") app_state.set_status(f"Page Editor: {comp.get('name', '?')}")
@@ -377,6 +378,7 @@ def dismiss_ox_page_view(restore_canvas=True):
ox_page_panel.destroy() ox_page_panel.destroy()
ox_page_panel = None ox_page_panel = None
update_zoom_label()
if restore_canvas: if restore_canvas:
_give_editor_pane_back() _give_editor_pane_back()
@@ -617,7 +619,11 @@ def do_tidy_layout(event=None):
def update_zoom_label(): def update_zoom_label():
"""Puts the painted scale on the toolbar as a percentage.""" """Puts the painted scale on the toolbar as a percentage."""
if zoom_label is not None: if zoom_label is None:
return
if ox_page_panel is not None:
zoom_label.config(text=f"{round(ox_page_panel.zoom * 100)}%")
else:
zoom_label.config(text=f"{round(app_state.zoom * 100)}%") zoom_label.config(text=f"{round(app_state.zoom * 100)}%")
@@ -655,16 +661,28 @@ def zoom_to(level, anchor=None):
def do_zoom_in(event=None): def do_zoom_in(event=None):
if ox_page_panel is not None:
ox_page_panel.set_zoom(ox_page_panel.zoom_step(1))
update_zoom_label()
return "break"
zoom_to(wiresheet.zoom_step(1)) zoom_to(wiresheet.zoom_step(1))
return "break" return "break"
def do_zoom_out(event=None): def do_zoom_out(event=None):
if ox_page_panel is not None:
ox_page_panel.set_zoom(ox_page_panel.zoom_step(-1))
update_zoom_label()
return "break"
zoom_to(wiresheet.zoom_step(-1)) zoom_to(wiresheet.zoom_step(-1))
return "break" return "break"
def do_zoom_reset(event=None): def do_zoom_reset(event=None):
if ox_page_panel is not None:
ox_page_panel.set_zoom(1.0)
update_zoom_label()
return "break"
zoom_to(1.0) zoom_to(1.0)
return "break" return "break"
@@ -675,6 +693,15 @@ def do_zoom_fit(event=None):
The fitted scale is not one of the ladder's, so the percentage on the toolbar is The fitted scale is not one of the ladder's, so the percentage on the toolbar is
where it is read off; stepping in or out from there rejoins the ladder. where it is read off; stepping in or out from there rejoins the ladder.
""" """
if ox_page_panel is not None:
level = ox_page_panel.fit_zoom_level()
if level is None:
app_state.set_status("Cannot fit — page editor canvas not ready.")
return "break"
ox_page_panel.set_zoom(level)
update_zoom_label()
return "break"
canvas = app_state.canvas canvas = app_state.canvas
level = wiresheet.fit_zoom_level() level = wiresheet.fit_zoom_level()
bounds = wiresheet.sheet_content_bounds() bounds = wiresheet.sheet_content_bounds()
@@ -686,8 +713,6 @@ def do_zoom_fit(event=None):
applied = wiresheet.set_zoom(level) applied = wiresheet.set_zoom(level)
update_zoom_label() update_zoom_label()
# Bring the sheet's own top-left corner to the top-left of the view, since a fitted
# sheet is measured from there rather than from the canvas origin.
region = str(canvas.cget("scrollregion")).split() region = str(canvas.cget("scrollregion")).split()
if len(region) == 4: if len(region) == 4:
span_x = float(region[2]) or 1.0 span_x = float(region[2]) or 1.0
+119 -21
View File
@@ -16,6 +16,12 @@ import os
import tkinter as tk import tkinter as tk
from tkinter import font as tkfont, ttk 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 app_state
HEADER_BG = "#f2f2f2" HEADER_BG = "#f2f2f2"
@@ -36,6 +42,8 @@ DEVICE_PROFILES = {
} }
DEFAULT_SCREEN = (800, 480) DEFAULT_SCREEN = (800, 480)
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 CANVAS_PADDING = 16
WIDGET_OUTLINE = "#7f9db9" WIDGET_OUTLINE = "#7f9db9"
@@ -109,14 +117,21 @@ def _detect_screen_size():
def _load_frame_image(profile): def _load_frame_image(profile):
if profile is None: if profile is None:
return None return None, None
path = os.path.join(_ASSETS_DIR, profile["frame_file"]) path = os.path.join(_ASSETS_DIR, profile["frame_file"])
if not os.path.isfile(path): if not os.path.isfile(path):
return None 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: try:
return tk.PhotoImage(file=path) return None, tk.PhotoImage(file=path)
except tk.TclError: except tk.TclError:
return None return None, None
def _is_ox_widget(comp): def _is_ox_widget(comp):
@@ -139,7 +154,8 @@ class OxPageViewPanel:
profile = _detect_device_profile() profile = _detect_device_profile()
self._screen_w, self._screen_h = _detect_screen_size() self._screen_w, self._screen_h = _detect_screen_size()
self._frame_image = _load_frame_image(profile) self._frame_pil, self._frame_image = _load_frame_image(profile)
self._scaled_frame = None
if self._frame_image and profile: if self._frame_image and profile:
self._screen_ox = profile["screen_offset"][0] self._screen_ox = profile["screen_offset"][0]
@@ -158,6 +174,7 @@ class OxPageViewPanel:
self._canvas_w = self._screen_w + CANVAS_PADDING * 2 self._canvas_w = self._screen_w + CANVAS_PADDING * 2
self._canvas_h = self._screen_h + CANVAS_PADDING * 2 self._canvas_h = self._screen_h + CANVAS_PADDING * 2
self._zoom = 0.5
self._selected_widget = None self._selected_widget = None
self._widget_items = {} self._widget_items = {}
self._tag_to_comp = {} self._tag_to_comp = {}
@@ -241,7 +258,7 @@ class OxPageViewPanel:
self._canvas = tk.Canvas( self._canvas = tk.Canvas(
parent, bg="#e0e0e0", highlightthickness=0, parent, bg="#e0e0e0", highlightthickness=0,
xscrollcommand=hscroll.set, yscrollcommand=vscroll.set, xscrollcommand=hscroll.set, yscrollcommand=vscroll.set,
scrollregion=(0, 0, self._canvas_w, self._canvas_h), scrollregion=(0, 0, int(self._canvas_w * self._zoom), int(self._canvas_h * self._zoom)),
) )
self._canvas.pack(fill=tk.BOTH, expand=True) self._canvas.pack(fill=tk.BOTH, expand=True)
vscroll.config(command=self._canvas.yview) vscroll.config(command=self._canvas.yview)
@@ -251,27 +268,38 @@ class OxPageViewPanel:
self._canvas.bind("<B1-Motion>", self._on_canvas_drag) self._canvas.bind("<B1-Motion>", self._on_canvas_drag)
self._canvas.bind("<ButtonRelease-1>", self._on_canvas_release) self._canvas.bind("<ButtonRelease-1>", self._on_canvas_release)
self._canvas.bind("<ButtonPress-3>", self._on_canvas_right_click) self._canvas.bind("<ButtonPress-3>", self._on_canvas_right_click)
self._canvas.bind("<Control-MouseWheel>", self._on_wheel_zoom)
def _render_page(self): def _render_page(self):
c = self._canvas c = self._canvas
c.delete("all") c.delete("all")
self._widget_items.clear() self._widget_items.clear()
self._tag_to_comp.clear() self._tag_to_comp.clear()
z = self._zoom
sx = self._screen_canvas_x sx = self._screen_canvas_x * z
sy = self._screen_canvas_y sy = self._screen_canvas_y * z
bg_val = _get_prop(self.comp, "backgroundColor", "65535") bg_val = _get_prop(self.comp, "backgroundColor", "65535")
bg_colour = _rgb565_to_hex(bg_val) bg_colour = _rgb565_to_hex(bg_val)
c.create_rectangle(sx, sy, sx + self._screen_w, sy + self._screen_h, c.create_rectangle(sx, sy, sx + self._screen_w * z, sy + self._screen_h * z,
fill=bg_colour, outline="", fill=bg_colour, outline="",
tags=("screen_bg",)) tags=("screen_bg",))
if self._frame_image: if self._frame_image:
c.create_image(self._frame_ox, self._frame_oy, anchor="nw", if z != 1.0 and self._frame_pil is not None:
image=self._frame_image, tags=("device_frame",)) 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: else:
c.create_rectangle(sx, sy, sx + self._screen_w, sy + self._screen_h, c.create_rectangle(sx, sy, sx + self._screen_w * z, sy + self._screen_h * z,
fill="", outline="#333333", width=2, fill="", outline="#333333", width=2,
tags=("screen_border",)) tags=("screen_border",))
@@ -286,8 +314,9 @@ class OxPageViewPanel:
def _draw_widget(self, widget): def _draw_widget(self, widget):
c = self._canvas c = self._canvas
sx = self._screen_canvas_x z = self._zoom
sy = self._screen_canvas_y sx = self._screen_canvas_x * z
sy = self._screen_canvas_y * z
x = int(_get_prop(widget, "x", "0")) x = int(_get_prop(widget, "x", "0"))
y = int(_get_prop(widget, "y", "0")) y = int(_get_prop(widget, "y", "0"))
@@ -297,8 +326,10 @@ class OxPageViewPanel:
w = max(WIDGET_MIN_SIZE, w) w = max(WIDGET_MIN_SIZE, w)
h = max(WIDGET_MIN_SIZE, h) h = max(WIDGET_MIN_SIZE, h)
cx = sx + x cx = sx + x * z
cy = sy + y cy = sy + y * z
cw = w * z
ch = h * z
tag = f"ox_widget_{id(widget)}" tag = f"ox_widget_{id(widget)}"
comp_type = widget.get("type", "") comp_type = widget.get("type", "")
@@ -311,14 +342,14 @@ class OxPageViewPanel:
text_val = _get_prop(widget, "textColor") text_val = _get_prop(widget, "textColor")
text_colour = _rgb565_to_hex(text_val) if text_val is not None else "#000000" text_colour = _rgb565_to_hex(text_val) if text_val is not None else "#000000"
c.create_rectangle(cx, cy, cx + w, cy + h, c.create_rectangle(cx, cy, cx + cw, cy + ch,
fill=fill, outline=WIDGET_OUTLINE, fill=fill, outline=WIDGET_OUTLINE,
width=WIDGET_OUTLINE_WIDTH, width=WIDGET_OUTLINE_WIDTH,
tags=(tag, "ox_widget", "ox_widget_rect")) tags=(tag, "ox_widget", "ox_widget_rect"))
label = f"{name}\n({short})" label = f"{name}\n({short})"
font_size = max(7, min(10, min(w, h) // 8)) font_size = max(7, min(10, int(min(cw, ch) // 8)))
c.create_text(cx + w / 2, cy + h / 2, text=label, c.create_text(cx + cw / 2, cy + ch / 2, text=label,
fill=text_colour, font=("Segoe UI", font_size), fill=text_colour, font=("Segoe UI", font_size),
anchor="center", tags=(tag, "ox_widget", "ox_widget_text")) anchor="center", tags=(tag, "ox_widget", "ox_widget_text"))
@@ -445,10 +476,11 @@ class OxPageViewPanel:
return return
c = self._canvas c = self._canvas
z = self._zoom
bbox = c.bbox(f"{tag} && ox_widget_rect") bbox = c.bbox(f"{tag} && ox_widget_rect")
if bbox: if bbox:
new_x = bbox[0] - self._screen_canvas_x new_x = int((bbox[0] - self._screen_canvas_x * z) / z)
new_y = bbox[1] - self._screen_canvas_y new_y = int((bbox[1] - self._screen_canvas_y * z) / z)
new_x = max(0, min(new_x, self._screen_w)) new_x = max(0, min(new_x, self._screen_w))
new_y = max(0, min(new_y, self._screen_h)) new_y = max(0, min(new_y, self._screen_h))
@@ -500,6 +532,72 @@ class OxPageViewPanel:
def _open_properties(self, widget): def _open_properties(self, widget):
pass 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._render_page()
def refresh(self): def refresh(self):
self._render_page() self._render_page()