Add colormap combobox and utils (#195)

* feat: add colormap combobox

* working on styles

* add comment

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

* progress on combo

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

* decent styles

* move stuff around

* adding tests

* add numpy for tests

* add cmap to tests

* fix type

* fix for pyqt

* remove topointf

* better  lineedit styles

* better add colormap

* increate linux atol

* cast to int

* more tests

* tests

* try fix

* try fix test

* again

* skip pyside

* test import

* fix lineedit

* add checkerboard for transparency

---------

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-10 19:59:11 -04:00
committed by GitHub
parent 6993c88311
commit 60f442789f
13 changed files with 994 additions and 3 deletions

View File

@@ -0,0 +1,19 @@
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from superqt.cmap import CmapCatalogComboBox, QColormapComboBox
app = QApplication([])
wdg = QWidget()
layout = QVBoxLayout(wdg)
catalog_combo = CmapCatalogComboBox(interpolation="linear")
selected_cmap_combo = QColormapComboBox(allow_user_colormaps=True)
selected_cmap_combo.addColormaps(["viridis", "plasma", "magma", "inferno", "turbo"])
layout.addWidget(catalog_combo)
layout.addWidget(selected_cmap_combo)
wdg.show()
app.exec()

View File

@@ -47,7 +47,7 @@ dependencies = [
# extras
# https://peps.python.org/pep-0621/#dependencies-optional-dependencies
[project.optional-dependencies]
test = ["pint", "pytest", "pytest-cov", "pytest-qt"]
test = ["pint", "pytest", "pytest-cov", "pytest-qt", "numpy", "cmap"]
dev = [
"black",
"ipython",
@@ -61,6 +61,7 @@ dev = [
]
docs = ["mkdocs-macros-plugin", "mkdocs-material", "mkdocstrings[python]"]
quantity = ["pint"]
cmap = ["cmap >=0.1.1"]
pyside2 = ["pyside2"]
# see issues surrounding usage of Generics in pyside6.5.x
# https://github.com/pyapp-kit/superqt/pull/177

View File

@@ -8,6 +8,7 @@ except PackageNotFoundError:
__version__ = "unknown"
if TYPE_CHECKING:
from .combobox import QColormapComboBox
from .spinbox._quantity import QQuantity
from .collapsible import QCollapsible
@@ -31,6 +32,7 @@ __all__ = [
"ensure_object_thread",
"QDoubleRangeSlider",
"QCollapsible",
"QColormapComboBox",
"QDoubleSlider",
"QElidingLabel",
"QElidingLineEdit",
@@ -54,4 +56,8 @@ def __getattr__(name: str) -> Any:
from .spinbox._quantity import QQuantity
return QQuantity
if name == "QColormapComboBox":
from .cmap import QColormapComboBox
return QColormapComboBox
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@@ -0,0 +1,23 @@
try:
import cmap
except ImportError as e:
raise ImportError(
"The cmap package is required to use superqt colormap utilities. "
"Install it with `pip install cmap` or `pip install superqt[cmap]`."
) from e
else:
del cmap
from ._catalog_combo import CmapCatalogComboBox
from ._cmap_combo import QColormapComboBox
from ._cmap_item_delegate import QColormapItemDelegate
from ._cmap_line_edit import QColormapLineEdit
from ._cmap_utils import draw_colormap
__all__ = [
"QColormapItemDelegate",
"draw_colormap",
"QColormapLineEdit",
"CmapCatalogComboBox",
"QColormapComboBox",
]

View File

@@ -0,0 +1,94 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Container
from cmap import Colormap
from qtpy.QtCore import Qt, Signal
from qtpy.QtGui import QKeyEvent
from qtpy.QtWidgets import QComboBox, QCompleter, QWidget
from ._cmap_item_delegate import QColormapItemDelegate
from ._cmap_line_edit import QColormapLineEdit
from ._cmap_utils import try_cast_colormap
if TYPE_CHECKING:
from cmap._catalog import Category, Interpolation
class CmapCatalogComboBox(QComboBox):
"""A combo box for selecting a colormap from the entire cmap catalog.
Parameters
----------
parent : QWidget, optional
The parent widget.
prefer_short_names : bool, optional
If True (default), short names (without the namespace prefix) will be
preferred over fully qualified names. In cases where the same short name is
used in multiple namespaces, they will *all* be referred to by their fully
qualified (namespaced) name.
categories : Container[Category], optional
If provided, only return names from the given categories.
interpolation : Interpolation, optional
If provided, only return names that have the given interpolation method.
"""
currentColormapChanged = Signal(Colormap)
def __init__(
self,
parent: QWidget | None = None,
*,
categories: Container[Category] = (),
prefer_short_names: bool = True,
interpolation: Interpolation | None = None,
) -> None:
super().__init__(parent)
# get valid names according to preferences
word_list = sorted(
Colormap.catalog().unique_keys(
prefer_short_names=prefer_short_names,
categories=categories,
interpolation=interpolation,
)
)
# initialize the combobox
self.addItems(word_list)
self.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
self.setEditable(True)
self.setDuplicatesEnabled(False)
# (must come before setCompleter)
self.setLineEdit(QColormapLineEdit(self))
# setup the completer
completer = QCompleter(word_list)
completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
completer.setFilterMode(Qt.MatchFlag.MatchContains)
completer.setModel(self.model())
self.setCompleter(completer)
# set the delegate for both the popup and the combobox
delegate = QColormapItemDelegate()
if popup := completer.popup():
popup.setItemDelegate(delegate)
self.setItemDelegate(delegate)
self.currentTextChanged.connect(self._on_text_changed)
def currentColormap(self) -> Colormap | None:
"""Returns the currently selected Colormap or None if not yet selected."""
return try_cast_colormap(self.currentText())
def keyPressEvent(self, e: QKeyEvent | None) -> None:
if e and e.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return):
# select the first completion when pressing enter if the popup is visible
if (completer := self.completer()) and completer.completionCount():
self.lineEdit().setText(completer.currentCompletion()) # type: ignore
return super().keyPressEvent(e)
def _on_text_changed(self, text: str) -> None:
if (cmap := try_cast_colormap(text)) is not None:
self.currentColormapChanged.emit(cmap)

View File

@@ -0,0 +1,218 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Sequence
from cmap import Colormap
from qtpy.QtCore import Qt, Signal
from qtpy.QtWidgets import (
QButtonGroup,
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from superqt.utils import signals_blocked
from ._catalog_combo import CmapCatalogComboBox
from ._cmap_item_delegate import QColormapItemDelegate
from ._cmap_line_edit import QColormapLineEdit
from ._cmap_utils import try_cast_colormap
if TYPE_CHECKING:
from cmap._colormap import ColorStopsLike
CMAP_ROLE = Qt.ItemDataRole.UserRole + 1
class QColormapComboBox(QComboBox):
"""A drop down menu for selecting colors.
Parameters
----------
parent : QWidget, optional
The parent widget.
allow_user_colormaps : bool, optional
Whether the user can add custom colormaps by clicking the "Add
Colormap..." item. Default is False. Can also be set with
`setUserAdditionsAllowed`.
add_colormap_text: str, optional
The text to display for the "Add Colormap..." item.
Default is "Add Colormap...".
"""
currentColormapChanged = Signal(Colormap)
def __init__(
self,
parent: QWidget | None = None,
*,
allow_user_colormaps: bool = False,
add_colormap_text: str = "Add Colormap...",
) -> None:
# init QComboBox
super().__init__(parent)
self._add_color_text: str = add_colormap_text
self._allow_user_colors: bool = allow_user_colormaps
self._last_cmap: Colormap | None = None
self.setLineEdit(_PopupColormapLineEdit(self))
self.lineEdit().setReadOnly(True)
self.setItemDelegate(QColormapItemDelegate(self))
self.currentIndexChanged.connect(self._on_index_changed)
# there's a little bit of a potential bug here:
# if the user clicks on the "Add Colormap..." item
# then an indexChanged signal will be emitted, but it may not
# actually represent a "true" change in the index if they dismiss the dialog
self.activated.connect(self._on_activated)
self.setUserAdditionsAllowed(allow_user_colormaps)
def userAdditionsAllowed(self) -> bool:
"""Returns whether the user can add custom colors."""
return self._allow_user_colors
def setUserAdditionsAllowed(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:
super().clear()
self.setUserAdditionsAllowed(self._allow_user_colors)
def itemColormap(self, index: int) -> Colormap | None:
"""Returns the color of the item at the given index."""
return self.itemData(index, CMAP_ROLE)
def addColormap(self, cmap: ColorStopsLike) -> None:
"""Adds the colormap to the QComboBox."""
if (_cmap := try_cast_colormap(cmap)) is None:
raise ValueError(f"Invalid colormap value: {cmap!r}")
for i in range(self.count()):
if item := self.itemColormap(i):
if item.name == _cmap.name:
return # no duplicates # pragma: no cover
had_items = self.count() > int(self._allow_user_colors)
# add the new color and set the background color of that item
self.addItem(_cmap.name.rsplit(":", 1)[-1])
self.setItemData(self.count() - 1, _cmap, CMAP_ROLE)
if not had_items: # first item added
self._on_index_changed(self.count() - 1)
# make sure the "Add Colormap..." 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 addColormaps(self, colors: Sequence[Any]) -> None:
"""Adds colors to the QComboBox."""
for color in colors:
self.addColormap(color)
def currentColormap(self) -> Colormap | None:
"""Returns the currently selected Colormap or None if not yet selected."""
return self.currentData(CMAP_ROLE)
def setCurrentColormap(self, color: Any) -> None:
"""Adds the color to the QComboBox and selects it."""
if not (cmap := try_cast_colormap(color)):
raise ValueError(f"Invalid colormap value: {color!r}")
for idx in range(self.count()):
if (item := self.itemColormap(idx)) and item.name == cmap.name:
self.setCurrentIndex(idx)
def _on_activated(self, index: int) -> None:
if self.itemText(index) != self._add_color_text:
return
dlg = _CmapNameDialog(self, Qt.WindowType.Sheet)
if dlg.exec() and (cmap := dlg.combo.currentColormap()):
# add the color and select it, without adding duplicates
for i in range(self.count()):
if (item := self.itemColormap(i)) and cmap.name == item.name:
self.setCurrentIndex(i)
return
self.addColormap(cmap)
self.currentIndexChanged.emit(self.currentIndex())
elif self._last_cmap is not None:
# user canceled, restore previous color without emitting signal
idx = self.findData(self._last_cmap, CMAP_ROLE)
if idx >= 0:
with signals_blocked(self):
self.setCurrentIndex(idx)
def _on_index_changed(self, index: int) -> None:
colormap = self.itemData(index, CMAP_ROLE)
if isinstance(colormap, Colormap):
self.currentColormapChanged.emit(colormap)
self.lineEdit().setColormap(colormap)
self._last_cmap = colormap
CATEGORIES = ("sequential", "diverging", "cyclic", "qualitative", "miscellaneous")
class _CmapNameDialog(QDialog):
def __init__(self, *args: Any) -> None:
super().__init__(*args)
self.combo = CmapCatalogComboBox()
B = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
btns = QDialogButtonBox(B)
btns.accepted.connect(self.accept)
btns.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.addWidget(self.combo)
self._btn_group = QButtonGroup(self)
self._btn_group.setExclusive(False)
for cat in CATEGORIES:
box = QCheckBox(cat)
self._btn_group.addButton(box)
box.setChecked(True)
box.toggled.connect(self._on_check_toggled)
layout.addWidget(box)
layout.addWidget(btns)
self.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Maximum)
self.resize(self.sizeHint())
def _on_check_toggled(self) -> None:
# get valid names according to preferences
word_list = Colormap.catalog().unique_keys(
prefer_short_names=True,
categories={b.text() for b in self._btn_group.buttons() if b.isChecked()},
)
self.combo.clear()
self.combo.addItems(sorted(word_list))
class _PopupColormapLineEdit(QColormapLineEdit):
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 parent and hasattr(parent, "showPopup"):
parent.showPopup()

View File

@@ -0,0 +1,107 @@
from __future__ import annotations
from typing import cast
from cmap import Colormap
from qtpy.QtCore import QModelIndex, QObject, QPersistentModelIndex, QRect, QSize, Qt
from qtpy.QtGui import QColor, QPainter
from qtpy.QtWidgets import QStyle, QStyledItemDelegate, QStyleOptionViewItem
from ._cmap_utils import CMAP_ROLE, draw_colormap, pick_font_color, try_cast_colormap
DEFAULT_SIZE = QSize(80, 22)
DEFAULT_BORDER_COLOR = QColor(Qt.GlobalColor.transparent)
class QColormapItemDelegate(QStyledItemDelegate):
"""Delegate that draws colormaps into a QAbstractItemView item.
Parameters
----------
parent : QObject, optional
The parent object.
item_size : QSize, optional
The size hint for each item, by default QSize(80, 22).
fractional_colormap_width : float, optional
The fraction of the widget width to use for the colormap swatch. If the
colormap is full width (greater than 0.75), the swatch will be drawn behind
the text. Otherwise, the swatch will be drawn to the left of the text.
Default is 0.33.
padding : int, optional
The padding (in pixels) around the edge of the item, by default 1.
checkerboard_size : int, optional
Size (in pixels) of the checkerboard pattern to draw behind colormaps with
transparency, by default 4. If 0, no checkerboard is drawn.
"""
def __init__(
self,
parent: QObject | None = None,
*,
item_size: QSize = DEFAULT_SIZE,
fractional_colormap_width: float = 1,
padding: int = 1,
checkerboard_size: int = 4,
) -> None:
super().__init__(parent)
self._item_size = item_size
self._colormap_fraction = fractional_colormap_width
self._padding = padding
self._border_color: QColor | None = DEFAULT_BORDER_COLOR
self._checkerboard_size = checkerboard_size
def sizeHint(
self, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex
) -> QSize:
return super().sizeHint(option, index).expandedTo(self._item_size)
def paint(
self,
painter: QPainter,
option: QStyleOptionViewItem,
index: QModelIndex | QPersistentModelIndex,
) -> None:
self.initStyleOption(option, index)
rect = cast("QRect", option.rect) # type: ignore
selected = option.state & QStyle.StateFlag.State_Selected # type: ignore
text = index.data(Qt.ItemDataRole.DisplayRole)
colormap: Colormap | None = index.data(CMAP_ROLE) or try_cast_colormap(text)
if not colormap: # pragma: no cover
return super().paint(painter, option, index)
painter.save()
rect.adjust(self._padding, self._padding, -self._padding, -self._padding)
cmap_rect = QRect(rect)
cmap_rect.setWidth(int(rect.width() * self._colormap_fraction))
lighter = 110 if selected else 100
border = self._border_color if selected else None
draw_colormap(
painter,
colormap,
cmap_rect,
lighter=lighter,
border_color=border,
checkerboard_size=self._checkerboard_size,
)
# # make new rect with the remaining space
text_rect = QRect(rect)
if self._colormap_fraction > 0.75:
text_align = Qt.AlignmentFlag.AlignCenter
alpha = 230 if selected else 140
text_color = pick_font_color(colormap, alpha=alpha)
else:
text_align = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
text_color = QColor(Qt.GlobalColor.black)
text_rect.adjust(
cmap_rect.width() + self._padding + 4, 0, -self._padding - 2, 0
)
painter.setPen(text_color)
# cast to int works all the way back to Qt 5.12...
# but the enum only works since Qt 5.14
painter.drawText(text_rect, int(text_align), text)
painter.restore()

View File

@@ -0,0 +1,129 @@
from __future__ import annotations
from cmap import Colormap
from qtpy.QtCore import Qt
from qtpy.QtGui import QIcon, QPainter, QPaintEvent, QPalette
from qtpy.QtWidgets import QApplication, QLineEdit, QStyle, QWidget
from ._cmap_utils import draw_colormap, pick_font_color, try_cast_colormap
MISSING = QStyle.StandardPixmap.SP_TitleBarContextHelpButton
class QColormapLineEdit(QLineEdit):
"""A QLineEdit that shows a colormap swatch.
When the current text is a valid colormap name from the `cmap` package, a swatch
of the colormap will be shown to the left of the text (if `fractionalColormapWidth`
is less than .75) or behind the text (for when the colormap fills the full width).
If the current text is not a valid colormap name, a swatch of the fallback colormap
will be shown instead (by default, a gray colormap) if `fractionalColormapWidth` is
less than .75.
Parameters
----------
parent : QWidget, optional
The parent widget.
fractional_colormap_width : float, optional
The fraction of the widget width to use for the colormap swatch. If the
colormap is full width (greater than 0.75), the swatch will be drawn behind
the text. Otherwise, the swatch will be drawn to the left of the text.
Default is 0.33.
fallback_cmap : Colormap | str | None, optional
The colormap to use when the current text is not a recognized colormap.
by default "gray".
missing_icon : QIcon | QStyle.StandardPixmap, optional
The icon to show when the current text is not a recognized colormap and
`fractionalColormapWidth` is less than .75. Default is a question mark.
checkerboard_size : int, optional
Size (in pixels) of the checkerboard pattern to draw behind colormaps with
transparency, by default 4. If 0, no checkerboard is drawn.
"""
def __init__(
self,
parent: QWidget | None = None,
*,
fractional_colormap_width: float = 0.33,
fallback_cmap: Colormap | str | None = "gray",
missing_icon: QIcon | QStyle.StandardPixmap = MISSING,
checkerboard_size: int = 4,
) -> None:
super().__init__(parent)
self.setFractionalColormapWidth(fractional_colormap_width)
self.setMissingColormap(fallback_cmap)
self._checkerboard_size = checkerboard_size
if isinstance(missing_icon, QStyle.StandardPixmap):
self._missing_icon: QIcon = self.style().standardIcon(missing_icon)
elif isinstance(missing_icon, QIcon):
self._missing_icon = missing_icon
else: # pragma: no cover
raise TypeError("missing_icon must be a QIcon or QStyle.StandardPixmap")
self._cmap: Colormap | None = None # current colormap
self.textChanged.connect(self.setColormap)
def setFractionalColormapWidth(self, fraction: float) -> None:
self._colormap_fraction: float = float(fraction)
align = Qt.AlignmentFlag.AlignVCenter
if self._cmap_is_full_width():
align |= Qt.AlignmentFlag.AlignCenter
else:
align |= Qt.AlignmentFlag.AlignLeft
self.setAlignment(align)
def fractionalColormapWidth(self) -> float:
return self._colormap_fraction
def setMissingColormap(self, cmap: Colormap | str | None) -> None:
self._missing_cmap: Colormap | None = try_cast_colormap(cmap)
def colormap(self) -> Colormap | None:
return self._cmap
def setColormap(self, cmap: Colormap | str | None) -> None:
self._cmap = try_cast_colormap(cmap)
# set self font color to contrast with the colormap
if self._cmap and self._cmap_is_full_width():
text = pick_font_color(self._cmap)
else:
text = QApplication.palette().color(QPalette.ColorRole.Text)
palette = self.palette()
palette.setColor(QPalette.ColorRole.Text, text)
self.setPalette(palette)
def _cmap_is_full_width(self):
return self._colormap_fraction >= 0.75
def paintEvent(self, e: QPaintEvent) -> None:
# don't draw the background
# otherwise it will cover the colormap during super().paintEvent
# FIXME: this appears to need to be reset during every paint event...
# otherwise something is resetting it
palette = self.palette()
palette.setColor(palette.ColorRole.Base, Qt.GlobalColor.transparent)
self.setPalette(palette)
cmap_rect = self.rect().adjusted(2, 0, 0, 0)
cmap_rect.setWidth(int(cmap_rect.width() * self._colormap_fraction))
left_margin = 6
if not self._cmap_is_full_width():
# leave room for the colormap
left_margin += cmap_rect.width()
self.setTextMargins(left_margin, 2, 0, 0)
if self._cmap:
draw_colormap(
self, self._cmap, cmap_rect, checkerboard_size=self._checkerboard_size
)
elif not self._cmap_is_full_width():
if self._missing_cmap:
draw_colormap(self, self._missing_cmap, cmap_rect)
self._missing_icon.paint(QPainter(self), cmap_rect.adjusted(4, 4, 0, -4))
super().paintEvent(e) # draw text (must come after draw_colormap)

View File

@@ -0,0 +1,162 @@
from __future__ import annotations
from contextlib import suppress
from typing import TYPE_CHECKING, Any
from cmap import Colormap
from qtpy.QtCore import QPointF, QRect, QRectF, Qt
from qtpy.QtGui import QColor, QLinearGradient, QPaintDevice, QPainter
if TYPE_CHECKING:
from cmap._colormap import ColorStopsLike
CMAP_ROLE = Qt.ItemDataRole.UserRole + 1
def draw_colormap(
painter_or_device: QPainter | QPaintDevice,
cmap: Colormap | ColorStopsLike,
rect: QRect | QRectF | None = None,
border_color: QColor | str | None = None,
border_width: int = 1,
lighter: int = 100,
checkerboard_size: int = 4,
) -> None:
"""Draw a colormap onto a QPainter or QPaintDevice.
Parameters
----------
painter_or_device : QPainter | QPaintDevice
A `QPainter` instance or a `QPaintDevice` (e.g. a QWidget or QPixmap) onto
which to paint the colormap.
cmap : Colormap | Any
`cmap.Colormap` instance, or anything that can be converted to one (such as a
string name of a colormap in the `cmap` catalog).
https://cmap-docs.readthedocs.io/en/latest/colormaps/#colormaplike-objects
rect : QRect | QRectF | None, optional
A rect onto which to draw. If `None`, the `painter.viewport()` will be
used. by default `None`
border_color : QColor | str | None
If not `None`, a border of color `border_color` and width `border_width` is
included around the edge, by default None.
border_width : int, optional
The width of the border to draw (provided `border_color` is not `None`),
by default 2
lighter : int, optional
Percentage by which to lighten (or darken) the colors. Greater than 100
lightens, less than 100 darkens, by default 100 (i.e. no change).
checkerboard_size : bool, optional
Size (in pixels) of the checkerboard pattern to draw, by default 5.
If 0, no checkerboard is drawn.
Examples
--------
```python
from qtpy.QtGui import QPixmap
from qtpy.QtWidgets import QWidget
from superqt.utils import draw_colormap
viridis = 'viridis' # or cmap.Colormap('viridis')
class W(QWidget):
def paintEvent(self, event) -> None:
draw_colormap(self, viridis, event.rect())
# or draw onto a QPixmap
pm = QPixmap(200, 200)
draw_colormap(pm, viridis)
```
"""
if isinstance(painter_or_device, QPainter):
painter = painter_or_device
elif isinstance(painter_or_device, QPaintDevice):
painter = QPainter(painter_or_device)
else:
raise TypeError(
"Expected a QPainter or QPaintDevice instance, "
f"got {type(painter_or_device)!r} instead."
)
if (cmap_ := try_cast_colormap(cmap)) is None:
raise TypeError(
f"Expected a Colormap instance or something that can be "
f"converted to one, got {cmap!r} instead."
)
if rect is None:
rect = painter.viewport()
painter.setPen(Qt.PenStyle.NoPen)
if border_width and border_color is not None:
# draw rect, and then contract it by border_width
painter.setPen(QColor(border_color))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(rect)
rect = rect.adjusted(border_width, border_width, -border_width, -border_width)
if checkerboard_size:
_draw_checkerboard(painter, rect, checkerboard_size)
if (
cmap_.interpolation == "nearest"
or getattr(cmap_.color_stops, "_interpolation", "") == "nearest"
):
# XXX: this is a little bit of a hack.
# when the interpolation is nearest, the last stop is often at 1.0
# which means that the last color is not drawn.
# to fix this, we shrink the drawing area slightly
# it might not work well with unenvenly-spaced stops
# (but those are uncommon for categorical colormaps)
width = rect.width() - rect.width() / len(cmap_.color_stops)
for stop in cmap_.color_stops:
painter.setBrush(QColor(stop.color.hex).lighter(lighter))
painter.drawRect(rect.adjusted(int(stop.position * width), 0, 0, 0))
else:
gradient = QLinearGradient(QPointF(rect.topLeft()), QPointF(rect.topRight()))
for stop in cmap_.color_stops:
gradient.setColorAt(stop.position, QColor(stop.color.hex).lighter(lighter))
painter.setBrush(gradient)
painter.drawRect(rect)
def _draw_checkerboard(
painter: QPainter, rect: QRect | QRectF, checker_size: int
) -> None:
darkgray = QColor("#969696")
lightgray = QColor("#C8C8C8")
sz = checker_size
h, w = rect.height(), rect.width()
left, top = rect.left(), rect.top()
full_rows = h // sz
full_cols = w // sz
for row in range(int(full_rows) + 1):
szh = sz if row < full_rows else int(h % sz)
for col in range(int(full_cols) + 1):
szw = sz if col < full_cols else int(w % sz)
color = lightgray if (row + col) % 2 == 0 else darkgray
painter.fillRect(int(col * sz + left), int(row * sz + top), szw, szh, color)
def try_cast_colormap(val: Any) -> Colormap | None:
"""Try to cast `val` to a Colormap instance, return None if it fails."""
if isinstance(val, Colormap):
return val
with suppress(Exception):
return Colormap(val)
return None
def pick_font_color(cmap: Colormap, at_stop: float = 0.49, alpha: int = 255) -> QColor:
"""Pick a font shade that contrasts with the given colormap at `at_stop`."""
if _is_dark(cmap, at_stop):
return QColor(0, 0, 0, alpha)
else:
return QColor(255, 255, 255, alpha)
def _is_dark(cmap: Colormap, at_stop: float, threshold: float = 110) -> bool:
"""Return True if the color at `at_stop` is dark according to `threshold`."""
color = cmap(at_stop)
r, g, b, a = color.rgba8
return (r * 0.299 + g * 0.587 + b * 0.114) > threshold

View File

@@ -1,4 +1,18 @@
from typing import TYPE_CHECKING, Any
from ._enum_combobox import QEnumComboBox
from ._searchable_combo_box import QSearchableComboBox
__all__ = ("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
return QColormapComboBox
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@@ -1,8 +1,16 @@
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from superqt.cmap import draw_colormap
__all__ = (
"CodeSyntaxHighlight",
"create_worker",
"qimage_to_array",
"draw_colormap",
"ensure_main_thread",
"ensure_object_thread",
"exceptions_as_dialog",
"FunctionWorker",
"GeneratorWorker",
"new_worker_qthread",
@@ -14,12 +22,12 @@ __all__ = (
"signals_blocked",
"thread_worker",
"WorkerBase",
"exceptions_as_dialog",
)
from ._code_syntax_highlight import CodeSyntaxHighlight
from ._ensure_thread import ensure_main_thread, ensure_object_thread
from ._errormsg_context import exceptions_as_dialog
from ._img_utils import qimage_to_array
from ._message_handler import QMessageHandler
from ._misc import signals_blocked
from ._qthreading import (
@@ -31,3 +39,11 @@ from ._qthreading import (
thread_worker,
)
from ._throttler import QSignalDebouncer, QSignalThrottler, qdebounced, qthrottled
def __getattr__(name: str) -> Any: # pragma: no cover
if name == "draw_colormap":
from superqt.cmap import draw_colormap
return draw_colormap
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@@ -0,0 +1,40 @@
from typing import TYPE_CHECKING
from qtpy.QtGui import QImage
if TYPE_CHECKING:
import numpy as np
def qimage_to_array(img: QImage) -> "np.ndarray":
"""Convert QImage to an array.
Parameters
----------
img : QImage
QImage to be converted.
Returns
-------
arr : np.ndarray
Numpy array of type uint8 and shape (h, w, 4). Index [0, 0] is the
upper-left corner of the rendered region.
"""
import numpy as np
# cast to ARGB32 if necessary
if img.format() != QImage.Format.Format_ARGB32:
img = img.convertToFormat(QImage.Format.Format_ARGB32)
h, w, c = img.height(), img.width(), 4
# pyside returns a memoryview, pyqt returns a sizeless void pointer
b = img.constBits() # Returns a pointer to the first pixel data.
if hasattr(b, "setsize"):
b.setsize(h * w * c)
# reshape to h, w, c
arr = np.frombuffer(b, np.uint8).reshape(h, w, c)
# reverse channel colors for numpy
return arr.take([2, 1, 0, 3], axis=2)

162
tests/test_cmap.py Normal file
View File

@@ -0,0 +1,162 @@
import platform
from unittest.mock import patch
import numpy as np
import pytest
from qtpy import API_NAME
try:
from cmap import Colormap
except ImportError:
pytest.skip("cmap not installed", allow_module_level=True)
from qtpy.QtCore import QRect
from qtpy.QtGui import QPainter, QPixmap
from qtpy.QtWidgets import QStyleOptionViewItem, QWidget
from superqt import QColormapComboBox
from superqt.cmap import (
CmapCatalogComboBox,
QColormapItemDelegate,
QColormapLineEdit,
_cmap_combo,
draw_colormap,
)
from superqt.utils import qimage_to_array
def test_draw_cmap(qtbot):
# draw into a QWidget
wdg = QWidget()
qtbot.addWidget(wdg)
draw_colormap(wdg, "viridis")
# draw into any QPaintDevice
draw_colormap(QPixmap(), "viridis")
# pass a painter an explicit colormap and a rect
draw_colormap(QPainter(), Colormap(("red", "yellow", "blue")), QRect())
# test with a border
draw_colormap(wdg, "viridis", border_color="red", border_width=2)
with pytest.raises(TypeError, match="Expected a QPainter or QPaintDevice instance"):
draw_colormap(QRect(), "viridis") # type: ignore
with pytest.raises(TypeError, match="Expected a Colormap instance or something"):
draw_colormap(QPainter(), "not a recognized string or cmap", QRect())
def test_cmap_draw_result():
"""Test that the image drawn actually looks correct."""
# draw into any QPaintDevice
w = 100
h = 20
pix = QPixmap(w, h)
cmap = Colormap("viridis")
draw_colormap(pix, cmap)
ary1 = cmap(np.tile(np.linspace(0, 1, w), (h, 1)), bytes=True)
ary2 = qimage_to_array(pix.toImage())
# there are some subtle differences between how qimage draws and how
# cmap draws, so we can't assert that the arrays are exactly equal.
# they are visually indistinguishable, and numbers are close within 4 (/255) values
# and linux, for some reason, is a bit more different``
atol = 8 if platform.system() == "Linux" else 4
np.testing.assert_allclose(ary1, ary2, atol=atol)
cmap2 = Colormap(("#230777",), name="MyMap")
draw_colormap(pix, cmap2) # include transparency
def test_catalog_combo(qtbot):
wdg = CmapCatalogComboBox()
qtbot.addWidget(wdg)
wdg.show()
wdg.setCurrentText("viridis")
assert wdg.currentColormap() == Colormap("viridis")
def test_cmap_combo(qtbot):
wdg = QColormapComboBox(allow_user_colormaps=True)
qtbot.addWidget(wdg)
wdg.show()
assert wdg.userAdditionsAllowed()
with qtbot.waitSignal(wdg.currentColormapChanged):
wdg.addColormaps([Colormap("viridis"), "magma", ("red", "blue", "green")])
assert wdg.currentColormap().name.split(":")[-1] == "viridis"
with pytest.raises(ValueError, match="Invalid colormap"):
wdg.addColormap("not a recognized string or cmap")
assert wdg.currentColormap().name.split(":")[-1] == "viridis"
assert wdg.currentIndex() == 0
assert wdg.count() == 4 # includes "Add Colormap..."
wdg.setCurrentColormap("magma")
assert wdg.count() == 4 # make sure we didn't duplicate
assert wdg.currentIndex() == 1
if API_NAME == "PySide2":
return # the rest fails on CI... but works locally
# click the Add Colormap... item
with qtbot.waitSignal(wdg.currentColormapChanged):
with patch.object(_cmap_combo._CmapNameDialog, "exec", return_value=True):
wdg._on_activated(wdg.count() - 1)
assert wdg.count() == 5
# this could potentially fail in the future if cmap catalog changes
# but mocking the return value of the dialog is also annoying
assert wdg.itemColormap(3).name.split(":")[-1] == "accent"
# click the Add Colormap... item, but cancel the dialog
with patch.object(_cmap_combo._CmapNameDialog, "exec", return_value=False):
wdg._on_activated(wdg.count() - 1)
def test_cmap_item_delegate(qtbot):
wdg = CmapCatalogComboBox()
qtbot.addWidget(wdg)
view = wdg.view()
delegate = view.itemDelegate()
assert isinstance(delegate, QColormapItemDelegate)
# smoke tests:
painter = QPainter()
option = QStyleOptionViewItem()
index = wdg.model().index(0, 0)
delegate._colormap_fraction = 1
delegate.paint(painter, option, index)
delegate._colormap_fraction = 0.33
delegate.paint(painter, option, index)
assert delegate.sizeHint(option, index) == delegate._item_size
def test_cmap_line_edit(qtbot, qapp):
wdg = QColormapLineEdit()
qtbot.addWidget(wdg)
wdg.show()
wdg.setColormap("viridis")
assert wdg.colormap() == Colormap("viridis")
wdg.setText("magma") # also works if the name is recognized
assert wdg.colormap() == Colormap("magma")
qapp.processEvents()
qtbot.wait(10) # force the paintEvent
wdg.setFractionalColormapWidth(1)
assert wdg.fractionalColormapWidth() == 1
wdg.update()
qapp.processEvents()
qtbot.wait(10) # force the paintEvent
wdg.setText("not-a-cmap")
assert wdg.colormap() is None
# or
wdg.setFractionalColormapWidth(0.3)
wdg.setColormap(None)
assert wdg.colormap() is None
qapp.processEvents()
qtbot.wait(10) # force the paintEvent