Merge pull request #1392 from RumovZ/missing-row-handling

Handle missing rows consistently and speed up selections
This commit is contained in:
Damien Elmes 2021-10-01 14:24:04 +10:00 committed by GitHub
commit ee99578c06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 178 additions and 41 deletions

View File

@ -9,13 +9,7 @@ import aqt
import aqt.forms
from anki._legacy import deprecated
from anki.cards import Card, CardId
from anki.collection import (
Collection,
Config,
OpChanges,
OpChangesWithCount,
SearchNode,
)
from anki.collection import Collection, Config, OpChanges, SearchNode
from anki.consts import *
from anki.errors import NotFoundError
from anki.lang import without_unicode_isolation
@ -60,7 +54,6 @@ from aqt.utils import (
saveState,
showWarning,
skip_if_selection_is_empty,
tooltip,
tr,
)
@ -417,9 +410,7 @@ class Browser(QMainWindow):
gui_hooks.editor_did_init.remove(add_preview_button)
@ensure_editor_saved
def onRowChanged(
self, _current: Optional[QItemSelection], _previous: Optional[QItemSelection]
) -> None:
def on_row_changed(self) -> None:
"""Update current note and hide/show editor."""
if self._closeEventHasCleanedUp:
return
@ -628,17 +619,8 @@ class Browser(QMainWindow):
if focus != self.form.tableView:
return
nids = self.table.get_selected_note_ids()
def after_remove(changes: OpChangesWithCount) -> None:
tooltip(tr.browsing_cards_deleted(count=changes.count))
# select the next card if there is one
self.focusTo = self.editor.currentField
self.table.to_next_row()
remove_notes(parent=self, note_ids=nids).success(
after_remove
).run_in_background()
nids = self.table.to_row_of_unselected_note()
remove_notes(parent=self, note_ids=nids).run_in_background()
# legacy

View File

