move query_op into operations/, and add the ability to show progress

This commit is contained in:
Damien Elmes 2021-05-08 16:20:10 +10:00
parent b887032244
commit f2db822c08
9 changed files with 126 additions and 58 deletions

View File

@ -8,6 +8,7 @@ from typing import List, Sequence
import aqt import aqt
from anki.notes import NoteId from anki.notes import NoteId
from aqt import AnkiQt from aqt import AnkiQt
from aqt.operations import QueryOp
from aqt.operations.note import find_and_replace from aqt.operations.note import find_and_replace
from aqt.operations.tag import find_and_replace_tag from aqt.operations.tag import find_and_replace_tag
from aqt.qt import * from aqt.qt import *
@ -40,10 +41,11 @@ class FindAndReplaceDialog(QDialog):
self.field_names: List[str] = [] self.field_names: List[str] = []
# fetch field names and then show # fetch field names and then show
mw.query_op( QueryOp(
lambda: mw.col.field_names_for_note_ids(note_ids), parent=mw,
op=lambda col: col.field_names_for_note_ids(note_ids),
success=self._show, success=self._show,
) ).run_in_background()
def _show(self, field_names: Sequence[str]) -> None: def _show(self, field_names: Sequence[str]) -> None:
# add "all fields" and "tags" to the top of the list # add "all fields" and "tags" to the top of the list

View File

@ -20,6 +20,7 @@ from aqt.browser.sidebar.searchbar import SidebarSearchBar
from aqt.browser.sidebar.toolbar import SidebarTool, SidebarToolbar from aqt.browser.sidebar.toolbar import SidebarTool, SidebarToolbar
from aqt.clayout import CardLayout from aqt.clayout import CardLayout
from aqt.models import Models from aqt.models import Models
from aqt.operations import QueryOp
from aqt.operations.deck import ( from aqt.operations.deck import (
remove_decks, remove_decks,
rename_deck, rename_deck,
@ -163,7 +164,9 @@ class SidebarTreeView(QTreeView):
# needs to be set after changing model # needs to be set after changing model
qconnect(self.selectionModel().selectionChanged, self._on_selection_changed) 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: def restore_current(self, current: SidebarItem) -> None:
if current := self.find_item(current.has_same_id): if current := self.find_item(current.has_same_id):
@ -892,7 +895,11 @@ class SidebarTreeView(QTreeView):
new_name=full_name, new_name=full_name,
).run_in_background() ).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: def delete_decks(self, _item: SidebarItem) -> None:
remove_decks(parent=self, deck_ids=self._selected_decks()).run_in_background() remove_decks(parent=self, deck_ids=self._selected_decks()).run_in_background()

View File

@ -12,6 +12,7 @@ from anki.collection import OpChanges
from anki.decks import Deck, DeckCollapseScope, DeckId, DeckTreeNode from anki.decks import Deck, DeckCollapseScope, DeckId, DeckTreeNode
from anki.utils import intTime from anki.utils import intTime
from aqt import AnkiQt, gui_hooks from aqt import AnkiQt, gui_hooks
from aqt.operations import QueryOp
from aqt.operations.deck import ( from aqt.operations.deck import (
add_deck_dialog, add_deck_dialog,
remove_decks, remove_decks,
@ -284,7 +285,9 @@ class DeckBrowser:
parent=self.mw, deck_id=did, new_name=new_name parent=self.mw, deck_id=did, new_name=new_name
).run_in_background() ).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: def _options(self, did: DeckId) -> None:
# select the deck first, because the dyn deck conf assumes the deck # select the deck first, because the dyn deck conf assumes the deck

View File

@ -5,6 +5,7 @@ from __future__ import annotations
import aqt import aqt
from anki.decks import Deck from anki.decks import Deck
from aqt.operations import QueryOp
from aqt.operations.deck import update_deck from aqt.operations.deck import update_deck
from aqt.qt import * from aqt.qt import *
from aqt.utils import addCloseShortcut, disable_help_button, restoreGeom, saveGeom, tr from aqt.utils import addCloseShortcut, disable_help_button, restoreGeom, saveGeom, tr
@ -22,7 +23,11 @@ class DeckDescriptionDialog(QDialog):
# set on success # set on success
self.deck: Deck 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: def _setup_and_show(self, deck: Deck) -> None:
if deck.WhichOneof("kind") != "normal": if deck.WhichOneof("kind") != "normal":

View File

