feat(gui): add shared zoomable image preview

This commit is contained in:
chengma
2026-07-16 09:13:44 +08:00
parent 4cf0f46e90
commit dbbf3ce8f7
5 changed files with 369 additions and 68 deletions
+239
View File
@@ -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())
+4 -28
View File
@@ -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", ""))
+3 -37
View File
@@ -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):
+6 -3
View File
@@ -3,7 +3,7 @@ id: T-610
title: AI工场原图预览默认适应窗口并支持缩放查看
phase: 7
deps: [T-609]
status: TODO
status: DONE
created: 2026-07-11
---
@@ -19,7 +19,7 @@ created: 2026-07-11
### 1. 默认适应窗口显示完整图片
修改 `app/gui/tabs/image_studio.py` 中的 `ImageStudioPreviewDialog`:
修改旧兼容 `app/gui/tabs/image_studio.py` 中的 `ImageStudioPreviewDialog`,并同步当前正式第六 Tab 使用的 `app/gui/tabs/product_suite.py::ProductSuitePreviewDialog`。两者复用同一个原生 PySide6 预览组件,避免只修复已隐藏的旧「AI工场」入口:
- 保留完整原始 `QPixmap`,不读取缩略图缓存、不重新下载、不修改本地图片。
- 对可用图片,预览首次打开默认使用「适应窗口」模式:按 `QScrollArea.viewport()` 的实际可用尺寸等比缩放,保持原始比例,不裁切、不拉伸变形。
@@ -88,4 +88,7 @@ git diff --check
## 执行记录
- 待执行。
- 2026-07-16:新增 `app/gui/image_preview.py::ImagePreviewDialog`,保留完整本地图像,默认等比适应窗口且小图不放大;提供适应窗口、100%、缩小、放大和 25%~400% 固定倍率,固定倍率下窗口 resize 不重置,超大图片在适应模式下可低于 25% 以保证完整显示。
- 2026-07-16:旧兼容 `ImageStudioPreviewDialog` 与当前正式第六 Tab 的 `ProductSuitePreviewDialog` 改为复用同一预览组件;标题只显示中文上下文和原始分辨率,文件缺失时显示中文空状态并禁用缩放,不暴露本地路径。
- 2026-07-16:`tests/test_gui.py` 新增默认适应、100% 滚动查看、固定倍率 resize、缩放边界、小图不放大、旧入口复用和缺失文件回归测试。
- 验证:当前工作区定向 3 项 GUI 测试通过,ruff、compileall、`git diff --check` 通过;当前工作区全量测试仅受任务开始前未提交的默认封面提示词改名影响而失败 3 项。将本任务文件复制到基于 `HEAD` 的隔离 worktree 后,`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`(498 项)和 `git diff --check` 全部通过。
+117
View File
@@ -54,8 +54,10 @@ from app.gui import (
GenerateWorker,
GenerateTab,
ForcedUpdateDialog,
ImageStudioPreviewDialog,
ImageStudioTab,
MainWindow,
ProductSuitePreviewDialog,
ProductSuiteTab,
SettingsTab,
TAB_STYLE,
@@ -4515,6 +4517,121 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_shared_image_preview_defaults_to_fit_and_keeps_fixed_zoom_on_resize(self):
with self.make_temp_dir() as temp_dir:
image_path = self.write_test_image(
os.path.join(temp_dir, "suite-large.jpg"),
width=1200,
height=800,
)
dialog = ProductSuitePreviewDialog(image_path, "商品原图预览")
self.addCleanup(dialog.close)
dialog.resize(600, 450)
dialog.show()
QApplication.processEvents()
dialog._resize_timer.stop()
dialog._render()
fit_pixmap = dialog.image_label.pixmap()
viewport = dialog.scroll.viewport().size()
self.assertTrue(dialog.fit_to_window)
self.assertEqual("适应窗口", dialog.zoom_label.text())
self.assertIn("1200x800", dialog.windowTitle())
self.assertLessEqual(fit_pixmap.width(), viewport.width())
self.assertLessEqual(fit_pixmap.height(), viewport.height())
self.assertAlmostEqual(1.5, fit_pixmap.width() / fit_pixmap.height(), places=1)
dialog.show_actual_size()
QApplication.processEvents()
self.assertFalse(dialog.fit_to_window)
self.assertEqual("100%", dialog.zoom_label.text())
self.assertEqual(QSize(1200, 800), dialog.image_label.pixmap().size())
self.assertEqual(QSize(1200, 800), dialog.image_label.size())
self.assertGreater(dialog.scroll.horizontalScrollBar().maximum(), 0)
self.assertGreater(dialog.scroll.verticalScrollBar().maximum(), 0)
dialog.resize(520, 360)
QApplication.processEvents()
dialog._resize_timer.stop()
dialog._render()
self.assertEqual(QSize(1200, 800), dialog.image_label.pixmap().size())
dialog.show_fit()
self.assertTrue(dialog.fit_to_window)
self.assertEqual("适应窗口", dialog.zoom_label.text())
self.assertLessEqual(
dialog.image_label.pixmap().width(),
dialog.scroll.viewport().width(),
)
self.assertLessEqual(
dialog.image_label.pixmap().height(),
dialog.scroll.viewport().height(),
)
self.assert_removed(temp_dir)
def test_shared_image_preview_bounds_zoom_and_does_not_enlarge_small_fit_image(self):
with self.make_temp_dir() as temp_dir:
image_path = self.write_test_image(
os.path.join(temp_dir, "suite-small.jpg"),
width=200,
height=100,
)
dialog = ProductSuitePreviewDialog(image_path, "图片预览")
self.addCleanup(dialog.close)
dialog.resize(700, 500)
dialog.show()
QApplication.processEvents()
dialog._resize_timer.stop()
dialog._render()
self.assertEqual(QSize(200, 100), dialog.image_label.pixmap().size())
for _index in range(20):
dialog.zoom_out()
self.assertEqual(0.25, dialog.zoom_factor)
self.assertEqual("25%", dialog.zoom_label.text())
self.assertFalse(dialog.zoom_out_button.isEnabled())
for _index in range(20):
dialog.zoom_in()
self.assertEqual(4.0, dialog.zoom_factor)
self.assertEqual("400%", dialog.zoom_label.text())
self.assertFalse(dialog.zoom_in_button.isEnabled())
self.assertEqual(QSize(800, 400), dialog.image_label.pixmap().size())
self.assert_removed(temp_dir)
def test_shared_image_preview_legacy_wrapper_and_missing_file_state(self):
with self.make_temp_dir() as temp_dir:
image_path = self.write_test_image(
os.path.join(temp_dir, "legacy.jpg"),
width=320,
height=240,
)
asset = SimpleNamespace(id=7, kind="original", local_path=image_path)
legacy = ImageStudioPreviewDialog(asset)
self.addCleanup(legacy.close)
legacy.show()
QApplication.processEvents()
legacy._resize_timer.stop()
legacy._render()
self.assertIn("AI工场图片预览", legacy.windowTitle())
self.assertIn("320x240", legacy.windowTitle())
self.assertEqual("适应窗口", legacy.zoom_label.text())
missing_path = os.path.join(temp_dir, "missing-private-name.jpg")
missing = ProductSuitePreviewDialog(missing_path, "商品原图预览")
self.addCleanup(missing.close)
missing._render()
self.assertEqual("图片文件不存在或无法读取", missing.image_label.text())
self.assertEqual("无法预览", missing.zoom_label.text())
self.assertFalse(missing.fit_button.isEnabled())
self.assertFalse(missing.actual_size_button.isEnabled())
self.assertNotIn(missing_path, missing.windowTitle())
self.assert_removed(temp_dir)
def test_cover_gallery_previous_next_switch_visible_tasks_without_resizing(self):
with self.make_temp_dir() as temp_dir:
cfg, account, tasks, canonicals, _archives = self._cover_gallery_task_set(temp_dir, count=3)