diff --git a/booru_viewer/gui/media/image_viewer.py b/booru_viewer/gui/media/image_viewer.py new file mode 100644 index 0000000..1c505ef --- /dev/null +++ b/booru_viewer/gui/media/image_viewer.py @@ -0,0 +1,154 @@ +"""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() diff --git a/booru_viewer/gui/preview.py b/booru_viewer/gui/preview.py index f02c35a..4975a61 100644 --- a/booru_viewer/gui/preview.py +++ b/booru_viewer/gui/preview.py @@ -1075,153 +1075,6 @@ class FullscreenPreview(QMainWindow): super().closeEvent(event) -# -- 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() - - class _ClickSeekSlider(QSlider): """Slider that jumps to the clicked position instead of page-stepping.""" clicked_position = Signal(int) @@ -2239,3 +2092,4 @@ class ImagePreview(QWidget): # -- Refactor compatibility shims (deleted in commit 14) -- from .media.constants import VIDEO_EXTENSIONS, _is_video # re-export for refactor compat from .popout.viewport import Viewport, _DRIFT_TOLERANCE # re-export for refactor compat +from .media.image_viewer import ImageViewer # re-export for refactor compat