Skip to content

Commit

Permalink
Add colormap combobox and utils (#195)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
tlambert03 and pre-commit-ci[bot] authored Sep 10, 2023
1 parent 6993c88 commit 60f4427
Show file tree
Hide file tree
Showing 13 changed files with 994 additions and 3 deletions.
19 changes: 19 additions & 0 deletions examples/colormap_combo_box.py
Original file line number Diff line number Diff line change
@@ -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()
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/superqt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
__version__ = "unknown"

if TYPE_CHECKING:
from .combobox import QColormapComboBox
from .spinbox._quantity import QQuantity

from .collapsible import QCollapsible
Expand All @@ -31,6 +32,7 @@
"ensure_object_thread",
"QDoubleRangeSlider",
"QCollapsible",
"QColormapComboBox",
"QDoubleSlider",
"QElidingLabel",
"QElidingLineEdit",
Expand All @@ -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}")
23 changes: 23 additions & 0 deletions src/superqt/cmap/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
94 changes: 94 additions & 0 deletions src/superqt/cmap/_catalog_combo.py
Original file line number Diff line number Diff line change
@@ -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)
218 changes: 218 additions & 0 deletions src/superqt/cmap/_cmap_combo.py
Original file line number Diff line number Diff line change
@@ -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()
Loading

0 comments on commit 60f4427

Please sign in to comment.