fix(gui): make cover gallery previews responsive

This commit is contained in:
chengma
2026-07-14 08:13:56 +08:00
parent 3217d4b11a
commit fb4403228d
3 changed files with 308 additions and 27 deletions
+154 -23
View File
@@ -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)
+8 -2
View File
@@ -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`。
+146 -2
View File
@@ -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)