From 05876f1299fb7751b51c4b821e8562c28ece469a Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 19 Mar 2021 00:06:54 +1000 Subject: [PATCH] cache card list cell content Qt is pretty enthusiastic about redrawing the card list when any sort of activity occurs, and by serving blank cells while the DB was busy, we were getting ugly flashes, and cells getting stuck blank. Resolve the issue by calculating a row up front and caching it, then serving stale content when updates are blocked. --- qt/aqt/browser.py | 263 ++++++++++++++++++++++++++++++++-------------- qt/aqt/main.py | 2 + 2 files changed, 185 insertions(+), 80 deletions(-) diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 16e3ac27d..fffcfc91e 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -6,7 +6,7 @@ from __future__ import annotations import html import time from concurrent.futures import Future -from dataclasses import dataclass +from dataclasses import dataclass, field from operator import itemgetter from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union, cast @@ -98,6 +98,25 @@ class SearchContext: # Data model ########################################################################## +# temporary cache to avoid hitting the database on redraw +@dataclass +class Cell: + text: str = "" + font: Optional[Tuple[str, int]] = None + is_rtl: bool = False + + +@dataclass +class CellRow: + columns: List[Cell] + refreshed_at: float = field(default_factory=time.time) + card_flag: int = 0 + marked: bool = False + suspended: bool = False + + def is_stale(self, threshold: float) -> bool: + return self.refreshed_at < threshold + class DataModel(QAbstractTableModel): def __init__(self, browser: Browser) -> None: @@ -110,11 +129,17 @@ class DataModel(QAbstractTableModel): ) self.cards: Sequence[int] = [] self.cardObjs: Dict[int, Card] = {} - self._refresh_needed = False + self._row_cache: Dict[int, CellRow] = {} + self._last_refresh = 0.0 + # serve stale content to avoid hitting the DB? self.block_updates = False def getCard(self, index: QModelIndex) -> Optional[Card]: - id = self.cards[index.row()] + return self._get_card_by_row(index.row()) + + def _get_card_by_row(self, row: int) -> Optional[Card]: + "None if card is not in DB." + id = self.cards[row] if not id in self.cardObjs: try: card = self.col.getCard(id) @@ -124,6 +149,53 @@ class DataModel(QAbstractTableModel): self.cardObjs[id] = card return self.cardObjs[id] + # Card and cell data cache + ###################################################################### + # Stopgap until we can fetch this data a row at a time from Rust. + + def get_cell(self, index: QModelIndex) -> Cell: + row = self.get_row(index.row()) + return row.columns[index.column()] + + def get_row(self, row: int) -> CellRow: + if entry := self._row_cache.get(row): + if not self.block_updates and entry.is_stale(self._last_refresh): + # need to refresh + entry = self._build_cell_row(row) + self._row_cache[row] = entry + return entry + else: + # return entry, even if it's stale + return entry + elif self.block_updates: + # blank entry until we unblock + return CellRow(columns=[Cell(text="blocked")] * len(self.activeCols)) + else: + # missing entry, need to build + entry = self._build_cell_row(row) + self._row_cache[row] = entry + return entry + + def _build_cell_row(self, row: int) -> CellRow: + if not (card := self._get_card_by_row(row)): + cell = Cell(text=tr(TR.BROWSING_ROW_DELETED)) + return CellRow(columns=[cell] * len(self.activeCols)) + + return CellRow( + columns=[ + Cell( + text=self._column_data(card, column_type), + font=self._font(card, column_type), + is_rtl=self._is_rtl(card, column_type), + ) + for column_type in self.activeCols + ], + # should probably make these an enum instead? + card_flag=card.user_flag(), + marked=card.note().has_tag(MARKED_TAG), + suspended=card.queue == QUEUE_TYPE_SUSPENDED, + ) + # Model interface ###################################################################### @@ -138,23 +210,16 @@ class DataModel(QAbstractTableModel): return len(self.activeCols) def data(self, index: QModelIndex = QModelIndex(), role: int = 0) -> Any: - if self.block_updates: - return if not index.isValid(): return if role == Qt.FontRole: - if self.activeCols[index.column()] not in ("question", "answer", "noteFld"): - return - c = self.getCard(index) - if not c: - return - t = c.template() - if not t.get("bfont"): - return - f = QFont() - f.setFamily(cast(str, t.get("bfont", "arial"))) - f.setPixelSize(cast(int, t.get("bsize", 12))) - return f + if font := self.get_cell(index).font: + qfont = QFont() + qfont.setFamily(font[0]) + qfont.setPixelSize(font[1]) + return qfont + else: + return None elif role == Qt.TextAlignmentRole: align: Union[Qt.AlignmentFlag, int] = Qt.AlignVCenter @@ -170,7 +235,7 @@ class DataModel(QAbstractTableModel): align |= Qt.AlignHCenter return align elif role == Qt.DisplayRole or role == Qt.EditRole: - return self.columnData(index) + return self.get_cell(index).text else: return @@ -220,7 +285,7 @@ class DataModel(QAbstractTableModel): return top_left = self.index(0, 0) bottom_right = self.index(len(self.cards) - 1, len(self.activeCols) - 1) - self.cardObjs = {} + self._last_refresh = time.time() self.dataChanged.emit(top_left, bottom_right) # type: ignore def reset(self) -> None: @@ -234,6 +299,7 @@ class DataModel(QAbstractTableModel): self.saveSelection() self.beginResetModel() self.cardObjs = {} + self._row_cache = {} def endReset(self) -> None: self.endResetModel() @@ -305,15 +371,19 @@ class DataModel(QAbstractTableModel): tv.selectRow(0) def op_executed(self, op: OpChanges, focused: bool) -> None: + print("op executed") if op.card or op.note or op.deck or op.notetype: - self._refresh_needed = True + # clear card cache + self.cardObjs = {} if focused: - self.refresh_if_needed() - - def refresh_if_needed(self) -> None: - if self._refresh_needed: self.redraw_cells() - self._refresh_needed = False + + def begin_blocking(self) -> None: + self.block_updates = True + + def end_blocking(self) -> None: + self.block_updates = False + self.redraw_cells() # Column data ###################################################################### @@ -324,66 +394,87 @@ class DataModel(QAbstractTableModel): def time_format(self) -> str: return "%Y-%m-%d" + def _font(self, card: Card, column_type: str) -> Optional[Tuple[str, int]]: + if column_type not in ("question", "answer", "noteFld"): + return None + + template = card.template() + if not template.get("bfont"): + return None + + return ( + cast(str, template.get("bfont", "arial")), + cast(int, template.get("bsize", 12)), + ) + + # legacy def columnData(self, index: QModelIndex) -> str: col = index.column() type = self.columnType(col) c = self.getCard(index) if not c: return tr(TR.BROWSING_ROW_DELETED) + else: + return self._column_data(c, type) + + def _column_data(self, card: Card, column_type: str) -> str: + type = column_type if type == "question": - return self.question(c) + return self.question(card) elif type == "answer": - return self.answer(c) + return self.answer(card) elif type == "noteFld": - f = c.note() + f = card.note() return htmlToTextLine(f.fields[self.col.models.sortIdx(f.model())]) elif type == "template": - t = c.template()["name"] - if c.model()["type"] == MODEL_CLOZE: - t = f"{t} {c.ord + 1}" + t = card.template()["name"] + if card.model()["type"] == MODEL_CLOZE: + t = f"{t} {card.ord + 1}" return cast(str, t) elif type == "cardDue": # catch invalid dates try: - t = self.nextDue(c, index) + t = self._next_due(card) except: t = "" - if c.queue < 0: + if card.queue < 0: t = f"({t})" return t elif type == "noteCrt": - return time.strftime(self.time_format(), time.localtime(c.note().id / 1000)) + return time.strftime( + self.time_format(), time.localtime(card.note().id / 1000) + ) elif type == "noteMod": - return time.strftime(self.time_format(), time.localtime(c.note().mod)) + return time.strftime(self.time_format(), time.localtime(card.note().mod)) elif type == "cardMod": - return time.strftime(self.time_format(), time.localtime(c.mod)) + return time.strftime(self.time_format(), time.localtime(card.mod)) elif type == "cardReps": - return str(c.reps) + return str(card.reps) elif type == "cardLapses": - return str(c.lapses) + return str(card.lapses) elif type == "noteTags": - return " ".join(c.note().tags) + return " ".join(card.note().tags) elif type == "note": - return c.model()["name"] + return card.model()["name"] elif type == "cardIvl": - if c.type == CARD_TYPE_NEW: + if card.type == CARD_TYPE_NEW: return tr(TR.BROWSING_NEW) - elif c.type == CARD_TYPE_LRN: + elif card.type == CARD_TYPE_LRN: return tr(TR.BROWSING_LEARNING) - return self.col.format_timespan(c.ivl * 86400) + return self.col.format_timespan(card.ivl * 86400) elif type == "cardEase": - if c.type == CARD_TYPE_NEW: + if card.type == CARD_TYPE_NEW: return tr(TR.BROWSING_NEW) - return "%d%%" % (c.factor / 10) + return "%d%%" % (card.factor / 10) elif type == "deck": - if c.odid: + if card.odid: # in a cram deck return "%s (%s)" % ( - self.browser.mw.col.decks.name(c.did), - self.browser.mw.col.decks.name(c.odid), + self.browser.mw.col.decks.name(card.did), + self.browser.mw.col.decks.name(card.odid), ) # normal deck - return self.browser.mw.col.decks.name(c.did) + return self.browser.mw.col.decks.name(card.did) else: return "" @@ -402,30 +493,38 @@ class DataModel(QAbstractTableModel): return a[len(q) :].strip() return a + # legacy def nextDue(self, c: Card, index: QModelIndex) -> str: + return self._next_due(c) + + def _next_due(self, card: Card) -> str: date: float - if c.odid: + if card.odid: return tr(TR.BROWSING_FILTERED) - elif c.queue == QUEUE_TYPE_LRN: - date = c.due - elif c.queue == QUEUE_TYPE_NEW or c.type == CARD_TYPE_NEW: - return tr(TR.STATISTICS_DUE_FOR_NEW_CARD, number=c.due) - elif c.queue in (QUEUE_TYPE_REV, QUEUE_TYPE_DAY_LEARN_RELEARN) or ( - c.type == CARD_TYPE_REV and c.queue < 0 + elif card.queue == QUEUE_TYPE_LRN: + date = card.due + elif card.queue == QUEUE_TYPE_NEW or card.type == CARD_TYPE_NEW: + return tr(TR.STATISTICS_DUE_FOR_NEW_CARD, number=card.due) + elif card.queue in (QUEUE_TYPE_REV, QUEUE_TYPE_DAY_LEARN_RELEARN) or ( + card.type == CARD_TYPE_REV and card.queue < 0 ): - date = time.time() + ((c.due - self.col.sched.today) * 86400) + date = time.time() + ((card.due - self.col.sched.today) * 86400) else: return "" return time.strftime(self.time_format(), time.localtime(date)) + # legacy def isRTL(self, index: QModelIndex) -> bool: col = index.column() type = self.columnType(col) - if type != "noteFld": + c = self.getCard(index) + return self._is_rtl(c, type) + + def _is_rtl(self, card: Card, column_type: str) -> bool: + if column_type != "noteFld": return False - c = self.getCard(index) - nt = c.note().model() + nt = card.note().model() return nt["flds"][self.col.models.sortIdx(nt)]["rtl"] @@ -442,25 +541,23 @@ class StatusDelegate(QItemDelegate): def paint( self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex ) -> None: - if self.model.block_updates: - return QItemDelegate.paint(self, painter, option, index) + row = self.model.get_row(index.row()) + cell = row.columns[index.column()] - c = self.model.getCard(index) - if not c: - return QItemDelegate.paint(self, painter, option, index) - - if self.model.isRTL(index): + if cell.is_rtl: option.direction = Qt.RightToLeft - col = None - if c.user_flag() > 0: - col = getattr(colors, f"FLAG{c.user_flag()}_BG") - elif c.note().has_tag(MARKED_TAG): - col = colors.MARKED_BG - elif c.queue == QUEUE_TYPE_SUSPENDED: - col = colors.SUSPENDED_BG - if col: - brush = QBrush(theme_manager.qcolor(col)) + if row.card_flag: + color = getattr(colors, f"FLAG{row.card_flag}_BG") + elif row.marked: + color = colors.MARKED_BG + elif row.suspended: + color = colors.SUSPENDED_BG + else: + color = None + + if color: + brush = QBrush(theme_manager.qcolor(color)) painter.save() painter.fillRect(option.rect, brush) painter.restore() @@ -519,10 +616,10 @@ class Browser(QMainWindow): def on_backend_will_block(self) -> None: # make sure the card list doesn't try to refresh itself during the operation, # as that will block the UI - self.model.block_updates = True + self.model.begin_blocking() def on_backend_did_block(self) -> None: - self.model.block_updates = False + self.model.end_blocking() def on_operation_did_execute(self, changes: OpChanges) -> None: focused = current_top_level_widget() == self @@ -530,9 +627,15 @@ class Browser(QMainWindow): self.sidebar.op_executed(changes, focused) if changes.note or changes.notetype: if not self.editor.is_updating_note(): + # fixme: this will leave the splitter shown, but with no current + # note being edited note = self.editor.note if note: - note.load() + try: + note.load() + except NotFoundError: + self.editor.set_note(None) + return self.editor.set_note(note) self._renderPreview() @@ -540,7 +643,7 @@ class Browser(QMainWindow): def on_focus_change(self, new: Optional[QWidget], old: Optional[QWidget]) -> None: if current_top_level_widget() == self: self.setUpdatesEnabled(True) - self.model.refresh_if_needed() + self.model.redraw_cells() self.sidebar.refresh_if_needed() def setupMenus(self) -> None: diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 06d18617e..f8d09ff1d 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -1254,6 +1254,8 @@ title="%s" %s>%s""" % ( if on_done: on_done(result) + # fixme: perform_op? -> needs to save + # fixme: parent self.taskman.with_progress(self.col.undo, on_done_outer) def update_undo_actions(self) -> None: