diff --git a/app/gui/tabs/generate.py b/app/gui/tabs/generate.py index 8493d1c..1611e5c 100644 --- a/app/gui/tabs/generate.py +++ b/app/gui/tabs/generate.py @@ -34,31 +34,88 @@ class OriginalImageDialog(QDialog): def __init__(self, image_path, parent=None): super().__init__(parent) self.image_path = image_path + self.image = QImage(str(image_path)) + self.fit_to_window = True + 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._update_image_display) self.setWindowTitle(os.path.basename(str(image_path or "")) or "原图") layout = QVBoxLayout(self) - image = QImage(str(image_path)) - scroll = QScrollArea() - scroll.setWidgetResizable(False) - image_label = QLabel() - image_label.setAlignment(Qt.AlignCenter) - if image.isNull(): - image_label.setText("图片读取失败") + self.scroll = QScrollArea() + self.scroll.setObjectName("originalImageScrollArea") + self.scroll.setWidgetResizable(False) + self.image_label = QLabel() + self.image_label.setObjectName("originalImageLabel") + self.image_label.setAlignment(Qt.AlignCenter) + if self.image.isNull(): + self.image_label.setText("图片读取失败") else: self.setWindowTitle( - f"{os.path.basename(str(image_path))} · {image.width()}x{image.height()}" + f"{os.path.basename(str(image_path))} · {self.image.width()}x{self.image.height()}" ) - pixmap = QPixmap.fromImage(image) - image_label.setPixmap(pixmap) - image_label.resize(pixmap.size()) - scroll.setWidget(image_label) - layout.addWidget(scroll, 1) + self.scroll.setWidget(self.image_label) + layout.addWidget(self.scroll, 1) button_layout = QHBoxLayout() button_layout.addStretch(1) + self.display_mode_button = QPushButton("原始尺寸") + self.display_mode_button.setObjectName("originalImageDisplayModeButton") + self.display_mode_button.setToolTip("按图片原始像素查看") + self.display_mode_button.setEnabled(not self.image.isNull()) + self.display_mode_button.clicked.connect(self.toggle_display_mode) + button_layout.addWidget(self.display_mode_button) close_button = QPushButton("关闭") close_button.clicked.connect(self.reject) button_layout.addWidget(close_button) layout.addLayout(button_layout) - self._fit_to_screen(image) + self._fit_to_screen(self.image) + self._update_image_display() + QTimer.singleShot(0, self._update_image_display) + + def resizeEvent(self, event): + super().resizeEvent(event) + self._resize_timer.start() + + def toggle_display_mode(self, checked=False): + self.fit_to_window = not self.fit_to_window + if self.fit_to_window: + self.display_mode_button.setText("原始尺寸") + self.display_mode_button.setToolTip("按图片原始像素查看") + else: + self.display_mode_button.setText("适应窗口") + self.display_mode_button.setToolTip("缩放图片以完整显示在窗口内") + self._display_size = QSize() + self._update_image_display() + QTimer.singleShot(0, self._update_image_display) + + def _update_image_display(self): + viewport_size = self.scroll.viewport().size() + viewport_width = max(1, viewport_size.width()) + viewport_height = max(1, viewport_size.height()) + if self.image.isNull(): + self.image_label.clear() + self.image_label.setText("图片读取失败") + self.image_label.resize(viewport_width, viewport_height) + return + if self.fit_to_window: + target_size = QSize(viewport_width, viewport_height) + pixmap = QPixmap.fromImage( + self.image.scaled( + target_size, + Qt.KeepAspectRatio, + Qt.SmoothTransformation, + ) + ) + label_size = target_size + else: + pixmap = QPixmap.fromImage(self.image) + label_size = pixmap.size() + if self._display_size == label_size and self.image_label.pixmap() is not None: + return + self.image_label.setPixmap(pixmap) + self.image_label.resize(label_size) + self._display_size = QSize(label_size) def _fit_to_screen(self, image): available = _available_geometry() @@ -132,6 +189,8 @@ class _SegmentedProgressBar(QProgressBar): class CoverGalleryDialog(QDialog): THUMBNAIL_SIZE = 180 + MAX_THUMBNAIL_SIZE = 360 + CANDIDATE_TEXT_HEIGHT = 72 OLD_THUMBNAIL_SIZE = 300 def __init__( @@ -163,6 +222,14 @@ class CoverGalleryDialog(QDialog): self.current_path = _normalize_file_path(getattr(task, "new_cover_path", None)) self.selected_path = None self.changed = False + self.candidate_thumbnail_size = self.THUMBNAIL_SIZE + self.candidate_images = {} + self.candidate_thumbnails = {} + self.candidate_items = {} + self._candidate_resize_timer = QTimer(self) + self._candidate_resize_timer.setSingleShot(True) + self._candidate_resize_timer.setInterval(60) + self._candidate_resize_timer.timeout.connect(self._update_candidate_thumbnail_layout) self.candidate_buttons = {} self.button_group = QButtonGroup(self) self.button_group.setExclusive(True) @@ -203,6 +270,7 @@ class CoverGalleryDialog(QDialog): self._update_reset_cover_button_state() self._fit_to_screen() self._install_navigation_event_filters() + QTimer.singleShot(0, self._update_candidate_thumbnail_layout) def _old_cover_panel(self): panel = QWidget() @@ -267,28 +335,30 @@ class CoverGalleryDialog(QDialog): def _candidate_item(self, candidate_path, index): item = QWidget() - item.setFixedWidth(self.THUMBNAIL_SIZE + 16) + item.setFixedWidth(self.candidate_thumbnail_size + 16) layout = QVBoxLayout(item) layout.setContentsMargins(0, 0, 0, 0) thumbnail = _CoverThumbnailLabel(candidate_path, self.open_original_image) thumbnail.setObjectName("coverCandidateThumbnail") thumbnail.setAlignment(Qt.AlignCenter) image = _load_image(candidate_path) + self.candidate_images[candidate_path] = image + self.candidate_thumbnails[candidate_path] = thumbnail + self.candidate_items[candidate_path] = item if image.isNull(): thumbnail.setText("图片读取失败") - thumbnail.setMinimumSize(self.THUMBNAIL_SIZE, self.THUMBNAIL_SIZE) else: thumbnail.setPixmap( QPixmap.fromImage( image.scaled( - self.THUMBNAIL_SIZE, - self.THUMBNAIL_SIZE, + self.candidate_thumbnail_size, + self.candidate_thumbnail_size, Qt.KeepAspectRatio, Qt.SmoothTransformation, ) ) ) - thumbnail.setMinimumSize(self.THUMBNAIL_SIZE, self.THUMBNAIL_SIZE) + thumbnail.setFixedSize(self.candidate_thumbnail_size, self.candidate_thumbnail_size) radio = QRadioButton(_candidate_label(candidate_path, self.current_path)) radio.setObjectName(f"coverCandidateRadio{index}") radio.setFocusPolicy(Qt.ClickFocus) @@ -320,15 +390,14 @@ class CoverGalleryDialog(QDialog): min_width = ( margins.left() + margins.right() - + candidate_count * (self.THUMBNAIL_SIZE + 16) + + candidate_count * (self.candidate_thumbnail_size + 16) + max(0, candidate_count - 1) * spacing ) else: min_width = margins.left() + margins.right() + 320 min_height = max( - self.THUMBNAIL_SIZE, + self.candidate_thumbnail_size, hint.height(), - content.minimumHeight(), ) size = QSize(max(min_width, hint.width()), min_height) content.setMinimumSize(size) @@ -338,6 +407,64 @@ class CoverGalleryDialog(QDialog): if scroll is not None: scroll.updateGeometry() + def resizeEvent(self, event): + super().resizeEvent(event) + self._candidate_resize_timer.start() + + def _candidate_thumbnail_target_size(self): + scroll = getattr(self, "candidate_scroll", None) + if scroll is None or not self.candidates: + return self.THUMBNAIL_SIZE + viewport = scroll.viewport() + viewport_width = viewport.width() + viewport_height = viewport.height() + if viewport_width <= 0 or viewport_height <= 0: + return self.candidate_thumbnail_size + visible_count = min(3, max(1, len(self.candidates))) + spacing = max(0, self.candidate_layout.spacing()) + width_budget = ( + viewport_width + - max(0, visible_count - 1) * spacing + - visible_count * 16 + ) // visible_count + height_budget = viewport_height - self.CANDIDATE_TEXT_HEIGHT + target = min(width_budget, height_budget, self.MAX_THUMBNAIL_SIZE) + return max(self.THUMBNAIL_SIZE, int(target)) + + def _update_candidate_thumbnail_layout(self): + if not self.candidates: + self._sync_candidate_content_size() + return + target_size = self._candidate_thumbnail_target_size() + if target_size == self.candidate_thumbnail_size: + self._sync_candidate_content_size() + return + self.candidate_thumbnail_size = target_size + for candidate_path in self.candidates: + item = self.candidate_items.get(candidate_path) + thumbnail = self.candidate_thumbnails.get(candidate_path) + image = self.candidate_images.get(candidate_path) + if item is not None: + item.setFixedWidth(target_size + 16) + if thumbnail is None: + continue + thumbnail.setFixedSize(target_size, target_size) + thumbnail.clear() + if image is None or image.isNull(): + thumbnail.setText("图片读取失败") + continue + thumbnail.setPixmap( + QPixmap.fromImage( + image.scaled( + target_size, + target_size, + Qt.KeepAspectRatio, + Qt.SmoothTransformation, + ) + ) + ) + self._sync_candidate_content_size() + def _sync_initial_selection(self): for candidate_path, radio in self.candidate_buttons.items(): if _same_file(candidate_path, self.current_path): @@ -609,6 +736,9 @@ class CoverGalleryDialog(QDialog): def _rebuild_candidate_items(self): _clear_layout(self.candidate_layout) self.candidate_buttons = {} + self.candidate_images = {} + self.candidate_thumbnails = {} + self.candidate_items = {} self.button_group = QButtonGroup(self) self.button_group.setExclusive(True) if self.candidates: @@ -622,6 +752,7 @@ class CoverGalleryDialog(QDialog): empty_label.setAlignment(Qt.AlignCenter) self.candidate_layout.addWidget(empty_label) self._sync_candidate_content_size() + self._candidate_resize_timer.start() self._install_navigation_event_filters() def _fit_to_screen(self): @@ -631,7 +762,7 @@ class CoverGalleryDialog(QDialog): return max_width = max(520, int(available.width() * 0.9)) max_height = max(420, int(available.height() * 0.9)) - self.resize(min(960, max_width), min(580, max_height)) + self.resize(min(1180, max_width), min(720, max_height)) _center_dialog(self, available) diff --git a/docs/tasks/T-626.md b/docs/tasks/T-626.md index 882d4fa..daf8b2d 100644 --- a/docs/tasks/T-626.md +++ b/docs/tasks/T-626.md @@ -3,7 +3,7 @@ id: T-626 title: ②封面画廊生成图随窗口自适应显示 phase: 7 deps: [T-574] -status: TODO +status: DONE created: 2026-07-13 --- @@ -75,4 +75,10 @@ T-573 只修复了候选图横向容器没有按数量撑开导致的遮挡问 ## 执行记录 -- 待实现。 +- 2026-07-14:完成 T-626。 + - `app/gui/tabs/generate.py`:封面画廊候选图改为按候选区 viewport、候选数量和可用高度在 `180~360px` 内动态计算;候选 item、缩略图和横向内容宽度使用同一尺寸,窗口 resize、任务切换和候选重建后自动同步。 + - 候选原始 `QImage` 在当前任务画廊内缓存,连续 resize 使用单次计时器合并重绘;预览继续使用 `Qt.KeepAspectRatio` 和平滑缩放,读取失败时保持中文占位。 + - `OriginalImageDialog` 默认按窗口完整显示图片,新增「原始尺寸 / 适应窗口」切换;原始尺寸保留滚动查看,窗口尺寸变化后自动更新预览。 + - `tests/test_gui.py`:新增候选图随窗口变化、非方图比例、损坏图片占位、任务切换后响应式尺寸,以及原图双模式和失败占位测试;既有候选内容宽度断言改为使用当前动态尺寸。 + - 主工作区存在与本任务无关的默认封面模板未提交改名,导致两项旧模板断言失败;未回退用户改动,在基于 `HEAD` 的干净 worktree 中仅套用 T-626 代码和测试差异完成验证。 + - 验证通过:`py -3.10 -m unittest tests.test_gui`(179 tests)、`py -3.10 -m unittest discover -s tests`(444 tests)、`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。 diff --git a/tests/test_gui.py b/tests/test_gui.py index ae1c55d..f0c477a 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -4069,7 +4069,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertGreaterEqual(len(dialog.candidates), 4) expected_width = ( - len(dialog.candidates) * (dialog.THUMBNAIL_SIZE + 16) + len(dialog.candidates) * (dialog.candidate_thumbnail_size + 16) + (len(dialog.candidates) - 1) * dialog.candidate_layout.spacing() ) self.assertIs(dialog.candidate_scroll.widget(), dialog.candidate_content) @@ -4078,6 +4078,63 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_cover_gallery_candidate_images_resize_with_viewport_and_keep_ratio(self): + with self.make_temp_dir() as temp_dir: + cfg, account, task, canonical = self._cover_gallery_task(temp_dir) + self.write_test_image(canonical, width=240, height=120) + dialog = CoverGalleryDialog(task, cfg["image_dir"], cfg["db_path"], account=account) + self.addCleanup(dialog.close) + dialog.show() + + dialog.resize(650, 460) + QApplication.processEvents() + dialog._candidate_resize_timer.stop() + dialog._update_candidate_thumbnail_layout() + small_size = dialog.candidate_thumbnail_size + thumbnail = dialog.candidate_thumbnails[canonical] + small_pixmap = thumbnail.pixmap() + + self.assertEqual(QSize(small_size, small_size), thumbnail.size()) + self.assertIsNotNone(small_pixmap) + self.assertAlmostEqual(2.0, small_pixmap.width() / small_pixmap.height(), places=1) + + dialog.resize(1100, 700) + QApplication.processEvents() + dialog._candidate_resize_timer.stop() + dialog._update_candidate_thumbnail_layout() + large_size = dialog.candidate_thumbnail_size + large_pixmap = thumbnail.pixmap() + + self.assertGreater(large_size, small_size) + self.assertLessEqual(large_size, dialog.MAX_THUMBNAIL_SIZE) + self.assertEqual(QSize(large_size, large_size), thumbnail.size()) + self.assertAlmostEqual(2.0, large_pixmap.width() / large_pixmap.height(), places=1) + expected_width = len(dialog.candidates) * (large_size + 16) + expected_width += max(0, len(dialog.candidates) - 1) * dialog.candidate_layout.spacing() + self.assertGreaterEqual(dialog.candidate_content.minimumWidth(), expected_width) + + self.assert_removed(temp_dir) + + def test_cover_gallery_broken_candidate_keeps_failure_placeholder_after_resize(self): + with self.make_temp_dir() as temp_dir: + cfg, account, task, canonical = self._cover_gallery_task(temp_dir) + with open(canonical, "wb") as fh: + fh.write(b"not-an-image") + dialog = CoverGalleryDialog(task, cfg["image_dir"], cfg["db_path"], account=account) + self.addCleanup(dialog.close) + dialog.resize(1000, 680) + dialog._update_candidate_thumbnail_layout() + + thumbnail = dialog.candidate_thumbnails[canonical] + self.assertTrue(dialog.candidate_images[canonical].isNull()) + self.assertEqual("图片读取失败", thumbnail.text()) + self.assertEqual( + QSize(dialog.candidate_thumbnail_size, dialog.candidate_thumbnail_size), + thumbnail.size(), + ) + + self.assert_removed(temp_dir) + def test_cover_gallery_switch_task_resyncs_candidate_content_width(self): with self.make_temp_dir() as temp_dir: cfg, account, tasks, canonicals, archives = self._cover_gallery_task_set(temp_dir, count=2) @@ -4100,7 +4157,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertTrue(dialog.switch_task(1)) expected_width = ( - len(dialog.candidates) * (dialog.THUMBNAIL_SIZE + 16) + len(dialog.candidates) * (dialog.candidate_thumbnail_size + 16) + (len(dialog.candidates) - 1) * dialog.candidate_layout.spacing() ) self.assertEqual(tasks[1].id, dialog.task.id) @@ -4111,6 +4168,40 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_cover_gallery_switch_task_keeps_responsive_thumbnail_size(self): + with self.make_temp_dir() as temp_dir: + cfg, account, tasks, _canonicals, _archives = self._cover_gallery_task_set( + temp_dir, + count=2, + ) + dialog = CoverGalleryDialog( + tasks[0], + cfg["image_dir"], + cfg["db_path"], + account=account, + visible_tasks=tasks, + task_index=0, + account_by_alias={"alias-a": account}, + ) + self.addCleanup(dialog.close) + dialog.resize(1100, 700) + dialog.show() + QApplication.processEvents() + dialog._candidate_resize_timer.stop() + dialog._update_candidate_thumbnail_layout() + responsive_size = dialog.candidate_thumbnail_size + + self.assertGreater(responsive_size, dialog.THUMBNAIL_SIZE) + self.assertTrue(dialog.switch_task(1)) + dialog._candidate_resize_timer.stop() + dialog._update_candidate_thumbnail_layout() + + self.assertEqual(responsive_size, dialog.candidate_thumbnail_size) + for thumbnail in dialog.candidate_thumbnails.values(): + self.assertEqual(QSize(responsive_size, responsive_size), thumbnail.size()) + + self.assert_removed(temp_dir) + def test_cover_gallery_save_switches_current_cover(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) @@ -4265,6 +4356,59 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_original_image_dialog_switches_between_fit_and_original_size(self): + with self.make_temp_dir() as temp_dir: + image_path = self.write_test_image( + os.path.join(temp_dir, "large-cover.jpg"), + width=1200, + height=800, + ) + dialog = OriginalImageDialog(image_path) + self.addCleanup(dialog.close) + dialog.resize(600, 450) + dialog.show() + QApplication.processEvents() + dialog._resize_timer.stop() + dialog._update_image_display() + + fit_pixmap = dialog.image_label.pixmap() + viewport_size = dialog.scroll.viewport().size() + self.assertTrue(dialog.fit_to_window) + self.assertEqual("原始尺寸", dialog.display_mode_button.text()) + self.assertLessEqual(fit_pixmap.width(), viewport_size.width()) + self.assertLessEqual(fit_pixmap.height(), viewport_size.height()) + self.assertAlmostEqual(1.5, fit_pixmap.width() / fit_pixmap.height(), places=1) + + dialog.toggle_display_mode() + + original_pixmap = dialog.image_label.pixmap() + self.assertFalse(dialog.fit_to_window) + self.assertEqual("适应窗口", dialog.display_mode_button.text()) + self.assertEqual(QSize(1200, 800), original_pixmap.size()) + self.assertEqual(QSize(1200, 800), dialog.image_label.size()) + + dialog.toggle_display_mode() + + self.assertTrue(dialog.fit_to_window) + self.assertEqual("原始尺寸", dialog.display_mode_button.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_original_image_dialog_keeps_chinese_failure_placeholder(self): + with self.make_temp_dir() as temp_dir: + dialog = OriginalImageDialog(os.path.join(temp_dir, "missing.jpg")) + self.addCleanup(dialog.close) + dialog.resize(520, 400) + dialog._update_image_display() + + self.assertTrue(dialog.image.isNull()) + self.assertEqual("图片读取失败", dialog.image_label.text()) + self.assertFalse(dialog.display_mode_button.isEnabled()) + + 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)