diff --git a/proto/backend.proto b/proto/backend.proto index 806929e66..43dcea93e 100644 --- a/proto/backend.proto +++ b/proto/backend.proto @@ -33,7 +33,7 @@ message BackendInput { DeckTreeIn deck_tree = 18; SearchCardsIn search_cards = 19; SearchNotesIn search_notes = 20; -// RenderCardIn render_card = 21; + RenderUncommittedCardIn render_uncommitted_card = 21; int64 local_minutes_west = 22; string strip_av_tags = 23; ExtractAVTagsIn extract_av_tags = 24; @@ -120,7 +120,7 @@ message BackendOutput { DeckTreeNode deck_tree = 18; SearchCardsOut search_cards = 19; SearchNotesOut search_notes = 20; -// RenderCardOut render_card = 21; + RenderCardOut render_uncommitted_card = 21; string add_media_file = 26; Empty sync_media = 27; MediaCheckOut check_media = 28; @@ -269,6 +269,12 @@ message RenderExistingCardIn { bool browser = 2; } +message RenderUncommittedCardIn { + Note note = 1; + uint32 card_ord = 2; + bytes template = 3; +} + message RenderCardOut { repeated RenderedTemplateNode question_nodes = 1; repeated RenderedTemplateNode answer_nodes = 2; diff --git a/pylib/anki/cards.py b/pylib/anki/cards.py index 75ff244f5..29248df2f 100644 --- a/pylib/anki/cards.py +++ b/pylib/anki/cards.py @@ -14,7 +14,6 @@ from anki.models import NoteType, Template from anki.notes import Note from anki.rsbackend import BackendCard from anki.sound import AVTag -from anki.utils import joinFields # Cards ########################################################################## @@ -133,6 +132,9 @@ class Card: ).render() return self._render_output + def set_render_output(self, output: anki.template.TemplateRenderOutput) -> None: + self._render_output = output + def note(self, reload: bool = False) -> Note: if not self._note or reload: self._note = self.col.getNote(self.nid) diff --git a/pylib/anki/models.py b/pylib/anki/models.py index 5f3a31a7f..2739ee9cd 100644 --- a/pylib/anki/models.py +++ b/pylib/anki/models.py @@ -408,24 +408,27 @@ class ModelManager: t["name"] = name return t - def addTemplate(self, m: NoteType, template: Template) -> None: + def addTemplate(self, m: NoteType, template: Template, save=True) -> None: if m["id"]: self.col.modSchema(check=True) m["tmpls"].append(template) - if m["id"]: + if save and m["id"]: self.save(m) - def remTemplate(self, m: NoteType, template: Template) -> None: + def remTemplate(self, m: NoteType, template: Template, save=True) -> None: assert len(m["tmpls"]) > 1 self.col.modSchema(check=True) m["tmpls"].remove(template) - self.save(m) + if save: + self.save(m) - def moveTemplate(self, m: NoteType, template: Template, idx: int) -> None: + def moveTemplate( + self, m: NoteType, template: Template, idx: int, save=True + ) -> None: self.col.modSchema(check=True) oldidx = m["tmpls"].index(template) @@ -435,7 +438,8 @@ class ModelManager: m["tmpls"].remove(template) m["tmpls"].insert(idx, template) - self.save(m) + if save: + self.save(m) # Model changing ########################################################################## diff --git a/pylib/anki/rsbackend.py b/pylib/anki/rsbackend.py index dc09b6ab0..de3287105 100644 --- a/pylib/anki/rsbackend.py +++ b/pylib/anki/rsbackend.py @@ -312,6 +312,23 @@ class RustBackend: return PartiallyRenderedCard(qnodes, anodes) + def render_uncommitted_card( + self, note: BackendNote, card_ord: int, template: Dict + ) -> PartiallyRenderedCard: + template_json = orjson.dumps(template) + out = self._run_command( + pb.BackendInput( + render_uncommitted_card=pb.RenderUncommittedCardIn( + note=note, template=template_json, card_ord=card_ord + ) + ) + ).render_uncommitted_card + + qnodes = proto_replacement_list_to_native(out.question_nodes) # type: ignore + anodes = proto_replacement_list_to_native(out.answer_nodes) # type: ignore + + return PartiallyRenderedCard(qnodes, anodes) + def local_minutes_west(self, stamp: int) -> int: return self._run_command( pb.BackendInput(local_minutes_west=stamp) diff --git a/pylib/anki/template.py b/pylib/anki/template.py index 26124ecfa..298f4db8f 100644 --- a/pylib/anki/template.py +++ b/pylib/anki/template.py @@ -56,15 +56,10 @@ class TemplateRenderContext: return TemplateRenderContext(card.col, card, card.note(), browser) @classmethod - def from_card_layout(cls, note: Note, card_ord: int) -> TemplateRenderContext: - card = cls.synthesized_card(note.col, card_ord) - return TemplateRenderContext(note.col, card, note) - - @classmethod - def synthesized_card(cls, col: anki.storage._Collection, ord: int): - c = Card(col) - c.ord = ord - return c + def from_card_layout( + cls, note: Note, card: Card, template: Dict + ) -> TemplateRenderContext: + return TemplateRenderContext(note.col, card, note, template=template) def __init__( self, @@ -72,7 +67,7 @@ class TemplateRenderContext: card: Card, note: Note, browser: bool = False, - template: Optional[Any] = None, + template: Optional[Dict] = None, ) -> None: self._col = col.weakref() self._card = card @@ -146,7 +141,9 @@ class TemplateRenderContext: def _partially_render(self) -> PartiallyRenderedCard: if self._template: # card layout screen - raise Exception("nyi") + return self._col.backend.render_uncommitted_card( + self._note.to_backend_note(), self._card.ord, self._template + ) else: # existing card (eg study mode) return self._col.backend.render_existing_card(self._card.id, self._browser) diff --git a/qt/aqt/clayout.py b/qt/aqt/clayout.py index 150f5ddaa..463030000 100644 --- a/qt/aqt/clayout.py +++ b/qt/aqt/clayout.py @@ -2,10 +2,9 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -import collections import json import re -from typing import Optional +from typing import List, Optional import aqt from anki.cards import Card @@ -13,6 +12,7 @@ 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 anki.utils import isMac, isWin, joinFields from aqt import AnkiQt, gui_hooks from aqt.qt import * @@ -27,41 +27,34 @@ from aqt.utils import ( saveGeom, showInfo, showWarning, + tooltip, ) from aqt.webview import AnkiWebView +# fixme: removeColons() +# fixme: previewing with empty fields +# fixme: deck name on new cards +# fixme: card count when removing +# fixme: i18n +# fixme: change tracking and tooltip in fields + class CardLayout(QDialog): - card: Optional[Card] - def __init__( - self, - mw: AnkiQt, - note: Note, - ord=0, - parent: Optional[QWidget] = None, - addMode=False, + self, mw: AnkiQt, note: Note, ord=0, parent: Optional[QWidget] = None, ): 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 + self.col = self.mw.col.weakref() self.mm = self.mw.col.models self.model = note.model() + self.templates = self.model["tmpls"] + self.mm._remove_from_cache(self.model["id"]) self.mw.checkpoint(_("Card Types")) - self.addMode = addMode - if addMode: - # save it to DB temporarily - self.emptyFields = [] - for name, val in list(note.items()): - if val.strip(): - continue - self.emptyFields.append(name) - note[name] = "(%s)" % name - note.flush() - self.removeColons() + self.changed = False self.setupTopArea() self.setupMainArea() self.setupButtons() @@ -74,7 +67,7 @@ class CardLayout(QDialog): v1.setContentsMargins(12, 12, 12, 12) self.setLayout(v1) gui_hooks.card_layout_will_show(self) - self.redraw() + self.redraw_everything() restoreGeom(self, "CardLayout") self.setWindowModality(Qt.ApplicationModal) self.show() @@ -82,32 +75,26 @@ class CardLayout(QDialog): # as users tend to accidentally type into the template self.setFocus() - def redraw(self): - did = None - if hasattr(self.parent(), "deckChooser"): - did = self.parent().deckChooser.selectedId() - self.cards = self.col.previewCards(self.note, 2, did=did) - idx = self.ord - if idx >= len(self.cards): - self.ord = len(self.cards) - 1 - - self.redrawing = True + def redraw_everything(self): + self.ignore_change_signals = True self.updateTopArea() self.updateMainArea() - self.redrawing = False - self.onCardSelected(self.ord) + self.ignore_change_signals = False + self.update_current_ordinal_and_redraw(self.ord) - def setupShortcuts(self): - for i in range(1, 9): - QShortcut( - QKeySequence("Ctrl+%d" % i), - self, - activated=lambda i=i: self.selectCard(i), - ) # type: ignore + def update_current_ordinal_and_redraw(self, idx): + if self.ignore_change_signals: + return + self.ord = idx + self.playedAudio = {} + self.fill_fields_from_template() + self.renderPreview() - def selectCard(self, n): - self.ord = n - 1 - self.redraw() + def _isCloze(self): + return self.model["type"] == MODEL_CLOZE + + # Top area + ########################################################################## def setupTopArea(self): self.topArea = QWidget() @@ -115,7 +102,10 @@ class CardLayout(QDialog): self.topAreaForm.setupUi(self.topArea) self.topAreaForm.templateOptions.setText(_("Options") + " " + downArrow()) qconnect(self.topAreaForm.templateOptions.clicked, self.onMore) - qconnect(self.topAreaForm.templatesBox.currentIndexChanged, self.onCardSelected) + qconnect( + self.topAreaForm.templatesBox.currentIndexChanged, + self.update_current_ordinal_and_redraw, + ) def updateTopArea(self): cnt = self.mw.col.models.useCount(self.model) @@ -130,17 +120,19 @@ class CardLayout(QDialog): self.updateCardNames() def updateCardNames(self): - self.redrawing = True + self.ignore_change_signals = True combo = self.topAreaForm.templatesBox combo.clear() - combo.addItems(self._summarizedName(t) for t in self.model["tmpls"]) + combo.addItems( + self._summarizedName(idx, tmpl) for (idx, tmpl) in enumerate(self.templates) + ) combo.setCurrentIndex(self.ord) combo.setEnabled(not self._isCloze()) - self.redrawing = False + self.ignore_change_signals = False - def _summarizedName(self, tmpl): + def _summarizedName(self, idx: int, tmpl: Dict): return "{}: {}: {} -> {}".format( - tmpl["ord"] + 1, + idx + 1, tmpl["name"], self._fieldsOnTemplate(tmpl["qfmt"]), self._fieldsOnTemplate(tmpl["afmt"]), @@ -148,8 +140,8 @@ class CardLayout(QDialog): def _fieldsOnTemplate(self, fmt): matches = re.findall("{{[^#/}]+?}}", fmt) - charsAllowed = 30 - result: Dict[str, bool] = collections.OrderedDict() + chars_allowed = 30 + field_names: List[str] = [] for m in matches: # strip off mustache m = re.sub(r"[{}]", "", m) @@ -159,19 +151,30 @@ class CardLayout(QDialog): if m == "FrontSide": continue - if m not in result: - result[m] = True - charsAllowed -= len(m) - if charsAllowed <= 0: - break + field_names.append(m) + chars_allowed -= len(m) + if chars_allowed <= 0: + break - s = "+".join(result.keys()) - if charsAllowed <= 0: + s = "+".join(field_names) + if chars_allowed <= 0: s += "+..." return s - def _isCloze(self): - return self.model["type"] == MODEL_CLOZE + def setupShortcuts(self): + for i in range(1, 9): + QShortcut( + QKeySequence("Ctrl+%d" % i), + self, + activated=lambda i=i: self.selectCard(i), + ) # type: ignore + + def selectCard(self, n): + self.ord = n - 1 + self.redraw_everything() + + # Main area + ########################################################################## def setupMainArea(self): w = self.mainArea = QWidget() @@ -192,9 +195,9 @@ class CardLayout(QDialog): tform.tlayout2.setContentsMargins(0, 11, 0, 0) tform.tlayout3.setContentsMargins(0, 11, 0, 0) tform.groupBox_3.setTitle(_("Styling (shared between cards)")) - qconnect(tform.front.textChanged, self.saveCard) - qconnect(tform.css.textChanged, self.saveCard) - qconnect(tform.back.textChanged, self.saveCard) + qconnect(tform.front.textChanged, self.write_edits_to_template_and_redraw) + qconnect(tform.css.textChanged, self.write_edits_to_template_and_redraw) + qconnect(tform.back.textChanged, self.write_edits_to_template_and_redraw) l.addWidget(left, 5) # preview area right = QWidget() @@ -235,7 +238,7 @@ class CardLayout(QDialog): def _on_bridge_cmd(self, cmd: str) -> Any: if cmd.startswith("play:"): - play_clicked_audio(cmd, self.card) + play_clicked_audio(cmd, self.rendered_card) def updateMainArea(self): if self._isCloze(): @@ -243,23 +246,14 @@ class CardLayout(QDialog): for g in self.pform.groupBox, self.pform.groupBox_2: g.setTitle(g.title() + _(" (1 of %d)") % max(cnt, 1)) - def onRemove(self): - if len(self.model["tmpls"]) < 2: - return showInfo(_("At least one card type is required.")) - idx = self.ord - msg = _("Delete the '%(a)s' card type, and its %(b)s?") % dict( - a=self.model["tmpls"][idx]["name"], b=_("cards") - ) - if not askUser(msg): - return - self.mm.remTemplate(self.model, self.cards[idx].template()) - self.redraw() - - def removeColons(self): - # colons in field names conflict with the template language - for fld in self.model["flds"]: - if ":" in fld["name"]: - self.mm.renameField(self.model, fld, fld["name"]) + def ephemeral_card_for_rendering(self) -> Card: + card = Card(self.col) + card.ord = self.ord + output = TemplateRenderContext.from_card_layout( + self.note, card, template=self.current_template() + ).render() + card.set_render_output(output) + return card # Buttons ########################################################################## @@ -291,21 +285,15 @@ class CardLayout(QDialog): l.addWidget(close) qconnect(close.clicked, self.reject) - # Cards + # Reading/writing question/answer/css ########################################################################## - def onCardSelected(self, idx): - if self.redrawing: - return - self.card = self.cards[idx] - self.ord = idx - self.playedAudio = {} - self.readCard() - self.renderPreview() + def current_template(self) -> Dict: + return self.templates[self.ord] - def readCard(self): - t = self.card.template() - self.redrawing = True + def fill_fields_from_template(self): + t = self.current_template() + self.ignore_change_signals = True self.tform.front.setPlainText(t["qfmt"]) self.tform.css.setPlainText(self.model["css"]) self.tform.back.setPlainText(t["afmt"]) @@ -321,17 +309,16 @@ class CardLayout(QDialog): self.tform.front.setTabStopDistance(tab_width) self.tform.css.setTabStopDistance(tab_width) self.tform.back.setTabStopDistance(tab_width) - self.redrawing = False + self.ignore_change_signals = False - def saveCard(self): - if self.redrawing: + def write_edits_to_template_and_redraw(self): + if self.ignore_change_signals: return - text = self.tform.front.toPlainText() - self.card.template()["qfmt"] = text - text = self.tform.css.toPlainText() - self.card.model()["css"] = text - text = self.tform.back.toPlainText() - self.card.template()["afmt"] = text + self.changed = True + t = self.current_template() + t["qfmt"] = self.tform.front.toPlainText() + t["afmt"] = self.tform.back.toPlainText() + self.model["css"] = self.tform.css.toPlainText() self.renderPreview() # Preview @@ -342,7 +329,7 @@ class CardLayout(QDialog): def renderPreview(self): # schedule a preview when timing stops self.cancelPreviewTimer() - self._previewTimer = self.mw.progress.timer(500, self._renderPreview, False) + self._previewTimer = self.mw.progress.timer(200, self._renderPreview, False) def cancelPreviewTimer(self): if self._previewTimer: @@ -352,12 +339,13 @@ class CardLayout(QDialog): def _renderPreview(self) -> None: self.cancelPreviewTimer() - c = self.card + c = self.rendered_card = self.ephemeral_card_for_rendering() + ti = self.maybeTextInput bodyclass = theme_manager.body_classes_for_card_ord(c.ord) - q = ti(self.mw.prepare_card_text_for_display(c.q(reload=True))) + q = ti(self.mw.prepare_card_text_for_display(c.q())) q = gui_hooks.card_will_show(q, c, "clayoutQuestion") a = ti(self.mw.prepare_card_text_for_display(c.a()), type="a") @@ -398,21 +386,43 @@ class CardLayout(QDialog): # Card operations ###################################################################### - def onRename(self): - name = getOnlyText(_("New name:"), default=self.card.template()["name"]) - if not name: + def onRemove(self): + if len(self.templates) < 2: + return showInfo(_("At least one card type is required.")) + template = self.current_template() + msg = _("Delete the '%(a)s' card type, and its %(b)s?") % dict( + a=template["name"], b=_("cards") + ) + if not askUser(msg): return - if name in [ - c.template()["name"] for c in self.cards if c.template()["ord"] != self.ord - ]: - return showWarning(_("That name is already used.")) - self.card.template()["name"] = name - self.redraw() + + self.changed = True + self.mm.remTemplate(self.model, template, save=False) + + # ensure current ordinal is within bounds + idx = self.ord + if idx >= len(self.templates): + self.ord = len(self.templates) - 1 + + self.redraw_everything() + + def onRename(self): + template = self.current_template() + name = getOnlyText(_("New name:"), default=template["name"]) + if not name.strip(): + return + + self.changed = True + template["name"] = name + self.redraw_everything() def onReorder(self): - n = len(self.cards) - cur = self.card.template()["ord"] + 1 # type: ignore - pos = getOnlyText(_("Enter new card position (1...%s):") % n, default=str(cur)) + n = len(self.templates) + template = self.current_template() + current_pos = self.templates.index(template) + 1 + pos = getOnlyText( + _("Enter new card position (1...%s):") % n, default=str(current_pos) + ) if not pos: return try: @@ -421,18 +431,19 @@ class CardLayout(QDialog): return if pos < 1 or pos > n: return - if pos == cur: + if pos == current_pos: return - pos -= 1 - self.mm.moveTemplate(self.model, self.card.template(), pos) - self.ord = pos - self.redraw() + new_idx = pos - 1 + self.changed = True + self.mm.moveTemplate(self.model, template, new_idx, save=False) + self.ord = new_idx + self.redraw_everything() def _newCardName(self): - n = len(self.cards) + 1 + n = len(self.templates) + 1 while 1: name = _("Card %d") % n - if name not in [c.template()["name"] for c in self.cards]: + if name not in [t["name"] for t in self.templates]: break n += 1 return name @@ -449,19 +460,20 @@ class CardLayout(QDialog): ) if not askUser(txt): return + self.changed = True name = self._newCardName() t = self.mm.newTemplate(name) - old = self.card.template() + old = self.current_template() t["qfmt"] = old["qfmt"] t["afmt"] = old["afmt"] - self.mm.addTemplate(self.model, t) - self.ord = len(self.cards) - self.redraw() + self.mm.addTemplate(self.model, t, save=False) + self.ord = len(self.templates) - 1 + self.redraw_everything() def onFlip(self): - old = self.card.template() + old = self.current_template() self._flipQA(old, old) - self.redraw() + self.redraw_everything() def _flipQA(self, src, dst): m = re.match("(?s)(.+)