diff --git a/lib/mayaUsd/resources/ae/CMakeLists.txt b/lib/mayaUsd/resources/ae/CMakeLists.txt index 9edf23921..310b1f320 100644 --- a/lib/mayaUsd/resources/ae/CMakeLists.txt +++ b/lib/mayaUsd/resources/ae/CMakeLists.txt @@ -60,6 +60,8 @@ if(MAYA_APP_VERSION VERSION_GREATER_EQUAL 2023) install(FILES ${MAYAUSD_SHARED_COMPONENTS}/common/__init__.py + ${MAYAUSD_SHARED_COMPONENTS}/common/filteredStringListModel.py + ${MAYAUSD_SHARED_COMPONENTS}/common/filteredStringListView.py ${MAYAUSD_SHARED_COMPONENTS}/common/list.py ${MAYAUSD_SHARED_COMPONENTS}/common/persistentStorage.py ${MAYAUSD_SHARED_COMPONENTS}/common/resizable.py diff --git a/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/collection/includeExcludeWidget.py b/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/collection/includeExcludeWidget.py index a391527a0..cd6067d5f 100644 --- a/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/collection/includeExcludeWidget.py +++ b/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/collection/includeExcludeWidget.py @@ -4,12 +4,15 @@ from .expressionRulesMenu import ExpressionMenu try: - from PySide6.QtWidgets import QFrame, QWidget, QHBoxLayout, QVBoxLayout# type: ignore + from PySide6.QtWidgets import QFrame, QWidget, QHBoxLayout, QVBoxLayout, QLineEdit, QSizePolicy # type: ignore except ImportError: - from PySide2.QtWidgets import QFrame, QWidget, QHBoxLayout, QVBoxLayout # type: ignore + from PySide2.QtWidgets import QFrame, QWidget, QHBoxLayout, QVBoxLayout, QLineEdit, QSizePolicy # type: ignore from pxr import Usd +# TODO: support I8N +kSearchPlaceHolder = 'Search...' + class IncludeExcludeWidget(QWidget): def __init__(self, collection: Usd.CollectionAPI = None, parent: QWidget = None): super(IncludeExcludeWidget, self).__init__(parent) @@ -35,27 +38,37 @@ def __init__(self, collection: Usd.CollectionAPI = None, parent: QWidget = None) self._expressionMenu = ExpressionMenu(self._collection, self) menuButton = MenuButton(self._expressionMenu, self) + self._filterWidget = QLineEdit() + self._filterWidget.setContentsMargins(0, 0, 0, 0) + self._filterWidget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self._filterWidget.setPlaceholderText(kSearchPlaceHolder) + self._filterWidget.setClearButtonEnabled(True) + separator = QFrame() separator.setFrameShape(QFrame.VLine) headerWidget = QWidget(self) + headerWidget.setContentsMargins(0, 0, 0, 0) headerLayout = QHBoxLayout(headerWidget) - headerLayout.addStretch(1) - # Will need a separator for future designs - #headerLayout.addWidget(separator) + headerLayout.setContentsMargins(0, 2, 2, 0) + headerLayout.addWidget(self._filterWidget) + headerLayout.addWidget(separator) headerLayout.addWidget(menuButton) includeExcludeLayout.addWidget(headerWidget) self._include = StringList(includes, "Include", "Include all", self) self._include.cbIncludeAll.setChecked(shouldIncludeAll) self._include.cbIncludeAll.stateChanged.connect(self.onIncludeAllToggle) - self._resizableInclude = Resizable(self._include, "USD_Light_Linking", "IncludeListHeight", self) + self._resizableInclude = Resizable(self._include, "USD_Light_Linking", "IncludeListHeight", self, defaultSize=80) includeExcludeLayout.addWidget(self._resizableInclude) self._exclude = StringList(excludes, "Exclude", "", self) - self._resizableExclude = Resizable(self._exclude, "USD_Light_Linking", "ExcludeListHeight", self) + self._resizableExclude = Resizable(self._exclude, "USD_Light_Linking", "ExcludeListHeight", self, defaultSize=80) includeExcludeLayout.addWidget(self._resizableExclude) + self._filterWidget.textChanged.connect(self._include.list._model.setFilter) + self._filterWidget.textChanged.connect(self._exclude.list._model.setFilter) + self.setLayout(includeExcludeLayout) def update(self): diff --git a/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/common/filteredStringListModel.py b/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/common/filteredStringListModel.py new file mode 100644 index 000000000..d7366dc3c --- /dev/null +++ b/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/common/filteredStringListModel.py @@ -0,0 +1,81 @@ +from typing import Sequence + +try: + from PySide6.QtCore import ( + QStringListModel, + Signal, + ) +except: + from PySide2.QtCore import ( + QStringListModel, + Signal, + ) + + +class FilteredStringListModel(QStringListModel): + ''' + A Qt string list model that can be filtered. + ''' + filterChanged = Signal() + + def __init__(self, items: Sequence[str] = None, parent=None): + super(FilteredStringListModel, self).__init__(items if items else [], parent) + self._unfilteredItems = items + self._isFilteredEmpty = False + self._filter = "" + + def setStringList(self, items: Sequence[str]): + ''' + Override base class implementation to properly rebuild + the filtered list. + ''' + self._unfilteredItems = items + self._isFilteredEmpty = False + super(FilteredStringListModel, self).setStringList(items) + self._rebuildFilteredModel() + + def _rebuildFilteredModel(self): + ''' + Rebuild the model by applying the filter. + ''' + if not self._filter: + filteredItems = self._unfilteredItems + else: + filters = [filter.lower() for filter in self._filter.split('*')] + filteredItems = [item for item in self._unfilteredItems if self._isValidItem(item, filters)] + self._isFilteredEmpty = bool(self._unfilteredItems) and not bool(filteredItems) + # Note: don't call our own version, otehrwise we would get infinite recursion. + super(FilteredStringListModel, self).setStringList(filteredItems) + + def _isValidItem(self, item: str, filters: Sequence[str]): + ''' + Verify if the item passes all the filters. + + We search each given filter in sequence, each one must match + somewhere in the item starting at the end of the point where + the preceeding filter ended: + ''' + index = 0 + item = item.lower() + for filter in filters: + newIndex = item.find(filter, index) + if newIndex < 0: + return False + index = newIndex + len(filter) + return True + + def isFilteredEmpty(self): + ''' + Verify if the model is empty because it was entirely filtered out. + ''' + return self._isFilteredEmpty + + def setFilter(self, filter: str): + ''' + Set the filter to be applied to the model and rebuild the model. + ''' + if filter == self._filter: + return + self._filter = filter + self._rebuildFilteredModel() + self.filterChanged.emit() diff --git a/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/common/filteredStringListView.py b/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/common/filteredStringListView.py new file mode 100644 index 000000000..7b934786d --- /dev/null +++ b/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/common/filteredStringListView.py @@ -0,0 +1,83 @@ +from typing import Sequence, Union +from .theme import Theme +from .filteredStringListModel import FilteredStringListModel + +try: + from PySide6.QtCore import ( + QModelIndex, + QPersistentModelIndex, + QRect, + QSize, + QStringListModel, + Qt, + Signal, + ) + from PySide6.QtGui import QPainter, QPaintEvent, QPen, QColor + from PySide6.QtWidgets import QStyleOptionViewItem, QStyledItemDelegate, QListView +except: + from PySide2.QtCore import ( + QModelIndex, + QPersistentModelIndex, + QRect, + QSize, + QStringListModel, + Qt, + Signal, + ) + from PySide2.QtGui import QPainter, QPaintEvent, QPen, QColor # type: ignore + from PySide2.QtWidgets import QStyleOptionViewItem, QStyledItemDelegate, QListView + + +kNoObjectFoundLabel = 'No objects found' + +class FilteredStringListView(QListView): + selectedItemsChanged = Signal() + + class Delegate(QStyledItemDelegate): + def __init__(self, model: QStringListModel, parent=None): + super(FilteredStringListView.Delegate, self).__init__(parent) + self._model = model + + def sizeHint(self, option: QStyleOptionViewItem, index: Union[QModelIndex, QPersistentModelIndex]): + s: int = Theme.instance().uiScaled(24) + return QSize(s, s) + + def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QModelIndex, QPersistentModelIndex]): + s: str = self._model.data(index, Qt.DisplayRole) + Theme.instance().paintStringListEntry(painter, option.rect, s) + + def __init__(self, items: Sequence[str] = None, parent=None): + super(FilteredStringListView, self).__init__(parent) + self._model = FilteredStringListModel(items if items else [], self) + self.setModel(self._model) + + self.setUniformItemSizes(True) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setTextElideMode(Qt.TextElideMode.ElideMiddle) + self.setSelectionBehavior(QListView.SelectRows) + self.setSelectionMode(QListView.MultiSelection) + self.setContentsMargins(0,0,0,0) + + self.selectionModel().selectionChanged.connect(lambda: self.selectedItemsChanged.emit()) + + def drawFrame(self, painter: QPainter): + pass + + def paintEvent(self, event: QPaintEvent): + super(FilteredStringListView, self).paintEvent(event) + if self._model.isFilteredEmpty(): + painter = QPainter(self.viewport()) + painter.setPen(QColor(128, 128, 128)) + painter.drawText(self.rect(), Qt.AlignCenter, kNoObjectFoundLabel) + + @property + def items(self) -> Sequence[str]: + return self._model.stringList + + @items.setter + def items(self, items: Sequence[str]): + self._model.setStringList(items) + + @property + def selectedItems(self) -> Sequence[str]: + return [index.data(Qt.DisplayRole) for index in self.selectedIndexes()] diff --git a/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/common/list.py b/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/common/list.py index a80534e2a..fbe3e5a2d 100644 --- a/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/common/list.py +++ b/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/common/list.py @@ -1,89 +1,17 @@ -from typing import Sequence, Union -from .theme import Theme +from typing import Sequence +from .filteredStringListView import FilteredStringListView try: - from PySide6.QtCore import ( # type: ignore - QModelIndex, - QPersistentModelIndex, - QSize, - QStringListModel, - Qt, - Signal, - ) - from PySide6.QtGui import QPainter # type: ignore - from PySide6.QtWidgets import ( # type: ignore - QStyleOptionViewItem, - QStyledItemDelegate, - QListView, - QLabel, - QVBoxLayout, - QHBoxLayout, - QWidget, - QCheckBox, - ) + from PySide6.QtWidgets import QLabel, QVBoxLayout, QHBoxLayout, QWidget, QCheckBox except: - from PySide2.QtCore import ( # type: ignore - QModelIndex, - QPersistentModelIndex, - QSize, - QStringListModel, - Qt, - Signal, - ) - from PySide2.QtGui import QPainter # type: ignore - from PySide2.QtWidgets import QStyleOptionViewItem, QStyledItemDelegate, QListView, QLabel, QVBoxLayout, QHBoxLayout, QWidget, QCheckBox # type: ignore - -class _StringList(QListView): - selectedItemsChanged = Signal() - - class Delegate(QStyledItemDelegate): - def __init__(self, model: QStringListModel, parent=None): - super(_StringList.Delegate, self).__init__(parent) - self._model = model - - def sizeHint(self, option: QStyleOptionViewItem, index: Union[QModelIndex, QPersistentModelIndex]): - s: int = Theme.instance().uiScaled(24) - return QSize(s, s) - - def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QModelIndex, QPersistentModelIndex]): - s: str = self._model.data(index, Qt.DisplayRole) - Theme.instance().paintStringListEntry(painter, option.rect, s) - - def __init__(self, items: Sequence[str] = [], parent=None): - super(_StringList, self).__init__(parent) - self._model = QStringListModel(items, self) - self.setModel(self._model) - - self.setUniformItemSizes(True) - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.setTextElideMode(Qt.TextElideMode.ElideMiddle) - self.setSelectionBehavior(QListView.SelectRows) - self.setSelectionMode(QListView.MultiSelection) - self.setContentsMargins(0,0,0,0) - - self.selectionModel().selectionChanged.connect(lambda: self.selectedItemsChanged.emit()) - - def drawFrame(self, painter: QPainter): - pass - - @property - def items(self) -> Sequence[str]: - return self._model.stringList - - @items.setter - def items(self, items: Sequence[str]): - self._model.setStringList(items) - - @property - def selectedItems(self) -> Sequence[str]: - return [index.data(Qt.DisplayRole) for index in self.selectedIndexes()] + from PySide2.QtWidgets import QLabel, QVBoxLayout, QHBoxLayout, QWidget, QCheckBox # type: ignore class StringList(QWidget): - def __init__(self, items: Sequence[str] = [], headerTitle: str = "", toggleTitle: str = "", parent=None): + def __init__(self, items: Sequence[str] = None, headerTitle: str = "", toggleTitle: str = "", parent=None): super().__init__() - self.list = _StringList(items, self) + self.list = FilteredStringListView(items if items else [], self) layout = QVBoxLayout(self) LEFT_RIGHT_MARGINS = 2 diff --git a/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/common/resizable.py b/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/common/resizable.py index 2c7ac121f..91d262ed1 100644 --- a/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/common/resizable.py +++ b/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/common/resizable.py @@ -103,6 +103,7 @@ def __init__( persistentStorageGroup: str = None, persistentStorageKey: str = None, parent: QWidget = None, + defaultSize = -1, ): super(Resizable, self).__init__(parent) @@ -127,6 +128,8 @@ def __init__( self._widget: QWidget = None self.loadPersistentStorage() + if self._contentSize < 0 and defaultSize > 0: + self._contentSize = defaultSize if w is not None: self.widget = w