Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 73 additions & 1 deletion src/scripto/gui_qt/history_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"))
Expand All @@ -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)
Expand Down Expand Up @@ -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()

Expand All @@ -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)
Expand Down Expand Up @@ -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))
Expand All @@ -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:
Expand All @@ -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():
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions src/scripto/i18n/en.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/scripto/i18n/zh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "外观",
Expand Down Expand Up @@ -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": "保存",
Expand Down
70 changes: 69 additions & 1 deletion tests/test_gui_qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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() == []


Expand Down Expand Up @@ -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() == []
Loading