Skip to content

Commit

Permalink
feat: add QColorComboBox for picking single colors (#194)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
tlambert03 and pre-commit-ci[bot] authored Sep 11, 2023
1 parent 60f4427 commit 658995a
Show file tree
Hide file tree
Showing 5 changed files with 412 additions and 9 deletions.
23 changes: 23 additions & 0 deletions examples/color_combo_box.py
Original file line number Diff line number Diff line change
@@ -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_()
13 changes: 7 additions & 6 deletions src/superqt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -30,9 +26,10 @@
__all__ = [
"ensure_main_thread",
"ensure_object_thread",
"QDoubleRangeSlider",
"QCollapsible",
"QColorComboBox",
"QColormapComboBox",
"QDoubleRangeSlider",
"QDoubleSlider",
"QElidingLabel",
"QElidingLineEdit",
Expand All @@ -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":
Expand Down
12 changes: 9 additions & 3 deletions src/superqt/combobox/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
287 changes: 287 additions & 0 deletions src/superqt/combobox/_color_combobox.py
Original file line number Diff line number Diff line change
@@ -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)
Loading

0 comments on commit 658995a

Please sign in to comment.