# Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import json import re import time from typing import Any, Callable import aqt.browser from anki.cards import Card from anki.collection import Config from anki.tags import MARKED_TAG from aqt import AnkiQt, gui_hooks from aqt.qt import ( QCheckBox, QDialog, QDialogButtonBox, QKeySequence, QShortcut, Qt, QTimer, QVBoxLayout, QWidget, qconnect, ) from aqt.reviewer import replay_audio from aqt.sound import av_player, play_clicked_audio from aqt.theme import theme_manager from aqt.utils import disable_help_button, restoreGeom, saveGeom, setWindowIcon, tr from aqt.webview import AnkiWebView, AnkiWebViewKind LastStateAndMod = tuple[str, int, int] class Previewer(QDialog): _last_state: LastStateAndMod | None = None _card_changed = False _last_render: int | float = 0 _timer: QTimer | None = None _show_both_sides = False def __init__( self, parent: QWidget, mw: AnkiQt, on_close: Callable[[], None] ) -> None: super().__init__(None, Qt.WindowType.Window) mw.garbage_collect_on_dialog_finish(self) self._open = True self._parent = parent self._close_callback = on_close self.mw = mw disable_help_button(self) setWindowIcon(self) gui_hooks.previewer_did_init(self) def card(self) -> Card | None: raise NotImplementedError def card_changed(self) -> bool: raise NotImplementedError def open(self) -> None: self._state = "question" self._last_state = None self._create_gui() self._setup_web_view() self.render_card() restoreGeom(self, "preview") self.show() def _create_gui(self) -> None: self.setWindowTitle(tr.actions_preview()) self.close_shortcut = QShortcut(QKeySequence("Ctrl+Shift+P"), self) qconnect(self.close_shortcut.activated, self.close) qconnect(self.finished, self._on_finished) self.silentlyClose = True self.vbox = QVBoxLayout() self.vbox.setContentsMargins(0, 0, 0, 0) self._web = AnkiWebView(kind=AnkiWebViewKind.PREVIEWER) self.vbox.addWidget(self._web) self.bbox = QDialogButtonBox() self.bbox.setLayoutDirection(Qt.LayoutDirection.LeftToRight) gui_hooks.card_review_webview_did_init(self._web, AnkiWebViewKind.PREVIEWER) self._replay = self.bbox.addButton( tr.actions_replay_audio(), QDialogButtonBox.ButtonRole.ActionRole ) self._replay.setAutoDefault(False) self._replay.setShortcut(QKeySequence("R")) self._replay.setToolTip(tr.actions_shortcut_key(val="R")) qconnect(self._replay.clicked, self._on_replay_audio) both_sides_button = QCheckBox(tr.qt_misc_back_side_only()) both_sides_button.setShortcut(QKeySequence("B")) both_sides_button.setToolTip(tr.actions_shortcut_key(val="B")) self.bbox.addButton(both_sides_button, QDialogButtonBox.ButtonRole.ActionRole) self._show_both_sides = self.mw.col.get_config_bool( Config.Bool.PREVIEW_BOTH_SIDES ) both_sides_button.setChecked(self._show_both_sides) qconnect(both_sides_button.toggled, self._on_show_both_sides) self.vbox.addWidget(self.bbox) self.setLayout(self.vbox) def _on_finished(self, ok: int) -> None: saveGeom(self, "preview") self._on_close() def _on_replay_audio(self) -> None: gui_hooks.audio_will_replay(self._web, self.card(), self._state == "question") if self._state == "question": replay_audio(self.card(), True) elif self._state == "answer": replay_audio(self.card(), False) def _on_close(self) -> None: self._open = False self._close_callback() self._web.cleanup() self._web = None def _setup_web_view(self) -> None: self._web.stdHtml( self.mw.reviewer.revHtml(), css=["css/reviewer.css"], js=[ "js/mathjax.js", "js/vendor/mathjax/tex-chtml.js", "js/reviewer.js", ], context=self, ) self._web.allow_drops = True self._web.eval("_blockDefaultDragDropBehavior();") 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 _update_flag_and_mark_icons(self, card: Card | None) -> None: if card: flag = card.user_flag() marked = card.note(reload=True).has_tag(MARKED_TAG) else: flag = 0 marked = False self._web.eval(f"_drawFlag({flag}); _drawMark({json.dumps(marked)});") def render_card(self) -> None: self.cancel_timer() # Keep track of whether render() has ever been called # with cardChanged=True since the last successful render self._card_changed |= self.card_changed() # avoid rendering in quick succession elap_ms = int((time.time() - self._last_render) * 1000) delay = 300 if elap_ms < delay: self._timer = self.mw.progress.timer( delay - elap_ms, self._render_scheduled, False, parent=self ) else: self._render_scheduled() def cancel_timer(self) -> None: 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() self._update_flag_and_mark_icons(c) func = "_showQuestion" ans_txt = "" if not c: txt = tr.qt_misc_please_select_1_card() bodyclass = "" self._last_state = None else: if self._show_both_sides: 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.question(reload=True) ans_txt = c.answer() if self._state == "answer": func = "_showAnswer" txt = ans_txt txt = re.sub(r"\[\[type:[^]]+\]\]", "", txt) bodyclass = theme_manager.body_classes_for_card_ord(c.ord) if c.autoplay(): self._web.setPlaybackRequiresGesture(False) if self._show_both_sides: # 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() else: audio = [] self._web.setPlaybackRequiresGesture(True) gui_hooks.av_player_will_play_tags(audio, self._state, self) av_player.play_tags(audio) txt = self.mw.prepare_card_text_for_display(txt) txt = gui_hooks.card_will_show(txt, c, f"preview{self._state.capitalize()}") self._last_state = self._state_and_mod() js: str if self._state == "question": ans_txt = self.mw.col.media.escape_media_filenames(ans_txt) js = f"{func}({json.dumps(txt)}, {json.dumps(ans_txt)}, '{bodyclass}');" else: js = f"{func}({json.dumps(txt)}, '{bodyclass}');" self._web.eval(js) self._card_changed = False def _on_show_both_sides(self, toggle: bool) -> None: self._show_both_sides = toggle self.mw.col.set_config_bool(Config.Bool.PREVIEW_BOTH_SIDES, toggle) gui_hooks.previewer_will_redraw_after_show_both_sides_toggled( self._web, self.card(), self._state == "question", toggle ) if self._state == "answer" and not toggle: self._state = "question" self.render_card() def _state_and_mod(self) -> tuple[str, int, int]: c = self.card() n = c.note() n.load() return (self._state, c.id, n.mod) def state(self) -> str: return self._state class MultiCardPreviewer(Previewer): def card(self) -> Card | None: # need to state explicitly it's not implement to avoid W0223 raise NotImplementedError def card_changed(self) -> bool: # need to state explicitly it's not implement to avoid W0223 raise NotImplementedError def _create_gui(self) -> None: super()._create_gui() self._prev = self.bbox.addButton( ">" if self.layoutDirection() == Qt.LayoutDirection.RightToLeft else "<", QDialogButtonBox.ButtonRole.ActionRole, ) self._prev.setAutoDefault(False) self._prev.setShortcut(QKeySequence("Left")) self._prev.setToolTip(tr.qt_misc_shortcut_key_left_arrow()) self._next = self.bbox.addButton( "<" if self.layoutDirection() == Qt.LayoutDirection.RightToLeft else ">", QDialogButtonBox.ButtonRole.ActionRole, ) self._next.setAutoDefault(True) self._next.setShortcut(QKeySequence("Right")) self._next.setToolTip(tr.qt_misc_shortcut_key_right_arrow_or_enter()) qconnect(self._prev.clicked, self._on_prev) qconnect(self._next.clicked, self._on_next) def _on_prev(self) -> None: if self._state == "answer" and not self._show_both_sides: self._state = "question" self.render_card() else: self._on_prev_card() def _on_prev_card(self) -> None: pass def _on_next(self) -> None: if self._state == "question": self._state = "answer" self.render_card() else: self._on_next_card() def _on_next_card(self) -> None: pass def _updateButtons(self) -> None: if not self._open: return self._prev.setEnabled(self._should_enable_prev()) self._next.setEnabled(self._should_enable_next()) def _should_enable_prev(self) -> bool: return self._state == "answer" and not self._show_both_sides def _should_enable_next(self) -> bool: return self._state == "question" def _on_close(self) -> None: super()._on_close() self._prev = None self._next = None class BrowserPreviewer(MultiCardPreviewer): _last_card_id = 0 _parent: aqt.browser.Browser | None def __init__( self, parent: aqt.browser.Browser, mw: AnkiQt, on_close: Callable[[], None] ) -> None: super().__init__(parent=parent, mw=mw, on_close=on_close) def card(self) -> Card | None: if self._parent.singleCard: return self._parent.card else: return None def card_changed(self) -> bool: c = self.card() if not c: return True else: changed = c.id != self._last_card_id self._last_card_id = c.id return changed def _on_prev_card(self) -> None: self._parent.onPreviousCard() def _on_next_card(self) -> None: self._parent.onNextCard() def _should_enable_prev(self) -> bool: return super()._should_enable_prev() or self._parent.has_previous_card() def _should_enable_next(self) -> bool: return super()._should_enable_next() or self._parent.has_next_card() def _render_scheduled(self) -> None: super()._render_scheduled() self._updateButtons()