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 @@ -217,6 +217,7 @@ qt_accel_edit + @@ -319,7 +320,7 @@ qt_accel_undo - Ctrl+Alt+Z + Ctrl+Z @@ -613,6 +614,14 @@ Alt+T + + + qt_accel_redo + + + Ctrl+Shift+Z + + 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 @@ -63,6 +63,7 @@ qt_accel_edit + @@ -237,6 +238,17 @@ Ctrl+Shift+A + + + false + + + qt_accel_redo + + + Ctrl+Shift+Z + + 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",