diff --git a/CHANGELOG.md b/CHANGELOG.md index 23ce154..5e6f75a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Python 3.10 or later is now required. Python 3.9 users must keep an earlier compatible release. +### Bug fixes + +- Fixed [Issue #113](https://github.com/PlotPyStack/PythonQwt/issues/113): after replacing the scale draw of an axis (`QwtPlot.setAxisScaleDraw`), the axis ticks could be misaligned with the grid lines and plot items, until the next scale division change (e.g. after panning or zooming): a replot did not fix it. The new scale draw was laid out before getting the position and length of the previous one, so that the axis reserved space for its end labels that the canvas did not take into account. The new scale draw now takes the geometry of the previous one, and the axis stays aligned with the canvas + ## Version 0.16.3 ### New features diff --git a/qwt/scale_widget.py b/qwt/scale_widget.py index fb3eac6..823f75c 100644 --- a/qwt/scale_widget.py +++ b/qwt/scale_widget.py @@ -15,7 +15,7 @@ import math -from qtpy.QtCore import QObject, QRectF, QSize, Qt, Signal +from qtpy.QtCore import QObject, QPointF, QRectF, QSize, Qt, Signal from qtpy.QtGui import QPainter, QPalette from qtpy.QtWidgets import QSizePolicy, QStyle, QStyleOption, QWidget @@ -339,6 +339,11 @@ class destructor or the next call of `setScaleDraw()`. if sd.scaleMap().transformation(): transform = sd.scaleMap().transformation().copy() scaleDraw.setTransformation(transform) + # The border distance hint computed by `layoutScale` depends on the + # scale geometry: without it, a zero-length scale gives the axis + # border distances differing from those used by the canvas maps. + scaleDraw.move(QPointF(sd.pos())) + scaleDraw.setLength(sd.length()) self.__data.scaleDraw = scaleDraw self.layoutScale() diff --git a/qwt/tests/test_issue113_scaledraw_geometry.py b/qwt/tests/test_issue113_scaledraw_geometry.py new file mode 100644 index 0000000..ff56a57 --- /dev/null +++ b/qwt/tests/test_issue113_scaledraw_geometry.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the Qwt License +# (see LICENSE file for more details) + +""" +Test for issue 113: axis ticks misaligned with the canvas after replacing the +scale draw. + +``QwtScaleWidget.setScaleDraw`` passed the alignment, the scale division and +the transformation of the previous scale draw to the new one, but not its +position and length. The border distance hint was then computed from a scale +of zero length, so the axis was laid out with border distances differing from +the stored ones used by ``QwtPlot.canvasMap``: grid lines and plot items no +longer matched the ticks, even after a replot, until the next scale division +change. +""" + +import numpy as np +import pytest +from qtpy import QtWidgets as QW + +from qwt import QwtPlot, QwtPlotCurve, QwtPlotGrid, QwtScaleDraw + +# Scale bounds that are not major ticks: end labels do not overflow the scale +# ends, so that the stored border distances are zero +X_BOUNDS = (3.06e-7, 4.1e-7) +Y_BOUNDS = (-1.05, 1.05) +AXES = (QwtPlot.xBottom, QwtPlot.yLeft) +AXIS_IDS = ("xBottom", "yLeft") + + +def _ensure_app(): + # A live QApplication must exist before constructing any QWidget, otherwise + # Qt aborts the process. Tests run in a shared interpreter, but no test + # keeps a persistent Python reference to the application, so the singleton + # may be garbage-collected between tests (observed on Linux/PyQt5 in CI). + return QW.QApplication.instance() or QW.QApplication([]) + + +def _make_plot(app): + plot = QwtPlot() + x = np.linspace(*X_BOUNDS, 1000) + QwtPlotCurve.make(x, np.tanh((x - 3.1e-7) * 1e8), plot=plot) + QwtPlotGrid().attach(plot) + plot.setAxisScale(QwtPlot.xBottom, *X_BOUNDS) + plot.setAxisScale(QwtPlot.yLeft, *Y_BOUNDS) + plot.resize(600, 400) + plot.show() + plot.replot() + app.processEvents() + return plot + + +def _scale_ends(plot, axis_id): + """Return the ends of the axis scale and of the canvas map, in canvas + coordinates""" + widget = plot.axisWidget(axis_id) + if axis_id in (QwtPlot.yLeft, QwtPlot.yRight): + offset = widget.y() - plot.canvas().y() + else: + offset = widget.x() - plot.canvas().x() + smap = widget.scaleDraw().scaleMap() + cmap = plot.canvasMap(axis_id) + scale_ends = sorted((smap.p1() + offset, smap.p2() + offset)) + canvas_ends = sorted((cmap.p1(), cmap.p2())) + return scale_ends, canvas_ends + + +@pytest.mark.parametrize("axis_id", AXES, ids=AXIS_IDS) +def test_replaced_scale_draw_keeps_geometry(axis_id): + """The new scale draw takes the position and length of the previous one.""" + app = _ensure_app() # keep a reference alive for the duration of the test + plot = _make_plot(app) + + old_draw = plot.axisScaleDraw(axis_id) + pos, length = old_draw.pos(), old_draw.length() + new_draw = QwtScaleDraw() + plot.setAxisScaleDraw(axis_id, new_draw) + + assert plot.axisScaleDraw(axis_id) is new_draw + assert new_draw.pos() == pos + assert new_draw.length() == length + + plot.close() + del app + + +@pytest.mark.parametrize("axis_id", AXES, ids=AXIS_IDS) +def test_axis_aligned_with_canvas_after_scale_draw_change(axis_id): + """Axis ticks stay aligned with the canvas after replacing the scale draw.""" + app = _ensure_app() # keep a reference alive for the duration of the test + plot = _make_plot(app) + + scale_ends, canvas_ends = _scale_ends(plot, axis_id) + assert scale_ends == pytest.approx(canvas_ends, abs=0.5) + + plot.setAxisScaleDraw(axis_id, QwtScaleDraw()) + plot.replot() + app.processEvents() + + scale_ends, canvas_ends = _scale_ends(plot, axis_id) + assert scale_ends == pytest.approx(canvas_ends, abs=0.5) + + plot.close() + del app + + +if __name__ == "__main__": + for axis in AXES: + test_replaced_scale_draw_keeps_geometry(axis) + test_axis_aligned_with_canvas_after_scale_draw_change(axis) + print("OK")