PythonQwt 0.16.2__py3-none-any.whl → 0.16.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PythonQwt
3
- Version: 0.16.2
3
+ Version: 0.16.3
4
4
  Summary: Qt plotting widgets for Python
5
5
  Author-email: Pierre Raybaut <pierre.raybaut@gmail.com>
6
6
  License: PythonQwt License Agreement
@@ -1,5 +1,5 @@
1
- pythonqwt-0.16.2.dist-info/licenses/LICENSE,sha256=qjEk8TRuXmFS7QC-omINvD1UPGqWaOs6CzcCZoMEhdI,33457
2
- qwt/__init__.py,sha256=U3tgDHGBj6RL0pfmUJR82xLCU-f4m_uzWb3LuSIr3lI,5990
1
+ pythonqwt-0.16.3.dist-info/licenses/LICENSE,sha256=qjEk8TRuXmFS7QC-omINvD1UPGqWaOs6CzcCZoMEhdI,33457
2
+ qwt/__init__.py,sha256=0eKAVij0c3t6sNd8fHi5OulCvZbC3PAT6hZrJcCSgJU,5990
3
3
  qwt/_math.py,sha256=fNcPJcaK-ldCCFB20T2N58LEkQ1lwZY1-q5veXXhwl8,1501
4
4
  qwt/color_map.py,sha256=fLRpymqOXtLrhuPrbbCxTvNINbr2GH7pYBG4fZidrcs,11812
5
5
  qwt/column_symbol.py,sha256=0aFyQ05ryTKbaSTctkhtXa0pB_tWG444zlH3991_bt0,5760
@@ -7,11 +7,11 @@ qwt/dyngrid_layout.py,sha256=wgff6mZ1okNhQRRbwaw1I__vOXqGdUG0uafkShuns08,13477
7
7
  qwt/graphic.py,sha256=rSxvgIAm9ctcyszvZrN_03KpBhKSHabbPYdcrztSta8,28962
8
8
  qwt/interval.py,sha256=PMrCQxV9NXGk2JG0MNjJ3zGTNTSSRfa_noxbpm-Okns,12287
9
9
  qwt/legend.py,sha256=GBUim67HWgwLat5tOWDpK98fWcFxV-RkxWjoyWd9Gao,31313
10
- qwt/null_paintdevice.py,sha256=1pgbcvyRTVN32RGzuPzUOm0iUImjvPQ4l5OfJVyA3hg,9375
10
+ qwt/null_paintdevice.py,sha256=MQ_H4yc9qMqjAoCziaP3Sa6Mnu9wMSpbxTDisI7j1Dw,9381
11
11
  qwt/painter.py,sha256=k345puF4MAoTye0WBveiTNpCNWXx1K2o62_I0PZYlIA,16536
12
12
  qwt/painter_command.py,sha256=7D3goNsX_2Ufr5kCpC3Cr1t8GoD5Yz0G1oIZJKvoGBY,8501
13
- qwt/plot.py,sha256=XXcITYdLiAjIbT-8YfGkroNn5IhZsUs9oPvm1k_uFoE,76659
14
- qwt/plot_canvas.py,sha256=-Yjlkt0igkrsis6z8GaT2DXOMeaR699gi5wmU9EtPH4,30835
13
+ qwt/plot.py,sha256=qw35nDxdDcrH-5MxijNo73r3xZ7EYb0S1UiHpavhEVE,77707
14
+ qwt/plot_canvas.py,sha256=KK4saSTuoz5rab2xaQCAimlQlNQIo6GE3TVmHhMdsIc,31038
15
15
  qwt/plot_curve.py,sha256=ThlfxUjB4yiP_Dh96P7PXusyqUfDWcRcyt31OHvu2vk,37059
16
16
  qwt/plot_directpainter.py,sha256=JCWNhCC21L1A1HZnxd-C95QNlrVJbLztmGab-X8gCGc,10165
17
17
  qwt/plot_grid.py,sha256=h-cDTzibEzQyX2g25SBg5ysyXwmONmMFy9NcuzFySYc,16607
@@ -19,9 +19,10 @@ qwt/plot_layout.py,sha256=tD94nsFzWt1LQNFxgZbYsyM66ib6I6wBK3ObS7M1w_o,46183
19
19
  qwt/plot_marker.py,sha256=6vDHdMsyGClMruWLA4lChUCpoLzmxGNkvmICm0KjP6I,21284
20
20
  qwt/plot_renderer.py,sha256=v-fSwicMGQBa_kQx4hJJptlVNpNsKbgWk9jjqG-r_1g,27201
21
21
  qwt/plot_series.py,sha256=Wa_Wunu-t_L8vl95RJq6__k-XA8UV2YNIsc8yNGpTT8,10654
22
+ qwt/qtdesigner.py,sha256=9oXSPD3m6vk1Tc8KLUM7daRuELXnOvNumbo1hlcrvJ4,6753
22
23
  qwt/qthelpers.py,sha256=GpvLr96Ip8SIGwyFfs0f7ocHyKPB7PEjHBQyrpw84xw,1405
23
24
  qwt/scale_div.py,sha256=_iI5cy4s8H6wigE8xsn7W44T2-DqVADqdjQ31uJPa_I,9266
24
- qwt/scale_draw.py,sha256=KYal9YOpvHjFWGkBFPVVNq_64cxM1vsHg_f_n-2nKNU,40547
25
+ qwt/scale_draw.py,sha256=ZE8NSm3qSqwGtYo236mUZT5OC7weTwTWo_GZorhAIN4,40545
25
26
  qwt/scale_engine.py,sha256=7KQB5k-JXuJMsEMQXBJBNppCN-rTN97UKQCYXdazvqQ,36674
26
27
  qwt/scale_map.py,sha256=rHmPv7e9voEFuAIInQJq60mTtIGrLMViNlgJYu20znA,9605
