feat: add QColorComboBox for picking single colors (#194)

* feat: add QColorCombo

* more features

* test: add some tests

* fix: import the future

* more tests

* style: [pre-commit.ci] auto fixes [...]

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Talley Lambert
2023-09-11 08:56:37 -04:00
committed by GitHub
parent 60f442789f
commit 658995a0b4
5 changed files with 412 additions and 9 deletions

View File

@@ -0,0 +1,23 @@
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QApplication
from superqt import QColorComboBox
app = QApplication([])
w = QColorComboBox()
# adds an item "Add Color" that opens a QColorDialog when clicked
w.setUserColorsAllowed(True)
# colors can be any argument that can be passed to QColor
# (tuples and lists will be expanded to QColor(*color)
COLORS = [QColor("red"), "orange", (255, 255, 0), "green", "#00F", "indigo", "violet"]
w.addColors(COLORS)
# as with addColors, colors will be cast to QColor when using setColors
w.setCurrentColor("indigo")
w.resize(200, 50)
w.show()
w.currentColorChanged.connect(print)
app.exec_()

View File

@@ -7,12 +7,8 @@ try:
except PackageNotFoundError: except PackageNotFoundError:
__version__ = "unknown" __version__ = "unknown"
if TYPE_CHECKING:
from .combobox import QColormapComboBox
from .spinbox._quantity import QQuantity
from .collapsible import QCollapsible from .collapsible import QCollapsible
from .combobox import QEnumComboBox, QSearchableComboBox from .combobox import QColorComboBox, QEnumComboBox, QSearchableComboBox
from .elidable import QElidingLabel, QElidingLineEdit from .elidable import QElidingLabel, QElidingLineEdit
from .selection import QSearchableListWidget, QSearchableTreeWidget from .selection import QSearchableListWidget, QSearchableTreeWidget
from .sliders import ( from .sliders import (
@@ -30,9 +26,10 @@ from .utils import QMessageHandler, ensure_main_thread, ensure_object_thread
__all__ = [ __all__ = [
"ensure_main_thread", "ensure_main_thread",
"ensure_object_thread", "ensure_object_thread",
"QDoubleRangeSlider",
"QCollapsible", "QCollapsible",
"QColorComboBox",
"QColormapComboBox", "QColormapComboBox",
"QDoubleRangeSlider",
"QDoubleSlider", "QDoubleSlider",
"QElidingLabel", "QElidingLabel",
"QElidingLineEdit", "QElidingLineEdit",
@@ -50,6 +47,10 @@ __all__ = [
"QSearchableTreeWidget", "QSearchableTreeWidget",
] ]
if TYPE_CHECKING:
from .combobox import QColormapComboBox
from .spinbox._quantity import QQuantity
def __getattr__(name: str) -> Any: def __getattr__(name: str) -> Any:
if name == "QQuantity": if name == "QQuantity":

View File

@@ -1,15 +1,21 @@
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from ._color_combobox import QColorComboBox
from ._enum_combobox import QEnumComboBox from ._enum_combobox import QEnumComboBox
from ._searchable_combo_box import QSearchableComboBox from ._searchable_combo_box import QSearchableComboBox
__all__ = (
"QColorComboBox",
"QColormapComboBox",
"QEnumComboBox",
"QSearchableComboBox",
)
if TYPE_CHECKING: if TYPE_CHECKING:
from superqt.cmap import QColormapComboBox from superqt.cmap import QColormapComboBox
__all__ = ("QEnumComboBox", "QSearchableComboBox", "QColormapComboBox")
def __getattr__(name: str) -> Any: # pragma: no cover def __getattr__(name: str) -> Any: # pragma: no cover
if name == "QColormapComboBox": if name == "QColormapComboBox":
from superqt.cmap import QColormapComboBox from superqt.cmap import QColormapComboBox

View File

@@ -0,0 +1,287 @@
from __future__ import annotations
import warnings
from contextlib import suppress
from enum import IntEnum, auto
from typing import Any, Literal, Sequence, cast
from qtpy.QtCore import QModelIndex, QPersistentModelIndex, QRect, QSize, Qt, Signal
from qtpy.QtGui import QColor, QPainter
from qtpy.QtWidgets import (
QAbstractItemDelegate,
QColorDialog,
QComboBox,
QLineEdit,
QStyle,
QStyleOptionViewItem,
QWidget,
)
from superqt.utils import signals_blocked
_NAME_MAP = {QColor(x).name(): x for x in QColor.colorNames()}
COLOR_ROLE = Qt.ItemDataRole.BackgroundRole
class InvalidColorPolicy(IntEnum):
"""Policy for handling invalid colors."""
Ignore = auto()
Warn = auto()
Raise = auto()
class _ColorComboLineEdit(QLineEdit):
"""A read-only line edit that shows the parent ComboBox popup when clicked."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setReadOnly(True)
# hide any original text
self.setStyleSheet("color: transparent")
self.setText("")
def mouseReleaseEvent(self, _: Any) -> None:
"""Show parent popup when clicked.
Without this, only the down arrow will show the popup. And if mousePressEvent
is used instead, the popup will show and then immediately hide.
"""
parent = self.parent()
if hasattr(parent, "showPopup"):
parent.showPopup()
class _ColorComboItemDelegate(QAbstractItemDelegate):
"""Delegate that draws color squares in the ComboBox.
This provides more control than simply setting various data roles on the item,
and makes for a nicer appearance. Importantly, it prevents the color from being
obscured on hover.
"""
def sizeHint(
self, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex
) -> QSize:
return QSize(20, 20)
def paint(
self,
painter: QPainter,
option: QStyleOptionViewItem,
index: QModelIndex | QPersistentModelIndex,
) -> None:
color: QColor | None = index.data(COLOR_ROLE)
rect = cast("QRect", option.rect) # type: ignore
state = cast("QStyle.StateFlag", option.state) # type: ignore
selected = state & QStyle.StateFlag.State_Selected
border = QColor("lightgray")
if not color:
# not a color square, just draw the text
text_color = Qt.GlobalColor.black if selected else Qt.GlobalColor.gray
painter.setPen(text_color)
text = index.data(Qt.ItemDataRole.DisplayRole)
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, text)
return
# slightly larger border for rect
pen = painter.pen()
pen.setWidth(2)
pen.setColor(border)
painter.setPen(pen)
if selected:
# if hovering, give a slight highlight and draw the color name
painter.setBrush(color.lighter(110))
painter.drawRect(rect)
# use user friendly color name if available
name = _NAME_MAP.get(color.name(), color.name())
painter.setPen(_pick_font_color(color))
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, name)
else: # not hovering
painter.setBrush(color)
painter.drawRect(rect)
class QColorComboBox(QComboBox):
"""A drop down menu for selecting colors.
Parameters
----------
parent : QWidget, optional
The parent widget.
allow_user_colors : bool, optional
Whether to show an "Add Color" item that opens a QColorDialog when clicked.
Whether the user can add custom colors by clicking the "Add Color" item.
Default is False. Can also be set with `setUserColorsAllowed`.
add_color_text: str, optional
The text to display for the "Add Color" item. Default is "Add Color...".
"""
currentColorChanged = Signal(QColor)
def __init__(
self,
parent: QWidget | None = None,
*,
allow_user_colors: bool = False,
add_color_text: str = "Add Color...",
) -> None:
# init QComboBox
super().__init__(parent)
self._invalid_policy: InvalidColorPolicy = InvalidColorPolicy.Ignore
self._add_color_text: str = add_color_text
self._allow_user_colors: bool = allow_user_colors
self._last_color: QColor = QColor()
self.setLineEdit(_ColorComboLineEdit(self))
self.setItemDelegate(_ColorComboItemDelegate())
self.currentIndexChanged.connect(self._on_index_changed)
self.activated.connect(self._on_activated)
self.setUserColorsAllowed(allow_user_colors)
def setInvalidColorPolicy(
self, policy: InvalidColorPolicy | int | Literal["Raise", "Ignore", "Warn"]
) -> None:
"""Sets the policy for handling invalid colors."""
if isinstance(policy, str):
policy = InvalidColorPolicy[policy]
elif isinstance(policy, int):
policy = InvalidColorPolicy(policy)
elif not isinstance(policy, InvalidColorPolicy):
raise TypeError(f"Invalid policy type: {type(policy)!r}")
self._invalid_policy = policy
def invalidColorPolicy(self) -> InvalidColorPolicy:
"""Returns the policy for handling invalid colors."""
return self._invalid_policy
InvalidColorPolicy = InvalidColorPolicy
def userColorsAllowed(self) -> bool:
"""Returns whether the user can add custom colors."""
return self._allow_user_colors
def setUserColorsAllowed(self, allow: bool) -> None:
"""Sets whether the user can add custom colors."""
self._allow_user_colors = bool(allow)
idx = self.findData(self._add_color_text, Qt.ItemDataRole.DisplayRole)
if idx < 0:
if self._allow_user_colors:
self.addItem(self._add_color_text)
elif not self._allow_user_colors:
self.removeItem(idx)
def clear(self) -> None:
"""Clears the QComboBox of all entries (leaves "Add colors" if enabled)."""
super().clear()
self.setUserColorsAllowed(self._allow_user_colors)
def addColor(self, color: Any) -> None:
"""Adds the color to the QComboBox."""
_color = _cast_color(color)
if not _color.isValid():
if self._invalid_policy == InvalidColorPolicy.Raise:
raise ValueError(f"Invalid color: {color!r}")
elif self._invalid_policy == InvalidColorPolicy.Warn:
warnings.warn(f"Ignoring invalid color: {color!r}", stacklevel=2)
return
c = self.currentColor()
if self.findData(_color) > -1: # avoid duplicates
return
# add the new color and set the background color of that item
self.addItem("", _color)
self.setItemData(self.count() - 1, _color, COLOR_ROLE)
if not c or not c.isValid():
self._on_index_changed(self.count() - 1)
# make sure the "Add Color" item is last
idx = self.findData(self._add_color_text, Qt.ItemDataRole.DisplayRole)
if idx >= 0:
with signals_blocked(self):
self.removeItem(idx)
self.addItem(self._add_color_text)
def itemColor(self, index: int) -> QColor | None:
"""Returns the color of the item at the given index."""
return self.itemData(index, COLOR_ROLE)
def addColors(self, colors: Sequence[Any]) -> None:
"""Adds colors to the QComboBox."""
for color in colors:
self.addColor(color)
def currentColor(self) -> QColor | None:
"""Returns the currently selected QColor or None if not yet selected."""
return self.currentData(COLOR_ROLE)
def setCurrentColor(self, color: Any) -> None:
"""Adds the color to the QComboBox and selects it."""
idx = self.findData(_cast_color(color), COLOR_ROLE)
if idx >= 0:
self.setCurrentIndex(idx)
def currentColorName(self) -> str | None:
"""Returns the name of the currently selected QColor or black if None."""
color = self.currentColor()
return color.name() if color else "#000000"
def _on_activated(self, index: int) -> None:
if self.itemText(index) != self._add_color_text:
return
# show temporary text while dialog is open
self.lineEdit().setStyleSheet("background-color: white; color: gray;")
self.lineEdit().setText("Pick a Color ...")
try:
color = QColorDialog.getColor()
finally:
self.lineEdit().setText("")
if color.isValid():
# add the color and select it
self.addColor(color)
elif self._last_color.isValid():
# user canceled, restore previous color without emitting signal
idx = self.findData(self._last_color, COLOR_ROLE)
if idx >= 0:
with signals_blocked(self):
self.setCurrentIndex(idx)
hex_ = self._last_color.name()
self.lineEdit().setStyleSheet(f"background-color: {hex_};")
return
def _on_index_changed(self, index: int) -> None:
color = self.itemData(index, COLOR_ROLE)
if isinstance(color, QColor):
self.lineEdit().setStyleSheet(f"background-color: {color.name()};")
self.currentColorChanged.emit(color)
self._last_color = color
def _cast_color(val: Any) -> QColor:
with suppress(TypeError):
color = QColor(val)
if color.isValid():
return color
if isinstance(val, (tuple, list)):
with suppress(TypeError):
color = QColor(*val)
if color.isValid():
return color
return QColor()
def _pick_font_color(color: QColor) -> QColor:
"""Pick a font shade that contrasts with the given color."""
if (color.red() * 0.299 + color.green() * 0.587 + color.blue() * 0.114) > 80:
return QColor(0, 0, 0, 128)
else:
return QColor(255, 255, 255, 128)

86
tests/test_color_combo.py Normal file
View File

@@ -0,0 +1,86 @@
from unittest.mock import patch
import pytest
from qtpy import API_NAME
from qtpy.QtGui import QColor, QPainter
from qtpy.QtWidgets import QStyleOptionViewItem
from superqt import QColorComboBox
from superqt.combobox import _color_combobox
def test_q_color_combobox(qtbot):
wdg = QColorComboBox()
qtbot.addWidget(wdg)
wdg.show()
wdg.setUserColorsAllowed(True)
# colors can be any argument that can be passed to QColor
# (tuples and lists will be expanded to QColor(*color)
COLORS = [QColor("red"), "orange", (255, 255, 0), "green", "#00F", "indigo"]
wdg.addColors(COLORS)
colors = [wdg.itemColor(i) for i in range(wdg.count())]
assert colors == [
QColor("red"),
QColor("orange"),
QColor("yellow"),
QColor("green"),
QColor("blue"),
QColor("indigo"),
None, # "Add Color" item
]
# as with addColors, colors will be cast to QColor when using setColors
wdg.setCurrentColor("indigo")
assert wdg.currentColor() == QColor("indigo")
assert wdg.currentColorName() == "#4b0082"
wdg.clear()
assert wdg.count() == 1 # "Add Color" item
wdg.setUserColorsAllowed(False)
assert not wdg.count()
wdg.setInvalidColorPolicy(wdg.InvalidColorPolicy.Ignore)
wdg.setInvalidColorPolicy(2)
wdg.setInvalidColorPolicy("Raise")
with pytest.raises(TypeError):
wdg.setInvalidColorPolicy(1.0) # type: ignore
with pytest.raises(ValueError):
wdg.addColor("invalid")
def test_q_color_delegate(qtbot):
wdg = QColorComboBox()
view = wdg.view()
delegate = wdg.itemDelegate()
qtbot.addWidget(wdg)
wdg.show()
# smoke tests:
painter = QPainter()
option = QStyleOptionViewItem()
index = wdg.model().index(0, 0)
delegate.paint(painter, option, index)
wdg.addColors(["red", "orange", "yellow"])
view.selectAll()
index = wdg.model().index(1, 0)
delegate.paint(painter, option, index)
@pytest.mark.skipif(API_NAME == "PySide2", reason="hangs on CI")
def test_activated(qtbot):
wdg = QColorComboBox()
qtbot.addWidget(wdg)
wdg.show()
wdg.setUserColorsAllowed(True)
with patch.object(_color_combobox.QColorDialog, "getColor", lambda: QColor("red")):
wdg._on_activated(wdg.count() - 1) # "Add Color" item
assert wdg.currentColor() == QColor("red")
with patch.object(_color_combobox.QColorDialog, "getColor", lambda: QColor()):
wdg._on_activated(wdg.count() - 1) # "Add Color" item
assert wdg.currentColor() == QColor("red")