from enum import Enum, auto
from pathlib import Path
from PySide6.QtCore import QEvent, QPoint, QSize, Qt, Signal
from PySide6.QtGui import QKeySequence, QPixmap, QShortcut
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QScrollArea,
QSizePolicy,
QToolButton,
QVBoxLayout,
QWidget,
)
from pbpicat.config import DEFAULTS, app_qsettings
from pbpicat.image_io import load_pixmap
from .icons import ICON_SIZE as _ICON_SIZE
from .icons import get_icon
class _ZoomMode(Enum):
FIT_WINDOW = auto()
FIT_WIDTH = auto()
FIT_HEIGHT = auto()
ONE_TO_ONE = auto()
CUSTOM = auto()
[docs]
class ImageViewer(QWidget):
"""
Non-modal independent image viewer with zoom toolbar.
Zoom modes (toolbar, left to right):
1:1 | Zoom in | Zoom out | Width | Height | Window (default)
"""
navigate_prev = Signal()
navigate_next = Signal()
open_requested = Signal()
open_with_requested = Signal()
template_requested = Signal()
delete_requested = Signal()
rotate_requested = Signal(object) # int (90, -90, 180) or "auto"
def __init__(
self,
image_path: Path,
parent=None,
zoom_step_percent: int = DEFAULTS["zoom_step_percent"],
zoom_max_percent: int = DEFAULTS["zoom_max_percent"],
auto_rotate: bool = True,
):
super().__init__(parent, Qt.Window)
self.setMinimumSize(300, 200)
self._zoom_step = zoom_step_percent / 100.0
self._zoom_max = zoom_max_percent / 100.0
self._mode = _ZoomMode.FIT_WINDOW
self._factor = 1.0 # used only in CUSTOM mode
self._zoom_min = 0.01
self._drag_pos: QPoint | None = None
self._rotate_auto_btn: QToolButton | None = None
self._reset_exif_btn: QToolButton | None = None
self._auto_rotate = auto_rotate
self._current_path: Path | None = None
self._setup_ui()
self._setup_shortcuts()
saved_geom = app_qsettings().value("image_viewer/geometry")
if saved_geom:
self.restoreGeometry(saved_geom)
self.load_image(image_path)
if not saved_geom:
screen = self.screen() or (parent.screen() if parent else None)
if screen:
avail = screen.availableGeometry()
self.resize(int(avail.width() * 0.75), int(avail.height() * 0.75))
else:
self.resize(1000, 700)
# ------------------------------------------------------------------
# UI construction
# ------------------------------------------------------------------
def _setup_ui(self) -> None:
root = QVBoxLayout(self)
root.setContentsMargins(4, 4, 4, 4)
root.setSpacing(4)
root.addLayout(self._build_toolbar())
self._scroll = QScrollArea()
self._scroll.setAlignment(Qt.AlignCenter)
self._scroll.setWidgetResizable(False)
self._label = QLabel()
self._label.setAlignment(Qt.AlignCenter)
self._label.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
self._label.installEventFilter(self)
self._scroll.setWidget(self._label)
root.addWidget(self._scroll, stretch=1)
def _build_toolbar(self) -> QHBoxLayout:
tb = QHBoxLayout()
tb.setSpacing(2)
specs = [
("zoom-fit", "⊡", _("Fit window 0 / X"), self._act_fit_window),
("zoom-original", "1:1", _("Actual size (1:1) 1 / Z"), self._act_1to1),
("zoom-width", "↔", _("Fit width W"), self._act_fit_width),
("zoom-height", "↕", _("Fit height H"), self._act_fit_height),
]
self._zoom_buttons: list[QToolButton] = []
for icon_name, fallback, tip, slot in specs:
btn = QToolButton()
btn.setIcon(get_icon(icon_name, text_fallback=fallback))
btn.setIconSize(QSize(_ICON_SIZE, _ICON_SIZE))
btn.setToolTip(tip)
btn.setCheckable(True)
btn.clicked.connect(lambda _checked, s=slot: s())
tb.addWidget(btn)
self._zoom_buttons.append(btn)
# The "fit window" button is active by default
self._zoom_buttons[0].setChecked(True)
# Separator between mode buttons and zoom in/out
sep = QToolButton()
sep.setEnabled(False)
sep.setFixedWidth(8)
tb.addWidget(sep)
for icon_name, fallback, tip, slot in [
("zoom-in", "+", _("Zoom in +"), self._act_zoom_in),
("zoom-out", "-", _("Zoom out −"), self._act_zoom_out),
]:
btn = QToolButton()
btn.setIcon(get_icon(icon_name, text_fallback=fallback))
btn.setIconSize(QSize(_ICON_SIZE, _ICON_SIZE))
btn.setToolTip(tip)
btn.clicked.connect(slot)
tb.addWidget(btn)
sep2 = QToolButton()
sep2.setEnabled(False)
sep2.setFixedWidth(8)
tb.addWidget(sep2)
for icon_name, fallback, tip, callback in [
("object-rotate-left", "↺", _("Rotate 90° CCW"), lambda: self.rotate_requested.emit(-90)),
("object-rotate-right", "↻", _("Rotate 90° CW"), lambda: self.rotate_requested.emit(90)),
("object-flip-vertical", "↕", _("Rotate 180°"), lambda: self.rotate_requested.emit(180)),
]:
btn = QToolButton()
btn.setIcon(get_icon(icon_name, text_fallback=fallback))
btn.setIconSize(QSize(_ICON_SIZE, _ICON_SIZE))
btn.setToolTip(tip)
btn.clicked.connect(callback)
tb.addWidget(btn)
self._rotate_auto_btn = QToolButton()
self._rotate_auto_btn.setIcon(get_icon("auto-rotate", text_fallback="EXIF"))
self._rotate_auto_btn.setIconSize(QSize(_ICON_SIZE, _ICON_SIZE))
self._rotate_auto_btn.setToolTip(_("Apply EXIF orientation"))
self._rotate_auto_btn.clicked.connect(lambda: self.rotate_requested.emit("auto"))
self._rotate_auto_btn.setEnabled(False)
tb.addWidget(self._rotate_auto_btn)
self._reset_exif_btn = QToolButton()
self._reset_exif_btn.setIcon(get_icon("reset-exif", "edit-clear", text_fallback="0°"))
self._reset_exif_btn.setIconSize(QSize(_ICON_SIZE, _ICON_SIZE))
self._reset_exif_btn.setToolTip(_("Reset EXIF orientation"))
self._reset_exif_btn.clicked.connect(lambda: self.rotate_requested.emit("reset_exif"))
self._reset_exif_btn.setEnabled(False)
tb.addWidget(self._reset_exif_btn)
sep3 = QToolButton()
sep3.setEnabled(False)
sep3.setFixedWidth(8)
tb.addWidget(sep3)
for icon_name, fallback, tip, callback in [
("open", "▶", _("Open") + " Ctrl+O", lambda: self.open_requested.emit()),
("open-with", "▶…", _("Open with") + " Ctrl+Shift+O", lambda: self.open_with_requested.emit()),
("rename-template", "T", _("Template"), lambda: self.template_requested.emit()),
("delete", "✕", _("Delete") + " Del", lambda: self.delete_requested.emit()),
]:
btn = QToolButton()
btn.setIcon(get_icon(icon_name, text_fallback=fallback))
btn.setIconSize(QSize(_ICON_SIZE, _ICON_SIZE))
btn.setToolTip(tip)
btn.clicked.connect(callback)
tb.addWidget(btn)
tb.addStretch()
self._zoom_label = QLabel()
self._zoom_label.setMinimumWidth(180)
self._zoom_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
tb.addWidget(self._zoom_label)
return tb
def _setup_shortcuts(self) -> None:
pairs = [
(QKeySequence(Qt.Key.Key_0), self._act_fit_window),
(QKeySequence(Qt.Key.Key_X), self._act_fit_window),
(QKeySequence(Qt.Key.Key_1), self._act_1to1),
(QKeySequence(Qt.Key.Key_Z), self._act_1to1),
(QKeySequence(Qt.Key.Key_W), self._act_fit_width),
(QKeySequence(Qt.Key.Key_H), self._act_fit_height),
(QKeySequence(Qt.Key.Key_Plus), self._act_zoom_in),
(QKeySequence(Qt.Key.Key_Equal), self._act_zoom_in),
(QKeySequence(Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_Plus), self._act_zoom_in),
(QKeySequence(Qt.Key.Key_Minus), self._act_zoom_out),
(QKeySequence(Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_Minus), self._act_zoom_out),
(QKeySequence(Qt.Key.Key_Up), self.navigate_prev),
(QKeySequence(Qt.Key.Key_Down), self.navigate_next),
(QKeySequence.StandardKey.Open, self.open_requested),
(
QKeySequence(Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier | Qt.Key.Key_O),
self.open_with_requested,
),
(QKeySequence(Qt.Key.Key_Delete), self.delete_requested),
(QKeySequence(Qt.Key.Key_Escape), self.close),
]
for seq, slot in pairs:
sc = QShortcut(seq, self)
sc.activated.connect(slot)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
[docs]
def load_image(self, path: Path) -> None:
self._current_path = path
self.setWindowTitle(path.name)
self._pixmap = load_pixmap(path, auto_rotate=self._auto_rotate)
if not self._pixmap.isNull():
w, h = self._pixmap.width(), self._pixmap.height()
min_dim = min(w, h)
self._zoom_min = (64 / min_dim) if min_dim >= 64 else 1.0
self._label.setText("")
else:
self._zoom_min = 0.01
self._label.setText(_("Cannot load image."))
if self._rotate_auto_btn is not None:
from pbpicat.image_ops import get_exif_orientation
has_exif = get_exif_orientation(path) is not None
self._rotate_auto_btn.setEnabled(has_exif)
self._reset_exif_btn.setEnabled(has_exif)
self._apply_zoom(center=True)
[docs]
def set_auto_rotate(self, value: bool) -> None:
if self._auto_rotate == value:
return
self._auto_rotate = value
if self._current_path is not None:
self.load_image(self._current_path)
[docs]
def show_message(self, text: str) -> None:
self._pixmap = QPixmap()
self._label.setPixmap(QPixmap())
self._label.setText(text)
self._zoom_label.setText("")
# ------------------------------------------------------------------
# Zoom actions
# ------------------------------------------------------------------
def _act_1to1(self) -> None:
self._set_mode(_ZoomMode.ONE_TO_ONE)
def _act_zoom_in(self) -> None:
self._apply_custom(self._current_factor() + self._zoom_step)
def _act_zoom_out(self) -> None:
self._apply_custom(self._current_factor() - self._zoom_step)
def _act_fit_width(self) -> None:
self._set_mode(_ZoomMode.FIT_WIDTH)
def _act_fit_height(self) -> None:
self._set_mode(_ZoomMode.FIT_HEIGHT)
def _act_fit_window(self) -> None:
self._set_mode(_ZoomMode.FIT_WINDOW)
# ------------------------------------------------------------------
# Zoom engine
# ------------------------------------------------------------------
def _set_mode(self, mode: _ZoomMode) -> None:
self._mode = mode
self._update_button_states()
self._apply_zoom(center=True)
def _apply_custom(self, factor: float) -> None:
self._factor = max(self._zoom_min, min(self._zoom_max, factor))
self._mode = _ZoomMode.CUSTOM
self._update_button_states()
self._apply_zoom()
def _current_factor(self) -> float:
"""Return the effective zoom factor regardless of mode."""
if self._pixmap.isNull():
return 1.0
vp = self._scroll.viewport()
w, h = self._pixmap.width(), self._pixmap.height()
if self._mode == _ZoomMode.FIT_WINDOW:
if w == 0 or h == 0:
return 1.0
fw = vp.width() / w
fh = vp.height() / h
return min(fw, fh)
if self._mode == _ZoomMode.FIT_WIDTH:
return vp.width() / w if w else 1.0
if self._mode == _ZoomMode.FIT_HEIGHT:
return vp.height() / h if h else 1.0
if self._mode == _ZoomMode.ONE_TO_ONE:
return 1.0
return self._factor # CUSTOM
def _apply_zoom(self, center: bool = False) -> None:
if self._pixmap.isNull():
return
vp = self._scroll.viewport()
orig_w, orig_h = self._pixmap.width(), self._pixmap.height()
if self._mode == _ZoomMode.FIT_WINDOW:
scaled = self._pixmap.scaled(vp.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)
elif self._mode == _ZoomMode.FIT_WIDTH:
scaled = self._pixmap.scaledToWidth(vp.width(), Qt.SmoothTransformation)
elif self._mode == _ZoomMode.FIT_HEIGHT:
scaled = self._pixmap.scaledToHeight(vp.height(), Qt.SmoothTransformation)
elif self._mode == _ZoomMode.ONE_TO_ONE:
scaled = self._pixmap
else: # CUSTOM
tw = max(1, int(orig_w * self._factor))
th = max(1, int(orig_h * self._factor))
scaled = self._pixmap.scaled(tw, th, Qt.KeepAspectRatio, Qt.SmoothTransformation)
hbar = self._scroll.horizontalScrollBar()
vbar = self._scroll.verticalScrollBar()
# For CUSTOM (zoom in/out): preserve scroll centre by percentage
if self._mode == _ZoomMode.CUSTOM:
hmax_before = hbar.maximum() or 1
vmax_before = vbar.maximum() or 1
hpct = hbar.value() / hmax_before
vpct = vbar.value() / vmax_before
self._label.setPixmap(scaled)
self._label.resize(scaled.size())
if self._mode == _ZoomMode.CUSTOM:
hbar.setValue(int(hpct * hbar.maximum()))
vbar.setValue(int(vpct * vbar.maximum()))
elif center:
if self._mode == _ZoomMode.ONE_TO_ONE:
hbar.setValue(hbar.maximum() // 2)
vbar.setValue(vbar.maximum() // 2)
elif self._mode == _ZoomMode.FIT_WIDTH:
vbar.setValue(vbar.maximum() // 2)
elif self._mode == _ZoomMode.FIT_HEIGHT:
hbar.setValue(hbar.maximum() // 2)
# Update zoom label
factor = scaled.width() / orig_w if orig_w else 1.0
at_max = self._mode == _ZoomMode.CUSTOM and self._factor >= self._zoom_max
suffix = _(" (max)") if at_max else ""
self._zoom_label.setText(f"{orig_w}×{orig_h} {factor * 100:.0f}%{suffix}")
def _update_button_states(self) -> None:
mode_order = [
_ZoomMode.FIT_WINDOW,
_ZoomMode.ONE_TO_ONE,
_ZoomMode.FIT_WIDTH,
_ZoomMode.FIT_HEIGHT,
]
for btn, mode in zip(self._zoom_buttons, mode_order):
btn.setChecked(self._mode == mode)
# ------------------------------------------------------------------
# Qt overrides
# ------------------------------------------------------------------
[docs]
def eventFilter(self, obj, event) -> bool: # noqa: N802
if obj is self._label:
etype = event.type()
if etype == QEvent.Type.MouseButtonPress and event.button() == Qt.LeftButton:
if event.modifiers() & Qt.ControlModifier:
self._zoom_to_point(event.position().toPoint())
return True
self._drag_pos = event.globalPosition().toPoint()
self._label.setCursor(Qt.OpenHandCursor)
return False
if etype == QEvent.Type.MouseButtonPress and event.button() == Qt.RightButton:
if event.modifiers() & Qt.ControlModifier:
self._zoom_to_point(event.position().toPoint(), direction=-1)
return True
if etype == QEvent.Type.MouseMove and (event.buttons() & Qt.LeftButton) and self._drag_pos is not None:
pos = event.globalPosition().toPoint()
delta = pos - self._drag_pos
self._drag_pos = pos
self._label.setCursor(Qt.ClosedHandCursor)
hbar = self._scroll.horizontalScrollBar()
vbar = self._scroll.verticalScrollBar()
hbar.setValue(hbar.value() - delta.x())
vbar.setValue(vbar.value() - delta.y())
return True
if etype == QEvent.Type.MouseButtonRelease and event.button() == Qt.LeftButton:
self._drag_pos = None
self._label.setCursor(Qt.ArrowCursor)
return False
if etype == QEvent.Type.MouseButtonDblClick and event.button() == Qt.LeftButton:
self._drag_pos = None
self._center_on_label_pos(event.position().toPoint())
return True
return super().eventFilter(obj, event)
def _center_on_label_pos(self, label_pos) -> None:
"""Scroll to center the viewport on the given label-coordinate point."""
hbar = self._scroll.horizontalScrollBar()
vbar = self._scroll.verticalScrollBar()
vp = self._scroll.viewport()
hbar.setValue(int(label_pos.x() - vp.width() / 2))
vbar.setValue(int(label_pos.y() - vp.height() / 2))
def _zoom_to_point(self, label_pos, direction: int = 1) -> None:
label_w = self._label.width()
label_h = self._label.height()
if label_w == 0 or label_h == 0:
self._act_zoom_in() if direction > 0 else self._act_zoom_out()
return
fx = label_pos.x() / label_w
fy = label_pos.y() / label_h
self._factor = max(self._zoom_min, min(self._zoom_max, self._current_factor() + direction * self._zoom_step))
self._mode = _ZoomMode.CUSTOM
self._update_button_states()
self._apply_zoom()
hbar = self._scroll.horizontalScrollBar()
vbar = self._scroll.verticalScrollBar()
vp = self._scroll.viewport()
hbar.setValue(int(fx * self._label.width() - vp.width() / 2))
vbar.setValue(int(fy * self._label.height() - vp.height() / 2))
[docs]
def closeEvent(self, event) -> None: # noqa: N802
app_qsettings().setValue("image_viewer/geometry", self.saveGeometry())
super().closeEvent(event)
[docs]
def resizeEvent(self, event) -> None: # noqa: N802
super().resizeEvent(event)
if self._mode in (_ZoomMode.FIT_WINDOW, _ZoomMode.FIT_WIDTH, _ZoomMode.FIT_HEIGHT):
self._apply_zoom()
[docs]
def showEvent(self, event) -> None: # noqa: N802
super().showEvent(event)
self._apply_zoom(center=True)