27
28
  qwt/scale_widget.py,sha256=p202DrRgMraGbVcubzkatvVXOLbO9CPho729hz6gqa0,27125
@@ -46,16 +47,21 @@ qwt/tests/test_eventfilter.py,sha256=31vCsCzRcoq3cyMlDAH5fthA45QqeFXKaWyhfZME6rE
46
47
  qwt/tests/test_highdpi.py,sha256=Xafo016q-RX4918JddBGD60HZ4GpuYJBJokEO15md24,1053
47
48
  qwt/tests/test_image.py,sha256=yY_AplKEdm-M7uB5G50p4E68ZzNamYvGSfN5rvlVPPM,6549
48
49
  qwt/tests/test_issue107_gdi_leak.py,sha256=7IgZd9vJyD0_ea4CIDJ_SjlYDwYkA94aSRWEqt4y2ew,2203
50
+ qwt/tests/test_issue109_backingstore.py,sha256=2PbNmobG7QsdXhE-vgPiKI6TaXbAz9m6G_qIDIkvMa8,2461
51
+ qwt/tests/test_issue33_sidebyside.py,sha256=Tuu6XcRrV4ucPXmR8Y94uegRwCDWAN40EfuDQyVKHzE,10186
52
+ qwt/tests/test_issue88_mousetracking.py,sha256=jr6iSBb1LIa32Rqt8opBXElC_ar0ru1j8XG1RUM_y5c,2360
49
53
  qwt/tests/test_loadtest.py,sha256=ZTHatcGAbLyvS0BpbOWbzPD2Bjz4rckF7oxZsUVQ5KM,1812
50
54
  qwt/tests/test_logcurve.py,sha256=XOtR0asSqFfzc0VL0qfXN0fXNvxvLnz5fxmNZ2gzQWk,1129
51
55
  qwt/tests/test_mapdemo.py,sha256=e7rvB8IPKYVVii8UgdHSbOHLc3i7eB21EQNKZ151Kc8,3423
52
56
  qwt/tests/test_multidemo.py,sha256=YS-P08qcakD41OJy6ZI53ofKOnUvD-2_uum0yOLiIRc,2530
57
+ qwt/tests/test_qtdesigner.py,sha256=DOVapZrBvNAIA1GS0zFyLkA4Wxlyllh5lMhuGVfL2gc,1542
58
+ qwt/tests/test_qtdesigner.ui,sha256=iqxZYkst-ZhBe08YVeptVvbohathyEHFxsHtErpHMj0,685
53
59
  qwt/tests/test_relativemargin.py,sha256=Shn0aE7UeETSSaQFpt4DrMOFeMN3p4rmEHKqPhXfQTI,2097
54
60
  qwt/tests/test_simple.py,sha256=4opQp_-RkcVdpRyYSh89ddBkeXCPrppB-_2KTiu5vjI,2347
55
61
  qwt/tests/test_stylesheet.py,sha256=cuL1iY4pp5ZoMX4OI86amJA6R5SzQsH1JUWLtEiitZ4,950
56
62
  qwt/tests/test_symbols.py,sha256=YcGM0_Em8QtcbqpLSU98PxvGo7BIIYviP2o_4Fg2LbI,5607
57
63
  qwt/tests/test_vertical.py,sha256=hWhPASIRb1nrd6d3m3dPoccwl5RoCVH_r_C8VK2znMI,3285
58
- qwt/tests/utils.py,sha256=kmAl8B0yKq1FCzVU4pBDAXwcQUGa4BE8m6FsNECEVEQ,10584
64
+ qwt/tests/utils.py,sha256=5DdF53mL9VEn-f2WjXL4LMRO2MaUMFUvsKFprHnN3nw,10646
59
65
  qwt/tests/data/PythonQwt.svg,sha256=t-NYdVPqz7BcqtXNWCJvo8udrRmtGh57ksSYWUl1h4o,15232
60
66
  qwt/tests/data/bodedemo.png,sha256=8jP1YLE6HdwjEmrQyJclLxiYI4Zs4t0z0BoN1JT3GPQ,24414
61
67
  qwt/tests/data/cartesian.png,sha256=Cwbn5Jy3VT-OisFHxzMCVbfdbJacUMZ9uGCD9sCxA-Q,51094
@@ -78,8 +84,8 @@ qwt/tests/data/symbol.svg,sha256=ONkkohVqpg3OO2-XEZge7UZLqqNyOQXbSpLgHXcTBFU,127
78
84
  qwt/tests/data/symbols.png,sha256=wiqRBc2MJluJ-am9LtZ0K9DHV9BnreYMiEvfzJUFzCk,55586
79
85
  qwt/tests/data/testlauncher.png,sha256=WmucObp30QYYKsVRBs5-6t1aEN0FbIWRmOVFN1gt-vQ,139676
80
86
  qwt/tests/data/vertical.png,sha256=EvyG6q6rZm6pTT08Y_nzdBYCnqmoX1QDuEQtp2pyGlw,34076
