start migrating perform_op() into builder in separate file

By passing back the builder to the calling code to run, we don't need
to plumb extra arguments like success= and handler= through each
operation, and the ability to override the default tooltip behaviour
comes free on all operations
This commit is contained in:
Damien Elmes 2021-04-06 12:47:55 +10:00
parent 1ece868d02
commit b8fc195cdf
7 changed files with 196 additions and 103 deletions

View File

@ -695,13 +695,8 @@ where id in %s"""
if not (tags := tags or self._prompt_for_tags(tr.browsing_enter_tags_to_add())): if not (tags := tags or self._prompt_for_tags(tr.browsing_enter_tags_to_add())):
return return
add_tags_to_notes( add_tags_to_notes(
mw=self.mw, parent=self, note_ids=self.selected_notes(), space_separated_tags=tags
note_ids=self.selected_notes(), ).run(handler=self)
space_separated_tags=tags,
success=lambda out: tooltip(
tr.browsing_notes_updated(count=out.count), parent=self
),
)
@ensure_editor_saved_on_trigger @ensure_editor_saved_on_trigger
def remove_tags_from_selected_notes(self, tags: Optional[str] = None) -> None: def remove_tags_from_selected_notes(self, tags: Optional[str] = None) -> None:
@ -710,14 +705,10 @@ where id in %s"""
tags := tags or self._prompt_for_tags(tr.browsing_enter_tags_to_delete()) tags := tags or self._prompt_for_tags(tr.browsing_enter_tags_to_delete())
): ):
return return
remove_tags_from_notes( remove_tags_from_notes(
mw=self.mw, parent=self, note_ids=self.selected_notes(), space_separated_tags=tags
note_ids=self.selected_notes(), ).run(handler=self)
space_separated_tags=tags,
success=lambda out: tooltip(
tr.browsing_notes_updated(count=out.count), parent=self
),
)
def _prompt_for_tags(self, prompt: str) -> Optional[str]: def _prompt_for_tags(self, prompt: str) -> Optional[str]:
(tags, ok) = getTag(self, self.col, prompt) (tags, ok) = getTag(self, self.col, prompt)
@ -728,7 +719,7 @@ where id in %s"""
@ensure_editor_saved_on_trigger @ensure_editor_saved_on_trigger
def clear_unused_tags(self) -> None: def clear_unused_tags(self) -> None:
clear_unused_tags(mw=self.mw, parent=self) clear_unused_tags(parent=self).run()
addTags = add_tags_to_selected_notes addTags = add_tags_to_selected_notes
deleteTags = remove_tags_from_selected_notes deleteTags = remove_tags_from_selected_notes

View File

@ -61,6 +61,11 @@ from aqt.emptycards import show_empty_cards
from aqt.legacy import install_pylib_legacy from aqt.legacy import install_pylib_legacy
from aqt.mediacheck import check_media_db from aqt.mediacheck import check_media_db
from aqt.mediasync import MediaSyncer from aqt.mediasync import MediaSyncer
from aqt.operations import (
CollectionOpFailureCallback,
CollectionOpSuccessCallback,
ResultWithChanges,
)
from aqt.operations.collection import undo from aqt.operations.collection import undo
from aqt.profiles import ProfileManager as ProfileManagerType from aqt.profiles import ProfileManager as ProfileManagerType
from aqt.qt import * from aqt.qt import *
@ -91,30 +96,6 @@ from aqt.utils import (
tr, tr,
) )
class HasChangesProperty(Protocol):
changes: OpChanges
# either an OpChanges object, or an object with .changes on it. This bound
# doesn't actually work for protobuf objects, so new protobuf objects will
# either need to be added here, or cast at call time
ResultWithChanges = TypeVar(
"ResultWithChanges",
bound=Union[
OpChanges,
OpChangesWithCount,
OpChangesWithId,
OpChangesAfterUndo,
HasChangesProperty,
],
)
T = TypeVar("T")
PerformOpOptionalSuccessCallback = Optional[Callable[[ResultWithChanges], Any]]
PerformOpOptionalFailureCallback = Optional[Callable[[Exception], Any]]
install_pylib_legacy() install_pylib_legacy()
MainWindowState = Literal[ MainWindowState = Literal[
@ -122,6 +103,12 @@ MainWindowState = Literal[
] ]
T = TypeVar("T")
PerformOpOptionalSuccessCallback = Optional[CollectionOpSuccessCallback]
PerformOpOptionalFailureCallback = Optional[CollectionOpFailureCallback]
class AnkiQt(QMainWindow): class AnkiQt(QMainWindow):
col: Collection col: Collection
pm: ProfileManagerType pm: ProfileManagerType

View File

@ -1,2 +1,133 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
from concurrent.futures._base import Future
from typing import Any, Callable, Generic, Optional, Protocol, TypeVar, Union
import aqt
from anki.collection import (
Collection,
OpChanges,
OpChangesAfterUndo,
OpChangesWithCount,
OpChangesWithId,
)
from aqt.qt import QWidget
from aqt.utils import showWarning
class HasChangesProperty(Protocol):
changes: OpChanges
# either an OpChanges object, or an object with .changes on it. This bound
# doesn't actually work for protobuf objects, so new protobuf objects will
# either need to be added here, or cast at call time
ResultWithChanges = TypeVar(
"ResultWithChanges",
bound=Union[
OpChanges,
OpChangesWithCount,
OpChangesWithId,
OpChangesAfterUndo,
HasChangesProperty,
],
)
T = TypeVar("T")
CollectionOpSuccessCallback = Callable[[ResultWithChanges], Any]
CollectionOpFailureCallback = Optional[Callable[[Exception], Any]]
class CollectionOp(Generic[ResultWithChanges]):
"""Helper to perform a mutating DB operation on a background thread, and update UI.
`op` should either return OpChanges, or an object with a 'changes'
property. The changes will be passed to `operation_did_execute` so that
the UI can decide whether it needs to update itself.
- 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
- Updates undo state at the end of the operation
- Commits changes
- Fires the `operation_(will|did)_reset` hooks
- Fires the legacy `state_did_reset` hook
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.
"""
_success: Optional[CollectionOpSuccessCallback] = None
_failure: Optional[CollectionOpFailureCallback] = None
def __init__(self, parent: QWidget, op: Callable[[Collection], ResultWithChanges]):
self._parent = parent
self._op = op
def success(self, success: Optional[CollectionOpSuccessCallback]) -> CollectionOp:
self._success = success
return self
def failure(self, failure: Optional[CollectionOpFailureCallback]) -> CollectionOp:
self._failure = failure
return self
def run(self, *, handler: Optional[object] = None) -> None:
aqt.mw._increase_background_ops()
def wrapped_op() -> ResultWithChanges:
return self._op(aqt.mw.col)
def wrapped_done(future: Future) -> None:
aqt.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:
if self._success:
self._success(result)
finally:
# update undo status
status = aqt.mw.col.undo_status()
aqt.mw._update_undo_actions_for_status_and_save(status)
# fire change hooks
self._fire_change_hooks_after_op_performed(result, handler)
aqt.mw.taskman.with_progress(wrapped_op, wrapped_done)
def _fire_change_hooks_after_op_performed(
self,
result: ResultWithChanges,
handler: Optional[object],
) -> None:
if isinstance(result, OpChanges):
changes = result
else:
changes = result.changes
# fire new hook
print("op changes:")
print(changes)
aqt.gui_hooks.operation_did_execute(changes, handler)
# fire legacy hook so old code notices changes
if aqt.mw.col.op_made_changes(changes):
aqt.gui_hooks.state_did_reset()

View File

@ -3,94 +3,88 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional, Sequence from typing import Sequence
from anki.collection import OpChangesWithCount from anki.collection import OpChangesWithCount
from anki.notes import NoteId from anki.notes import NoteId
from aqt import AnkiQt, QWidget from aqt import AnkiQt, QWidget
from aqt.main import PerformOpOptionalSuccessCallback from aqt.operations import CollectionOp
from aqt.utils import showInfo, tooltip, tr from aqt.utils import showInfo, tooltip, tr
def add_tags_to_notes( def add_tags_to_notes(
*, *,
mw: AnkiQt, parent: QWidget,
note_ids: Sequence[NoteId], note_ids: Sequence[NoteId],
space_separated_tags: str, space_separated_tags: str,
success: PerformOpOptionalSuccessCallback = None, ) -> CollectionOp:
handler: Optional[object] = None, return CollectionOp(
) -> None: parent, lambda col: col.tags.bulk_add(note_ids, space_separated_tags)
mw.perform_op( ).success(
lambda: mw.col.tags.bulk_add(note_ids, space_separated_tags), lambda out: tooltip(tr.browsing_notes_updated(count=out.count), parent=parent)
success=success,
handler=handler,
) )
def remove_tags_from_notes( def remove_tags_from_notes(
*, *,
mw: AnkiQt, parent: QWidget,
note_ids: Sequence[NoteId], note_ids: Sequence[NoteId],
space_separated_tags: str, space_separated_tags: str,
success: PerformOpOptionalSuccessCallback = None, ) -> CollectionOp:
handler: Optional[object] = None, return CollectionOp(
) -> None: parent, lambda col: col.tags.bulk_remove(note_ids, space_separated_tags)
mw.perform_op( ).success(
lambda: mw.col.tags.bulk_remove(note_ids, space_separated_tags), lambda out: tooltip(tr.browsing_notes_updated(count=out.count), parent=parent)
success=success,
handler=handler,
) )
def clear_unused_tags(*, mw: AnkiQt, parent: QWidget) -> None: def clear_unused_tags(*, parent: QWidget) -> CollectionOp:
mw.perform_op( return CollectionOp(parent, lambda col: col.tags.clear_unused_tags()).success(
mw.col.tags.clear_unused_tags, lambda out: tooltip(
success=lambda out: tooltip(
tr.browsing_removed_unused_tags_count(count=out.count), parent=parent tr.browsing_removed_unused_tags_count(count=out.count), parent=parent
), )
) )
def rename_tag( def rename_tag(
*, *,
mw: AnkiQt,
parent: QWidget, parent: QWidget,
current_name: str, current_name: str,
new_name: str, new_name: str,
) -> None: ) -> CollectionOp:
def success(out: OpChangesWithCount) -> None: def success(out: OpChangesWithCount) -> None:
if out.count: if out.count:
tooltip(tr.browsing_notes_updated(count=out.count), parent=parent) tooltip(tr.browsing_notes_updated(count=out.count), parent=parent)
else: else:
showInfo(tr.browsing_tag_rename_warning_empty(), parent=parent) showInfo(tr.browsing_tag_rename_warning_empty(), parent=parent)
mw.perform_op( return CollectionOp(
lambda: mw.col.tags.rename(old=current_name, new=new_name), parent,
success=success, lambda col: col.tags.rename(old=current_name, new=new_name),
) ).success(success)
def remove_tags_from_all_notes( def remove_tags_from_all_notes(
*, mw: AnkiQt, parent: QWidget, space_separated_tags: str *, parent: QWidget, space_separated_tags: str
) -> None: ) -> CollectionOp:
mw.perform_op( return CollectionOp(
lambda: mw.col.tags.remove(space_separated_tags=space_separated_tags), parent, lambda col: col.tags.remove(space_separated_tags=space_separated_tags)
success=lambda out: tooltip( ).success(
tr.browsing_notes_updated(count=out.count), parent=parent lambda out: tooltip(tr.browsing_notes_updated(count=out.count), parent=parent)
),
) )
def reparent_tags( def reparent_tags(
*, mw: AnkiQt, parent: QWidget, tags: Sequence[str], new_parent: str *, parent: QWidget, tags: Sequence[str], new_parent: str
) -> None: ) -> CollectionOp:
mw.perform_op( return CollectionOp(
lambda: mw.col.tags.reparent(tags=tags, new_parent=new_parent), parent, lambda col: col.tags.reparent(tags=tags, new_parent=new_parent)
success=lambda out: tooltip( ).success(
tr.browsing_notes_updated(count=out.count), parent=parent lambda out: tooltip(tr.browsing_notes_updated(count=out.count), parent=parent)
),
) )
def set_tag_collapsed(*, mw: AnkiQt, tag: str, collapsed: bool) -> None: def set_tag_collapsed(*, parent: QWidget, tag: str, collapsed: bool) -> CollectionOp:
mw.perform_op(lambda: mw.col.tags.set_collapsed(tag=tag, collapsed=collapsed)) return CollectionOp(
parent, lambda col: col.tags.set_collapsed(tag=tag, collapsed=collapsed)
)

View File

@ -851,20 +851,14 @@ time = %(time)d;
note = self.card.note() note = self.card.note()
if note.has_tag(MARKED_TAG): if note.has_tag(MARKED_TAG):
remove_tags_from_notes( remove_tags_from_notes(
mw=self.mw, parent=self.mw, note_ids=[note.id], space_separated_tags=MARKED_TAG
note_ids=[note.id], ).success(redraw_mark).run(handler=self)
space_separated_tags=MARKED_TAG,
handler=self,
success=redraw_mark,
)
else: else:
add_tags_to_notes( add_tags_to_notes(
mw=self.mw, parent=self.mw,
note_ids=[note.id], note_ids=[note.id],
space_separated_tags=MARKED_TAG, space_separated_tags=MARKED_TAG,
handler=self, ).success(redraw_mark).run(handler=self)
success=redraw_mark,
)
def on_set_due(self) -> None: def on_set_due(self) -> None:
if self.mw.state != "review" or not self.card: if self.mw.state != "review" or not self.card:

View File

@ -652,7 +652,7 @@ class SidebarTreeView(QTreeView):
else: else:
new_parent = target.full_name new_parent = target.full_name
reparent_tags(mw=self.mw, parent=self.browser, tags=tags, new_parent=new_parent) reparent_tags(parent=self.browser, tags=tags, new_parent=new_parent).run()
return True return True
@ -947,8 +947,8 @@ class SidebarTreeView(QTreeView):
def toggle_expand(node: TagTreeNode) -> Callable[[bool], None]: def toggle_expand(node: TagTreeNode) -> Callable[[bool], None]:
full_name = head + node.name full_name = head + node.name
return lambda expanded: set_tag_collapsed( return lambda expanded: set_tag_collapsed(
mw=self.mw, tag=full_name, collapsed=not expanded parent=self, tag=full_name, collapsed=not expanded
) ).run()
for node in nodes: for node in nodes:
item = SidebarItem( item = SidebarItem(
@ -1209,9 +1209,7 @@ class SidebarTreeView(QTreeView):
tags = self.mw.col.tags.join(self._selected_tags()) tags = self.mw.col.tags.join(self._selected_tags())
item.name = "..." item.name = "..."
remove_tags_from_all_notes( remove_tags_from_all_notes(parent=self.browser, space_separated_tags=tags).run()
mw=self.mw, parent=self.browser, space_separated_tags=tags
)
def rename_tag(self, item: SidebarItem, new_name: str) -> None: def rename_tag(self, item: SidebarItem, new_name: str) -> None:
if not new_name or new_name == item.name: if not new_name or new_name == item.name:
@ -1226,11 +1224,10 @@ class SidebarTreeView(QTreeView):
item.full_name = new_name item.full_name = new_name
rename_tag( rename_tag(
mw=self.mw,
parent=self.browser, parent=self.browser,
current_name=old_name, current_name=old_name,
new_name=new_name, new_name=new_name,
) ).run()
# Saved searches # Saved searches
#################################### ####################################

View File

@ -26,7 +26,6 @@ from anki.hooks import runFilter, runHook
from anki.models import NotetypeDict from anki.models import NotetypeDict
from aqt.qt import QDialog, QEvent, QMenu, QWidget from aqt.qt import QDialog, QEvent, QMenu, QWidget
from aqt.tagedit import TagEdit from aqt.tagedit import TagEdit
import aqt.operations
""" """
# Hook list # Hook list