From 60f442789fabe0284a7ef2a4e7933e55a38be277 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 10 Sep 2023 19:59:11 -0400 Subject: [PATCH] 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> --- examples/colormap_combo_box.py | 19 +++ pyproject.toml | 3 +- src/superqt/__init__.py | 6 + src/superqt/cmap/__init__.py | 23 +++ src/superqt/cmap/_catalog_combo.py | 94 ++++++++++ src/superqt/cmap/_cmap_combo.py | 218 ++++++++++++++++++++++++ src/superqt/cmap/_cmap_item_delegate.py | 107 ++++++++++++ src/superqt/cmap/_cmap_line_edit.py | 129 ++++++++++++++ src/superqt/cmap/_cmap_utils.py | 162 ++++++++++++++++++ src/superqt/combobox/__init__.py | 16 +- src/superqt/utils/__init__.py | 18 +- src/superqt/utils/_img_utils.py | 40 +++++ tests/test_cmap.py | 162 ++++++++++++++++++ 13 files changed, 994 insertions(+), 3 deletions(-) create mode 100644 examples/colormap_combo_box.py create mode 100644 src/superqt/cmap/__init__.py create mode 100644 src/superqt/cmap/_catalog_combo.py create mode 100644 src/superqt/cmap/_cmap_combo.py create mode 100644 src/superqt/cmap/_cmap_item_delegate.py create mode 100644 src/superqt/cmap/_cmap_line_edit.py create mode 100644 src/superqt/cmap/_cmap_utils.py create mode 100644 src/superqt/utils/_img_utils.py create mode 100644 tests/test_cmap.py diff --git a/examples/colormap_combo_box.py b/examples/colormap_combo_box.py new file mode 100644 index 0000000..6b5a9b6 --- /dev/null +++ b/examples/colormap_combo_box.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index d1f3199..a406fa9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/src/superqt/__init__.py b/src/superqt/__init__.py index be9e088..fe1a7a1 100644 --- a/src/superqt/__init__.py +++ b/src/superqt/__init__.py @@ -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}") diff --git a/src/superqt/cmap/__init__.py b/src/superqt/cmap/__init__.py new file mode 100644 index 0000000..458110d --- /dev/null +++ b/src/superqt/cmap/__init__.py @@ -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", +] diff --git a/src/superqt/cmap/_catalog_combo.py b/src/superqt/cmap/_catalog_combo.py new file mode 100644 index 0000000..a1af452 --- /dev/null +++ b/src/superqt/cmap/_catalog_combo.py @@ -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) diff --git a/src/superqt/cmap/_cmap_combo.py b/src/superqt/cmap/_cmap_combo.py new file mode 100644 index 0000000..65326e4 --- /dev/null +++ b/src/superqt/cmap/_cmap_combo.py @@ -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() diff --git a/src/superqt/cmap/_cmap_item_delegate.py b/src/superqt/cmap/_cmap_item_delegate.py new file mode 100644 index 0000000..05785e9 --- /dev/null +++ b/src/superqt/cmap/_cmap_item_delegate.py @@ -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() diff --git a/src/superqt/cmap/_cmap_line_edit.py b/src/superqt/cmap/_cmap_line_edit.py new file mode 100644 index 0000000..a2d6665 --- /dev/null +++ b/src/superqt/cmap/_cmap_line_edit.py @@ -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) diff --git a/src/superqt/cmap/_cmap_utils.py b/src/superqt/cmap/_cmap_utils.py new file mode 100644 index 0000000..6dae1a9 --- /dev/null +++ b/src/superqt/cmap/_cmap_utils.py @@ -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 diff --git a/src/superqt/combobox/__init__.py b/src/superqt/combobox/__init__.py index 656baed..9166471 100644 --- a/src/superqt/combobox/__init__.py +++ b/src/superqt/combobox/__init__.py @@ -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}") diff --git a/src/superqt/utils/__init__.py b/src/superqt/utils/__init__.py index 3a9b8bd..55d485e 100644 --- a/src/superqt/utils/__init__.py +++ b/src/superqt/utils/__init__.py @@ -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}") diff --git a/src/superqt/utils/_img_utils.py b/src/superqt/utils/_img_utils.py new file mode 100644 index 0000000..d5a920d --- /dev/null +++ b/src/superqt/utils/_img_utils.py @@ -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) diff --git a/tests/test_cmap.py b/tests/test_cmap.py new file mode 100644 index 0000000..2410877 --- /dev/null +++ b/tests/test_cmap.py @@ -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