From a44365433f941d0a614b729bfc17fe48e06aa67c Mon Sep 17 00:00:00 2001 From: Mike Hendricks Date: Tue, 2 Jan 2024 15:13:14 -0800 Subject: [PATCH] Add Focus To Name feature using fuzzy search --- preditor/gui/fuzzy_search/__init__.py | 0 preditor/gui/fuzzy_search/fuzzy_search.py | 93 +++++++++++++++ .../group_tab_widget/grouped_tab_models.py | 108 ++++++++++++++++++ preditor/gui/loggerwindow.py | 20 ++++ preditor/gui/ui/loggerwindow.ui | 9 ++ 5 files changed, 230 insertions(+) create mode 100644 preditor/gui/fuzzy_search/__init__.py create mode 100644 preditor/gui/fuzzy_search/fuzzy_search.py create mode 100644 preditor/gui/group_tab_widget/grouped_tab_models.py diff --git a/preditor/gui/fuzzy_search/__init__.py b/preditor/gui/fuzzy_search/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/preditor/gui/fuzzy_search/fuzzy_search.py b/preditor/gui/fuzzy_search/fuzzy_search.py new file mode 100644 index 00000000..f341ac3b --- /dev/null +++ b/preditor/gui/fuzzy_search/fuzzy_search.py @@ -0,0 +1,93 @@ +from __future__ import absolute_import + +from functools import partial + +from Qt.QtCore import QModelIndex, QPoint, Qt, Signal +from Qt.QtWidgets import QFrame, QLineEdit, QListView, QShortcut, QVBoxLayout + +from ..group_tab_widget.grouped_tab_models import GroupTabFuzzyFilterProxyModel + + +class FuzzySearch(QFrame): + canceled = Signal("QModelIndex") + """Passes the original QModelIndex for the tab that was selected when the + widget was first shown. This lets you reset back to the orignal state.""" + highlighted = Signal("QModelIndex") + """Emitted when the user navitages to the given index, but hasn't selected.""" + selected = Signal("QModelIndex") + """Emitted when the user selects a item.""" + + def __init__(self, model, parent=None, **kwargs): + super(FuzzySearch, self).__init__(parent=parent, **kwargs) + self.y_offset = 100 + self.setMinimumSize(400, 200) + self.uiCloseSCT = QShortcut( + Qt.Key_Escape, self, context=Qt.WidgetWithChildrenShortcut + ) + self.uiCloseSCT.activated.connect(self._canceled) + + self.uiUpSCT = QShortcut(Qt.Key_Up, self, context=Qt.WidgetWithChildrenShortcut) + self.uiUpSCT.activated.connect(partial(self.increment_selection, -1)) + self.uiDownSCT = QShortcut( + Qt.Key_Down, self, context=Qt.WidgetWithChildrenShortcut + ) + self.uiDownSCT.activated.connect(partial(self.increment_selection, 1)) + + lyt = QVBoxLayout(self) + self.uiLineEDIT = QLineEdit(parent=self) + self.uiLineEDIT.textChanged.connect(self.update_completer) + self.uiLineEDIT.returnPressed.connect(self.activated) + lyt.addWidget(self.uiLineEDIT) + self.uiResultsLIST = QListView(self) + self.uiResultsLIST.activated.connect(self.activated) + self.proxy_model = GroupTabFuzzyFilterProxyModel(self) + self.proxy_model.setSourceModel(model) + self.uiResultsLIST.setModel(self.proxy_model) + lyt.addWidget(self.uiResultsLIST) + + self.original_model_index = model.original_model_index + + def activated(self): + current = self.uiResultsLIST.currentIndex() + self.selected.emit(current) + self.hide() + + def increment_selection(self, direction): + current = self.uiResultsLIST.currentIndex() + col = 0 + row = 0 + if current.isValid(): + col = current.column() + row = current.row() + direction + new = self.uiResultsLIST.model().index(row, col) + self.uiResultsLIST.setCurrentIndex(new) + self.highlighted.emit(new) + + def update_completer(self, wildcard): + if wildcard: + if not self.uiResultsLIST.currentIndex().isValid(): + new = self.uiResultsLIST.model().index(0, 0) + self.uiResultsLIST.setCurrentIndex(new) + else: + self.uiResultsLIST.clearSelection() + self.uiResultsLIST.setCurrentIndex(QModelIndex()) + self.proxy_model.setFuzzySearch(wildcard) + self.highlighted.emit(self.uiResultsLIST.currentIndex()) + + def _canceled(self): + # Restore the original tab as the user didn't choose the new tab + self.canceled.emit(self.original_model_index) + self.hide() + + def reposition(self): + pgeo = self.parent().geometry() + geo = self.geometry() + center = QPoint(pgeo.width() // 2, 0) + geo.moveCenter(center) + geo.setY(self.y_offset) + self.setGeometry(geo) + + def popup(self): + self.show() + self.reposition() + self.uiLineEDIT.setFocus(Qt.PopupFocusReason) diff --git a/preditor/gui/group_tab_widget/grouped_tab_models.py b/preditor/gui/group_tab_widget/grouped_tab_models.py new file mode 100644 index 00000000..e7eac9a5 --- /dev/null +++ b/preditor/gui/group_tab_widget/grouped_tab_models.py @@ -0,0 +1,108 @@ +from __future__ import absolute_import + +import re + +from Qt.QtCore import QSortFilterProxyModel, Qt +from Qt.QtGui import QStandardItem, QStandardItemModel + + +class GroupTabItemModel(QStandardItemModel): + GroupIndexRole = Qt.UserRole + 1 + TabIndexRole = GroupIndexRole + 1 + + def __init__(self, manager, *args, **kwargs): + super(GroupTabItemModel, self).__init__(*args, **kwargs) + self.manager = manager + + def workbox_indexes_from_model_index(self, index): + """Returns the group_index and tab_index for the provided QModelIndex""" + return ( + index.data(self.GroupIndexRole), + index.data(self.TabIndexRole), + ) + + def pathFromIndex(self, index): + parts = [""] + while index.isValid(): + parts.append(self.data(index, Qt.DisplayRole)) + index = index.parent() + if len(parts) == 1: + return "" + return "/".join([x for x in parts[::-1] if x]) + + +class GroupTabTreeItemModel(GroupTabItemModel): + def process(self): + root = self.invisibleRootItem() + current_group = self.manager.currentIndex() + current_tab = self.manager.currentWidget().currentIndex() + + prev_group = -1 + all_widgets = self.manager.all_widgets() + for _, group_name, tab_name, group_index, tab_index in all_widgets: + if prev_group != group_index: + group_item = QStandardItem(group_name) + group_item.setData(group_index, self.GroupIndexRole) + root.appendRow(group_item) + prev_group = group_index + + tab_item = QStandardItem(tab_name) + tab_item.setData(group_index, self.GroupIndexRole) + tab_item.setData(tab_index, self.TabIndexRole) + group_item.appendRow(tab_item) + if group_index == current_group and tab_index == current_tab: + self.original_model_index = self.indexFromItem(tab_item) + + +class GroupTabListItemModel(GroupTabItemModel): + def flags(self, index): + return Qt.ItemIsEnabled | Qt.ItemIsSelectable + + def process(self): + root = self.invisibleRootItem() + current_group = self.manager.currentIndex() + current_tab = self.manager.currentWidget().currentIndex() + + all_widgets = self.manager.all_widgets() + for _, group_name, tab_name, group_index, tab_index in all_widgets: + tab_item = QStandardItem('/'.join((group_name, tab_name))) + tab_item.setData(group_index, self.GroupIndexRole) + tab_item.setData(tab_index, self.TabIndexRole) + root.appendRow(tab_item) + if group_index == current_group and tab_index == current_tab: + self.original_model_index = self.indexFromItem(tab_item) + + +class GroupTabFuzzyFilterProxyModel(QSortFilterProxyModel): + """Implements a fuzzy search filter proxy model.""" + + def __init__(self, parent=None): + super(GroupTabFuzzyFilterProxyModel, self).__init__(parent=parent) + self._fuzzy_regex = None + + def setFuzzySearch(self, search): + search = '.*'.join(search) + # search = '.*{}.*'.format(search) + self._fuzzy_regex = re.compile(search, re.I) + self.invalidateFilter() + + def filterAcceptsRow(self, sourceRow, sourceParent): + if self.filterKeyColumn() == 0 and self._fuzzy_regex: + + index = self.sourceModel().index(sourceRow, 0, sourceParent) + data = self.sourceModel().data(index) + ret = bool(self._fuzzy_regex.search(data)) + return ret + + return super(GroupTabFuzzyFilterProxyModel, self).filterAcceptsRow( + sourceRow, sourceParent + ) + + def pathFromIndex(self, index): + parts = [""] + while index.isValid(): + parts.append(self.data(index, Qt.DisplayRole)) + index = index.parent() + if len(parts) == 1: + return "" + return "/".join([x for x in parts[::-1] if x]) diff --git a/preditor/gui/loggerwindow.py b/preditor/gui/loggerwindow.py index 1b63ef30..2a09a235 100644 --- a/preditor/gui/loggerwindow.py +++ b/preditor/gui/loggerwindow.py @@ -37,6 +37,8 @@ ) from ..delayable_engine import DelayableEngine from ..gui import Dialog, Window, loadUi +from ..gui.fuzzy_search.fuzzy_search import FuzzySearch +from ..gui.group_tab_widget.grouped_tab_models import GroupTabListItemModel from ..logging_config import LoggingConfig from ..utils import stylesheets from .completer import CompleterMode @@ -180,6 +182,8 @@ def __init__(self, parent, name=None, run_workbox=False, standalone=False): self.uiGroup8ACT.triggered.connect(partial(self.gotoGroupByIndex, 8)) self.uiGroupLastACT.triggered.connect(partial(self.gotoGroupByIndex, -1)) + self.uiFocusNameACT.triggered.connect(self.show_focus_name) + self.uiCommentToggleACT.triggered.connect(self.comment_toggle) self.uiSpellCheckEnabledACT.toggled.connect(self.setSpellCheckEnabled) @@ -1013,6 +1017,22 @@ def showEvent(self, event): def show_workbox_options(self): self.uiWorkboxSTACK.setCurrentIndex(WorkboxPages.Options) + @Slot() + def show_focus_name(self): + model = GroupTabListItemModel(manager=self.uiWorkboxTAB) + model.process() + + def update_tab(index): + group, tab = model.workbox_indexes_from_model_index(index) + if group is not None: + self.uiWorkboxTAB.set_current_groups_from_index(group, tab) + + w = FuzzySearch(model, parent=self) + w.selected.connect(update_tab) + w.canceled.connect(update_tab) + w.highlighted.connect(update_tab) + w.popup() + def updateCopyIndentsAsSpaces(self): for workbox, _, _, _, _ in self.uiWorkboxTAB.all_widgets(): workbox.__set_copy_indents_as_spaces__( diff --git a/preditor/gui/ui/loggerwindow.ui b/preditor/gui/ui/loggerwindow.ui index b19f3c28..4fc142c3 100644 --- a/preditor/gui/ui/loggerwindow.ui +++ b/preditor/gui/ui/loggerwindow.ui @@ -322,6 +322,7 @@ + @@ -931,6 +932,14 @@ at the indicated line in the specified text editor. Backup + + + Focus To Name + + + Ctrl+P + + Restart PrEditor