# /*##########################################################################
#
# Copyright (c) 2004-2023 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# ###########################################################################*/
"""A QDialog widget to set-up the colormap.

It uses a description of colormaps as dict compatible with :class:`Plot`.

To run the following sample code, a QApplication must be initialized.

Create the colormap dialog and set the colormap description and data range:

>>> from silx.gui.dialog.ColormapDialog import ColormapDialog
>>> from silx.gui.colors import Colormap

>>> dialog = ColormapDialog()
>>> colormap = Colormap(name='red', normalization='log',
...                     vmin=1., vmax=2.)

>>> dialog.setColormap(colormap)
>>> colormap.setVRange(1., 100.)  # This scale the width of the plot area
>>> dialog.show()

Get the colormap description (compatible with :class:`Plot`) from the dialog:

>>> cmap = dialog.getColormap()
>>> cmap.getName()
'red'

It is also possible to display an histogram of the image in the dialog.
This updates the data range with the range of the bins.

>>> import numpy
>>> image = numpy.random.normal(size=512 * 512).reshape(512, -1)
>>> hist, bin_edges = numpy.histogram(image, bins=10)
>>> dialog.setHistogram(hist, bin_edges)

The updates of the colormap description are also available through the signal:
:attr:`ColormapDialog.sigColormapChanged`.
"""  # noqa

from __future__ import annotations

__authors__ = ["V.A. Sole", "T. Vincent", "H. Payno"]
__license__ = "MIT"
__date__ = "08/12/2020"

import enum
import logging

import numpy

from .. import qt
from .. import utils
from ..colors import Colormap, cursorColorForColormap
from ..plot import PlotWidget
from ..plot.items.axis import Axis
from ..plot.items import BoundingRect
from silx.gui.widgets.FloatEdit import FloatEdit
import weakref
from silx.math.combo import min_max
from silx.gui.plot import items
from silx.gui import icons
from silx.gui.qt import inspect as qtinspect
from silx.gui.widgets.ColormapNameComboBox import ColormapNameComboBox
from silx.gui.widgets.FormGridLayout import FormGridLayout
from silx.math.histogram import Histogramnd
from silx.gui.plot.items.roi import RectangleROI
from silx.gui.plot.tools.roi import RegionOfInterestManager
from silx.utils.deprecation import deprecated
from silx.utils.enum import Enum as _Enum

_logger = logging.getLogger(__name__)

_colormapIconPreview = {}


class _DataRefHolder(items.Item, items.ColormapMixIn):
    """Holder for a weakref of a numpy array.

    It provides features from `ColormapMixIn`.
    """

    def __init__(self, dataRef):
        items.Item.__init__(self)
        items.ColormapMixIn.__init__(self)
        self.__dataRef = dataRef
        self._updated(items.ItemChangedType.DATA)

    def getColormappedData(self, copy=True):
        return self.__dataRef()


class _BoundaryWidget(qt.QWidget):
    """Widget to edit a boundary of the colormap (vmin or vmax)"""

    sigAutoScaleChanged = qt.Signal(object)
    """Signal emitted when the autoscale was changed

    True is sent as an argument if autoscale is set to true.
    """

    sigValueChanged = qt.Signal(object)
    """Signal emitted when value is changed

    The new value is sent as an argument.
    """

    def __init__(self, parent=None, value=0.0):
        qt.QWidget.__init__(self, parent=parent)
        self.setLayout(qt.QHBoxLayout())
        self.layout().setContentsMargins(0, 0, 0, 0)
        self._numVal = FloatEdit(parent=self, value=value)

        self._iconAuto = icons.getQIcon("scale-auto")
        self._iconFixed = icons.getQIcon("scale-fixed")

        self._autoToggleAction = qt.QAction(self)
        self._autoToggleAction.setText("Auto scale")
        self._autoToggleAction.setToolTip("Toggle auto scale")
        self._autoToggleAction.setCheckable(True)
        self._autoToggleAction.setIcon(self._iconFixed)
        self._autoToggleAction.setChecked(False)
        self._autoToggleAction.toggled.connect(self._autoToggled)

        self._numVal.addAction(self._autoToggleAction, qt.QLineEdit.LeadingPosition)

        self.layout().addWidget(self._numVal)
        self._autoCB = qt.QCheckBox("auto", parent=self)
        self.layout().addWidget(self._autoCB)
        self._autoCB.setChecked(False)
        self._autoCB.setVisible(False)

        self._autoCB.toggled.connect(self._autoToggled)
        self._numVal.textEdited.connect(self.__textEdited)
        self._numVal.editingFinished.connect(self.__editingFinished)
        self.setFocusProxy(self._numVal)

        self.__textWasEdited = False
        """True if the text was edited, in order to send an event
        at the end of the user interaction"""

        self.__realValue = None
        """Store the real value set by setValue, to avoid
        rounding of the widget"""

    def __textEdited(self):
        self.__textWasEdited = True

    def __editingFinished(self):
        if self.__textWasEdited:
            value = self._numVal.value()
            self.__realValue = value
            with utils.blockSignals(self._numVal):
                # Fix the formatting
                self._numVal.setValue(self.__realValue)
            self.sigValueChanged.emit(value)
            self.__textWasEdited = False

    def isAutoChecked(self):
        return self._autoCB.isChecked()

    def getValue(self):
        """Returns the stored range. If autoscale is
        enabled, this returns None.
        """
        if self._autoCB.isChecked():
            return None
        if self.__realValue is not None:
            return self.__realValue
        return self._numVal.value()

    def _autoToggled(self, enabled):
        self._updateAutoScaleState(enabled)
        self._updateDisplayedText()
        self.sigAutoScaleChanged.emit(enabled)

    def _updateDisplayedText(self):
        self.__textWasEdited = False
        if self._autoCB.isChecked() and self.__realValue is not None:
            with utils.blockSignals(self._numVal):
                self._numVal.setValue(self.__realValue)

    def setValue(self, value, isAuto=False):
        """Set the value of the boundary.

        :param float value: A finite value for the boundary
        :param bool isAuto: If true, the finite value was automatically computed
            from the data, else it is a fixed custom value.
        """
        assert value is not None
        self._autoCB.setChecked(isAuto)
        with utils.blockSignals(self._numVal):
            if isAuto or self.__realValue != value:
                if not self.__textWasEdited:
                    self._numVal.setValue(value)
            self.__realValue = value
            self._updateAutoScaleState(isAuto)

    def _updateAutoScaleState(self, isAutoScale):
        self._numVal.setReadOnly(isAutoScale)
        palette = qt.QPalette()
        if isAutoScale:
            color = palette.color(qt.QPalette.Disabled, qt.QPalette.Base)
            icon = self._iconAuto
        else:
            color = palette.color(qt.QPalette.Active, qt.QPalette.Base)
            icon = self._iconFixed
        palette.setColor(qt.QPalette.Base, color)
        self._numVal.setPalette(palette)
        self._autoToggleAction.setIcon(icon)


class _AutoscaleModeComboBox(qt.QComboBox):
    DATA = {
        Colormap.MINMAX: ("Min/max", "Use the data min/max"),
        Colormap.STDDEV3: ("Mean±3std", "Use the data mean ± 3 × standard deviation"),
        Colormap.PERCENTILE_1_99: ("Percentile 1-99", "Use 1st to 99th percentile of data"),
    }

    def __init__(self, parent: qt.QWidget):
        super(_AutoscaleModeComboBox, self).__init__(parent=parent)
        self.currentIndexChanged.connect(self.__updateTooltip)
        self._init()

    def _init(self):
        for mode in Colormap.AUTOSCALE_MODES:
            label, tooltip = self.DATA.get(mode, (mode, None))
            self.addItem(label, mode)
            if tooltip is not None:
                self.setItemData(self.count() - 1, tooltip, qt.Qt.ToolTipRole)

    def setCurrentIndex(self, index):
        self.__updateTooltip(index)
        super(_AutoscaleModeComboBox, self).setCurrentIndex(index)

    def __updateTooltip(self, index):
        if index > -1:
            tooltip = self.itemData(index, qt.Qt.ToolTipRole)
        else:
            tooltip = ""
        self.setToolTip(tooltip)

    def currentMode(self):
        index = self.currentIndex()
        return self.itemData(index)

    def setCurrentMode(self, mode):
        for index in range(self.count()):
            if mode == self.itemData(index):
                self.setCurrentIndex(index)
                return
        if mode is None:
            # If None was not a value
            self.setCurrentIndex(-1)
            return
        self.addItem(mode, mode)
        self.setCurrentIndex(self.count() - 1)


