From dbbf3ce8f7859bcc8dd92a15a4d69047218f72a7 Mon Sep 17 00:00:00 2001 From: chengma Date: Thu, 16 Jul 2026 09:13:44 +0800 Subject: [PATCH] feat(gui): add shared zoomable image preview --- app/gui/image_preview.py | 239 ++++++++++++++++++++++++++++++++++ app/gui/tabs/image_studio.py | 32 +---- app/gui/tabs/product_suite.py | 40 +----- docs/tasks/T-610.md | 9 +- tests/test_gui.py | 117 +++++++++++++++++ 5 files changed, 369 insertions(+), 68 deletions(-) create mode 100644 app/gui/image_preview.py diff --git a/app/gui/image_preview.py b/app/gui/image_preview.py new file mode 100644 index 0000000..e24c745 --- /dev/null +++ b/app/gui/image_preview.py @@ -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()) diff --git a/app/gui/tabs/image_studio.py b/app/gui/tabs/image_studio.py index dc7c0ef..88ff60a 100644 --- a/app/gui/tabs/image_studio.py +++ b/app/gui/tabs/image_studio.py @@ -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", "")) diff --git a/app/gui/tabs/product_suite.py b/app/gui/tabs/product_suite.py index cce3f66..fe1b4e2 100644 --- a/app/gui/tabs/product_suite.py +++ b/app/gui/tabs/product_suite.py @@ -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): diff --git a/docs/tasks/T-610.md b/docs/tasks/T-610.md index 4c33090..8f6daa0 100644 --- a/docs/tasks/T-610.md +++ b/docs/tasks/T-610.md @@ -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` 全部通过。 diff --git a/tests/test_gui.py b/tests/test_gui.py index 851a9c9..3e52fa3 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -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)