# Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import copy import json import re from typing import List, Optional import aqt from anki.cards import Card from anki.consts import * from anki.lang import _, ngettext from anki.notes import Note from anki.rsbackend import TemplateError from anki.template import TemplateRenderContext from aqt import AnkiQt, gui_hooks from aqt.qt import * from aqt.schema_change_tracker import ChangeTracker from aqt.sound import av_player, play_clicked_audio from aqt.theme import theme_manager from aqt.utils import ( TR, askUser, downArrow, getOnlyText, openHelp, restoreGeom, saveGeom, shortcut, showInfo, showWarning, tooltip, tr, ) from aqt.webview import AnkiWebView class CardLayout(QDialog): def __init__( self, mw: AnkiQt, note: Note, ord=0, parent: Optional[QWidget] = None, fill_empty: bool = False, ): QDialog.__init__(self, parent or mw, Qt.Window) mw.setupDialogGC(self) self.mw = aqt.mw self.note = note self.ord = ord self.col = self.mw.col.weakref() self.mm = self.mw.col.models self.model = note.model() self.templates = self.model["tmpls"] self._want_fill_empty_on = fill_empty self.have_autoplayed = False self.mm._remove_from_cache(self.model["id"]) self.mw.checkpoint(_("Card Types")) self.change_tracker = ChangeTracker(self.mw) self.setupTopArea() self.setupMainArea() self.setupButtons() self.setupShortcuts() self.setWindowTitle(_("Card Types for %s") % self.model["name"]) v1 = QVBoxLayout() v1.addWidget(self.topArea) v1.addWidget(self.mainArea) v1.addLayout(self.buttons) v1.setContentsMargins(12, 12, 12, 12) self.setLayout(v1) gui_hooks.card_layout_will_show(self) self.redraw_everything() restoreGeom(self, "CardLayout") self.setWindowModality(Qt.ApplicationModal) self.show() # take the focus away from the first input area when starting up, # as users tend to accidentally type into the template self.setFocus() def redraw_everything(self): self.ignore_change_signals = True self.updateTopArea() self.ignore_change_signals = False self.update_current_ordinal_and_redraw(self.ord) def update_current_ordinal_and_redraw(self, idx): if self.ignore_change_signals: return self.ord = idx self.have_autoplayed = False self.fill_fields_from_template() self.renderPreview() def _isCloze(self): return self.model["type"] == MODEL_CLOZE # Top area ########################################################################## def setupTopArea(self): self.topArea = QWidget() self.topArea.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) self.topAreaForm = aqt.forms.clayout_top.Ui_Form() self.topAreaForm.setupUi(self.topArea) self.topAreaForm.templateOptions.setText(_("Options") + " " + downArrow()) qconnect(self.topAreaForm.templateOptions.clicked, self.onMore) qconnect( self.topAreaForm.templatesBox.currentIndexChanged, self.update_current_ordinal_and_redraw, ) self.topAreaForm.card_type_label.setText(tr(TR.CARD_TEMPLATES_CARD_TYPE)) def updateTopArea(self): self.updateCardNames() def updateCardNames(self): self.ignore_change_signals = True combo = self.topAreaForm.templatesBox combo.clear() combo.addItems( self._summarizedName(idx, tmpl) for (idx, tmpl) in enumerate(self.templates) ) combo.setCurrentIndex(self.ord) combo.setEnabled(not self._isCloze()) self.ignore_change_signals = False def _summarizedName(self, idx: int, tmpl: Dict): return "{}: {}: {} -> {}".format( idx + 1, tmpl["name"], self._fieldsOnTemplate(tmpl["qfmt"]), self._fieldsOnTemplate(tmpl["afmt"]), ) def _fieldsOnTemplate(self, fmt): matches = re.findall("{{[^#/}]+?}}", fmt) chars_allowed = 30 field_names: List[str] = [] for m in matches: # strip off mustache m = re.sub(r"[{}]", "", m) # strip off modifiers m = m.split(":")[-1] # don't show 'FrontSide' if m == "FrontSide": continue field_names.append(m) chars_allowed -= len(m) if chars_allowed <= 0: break s = "+".join(field_names) if chars_allowed <= 0: s += "+..." return s def setupShortcuts(self): self.tform.front_button.setToolTip(shortcut("Ctrl+1")) self.tform.back_button.setToolTip(shortcut("Ctrl+2")) self.tform.style_button.setToolTip(shortcut("Ctrl+3")) QShortcut( # type: ignore QKeySequence("Ctrl+1"), self, activated=self.tform.front_button.click, ) QShortcut( # type: ignore QKeySequence("Ctrl+2"), self, activated=self.tform.back_button.click, ) QShortcut( # type: ignore QKeySequence("Ctrl+3"), self, activated=self.tform.style_button.click, ) # Main area setup ########################################################################## def setupMainArea(self): split = self.mainArea = QSplitter() split.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) split.setOrientation(Qt.Horizontal) left = QWidget() tform = self.tform = aqt.forms.template.Ui_Form() tform.setupUi(left) split.addWidget(left) split.setCollapsible(0, False) right = QWidget() self.pform = aqt.forms.preview.Ui_Form() pform = self.pform pform.setupUi(right) pform.preview_front.setText(tr(TR.CARD_TEMPLATES_FRONT_PREVIEW)) pform.preview_back.setText(tr(TR.CARD_TEMPLATES_BACK_PREVIEW)) pform.preview_box.setTitle(tr(TR.CARD_TEMPLATES_PREVIEW_BOX)) self.setup_edit_area() self.setup_preview() split.addWidget(right) split.setCollapsible(1, False) def setup_edit_area(self): tform = self.tform tform.front_button.setText(tr(TR.CARD_TEMPLATES_FRONT_TEMPLATE)) tform.back_button.setText(tr(TR.CARD_TEMPLATES_BACK_TEMPLATE)) tform.style_button.setText(tr(TR.CARD_TEMPLATES_TEMPLATE_STYLING)) tform.groupBox.setTitle(tr(TR.CARD_TEMPLATES_TEMPLATE_BOX)) cnt = self.mw.col.models.useCount(self.model) self.tform.changes_affect_label.setText( self.col.tr(TR.CARD_TEMPLATES_CHANGES_WILL_AFFECT_NOTES, count=cnt) ) qconnect(tform.edit_area.textChanged, self.write_edits_to_template_and_redraw) qconnect(tform.front_button.clicked, self.on_editor_toggled) qconnect(tform.back_button.clicked, self.on_editor_toggled) qconnect(tform.style_button.clicked, self.on_editor_toggled) self.current_editor_index = 0 self.tform.edit_area.setAcceptRichText(False) self.tform.edit_area.setFont(QFont("Courier")) if qtminor < 10: self.tform.edit_area.setTabStopWidth(30) else: tab_width = self.fontMetrics().width(" " * 4) self.tform.edit_area.setTabStopDistance(tab_width) widg = tform.search_edit widg.setPlaceholderText("Search") qconnect(widg.textChanged, self.on_search_changed) qconnect(widg.returnPressed, self.on_search_next) def setup_cloze_number_box(self): names = (_("Cloze %d") % n for n in self.cloze_numbers) self.pform.cloze_number_combo.addItems(names) try: idx = self.cloze_numbers.index(self.ord + 1) self.pform.cloze_number_combo.setCurrentIndex(idx) except ValueError: # invalid cloze pass qconnect( self.pform.cloze_number_combo.currentIndexChanged, self.on_change_cloze ) def on_change_cloze(self, idx: int) -> None: self.ord = self.cloze_numbers[idx] - 1 self.have_autoplayed = False self._renderPreview() def on_editor_toggled(self): if self.tform.front_button.isChecked(): self.current_editor_index = 0 self.pform.preview_front.setChecked(True) self.on_preview_toggled() self.add_field_button.setHidden(False) elif self.tform.back_button.isChecked(): self.current_editor_index = 1 self.pform.preview_back.setChecked(True) self.on_preview_toggled() self.add_field_button.setHidden(False) else: self.current_editor_index = 2 self.add_field_button.setHidden(True) self.fill_fields_from_template() def on_search_changed(self, text: str): editor = self.tform.edit_area if not editor.find(text): # try again from top cursor = editor.textCursor() cursor.movePosition(QTextCursor.Start) editor.setTextCursor(cursor) if not editor.find(text): tooltip("No matches found.") def on_search_next(self): text = self.tform.search_edit.text() self.on_search_changed(text) def setup_preview(self): pform = self.pform self.preview_web = AnkiWebView(title="card layout") pform.verticalLayout.addWidget(self.preview_web) pform.verticalLayout.setStretch(1, 99) pform.preview_front.isChecked() qconnect(pform.preview_front.clicked, self.on_preview_toggled) qconnect(pform.preview_back.clicked, self.on_preview_toggled) if self._want_fill_empty_on: pform.fill_empty.setChecked(True) qconnect(pform.fill_empty.toggled, self.on_preview_toggled) if not self.note_has_empty_field(): pform.fill_empty.setHidden(True) pform.fill_empty.setText(tr(TR.CARD_TEMPLATES_FILL_EMPTY)) jsinc = [ "jquery.js", "browsersel.js", "mathjax/conf.js", "mathjax/MathJax.js", "reviewer.js", ] self.preview_web.stdHtml( self.mw.reviewer.revHtml(), css=["reviewer.css"], js=jsinc, context=self, ) self.preview_web.set_bridge_command(self._on_bridge_cmd, self) if self._isCloze(): nums = list(self.note.cloze_numbers_in_fields()) if self.ord + 1 not in nums: # current card is empty nums.append(self.ord + 1) self.cloze_numbers = sorted(nums) self.setup_cloze_number_box() else: self.cloze_numbers = [] self.pform.cloze_number_combo.setHidden(True) def on_preview_toggled(self): self.have_autoplayed = False self._renderPreview() def _on_bridge_cmd(self, cmd: str) -> Any: if cmd.startswith("play:"): play_clicked_audio(cmd, self.rendered_card) def note_has_empty_field(self) -> bool: for field in self.note.fields: if not field.strip(): # ignores HTML, but this should suffice return True return False # Buttons ########################################################################## def setupButtons(self): l = self.buttons = QHBoxLayout() help = QPushButton(_("Help")) help.setAutoDefault(False) l.addWidget(help) qconnect(help.clicked, self.onHelp) l.addStretch() self.add_field_button = QPushButton(_("Add Field")) self.add_field_button.setAutoDefault(False) l.addWidget(self.add_field_button) qconnect(self.add_field_button.clicked, self.onAddField) if not self._isCloze(): flip = QPushButton(_("Flip")) flip.setAutoDefault(False) l.addWidget(flip) qconnect(flip.clicked, self.onFlip) l.addStretch() save = QPushButton(_("Save")) save.setAutoDefault(False) l.addWidget(save) qconnect(save.clicked, self.accept) close = QPushButton(_("Cancel")) close.setAutoDefault(False) l.addWidget(close) qconnect(close.clicked, self.reject) # Reading/writing question/answer/css ########################################################################## def current_template(self) -> Dict: if self._isCloze(): return self.templates[0] return self.templates[self.ord] def fill_fields_from_template(self): t = self.current_template() self.ignore_change_signals = True if self.current_editor_index == 0: text = t["qfmt"] elif self.current_editor_index == 1: text = t["afmt"] else: text = self.model["css"] self.tform.edit_area.setPlainText(text) self.ignore_change_signals = False def write_edits_to_template_and_redraw(self): if self.ignore_change_signals: return self.change_tracker.mark_basic() text = self.tform.edit_area.toPlainText() if self.current_editor_index == 0: self.current_template()["qfmt"] = text elif self.current_editor_index == 1: self.current_template()["afmt"] = text else: self.model["css"] = text self.renderPreview() # Preview ########################################################################## _previewTimer = None def renderPreview(self): # schedule a preview when timing stops self.cancelPreviewTimer() self._previewTimer = self.mw.progress.timer(200, self._renderPreview, False) def cancelPreviewTimer(self): if self._previewTimer: self._previewTimer.stop() self._previewTimer = None def _renderPreview(self) -> None: self.cancelPreviewTimer() c = self.rendered_card = self.ephemeral_card_for_rendering() ti = self.maybeTextInput bodyclass = theme_manager.body_classes_for_card_ord(c.ord) if self.pform.preview_front.isChecked(): q = ti(self.mw.prepare_card_text_for_display(c.q())) q = gui_hooks.card_will_show(q, c, "clayoutQuestion") text = q else: a = ti(self.mw.prepare_card_text_for_display(c.a()), type="a") a = gui_hooks.card_will_show(a, c, "clayoutAnswer") text = a # use _showAnswer to avoid the longer delay self.preview_web.eval("_showAnswer(%s,'%s');" % (json.dumps(text), bodyclass)) if not self.have_autoplayed: self.have_autoplayed = True if c.autoplay(): if self.pform.preview_front.isChecked(): audio = c.question_av_tags() else: audio = c.answer_av_tags() av_player.play_tags(audio) else: av_player.clear_queue_and_maybe_interrupt() self.updateCardNames() def maybeTextInput(self, txt, type="q"): if "[[type:" not in txt: return txt origLen = len(txt) txt = txt.replace("