9f3f6bab7d
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.
224 lines
6.8 KiB
Python
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)
|