From 0a5be6543e6d347f8e580433a083669e2cc97c44 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sun, 14 Mar 2021 22:08:37 +1000 Subject: [PATCH] experiment with replacing requireReset with updates on focus-in - This avoids the need for a separate screen, though we may want to slightly fade out the display when information is stale. - Means the browser can delay updates just like the main window does. --- ftl/qt/qt-misc.ftl | 2 - qt/aqt/browser.py | 21 +++++-- qt/aqt/deckbrowser.py | 18 ++++-- qt/aqt/main.py | 128 +++++++++++++++++++-------------------- qt/aqt/overview.py | 18 +++--- qt/aqt/reviewer.py | 51 ++++++++++++---- qt/aqt/utils.py | 14 +++++ qt/tools/genhooks_gui.py | 11 +++- 8 files changed, 163 insertions(+), 100 deletions(-) diff --git a/ftl/qt/qt-misc.ftl b/ftl/qt/qt-misc.ftl index e1de29fbe..1da297bb2 100644 --- a/ftl/qt/qt-misc.ftl +++ b/ftl/qt/qt-misc.ftl @@ -36,7 +36,6 @@ qt-misc-please-select-a-deck = Please select a deck. qt-misc-please-use-fileimport-to-import-this = Please use File>Import to import this file. qt-misc-processing = Processing... qt-misc-replace-your-collection-with-an-earlier = Replace your collection with an earlier backup? -qt-misc-resume-now = Resume Now qt-misc-revert-to-backup = Revert to backup qt-misc-reverted-to-state-prior-to = Reverted to state prior to '{ $val }'. qt-misc-segoe-ui = "Segoe UI" @@ -56,7 +55,6 @@ qt-misc-unable-to-move-existing-file-to = Unable to move existing file to trash qt-misc-undo = Undo qt-misc-undo2 = Undo { $val } qt-misc-unexpected-response-code = Unexpected response code: { $val } -qt-misc-waiting-for-editing-to-finish = Waiting for editing to finish. qt-misc-would-you-like-to-download-it = Would you like to download it now? qt-misc-your-collection-file-appears-to-be = Your collection file appears to be corrupt. This can happen when the file is copied or moved while Anki is open, or when the collection is stored on a network or cloud drive. If problems persist after restarting your computer, please open an automatic backup from the profile screen. qt-misc-your-computers-storage-may-be-full = Your computer's storage may be full. Please delete some unneeded files, then try again. diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 4d21fddbe..a1e4b425d 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -34,6 +34,7 @@ from aqt.utils import ( TR, HelpPage, askUser, + current_top_level_widget, disable_help_button, getTag, openHelp, @@ -91,6 +92,7 @@ class DataModel(QAbstractTableModel): ) self.cards: Sequence[int] = [] self.cardObjs: Dict[int, Card] = {} + self.refresh_needed = False def getCard(self, index: QModelIndex) -> Optional[Card]: id = self.cards[index.row()] @@ -203,6 +205,7 @@ class DataModel(QAbstractTableModel): def reset(self) -> None: self.beginReset() self.endReset() + self.refresh_needed = False # caller must have called editor.saveNow() before calling this or .reset() def beginReset(self) -> None: @@ -281,8 +284,14 @@ class DataModel(QAbstractTableModel): else: tv.selectRow(0) - def maybe_redraw_after_operation(self, op: OperationInfo) -> None: + def op_executed(self, op: OperationInfo, focused: bool) -> None: if op.changes.card or op.changes.note or op.changes.deck or op.changes.notetype: + self.refresh_needed = True + if focused: + self.refresh_if_needed() + + def refresh_if_needed(self) -> None: + if self.refresh_needed: self.reset() # Column data @@ -490,7 +499,11 @@ class Browser(QMainWindow): def on_operation_did_execute(self, op: OperationInfo) -> None: self.setUpdatesEnabled(True) - self.model.maybe_redraw_after_operation(op) + self.model.op_executed(op, current_top_level_widget() == self) + + def on_focus_change(self, new: Optional[QWidget], old: Optional[QWidget]) -> None: + if current_top_level_widget() == self: + self.model.refresh_if_needed() def setupMenus(self) -> None: # pylint: disable=unnecessary-lambda @@ -1415,7 +1428,6 @@ where id in %s""" def setupHooks(self) -> None: gui_hooks.undo_state_did_change.append(self.onUndoState) - gui_hooks.state_did_reset.append(self.onReset) gui_hooks.editor_did_fire_typing_timer.append(self.refreshCurrentCard) gui_hooks.editor_did_load_note.append(self.onLoadNote) gui_hooks.editor_did_unfocus_field.append(self.on_unfocus_field) @@ -1423,10 +1435,10 @@ where id in %s""" gui_hooks.sidebar_should_refresh_notetypes.append(self.on_item_added) gui_hooks.operation_will_execute.append(self.on_operation_will_execute) 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.state_did_reset.remove(self.onReset) gui_hooks.editor_did_fire_typing_timer.remove(self.refreshCurrentCard) gui_hooks.editor_did_load_note.remove(self.onLoadNote) gui_hooks.editor_did_unfocus_field.remove(self.on_unfocus_field) @@ -1434,6 +1446,7 @@ where id in %s""" gui_hooks.sidebar_should_refresh_notetypes.remove(self.on_item_added) gui_hooks.operation_will_execute.remove(self.on_operation_will_execute) gui_hooks.operation_did_execute.remove(self.on_operation_did_execute) + gui_hooks.focus_did_change.remove(self.on_focus_change) def on_unfocus_field(self, changed: bool, note: Note, field_idx: int) -> None: self.refreshCurrentCard(note) diff --git a/qt/aqt/deckbrowser.py b/qt/aqt/deckbrowser.py index 87aaaf6b7..a25b5e4f0 100644 --- a/qt/aqt/deckbrowser.py +++ b/qt/aqt/deckbrowser.py @@ -62,7 +62,7 @@ class DeckBrowser: self.bottom = BottomBar(mw, mw.bottomWeb) self.scrollPos = QPoint(0, 0) self._v1_message_dismissed_at = 0 - gui_hooks.operation_did_execute.append(self.on_operation_did_execute) + self.refresh_needed = False def show(self) -> None: av_player.stop_and_clear_queue() @@ -70,17 +70,23 @@ class DeckBrowser: self._renderPage() # redraw top bar for theme change self.mw.toolbar.redraw() + self.refresh() def refresh(self) -> None: self._renderPage() + self.refresh_needed = False - def on_operation_did_execute(self, op: OperationInfo) -> None: - if self.mw.state != "deckBrowser": - return - - if self.mw.col.op_affects_study_queue(op): + def refresh_if_needed(self) -> None: + if self.refresh_needed: self.refresh() + def op_executed(self, op: OperationInfo, focused: bool) -> None: + if self.mw.col.op_affects_study_queue(op): + self.refresh_needed = True + + if focused: + self.refresh_if_needed() + # Event handlers ########################################################################## diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 8c86f6de6..59f133800 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -72,6 +72,7 @@ from aqt.utils import ( HelpPage, askUser, checkInvalidFilename, + current_top_level_widget, disable_help_button, getFile, getOnlyText, @@ -85,6 +86,7 @@ from aqt.utils import ( showInfo, showWarning, tooltip, + top_level_widget, tr, ) @@ -92,28 +94,6 @@ T = TypeVar("T") install_pylib_legacy() - -class ResetReason(enum.Enum): - Unknown = "unknown" - AddCardsAddNote = "addCardsAddNote" - EditCurrentInit = "editCurrentInit" - EditorBridgeCmd = "editorBridgeCmd" - BrowserSetDeck = "browserSetDeck" - BrowserAddTags = "browserAddTags" - BrowserRemoveTags = "browserRemoveTags" - BrowserSuspend = "browserSuspend" - BrowserReposition = "browserReposition" - BrowserReschedule = "browserReschedule" - BrowserFindReplace = "browserFindReplace" - BrowserTagDupes = "browserTagDupes" - BrowserDeleteDeck = "browserDeleteDeck" - - -class ResetRequired: - def __init__(self, mw: AnkiQt) -> None: - self.mw = mw - - MainWindowState = Literal[ "startup", "deckBrowser", "overview", "review", "resetRequired", "profileManager" ] @@ -194,6 +174,7 @@ class AnkiQt(QMainWindow): self.setupHooks() self.setup_timers() self.updateTitleBar() + self.setup_focus() # screens self.setupDeckBrowser() self.setupOverview() @@ -222,6 +203,12 @@ class AnkiQt(QMainWindow): "Shortcut to create a weak reference that doesn't break code completion." return weakref.proxy(self) # type: ignore + def setup_focus(self) -> None: + qconnect(self.app.focusChanged, self.on_focus_changed) + + def on_focus_changed(self, old: QWidget, new: QWidget) -> None: + gui_hooks.focus_did_change(new, old) + # Profiles ########################################################################## @@ -771,11 +758,32 @@ class AnkiQt(QMainWindow): setattr(op.changes, field.name, True) gui_hooks.operation_did_execute(op) + def on_operation_did_execute(self, op: OperationInfo) -> None: + "Notify current screen of changes." + focused = current_top_level_widget() == self + if self.state == "review": + self.reviewer.op_executed(op, focused) + elif self.state == "overview": + self.overview.op_executed(op, focused) + elif self.state == "deckBrowser": + self.deckBrowser.op_executed(op, focused) + + def on_focus_did_change( + self, new_focus: Optional[QWidget], _old: Optional[QWidget] + ) -> None: + "If main window has received focus, ensure current UI state is updated." + if new_focus and top_level_widget(new_focus) == self: + if self.state == "review": + self.reviewer.refresh_if_needed() + elif self.state == "overview": + self.overview.refresh_if_needed() + elif self.state == "deckBrowser": + self.deckBrowser.refresh_if_needed() + def reset(self, unused_arg: bool = False) -> None: """Legacy method of telling UI to refresh after changes made to DB. New code should use mw.perform_op() instead.""" - if self.col: # fire new `operation_did_execute` hook first. If the overview # or review screen are currently open, they will rebuild the study @@ -783,63 +791,26 @@ class AnkiQt(QMainWindow): self._synthesize_op_did_execute_from_reset() # fire the old reset hook gui_hooks.state_did_reset() - self.update_undo_actions() - # fixme: double-check - # self.moveToState(self.state) + # legacy def requireReset( self, modal: bool = False, - reason: ResetReason = ResetReason.Unknown, + reason: Any = None, context: Any = None, ) -> None: - "Signal queue needs to be rebuilt when edits are finished or by user." - self.autosave() - self.resetModal = modal - if gui_hooks.main_window_should_require_reset( - self.interactiveState(), reason, context - ): - self.moveToState("resetRequired") - - def interactiveState(self) -> bool: - "True if not in profile manager, syncing, etc." - return self.state in ("overview", "review", "deckBrowser") + self.reset() def maybeReset(self) -> None: - self.autosave() - if self.state == "resetRequired": - self.state = self.returnState - self.reset() + pass def delayedMaybeReset(self) -> None: - # if we redraw the page in a button click event it will often crash on - # windows - self.progress.timer(100, self.maybeReset, False) + pass def _resetRequiredState(self, oldState: MainWindowState) -> None: - if oldState != "resetRequired": - self.returnState = oldState - if self.resetModal: - # we don't have to change the webview, as we have a covering window - return - web_context = ResetRequired(self) - self.web.set_bridge_command(lambda url: self.delayedMaybeReset(), web_context) - i = tr(TR.QT_MISC_WAITING_FOR_EDITING_TO_FINISH) - b = self.button("refresh", tr(TR.QT_MISC_RESUME_NOW), id="resume") - self.web.stdHtml( - f""" -
-
-{i}

