Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

QCheckComboBox for Easy Multiple Items Selection #91

Closed
wants to merge 27 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
2642839
Initial implementation of QCheckComboBox
MosGeo Jun 9, 2022
4467973
Running pre-commit on files
MosGeo Jun 9, 2022
0d2a185
Typing compatiblity
MosGeo Jun 9, 2022
b9f4129
Fix issue with PySide6
MosGeo Jun 12, 2022
699f30b
Fix issue with stopping condition
MosGeo Jun 12, 2022
60ad447
Prevent double clicking on items from closing the pop-up.
MosGeo Jul 6, 2022
fe3689e
Merge branch 'main' into main
tlambert03 Jul 7, 2022
5913136
Update tests/test_check_combobox.py
MosGeo Jul 26, 2022
cf70a08
Update tests/test_check_combobox.py
MosGeo Jul 26, 2022
c92b7a2
Update tests/test_check_combobox.py
MosGeo Jul 26, 2022
221a10d
Update tests/test_check_combobox.py
MosGeo Jul 26, 2022
f2fcb65
Update tests/test_check_combobox.py
MosGeo Jul 26, 2022
f9700fc
Update tests/test_check_combobox.py
MosGeo Jul 26, 2022
97aa0cc
Update tests/test_check_combobox.py
MosGeo Jul 26, 2022
1344235
Update tests/test_check_combobox.py
MosGeo Jul 26, 2022
df879a2
Update tests/test_check_combobox.py
MosGeo Jul 26, 2022
5dbc982
Update tests/test_check_combobox.py
MosGeo Jul 26, 2022
2b52020
Update tests/test_check_combobox.py
MosGeo Jul 26, 2022
12e8dbe
Update tests/test_check_combobox.py
MosGeo Jul 26, 2022
8f71c26
Added insertItem function
MosGeo Jul 26, 2022
f495b30
Added icon parameter
MosGeo Jul 26, 2022
333f89f
Added signal
MosGeo Jul 26, 2022
b270969
Added checkedTexts function
MosGeo Jul 26, 2022
91cd54d
Merge branch 'napari:main' into main
MosGeo Jul 27, 2022
0fae340
Merge branch 'main' into main
tlambert03 Dec 1, 2022
79e3970
Merge branch 'main' into main
tlambert03 Mar 12, 2023
6137bcb
style: [pre-commit.ci] auto fixes [...]
pre-commit-ci[bot] Mar 12, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions examples/check_combobox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from qtpy.QtWidgets import QApplication, QMainWindow, QPushButton, QVBoxLayout, QWidget

from superqt import QCheckComboBox


def change_label_type() -> None:
"""Function used to swtich the label type"""
if combobox.labelType() == QCheckComboBox.QCheckComboBoxLabelType.SELECTED_ITEMS:
combobox.setLabelType(QCheckComboBox.QCheckComboBoxLabelType.STRING)
combobox.setLabelText("Select Sample")
else:
combobox.setLabelType(QCheckComboBox.QCheckComboBoxLabelType.SELECTED_ITEMS)


# Create main window
app = QApplication([])
main_window = QMainWindow()
main_widget = QWidget()
main_layout = QVBoxLayout()
main_window.setFixedWidth(700)
main_window.setFixedHeight(450)

# Create the check comobobox
combobox = QCheckComboBox()
combobox.setLabelText("Select items")
texts = [f"Item {i}" for i in range(5)]
combobox.addItems(texts)

# Use insertItem instead of addItems
combobox.insertItem(1, text="New Item at index 1")

# Add button to change the label type
button = QPushButton("Change label type")
button.clicked.connect(lambda: change_label_type())

# Add widgets to main window
main_widget.setLayout(main_layout)
main_widget.layout().addWidget(combobox)
main_widget.layout().addWidget(button)
main_window.setCentralWidget(main_widget)
main_window.show()

