Use backend rows in browser.py

This commit is contained in:
RumovZ 2021-03-20 12:03:26 +01:00
parent c68a6131e0
commit 922fccee58
2 changed files with 116 additions and 245 deletions

View file

@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, List, Literal, Optional, Sequence, Tuple, Union from typing import Any, Generator, List, Literal, Optional, Sequence, Tuple, Union
import anki._backend.backend_pb2 as _pb import anki._backend.backend_pb2 as _pb
@ -18,6 +18,7 @@ UndoStatus = _pb.UndoStatus
OpChanges = _pb.OpChanges OpChanges = _pb.OpChanges
OpChangesWithCount = _pb.OpChangesWithCount OpChangesWithCount = _pb.OpChangesWithCount
DefaultsForAdding = _pb.DeckAndNotetype DefaultsForAdding = _pb.DeckAndNotetype
BrowserRow = _pb.BrowserRow
import copy import copy
import os import os
@ -682,6 +683,20 @@ class Collection:
else: else:
return SearchNode.Group.Joiner.OR return SearchNode.Group.Joiner.OR
# Browser rows
##########################################################################
def browser_row_for_card(
self, cid: int
) -> Tuple[Generator[Tuple[str, bool], None, None], BrowserRow.Color, str, int]:
row = self._backend.browser_row_for_card(cid)
return (
((cell.text, cell.is_rtl) for cell in row.cells),
row.color,
row.font_name,
row.font_size,
)
# Config # Config
########################################################################## ##########################################################################

View file

