mirror of
https://github.com/pyapp-kit/superqt.git
synced 2025-07-21 12:11:07 +02:00
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:
19
examples/colormap_combo_box.py
Normal file
19
examples/colormap_combo_box.py
Normal 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()
|
@@ -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
|
||||
|
@@ -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}")
|
||||
|
23
src/superqt/cmap/__init__.py
Normal file
23
src/superqt/cmap/__init__.py
Normal 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",
|
||||
]
|
94
src/superqt/cmap/_catalog_combo.py
Normal file
94
src/superqt/cmap/_catalog_combo.py
Normal 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)
|
218
src/superqt/cmap/_cmap_combo.py
Normal file
218
src/superqt/cmap/_cmap_combo.py
Normal 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()
|
107
src/superqt/cmap/_cmap_item_delegate.py
Normal file
107
src/superqt/cmap/_cmap_item_delegate.py
Normal 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()
|
129
src/superqt/cmap/_cmap_line_edit.py
Normal file
129
src/superqt/cmap/_cmap_line_edit.py
Normal 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)
|
162
src/superqt/cmap/_cmap_utils.py
Normal file
162
src/superqt/cmap/_cmap_utils.py
Normal 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
|
@@ -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}")
|
||||
|
@@ -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}")
|
||||
|
40
src/superqt/utils/_img_utils.py
Normal file
40
src/superqt/utils/_img_utils.py
Normal 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
162
tests/test_cmap.py
Normal 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
|
Reference in New Issue
Block a user