Step 3 of the gui/app.py + gui/preview.py structural refactor. Pure move: the zoom/pan image viewer class is now in its own module under media/. preview.py grows another re-export shim line so ImagePreview and FullscreenPreview (both still in preview.py) can keep constructing ImageViewer instances unchanged. Shim removed in commit 14.
155 lines
5.3 KiB
Python
155 lines
5.3 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 self._pixmap:
|
|
self._fit_to_view()
|
|
self.update()
|