Skip to content

Commit df43e03

Browse files
committed
fix(image): custom colorbar histogram with custom right click menu for better UX
1 parent 1e5746d commit df43e03

4 files changed

Lines changed: 535 additions & 1 deletion

File tree

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
"""HistogramLUTItem with a BEC-specific right-click context menu.
2+
3+
pyqtgraph's :class:`~pyqtgraph.HistogramLUTItem` renders the colorbar histogram
4+
as an ordinary plot, so right-clicking it shows the generic plot context menu
5+
(``View All``, ``X/Y Axis``, ``Mouse Mode`` …). That is confusing for a colorbar,
6+
where the only ranges that matter are the image color levels and the histogram
7+
display range. :class:`BECHistogramLUTItem` replaces that menu on the histogram
8+
view with a focused one and leaves the gradient (colormap) menu untouched.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import pyqtgraph as pg
14+
from pyqtgraph.widgets.ColorMapMenu import ColorMapMenu
15+
from qtpy.QtCore import Signal
16+
from qtpy.QtWidgets import (
17+
QDialog,
18+
QDialogButtonBox,
19+
QFormLayout,
20+
QLabel,
21+
QLayout,
22+
QMenu,
23+
QVBoxLayout,
24+
QWidget,
25+
)
26+
27+
from bec_widgets.widgets.utility.spinbox.decimal_spinbox import BECSpinBox
28+
29+
30+
class RangeDialog(QDialog):
31+
"""
32+
Modal dialog to enter a ``(min, max)`` floating point range.
33+
"""
34+
35+
def __init__(
36+
self,
37+
parent: QWidget | None = None,
38+
*,
39+
title: str,
40+
label: str,
41+
vmin: float,
42+
vmax: float,
43+
decimals: int = 4,
44+
) -> None:
45+
"""
46+
Initialize the range dialog.
47+
48+
Args:
49+
parent (QWidget | None): The parent widget the dialog is modal to.
50+
title (str): The window title of the dialog.
51+
label (str): A description shown above the inputs. If empty, no label is shown.
52+
vmin (float): The initial value of the minimum spin box.
53+
vmax (float): The initial value of the maximum spin box.
54+
decimals (int): The number of decimals shown in both spin boxes.
55+
"""
56+
super().__init__(parent=parent)
57+
self.setWindowTitle(title)
58+
self.setModal(True)
59+
60+
layout = QVBoxLayout(self)
61+
# Fixed-size, non-resizable dialog that hugs its contents.
62+
layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
63+
if label:
64+
layout.addWidget(QLabel(label))
65+
66+
form = QFormLayout()
67+
self.min_spinbox = BECSpinBox(parent=self)
68+
self.max_spinbox = BECSpinBox(parent=self)
69+
for spinbox, value in ((self.min_spinbox, vmin), (self.max_spinbox, vmax)):
70+
spinbox.setDecimals(decimals)
71+
spinbox.setValue(float(value))
72+
form.addRow("Minimum:", self.min_spinbox)
73+
form.addRow("Maximum:", self.max_spinbox)
74+
layout.addLayout(form)
75+
76+
self.button_box = QDialogButtonBox(
77+
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self
78+
)
79+
self.button_box.accepted.connect(self.accept)
80+
self.button_box.rejected.connect(self.reject)
81+
layout.addWidget(self.button_box)
82+
83+
def get_range(self) -> tuple[float, float]:
84+
"""
85+
Return the entered range, ordered so that ``min <= max``.
86+
87+
Returns:
88+
tuple[float, float]: The (minimum, maximum) values entered by the user.
89+
"""
90+
low, high = self.min_spinbox.value(), self.max_spinbox.value()
91+
return (low, high) if low <= high else (high, low)
92+
93+
@classmethod
94+
def get_new_range(
95+
cls,
96+
parent: QWidget | None,
97+
*,
98+
title: str,
99+
label: str,
100+
vmin: float,
101+
vmax: float,
102+
decimals: int = 4,
103+
) -> tuple[float, float] | None:
104+
"""
105+
Show the dialog modally and return the new range.
106+
107+
Args:
108+
parent (QWidget | None): The parent widget the dialog is modal to.
109+
title (str): The window title of the dialog.
110+
label (str): A description shown above the inputs.
111+
vmin (float): The initial value of the minimum spin box.
112+
vmax (float): The initial value of the maximum spin box.
113+
decimals (int): The number of decimals shown in both spin boxes.
114+
115+
Returns:
116+
tuple[float, float] | None: The new (min, max) range, or None if cancelled.
117+
"""
118+
dialog = cls(parent, title=title, label=label, vmin=vmin, vmax=vmax, decimals=decimals)
119+
try:
120+
if dialog.exec() == QDialog.DialogCode.Accepted:
121+
return dialog.get_range()
122+
return None
123+
finally:
124+
dialog.deleteLater()
125+
126+
127+
class BECHistogramLUTItem(pg.HistogramLUTItem):
128+
"""
129+
:class:`~pyqtgraph.HistogramLUTItem` with a BEC-specific context menu.
130+
131+
The default pyqtgraph context menu on the histogram view is replaced with one
132+
exposing the controls that matter for a colorbar:
133+
134+
* **Colormap** -- a submenu (pyqtgraph's :class:`~pyqtgraph.widgets.ColorMapMenu.ColorMapMenu`)
135+
to pick the image colormap. The choice is emitted through
136+
:attr:`sigColorMapChangeRequested` so the owning image widget applies it through
137+
its usual colormap handling (config + multi-layer sync + colorbar).
138+
* **Image scaling (levels)** -- the ``LinearRegionItem`` mapping data values to
139+
colors. Changes are emitted through :attr:`sigColorLevelsChangeRequested` and
140+
:attr:`sigAutoLevelsRequested` so the owning image widget keeps autorange and
141+
multi-layer state consistent (rather than mutating levels here directly).
142+
* **Histogram display range** -- the visible range of the histogram plot, applied
143+
directly via :meth:`~pyqtgraph.HistogramLUTItem.setHistogramRange` /
144+
:meth:`~pyqtgraph.HistogramLUTItem.autoHistogramRange`.
145+
146+
The gradient (colormap) context menu on the right edge is left untouched.
147+
"""
148+
149+
#: Emitted with the chosen colormap name when the user picks one from the menu.
150+
sigColorMapChangeRequested = Signal(str)
151+
#: Emitted with ``(vmin, vmax)`` when the user sets explicit color levels.
152+
sigColorLevelsChangeRequested = Signal(tuple)
153+
#: Emitted when the user requests autoscaling of the color levels.
154+
sigAutoLevelsRequested = Signal()
155+
156+
def __init__(self, *args, **kwargs):
157+
super().__init__(*args, **kwargs)
158+
self._bec_menu: QMenu | None = None
159+
self._colormap_menu: ColorMapMenu | None = None
160+
self._cleaned_up = False
161+
self._install_bec_context_menu()
162+
163+
################################################################################
164+
# Context menu
165+
166+
def _install_bec_context_menu(self) -> None:
167+
"""
168+
Build the BEC menu and route the histogram view right-clicks to it.
169+
"""
170+
menu = QMenu()
171+
172+
menu.addSection("Colormap")
173+
self._colormap_menu = ColorMapMenu(showColorMapSubMenus=True)
174+
self._colormap_menu.setTitle("Select colormap")
175+
self._colormap_menu.sigColorMapTriggered.connect(self._on_colormap_triggered)
176+
menu.addMenu(self._colormap_menu)
177+
178+
menu.addSection("Image scaling (levels)")
179+
set_levels = menu.addAction("Set levels…")
180+
set_levels.setToolTip("Set the data range mapped to the colormap (image scaling).")
181+
set_levels.triggered.connect(self._open_levels_dialog)
182+
auto_levels = menu.addAction("Autoscale levels")
183+
auto_levels.triggered.connect(self.sigAutoLevelsRequested)
184+
185+
menu.addSection("Histogram display range")
186+
set_hist = menu.addAction("Set histogram range…")
187+
set_hist.setToolTip("Set the visible range of the histogram plot.")
188+
set_hist.triggered.connect(self._open_histogram_range_dialog)
189+
reset_hist = menu.addAction("Reset histogram range")
190+
reset_hist.triggered.connect(self.autoHistogramRange)
191+
192+
self._bec_menu = menu
193+
194+
# Route right-clicks on the histogram ViewBox to the BEC menu only. The
195+
# original ViewBoxMenu (self.vb.menu) is left in place but never shown, so
196+
# internal pyqtgraph calls such as updateViewLists() keep working.
197+
vb = self.vb
198+
vb.getMenu = self._vb_get_menu
199+
vb.getContextMenus = self._vb_get_context_menus
200+
vb.raiseContextMenu = self._vb_raise_context_menu
201+
202+
def _vb_get_menu(self, ev):
203+
"""
204+
Return the BEC menu instead of the ViewBox's default menu.
205+
206+
Args:
207+
ev: The mouse event that requested the menu (unused).
208+
209+
Returns:
210+
QMenu | None: The BEC context menu.
211+
"""
212+
return self._bec_menu
213+
214+
def _vb_get_context_menus(self, event):
215+
"""
216+
Return the BEC menu actions for aggregation into child-item menus.
217+
218+
Args:
219+
event: The mouse event that requested the menu (unused).
220+
221+
Returns:
222+
list: The actions of the BEC context menu, or an empty list.
223+
"""
224+
return self._bec_menu.actions() if self._bec_menu is not None else []
225+
226+
def _vb_raise_context_menu(self, ev):
227+
"""
228+
Pop up the BEC menu at the event position.
229+
230+
Args:
231+
ev: The mouse event that triggered the context menu.
232+
"""
233+
if self._bec_menu is not None:
234+
self._bec_menu.popup(ev.screenPos().toPoint())
235+
236+
def _on_colormap_triggered(self, cmap) -> None:
237+
"""
238+
Forward a colormap chosen in the submenu as a name on the BEC signal.
239+
240+
Args:
241+
cmap (pyqtgraph.ColorMap): The colormap selected in the submenu.
242+
"""
243+
name = getattr(cmap, "name", None)
244+
if name:
245+
self.sigColorMapChangeRequested.emit(name)
246+
247+
################################################################################
248+
# Actions
249+
250+
def _dialog_parent(self) -> QWidget | None:
251+
"""
252+
Return a QWidget to parent modal dialogs to (the host graphics view).
253+
254+
Returns:
255+
QWidget | None: The graphics view hosting the item, or None if unavailable.
256+
"""
257+
scene = self.scene()
258+
if scene is not None:
259+
views = scene.views()
260+
if views:
261+
return views[0]
262+
return None
263+
264+
def _open_levels_dialog(self) -> None:
265+
"""
266+
Open the dialog to set the image color levels (image scaling).
267+
"""
268+
vmin, vmax = (float(x) for x in self.getLevels())
269+
new_range = RangeDialog.get_new_range(
270+
self._dialog_parent(),
271+
title="Set color levels",
272+
label="Data values mapped to the colormap (image scaling).",
273+
vmin=vmin,
274+
vmax=vmax,
275+
)
276+
if new_range is not None:
277+
self.sigColorLevelsChangeRequested.emit(new_range)
278+
279+
def _open_histogram_range_dialog(self) -> None:
280+
"""
281+
Open the dialog to set the histogram's display range.
282+
"""
283+
vmin, vmax = (float(x) for x in self.getHistogramRange())
284+
new_range = RangeDialog.get_new_range(
285+
self._dialog_parent(),
286+
title="Set histogram range",
287+
label="Visible range of the histogram plot.",
288+
vmin=vmin,
289+
vmax=vmax,
290+
)
291+
if new_range is not None:
292+
self.setHistogramRange(*new_range)
293+
294+
################################################################################
295+
# Cleanup
296+
297+
def cleanup(self) -> None:
298+
"""
299+
Tear down the BEC menu and the inherited pyqtgraph menus/dialogs.
300+
301+
``HistogramLUTItem`` owns several parentless top-level widgets (the ViewBox
302+
menu, the gradient ColorMapMenu and its QColorDialog). They must be closed
303+
explicitly, otherwise they linger as leaked top-level widgets.
304+
"""
305+
if self._cleaned_up:
306+
return
307+
self._cleaned_up = True
308+
309+
# Restore the patched ViewBox context-menu methods.
310+
vb_dict = self.vb.__dict__
311+
for name in ("getMenu", "getContextMenus", "raiseContextMenu"):
312+
vb_dict.pop(name, None)
313+
314+
# Tear down the BEC context menu and its colormap submenu.
315+
if self._colormap_menu is not None:
316+
self._colormap_menu.close()
317+
self._colormap_menu.deleteLater()
318+
self._colormap_menu = None
319+
if self._bec_menu is not None:
320+
self._bec_menu.close()
321+
self._bec_menu.deleteLater()
322+
self._bec_menu = None
323+
324+
# Tear down the inherited pyqtgraph menus and dialogs.
325+
self.vb.menu.close()
326+
self.vb.menu.deleteLater()
327+
self.gradient.menu.close()
328+
self.gradient.menu.deleteLater()
329+
self.gradient.colorDialog.close()
330+
self.gradient.colorDialog.deleteLater()

bec_widgets/widgets/plots/image/image_base.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
1515
from bec_widgets.utils.side_panel import SidePanel
1616
from bec_widgets.utils.toolbars.actions import MaterialIconAction, SwitchableToolBarAction
17+
from bec_widgets.widgets.plots.image.bec_histogram_lut_item import BECHistogramLUTItem
1718
from bec_widgets.widgets.plots.image.image_item import ImageItem
1819
from bec_widgets.widgets.plots.image.image_roi_plot import ImageROIPlot
1920
from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree
@@ -471,11 +472,15 @@ def disable_autorange():
471472
self.config.color_bar = "simple"
472473

473474
elif style == "full":
474-
self._color_bar = pg.HistogramLUTItem()
475+
self._color_bar = BECHistogramLUTItem()
475476
self._color_bar.setImageItem(self.layer_manager["main"].image)
476477
self.config.color_bar = "full"
477478
self._apply_colormap_to_colorbar(self.config.color_map)
478479
self._color_bar.sigLevelsChanged.connect(disable_autorange)
480+
# Custom colorbar context menu (replaces pyqtgraph's default plot menu).
481+
self._color_bar.sigColorMapChangeRequested.connect(self._set_colormap_from_menu)
482+
self._color_bar.sigColorLevelsChangeRequested.connect(self._set_vrange_from_menu)
483+
self._color_bar.sigAutoLevelsRequested.connect(self._autorange_from_menu)
479484

480485
self.plot_widget.addItem(self._color_bar, row=0, col=1)
481486
else:
@@ -837,6 +842,21 @@ def _set_vrange(self, value: tuple | list | QPointF, disable_autorange: bool = T
837842

838843
# self.toolbar.components.get_action("image_autorange").set_state_all(False)
839844

845+
@SafeSlot(str)
846+
def _set_colormap_from_menu(self, color_map: str):
847+
"""Apply a colormap chosen from the colorbar context menu."""
848+
self.color_map = color_map
849+
850+
@SafeSlot(object)
851+
def _set_vrange_from_menu(self, vrange: tuple[float, float]):
852+
"""Apply explicit color levels requested from the colorbar context menu."""
853+
self._set_vrange(vrange, disable_autorange=True)
854+
855+
@SafeSlot()
856+
def _autorange_from_menu(self):
857+
"""Enable autorange (current mode) from the colorbar context menu."""
858+
self.autorange = True
859+
840860
@property
841861
def v_min(self) -> float:
842862
"""
@@ -1032,6 +1052,10 @@ def cleanup_histogram_lut_item(histogram_lut_item: pg.HistogramLUTItem):
10321052
Args:
10331053
histogram_lut_item(pg.HistogramLUTItem): The HistogramLUTItem to clean up.
10341054
"""
1055+
if isinstance(histogram_lut_item, BECHistogramLUTItem):
1056+
histogram_lut_item.cleanup()
1057+
return
1058+
10351059
histogram_lut_item.vb.menu.close()
10361060
histogram_lut_item.vb.menu.deleteLater()
10371061

0 commit comments

Comments
 (0)