b9251290ca
This adds Python 3.9 and 3.10 typing syntax to files that import attributions from __future___. Python 3.9 should be able to cope with the 3.10 syntax, but Python 3.8 will no longer work. On Windows/Mac, install the latest Python 3.9 version from python.org. There are currently no orjson wheels for Python 3.10 on Windows/Mac, which will break the build unless you have Rust installed separately. On Linux, modern distros should have Python 3.9 available already. If you're on an older distro, you'll need to build Python from source first.
361 lines
11 KiB
Python
361 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.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.Normal, QIcon.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.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.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():
|
|
AnkiWebView.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()
|
|
av_player.play_tags(audio)
|
|
else:
|
|
AnkiWebView.setPlaybackRequiresGesture(True)
|
|
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.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.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()
|