@ -30,6 +30,7 @@ from anki.httpclient import HttpClient
from anki.notes import DuplicateOrEmptyResult, Note from anki.notes import DuplicateOrEmptyResult, Note
from anki.utils import checksum, isLin, isWin, namedtmp from anki.utils import checksum, isLin, isWin, namedtmp
from aqt import AnkiQt, colors, gui_hooks from aqt import AnkiQt, colors, gui_hooks
from aqt.operations import QueryOp
from aqt.operations.note import update_note from aqt.operations.note import update_note
from aqt.qt import * from aqt.qt import *
from aqt.sound import av_player from aqt.sound import av_player
@ -496,7 +497,11 @@ $editorToolbar.then(({{ toolbar }}) => toolbar.appendGroup({{
return return
self._update_duplicate_display(result) 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 checkValid = _check_and_update_duplicate_display_async

View File

@ -10,6 +10,7 @@ from anki.errors import SearchError
from anki.lang import without_unicode_isolation from anki.lang import without_unicode_isolation
from anki.scheduler import FilteredDeckForUpdate from anki.scheduler import FilteredDeckForUpdate
from aqt import AnkiQt, colors, gui_hooks from aqt import AnkiQt, colors, gui_hooks
from aqt.operations import QueryOp
from aqt.operations.scheduling import add_or_update_filtered_deck from aqt.operations.scheduling import add_or_update_filtered_deck
from aqt.qt import * from aqt.qt import *
from aqt.theme import theme_manager from aqt.theme import theme_manager
@ -56,11 +57,11 @@ class FilteredDeckConfigDialog(QDialog):
# set on successful query # set on successful query
self.deck: FilteredDeckForUpdate self.deck: FilteredDeckForUpdate
mw.query_op( QueryOp(
lambda: mw.col.sched.get_or_create_filtered_deck(deck_id=deck_id), parent=self.mw,
op=lambda col: col.sched.get_or_create_filtered_deck(deck_id=deck_id),
success=self.load_deck_and_show, 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: def on_fetch_error(self, exc: Exception) -> None:
showWarning(str(exc)) showWarning(str(exc))

View File

@ -695,47 +695,6 @@ class AnkiQt(QMainWindow):
# Resetting state # 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: def _increase_background_ops(self) -> None:
if not self._background_op_count: if not self._background_op_count:
gui_hooks.backend_will_block() gui_hooks.backend_will_block()

View File

@ -11,6 +11,7 @@ from anki.lang import without_unicode_isolation
from anki.models import NotetypeDict, NotetypeId, NotetypeNameIdUseCount from anki.models import NotetypeDict, NotetypeId, NotetypeNameIdUseCount
from anki.notes import Note from anki.notes import Note
from aqt import AnkiQt, gui_hooks from aqt import AnkiQt, gui_hooks
from aqt.operations import QueryOp
from aqt.operations.notetype import ( from aqt.operations.notetype import (
add_notetype_legacy, add_notetype_legacy,
remove_notetype, remove_notetype,
@ -107,10 +108,11 @@ class Models(QDialog):
maybeHideClose(box) maybeHideClose(box)
def refresh_list(self, *ignored_args: Any) -> None: def refresh_list(self, *ignored_args: Any) -> None:
self.mw.query_op( QueryOp(
self.col.models.all_use_counts, parent=self,
op=lambda col: col.models.all_use_counts(),
success=self.updateModelsList, success=self.updateModelsList,
) ).run_in_background()
def onRename(self) -> None: def onRename(self) -> None:
nt = self.current_notetype() nt = self.current_notetype()

View File

@ -62,7 +62,7 @@ class CollectionOp(Generic[ResultWithChanges]):
""" """
_success: Optional[Callable[[ResultWithChanges], Any]] = None _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]): def __init__(self, parent: QWidget, op: Callable[[Collection], ResultWithChanges]):
self._parent = parent self._parent = parent
@ -75,7 +75,7 @@ class CollectionOp(Generic[ResultWithChanges]):
return self return self
def failure( def failure(
self, failure: Optional[Optional[Callable[[Exception], Any]]] self, failure: Optional[Callable[[Exception], Any]]
) -> CollectionOp[ResultWithChanges]: ) -> CollectionOp[ResultWithChanges]:
self._failure = failure self._failure = failure
return self return self
@ -140,3 +140,87 @@ class CollectionOp(Generic[ResultWithChanges]):
# fire legacy hook so old code notices changes # fire legacy hook so old code notices changes
if mw.col.op_made_changes(changes): if mw.col.op_made_changes(changes):
aqt.gui_hooks.state_did_reset() 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)