@ -5,21 +5,32 @@ from __future__ import annotations
import html import html
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass
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,
Generator,
List,
Optional,
Sequence,
Tuple,
Union,
cast,
)
import aqt import aqt
import aqt.forms import aqt.forms
from anki.cards import Card from anki.cards import Card
from anki.collection import Collection, Config, OpChanges, SearchNode from anki.collection import BrowserRow, Collection, Config, OpChanges, SearchNode
from anki.consts import * from anki.consts import *
from anki.errors import InvalidInput, NotFoundError from anki.errors import InvalidInput, NotFoundError
from anki.lang import without_unicode_isolation from anki.lang import without_unicode_isolation
from anki.models import NoteType from anki.models import NoteType
from anki.stats import CardStats from anki.stats import CardStats
from anki.tags import MARKED_TAG from anki.tags import MARKED_TAG
from anki.utils import htmlToTextLine, ids2str, isMac, isWin from anki.utils import ids2str, isMac, isWin
from aqt import AnkiQt, colors, gui_hooks from aqt import AnkiQt, colors, gui_hooks
from aqt.card_ops import set_card_deck, set_card_flag from aqt.card_ops import set_card_deck, set_card_flag
from aqt.editor import Editor from aqt.editor import Editor
@ -91,25 +102,63 @@ class SearchContext:
# Data model # Data model
########################################################################## ##########################################################################
# temporary cache to avoid hitting the database on redraw
@dataclass @dataclass
class Cell: class Cell:
text: str = "" text: str
font: Optional[Tuple[str, int]] = None is_rtl: bool
is_rtl: bool = False
@dataclass
class CellRow: class CellRow:
columns: List[Cell] def __init__(
refreshed_at: float = field(default_factory=time.time) self,
card_flag: int = 0 cells: Generator[Tuple[str, bool], None, None],
marked: bool = False color: BrowserRow.Color.V,
suspended: bool = False font_name: str,
font_size: int,
) -> None:
self.refreshed_at: float = time.time()
self.cells: Tuple[Cell, ...] = tuple(Cell(*cell) for cell in cells)
self.color: Optional[Tuple[str, str]] = backend_color_to_aqt_color(color)
self.font_name: str = font_name or "arial"
self.font_size: int = font_size if font_size > 0 else 12
def is_stale(self, threshold: float) -> bool: def is_stale(self, threshold: float) -> bool:
return self.refreshed_at < threshold return self.refreshed_at < threshold
@staticmethod
def generic(length: int, cell_text: str) -> CellRow:
return CellRow(
((cell_text, False) for cell in range(length)),
BrowserRow.COLOR_DEFAULT,
"arial",
12,
)
@staticmethod
def placeholder(length: int) -> CellRow:
return CellRow.generic(length, "...")
@staticmethod
def deleted(length: int) -> CellRow:
return CellRow.generic(length, tr(TR.BROWSING_ROW_DELETED))
def backend_color_to_aqt_color(color: BrowserRow.Color.V) -> Optional[Tuple[str, str]]:
if color == BrowserRow.COLOR_MARKED:
return colors.MARKED_BG
if color == BrowserRow.COLOR_SUSPENDED:
return colors.SUSPENDED_BG
if color == BrowserRow.COLOR_FLAG_RED:
return colors.FLAG1_BG
if color == BrowserRow.COLOR_FLAG_ORANGE:
return colors.FLAG2_BG
if color == BrowserRow.COLOR_FLAG_GREEN:
return colors.FLAG3_BG
if color == BrowserRow.COLOR_FLAG_BLUE:
return colors.FLAG4_BG
return None
class DataModel(QAbstractTableModel): class DataModel(QAbstractTableModel):
def __init__(self, browser: Browser) -> None: def __init__(self, browser: Browser) -> None:
@ -121,73 +170,38 @@ class DataModel(QAbstractTableModel):
"activeCols", ["noteFld", "template", "cardDue", "deck"] "activeCols", ["noteFld", "template", "cardDue", "deck"]
) )
self.cards: Sequence[int] = [] self.cards: Sequence[int] = []
self.cardObjs: Dict[int, Card] = {} self._rows: Dict[int, CellRow] = {}
self._row_cache: Dict[int, CellRow] = {}
self._last_refresh = 0.0 self._last_refresh = 0.0
# serve stale content to avoid hitting the DB? # serve stale content to avoid hitting the DB?
self.block_updates = False self.block_updates = False
def getCard(self, index: QModelIndex) -> Optional[Card]: def get_id(self, index: QModelIndex) -> int:
return self._get_card_by_row(index.row()) return self.cards[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)
except NotFoundError:
# deleted
card = None
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: def get_cell(self, index: QModelIndex) -> Cell:
row = self.get_row(index.row()) return self.get_row(index).cells[index.column()]
return row.columns[index.column()]
def get_row(self, row: int) -> CellRow: def get_row(self, index: QModelIndex) -> CellRow:
if entry := self._row_cache.get(row): cid = self.get_id(index)
if not self.block_updates and entry.is_stale(self._last_refresh): if row := self._rows.get(cid):
if not self.block_updates and row.is_stale(self._last_refresh):
# need to refresh # need to refresh
entry = self._build_cell_row(row) self._rows[cid] = self._fetch_row_from_backend(cid)
self._row_cache[row] = entry return self._rows[cid]
return entry # return row, even if it's stale
else: return row
# return entry, even if it's stale if self.block_updates:
return entry # blank row until we unblock
elif self.block_updates: return CellRow.placeholder(len(self.activeCols))
# blank entry until we unblock # missing row, need to build
return CellRow(columns=[Cell(text="...")] * len(self.activeCols)) self._rows[cid] = self._fetch_row_from_backend(cid)
else: return self._rows[cid]
# 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: def _fetch_row_from_backend(self, cid: int) -> CellRow:
if not (card := self._get_card_by_row(row)): try:
cell = Cell(text=tr(TR.BROWSING_ROW_DELETED)) return CellRow(*self.col.browser_row_for_card(cid))
return CellRow(columns=[cell] * len(self.activeCols)) except:
return CellRow.deleted(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
###################################################################### ######################################################################
@ -206,13 +220,13 @@ class DataModel(QAbstractTableModel):
if not index.isValid(): if not index.isValid():
return return
if role == Qt.FontRole: if role == Qt.FontRole:
if font := self.get_cell(index).font: if self.activeCols[index.column()] not in ("question", "answer", "noteFld"):
qfont = QFont() return
qfont.setFamily(font[0]) qfont = QFont()
qfont.setPixelSize(font[1]) row = self.get_row(index)
return qfont qfont.setFamily(row.font_name)
else: qfont.setPixelSize(row.font_size)
return None return qfont
elif role == Qt.TextAlignmentRole: elif role == Qt.TextAlignmentRole:
align: Union[Qt.AlignmentFlag, int] = Qt.AlignVCenter align: Union[Qt.AlignmentFlag, int] = Qt.AlignVCenter
@ -238,7 +252,7 @@ class DataModel(QAbstractTableModel):
if orientation == Qt.Vertical: if orientation == Qt.Vertical:
return None return None
elif role == Qt.DisplayRole and section < len(self.activeCols): elif role == Qt.DisplayRole and section < len(self.activeCols):
type = self.columnType(section) type = self.activeCols[section]
txt = None txt = None
for stype, name in self.browser.columns: for stype, name in self.browser.columns:
if type == stype: if type == stype:
@ -291,8 +305,7 @@ class DataModel(QAbstractTableModel):
self.browser.mw.progress.start() self.browser.mw.progress.start()
self.saveSelection() self.saveSelection()
self.beginResetModel() self.beginResetModel()
self.cardObjs = {} self._rows = {}
self._row_cache = {}
def endReset(self) -> None: def endReset(self) -> None:
self.endResetModel() self.endResetModel()
@ -366,8 +379,7 @@ class DataModel(QAbstractTableModel):
def op_executed(self, op: OpChanges, focused: bool) -> None: def op_executed(self, op: OpChanges, focused: bool) -> None:
print("op executed") 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:
# clear card cache self._rows = {}
self.cardObjs = {}
if focused: if focused:
self.redraw_cells() self.redraw_cells()
@ -378,148 +390,6 @@ class DataModel(QAbstractTableModel):
self.block_updates = False self.block_updates = False
self.redraw_cells() self.redraw_cells()
# Column data
######################################################################
def columnType(self, column: int) -> str:
return self.activeCols[column]
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(card)
elif type == "answer":
return self.answer(card)
elif type == "noteFld":
f = card.note()
return htmlToTextLine(f.fields[self.col.models.sortIdx(f.model())])
elif type == "template":
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._next_due(card)
except:
t = ""
if card.queue < 0:
t = f"({t})"
return t
elif type == "noteCrt":
return time.strftime(
self.time_format(), time.localtime(card.note().id / 1000)
)
elif type == "noteMod":
return time.strftime(self.time_format(), time.localtime(card.note().mod))
elif type == "cardMod":
return time.strftime(self.time_format(), time.localtime(card.mod))
elif type == "cardReps":
return str(card.reps)
elif type == "cardLapses":
return str(card.lapses)
elif type == "noteTags":
return " ".join(card.note().tags)
elif type == "note":
return card.model()["name"]
elif type == "cardIvl":
if card.type == CARD_TYPE_NEW:
return tr(TR.BROWSING_NEW)
elif card.type == CARD_TYPE_LRN:
return tr(TR.BROWSING_LEARNING)
return self.col.format_timespan(card.ivl * 86400)
elif type == "cardEase":
if card.type == CARD_TYPE_NEW:
return tr(TR.BROWSING_NEW)
return "%d%%" % (card.factor / 10)
elif type == "deck":
if card.odid:
# in a cram deck
return "%s (%s)" % (
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(card.did)
else:
return ""
def question(self, c: Card) -> str:
return htmlToTextLine(c.q(browser=True))
def answer(self, c: Card) -> str:
if c.template().get("bafmt"):
# they have provided a template, use it verbatim
c.q(browser=True)
return htmlToTextLine(c.a())
# need to strip question from answer
q = self.question(c)
a = htmlToTextLine(c.a())
if a.startswith(q):
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 card.odid:
return tr(TR.BROWSING_FILTERED)
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() + ((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)
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
nt = card.note().model()
return nt["flds"][self.col.models.sortIdx(nt)]["rtl"]
# Line painter # Line painter
###################################################################### ######################################################################
@ -534,27 +404,13 @@ class StatusDelegate(QItemDelegate):
def paint( def paint(
self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex
) -> None: ) -> None:
row = self.model.get_row(index.row()) if self.model.get_cell(index).is_rtl:
cell = row.columns[index.column()]
if cell.is_rtl:
option.direction = Qt.RightToLeft option.direction = Qt.RightToLeft
if row_color := self.model.get_row(index).color:
if row.card_flag: brush = QBrush(theme_manager.qcolor(row_color))
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.save()
painter.fillRect(option.rect, brush) painter.fillRect(option.rect, brush)
painter.restore() painter.restore()
return QItemDelegate.paint(self, painter, option, index) return QItemDelegate.paint(self, painter, option, index)
@ -962,7 +818,7 @@ QTableView {{ gridline-color: {grid} }}
show = self.model.cards and update == 1 show = self.model.cards and update == 1
idx = self.form.tableView.selectionModel().currentIndex() idx = self.form.tableView.selectionModel().currentIndex()
if idx.isValid(): if idx.isValid():
self.card = self.model.getCard(idx) self.card = self.col.getCard(self.model.get_id(idx))
show = show and self.card is not None show = show and self.card is not None
self.form.splitter.widget(1).setVisible(bool(show)) self.form.splitter.widget(1).setVisible(bool(show))