anki/qt/aqt/browser/previewer.py
Damien Elmes a7812dedc0 switch to new-style PyQt scoped enums and Qt6
The enum changes should work on PyQt 5.x, and are required in PyQt 6.x.
They are not supported by the PyQt5 typings however, so we need to run
our tests with PyQt6.
2021-10-15 12:57:19 +10:00

359 lines
11 KiB
Python

# 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,
QIcon,
QKeySequence,
QPixmap,
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, tr
from aqt.webview import AnkiWebView
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
icon = QIcon()
icon.addPixmap(QPixmap("icons:anki.png"), QIcon.Mode.Normal, QIcon.State.Off)
disable_help_button(self)
self.setWindowIcon(icon)
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()
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(title="previewer")
self.vbox.addWidget(self._web)
self.bbox = QDialogButtonBox()
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)
restoreGeom(self, "preview")
def _on_finished(self, ok: int) -> None:
saveGeom(self, "preview")
self.mw.progress.timer(100, self._on_close, False)
def _on_replay_audio(self) -> None:
if self._state == "question":
replay_audio(self.card(), True)
elif self._state == "answer":
replay_audio(self.card(), False)
def close(self) -> None:
self._on_close()
super().close()
def _on_close(self) -> None:
self._open = False
self._close_callback()
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.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
)
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():
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()
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, 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)
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("<", 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(">", 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()