diff --git a/examples/compareBackends.py b/examples/compareBackends.py new file mode 100644 index 0000000000..00208011ca --- /dev/null +++ b/examples/compareBackends.py @@ -0,0 +1,375 @@ +# /*########################################################################## +# +# Copyright (c) 2017-2021 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. +# +# ###########################################################################*/ + +""" +Compare one to one backend rendering +""" + +from __future__ import annotations + +__license__ = "MIT" + +import numpy +import sys +import functools + +from silx.gui import qt + +from silx.gui.plot import PlotWidget +from silx.gui.plot import items +from silx.gui.plot.items.marker import Marker +from silx.gui.plot.utils.axis import SyncAxes + + +_DESCRIPTIONS = {} + + +class MyPlotWindow(qt.QMainWindow): + """QMainWindow with selected tools""" + + def __init__(self, parent=None): + super(MyPlotWindow, self).__init__(parent) + + # Create a PlotWidget + self._plot1 = PlotWidget(parent=self, backend="mpl") + self._plot1.setGraphTitle("matplotlib") + self._plot2 = PlotWidget(parent=self, backend="opengl") + self._plot2.setGraphTitle("opengl") + + self.constraintX = SyncAxes( + [ + self._plot1.getXAxis(), + self._plot2.getXAxis(), + ] + ) + self.constraintY = SyncAxes( + [ + self._plot1.getYAxis(), + self._plot2.getYAxis(), + ] + ) + + plotWidget = qt.QWidget(self) + plotLayout = qt.QHBoxLayout(plotWidget) + plotLayout.addWidget(self._plot1) + plotLayout.addWidget(self._plot2) + plotLayout.setContentsMargins(0, 0, 0, 0) + plotLayout.setContentsMargins(0, 0, 0, 0) + + options = self.createOptions(self) + centralWidget = qt.QWidget(self) + layout = qt.QHBoxLayout(centralWidget) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(options) + layout.addWidget(plotWidget) + + self.setCentralWidget(centralWidget) + + self._state = {} + + def clear(self): + self._state = {} + + def createOptions(self, parent): + options = qt.QWidget(parent) + layout = qt.QVBoxLayout(options) + for id, description in _DESCRIPTIONS.items(): + label, _func = description + button = qt.QPushButton(label, self) + button.clicked.connect(functools.partial(self.showUseCase, id)) + layout.addWidget(button) + layout.addStretch() + return options + + def showUseCase(self, name: str): + description = _DESCRIPTIONS.get(name) + if description is None: + raise ValueError(f"Unknown use case '{name}'") + setupFunc = description[1] + self.clear() + for p in [self._plot1, self._plot2]: + p.clear() + setupFunc(self, p) + p.resetZoom() + + def _register(name, label): + def decorator(func): + _DESCRIPTIONS[name] = (label, func) + return func + + return decorator + + def _addLine( + self, + plot, + lineWidth: float, + lineStyle: str, + color: str, + gapColor: str | None, + curve: bool, + ): + state = self._state.setdefault(plot, {}) + x = state.get("x", 0) + y = state.get("y", 0) + x += 10 + state["x"] = x + state["y"] = y + + start = (x - 20, y + 0) + stop = (x + 40, y + 100) + + def createShape(): + shape = items.Shape("polylines") + shape.setPoints(numpy.array((start, stop))) + shape.setLineWidth(lineWidth) + shape.setLineStyle(lineStyle) + shape.setColor(color) + if gapColor is not None: + shape.setLineGapColor(gapColor) + return shape + + def createCurve(): + curve = items.Curve() + array = numpy.array((start, stop)).T + curve.setData(array[0], array[1]) + curve.setLineWidth(lineWidth) + curve.setLineStyle(lineStyle) + curve.setColor(color) + curve.setSymbol("") + if gapColor is not None: + curve.setLineGapColor(gapColor) + return curve + + if curve: + plot.addItem(createCurve()) + else: + plot.addItem(createShape()) + + @_register("linewidth", "Line width") + def _setupLineStyle(self, plot: PlotWidget): + self._addLine(plot, 0.5, "-", "#0000FF", None, curve=False) + self._addLine(plot, 1.0, "-", "#0000FF", None, curve=False) + self._addLine(plot, 2.0, "-", "#0000FF", None, curve=False) + self._addLine(plot, 4.0, "-", "#0000FF", None, curve=False) + self._addLine(plot, 0.5, "-", "#00FFFF", None, curve=True) + self._addLine(plot, 1.0, "-", "#00FFFF", None, curve=True) + self._addLine(plot, 2.0, "-", "#00FFFF", None, curve=True) + self._addLine(plot, 4.0, "-", "#00FFFF", None, curve=True) + + @_register("linestyle", "Line style") + def _setupLineStyle(self, plot: PlotWidget): + self._addLine(plot, 1.0, "--", "#0000FF", None, curve=False) + self._addLine(plot, 1.0, "-.", "#0000FF", None, curve=False) + self._addLine(plot, 1.0, ":", "#0000FF", None, curve=False) + self._addLine(plot, 2.0, "--", "#00FFFF", None, curve=True) + self._addLine(plot, 2.0, "-.", "#00FFFF", None, curve=True) + self._addLine(plot, 2.0, ":", "#00FFFF", None, curve=True) + + @_register("gapcolor", "LineStyle Gap Color") + def _setupLineStyleGapColor(self, plot): + self._addLine(plot, 1.0, "-", "#FF00FF", "black", curve=False) + self._addLine(plot, 1.0, "-.", "#FF00FF", "black", curve=False) + self._addLine(plot, 1.0, "--", "#FF00FF", "black", curve=False) + self._addLine(plot, 0.5, "--", "#FF00FF", "black", curve=False) + self._addLine(plot, 1.5, "--", "#FF00FF", "black", curve=False) + self._addLine(plot, 2.0, "--", "#FF00FF", "black", curve=False) + plot.setGraphXLimits(0, 100) + plot.setGraphYLimits(0, 100) + + @_register("curveshape", "Curve vs Shape") + def _setupLineStyleCurveShape(self, plot): + self._addLine(plot, 1.0, (0, (5, 5)), "#00FF00", None, curve=False) + self._addLine(plot, 4.0, (0, (3, 3)), "#00FF00", None, curve=False) + self._addLine(plot, 4.0, (0, (5, 5)), "#00FF00", None, curve=False) + self._addLine(plot, 4.0, (0, (7, 7)), "#00FF00", None, curve=False) + self._addLine(plot, 1.0, (0, (5, 5)), "#00FFFF", None, curve=True) + self._addLine(plot, 4.0, (0, (3, 3)), "#00FFFF", None, curve=True) + self._addLine(plot, 4.0, (0, (5, 5)), "#00FFFF", None, curve=True) + self._addLine(plot, 4.0, (0, (7, 7)), "#00FFFF", None, curve=True) + plot.setGraphXLimits(0, 100) + plot.setGraphYLimits(0, 100) + + @_register("text", "Text") + def _setupText(self, plot): + plot.getDefaultColormap().setName("viridis") + + # Add an image to the plot + x = numpy.outer(numpy.linspace(-10, 10, 200), numpy.linspace(-10, 5, 150)) + image = numpy.sin(x) / x + plot.addImage(image) + + label = Marker() + label.setPosition(40, 150) + label.setText("No background") + plot.addItem(label) + + label = Marker() + label.setPosition(50, 50) + label.setText("Foo bar\nmmmmmmmmmmmmmmmmmmmm") + label.setBackgroundColor("#FFFFFF44") + plot.addItem(label) + + label2 = Marker() + label2.setPosition(70, 70) + label2.setText("Foo bar") + label2.setColor("red") + label2.setBackgroundColor("#00000044") + plot.addItem(label2) + + label3 = Marker() + label3.setPosition(10, 70) + label3.setText("Pioupiou") + label3.setColor("yellow") + label3.setBackgroundColor("#000000") + plot.addItem(label3) + + @_register("marker", "Marker") + def _setupMarker(self, plot): + plot.getDefaultColormap().setName("viridis") + + # Add an image to the plot + x = numpy.outer(numpy.linspace(-10, 10, 200), numpy.linspace(-10, 5, 150)) + image = numpy.sin(x) / x + plot.addImage(image) + + label = Marker() + label.setSymbol("o") + label.setPosition(30, 30) + label.setColor("white") + plot.addItem(label) + + label = Marker() + label.setSymbol(".") + label.setPosition(50, 30) + label.setColor("white") + plot.addItem(label) + + label = Marker() + label.setSymbol(",") + label.setPosition(70, 30) + label.setColor("white") + plot.addItem(label) + + label = Marker() + label.setSymbol("+") + # label.setSymbolSize(100) + label.setPosition(30, 50) + label.setColor("white") + plot.addItem(label) + + label = Marker() + label.setSymbol("x") + label.setPosition(50, 50) + label.setColor("white") + plot.addItem(label) + + label = Marker() + label.setSymbol("d") + label.setPosition(70, 50) + label.setColor("white") + plot.addItem(label) + + label = Marker() + label.setSymbol("s") + label.setPosition(30, 70) + label.setColor("white") + plot.addItem(label) + + label = Marker() + label.setSymbol("|") + label.setPosition(50, 70) + label.setColor("white") + plot.addItem(label) + + label = Marker() + label.setSymbol("_") + label.setPosition(70, 70) + label.setColor("white") + plot.addItem(label) + + @_register("arrows", "Arrows") + def _setupArrows(self, plot): + """Display few lines with markers.""" + plot.setDataMargins(0.1, 0.1, 0.1, 0.1) + + plot.addCurve( + x=[-10, 0, 0, -10, -10], y=[90, 90, 10, 10, 90], legend="box1", color="gray" + ) + plot.addCurve( + x=[110, 100, 100, 110, 110], + y=[90, 90, 10, 10, 90], + legend="box2", + color="gray", + ) + plot.addCurve( + y=[-10, 0, 0, -10, -10], x=[90, 90, 10, 10, 90], legend="box3", color="gray" + ) + plot.addCurve( + y=[110, 100, 100, 110, 110], + x=[90, 90, 10, 10, 90], + legend="box4", + color="gray", + ) + + def addCompositeLine( + source, destination, symbolSource, symbolDestination, legend, color + ): + line = numpy.array([source, destination]).T + plot.addCurve(x=line[0, :], y=line[1, :], color=color, legend=legend) + plot.addMarker(x=source[0], y=source[1], symbol=symbolSource, color=color) + plot.addMarker( + x=destination[0], + y=destination[1], + symbol=symbolDestination, + color=color, + ) + + addCompositeLine([0, 50], [100, 50], "caretleft", "caretright", "l1", "red") + addCompositeLine([0, 30], [100, 30], "tickup", "tickdown", "l2", "blue") + addCompositeLine([0, 70], [100, 70], "|", "|", "l3", "black") + + addCompositeLine([50, 0], [50, 100], "caretdown", "caretup", "l4", "red") + addCompositeLine([30, 0], [30, 100], "tickleft", "tickright", "l5", "blue") + addCompositeLine([70, 0], [70, 100], "_", "_", "l6", "black") + + +def main(): + global app + app = qt.QApplication([]) + + # Create the ad hoc window containing a PlotWidget and associated tools + window = MyPlotWindow() + window.setAttribute(qt.Qt.WA_DeleteOnClose) + window.show() + if len(sys.argv) == 1: + useCase = "linestyle" + else: + useCase = sys.argv[1] + window.showUseCase(useCase) + app.exec() + + +if __name__ == "__main__": + main() diff --git a/examples/compositeline.py b/examples/compositeline.py deleted file mode 100644 index fbf43aad60..0000000000 --- a/examples/compositeline.py +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env python -# /*########################################################################## -# -# Copyright (c) 2016-2019 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. -# -# ###########################################################################*/ -""" -Example to show the use of markers to draw head and tail of lines. -""" - -__license__ = "MIT" - -import logging -from silx.gui.plot import Plot1D -from silx.gui import qt -import numpy - - -logging.basicConfig() -logger = logging.getLogger(__name__) - - -def main(argv=None): - """Display few lines with markers.""" - global app # QApplication must be global to avoid seg fault on quit - app = qt.QApplication([]) - sys.excepthook = qt.exceptionHandler - - mainWindow = Plot1D(backend="gl") - mainWindow.setAttribute(qt.Qt.WA_DeleteOnClose) - plot = mainWindow - plot.setDataMargins(0.1, 0.1, 0.1, 0.1) - - plot.addCurve( - x=[-10, 0, 0, -10, -10], y=[90, 90, 10, 10, 90], legend="box1", color="gray" - ) - plot.addCurve( - x=[110, 100, 100, 110, 110], y=[90, 90, 10, 10, 90], legend="box2", color="gray" - ) - plot.addCurve( - y=[-10, 0, 0, -10, -10], x=[90, 90, 10, 10, 90], legend="box3", color="gray" - ) - plot.addCurve( - y=[110, 100, 100, 110, 110], x=[90, 90, 10, 10, 90], legend="box4", color="gray" - ) - - def addLine(source, destination, symbolSource, symbolDestination, legend, color): - line = numpy.array([source, destination]).T - plot.addCurve(x=line[0, :], y=line[1, :], color=color, legend=legend) - plot.addMarker(x=source[0], y=source[1], symbol=symbolSource, color=color) - plot.addMarker( - x=destination[0], y=destination[1], symbol=symbolDestination, color=color - ) - - addLine([0, 50], [100, 50], "caretleft", "caretright", "l1", "red") - addLine([0, 30], [100, 30], "tickup", "tickdown", "l2", "blue") - addLine([0, 70], [100, 70], "|", "|", "l3", "black") - - addLine([50, 0], [50, 100], "caretdown", "caretup", "l4", "red") - addLine([30, 0], [30, 100], "tickleft", "tickright", "l5", "blue") - addLine([70, 0], [70, 100], "_", "_", "l6", "black") - - mainWindow.setVisible(True) - return app.exec() - - -if __name__ == "__main__": - import sys - - sys.exit(main(argv=sys.argv[1:])) 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..facb63c8eb 100755 --- a/src/silx/gui/plot/backends/BackendMatplotlib.py +++ b/src/silx/gui/plot/backends/BackendMatplotlib.py @@ -962,6 +962,9 @@ def addMarker( else: assert False + if bgcolor is None: + bgcolor = "none" + marker = self._getMarkerFromSymbol(symbol) if x is not None and y is not None: line = ax.plot( @@ -1636,6 +1639,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 9aacbc3818..807ac69fa0 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, @@ -590,6 +603,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 @@ -640,6 +654,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 @@ -673,6 +688,7 @@ def _renderItems(self, overlay=False): (0, height), color=color, width=item["linewidth"], + dashOffset=item["dashoffset"], dashPattern=item["dashpattern"], ) context.matrix = self.matScreenProj @@ -861,21 +877,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, @@ -996,6 +1028,7 @@ def addCurve( if fill is True: fillColor = color + dashoffset, dashpattern = self._lineStyleToDashOffsetPattern(linestyle) curve = glutils.GLPlotCurve2D( x, y, @@ -1005,7 +1038,8 @@ def addCurve( lineColor=color, lineGapColor=gapcolor, lineWidth=linewidth, - lineDashPattern=self._lineStyleToDashPattern(linestyle), + lineDashOffset=dashoffset, + lineDashPattern=dashpattern, marker=symbol, markerColor=color, markerSize=symbolsize, @@ -1110,9 +1144,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 getDefaultFont(self): @@ -1145,7 +1188,7 @@ def addMarker( if font is None: font = self.getDefaultFont() - dashpattern = self._lineStyleToDashPattern(linestyle) + dashoffset, dashpattern = self._lineStyleToDashOffsetPattern(linestyle) return _MarkerItem( x, y, @@ -1153,6 +1196,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