mirror of
https://github.com/pyapp-kit/superqt.git
synced 2025-12-16 11:10:06 +01:00
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:
23
examples/color_combo_box.py
Normal file
23
examples/color_combo_box.py
Normal 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_()
|
||||
@@ -7,12 +7,8 @@ try:
|
||||
except PackageNotFoundError:
|
||||
__version__ = "unknown"
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .combobox import QColormapComboBox
|
||||
from .spinbox._quantity import QQuantity
|
||||
|
||||
from .collapsible import QCollapsible
|
||||
from .combobox import QEnumComboBox, QSearchableComboBox
|
||||
from .combobox import QColorComboBox, QEnumComboBox, QSearchableComboBox
|
||||
from .elidable import QElidingLabel, QElidingLineEdit
|
||||
from .selection import QSearchableListWidget, QSearchableTreeWidget
|
||||
from .sliders import (
|
||||
@@ -30,9 +26,10 @@ from .utils import QMessageHandler, ensure_main_thread, ensure_object_thread
|
||||
__all__ = [
|
||||
"ensure_main_thread",
|
||||
"ensure_object_thread",
|
||||
"QDoubleRangeSlider",
|
||||
"QCollapsible",
|
||||
"QColorComboBox",
|
||||
"QColormapComboBox",
|
||||
"QDoubleRangeSlider",
|
||||
"QDoubleSlider",
|
||||
"QElidingLabel",
|
||||
"QElidingLineEdit",
|
||||
@@ -50,6 +47,10 @@ __all__ = [
|
||||
"QSearchableTreeWidget",
|
||||
]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .combobox import QColormapComboBox
|
||||
from .spinbox._quantity import QQuantity
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name == "QQuantity":
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ._color_combobox import QColorComboBox
|
||||
from ._enum_combobox import QEnumComboBox
|
||||
from ._searchable_combo_box import QSearchableComboBox
|
||||
|
||||
__all__ = (
|
||||
"QColorComboBox",
|
||||
"QColormapComboBox",
|
||||
"QEnumComboBox",
|
||||
"QSearchableComboBox",
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superqt.cmap import QColormapComboBox
|
||||
|
||||
|
||||
__all__ = ("QEnumComboBox", "QSearchableComboBox", "QColormapComboBox")
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any: # pragma: no cover
|
||||
if name == "QColormapComboBox":
|
||||
from superqt.cmap import QColormapComboBox
|
||||
|
||||
287
src/superqt/combobox/_color_combobox.py
Normal file
287
src/superqt/combobox/_color_combobox.py
Normal 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
86
tests/test_color_combo.py
Normal 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")
|
||||
Reference in New Issue
Block a user