Skip to content

Commit 3285493

Browse files
authored
feat: improve cmap combobox generalization and add right-click-to remove (#333)
* feat: add right-click "Remove" option for dropdown items in QColormapComboBox refactor: improve layout handling in QColormapLineEdit * fix: adjust colormap width and ensure swatch positioning in QColormapLineEdit * fix: improve colormap addition and selection logic in QColormapComboBox
1 parent 60cc8cf commit 3285493

2 files changed

Lines changed: 85 additions & 21 deletions

File tree

src/superqt/cmap/_cmap_combo.py

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING, Any
3+
from typing import TYPE_CHECKING, Any, cast
44

55
from cmap import Colormap
6-
from qtpy.QtCore import QSortFilterProxyModel, QStringListModel, Qt, Signal
6+
from qtpy.QtCore import (
7+
QEvent,
8+
QObject,
9+
QSortFilterProxyModel,
10+
QStringListModel,
11+
Qt,
12+
Signal,
13+
)
714
from qtpy.QtWidgets import (
815
QButtonGroup,
916
QCheckBox,
1017
QComboBox,
1118
QCompleter,
1219
QDialog,
1320
QDialogButtonBox,
21+
QMenu,
1422
QSizePolicy,
1523
QVBoxLayout,
1624
QWidget,
@@ -27,7 +35,7 @@
2735
from collections.abc import Sequence
2836

2937
from cmap._colormap import ColorStopsLike
30-
from qtpy.QtGui import QKeyEvent
38+
from qtpy.QtGui import QKeyEvent, QMouseEvent
3139

3240

3341
CMAP_ROLE = Qt.ItemDataRole.UserRole + 1
@@ -109,6 +117,10 @@ def __init__(
109117
self.currentIndexChanged.connect(self._on_index_changed)
110118
line_edit.editingFinished.connect(self._on_editing_finished)
111119

120+
# Enable right-click "Remove" on dropdown items
121+
if (view := self.view()) and (viewport := view.viewport()):
122+
viewport.installEventFilter(self)
123+
112124
def userAdditionsAllowed(self) -> bool:
113125
"""Returns whether the user can add custom colors."""
114126
return self._allow_user_colors
@@ -204,7 +216,20 @@ def setCurrentColormap(self, color: Any) -> None:
204216

205217
for idx in range(self.count()):
206218
if (item := self.itemColormap(idx)) and item.name == cmap.name:
219+
# cmap_ is already here - just select it
207220
self.setCurrentIndex(idx)
221+
return
222+
223+
# cmap not in the combo box yet — add it, then select it
224+
self.addColormap(cmap)
225+
idx = self.findData(cmap, CMAP_ROLE)
226+
if idx >= 0:
227+
old_idx = self.currentIndex()
228+
self.setCurrentIndex(idx)
229+
if idx == old_idx:
230+
# setCurrentIndex won't emit if the index didn't actually change
231+
# (e.g. first item added at index 0 when current index is already 0)
232+
self._on_index_changed(idx)
208233

209234
def _on_activated(self, index: int) -> None:
210235
if self.itemText(index) != self._add_color_text:
@@ -217,8 +242,7 @@ def _on_activated(self, index: int) -> None:
217242
if (item := self.itemColormap(i)) and cmap.name == item.name:
218243
self.setCurrentIndex(i)
219244
return
220-
self.addColormap(cmap)
221-
self.currentIndexChanged.emit(self.currentIndex())
245+
self.setCurrentColormap(cmap)
222246
elif self._last_cmap is not None:
223247
# user canceled, restore previous color without emitting signal
224248
idx = self.findData(self._last_cmap, CMAP_ROLE)
@@ -257,6 +281,34 @@ def _on_editing_finished(self) -> None:
257281
if self.findData(cmap, CMAP_ROLE) < 0:
258282
self.addColormap(cmap)
259283

284+
def eventFilter(self, obj: QObject | None, event: QEvent | None) -> bool:
285+
if event and event.type() == QEvent.Type.MouseButtonRelease:
286+
mouse_event = cast("QMouseEvent", event)
287+
if mouse_event.button() == Qt.MouseButton.RightButton:
288+
view = self.view()
289+
if view and obj is view.viewport():
290+
index = view.indexAt(mouse_event.pos())
291+
if index.isValid():
292+
text = self.itemText(index.row())
293+
if text != self._add_color_text:
294+
self._show_remove_menu(view, mouse_event.pos(), index.row())
295+
return True # consume the right-click
296+
return super().eventFilter(obj, event)
297+
298+
def _show_remove_menu(self, view: Any, pos: Any, row: int) -> None:
299+
menu = QMenu(view)
300+
remove_action = menu.addAction("Remove")
301+
if menu.exec(view.viewport().mapToGlobal(pos)) == remove_action:
302+
was_current = row == self.currentIndex()
303+
self.removeItem(row)
304+
self._update_completer_model()
305+
if was_current:
306+
# select the first actual colormap, skipping "Add Colormap..."
307+
for i in range(self.count()):
308+
if self.itemColormap(i) is not None:
309+
self.setCurrentIndex(i)
310+
return
311+
260312
def keyPressEvent(self, e: QKeyEvent | None) -> None:
261313
if e and e.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return):
262314
# select the first completion when pressing enter if the popup is visible

src/superqt/cmap/_cmap_line_edit.py

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@
44

55
from qtpy.QtCore import QRect, Qt
66
from qtpy.QtGui import QIcon, QPainter, QPaintEvent, QPalette
7-
from qtpy.QtWidgets import QApplication, QLineEdit, QStyle, QWidget
7+
from qtpy.QtWidgets import (
8+
QApplication,
9+
QLineEdit,
10+
QStyle,
11+
QStyleOptionFrame,
12+
QWidget,
13+
)
814

915
from ._cmap_utils import draw_colormap, pick_font_color, try_cast_colormap
1016

@@ -56,7 +62,7 @@ def __init__(
5662
self,
5763
parent: QWidget | None = None,
5864
*,
59-
fractional_colormap_width: float = 0.33,
65+
fractional_colormap_width: float = 0.35,
6066
fallback_cmap: Colormap | str | None = "gray",
6167
missing_icon: QIcon | QStyle.StandardPixmap = MISSING,
6268
checkerboard_size: int = 4,
@@ -150,27 +156,35 @@ def setColormap(self, cmap: Colormap | str | None) -> None:
150156
def _cmap_is_full_width(self):
151157
return self._colormap_fraction >= 0.75
152158

159+
def _content_rect(self) -> QRect:
160+
"""Return the style-aware content rect for this line edit."""
161+
opt = QStyleOptionFrame()
162+
self.initStyleOption(opt)
163+
return self.style().subElementRect(
164+
QStyle.SubElement.SE_LineEditContents, opt, self
165+
)
166+
153167
def _cmap_rect(self) -> QRect:
154-
cmap_rect = self.rect().adjusted(2, 0, 0, 0)
155-
cmap_rect.setWidth(int(cmap_rect.width() * self._colormap_fraction))
156-
return cmap_rect
168+
cr = self._content_rect()
169+
# Apply the horizontal content inset as vertical padding too,
170+
# so the swatch sits inside the style's painted background.
171+
v_pad = cr.x() - self.rect().x()
172+
return QRect(
173+
max(cr.x(), 2),
174+
v_pad,
175+
int(cr.width() * self._colormap_fraction),
176+
self.rect().height() - 2 * v_pad,
177+
)
157178

158179
def resizeEvent(self, e: Any) -> None:
159180
left_margin = 6
160181
if not self._cmap_is_full_width():
161-
# leave room for the colormap
162-
left_margin += self._cmap_rect().width()
182+
left_margin = self._cmap_rect().right() + 4 - self.rect().x()
163183
self.setTextMargins(left_margin, 2, 0, 0)
164184
super().resizeEvent(e)
165185

166186
def paintEvent(self, e: QPaintEvent) -> None:
167-
# don't draw the background
168-
# otherwise it will cover the colormap during super().paintEvent
169-
# FIXME: this appears to need to be reset during every paint event...
170-
# otherwise something is resetting it
171-
palette = self.palette()
172-
palette.setColor(palette.ColorRole.Base, Qt.GlobalColor.transparent)
173-
self.setPalette(palette)
187+
super().paintEvent(e) # let the style paint background + text first
174188

175189
cmap_rect = self._cmap_rect()
176190
if self._cmap:
@@ -181,5 +195,3 @@ def paintEvent(self, e: QPaintEvent) -> None:
181195
if self._missing_cmap:
182196
draw_colormap(self, self._missing_cmap, cmap_rect)
183197
self._missing_icon.paint(QPainter(self), cmap_rect.adjusted(4, 4, 0, -4))
184-
185-
super().paintEvent(e) # draw text (must come after draw_colormap)

0 commit comments

Comments
 (0)