diff --git a/src/scripto/gui_qt/history_page.py b/src/scripto/gui_qt/history_page.py index 07d45a4..149f0e3 100644 --- a/src/scripto/gui_qt/history_page.py +++ b/src/scripto/gui_qt/history_page.py @@ -7,12 +7,14 @@ from html import escape from pathlib import Path +from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QCheckBox, QDialog, QHBoxLayout, QLabel, QMenu, + QMessageBox, QPlainTextEdit, QProgressBar, QPushButton, @@ -40,6 +42,18 @@ def __init__(self, window): def _build(self) -> None: t = self.t self._selected: set[str] = set() + self._checkboxes: dict[str, QCheckBox] = {} + # Set while select-all drives every row, so the toolbar is rebuilt + # once at the end instead of once per card. + self._bulk_selecting = False + + # `clicked` and not `toggled`: it fires only on a real click, so the + # programmatic state sync below can't loop back into here. + self.select_all = QCheckBox(t("gui.select_all")) + self.select_all.setTristate(True) + self.select_all.clicked.connect(self._toggle_all) + self.select_all.hide() + refresh_btn = QPushButton(t("gui.history_refresh")) refresh_btn.clicked.connect(self.refresh) clean_btn = QPushButton(t("gui.history_clean")) @@ -62,6 +76,7 @@ def _build(self) -> None: top = QHBoxLayout() top.setSpacing(8) + top.addWidget(self.select_all) top.addWidget(refresh_btn) top.addWidget(clean_btn) top.addWidget(self.translate_selected_btn) @@ -107,6 +122,7 @@ def refresh(self) -> None: t = self.t clear_layout(self.list_box, keep_tail=1) self._selected.clear() + self._checkboxes.clear() self._badges.clear() self._sync_delete_button() @@ -127,6 +143,7 @@ def refresh(self) -> None: select.toggled.connect( lambda on, s=group.source: self._toggle_selected(s, on) ) + self._checkboxes[group.source] = select row.addWidget(select) name = ElidedLabel(group.name) @@ -182,6 +199,10 @@ def refresh(self) -> None: self.list_box.insertWidget(self.list_box.count() - 1, frame) + # Again now that the cards exist: the sync above ran against an empty + # page and left select-all hidden. + self._sync_delete_button() + def _clean(self) -> None: removed = self.vm.history_clean_missing() self.window_ref.toast(self.t("gui.history_cleaned", n=removed)) @@ -194,10 +215,23 @@ def _toggle_selected(self, source: str, on: bool) -> None: self._selected.add(source) else: self._selected.discard(source) + if not self._bulk_selecting: + self._sync_delete_button() + + def _toggle_all(self) -> None: + """Select every row, or clear the selection once everything is on.""" + target = len(self._selected) < len(self._checkboxes) + self._bulk_selecting = True + try: + for box in self._checkboxes.values(): + box.setChecked(target) + finally: + self._bulk_selecting = False self._sync_delete_button() def _sync_delete_button(self) -> None: count = len(self._selected) + total = len(self._checkboxes) self.delete_selected_btn.setVisible(count > 0) self.translate_selected_btn.setVisible(count > 0) if count: @@ -208,6 +242,18 @@ def _sync_delete_button(self) -> None: self.t("gui.translate_selected", n=count) ) + self.select_all.setVisible(total > 0) + state = Qt.CheckState.Unchecked + if count and count == total: + state = Qt.CheckState.Checked + elif count: + state = Qt.CheckState.PartiallyChecked + self.select_all.setCheckState(state) # programmatic: emits no `clicked` + self.select_all.setText( + self.t("gui.select_all_n", n=count, total=total) if count + else self.t("gui.select_all") + ) + def _translate_selected(self, target: str) -> None: queued = 0 for group in self.vm.history_groups(): @@ -278,7 +324,33 @@ def tick_translations(self) -> None: self.refresh() def _delete_selected(self) -> None: - self._delete_sources(set(self._selected)) + sources = set(self._selected) + if len(sources) > 1 and not self._confirm_delete(len(sources)): + return + self._delete_sources(sources) + + def _confirm_delete(self, count: int) -> bool: + """Ask before a bulk delete — select-all makes it two clicks. + + Records only; the subtitles on disk are untouched. But the index + itself has no undo, so wiping a few hundred rows by accident is + worth one dialog. Buttons carry our own labels because Qt's + standard ones follow the system locale, not the app's language. + """ + box = QMessageBox(self) + box.setIcon(QMessageBox.Icon.Warning) + box.setWindowTitle(self.t("gui.history_delete")) + box.setText(self.t("gui.history_delete_confirm", n=count)) + box.setInformativeText(self.t("gui.history_delete_confirm_note")) + delete = box.addButton( + self.t("gui.history_delete"), QMessageBox.ButtonRole.DestructiveRole + ) + cancel = box.addButton( + self.t("gui.cancel"), QMessageBox.ButtonRole.RejectRole + ) + box.setDefaultButton(cancel) + box.exec() + return box.clickedButton() is delete def _delete_sources(self, sources: set[str]) -> None: removed = self.vm.history_delete_sources(sources) diff --git a/src/scripto/i18n/en.py b/src/scripto/i18n/en.py index cb8f662..fcda9c3 100644 --- a/src/scripto/i18n/en.py +++ b/src/scripto/i18n/en.py @@ -86,6 +86,8 @@ "gui.history_deleted": "deleted", "gui.history_clean": "Clean up missing", "gui.history_cleaned": "Removed {n} stale entries.", + "gui.select_all": "Select all", + "gui.select_all_n": "{n} of {total} selected", "gui.history_refresh": "Refresh", "gui.settings_language": "Interface language", "gui.settings_theme": "Appearance", @@ -166,6 +168,8 @@ "gui.history_view": "View", "gui.history_delete": "Delete record", "gui.history_delete_selected": "Delete selected ({n})", + "gui.history_delete_confirm": "Delete {n} selected records?", + "gui.history_delete_confirm_note": "Records only — the subtitle files on disk are untouched. This cannot be undone.", "gui.history_deleted_n": "Removed {n} record(s) — output files were not touched.", "gui.edit": "Edit", "gui.save": "Save", diff --git a/src/scripto/i18n/zh.py b/src/scripto/i18n/zh.py index 06f8dfb..d470ed2 100644 --- a/src/scripto/i18n/zh.py +++ b/src/scripto/i18n/zh.py @@ -86,6 +86,8 @@ "gui.history_deleted": "已被删除", "gui.history_clean": "清理失效条目", "gui.history_cleaned": "已清理 {n} 条失效记录。", + "gui.select_all": "全选", + "gui.select_all_n": "已选 {n} / {total}", "gui.history_refresh": "刷新", "gui.settings_language": "界面语言", "gui.settings_theme": "外观", @@ -166,6 +168,8 @@ "gui.history_view": "查看", "gui.history_delete": "删除记录", "gui.history_delete_selected": "删除选中({n})", + "gui.history_delete_confirm": "删除选中的 {n} 条记录?", + "gui.history_delete_confirm_note": "只删除历史记录,磁盘上的字幕文件不受影响。此操作无法撤销。", "gui.history_deleted_n": "已删除 {n} 条记录——磁盘上的输出文件未受影响。", "gui.edit": "编辑", "gui.save": "保存", diff --git a/tests/test_gui_qt.py b/tests/test_gui_qt.py index 672310b..bf7d630 100644 --- a/tests/test_gui_qt.py +++ b/tests/test_gui_qt.py @@ -185,7 +185,8 @@ def test_history_delete_single_and_batch(tmp_path, qapp): page._toggle_selected(b, True) page._toggle_selected(c, True) assert page.delete_selected_btn.isVisibleTo(page) - page._delete_selected() # batch + page._confirm_delete = lambda _n: True # the batch path asks first + page._delete_selected() assert window.vm.history_groups() == [] @@ -262,3 +263,70 @@ def test_failure_text_is_localized_when_core_supplied_a_key(tmp_path, qapp): broken = FileRow(id=3, path=tmp_path / "clip.mp4", status="failed", error="fallback text", error_key="errors.icloud_timeout") assert page.error_text(broken) == "fallback text" + + +def test_history_select_all_toggles_every_card(tmp_path, qapp): + from PySide6.QtCore import Qt + + window = make_window(tmp_path, qapp) + sources = [_seed_history(tmp_path, window.vm, s) for s in ("a", "b", "c")] + page = window.history_page + page.refresh() + + assert page.select_all.isVisibleTo(page) + assert page.select_all.checkState() == Qt.CheckState.Unchecked + assert not page.delete_selected_btn.isVisibleTo(page) + + page._toggle_all() # what a click on it does + assert page._selected == set(sources) + assert all(box.isChecked() for box in page._checkboxes.values()) + assert page.select_all.checkState() == Qt.CheckState.Checked + assert "3" in page.delete_selected_btn.text() + + page._toggle_all() # clicking again clears + assert page._selected == set() + assert page.select_all.checkState() == Qt.CheckState.Unchecked + assert not page.delete_selected_btn.isVisibleTo(page) + + # One card on its own leaves the master box in between. + page._checkboxes[sources[0]].setChecked(True) + assert page.select_all.checkState() == Qt.CheckState.PartiallyChecked + # ...and select-all from a partial state selects the rest, not none. + page._toggle_all() + assert page._selected == set(sources) + + +def test_history_select_all_hides_when_there_is_nothing_to_select(tmp_path, qapp): + window = make_window(tmp_path, qapp) + page = window.history_page + page.refresh() + assert not page.select_all.isVisibleTo(page) + + +def test_bulk_history_delete_asks_first(tmp_path, qapp): + window = make_window(tmp_path, qapp) + sources = [_seed_history(tmp_path, window.vm, s) for s in ("a", "b", "c")] + page = window.history_page + page.refresh() + + asked = [] + page._confirm_delete = lambda n: asked.append(n) or False # user cancels + page._toggle_all() + page._delete_selected() + assert asked == [3] + assert len(window.vm.history_groups()) == 3 # nothing was deleted + + page._confirm_delete = lambda n: asked.append(n) or True # user confirms + page._delete_selected() + assert asked == [3, 3] + assert window.vm.history_groups() == [] + + # A single row keeps the old one-click behaviour — no dialog. + _seed_history(tmp_path, window.vm, "d") + page.refresh() + page._confirm_delete = lambda n: asked.append(n) or False + page._delete_sources({sources[0]}) + page._checkboxes and page._checkboxes[list(page._checkboxes)[0]].setChecked(True) + page._delete_selected() + assert asked == [3, 3] # never asked again + assert window.vm.history_groups() == []