From 2000c80fd2c731d51f0a50336adbec7ef31d1654 Mon Sep 17 00:00:00 2001 From: RumovZ Date: Mon, 12 Apr 2021 09:50:44 +0200 Subject: [PATCH] Refactor browser and table into folders --- qt/aqt/browser/__init__.py | 22 + qt/aqt/{ => browser}/browser.py | 223 +----- qt/aqt/browser/dialogs.py | 226 ++++++ qt/aqt/browser/table/__init__.py | 96 +++ qt/aqt/browser/table/model.py | 301 ++++++++ qt/aqt/browser/table/state.py | 245 +++++++ qt/aqt/browser/table/table.py | 520 ++++++++++++++ qt/aqt/table.py | 1134 ------------------------------ qt/tools/genhooks_gui.py | 10 +- 9 files changed, 1418 insertions(+), 1359 deletions(-) create mode 100644 qt/aqt/browser/__init__.py rename qt/aqt/{ => browser}/browser.py (82%) create mode 100644 qt/aqt/browser/dialogs.py create mode 100644 qt/aqt/browser/table/__init__.py create mode 100644 qt/aqt/browser/table/model.py create mode 100644 qt/aqt/browser/table/state.py create mode 100644 qt/aqt/browser/table/table.py delete mode 100644 qt/aqt/table.py diff --git a/qt/aqt/browser/__init__.py b/qt/aqt/browser/__init__.py new file mode 100644 index 000000000..56319c6b9 --- /dev/null +++ b/qt/aqt/browser/__init__.py @@ -0,0 +1,22 @@ +# Copyright: Ankitects Pty Ltd and contributors +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +from __future__ import annotations + +from .browser import Browser +from .dialogs import CardInfoDialog, ChangeModel, FindDupesDialog +from .table import ( + CardState, + Cell, + CellRow, + Column, + Columns, + DataModel, + ItemId, + ItemList, + ItemState, + NoteState, + SearchContext, + StatusDelegate, + Table, +) diff --git a/qt/aqt/browser.py b/qt/aqt/browser/browser.py similarity index 82% rename from qt/aqt/browser.py rename to qt/aqt/browser/browser.py index 9a0c97c21..7dc2db756 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser/browser.py @@ -4,8 +4,7 @@ from __future__ import annotations import html -from dataclasses import dataclass -from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union +from typing import Any, Callable, List, Optional, Sequence, Tuple, Union import aqt import aqt.forms @@ -14,12 +13,13 @@ from anki.collection import Collection, Config, OpChanges, SearchNode from anki.consts import * from anki.errors import NotFoundError from anki.lang import without_unicode_isolation -from anki.models import NotetypeDict from anki.notes import NoteId from anki.stats import CardStats from anki.tags import MARKED_TAG from anki.utils import ids2str, isMac from aqt import AnkiQt, gui_hooks +from aqt.browser.dialogs import CardInfoDialog, ChangeModel, FindDupesDialog +from aqt.browser.table import Table from aqt.editor import Editor from aqt.exporting import ExportDialog from aqt.find_and_replace import FindAndReplaceDialog @@ -44,11 +44,9 @@ from aqt.previewer import Previewer from aqt.qt import * from aqt.sidebar import SidebarTreeView from aqt.switch import Switch -from aqt.table import Table from aqt.utils import ( HelpPage, KeyboardModifiersPressed, - askUser, current_top_level_widget, disable_help_button, ensure_editor_saved, @@ -75,16 +73,6 @@ from aqt.utils import ( from aqt.webview import AnkiWebView -@dataclass -class FindDupesDialog: - dialog: QDialog - browser: Browser - - -# Browser window -###################################################################### - - class Browser(QMainWindow): mw: AnkiQt col: Collection @@ -1018,208 +1006,3 @@ where id in %s""" def onCardList(self) -> None: self.form.tableView.setFocus() - - -# Change model dialog -###################################################################### - - -class ChangeModel(QDialog): - def __init__(self, browser: Browser, nids: Sequence[NoteId]) -> None: - QDialog.__init__(self, browser) - self.browser = browser - self.nids = nids - self.oldModel = browser.card.note().model() - self.form = aqt.forms.changemodel.Ui_Dialog() - self.form.setupUi(self) - disable_help_button(self) - self.setWindowModality(Qt.WindowModal) - self.setup() - restoreGeom(self, "changeModel") - gui_hooks.state_did_reset.append(self.onReset) - gui_hooks.current_note_type_did_change.append(self.on_note_type_change) - # ugh - these are set dynamically by rebuildTemplateMap() - self.tcombos: List[QComboBox] = [] - self.fcombos: List[QComboBox] = [] - self.exec_() - - def on_note_type_change(self, notetype: NotetypeDict) -> None: - self.onReset() - - def setup(self) -> None: - # maps - self.flayout = QHBoxLayout() - self.flayout.setContentsMargins(0, 0, 0, 0) - self.fwidg = None - self.form.fieldMap.setLayout(self.flayout) - self.tlayout = QHBoxLayout() - self.tlayout.setContentsMargins(0, 0, 0, 0) - self.twidg = None - self.form.templateMap.setLayout(self.tlayout) - if self.style().objectName() == "gtk+": - # gtk+ requires margins in inner layout - self.form.verticalLayout_2.setContentsMargins(0, 11, 0, 0) - self.form.verticalLayout_3.setContentsMargins(0, 11, 0, 0) - # model chooser - import aqt.modelchooser - - self.oldModel = self.browser.col.models.get( - self.browser.col.db.scalar( - "select mid from notes where id = ?", self.nids[0] - ) - ) - self.form.oldModelLabel.setText(self.oldModel["name"]) - self.modelChooser = aqt.modelchooser.ModelChooser( - self.browser.mw, self.form.modelChooserWidget, label=False - ) - self.modelChooser.models.setFocus() - qconnect(self.form.buttonBox.helpRequested, self.onHelp) - self.modelChanged(self.browser.mw.col.models.current()) - self.pauseUpdate = False - - def onReset(self) -> None: - self.modelChanged(self.browser.col.models.current()) - - def modelChanged(self, model: Dict[str, Any]) -> None: - self.targetModel = model - self.rebuildTemplateMap() - self.rebuildFieldMap() - - def rebuildTemplateMap( - self, key: Optional[str] = None, attr: Optional[str] = None - ) -> None: - if not key: - key = "t" - attr = "tmpls" - map = getattr(self, key + "widg") - lay = getattr(self, key + "layout") - src = self.oldModel[attr] - dst = self.targetModel[attr] - if map: - lay.removeWidget(map) - map.deleteLater() - setattr(self, key + "MapWidget", None) - map = QWidget() - l = QGridLayout() - combos = [] - targets = [x["name"] for x in dst] + [tr.browsing_nothing()] - indices = {} - for i, x in enumerate(src): - l.addWidget(QLabel(tr.browsing_change_to(val=x["name"])), i, 0) - cb = QComboBox() - cb.addItems(targets) - idx = min(i, len(targets) - 1) - cb.setCurrentIndex(idx) - indices[cb] = idx - qconnect( - cb.currentIndexChanged, - lambda i, cb=cb, key=key: self.onComboChanged(i, cb, key), - ) - combos.append(cb) - l.addWidget(cb, i, 1) - map.setLayout(l) - lay.addWidget(map) - setattr(self, key + "widg", map) - setattr(self, key + "layout", lay) - setattr(self, key + "combos", combos) - setattr(self, key + "indices", indices) - - def rebuildFieldMap(self) -> None: - return self.rebuildTemplateMap(key="f", attr="flds") - - def onComboChanged(self, i: int, cb: QComboBox, key: str) -> None: - indices = getattr(self, key + "indices") - if self.pauseUpdate: - indices[cb] = i - return - combos = getattr(self, key + "combos") - if i == cb.count() - 1: - # set to 'nothing' - return - # find another combo with same index - for c in combos: - if c == cb: - continue - if c.currentIndex() == i: - self.pauseUpdate = True - c.setCurrentIndex(indices[cb]) - self.pauseUpdate = False - break - indices[cb] = i - - def getTemplateMap( - self, - old: Optional[List[Dict[str, Any]]] = None, - combos: Optional[List[QComboBox]] = None, - new: Optional[List[Dict[str, Any]]] = None, - ) -> Dict[int, Optional[int]]: - if not old: - old = self.oldModel["tmpls"] - combos = self.tcombos - new = self.targetModel["tmpls"] - template_map: Dict[int, Optional[int]] = {} - for i, f in enumerate(old): - idx = combos[i].currentIndex() - if idx == len(new): - # ignore - template_map[f["ord"]] = None - else: - f2 = new[idx] - template_map[f["ord"]] = f2["ord"] - return template_map - - def getFieldMap(self) -> Dict[int, Optional[int]]: - return self.getTemplateMap( - old=self.oldModel["flds"], combos=self.fcombos, new=self.targetModel["flds"] - ) - - def cleanup(self) -> None: - gui_hooks.state_did_reset.remove(self.onReset) - gui_hooks.current_note_type_did_change.remove(self.on_note_type_change) - self.modelChooser.cleanup() - saveGeom(self, "changeModel") - - def reject(self) -> None: - self.cleanup() - return QDialog.reject(self) - - def accept(self) -> None: - # check maps - fmap = self.getFieldMap() - cmap = self.getTemplateMap() - if any(True for c in list(cmap.values()) if c is None): - if not askUser(tr.browsing_any_cards_mapped_to_nothing_will()): - return - self.browser.mw.checkpoint(tr.browsing_change_note_type()) - b = self.browser - b.mw.col.modSchema(check=True) - b.mw.progress.start() - b.begin_reset() - mm = b.mw.col.models - mm.change(self.oldModel, list(self.nids), self.targetModel, fmap, cmap) - b.search() - b.end_reset() - b.mw.progress.finish() - b.mw.reset() - self.cleanup() - QDialog.accept(self) - - def onHelp(self) -> None: - openHelp(HelpPage.BROWSING_OTHER_MENU_ITEMS) - - -# Card Info Dialog -###################################################################### - - -class CardInfoDialog(QDialog): - silentlyClose = True - - def __init__(self, browser: Browser) -> None: - super().__init__(browser) - self.browser = browser - disable_help_button(self) - - def reject(self) -> None: - saveGeom(self, "revlog") - return QDialog.reject(self) diff --git a/qt/aqt/browser/dialogs.py b/qt/aqt/browser/dialogs.py new file mode 100644 index 000000000..f98df777a --- /dev/null +++ b/qt/aqt/browser/dialogs.py @@ -0,0 +1,226 @@ +# Copyright: Ankitects Pty Ltd and contributors +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Sequence + +import aqt +from anki.consts import * +from anki.models import NotetypeDict +from anki.notes import NoteId +from aqt import gui_hooks +from aqt.qt import * +from aqt.utils import ( + HelpPage, + askUser, + disable_help_button, + openHelp, + restoreGeom, + saveGeom, + tr, +) + + +@dataclass +class FindDupesDialog: + dialog: QDialog + browser: aqt.browser.Browser + + +class ChangeModel(QDialog): + def __init__(self, browser: aqt.browser.Browser, nids: Sequence[NoteId]) -> None: + QDialog.__init__(self, browser) + self.browser = browser + self.nids = nids + self.oldModel = browser.card.note().model() + self.form = aqt.forms.changemodel.Ui_Dialog() + self.form.setupUi(self) + disable_help_button(self) + self.setWindowModality(Qt.WindowModal) + self.setup() + restoreGeom(self, "changeModel") + gui_hooks.state_did_reset.append(self.onReset) + gui_hooks.current_note_type_did_change.append(self.on_note_type_change) + # ugh - these are set dynamically by rebuildTemplateMap() + self.tcombos: List[QComboBox] = [] + self.fcombos: List[QComboBox] = [] + self.exec_() + + def on_note_type_change(self, notetype: NotetypeDict) -> None: + self.onReset() + + def setup(self) -> None: + # maps + self.flayout = QHBoxLayout() + self.flayout.setContentsMargins(0, 0, 0, 0) + self.fwidg = None + self.form.fieldMap.setLayout(self.flayout) + self.tlayout = QHBoxLayout() + self.tlayout.setContentsMargins(0, 0, 0, 0) + self.twidg = None + self.form.templateMap.setLayout(self.tlayout) + if self.style().objectName() == "gtk+": + # gtk+ requires margins in inner layout + self.form.verticalLayout_2.setContentsMargins(0, 11, 0, 0) + self.form.verticalLayout_3.setContentsMargins(0, 11, 0, 0) + # model chooser + import aqt.modelchooser + + self.oldModel = self.browser.col.models.get( + self.browser.col.db.scalar( + "select mid from notes where id = ?", self.nids[0] + ) + ) + self.form.oldModelLabel.setText(self.oldModel["name"]) + self.modelChooser = aqt.modelchooser.ModelChooser( + self.browser.mw, self.form.modelChooserWidget, label=False + ) + self.modelChooser.models.setFocus() + qconnect(self.form.buttonBox.helpRequested, self.onHelp) + self.modelChanged(self.browser.mw.col.models.current()) + self.pauseUpdate = False + + def onReset(self) -> None: + self.modelChanged(self.browser.col.models.current()) + + def modelChanged(self, model: Dict[str, Any]) -> None: + self.targetModel = model + self.rebuildTemplateMap() + self.rebuildFieldMap() + + def rebuildTemplateMap( + self, key: Optional[str] = None, attr: Optional[str] = None + ) -> None: + if not key: + key = "t" + attr = "tmpls" + map = getattr(self, key + "widg") + lay = getattr(self, key + "layout") + src = self.oldModel[attr] + dst = self.targetModel[attr] + if map: + lay.removeWidget(map) + map.deleteLater() + setattr(self, key + "MapWidget", None) + map = QWidget() + l = QGridLayout() + combos = [] + targets = [x["name"] for x in dst] + [tr.browsing_nothing()] + indices = {} + for i, x in enumerate(src): + l.addWidget(QLabel(tr.browsing_change_to(val=x["name"])), i, 0) + cb = QComboBox() + cb.addItems(targets) + idx = min(i, len(targets) - 1) + cb.setCurrentIndex(idx) + indices[cb] = idx + qconnect( + cb.currentIndexChanged, + lambda i, cb=cb, key=key: self.onComboChanged(i, cb, key), + ) + combos.append(cb) + l.addWidget(cb, i, 1) + map.setLayout(l) + lay.addWidget(map) + setattr(self, key + "widg", map) + setattr(self, key + "layout", lay) + setattr(self, key + "combos", combos) + setattr(self, key + "indices", indices) + + def rebuildFieldMap(self) -> None: + return self.rebuildTemplateMap(key="f", attr="flds") + + def onComboChanged(self, i: int, cb: QComboBox, key: str) -> None: + indices = getattr(self, key + "indices") + if self.pauseUpdate: + indices[cb] = i + return + combos = getattr(self, key + "combos") + if i == cb.count() - 1: + # set to 'nothing' + return + # find another combo with same index + for c in combos: + if c == cb: + continue + if c.currentIndex() == i: + self.pauseUpdate = True + c.setCurrentIndex(indices[cb]) + self.pauseUpdate = False + break + indices[cb] = i + + def getTemplateMap( + self, + old: Optional[List[Dict[str, Any]]] = None, + combos: Optional[List[QComboBox]] = None, + new: Optional[List[Dict[str, Any]]] = None, + ) -> Dict[int, Optional[int]]: + if not old: + old = self.oldModel["tmpls"] + combos = self.tcombos + new = self.targetModel["tmpls"] + template_map: Dict[int, Optional[int]] = {} + for i, f in enumerate(old): + idx = combos[i].currentIndex() + if idx == len(new): + # ignore + template_map[f["ord"]] = None + else: + f2 = new[idx] + template_map[f["ord"]] = f2["ord"] + return template_map + + def getFieldMap(self) -> Dict[int, Optional[int]]: + return self.getTemplateMap( + old=self.oldModel["flds"], combos=self.fcombos, new=self.targetModel["flds"] + ) + + def cleanup(self) -> None: + gui_hooks.state_did_reset.remove(self.onReset) + gui_hooks.current_note_type_did_change.remove(self.on_note_type_change) + self.modelChooser.cleanup() + saveGeom(self, "changeModel") + + def reject(self) -> None: + self.cleanup() + return QDialog.reject(self) + + def accept(self) -> None: + # check maps + fmap = self.getFieldMap() + cmap = self.getTemplateMap() + if any(True for c in list(cmap.values()) if c is None): + if not askUser(tr.browsing_any_cards_mapped_to_nothing_will()): + return + self.browser.mw.checkpoint(tr.browsing_change_note_type()) + b = self.browser + b.mw.col.modSchema(check=True) + b.mw.progress.start() + b.begin_reset() + mm = b.mw.col.models + mm.change(self.oldModel, list(self.nids), self.targetModel, fmap, cmap) + b.search() + b.end_reset() + b.mw.progress.finish() + b.mw.reset() + self.cleanup() + QDialog.accept(self) + + def onHelp(self) -> None: + openHelp(HelpPage.BROWSING_OTHER_MENU_ITEMS) + + +class CardInfoDialog(QDialog): + silentlyClose = True + + def __init__(self, browser: aqt.browser.Browser) -> None: + super().__init__(browser) + self.browser = browser + disable_help_button(self) + + def reject(self) -> None: + saveGeom(self, "revlog") + return QDialog.reject(self) diff --git a/qt/aqt/browser/table/__init__.py b/qt/aqt/browser/table/__init__.py new file mode 100644 index 000000000..6f9bfa110 --- /dev/null +++ b/qt/aqt/browser/table/__init__.py @@ -0,0 +1,96 @@ +# -*- 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 dataclasses import dataclass +from typing import TYPE_CHECKING, Generator, Optional, Sequence, Tuple, Union + +import aqt +from anki.cards import CardId +from anki.collection import BrowserColumns as Columns +from anki.collection import BrowserRow +from anki.notes import NoteId +from aqt import colors +from aqt.utils import tr + +Column = Columns.Column +ItemId = Union[CardId, NoteId] +ItemList = Union[Sequence[CardId], Sequence[NoteId]] + + +@dataclass +class SearchContext: + search: str + browser: aqt.browser.Browser + order: Union[bool, str, Column] = True + reverse: bool = False + # if set, provided ids will be used instead of the regular search + ids: Optional[Sequence[ItemId]] = None + + +@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 + + +from .model import DataModel +from .state import CardState, ItemState, NoteState +from .table import StatusDelegate, Table diff --git a/qt/aqt/browser/table/model.py b/qt/aqt/browser/table/model.py new file mode 100644 index 000000000..76b4320cf --- /dev/null +++ b/qt/aqt/browser/table/model.py @@ -0,0 +1,301 @@ +# -*- 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 typing import Any, Dict, List, Optional, Sequence, Union, cast + +from anki.cards import Card, CardId +from anki.collection import BrowserColumns as Columns +from anki.collection import Collection +from anki.consts import * +from anki.errors import NotFoundError +from anki.notes import Note, NoteId +from aqt import gui_hooks +from aqt.browser.table import Cell, CellRow, Column, ItemId, SearchContext +from aqt.browser.table.state import ItemState +from aqt.qt import * +from aqt.utils import tr + + +class DataModel(QAbstractTableModel): + """Data manager for the browser table. + + _items -- The card or note ids currently hold and corresponding to the + table's rows. + _rows -- The cached data objects to render items to rows. + columns -- The data objects of all available columns, used to define the display + of active columns and list all toggleable columns to the user. + _block_updates -- If True, serve stale content to avoid hitting the DB. + _stale_cutoff -- A threshold to decide whether a cached row has gone stale. + """ + + def __init__(self, col: Collection, state: ItemState) -> None: + QAbstractTableModel.__init__(self) + self.col: Collection = col + self.columns: Dict[str, Column] = dict( + ((c.key, c) for c in self.col.all_browser_columns()) + ) + gui_hooks.browser_did_fetch_columns(self.columns) + self._state: ItemState = state + self._items: Sequence[ItemId] = [] + self._rows: Dict[int, CellRow] = {} + 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: + if context.order is True: + try: + context.order = self.columns[self._state.sort_column] + except KeyError: + # invalid sort column in config + context.order = self.columns["noteCrt"] + context.reverse = self._state.sort_backwards + gui_hooks.browser_will_search(context) + if context.ids is None: + context.ids = self._state.find_items( + context.search, context.order, context.reverse + ) + 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 column_at(self, index: QModelIndex) -> Column: + return self.column_at_section(index.column()) + + def column_at_section(self, section: int) -> Column: + """Returns the column object corresponding to the active column at index or the default + column object if no data is associated with the active column. + """ + key = self._state.column_key_at(section) + try: + return self.columns[key] + except KeyError: + self.columns[key] = addon_column_fillin(key) + return self.columns[key] + + 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 not self.column_at(index).uses_cell_font: + 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.column_at(index).alignment == Columns.ALIGNMENT_CENTER: + 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.Horizontal and role == Qt.DisplayRole: + return self._state.column_label(self.column_at_section(section)) + 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) + + +def addon_column_fillin(key: str) -> Column: + """Return a column with generic fields and a label indicating to the user that this column was + added by an add-on. + """ + return Column( + key=key, + cards_mode_label=tr.browsing_addon(), + notes_mode_label=tr.browsing_addon(), + sorting=Columns.SORTING_NONE, + uses_cell_font=False, + alignment=Columns.ALIGNMENT_CENTER, + ) diff --git a/qt/aqt/browser/table/state.py b/qt/aqt/browser/table/state.py new file mode 100644 index 000000000..5117afbf1 --- /dev/null +++ b/qt/aqt/browser/table/state.py @@ -0,0 +1,245 @@ +# -*- 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 + +from abc import ABC, abstractmethod, abstractproperty +from typing import List, Sequence, Union, cast + +from anki.cards import Card, CardId +from anki.collection import Collection, Config +from anki.notes import Note, NoteId +from anki.utils import ids2str +from aqt.browser.table import Column, ItemId, ItemList + + +class ItemState(ABC): + config_key_prefix: 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)}") + + def column_key_at(self, index: int) -> str: + return self._active_columns[index] + + def column_label(self, column: Column) -> str: + return ( + column.notes_mode_label if self.is_notes_mode() else column.cards_mode_label + ) + + # Columns and sorting + + # abstractproperty is deprecated but used due to mypy limitations + # (https://github.com/python/mypy/issues/1362) + @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, Column], reverse: bool + ) -> 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.config_key_prefix = "editor" + 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 + ) + + @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, Column], reverse: bool + ) -> Sequence[ItemId]: + return self.col.find_cards(search, order, reverse) + + 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.config_key_prefix = "editorNotesMode" + 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 + ) + + @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, Column], reverse: bool + ) -> Sequence[ItemId]: + return self.col.find_notes(search, order, reverse) + + def get_item_from_card_id(self, card: CardId) -> ItemId: + return self.col.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) diff --git a/qt/aqt/browser/table/table.py b/qt/aqt/browser/table/table.py new file mode 100644 index 000000000..64fac9c96 --- /dev/null +++ b/qt/aqt/browser/table/table.py @@ -0,0 +1,520 @@ +# -*- 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 + +from typing import Any, Callable, List, Optional, Sequence, Tuple, cast + +import aqt +import aqt.forms +from anki.cards import Card, CardId +from anki.collection import Collection, Config, OpChanges +from anki.consts import * +from anki.notes import Note, NoteId +from anki.utils import isWin +from aqt import colors, gui_hooks +from aqt.browser.table import Columns, ItemId, SearchContext +from aqt.browser.table.model import DataModel +from aqt.browser.table.state import CardState, ItemState, NoteState +from aqt.qt import * +from aqt.theme import theme_manager +from aqt.utils import ( + KeyboardModifiersPressed, + qtMenuShortcutWorkaround, + restoreHeader, + saveHeader, + showInfo, + tr, +) + + +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() + + def cleanup(self) -> None: + self._save_header() + + # 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() + if (row := self._model.get_card_row(card_id)) is not None: + self._view.selectRow(row) + + # 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, handler: Optional[object], 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_header() + 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._restore_header() + 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) + + def _save_header(self) -> None: + saveHeader(self._view.horizontalHeader(), self._state.config_key_prefix) + + def _restore_header(self) -> None: + restoreHeader(self._view.horizontalHeader(), self._state.config_key_prefix) + + # 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() + hh.setHighlightSections(False) + hh.setMinimumSectionSize(50) + hh.setSectionsMovable(True) + hh.setContextMenuPolicy(Qt.CustomContextMenu) + self._restore_header() + self._set_column_sizes() + self._set_sort_indicator() + qconnect(hh.customContextMenuRequested, self._on_header_context) + 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 key, column in self._model.columns.items(): + a = m.addAction(self._state.column_label(column)) + a.setCheckable(True) + a.setChecked(self._model.active_column_index(key) is not None) + qconnect( + a.toggled, + lambda checked, key=key: self._on_column_toggled(checked, key), + ) + 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, section: int, order: int) -> None: + order = bool(order) + column = self._model.column_at_section(section) + if column.sorting == Columns.SORTING_NONE: + showInfo(tr.browsing_sorting_on_this_column_is_not()) + sort_key = self._state.sort_column + else: + sort_key = column.key + if self._state.sort_column != sort_key: + self._state.sort_column = sort_key + # default to descending for non-text fields + if column.sorting == Columns.SORTING_REVERSED: + 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, + ), + ) + + +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) diff --git a/qt/aqt/table.py b/qt/aqt/table.py deleted file mode 100644 index ea19fc379..000000000 --- a/qt/aqt/table.py +++ /dev/null @@ -1,1134 +0,0 @@ -# -*- 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 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 BrowserColumns as Columns -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.qt import * -from aqt.theme import theme_manager -from aqt.utils import ( - KeyboardModifiersPressed, - qtMenuShortcutWorkaround, - restoreHeader, - saveHeader, - showInfo, - tr, -) - -Column = Columns.Column -ItemId = Union[CardId, NoteId] -ItemList = Union[Sequence[CardId], Sequence[NoteId]] - - -@dataclass -class SearchContext: - search: str - browser: aqt.browser.Browser - order: Union[bool, str, Column] = True - reverse: bool = False - # 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() - - def cleanup(self) -> None: - self._save_header() - - # 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() - if (row := self._model.get_card_row(card_id)) is not None: - self._view.selectRow(row) - - # 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, handler: Optional[object], 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_header() - 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._restore_header() - 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) - - def _save_header(self) -> None: - saveHeader(self._view.horizontalHeader(), self._state.config_key_prefix) - - def _restore_header(self) -> None: - restoreHeader(self._view.horizontalHeader(), self._state.config_key_prefix) - - # 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() - hh.setHighlightSections(False) - hh.setMinimumSectionSize(50) - hh.setSectionsMovable(True) - hh.setContextMenuPolicy(Qt.CustomContextMenu) - self._restore_header() - self._set_column_sizes() - self._set_sort_indicator() - qconnect(hh.customContextMenuRequested, self._on_header_context) - 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 key, column in self._model.columns.items(): - a = m.addAction(self._state.column_label(column)) - a.setCheckable(True) - a.setChecked(self._model.active_column_index(key) is not None) - qconnect( - a.toggled, - lambda checked, key=key: self._on_column_toggled(checked, key), - ) - 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, section: int, order: int) -> None: - order = bool(order) - column = self._model.column_at_section(section) - if column.sorting == Columns.SORTING_NONE: - showInfo(tr.browsing_sorting_on_this_column_is_not()) - sort_key = self._state.sort_column - else: - sort_key = column.key - if self._state.sort_column != sort_key: - self._state.sort_column = sort_key - # default to descending for non-text fields - if column.sorting == Columns.SORTING_REVERSED: - 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): - config_key_prefix: 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)}") - - def column_key_at(self, index: int) -> str: - return self._active_columns[index] - - def column_label(self, column: Column) -> str: - return ( - column.notes_mode_label if self.is_notes_mode() else column.cards_mode_label - ) - - # Columns and sorting - - # abstractproperty is deprecated but used due to mypy limitations - # (https://github.com/python/mypy/issues/1362) - @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, Column], reverse: bool - ) -> 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.config_key_prefix = "editor" - 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 - ) - - @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, Column], reverse: bool - ) -> Sequence[ItemId]: - return self.col.find_cards(search, order, reverse) - - 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.config_key_prefix = "editorNotesMode" - 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 - ) - - @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, Column], reverse: bool - ) -> Sequence[ItemId]: - return self.col.find_notes(search, order, reverse) - - def get_item_from_card_id(self, card: CardId) -> ItemId: - return self.col.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): - """Data manager for the browser table. - - _items -- The card or note ids currently hold and corresponding to the - table's rows. - _rows -- The cached data objects to render items to rows. - columns -- The data objects of all available columns, used to define the display - of active columns and list all toggleable columns to the user. - _block_updates -- If True, serve stale content to avoid hitting the DB. - _stale_cutoff -- A threshold to decide whether a cached row has gone stale. - """ - - def __init__(self, col: Collection, state: ItemState) -> None: - QAbstractTableModel.__init__(self) - self.col: Collection = col - self.columns: Dict[str, Column] = dict( - ((c.key, c) for c in self.col.all_browser_columns()) - ) - gui_hooks.browser_did_fetch_columns(self.columns) - self._state: ItemState = state - self._items: Sequence[ItemId] = [] - self._rows: Dict[int, CellRow] = {} - 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: - if context.order is True: - try: - context.order = self.columns[self._state.sort_column] - except KeyError: - # invalid sort column in config - context.order = self.columns["noteCrt"] - context.reverse = self._state.sort_backwards - gui_hooks.browser_will_search(context) - if context.ids is None: - context.ids = self._state.find_items( - context.search, context.order, context.reverse - ) - 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 column_at(self, index: QModelIndex) -> Column: - return self.column_at_section(index.column()) - - def column_at_section(self, section: int) -> Column: - """Returns the column object corresponding to the active column at index or the default - column object if no data is associated with the active column. - """ - key = self._state.column_key_at(section) - try: - return self.columns[key] - except KeyError: - self.columns[key] = addon_column_fillin(key) - return self.columns[key] - - 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 not self.column_at(index).uses_cell_font: - 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.column_at(index).alignment == Columns.ALIGNMENT_CENTER: - 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.Horizontal and role == Qt.DisplayRole: - return self._state.column_label(self.column_at_section(section)) - 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) - - -def addon_column_fillin(key: str) -> Column: - """Return a column with generic fields and a label indicating to the user that this column was - added by an add-on. - """ - return Column( - key=key, - cards_mode_label=tr.browsing_addon(), - notes_mode_label=tr.browsing_addon(), - sorting=Columns.SORTING_NONE, - uses_cell_font=False, - alignment=Columns.ALIGNMENT_CENTER, - ) diff --git a/qt/tools/genhooks_gui.py b/qt/tools/genhooks_gui.py index f7fdd61c9..19d7a62f0 100644 --- a/qt/tools/genhooks_gui.py +++ b/qt/tools/genhooks_gui.py @@ -386,7 +386,7 @@ hooks = [ ), Hook( name="browser_will_search", - args=["context: aqt.table.SearchContext"], + args=["context: aqt.browser.SearchContext"], doc="""Allows you to modify the search text, or perform your own search. You can modify context.search to change the text that is sent to the @@ -401,15 +401,15 @@ hooks = [ ), Hook( name="browser_did_search", - args=["context: aqt.table.SearchContext"], + args=["context: aqt.browser.SearchContext"], doc="""Allows you to modify the list of returned card ids from a search.""", ), Hook( name="browser_did_fetch_row", args=[ - "card_or_note_id: aqt.table.ItemId", + "card_or_note_id: aqt.browser.ItemId", "is_note: bool", - "row: aqt.table.CellRow", + "row: aqt.browser.CellRow", "columns: Sequence[str]", ], doc="""Allows you to add or modify content to a row in the browser. @@ -424,7 +424,7 @@ hooks = [ ), Hook( name="browser_did_fetch_columns", - args=["columns: Dict[str, aqt.table.Column]"], + args=["columns: Dict[str, aqt.browser.Column]"], doc="""Allows you to add custom columns to the browser. columns is a dictionary of data obejcts. You can add an entry with a custom