feat(gui): add shared zoomable image preview
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
"""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())
|
||||
@@ -9,6 +9,7 @@ from PySide6.QtWidgets import QListView, QListWidget, QListWidgetItem, QSizePoli
|
||||
|
||||
from ... import accounts, appconfig, cmhub_models, db, diagnostics, image_studio, image_studio_export, image_studio_images, prompts
|
||||
from .. import file_manager
|
||||
from ..image_preview import ImagePreviewDialog
|
||||
from ..widgets import *
|
||||
from ..workers import (
|
||||
ImageStudioDownloadOriginalWorker as _RealImageStudioDownloadOriginalWorker,
|
||||
@@ -222,38 +223,13 @@ class ImageStudioSelectionList(QListWidget):
|
||||
return None
|
||||
|
||||
|
||||
class ImageStudioPreviewDialog(QDialog):
|
||||
"""Simple large image preview used by original and pool tables."""
|
||||
class ImageStudioPreviewDialog(ImagePreviewDialog):
|
||||
"""Compatibility wrapper for the shared full-image preview."""
|
||||
|
||||
def __init__(self, asset, parent=None):
|
||||
super().__init__(parent)
|
||||
self.asset = asset
|
||||
self.setWindowTitle(self._title_for_asset(asset))
|
||||
layout = QVBoxLayout(self)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(False)
|
||||
image_label = QLabel()
|
||||
image_label.setAlignment(Qt.AlignCenter)
|
||||
path = str(getattr(asset, "local_path", "") or "")
|
||||
image = QImage(path) if path and os.path.isfile(path) else QImage()
|
||||
if image.isNull():
|
||||
image_label.setText("图片尚未下载或读取失败")
|
||||
image_label.setMinimumSize(420, 260)
|
||||
else:
|
||||
image_label.setPixmap(QPixmap.fromImage(image))
|
||||
image_label.resize(image.size())
|
||||
self.setWindowTitle(
|
||||
f"{self._title_for_asset(asset)} · {image.width()}x{image.height()}"
|
||||
)
|
||||
scroll.setWidget(image_label)
|
||||
layout.addWidget(scroll, 1)
|
||||
buttons = QHBoxLayout()
|
||||
buttons.addStretch(1)
|
||||
close_button = QPushButton("关闭")
|
||||
close_button.clicked.connect(self.reject)
|
||||
buttons.addWidget(close_button)
|
||||
layout.addLayout(buttons)
|
||||
self.resize(720, 520)
|
||||
super().__init__(path, self._title_for_asset(asset), parent)
|
||||
|
||||
def _title_for_asset(self, asset):
|
||||
badge = _asset_badge(getattr(asset, "kind", ""))
|
||||
|
||||
@@ -13,7 +13,6 @@ from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QFileDialog,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
@@ -43,6 +42,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from ... import accounts, appconfig, diagnostics, image_studio, image_studio_images, product_suite
|
||||
from .. import file_manager
|
||||
from ..image_preview import ImagePreviewDialog
|
||||
from ..widgets import COLOR_DANGER, _emit_status, run_worker
|
||||
from ..workers import (
|
||||
ImageStudioDownloadOriginalWorker,
|
||||
@@ -109,42 +109,8 @@ def _user_error(error):
|
||||
return text if len(text) <= 90 else text[:87] + "..."
|
||||
|
||||
|
||||
class ProductSuitePreviewDialog(QDialog):
|
||||
"""Responsive preview for product originals and generated assets."""
|
||||
|
||||
def __init__(self, path, title="图片预览", parent=None):
|
||||
super().__init__(parent)
|
||||
self._source = QPixmap(str(path or ""))
|
||||
self.setWindowTitle(str(title or "图片预览"))
|
||||
layout = QVBoxLayout(self)
|
||||
self.image_label = QLabel()
|
||||
self.image_label.setAlignment(Qt.AlignCenter)
|
||||
self.image_label.setMinimumSize(320, 240)
|
||||
layout.addWidget(self.image_label, 1)
|
||||
close_button = QPushButton("关闭")
|
||||
close_button.clicked.connect(self.accept)
|
||||
button_row = QHBoxLayout()
|
||||
button_row.addStretch(1)
|
||||
button_row.addWidget(close_button)
|
||||
layout.addLayout(button_row)
|
||||
self.resize(820, 620)
|
||||
self._render()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
self._render()
|
||||
|
||||
def _render(self):
|
||||
if self._source.isNull():
|
||||
self.image_label.setText("图片文件不存在或无法读取")
|
||||
self.image_label.setPixmap(QPixmap())
|
||||
return
|
||||
target = self.image_label.size() - QSize(16, 16)
|
||||
if target.width() <= 0 or target.height() <= 0:
|
||||
return
|
||||
self.image_label.setPixmap(
|
||||
self._source.scaled(target, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||
)
|
||||
class ProductSuitePreviewDialog(ImagePreviewDialog):
|
||||
"""Current product-suite wrapper for the shared full-image preview."""
|
||||
|
||||
|
||||
class ProductOriginalDelegate(QStyledItemDelegate):
|
||||
|
||||
Reference in New Issue
Block a user