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:
parent
09076da937
commit
05876f1299
@ -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:
|
||||
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:
|
||||
|
@ -1254,6 +1254,8 @@ title="%s" %s>%s</button>""" % (
|
||||
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:
|
||||
|
Loading…
Reference in New Issue
Block a user