diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 06dcdb29e..46950ed17 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 BrowserPreviewer 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,21 @@ 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.close() + 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 = BrowserPreviewer(self, self.mw) + self._previewer.open() 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.render(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.cancel_timer() # Card deletion ###################################################################### diff --git a/qt/aqt/previewer.py b/qt/aqt/previewer.py new file mode 100644 index 000000000..bd6b80dc3 --- /dev/null +++ b/qt/aqt/previewer.py @@ -0,0 +1,371 @@ +import json +import re +import time +from typing import Any, List, Optional, Union + +from anki.cards import Card +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 + + +class Previewer(QDialog): + _last_state = None + _card_changed = False + _last_render: Union[int, float] = 0 + _timer = None + + def __init__(self, parent: QWidget, mw: AnkiQt): + super().__init__(None, Qt.Window) + self._open = True + self._parent = parent + self.mw = mw + + def card(self) -> Optional[Card]: + raise NotImplementedError + + def open(self): + self._state = "question" + self._last_state = None + self._create_gui() + self._setup_web_view() + self.render(True) + self.show() + + def _create_gui(self): + self.setWindowTitle(_("Preview")) + + self.finished.connect(self._onFinished) + self.silentlyClose = True + self.vbox = QVBoxLayout() + self.vbox.setContentsMargins(0, 0, 0, 0) + self._web = AnkiWebView(title="previewer") + self.vbox.addWidget(self._web) + self.bbox = QDialogButtonBox() + + self._replay = self.bbox.addButton( + _("Replay Audio"), QDialogButtonBox.ActionRole + ) + self._replay.setAutoDefault(False) + self._replay.setShortcut(QKeySequence("R")) + self._replay.setToolTip(_("Shortcut key: %s" % "R")) + self._replay.clicked.connect(self._onReplayAudio) + + self.showBothSides = QCheckBox(_("Show Both Sides")) + self.showBothSides.setShortcut(QKeySequence("B")) + self.showBothSides.setToolTip(_("Shortcut key: %s" % "B")) + self.bbox.addButton(self.showBothSides, QDialogButtonBox.ActionRole) + self._bothSides = self.mw.col.conf.get("previewBothSides", False) + self.showBothSides.setChecked(self._bothSides) + self.showBothSides.toggled.connect(self._on_show_both_sides) + + self.vbox.addWidget(self.bbox) + self.setLayout(self.vbox) + restoreGeom(self, "preview") + + def _on_finished(self, ok): + saveGeom(self, "preview") + self.mw.progress.timer(100, self._on_close, False) + + def _on_replay_audio(self): + self.mw.reviewer.replayAudio(self) + + def close(self): + if self: + self.close() + self._on_close() + + def _on_close(self): + self._open = False + + def _setup_web_view(self): + jsinc = [ + "jquery.js", + "browsersel.js", + "mathjax/conf.js", + "mathjax/MathJax.js", + "reviewer.js", + ] + self._previewWeb.stdHtml( + self.mw.reviewer.revHtml(), css=["reviewer.css"], js=jsinc, context=self, + ) + self._web.set_bridge_command(self._on_bridge_cmd, self) + + def _on_bridge_cmd(self, cmd: str) -> Any: + if cmd.startswith("play:"): + play_clicked_audio(cmd, self.card()) + + def render(self, cardChanged=False): + self.cancel_timer() + # Keep track of whether render() has ever been called + # with cardChanged=True since the last successful render + self._card_changed |= cardChanged + # avoid rendering in quick succession + elapMS = int((time.time() - self._last_render) * 1000) + delay = 300 + if elapMS < delay: + self._timer = self.mw.progress.timer( + delay - elapMS, self._render_scheduled, False + ) + else: + self._render_scheduled() + + def cancel_timer(self): + if self._timer: + self._timer.stop() + self._timer = None + + def _render_scheduled(self) -> None: + self.cancel_timer() + self._last_render = time.time() + + if not self._open: + return + c = self.card() + func = "_showQuestion" + if not c: + txt = _("(please select 1 card)") + bodyclass = "" + self._last_state = None + else: + if self._bothSides: + self._state = "answer" + elif self._card_changed: + self._state = "question" + + currentState = self._state_and_mod() + if currentState == self._last_state: + # nothing has changed, avoid refreshing + return + + # need to force reload even if answer + txt = c.q(reload=True) + + if self._state == "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._bothSides: + # 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._state == "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._state.capitalize()) + self._last_state = self._state_and_mod() + self._web.eval("{}({},'{}');".format(func, json.dumps(txt), bodyclass)) + self._card_changed = False + + def _on_show_both_sides(self, toggle): + self._bothSides = toggle + self.mw.col.conf["previewBothSides"] = toggle + self.mw.col.setMod() + if self._state == "answer" and not toggle: + self._state = "question" + self.render() + + def _state_and_mod(self): + c = self.card() + n = c.note() + n.load() + return (self._state, c.id, n.mod) + + +class MultipleCardsPreviewer(Previewer): + def card(self) -> Optional[Card]: + # need to state explicitly it's not implement to avoid W0223 + raise NotImplementedError + + def _create_gui(self): + super()._create_gui() + self._prev = self.bbox.addButton("<", QDialogButtonBox.ActionRole) + self._prev.setAutoDefault(False) + self._prev.setShortcut(QKeySequence("Left")) + self._prev.setToolTip(_("Shortcut key: Left arrow")) + + self._next = self.bbox.addButton(">", QDialogButtonBox.ActionRole) + self._next.setAutoDefault(True) + self._next.setShortcut(QKeySequence("Right")) + self._next.setToolTip(_("Shortcut key: Right arrow or Enter")) + + self._prev.clicked.connect(self._on_prev) + self._next.clicked.connect(self._on_next) + + def _on_prev(self): + if self._state == "answer" and not self._bothSides: + self._state = "question" + self.render() + else: + self._on_prev_card() + + def _on_prev_card(self): + ... + + def _on_next(self): + if self._state == "question": + self._state = "answer" + self.render() + else: + self._on_next_card() + + def _on_next_card(self): + ... + + def _updateButtons(self): + if not self._open: + return + self._prev.setEnabled(self._should_enable_prev()) + self._next.setEnabled(self._should_enable_next()) + + def _should_enable_prev(self): + return self._state == "answer" and not self._bothSides + + def _should_enable_next(self): + return self._state == "question" + + def _on_close(self): + super()._on_close() + self._prev = None + self._next = None + + +class BrowserPreviewer(MultipleCardsPreviewer): + def card(self) -> Optional[Card]: + if self._parent.singleCard: + return self._parent.card + else: + return None + + def _on_finished(self, ok): + super()._on_finished(ok) + self._parent.form.previewButton.setChecked(False) + + def _on_prev_card(self): + self._parent.editor.saveNow( + lambda: self._parent._moveCur(QAbstractItemView.MoveUp) + ) + + def _on_next_card(self): + self._parent.editor.saveNow( + lambda: self._parent._moveCur(QAbstractItemView.MoveDown) + ) + + def _should_enable_prev(self): + return super()._should_enable_prev() or self._parent.currentRow() > 0 + + def _should_enable_next(self): + return ( + super()._should_enable_next() + or self._parent.currentRow() < self._parent.model.rowCount(None) - 1 + ) + + def _on_close(self): + super()._on_close() + self._parent.previewer = None + + def _render_scheduled(self) -> None: + super()._render_scheduled() + self._updateButtons() + + +class ListCardsPreviewer(MultipleCardsPreviewer): + def __init__(self, cards: List[Union[Card, int]], *args, **kwargs): + """A previewer displaying a list of card. + + List can be changed by setting self.cards to a new value. + + self.cards contains both cid and card. So that card is loaded + only when required and is not loaded twice. + + """ + self.index = 0 + self.cards = cards + super().__init__(*args, **kwargs) + + def card(self): + if not self.cards: + return None + if isinstance(self.cards[self.index], int): + self.cards[self.index] = self.mw.col.getCard(self.cards[self.index]) + return self.cards[self.index] + + def open(self): + if not self.cards: + return + super().open() + + def _on_prev_card(self): + self.index -= 1 + self.render() + + def _on_next_card(self): + self.index += 1 + self.render() + + def _should_enable_prev(self): + return super()._should_enable_prev() or self.index > 0 + + def _should_enable_next(self): + return super()._should_enable_next() or self.index < len(self.cards) - 1 + + def _on_other_side(self): + if self._state == "question": + self._state = "answer" + else: + self._state = "question" + self.render() + + +class SingleCardPreviewer(Previewer): + def __init__(self, card: Card, *args, **kwargs): + self._card = card + super().__init__(*args, **kwargs) + + def card(self) -> Card: + return self._card + + def _create_gui(self): + super()._create_gui() + self._other_side = self.bbox.addButton( + "Other side", QDialogButtonBox.ActionRole + ) + self._other_side.setAutoDefault(False) + self._other_side.setShortcut(QKeySequence("Right")) + self._other_side.setShortcut(QKeySequence("Left")) + self._other_side.setToolTip(_("Shortcut key: Left or Right arrow")) + self._other_side.clicked.connect(self._on_other_side) + + def _on_other_side(self): + if self._state == "question": + self._state = "answer" + else: + self._state = "question" + self.render()