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)
173 lines
6.2 KiB
Python
173 lines
6.2 KiB
Python
"""Zoom/pan image viewer used by both the embedded preview and the popout."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from PySide6.QtCore import Qt, QPointF, Signal
|
|
from PySide6.QtGui import QPixmap, QPainter, QWheelEvent, QMouseEvent, QKeyEvent, QMovie
|
|
from PySide6.QtWidgets import QWidget
|
|
|
|
|
|
# -- Image Viewer (zoom/pan) --
|
|
|
|
class ImageViewer(QWidget):
|
|
"""Zoomable, pannable image viewer."""
|
|
|
|
close_requested = Signal()
|
|
|
|
def __init__(self, parent: QWidget | None = None) -> None:
|
|
super().__init__(parent)
|
|
self._pixmap: QPixmap | None = None
|
|
self._movie: QMovie | None = None
|
|
self._zoom = 1.0
|
|
self._offset = QPointF(0, 0)
|
|
self._drag_start: QPointF | None = None
|
|
self._drag_offset = QPointF(0, 0)
|
|
self.setMouseTracking(True)
|
|
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
|
self._info_text = ""
|
|
|
|
def set_image(self, pixmap: QPixmap, info: str = "") -> None:
|
|
self._stop_movie()
|
|
self._pixmap = pixmap
|
|
self._zoom = 1.0
|
|
self._offset = QPointF(0, 0)
|
|
self._info_text = info
|
|
self._fit_to_view()
|
|
self.update()
|
|
|
|
def set_gif(self, path: str, info: str = "") -> None:
|
|
self._stop_movie()
|
|
self._movie = QMovie(path)
|
|
self._movie.frameChanged.connect(self._on_gif_frame)
|
|
self._movie.start()
|
|
self._info_text = info
|
|
# Set initial pixmap from first frame
|
|
self._pixmap = self._movie.currentPixmap()
|
|
self._zoom = 1.0
|
|
self._offset = QPointF(0, 0)
|
|
self._fit_to_view()
|
|
self.update()
|
|
|
|
def _on_gif_frame(self) -> None:
|
|
if self._movie:
|
|
self._pixmap = self._movie.currentPixmap()
|
|
self.update()
|
|
|
|
def _stop_movie(self) -> None:
|
|
if self._movie:
|
|
self._movie.stop()
|
|
self._movie = None
|
|
|
|
def clear(self) -> None:
|
|
self._stop_movie()
|
|
self._pixmap = None
|
|
self._info_text = ""
|
|
self.update()
|
|
|
|
def _fit_to_view(self) -> None:
|
|
if not self._pixmap:
|
|
return
|
|
vw, vh = self.width(), self.height()
|
|
pw, ph = self._pixmap.width(), self._pixmap.height()
|
|
if pw == 0 or ph == 0:
|
|
return
|
|
scale_w = vw / pw
|
|
scale_h = vh / ph
|
|
# No 1.0 cap — scale up to fill the available view, matching how
|
|
# the video player fills its widget. In the popout the window is
|
|
# already aspect-locked to the image's aspect, so scaling up
|
|
# produces a clean fill with no letterbox. In the embedded
|
|
# preview the user can drag the splitter past the image's native
|
|
# size; letting it scale up there fills the pane the same way
|
|
# the popout does.
|
|
self._zoom = min(scale_w, scale_h)
|
|
self._offset = QPointF(
|
|
(vw - pw * self._zoom) / 2,
|
|
(vh - ph * self._zoom) / 2,
|
|
)
|
|
|
|
def paintEvent(self, event) -> None:
|
|
p = QPainter(self)
|
|
pal = self.palette()
|
|
p.fillRect(self.rect(), pal.color(pal.ColorRole.Window))
|
|
if self._pixmap:
|
|
p.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
|
|
p.translate(self._offset)
|
|
p.scale(self._zoom, self._zoom)
|
|
p.drawPixmap(0, 0, self._pixmap)
|
|
p.resetTransform()
|
|
p.end()
|
|
|
|
def wheelEvent(self, event: QWheelEvent) -> None:
|
|
if not self._pixmap:
|
|
return
|
|
delta = event.angleDelta().y()
|
|
if delta == 0:
|
|
# Pure horizontal tilt — let parent handle (navigation)
|
|
event.ignore()
|
|
return
|
|
mouse_pos = event.position()
|
|
old_zoom = self._zoom
|
|
factor = 1.15 if delta > 0 else 1 / 1.15
|
|
self._zoom = max(0.1, min(self._zoom * factor, 20.0))
|
|
ratio = self._zoom / old_zoom
|
|
self._offset = mouse_pos - ratio * (mouse_pos - self._offset)
|
|
self.update()
|
|
|
|
def mousePressEvent(self, event: QMouseEvent) -> None:
|
|
if event.button() == Qt.MouseButton.MiddleButton:
|
|
self._fit_to_view()
|
|
self.update()
|
|
elif event.button() == Qt.MouseButton.LeftButton:
|
|
self._drag_start = event.position()
|
|
self._drag_offset = QPointF(self._offset)
|
|
self.setCursor(Qt.CursorShape.ClosedHandCursor)
|
|
|
|
def mouseMoveEvent(self, event: QMouseEvent) -> None:
|
|
if self._drag_start is not None:
|
|
delta = event.position() - self._drag_start
|
|
self._offset = self._drag_offset + delta
|
|
self.update()
|
|
|
|
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
|
|
self._drag_start = None
|
|
self.setCursor(Qt.CursorShape.ArrowCursor)
|
|
|
|
def keyPressEvent(self, event: QKeyEvent) -> None:
|
|
if event.key() in (Qt.Key.Key_Escape, Qt.Key.Key_Q):
|
|
self.close_requested.emit()
|
|
elif event.key() == Qt.Key.Key_0:
|
|
self._fit_to_view()
|
|
self.update()
|
|
elif event.key() in (Qt.Key.Key_Plus, Qt.Key.Key_Equal):
|
|
self._zoom = min(self._zoom * 1.2, 20.0)
|
|
self.update()
|
|
elif event.key() == Qt.Key.Key_Minus:
|
|
self._zoom = max(self._zoom / 1.2, 0.1)
|
|
self.update()
|
|
else:
|
|
event.ignore()
|
|
|
|
def resizeEvent(self, event) -> None:
|
|
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()
|