Merge pull request #951 from abdnh/sidebar-search

Add search bar to the sidebar
This commit is contained in:
Damien Elmes 2021-01-29 11:32:26 +10:00 committed by GitHub
commit 9584efc066
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 139 additions and 40 deletions

View File

@ -35,7 +35,7 @@ from aqt.main import ResetReason
from aqt.previewer import BrowserPreviewer as PreviewDialog from aqt.previewer import BrowserPreviewer as PreviewDialog
from aqt.previewer import Previewer from aqt.previewer import Previewer
from aqt.qt import * from aqt.qt import *
from aqt.sidebar import SidebarTreeView from aqt.sidebar import SidebarSearchBar, SidebarTreeView
from aqt.theme import theme_manager from aqt.theme import theme_manager
from aqt.utils import ( from aqt.utils import (
TR, TR,
@ -912,6 +912,17 @@ QTableView {{ gridline-color: {grid} }}
self.sidebar = SidebarTreeView(self) self.sidebar = SidebarTreeView(self)
self.sidebarTree = self.sidebar # legacy alias self.sidebarTree = self.sidebar # legacy alias
dw.setWidget(self.sidebar) dw.setWidget(self.sidebar)
self.sidebar.searchBar = searchBar = SidebarSearchBar(self.sidebar)
qconnect(
QShortcut(QKeySequence("Ctrl+Shift+B"), self).activated,
self.focusSidebarSearchBar,
)
l = QVBoxLayout()
l.addWidget(searchBar)
l.addWidget(self.sidebar)
w = QWidget()
w.setLayout(l)
dw.setWidget(w)
self.sidebarDockWidget.setFloating(False) self.sidebarDockWidget.setFloating(False)
self.sidebarDockWidget.setTitleBarWidget(QWidget()) self.sidebarDockWidget.setTitleBarWidget(QWidget())
@ -921,12 +932,19 @@ QTableView {{ gridline-color: {grid} }}
# UI is more responsive # UI is more responsive
self.mw.progress.timer(10, self.sidebar.refresh, False) self.mw.progress.timer(10, self.sidebar.refresh, False)
def focusSidebar(self) -> None: def showSidebar(self) -> None:
# workaround for PyQt focus bug # workaround for PyQt focus bug
self.editor.hideCompleters() self.editor.hideCompleters()
self.sidebarDockWidget.setVisible(True) self.sidebarDockWidget.setVisible(True)
def focusSidebar(self) -> None:
self.showSidebar()
self.sidebar.setFocus() self.sidebar.setFocus()
def focusSidebarSearchBar(self) -> None:
self.showSidebar()
self.sidebar.searchBar.setFocus()
# legacy # legacy
def maybeRefreshSidebar(self) -> None: def maybeRefreshSidebar(self) -> None:
self.sidebar.refresh() self.sidebar.refresh()

View File

@ -66,6 +66,7 @@ class SidebarItem:
self.children: List["SidebarItem"] = [] self.children: List["SidebarItem"] = []
self.parentItem: Optional["SidebarItem"] = None self.parentItem: Optional["SidebarItem"] = None
self.tooltip: Optional[str] = None self.tooltip: Optional[str] = None
self.row_in_parent: Optional[int] = None
def addChild(self, cb: "SidebarItem") -> None: def addChild(self, cb: "SidebarItem") -> None:
self.children.append(cb) self.children.append(cb)
@ -82,6 +83,16 @@ class SidebarModel(QAbstractItemModel):
def __init__(self, root: SidebarItem) -> None: def __init__(self, root: SidebarItem) -> None:
super().__init__() super().__init__()
self.root = root self.root = root
self._cache_rows(root)
def _cache_rows(self, node: SidebarItem):
"Cache index of children in parent."
for row, item in enumerate(node.children):
item.row_in_parent = row
self._cache_rows(item)
def item_for_index(self, idx: QModelIndex) -> SidebarItem:
return idx.internalPointer()
# Qt API # Qt API
###################################################################### ######################################################################
@ -121,9 +132,7 @@ class SidebarModel(QAbstractItemModel):
if parentItem is None or parentItem == self.root: if parentItem is None or parentItem == self.root:
return QModelIndex() return QModelIndex()
row = parentItem.rowForChild(childItem) row = parentItem.row_in_parent
if row is None:
return QModelIndex()
return self.createIndex(row, 0, parentItem) return self.createIndex(row, 0, parentItem)
@ -131,7 +140,7 @@ class SidebarModel(QAbstractItemModel):
if not index.isValid(): if not index.isValid():
return QVariant() return QVariant()
if role not in (Qt.DisplayRole, Qt.DecorationRole, Qt.ToolTipRole): if role not in (Qt.DisplayRole, Qt.DecorationRole, Qt.ToolTipRole, Qt.EditRole):
return QVariant() return QVariant()
item: SidebarItem = index.internalPointer() item: SidebarItem = index.internalPointer()
@ -140,6 +149,8 @@ class SidebarModel(QAbstractItemModel):
return QVariant(item.name) return QVariant(item.name)
elif role == Qt.ToolTipRole: elif role == Qt.ToolTipRole:
return QVariant(item.tooltip) return QVariant(item.tooltip)
elif role == Qt.EditRole:
return QVariant(item.full_name)
else: else:
return QVariant(theme_manager.icon_from_resources(item.icon)) return QVariant(theme_manager.icon_from_resources(item.icon))
@ -150,32 +161,50 @@ class SidebarModel(QAbstractItemModel):
print("iconFromRef() deprecated") print("iconFromRef() deprecated")
return theme_manager.icon_from_resources(iconRef) return theme_manager.icon_from_resources(iconRef)
def expandWhereNeccessary(self, tree: QTreeView) -> None:
for row, child in enumerate(self.root.children):
if child.expanded:
idx = self.index(row, 0, QModelIndex())
self._expandWhereNeccessary(idx, tree)
def _expandWhereNeccessary(self, parent: QModelIndex, tree: QTreeView) -> None: def expand_where_necessary(model: SidebarModel, tree: QTreeView, parent=None) -> None:
parentItem: SidebarItem parent = parent or QModelIndex()
if not parent.isValid(): for row in range(model.rowCount(parent)):
parentItem = self.root idx = model.index(row, 0, parent)
else: if not idx.isValid():
parentItem = parent.internalPointer()
# nothing to do?
if not parentItem.expanded:
return
# expand children
for row, child in enumerate(parentItem.children):
if not child.expanded:
continue continue
childIdx = self.index(row, 0, parent) expand_where_necessary(model, tree, idx)
self._expandWhereNeccessary(childIdx, tree) item = model.item_for_index(idx)
if item and item.expanded:
tree.setExpanded(idx, True)
# then ourselves
tree.setExpanded(parent, True) class FilterModel(QSortFilterProxyModel):
def item_for_index(self, idx: QModelIndex) -> Optional[SidebarItem]:
if not idx.isValid():
return None
return self.mapToSource(idx).internalPointer()
class SidebarSearchBar(QLineEdit):
def __init__(self, sidebar: SidebarTreeView):
QLineEdit.__init__(self, sidebar)
self.sidebar = sidebar
self.timer = QTimer(self)
self.timer.setInterval(600)
self.timer.setSingleShot(True)
qconnect(self.timer.timeout, self.onSearch)
qconnect(self.textChanged, self.onTextChanged)
def onTextChanged(self, text: str):
if not self.timer.isActive():
self.timer.start()
def onSearch(self):
self.sidebar.search_for(self.text())
def keyPressEvent(self, evt):
if evt.key() in (Qt.Key_Up, Qt.Key_Down):
self.sidebar.setFocus()
elif evt.key() in (Qt.Key_Enter, Qt.Key_Return):
self.onSearch()
else:
QLineEdit.keyPressEvent(self, evt)
class SidebarTreeView(QTreeView): class SidebarTreeView(QTreeView):
@ -184,6 +213,7 @@ class SidebarTreeView(QTreeView):
self.browser = browser self.browser = browser
self.mw = browser.mw self.mw = browser.mw
self.col = self.mw.col self.col = self.mw.col
self.current_search: Optional[str] = None
self.setContextMenuPolicy(Qt.CustomContextMenu) self.setContextMenuPolicy(Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self.onContextMenu) # type: ignore self.customContextMenuRequested.connect(self.onContextMenu) # type: ignore
@ -214,6 +244,9 @@ class SidebarTreeView(QTreeView):
bgcolor = QPalette().window().color().name() bgcolor = QPalette().window().color().name()
self.setStyleSheet("QTreeView { background: '%s'; }" % bgcolor) self.setStyleSheet("QTreeView { background: '%s'; }" % bgcolor)
def model(self) -> Union[FilterModel, SidebarModel]:
return super().model()
def refresh(self) -> None: def refresh(self) -> None:
"Refresh list. No-op if sidebar is not visible." "Refresh list. No-op if sidebar is not visible."
if not self.isVisible(): if not self.isVisible():
@ -222,15 +255,57 @@ class SidebarTreeView(QTreeView):
def on_done(fut: Future): def on_done(fut: Future):
root = fut.result() root = fut.result()
model = SidebarModel(root) model = SidebarModel(root)
# from PyQt5.QtTest import QAbstractItemModelTester
# tester = QAbstractItemModelTester(model)
self.setModel(model) self.setModel(model)
model.expandWhereNeccessary(self) if self.current_search:
self.search_for(self.current_search)
else:
expand_where_necessary(model, self)
self.mw.taskman.run_in_background(self._root_tree, on_done) self.mw.taskman.run_in_background(self._root_tree, on_done)
def search_for(self, text: str):
if not text.strip():
self.current_search = None
self.refresh()
return
if not isinstance(self.model(), FilterModel):
filter_model = FilterModel(self)
filter_model.setSourceModel(self.model())
filter_model.setFilterCaseSensitivity(False) # type: ignore
filter_model.setRecursiveFilteringEnabled(True)
filter_model.setFilterRole(Qt.EditRole)
self.setModel(filter_model)
else:
filter_model = self.model()
self.current_search = text
# Without collapsing first, can be very slow. Surely there's
# a better way than this?
self.collapseAll()
filter_model.setFilterFixedString(text)
self.expandAll()
def drawRow(
self, painter: QPainter, options: QStyleOptionViewItem, idx: QModelIndex
):
if self.current_search is None:
return super().drawRow(painter, options, idx)
if not (item := self.model().item_for_index(idx)):
return super().drawRow(painter, options, idx)
if self.current_search.lower() in item.full_name.lower():
brush = QBrush(QColor("lightyellow"))
painter.save()
painter.fillRect(options.rect, brush)
painter.restore()
return super().drawRow(painter, options, idx)
def onClickCurrent(self) -> None: def onClickCurrent(self) -> None:
idx = self.currentIndex() idx = self.currentIndex()
if idx.isValid(): if item := self.model().item_for_index(idx):
item: "aqt.browser.SidebarItem" = idx.internalPointer()
if item.onClick: if item.onClick:
item.onClick() item.onClick()
@ -246,14 +321,18 @@ class SidebarTreeView(QTreeView):
super().keyPressEvent(event) super().keyPressEvent(event)
def onExpansion(self, idx: QModelIndex) -> None: def onExpansion(self, idx: QModelIndex) -> None:
if self.current_search:
return
self._onExpansionChange(idx, True) self._onExpansionChange(idx, True)
def onCollapse(self, idx: QModelIndex) -> None: def onCollapse(self, idx: QModelIndex) -> None:
if self.current_search:
return
self._onExpansionChange(idx, False) self._onExpansionChange(idx, False)
def _onExpansionChange(self, idx: QModelIndex, expanded: bool) -> None: def _onExpansionChange(self, idx: QModelIndex, expanded: bool) -> None:
item: "aqt.browser.SidebarItem" = idx.internalPointer() item = self.model().item_for_index(idx)
if item.expanded != expanded: if item and item.expanded != expanded:
item.expanded = expanded item.expanded = expanded
if item.onExpanded: if item.onExpanded:
item.onExpanded(expanded) item.onExpanded(expanded)
@ -359,6 +438,7 @@ class SidebarTreeView(QTreeView):
not node.collapsed, not node.collapsed,
item_type=SidebarItemType.DECK, item_type=SidebarItemType.DECK,
id=node.deck_id, id=node.deck_id,
full_name=head + node.name,
) )
root.addChild(item) root.addChild(item)
newhead = head + node.name + "::" newhead = head + node.name + "::"
@ -384,6 +464,7 @@ class SidebarTreeView(QTreeView):
":/icons/notetype.svg", ":/icons/notetype.svg",
self._template_filter(nt["name"], c), self._template_filter(nt["name"], c),
item_type=SidebarItemType.TEMPLATE, item_type=SidebarItemType.TEMPLATE,
full_name=nt["name"] + "::" + tmpl["name"],
) )
item.addChild(child) item.addChild(child)
@ -423,7 +504,7 @@ class SidebarTreeView(QTreeView):
def onContextMenu(self, point: QPoint) -> None: def onContextMenu(self, point: QPoint) -> None:
idx: QModelIndex = self.indexAt(point) idx: QModelIndex = self.indexAt(point)
item: "aqt.browser.SidebarItem" = idx.internalPointer() item = self.model().item_for_index(idx)
if not item: if not item:
return return
item_type: SidebarItemType = item.item_type item_type: SidebarItemType = item.item_type
@ -450,7 +531,7 @@ class SidebarTreeView(QTreeView):
self.mw.col.decks.rename(deck, new_name) self.mw.col.decks.rename(deck, new_name)
except DeckRenameError as e: except DeckRenameError as e:
return showWarning(e.description) return showWarning(e.description)
self.browser.maybeRefreshSidebar() self.refresh()
self.mw.deckBrowser.refresh() self.mw.deckBrowser.refresh()
def remove_tag(self, item: "aqt.browser.SidebarItem") -> None: def remove_tag(self, item: "aqt.browser.SidebarItem") -> None:
@ -467,7 +548,7 @@ class SidebarTreeView(QTreeView):
self.mw.requireReset(reason=ResetReason.BrowserRemoveTags, context=self) self.mw.requireReset(reason=ResetReason.BrowserRemoveTags, context=self)
self.browser.model.endReset() self.browser.model.endReset()
fut.result() fut.result()
self.browser.maybeRefreshSidebar() self.refresh()
self.mw.checkpoint(tr(TR.ACTIONS_REMOVE_TAG)) self.mw.checkpoint(tr(TR.ACTIONS_REMOVE_TAG))
self.browser.model.beginReset() self.browser.model.beginReset()
@ -495,7 +576,7 @@ class SidebarTreeView(QTreeView):
showInfo(tr(TR.BROWSING_TAG_RENAME_WARNING_EMPTY)) showInfo(tr(TR.BROWSING_TAG_RENAME_WARNING_EMPTY))
return return
self.browser.maybeRefreshSidebar() self.refresh()
self.mw.checkpoint(tr(TR.ACTIONS_RENAME_TAG)) self.mw.checkpoint(tr(TR.ACTIONS_RENAME_TAG))
self.browser.model.beginReset() self.browser.model.beginReset()
@ -515,7 +596,7 @@ class SidebarTreeView(QTreeView):
self.mw.requireReset(reason=ResetReason.BrowserDeleteDeck, context=self) self.mw.requireReset(reason=ResetReason.BrowserDeleteDeck, context=self)
self.browser.search() self.browser.search()
self.browser.model.endReset() self.browser.model.endReset()
self.browser.maybeRefreshSidebar() self.refresh()
res = fut.result() # Required to check for errors res = fut.result() # Required to check for errors
self.mw.checkpoint(tr(TR.DECKS_DELETE_DECK)) self.mw.checkpoint(tr(TR.DECKS_DELETE_DECK))