From 0b2602b460fbe58e6fde43999cf644b348e8b791 Mon Sep 17 00:00:00 2001 From: Peter Sobolewski <76622105+psobolewskiPhD@users.noreply.github.com> Date: Mon, 17 Mar 2025 19:16:44 -0400 Subject: [PATCH] Enh: Adds a filterable kwarg to ColormapComboBox enabling filtering (like Catalog) (#278) --- src/superqt/cmap/_cmap_combo.py | 117 +++++++++++++++++++++++++--- src/superqt/cmap/_cmap_line_edit.py | 47 +++++++++++ tests/test_cmap.py | 5 +- 3 files changed, 155 insertions(+), 14 deletions(-) diff --git a/src/superqt/cmap/_cmap_combo.py b/src/superqt/cmap/_cmap_combo.py index a18ada7..1abdf74 100644 --- a/src/superqt/cmap/_cmap_combo.py +++ b/src/superqt/cmap/_cmap_combo.py @@ -3,11 +3,12 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any from cmap import Colormap -from qtpy.QtCore import Qt, Signal +from qtpy.QtCore import QSortFilterProxyModel, QStringListModel, Qt, Signal from qtpy.QtWidgets import ( QButtonGroup, QCheckBox, QComboBox, + QCompleter, QDialog, QDialogButtonBox, QSizePolicy, @@ -26,6 +27,7 @@ if TYPE_CHECKING: from collections.abc import Sequence from cmap._colormap import ColorStopsLike + from qtpy.QtGui import QKeyEvent CMAP_ROLE = Qt.ItemDataRole.UserRole + 1 @@ -45,6 +47,9 @@ class QColormapComboBox(QComboBox): add_colormap_text: str, optional The text to display for the "Add Colormap..." item. Default is "Add Colormap...". + filterable: bool, optional + Whether the user can filter colormaps by typing in the line edit. + Default is True. Can also be set with `setFilterable`. """ currentColormapChanged = Signal(Colormap) @@ -55,18 +60,20 @@ class QColormapComboBox(QComboBox): *, allow_user_colormaps: bool = False, add_colormap_text: str = "Add Colormap...", + filterable: bool = True, ) -> 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._filterable: bool = False - self.setLineEdit(_PopupColormapLineEdit(self)) - self.lineEdit().setReadOnly(True) + line_edit = _PopupColormapLineEdit(self, allow_invalid=False) + self.setLineEdit(line_edit) + self.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) 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 @@ -75,6 +82,33 @@ class QColormapComboBox(QComboBox): self.setUserAdditionsAllowed(allow_user_colormaps) + # Create a proxy model to handle filtering + self._proxy_model = QSortFilterProxyModel(self) + # use string list model as source model + self._proxy_model.setSourceModel(QStringListModel(self)) + self._proxy_model.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + + # Setup completer + self._completer = QCompleter(self) + self._completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + self._completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion) + self._completer.setFilterMode(Qt.MatchFlag.MatchContains) + self._completer.setModel(self._proxy_model) + + # set the delegate for both the popup and the combobox + if popup := self._completer.popup(): + popup.setItemDelegate(self.itemDelegate()) + + # Update completer model when items change + if model := self.model(): + model.rowsInserted.connect(self._update_completer_model) + model.rowsRemoved.connect(self._update_completer_model) + + self.setFilterable(filterable) + + self.currentIndexChanged.connect(self._on_index_changed) + line_edit.editingFinished.connect(self._on_editing_finished) + def userAdditionsAllowed(self) -> bool: """Returns whether the user can add custom colors.""" return self._allow_user_colors @@ -96,9 +130,26 @@ class QColormapComboBox(QComboBox): elif not self._allow_user_colors: self.removeItem(idx) + def setFilterable(self, filterable: bool) -> None: + """Set whether the user can enter/filter colormaps by typing in the line edit. + + If enabled, the user can select the text in the line edit and type to + filter the list of colormaps. The completer will show a list of matching + colormaps as the user types. If disabled, the user can only select from + the combo box dropdown. + """ + self._filterable = bool(filterable) + self.setCompleter(self._completer if self._filterable else None) + self.lineEdit().setReadOnly(not self._filterable) + + def isFilterable(self) -> bool: + """Returns whether the user can filter the list of colormaps.""" + return self._filterable + def clear(self) -> None: super().clear() self.setUserAdditionsAllowed(self._allow_user_colors) + self._update_completer_model() def itemColormap(self, index: int) -> Colormap | None: """Returns the color of the item at the given index.""" @@ -124,14 +175,23 @@ class QColormapComboBox(QComboBox): # 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) + self._block_completer_update = True + try: + with signals_blocked(self): + self.removeItem(idx) + self.addItem(self._add_color_text) + finally: + self._block_completer_update = False def addColormaps(self, colors: Sequence[Any]) -> None: """Adds colors to the QComboBox.""" - for color in colors: - self.addColormap(color) + self._block_completer_update = True + try: + for color in colors: + self.addColormap(color) + finally: + self._block_completer_update = False + self._update_completer_model() def currentColormap(self) -> Colormap | None: """Returns the currently selected Colormap or None if not yet selected.""" @@ -173,6 +233,37 @@ class QColormapComboBox(QComboBox): self.lineEdit().setColormap(colormap) self._last_cmap = colormap + def _update_completer_model(self) -> None: + """Update the completer's model with current items.""" + if getattr(self, "_block_completer_update", False): + return + + # Ensure we are updating the source model of the proxy + if isinstance(src_model := self._proxy_model.sourceModel(), QStringListModel): + words = [ + txt + for i in range(self.count()) + if (txt := self.itemText(i)) != self._add_color_text + ] + src_model.setStringList(words) + self._proxy_model.invalidate() + + def _on_editing_finished(self) -> None: + text = self.lineEdit().text() + if (cmap := try_cast_colormap(text)) is not None: + self.currentColormapChanged.emit(cmap) + + # if the cmap is not in the list, add it + if self.findData(cmap, CMAP_ROLE) < 0: + self.addColormap(cmap) + + 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) + CATEGORIES = ("sequential", "diverging", "cyclic", "qualitative", "miscellaneous") @@ -220,7 +311,9 @@ class _PopupColormapLineEdit(QColormapLineEdit): Without this, only the down arrow will show the popup. And if mousePressEvent is used instead, the popup will show and then immediately hide. + Also ensure that the popup is not shown when the user selects text. """ - parent = self.parent() - if parent and hasattr(parent, "showPopup"): - parent.showPopup() + if not self.hasSelectedText(): + parent = self.parent() + if parent and hasattr(parent, "showPopup"): + parent.showPopup() diff --git a/src/superqt/cmap/_cmap_line_edit.py b/src/superqt/cmap/_cmap_line_edit.py index 8e93140..40b85b5 100644 --- a/src/superqt/cmap/_cmap_line_edit.py +++ b/src/superqt/cmap/_cmap_line_edit.py @@ -43,6 +43,13 @@ class QColormapLineEdit(QLineEdit): 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. + allow_invalid : bool, optional + If True, the user can enter any text, even if it does not represent a valid + colormap (and `fallback_cmap` will be shown if it's invalid). If False, the text + will be validated when editing is finished or focus is lost, and if the text is + not a valid colormap, it will be reverted to the first available valid option + from the completer, or, if that's not available, the last valid colormap. + Default is True. This is only settable at initialization. """ def __init__( @@ -53,6 +60,7 @@ class QColormapLineEdit(QLineEdit): fallback_cmap: Colormap | str | None = "gray", missing_icon: QIcon | QStyle.StandardPixmap = MISSING, checkerboard_size: int = 4, + allow_invalid: bool = True, ) -> None: super().__init__(parent) self.setFractionalColormapWidth(fractional_colormap_width) @@ -69,6 +77,45 @@ class QColormapLineEdit(QLineEdit): self._cmap: Colormap | None = None # current colormap self.textChanged.connect(self.setColormap) + self._lastValidColormap: Colormap | None = None + if not allow_invalid: + self.editingFinished.connect(self._validate) + + def _validate(self) -> None: + """Called when editing is finished or focus is lost. + + If the current text does not represent a valid colormap, revert to the first + available valid option from the completer, or, if that's not available, revert + to the last valid colormap. + """ + if self._cmap is None: + candidate = self._fist_completer_option() + if candidate is not None: + self.setColormap(candidate) + self.setText(candidate.name.rsplit(":", 1)[-1]) + elif self._lastValidColormap is not None: + self.setColormap(self._lastValidColormap) + self.setText(self._lastValidColormap.name.rsplit(":", 1)[-1]) + # Optionally, if neither is available, you might decide to clear the text. + else: + # Update the last valid value. + self._lastValidColormap = self._cmap + + def _fist_completer_option(self) -> Colormap | None: + """Return the first valid Colormap from the completer's current filtered list. + + or None if no valid option is available. + """ + if ( + (completer := self.completer()) is None + or (model := completer.model()) is None + or model.rowCount() == 0 + ): + return None + + first_item = model.index(0, 0).data(Qt.ItemDataRole.DisplayRole) + return try_cast_colormap(first_item) + def setFractionalColormapWidth(self, fraction: float) -> None: self._colormap_fraction: float = float(fraction) align = Qt.AlignmentFlag.AlignVCenter diff --git a/tests/test_cmap.py b/tests/test_cmap.py index 2410877..a6cb1aa 100644 --- a/tests/test_cmap.py +++ b/tests/test_cmap.py @@ -76,8 +76,9 @@ def test_catalog_combo(qtbot): assert wdg.currentColormap() == Colormap("viridis") -def test_cmap_combo(qtbot): - wdg = QColormapComboBox(allow_user_colormaps=True) +@pytest.mark.parametrize("filterable", [False, True]) +def test_cmap_combo(qtbot, filterable): + wdg = QColormapComboBox(allow_user_colormaps=True, filterable=filterable) qtbot.addWidget(wdg) wdg.show() assert wdg.userAdditionsAllowed()