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:
+1
-1
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
|
||||
import configparser
|
||||
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")
|
||||
DEFAULT_GRID_SIZE = 10
|
||||
DEFAULT_ADD_TIMESTAMP = True
|
||||
|
||||
@@ -366,6 +366,7 @@ def show_ox_page_view(comp):
|
||||
)
|
||||
ox_page_panel.frame.pack(fill=tk.BOTH, expand=True)
|
||||
record_visit(VIEW_PAGE, comp)
|
||||
update_zoom_label()
|
||||
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 = None
|
||||
update_zoom_label()
|
||||
|
||||
if restore_canvas:
|
||||
_give_editor_pane_back()
|
||||
@@ -617,7 +619,11 @@ def do_tidy_layout(event=None):
|
||||
|
||||
def update_zoom_label():
|
||||
"""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)}%")
|
||||
|
||||
|
||||
@@ -655,16 +661,28 @@ def zoom_to(level, anchor=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))
|
||||
return "break"
|
||||
|
||||
|
||||
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))
|
||||
return "break"
|
||||
|
||||
|
||||
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)
|
||||
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
|
||||
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
|
||||
level = wiresheet.fit_zoom_level()
|
||||
bounds = wiresheet.sheet_content_bounds()
|
||||
@@ -686,8 +713,6 @@ def do_zoom_fit(event=None):
|
||||
applied = wiresheet.set_zoom(level)
|
||||
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()
|
||||
if len(region) == 4:
|
||||
span_x = float(region[2]) or 1.0
|
||||
|
||||
+117
-19
@@ -16,6 +16,12 @@ 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
|
||||
|
||||
HEADER_BG = "#f2f2f2"
|
||||
@@ -36,6 +42,8 @@ DEVICE_PROFILES = {
|
||||
}
|
||||
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
|
||||
|
||||
WIDGET_OUTLINE = "#7f9db9"
|
||||
@@ -109,14 +117,21 @@ def _detect_screen_size():
|
||||
|
||||
def _load_frame_image(profile):
|
||||
if profile is None:
|
||||
return None
|
||||
return None, None
|
||||
path = os.path.join(_ASSETS_DIR, profile["frame_file"])
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
return None, None
|
||||
if _HAS_PIL:
|
||||
try:
|
||||
return tk.PhotoImage(file=path)
|
||||
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
|
||||
return None, None
|
||||
|
||||
|
||||
def _is_ox_widget(comp):
|
||||
@@ -139,7 +154,8 @@ class OxPageViewPanel:
|
||||
|
||||
profile = _detect_device_profile()
|
||||
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:
|
||||
self._screen_ox = profile["screen_offset"][0]
|
||||
@@ -158,6 +174,7 @@ class OxPageViewPanel:
|
||||
self._canvas_w = self._screen_w + CANVAS_PADDING * 2
|
||||
self._canvas_h = self._screen_h + CANVAS_PADDING * 2
|
||||
|
||||
self._zoom = 0.5
|
||||
self._selected_widget = None
|
||||
self._widget_items = {}
|
||||
self._tag_to_comp = {}
|
||||
@@ -241,7 +258,7 @@ class OxPageViewPanel:
|
||||
self._canvas = tk.Canvas(
|
||||
parent, bg="#e0e0e0", highlightthickness=0,
|
||||
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)
|
||||
vscroll.config(command=self._canvas.yview)
|
||||
@@ -251,27 +268,38 @@ class OxPageViewPanel:
|
||||
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
|
||||
sy = self._screen_canvas_y
|
||||
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, sy + self._screen_h,
|
||||
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, sy + self._screen_h,
|
||||
c.create_rectangle(sx, sy, sx + self._screen_w * z, sy + self._screen_h * z,
|
||||
fill="", outline="#333333", width=2,
|
||||
tags=("screen_border",))
|
||||
|
||||
@@ -286,8 +314,9 @@ class OxPageViewPanel:
|
||||
|
||||
def _draw_widget(self, widget):
|
||||
c = self._canvas
|
||||
sx = self._screen_canvas_x
|
||||
sy = self._screen_canvas_y
|
||||
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"))
|
||||
@@ -297,8 +326,10 @@ class OxPageViewPanel:
|
||||
w = max(WIDGET_MIN_SIZE, w)
|
||||
h = max(WIDGET_MIN_SIZE, h)
|
||||
|
||||
cx = sx + x
|
||||
cy = sy + y
|
||||
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", "")
|
||||
@@ -311,14 +342,14 @@ class OxPageViewPanel:
|
||||
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 + w, cy + h,
|
||||
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, min(w, h) // 8))
|
||||
c.create_text(cx + w / 2, cy + h / 2, text=label,
|
||||
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"))
|
||||
|
||||
@@ -445,10 +476,11 @@ class OxPageViewPanel:
|
||||
return
|
||||
|
||||
c = self._canvas
|
||||
z = self._zoom
|
||||
bbox = c.bbox(f"{tag} && ox_widget_rect")
|
||||
if bbox:
|
||||
new_x = bbox[0] - self._screen_canvas_x
|
||||
new_y = bbox[1] - self._screen_canvas_y
|
||||
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))
|
||||
@@ -500,6 +532,72 @@ class OxPageViewPanel:
|
||||
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._render_page()
|
||||
|
||||
def refresh(self):
|
||||
self._render_page()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user