diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 06dcdb29e..b9441b38a 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -5,8 +5,6 @@ from __future__ import annotations import html -import json -import re import sre_constants import time import unicodedata @@ -16,6 +14,7 @@ from operator import itemgetter from typing import Callable, List, Optional, Sequence, Union import anki +import aqt import aqt.forms from anki import hooks from anki.cards import Card @@ -29,8 +28,8 @@ from anki.utils import htmlToTextLine, ids2str, intTime, isMac, isWin from aqt import AnkiQt, gui_hooks from aqt.editor import Editor from aqt.exporting import ExportDialog +from aqt.previewer import Previewer from aqt.qt import * -from aqt.sound import av_player, play_clicked_audio from aqt.theme import theme_manager from aqt.utils import ( MenuList, @@ -57,12 +56,6 @@ from aqt.utils import ( from aqt.webview import AnkiWebView -@dataclass -class PreviewDialog: - dialog: QDialog - browser: Browser - - @dataclass class FindDupesDialog: dialog: QDialog @@ -583,7 +576,7 @@ class Browser(QMainWindow): self.col = self.mw.col self.lastFilter = "" self.focusTo = None - self._previewWindow = None + self._previewer = None self._closeEventHasCleanedUp = False self.form = aqt.forms.browser.Ui_Dialog() self.form.setupUi(self) @@ -1569,229 +1562,22 @@ where id in %s""" # Preview ###################################################################### - _previewTimer = None - _lastPreviewRender: Union[int, float] = 0 - _lastPreviewState = None - _previewCardChanged = False - def onTogglePreview(self): - if self._previewWindow: - self._closePreview() + if self._previewer: + self._previewer._closePreview() + self._previewer = None else: - self._openPreview() - def _openPreview(self): - self._previewState = "question" - self._lastPreviewState = None - self._previewWindow = QDialog(None, Qt.Window) - self._previewWindow.setWindowTitle(_("Preview")) - - self._previewWindow.finished.connect(self._onPreviewFinished) - self._previewWindow.silentlyClose = True - vbox = QVBoxLayout() - vbox.setContentsMargins(0, 0, 0, 0) - self._previewWeb = AnkiWebView(title="previewer") - vbox.addWidget(self._previewWeb) - bbox = QDialogButtonBox() - - self._previewReplay = bbox.addButton( - _("Replay Audio"), QDialogButtonBox.ActionRole - ) - self._previewReplay.setAutoDefault(False) - self._previewReplay.setShortcut(QKeySequence("R")) - self._previewReplay.setToolTip(_("Shortcut key: %s" % "R")) - - self._previewPrev = bbox.addButton("<", QDialogButtonBox.ActionRole) - self._previewPrev.setAutoDefault(False) - self._previewPrev.setShortcut(QKeySequence("Left")) - self._previewPrev.setToolTip(_("Shortcut key: Left arrow")) - - self._previewNext = bbox.addButton(">", QDialogButtonBox.ActionRole) - self._previewNext.setAutoDefault(True) - self._previewNext.setShortcut(QKeySequence("Right")) - self._previewNext.setToolTip(_("Shortcut key: Right arrow or Enter")) - - self._previewPrev.clicked.connect(self._onPreviewPrev) - self._previewNext.clicked.connect(self._onPreviewNext) - self._previewReplay.clicked.connect(self._onReplayAudio) - - self.previewShowBothSides = QCheckBox(_("Show Both Sides")) - self.previewShowBothSides.setShortcut(QKeySequence("B")) - self.previewShowBothSides.setToolTip(_("Shortcut key: %s" % "B")) - bbox.addButton(self.previewShowBothSides, QDialogButtonBox.ActionRole) - self._previewBothSides = self.col.conf.get("previewBothSides", False) - self.previewShowBothSides.setChecked(self._previewBothSides) - self.previewShowBothSides.toggled.connect(self._onPreviewShowBothSides) - - self._setupPreviewWebview() - - vbox.addWidget(bbox) - self._previewWindow.setLayout(vbox) - restoreGeom(self._previewWindow, "preview") - self._previewWindow.show() - self._renderPreview(True) - - def _onPreviewFinished(self, ok): - saveGeom(self._previewWindow, "preview") - self.mw.progress.timer(100, self._onClosePreview, False) - self.form.previewButton.setChecked(False) - - def _onPreviewPrev(self): - if self._previewState == "answer" and not self._previewBothSides: - self._previewState = "question" - self._renderPreview() - else: - self.editor.saveNow(lambda: self._moveCur(QAbstractItemView.MoveUp)) - - def _onPreviewNext(self): - if self._previewState == "question": - self._previewState = "answer" - self._renderPreview() - else: - self.editor.saveNow(lambda: self._moveCur(QAbstractItemView.MoveDown)) - - def _onReplayAudio(self): - self.mw.reviewer.replayAudio(self) - - def _updatePreviewButtons(self): - if not self._previewWindow: - return - current = self.currentRow() - canBack = current > 0 or ( - current == 0 - and self._previewState == "answer" - and not self._previewBothSides - ) - self._previewPrev.setEnabled(bool(self.singleCard and canBack)) - canForward = ( - self.currentRow() < self.model.rowCount(None) - 1 - or self._previewState == "question" - ) - self._previewNext.setEnabled(bool(self.singleCard and canForward)) - - def _closePreview(self): - if self._previewWindow: - self._previewWindow.close() - self._onClosePreview() - - def _onClosePreview(self): - self._previewWindow = self._previewPrev = self._previewNext = None - - def _setupPreviewWebview(self): - jsinc = [ - "jquery.js", - "browsersel.js", - "mathjax/conf.js", - "mathjax/MathJax.js", - "reviewer.js", - ] - web_context = PreviewDialog(dialog=self._previewWindow, browser=self) - self._previewWeb.stdHtml( - self.mw.reviewer.revHtml(), - css=["reviewer.css"], - js=jsinc, - context=web_context, - ) - self._previewWeb.set_bridge_command( - self._on_preview_bridge_cmd, web_context, - ) - - def _on_preview_bridge_cmd(self, cmd: str) -> Any: - if cmd.startswith("play:"): - play_clicked_audio(cmd, self.card) + self._previewer = Previewer(self, self.mw) + self._previewer._openPreview() def _renderPreview(self, cardChanged=False): - self._cancelPreviewTimer() - # Keep track of whether _renderPreview() has ever been called - # with cardChanged=True since the last successful render - self._previewCardChanged |= cardChanged - # avoid rendering in quick succession - elapMS = int((time.time() - self._lastPreviewRender) * 1000) - delay = 300 - if elapMS < delay: - self._previewTimer = self.mw.progress.timer( - delay - elapMS, self._renderScheduledPreview, False - ) - else: - self._renderScheduledPreview() + if self._previewer: + self._previewer._renderPreview(cardChanged) def _cancelPreviewTimer(self): - if self._previewTimer: - self._previewTimer.stop() - self._previewTimer = None - - def _renderScheduledPreview(self) -> None: - self._cancelPreviewTimer() - self._lastPreviewRender = time.time() - - if not self._previewWindow: - return - c = self.card - func = "_showQuestion" - if not c or not self.singleCard: - txt = _("(please select 1 card)") - bodyclass = "" - self._lastPreviewState = None - else: - if self._previewBothSides: - self._previewState = "answer" - elif self._previewCardChanged: - self._previewState = "question" - - currentState = self._previewStateAndMod() - if currentState == self._lastPreviewState: - # nothing has changed, avoid refreshing - return - - # need to force reload even if answer - txt = c.q(reload=True) - - if self._previewState == "answer": - func = "_showAnswer" - txt = c.a() - txt = re.sub(r"\[\[type:[^]]+\]\]", "", txt) - - bodyclass = theme_manager.body_classes_for_card_ord(c.ord) - - if self.mw.reviewer.autoplay(c): - if self._previewBothSides: - # if we're showing both sides at once, remove any audio - # from the answer that's appeared on the question already - question_audio = c.question_av_tags() - only_on_answer_audio = [ - x for x in c.answer_av_tags() if x not in question_audio - ] - audio = question_audio + only_on_answer_audio - elif self._previewState == "question": - audio = c.question_av_tags() - else: - audio = c.answer_av_tags() - av_player.play_tags(audio) - else: - av_player.clear_queue_and_maybe_interrupt() - - txt = self.mw.prepare_card_text_for_display(txt) - txt = gui_hooks.card_will_show( - txt, c, "preview" + self._previewState.capitalize() - ) - self._lastPreviewState = self._previewStateAndMod() - self._updatePreviewButtons() - self._previewWeb.eval("{}({},'{}');".format(func, json.dumps(txt), bodyclass)) - self._previewCardChanged = False - - def _onPreviewShowBothSides(self, toggle): - self._previewBothSides = toggle - self.col.conf["previewBothSides"] = toggle - self.col.setMod() - if self._previewState == "answer" and not toggle: - self._previewState = "question" - self._renderPreview() - - def _previewStateAndMod(self): - c = self.card - n = c.note() - n.load() - return (self._previewState, c.id, n.mod) + if self._previewer: + self._previewer._cancelPreviewTimer() # Card deletion ###################################################################### diff --git a/qt/aqt/previewer.py b/qt/aqt/previewer.py new file mode 100644 index 000000000..6af0ec9ac --- /dev/null +++ b/qt/aqt/previewer.py @@ -0,0 +1,257 @@ +import json +import re +import time +from dataclasses import dataclass +from typing import Any, Union + +from anki.lang import _ +from aqt import AnkiQt, gui_hooks +from aqt.qt import ( + QAbstractItemView, + QCheckBox, + QDialog, + QDialogButtonBox, + QKeySequence, + Qt, + QVBoxLayout, + QWidget, +) +from aqt.sound import av_player, play_clicked_audio +from aqt.theme import theme_manager +from aqt.utils import restoreGeom, saveGeom +from aqt.webview import AnkiWebView + + +@dataclass +class PreviewDialog: + dialog: QDialog + parent: QWidget + + +class Previewer: + _lastPreviewState = None + _previewCardChanged = False + _lastPreviewRender: Union[int, float] = 0 + _previewTimer = None + + def __init__(self, parent: QWidget, mw: AnkiQt): + self.parent = parent + self.mw = mw + + def _openPreview(self): + self._previewState = "question" + self._lastPreviewState = None + self._previewWindow = QDialog(None, Qt.Window) + self._previewWindow.setWindowTitle(_("Preview")) + + self._previewWindow.finished.connect(self._onPreviewFinished) + self._previewWindow.silentlyClose = True + vbox = QVBoxLayout() + vbox.setContentsMargins(0, 0, 0, 0) + self._previewWeb = AnkiWebView(title="previewer") + vbox.addWidget(self._previewWeb) + bbox = QDialogButtonBox() + + self._previewReplay = bbox.addButton( + _("Replay Audio"), QDialogButtonBox.ActionRole + ) + self._previewReplay.setAutoDefault(False) + self._previewReplay.setShortcut(QKeySequence("R")) + self._previewReplay.setToolTip(_("Shortcut key: %s" % "R")) + + self._previewPrev = bbox.addButton("<", QDialogButtonBox.ActionRole) + self._previewPrev.setAutoDefault(False) + self._previewPrev.setShortcut(QKeySequence("Left")) + self._previewPrev.setToolTip(_("Shortcut key: Left arrow")) + + self._previewNext = bbox.addButton(">", QDialogButtonBox.ActionRole) + self._previewNext.setAutoDefault(True) + self._previewNext.setShortcut(QKeySequence("Right")) + self._previewNext.setToolTip(_("Shortcut key: Right arrow or Enter")) + + self._previewPrev.clicked.connect(self._onPreviewPrev) + self._previewNext.clicked.connect(self._onPreviewNext) + self._previewReplay.clicked.connect(self._onReplayAudio) + + self.previewShowBothSides = QCheckBox(_("Show Both Sides")) + self.previewShowBothSides.setShortcut(QKeySequence("B")) + self.previewShowBothSides.setToolTip(_("Shortcut key: %s" % "B")) + bbox.addButton(self.previewShowBothSides, QDialogButtonBox.ActionRole) + self._previewBothSides = self.mw.col.conf.get("previewBothSides", False) + self.previewShowBothSides.setChecked(self._previewBothSides) + self.previewShowBothSides.toggled.connect(self._onPreviewShowBothSides) + + self._setupPreviewWebview() + + vbox.addWidget(bbox) + self._previewWindow.setLayout(vbox) + restoreGeom(self._previewWindow, "preview") + self._previewWindow.show() + self._renderPreview(True) + + def _onPreviewFinished(self, ok): + saveGeom(self._previewWindow, "preview") + self.mw.progress.timer(100, self._onClosePreview, False) + self.parent.form.previewButton.setChecked(False) + + def _onPreviewPrev(self): + if self._previewState == "answer" and not self._previewBothSides: + self._previewState = "question" + self._renderPreview() + else: + self.parent.editor.saveNow( + lambda: self.parent._moveCur(QAbstractItemView.MoveUp) + ) + + def _onPreviewNext(self): + if self._previewState == "question": + self._previewState = "answer" + self._renderPreview() + else: + self.parent.editor.saveNow( + lambda: self.parent._moveCur(QAbstractItemView.MoveDown) + ) + + def _onReplayAudio(self): + self.mw.reviewer.replayAudio(self) + + def _updatePreviewButtons(self): + if not self._previewWindow: + return + current = self.parent.currentRow() + canBack = current > 0 or ( + current == 0 + and self._previewState == "answer" + and not self._previewBothSides + ) + self._previewPrev.setEnabled(bool(self.parent.singleCard and canBack)) + canForward = ( + self.parent.currentRow() < self.parent.model.rowCount(None) - 1 + or self._previewState == "question" + ) + self._previewNext.setEnabled(bool(self.parent.singleCard and canForward)) + + def _closePreview(self): + if self._previewWindow: + self._previewWindow.close() + self._onClosePreview() + + def _onClosePreview(self): + self.parent.previewer = None + self._previewWindow = self._previewPrev = self._previewNext = None + + def _setupPreviewWebview(self): + jsinc = [ + "jquery.js", + "browsersel.js", + "mathjax/conf.js", + "mathjax/MathJax.js", + "reviewer.js", + ] + web_context = PreviewDialog(dialog=self._previewWindow, parent=self.parent) + self._previewWeb.stdHtml( + self.mw.reviewer.revHtml(), + css=["reviewer.css"], + js=jsinc, + context=web_context, + ) + self._previewWeb.set_bridge_command( + self._on_preview_bridge_cmd, web_context, + ) + + def _on_preview_bridge_cmd(self, cmd: str) -> Any: + if cmd.startswith("play:"): + play_clicked_audio(cmd, self.parent.card) + + def _renderPreview(self, cardChanged=False): + self._cancelPreviewTimer() + # Keep track of whether _renderPreview() has ever been called + # with cardChanged=True since the last successful render + self._previewCardChanged |= cardChanged + # avoid rendering in quick succession + elapMS = int((time.time() - self._lastPreviewRender) * 1000) + delay = 300 + if elapMS < delay: + self._previewTimer = self.mw.progress.timer( + delay - elapMS, self._renderScheduledPreview, False + ) + else: + self._renderScheduledPreview() + + def _cancelPreviewTimer(self): + if self._previewTimer: + self._previewTimer.stop() + self._previewTimer = None + + def _renderScheduledPreview(self) -> None: + self._cancelPreviewTimer() + self._lastPreviewRender = time.time() + + if not self._previewWindow: + return + c = self.parent.card + func = "_showQuestion" + if not c or not self.parent.singleCard: + txt = _("(please select 1 card)") + bodyclass = "" + self._lastPreviewState = None + else: + if self._previewBothSides: + self._previewState = "answer" + elif self._previewCardChanged: + self._previewState = "question" + + currentState = self._previewStateAndMod() + if currentState == self._lastPreviewState: + # nothing has changed, avoid refreshing + return + + # need to force reload even if answer + txt = c.q(reload=True) + + if self._previewState == "answer": + func = "_showAnswer" + txt = c.a() + txt = re.sub(r"\[\[type:[^]]+\]\]", "", txt) + + bodyclass = theme_manager.body_classes_for_card_ord(c.ord) + + if self.mw.reviewer.autoplay(c): + if self._previewBothSides: + # if we're showing both sides at once, remove any audio + # from the answer that's appeared on the question already + question_audio = c.question_av_tags() + only_on_answer_audio = [ + x for x in c.answer_av_tags() if x not in question_audio + ] + audio = question_audio + only_on_answer_audio + elif self._previewState == "question": + audio = c.question_av_tags() + else: + audio = c.answer_av_tags() + av_player.play_tags(audio) + else: + av_player.clear_queue_and_maybe_interrupt() + + txt = self.mw.prepare_card_text_for_display(txt) + txt = gui_hooks.card_will_show( + txt, c, "preview" + self._previewState.capitalize() + ) + self._lastPreviewState = self._previewStateAndMod() + self._updatePreviewButtons() + self._previewWeb.eval("{}({},'{}');".format(func, json.dumps(txt), bodyclass)) + self._previewCardChanged = False + + def _onPreviewShowBothSides(self, toggle): + self._previewBothSides = toggle + self.mw.col.conf["previewBothSides"] = toggle + self.mw.col.setMod() + if self._previewState == "answer" and not toggle: + self._previewState = "question" + self._renderPreview() + + def _previewStateAndMod(self): + c = self.parent.card + n = c.note() + n.load() + return (self._previewState, c.id, n.mod)