diff --git a/src/silx/gui/plot/LegendSelector.py b/src/silx/gui/plot/LegendSelector.py index 5d92ff9141..22348fb0f6 100755 --- a/src/silx/gui/plot/LegendSelector.py +++ b/src/silx/gui/plot/LegendSelector.py @@ -252,7 +252,7 @@ def setData(self, modelIndex, value, role): elif role == self.iconLineWidthRole: item[1]["linewidth"] = int(value) elif role == self.iconLineStyleRole: - item[1]["linestyle"] = str(value) + item[1]["linestyle"] = value elif role == self.iconSymbolRole: item[1]["symbol"] = str(value) elif role == qt.Qt.CheckStateRole: @@ -674,7 +674,7 @@ def _handleMouseClick(self, modelIndex): "legend": str(modelIndex.data(qt.Qt.DisplayRole)), "icon": { "linewidth": str(modelIndex.data(LegendModel.iconLineWidthRole)), - "linestyle": str(modelIndex.data(LegendModel.iconLineStyleRole)), + "linestyle": modelIndex.data(LegendModel.iconLineStyleRole), "symbol": str(modelIndex.data(LegendModel.iconSymbolRole)), }, "selected": modelIndex.data(qt.Qt.CheckStateRole), diff --git a/src/silx/gui/plot/backends/BackendBase.py b/src/silx/gui/plot/backends/BackendBase.py index ded09a0455..8d70286a8a 100755 --- a/src/silx/gui/plot/backends/BackendBase.py +++ b/src/silx/gui/plot/backends/BackendBase.py @@ -126,13 +126,14 @@ def addCurve( - 's' square :param float linewidth: The width of the curve in pixels - :param str linestyle: Type of line:: + :param linestyle: Type of line:: - ' ' or '' no line - '-' solid line - '--' dashed line - '-.' dash-dot line - ':' dotted line + - (offset, (dash pattern)) :param str yaxis: The Y axis this curve belongs to in: 'left', 'right' :param xerror: Values with the uncertainties on the x values @@ -190,7 +191,7 @@ def addShape( :param str color: Color of the item :param bool fill: True to fill the shape :param bool overlay: True if item is an overlay, False otherwise - :param str linestyle: Style of the line. + :param linestyle: Style of the line. Only relevant for line markers where X or Y is None. Value in: @@ -199,6 +200,7 @@ def addShape( - '--' dashed line - '-.' dash-dot line - ':' dotted line + - (offset, (dash pattern)) :param float linewidth: Width of the line. Only relevant for line markers where X or Y is None. :param str gapcolor: Background color of the line, e.g., 'blue', 'b', @@ -214,7 +216,7 @@ def addMarker( text: str | None, color: str, symbol: str | None, - linestyle: str, + linestyle: str | tuple[float, tuple[float, ...] | None], linewidth: float, constraint: Callable[[float, float], tuple[float, float]] | None, yaxis: str, @@ -250,6 +252,7 @@ def addMarker( - '--' dashed line - '-.' dash-dot line - ':' dotted line + - (offset, (dash pattern)) :param linewidth: Width of the line. Only relevant for line markers where X or Y is None. :param constraint: A function filtering marker displacement by @@ -300,8 +303,9 @@ def setGraphCursor(self, flag, color, linewidth, linestyle): - '--' dashed line - '-.' dash-dot line - ':' dotted line + - (offset, (dash pattern)) - :type linestyle: None or one of the predefined styles. + :type linestyle: None, one of the predefined styles or (offset, (dash pattern)). """ pass diff --git a/src/silx/gui/plot/backends/BackendMatplotlib.py b/src/silx/gui/plot/backends/BackendMatplotlib.py index 1c4dfb1b0a..0e52d824f9 100755 --- a/src/silx/gui/plot/backends/BackendMatplotlib.py +++ b/src/silx/gui/plot/backends/BackendMatplotlib.py @@ -1636,6 +1636,10 @@ def draw(self): self.updateZOrder() + if not qt_inspect.isValid(self): + _logger.info("draw requested but widget no longer exists") + return + # Starting with mpl 2.1.0, toggling autoscale raises a ValueError # in some situations. See #1081, #1136, #1163, if self._matplotlibVersion >= Version("2.0.0"): diff --git a/src/silx/gui/plot/backends/BackendOpenGL.py b/src/silx/gui/plot/backends/BackendOpenGL.py index 10eaca7bd4..da2fec0a61 100755 --- a/src/silx/gui/plot/backends/BackendOpenGL.py +++ b/src/silx/gui/plot/backends/BackendOpenGL.py @@ -58,7 +58,17 @@ class _ShapeItem(dict): def __init__( - self, x, y, shape, color, fill, overlay, linewidth, dashpattern, gapcolor + self, + x, + y, + shape, + color, + fill, + overlay, + linewidth, + dashoffset, + dashpattern, + gapcolor, ): super(_ShapeItem, self).__init__() @@ -85,6 +95,7 @@ def __init__( "x": x, "y": y, "linewidth": linewidth, + "dashoffset": dashoffset, "dashpattern": dashpattern, "gapcolor": gapcolor, } @@ -100,6 +111,7 @@ def __init__( color, symbol, linewidth, + dashoffset, dashpattern, constraint, yaxis, @@ -125,6 +137,7 @@ def __init__( "constraint": constraint if isConstraint else None, "symbol": symbol, "linewidth": linewidth, + "dashoffset": dashoffset, "dashpattern": dashpattern, "yaxis": yaxis, "font": font, @@ -588,6 +601,7 @@ def _renderItems(self, overlay=False): color=item["color"], gapColor=item["gapcolor"], width=item["linewidth"], + dashOffset=item["dashoffset"], dashPattern=item["dashpattern"], ) context.matrix = self.matScreenProj @@ -638,6 +652,7 @@ def _renderItems(self, overlay=False): (pixelPos[1], pixelPos[1]), color=color, width=item["linewidth"], + dashOffset=item["dashoffset"], dashPattern=item["dashpattern"], ) context.matrix = self.matScreenProj @@ -671,6 +686,7 @@ def _renderItems(self, overlay=False): (0, height), color=color, width=item["linewidth"], + dashOffset=item["dashoffset"], dashPattern=item["dashpattern"], ) context.matrix = self.matScreenProj @@ -859,21 +875,37 @@ def _castArrayTo(v): else: raise ValueError("Unsupported data type") - _DASH_PATTERNS = { # Convert from linestyle to dash pattern - "": None, - " ": None, - "-": (), - "--": (3.7, 1.6, 3.7, 1.6), - "-.": (6.4, 1.6, 1, 1.6), - ":": (1, 1.65, 1, 1.65), - None: None, + _DASH_PATTERNS = { + "": (0.0, None), + " ": (0.0, None), + "-": (0.0, ()), + "--": (0.0, (3.7, 1.6, 3.7, 1.6)), + "-.": (0.0, (6.4, 1.6, 1, 1.6)), + ":": (0.0, (1, 1.65, 1, 1.65)), + None: (0.0, None), } + """Convert from linestyle to (offset, (dash pattern)) - def _lineStyleToDashPattern( - self, style: str | None - ) -> tuple[float, float, float, float] | tuple[()] | None: - """Convert a linestyle to its corresponding dash pattern""" - return self._DASH_PATTERNS[style] + Note: dash pattern internal convention differs from matplotlib: + - None: no line at all + - (): "solid" line + """ + + def _lineStyleToDashOffsetPattern( + self, style + ) -> tuple[float, tuple[float, float, float, float] | tuple[()] | None]: + """Convert a linestyle to its corresponding offset and dash pattern""" + if style is None or isinstance(style, str): + return self._DASH_PATTERNS[style] + + # (offset, (dash pattern)) case + offset, pattern = style + if pattern is None: + # Convert from matplotlib to internal representation of solid + pattern = () + if len(pattern) == 2: + pattern = pattern * 2 + return offset, pattern def addCurve( self, @@ -994,6 +1026,7 @@ def addCurve( if fill is True: fillColor = color + dashoffset, dashpattern = self._lineStyleToDashOffsetPattern(linestyle) curve = glutils.GLPlotCurve2D( x, y, @@ -1003,7 +1036,8 @@ def addCurve( lineColor=color, lineGapColor=gapcolor, lineWidth=linewidth, - lineDashPattern=self._lineStyleToDashPattern(linestyle), + lineDashOffset=dashoffset, + lineDashPattern=dashpattern, marker=symbol, markerColor=color, markerSize=symbolsize, @@ -1108,9 +1142,18 @@ def addShape( if self._plotFrame.yAxis.isLog and y.min() <= 0.0: raise RuntimeError("Cannot add item with Y <= 0 with Y axis log scale") - dashpattern = self._lineStyleToDashPattern(linestyle) + dashoffset, dashpattern = self._lineStyleToDashOffsetPattern(linestyle) return _ShapeItem( - x, y, shape, color, fill, overlay, linewidth, dashpattern, gapcolor + x, + y, + shape, + color, + fill, + overlay, + linewidth, + dashoffset, + dashpattern, + gapcolor, ) def addMarker( @@ -1128,7 +1171,7 @@ def addMarker( bgcolor: RGBAColorType | None, ): font = qt.QApplication.instance().font() if font is None else font - dashpattern = self._lineStyleToDashPattern(linestyle) + dashoffset, dashpattern = self._lineStyleToDashOffsetPattern(linestyle) return _MarkerItem( x, y, @@ -1136,6 +1179,7 @@ def addMarker( color, symbol, linewidth, + dashoffset, dashpattern, constraint, yaxis, diff --git a/src/silx/gui/plot/backends/glutils/GLPlotCurve.py b/src/silx/gui/plot/backends/glutils/GLPlotCurve.py index 54acdc6f7c..5ead50ff31 100644 --- a/src/silx/gui/plot/backends/glutils/GLPlotCurve.py +++ b/src/silx/gui/plot/backends/glutils/GLPlotCurve.py @@ -292,6 +292,8 @@ class GLLines2D(object): "unscaled" dash pattern as 4 lengths in points (dash1, gap1, dash2, gap2). This pattern is scaled with the line width. Set to () to draw solid lines (default), and to None to disable rendering. + :param float dashOffset: The offset in points the patterns starts at. + The offset is scaled with the line width. :param drawMode: OpenGL drawing mode :param List[float] offset: Translation of coordinates (ox, oy) """ @@ -353,13 +355,14 @@ class GLLines2D(object): /* Dashes: [0, x], [y, z] Dash period: w */ uniform vec4 dash; + uniform float dashOffset; uniform vec4 gapColor; varying float vDist; varying vec4 vColor; void main(void) { - float dist = mod(vDist, dash.w); + float dist = mod(vDist + dashOffset, dash.w); if ((dist > dash.x && dist < dash.y) || dist > dash.z) { if (gapColor.a == 0.) { discard; // Discard full transparent bg color @@ -383,6 +386,7 @@ def __init__( color=(0.0, 0.0, 0.0, 1.0), gapColor=None, width=1, + dashOffset=0.0, dashPattern=(), drawMode=None, offset=(0.0, 0.0), @@ -416,6 +420,7 @@ def __init__( self.gapColor = gapColor self.width = width self.dashPattern = dashPattern + self.dashOffset = dashOffset self.offset = offset self._drawMode = drawMode if drawMode is not None else gl.GL_LINE_STRIP @@ -447,6 +452,7 @@ def render(self, context): offset * scale for offset in numpy.cumsum(self.dashPattern) ) gl.glUniform4f(program.uniforms["dash"], *dashOffsets) + gl.glUniform1f(program.uniforms["dashOffset"], self.dashOffset * scale) if self.gapColor is None: # Use fully transparent color which gets discarded in shader @@ -1188,6 +1194,7 @@ def __init__( lineColor=(0.0, 0.0, 0.0, 1.0), lineGapColor=None, lineWidth=1, + lineDashOffset=0.0, lineDashPattern=(), marker=SQUARE, markerColor=(0.0, 0.0, 0.0, 1.0), @@ -1278,6 +1285,7 @@ def deduce_baseline(baseline): self.lines.color = lineColor self.lines.gapColor = lineGapColor self.lines.width = lineWidth + self.lines.dashOffset = lineDashOffset self.lines.dashPattern = lineDashPattern self.lines.offset = self.offset @@ -1305,6 +1313,8 @@ def deduce_baseline(baseline): lineWidth = _proxyProperty(("lines", "width")) + lineDashOffset = _proxyProperty(("lines", "dashOffset")) + lineDashPattern = _proxyProperty(("lines", "dashPattern")) marker = _proxyProperty(("points", "marker")) diff --git a/src/silx/gui/plot/items/core.py b/src/silx/gui/plot/items/core.py index 50386f4c19..324e49ecb7 100644 --- a/src/silx/gui/plot/items/core.py +++ b/src/silx/gui/plot/items/core.py @@ -23,12 +23,13 @@ # ###########################################################################*/ """This module provides the base class for items of the :class:`Plot`. """ +from __future__ import annotations + __authors__ = ["T. Vincent"] __license__ = "MIT" __date__ = "08/12/2020" -import collections from collections import abc from copy import deepcopy import logging @@ -834,50 +835,74 @@ def setSymbolSize(self, size): self._updated(ItemChangedType.SYMBOL_SIZE) +LineStyleType = Union[ + str, + Tuple[float, None], + Tuple[float, Tuple[float, float]], + Tuple[float, Tuple[float, float, float, float]], +] +"""Type for :class:`LineMixIn`'s line style""" + + class LineMixIn(ItemMixInBase): """Mix-in class for item with line""" - _DEFAULT_LINEWIDTH = 1.0 + _DEFAULT_LINEWIDTH: float = 1.0 """Default line width""" - _DEFAULT_LINESTYLE = "-" + _DEFAULT_LINESTYLE: LineStyleType = "-" """Default line style""" _SUPPORTED_LINESTYLE = "", " ", "-", "--", "-.", ":", None """Supported line styles""" def __init__(self): - self._linewidth = self._DEFAULT_LINEWIDTH - self._linestyle = self._DEFAULT_LINESTYLE + self._linewidth: float = self._DEFAULT_LINEWIDTH + self._linestyle: LineStyleType = self._DEFAULT_LINESTYLE @classmethod - def getSupportedLineStyles(cls): - """Returns list of supported line styles. - - :rtype: List[str,None] - """ + def getSupportedLineStyles(cls) -> tuple[str | None]: + """Returns list of supported constant line styles.""" return cls._SUPPORTED_LINESTYLE - def getLineWidth(self): - """Return the curve line width in pixels - - :rtype: float - """ + def getLineWidth(self) -> float: + """Return the curve line width in pixels""" return self._linewidth - def setLineWidth(self, width): + def setLineWidth(self, width: float): """Set the width in pixel of the curve line See :meth:`getLineWidth`. - - :param float width: Width in pixels """ width = float(width) if width != self._linewidth: self._linewidth = width self._updated(ItemChangedType.LINE_WIDTH) - def getLineStyle(self): + @classmethod + def isValidLineStyle(cls, style: LineStyleType | None) -> bool: + """Returns True for valid styles""" + if style is None or style in cls.getSupportedLineStyles(): + return True + if not isinstance(style, tuple): + return False + if ( + len(style) == 2 + and isinstance(style[0], float) + and ( + style[1] is None + or style[1] == () + or ( + isinstance(style[1], tuple) + and len(style[1]) in (2, 4) + and all(map(lambda item: isinstance(item, float), style[1])) + ) + ) + ): + return True + return False + + def getLineStyle(self) -> LineStyleType: """Return the type of the line Type of line:: @@ -887,20 +912,19 @@ def getLineStyle(self): - '--' dashed line - '-.' dash-dot line - ':' dotted line - - :rtype: str + - (offset, (dash pattern)) """ return self._linestyle - def setLineStyle(self, style): + def setLineStyle(self, style: LineStyleType | None): """Set the style of the curve line. See :meth:`getLineStyle`. - :param str style: Line style + :param style: Line style """ - style = str(style) - assert style in self.getSupportedLineStyles() + if not self.isValidLineStyle(style): + raise ValueError(f"No a valid line style: {style}") if style is None: style = self._DEFAULT_LINESTYLE if style != self._linestyle: diff --git a/src/silx/gui/plot/items/curve.py b/src/silx/gui/plot/items/curve.py index 2e2f62fa69..7d1150bb1e 100644 --- a/src/silx/gui/plot/items/curve.py +++ b/src/silx/gui/plot/items/curve.py @@ -23,6 +23,7 @@ # ###########################################################################*/ """This module provides the :class:`Curve` item of the :class:`Plot`. """ +from __future__ import annotations __authors__ = ["T. Vincent"] __license__ = "MIT" @@ -43,6 +44,7 @@ FillMixIn, LineMixIn, LineGapColorMixIn, + LineStyleType, SymbolMixIn, BaselineMixIn, HighlightedMixIn, @@ -69,7 +71,7 @@ class CurveStyle(_Style): def __init__( self, color=None, - linestyle=None, + linestyle: LineStyleType | None = None, linewidth=None, symbol=None, symbolsize=None, @@ -86,8 +88,8 @@ def __init__( color = colors.rgba(color) self._color = color - if linestyle is not None: - assert linestyle in LineMixIn.getSupportedLineStyles() + if not LineMixIn.isValidLineStyle(linestyle): + raise ValueError(f"Not a valid line style: {linestyle}") self._linestyle = linestyle self._linewidth = None if linewidth is None else float(linewidth) @@ -120,7 +122,7 @@ def getLineGapColor(self): """ return self._gapcolor - def getLineStyle(self): + def getLineStyle(self) -> LineStyleType | None: """Return the type of the line or None if not set. Type of line:: @@ -130,8 +132,7 @@ def getLineStyle(self): - '--' dashed line - '-.' dash-dot line - ':' dotted line - - :rtype: Union[str,None] + - (offset, (dash pattern)) """ return self._linestyle diff --git a/src/silx/gui/plot/test/testItem.py b/src/silx/gui/plot/test/testItem.py index 7af8537dae..8a6db40289 100644 --- a/src/silx/gui/plot/test/testItem.py +++ b/src/silx/gui/plot/test/testItem.py @@ -29,11 +29,12 @@ import numpy +import pytest from silx.gui.utils.testutils import SignalListener from silx.gui.plot.items.roi import RegionOfInterest from silx.gui.plot.items import ItemChangedType -from silx.gui.plot import items, PlotWidget +from silx.gui.plot import items from .utils import PlotWidgetTestCase @@ -514,3 +515,51 @@ def testPlotWidgetAddShape(plotWidget): assert numpy.array_equal(shape.getPoints(copy=False), ((0, 0), (1, 1))) assert shape.getName() == "test" assert shape.getType() == "polygon" + + +@pytest.mark.parametrize( + "linestyle", + ( + "", + "-", + "--", + "-.", + ":", + (0.0, None), + (0.5, ()), + (0.0, (5.0, 5.0)), + (4.0, (8.0, 4.0, 4.0, 4.0)), + ), +) +@pytest.mark.parametrize("plotWidget", ("mpl", "gl"), indirect=True) +def testLineStyle(qapp_utils, plotWidget, linestyle): + """Test different line styles for LineMixIn items""" + plotWidget.setGraphTitle(f"Line style: {linestyle}") + + curve = plotWidget.addCurve((0, 1), (0, 1), linestyle=linestyle) + assert curve.getLineStyle() == linestyle + + histogram = plotWidget.addHistogram((0.25, 0.75, 0.25), (0.0, 0.33, 0.66, 1.0)) + histogram.setLineStyle(linestyle) + assert histogram.getLineStyle() == linestyle + + polylines = plotWidget.addShape( + (0, 1), (1, 0), shape="polylines", linestyle=linestyle + ) + assert polylines.getLineStyle() == linestyle + + rectangle = plotWidget.addShape( + (0.4, 0.6), (0.4, 0.6), shape="rectangle", linestyle=linestyle + ) + assert rectangle.getLineStyle() == linestyle + + xmarker = plotWidget.addXMarker(0.5) + xmarker.setLineStyle(linestyle) + assert xmarker.getLineStyle() == linestyle + + ymarker = plotWidget.addYMarker(0.5) + ymarker.setLineStyle(linestyle) + assert ymarker.getLineStyle() == linestyle + + plotWidget.replot() + qapp_utils.qWait(100) diff --git a/src/silx/gui/plot/tools/test/testCurveLegendsWidget.py b/src/silx/gui/plot/tools/test/testCurveLegendsWidget.py index 22afb95ada..9f1a184095 100644 --- a/src/silx/gui/plot/tools/test/testCurveLegendsWidget.py +++ b/src/silx/gui/plot/tools/test/testCurveLegendsWidget.py @@ -97,7 +97,14 @@ def testUpdateCurves(self): # Change curve style curve = self.plot.getCurve("a") curve.setLineWidth(2) - for linestyle in (":", "", "--", "-"): + for linestyle in ( + ":", + "", + "--", + "-", + (0.0, (5.0, 5.0)), + (5.0, (10.0, 2.0, 2.0, 5.0)), + ): with self.subTest(linestyle=linestyle): curve.setLineStyle(linestyle) self.qapp.processEvents() diff --git a/src/silx/gui/widgets/LegendIconWidget.py b/src/silx/gui/widgets/LegendIconWidget.py index c5d091e122..ae86c35d02 100755 --- a/src/silx/gui/widgets/LegendIconWidget.py +++ b/src/silx/gui/widgets/LegendIconWidget.py @@ -153,6 +153,7 @@ def __init__(self, parent=None): # Line attributes self.lineStyle = qt.Qt.NoPen + self.__dashPattern = [] self.lineWidth = 1.0 self.lineColor = qt.Qt.green @@ -212,12 +213,21 @@ def setLineStyle(self, style): - '--': dashed - ':': dotted - '-.': dash and dot + - (offset, (dash pattern)) - :param str style: The linestyle to use + :param style: The linestyle to use """ + print("setLineStyle", style) if style not in _LineStyles: - raise ValueError("Unknown style: %s", style) - self.lineStyle = _LineStyles[style] + self.lineStyle = qt.Qt.SolidLine + dashPattern = style[1] + if dashPattern is None or dashPattern == (): + self.__dashPattern = None + else: + self.__dashPattern = style[1] + else: + self.lineStyle = _LineStyles[style] + self.__dashPattern = None self.update() def _toLut(self, colormap): @@ -382,6 +392,8 @@ def paint(self, painter, rect, palette): self.lineStyle, qt.Qt.FlatCap, ) + if self.__dashPattern is not None: + linePen.setDashPattern(self.__dashPattern) llist.append((linePath, linePen, lineBrush)) isValidSymbol = len(self.symbol) and self.symbol not in _NoSymbols