# Run
app.exec_()
3 changes: 2 additions & 1 deletion src/superqt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from ._eliding_label import QElidingLabel
from .collapsible import QCollapsible
from .combobox import QEnumComboBox, QSearchableComboBox
from .combobox import QCheckComboBox, QEnumComboBox, QSearchableComboBox
from .selection import QSearchableListWidget
from .sliders import (
QDoubleRangeSlider,
Expand All @@ -28,6 +28,7 @@
__all__ = [
"ensure_main_thread",
"ensure_object_thread",
"QCheckComboBox",
"QDoubleRangeSlider",
"QCollapsible",
"QDoubleSlider",
Expand Down
3 changes: 2 additions & 1 deletion src/superqt/combobox/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from ._check_combobox import QCheckComboBox
from ._enum_combobox import QEnumComboBox
from ._searchable_combo_box import QSearchableComboBox

__all__ = ("QEnumComboBox", "QSearchableComboBox")
__all__ = ("QEnumComboBox", "QSearchableComboBox", "QCheckComboBox")
189 changes: 189 additions & 0 deletions src/superqt/combobox/_check_combobox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
from enum import Enum, auto
from typing import Any, List, Union

from qtpy.QtCore import QEvent, Qt, Signal
from qtpy.QtGui import QIcon, QStandardItem
from qtpy.QtWidgets import QComboBox, QStyle, QStyleOptionComboBox, QStylePainter


class QCheckComboBox(QComboBox):
"""
A combobox with a check for each item inserted.
based on https://stackoverflow.com/questions/47575880/qcombobox-set-title-text-regardless-of-items.
"""

class QCheckComboBoxLabelType(Enum):
"""Label type."""

STRING = auto()
SELECTED_ITEMS = auto()

_label_text: str = "Select items"
_label_type: QCheckComboBoxLabelType = QCheckComboBoxLabelType.STRING
selectionUpdated = Signal(int, bool)

def __init__(self) -> None:
"""Initializes the widget."""
super().__init__()
self.view().pressed.connect(self._handleItemPressed)
self.view().doubleClicked.connect(self._handleItemPressed)
self._changed = False

def _update_label_text_with_selected_items(self) -> None:
checked_indices = self.checkedIndices()
selected_text_list = []
for index in checked_indices:
selected_text_list.append(self.itemText(index))
self.setLabelText(", ".join(selected_text_list))

def setLabelText(self, label_text: str) -> None:
"""Sets the label text."""
self._label_text = label_text
self.repaint()

def labelText(self) -> str:
return self._label_text

def setLabelType(self, label_type: QCheckComboBoxLabelType) -> None:
"""Sets the label type."""
self._label_type = label_type
if label_type == QCheckComboBox.QCheckComboBoxLabelType.SELECTED_ITEMS:
self._update_label_text_with_selected_items()

def labelType(self) -> QCheckComboBoxLabelType:
"""Returns label type."""
return self._label_type

def _handleItemPressed(self, index: int) -> None:
"""Updates item checked status."""
item = self.model().itemFromIndex(index)
if item.checkState() == Qt.Checked:
item.setCheckState(Qt.Unchecked)
else:
item.setCheckState(Qt.Checked)

if self._label_type == QCheckComboBox.QCheckComboBoxLabelType.SELECTED_ITEMS:
self._update_label_text_with_selected_items()
self._changed = True

def addItem(
self, text: str, icon: QIcon = None, userData: Any = None, checked: bool = True
) -> None:
"""Overrides the combobox additem to make sure it is chackable."""
super().addItem(text, userData)
item: QStandardItem = self.model().item(self.count() - 1, self.modelColumn())
item.setCheckable(True)
self.setItemChecked(self.count() - 1, checked=checked)
if icon:
item.setIcon(icon)
if (
self._label_type == QCheckComboBox.QCheckComboBoxLabelType.SELECTED_ITEMS
and checked is True
):
self._update_label_text_with_selected_items()

def addItems(
self, texts: List[str], checked: Union[bool, List[bool]] = True
) -> None:
"""Overirdes the combobox addItems to make sure it is checkable."""
if isinstance(checked, bool):
checked = [checked] * len(texts)

for text, checked_value in zip(texts, checked):
self.addItem(text=text, userData=None, checked=checked_value)

if (
self._label_type == QCheckComboBox.QCheckComboBoxLabelType.SELECTED_ITEMS
and any(checked) is True
):
self._update_label_text_with_selected_items()

def insertItem(
self,
index: int,
text: str,
icon: QIcon = None,
userData: Any = ...,
checked: bool = True,
) -> None:
"""Inserts an item."""
super().insertItem(index, text, userData)
item: QStandardItem = self.model().item(index, self.modelColumn())
item.setCheckable(True)
self.setItemChecked(index, checked=checked)
if icon:
item.setIcon(icon)
if (
self._label_type == QCheckComboBox.QCheckComboBoxLabelType.SELECTED_ITEMS
and checked is True
):
self._update_label_text_with_selected_items()

def hidePopup(self) -> None:
"""Override hidePopup to disable it if an item state has changed."""
if not self._changed:
super().hidePopup()
self._changed = False

def itemChecked(self, index: int) -> bool:
"""Returns current checked state as boolean."""
item: QStandardItem = self.model().item(index, self.modelColumn())
is_checked: bool = item.checkState() == Qt.Checked
return is_checked

def setItemChecked(self, index: int, checked: bool = True) -> None:
"""Sets the status."""
item: QStandardItem = self.model().item(index)
checked_state_old = item.checkState()
checked_state_new = Qt.Checked if checked else Qt.Unchecked

# Stopping condition
if checked_state_old == checked_state_new:
return

item.setCheckState(checked_state_new)
self.selectionUpdated.emit(index, checked)

if self._label_type == QCheckComboBox.QCheckComboBoxLabelType.SELECTED_ITEMS:
self._update_label_text_with_selected_items()

def setAllItemChecked(self, checked: bool = True) -> None:
"""Set all item checked status in one go."""
for i in range(self.count()):
self.setItemChecked(i, checked=checked)

def checkedIndices(self) -> List[int]:
"""Returns the checked indices."""
indecies = []
for i in range(self.count()):
item = self.model().item(i)
if item.checkState() == Qt.Checked:
indecies.append(i)
return indecies

def uncheckedIndices(self) -> List[int]:
"""Returns teh unchecked indices."""
indecies = []
for i in range(self.count()):
item = self.model().item(i)
if item.checkState() == Qt.Unchecked:
indecies.append(i)
return indecies

def checkedTexts(self) -> List[str]:
"""Returns the checked indices."""
texts = []
for i in range(self.count()):
item = self.model().item(i)
if item.checkState() == Qt.Checked:
texts.append(item.text())
return texts

def paintEvent(self, event: QEvent) -> None:
"""Overrides the paint event to update the label."""
painter = QStylePainter(self)
opt = QStyleOptionComboBox()
self.initStyleOption(opt)
opt.currentText = self._label_text
painter.drawComplexControl(QStyle.CC_ComboBox, opt)
painter.drawControl(QStyle.CE_ComboBoxLabel, opt)
140 changes: 140 additions & 0 deletions tests/test_check_combobox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
from pytestqt.qtbot import QtBot
from qtpy.QtCore import QEvent

from superqt import QCheckComboBox


def test_add_item(qtbot: QtBot) -> None:
"""Tests the addition of item"""

check_combobox = QCheckComboBox()
MosGeo marked this conversation as resolved.
Show resolved Hide resolved
qtbot.add_widget(check_combobox)

check_combobox.addItem("Item 1", userData=1, checked=False)
check_combobox.addItem("Item 2", userData="2", checked=True)

assert check_combobox.itemData(0) == 1
assert check_combobox.itemData(1) == "2"
assert check_combobox.itemText(0) == "Item 1"
assert check_combobox.itemText(1) == "Item 2"


def test_add_items(qtbot: QtBot) -> None:
"""Tests the addition of items"""

check_combobox1 = QCheckComboBox()
MosGeo marked this conversation as resolved.
Show resolved Hide resolved
qtbot.add_widget(check_combobox1)
check_combobox1.addItems(["Item 1", "Item 2", "Item 3"])
assert check_combobox1.itemText(0) == "Item 1"
assert check_combobox1.itemText(1) == "Item 2"
assert check_combobox1.itemText(2) == "Item 3"

check_combobox2 = QCheckComboBox()
MosGeo marked this conversation as resolved.
Show resolved Hide resolved
qtbot.add_widget(check_combobox2)
check_combobox2.addItems(["Item 1", "Item 2"], checked=False)
assert check_combobox2.itemChecked(0) is False
assert check_combobox2.itemChecked(1) is False

check_combobox3 = QCheckComboBox()
MosGeo marked this conversation as resolved.
Show resolved Hide resolved
qtbot.add_widget(check_combobox3)
check_combobox3.addItems(["Item 1", "Item 2"], checked=True)
assert check_combobox3.itemChecked(0) is True
assert check_combobox3.itemChecked(1) is True

check_combobox4 = QCheckComboBox()
MosGeo marked this conversation as resolved.
Show resolved Hide resolved
qtbot.add_widget(check_combobox4)
check_combobox4.addItems(["Item 1", "Item 2"], checked=[True, False])
assert check_combobox4.itemChecked(0) is True
assert check_combobox4.itemChecked(1) is False


def test_set_all_items_checked(qtbot: QtBot) -> None:
"""Tests setting all items checked status"""
check_combobox = QCheckComboBox()
MosGeo marked this conversation as resolved.
Show resolved Hide resolved
qtbot.add_widget(check_combobox)
check_combobox.addItems(["Item 1", "Item 2", "Item 3"], checked=[True, False, True])
checked_status = [
check_combobox.itemChecked(i) for i in range(check_combobox.count())
]
assert checked_status == [True, False, True]

check_combobox.setAllItemChecked(False)
checked_status = [
check_combobox.itemChecked(i) for i in range(check_combobox.count())
]
assert checked_status == [False, False, False]

check_combobox.setAllItemChecked(True)
checked_status = [
check_combobox.itemChecked(i) for i in range(check_combobox.count())
]
assert checked_status == [True, True, True]


def test_indices_retrival(qtbot: QtBot) -> None:
"""Tests retrival of the indices checked and unchecked"""
check_combobox = QCheckComboBox()
MosGeo marked this conversation as resolved.
Show resolved Hide resolved
qtbot.add_widget(check_combobox)
check_combobox.addItems(["Item 1", "Item 2", "Item 3"], checked=[True, False, True])
assert check_combobox.checkedIndices() == [0, 2]
assert check_combobox.uncheckedIndices() == [1]


def test_changing_label_string(qtbot: QtBot) -> None:
"""Tests changing the label string"""
check_combobox = QCheckComboBox()
MosGeo marked this conversation as resolved.
Show resolved Hide resolved
qtbot.add_widget(check_combobox)
check_combobox.setLabelText("Please select items")
assert check_combobox.labelText() == "Please select items"


def test_selected_items_label_type(qtbot: QtBot) -> None:
"""Tests selected item label"""
check_combobox = QCheckComboBox()
MosGeo marked this conversation as resolved.
Show resolved Hide resolved
qtbot.add_widget(check_combobox)
check_combobox.setLabelType(QCheckComboBox.QCheckComboBoxLabelType.SELECTED_ITEMS)
assert (
check_combobox.labelType()
== QCheckComboBox.QCheckComboBoxLabelType.SELECTED_ITEMS
)

check_combobox.addItem("Item 1")
check_combobox.addItems(["Item 2", "Item 3"])
check_combobox.setItemChecked(1, False)
assert check_combobox.labelText() == "Item 1, Item 3"


def test_paint_event(qtbot: QtBot) -> None:
"""Simple test for paint event; execute without error"""
check_combobox = QCheckComboBox()
qtbot.add_widget(check_combobox)
check_combobox.show()
check_combobox.setLabelText("A new label")
check_combobox.paintEvent(QEvent(QEvent.Paint))
check_combobox.hide()


def test_hidepopup(qtbot: QtBot) -> None:
check_combobox = QCheckComboBox()
MosGeo marked this conversation as resolved.
Show resolved Hide resolved
qtbot.add_widget(check_combobox)
check_combobox._changed = True
check_combobox.hidePopup()
assert check_combobox._changed is False
check_combobox.hidePopup()


def test_handle_item_checked(qtbot: QtBot) -> None:
"""Tests the check combobox interactions"""
check_combobox = QCheckComboBox()
MosGeo marked this conversation as resolved.
Show resolved Hide resolved
qtbot.add_widget(check_combobox)
check_combobox.addItems(["Item 1", "Item 2"], [True, False])
check_combobox.setLabelType(QCheckComboBox.QCheckComboBoxLabelType.SELECTED_ITEMS)
model = check_combobox.model()

model_index = model.index(0, 0)
check_combobox._handleItemPressed(model_index)
assert check_combobox.itemChecked(0) is False

model_index = model.index(1, 0)
check_combobox._handleItemPressed(model_index)
assert check_combobox.itemChecked(1) is True