Files
cmshoppe/app/gui/image_preview.py
T

240 lines
8.7 KiB
Python

"""Reusable full-image preview dialog for desktop image workflows."""
from __future__ import annotations
import math
import os
from PySide6.QtCore import QSize, Qt, QTimer
from PySide6.QtGui import QImageReader, QPixmap
from PySide6.QtWidgets import (
QApplication,
QDialog,
QHBoxLayout,
QLabel,
QPushButton,
QScrollArea,
QToolButton,
QVBoxLayout,
)
class ImagePreviewDialog(QDialog):
"""Preview a local image with bounded zoom and fit-to-window behavior."""
MIN_ZOOM = 0.25
MAX_ZOOM = 4.0
ZOOM_STEP = 0.25
def __init__(self, path, title="图片预览", parent=None):
super().__init__(parent)
self._base_title = str(title or "图片预览")
self._source = self._load_source(path)
self.fit_to_window = True
self.zoom_factor = 1.0
self._effective_zoom = 1.0
self._display_size = QSize()
self._resize_timer = QTimer(self)
self._resize_timer.setSingleShot(True)
self._resize_timer.setInterval(50)
self._resize_timer.timeout.connect(self._render)
self.setWindowTitle(self._window_title())
layout = QVBoxLayout(self)
self.scroll = QScrollArea()
self.scroll.setObjectName("imagePreviewScrollArea")
self.scroll.setWidgetResizable(False)
self.scroll.setAlignment(Qt.AlignCenter)
self.image_label = QLabel()
self.image_label.setObjectName("imagePreviewLabel")
self.image_label.setAlignment(Qt.AlignCenter)
self.scroll.setWidget(self.image_label)
layout.addWidget(self.scroll, 1)
controls = QHBoxLayout()
self.zoom_out_button = QToolButton()
self.zoom_out_button.setObjectName("imagePreviewZoomOutButton")
self.zoom_out_button.setText("-")
self.zoom_out_button.setToolTip("缩小图片")
self.zoom_out_button.setAccessibleName("缩小图片")
self.zoom_out_button.setMinimumSize(32, 32)
self.zoom_out_button.clicked.connect(self.zoom_out)
controls.addWidget(self.zoom_out_button)
self.zoom_label = QLabel("适应窗口")
self.zoom_label.setObjectName("imagePreviewZoomLabel")
self.zoom_label.setAlignment(Qt.AlignCenter)
self.zoom_label.setMinimumWidth(72)
controls.addWidget(self.zoom_label)
self.zoom_in_button = QToolButton()
self.zoom_in_button.setObjectName("imagePreviewZoomInButton")
self.zoom_in_button.setText("+")
self.zoom_in_button.setToolTip("放大图片")
self.zoom_in_button.setAccessibleName("放大图片")
self.zoom_in_button.setMinimumSize(32, 32)
self.zoom_in_button.clicked.connect(self.zoom_in)
controls.addWidget(self.zoom_in_button)
self.fit_button = QPushButton("适应窗口")
self.fit_button.setObjectName("imagePreviewFitButton")
self.fit_button.setCheckable(True)
self.fit_button.setChecked(True)
self.fit_button.setToolTip("完整显示图片且不放大超过原始尺寸")
self.fit_button.clicked.connect(self.show_fit)
controls.addWidget(self.fit_button)
self.actual_size_button = QPushButton("100%")
self.actual_size_button.setObjectName("imagePreviewActualSizeButton")
self.actual_size_button.setToolTip("按图片原始像素查看")
self.actual_size_button.clicked.connect(self.show_actual_size)
controls.addWidget(self.actual_size_button)
controls.addStretch(1)
close_button = QPushButton("关闭")
close_button.clicked.connect(self.reject)
controls.addWidget(close_button)
layout.addLayout(controls)
self._fit_to_screen()
self._render()
QTimer.singleShot(0, self._render)
@staticmethod
def _load_source(path):
normalized = str(path or "")
if not normalized or not os.path.isfile(normalized):
return QPixmap()
reader = QImageReader(normalized)
reader.setAutoTransform(True)
image = reader.read()
return QPixmap.fromImage(image) if not image.isNull() else QPixmap()
def _window_title(self):
if self._source.isNull():
return self._base_title
return "%s · %dx%d" % (
self._base_title,
self._source.width(),
self._source.height(),
)
def resizeEvent(self, event):
super().resizeEvent(event)
if self.fit_to_window:
self._resize_timer.start()
def showEvent(self, event):
super().showEvent(event)
QTimer.singleShot(0, self._render)
def show_fit(self, checked=False):
self.fit_to_window = True
self._display_size = QSize()
self._render()
def show_actual_size(self, checked=False):
self.fit_to_window = False
self.zoom_factor = 1.0
self._display_size = QSize()
self._render()
def zoom_in(self, checked=False):
self._step_zoom(1)
def zoom_out(self, checked=False):
self._step_zoom(-1)
def _step_zoom(self, direction):
if self._source.isNull():
return
current = self._effective_zoom if self.fit_to_window else self.zoom_factor
if direction > 0:
stepped = (math.floor(current / self.ZOOM_STEP + 1e-9) + 1) * self.ZOOM_STEP
else:
stepped = (math.ceil(current / self.ZOOM_STEP - 1e-9) - 1) * self.ZOOM_STEP
self.fit_to_window = False
self.zoom_factor = max(self.MIN_ZOOM, min(self.MAX_ZOOM, stepped))
self._display_size = QSize()
self._render()
def _fit_scale(self):
viewport = self.scroll.viewport().size()
if self._source.isNull() or viewport.width() <= 0 or viewport.height() <= 0:
return 1.0
return min(
1.0,
viewport.width() / self._source.width(),
viewport.height() / self._source.height(),
)
def _render(self):
viewport = self.scroll.viewport().size()
viewport_width = max(1, viewport.width())
viewport_height = max(1, viewport.height())
if self._source.isNull():
self.image_label.clear()
self.image_label.setText("图片文件不存在或无法读取")
self.image_label.resize(max(320, viewport_width), max(240, viewport_height))
self.zoom_label.setText("无法预览")
self._set_controls_enabled(False)
return
if self.fit_to_window:
scale = max(1e-6, min(1.0, self._fit_scale()))
else:
scale = max(self.MIN_ZOOM, min(self.MAX_ZOOM, self.zoom_factor))
self._effective_zoom = scale
display_size = QSize(
max(1, int(round(self._source.width() * scale))),
max(1, int(round(self._source.height() * scale))),
)
if display_size == self._source.size():
pixmap = self._source
else:
pixmap = self._source.scaled(
display_size,
Qt.KeepAspectRatio,
Qt.SmoothTransformation,
)
if display_size != self._display_size or self.image_label.pixmap() is None:
self.image_label.setPixmap(pixmap)
self.image_label.resize(pixmap.size())
self._display_size = QSize(pixmap.size())
self.zoom_label.setText(
"适应窗口" if self.fit_to_window else "%d%%" % int(round(scale * 100))
)
self.fit_button.setChecked(self.fit_to_window)
self._set_controls_enabled(True)
def _set_controls_enabled(self, available):
self.fit_button.setEnabled(available)
self.actual_size_button.setEnabled(available)
self.zoom_out_button.setEnabled(
available and self._effective_zoom > self.MIN_ZOOM + 1e-9
)
self.zoom_in_button.setEnabled(
available and self._effective_zoom < self.MAX_ZOOM - 1e-9
)
def _fit_to_screen(self):
screen = self.parentWidget().screen() if self.parentWidget() is not None else None
screen = screen or QApplication.primaryScreen()
if screen is None:
self.resize(720, 520)
return
available = screen.availableGeometry()
max_width = max(1, int(available.width() * 0.9))
max_height = max(1, int(available.height() * 0.9))
min_width = min(560, max_width)
min_height = min(420, max_height)
source_width = self._source.width() if not self._source.isNull() else 640
source_height = self._source.height() if not self._source.isNull() else 480
self.resize(
min(max_width, max(min_width, source_width + 48)),
min(max_height, max(min_height, source_height + 104)),
)
frame = self.frameGeometry()
frame.moveCenter(available.center())
self.move(frame.topLeft())