class _AutoScaleButton(qt.QPushButton):
    autoRangeChanged = qt.Signal(object)

    def __init__(self, parent=None):
        qt.QPushButton.__init__(self, parent=parent)
        self.setText("Autoscale")
        self.setToolTip("Enable/disable the autoscale for both min and max")
        self.setCheckable(True)
        self.toggled[bool].connect(self.__toggled)
        self.setFocusPolicy(qt.Qt.TabFocus)

    def __toggled(self, checked):
        autoRange = checked, checked
        self.setAutoRange(autoRange)
        self.autoRangeChanged.emit(autoRange)

    def setAutoRangeFromColormap(self, colormap):
        vRange = colormap.getVRange()
        autoRange = vRange[0] is None, vRange[1] is None
        self.setAutoRange(autoRange)

    def setAutoRange(self, autoRange):
        with utils.blockSignals(self):
            self.setChecked(autoRange[0] if autoRange[0] == autoRange[1] else False)


@enum.unique
class DisplayMode(_Enum):
    """Enum for each mode of display of the data in the plot."""

    RANGE = "range"
    HISTOGRAM = "histogram"


class _ColormapHistogram(qt.QWidget):
    """Display the colormap and the data as a plot."""

    sigRangeMoving = qt.Signal(object, object, object)
    """Emitted when a mouse interaction moves the location
    of the colormap range in the plot.

    This signal contains 2 elements:

    - vmin: A float value if this range was moved, else None
    - vmax: A float value if this range was moved, else None
    - gammaPos: A float value if this range was moved, else None
    """

    sigRangeMoved = qt.Signal(object, object, object)
    """Emitted when a mouse interaction stop.

    This signal contains 2 elements:

    - vmin: A float value if this range was moved, else None
    - vmax: A float value if this range was moved, else None
    - gammaPos: A float value if this range was moved, else None
    """

    def __init__(self, parent):
        qt.QWidget.__init__(self, parent=parent)
        self._displayMode = DisplayMode.RANGE
        self._finiteRange = None, None
        self._initPlot()

        self._histogramData = {}
        """Histogram displayed in the plot"""

        self._dragging = False, False, False
        """True, if the min or the max handle is dragging"""

        self._dataRange = {}
        """Histogram displayed in the plot"""

        self._invalidated = False

    def paintEvent(self, event):
        if self._invalidated:
            self._updateDisplayMode()
            self._invalidated = False
        self._updateMarkerPosition()
        return super(_ColormapHistogram, self).paintEvent(event)

    def getFiniteRange(self):
        """Returns the colormap range as displayed in the plot."""
        return self._finiteRange

    def setFiniteRange(self, vRange):
        """Set the colormap range to use in the plot.

        Here there is no concept of auto. The values should
        not be None, except if there is no range or marker
        to display.
        """
        # Do not reset the limit for handle about to be dragged
        if self._dragging[0]:
            vRange = self._finiteRange[0], vRange[1]
        if self._dragging[1]:
            vRange = vRange[0], self._finiteRange[1]

        if vRange == self._finiteRange:
            return

        self._finiteRange = vRange
        self.update()

    def getColormap(self):
        return self.parent().getColormap()

    def _getNormalizedHistogram(self):
        """Return an histogram already normalized according to the colormap
        normalization.

        Returns a tuple edges, counts
        """
        norm = self._getNorm()
        histogram = self._histogramData.get(norm, None)
        if histogram is None:
            histogram = self._computeNormalizedHistogram()
            self._histogramData[norm] = histogram
        return histogram

    def _computeNormalizedHistogram(self):
        colormap = self.getColormap()
        if colormap is None:
            norm = Colormap.LINEAR
        else:
            norm = colormap.getNormalization()

        # Try to use the histogram defined in the dialog
        histo = self.parent()._getHistogram()
        if histo is not None:
            counts, edges = histo
            normalizer = Colormap(normalization=norm)._getNormalizer()
            mask = normalizer.is_valid(edges[:-1])  # Check lower bin edges only
            firstValid = numpy.argmax(mask)  # edges increases monotonically
            if firstValid == 0:  # Mask is all False or all True
                return (counts, edges) if mask[0] else (None, None)
            else:  # Clip to valid values
                return counts[firstValid:], edges[firstValid:]

        data = self.parent()._getArray()
        if data is None:
            return None, None
        dataRange = self._getNormalizedDataRange()
        if dataRange[0] is None or dataRange[1] is None:
            return None, None
        counts, edges = self.parent().computeHistogram(
            data, scale=norm, dataRange=dataRange
        )
        return counts, edges

    def _getNormalizedDataRange(self):
        """Return a data range already normalized according to the colormap
        normalization.

        Returns a tuple with min and max
        """
        norm = self._getNorm()
        dataRange = self._dataRange.get(norm, None)
        if dataRange is None:
            dataRange = self._computeNormalizedDataRange()
            self._dataRange[norm] = dataRange
        return dataRange

    def _computeNormalizedDataRange(self):
        colormap = self.getColormap()
        if colormap is None:
            norm = Colormap.LINEAR
        else:
            norm = colormap.getNormalization()

        # Try to use the one defined in the dialog
        dataRange = self.parent()._getDataRange()
        if dataRange is not None:
            if norm in (Colormap.LINEAR, Colormap.GAMMA, Colormap.ARCSINH):
                return dataRange[0], dataRange[2]
            elif norm == Colormap.LOGARITHM:
                return dataRange[1], dataRange[2]
            elif norm == Colormap.SQRT:
                return dataRange[1], dataRange[2]
            else:
                _logger.error("Undefined %s normalization", norm)

        # Try to use the histogram defined in the dialog
        histo = self.parent()._getHistogram()
        if histo is not None:
            _histo, edges = histo
            normalizer = Colormap(normalization=norm)._getNormalizer()
            edges = edges[normalizer.is_valid(edges)]
            if edges.size == 0:
                return None, None
            else:
                dataRange = min_max(edges, finite=True)
                return dataRange.minimum, dataRange.maximum

        item = self.parent()._getItem()
        if item is not None:
            # Trick to reach data range using colormap cache
            cm = Colormap()
            cm.setVRange(None, None)
            cm.setNormalization(norm)
            dataRange = item._getColormapAutoscaleRange(cm)
            return dataRange

        # If there is no item, there is no data
        return None, None

    def _getDisplayableRange(self):
        """Returns the selected min/max range to apply to the data,
        according to the used scale.

        One or both limits can be None in case it is not displayable in the
        current axes scale.

        :returns: Tuple{float, float}
        """
        scale = self._plot.getXAxis().getScale()

        def isDisplayable(pos):
            if pos is None:
                return False
            if scale == Axis.LOGARITHMIC:
                return pos > 0.0
            return True

        posMin, posMax = self.getFiniteRange()
        if not isDisplayable(posMin):
            posMin = None
        if not isDisplayable(posMax):
            posMax = None

        return posMin, posMax

    def _initPlot(self):
        """Init the plot to display the range and the values"""
        self._plot = PlotWidget(self)
        self._plot.setAxesDisplayed(False)
        self._plot.setDataMargins(0.125, 0.125, 0.01, 0.01)
        self._plot.getXAxis().setLabel("Data Values")
        self._plot.getYAxis().setLabel("")
        self._plot.setInteractiveMode("select", zoomOnWheel=False)
        self._plot.setActiveCurveHandling(False)
        self._plot.setMinimumSize(qt.QSize(250, 200))
        self._plot.sigPlotSignal.connect(self._plotEventReceived)
        palette = self.palette()
        color = palette.color(qt.QPalette.Active, qt.QPalette.Window)
        self._plot.setBackgroundColor(color)
        self._plot.setDataBackgroundColor("white")

        lut = numpy.arange(256)
        lut.shape = 1, -1
        self._plot.addImage(lut, legend="lut")
        self._lutItem = self._plot._getItem("image", "lut")
        self._lutItem.setVisible(False)

        self._plot.addScatter(x=[], y=[], value=[], legend="lut2")
        self._lutItem2 = self._plot._getItem("scatter", "lut2")
        self._lutItem2.setVisible(False)
        self.__lutY = numpy.array([-0.05] * 256)
        self.__lutV = numpy.arange(256)

        self._bound = BoundingRect()
        self._plot.addItem(self._bound)
        self._bound.setVisible(True)

        # Add plot for histogram
        self._plotToolbar = qt.QToolBar(self)
        self._plotToolbar.setFloatable(False)
        self._plotToolbar.setMovable(False)
        self._plotToolbar.setIconSize(qt.QSize(8, 8))
        self._plotToolbar.setStyleSheet("QToolBar { border: 0px }")
        self._plotToolbar.setOrientation(qt.Qt.Vertical)

        group = qt.QActionGroup(self._plotToolbar)
        group.setExclusive(True)
        # data range mode
        action = qt.QAction("Data range", self)
        action.setToolTip(
            "Display the data range within the colormap range. A fast data processing have to be done."
        )
        action.setIcon(icons.getQIcon("colormap-range"))
        action.setCheckable(True)
        action.setData(DisplayMode.RANGE)
        action.setChecked(action.data() == self._displayMode)
        self._plotToolbar.addAction(action)
        group.addAction(action)
        self._dataRangeAction = action
        # histogram mode
        action = qt.QAction("Histogram", self)
        action.setToolTip(
            "Display the data histogram within the colormap range. A slow data processing have to be done. "
        )
        action.setIcon(icons.getQIcon("colormap-histogram"))
        action.setCheckable(True)
        action.setData(DisplayMode.HISTOGRAM)
        action.setChecked(action.data() == self._displayMode)
        self._plotToolbar.addAction(action)
        group.addAction(action)
        self._dataHistogramAction = action
        group.setExclusive(True)
        group.triggered.connect(self._displayModeChanged)

        plotBoxLayout = qt.QHBoxLayout()
        plotBoxLayout.setContentsMargins(0, 0, 0, 0)
        plotBoxLayout.setSpacing(2)
        plotBoxLayout.addWidget(self._plotToolbar)
        plotBoxLayout.addWidget(self._plot)
        plotBoxLayout.setSizeConstraint(qt.QLayout.SetMinimumSize)
        self.setLayout(plotBoxLayout)

    def _plotEventReceived(self, event):
        """Handle events from the plot"""
        kind = event["event"]

        if kind == "markerMoving":
            value = event["xdata"]
            if event["label"] == "Min":
                self._dragging = True, False, False
                self._finiteRange = value, self._finiteRange[1]
                self._last = value, None, None
                self._updateGammaPosition()
                self.sigRangeMoving.emit(*self._last)
            elif event["label"] == "Max":
                self._dragging = False, True, False
                self._finiteRange = self._finiteRange[0], value
                self._last = None, value, None
                self._updateGammaPosition()
                self.sigRangeMoving.emit(*self._last)
            elif event["label"] == "Gamma":
                self._dragging = False, False, True
                self._last = None, None, value
                self.sigRangeMoving.emit(*self._last)
            self._updateLutItem(self._finiteRange)
        elif kind == "markerMoved":
            self.sigRangeMoved.emit(*self._last)
            self._plot.resetZoom()
            self._dragging = False, False, False
        else:
            pass

    def _updateMarkerPosition(self):
        colormap = self.getColormap()
        posMin, posMax = self._getDisplayableRange()

        if colormap is None:
            isDraggable = False
        else:
            isDraggable = colormap.isEditable()

        with utils.blockSignals(self):
            if posMin is not None and not self._dragging[0]:
                self._plot.addXMarker(
                    posMin,
                    legend="Min",
                    text="Min",
                    draggable=isDraggable,
                    color="blue",
                    constraint=self._plotMinMarkerConstraint,
                )
            self._updateGammaPosition()
            if posMax is not None and not self._dragging[1]:
                self._plot.addXMarker(
                    posMax,
                    legend="Max",
                    text="\n\nMax",
                    draggable=isDraggable,
                    color="blue",
                    constraint=self._plotMaxMarkerConstraint,
                )

        self._updateLutItem((posMin, posMax))
        self._plot.resetZoom()

    def _updateGammaPosition(self):
        colormap = self.getColormap()
        posMin, posMax = self._getDisplayableRange()

        if colormap is None:
            gamma = None
        else:
            if colormap.getNormalization() == Colormap.GAMMA:
                gamma = colormap.getGammaNormalizationParameter()
            else:
                gamma = None

        if gamma is not None:
            if not self._dragging[2]:
                posRange = posMax - posMin
                if posRange > 0:
                    gammaPos = posMin + posRange * 0.5 ** (1 / gamma)
                else:
                    gammaPos = posMin
                marker = self._plot._getMarker(
                    self._plot.addXMarker(
                        gammaPos,
                        legend="Gamma",
                        text="\nGamma",
                        draggable=True,
                        color="blue",
                        constraint=self._plotGammaMarkerConstraint,
                    )
                )
                marker.setZValue(2)
        else:
            try:
                self._plot.removeMarker("Gamma")
            except Exception:
                pass

    def _updateLutItem(self, vRange):
        colormap = self.getColormap()
        if colormap is None:
            return

        if vRange is None:
            posMin, posMax = self._getDisplayableRange()
        else:
            posMin, posMax = vRange
        if posMin is None or posMax is None:
            self._lutItem.setVisible(False)
            pos = posMax if posMin is None else posMin
            if pos is not None:
                self._bound.setBounds((pos, pos, -0.1, 0))
            else:
                self._bound.setBounds((0, 0, -0.1, 0))
        else:
            norm = colormap.getNormalization()
            normColormap = colormap.copy()
            normColormap.setEditable(True)
            normColormap.setVRange(0, 255)
            normColormap.setNormalization(Colormap.LINEAR)
            if norm == Colormap.LINEAR:
                scale = (posMax - posMin) / 256
                self._lutItem.setColormap(normColormap)
                self._lutItem.setOrigin((posMin, -0.09))
                self._lutItem.setScale((scale, 0.08))
                self._lutItem.setVisible(True)
                self._lutItem2.setVisible(False)
            elif norm == Colormap.LOGARITHM:
                self._lutItem2.setVisible(False)
                self._lutItem2.setColormap(normColormap)
                xx = numpy.geomspace(posMin, posMax, 256)
                self._lutItem2.setData(
                    x=xx, y=self.__lutY, value=self.__lutV, copy=False
                )
                self._lutItem2.setSymbol("|")
                self._lutItem2.setVisible(True)
                self._lutItem.setVisible(False)
            else:
                # Fallback: Display with linear axis and applied normalization
                self._lutItem2.setVisible(False)
                normColormap.setNormalization(norm)
                self._lutItem2.setColormap(normColormap)
                xx = numpy.linspace(posMin, posMax, 256, endpoint=True)
                self._lutItem2.setData(
                    x=xx, y=self.__lutY, value=self.__lutV, copy=False
                )
                self._lutItem2.setSymbol("|")
                self._lutItem2.setVisible(True)
                self._lutItem.setVisible(False)

            self._bound.setBounds((posMin, posMax, -0.1, 1))

    def _plotMinMarkerConstraint(self, x, y):
        """Constraint of the min marker"""
        _vmin, vmax = self.getFiniteRange()
        if vmax is None:
            return x, y
        return min(x, vmax), y

    def _plotMaxMarkerConstraint(self, x, y):
        """Constraint of the max marker"""
        vmin, _vmax = self.getFiniteRange()
        if vmin is None:
            return x, y
        return max(x, vmin), y

    def _plotGammaMarkerConstraint(self, x, y):
        """Constraint of the gamma marker"""
        vmin, vmax = self.getFiniteRange()
        if vmin is not None:
            x = max(x, vmin)
        if vmax is not None:
            x = min(x, vmax)
        return x, y

    def setDisplayMode(self, mode: str | DisplayMode):
        mode = DisplayMode(mode)
        if mode is DisplayMode.HISTOGRAM:
            action = self._dataHistogramAction
        elif mode is DisplayMode.RANGE:
            action = self._dataRangeAction
        else:
            raise ValueError("Mode not supported")
        action.setChecked(True)
        self._displayModeChanged(action)

    def _setDisplayMode(self, mode):
        if self._displayMode == mode:
            return
        self._displayMode = mode
        self._updateDisplayMode()

    def getDisplayMode(self) -> DisplayMode:
        return self._displayMode

    @deprecated(since_version="2.1.0", replacement="getDisplayMode")
    def getDsiplayMode(self):
        return self.getDisplayMode()

    def _displayModeChanged(self, action):
        mode = action.data()
        self._setDisplayMode(mode)

    def invalidateData(self):
        self._histogramData = {}
        self._dataRange = {}
        self._invalidated = True
        self.update()

    def _updateDisplayMode(self):
        mode = self._displayMode

        norm = self._getNorm()
        if norm == Colormap.LINEAR:
            scale = Axis.LINEAR
        elif norm == Colormap.LOGARITHM:
            scale = Axis.LOGARITHMIC
        else:
            scale = Axis.LINEAR

        axis = self._plot.getXAxis()
        axis.setScale(scale)

        if mode == DisplayMode.RANGE:
            dataRange = self._getNormalizedDataRange()
            xmin, xmax = dataRange
            if xmax is None or xmin is None:
                self._plot.remove(legend="Data", kind="histogram")
            else:
                histogram = numpy.array([1])
                bin_edges = numpy.array([xmin, xmax])
                self._plot.addHistogram(
                    histogram,
                    bin_edges,
                    legend="Data",
                    color="gray",
                    align="center",
                    fill=True,
                    z=1,
                )

        elif mode == DisplayMode.HISTOGRAM:
            histogram, bin_edges = self._getNormalizedHistogram()
            if histogram is None or bin_edges is None:
                self._plot.remove(legend="Data", kind="histogram")
            else:
                histogram = numpy.array(histogram, copy=True)
                bin_edges = numpy.array(bin_edges, copy=True)
                with numpy.errstate(invalid="ignore"):
                    norm_histogram = histogram / numpy.nanmax(histogram)
                self._plot.addHistogram(
                    norm_histogram,
                    bin_edges,
                    legend="Data",
                    color="gray",
                    align="center",
                    fill=True,
                    z=1,
                )
        else:
            _logger.error("Mode unsupported")

    def sizeHint(self):
        return self.layout().minimumSize()

    def updateLut(self):
        self._updateLutItem(None)

    def _getNorm(self):
        colormap = self.getColormap()
        if colormap is None:
            return Axis.LINEAR
        else:
            norm = colormap.getNormalization()
            return norm

    def updateNormalization(self):
        self._updateDisplayMode()
        self.update()


