Merge pull request #532 from Arthur-Milchior/previewer

Previewer
This commit is contained in:
Damien Elmes 2020-04-03 08:20:43 +10:00 committed by GitHub
commit eae46b4a6f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 383 additions and 227 deletions

View file

@ -5,8 +5,6 @@
from __future__ import annotations from __future__ import annotations
import html import html
import json
import re
import sre_constants import sre_constants
import time import time
import unicodedata import unicodedata
@ -16,6 +14,7 @@ from operator import itemgetter
from typing import Callable, List, Optional, Sequence, Union from typing import Callable, List, Optional, Sequence, Union
import anki import anki
import aqt
import aqt.forms import aqt.forms
from anki import hooks from anki import hooks
from anki.cards import Card 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 import AnkiQt, gui_hooks
from aqt.editor import Editor from aqt.editor import Editor
from aqt.exporting import ExportDialog from aqt.exporting import ExportDialog
from aqt.previewer import BrowserPreviewer
from aqt.qt import * from aqt.qt import *
from aqt.sound import av_player, play_clicked_audio
from aqt.theme import theme_manager from aqt.theme import theme_manager
from aqt.utils import ( from aqt.utils import (
MenuList, MenuList,
@ -57,12 +56,6 @@ from aqt.utils import (
from aqt.webview import AnkiWebView from aqt.webview import AnkiWebView
@dataclass
class PreviewDialog:
dialog: QDialog
browser: Browser
@dataclass @dataclass
class FindDupesDialog: class FindDupesDialog:
dialog: QDialog dialog: QDialog
@ -583,7 +576,7 @@ class Browser(QMainWindow):
self.col = self.mw.col self.col = self.mw.col
self.lastFilter = "" self.lastFilter = ""
self.focusTo = None self.focusTo = None
self._previewWindow = None self._previewer = None
self._closeEventHasCleanedUp = False self._closeEventHasCleanedUp = False
self.form = aqt.forms.browser.Ui_Dialog() self.form = aqt.forms.browser.Ui_Dialog()
self.form.setupUi(self) self.form.setupUi(self)
@ -1569,229 +1562,21 @@ where id in %s"""
# Preview # Preview
###################################################################### ######################################################################
_previewTimer = None
_lastPreviewRender: Union[int, float] = 0
_lastPreviewState = None
_previewCardChanged = False
def onTogglePreview(self): def onTogglePreview(self):
if self._previewWindow: if self._previewer:
self._closePreview() self._previewer.close()
self._previewer = None
else: else:
self._openPreview() self._previewer = BrowserPreviewer(self, self.mw)
self._previewer.open()
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)
def _renderPreview(self, cardChanged=False): def _renderPreview(self, cardChanged=False):
self._cancelPreviewTimer() if self._previewer:
# Keep track of whether _renderPreview() has ever been called self._previewer.render(cardChanged)
# 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): def _cancelPreviewTimer(self):
if self._previewTimer: if self._previewer:
self._previewTimer.stop() self._previewer.cancel_timer()
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)
# Card deletion # Card deletion
###################################################################### ######################################################################

371
qt/aqt/previewer.py Normal file
View file

@ -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()