diff --git a/app_state.py b/app_state.py index 5cef5a9..b81b00c 100644 --- a/app_state.py +++ b/app_state.py @@ -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 diff --git a/main.py b/main.py index e60036f..b6d9e05 100644 --- a/main.py +++ b/main.py @@ -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 diff --git a/ox_page_view.py b/ox_page_view.py index d14d076..c6ccbad 100644 --- a/ox_page_view.py +++ b/ox_page_view.py @@ -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: + 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 tk.PhotoImage(file=path) + 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("", self._on_canvas_drag) self._canvas.bind("", self._on_canvas_release) self._canvas.bind("", self._on_canvas_right_click) + self._canvas.bind("", 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: - c.create_image(self._frame_ox, self._frame_oy, anchor="nw", - image=self._frame_image, tags=("device_frame",)) + 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()