@ -4,7 +4,7 @@
from __future__ import annotations
import time
from typing import Any, Dict, List, Optional, Sequence, Union, cast
from typing import Any, Callable, Dict, List, Optional, Sequence, Union, cast
import aqt
from anki.cards import Card, CardId
@ -32,7 +32,13 @@ class DataModel(QAbstractTableModel):
_stale_cutoff -- A threshold to decide whether a cached row has gone stale.
"""
def __init__(self, col: Collection, state: ItemState) -> None:
def __init__(
self,
col: Collection,
state: ItemState,
row_state_will_change_callback: Callable,
row_state_changed_callback: Callable,
) -> None:
QAbstractTableModel.__init__(self)
self.col: Collection = col
self.columns: Dict[str, Column] = dict(
@ -44,6 +50,8 @@ class DataModel(QAbstractTableModel):
self._rows: Dict[int, CellRow] = {}
self._block_updates = False
self._stale_cutoff = 0.0
self._on_row_state_will_change = row_state_will_change_callback
self._on_row_state_changed = row_state_changed_callback
self._want_tooltips = aqt.mw.pm.show_browser_table_tooltips()
# Row Object Interface
@ -59,15 +67,34 @@ class DataModel(QAbstractTableModel):
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 self._fetch_row_and_update_cache(index, item, row)
# 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._fetch_row_and_update_cache(index, item, None)
def _fetch_row_and_update_cache(
self, index: QModelIndex, item: ItemId, old_row: Optional[CellRow]
) -> CellRow:
"""Fetch a row from the backend, add it to the cache and return it.
Then fire callbacks if the row is being deleted or restored.
"""
new_row = self._fetch_row_from_backend(item)
# row state has changed if existence of cached and fetched counterparts differ
# if the row was previously uncached, it is assumed to have existed
state_change = (
new_row.is_deleted
if old_row is None
else old_row.is_deleted != new_row.is_deleted
)
if state_change:
self._on_row_state_will_change(index, not new_row.is_deleted)
self._rows[item] = new_row
if state_change:
self._on_row_state_changed(index, not new_row.is_deleted)
return self._rows[item]
def _fetch_row_from_backend(self, item: ItemId) -> CellRow:
@ -92,6 +119,10 @@ class DataModel(QAbstractTableModel):
)
return row
def get_cached_row(self, index: QModelIndex) -> Optional[CellRow]:
"""Get row if it is cached, regardless of staleness."""
return self._rows.get(self.get_item(index))
# Reset
def mark_cache_stale(self) -> None:
@ -153,6 +184,11 @@ class DataModel(QAbstractTableModel):
def get_note_ids(self, indices: List[QModelIndex]) -> Sequence[NoteId]:
return self._state.get_note_ids(self.get_items(indices))
def get_note_id(self, index: QModelIndex) -> Optional[NoteId]:
if nid_list := self._state.get_note_ids([self.get_item(index)]):
return nid_list[0]
return None
# Get row numbers from items
def get_item_row(self, item: ItemId) -> Optional[int]:
@ -175,6 +211,8 @@ class DataModel(QAbstractTableModel):
def get_card(self, index: QModelIndex) -> Optional[Card]:
"""Try to return the indicated, possibly deleted card."""
if not index.isValid():
return None
try:
return self._state.get_card(self.get_item(index))
except NotFoundError:
@ -182,6 +220,8 @@ class DataModel(QAbstractTableModel):
def get_note(self, index: QModelIndex) -> Optional[Note]:
"""Try to return the indicated, possibly deleted note."""
if not index.isValid():
return None
try:
return self._state.get_note(self.get_item(index))
except NotFoundError:
@ -295,8 +335,10 @@ class DataModel(QAbstractTableModel):
return None
def flags(self, index: QModelIndex) -> Qt.ItemFlags:
if self.get_row(index).is_deleted:
return Qt.ItemFlags(Qt.NoItemFlags)
# shortcut for large selections (Ctrl+A) to avoid fetching large numbers of rows at once
if row := self.get_cached_row(index):
if row.is_deleted:
return Qt.ItemFlags(Qt.NoItemFlags)
return cast(Qt.ItemFlags, Qt.ItemIsEnabled | Qt.ItemIsSelectable)

View File

@ -39,8 +39,17 @@ class Table:
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._model = DataModel(
self.col,
self._state,
self._on_row_state_will_change,
self._on_row_state_changed,
)
self._view: Optional[QTableView] = None
# cached for performance
self._len_selection = 0
self._selected_rows: Optional[List[QModelIndex]] = None
# temporarily set for selection preservation
self._current_item: Optional[ItemId] = None
self._selected_items: Sequence[ItemId] = []
@ -60,8 +69,11 @@ class Table:
def len(self) -> int:
return self._model.len_rows()
def len_selection(self) -> int:
return len(self._view.selectionModel().selectedRows())
def len_selection(self, refresh: bool = False) -> int:
# `len(self._view.selectionModel().selectedRows())` is slow for large
# selections, because Qt queries flags() for every selected cell, so we
# calculate the number of selected rows ourselves
return self._len_selection
def has_current(self) -> bool:
return self._view.selectionModel().currentIndex().isValid()
@ -78,13 +90,9 @@ class Table:
# 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]:
@ -111,6 +119,8 @@ class Table:
self._view.selectAll()
def clear_selection(self) -> None:
self._len_selection = 0
self._selected_rows = None
self._view.selectionModel().clear()
def invert_selection(self) -> None:
@ -126,10 +136,12 @@ class Table:
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()
self._reset_selection()
if (row := self._model.get_card_row(card_id)) is not None:
self._view.selectRow(row)
self._scroll_to_row(row, scroll_even_if_visible=True)
else:
self.browser.on_row_changed()
# Reset
@ -201,6 +213,43 @@ class Table:
def to_last_row(self) -> None:
self._move_current_to_row(self._model.len_rows() - 1)
def to_row_of_unselected_note(self) -> Sequence[NoteId]:
"""Select and set focus to a row whose note is not selected, trying
the rows below the bottomost, then above the topmost selected row.
If that's not possible, clear selection.
Return previously selected note ids.
"""
nids = self.get_selected_note_ids()
bottom = max(r.row() for r in self._selected()) + 1
for row in range(bottom, self.len()):
index = self._model.index(row, 0)
if self._model.get_row(index).is_deleted:
continue
if self._model.get_note_id(index) in nids:
continue
self._move_current_to_row(row)
return nids
top = min(r.row() for r in self._selected()) - 1
for row in range(top, -1, -1):
index = self._model.index(row, 0)
if self._model.get_row(index).is_deleted:
continue
if self._model.get_note_id(index) in nids:
continue
self._move_current_to_row(row)
return nids
self._reset_selection()
self.browser.on_row_changed()
return nids
def clear_current(self) -> None:
self._view.selectionModel().setCurrentIndex(
QModelIndex(), QItemSelectionModel.NoUpdate
)
# Private methods
######################################################################
@ -210,7 +259,9 @@ class Table:
return self._view.selectionModel().currentIndex()
def _selected(self) -> List[QModelIndex]:
return self._view.selectionModel().selectedRows()
if self._selected_rows is None:
self._selected_rows = self._view.selectionModel().selectedRows()
return self._selected_rows
def _set_current(self, row: int, column: int = 0) -> None:
index = self._model.index(
@ -218,6 +269,15 @@ class Table:
)
self._view.selectionModel().setCurrentIndex(index, QItemSelectionModel.NoUpdate)
def _reset_selection(self) -> None:
"""Remove selection and focus without emitting signals.
If no selection change is triggerd afterwards, `browser.on_row_changed()`
must be called.
"""
self._view.selectionModel().reset()
self._len_selection = 0
self._selected_rows = None
def _select_rows(self, rows: List[int]) -> None:
selection = QItemSelection()
for row in rows:
@ -269,7 +329,7 @@ class Table:
self._view.selectionModel()
self._view.setItemDelegate(StatusDelegate(self.browser, self._model))
qconnect(
self._view.selectionModel().selectionChanged, self.browser.onRowChanged
self._view.selectionModel().selectionChanged, self._on_selection_changed
)
self._view.setWordWrap(False)
self._view.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel)
@ -315,6 +375,59 @@ class Table:
# Slots
def _on_selection_changed(
self, selected: QItemSelection, deselected: QItemSelection
) -> None:
# `selection.indexes()` calls `flags()` for all the selection's indexes,
# whereas `selectedRows()` calls it for the indexes of the resulting selection.
# Both may be slow, so we try to optimise.
if KeyboardModifiersPressed().shift or KeyboardModifiersPressed().control:
# Current selection is modified. The number of added/removed rows is
# usually smaller than the number of rows in the resulting selection.
self._len_selection += (
len(selected.indexes()) - len(deselected.indexes())
) // self._model.len_columns()
else:
# New selection is created. Usually a single row or none at all.
self._len_selection = len(self._view.selectionModel().selectedRows())
self._selected_rows = None
self.browser.on_row_changed()
def _on_row_state_will_change(self, index: QModelIndex, was_restored: bool) -> None:
if not was_restored:
row_changed = False
if self._view.selectionModel().isSelected(index):
# calculate directly, because 'self.len_selection()' is slow and
# this method will be called a lot if a lot of rows were deleted
self._len_selection -= 1
row_changed = True
self._selected_rows = None
if index.row() == self._current().row():
# avoid focus on deleted (disabled) rows
self.clear_current()
row_changed = True
if row_changed:
self.browser.on_row_changed()
def _on_row_state_changed(self, index: QModelIndex, was_restored: bool) -> None:
if was_restored:
if self._view.selectionModel().isSelected(index):
self._len_selection += 1
self._selected_rows = None
if not self._current().isValid() and self.len_selection() == 0:
# restore focus for convenience
self._select_rows([index.row()])
self._set_current(index.row())
self._scroll_to_row(index.row())
# row change has been triggered
return
self.browser.on_row_changed()
# Workaround for a bug where the flags for the first column don't update
# automatically (due to the shortcut in 'model.flags()')
top_left = self._model.index(index.row(), 0)
bottom_right = self._model.index(index.row(), self._model.len_columns() - 1)
self._model.dataChanged.emit(top_left, bottom_right) # type: ignore
def _on_context_menu(self, _point: QPoint) -> None:
menu = QMenu()
if self.is_notes_mode():
@ -400,7 +513,7 @@ class Table:
"""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()
self._reset_selection()
if not self._model.is_empty():
rows, current = new_selected_and_current()
rows = self._qualify_selected_rows(rows, current)
@ -410,7 +523,7 @@ class Table:
self._scroll_to_row(current)
if self.len_selection() == 0:
# no row change will fire
self.browser.onRowChanged(QItemSelection(), QItemSelection())
self.browser.on_row_changed()
self._selected_items = []
self._current_item = None