-{b}
- -""", - context=web_context, - ) - self.bottomWeb.hide() - self.web.setFocus() + pass # HTML helpers ########################################################################## @@ -1403,7 +1374,7 @@ title="%s" %s>%s""" % ( if elap > minutes * 60: self.maybe_auto_sync_media() - # Permanent libanki hooks + # Permanent hooks ########################################################################## def setupHooks(self) -> None: @@ -1413,6 +1384,8 @@ title="%s" %s>%s""" % ( gui_hooks.av_player_will_play.append(self.on_av_player_will_play) gui_hooks.av_player_did_end_playing.append(self.on_av_player_did_end_playing) + gui_hooks.operation_did_execute.append(self.on_operation_did_execute) + gui_hooks.focus_did_change.append(self.on_focus_did_change) self._activeWindowOnPlay: Optional[QWidget] = None @@ -1748,6 +1721,10 @@ title="%s" %s>%s""" % ( def _isAddon(self, buf: str) -> bool: return buf.endswith(self.addonManager.ext) + def interactiveState(self) -> bool: + "True if not in profile manager, syncing, etc." + return self.state in ("overview", "review", "deckBrowser") + # GC ########################################################################## # The default Python garbage collection can trigger on any thread. This can @@ -1803,3 +1780,20 @@ title="%s" %s>%s""" % ( def serverURL(self) -> str: return "http://127.0.0.1:%d/" % self.mediaServer.getPort() + + +# legacy +class ResetReason(enum.Enum): + Unknown = "unknown" + AddCardsAddNote = "addCardsAddNote" + EditCurrentInit = "editCurrentInit" + EditorBridgeCmd = "editorBridgeCmd" + BrowserSetDeck = "browserSetDeck" + BrowserAddTags = "browserAddTags" + BrowserRemoveTags = "browserRemoveTags" + BrowserSuspend = "browserSuspend" + BrowserReposition = "browserReposition" + BrowserReschedule = "browserReschedule" + BrowserFindReplace = "browserFindReplace" + BrowserTagDupes = "browserTagDupes" + BrowserDeleteDeck = "browserDeleteDeck" diff --git a/qt/aqt/overview.py b/qt/aqt/overview.py index 5e2fc979c..b3239313a 100644 --- a/qt/aqt/overview.py +++ b/qt/aqt/overview.py @@ -43,7 +43,7 @@ class Overview: self.mw = mw self.web = mw.web self.bottom = BottomBar(mw, mw.bottomWeb) - gui_hooks.operation_did_execute.append(self.on_operation_did_execute) + self.refresh_needed = False def show(self) -> None: av_player.stop_and_clear_queue() @@ -57,15 +57,19 @@ class Overview: self._renderBottom() self.mw.web.setFocus() gui_hooks.overview_did_refresh(self) + self.refresh_needed = False - def on_operation_did_execute(self, op: OperationInfo) -> None: - if self.mw.state != "overview": - return - - if self.mw.col.op_affects_study_queue(op): - # will also cover the deck description modified case + def refresh_if_needed(self) -> None: + if self.refresh_needed: self.refresh() + def op_executed(self, op: OperationInfo, focused: bool) -> None: + if self.mw.col.op_affects_study_queue(op): + self.refresh_needed = True + + if focused: + self.refresh_if_needed() + # Handlers ############################################################ diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 5109b6ec4..c0b87a2b6 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -7,6 +7,7 @@ import html import json import re import unicodedata as ucd +from enum import Enum, auto from typing import Any, Callable, List, Match, Optional, Sequence, Tuple, Union from PyQt5.QtCore import Qt @@ -14,6 +15,7 @@ from PyQt5.QtCore import Qt from anki import hooks from anki.cards import Card from anki.collection import Config, OperationInfo +from anki.types import assert_exhaustive from anki.utils import stripHTML from aqt import AnkiQt, gui_hooks from aqt.profiles import VideoDriver @@ -38,6 +40,14 @@ class ReviewerBottomBar: self.reviewer = reviewer +class RefreshNeeded(Enum): + NO = auto() + NOTE_MARK = auto() + CARD_FLAG = auto() + QUEUE = auto() + CARD = auto() + + def replay_audio(card: Card, question_side: bool) -> None: if question_side: av_player.play_tags(card.question_av_tags()) @@ -61,17 +71,17 @@ class Reviewer: self._recordedAudio: Optional[str] = None self.typeCorrect: str = None # web init happens before this is set self.state: Optional[str] = None + self.refresh_needed = RefreshNeeded.NO self.bottom = BottomBar(mw, mw.bottomWeb) hooks.card_did_leech.append(self.onLeech) - gui_hooks.operation_did_execute.append(self.on_operation_did_execute) def show(self) -> None: - self.mw.col.reset() self.mw.setStateShortcuts(self._shortcutKeys()) # type: ignore self.web.set_bridge_command(self._linkHandler, self) self.bottom.web.set_bridge_command(self._linkHandler, ReviewerBottomBar(self)) self._reps: int = None - self.nextCard() + self.refresh_needed = RefreshNeeded.QUEUE + self.refresh_if_needed() def lastCard(self) -> Optional[Card]: if self._answeredIds: @@ -87,26 +97,41 @@ class Reviewer: gui_hooks.reviewer_will_end() self.card = None - def on_operation_did_execute(self, op: OperationInfo) -> None: - if self.mw.state != "review": + def refresh_if_needed(self) -> None: + if self.refresh_needed is RefreshNeeded.NO: return - - if op.kind == OperationInfo.UPDATE_NOTE_TAGS: + elif self.refresh_needed is RefreshNeeded.NOTE_MARK: self.card.load() self._update_mark_icon() - elif op.kind == OperationInfo.SET_CARD_FLAG: + elif self.refresh_needed is RefreshNeeded.CARD_FLAG: # fixme: v3 mtime check self.card.load() self._update_flag_icon() - elif self.mw.col.op_affects_study_queue(op): - # need queue rebuild + elif self.refresh_needed is RefreshNeeded.QUEUE: self.mw.col.reset() self.nextCard() - return - elif op.changes.note or op.changes.notetype or op.changes.tag: - # need redraw of current card + elif self.refresh_needed is RefreshNeeded.CARD: self.card.load() self._showQuestion() + else: + assert_exhaustive(self.refresh_needed) + + self.refresh_needed = RefreshNeeded.NO + + def op_executed(self, op: OperationInfo, focused: bool) -> None: + if op.kind == OperationInfo.UPDATE_NOTE_TAGS: + self.refresh_needed = RefreshNeeded.NOTE_MARK + elif op.kind == OperationInfo.SET_CARD_FLAG: + self.refresh_needed = RefreshNeeded.CARD_FLAG + elif self.mw.col.op_affects_study_queue(op): + self.refresh_needed = RefreshNeeded.QUEUE + elif op.changes.note or op.changes.notetype or op.changes.tag: + self.refresh_needed = RefreshNeeded.CARD + else: + self.refresh_needed = RefreshNeeded.NO + + if focused: + self.refresh_if_needed() # Fetching a card ########################################################################## diff --git a/qt/aqt/utils.py b/qt/aqt/utils.py index 8d94aa5c7..870d7b444 100644 --- a/qt/aqt/utils.py +++ b/qt/aqt/utils.py @@ -729,6 +729,20 @@ def downArrow() -> str: return "▾" +def top_level_widget(widget: QWidget) -> QWidget: + window = None + while widget := widget.parent(): + window = widget + return window + + +def current_top_level_widget() -> Optional[QWidget]: + if widget := QApplication.focusWidget(): + return top_level_widget(widget) + else: + return None + + # Tooltips ###################################################################### diff --git a/qt/tools/genhooks_gui.py b/qt/tools/genhooks_gui.py index e04b78c1d..aca968811 100644 --- a/qt/tools/genhooks_gui.py +++ b/qt/tools/genhooks_gui.py @@ -24,7 +24,7 @@ from anki.cards import Card from anki.decks import Deck, DeckConfig from anki.hooks import runFilter, runHook from anki.models import NoteType -from aqt.qt import QDialog, QEvent, QMenu +from aqt.qt import QDialog, QEvent, QMenu, QWidget from aqt.tagedit import TagEdit """ @@ -417,6 +417,15 @@ hooks = [ mw.reset(), `operation_will_execute` will not be called. """, ), + Hook( + name="focus_did_change", + args=[ + "new: Optional[QWidget]", + "old: Optional[QWidget]", + ], + doc="""Called each time the focus changes. Can be used to defer updates from + `operation_did_execute` until a window is brought to the front.""", + ), # Webview ################### Hook(