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.
This commit is contained in:
Damien Elmes 2021-03-19 00:06:54 +10:00
parent 09076da937
commit 05876f1299
2 changed files with 185 additions and 80 deletions

View File

@ -6,7 +6,7 @@ from __future__ import annotations
import html import html
import time import time
from concurrent.futures import Future from concurrent.futures import Future
from dataclasses import dataclass from dataclasses import dataclass, field
from operator import itemgetter from operator import itemgetter
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union, cast from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union, cast
@ -98,6 +98,25 @@ class SearchContext:
# Data model # 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): class DataModel(QAbstractTableModel):
def __init__(self, browser: Browser) -> None: def __init__(self, browser: Browser) -> None:
@ -110,11 +129,17 @@ class DataModel(QAbstractTableModel):
) )
self.cards: Sequence[int] = [] self.cards: Sequence[int] = []
self.cardObjs: Dict[int, Card] = {} 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 self.block_updates = False
def getCard(self, index: QModelIndex) -> Optional[Card]: 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: if not id in self.cardObjs:
try: try:
card = self.col.getCard(id) card = self.col.getCard(id)
@ -124,6 +149,53 @@ class DataModel(QAbstractTableModel):
self.cardObjs[id] = card self.cardObjs[id] = card
return self.cardObjs[id] 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 # Model interface
###################################################################### ######################################################################
@ -138,23 +210,16 @@ class DataModel(QAbstractTableModel):
return len(self.activeCols) return len(self.activeCols)
def data(self, index: QModelIndex = QModelIndex(), role: int = 0) -> Any: def data(self, index: QModelIndex = QModelIndex(), role: int = 0) -> Any:
if self.block_updates:
return
if not index.isValid(): if not index.isValid():
return return
if role == Qt.FontRole: if role == Qt.FontRole:
if self.activeCols[index.column()] not in ("question", "answer", "noteFld"): if font := self.get_cell(index).font:
return qfont = QFont()
c = self.getCard(index) qfont.setFamily(font[0])
if not c: qfont.setPixelSize(font[1])
return return qfont
t = c.template() else:
if not t.get("bfont"): return None
return
f = QFont()
f.setFamily(cast(str, t.get("bfont", "arial")))
f.setPixelSize(cast(int, t.get("bsize", 12)))
return f
elif role == Qt.TextAlignmentRole: elif role == Qt.TextAlignmentRole:
align: Union[Qt.AlignmentFlag, int] = Qt.AlignVCenter align: Union[Qt.AlignmentFlag, int] = Qt.AlignVCenter
@ -170,7 +235,7 @@ class DataModel(QAbstractTableModel):
align |= Qt.AlignHCenter align |= Qt.AlignHCenter
return align return align
elif role == Qt.DisplayRole or role == Qt.EditRole: elif role == Qt.DisplayRole or role == Qt.EditRole:
return self.columnData(index) return self.get_cell(index).text
else: else:
return return
@ -220,7 +285,7 @@ class DataModel(QAbstractTableModel):
return return
top_left = self.index(0, 0) top_left = self.index(0, 0)
bottom_right = self.index(len(self.cards) - 1, len(self.activeCols) - 1) 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 self.dataChanged.emit(top_left, bottom_right) # type: ignore
def reset(self) -> None: def reset(self) -> None:
@ -234,6 +299,7 @@ class DataModel(QAbstractTableModel):
self.saveSelection() self.saveSelection()
self.beginResetModel() self.beginResetModel()
self.cardObjs = {} self.cardObjs = {}
self._row_cache = {}
def endReset(self) -> None: def endReset(self) -> None:
self.endResetModel() self.endResetModel()
@ -305,15 +371,19 @@ class DataModel(QAbstractTableModel):
tv.selectRow(0) tv.selectRow(0)
def op_executed(self, op: OpChanges, focused: bool) -> None: def op_executed(self, op: OpChanges, focused: bool) -> None:
print("op executed")
if op.card or op.note or op.deck or op.notetype: if op.card or op.note or op.deck or op.notetype:
self._refresh_needed = True # clear card cache
self.cardObjs = {}
if focused: if focused:
self.refresh_if_needed()
def refresh_if_needed(self) -> None:
if self._refresh_needed:
self.redraw_cells() 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 # Column data
###################################################################### ######################################################################
@ -324,66 +394,87 @@ class DataModel(QAbstractTableModel):
def time_format(self) -> str: def time_format(self) -> str:
return "%Y-%m-%d" 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: def columnData(self, index: QModelIndex) -> str:
col = index.column() col = index.column()
type = self.columnType(col) type = self.columnType(col)
c = self.getCard(index) c = self.getCard(index)
if not c: if not c:
return tr(TR.BROWSING_ROW_DELETED) 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": if type == "question":
return self.question(c) return self.question(card)
elif type == "answer": elif type == "answer":
return self.answer(c) return self.answer(card)
elif type == "noteFld": elif type == "noteFld":
f = c.note() f = card.note()
return htmlToTextLine(f.fields[self.col.models.sortIdx(f.model())]) return htmlToTextLine(f.fields[self.col.models.sortIdx(f.model())])
elif type == "template": elif type == "template":
t = c.template()["name"] t = card.template()["name"]
if c.model()["type"] == MODEL_CLOZE: if card.model()["type"] == MODEL_CLOZE:
t = f"{t} {c.ord + 1}" t = f"{t} {card.ord + 1}"
return cast(str, t) return cast(str, t)
elif type == "cardDue": elif type == "cardDue":
# catch invalid dates # catch invalid dates
try: try:
t = self.nextDue(c, index) t = self._next_due(card)
except: except:
t = "" t = ""
if c.queue < 0: if card.queue < 0:
t = f"({t})" t = f"({t})"
return t return t
elif type == "noteCrt": 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": 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": 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": elif type == "cardReps":
return str(c.reps) return str(card.reps)
elif type == "cardLapses": elif type == "cardLapses":
return str(c.lapses) return str(card.lapses)
elif type == "noteTags": elif type == "noteTags":
return " ".join(c.note().tags) return " ".join(card.note().tags)
elif type == "note": elif type == "note":
return c.model()["name"] return card.model()["name"]
elif type == "cardIvl": elif type == "cardIvl":
if c.type == CARD_TYPE_NEW: if card.type == CARD_TYPE_NEW:
return tr(TR.BROWSING_NEW) return tr(TR.BROWSING_NEW)
elif c.type == CARD_TYPE_LRN: elif card.type == CARD_TYPE_LRN:
return tr(TR.BROWSING_LEARNING) return tr(TR.BROWSING_LEARNING)
return self.col.format_timespan(c.ivl * 86400) return self.col.format_timespan(card.ivl * 86400)
elif type == "cardEase": elif type == "cardEase":
if c.type == CARD_TYPE_NEW: if card.type == CARD_TYPE_NEW:
return tr(TR.BROWSING_NEW) return tr(TR.BROWSING_NEW)
return "%d%%" % (c.factor / 10) return "%d%%" % (card.factor / 10)
elif type == "deck": elif type == "deck":
if c.odid: if card.odid:
# in a cram deck # in a cram deck
return "%s (%s)" % ( return "%s (%s)" % (
self.browser.mw.col.decks.name(c.did), self.browser.mw.col.decks.name(card.did),
self.browser.mw.col.decks.name(c.odid), self.browser.mw.col.decks.name(card.odid),
) )
# normal deck # normal deck
return self.browser.mw.col.decks.name(c.did) return self.browser.mw.col.decks.name(card.did)
else: else:
return "" return ""
@ -402,30 +493,38 @@ class DataModel(QAbstractTableModel):
return a[len(q) :].strip() return a[len(q) :].strip()
return a return a
# legacy
def nextDue(self, c: Card, index: QModelIndex) -> str: def nextDue(self, c: Card, index: QModelIndex) -> str:
return self._next_due(c)
def _next_due(self, card: Card) -> str:
date: float date: float
if c.odid: if card.odid:
return tr(TR.BROWSING_FILTERED) return tr(TR.BROWSING_FILTERED)
elif c.queue == QUEUE_TYPE_LRN: elif card.queue == QUEUE_TYPE_LRN:
date = c.due date = card.due
elif c.queue == QUEUE_TYPE_NEW or c.type == CARD_TYPE_NEW: elif card.queue == QUEUE_TYPE_NEW or card.type == CARD_TYPE_NEW:
return tr(TR.STATISTICS_DUE_FOR_NEW_CARD, number=c.due) return tr(TR.STATISTICS_DUE_FOR_NEW_CARD, number=card.due)
elif c.queue in (QUEUE_TYPE_REV, QUEUE_TYPE_DAY_LEARN_RELEARN) or ( elif card.queue in (QUEUE_TYPE_REV, QUEUE_TYPE_DAY_LEARN_RELEARN) or (
c.type == CARD_TYPE_REV and c.queue < 0 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: else:
return "" return ""
return time.strftime(self.time_format(), time.localtime(date)) return time.strftime(self.time_format(), time.localtime(date))
# legacy
def isRTL(self, index: QModelIndex) -> bool: def isRTL(self, index: QModelIndex) -> bool:
col = index.column() col = index.column()
type = self.columnType(col) 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 return False
c = self.getCard(index) nt = card.note().model()
nt = c.note().model()
return nt["flds"][self.col.models.sortIdx(nt)]["rtl"] return nt["flds"][self.col.models.sortIdx(nt)]["rtl"]
@ -442,25 +541,23 @@ class StatusDelegate(QItemDelegate):
def paint( def paint(
self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex
) -> None: ) -> None:
if self.model.block_updates: row = self.model.get_row(index.row())
return QItemDelegate.paint(self, painter, option, index) cell = row.columns[index.column()]
c = self.model.getCard(index) if cell.is_rtl:
if not c:
return QItemDelegate.paint(self, painter, option, index)
if self.model.isRTL(index):
option.direction = Qt.RightToLeft option.direction = Qt.RightToLeft
col = None if row.card_flag:
if c.user_flag() > 0: color = getattr(colors, f"FLAG{row.card_flag}_BG")
col = getattr(colors, f"FLAG{c.user_flag()}_BG") elif row.marked:
elif c.note().has_tag(MARKED_TAG): color = colors.MARKED_BG
col = colors.MARKED_BG elif row.suspended:
elif c.queue == QUEUE_TYPE_SUSPENDED: color = colors.SUSPENDED_BG
col = colors.SUSPENDED_BG else:
if col: color = None
brush = QBrush(theme_manager.qcolor(col))
if color:
brush = QBrush(theme_manager.qcolor(color))
painter.save() painter.save()
painter.fillRect(option.rect, brush) painter.fillRect(option.rect, brush)
painter.restore() painter.restore()
@ -519,10 +616,10 @@ class Browser(QMainWindow):
def on_backend_will_block(self) -> None: def on_backend_will_block(self) -> None:
# make sure the card list doesn't try to refresh itself during the operation, # make sure the card list doesn't try to refresh itself during the operation,
# as that will block the UI # as that will block the UI
self.model.block_updates = True self.model.begin_blocking()
def on_backend_did_block(self) -> None: 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: def on_operation_did_execute(self, changes: OpChanges) -> None:
focused = current_top_level_widget() == self focused = current_top_level_widget() == self
@ -530,9 +627,15 @@ class Browser(QMainWindow):
self.sidebar.op_executed(changes, focused) self.sidebar.op_executed(changes, focused)
if changes.note or changes.notetype: if changes.note or changes.notetype:
if not self.editor.is_updating_note(): 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 note = self.editor.note
if note: if note:
note.load() try:
note.load()
except NotFoundError:
self.editor.set_note(None)
return
self.editor.set_note(note) self.editor.set_note(note)
self._renderPreview() self._renderPreview()
@ -540,7 +643,7 @@ class Browser(QMainWindow):
def on_focus_change(self, new: Optional[QWidget], old: Optional[QWidget]) -> None: def on_focus_change(self, new: Optional[QWidget], old: Optional[QWidget]) -> None:
if current_top_level_widget() == self: if current_top_level_widget() == self:
self.setUpdatesEnabled(True) self.setUpdatesEnabled(True)
self.model.refresh_if_needed() self.model.redraw_cells()
self.sidebar.refresh_if_needed() self.sidebar.refresh_if_needed()
def setupMenus(self) -> None: def setupMenus(self) -> None:

View File

@ -1254,6 +1254,8 @@ title="%s" %s>%s</button>""" % (
if on_done: if on_done:
on_done(result) on_done(result)
# fixme: perform_op? -> needs to save
# fixme: parent
self.taskman.with_progress(self.col.undo, on_done_outer) self.taskman.with_progress(self.col.undo, on_done_outer)
def update_undo_actions(self) -> None: def update_undo_actions(self) -> None: