Merge pull request #1117 from RumovZ/browser-folder
Refactor browser and table into folders
This commit is contained in:
commit
32ffd20c23
22
qt/aqt/browser/__init__.py
Normal file
22
qt/aqt/browser/__init__.py
Normal file
@ -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,
|
||||
)
|
@ -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)
|
226
qt/aqt/browser/dialogs.py
Normal file
226
qt/aqt/browser/dialogs.py
Normal file
@ -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)
|
96
qt/aqt/browser/table/__init__.py
Normal file
96
qt/aqt/browser/table/__init__.py
Normal file
@ -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
|
301
qt/aqt/browser/table/model.py
Normal file
301
qt/aqt/browser/table/model.py
Normal file
@ -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,
|
||||
)
|
245
qt/aqt/browser/table/state.py
Normal file
245
qt/aqt/browser/table/state.py
Normal file
@ -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)
|
520
qt/aqt/browser/table/table.py
Normal file
520
qt/aqt/browser/table/table.py
Normal file
@ -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)
|
1134
qt/aqt/table.py
1134
qt/aqt/table.py
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user