758550ea17
I think this may have accidentally been changed in the refactoring. If we discard self._rows, it will result in the entire table flashing "..." until the new data is available. Instead, we leave the cached rows alone, and just update the cutoff point, so we can serve stale content (avoiding any visible redraw) until the new data is available. I've updated search() to reset the rows there, so we free up memory on a new search.
1132 lines
38 KiB
Python
1132 lines
38 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright: Ankitects Pty Ltd and contributors
|
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from abc import ABC, abstractmethod, abstractproperty
|
|
from dataclasses import dataclass
|
|
from operator import itemgetter
|
|
from typing import (
|
|
Any,
|
|
Callable,
|
|
Dict,
|
|
Generator,
|
|
List,
|
|
Optional,
|
|
Sequence,
|
|
Tuple,
|
|
Union,
|
|
cast,
|
|
)
|
|
|
|
import aqt
|
|
import aqt.forms
|
|
from anki.cards import Card, CardId
|
|
from anki.collection import BrowserRow, Collection, Config, OpChanges
|
|
from anki.consts import *
|
|
from anki.errors import NotFoundError
|
|
from anki.notes import Note, NoteId
|
|
from anki.utils import ids2str, isWin
|
|
from aqt import colors, gui_hooks
|
|
from aqt.operations import OpMeta
|
|
from aqt.qt import *
|
|
from aqt.theme import theme_manager
|
|
from aqt.utils import (
|
|
KeyboardModifiersPressed,
|
|
qtMenuShortcutWorkaround,
|
|
restoreHeader,
|
|
showInfo,
|
|
tr,
|
|
)
|
|
|
|
ItemId = Union[CardId, NoteId]
|
|
ItemList = Union[Sequence[CardId], Sequence[NoteId]]
|
|
|
|
|
|
@dataclass
|
|
class SearchContext:
|
|
search: str
|
|
browser: aqt.browser.Browser
|
|
order: Union[bool, str] = True
|
|
# if set, provided ids will be used instead of the regular search
|
|
ids: Optional[Sequence[ItemId]] = None
|
|
|
|
|
|
class Table:
|
|
SELECTION_LIMIT: int = 500
|
|
|
|
def __init__(self, browser: aqt.browser.Browser) -> None:
|
|
self.browser = browser
|
|
self.col: Collection = browser.col
|
|
self._state: ItemState = (
|
|
NoteState(self.col)
|
|
if self.col.get_config_bool(Config.Bool.BROWSER_TABLE_SHOW_NOTES_MODE)
|
|
else CardState(self.col)
|
|
)
|
|
self._model = DataModel(self.col, self._state)
|
|
self._view: Optional[QTableView] = None
|
|
self._current_item: Optional[ItemId] = None
|
|
self._selected_items: Sequence[ItemId] = []
|
|
|
|
def set_view(self, view: QTableView) -> None:
|
|
self._view = view
|
|
self._setup_view()
|
|
self._setup_headers()
|
|
|
|
# Public Methods
|
|
######################################################################
|
|
|
|
# Get metadata
|
|
|
|
def len(self) -> int:
|
|
return self._model.len_rows()
|
|
|
|
def len_selection(self) -> int:
|
|
return len(self._view.selectionModel().selectedRows())
|
|
|
|
def has_current(self) -> bool:
|
|
return self._view.selectionModel().currentIndex().isValid()
|
|
|
|
def has_previous(self) -> bool:
|
|
return self.has_current() and self._current().row() > 0
|
|
|
|
def has_next(self) -> bool:
|
|
return self.has_current() and self._current().row() < self.len() - 1
|
|
|
|
def is_notes_mode(self) -> bool:
|
|
return self._state.is_notes_mode()
|
|
|
|
# Get objects
|
|
|
|
def get_current_card(self) -> Optional[Card]:
|
|
if not self.has_current():
|
|
return None
|
|
return self._model.get_card(self._current())
|
|
|
|
def get_current_note(self) -> Optional[Note]:
|
|
if not self.has_current():
|
|
return None
|
|
return self._model.get_note(self._current())
|
|
|
|
def get_single_selected_card(self) -> Optional[Card]:
|
|
"""If there is only one row selected return its card, else None.
|
|
This may be a different one than the current card."""
|
|
if self.len_selection() != 1:
|
|
return None
|
|
return self._model.get_card(self._selected()[0])
|
|
|
|
# Get ids
|
|
|
|
def get_selected_card_ids(self) -> Sequence[CardId]:
|
|
return self._model.get_card_ids(self._selected())
|
|
|
|
def get_selected_note_ids(self) -> Sequence[NoteId]:
|
|
return self._model.get_note_ids(self._selected())
|
|
|
|
def get_card_ids_from_selected_note_ids(self) -> Sequence[CardId]:
|
|
return self._state.card_ids_from_note_ids(self.get_selected_note_ids())
|
|
|
|
# Selecting
|
|
|
|
def select_all(self) -> None:
|
|
self._view.selectAll()
|
|
|
|
def clear_selection(self) -> None:
|
|
self._view.selectionModel().clear()
|
|
|
|
def invert_selection(self) -> None:
|
|
selection = self._view.selectionModel().selection()
|
|
self.select_all()
|
|
self._view.selectionModel().select(
|
|
selection,
|
|
cast(
|
|
QItemSelectionModel.SelectionFlags,
|
|
QItemSelectionModel.Deselect | QItemSelectionModel.Rows,
|
|
),
|
|
)
|
|
|
|
def select_single_card(self, card_id: CardId) -> None:
|
|
"""Try to set the selection to the item corresponding to the given card."""
|
|
self.clear_selection()
|
|
try:
|
|
self._view.selectRow(self._model.get_card_row(card_id))
|
|
except ValueError:
|
|
pass
|
|
|
|
# Reset
|
|
|
|
def reset(self) -> None:
|
|
"""Reload table data from collection and redraw."""
|
|
self.begin_reset()
|
|
self.end_reset()
|
|
|
|
def begin_reset(self) -> None:
|
|
self._save_selection()
|
|
self._model.begin_reset()
|
|
|
|
def end_reset(self) -> None:
|
|
self._model.end_reset()
|
|
self._restore_selection(self._intersected_selection)
|
|
|
|
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.begin_blocking()
|
|
|
|
def on_backend_did_block(self) -> None:
|
|
self._model.end_blocking()
|
|
|
|
def redraw_cells(self) -> None:
|
|
self._model.redraw_cells()
|
|
|
|
def op_executed(self, changes: OpChanges, meta: OpMeta, focused: bool) -> None:
|
|
if changes.browser_table:
|
|
self._model.mark_cache_stale()
|
|
if focused:
|
|
self.redraw_cells()
|
|
|
|
# Modify table
|
|
|
|
def search(self, txt: str) -> None:
|
|
self._save_selection()
|
|
self._model.search(SearchContext(search=txt, browser=self.browser))
|
|
self._restore_selection(self._intersected_selection)
|
|
|
|
def toggle_state(self, is_notes_mode: bool, last_search: str) -> None:
|
|
if is_notes_mode == self.is_notes_mode():
|
|
return
|
|
self._save_selection()
|
|
self._state = self._model.toggle_state(
|
|
SearchContext(search=last_search, browser=self.browser)
|
|
)
|
|
self.col.set_config_bool(
|
|
Config.Bool.BROWSER_TABLE_SHOW_NOTES_MODE, self.is_notes_mode()
|
|
)
|
|
self._set_sort_indicator()
|
|
self._set_column_sizes()
|
|
self._restore_selection(self._toggled_selection)
|
|
|
|
# Move cursor
|
|
|
|
def to_previous_row(self) -> None:
|
|
self._move_current(QAbstractItemView.MoveUp)
|
|
|
|
def to_next_row(self) -> None:
|
|
self._move_current(QAbstractItemView.MoveDown)
|
|
|
|
def to_first_row(self) -> None:
|
|
self._move_current_to_row(0)
|
|
|
|
def to_last_row(self) -> None:
|
|
self._move_current_to_row(self._model.len_rows() - 1)
|
|
|
|
# Private methods
|
|
######################################################################
|
|
|
|
# Helpers
|
|
|
|
def _current(self) -> QModelIndex:
|
|
return self._view.selectionModel().currentIndex()
|
|
|
|
def _selected(self) -> List[QModelIndex]:
|
|
return self._view.selectionModel().selectedRows()
|
|
|
|
def _set_current(self, row: int, column: int = 0) -> None:
|
|
index = self._model.index(
|
|
row, self._view.horizontalHeader().logicalIndex(column)
|
|
)
|
|
self._view.selectionModel().setCurrentIndex(index, QItemSelectionModel.NoUpdate)
|
|
|
|
def _select_rows(self, rows: List[int]) -> None:
|
|
selection = QItemSelection()
|
|
for row in rows:
|
|
selection.select(
|
|
self._model.index(row, 0),
|
|
self._model.index(row, self._model.len_columns() - 1),
|
|
)
|
|
self._view.selectionModel().select(selection, QItemSelectionModel.SelectCurrent)
|
|
|
|
def _set_sort_indicator(self) -> None:
|
|
hh = self._view.horizontalHeader()
|
|
index = self._model.active_column_index(self._state.sort_column)
|
|
if index is None:
|
|
hh.setSortIndicatorShown(False)
|
|
return
|
|
if self._state.sort_backwards:
|
|
order = Qt.DescendingOrder
|
|
else:
|
|
order = Qt.AscendingOrder
|
|
hh.blockSignals(True)
|
|
hh.setSortIndicator(index, order)
|
|
hh.blockSignals(False)
|
|
hh.setSortIndicatorShown(True)
|
|
|
|
def _set_column_sizes(self) -> None:
|
|
hh = self._view.horizontalHeader()
|
|
hh.setSectionResizeMode(QHeaderView.Interactive)
|
|
hh.setSectionResizeMode(
|
|
hh.logicalIndex(self._model.len_columns() - 1), QHeaderView.Stretch
|
|
)
|
|
# this must be set post-resize or it doesn't work
|
|
hh.setCascadingSectionResizes(False)
|
|
|
|
# Setup
|
|
|
|
def _setup_view(self) -> None:
|
|
self._view.setSortingEnabled(True)
|
|
self._view.setModel(self._model)
|
|
self._view.selectionModel()
|
|
self._view.setItemDelegate(StatusDelegate(self.browser, self._model))
|
|
qconnect(
|
|
self._view.selectionModel().selectionChanged, self.browser.onRowChanged
|
|
)
|
|
self._view.setWordWrap(False)
|
|
self._update_font()
|
|
if not theme_manager.night_mode:
|
|
self._view.setStyleSheet(
|
|
"QTableView{ selection-background-color: rgba(150, 150, 150, 50); "
|
|
"selection-color: black; }"
|
|
)
|
|
elif theme_manager.macos_dark_mode():
|
|
self._view.setStyleSheet(
|
|
f"QTableView {{ gridline-color: {colors.FRAME_BG} }}"
|
|
)
|
|
self._view.setContextMenuPolicy(Qt.CustomContextMenu)
|
|
qconnect(self._view.customContextMenuRequested, self._on_context_menu)
|
|
|
|
def _update_font(self) -> None:
|
|
# we can't choose different line heights efficiently, so we need
|
|
# to pick a line height big enough for any card template
|
|
curmax = 16
|
|
for m in self.col.models.all():
|
|
for t in m["tmpls"]:
|
|
bsize = t.get("bsize", 0)
|
|
if bsize > curmax:
|
|
curmax = bsize
|
|
self._view.verticalHeader().setDefaultSectionSize(curmax + 6)
|
|
|
|
def _setup_headers(self) -> None:
|
|
vh = self._view.verticalHeader()
|
|
hh = self._view.horizontalHeader()
|
|
if not isWin:
|
|
vh.hide()
|
|
hh.show()
|
|
restoreHeader(hh, "editor")
|
|
hh.setHighlightSections(False)
|
|
hh.setMinimumSectionSize(50)
|
|
hh.setSectionsMovable(True)
|
|
self._set_column_sizes()
|
|
hh.setContextMenuPolicy(Qt.CustomContextMenu)
|
|
qconnect(hh.customContextMenuRequested, self._on_header_context)
|
|
self._set_sort_indicator()
|
|
qconnect(hh.sortIndicatorChanged, self._on_sort_column_changed)
|
|
qconnect(hh.sectionMoved, self._on_column_moved)
|
|
|
|
# Slots
|
|
|
|
def _on_context_menu(self, _point: QPoint) -> None:
|
|
menu = QMenu()
|
|
if self.is_notes_mode():
|
|
main = self.browser.form.menu_Notes
|
|
other = self.browser.form.menu_Cards
|
|
other_name = tr.qt_accel_cards()
|
|
else:
|
|
main = self.browser.form.menu_Cards
|
|
other = self.browser.form.menu_Notes
|
|
other_name = tr.qt_accel_notes()
|
|
for action in main.actions():
|
|
menu.addAction(action)
|
|
menu.addSeparator()
|
|
sub_menu = menu.addMenu(other_name)
|
|
for action in other.actions():
|
|
sub_menu.addAction(action)
|
|
gui_hooks.browser_will_show_context_menu(self.browser, menu)
|
|
qtMenuShortcutWorkaround(menu)
|
|
menu.exec_(QCursor.pos())
|
|
|
|
def _on_header_context(self, pos: QPoint) -> None:
|
|
gpos = self._view.mapToGlobal(pos)
|
|
m = QMenu()
|
|
for column, name in self._state.columns:
|
|
a = m.addAction(name)
|
|
a.setCheckable(True)
|
|
a.setChecked(self._model.active_column_index(column) is not None)
|
|
qconnect(
|
|
a.toggled,
|
|
lambda checked, column=column: self._on_column_toggled(checked, column),
|
|
)
|
|
gui_hooks.browser_header_will_show_context_menu(self.browser, m)
|
|
m.exec_(gpos)
|
|
|
|
def _on_column_moved(self, *_args: Any) -> None:
|
|
self._set_column_sizes()
|
|
|
|
def _on_column_toggled(self, checked: bool, column: str) -> None:
|
|
if not checked and self._model.len_columns() < 2:
|
|
showInfo(tr.browsing_you_must_have_at_least_one())
|
|
return
|
|
self._model.toggle_column(column)
|
|
self._set_column_sizes()
|
|
# sorted field may have been hidden or revealed
|
|
self._set_sort_indicator()
|
|
if checked:
|
|
self._scroll_to_column(self._model.len_columns() - 1)
|
|
|
|
def _on_sort_column_changed(self, index: int, order: int) -> None:
|
|
order = bool(order)
|
|
sort_column = self._model.active_column(index)
|
|
if sort_column in ("question", "answer"):
|
|
showInfo(tr.browsing_sorting_on_this_column_is_not())
|
|
sort_column = self._state.sort_column
|
|
if self._state.sort_column != sort_column:
|
|
self._state.sort_column = sort_column
|
|
# default to descending for non-text fields
|
|
if sort_column == "noteFld":
|
|
order = not order
|
|
self._state.sort_backwards = order
|
|
self.browser.search()
|
|
else:
|
|
if self._state.sort_backwards != order:
|
|
self._state.sort_backwards = order
|
|
self._reverse()
|
|
self._set_sort_indicator()
|
|
|
|
def _reverse(self) -> None:
|
|
self._save_selection()
|
|
self._model.reverse()
|
|
self._restore_selection(self._intersected_selection)
|
|
|
|
# Restore selection
|
|
|
|
def _save_selection(self) -> None:
|
|
"""Save the current item and selected items."""
|
|
if self.has_current():
|
|
self._current_item = self._model.get_item(self._current())
|
|
self._selected_items = self._model.get_items(self._selected())
|
|
|
|
def _restore_selection(self, new_selected_and_current: Callable) -> None:
|
|
"""Restore the saved selection and current element as far as possible and scroll to the
|
|
new current element. Clear the saved selection.
|
|
"""
|
|
self.clear_selection()
|
|
if not self._model.is_empty():
|
|
rows, current = new_selected_and_current()
|
|
rows = self._qualify_selected_rows(rows, current)
|
|
current = current or rows[0]
|
|
self._select_rows(rows)
|
|
self._set_current(current)
|
|
# editor may pop up and hide the row later on
|
|
QTimer.singleShot(100, lambda: self._scroll_to_row(current))
|
|
if self.len_selection() == 0:
|
|
# no row change will fire
|
|
self.browser.onRowChanged(QItemSelection(), QItemSelection())
|
|
self._selected_items = []
|
|
self._current_item = None
|
|
|
|
def _qualify_selected_rows(
|
|
self, rows: List[int], current: Optional[int]
|
|
) -> List[int]:
|
|
"""Return between 1 and SELECTION_LIMIT rows, as far as possible from rows or current."""
|
|
if rows:
|
|
if len(rows) < self.SELECTION_LIMIT:
|
|
return rows
|
|
if current and current in rows:
|
|
return [current]
|
|
return rows[0:1]
|
|
return [current if current else 0]
|
|
|
|
def _intersected_selection(self) -> Tuple[List[int], Optional[int]]:
|
|
"""Return all rows of items that were in the saved selection and the row of the saved
|
|
current element if present.
|
|
"""
|
|
selected_rows = self._model.get_item_rows(self._selected_items)
|
|
current_row = self._current_item and self._model.get_item_row(
|
|
self._current_item
|
|
)
|
|
return selected_rows, current_row
|
|
|
|
def _toggled_selection(self) -> Tuple[List[int], Optional[int]]:
|
|
"""Convert the items of the saved selection and current element to the new state and
|
|
return their rows.
|
|
"""
|
|
selected_rows = self._model.get_item_rows(
|
|
self._state.get_new_items(self._selected_items)
|
|
)
|
|
current_row = None
|
|
if self._current_item:
|
|
if new_current := self._state.get_new_items([self._current_item]):
|
|
current_row = self._model.get_item_row(new_current[0])
|
|
return selected_rows, current_row
|
|
|
|
# Move
|
|
|
|
def _scroll_to_row(self, row: int) -> None:
|
|
"""Scroll vertically to row."""
|
|
top_border = self._view.rowViewportPosition(row)
|
|
bottom_border = top_border + self._view.rowHeight(0)
|
|
visible = top_border >= 0 and bottom_border < self._view.viewport().height()
|
|
if not visible:
|
|
horizontal = self._view.horizontalScrollBar().value()
|
|
self._view.scrollTo(self._model.index(row, 0), self._view.PositionAtCenter)
|
|
self._view.horizontalScrollBar().setValue(horizontal)
|
|
|
|
def _scroll_to_column(self, column: int) -> None:
|
|
"""Scroll horizontally to column."""
|
|
position = self._view.columnViewportPosition(column)
|
|
visible = 0 <= position < self._view.viewport().width()
|
|
if not visible:
|
|
vertical = self._view.verticalScrollBar().value()
|
|
self._view.scrollTo(
|
|
self._model.index(0, column), self._view.PositionAtCenter
|
|
)
|
|
self._view.verticalScrollBar().setValue(vertical)
|
|
|
|
def _move_current(self, direction: int, index: QModelIndex = None) -> None:
|
|
if not self.has_current():
|
|
return
|
|
if index is None:
|
|
index = self._view.moveCursor(
|
|
cast(QAbstractItemView.CursorAction, direction),
|
|
self.browser.mw.app.keyboardModifiers(),
|
|
)
|
|
self._view.selectionModel().setCurrentIndex(
|
|
index,
|
|
cast(
|
|
QItemSelectionModel.SelectionFlag,
|
|
QItemSelectionModel.Clear
|
|
| QItemSelectionModel.Select
|
|
| QItemSelectionModel.Rows,
|
|
),
|
|
)
|
|
|
|
def _move_current_to_row(self, row: int) -> None:
|
|
old = self._view.selectionModel().currentIndex()
|
|
self._move_current(None, self._model.index(row, 0))
|
|
if not KeyboardModifiersPressed().shift:
|
|
return
|
|
new = self._view.selectionModel().currentIndex()
|
|
selection = QItemSelection(new, old)
|
|
self._view.selectionModel().select(
|
|
selection,
|
|
cast(
|
|
QItemSelectionModel.SelectionFlag,
|
|
QItemSelectionModel.SelectCurrent | QItemSelectionModel.Rows,
|
|
),
|
|
)
|
|
|
|
|
|
# ItemStates
|
|
######################################################################
|
|
|
|
|
|
class ItemState(ABC):
|
|
_columns: List[Tuple[str, str]]
|
|
_active_columns: List[str]
|
|
_sort_column: str
|
|
_sort_backwards: bool
|
|
|
|
def __init__(self, col: Collection) -> None:
|
|
self.col = col
|
|
|
|
def is_notes_mode(self) -> bool:
|
|
"""Return True if the state is a NoteState."""
|
|
return isinstance(self, NoteState)
|
|
|
|
# Stateless Helpers
|
|
|
|
def note_ids_from_card_ids(self, items: Sequence[ItemId]) -> Sequence[NoteId]:
|
|
return self.col.db.list(
|
|
f"select distinct nid from cards where id in {ids2str(items)}"
|
|
)
|
|
|
|
def card_ids_from_note_ids(self, items: Sequence[ItemId]) -> Sequence[CardId]:
|
|
return self.col.db.list(f"select id from cards where nid in {ids2str(items)}")
|
|
|
|
# Columns and sorting
|
|
|
|
# abstractproperty is deprecated but used due to mypy limitations
|
|
# (https://github.com/python/mypy/issues/1362)
|
|
@abstractproperty
|
|
def columns(self) -> List[Tuple[str, str]]:
|
|
"""Return all for the state available columns."""
|
|
|
|
@abstractproperty
|
|
def active_columns(self) -> List[str]:
|
|
"""Return the saved or default columns for the state."""
|
|
|
|
@abstractmethod
|
|
def toggle_active_column(self, column: str) -> None:
|
|
"""Add or remove an active column."""
|
|
|
|
@abstractproperty
|
|
def sort_column(self) -> str:
|
|
"""Return the sort column from the config."""
|
|
|
|
@sort_column.setter
|
|
def sort_column(self, column: str) -> None:
|
|
"""Save the sort column in the config."""
|
|
|
|
@abstractproperty
|
|
def sort_backwards(self) -> bool:
|
|
"""Return the sort order from the config."""
|
|
|
|
@sort_backwards.setter
|
|
def sort_backwards(self, order: bool) -> None:
|
|
"""Save the sort order in the config."""
|
|
|
|
# Get objects
|
|
|
|
@abstractmethod
|
|
def get_card(self, item: ItemId) -> Card:
|
|
"""Return the item if it's a card or its first card if it's a note."""
|
|
|
|
@abstractmethod
|
|
def get_note(self, item: ItemId) -> Note:
|
|
"""Return the item if it's a note or its note if it's a card."""
|
|
|
|
# Get ids
|
|
|
|
@abstractmethod
|
|
def find_items(self, search: str, order: Union[bool, str]) -> Sequence[ItemId]:
|
|
"""Return the item ids fitting the given search and order."""
|
|
|
|
@abstractmethod
|
|
def get_item_from_card_id(self, card: CardId) -> ItemId:
|
|
"""Return the appropriate item id for a card id."""
|
|
|
|
@abstractmethod
|
|
def get_card_ids(self, items: Sequence[ItemId]) -> Sequence[CardId]:
|
|
"""Return the card ids for the given item ids."""
|
|
|
|
@abstractmethod
|
|
def get_note_ids(self, items: Sequence[ItemId]) -> Sequence[NoteId]:
|
|
"""Return the note ids for the given item ids."""
|
|
|
|
# Toggle
|
|
|
|
@abstractmethod
|
|
def toggle_state(self) -> ItemState:
|
|
"""Return an instance of the other state."""
|
|
|
|
@abstractmethod
|
|
def get_new_items(self, old_items: Sequence[ItemId]) -> ItemList:
|
|
"""Given a list of ids from the other state, return the corresponding ids for this state."""
|
|
|
|
|
|
class CardState(ItemState):
|
|
def __init__(self, col: Collection) -> None:
|
|
super().__init__(col)
|
|
self._load_columns()
|
|
self._active_columns = self.col.load_browser_card_columns()
|
|
self._sort_column = self.col.get_config("sortType")
|
|
self._sort_backwards = self.col.get_config_bool(
|
|
Config.Bool.BROWSER_SORT_BACKWARDS
|
|
)
|
|
|
|
def _load_columns(self) -> None:
|
|
self._columns = [
|
|
("question", tr.browsing_question()),
|
|
("answer", tr.browsing_answer()),
|
|
("template", tr.browsing_card()),
|
|
("deck", tr.decks_deck()),
|
|
("noteFld", tr.browsing_sort_field()),
|
|
("noteCrt", tr.browsing_created()),
|
|
("noteMod", tr.search_note_modified()),
|
|
("cardMod", tr.search_card_modified()),
|
|
("cardDue", tr.statistics_due_date()),
|
|
("cardIvl", tr.browsing_interval()),
|
|
("cardEase", tr.browsing_ease()),
|
|
("cardReps", tr.scheduling_reviews()),
|
|
("cardLapses", tr.scheduling_lapses()),
|
|
("noteTags", tr.editing_tags()),
|
|
("note", tr.browsing_note()),
|
|
]
|
|
self._columns.sort(key=itemgetter(1))
|
|
|
|
@property
|
|
def columns(self) -> List[Tuple[str, str]]:
|
|
return self._columns
|
|
|
|
@property
|
|
def active_columns(self) -> List[str]:
|
|
return self._active_columns
|
|
|
|
def toggle_active_column(self, column: str) -> None:
|
|
if column in self._active_columns:
|
|
self._active_columns.remove(column)
|
|
else:
|
|
self._active_columns.append(column)
|
|
self.col.set_browser_card_columns(self._active_columns)
|
|
|
|
@property
|
|
def sort_column(self) -> str:
|
|
return self._sort_column
|
|
|
|
@sort_column.setter
|
|
def sort_column(self, column: str) -> None:
|
|
self.col.set_config("sortType", column)
|
|
self._sort_column = column
|
|
|
|
@property
|
|
def sort_backwards(self) -> bool:
|
|
return self._sort_backwards
|
|
|
|
@sort_backwards.setter
|
|
def sort_backwards(self, order: bool) -> None:
|
|
self.col.set_config_bool(Config.Bool.BROWSER_SORT_BACKWARDS, order)
|
|
self._sort_backwards = order
|
|
|
|
def get_card(self, item: ItemId) -> Card:
|
|
return self.col.get_card(CardId(item))
|
|
|
|
def get_note(self, item: ItemId) -> Note:
|
|
return self.get_card(item).note()
|
|
|
|
def find_items(self, search: str, order: Union[bool, str]) -> Sequence[ItemId]:
|
|
return self.col.find_cards(search, order)
|
|
|
|
def get_item_from_card_id(self, card: CardId) -> ItemId:
|
|
return card
|
|
|
|
def get_card_ids(self, items: Sequence[ItemId]) -> Sequence[CardId]:
|
|
return cast(Sequence[CardId], items)
|
|
|
|
def get_note_ids(self, items: Sequence[ItemId]) -> Sequence[NoteId]:
|
|
return super().note_ids_from_card_ids(items)
|
|
|
|
def toggle_state(self) -> NoteState:
|
|
return NoteState(self.col)
|
|
|
|
def get_new_items(self, old_items: Sequence[ItemId]) -> Sequence[CardId]:
|
|
return super().card_ids_from_note_ids(old_items)
|
|
|
|
|
|
class NoteState(ItemState):
|
|
def __init__(self, col: Collection) -> None:
|
|
super().__init__(col)
|
|
self._load_columns()
|
|
self._active_columns = self.col.load_browser_note_columns()
|
|
self._sort_column = self.col.get_config("noteSortType")
|
|
self._sort_backwards = self.col.get_config_bool(
|
|
Config.Bool.BROWSER_NOTE_SORT_BACKWARDS
|
|
)
|
|
|
|
def _load_columns(self) -> None:
|
|
self._columns = [
|
|
("note", tr.browsing_note()),
|
|
("noteCards", tr.editing_cards()),
|
|
("noteCrt", tr.browsing_created()),
|
|
("noteDue", tr.statistics_due_date()),
|
|
("noteEase", tr.browsing_average_ease()),
|
|
("noteFld", tr.browsing_sort_field()),
|
|
("noteIvl", tr.browsing_average_interval()),
|
|
("noteLapses", tr.scheduling_lapses()),
|
|
("noteMod", tr.search_note_modified()),
|
|
("noteReps", tr.scheduling_reviews()),
|
|
("noteTags", tr.editing_tags()),
|
|
]
|
|
self._columns.sort(key=itemgetter(1))
|
|
|
|
@property
|
|
def columns(self) -> List[Tuple[str, str]]:
|
|
return self._columns
|
|
|
|
@property
|
|
def active_columns(self) -> List[str]:
|
|
return self._active_columns
|
|
|
|
def toggle_active_column(self, column: str) -> None:
|
|
if column in self._active_columns:
|
|
self._active_columns.remove(column)
|
|
else:
|
|
self._active_columns.append(column)
|
|
self.col.set_browser_note_columns(self._active_columns)
|
|
|
|
@property
|
|
def sort_column(self) -> str:
|
|
return self._sort_column
|
|
|
|
@sort_column.setter
|
|
def sort_column(self, column: str) -> None:
|
|
self.col.set_config("noteSortType", column)
|
|
self._sort_column = column
|
|
|
|
@property
|
|
def sort_backwards(self) -> bool:
|
|
return self._sort_backwards
|
|
|
|
@sort_backwards.setter
|
|
def sort_backwards(self, order: bool) -> None:
|
|
self.col.set_config_bool(Config.Bool.BROWSER_NOTE_SORT_BACKWARDS, order)
|
|
self._sort_backwards = order
|
|
|
|
def get_card(self, item: ItemId) -> Card:
|
|
return self.get_note(item).cards()[0]
|
|
|
|
def get_note(self, item: ItemId) -> Note:
|
|
return self.col.get_note(NoteId(item))
|
|
|
|
def find_items(self, search: str, order: Union[bool, str]) -> Sequence[ItemId]:
|
|
return self.col.find_notes(search, order)
|
|
|
|
def get_item_from_card_id(self, card: CardId) -> ItemId:
|
|
return self.get_card(card).note().id
|
|
|
|
def get_card_ids(self, items: Sequence[ItemId]) -> Sequence[CardId]:
|
|
return super().card_ids_from_note_ids(items)
|
|
|
|
def get_note_ids(self, items: Sequence[ItemId]) -> Sequence[NoteId]:
|
|
return cast(Sequence[NoteId], items)
|
|
|
|
def toggle_state(self) -> CardState:
|
|
return CardState(self.col)
|
|
|
|
def get_new_items(self, old_items: Sequence[ItemId]) -> Sequence[NoteId]:
|
|
return super().note_ids_from_card_ids(old_items)
|
|
|
|
|
|
# Data model
|
|
##########################################################################
|
|
|
|
|
|
@dataclass
|
|
class Cell:
|
|
text: str
|
|
is_rtl: bool
|
|
|
|
|
|
class CellRow:
|
|
is_deleted: bool = False
|
|
|
|
def __init__(
|
|
self,
|
|
cells: Generator[Tuple[str, bool], None, None],
|
|
color: BrowserRow.Color.V,
|
|
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:
|
|
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:
|
|
row = CellRow.generic(length, tr.browsing_row_deleted())
|
|
row.is_deleted = True
|
|
return row
|
|
|
|
|
|
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):
|
|
def __init__(self, col: Collection, state: ItemState) -> None:
|
|
QAbstractTableModel.__init__(self)
|
|
self.col: Collection = col
|
|
self._state: ItemState = state
|
|
self._items: Sequence[ItemId] = []
|
|
self._rows: Dict[int, CellRow] = {}
|
|
# serve stale content to avoid hitting the DB?
|
|
self._block_updates = False
|
|
self._stale_cutoff = 0.0
|
|
|
|
# Row Object Interface
|
|
######################################################################
|
|
|
|
# Get Rows
|
|
|
|
def get_cell(self, index: QModelIndex) -> Cell:
|
|
return self.get_row(index).cells[index.column()]
|
|
|
|
def get_row(self, index: QModelIndex) -> CellRow:
|
|
item = self.get_item(index)
|
|
if row := self._rows.get(item):
|
|
if not self._block_updates and row.is_stale(self._stale_cutoff):
|
|
# need to refresh
|
|
self._rows[item] = self._fetch_row_from_backend(item)
|
|
return self._rows[item]
|
|
# return row, even if it's stale
|
|
return row
|
|
if self._block_updates:
|
|
# blank row until we unblock
|
|
return CellRow.placeholder(self.len_columns())
|
|
# missing row, need to build
|
|
self._rows[item] = self._fetch_row_from_backend(item)
|
|
return self._rows[item]
|
|
|
|
def _fetch_row_from_backend(self, item: ItemId) -> CellRow:
|
|
try:
|
|
row = CellRow(*self.col.browser_row_for_id(item))
|
|
except NotFoundError:
|
|
return CellRow.deleted(self.len_columns())
|
|
except Exception as e:
|
|
return CellRow.generic(self.len_columns(), str(e))
|
|
|
|
gui_hooks.browser_did_fetch_row(
|
|
item, self._state.is_notes_mode(), row, self._state.active_columns
|
|
)
|
|
return row
|
|
|
|
# Reset
|
|
|
|
def mark_cache_stale(self) -> None:
|
|
self._stale_cutoff = time.time()
|
|
|
|
def reset(self) -> None:
|
|
self.begin_reset()
|
|
self.end_reset()
|
|
|
|
def begin_reset(self) -> None:
|
|
self.beginResetModel()
|
|
self.mark_cache_stale()
|
|
|
|
def end_reset(self) -> None:
|
|
self.endResetModel()
|
|
|
|
# Block/Unblock
|
|
|
|
def begin_blocking(self) -> None:
|
|
self._block_updates = True
|
|
|
|
def end_blocking(self) -> None:
|
|
self._block_updates = False
|
|
self.redraw_cells()
|
|
|
|
def redraw_cells(self) -> None:
|
|
"Update cell contents, without changing search count/columns/sorting."
|
|
if self.is_empty():
|
|
return
|
|
top_left = self.index(0, 0)
|
|
bottom_right = self.index(self.len_rows() - 1, self.len_columns() - 1)
|
|
self.dataChanged.emit(top_left, bottom_right) # type: ignore
|
|
|
|
# Item Interface
|
|
######################################################################
|
|
|
|
# Get metadata
|
|
|
|
def is_empty(self) -> bool:
|
|
return not self._items
|
|
|
|
def len_rows(self) -> int:
|
|
return len(self._items)
|
|
|
|
def len_columns(self) -> int:
|
|
return len(self._state.active_columns)
|
|
|
|
# Get items (card or note ids depending on state)
|
|
|
|
def get_item(self, index: QModelIndex) -> ItemId:
|
|
return self._items[index.row()]
|
|
|
|
def get_items(self, indices: List[QModelIndex]) -> Sequence[ItemId]:
|
|
return [self.get_item(index) for index in indices]
|
|
|
|
def get_card_ids(self, indices: List[QModelIndex]) -> Sequence[CardId]:
|
|
return self._state.get_card_ids(self.get_items(indices))
|
|
|
|
def get_note_ids(self, indices: List[QModelIndex]) -> Sequence[NoteId]:
|
|
return self._state.get_note_ids(self.get_items(indices))
|
|
|
|
# Get row numbers from items
|
|
|
|
def get_item_row(self, item: ItemId) -> Optional[int]:
|
|
for row, i in enumerate(self._items):
|
|
if i == item:
|
|
return row
|
|
return None
|
|
|
|
def get_item_rows(self, items: Sequence[ItemId]) -> List[int]:
|
|
rows = []
|
|
for row, i in enumerate(self._items):
|
|
if i in items:
|
|
rows.append(row)
|
|
return rows
|
|
|
|
def get_card_row(self, card_id: CardId) -> Optional[int]:
|
|
return self.get_item_row(self._state.get_item_from_card_id(card_id))
|
|
|
|
# Get objects (cards or notes)
|
|
|
|
def get_card(self, index: QModelIndex) -> Optional[Card]:
|
|
"""Try to return the indicated, possibly deleted card."""
|
|
try:
|
|
return self._state.get_card(self.get_item(index))
|
|
except NotFoundError:
|
|
return None
|
|
|
|
def get_note(self, index: QModelIndex) -> Optional[Note]:
|
|
"""Try to return the indicated, possibly deleted note."""
|
|
try:
|
|
return self._state.get_note(self.get_item(index))
|
|
except NotFoundError:
|
|
return None
|
|
|
|
# Table Interface
|
|
######################################################################
|
|
|
|
def toggle_state(self, context: SearchContext) -> ItemState:
|
|
self.beginResetModel()
|
|
self._state = self._state.toggle_state()
|
|
self.search(context)
|
|
return self._state
|
|
|
|
# Rows
|
|
|
|
def search(self, context: SearchContext) -> None:
|
|
self.begin_reset()
|
|
try:
|
|
gui_hooks.browser_will_search(context)
|
|
if context.ids is None:
|
|
context.ids = self._state.find_items(context.search, context.order)
|
|
gui_hooks.browser_did_search(context)
|
|
self._items = context.ids
|
|
self._rows = {}
|
|
finally:
|
|
self.end_reset()
|
|
|
|
def reverse(self) -> None:
|
|
self.beginResetModel()
|
|
self._items = list(reversed(self._items))
|
|
self.endResetModel()
|
|
|
|
# Columns
|
|
|
|
def active_column(self, index: int) -> str:
|
|
return self._state.active_columns[index]
|
|
|
|
def active_column_index(self, column: str) -> Optional[int]:
|
|
return (
|
|
self._state.active_columns.index(column)
|
|
if column in self._state.active_columns
|
|
else None
|
|
)
|
|
|
|
def toggle_column(self, column: str) -> None:
|
|
self.begin_reset()
|
|
self._state.toggle_active_column(column)
|
|
self.end_reset()
|
|
|
|
# Model interface
|
|
######################################################################
|
|
|
|
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
|
|
if parent and parent.isValid():
|
|
return 0
|
|
return self.len_rows()
|
|
|
|
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
|
|
if parent and parent.isValid():
|
|
return 0
|
|
return self.len_columns()
|
|
|
|
def data(self, index: QModelIndex = QModelIndex(), role: int = 0) -> Any:
|
|
if not index.isValid():
|
|
return QVariant()
|
|
if role == Qt.FontRole:
|
|
if self.active_column(index.column()) not in (
|
|
"question",
|
|
"answer",
|
|
"noteFld",
|
|
):
|
|
return QVariant()
|
|
qfont = QFont()
|
|
row = self.get_row(index)
|
|
qfont.setFamily(row.font_name)
|
|
qfont.setPixelSize(row.font_size)
|
|
return qfont
|
|
if role == Qt.TextAlignmentRole:
|
|
align: Union[Qt.AlignmentFlag, int] = Qt.AlignVCenter
|
|
if self.active_column(index.column()) not in (
|
|
"question",
|
|
"answer",
|
|
"template",
|
|
"deck",
|
|
"noteFld",
|
|
"note",
|
|
"noteTags",
|
|
):
|
|
align |= Qt.AlignHCenter
|
|
return align
|
|
if role in (Qt.DisplayRole, Qt.ToolTipRole):
|
|
return self.get_cell(index).text
|
|
return QVariant()
|
|
|
|
def headerData(
|
|
self, section: int, orientation: Qt.Orientation, role: int = 0
|
|
) -> Optional[str]:
|
|
if orientation == Qt.Vertical:
|
|
return None
|
|
elif role == Qt.DisplayRole and section < self.len_columns():
|
|
column = self.active_column(section)
|
|
txt = None
|
|
for stype, name in self._state.columns:
|
|
if column == stype:
|
|
txt = name
|
|
break
|
|
# give the user a hint an invalid column was added by an add-on
|
|
if not txt:
|
|
txt = tr.browsing_addon()
|
|
return txt
|
|
else:
|
|
return None
|
|
|
|
def flags(self, index: QModelIndex) -> Qt.ItemFlags:
|
|
if self.get_row(index).is_deleted:
|
|
return Qt.ItemFlags(Qt.NoItemFlags)
|
|
return cast(Qt.ItemFlags, Qt.ItemIsEnabled | Qt.ItemIsSelectable)
|
|
|
|
|
|
# Line painter
|
|
######################################################################
|
|
|
|
|
|
class StatusDelegate(QItemDelegate):
|
|
def __init__(self, browser: aqt.browser.Browser, model: DataModel) -> None:
|
|
QItemDelegate.__init__(self, browser)
|
|
self._model = model
|
|
|
|
def paint(
|
|
self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex
|
|
) -> None:
|
|
if self._model.get_cell(index).is_rtl:
|
|
option.direction = Qt.RightToLeft
|
|
if row_color := self._model.get_row(index).color:
|
|
brush = QBrush(theme_manager.qcolor(row_color))
|
|
painter.save()
|
|
painter.fillRect(option.rect, brush)
|
|
painter.restore()
|
|
return QItemDelegate.paint(self, painter, option, index)
|