fix(image): custom colorbar histogram with a custom right-click menu for better UX

This commit is contained in:
2026-07-16 13:45:30 +02:00
committed by Jan Wyzula
parent f558a1cdd4
commit b77649954f
5 changed files with 906 additions and 39 deletions
+2 -2
View File
@@ -2188,7 +2188,7 @@ class Heatmap(RPCBase):
self,
enabled: "bool",
style: "Literal['full', 'simple']" = "full",
vrange: "tuple[int, int] | None" = None,
vrange: "tuple[float, float] | None" = None,
):
"""
Enable the colorbar and switch types of colorbars.
@@ -2924,7 +2924,7 @@ class Image(RPCBase):
self,
enabled: "bool",
style: "Literal['full', 'simple']" = "full",
vrange: "tuple[int, int] | None" = None,
vrange: "tuple[float, float] | None" = None,
):
"""
Enable the colorbar and switch types of colorbars.
@@ -0,0 +1,484 @@
"""Colorbar items with a BEC-specific right-click context menu.
pyqtgraph's :class:`~pyqtgraph.HistogramLUTItem` renders the colorbar histogram
as an ordinary plot, so right-clicking it shows the generic plot context menu
(``View All``, ``X/Y Axis``, ``Mouse Mode`` …). That is confusing for a colorbar,
where the only ranges that matter are the image color levels and the histogram
display range. :class:`BECHistogramLUTItem` replaces that menu on the histogram
view with a focused one and leaves the gradient (colormap) menu untouched.
:class:`BECColorBarItem` gives the simple colorbar (:class:`~pyqtgraph.ColorBarItem`)
the same focused menu (minus the histogram-specific entries), replacing pyqtgraph's
built-in colormap-only menu which would bypass the widget's colormap handling.
"""
from __future__ import annotations
from typing import Callable
import pyqtgraph as pg
from pyqtgraph.widgets.ColorMapMenu import ColorMapMenu
from qtpy.QtCore import Qt, Signal
from qtpy.QtWidgets import (
QDialog,
QDialogButtonBox,
QFormLayout,
QLabel,
QLayout,
QMenu,
QVBoxLayout,
QWidget,
)
from bec_widgets.widgets.utility.spinbox.decimal_spinbox import BECSpinBox
def _graphics_item_dialog_parent(item: pg.GraphicsObject) -> QWidget | None:
"""Return a QWidget to parent modal dialogs to (the item's host graphics view).
Args:
item (pg.GraphicsObject): The graphics item requesting a dialog.
Returns:
QWidget | None: The graphics view hosting the item, or None if unavailable.
"""
scene = item.scene()
if scene is not None:
views = scene.views()
if views:
return views[0]
return None
def _make_bec_colormap_menu(on_triggered: Callable) -> ColorMapMenu:
"""Create the "Select colormap" submenu used by the BEC colorbar menus.
Args:
on_triggered(Callable) : Slot connected to the menu's ``sigColorMapTriggered``.
Returns:
ColorMapMenu: The colormap submenu (entries are built lazily by pyqtgraph).
"""
colormap_menu = ColorMapMenu(showColorMapSubMenus=True)
colormap_menu.setTitle("Select colormap")
colormap_menu.sigColorMapTriggered.connect(on_triggered)
# The 'None' entry clears the colormap in plain pyqtgraph; BEC always keeps a
# valid named colormap, so hide it to avoid a no-op menu entry.
for action in colormap_menu.actions():
if action.text() == "None":
action.setVisible(False)
break
return colormap_menu
class RangeDialog(QDialog):
"""
Modal dialog to enter a ``(min, max)`` floating point range.
"""
def __init__(
self,
parent: QWidget | None = None,
*,
title: str,
label: str,
vmin: float,
vmax: float,
decimals: int = 4,
) -> None:
"""
Initialize the range dialog.
Args:
parent (QWidget | None): The parent widget the dialog is modal to.
title (str): The window title of the dialog.
label (str): A description shown above the inputs. If empty, no label is shown.
vmin (float): The initial value of the minimum spin box.
vmax (float): The initial value of the maximum spin box.
decimals (int): The number of decimals shown in both spin boxes.
"""
super().__init__(parent=parent)
self.setWindowTitle(title)
self.setModal(True)
layout = QVBoxLayout(self)
# Fixed-size, non-resizable dialog that hugs its contents.
layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
if label:
layout.addWidget(QLabel(label))
form = QFormLayout()
self.min_spinbox = BECSpinBox(parent=self)
self.max_spinbox = BECSpinBox(parent=self)
for spinbox, value in ((self.min_spinbox, vmin), (self.max_spinbox, vmax)):
spinbox.setDecimals(decimals)
spinbox.setValue(float(value))
form.addRow("Minimum:", self.min_spinbox)
form.addRow("Maximum:", self.max_spinbox)
layout.addLayout(form)
self.button_box = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self
)
self.button_box.accepted.connect(self.accept)
self.button_box.rejected.connect(self.reject)
layout.addWidget(self.button_box)
def get_range(self) -> tuple[float, float]:
"""
Return the entered range, ordered so that ``min <= max``.
Returns:
tuple[float, float]: The (minimum, maximum) values entered by the user.
"""
low, high = self.min_spinbox.value(), self.max_spinbox.value()
return (low, high) if low <= high else (high, low)
@classmethod
def get_new_range(
cls,
parent: QWidget | None,
*,
title: str,
label: str,
vmin: float,
vmax: float,
decimals: int = 4,
) -> tuple[float, float] | None:
"""
Show the dialog modally and return the new range.
Args:
parent (QWidget | None): The parent widget the dialog is modal to.
title (str): The window title of the dialog.
label (str): A description shown above the inputs.
vmin (float): The initial value of the minimum spin box.
vmax (float): The initial value of the maximum spin box.
decimals (int): The number of decimals shown in both spin boxes.
Returns:
tuple[float, float] | None: The new (min, max) range, or None if cancelled.
"""
dialog = cls(parent, title=title, label=label, vmin=vmin, vmax=vmax, decimals=decimals)
try:
if dialog.exec() == QDialog.DialogCode.Accepted:
return dialog.get_range()
return None
finally:
dialog.deleteLater()
class BECHistogramLUTItem(pg.HistogramLUTItem):
"""
:class:`~pyqtgraph.HistogramLUTItem` with a BEC-specific context menu.
The default pyqtgraph context menu on the histogram view is replaced with one
exposing the controls that matter for a colorbar:
* **Colormap** -- a submenu (pyqtgraph's :class:`~pyqtgraph.widgets.ColorMapMenu.ColorMapMenu`)
to pick the image colormap. The choice is emitted through
:attr:`sigColorMapChangeRequested` so the owning image widget applies it through
its usual colormap handling (config + multi-layer sync + colorbar).
* **Image scaling (levels)** -- the ``LinearRegionItem`` mapping data values to
colors. Changes are emitted through :attr:`sigColorLevelsChangeRequested` and
:attr:`sigAutoLevelsRequested` so the owning image widget keeps autorange and
multi-layer state consistent (rather than mutating levels here directly).
* **Histogram display range** -- the visible range of the histogram plot, applied
directly via :meth:`~pyqtgraph.HistogramLUTItem.setHistogramRange` /
:meth:`~pyqtgraph.HistogramLUTItem.autoHistogramRange`.
The gradient (colormap) context menu on the right edge is left untouched.
"""
#: Emitted with the chosen colormap name when the user picks one from the menu.
sigColorMapChangeRequested = Signal(str)
#: Emitted with ``(vmin, vmax)`` when the user sets explicit color levels.
sigColorLevelsChangeRequested = Signal(tuple)
#: Emitted when the user requests autoscaling of the color levels.
sigAutoLevelsRequested = Signal()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._bec_menu: QMenu | None = None
self._colormap_menu: ColorMapMenu | None = None
self._cleaned_up_triggered = False
self._install_bec_context_menu()
################################################################################
# Context menu
def _install_bec_context_menu(self) -> None:
"""
Build the BEC menu and route the histogram view right-clicks to it.
"""
menu = QMenu()
menu.addSection("Colormap")
self._colormap_menu = _make_bec_colormap_menu(self._on_colormap_triggered)
menu.addMenu(self._colormap_menu)
menu.addSection("Image scaling (levels)")
set_levels = menu.addAction("Set levels…")
set_levels.setToolTip("Set the data range mapped to the colormap (image scaling).")
set_levels.triggered.connect(self._open_levels_dialog)
auto_levels = menu.addAction("Autoscale levels")
auto_levels.triggered.connect(self.sigAutoLevelsRequested)
menu.addSection("Histogram display range")
set_hist = menu.addAction("Set histogram range…")
set_hist.setToolTip("Set the visible range of the histogram plot.")
set_hist.triggered.connect(self._open_histogram_range_dialog)
reset_hist = menu.addAction("Reset histogram range")
reset_hist.triggered.connect(self.autoHistogramRange)
self._bec_menu = menu
# Route right-clicks on the histogram ViewBox to the BEC menu only. The
# original ViewBoxMenu (self.vb.menu) is left in place but never shown, so
# internal pyqtgraph calls such as updateViewLists() keep working.
vb = self.vb
vb.getMenu = self._vb_get_menu
vb.getContextMenus = self._vb_get_context_menus
vb.raiseContextMenu = self._vb_raise_context_menu
def _vb_get_menu(self, ev):
"""
Return the BEC menu instead of the ViewBox's default menu.
Args:
ev: The mouse event that requested the menu (unused).
Returns:
QMenu | None: The BEC context menu.
"""
return self._bec_menu
def _vb_get_context_menus(self, event):
"""
Return the BEC menu actions for aggregation into child-item menus.
Args:
event: The mouse event that requested the menu (unused).
Returns:
list: The actions of the BEC context menu, or an empty list.
"""
return self._bec_menu.actions() if self._bec_menu is not None else []
def _vb_raise_context_menu(self, ev):
"""
Pop up the BEC menu at the event position.
Args:
ev: The mouse event that triggered the context menu.
"""
if self._bec_menu is not None:
self._bec_menu.popup(ev.screenPos().toPoint())
def _on_colormap_triggered(self, cmap) -> None:
"""
Forward a colormap chosen in the submenu as a name on the BEC signal.
Args:
cmap (pyqtgraph.ColorMap): The colormap selected in the submenu.
"""
name = getattr(cmap, "name", None)
if name:
self.sigColorMapChangeRequested.emit(name)
################################################################################
# Actions
def _open_levels_dialog(self) -> None:
"""
Open the dialog to set the image color levels (image scaling).
"""
vmin, vmax = (float(x) for x in self.getLevels())
new_range = RangeDialog.get_new_range(
_graphics_item_dialog_parent(self),
title="Set color levels",
label="Data values mapped to the colormap (image scaling).",
vmin=vmin,
vmax=vmax,
)
if new_range is not None:
self.sigColorLevelsChangeRequested.emit(new_range)
def _open_histogram_range_dialog(self) -> None:
"""
Open the dialog to set the histogram's display range.
"""
vmin, vmax = (float(x) for x in self.getHistogramRange())
new_range = RangeDialog.get_new_range(
_graphics_item_dialog_parent(self),
title="Set histogram range",
label="Visible range of the histogram plot.",
vmin=vmin,
vmax=vmax,
)
if new_range is not None:
self.setHistogramRange(*new_range)
################################################################################
# Cleanup
def cleanup(self) -> None:
"""
Tear down the BEC menu and the inherited pyqtgraph menus/dialogs.
``HistogramLUTItem`` owns several parentless top-level widgets (the ViewBox
menu, the gradient ColorMapMenu and its QColorDialog). They must be closed
explicitly, otherwise they linger as leaked top-level widgets.
"""
# One-shot: the teardown below cannot be re-run — the inherited menus are deleted
# without being reset to None, so a second pass would close a dead C++ object.
if self._cleaned_up_triggered:
return
self._cleaned_up_triggered = True
# Restore the patched ViewBox context-menu methods.
vb_dict = self.vb.__dict__
for name in ("getMenu", "getContextMenus", "raiseContextMenu"):
vb_dict.pop(name, None)
# Tear down the BEC context menu and its colormap submenu.
if self._colormap_menu is not None:
self._colormap_menu.close()
self._colormap_menu.deleteLater()
self._colormap_menu = None
if self._bec_menu is not None:
self._bec_menu.close()
self._bec_menu.deleteLater()
self._bec_menu = None
# Tear down the inherited pyqtgraph menus and dialogs.
self.vb.menu.close()
self.vb.menu.deleteLater()
self.gradient.menu.close()
self.gradient.menu.deleteLater()
self.gradient.colorDialog.close()
self.gradient.colorDialog.deleteLater()
class BECColorBarItem(pg.ColorBarItem):
"""
:class:`~pyqtgraph.ColorBarItem` (the "simple" colorbar) with the BEC context menu.
pyqtgraph's built-in right-click menu on ColorBarItem only offers colormap
selection and applies it directly to the item, bypassing the owning widget's
colormap handling (config + multi-layer sync). This subclass replaces it with
the same focused menu as :class:`BECHistogramLUTItem` (minus the
histogram-specific entries) and forwards every request through signals so the
owning image widget stays the single source of truth.
"""
#: Emitted with the chosen colormap name when the user picks one from the menu.
sigColorMapChangeRequested = Signal(str)
#: Emitted with ``(vmin, vmax)`` when the user sets explicit color levels.
sigColorLevelsChangeRequested = Signal(tuple)
#: Emitted when the user requests autoscaling of the color levels.
sigAutoLevelsRequested = Signal()
def __init__(self, *args, **kwargs):
# Disable pyqtgraph's own colormap menu; the BEC menu replaces it.
kwargs.setdefault("colorMapMenu", False)
super().__init__(*args, **kwargs)
self._bec_menu: QMenu | None = None
self._colormap_menu: ColorMapMenu | None = None
self._cleaned_up_triggered = False
self._install_bec_context_menu()
################################################################################
# Context menu
def _install_bec_context_menu(self) -> None:
"""
Build the BEC menu shown on right-clicks anywhere on the colorbar.
"""
menu = QMenu()
menu.addSection("Colormap")
self._colormap_menu = _make_bec_colormap_menu(self._on_colormap_triggered)
menu.addMenu(self._colormap_menu)
menu.addSection("Image scaling (levels)")
set_levels = menu.addAction("Set levels…")
set_levels.setToolTip("Set the data range mapped to the colormap (image scaling).")
set_levels.triggered.connect(self._open_levels_dialog)
auto_levels = menu.addAction("Autoscale levels")
auto_levels.triggered.connect(self.sigAutoLevelsRequested)
self._bec_menu = menu
def mouseClickEvent(self, ev):
"""
Show the BEC menu on right-click; defer to pyqtgraph otherwise.
Args:
ev: The mouse click event.
"""
if ev.button() == Qt.MouseButton.RightButton and self._bec_menu is not None:
ev.accept()
self._bec_menu.popup(ev.screenPos().toPoint())
return
super().mouseClickEvent(ev)
def _on_colormap_triggered(self, cmap) -> None:
"""
Forward a colormap chosen in the submenu as a name on the BEC signal.
Args:
cmap (pyqtgraph.ColorMap): The colormap selected in the submenu.
"""
name = getattr(cmap, "name", None)
if name:
self.sigColorMapChangeRequested.emit(name)
def _open_levels_dialog(self) -> None:
"""
Open the dialog to set the image color levels (image scaling).
"""
vmin, vmax = (float(x) for x in self.levels())
new_range = RangeDialog.get_new_range(
_graphics_item_dialog_parent(self),
title="Set color levels",
label="Data values mapped to the colormap (image scaling).",
vmin=vmin,
vmax=vmax,
)
if new_range is not None:
self.sigColorLevelsChangeRequested.emit(new_range)
################################################################################
# Cleanup
def cleanup(self) -> None:
"""
Tear down the BEC menu and the menus inherited from :class:`~pyqtgraph.PlotItem`.
``ColorBarItem`` is a PlotItem, so it owns a parentless ViewBox menu and a
plot-options (``ctrlMenu``) menu even though both are disabled; close them
explicitly so they do not linger as leaked top-level widgets.
"""
# One-shot: the teardown below cannot be re-run — the inherited menus are deleted
# without being reset to None, so a second pass would close a dead C++ object.
if self._cleaned_up_triggered:
return
self._cleaned_up_triggered = True
if self._colormap_menu is not None:
self._colormap_menu.close()
self._colormap_menu.deleteLater()
self._colormap_menu = None
if self._bec_menu is not None:
self._bec_menu.close()
self._bec_menu.deleteLater()
self._bec_menu = None
# ColorBarItem disables its ViewBox menu, which leaves vb.menu as None.
if self.vb.menu is not None:
self.vb.menu.close()
self.vb.menu.deleteLater()
if self.ctrlMenu is not None:
self.ctrlMenu.close()
self.ctrlMenu.deleteLater()
+56 -37
View File
@@ -14,6 +14,10 @@ from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.side_panel import SidePanel
from bec_widgets.utils.toolbars.actions import MaterialIconAction, SwitchableToolBarAction
from bec_widgets.widgets.plots.image.bec_histogram_lut_item import (
BECColorBarItem,
BECHistogramLUTItem,
)
from bec_widgets.widgets.plots.image.image_item import ImageItem
from bec_widgets.widgets.plots.image.image_roi_plot import ImageROIPlot
from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree
@@ -476,7 +480,7 @@ class ImageBase(PlotBase):
self,
enabled: bool,
style: Literal["full", "simple"] = "full",
vrange: tuple[int, int] | None = None,
vrange: tuple[float, float] | None = None,
):
"""
Enable the colorbar and switch types of colorbars.
@@ -486,13 +490,14 @@ class ImageBase(PlotBase):
style(Literal["full", "simple"]): The type of colorbar to enable.
vrange(tuple): The range of values to use for the colorbar.
"""
autorange_state = self.layer_manager["main"].image.autorange
if enabled and style not in ("full", "simple"):
raise ValueError(f"Invalid colorbar style '{style}'; use 'full' or 'simple'.")
main_image = self.layer_manager["main"].image
autorange_state = main_image.autorange
saved_vrange = main_image.v_range
if enabled:
if self._color_bar:
if self.config.color_bar == "full":
self.cleanup_histogram_lut_item(self._color_bar)
self.plot_widget.removeItem(self._color_bar)
self._color_bar = None
self._remove_color_bar()
def disable_autorange():
logger.info("Disabling autorange")
@@ -500,31 +505,52 @@ class ImageBase(PlotBase):
if style == "simple":
cmap = Colors.get_colormap(self.config.color_map)
self._color_bar = pg.ColorBarItem(colorMap=cmap)
self._color_bar.setImageItem(self.layer_manager["main"].image)
self._color_bar = BECColorBarItem(colorMap=cmap)
self._color_bar.setImageItem(main_image)
self._color_bar.sigLevelsChangeFinished.connect(disable_autorange)
self.config.color_bar = "simple"
elif style == "full":
self._color_bar = pg.HistogramLUTItem()
self._color_bar.setImageItem(self.layer_manager["main"].image)
self._color_bar = BECHistogramLUTItem()
self._color_bar.setImageItem(main_image)
self.config.color_bar = "full"
self._apply_colormap_to_colorbar(self.config.color_map)
self._color_bar.sigLevelsChanged.connect(disable_autorange)
# Custom colorbar context menu (replaces pyqtgraph's default menus).
self._color_bar.sigColorMapChangeRequested.connect(self._set_colormap_from_menu)
self._color_bar.sigColorLevelsChangeRequested.connect(self._set_vrange_from_menu)
self._color_bar.sigAutoLevelsRequested.connect(self._autorange_from_menu)
self.plot_widget.addItem(self._color_bar, row=0, col=1)
else:
if self._color_bar:
self.plot_widget.removeItem(self._color_bar)
self._color_bar = None
self._remove_color_bar()
self.config.color_bar = None
self.autorange = autorange_state
if enabled and not autorange_state:
# Attaching a colorbar re-levels the image (HistogramLUTItem's
# setImageItem auto-levels); restore the previous manual levels so
# switching between colorbar styles keeps the levels in sync.
self._set_vrange(saved_vrange, disable_autorange=False)
self._sync_colorbar_actions()
if vrange: # should be at the end to disable the autorange if defined
self.v_range = vrange
def _remove_color_bar(self) -> None:
"""
Remove the current colorbar from the plot and fully tear it down, including
the parentless menus/dialogs it owns.
"""
if self._color_bar is None:
return
if isinstance(self._color_bar, (BECHistogramLUTItem, BECColorBarItem)):
self._color_bar.cleanup()
self.plot_widget.removeItem(self._color_bar)
self._color_bar.deleteLater()
self._color_bar = None
def _apply_colormap_to_colorbar(self, color_map: str) -> None:
if not self._color_bar:
return
@@ -974,6 +1000,21 @@ class ImageBase(PlotBase):
# self.toolbar.components.get_action("image_autorange").set_state_all(False)
@SafeSlot(str)
def _set_colormap_from_menu(self, color_map: str):
"""Apply a colormap chosen from the colorbar context menu."""
self.color_map = color_map
@SafeSlot(object)
def _set_vrange_from_menu(self, vrange: tuple[float, float]):
"""Apply explicit color levels requested from the colorbar context menu."""
self._set_vrange(vrange, disable_autorange=True)
@SafeSlot()
def _autorange_from_menu(self):
"""Enable autorange (current mode) from the colorbar context menu."""
self.autorange = True
@property
def v_min(self) -> float:
"""
@@ -1171,22 +1212,6 @@ class ImageBase(PlotBase):
else:
colorbar_switch.set_state_all(False)
@staticmethod
def cleanup_histogram_lut_item(histogram_lut_item: pg.HistogramLUTItem):
"""
Clean up HistogramLUTItem safely, including open ViewBox menus and child widgets.
Args:
histogram_lut_item(pg.HistogramLUTItem): The HistogramLUTItem to clean up.
"""
histogram_lut_item.vb.menu.close()
histogram_lut_item.vb.menu.deleteLater()
histogram_lut_item.gradient.menu.close()
histogram_lut_item.gradient.menu.deleteLater()
histogram_lut_item.gradient.colorDialog.close()
histogram_lut_item.gradient.colorDialog.deleteLater()
def cleanup(self):
"""
Cleanup the widget.
@@ -1199,13 +1224,7 @@ class ImageBase(PlotBase):
roi.remove()
# Colorbar Cleanup
if self._color_bar:
if self.config.color_bar == "full":
self.cleanup_histogram_lut_item(self._color_bar)
if self.config.color_bar == "simple":
self.plot_widget.removeItem(self._color_bar)
self._color_bar.deleteLater()
self._color_bar = None
self._remove_color_bar()
# Popup cleanup
if self.roi_manager_dialog is not None:
@@ -0,0 +1,240 @@
import numpy as np
import pyqtgraph as pg
import pytest
from qtpy.QtCore import QPoint, QPointF, Qt
from bec_widgets.widgets.plots.image.bec_histogram_lut_item import (
BECColorBarItem,
BECHistogramLUTItem,
RangeDialog,
)
@pytest.fixture
def hist_item(qtbot):
"""A BECHistogramLUTItem living inside a real scene, cleaned up after the test."""
glw = pg.GraphicsLayoutWidget()
qtbot.addWidget(glw)
qtbot.waitExposed(glw)
item = BECHistogramLUTItem()
glw.addItem(item, row=0, col=0)
item.setImageItem(pg.ImageItem(np.arange(100, dtype=float).reshape(10, 10)))
yield item
item.cleanup()
def test_menu_actions_present(hist_item):
labels = [a.text() for a in hist_item._bec_menu.actions() if a.text()]
assert labels == [
"Colormap",
"Select colormap",
"Image scaling (levels)",
"Set levels…",
"Autoscale levels",
"Histogram display range",
"Set histogram range…",
"Reset histogram range",
]
def _menu_action(hist_item: BECHistogramLUTItem, text: str):
return next(a for a in hist_item._bec_menu.actions() if a.text() == text)
def test_colormap_submenu_forwards_name(hist_item):
"""Picking a colormap in the submenu re-emits its name on the BEC signal."""
assert hist_item._colormap_menu.actions() # the pyqtgraph colormap menu is populated
received = []
hist_item.sigColorMapChangeRequested.connect(received.append)
hist_item._colormap_menu.sigColorMapTriggered.emit(pg.colormap.get("viridis"))
assert received == ["viridis"]
def test_default_viewbox_menu_bypassed(hist_item):
"""The histogram view shows the BEC menu, not pyqtgraph's default plot menu."""
vb = hist_item.vb
assert vb.getMenu(None) is hist_item._bec_menu
assert vb.getContextMenus(None) == hist_item._bec_menu.actions()
# The original ViewBoxMenu is kept alive (not replaced) so pyqtgraph internals
# such as updateViewLists() keep working.
assert vb.menu.__class__.__name__ == "ViewBoxMenu"
vb.updateViewLists() # would raise AttributeError if vb.menu were a plain QMenu
def test_raise_context_menu_pops_bec_menu(hist_item, monkeypatch):
calls = []
monkeypatch.setattr(hist_item._bec_menu, "popup", lambda pos: calls.append(pos))
class _Pos:
def toPoint(self):
return QPoint(1, 2)
class _Event:
def screenPos(self):
return _Pos()
hist_item.vb.raiseContextMenu(_Event())
assert calls == [QPoint(1, 2)]
def test_set_levels_action_emits_request(hist_item, monkeypatch):
monkeypatch.setattr(RangeDialog, "get_new_range", staticmethod(lambda *a, **k: (3.0, 9.0)))
received = []
hist_item.sigColorLevelsChangeRequested.connect(received.append)
_menu_action(hist_item, "Set levels…").trigger()
assert received == [(3.0, 9.0)]
def test_set_levels_action_cancel_emits_nothing(hist_item, monkeypatch):
monkeypatch.setattr(RangeDialog, "get_new_range", staticmethod(lambda *a, **k: None))
received = []
hist_item.sigColorLevelsChangeRequested.connect(received.append)
_menu_action(hist_item, "Set levels…").trigger()
assert received == []
def test_autoscale_levels_action_emits_request(hist_item):
received = []
hist_item.sigAutoLevelsRequested.connect(lambda: received.append(True))
_menu_action(hist_item, "Autoscale levels").trigger()
assert received == [True]
def test_set_histogram_range_action_applies_range(hist_item, monkeypatch):
monkeypatch.setattr(RangeDialog, "get_new_range", staticmethod(lambda *a, **k: (5.0, 25.0)))
recorded = []
monkeypatch.setattr(hist_item, "setHistogramRange", lambda mn, mx: recorded.append((mn, mx)))
_menu_action(hist_item, "Set histogram range…").trigger()
assert recorded == [(5.0, 25.0)]
def test_reset_histogram_range_action_reenables_histogram_autorange(hist_item):
hist_item.setHistogramRange(5.0, 25.0)
assert hist_item.vb.autoRangeEnabled()[1] is False
_menu_action(hist_item, "Reset histogram range").trigger()
assert hist_item.vb.autoRangeEnabled()[1]
def test_cleanup_restores_viewbox_and_clears_menu(hist_item):
vb = hist_item.vb
assert {"getMenu", "getContextMenus", "raiseContextMenu"} <= set(vb.__dict__)
hist_item.cleanup()
assert not ({"getMenu", "getContextMenus", "raiseContextMenu"} & set(vb.__dict__))
assert hist_item._bec_menu is None
assert hist_item._colormap_menu is None
# idempotent: cleaning up again (as the fixture teardown will) must not raise
hist_item.cleanup()
def test_range_dialog_orders_values(qtbot):
dialog = RangeDialog(None, title="t", label="l", vmin=1.0, vmax=4.0)
qtbot.addWidget(dialog)
qtbot.waitExposed(dialog)
dialog.min_spinbox.setValue(8.0)
dialog.max_spinbox.setValue(2.0)
assert dialog.get_range() == (2.0, 8.0)
def test_range_dialog_returns_fractional_values(qtbot):
"""The dialog hands fractional values through unchanged (regression context:
they used to be truncated later by the QPoint-based tuple conversion)."""
dialog = RangeDialog(None, title="t", label="l", vmin=0.25, vmax=10.5)
qtbot.addWidget(dialog)
qtbot.waitExposed(dialog)
assert dialog.get_range() == (0.25, 10.5)
def test_colormap_menu_hides_none_entry(hist_item):
none_actions = [a for a in hist_item._colormap_menu.actions() if a.text() == "None"]
assert none_actions and not none_actions[0].isVisible()
################################################################################
# BECColorBarItem (simple colorbar)
@pytest.fixture
def color_bar_item(qtbot):
"""A BECColorBarItem attached to an image inside a real scene."""
glw = pg.GraphicsLayoutWidget()
qtbot.addWidget(glw)
qtbot.waitExposed(glw)
image_item = pg.ImageItem(np.arange(100, dtype=float).reshape(10, 10))
item = BECColorBarItem()
item.setImageItem(image_item)
glw.addItem(item, row=0, col=0)
yield item
item.cleanup()
class _FakeRightClick:
def __init__(self):
self.accepted = False
def button(self):
return Qt.MouseButton.RightButton
def accept(self):
self.accepted = True
def screenPos(self):
return QPointF(3.0, 4.0)
def test_simple_colorbar_menu_actions_present(color_bar_item):
labels = [a.text() for a in color_bar_item._bec_menu.actions() if a.text()]
assert labels == [
"Colormap",
"Select colormap",
"Image scaling (levels)",
"Set levels…",
"Autoscale levels",
]
def test_simple_colorbar_native_menu_disabled(color_bar_item):
"""pyqtgraph's built-in colormap menu is off; the BEC menu replaces it."""
assert color_bar_item.colorMapMenu is False
def test_simple_colorbar_right_click_pops_bec_menu(color_bar_item, monkeypatch):
calls = []
monkeypatch.setattr(color_bar_item._bec_menu, "popup", lambda pos: calls.append(pos))
ev = _FakeRightClick()
color_bar_item.mouseClickEvent(ev)
assert ev.accepted
assert calls == [QPoint(3, 4)]
def test_simple_colorbar_set_levels_action_emits_request(color_bar_item, monkeypatch):
monkeypatch.setattr(RangeDialog, "get_new_range", staticmethod(lambda *a, **k: (3.0, 9.0)))
received = []
color_bar_item.sigColorLevelsChangeRequested.connect(received.append)
action = next(a for a in color_bar_item._bec_menu.actions() if a.text() == "Set levels…")
action.trigger()
assert received == [(3.0, 9.0)]
def test_simple_colorbar_autoscale_action_emits_request(color_bar_item):
received = []
color_bar_item.sigAutoLevelsRequested.connect(lambda: received.append(True))
action = next(a for a in color_bar_item._bec_menu.actions() if a.text() == "Autoscale levels")
action.trigger()
assert received == [True]
def test_simple_colorbar_colormap_submenu_forwards_name(color_bar_item):
received = []
color_bar_item.sigColorMapChangeRequested.connect(received.append)
color_bar_item._colormap_menu.sigColorMapTriggered.emit(pg.colormap.get("viridis"))
assert received == ["viridis"]
def test_simple_colorbar_cleanup_idempotent(color_bar_item):
color_bar_item.cleanup()
assert color_bar_item._bec_menu is None
assert color_bar_item._colormap_menu is None
# idempotent: cleaning up again (as the fixture teardown will) must not raise
color_bar_item.cleanup()
@@ -4,6 +4,10 @@ import pytest
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import QPointF, Qt
from bec_widgets.widgets.plots.image.bec_histogram_lut_item import (
BECColorBarItem,
BECHistogramLUTItem,
)
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.image.image_processor import ImageProcessor, ProcessingConfig
from tests.unit_tests.client_mocks import mocked_client
@@ -124,6 +128,126 @@ def test_enable_full_colorbar(qtbot, mocked_client):
assert bec_image_view.main_image.autorange_mode == "mean"
def test_full_colorbar_uses_bec_histogram_lut_item(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.enable_full_colorbar = True
color_bar = bec_image_view._color_bar
assert isinstance(color_bar, BECHistogramLUTItem)
# The confusing default pyqtgraph plot menu is replaced on the histogram view.
assert color_bar.vb.getMenu(None) is color_bar._bec_menu
def test_colorbar_menu_set_levels_updates_vrange(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.enable_full_colorbar = True
assert bec_image_view.autorange is True
# Simulate the "Set levels…" context-menu action (image scaling via the region).
bec_image_view._color_bar.sigColorLevelsChangeRequested.emit((0, 50))
assert bec_image_view.v_range == QPointF(0, 50)
assert bec_image_view.main_image.levels == (0, 50)
# Setting explicit levels disables autorange.
assert bec_image_view.autorange is False
def test_colorbar_menu_autoscale_enables_autorange(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.enable_full_colorbar = True
bec_image_view.autorange = False
# Simulate the "Autoscale levels" context-menu action.
bec_image_view._color_bar.sigAutoLevelsRequested.emit()
assert bec_image_view.autorange is True
def test_colorbar_menu_colormap_updates_image(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.enable_full_colorbar = True
# Simulate picking a colormap from the colorbar context menu.
bec_image_view._color_bar.sigColorMapChangeRequested.emit("viridis")
assert bec_image_view.color_map == "viridis"
assert bec_image_view.config.color_map == "viridis"
assert bec_image_view.main_image.color_map == "viridis"
def test_simple_colorbar_uses_bec_colorbar_item(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.enable_simple_colorbar = True
assert isinstance(bec_image_view._color_bar, BECColorBarItem)
def test_simple_colorbar_menu_signals_update_image_state(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.enable_simple_colorbar = True
# "Set levels…" applies explicit levels and disables autorange.
bec_image_view._color_bar.sigColorLevelsChangeRequested.emit((1.0, 42.0))
assert bec_image_view.v_range == QPointF(1.0, 42.0)
assert bec_image_view.autorange is False
# "Autoscale levels" re-enables autorange.
bec_image_view._color_bar.sigAutoLevelsRequested.emit()
assert bec_image_view.autorange is True
# Picking a colormap goes through the BEC colormap handling (config + image).
bec_image_view._color_bar.sigColorMapChangeRequested.emit("viridis")
assert bec_image_view.config.color_map == "viridis"
assert bec_image_view.main_image.color_map == "viridis"
@pytest.mark.parametrize(
"first, second", [("full", "simple"), ("simple", "full"), ("full", "full")]
)
def test_switching_colorbar_styles_preserves_manual_levels(qtbot, mocked_client, first, second):
"""Switching between colorbar styles must not reset manually set levels."""
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.main_image.set_data(np.arange(100, dtype=float).reshape(10, 10))
bec_image_view.enable_colorbar(True, first)
bec_image_view.v_range = (5.0, 77.0)
assert bec_image_view.autorange is False
bec_image_view.enable_colorbar(True, second)
assert bec_image_view.autorange is False
assert bec_image_view.v_range == QPointF(5.0, 77.0)
assert bec_image_view.main_image.v_range == (5.0, 77.0)
def test_switching_colorbar_styles_keeps_autorange(qtbot, mocked_client):
"""With autorange on, switching colorbar styles keeps autorange enabled."""
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.main_image.set_data(np.arange(100, dtype=float).reshape(10, 10))
bec_image_view.enable_full_colorbar = True
assert bec_image_view.autorange is True
bec_image_view.enable_simple_colorbar = True
assert bec_image_view.autorange is True
@pytest.mark.parametrize("colorbar_type", ["simple", "full"])
def test_disable_colorbar_cleans_up(qtbot, mocked_client, colorbar_type):
"""Disabling the colorbar tears down its parentless menus (no leaked top-levels)."""
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.enable_colorbar(True, colorbar_type)
color_bar = bec_image_view._color_bar
bec_image_view.enable_colorbar(False)
assert bec_image_view._color_bar is None
assert bec_image_view.config.color_bar is None
assert color_bar._cleaned_up_triggered is True
def test_enable_colorbar_invalid_style_raises(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
with pytest.raises(ValueError, match="Invalid colorbar style"):
bec_image_view.enable_colorbar(True, "fancy")
@pytest.mark.parametrize("colorbar_type", ["simple", "full"])
def test_enable_colorbar_with_vrange(qtbot, mocked_client, colorbar_type):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)