diff --git a/app/gui/tabs/generate.py b/app/gui/tabs/generate.py index 1611e5c..9fd7a47 100644 --- a/app/gui/tabs/generate.py +++ b/app/gui/tabs/generate.py @@ -30,6 +30,58 @@ class _CoverThumbnailLabel(QLabel): super().mouseDoubleClickEvent(event) +class _CoverCandidateCard(QWidget): + EXTRA_WIDTH = 16 + + def __init__(self, thumbnail, radio, meta, parent=None): + super().__init__(parent) + self.thumbnail = thumbnail + self.radio = radio + self.meta = meta + self.card_layout = QVBoxLayout(self) + self.card_layout.setContentsMargins(0, 0, 0, 0) + self.card_layout.setSpacing(6) + self.card_layout.addWidget(self.thumbnail) + self.card_layout.addWidget(self.radio) + self.card_layout.addWidget(self.meta) + + @staticmethod + def _widget_height(widget, width): + height = widget.heightForWidth(width) if widget.hasHeightForWidth() else -1 + if height < 0: + height = widget.sizeHint().height() + return max(height, widget.minimumHeight(), widget.minimumSizeHint().height()) + + def set_thumbnail_size(self, size): + size = max(1, int(size)) + self.setFixedWidth(size + self.EXTRA_WIDTH) + self.thumbnail.setFixedSize(size, size) + self.sync_geometry() + + def sync_geometry(self): + margins = self.card_layout.contentsMargins() + content_width = max(1, self.width() - margins.left() - margins.right()) + widget_heights = [ + self.thumbnail.height(), + self._widget_height(self.radio, content_width), + self._widget_height(self.meta, content_width), + ] + required_height = ( + margins.top() + + margins.bottom() + + sum(widget_heights) + + max(0, len(widget_heights) - 1) * max(0, self.card_layout.spacing()) + ) + self.card_layout.invalidate() + self.setFixedHeight(required_height) + self.card_layout.activate() + self.updateGeometry() + return required_height + + def control_height(self): + return max(0, self.height() - self.thumbnail.height()) + + class OriginalImageDialog(QDialog): def __init__(self, image_path, parent=None): super().__init__(parent) @@ -190,7 +242,7 @@ class _SegmentedProgressBar(QProgressBar): class CoverGalleryDialog(QDialog): THUMBNAIL_SIZE = 180 MAX_THUMBNAIL_SIZE = 360 - CANDIDATE_TEXT_HEIGHT = 72 + CANDIDATE_CONTROL_FALLBACK_HEIGHT = 72 OLD_THUMBNAIL_SIZE = 300 def __init__( @@ -320,7 +372,11 @@ class CoverGalleryDialog(QDialog): self.candidate_layout.setSpacing(12) if self.candidates: for index, candidate_path in enumerate(self.candidates): - self.candidate_layout.addWidget(self._candidate_item(candidate_path, index)) + self.candidate_layout.addWidget( + self._candidate_item(candidate_path, index), + 0, + Qt.AlignTop, + ) self.candidate_layout.addStretch(1) else: empty_label = QLabel("暂无生成封面图片") @@ -334,17 +390,12 @@ class CoverGalleryDialog(QDialog): return panel def _candidate_item(self, candidate_path, index): - item = QWidget() - 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("图片读取失败") else: @@ -358,8 +409,7 @@ class CoverGalleryDialog(QDialog): ) ) ) - thumbnail.setFixedSize(self.candidate_thumbnail_size, self.candidate_thumbnail_size) - radio = QRadioButton(_candidate_label(candidate_path, self.current_path)) + radio = QRadioButton(_candidate_label(candidate_path, self.current_path, index)) radio.setObjectName(f"coverCandidateRadio{index}") radio.setFocusPolicy(Qt.ClickFocus) radio.toggled.connect( @@ -370,10 +420,16 @@ class CoverGalleryDialog(QDialog): meta = QLabel(_candidate_meta(candidate_path, self.current_path)) meta.setWordWrap(True) meta.setObjectName("coverCandidateMetaLabel") - layout.addWidget(thumbnail) - layout.addWidget(radio) - layout.addWidget(meta) - layout.addStretch(1) + basename = os.path.basename(str(candidate_path or "")) + file_tooltip = f"完整文件名:{basename}" + radio.setToolTip(file_tooltip) + meta.setToolTip(file_tooltip) + thumbnail.setToolTip(f"双击查看原图\n{file_tooltip}") + item = _CoverCandidateCard(thumbnail, radio, meta) + item.setObjectName("coverCandidateCard") + item.setToolTip(file_tooltip) + item.set_thumbnail_size(self.candidate_thumbnail_size) + self.candidate_items[candidate_path] = item return item def _sync_candidate_content_size(self): @@ -381,27 +437,40 @@ class CoverGalleryDialog(QDialog): content = getattr(self, "candidate_content", None) if layout is None or content is None: return + candidate_items = [ + self.candidate_items[candidate_path] + for candidate_path in self.candidates + if candidate_path in self.candidate_items + ] + for item in candidate_items: + if isinstance(item, _CoverCandidateCard): + item.sync_geometry() + layout.invalidate() layout.activate() margins = layout.contentsMargins() spacing = max(0, layout.spacing()) hint = layout.sizeHint() - if self.candidates: - candidate_count = len(self.candidates) + if candidate_items: min_width = ( margins.left() + margins.right() - + candidate_count * (self.candidate_thumbnail_size + 16) - + max(0, candidate_count - 1) * spacing + + sum(item.width() for item in candidate_items) + + max(0, len(candidate_items) - 1) * spacing + ) + min_height = ( + margins.top() + + margins.bottom() + + max(item.height() for item in candidate_items) ) else: min_width = margins.left() + margins.right() + 320 - min_height = max( - self.candidate_thumbnail_size, - hint.height(), - ) - size = QSize(max(min_width, hint.width()), min_height) + min_height = max(self.THUMBNAIL_SIZE, hint.height()) + size = QSize(max(min_width, hint.width()), max(1, min_height)) content.setMinimumSize(size) content.resize(size) + layout.invalidate() + layout.setGeometry(content.rect()) + layout.activate() content.updateGeometry() scroll = getattr(self, "candidate_scroll", None) if scroll is not None: @@ -425,30 +494,38 @@ class CoverGalleryDialog(QDialog): width_budget = ( viewport_width - max(0, visible_count - 1) * spacing - - visible_count * 16 + - visible_count * _CoverCandidateCard.EXTRA_WIDTH ) // visible_count - height_budget = viewport_height - self.CANDIDATE_TEXT_HEIGHT + control_heights = [ + item.control_height() + for item in self.candidate_items.values() + if isinstance(item, _CoverCandidateCard) + ] + control_height = max( + control_heights, + default=self.CANDIDATE_CONTROL_FALLBACK_HEIGHT, + ) + height_budget = viewport_height - control_height target = min(width_budget, height_budget, self.MAX_THUMBNAIL_SIZE) return max(self.THUMBNAIL_SIZE, int(target)) - def _update_candidate_thumbnail_layout(self): + def _update_candidate_thumbnail_layout(self, force=False): 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 + size_changed = target_size != self.candidate_thumbnail_size 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 isinstance(item, _CoverCandidateCard): + item.set_thumbnail_size(target_size) if thumbnail is None: continue - thumbnail.setFixedSize(target_size, target_size) + if not size_changed and not force: + continue thumbnail.clear() if image is None or image.isNull(): thumbnail.setText("图片读取失败") @@ -743,7 +820,11 @@ class CoverGalleryDialog(QDialog): self.button_group.setExclusive(True) if self.candidates: for index, candidate_path in enumerate(self.candidates): - self.candidate_layout.addWidget(self._candidate_item(candidate_path, index)) + self.candidate_layout.addWidget( + self._candidate_item(candidate_path, index), + 0, + Qt.AlignTop, + ) self.candidate_layout.addStretch(1) else: empty_label = QLabel("暂无生成封面图片") @@ -751,7 +832,9 @@ class CoverGalleryDialog(QDialog): empty_label.setMinimumSize(320, self.THUMBNAIL_SIZE) empty_label.setAlignment(Qt.AlignCenter) self.candidate_layout.addWidget(empty_label) - self._sync_candidate_content_size() + self._candidate_resize_timer.stop() + self._update_candidate_thumbnail_layout(force=True) + self.candidate_scroll.horizontalScrollBar().setValue(0) self._candidate_resize_timer.start() self._install_navigation_event_filters() @@ -805,8 +888,8 @@ def _set_image_label(label, image_path, size, empty_text): ) -def _candidate_label(candidate_path, current_path): - return "当前生效" if _same_file(candidate_path, current_path) else os.path.basename(candidate_path) +def _candidate_label(candidate_path, current_path, index): + return "当前生效" if _same_file(candidate_path, current_path) else f"候选 {int(index) + 1}" def _candidate_meta(candidate_path, current_path): diff --git a/docs/tasks/T-629.md b/docs/tasks/T-629.md index 8b308ca..8f0a8ca 100644 --- a/docs/tasks/T-629.md +++ b/docs/tasks/T-629.md @@ -3,7 +3,7 @@ id: T-629 title: ②封面画廊切换记录后候选勾选区自适应布局修复 phase: 7 deps: [T-626] -status: TODO +status: DONE created: 2026-07-14 --- @@ -88,4 +88,10 @@ created: 2026-07-14 ## 执行记录 -- 待实现。 +- 2026-07-14:完成 T-629。 + - 复现确认:从单候选切换到 6 张候选时,旧实现把候选内容高度暂时设为图片高度 `360px`,但完整卡片实际需要 `402px`,导致勾选框被压到图片内部;延迟 resize timer 执行后才可能恢复。 + - `app/gui/tabs/generate.py`:新增内部候选卡片组件,统一按图片、勾选框、说明文字、margin 和 spacing 的实际尺寸计算固定卡片高度;内容容器按所有卡片真实宽高同步尺寸,候选卡片顶部对齐。 + - 任务切换和候选重建后立即按新候选数量及当前 viewport 强制重算图片与完整卡片尺寸,不再依赖约 `60ms` timer 才获得正确布局;切换记录时同时重置横向滚动位置,窗口连续 resize 仍保留合并重绘。 + - 非当前候选的可见名称改为「候选 N」,完整文件名保留在中文 tooltip,避免长文件名干扰尺寸;损坏图片继续使用固定图片区和「图片读取失败」占位。 + - `tests/test_gui.py`:新增真实 `↓` 键切换回归测试,覆盖单候选到多候选、长文件名、损坏图片、切换同步阶段、Qt 事件处理后、横向滚动复位和往返切换;逐卡断言图片、勾选框、说明文字及横向卡片几何不重叠。 + - 当前工作区运行 `tests.test_gui` 时,仅有任务开始前已存在的默认封面提示词 `papa1.txt` 改名造成的 2 项旧断言失败;未回退用户改动。在只包含 T-629 三个文件的隔离 worktree 中验证通过:`py -3.10 -m unittest tests.test_gui`(182 项)、`py -3.10 -m unittest discover -s tests`(462 项)、`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 d9189f1..e62816b 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -181,6 +181,38 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertTrue(image.save(path)) return path + def assert_cover_candidate_cards_do_not_overlap(self, dialog, check_horizontal=True): + previous_right = None + maximum_card_height = 0 + for candidate_path in dialog.candidates: + card = dialog.candidate_items[candidate_path] + layout = card.layout() + thumbnail = layout.itemAt(0).widget() + radio = layout.itemAt(1).widget() + meta = layout.itemAt(2).widget() + spacing = max(0, layout.spacing()) + + self.assertGreaterEqual( + radio.geometry().top(), + thumbnail.geometry().bottom() + 1 + spacing, + ) + self.assertGreaterEqual( + meta.geometry().top(), + radio.geometry().bottom() + 1 + spacing, + ) + self.assertGreaterEqual(card.minimumHeight(), layout.sizeHint().height()) + self.assertGreaterEqual(card.height(), meta.geometry().bottom() + 1) + if check_horizontal and previous_right is not None: + self.assertGreaterEqual( + card.geometry().left(), + previous_right + 1 + dialog.candidate_layout.spacing(), + ) + previous_right = card.geometry().right() + maximum_card_height = max(maximum_card_height, card.height()) + + self.assertGreaterEqual(dialog.candidate_content.minimumHeight(), maximum_card_height) + self.assertGreaterEqual(dialog.candidate_content.height(), maximum_card_height) + def _cover_gallery_task(self, temp_dir, generate_cover=True): cfg = self.make_config(temp_dir) cfg.setdefault("ai", {})["generate_cover"] = bool(generate_cover) @@ -4205,6 +4237,77 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_cover_gallery_arrow_switch_syncs_full_candidate_card_geometry(self): + with self.make_temp_dir() as temp_dir: + cfg, account, tasks, canonicals, archives = self._cover_gallery_task_set( + temp_dir, + count=2, + ) + os.remove(archives[0]) + second_prefix = os.path.splitext(canonicals[1])[0] + long_candidate = self.write_test_image( + f"{second_prefix}_20260709101100_候选图片文件名很长用于验证自适应布局.jpg" + ) + self.write_test_image(f"{second_prefix}_20260709101200.jpg") + broken_candidate = f"{second_prefix}_20260709101300.jpg" + with open(broken_candidate, "wb") as file_handle: + file_handle.write(b"not-an-image") + + 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() + initial_thumbnail_size = dialog.candidate_thumbnail_size + dialog.next_button.setFocus() + down_event = QKeyEvent(QKeyEvent.KeyPress, gui.Qt.Key_Down, gui.Qt.NoModifier) + + QApplication.sendEvent(dialog.next_button, down_event) + + self.assertTrue(down_event.isAccepted()) + self.assertEqual(tasks[1].id, dialog.task.id) + self.assertGreaterEqual(len(dialog.candidates), 5) + self.assertLess(dialog.candidate_thumbnail_size, initial_thumbnail_size) + self.assertEqual(0, dialog.candidate_scroll.horizontalScrollBar().value()) + self.assert_cover_candidate_cards_do_not_overlap(dialog, check_horizontal=False) + self.assertEqual("图片读取失败", dialog.candidate_thumbnails[broken_candidate].text()) + self.assertNotEqual( + os.path.basename(long_candidate), + dialog.candidate_buttons[long_candidate].text(), + ) + self.assertIn( + os.path.basename(long_candidate), + dialog.candidate_buttons[long_candidate].toolTip(), + ) + + QApplication.processEvents() + dialog._candidate_resize_timer.stop() + dialog._update_candidate_thumbnail_layout() + QApplication.processEvents() + self.assert_cover_candidate_cards_do_not_overlap(dialog) + + horizontal_bar = dialog.candidate_scroll.horizontalScrollBar() + self.assertGreater(horizontal_bar.maximum(), 0) + horizontal_bar.setValue(horizontal_bar.maximum()) + self.assertGreater(horizontal_bar.value(), 0) + self.assertTrue(dialog.switch_task(-1)) + self.assertEqual(0, horizontal_bar.value()) + self.assert_cover_candidate_cards_do_not_overlap(dialog, check_horizontal=False) + QApplication.processEvents() + self.assert_cover_candidate_cards_do_not_overlap(dialog) + + 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)