Move ImageViewer from preview.py to media/image_viewer.py (no behavior change)
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.
This commit is contained in:
parent
18a86358e2
commit
2865be4826
154
booru_viewer/gui/media/image_viewer.py
Normal file
154
booru_viewer/gui/media/image_viewer.py
Normal file
@ -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()
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user