anki/qt/aqt/operations/__init__.py
Damien Elmes 9f3f6bab7d enable redo support
Also:

- fix issues where the Undo action in the Browse screen was not
consistent with the main window. The existing hook signature has been
changed; from a snapshot of the add-on code from a few months ago, it
was not a hook that was being used by anyone.
- change the undo shortcut in the Browse window to match the main
window. It was different because undoing a change in the editing area
could accidentally trigger an undo of an operation, but the damage is
limited now that (most) operations can be redone. If it still proves to
be a problem, perhaps we should just always swallow ctrl+z when an
editing field is focused.
2021-05-19 15:18:39 +10:00

224 lines
6.8 KiB
Python

# Copyright: Ankitects Pty Ltd and contributors
# 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,
],
)
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[Callable[[ResultWithChanges], Any]] = None
_failure: Optional[Callable[[Exception], Any]] = None
def __init__(self, parent: QWidget, op: Callable[[Collection], ResultWithChanges]):
self._parent = parent
self._op = op
def success(
self, success: Optional[Callable[[ResultWithChanges], Any]]
) -> CollectionOp[ResultWithChanges]:
self._success = success
return self
def failure(
self, failure: Optional[Callable[[Exception], Any]]
) -> CollectionOp[ResultWithChanges]:
self._failure = failure
return self
def run_in_background(self, *, initiator: Optional[object] = None) -> None:
from aqt import mw
assert mw
mw._increase_background_ops()
def wrapped_op() -> ResultWithChanges:
assert mw
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:
if self._success:
self._success(result)
finally:
mw.update_undo_actions()
mw.autosave()
# fire change hooks
self._fire_change_hooks_after_op_performed(result, initiator)
mw.taskman.with_progress(wrapped_op, wrapped_done)
def _fire_change_hooks_after_op_performed(
self,
result: ResultWithChanges,
handler: Optional[object],
) -> None:
from aqt import mw
assert mw
if isinstance(result, OpChanges):
changes = result
else:
changes = result.changes
# fire new hook
aqt.gui_hooks.operation_did_execute(changes, handler)
# 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)