diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 849c204a..aecf751f 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -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. diff --git a/bec_widgets/widgets/plots/image/bec_histogram_lut_item.py b/bec_widgets/widgets/plots/image/bec_histogram_lut_item.py new file mode 100644 index 00000000..a0a6ccb6 --- /dev/null +++ b/bec_widgets/widgets/plots/image/bec_histogram_lut_item.py @@ -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() diff --git a/bec_widgets/widgets/plots/image/image_base.py b/bec_widgets/widgets/plots/image/image_base.py index f0899863..ba8345df 100644 --- a/bec_widgets/widgets/plots/image/image_base.py +++ b/bec_widgets/widgets/plots/image/image_base.py @@ -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: diff --git a/tests/unit_tests/test_bec_histogram_lut_item.py b/tests/unit_tests/test_bec_histogram_lut_item.py new file mode 100644 index 00000000..8ea686a8 --- /dev/null +++ b/tests/unit_tests/test_bec_histogram_lut_item.py @@ -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() diff --git a/tests/unit_tests/test_image_view_next_gen.py b/tests/unit_tests/test_image_view_next_gen.py index 10c02f08..feb11525 100644 --- a/tests/unit_tests/test_image_view_next_gen.py +++ b/tests/unit_tests/test_image_view_next_gen.py @@ -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)