diff --git a/qt/aqt/browser/find_and_replace.py b/qt/aqt/browser/find_and_replace.py index c82db8aa6..d6d111f58 100644 --- a/qt/aqt/browser/find_and_replace.py +++ b/qt/aqt/browser/find_and_replace.py @@ -8,6 +8,7 @@ from typing import List, Sequence import aqt from anki.notes import NoteId from aqt import AnkiQt +from aqt.operations import QueryOp from aqt.operations.note import find_and_replace from aqt.operations.tag import find_and_replace_tag from aqt.qt import * @@ -40,10 +41,11 @@ class FindAndReplaceDialog(QDialog): self.field_names: List[str] = [] # fetch field names and then show - mw.query_op( - lambda: mw.col.field_names_for_note_ids(note_ids), + QueryOp( + parent=mw, + op=lambda col: col.field_names_for_note_ids(note_ids), success=self._show, - ) + ).run_in_background() def _show(self, field_names: Sequence[str]) -> None: # add "all fields" and "tags" to the top of the list diff --git a/qt/aqt/browser/sidebar/tree.py b/qt/aqt/browser/sidebar/tree.py index a56588c2f..407faadef 100644 --- a/qt/aqt/browser/sidebar/tree.py +++ b/qt/aqt/browser/sidebar/tree.py @@ -20,6 +20,7 @@ from aqt.browser.sidebar.searchbar import SidebarSearchBar from aqt.browser.sidebar.toolbar import SidebarTool, SidebarToolbar from aqt.clayout import CardLayout from aqt.models import Models +from aqt.operations import QueryOp from aqt.operations.deck import ( remove_decks, rename_deck, @@ -163,7 +164,9 @@ class SidebarTreeView(QTreeView): # needs to be set after changing model qconnect(self.selectionModel().selectionChanged, self._on_selection_changed) - self.mw.query_op(self._root_tree, success=on_done) + QueryOp( + parent=self.browser, op=lambda _: self._root_tree(), success=on_done + ).run_in_background() def restore_current(self, current: SidebarItem) -> None: if current := self.find_item(current.has_same_id): @@ -892,7 +895,11 @@ class SidebarTreeView(QTreeView): new_name=full_name, ).run_in_background() - self.mw.query_op(lambda: self.mw.col.get_deck(deck_id), success=after_fetch) + QueryOp( + parent=self.browser, + op=lambda col: col.get_deck(deck_id), + success=after_fetch, + ).run_in_background() def delete_decks(self, _item: SidebarItem) -> None: remove_decks(parent=self, deck_ids=self._selected_decks()).run_in_background() diff --git a/qt/aqt/deckbrowser.py b/qt/aqt/deckbrowser.py index bebcbd6b6..e59f1d450 100644 --- a/qt/aqt/deckbrowser.py +++ b/qt/aqt/deckbrowser.py @@ -12,6 +12,7 @@ from anki.collection import OpChanges from anki.decks import Deck, DeckCollapseScope, DeckId, DeckTreeNode from anki.utils import intTime from aqt import AnkiQt, gui_hooks +from aqt.operations import QueryOp from aqt.operations.deck import ( add_deck_dialog, remove_decks, @@ -284,7 +285,9 @@ class DeckBrowser: parent=self.mw, deck_id=did, new_name=new_name ).run_in_background() - self.mw.query_op(lambda: self.mw.col.get_deck(did), success=prompt) + QueryOp( + parent=self.mw, op=lambda col: col.get_deck(did), success=prompt + ).run_in_background() def _options(self, did: DeckId) -> None: # select the deck first, because the dyn deck conf assumes the deck diff --git a/qt/aqt/deckdescription.py b/qt/aqt/deckdescription.py index 2209c3215..f9dd789fb 100644 --- a/qt/aqt/deckdescription.py +++ b/qt/aqt/deckdescription.py @@ -5,6 +5,7 @@ from __future__ import annotations import aqt from anki.decks import Deck +from aqt.operations import QueryOp from aqt.operations.deck import update_deck from aqt.qt import * from aqt.utils import addCloseShortcut, disable_help_button, restoreGeom, saveGeom, tr @@ -22,7 +23,11 @@ class DeckDescriptionDialog(QDialog): # set on success self.deck: Deck - mw.query_op(mw.col.decks.get_current, success=self._setup_and_show) + QueryOp( + parent=self.mw, + op=lambda col: col.decks.get_current(), + success=self._setup_and_show, + ).run_in_background() def _setup_and_show(self, deck: Deck) -> None: if deck.WhichOneof("kind") != "normal": diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index 3ce48651f..e7b4cf254 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -30,6 +30,7 @@ from anki.httpclient import HttpClient from anki.notes import DuplicateOrEmptyResult, Note from anki.utils import checksum, isLin, isWin, namedtmp from aqt import AnkiQt, colors, gui_hooks +from aqt.operations import QueryOp from aqt.operations.note import update_note from aqt.qt import * from aqt.sound import av_player @@ -496,7 +497,11 @@ $editorToolbar.then(({{ toolbar }}) => toolbar.appendGroup({{ return self._update_duplicate_display(result) - self.mw.query_op(self.note.duplicate_or_empty, success=on_done) + QueryOp( + parent=self.parentWindow, + op=lambda _: self.note.duplicate_or_empty(), + success=on_done, + ).run_in_background() checkValid = _check_and_update_duplicate_display_async diff --git a/qt/aqt/filtered_deck.py b/qt/aqt/filtered_deck.py index 25af48f80..bdb29a9a7 100644 --- a/qt/aqt/filtered_deck.py +++ b/qt/aqt/filtered_deck.py @@ -10,6 +10,7 @@ from anki.errors import SearchError from anki.lang import without_unicode_isolation from anki.scheduler import FilteredDeckForUpdate from aqt import AnkiQt, colors, gui_hooks +from aqt.operations import QueryOp from aqt.operations.scheduling import add_or_update_filtered_deck from aqt.qt import * from aqt.theme import theme_manager @@ -56,11 +57,11 @@ class FilteredDeckConfigDialog(QDialog): # set on successful query self.deck: FilteredDeckForUpdate - mw.query_op( - lambda: mw.col.sched.get_or_create_filtered_deck(deck_id=deck_id), + QueryOp( + parent=self.mw, + op=lambda col: col.sched.get_or_create_filtered_deck(deck_id=deck_id), success=self.load_deck_and_show, - failure=self.on_fetch_error, - ) + ).failure(self.on_fetch_error).run_in_background() def on_fetch_error(self, exc: Exception) -> None: showWarning(str(exc)) diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 256640528..c9a6600b3 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -695,47 +695,6 @@ class AnkiQt(QMainWindow): # Resetting state ########################################################################## - def query_op( - self, - op: Callable[[], T], - *, - success: Callable[[T], Any] = None, - failure: Optional[Callable[[Exception], Any]] = None, - ) -> None: - """Run an operation that queries the DB on a background thread. - - Intended to be used for operations that do not change collection - state. Undo status will not be changed, and `operation_did_execute` - will not fire. No progress window will be shown either. - - `operations_will|did_execute` will still fire, so the UI can defer - updates during a background task. - """ - - def wrapped_done(future: Future) -> None: - self._decrease_background_ops() - # did something go wrong? - if exception := future.exception(): - if isinstance(exception, Exception): - if failure: - failure(exception) - else: - showWarning(str(exception)) - return - else: - # BaseException like SystemExit; rethrow it - future.result() - - result = future.result() - if success: - success(result) - - self._increase_background_ops() - self.taskman.run_in_background(op, wrapped_done) - - # Resetting state - ########################################################################## - def _increase_background_ops(self) -> None: if not self._background_op_count: gui_hooks.backend_will_block() diff --git a/qt/aqt/models.py b/qt/aqt/models.py index b329b9c1d..79cdd2003 100644 --- a/qt/aqt/models.py +++ b/qt/aqt/models.py @@ -11,6 +11,7 @@ from anki.lang import without_unicode_isolation from anki.models import NotetypeDict, NotetypeId, NotetypeNameIdUseCount from anki.notes import Note from aqt import AnkiQt, gui_hooks +from aqt.operations import QueryOp from aqt.operations.notetype import ( add_notetype_legacy, remove_notetype, @@ -107,10 +108,11 @@ class Models(QDialog): maybeHideClose(box) def refresh_list(self, *ignored_args: Any) -> None: - self.mw.query_op( - self.col.models.all_use_counts, + QueryOp( + parent=self, + op=lambda col: col.models.all_use_counts(), success=self.updateModelsList, - ) + ).run_in_background() def onRename(self) -> None: nt = self.current_notetype() diff --git a/qt/aqt/operations/__init__.py b/qt/aqt/operations/__init__.py index f416f1075..2d1e9921c 100644 --- a/qt/aqt/operations/__init__.py +++ b/qt/aqt/operations/__init__.py @@ -62,7 +62,7 @@ class CollectionOp(Generic[ResultWithChanges]): """ _success: Optional[Callable[[ResultWithChanges], Any]] = None - _failure: Optional[Optional[Callable[[Exception], Any]]] = None + _failure: Optional[Callable[[Exception], Any]] = None def __init__(self, parent: QWidget, op: Callable[[Collection], ResultWithChanges]): self._parent = parent @@ -75,7 +75,7 @@ class CollectionOp(Generic[ResultWithChanges]): return self def failure( - self, failure: Optional[Optional[Callable[[Exception], Any]]] + self, failure: Optional[Callable[[Exception], Any]] ) -> CollectionOp[ResultWithChanges]: self._failure = failure return self @@ -140,3 +140,87 @@ class CollectionOp(Generic[ResultWithChanges]): # fire legacy hook so old code notices changes if mw.col.op_made_changes(changes): aqt.gui_hooks.state_did_reset() + + +T = TypeVar("T") + + +class QueryOp(Generic[T]): + """Helper to perform a non-mutating DB query on a background thread. + + - Optionally shows progress popup for the duration of the op. + - Ensures the browser doesn't try to redraw during the operation, which can lead + to a frozen UI + + Be careful not to call any UI routines in `op`, as that may crash Qt. + This includes things select .selectedCards() in the browse screen. + + `success` will be called with the return value of op(). + + If op() throws an exception, it will be shown in a popup, or + passed to `failure` if it is provided. + """ + + _failure: Optional[Callable[[Exception], Any]] = None + _progress: Union[bool, str] = False + + def __init__( + self, + *, + parent: QWidget, + op: Callable[[Collection], T], + success: Callable[[T], Any], + ): + self._parent = parent + self._op = op + self._success = success + + def failure(self, failure: Optional[Callable[[Exception], Any]]) -> QueryOp[T]: + self._failure = failure + return self + + def with_progress(self, label: Optional[str] = None) -> QueryOp[T]: + self._progress = label or True + return self + + def run_in_background(self) -> None: + from aqt import mw + + assert mw + + mw._increase_background_ops() + + def wrapped_op() -> T: + assert mw + if self._progress: + label: Optional[str] + if isinstance(self._progress, str): + label = self._progress + else: + label = None + mw.progress.start(label=label) + return self._op(mw.col) + + def wrapped_done(future: Future) -> None: + assert mw + mw._decrease_background_ops() + # did something go wrong? + if exception := future.exception(): + if isinstance(exception, Exception): + if self._failure: + self._failure(exception) + else: + showWarning(str(exception), self._parent) + return + else: + # BaseException like SystemExit; rethrow it + future.result() + + result = future.result() + try: + self._success(result) + finally: + if self._progress: + mw.progress.finish() + + mw.taskman.run_in_background(wrapped_op, wrapped_done)