From 1063f2c99131767c077338c6a984ac1a8a199530 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 26 Mar 2026 20:13:56 -0400 Subject: [PATCH 1/3] feat: add right-click "Remove" option for dropdown items in QColormapComboBox refactor: improve layout handling in QColormapLineEdit --- src/superqt/cmap/_cmap_combo.py | 40 ++++++++++++++++++++++++-- src/superqt/cmap/_cmap_line_edit.py | 44 ++++++++++++++++++----------- 2 files changed, 66 insertions(+), 18 deletions(-) diff --git a/src/superqt/cmap/_cmap_combo.py b/src/superqt/cmap/_cmap_combo.py index 1abdf74b..ce4a7866 100644 --- a/src/superqt/cmap/_cmap_combo.py +++ b/src/superqt/cmap/_cmap_combo.py @@ -3,7 +3,15 @@ from typing import TYPE_CHECKING, Any from cmap import Colormap -from qtpy.QtCore import QSortFilterProxyModel, QStringListModel, Qt, Signal +from qtpy.QtCore import ( + QEvent, + QObject, + QSortFilterProxyModel, + QStringListModel, + Qt, + Signal, +) +from qtpy.QtGui import QMouseEvent from qtpy.QtWidgets import ( QButtonGroup, QCheckBox, @@ -11,6 +19,7 @@ QCompleter, QDialog, QDialogButtonBox, + QMenu, QSizePolicy, QVBoxLayout, QWidget, @@ -109,6 +118,10 @@ def __init__( self.currentIndexChanged.connect(self._on_index_changed) line_edit.editingFinished.connect(self._on_editing_finished) + # Enable right-click "Remove" on dropdown items + if (view := self.view()) and (viewport := view.viewport()): + viewport.installEventFilter(self) + def userAdditionsAllowed(self) -> bool: """Returns whether the user can add custom colors.""" return self._allow_user_colors @@ -218,7 +231,7 @@ def _on_activated(self, index: int) -> None: self.setCurrentIndex(i) return self.addColormap(cmap) - self.currentIndexChanged.emit(self.currentIndex()) + self.setCurrentColormap(cmap) elif self._last_cmap is not None: # user canceled, restore previous color without emitting signal idx = self.findData(self._last_cmap, CMAP_ROLE) @@ -257,6 +270,29 @@ def _on_editing_finished(self) -> None: if self.findData(cmap, CMAP_ROLE) < 0: self.addColormap(cmap) + def eventFilter(self, obj: QObject | None, event: QEvent | None) -> bool: + if event and isinstance(event, QMouseEvent): + if event.button() == Qt.MouseButton.RightButton: + view = self.view() + if view and obj is view.viewport(): + index = view.indexAt(event.pos()) + if index.isValid(): + text = self.itemText(index.row()) + if text != self._add_color_text: + self._show_remove_menu(view, event.pos(), index.row()) + return True # consume the right-click + return super().eventFilter(obj, event) + + def _show_remove_menu(self, view: Any, pos: Any, row: int) -> None: + menu = QMenu(view) + remove_action = menu.addAction("Remove") + if menu.exec(view.viewport().mapToGlobal(pos)) == remove_action: + was_current = row == self.currentIndex() + self.removeItem(row) + self._update_completer_model() + if was_current and self.count() > 0: + self.setCurrentIndex(0) + 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 diff --git a/src/superqt/cmap/_cmap_line_edit.py b/src/superqt/cmap/_cmap_line_edit.py index 40b85b57..c53e3f1d 100644 --- a/src/superqt/cmap/_cmap_line_edit.py +++ b/src/superqt/cmap/_cmap_line_edit.py @@ -4,7 +4,13 @@ from qtpy.QtCore import QRect, Qt from qtpy.QtGui import QIcon, QPainter, QPaintEvent, QPalette -from qtpy.QtWidgets import QApplication, QLineEdit, QStyle, QWidget +from qtpy.QtWidgets import ( + QApplication, + QLineEdit, + QStyle, + QStyleOptionFrame, + QWidget, +) from ._cmap_utils import draw_colormap, pick_font_color, try_cast_colormap @@ -56,7 +62,7 @@ def __init__( self, parent: QWidget | None = None, *, - fractional_colormap_width: float = 0.33, + fractional_colormap_width: float = 0.4, fallback_cmap: Colormap | str | None = "gray", missing_icon: QIcon | QStyle.StandardPixmap = MISSING, checkerboard_size: int = 4, @@ -150,27 +156,35 @@ def setColormap(self, cmap: Colormap | str | None) -> None: def _cmap_is_full_width(self): return self._colormap_fraction >= 0.75 + def _content_rect(self) -> QRect: + """Return the style-aware content rect for this line edit.""" + opt = QStyleOptionFrame() + self.initStyleOption(opt) + return self.style().subElementRect( + QStyle.SubElement.SE_LineEditContents, opt, self + ) + def _cmap_rect(self) -> QRect: - cmap_rect = self.rect().adjusted(2, 0, 0, 0) - cmap_rect.setWidth(int(cmap_rect.width() * self._colormap_fraction)) - return cmap_rect + cr = self._content_rect() + # Apply the horizontal content inset as vertical padding too, + # so the swatch sits inside the style's painted background. + v_pad = cr.x() - self.rect().x() + return QRect( + cr.x(), + v_pad, + int(cr.width() * self._colormap_fraction), + self.rect().height() - 2 * v_pad, + ) def resizeEvent(self, e: Any) -> None: left_margin = 6 if not self._cmap_is_full_width(): - # leave room for the colormap - left_margin += self._cmap_rect().width() + left_margin = self._cmap_rect().right() + 4 - self.rect().x() self.setTextMargins(left_margin, 2, 0, 0) super().resizeEvent(e) 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) + super().paintEvent(e) # let the style paint background + text first cmap_rect = self._cmap_rect() if self._cmap: @@ -181,5 +195,3 @@ def paintEvent(self, e: QPaintEvent) -> None: 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) From ac2f48159bafd2398de846280f5f1c416b854ff4 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 27 Mar 2026 09:24:11 -0400 Subject: [PATCH 2/3] fix: adjust colormap width and ensure swatch positioning in QColormapLineEdit --- src/superqt/cmap/_cmap_combo.py | 19 +++++++++++++------ src/superqt/cmap/_cmap_line_edit.py | 4 ++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/superqt/cmap/_cmap_combo.py b/src/superqt/cmap/_cmap_combo.py index ce4a7866..e7bce029 100644 --- a/src/superqt/cmap/_cmap_combo.py +++ b/src/superqt/cmap/_cmap_combo.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from cmap import Colormap from qtpy.QtCore import ( @@ -11,7 +11,6 @@ Qt, Signal, ) -from qtpy.QtGui import QMouseEvent from qtpy.QtWidgets import ( QButtonGroup, QCheckBox, @@ -36,7 +35,7 @@ from collections.abc import Sequence from cmap._colormap import ColorStopsLike - from qtpy.QtGui import QKeyEvent + from qtpy.QtGui import QKeyEvent, QMouseEvent CMAP_ROLE = Qt.ItemDataRole.UserRole + 1 @@ -217,7 +216,16 @@ def setCurrentColormap(self, color: Any) -> None: for idx in range(self.count()): if (item := self.itemColormap(idx)) and item.name == cmap.name: + # cmap_ is already here - just select it self.setCurrentIndex(idx) + return + + # cmap_ not in the combo box - add it! + self.addColormap(cmap) + # then, select it + idx = self.count() - (2 if self._allow_user_colors else 1) + self.setCurrentIndex(idx) + self._on_index_changed(idx) def _on_activated(self, index: int) -> None: if self.itemText(index) != self._add_color_text: @@ -230,7 +238,6 @@ def _on_activated(self, index: int) -> None: if (item := self.itemColormap(i)) and cmap.name == item.name: self.setCurrentIndex(i) return - self.addColormap(cmap) self.setCurrentColormap(cmap) elif self._last_cmap is not None: # user canceled, restore previous color without emitting signal @@ -271,8 +278,8 @@ def _on_editing_finished(self) -> None: self.addColormap(cmap) def eventFilter(self, obj: QObject | None, event: QEvent | None) -> bool: - if event and isinstance(event, QMouseEvent): - if event.button() == Qt.MouseButton.RightButton: + if event and event.type() == QEvent.Type.MouseButtonRelease: + if cast("QMouseEvent", event).button() == Qt.MouseButton.RightButton: view = self.view() if view and obj is view.viewport(): index = view.indexAt(event.pos()) diff --git a/src/superqt/cmap/_cmap_line_edit.py b/src/superqt/cmap/_cmap_line_edit.py index c53e3f1d..4363688d 100644 --- a/src/superqt/cmap/_cmap_line_edit.py +++ b/src/superqt/cmap/_cmap_line_edit.py @@ -62,7 +62,7 @@ def __init__( self, parent: QWidget | None = None, *, - fractional_colormap_width: float = 0.4, + fractional_colormap_width: float = 0.35, fallback_cmap: Colormap | str | None = "gray", missing_icon: QIcon | QStyle.StandardPixmap = MISSING, checkerboard_size: int = 4, @@ -170,7 +170,7 @@ def _cmap_rect(self) -> QRect: # so the swatch sits inside the style's painted background. v_pad = cr.x() - self.rect().x() return QRect( - cr.x(), + max(cr.x(), 2), v_pad, int(cr.width() * self._colormap_fraction), self.rect().height() - 2 * v_pad, From 02986b170a1ebba66186d88c7e4221cfbe1ed63b Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 27 Mar 2026 09:48:24 -0400 Subject: [PATCH 3/3] fix: improve colormap addition and selection logic in QColormapComboBox --- src/superqt/cmap/_cmap_combo.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/superqt/cmap/_cmap_combo.py b/src/superqt/cmap/_cmap_combo.py index e7bce029..dd2b4d0d 100644 --- a/src/superqt/cmap/_cmap_combo.py +++ b/src/superqt/cmap/_cmap_combo.py @@ -220,12 +220,16 @@ def setCurrentColormap(self, color: Any) -> None: self.setCurrentIndex(idx) return - # cmap_ not in the combo box - add it! + # cmap not in the combo box yet — add it, then select it self.addColormap(cmap) - # then, select it - idx = self.count() - (2 if self._allow_user_colors else 1) - self.setCurrentIndex(idx) - self._on_index_changed(idx) + idx = self.findData(cmap, CMAP_ROLE) + if idx >= 0: + old_idx = self.currentIndex() + self.setCurrentIndex(idx) + if idx == old_idx: + # setCurrentIndex won't emit if the index didn't actually change + # (e.g. first item added at index 0 when current index is already 0) + self._on_index_changed(idx) def _on_activated(self, index: int) -> None: if self.itemText(index) != self._add_color_text: @@ -279,14 +283,15 @@ def _on_editing_finished(self) -> None: def eventFilter(self, obj: QObject | None, event: QEvent | None) -> bool: if event and event.type() == QEvent.Type.MouseButtonRelease: - if cast("QMouseEvent", event).button() == Qt.MouseButton.RightButton: + mouse_event = cast("QMouseEvent", event) + if mouse_event.button() == Qt.MouseButton.RightButton: view = self.view() if view and obj is view.viewport(): - index = view.indexAt(event.pos()) + index = view.indexAt(mouse_event.pos()) if index.isValid(): text = self.itemText(index.row()) if text != self._add_color_text: - self._show_remove_menu(view, event.pos(), index.row()) + self._show_remove_menu(view, mouse_event.pos(), index.row()) return True # consume the right-click return super().eventFilter(obj, event) @@ -297,8 +302,12 @@ def _show_remove_menu(self, view: Any, pos: Any, row: int) -> None: was_current = row == self.currentIndex() self.removeItem(row) self._update_completer_model() - if was_current and self.count() > 0: - self.setCurrentIndex(0) + if was_current: + # select the first actual colormap, skipping "Add Colormap..." + for i in range(self.count()): + if self.itemColormap(i) is not None: + self.setCurrentIndex(i) + return def keyPressEvent(self, e: QKeyEvent | None) -> None: if e and e.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return):