class ColormapDialog(qt.QDialog):
    """A QDialog widget to set the colormap.

    :param parent: See :class:`QDialog`
    :param str title: The QDialog title
    """

    visibleChanged = qt.Signal(bool)
    """This event is sent when the dialog visibility change"""

    def __init__(self, parent=None, title="Colormap Dialog"):
        qt.QDialog.__init__(self, parent)
        self.setWindowTitle(title)

        self.__aboutToDelete = False
        self._colormap = None

        self._data = None
        """Weak ref to an external numpy array
        """
        self._itemHolder = None
        """Hard ref to a private item (used as holder to the data)
        This allow to reuse the item cache
        """
        self._item = None
        """Weak ref to an external item"""

        self._colormapped = None
        """Weak ref to reduce data update"""

        self._colormapChange = utils.LockReentrant()
        """Used as a semaphore to avoid editing the colormap object when we are
        only attempt to display it.
        Used instead of n connect and disconnect of the sigChanged. The
        disconnection to sigChanged was also limiting when this colormapdialog
        is used in the colormapaction and associated to the activeImageChanged.
        (because the activeImageChanged is send when the colormap changed and
        the self.setcolormap is a callback)
        """

        self.__colormapInvalidated = False
        self.__dataInvalidated = False

        self._histogramData = None

        self._dataRange = None
        """If defined 3-tuple containing information from a data:
        minimum, positive minimum, maximum"""

        self._colormapStoredState = None

        # Colormap row
        self._comboBoxColormap = ColormapNameComboBox(parent=self)
        self._comboBoxColormap.currentIndexChanged[int].connect(
            self._comboBoxColormapUpdated
        )

        # Normalization row
        self._comboBoxNormalization = qt.QComboBox(parent=self)
        normalizations = [
            ("Linear", Colormap.LINEAR),
            ("Gamma correction", Colormap.GAMMA),
            ("Arcsinh", Colormap.ARCSINH),
            ("Logarithmic", Colormap.LOGARITHM),
            ("Square root", Colormap.SQRT),
        ]
        for name, userData in normalizations:
            try:
                icon = icons.getQIcon("colormap-norm-%s" % userData)
            except:
                icon = qt.QIcon()
            self._comboBoxNormalization.addItem(icon, name, userData)
        self._comboBoxNormalization.currentIndexChanged[int].connect(
            self._normalizationUpdated
        )

        self._gammaSpinBox = qt.QDoubleSpinBox(parent=self)
        self._gammaSpinBox.setEnabled(False)
        self._gammaSpinBox.setRange(0.01, 100.0)
        self._gammaSpinBox.setDecimals(4)
        if hasattr(qt.QDoubleSpinBox, "setStepType"):
            # Introduced in Qt 5.12
            self._gammaSpinBox.setStepType(qt.QDoubleSpinBox.AdaptiveDecimalStepType)
        else:
            self._gammaSpinBox.setSingleStep(0.1)
        self._gammaSpinBox.valueChanged.connect(self._gammaUpdated)
        self._gammaSpinBox.setValue(2.0)

        autoScaleCombo = _AutoscaleModeComboBox(self)
        autoScaleCombo.currentIndexChanged.connect(self._autoscaleModeUpdated)
        self._autoScaleCombo = autoScaleCombo

        # Min row
        self._minValue = _BoundaryWidget(parent=self, value=1.0)
        self._minValue.sigAutoScaleChanged.connect(self._minAutoscaleUpdated)
        self._minValue.sigValueChanged.connect(self._minValueUpdated)
        self._minValue.setMinimumWidth(140)

        # Max row
        self._maxValue = _BoundaryWidget(parent=self, value=10.0)
        self._maxValue.sigAutoScaleChanged.connect(self._maxAutoscaleUpdated)
        self._maxValue.sigValueChanged.connect(self._maxValueUpdated)
        self._maxValue.setMinimumWidth(140)

        self._autoButtons = _AutoScaleButton(self)
        self._autoButtons.autoRangeChanged.connect(self._autoRangeButtonsUpdated)

        rangeLayout = qt.QGridLayout()
        miniFont = qt.QFont(self.font())
        miniFont.setPixelSize(8)
        labelMin = qt.QLabel("Min", self)
        labelMin.setFont(miniFont)
        labelMin.setAlignment(qt.Qt.AlignHCenter)
        labelMax = qt.QLabel("Max", self)
        labelMax.setAlignment(qt.Qt.AlignHCenter)
        labelMax.setFont(miniFont)
        rangeLayout.addWidget(labelMin, 0, 1)
        rangeLayout.addWidget(labelMax, 0, 3)
        rangeLayout.addWidget(self._minValue, 1, 1)
        rangeLayout.addWidget(self._maxValue, 1, 3)
        rangeLayout.setColumnStretch(0, 1)
        rangeLayout.setColumnStretch(1, 2)
        rangeLayout.setColumnStretch(2, 1)
        rangeLayout.setColumnStretch(3, 2)
        rangeLayout.setColumnStretch(4, 1)

        self._histoWidget = _ColormapHistogram(self)
        self._histoWidget.sigRangeMoving.connect(self._histogramRangeMoving)
        self._histoWidget.sigRangeMoved.connect(self._histogramRangeMoved)
        self._histoWidget.setSizePolicy(
            qt.QSizePolicy.Expanding, qt.QSizePolicy.Expanding
        )

        # Scale to buttons
        self._visibleAreaButton = qt.QPushButton(self)
        self._visibleAreaButton.setEnabled(False)
        self._visibleAreaButton.setText("Visible Area")
        self._visibleAreaButton.clicked.connect(
            self._handleScaleToVisibleAreaClicked, type=qt.Qt.QueuedConnection
        )

        # Place-holder for selected area ROI manager
        self._roiForColormapManager = None

        self._selectedAreaButton = qt.QPushButton(self)
        self._selectedAreaButton.setCheckable(True)
        self._selectedAreaButton.setEnabled(False)
        self._selectedAreaButton.setText("Selection")
        self._selectedAreaButton.setIcon(icons.getQIcon("add-shape-rectangle"))
        self._selectedAreaButton.setCheckable(True)
        self._selectedAreaButton.toggled.connect(
            self._handleScaleToSelectionToggled, type=qt.Qt.QueuedConnection
        )

        # define modal buttons
        types = qt.QDialogButtonBox.Ok | qt.QDialogButtonBox.Cancel
        self._buttonsModal = qt.QDialogButtonBox(parent=self)
        self._buttonsModal.setStandardButtons(types)
        self._buttonsModal.accepted.connect(self.accept)
        self._buttonsModal.rejected.connect(self.reject)

        # define non modal buttons
        types = qt.QDialogButtonBox.Close | qt.QDialogButtonBox.Reset
        self._buttonsNonModal = qt.QDialogButtonBox(parent=self)
        self._buttonsNonModal.setStandardButtons(types)
        button = self._buttonsNonModal.button(qt.QDialogButtonBox.Close)
        button.clicked.connect(self.accept)
        button.setDefault(True)
        button = self._buttonsNonModal.button(qt.QDialogButtonBox.Reset)
        button.clicked.connect(self.resetColormap)

        self._buttonsModal.setFocus(qt.Qt.OtherFocusReason)
        self._buttonsNonModal.setFocus(qt.Qt.OtherFocusReason)

        # Set the colormap to default values
        self.setColormap(
            Colormap(name="gray", normalization="linear", vmin=None, vmax=None)
        )

        self.setModal(self.isModal())

        layout = qt.QHBoxLayout()
        layout.setContentsMargins(0, 0, 0, 0)
        layout.addWidget(self._visibleAreaButton)
        layout.addWidget(self._selectedAreaButton)
        layout.addStretch()
        self._scaleToAreaGroup = qt.QWidget(self)
        self._scaleToAreaGroup.setLayout(layout)
        self._scaleToAreaGroup.setVisible(False)

        layoutScale = qt.QHBoxLayout()
        layoutScale.setContentsMargins(0, 0, 0, 0)
        layoutScale.addWidget(self._autoButtons)
        layoutScale.addWidget(self._autoScaleCombo)
        layoutScale.addStretch()

        formLayout = FormGridLayout(self)
        formLayout.setContentsMargins(10, 10, 10, 10)

        formLayout.addRow("Colormap:", self._comboBoxColormap)
        formLayout.addRow("Normalization:", self._comboBoxNormalization)
        formLayout.addRow("Gamma:", self._gammaSpinBox)

        formLayout.addItem(
            qt.QSpacerItem(1, 1, qt.QSizePolicy.Fixed, qt.QSizePolicy.Fixed)
        )
        formLayout.addRow(self._histoWidget)
        formLayout.setRowStretch(formLayout.rowCount() - 1, 1)
        formLayout.addRow(rangeLayout)
        formLayout.addItem(
            qt.QSpacerItem(1, 1, qt.QSizePolicy.Fixed, qt.QSizePolicy.Fixed)
        )
        formLayout.addRow("Scale:", layoutScale)
        formLayout.addRow("Fixed scale on:", self._scaleToAreaGroup)
        formLayout.addRow(self._buttonsModal)
        formLayout.addRow(self._buttonsNonModal)
        formLayout.setSizeConstraint(qt.QLayout.SetMinAndMaxSize)

        self.setTabOrder(self._comboBoxColormap, self._comboBoxNormalization)
        self.setTabOrder(self._comboBoxNormalization, self._gammaSpinBox)
        self.setTabOrder(self._gammaSpinBox, self._minValue)
        self.setTabOrder(self._minValue, self._maxValue)
        self.setTabOrder(self._maxValue, self._autoButtons)
        self.setTabOrder(self._autoButtons, self._autoScaleCombo)
        self.setTabOrder(self._autoScaleCombo, self._visibleAreaButton)
        self.setTabOrder(self._visibleAreaButton, self._selectedAreaButton)
        self.setTabOrder(self._selectedAreaButton, self._buttonsModal)
        self.setTabOrder(self._buttonsModal, self._buttonsNonModal)

        self._applyColormap()

    def getHistogramWidget(self):
        return self._histoWidget

    def _invalidateColormap(self):
        if self.isVisible():
            self._applyColormap()
        else:
            self.__colormapInvalidated = True

    def _invalidateData(self):
        if self.isVisible():
            self._updateWidgetRange()
            self._histoWidget.invalidateData()
        else:
            self.__dataInvalidated = True

    def _validate(self):
        if self.__colormapInvalidated:
            self._applyColormap()
        if self.__dataInvalidated:
            self._histoWidget.invalidateData()
        if self.__dataInvalidated or self.__colormapInvalidated:
            self._updateWidgetRange()
        self.__dataInvalidated = False
        self.__colormapInvalidated = False

    def showEvent(self, event):
        self.visibleChanged.emit(True)
        super(ColormapDialog, self).showEvent(event)
        if self.isVisible():
            self._validate()

    def closeEvent(self, event):
        if not self.isModal():
            self.accept()
        super(ColormapDialog, self).closeEvent(event)

    def hideEvent(self, event):
        if self._selectedAreaButton.isChecked():
            self._selectedAreaButton.setChecked(False)
        self.visibleChanged.emit(False)
        super(ColormapDialog, self).hideEvent(event)

    def close(self):
        if self._selectedAreaButton.isChecked():
            self._selectedAreaButton.setChecked(False)
        self.accept()
        qt.QDialog.close(self)

    def setModal(self, modal):
        assert type(modal) is bool
        self._buttonsNonModal.setVisible(not modal)
        self._buttonsModal.setVisible(modal)
        qt.QDialog.setModal(self, modal)

    def event(self, event):
        if event.type() == qt.QEvent.DeferredDelete:
            self.__aboutToDelete = True
        return super(ColormapDialog, self).event(event)

    def exec(self):
        wasModal = self.isModal()
        self.setModal(True)
        result = super(ColormapDialog, self).exec()
        if not self.__aboutToDelete:
            self.setModal(wasModal)
        return result

    def exec_(self):  # Qt5 compatibility wrapper
        return self.exec()

    def _getFiniteColormapRange(self):
        """Return a colormap range where auto ranges are fixed
        according to the available data.
        """
        colormap = self.getColormap()
        if colormap is None:
            return 1, 10

        item = self._getItem()
        if item is not None:
            return colormap.getColormapRange(item)
        # If there is not item, there is no data
        return colormap.getColormapRange(None)

    @staticmethod
    def computeDataRange(data):
        """Compute the data range as used by :meth:`setDataRange`.

        :param data: The data to process
        :rtype: List[Union[None,float]]
        """
        if data is None or len(data) == 0:
            return None, None, None

        dataRange = min_max(data, min_positive=True, finite=True)
        if dataRange.minimum is None:
            # Only non-finite data
            dataRange = None

        if dataRange is not None:
            dataRange = dataRange.minimum, dataRange.min_positive, dataRange.maximum

        if dataRange is None or len(dataRange) != 3:
            qt.QMessageBox.warning(
                None, "No Data", "Image data does not contain any real value"
            )
            dataRange = 1.0, 1.0, 10.0

        return dataRange

    @staticmethod
    def computeHistogram(data, scale=Axis.LINEAR, dataRange=None):
        """Compute the data histogram as used by :meth:`setHistogram`.

        :param data: The data to process
        :param dataRange: Optional range to compute the histogram, which is a
            tuple of min, max
        :rtype: Tuple(List(float),List(float)
        """
        # For compatibility
        if scale == Axis.LOGARITHMIC:
            scale = Colormap.LOGARITHM

        if data is None:
            return None, None

        if len(data) == 0:
            return None, None

        if data.ndim == 3:  # RGB(A) images
            _logger.info(
                "Converting current image from RGB(A) to grayscale\
                in order to compute the intensity distribution"
            )
            data = data[:, :, 0] * 0.299 + data[:, :, 1] * 0.587 + data[:, :, 2] * 0.114

        # bad hack: get 256 continuous bins in the case we have a B&W
        normalizeData = True
        if numpy.issubdtype(data.dtype, numpy.ubyte):
            normalizeData = False
        elif numpy.issubdtype(data.dtype, numpy.integer):
            if dataRange is not None:
                xmin, xmax = dataRange
                if xmin is not None and xmax is not None:
                    normalizeData = (xmax - xmin) > 255

        if normalizeData:
            if scale == Colormap.LOGARITHM:
                with numpy.errstate(divide="ignore", invalid="ignore"):
                    data = numpy.log10(data)

        if dataRange is not None:
            xmin, xmax = dataRange
            if xmin is None:
                return None, None
            if normalizeData:
                if scale == Colormap.LOGARITHM:
                    xmin, xmax = numpy.log10(xmin), numpy.log10(xmax)
        else:
            xmin, xmax = min_max(data, min_positive=False, finite=True)

        if xmin is None:
            return None, None

        nbins = min(256, int(numpy.sqrt(data.size)))
        data_range = xmin, xmax

        # bad hack: get 256 bins in the case we have a B&W
        if numpy.issubdtype(data.dtype, numpy.integer):
            if nbins > xmax - xmin:
                nbins = int(xmax - xmin)

        nbins = max(2, nbins)
        data = data.ravel().astype(numpy.float32)

        histogram = Histogramnd(data, n_bins=nbins, histo_range=data_range)
        bins = histogram.edges[0]
        if normalizeData:
            if scale == Colormap.LOGARITHM:
                bins = 10**bins
        return histogram.histo, bins

    def _getItem(self):
        if self._itemHolder is not None:
            return self._itemHolder
        if self._item is None:
            return None
        return self._item()

    def setItem(self, item):
        """Store the plot item.

        According to the state of the dialog, the item will be used to display
        the data range or the histogram of the data using :meth:`setDataRange`
        and :meth:`setHistogram`
        """
        old = self._getItem()
        if old is item:
            # While event from items are not supported, we can't ignore dup items
            if item is not None:
                array = item.getColormappedData(copy=False)
            else:
                array = None
            colormapped = self._colormapped
            if colormapped is not None:
                oldArray = colormapped()
            else:
                oldArray = None
            if oldArray is array:
                return

        self.__resetItem()
        try:
            if item is None:
                self._item = None
            else:
                if not isinstance(item, items.ColormapMixIn):
                    self._item = None
                    raise ValueError("Item %s is not supported" % item)
                item.sigItemChanged.connect(self.__itemChanged)
                self._item = weakref.ref(item, self._itemAboutToFinalize)
        finally:
            self._syncScaleToButtonsEnabled()
            self._dataRange = None
            self._histogramData = None
            self._invalidateData()

    def __resetItem(self):
        """Reset item and data used by the dialog"""
        self._data = None
        self._itemHolder = None
        if self._item is not None:
            item = self._item()
            self._item = None
            if item is not None:
                item.sigItemChanged.disconnect(self.__itemChanged)

    def __itemChanged(self, event):
        if event == items.ItemChangedType.DATA:
            self._invalidateData()

    def _getData(self):
        if self._data is None:
            return None
        return self._data()

    def setData(self, data):
        """Store the data

        According to the state of the dialog, the data will be used to display
        the data range or the histogram of the data using :meth:`setDataRange`
        and :meth:`setHistogram`
        """
        oldData = self._getData()
        if oldData is data:
            return

        self.__resetItem()
        self._syncScaleToButtonsEnabled()
        if data is not None:
            self._data = weakref.ref(data, self._dataAboutToFinalize)
            self._itemHolder = _DataRefHolder(self._data)

        self._dataRange = None
        self._histogramData = None

        self._invalidateData()

    def _getArray(self):
        data = self._getData()
        if data is not None:
            return data
        item = self._getItem()
        if item is not None:
            colormapped = item.getColormappedData(copy=False)
            if colormapped is not None:
                self._colormapped = weakref.ref(colormapped)
            else:
                self._colormapped = None
            return colormapped
        return None

    def _colormapAboutToFinalize(self, weakrefColormap):
        """Callback when the data weakref is about to be finalized."""
        if self._colormap is weakrefColormap and qtinspect.isValid(self):
            self.setColormap(None)

    def _dataAboutToFinalize(self, weakrefData):
        """Callback when the data weakref is about to be finalized."""
        if self._data is weakrefData and qtinspect.isValid(self):
            self.setData(None)

    def _itemAboutToFinalize(self, weakref):
        """Callback when the data weakref is about to be finalized."""
        if self._item is weakref and qtinspect.isValid(self):
            self.setItem(None)

    def _getHistogram(self):
        """Returns the histogram defined by the dialog as metadata
        to describe the data in order to speed up the dialog.

        :return: (hist, bin_edges)
        :rtype: 2-tuple of numpy arrays"""
        return self._histogramData

    def setHistogram(self, hist=None, bin_edges=None):
        """Set the histogram to display.

        This update the data range with the bounds of the bins.

        :param hist: array-like of counts or None to hide histogram
        :param bin_edges: array-like of bins edges or None to hide histogram
        """
        if hist is None or bin_edges is None:
            self._histogramData = None
        else:
            self._histogramData = numpy.array(hist), numpy.array(bin_edges)

        self._invalidateData()

    def getColormap(self):
        """Return the colormap description.

        :rtype: ~silx.gui.colors.Colormap
        """
        if self._colormap is None:
            return None
        return self._colormap()

    def resetColormap(self):
        """
        Reset the colormap state before modification.

        ..note :: the colormap reference state is the state when set or the
                  state when validated
        """
        colormap = self.getColormap()
        if colormap is not None and self._colormapStoredState is not None:
            if colormap != self._colormapStoredState:
                with self._colormapChange:
                    colormap.setFromColormap(self._colormapStoredState)
                self._applyColormap()

    def _getDataRange(self):
        """Returns the data range defined by the dialog as metadata
        to describe the data in order to speed up the dialog.

        :return: (minimum, positiveMin, maximum)
        :rtype: 3-tuple of floats or None"""
        return self._dataRange

    def setDataRange(self, minimum=None, positiveMin=None, maximum=None):
        """Set the range of data to use for the range of the histogram area.

        :param float minimum: The minimum of the data
        :param float positiveMin: The positive minimum of the data
        :param float maximum: The maximum of the data
        """
        self._dataRange = minimum, positiveMin, maximum
        self._invalidateData()

    def _setColormapRange(self, xmin, xmax):
        """Set a new range to the held colormap and update the
        widget."""
        colormap = self.getColormap()
        if colormap is not None:
            with self._colormapChange:
                colormap.setVRange(xmin, xmax)
        self._updateWidgetRange()

    def setColormapRangeFromDataBounds(self, bounds):
        """Set the range of the colormap from current item and rect.

        If there is no ColormapMixIn item attached to the ColormapDialog,
        nothing is done.

        :param Union[List[float],None] bounds:
            (xmin, xmax, ymin, ymax) Rectangular region in data space
        """
        if bounds is None:
            return  # no-op

        colormap = self.getColormap()
        if colormap is None:
            return  # no-op

        item = self._getItem()
        if not isinstance(item, items.ColormapMixIn):
            return  # no-op

        data = item.getColormappedData(copy=False)
        xmin, xmax, ymin, ymax = bounds

        if isinstance(item, items.ImageBase):
            if data.ndim != 2:
                return  # no-op

            ox, oy = item.getOrigin()
            sx, sy = item.getScale()

            ystart = max(0, int((ymin - oy) / sy))
            ystop = max(0, int(numpy.ceil((ymax - oy) / sy)))
            xstart = max(0, int((xmin - ox) / sx))
            xstop = max(0, int(numpy.ceil((xmax - ox) / sx)))

            subset = data[ystart:ystop, xstart:xstop]

        elif isinstance(item, items.Scatter):
            x = item.getXData(copy=False)
            y = item.getYData(copy=False)
            subset = data[
                numpy.logical_and(
                    numpy.logical_and(xmin <= x, x <= xmax),
                    numpy.logical_and(ymin <= y, y <= ymax),
                )
            ]

        if subset.size == 0:
            return  # no-op

        vmin, vmax = colormap._computeAutoscaleRange(subset)
        self._setColormapRange(vmin, vmax)

    def _updateWidgetRange(self):
        """Update the colormap range displayed into the widget."""
        xmin, xmax = self._getFiniteColormapRange()
        colormap = self.getColormap()
        if colormap is not None:
            vRange = colormap.getVRange()
            autoMin, autoMax = (r is None for r in vRange)
        else:
            autoMin, autoMax = False, False

        with utils.blockSignals(self._minValue):
            self._minValue.setValue(xmin, autoMin)
        with utils.blockSignals(self._maxValue):
            self._maxValue.setValue(xmax, autoMax)
        with utils.blockSignals(self._histoWidget):
            self._histoWidget.setFiniteRange((xmin, xmax))
        with utils.blockSignals(self._autoButtons):
            self._autoButtons.setAutoRange((autoMin, autoMax))

    def accept(self):
        self.storeCurrentState()
        qt.QDialog.accept(self)

    def storeCurrentState(self):
        """
        save the current value sof the colormap if the user want to undo is
        modifications
        """
        colormap = self.getColormap()
        if colormap is not None:
            self._colormapStoredState = colormap.copy()
        else:
            self._colormapStoredState = None

    def reject(self):
        self.resetColormap()
        qt.QDialog.reject(self)

    def setColormap(self, colormap):
        """Set the colormap description

        :param ~silx.gui.colors.Colormap colormap: the colormap to edit
        """
        assert colormap is None or isinstance(colormap, Colormap)
        if self._colormapChange.locked():
            return

        oldColormap = self.getColormap()
        if oldColormap is colormap:
            return
        if oldColormap is not None:
            oldColormap.sigChanged.disconnect(self._applyColormap)

        if colormap is not None:
            colormap.sigChanged.connect(self._applyColormap)
            colormap = weakref.ref(colormap, self._colormapAboutToFinalize)

        self._colormap = colormap
        self.storeCurrentState()
        self._invalidateColormap()

    def _updateResetButton(self):
        resetButton = self._buttonsNonModal.button(qt.QDialogButtonBox.Reset)
        rStateEnabled = False
        colormap = self.getColormap()
        if colormap is not None and colormap.isEditable():
            # can reset only in the case the colormap changed
            rStateEnabled = colormap != self._colormapStoredState
        resetButton.setEnabled(rStateEnabled)

    def _applyColormap(self):
        self._updateResetButton()
        if self._colormapChange.locked():
            return

        self._syncScaleToButtonsEnabled()

        colormap = self.getColormap()
        if colormap is None:
            self._comboBoxColormap.setEnabled(False)
            self._comboBoxNormalization.setEnabled(False)
            self._gammaSpinBox.setEnabled(False)
            self._autoScaleCombo.setEnabled(False)
            self._minValue.setEnabled(False)
            self._maxValue.setEnabled(False)
            self._autoButtons.setEnabled(False)
            self._histoWidget.setVisible(False)
            self._histoWidget.setFiniteRange((None, None))
        else:
            assert colormap.getNormalization() in Colormap.NORMALIZATIONS
            with utils.blockSignals(self._comboBoxColormap):
                self._comboBoxColormap.setCurrentLut(colormap)
                self._comboBoxColormap.setEnabled(colormap.isEditable())
            with utils.blockSignals(self._comboBoxNormalization):
                index = self._comboBoxNormalization.findData(
                    colormap.getNormalization()
                )
                if index < 0:
                    _logger.error(
                        "Unsupported normalization: %s" % colormap.getNormalization()
                    )
                else:
                    self._comboBoxNormalization.setCurrentIndex(index)
                self._comboBoxNormalization.setEnabled(colormap.isEditable())
            with utils.blockSignals(self._gammaSpinBox):
                self._gammaSpinBox.setValue(colormap.getGammaNormalizationParameter())
                self._gammaSpinBox.setEnabled(
                    colormap.getNormalization() == Colormap.GAMMA
                    and colormap.isEditable()
                )
            with utils.blockSignals(self._autoScaleCombo):
                self._autoScaleCombo.setCurrentMode(colormap.getAutoscaleMode())
                self._autoScaleCombo.setEnabled(colormap.isEditable())
            with utils.blockSignals(self._autoButtons):
                self._autoButtons.setEnabled(colormap.isEditable())
                self._autoButtons.setAutoRangeFromColormap(colormap)

            vmin, vmax = colormap.getVRange()
            if vmin is None or vmax is None:
                # Compute it only if needed
                dataRange = self._getFiniteColormapRange()
            else:
                dataRange = vmin, vmax

            with utils.blockSignals(self._minValue):
                self._minValue.setValue(vmin or dataRange[0], isAuto=vmin is None)
                self._minValue.setEnabled(colormap.isEditable())
            with utils.blockSignals(self._maxValue):
                self._maxValue.setValue(vmax or dataRange[1], isAuto=vmax is None)
                self._maxValue.setEnabled(colormap.isEditable())

            with utils.blockSignals(self._histoWidget):
                self._histoWidget.setVisible(True)
                self._histoWidget.setFiniteRange(dataRange)
                self._histoWidget.updateNormalization()

    def _comboBoxColormapUpdated(self):
        """Callback executed when the combo box with the colormap LUT
        is updated by user input.
        """
        colormap = self.getColormap()
        if colormap is not None:
            with self._colormapChange:
                name = self._comboBoxColormap.getCurrentName()
                if name is not None:
                    colormap.setName(name)
                else:
                    lut = self._comboBoxColormap.getCurrentColors()
                    colormap.setColormapLUT(lut)
        self._histoWidget.updateLut()

    def _autoRangeButtonsUpdated(self, autoRange):
        """Callback executed when the autoscale buttons widget
        is updated by user input.
        """
        dataRange = self._getFiniteColormapRange()

        # Final colormap range
        vmin = dataRange[0] if not autoRange[0] else None
        vmax = dataRange[1] if not autoRange[1] else None

        with self._colormapChange:
            colormap = self.getColormap()
            colormap.setVRange(vmin, vmax)

        with utils.blockSignals(self._minValue):
            self._minValue.setValue(vmin or dataRange[0], isAuto=vmin is None)
        with utils.blockSignals(self._maxValue):
            self._maxValue.setValue(vmax or dataRange[1], isAuto=vmax is None)

        self._updateWidgetRange()

    def _normalizationUpdated(self, index):
        """Callback executed when the normalization widget
        is updated by user input.
        """
        colormap = self.getColormap()
        if colormap is not None:
            normalization = self._comboBoxNormalization.itemData(index)
            self._gammaSpinBox.setEnabled(normalization == "gamma")

            with self._colormapChange:
                colormap.setNormalization(normalization)
                self._histoWidget.updateNormalization()

        self._updateWidgetRange()

    def _gammaUpdated(self, value):
        """Callback used to update the gamma normalization parameter"""
        colormap = self.getColormap()
        if colormap is not None:
            colormap.setGammaNormalizationParameter(value)

    def _autoscaleModeUpdated(self):
        """Callback executed when the autoscale mode widget
        is updated by user input.
        """
        mode = self._autoScaleCombo.currentMode()

        colormap = self.getColormap()
        if colormap is not None:
            with self._colormapChange:
                colormap.setAutoscaleMode(mode)

        self._updateWidgetRange()

    def _minAutoscaleUpdated(self, autoEnabled):
        """Callback executed when the min autoscale from
        the lineedit is updated by user input"""
        colormap = self.getColormap()
        xmin, xmax = colormap.getVRange()
        if autoEnabled:
            xmin = None
        else:
            xmin, _xmax = self._getFiniteColormapRange()
        self._setColormapRange(xmin, xmax)

    def _maxAutoscaleUpdated(self, autoEnabled):
        """Callback executed when the max autoscale from
        the lineedit is updated by user input"""
        colormap = self.getColormap()
        xmin, xmax = colormap.getVRange()
        if autoEnabled:
            xmax = None
        else:
            _xmin, xmax = self._getFiniteColormapRange()
        self._setColormapRange(xmin, xmax)

    def _minValueUpdated(self, value):
        """Callback executed when the lineedit min value is
        updated by user input"""
        xmin = value
        xmax = self._maxValue.getValue()
        if xmax is not None and xmin > xmax:
            # FIXME: This should be done in the widget itself
            xmin = xmax
            with utils.blockSignals(self._minValue):
                self._minValue.setValue(xmin)
        self._setColormapRange(xmin, xmax)

    def _maxValueUpdated(self, value):
        """Callback executed when the lineedit max value is
        updated by user input"""
        xmin = self._minValue.getValue()
        xmax = value
        if xmin is not None and xmin > xmax:
            # FIXME: This should be done in the widget itself
            xmax = xmin
            with utils.blockSignals(self._maxValue):
                self._maxValue.setValue(xmax)
        self._setColormapRange(xmin, xmax)

    def _histogramRangeMoving(self, vmin, vmax, gammaPos):
        """Callback executed when for colormap range displayed in
        the histogram widget is moving.

        :param vmin: Update of the minimum range, else None
        :param vmax: Update of the maximum range, else None
        :param gammaPos: Update of the gamma location, else None
        """
        colormap = self.getColormap()
        if vmin is not None:
            with self._colormapChange:
                colormap.setVMin(vmin)
            self._minValue.setValue(vmin)
        if vmax is not None:
            with self._colormapChange:
                colormap.setVMax(vmax)
            self._maxValue.setValue(vmax)
        if gammaPos is not None:
            vmin, vmax = self._histoWidget.getFiniteRange()
            if vmax < vmin:
                gamma = 1
            elif gammaPos >= vmax:
                gamma = self._gammaSpinBox.maximum()
            elif gammaPos <= vmin:
                gamma = self._gammaSpinBox.minimum()
            else:
                gamma = numpy.clip(
                    numpy.log(0.5) / numpy.log((gammaPos - vmin) / (vmax - vmin)),
                    self._gammaSpinBox.minimum(),
                    self._gammaSpinBox.maximum(),
                )
            with self._colormapChange:
                colormap.setGammaNormalizationParameter(gamma)
            with utils.blockSignals(self._gammaSpinBox):
                self._gammaSpinBox.setValue(gamma)

    def _histogramRangeMoved(self, vmin, vmax, gammaPos):
        """Callback executed when for colormap range displayed in
        the histogram widget has finished to move
        """
        if vmin is None and vmax is None:
            return
        xmin = self._minValue.getValue()
        xmax = self._maxValue.getValue()
        if vmin is None:
            vmin = xmin
        if vmax is None:
            vmax = xmax
        self._setColormapRange(vmin, vmax)

    def _syncScaleToButtonsEnabled(self):
        """Set the state of scale to buttons according to current item and colormap"""
        colormap = self.getColormap()
        enabled = (
            self._item is not None and colormap is not None and colormap.isEditable()
        )
        self._scaleToAreaGroup.setVisible(enabled)
        self._visibleAreaButton.setEnabled(enabled)
        if not enabled:
            self._selectedAreaButton.setChecked(False)
        self._selectedAreaButton.setEnabled(enabled)

    def _handleScaleToVisibleAreaClicked(self):
        """Set colormap range from current item's visible area"""
        item = self._getItem()
        if item is None:
            return  # no-op

        bounds = item.getVisibleBounds()
        if bounds is None:
            return  # no-op

        self.setColormapRangeFromDataBounds(bounds)

    def _handleScaleToSelectionToggled(self, checked=False):
        """Handle toggle of scale to selected are button"""
        # Reset any previous ROI manager
        if self._roiForColormapManager is not None:
            self._roiForColormapManager.clear()
            self._roiForColormapManager.stop()
            self._roiForColormapManager = None

        if not checked:  # Reset button status
            self._selectedAreaButton.setText("Selection")
            return

        item = self._getItem()
        if item is None:
            self._selectedAreaButton.setChecked(False)
            return  # no-op

        plotWidget = item.getPlot()
        if plotWidget is None:
            self._selectedAreaButton.setChecked(False)
            return  # no-op

        self._selectedAreaButton.setText("Draw Area...")

        self._roiForColormapManager = RegionOfInterestManager(parent=plotWidget)
        cmap = self.getColormap()
        self._roiForColormapManager.setColor(
            "black" if cmap is None else cursorColorForColormap(cmap.getName())
        )
        self._roiForColormapManager.sigInteractiveModeFinished.connect(
            self.__roiInteractiveModeFinished
        )
        self._roiForColormapManager.sigInteractiveRoiFinalized.connect(
            self.__roiFinalized
        )
        self._roiForColormapManager.start(RectangleROI)

    def __roiInteractiveModeFinished(self):
        self._selectedAreaButton.setChecked(False)

    def __roiFinalized(self, roi):
        if roi is not None:
            ox, oy = roi.getOrigin()
            width, height = roi.getSize()
            self.setColormapRangeFromDataBounds((ox, ox + width, oy, oy + height))
            # clear ROI
            self._roiForColormapManager.removeRoi(roi)

    def keyPressEvent(self, event):
        """Override key handling.

        It disables leaving the dialog when editing a text field.

        But several press of Return key can be use to validate and close the
        dialog.
        """
        if event.key() in (qt.Qt.Key_Enter, qt.Qt.Key_Return):
            # Bypass QDialog keyPressEvent
            # To avoid leaving the dialog when pressing enter on a text field
            if self._minValue.hasFocus():
                nextFocus = self._maxValue
            elif self._maxValue.hasFocus():
                if self.isModal():
                    nextFocus = self._buttonsModal.button(qt.QDialogButtonBox.Apply)
                else:
                    nextFocus = self._buttonsNonModal.button(qt.QDialogButtonBox.Close)
            else:
                nextFocus = None
            if nextFocus is not None:
                nextFocus.setFocus(qt.Qt.OtherFocusReason)
        else:
            super(ColormapDialog, self).keyPressEvent(event)
