From b571c9a48689785acd37413cf632d7ef68c72007 Mon Sep 17 00:00:00 2001 From: pax Date: Wed, 8 Apr 2026 16:19:35 -0500 Subject: [PATCH] F11 round-trip: preserve image zoom/pan + popout window position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related preservation bugs around the popout's F11 fullscreen toggle, both surfaced during the post-refactor verification sweep. 1. ImageViewer zoom/pan loss on resize ImageViewer.resizeEvent unconditionally called _fit_to_view() on every resize event. F11 enter resizes the widget to the full screen, F11 exit resizes it back to the windowed size — both fired _fit_to_view, clobbering any explicit user zoom and offset. Same problem for manual window drags and splitter moves. Fix: in resizeEvent, compute the previous-size fit-to-view zoom from event.oldSize() and compare to current _zoom. Only re-fit if the user was at fit-to-view at the previous size (within a 0.001 epsilon — tighter than any wheel/key zoom step). Otherwise leave _zoom and _offset alone. The first-resize case (no valid oldSize, e.g. initial layout) still defaults to fit, matching the original behavior for fresh widgets. 2. Popout window position lost on F11 round-trip FullscreenPreview._enter_fullscreen captured _windowed_geometry but the F11-exit restore goes through `_viewport` (the persistent center + long_side that drives _fit_to_content). The drift detection in _derive_viewport_for_fit only updates _viewport when _last_dispatched_rect is set AND a fit is being computed — neither path catches the "user dragged the popout with Super+drag and then immediately pressed F11" sequence: - Hyprland Super+drag does NOT fire Qt's moveEvent (xdg-toplevel doesn't expose absolute screen position to clients on Wayland), so Qt-side drift detection is dead on Hyprland. - The Hyprland-side drift detection in _derive_viewport_for_fit only fires inside a fit, and no fit is triggered between a drag and F11. - Result: _viewport still holds whatever it had before the drag — typically the saved-from-last-session geometry seeded by the first-fit one-shot at popout open. When F11 exits, the deferred _fit_to_content reads the stale viewport and restores the popout to the *previously seeded* position instead of where the user actually had it. Fix: in _enter_fullscreen, after capturing _windowed_geometry, also write the current windowed state into self._viewport directly. The viewport then holds the actual pre-fullscreen position regardless of how it got there (drag, drag+nav, drag+F11, etc.), and F11 exit's restore reads it correctly. Bundled into one commit because both fixes are "F11 round-trip should preserve where the user was" — the image fix preserves content state (zoom/pan), the popout fix preserves window state (position). Same theme, related root cause class. Bisecting one without the other would be misleading. Verified manually: - image: scroll-zoom + drag pan + F11 + F11 → zoom and pan preserved - image: untouched zoom + F11 + F11 → still fits to view - image: scroll-zoom + manual window resize → zoom preserved - popout: Super+drag to a new position + F11 + F11 → lands at the dragged position, not at the saved-from-last-session position - popout: same sequence on a video post → same result (videos don't have zoom/pan, but the window-position fix applies to all media) --- booru_viewer/gui/media/image_viewer.py | 22 ++++++++++++++++++++-- booru_viewer/gui/popout/window.py | 26 +++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/booru_viewer/gui/media/image_viewer.py b/booru_viewer/gui/media/image_viewer.py index 1c505ef..efe3d35 100644 --- a/booru_viewer/gui/media/image_viewer.py +++ b/booru_viewer/gui/media/image_viewer.py @@ -149,6 +149,24 @@ class ImageViewer(QWidget): event.ignore() def resizeEvent(self, event) -> None: - if self._pixmap: + if not self._pixmap: + return + pw, ph = self._pixmap.width(), self._pixmap.height() + if pw == 0 or ph == 0: + return + # Only re-fit if the user was at fit-to-view at the *previous* + # size. If they had explicitly zoomed/panned, leave _zoom and + # _offset alone — clobbering them on every resize (F11 toggle, + # manual window drag, splitter move) loses their state. Use + # event.oldSize() to compute the prior fit-to-view zoom and + # compare to current _zoom; the 0.001 epsilon absorbs float + # drift but is tighter than any wheel/key zoom step (±20%). + old = event.oldSize() + if old.isValid() and old.width() > 0 and old.height() > 0: + old_fit = min(old.width() / pw, old.height() / ph) + if abs(self._zoom - old_fit) < 0.001: + self._fit_to_view() + else: + # First resize (no valid old size) — default to fit. self._fit_to_view() - self.update() + self.update() diff --git a/booru_viewer/gui/popout/window.py b/booru_viewer/gui/popout/window.py index e7bfa3e..25c3638 100644 --- a/booru_viewer/gui/popout/window.py +++ b/booru_viewer/gui/popout/window.py @@ -905,15 +905,39 @@ class FullscreenPreview(QMainWindow): pass def _enter_fullscreen(self) -> None: - """Enter fullscreen — capture windowed geometry first so F11 back can restore it.""" + """Enter fullscreen — capture windowed geometry first so F11 back can restore it. + + Also capture the current windowed state into the persistent + `_viewport` so the F11-exit restore lands at the user's actual + pre-F11 position, not at a stale viewport from before they last + dragged the window. The drift detection in `_derive_viewport_for_fit` + only fires when `_last_dispatched_rect` is set AND a fit is being + computed — neither path catches the "user dragged the popout + with Super+drag and then immediately pressed F11" sequence, + because Hyprland Super+drag doesn't fire Qt's moveEvent and no + nav has happened to trigger a fit. Capturing fresh into + `_viewport` here makes the restore correct regardless. + """ from PySide6.QtCore import QRect win = self._hyprctl_get_window() if win and win.get("at") and win.get("size"): x, y = win["at"] w, h = win["size"] self._windowed_geometry = QRect(x, y, w, h) + self._viewport = Viewport( + center_x=x + w / 2, + center_y=y + h / 2, + long_side=float(max(w, h)), + ) else: self._windowed_geometry = self.frameGeometry() + rect = self._windowed_geometry + if rect.width() > 0 and rect.height() > 0: + self._viewport = Viewport( + center_x=rect.x() + rect.width() / 2, + center_y=rect.y() + rect.height() / 2, + long_side=float(max(rect.width(), rect.height())), + ) self.showFullScreen() def _exit_fullscreen(self) -> None: