From 658995a0b45a172e1b4e68ab6f89059d22708e9d Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 11 Sep 2023 08:56:37 -0400 Subject: [PATCH] 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> --- examples/color_combo_box.py | 23 ++ src/superqt/__init__.py | 13 +- src/superqt/combobox/__init__.py | 12 +- src/superqt/combobox/_color_combobox.py | 287 ++++++++++++++++++++++++ tests/test_color_combo.py | 86 +++++++ 5 files changed, 412 insertions(+), 9 deletions(-) create mode 100644 examples/color_combo_box.py create mode 100644 src/superqt/combobox/_color_combobox.py create mode 100644 tests/test_color_combo.py diff --git a/examples/color_combo_box.py b/examples/color_combo_box.py new file mode 100644 index 00000000..446582c4 --- /dev/null +++ b/examples/color_combo_box.py @@ -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_() diff --git a/src/superqt/__init__.py b/src/superqt/__init__.py index fe1a7a1a..f03ea3ff 100644 --- a/src/superqt/__init__.py +++ b/src/superqt/__init__.py @@ -7,12 +7,8 @@ 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 @@ __all__ = [ "ensure_main_thread", "ensure_object_thread", - "QDoubleRangeSlider", "QCollapsible", + "QColorComboBox", "QColormapComboBox", + "QDoubleRangeSlider", "QDoubleSlider", "QElidingLabel", "QElidingLineEdit", @@ -50,6 +47,10 @@ "QSearchableTreeWidget", ] +if TYPE_CHECKING: + from .combobox import QColormapComboBox + from .spinbox._quantity import QQuantity + def __getattr__(name: str) -> Any: if name == "QQuantity": diff --git a/src/superqt/combobox/__init__.py b/src/superqt/combobox/__init__.py index 91664718..f14b5584 100644 --- a/src/superqt/combobox/__init__.py +++ b/src/superqt/combobox/__init__.py @@ -1,13 +1,19 @@ from typing import TYPE_CHECKING, Any +from ._color_combobox import QColorComboBox from ._enum_combobox import QEnumComboBox from ._searchable_combo_box import QSearchableComboBox -if TYPE_CHECKING: - from superqt.cmap import QColormapComboBox +__all__ = ( + "QColorComboBox", + "QColormapComboBox", + "QEnumComboBox", + "QSearchableComboBox", +) -__all__ = ("QEnumComboBox", "QSearchableComboBox", "QColormapComboBox") +if TYPE_CHECKING: + from superqt.cmap import QColormapComboBox def __getattr__(name: str) -> Any: # pragma: no cover diff --git a/src/superqt/combobox/_color_combobox.py b/src/superqt/combobox/_color_combobox.py new file mode 100644 index 00000000..cdf04933 --- /dev/null +++ b/src/superqt/combobox/_color_combobox.py @@ -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) diff --git a/tests/test_color_combo.py b/tests/test_color_combo.py new file mode 100644 index 00000000..5656f55e --- /dev/null +++ b/tests/test_color_combo.py @@ -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")