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.
This commit is contained in:
Damien Elmes 2021-03-14 22:08:37 +10:00
parent 1e849316be
commit 0a5be6543e
8 changed files with 163 additions and 100 deletions

View File

@ -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.

View File

@ -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)

View File

@ -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
##########################################################################

View File

@ -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")
def maybeReset(self) -> None:
self.autosave()
if self.state == "resetRequired":
self.state = self.returnState
self.reset()
def maybeReset(self) -> None:
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"""
<center><div style="height: 100%">
<div style="position:relative; vertical-align: middle;">
{i}<br><br>
{b}</div></div></center>
<script>$('#resume').focus()</script>
""",
context=web_context,
)
self.bottomWeb.hide()
self.web.setFocus()
pass
# HTML helpers
##########################################################################
@ -1403,7 +1374,7 @@ title="%s" %s>%s</button>""" % (
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</button>""" % (
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</button>""" % (
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</button>""" % (
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"

View File

@ -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
############################################################

View File

@ -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
##########################################################################

View File

@ -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
######################################################################

View File

@ -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(