diff --git a/ftl/qt/qt-accel.ftl b/ftl/qt/qt-accel.ftl
index db7346212..e2ac4ae80 100644
--- a/ftl/qt/qt-accel.ftl
+++ b/ftl/qt/qt-accel.ftl
@@ -30,5 +30,6 @@ qt-accel-support-anki = &Support Anki...
qt-accel-switch-profile = &Switch Profile
qt-accel-tools = &Tools
qt-accel-undo = &Undo
+qt-accel-redo = &Redo
qt-accel-set-due-date = Set &Due Date...
qt-accel-forget = &Forget
diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py
index f403aa904..4c34c96e3 100644
--- a/pylib/anki/collection.py
+++ b/pylib/anki/collection.py
@@ -885,7 +885,7 @@ table.review-log {{ {revlog_style} }}
##########################################################################
def undo_status(self) -> UndoStatus:
- "Return the undo status. At the moment, redo is not supported."
+ "Return the undo status."
# check backend first
if status := self._check_backend_undo_status():
return status
@@ -939,6 +939,14 @@ table.review-log {{ {revlog_style} }}
self.models._clear_cache()
return out
+ def redo(self) -> OpChangesAfterUndo:
+ """Returns result of backend redo operation, or throws UndoEmpty."""
+ out = self._backend.redo()
+ self.clear_python_undo()
+ if out.changes.notetype:
+ self.models._clear_cache()
+ return out
+
def undo_legacy(self) -> LegacyUndoResult:
"Returns None if the legacy undo queue is empty."
if isinstance(self._undo, _ReviewsUndo):
diff --git a/qt/aqt/browser/browser.py b/qt/aqt/browser/browser.py
index c2b581137..95eddfd2a 100644
--- a/qt/aqt/browser/browser.py
+++ b/qt/aqt/browser/browser.py
@@ -19,7 +19,7 @@ from aqt import AnkiQt, gui_hooks
from aqt.editor import Editor
from aqt.exporting import ExportDialog
from aqt.operations.card import set_card_deck, set_card_flag
-from aqt.operations.collection import undo
+from aqt.operations.collection import redo, undo
from aqt.operations.note import remove_notes
from aqt.operations.scheduling import (
forget_cards,
@@ -35,6 +35,7 @@ from aqt.operations.tag import (
)
from aqt.qt import *
from aqt.switch import Switch
+from aqt.undo import UndoActionsInfo
from aqt.utils import (
HelpPage,
KeyboardModifiersPressed,
@@ -101,7 +102,8 @@ class Browser(QMainWindow):
self.setupMenus()
self.setupHooks()
self.setupEditor()
- self.onUndoState(self.mw.form.actionUndo.isEnabled())
+ # disable undo/redo
+ self.on_undo_state_change(mw.undo_actions_info())
self.setupSearch(card, search)
gui_hooks.browser_will_show(self)
self.show()
@@ -139,6 +141,7 @@ class Browser(QMainWindow):
f = self.form
# edit
qconnect(f.actionUndo.triggered, self.undo)
+ qconnect(f.actionRedo.triggered, self.redo)
qconnect(f.actionInvertSelection.triggered, self.table.invert_selection)
qconnect(f.actionSelectNotes.triggered, self.selectNotes)
if not isMac:
@@ -786,14 +789,14 @@ where id in %s"""
######################################################################
def setupHooks(self) -> None:
- gui_hooks.undo_state_did_change.append(self.onUndoState)
+ gui_hooks.undo_state_did_change.append(self.on_undo_state_change)
gui_hooks.backend_will_block.append(self.table.on_backend_will_block)
gui_hooks.backend_did_block.append(self.table.on_backend_did_block)
gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
gui_hooks.focus_did_change.append(self.on_focus_change)
def teardownHooks(self) -> None:
- gui_hooks.undo_state_did_change.remove(self.onUndoState)
+ gui_hooks.undo_state_did_change.remove(self.on_undo_state_change)
gui_hooks.backend_will_block.remove(self.table.on_backend_will_block)
gui_hooks.backend_did_block.remove(self.table.on_backend_will_block)
gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)
@@ -805,10 +808,15 @@ where id in %s"""
def undo(self) -> None:
undo(parent=self)
- def onUndoState(self, on: bool) -> None:
- self.form.actionUndo.setEnabled(on)
- if on:
- self.form.actionUndo.setText(self.mw.form.actionUndo.text())
+ def redo(self) -> None:
+ redo(parent=self)
+
+ def on_undo_state_change(self, info: UndoActionsInfo) -> None:
+ self.form.actionUndo.setText(info.undo_text)
+ self.form.actionUndo.setEnabled(info.can_undo)
+ self.form.actionRedo.setText(info.redo_text)
+ self.form.actionRedo.setEnabled(info.can_redo)
+ self.form.actionRedo.setVisible(info.show_redo)
# Edit: replacing
######################################################################
diff --git a/qt/aqt/forms/browser.ui b/qt/aqt/forms/browser.ui
index 9f499fe60..eb5528d9a 100644
--- a/qt/aqt/forms/browser.ui
+++ b/qt/aqt/forms/browser.ui
@@ -144,12 +144,12 @@
false
-
- false
-
20
+
+ false
+
true
@@ -209,7 +209,7 @@
0
0
750
- 21
+ 24
diff --git a/qt/aqt/forms/main.ui b/qt/aqt/forms/main.ui
index 3b270eab7..cfd2a38ad 100644
--- a/qt/aqt/forms/main.ui
+++ b/qt/aqt/forms/main.ui
@@ -46,7 +46,7 @@
0
0
667
- 22
+ 24
diff --git a/qt/aqt/main.py b/qt/aqt/main.py
index e28c3aaf4..3faa3feff 100644
--- a/qt/aqt/main.py
+++ b/qt/aqt/main.py
@@ -53,7 +53,7 @@ from aqt.emptycards import show_empty_cards
from aqt.legacy import install_pylib_legacy
from aqt.mediacheck import check_media_db
from aqt.mediasync import MediaSyncer
-from aqt.operations.collection import undo
+from aqt.operations.collection import redo, undo
from aqt.operations.deck import set_current_deck
from aqt.profiles import ProfileManager as ProfileManagerType
from aqt.qt import *
@@ -61,6 +61,7 @@ from aqt.qt import sip
from aqt.sync import sync_collection, sync_login
from aqt.taskman import TaskManager
from aqt.theme import theme_manager
+from aqt.undo import UndoActionsInfo
from aqt.utils import (
HelpPage,
KeyboardModifiersPressed,
@@ -1070,44 +1071,31 @@ title="%s" %s>%s""" % (
##########################################################################
def undo(self) -> None:
- "Call collection_ops.py:undo() directly instead."
+ "Call operations/collection.py:undo() directly instead."
undo(parent=self)
- def update_undo_actions(self, status: Optional[UndoStatus] = None) -> None:
- """Update menu text and enable/disable menu item as appropriate.
- Plural as this may handle redo in the future too."""
- if self.col:
- status = status or self.col.undo_status()
- undo_action = status.undo or None
- else:
- undo_action = None
+ def redo(self) -> None:
+ "Call operations/collection.py:redo() directly instead."
+ redo(parent=self)
- if undo_action:
- undo_action = tr.undo_undo_action(val=undo_action)
- self.form.actionUndo.setText(undo_action)
- self.form.actionUndo.setEnabled(True)
- gui_hooks.undo_state_did_change(True)
- else:
- self.form.actionUndo.setText(tr.undo_undo())
- self.form.actionUndo.setEnabled(False)
- gui_hooks.undo_state_did_change(False)
+ def undo_actions_info(self) -> UndoActionsInfo:
+ "Info about the current undo/redo state for updating menus."
+ status = self.col.undo_status() if self.col else UndoStatus()
+ return UndoActionsInfo.from_undo_status(status)
- def _update_undo_actions_for_status_and_save(self, status: UndoStatus) -> None:
- """Update menu text and enable/disable menu item as appropriate.
- Plural as this may handle redo in the future too."""
- undo_action = status.undo
+ def update_undo_actions(self) -> None:
+ """Tell the UI to redraw the undo/redo menu actions based on the current state.
- if undo_action:
- undo_action = tr.undo_undo_action(val=undo_action)
- self.form.actionUndo.setText(undo_action)
- self.form.actionUndo.setEnabled(True)
- gui_hooks.undo_state_did_change(True)
- else:
- self.form.actionUndo.setText(tr.undo_undo())
- self.form.actionUndo.setEnabled(False)
- gui_hooks.undo_state_did_change(False)
-
- self.col.autosave()
+ Usually you do not need to call this directly; it is called when a
+ CollectionOp is run, and will be called when the legacy .reset() or
+ .checkpoint() methods are used."""
+ info = self.undo_actions_info()
+ self.form.actionUndo.setText(info.undo_text)
+ self.form.actionUndo.setEnabled(info.can_undo)
+ self.form.actionRedo.setText(info.redo_text)
+ self.form.actionRedo.setEnabled(info.can_redo)
+ self.form.actionRedo.setVisible(info.show_redo)
+ gui_hooks.undo_state_did_change(info)
def checkpoint(self, name: str) -> None:
self.col.save(name)
@@ -1233,7 +1221,8 @@ title="%s" %s>%s""" % (
qconnect(m.actionExit.triggered, self.close)
qconnect(m.actionPreferences.triggered, self.onPrefs)
qconnect(m.actionAbout.triggered, self.onAbout)
- qconnect(m.actionUndo.triggered, self.onUndo)
+ qconnect(m.actionUndo.triggered, self.undo)
+ qconnect(m.actionRedo.triggered, self.redo)
if qtminor < 11:
m.actionUndo.setShortcut(QKeySequence("Ctrl+Alt+Z"))
qconnect(m.actionFullDatabaseCheck.triggered, self.onCheckDB)
diff --git a/qt/aqt/operations/__init__.py b/qt/aqt/operations/__init__.py
index 4c5d1db35..01e6d17e8 100644
--- a/qt/aqt/operations/__init__.py
+++ b/qt/aqt/operations/__init__.py
@@ -111,9 +111,8 @@ class CollectionOp(Generic[ResultWithChanges]):
if self._success:
self._success(result)
finally:
- # update undo status
- status = mw.col.undo_status()
- mw._update_undo_actions_for_status_and_save(status)
+ mw.update_undo_actions()
+ mw.autosave()
# fire change hooks
self._fire_change_hooks_after_op_performed(result, initiator)
diff --git a/qt/aqt/operations/collection.py b/qt/aqt/operations/collection.py
index 373921b5b..a2087f19b 100644
--- a/qt/aqt/operations/collection.py
+++ b/qt/aqt/operations/collection.py
@@ -32,6 +32,15 @@ def undo(*, parent: QWidget) -> None:
).run_in_background()
+def redo(*, parent: QWidget) -> None:
+ "Redo the last operation, and refresh the UI."
+
+ def on_success(out: OpChangesAfterUndo) -> None:
+ tooltip(tr.undo_action_redone(action=out.operation), parent=parent)
+
+ CollectionOp(parent, lambda col: col.redo()).success(on_success).run_in_background()
+
+
def _legacy_undo(*, parent: QWidget) -> None:
from aqt import mw
diff --git a/qt/aqt/undo.py b/qt/aqt/undo.py
new file mode 100644
index 000000000..ccb96e7f3
--- /dev/null
+++ b/qt/aqt/undo.py
@@ -0,0 +1,36 @@
+# 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 dataclasses import dataclass
+
+from anki.collection import UndoStatus
+
+
+@dataclass
+class UndoActionsInfo:
+ can_undo: bool
+ can_redo: bool
+
+ undo_text: str
+ redo_text: str
+
+ # menu item is hidden when legacy undo is active, since it can't be undone
+ show_redo: bool
+
+ @staticmethod
+ def from_undo_status(status: UndoStatus) -> UndoActionsInfo:
+ from aqt import tr
+
+ return UndoActionsInfo(
+ can_undo=bool(status.undo),
+ can_redo=bool(status.redo),
+ undo_text=tr.undo_undo_action(val=status.undo)
+ if status.undo
+ else tr.undo_undo(),
+ redo_text=tr.undo_redo_action(action=status.undo)
+ if status.redo
+ else tr.undo_redo(),
+ show_redo=status.last_step > 0,
+ )
diff --git a/qt/tools/genhooks_gui.py b/qt/tools/genhooks_gui.py
index 42e7fb085..de3fa6ccb 100644
--- a/qt/tools/genhooks_gui.py
+++ b/qt/tools/genhooks_gui.py
@@ -30,6 +30,7 @@ from anki.models import NotetypeDict
from anki.collection import OpChangesAfterUndo
from aqt.qt import QDialog, QEvent, QMenu, QWidget
from aqt.tagedit import TagEdit
+from aqt.undo import UndoActionsInfo
"""
# Hook list
@@ -675,9 +676,7 @@ gui_hooks.webview_did_inject_style_into_page.append(mytest)
args=["col: anki.collection.Collection"],
legacy_hook="colLoading",
),
- Hook(
- name="undo_state_did_change", args=["can_undo: bool"], legacy_hook="undoState"
- ),
+ Hook(name="undo_state_did_change", args=["info: UndoActionsInfo"]),
Hook(name="review_did_undo", args=["card_id: int"], legacy_hook="revertedCard"),
Hook(
name="style_did_init",