81
- pythonqwt-0.16.2.dist-info/METADATA,sha256=iy-EOwIq8Y7_5k0xwlPDLm1TuBLmomXNEZ-vl3BMPhY,45515
82
- pythonqwt-0.16.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
83
- pythonqwt-0.16.2.dist-info/entry_points.txt,sha256=pdPda-YcYigCi00hR4tMxWKu6byxM2x3zA8BQFDYvwI,46
84
- pythonqwt-0.16.2.dist-info/top_level.txt,sha256=KB1IBdWRWnaItyJMaECwZiEi1jWt3IvqCrRVVhMjTu8,4
85
- pythonqwt-0.16.2.dist-info/RECORD,,
87
+ pythonqwt-0.16.3.dist-info/METADATA,sha256=X3YbzcMwmBSzowUYsNzto4vrDG8cbHUiqNFRDK5GzDM,45515
88
+ pythonqwt-0.16.3.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
89
+ pythonqwt-0.16.3.dist-info/entry_points.txt,sha256=pdPda-YcYigCi00hR4tMxWKu6byxM2x3zA8BQFDYvwI,46
90
+ pythonqwt-0.16.3.dist-info/top_level.txt,sha256=KB1IBdWRWnaItyJMaECwZiEi1jWt3IvqCrRVVhMjTu8,4
91
+ pythonqwt-0.16.3.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.1)
2
+ Generator: setuptools (83.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
qwt/__init__.py CHANGED
@@ -63,7 +63,7 @@ from qwt.symbol import QwtSymbol as QSbl # see deprecated section
63
63
  from qwt.text import QwtText # noqa: F401
64
64
  from qwt.toqimage import array_to_qimage as toQImage # noqa: F401
65
65
 
66
- __version__ = "0.16.2"
66
+ __version__ = "0.16.3"
67
67
  QWT_VERSION_STR = "6.1.5"
68
68
 
69
69
 
qwt/null_paintdevice.py CHANGED
@@ -275,7 +275,7 @@ class QwtNullPaintDevice(QPaintDevice):
275
275
  elif deviceMetric == QPaintDevice.PdmDevicePixelRatio:
276
276
  value = 1
277
277
  elif deviceMetric == QPaintDevice.PdmDevicePixelRatioScaled:
278
- value = 1
278
+ value = 0x10000
279
279
  else:
280
280
  value = super(QwtNullPaintDevice, self).metric(deviceMetric)
281
281
  return value
qwt/plot.py CHANGED
@@ -997,9 +997,30 @@ class QwtPlot(QFrame):
997
997
  if canvas is not None:
998
998
  canvas.setParent(self)
999
999
  canvas.installEventFilter(self)
1000
+ # Mouse move events occur over the canvas widget, not the plot frame
1001
+ # itself, so the canvas must share the plot's mouse-tracking state
1002
+ # for `QwtPlot.setMouseTracking` to behave as expected.
1003
+ canvas.setMouseTracking(self.hasMouseTracking())
1000
1004
  if self.isVisible():
1001
1005
  canvas.show()
1002
1006
 
1007
+ def setMouseTracking(self, enable):
1008
+ """
1009
+ Enable or disable mouse tracking for the plot.
1010
+
1011
+ The plot's drawing area is occupied by the canvas widget, so mouse move
1012
+ events over the plot are received by the canvas rather than the plot
1013
+ itself. This override propagates the mouse-tracking state to the canvas
1014
+ so that enabling tracking on the plot delivers mouse move events even
1015
+ when no mouse button is pressed (see Issue #88).
1016
+
1017
+ :param bool enable: True to enable mouse tracking, False to disable it
1018
+ """
1019
+ super(QwtPlot, self).setMouseTracking(enable)
1020
+ canvas = self.__data.canvas
1021
+ if canvas is not None:
1022
+ canvas.setMouseTracking(enable)
1023
+
1003
1024
  def event(self, event):
1004
1025
  if event.type() == QEvent.LayoutRequest:
1005
1026
  self.updateLayout()
qwt/plot_canvas.py CHANGED
@@ -591,7 +591,6 @@ class QwtPlotCanvas(QFrame):
591
591
  if (
592
592
  self.testPaintAttribute(self.BackingStore)
593
593
  and self.__data.backingStore is not None
594
- and not self.__data.backingStore.isNull()
595
594
  ):
596
595
  bs = self.__data.backingStore
597
596
  pixelRatio = bs.devicePixelRatio()
@@ -615,6 +614,10 @@ class QwtPlotCanvas(QFrame):
615
614
  if self.frameWidth() > 0:
616
615
  self.drawBorder(p)
617
616
  p.end()
617
+ # Store the regenerated pixmap back: the C++ original uses a
618
+ # reference into the stored pixmap, whereas the rebinding above
619
+ # only updates the local variable.
620
+ self.__data.backingStore = bs
618
621
  painter.drawPixmap(0, 0, self.__data.backingStore)
619
622
  else:
620
623
  if self.testAttribute(Qt.WA_StyledBackground):
qwt/qtdesigner.py ADDED
@@ -0,0 +1,211 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Licensed under the terms of the Qwt License
4
+ # Copyright (c) 2015 Pierre Raybaut, for the Qt Designer plugin helpers
5
+ # (see LICENSE file for more details)
6
+
7
+ """
8
+ Qt Designer
9
+ -----------
10
+
11
+ The :mod:`qwt.qtdesigner` module provides helpers to integrate :mod:`qwt`
12
+ widgets into Qt Designer:
13
+
14
+ * :py:class:`QwtPlotWidget`
15
+ * :py:func:`loadui`
16
+ * :py:func:`compileui`
17
+ * :py:func:`create_qtdesigner_plugin`
18
+
19
+ :class:`QwtPlotWidget` is a thin :class:`qwt.QwtPlot` subclass exposing the
20
+ standard Qt widget constructor (``__init__(self, parent=None)``). It is the
21
+ widget promoted by the Qt Designer plugin, so that the code generated by
22
+ ``uic`` (e.g. ``QwtPlotWidget(parent=...)`` with PyQt6) instantiates it
23
+ correctly. :class:`qwt.QwtPlot` itself keeps its historical constructor.
24
+
25
+ .. note::
26
+
27
+ Qt Designer custom widget plugins rely on
28
+ :class:`QtDesigner.QPyDesignerCustomWidgetPlugin`, which is only available
29
+ with PyQt5/PyQt6. PySide6 does not expose this class.
30
+
31
+ .. autoclass:: QwtPlotWidget
32
+
33
+ .. autofunction:: loadui
34
+
35
+ .. autofunction:: compileui
36
+
37
+ .. autofunction:: create_qtdesigner_plugin
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import io
43
+
44
+ from qtpy import QtGui as QG
45
+ from qtpy import uic
46
+
47
+ from qwt.plot import QwtPlot
48
+
49
+
50
+ class QwtPlotWidget(QwtPlot):
51
+ """:class:`qwt.QwtPlot` widget with the standard Qt constructor.
52
+
53
+ This subclass is meant to be promoted in Qt Designer: unlike
54
+ :class:`qwt.QwtPlot` (whose constructor accepts the historical
55
+ ``QwtPlot([title], [parent])`` overloads), it exposes the conventional
56
+ ``__init__(self, parent=None)`` signature expected by the code generated
57
+ by ``uic`` (notably PyQt6, which uses ``QwtPlotWidget(parent=...)``).
58
+
59
+ Args:
60
+ parent: Parent widget
61
+ """
62
+
63
+ def __init__(self, parent=None):
64
+ super().__init__(parent)
65
+
66
+
67
+ def loadui(fname: str, replace_class: str | None = None):
68
+ """Return Widget or Window class from a Qt Designer ``.ui`` file.
69
+
70
+ When a promoted widget inherits from a class that is not known to the
71
+ ``uic`` parser (i.e. not a standard Qt widget), ``loadUiType`` fails to
72
+ resolve the widget hierarchy. Passing the offending base class name as
73
+ ``replace_class`` neutralizes it by substituting ``QFrame`` (the base
74
+ class of :class:`qwt.QwtPlot`) before the file is parsed.
75
+
76
+ For a plain :class:`qwt.QwtPlot` widget (which already inherits from
77
+ ``QFrame``), no replacement is required and ``replace_class`` can be left
78
+ to ``None``.
79
+
80
+ Args:
81
+ fname: Path to the ``.ui`` file
82
+ replace_class: Base class name to replace by ``QFrame``, or ``None``
83
+ to load the file unchanged
84
+
85
+ Returns:
86
+ The generated form class
87
+ """
88
+ with open(fname) as f:
89
+ uifile_text = f.read()
90
+ if replace_class is not None:
91
+ uifile_text = uifile_text.replace(replace_class, "QFrame")
92
+ ui, base_class = uic.loadUiType(io.StringIO(uifile_text))
93
+
94
+ class Form(base_class, ui):
95
+ """Form class generated from the ``.ui`` file"""
96
+
97
+ def __init__(self, parent=None):
98
+ super().__init__(parent)
99
+ self.setupUi(self)
100
+
101
+ return Form
102
+
103
+
104
+ def compileui(fname: str, replace_class: str | None = None) -> None:
105
+ """Compile a Qt Designer ``.ui`` file into a Python module.
106
+
107
+ Args:
108
+ fname: Path to the ``.ui`` file
109
+ replace_class: Base class name to replace by ``QFrame``, or ``None``
110
+ to compile the file unchanged
111
+ """
112
+ with open(fname) as f:
113
+ uifile_text = f.read()
114
+ if replace_class is not None:
115
+ uifile_text = uifile_text.replace(replace_class, "QFrame")
116
+ with open(fname.replace(".ui", "_ui.py"), "w") as pyfile:
117
+ uic.compileUi(io.StringIO(uifile_text), pyfile, pyqt3_wrapper=True)
118
+
119
+
120
+ def create_qtdesigner_plugin(
121
+ group: str,
122
+ module_name: str,
123
+ class_name: str,
124
+ icon: str | QG.QIcon | None = None,
125
+ tooltip: str = "",
126
+ whatsthis: str = "",
127
+ ):
128
+ """Return a custom Qt Designer plugin class.
129
+
130
+ Args:
131
+ group: Name of the group the widget belongs to in Qt Designer
132
+ module_name: Name of the module where the widget class is defined
133
+ class_name: Name of the widget class
134
+ icon: Icon shown in Qt Designer (path to an image file or
135
+ :class:`QtGui.QIcon` instance)
136
+ tooltip: Tool tip shown in Qt Designer
137
+ whatsthis: "What's this" help text shown in Qt Designer
138
+
139
+ Returns:
140
+ A :class:`QtDesigner.QPyDesignerCustomWidgetPlugin` subclass
141
+
142
+ Example::
143
+
144
+ Plugin = create_qtdesigner_plugin(
145
+ group="PythonQwt",
146
+ module_name="qwt.qtdesigner",
147
+ class_name="QwtPlotWidget",
148
+ )
149
+ """
150
+ from qtpy.QtDesigner import QPyDesignerCustomWidgetPlugin
151
+
152
+ Widget = getattr(__import__(module_name, fromlist=[class_name]), class_name)
153
+
154
+ class CustomWidgetPlugin(QPyDesignerCustomWidgetPlugin):
155
+ """Qt Designer plugin for the ``%s`` widget""" % class_name
156
+
157
+ def __init__(self, parent=None):
158
+ QPyDesignerCustomWidgetPlugin.__init__(self)
159
+ self.initialized = False
160
+
161
+ def initialize(self, core):
162
+ """Initialize the plugin"""
163
+ if self.initialized:
164
+ return
165
+ self.initialized = True
166
+
167
+ def isInitialized(self):
168
+ """Return whether the plugin has been initialized"""
169
+ return self.initialized
170
+
171
+ def createWidget(self, parent):
172
+ """Return a new instance of the custom widget"""
173
+ return Widget(parent)
174
+
175
+ def name(self):
176
+ """Return the name of the custom widget class"""
177
+ return class_name
178
+
179
+ def group(self):
180
+ """Return the name of the group the widget belongs to"""
181
+ return group
182
+
183
+ def icon(self):
184
+ """Return the icon shown in Qt Designer"""
185
+ if isinstance(icon, QG.QIcon):
186
+ return icon
187
+ elif isinstance(icon, str):
188
+ return QG.QIcon(icon)
189
+ return QG.QIcon()
190
+
191
+ def toolTip(self):
192
+ """Return the tool tip shown in Qt Designer"""
193
+ return tooltip
194
+
195
+ def whatsThis(self):
196
+ """Return the "What's this" help text shown in Qt Designer"""
197
+ return whatsthis
198
+
199
+ def isContainer(self):
200
+ """Return whether the widget is a container"""
201
+ return False
202
+
203
+ def domXml(self):
204
+ """Return the XML used to define the widget in Qt Designer"""
205
+ return f'<widget class="{class_name}" name="{class_name.lower()}" />\n'
206
+
207
+ def includeFile(self):
208
+ """Return the module name where the widget class is defined"""
209
+ return module_name
210
+
211
+ return CustomWidgetPlugin
qwt/scale_draw.py CHANGED
@@ -607,7 +607,7 @@ class QwtScaleDraw(QwtAbstractScaleDraw):
607
607
  * start: Start border distance
608
608
  * end: End border distance
609
609
  """
610
- start, end = 0, 1.0
610
+ start, end = 0, 1
611
611
 
612
612
  if not self.hasComponent(QwtAbstractScaleDraw.Labels):
613
613
  return start, end
@@ -0,0 +1,68 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Licensed under the terms of the Qwt License
4
+ # (see LICENSE file for more details)
5
+
6
+ """
7
+ Test for issue 109: the ``QwtPlotCanvas.BackingStore`` paint attribute was
8
+ non-functional.
9
+
10
+ After ``invalidateBackingStore()`` reset the buffer to an empty ``QPixmap()``,
11
+ ``paintEvent`` skipped the backing-store branch because of an extra
12
+ ``isNull()`` guard, so the double-buffering cache was never regenerated. The
13
+ regenerated pixmap was also only bound to a local variable and never stored
14
+ back. This test ensures the cache is refreshed (and reused) as intended.
15
+ """
16
+
17
+ import numpy as np
18
+ from qtpy import QtCore as QC
19
+ from qtpy import QtWidgets as QW
20
+
21
+ from qwt import QwtPlot, QwtPlotCurve
22
+
23
+
24
+ def _make_plot_with_backing_store():
25
+ plot = QwtPlot()
26
+ plot.resize(QC.QSize(400, 300))
27
+ canvas = plot.canvas()
28
+ canvas.setPaintAttribute(canvas.BackingStore, True)
29
+ x = np.linspace(0, 10, 100)
30
+ QwtPlotCurve.make(x, np.sin(x), "Sine", plot)
31
+ return plot, canvas
32
+
33
+
34
+ def test_backing_store_is_regenerated_after_replot():
35
+ """The backing store must be repopulated when painting after a replot."""
36
+ # A live QApplication must exist before constructing any QWidget, otherwise
37
+ # Qt aborts the process. Tests run in a shared interpreter, but no test
38
+ # keeps a persistent Python reference to the application, so the singleton
39
+ # may be garbage-collected between tests (observed on Linux/PyQt5 in CI).
40
+ app = QW.QApplication.instance() or QW.QApplication([])
41
+
42
+ plot, canvas = _make_plot_with_backing_store()
43
+ plot.show()
44
+ app.processEvents() # lay the canvas out and paint it a first time
45
+
46
+ plot.replot() # invalidates the backing store
47
+
48
+ # Force a paint so the backing store branch runs. ``grab()`` synchronously
49
+ # triggers ``paintEvent`` and works headless (QT_QPA_PLATFORM=offscreen),
50
+ # without depending on the event loop delivering a deferred repaint.
51
+ canvas.grab()
52
+
53
+ bs = canvas.backingStore()
54
+ assert bs is not None
55
+ assert not bs.isNull()
56
+ # The regenerated pixmap must match the canvas size (accounting for the
57
+ # device pixel ratio), proving it was actually rebuilt and stored back.
58
+ assert bs.size() == canvas.size() * bs.devicePixelRatio()
59
+
60
+ plot.close()
61
+ plot.deleteLater()
62
+ app.processEvents()
63
+
64
+
65
+ if __name__ == "__main__":
66
+ app = QW.QApplication.instance() or QW.QApplication([])
67
+ test_backing_store_is_regenerated_after_replot()
68
+ print("OK")
@@ -0,0 +1,260 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Licensed under the terms of the MIT License
4
+ # Copyright (c) 2024 Pierre Raybaut
5
+ # (see LICENSE file for more details)
6
+
7
+ """
8
+ Side-by-side plots sharing a single vertical scale (issue #33)
9
+ --------------------------------------------------------------
10
+
11
+ This example answers `issue #33
12
+ <https://github.com/PlotPyStack/PythonQwt/issues/33>`_: how to display several
13
+ vertical plots side-by-side, perfectly aligned on a *single* shared vertical
14
+ scale (here the depth down a drilled core), while each plot keeps its own
15
+ horizontal scale (element concentration, density, ...).
16
+
17
+ Two practical problems are solved here:
18
+
19
+ 1. **Vertical alignment.** When the left-most plot shows the depth axis and the
20
+ others do not, their canvases do not line up: an enabled vertical scale
21
+ reserves a "border distance" at both ends (so the extreme tick labels are not
22
+ clipped), which pushes its canvas down by a few pixels. To keep every canvas
23
+ aligned, the depth axis is *enabled on every plot* but only *drawn* on the
24
+ first one; on the other plots its components (labels, ticks, backbone) are
25
+ hidden while the very same border distance is reserved through
26
+ :py:meth:`qwt.scale_widget.QwtScaleWidget.setMinBorderDist`. This survives
27
+ relayout/zoom because :py:meth:`qwt.plot.QwtPlot.updateLayout` always honors
28
+ the minimum border distance.
29
+
30
+ 2. **Synchronized zoom/scroll.** A single vertical scroll bar and a pair of
31
+ zoom buttons drive the visible depth window of *all* plots at once, by
32
+ calling :py:meth:`qwt.plot.QwtPlot.setAxisScale` on the shared ``yLeft``
33
+ axis of each plot.
34
+ """
35
+
36
+ SHOW = True # Show test in GUI-based test launcher
37
+
38
+ import numpy as np
39
+ from qtpy import QtCore as QC
40
+ from qtpy import QtGui as QG
41
+ from qtpy import QtWidgets as QW
42
+
43
+ from qwt import (
44
+ QwtLinearScaleEngine,
45
+ QwtPlot,
46
+ QwtPlotCurve,
47
+ QwtPlotGrid,
48
+ QwtScaleDraw,
49
+ )
50
+ from qwt.tests import utils
51
+
52
+ DEPTH_MIN, DEPTH_MAX = 0.0, 120.0 # meters
53
+ SCROLL_RESOLUTION = 100 # scroll bar steps per meter
54
+
55
+
56
+ def make_drillcore_data():
57
+ """Return synthetic drill-core logs as ``(title, [(label, x, depth)], unit)``"""
58
+ depth = np.linspace(DEPTH_MIN, DEPTH_MAX, 800)
59
+ rng = np.random.default_rng(42)
60
+
61
+ def log(base, amp, n_bumps, noise):
62
+ signal = base + noise * rng.standard_normal(depth.size)
63
+ for _ in range(n_bumps):
64
+ center = rng.uniform(DEPTH_MIN, DEPTH_MAX)
65
+ width = rng.uniform(3.0, 12.0)
66
+ # Clip the exponent to avoid float64 underflow (numpy may be set to
67
+ # raise on underflow in the test suite).
68
+ signal += amp * np.exp(
69
+ np.clip(-(((depth - center) / width) ** 2), -50.0, 0.0)
70
+ )
71
+ return np.clip(signal, 0, None)
72
+
73
+ return [
74
+ (
75
+ "Iron",
76
+ "Fe (%)",
77
+ [("Fe", log(8.0, 18.0, 5, 0.6), depth)],
78
+ ),
79
+ (
80
+ "Copper",
81
+ "Cu (ppm)",
82
+ [
83
+ ("Cu", log(150.0, 600.0, 4, 20.0), depth),
84
+ ("Cu (smoothed)", log(150.0, 580.0, 4, 2.0), depth),
85
+ ],
86
+ ),
87
+ (
88
+ "Density",
89
+ "rho (g/cm3)",
90
+ [("rho", 2.6 + 0.04 * np.sin(depth / 6.0) + log(0.0, 0.2, 3, 0.01), depth)],
91
+ ),
92
+ ]
93
+
94
+
95
+ class DepthPlot(QwtPlot):
96
+ """A single drill-core log plot with depth on the (inverted) left axis"""
97
+
98
+ CURVE_COLORS = ("#1f77b4", "#d62728", "#2ca02c", "#9467bd")
99
+
100
+ def _curve_color(self, index):
101
+ """Return a :py:class:`QColor` for the curve at ``index``"""
102
+ return QG.QColor(self.CURVE_COLORS[index % len(self.CURVE_COLORS)])
103
+
104
+ def __init__(self, title, x_title, curves, show_depth_axis, parent=None):
105
+ super().__init__(parent)
106
+ self.setObjectName(title)
107
+ self.setTitle(title)
108
+
109
+ # By default QwtPlot uses a MinimumExpanding horizontal policy, which
110
+ # makes the surrounding layout treat sizeHint() (not minimumSizeHint())
111
+ # as a hard minimum width. With several plots side-by-side this prevents
112
+ # the widget from being shrunk. An "Ignored" horizontal policy lets every
113
+ # column shrink freely and share the available width equally.
114
+ self.setSizePolicy(QW.QSizePolicy.Ignored, QW.QSizePolicy.Preferred)
115
+
116
+ # Horizontal (data) axis on top, like column headers in a core log
117
+ self.enableAxis(self.xBottom, False)
118
+ self.enableAxis(self.xTop, True)
119
+ self.setAxisTitle(self.xTop, x_title)
120
+ # Limit the number of major ticks: the horizontal axis reserves room for
121
+ # all its tick labels (minLabelDist * majorCount), which otherwise sets a
122
+ # very large minimum width and crowds the labels when the plot is narrow.
123
+ self.setAxisMaxMajor(self.xTop, 4)
124
+
125
+ # Vertical (depth) axis: inverted so depth increases downwards
126
+ self.enableAxis(self.yLeft, True)
127
+ self.axisScaleEngine(self.yLeft).setAttribute(
128
+ QwtLinearScaleEngine.Inverted, True
129
+ )
130
+
131
+ if show_depth_axis:
132
+ self.setAxisTitle(self.yLeft, "Depth (m)")
133
+ else:
134
+ # Keep the axis enabled (so the canvas stays aligned with the
135
+ # reference plot) but hide everything it draws.
136
+ scale_draw = self.axisScaleDraw(self.yLeft)
137
+ scale_draw.enableComponent(QwtScaleDraw.Labels, False)
138
+ scale_draw.enableComponent(QwtScaleDraw.Ticks, False)
139
+ scale_draw.enableComponent(QwtScaleDraw.Backbone, False)
140
+ scale_draw.setMinimumExtent(0)
141
+
142
+ QwtPlotGrid.make(self, color=QC.Qt.lightGray, width=0, style=QC.Qt.DotLine)
143
+
144
+ for index, (label, x, depth) in enumerate(curves):
145
+ color = self._curve_color(index)
146
+ curve = QwtPlotCurve.make(x, depth, label, self, linecolor=color)
147
+ curve.setAxes(self.xTop, self.yLeft)
148
+
149
+ self.setAxisScale(self.yLeft, DEPTH_MIN, DEPTH_MAX)
150
+
151
+
152
+ class DrillCoreViewer(QW.QWidget):
153
+ """Several depth plots side-by-side, aligned on a single shared depth scale"""
154
+
155
+ def __init__(self, parent=None):
156
+ super().__init__(parent)
157
+ self.full_span = DEPTH_MAX - DEPTH_MIN
158
+ self.view_span = self.full_span
159
+ self.view_top = DEPTH_MIN
160
+
161
+ self.plots = []
162
+ plots_layout = QW.QHBoxLayout()
163
+ plots_layout.setSpacing(0)
164
+ for index, (title, x_title, curves) in enumerate(make_drillcore_data()):
165
+ plot = DepthPlot(title, x_title, curves, show_depth_axis=index == 0)
166
+ self.plots.append(plot)
167
+ plots_layout.addWidget(plot)
168
+
169
+ # Vertical scroll bar to move the visible depth window
170
+ self.scrollbar = QW.QScrollBar(QC.Qt.Vertical)
171
+ self.scrollbar.valueChanged.connect(self.scroll_to)
172
+ plots_layout.addWidget(self.scrollbar)
173
+
174
+ # Zoom buttons
175
+ zoom_in = QW.QToolButton()
176
+ zoom_in.setText("+")
177
+ zoom_in.setToolTip("Zoom in (depth)")
178
+ zoom_in.clicked.connect(lambda: self.zoom(0.5))
179
+ zoom_out = QW.QToolButton()
180
+ zoom_out.setText("-")
181
+ zoom_out.setToolTip("Zoom out (depth)")
182
+ zoom_out.clicked.connect(lambda: self.zoom(2.0))
183
+ reset = QW.QToolButton()
184
+ reset.setText("Reset")
185
+ reset.clicked.connect(self.reset_view)
186
+ toolbar = QW.QHBoxLayout()
187
+ toolbar.addWidget(QW.QLabel("Depth zoom:"))
188
+ toolbar.addWidget(zoom_in)
189
+ toolbar.addWidget(zoom_out)
190
+ toolbar.addWidget(reset)
191
+ toolbar.addStretch()
192
+
193
+ layout = QW.QVBoxLayout()
194
+ layout.addLayout(toolbar)
195
+ layout.addLayout(plots_layout)
196
+ self.setLayout(layout)
197
+
198
+ # Align the hidden depth axes with the reference one and apply the view.
199
+ QC.QTimer.singleShot(0, self._align_and_refresh)
200
+
201
+ def _align_and_refresh(self):
202
+ """Force identical vertical border distance on every depth axis"""
203
+ for plot in self.plots:
204
+ plot.replot() # ensure scale divisions are computed
205
+ ref_widget = self.plots[0].axisWidget(self.plots[0].yLeft)
206
+ start, end = ref_widget.getBorderDistHint()
207
+ for plot in self.plots[1:]:
208
+ plot.axisWidget(plot.yLeft).setMinBorderDist(start, end)
209
+ self._apply_view()
210
+
211
+ def _apply_view(self):
212
+ """Apply the current depth window to every plot and update the scroll bar"""
213
+ bottom = self.view_top + self.view_span
214
+ for plot in self.plots:
215
+ plot.setAxisScale(plot.yLeft, self.view_top, bottom)
216
+ plot.replot()
217
+ self._update_scrollbar()
218
+
219
+ def _update_scrollbar(self):
220
+ """Reflect the current depth window in the scroll bar geometry"""
221
+ hidden = max(self.full_span - self.view_span, 0.0)
222
+ self.scrollbar.blockSignals(True)
223
+ self.scrollbar.setRange(0, int(round(hidden * SCROLL_RESOLUTION)))
224
+ self.scrollbar.setPageStep(int(round(self.view_span * SCROLL_RESOLUTION)))
225
+ self.scrollbar.setSingleStep(
226
+ int(round(self.view_span * SCROLL_RESOLUTION / 10))
227
+ )
228
+ self.scrollbar.setValue(
229
+ int(round((self.view_top - DEPTH_MIN) * SCROLL_RESOLUTION))
230
+ )
231
+ self.scrollbar.setEnabled(hidden > 0)
232
+ self.scrollbar.blockSignals(False)
233
+
234
+ def scroll_to(self, value):
235
+ """Scroll bar handler: move the visible depth window"""
236
+ self.view_top = DEPTH_MIN + value / SCROLL_RESOLUTION
237
+ self._apply_view()
238
+
239
+ def zoom(self, factor):
240
+ """Zoom the visible depth window around its center by ``factor``"""
241
+ center = self.view_top + self.view_span / 2.0
242
+ self.view_span = min(self.full_span, self.view_span * factor)
243
+ self.view_top = center - self.view_span / 2.0
244
+ self.view_top = max(DEPTH_MIN, min(self.view_top, DEPTH_MAX - self.view_span))
245
+ self._apply_view()
246
+
247
+ def reset_view(self):
248
+ """Restore the full depth range"""
249
+ self.view_span = self.full_span
250
+ self.view_top = DEPTH_MIN
251
+ self._apply_view()
252
+
253
+
254
+ def test_issue33_sidebyside():
255
+ """Side-by-side plots sharing a single vertical scale (issue #33)"""
256
+ utils.test_widget(DrillCoreViewer, size=(700, 600))
257
+
258
+
259
+ if __name__ == "__main__":
260
+ test_issue33_sidebyside()
@@ -0,0 +1,70 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Licensed under the terms of the Qwt License
4
+ # (see LICENSE file for more details)
5
+
6
+ """
7
+ Test for issue 88: ``QwtPlot.setMouseTracking`` was ignored.
8
+
9
+ The plot's drawing area is occupied by the canvas widget, so mouse move events
10
+ over the plot are delivered to the canvas rather than to the ``QwtPlot`` frame.
11
+ Calling ``QwtPlot.setMouseTracking(True)`` therefore had no visible effect: the
12
+ canvas kept its default (button-pressed-only) move events. ``QwtPlot`` now
13
+ propagates its mouse-tracking state to the canvas so that the setting behaves
14
+ as documented by ``QWidget.setMouseTracking``.
15
+ """
16
+
17
+ from qtpy import QtWidgets as QW
18
+
19
+ from qwt import QwtPlot, QwtPlotCanvas
20
+
21
+
22
+ def _ensure_app():
23
+ # A live QApplication must exist before constructing any QWidget, otherwise
24
+ # Qt aborts the process. Tests run in a shared interpreter, but no test
25
+ # keeps a persistent Python reference to the application, so the singleton
26
+ # may be garbage-collected between tests (observed on Linux/PyQt5 in CI).
27
+ return QW.QApplication.instance() or QW.QApplication([])
28
+
29
+
30
+ def test_set_mouse_tracking_propagates_to_canvas():
31
+ """Enabling/disabling mouse tracking on the plot updates the canvas."""
32
+ app = _ensure_app() # keep a reference alive for the duration of the test
33
+ plot = QwtPlot()
34
+
35
+ # By default mouse tracking is off on both the plot and its canvas.
36
+ assert not plot.hasMouseTracking()
37
+ assert not plot.canvas().hasMouseTracking()
38
+
39
+ plot.setMouseTracking(True)
40
+ assert plot.hasMouseTracking()
41
+ assert plot.canvas().hasMouseTracking()
42
+
43
+ plot.setMouseTracking(False)
44
+ assert not plot.hasMouseTracking()
45
+ assert not plot.canvas().hasMouseTracking()
46
+
47
+ plot.close()
48
+ del app
49
+
50
+
51
+ def test_set_canvas_inherits_mouse_tracking_state():
52
+ """A canvas set after enabling tracking inherits the plot's state."""
53
+ app = _ensure_app() # keep a reference alive for the duration of the test
54
+ plot = QwtPlot()
55
+ plot.setMouseTracking(True)
56
+
57
+ new_canvas = QwtPlotCanvas(plot)
58
+ assert not new_canvas.hasMouseTracking()
59
+
60
+ plot.setCanvas(new_canvas)
61
+ assert plot.canvas() is new_canvas
62
+ assert new_canvas.hasMouseTracking()
63
+
64
+ plot.close()
65
+ del app
66
+
67
+
68
+ if __name__ == "__main__":
69
+ test_set_mouse_tracking_propagates_to_canvas()
70
+ test_set_canvas_inherits_mouse_tracking_state()
@@ -0,0 +1,55 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Licensed under the terms of the Qwt License
4
+ # Copyright (c) 2015 Pierre Raybaut, for the Qt Designer plugin test
5
+ # (see LICENSE file for more details)
6
+
7
+ """
8
+ Testing PythonQwt Qt Designer plugin
9
+
10
+ This plugin makes the :class:`qwt.QwtPlot` widget available in Qt Designer.
11
+ The test loads a ``.ui`` file embedding a ``QwtPlot`` widget, as Qt Designer
12
+ would generate it once the plugin is installed.
13
+ """
14
+
15
+ SHOW = True # Show test in GUI-based test launcher
16
+
17
+ import os.path as osp
18
+
19
+ import numpy as np
20
+ import pytest
21
+ from qtpy.QtCore import Qt
22
+
23
+ import qwt
24
+ from qwt.qtdesigner import loadui
25
+ from qwt.tests import utils
26
+
27
+ try:
28
+ FormClass = loadui(osp.splitext(__file__)[0] + ".ui")
29
+ except Exception as exc: # pragma: no cover - binding-specific uic limitation
30
+ pytest.skip(
31
+ "Qt Designer .ui loading is not supported by this Qt binding (%s)" % exc,
32
+ allow_module_level=True,
33
+ )
34
+
35
+
36
+ class QtDesignerWindow(FormClass):
37
+ """Window built from the Qt Designer ``.ui`` file"""
38
+
39
+ def __init__(self):
40
+ super().__init__()
41
+ plot = self.plotwidget
42
+ plot.setTitle("QtDesigner plugin example")
43
+ x = np.linspace(0.0, 10.0, 500)
44
+ qwt.QwtPlotCurve.make(x, np.sin(x), "y = sin(x)", plot, linecolor="red")
45
+ qwt.QwtPlotGrid.make(plot, color=Qt.lightGray, width=0, style=Qt.DotLine)
46
+ plot.replot()
47
+
48
+
49
+ def test_qtdesigner():
50
+ """Qt Designer plugin example"""
51
+ utils.test_widget(QtDesignerWindow, size=(600, 400))
52
+
53
+
54
+ if __name__ == "__main__":
55
+ test_qtdesigner()
@@ -0,0 +1,31 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <ui version="4.0">
3
+ <class>Dialog</class>
4
+ <widget class="QDialog" name="Dialog">
5
+ <property name="geometry">
6
+ <rect>
7
+ <x>0</x>
8
+ <y>0</y>
9
+ <width>500</width>
10
+ <height>400</height>
11
+ </rect>
12
+ </property>
13
+ <property name="windowTitle">
14
+ <string>Dialog</string>
15
+ </property>
16
+ <layout class="QVBoxLayout" name="verticalLayout">
17
+ <item>
18
+ <widget class="QwtPlotWidget" name="plotwidget"/>
19
+ </item>
20
+ </layout>
21
+ </widget>
22
+ <customwidgets>
23
+ <customwidget>
24
+ <class>QwtPlotWidget</class>
25
+ <extends>QFrame</extends>
26
+ <header>qwt.qtdesigner</header>
27
+ </customwidget>
28
+ </customwidgets>
29
+ <resources/>
30
+ <connections/>
31
+ </ui>
qwt/tests/utils.py CHANGED
@@ -41,6 +41,8 @@ TEST_PATH = osp.abspath(osp.dirname(__file__))
41
41
 
42
42
 
43
43
  class TestEnvironment:
44
+ __test__ = False # Tell pytest this is not a test class
45
+
44
46
  UNATTENDED_ARG = "unattended"
45
47
  SCREENSHOTS_ARG = "screenshots"
46
48
  UNATTENDED_ENV = "PYTHONQWT_UNATTENDED_TESTS"