diff --git a/docs/combobox.md b/docs/combobox.md index c068255..b7b88bf 100644 --- a/docs/combobox.md +++ b/docs/combobox.md @@ -61,3 +61,8 @@ combo.setEnumClass(SampleEnum, allow_none=True) ``` In this case there is added option `----` and `currentEnum` will return `None` for it. + +## QSearchableComboBox + +`QSearchableComboBox` is a variant of [`QComboBox`](https://doc.qt.io/qt-5/qcombobox.html) that +allow to filter list of options by enter part of text. It could be drop in replacement for `QComboBox`. diff --git a/docs/listwidgets.md b/docs/listwidgets.md new file mode 100644 index 0000000..d869634 --- /dev/null +++ b/docs/listwidgets.md @@ -0,0 +1,8 @@ +# ListWidget + +## QSearchableListWidget + +`QSearchableListWidget` is a variant of [`QListWidget`](https://doc.qt.io/qt-5/qlistwidget.html) that add text entry above list widget that allow to filter list +of available options. + +Because of implementation it does not inherit directly from `QListWidget` but satisfy it all api. The only limitation is that it cannot be used as argument of `QListWidgetItem` constructor. diff --git a/examples/searchable_combo_box.py b/examples/searchable_combo_box.py new file mode 100644 index 0000000..eb11940 --- /dev/null +++ b/examples/searchable_combo_box.py @@ -0,0 +1,11 @@ +from qtpy.QtWidgets import QApplication + +from superqt import QSearchableComboBox + +app = QApplication([]) + +slider = QSearchableComboBox() +slider.addItems(["foo", "bar", "baz", "foobar", "foobaz", "barbaz"]) +slider.show() + +app.exec_() diff --git a/examples/searchable_list_widget.py b/examples/searchable_list_widget.py new file mode 100644 index 0000000..a4b6df6 --- /dev/null +++ b/examples/searchable_list_widget.py @@ -0,0 +1,11 @@ +from qtpy.QtWidgets import QApplication + +from superqt import QSearchableListWidget + +app = QApplication([]) + +slider = QSearchableListWidget() +slider.addItems(["foo", "bar", "baz", "foobar", "foobaz", "barbaz"]) +slider.show() + +app.exec_() diff --git a/setup.cfg b/setup.cfg index d4265a3..5a27331 100644 --- a/setup.cfg +++ b/setup.cfg @@ -35,6 +35,7 @@ project_urls = [options] packages = find: install_requires = + packaging qtpy>=1.1.0 typing-extensions>=3.10.0.0 python_requires = >=3.7 diff --git a/src/superqt/__init__.py b/src/superqt/__init__.py index 82760bd..f07626e 100644 --- a/src/superqt/__init__.py +++ b/src/superqt/__init__.py @@ -7,7 +7,8 @@ except ImportError: from ._eliding_label import QElidingLabel from .collapsible import QCollapsible -from .combobox import QEnumComboBox +from .combobox import QEnumComboBox, QSearchableComboBox +from .selection import QSearchableListWidget from .sliders import ( QDoubleRangeSlider, QDoubleSlider, @@ -26,13 +27,15 @@ __all__ = [ "QDoubleRangeSlider", "QDoubleSlider", "QElidingLabel", + "QEnumComboBox", "QLabeledDoubleRangeSlider", "QLabeledDoubleSlider", "QLabeledRangeSlider", "QLabeledSlider", "QLargeIntSpinBox", "QMessageHandler", + "QSearchableComboBox", + "QSearchableListWidget", "QRangeSlider", - "QEnumComboBox", "QCollapsible", ] diff --git a/src/superqt/combobox/__init__.py b/src/superqt/combobox/__init__.py index 0ea124f..656baed 100644 --- a/src/superqt/combobox/__init__.py +++ b/src/superqt/combobox/__init__.py @@ -1,3 +1,4 @@ from ._enum_combobox import QEnumComboBox +from ._searchable_combo_box import QSearchableComboBox -__all__ = ("QEnumComboBox",) +__all__ = ("QEnumComboBox", "QSearchableComboBox") diff --git a/src/superqt/combobox/_searchable_combo_box.py b/src/superqt/combobox/_searchable_combo_box.py new file mode 100644 index 0000000..2fe16de --- /dev/null +++ b/src/superqt/combobox/_searchable_combo_box.py @@ -0,0 +1,48 @@ +from qtpy import QT_VERSION +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import QComboBox, QCompleter + +try: + is_qt_bellow_5_14 = tuple(int(x) for x in QT_VERSION.split(".")[:2]) < (5, 14) +except ValueError: + is_qt_bellow_5_14 = False + + +class QSearchableComboBox(QComboBox): + """ + ComboCox with completer for fast search in multiple options + """ + + if is_qt_bellow_5_14: + textActivated = Signal(str) # pragma: no cover + + def __init__(self, parent=None): + super().__init__(parent) + self.setEditable(True) + self.completer_object = QCompleter() + self.completer_object.setCaseSensitivity(Qt.CaseInsensitive) + self.completer_object.setCompletionMode(QCompleter.PopupCompletion) + self.completer_object.setFilterMode(Qt.MatchContains) + self.setCompleter(self.completer_object) + self.setInsertPolicy(QComboBox.NoInsert) + if is_qt_bellow_5_14: # pragma: no cover + self.currentIndexChanged.connect(self._text_activated) + + def _text_activated(self): # pragma: no cover + self.textActivated.emit(self.currentText()) + + def addItem(self, *args): + super().addItem(*args) + self.completer_object.setModel(self.model()) + + def addItems(self, *args): + super().addItems(*args) + self.completer_object.setModel(self.model()) + + def insertItem(self, *args) -> None: + super().insertItem(*args) + self.completer_object.setModel(self.model()) + + def insertItems(self, *args) -> None: + super().insertItems(*args) + self.completer_object.setModel(self.model()) diff --git a/src/superqt/selection/__init__.py b/src/superqt/selection/__init__.py new file mode 100644 index 0000000..66b3d1a --- /dev/null +++ b/src/superqt/selection/__init__.py @@ -0,0 +1,3 @@ +from ._searchable_list_widget import QSearchableListWidget + +__all__ = ("QSearchableListWidget",) diff --git a/src/superqt/selection/_searchable_list_widget.py b/src/superqt/selection/_searchable_list_widget.py new file mode 100644 index 0000000..6e8c7ec --- /dev/null +++ b/src/superqt/selection/_searchable_list_widget.py @@ -0,0 +1,46 @@ +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QLineEdit, QListWidget, QVBoxLayout, QWidget + + +class QSearchableListWidget(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + + self.list_widget = QListWidget() + + self.filter_widget = QLineEdit() + self.filter_widget.textChanged.connect(self.update_visible) + + layout = QVBoxLayout() + layout.addWidget(self.filter_widget) + layout.addWidget(self.list_widget) + self.setLayout(layout) + + def __getattr__(self, item): + if hasattr(self.list_widget, item): + return getattr(self.list_widget, item) + return super().__getattr__(item) + + def update_visible(self, text): + items_text = [ + x.text() for x in self.list_widget.findItems(text, Qt.MatchContains) + ] + for index in range(self.list_widget.count()): + item = self.item(index) + item.setHidden(item.text() not in items_text) + + def addItems(self, *args): + self.list_widget.addItems(*args) + self.update_visible(self.filter_widget.text()) + + def addItem(self, *args): + self.list_widget.addItem(*args) + self.update_visible(self.filter_widget.text()) + + def insertItems(self, *args): + self.list_widget.insertItems(*args) + self.update_visible(self.filter_widget.text()) + + def insertItem(self, *args): + self.list_widget.insertItem(*args) + self.update_visible(self.filter_widget.text()) diff --git a/tests/test_searchable_combobox.py b/tests/test_searchable_combobox.py new file mode 100644 index 0000000..9f4b891 --- /dev/null +++ b/tests/test_searchable_combobox.py @@ -0,0 +1,35 @@ +from superqt import QSearchableComboBox + + +class TestSearchableComboBox: + def test_constructor(self, qtbot): + widget = QSearchableComboBox() + qtbot.addWidget(widget) + + def test_add_items(self, qtbot): + widget = QSearchableComboBox() + qtbot.addWidget(widget) + widget.addItems(["foo", "bar"]) + assert widget.completer_object.model().rowCount() == 2 + widget.addItem("foobar") + assert widget.completer_object.model().rowCount() == 3 + widget.insertItem(1, "baz") + assert widget.completer_object.model().rowCount() == 4 + widget.insertItems(2, ["bazbar", "foobaz"]) + assert widget.completer_object.model().rowCount() == 6 + assert widget.itemText(0) == "foo" + assert widget.itemText(1) == "baz" + assert widget.itemText(2) == "bazbar" + + def test_completion(self, qtbot): + widget = QSearchableComboBox() + qtbot.addWidget(widget) + widget.addItems(["foo", "bar", "foobar", "baz", "bazbar", "foobaz"]) + + widget.completer_object.setCompletionPrefix("fo") + assert widget.completer_object.completionCount() == 3 + assert widget.completer_object.currentCompletion() == "foo" + widget.completer_object.setCurrentRow(1) + assert widget.completer_object.currentCompletion() == "foobar" + widget.completer_object.setCurrentRow(2) + assert widget.completer_object.currentCompletion() == "foobaz" diff --git a/tests/test_searchable_list.py b/tests/test_searchable_list.py new file mode 100644 index 0000000..c780f8a --- /dev/null +++ b/tests/test_searchable_list.py @@ -0,0 +1,34 @@ +from superqt import QSearchableListWidget + + +class TestSearchableListWidget: + def test_create(self, qtbot): + widget = QSearchableListWidget() + qtbot.addWidget(widget) + widget.addItem("aaa") + assert widget.count() == 1 + + def test_add_items(self, qtbot): + widget = QSearchableListWidget() + qtbot.addWidget(widget) + widget.addItems(["foo", "bar"]) + assert widget.count() == 2 + widget.insertItems(1, ["baz", "foobaz"]) + widget.insertItem(2, "foobar") + assert widget.count() == 5 + assert widget.item(0).text() == "foo" + assert widget.item(1).text() == "baz" + assert widget.item(2).text() == "foobar" + + def test_completion(self, qtbot): + widget = QSearchableListWidget() + qtbot.addWidget(widget) + widget.show() + widget.addItems(["foo", "bar", "foobar", "baz", "bazbar", "foobaz"]) + widget.filter_widget.setText("fo") + assert widget.count() == 6 + for i in range(widget.count()): + item = widget.item(i) + assert item.isHidden() == ("fo" not in item.text()) + + widget.hide()