From 4af3b1b1f997dd24e5d48391cbf2337602be403d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 11 Feb 2022 17:25:57 +0100 Subject: [PATCH 1/5] Draft implementation for search in settings --- openpype/tools/settings/settings/search.py | 137 +++++++++++++++++++++ openpype/tools/settings/settings/window.py | 25 ++++ 2 files changed, 162 insertions(+) create mode 100644 openpype/tools/settings/settings/search.py diff --git a/openpype/tools/settings/settings/search.py b/openpype/tools/settings/settings/search.py new file mode 100644 index 0000000000..3d61dd33fe --- /dev/null +++ b/openpype/tools/settings/settings/search.py @@ -0,0 +1,137 @@ +from functools import partial +import re + +from Qt import QtCore, QtWidgets +from openpype.tools.utils.models import TreeModel, Item +from openpype.tools.utils.lib import schedule + + +def get_entity_children(entity): + + if hasattr(entity, "values"): + return entity.values() + return [] + + +class RecursiveSortFilterProxyModel(QtCore.QSortFilterProxyModel): + """Filters recursively to regex in all columns""" + + def __init__(self): + super(RecursiveSortFilterProxyModel, self).__init__() + + # Note: Recursive filtering was introduced in Qt 5.10. + self.setRecursiveFilteringEnabled(True) + + def filterAcceptsRow(self, row, parent): + + if not parent.isValid(): + return False + + regex = self.filterRegExp() + if not regex.isEmpty() and regex.isValid(): + pattern = regex.pattern() + source_model = self.sourceModel() + + # Check current index itself in all columns + for column in range(source_model.columnCount(parent)): + source_index = source_model.index(row, column, parent) + if not source_index.isValid(): + continue + + key = source_model.data(source_index, self.filterRole()) + if not key: + continue + + if re.search(pattern, key, re.IGNORECASE): + return True + + return False + + return super(RecursiveSortFilterProxyModel, + self).filterAcceptsRow(row, parent) + + +class SearchEntitiesDialog(QtWidgets.QDialog): + + path_clicked = QtCore.Signal(str) + + def __init__(self, entity, parent=None): + super(SearchEntitiesDialog, self).__init__(parent=parent) + + layout = QtWidgets.QVBoxLayout(self) + + filter_edit = QtWidgets.QLineEdit() + filter_edit.setPlaceholderText("Search..") + + model = EntityTreeModel() + proxy = RecursiveSortFilterProxyModel() + proxy.setSourceModel(model) + proxy.setDynamicSortFilter(True) + view = QtWidgets.QTreeView() + view.setModel(proxy) + + layout.addWidget(filter_edit) + layout.addWidget(view) + + filter_edit.textChanged.connect(self._on_filter_changed) + view.selectionModel().selectionChanged.connect(self.on_select) + + view.setAllColumnsShowFocus(True) + view.setSortingEnabled(True) + view.sortByColumn(1, QtCore.Qt.AscendingOrder) + + self._model = model + self._proxy = proxy + self._view = view + + # Refresh to the passed entity + model.set_root(entity) + + view.resizeColumnToContents(0) + + def _on_filter_changed(self, txt): + # Provide slight delay to filtering so user can type quickly + schedule(partial(self.on_filter_changed, txt), 250, channel="search") + + def on_filter_changed(self, txt): + self._proxy.setFilterRegExp(txt) + + # WARNING This expanding and resizing is relatively slow. + self._view.expandAll() + self._view.resizeColumnToContents(0) + + def on_select(self): + current = self._view.currentIndex() + item = current.data(EntityTreeModel.ItemRole) + self.path_clicked.emit(item["path"]) + + +class EntityTreeModel(TreeModel): + + Columns = ["trail", "label", "key", "path"] + + def add_entity(self, entity, parent=None): + + item = Item() + + # Label and key can sometimes be emtpy so we use the trail from path + # in the most left column since it's never empty + item["trail"] = entity.path.rsplit("/", 1)[-1] + item["label"] = entity.label + item["key"] = entity.key + item["path"] = entity.path + + parent.add_child(item) + + for child in get_entity_children(entity): + self.add_entity(child, parent=item) + + def set_root(self, root_entity): + self.clear() + self.beginResetModel() + + # We don't want to see the root entity so we directly add its children + for child in get_entity_children(root_entity): + self.add_entity(child, parent=self._root_item) + self.endResetModel() + diff --git a/openpype/tools/settings/settings/window.py b/openpype/tools/settings/settings/window.py index 411e7b5e7f..bbfba088ba 100644 --- a/openpype/tools/settings/settings/window.py +++ b/openpype/tools/settings/settings/window.py @@ -164,3 +164,28 @@ class MainWidget(QtWidgets.QWidget): result = dialog.exec_() if result == 1: self.trigger_restart.emit() + + def keyPressEvent(self, event): + + # todo: This might not be the cleanest place but works for prototype + if event.matches(QtGui.QKeySequence.Find): + print("Search!") + + # todo: search in all widgets (or in active)? + widget = self._header_tab_widget.currentWidget() + root_entity = widget.entity + + from .search import SearchEntitiesDialog + search = SearchEntitiesDialog(root_entity, parent=self) + search.resize(700, 500) + search.setWindowTitle("Search") + search.show() + + def on_path(path): + widget.breadcrumbs_widget.change_path(path) + + search.path_clicked.connect(on_path) + event.accept() + return + + return super(MainWidget, self).keyPressEvent(event) From 12e7f7534750b2b086a6b6428ff7bb7fe111ca39 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Sat, 12 Feb 2022 13:30:03 +0100 Subject: [PATCH 2/5] renamed search.py to search_dialog.py --- .../tools/settings/settings/{search.py => search_dialog.py} | 0 openpype/tools/settings/settings/window.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename openpype/tools/settings/settings/{search.py => search_dialog.py} (100%) diff --git a/openpype/tools/settings/settings/search.py b/openpype/tools/settings/settings/search_dialog.py similarity index 100% rename from openpype/tools/settings/settings/search.py rename to openpype/tools/settings/settings/search_dialog.py diff --git a/openpype/tools/settings/settings/window.py b/openpype/tools/settings/settings/window.py index bbfba088ba..55930b5d88 100644 --- a/openpype/tools/settings/settings/window.py +++ b/openpype/tools/settings/settings/window.py @@ -5,6 +5,7 @@ from .categories import ( ProjectWidget ) from .widgets import ShadowWidget, RestartDialog +from .search_dialog import SearchEntitiesDialog from openpype import style from openpype.lib import is_admin_password_required @@ -175,7 +176,6 @@ class MainWidget(QtWidgets.QWidget): widget = self._header_tab_widget.currentWidget() root_entity = widget.entity - from .search import SearchEntitiesDialog search = SearchEntitiesDialog(root_entity, parent=self) search.resize(700, 500) search.setWindowTitle("Search") From 61b07c356dc404437a1f4b0ebd4956af6abfaa67 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Sat, 12 Feb 2022 15:08:48 +0100 Subject: [PATCH 3/5] extended search dialog and handle reset and recreation of dialog model --- .../tools/settings/settings/categories.py | 9 + .../tools/settings/settings/search_dialog.py | 173 +++++++++++------- openpype/tools/settings/settings/window.py | 52 ++++-- 3 files changed, 157 insertions(+), 77 deletions(-) diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py index adbde00bf1..28e38ea51d 100644 --- a/openpype/tools/settings/settings/categories.py +++ b/openpype/tools/settings/settings/categories.py @@ -86,6 +86,8 @@ class SettingsCategoryWidget(QtWidgets.QWidget): state_changed = QtCore.Signal() saved = QtCore.Signal(QtWidgets.QWidget) restart_required_trigger = QtCore.Signal() + restart_started = QtCore.Signal() + restart_finished = QtCore.Signal() full_path_requested = QtCore.Signal(str, str) def __init__(self, user_role, parent=None): @@ -307,7 +309,12 @@ class SettingsCategoryWidget(QtWidgets.QWidget): """Change path of widget based on category full path.""" pass + def change_path(self, path): + """Change path and go to widget.""" + self.breadcrumbs_widget.change_path(path) + def set_path(self, path): + """Called from clicked widget.""" self.breadcrumbs_widget.set_path(path) def _add_developer_ui(self, footer_layout): @@ -429,6 +436,7 @@ class SettingsCategoryWidget(QtWidgets.QWidget): self.require_restart_label.setText(value) def reset(self): + self.restart_started.emit() self.set_state(CategoryState.Working) self._on_reset_start() @@ -509,6 +517,7 @@ class SettingsCategoryWidget(QtWidgets.QWidget): self._on_reset_crash() else: self._on_reset_success() + self.restart_finished.emit() def _on_reset_crash(self): self.save_btn.setEnabled(False) diff --git a/openpype/tools/settings/settings/search_dialog.py b/openpype/tools/settings/settings/search_dialog.py index 3d61dd33fe..3f987c0010 100644 --- a/openpype/tools/settings/settings/search_dialog.py +++ b/openpype/tools/settings/settings/search_dialog.py @@ -1,13 +1,14 @@ -from functools import partial import re +import collections -from Qt import QtCore, QtWidgets -from openpype.tools.utils.models import TreeModel, Item -from openpype.tools.utils.lib import schedule +from Qt import QtCore, QtWidgets, QtGui + +ENTITY_LABEL_ROLE = QtCore.Qt.UserRole + 1 +ENTITY_PATH_ROLE = QtCore.Qt.UserRole + 2 def get_entity_children(entity): - + # TODO find better way how to go through all children if hasattr(entity, "values"): return entity.values() return [] @@ -23,115 +24,163 @@ class RecursiveSortFilterProxyModel(QtCore.QSortFilterProxyModel): self.setRecursiveFilteringEnabled(True) def filterAcceptsRow(self, row, parent): - if not parent.isValid(): return False regex = self.filterRegExp() if not regex.isEmpty() and regex.isValid(): pattern = regex.pattern() + compiled_regex = re.compile(pattern) source_model = self.sourceModel() # Check current index itself in all columns - for column in range(source_model.columnCount(parent)): - source_index = source_model.index(row, column, parent) - if not source_index.isValid(): - continue - - key = source_model.data(source_index, self.filterRole()) - if not key: - continue - - if re.search(pattern, key, re.IGNORECASE): - return True - + source_index = source_model.index(row, 0, parent) + if source_index.isValid(): + for role in (ENTITY_PATH_ROLE, ENTITY_LABEL_ROLE): + value = source_model.data(source_index, role) + if value and compiled_regex.search(value): + return True return False - return super(RecursiveSortFilterProxyModel, - self).filterAcceptsRow(row, parent) + return super( + RecursiveSortFilterProxyModel, self + ).filterAcceptsRow(row, parent) class SearchEntitiesDialog(QtWidgets.QDialog): - path_clicked = QtCore.Signal(str) - def __init__(self, entity, parent=None): + def __init__(self, parent): super(SearchEntitiesDialog, self).__init__(parent=parent) - layout = QtWidgets.QVBoxLayout(self) + self.setWindowTitle("Search Settings") - filter_edit = QtWidgets.QLineEdit() - filter_edit.setPlaceholderText("Search..") + filter_edit = QtWidgets.QLineEdit(self) + filter_edit.setPlaceholderText("Search...") model = EntityTreeModel() proxy = RecursiveSortFilterProxyModel() proxy.setSourceModel(model) proxy.setDynamicSortFilter(True) - view = QtWidgets.QTreeView() - view.setModel(proxy) + view = QtWidgets.QTreeView(self) + view.setAllColumnsShowFocus(True) + view.setSortingEnabled(True) + view.setModel(proxy) + model.setColumnCount(3) + + layout = QtWidgets.QVBoxLayout(self) layout.addWidget(filter_edit) layout.addWidget(view) + filter_changed_timer = QtCore.QTimer() + filter_changed_timer.setInterval(200) + + view.selectionModel().selectionChanged.connect( + self._on_selection_change + ) + filter_changed_timer.timeout.connect(self._on_filter_timer) filter_edit.textChanged.connect(self._on_filter_changed) - view.selectionModel().selectionChanged.connect(self.on_select) - - view.setAllColumnsShowFocus(True) - view.setSortingEnabled(True) - view.sortByColumn(1, QtCore.Qt.AscendingOrder) + self._filter_edit = filter_edit self._model = model self._proxy = proxy self._view = view + self._filter_changed_timer = filter_changed_timer - # Refresh to the passed entity - model.set_root(entity) + self._first_show = True - view.resizeColumnToContents(0) + def set_root_entity(self, entity): + self._model.set_root_entity(entity) + self._view.resizeColumnToContents(0) + + def showEvent(self, event): + super(SearchEntitiesDialog, self).showEvent(event) + if self._first_show: + self._first_show = False + self.resize(700, 500) def _on_filter_changed(self, txt): - # Provide slight delay to filtering so user can type quickly - schedule(partial(self.on_filter_changed, txt), 250, channel="search") + self._filter_changed_timer.start() - def on_filter_changed(self, txt): - self._proxy.setFilterRegExp(txt) + def _on_filter_timer(self): + text = self._filter_edit.text() + self._proxy.setFilterRegExp(text) # WARNING This expanding and resizing is relatively slow. self._view.expandAll() self._view.resizeColumnToContents(0) - def on_select(self): + def _on_selection_change(self): current = self._view.currentIndex() - item = current.data(EntityTreeModel.ItemRole) - self.path_clicked.emit(item["path"]) + path = current.data(ENTITY_PATH_ROLE) + self.path_clicked.emit(path) -class EntityTreeModel(TreeModel): +class EntityTreeModel(QtGui.QStandardItemModel): + def __init__(self, *args, **kwargs): + super(EntityTreeModel, self).__init__(*args, **kwargs) + self.setColumnCount(3) - Columns = ["trail", "label", "key", "path"] + def data(self, index, role=None): + if role is None: + role = QtCore.Qt.DisplayRole - def add_entity(self, entity, parent=None): + col = index.column() + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): + if col == 0: + pass + elif col == 1: + role = ENTITY_LABEL_ROLE + elif col == 2: + role = ENTITY_PATH_ROLE - item = Item() + if col > 0: + index = self.index(index.row(), 0, index.parent()) + return super(EntityTreeModel, self).data(index, role) - # Label and key can sometimes be emtpy so we use the trail from path - # in the most left column since it's never empty - item["trail"] = entity.path.rsplit("/", 1)[-1] - item["label"] = entity.label - item["key"] = entity.key - item["path"] = entity.path + def flags(self, index): + if index.column() > 0: + index = self.index(index.row(), 0, index.parent()) + return super(EntityTreeModel, self).flags(index) - parent.add_child(item) + def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole): + if role == QtCore.Qt.DisplayRole: + if section == 0: + return "Key" + elif section == 1: + return "Label" + elif section == 2: + return "Path" + return "" + return super(EntityTreeModel, self).headerData( + section, orientation, role + ) - for child in get_entity_children(entity): - self.add_entity(child, parent=item) - - def set_root(self, root_entity): - self.clear() - self.beginResetModel() + def set_root_entity(self, root_entity): + parent = self.invisibleRootItem() + parent.removeRows(0, parent.rowCount()) + if not root_entity: + return # We don't want to see the root entity so we directly add its children - for child in get_entity_children(root_entity): - self.add_entity(child, parent=self._root_item) - self.endResetModel() + fill_queue = collections.deque() + fill_queue.append((root_entity, parent)) + cols = self.columnCount() + while fill_queue: + parent_entity, parent_item = fill_queue.popleft() + child_items = [] + for child in get_entity_children(parent_entity): + label = child.label + path = child.path + key = path.split("/")[-1] + item = QtGui.QStandardItem(key) + item.setEditable(False) + item.setData(label, ENTITY_LABEL_ROLE) + item.setData(path, ENTITY_PATH_ROLE) + item.setColumnCount(cols) + child_items.append(item) + fill_queue.append((child, item)) + if child_items: + parent_item.appendRows(child_items) diff --git a/openpype/tools/settings/settings/window.py b/openpype/tools/settings/settings/window.py index 55930b5d88..f6fa9a83a5 100644 --- a/openpype/tools/settings/settings/window.py +++ b/openpype/tools/settings/settings/window.py @@ -55,19 +55,27 @@ class MainWidget(QtWidgets.QWidget): self.setLayout(layout) + search_dialog = SearchEntitiesDialog(self) + self._shadow_widget = ShadowWidget("Working...", self) self._shadow_widget.setVisible(False) + header_tab_widget.currentChanged.connect(self._on_tab_changed) + search_dialog.path_clicked.connect(self._on_search_path_clicked) + for tab_widget in tab_widgets: tab_widget.saved.connect(self._on_tab_save) tab_widget.state_changed.connect(self._on_state_change) tab_widget.restart_required_trigger.connect( self._on_restart_required ) + tab_widget.restart_started.connect(self._on_restart_started) + tab_widget.restart_finished.connect(self._on_restart_finished) tab_widget.full_path_requested.connect(self._on_full_path_request) self._header_tab_widget = header_tab_widget self.tab_widgets = tab_widgets + self._search_dialog = search_dialog def _on_tab_save(self, source_widget): for tab_widget in self.tab_widgets: @@ -151,6 +159,21 @@ class MainWidget(QtWidgets.QWidget): for tab_widget in self.tab_widgets: tab_widget.reset() + def _update_search_dialog(self, clear=False): + if self._search_dialog.isVisible(): + entity = None + if not clear: + widget = self._header_tab_widget.currentWidget() + entity = widget.entity + self._search_dialog.set_root_entity(entity) + + def _on_tab_changed(self): + self._update_search_dialog() + + def _on_search_path_clicked(self, path): + widget = self._header_tab_widget.currentWidget() + widget.change_path(path) + def _on_restart_required(self): # Don't show dialog if there are not registered slots for # `trigger_restart` signal. @@ -166,25 +189,24 @@ class MainWidget(QtWidgets.QWidget): if result == 1: self.trigger_restart.emit() + def _on_restart_started(self): + widget = self.sender() + current_widget = self._header_tab_widget.currentWidget() + if current_widget is widget: + self._update_search_dialog(True) + + def _on_restart_finished(self): + widget = self.sender() + current_widget = self._header_tab_widget.currentWidget() + if current_widget is widget: + self._update_search_dialog() + def keyPressEvent(self, event): - - # todo: This might not be the cleanest place but works for prototype if event.matches(QtGui.QKeySequence.Find): - print("Search!") - # todo: search in all widgets (or in active)? widget = self._header_tab_widget.currentWidget() - root_entity = widget.entity - - search = SearchEntitiesDialog(root_entity, parent=self) - search.resize(700, 500) - search.setWindowTitle("Search") - search.show() - - def on_path(path): - widget.breadcrumbs_widget.change_path(path) - - search.path_clicked.connect(on_path) + self._search_dialog.show() + self._search_dialog.set_root_entity(widget.entity) event.accept() return From 7cfbfeefa8496de9285f5e68ab9503a6805c8e21 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 16 Feb 2022 13:47:52 +0100 Subject: [PATCH 4/5] changed prefix 'restart' to 'reset' --- openpype/tools/settings/settings/categories.py | 8 ++++---- openpype/tools/settings/settings/window.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py index 28e38ea51d..08d3980e24 100644 --- a/openpype/tools/settings/settings/categories.py +++ b/openpype/tools/settings/settings/categories.py @@ -86,8 +86,8 @@ class SettingsCategoryWidget(QtWidgets.QWidget): state_changed = QtCore.Signal() saved = QtCore.Signal(QtWidgets.QWidget) restart_required_trigger = QtCore.Signal() - restart_started = QtCore.Signal() - restart_finished = QtCore.Signal() + reset_started = QtCore.Signal() + reset_finished = QtCore.Signal() full_path_requested = QtCore.Signal(str, str) def __init__(self, user_role, parent=None): @@ -436,7 +436,7 @@ class SettingsCategoryWidget(QtWidgets.QWidget): self.require_restart_label.setText(value) def reset(self): - self.restart_started.emit() + self.reset_started.emit() self.set_state(CategoryState.Working) self._on_reset_start() @@ -517,7 +517,7 @@ class SettingsCategoryWidget(QtWidgets.QWidget): self._on_reset_crash() else: self._on_reset_success() - self.restart_finished.emit() + self.reset_finished.emit() def _on_reset_crash(self): self.save_btn.setEnabled(False) diff --git a/openpype/tools/settings/settings/window.py b/openpype/tools/settings/settings/window.py index f6fa9a83a5..7c0c926fdd 100644 --- a/openpype/tools/settings/settings/window.py +++ b/openpype/tools/settings/settings/window.py @@ -69,8 +69,8 @@ class MainWidget(QtWidgets.QWidget): tab_widget.restart_required_trigger.connect( self._on_restart_required ) - tab_widget.restart_started.connect(self._on_restart_started) - tab_widget.restart_finished.connect(self._on_restart_finished) + tab_widget.reset_started.connect(self._on_reset_started) + tab_widget.reset_started.connect(self._on_reset_finished) tab_widget.full_path_requested.connect(self._on_full_path_request) self._header_tab_widget = header_tab_widget @@ -189,13 +189,13 @@ class MainWidget(QtWidgets.QWidget): if result == 1: self.trigger_restart.emit() - def _on_restart_started(self): + def _on_reset_started(self): widget = self.sender() current_widget = self._header_tab_widget.currentWidget() if current_widget is widget: self._update_search_dialog(True) - def _on_restart_finished(self): + def _on_reset_finished(self): widget = self.sender() current_widget = self._header_tab_widget.currentWidget() if current_widget is widget: From 24e22c1c598ef5eac849d68a80a8df179e182951 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 22 Feb 2022 11:52:53 +0100 Subject: [PATCH 5/5] Refactor breadcrumbs_widget -> breadcrumbs_bar Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/tools/settings/settings/categories.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py index 059a8b9bf8..14e25a54d8 100644 --- a/openpype/tools/settings/settings/categories.py +++ b/openpype/tools/settings/settings/categories.py @@ -383,7 +383,7 @@ class SettingsCategoryWidget(QtWidgets.QWidget): def change_path(self, path): """Change path and go to widget.""" - self.breadcrumbs_widget.change_path(path) + self.breadcrumbs_bar.change_path(path) def set_path(self, path): """Called from clicked widget."""