Skip to content

Commit aa1180d

Browse files
committed
fix(plot_canvas): support rounded borders in canvas style sheets
A canvas style sheet with rounded borders, e.g. plot.canvas().setStyleSheet("border: 2px solid black; border-radius: 8px;"), raised a TypeError on each style change, resize and paint event. As these exceptions are raised in Qt virtual methods, PyQt aborts the process with its default exception hook (0xC0000409 on Windows). Three porting bugs were on this code path, which the Qwt C++ code avoids with implicit conversions: - QwtStyleSheetRecorder.drawPath built QRectF(QPointF, QSize): the size is now converted to a QSizeF; - qwtCombinePathList used `rect.bottom` instead of `rect.bottom()` and None placeholders instead of empty paths (so that any border without all its corners failed), and reverted the input sub-paths in place: it now works on a QRectF, empty paths and copies of the sub-paths, as in Qwt C++; - qwtFillBackground passed the QRectF corner rectangles of the style sheet to QRegion.intersects, QPixmap and QWidget.mapTo, which expect integer types: they are now converted with QRectF.toAlignedRect(), as in Qwt C++. QRect rectangles (plain canvas with a border radius) are left unchanged by this conversion. Canvases without rounded style sheet borders render exactly as before: same images, pixel for pixel, with and without backing store, for canvases without style sheet, with non-rounded style sheets and with a border radius set by setBorderRadius. - Regression tests: incomplete and complete rounded corners combined without modifying the input paths; canvases with three rounded style sheets, with and without backing store, rendered without exception (exceptions raised in Qt virtual methods are collected), with a border path and a rounded corner filled with the plot background. - Changelog entry. Refs #115 Assisted-by: Claude Opus 5.5
1 parent 61c3d77 commit aa1180d

3 files changed

Lines changed: 91 additions & 5 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
- Fixed painting into a `QwtGraphic` with clipping enabled, which raised a `TypeError` on each paint operation (aborting the process with PyQt's default exception hook): clipped paint operations are now recorded, and the bounding rectangle of the graphic is clipped as in Qwt ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115))
1818
- Fixed the `QwtPainterCommand` copy constructor (`QwtPainterCommand(other)`), which raised an `AttributeError` for state commands and a `TypeError` (Qt objects cannot be pickled) for pixmap and image commands: commands of every type can now be copied, the copy being independent of the original ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115))
1919
- Fixed `QwtDynGridLayout` (used by `QwtLegend`), whose `numRows()` returned a float (e.g. `2.5` for 3 items on 2 columns) and which raised a `TypeError` when laying out its items with vertical expansion (`setExpandingDirections(Qt.Vertical)`) ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115))
20+
- Fixed canvas style sheets with rounded borders (e.g. `plot.canvas().setStyleSheet("border: 2px solid black; border-radius: 8px;")`), which raised a `TypeError` on each style change, resize and paint event (aborting the process with PyQt's default exception hook): the rounded border of the style sheet is now taken into account to clip the plot items and to fill the corners with the background of the parent widget, as in Qwt ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115))
2021

2122
## Version 0.16.3
2223

‎qwt/plot_canvas.py‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,17 @@
1616
import weakref
1717
from collections.abc import Sequence
1818

19-
from qtpy.QtCore import QEvent, QObject, QPoint, QPointF, QRect, QRectF, QSize, Qt
19+
from qtpy.QtCore import (
20+
QEvent,
21+
QObject,
22+
QPoint,
23+
QPointF,
24+
QRect,
25+
QRectF,
26+
QSize,
27+
QSizeF,
28+
Qt,
29+
)
2030
from qtpy.QtGui import (
2131
QBrush,
2232
QGradient,
@@ -81,7 +91,7 @@ def drawRects(self, rects, count):
8191
)
8292

8393
def drawPath(self, path):
84-
rect = QRectF(QPointF(0.0, 0.0), self.__size)
94+
rect = QRectF(QPointF(0.0, 0.0), QSizeF(self.__size))
8595
if path.controlPointRect().contains(rect.center()):
8696
self.setCornerRects(path)
8797
self.alignCornerRects(rect)
@@ -188,9 +198,11 @@ def qwtCombinePathList(rect, pathList):
188198
if not pathList:
189199
return QPainterPath()
190200

191-
ordered = [None] * 8
201+
rect = QRectF(rect)
202+
ordered = [QPainterPath() for _i in range(8)]
192203
for subPath in pathList:
193204
index = -1
205+
subPath = QPainterPath(subPath)
194206
br = subPath.controlPointRect()
195207
if br.center().x() < rect.center().x():
196208
if br.center().y() < rect.center().y():
@@ -199,7 +211,7 @@ def qwtCombinePathList(rect, pathList):
199211
else:
200212
index = 0
201213
else:
202-
if abs(br.bottom() - rect.bottom) < abs(br.left() - rect.left()):
214+
if abs(br.bottom() - rect.bottom()) < abs(br.left() - rect.left()):
203215
index = 6
204216
else:
205217
index = 7
@@ -297,7 +309,8 @@ def qwtFillBackground(*args):
297309
else:
298310
clipRegion = widget.contentsRect()
299311
bgWidget = qwtBackgroundWidget(widget.parentWidget())
300-
for rect in fillRects:
312+
for fillRect in fillRects:
313+
rect = QRectF(fillRect).toAlignedRect()
301314
if clipRegion.intersects(rect):
302315
pm = QPixmap(rect.size())
303316
QwtPainter.fillPixmap(

‎qwt/tests/test_issue115_porting_bugs.py‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from qwt.dyngrid_layout import QwtDynGridLayout
3838
from qwt.graphic import QwtGraphic
3939
from qwt.painter_command import QwtPainterCommand
40+
from qwt.plot_canvas import QwtPlotCanvas, qwtCombinePathList
4041
from qwt.plot_curve import series_to_polyline
4142
from qwt.transform import QwtPowerTransform
4243

@@ -567,6 +568,73 @@ def test_dyngrid_layout_vertical_expanding():
567568
del app
568569

569570

571+
# ---------------------------------------------------------------------------
572+
# Canvas style sheets with rounded borders
573+
# ---------------------------------------------------------------------------
574+
575+
576+
def _curve_path(start, ctrl1, ctrl2, end):
577+
path = QG.QPainterPath(QC.QPointF(*start))
578+
path.cubicTo(QC.QPointF(*ctrl1), QC.QPointF(*ctrl2), QC.QPointF(*end))
579+
return path
580+
581+
582+
@pytest.mark.parametrize("rect_type", (QC.QRect, QC.QRectF))
583+
def test_combine_path_list_incomplete_border(rect_type):
584+
"""Incomplete rounded borders are not combined (as in Qwt C++)"""
585+
rect = rect_type(0, 0, 100, 100)
586+
assert qwtCombinePathList(rect, []).isEmpty()
587+
corner = _curve_path((0.0, 10.0), (0.0, 7.0), (0.0, 6.0), (0.0, 5.0))
588+
assert qwtCombinePathList(rect, [corner]).isEmpty()
589+
590+
591+
def test_combine_path_list_rounded_corner():
592+
"""The sub-paths of a rounded corner are combined with the other corners"""
593+
rect = QC.QRectF(0.0, 0.0, 100.0, 100.0)
594+
left = _curve_path((0.0, 5.0), (0.0, 6.0), (0.0, 7.0), (0.0, 10.0))
595+
top = _curve_path((5.0, 0.0), (6.0, 0.0), (7.0, 0.0), (10.0, 0.0))
596+
references = [QG.QPainterPath(left), QG.QPainterPath(top)]
597+
path = qwtCombinePathList(rect, [left, top])
598+
assert not path.isEmpty()
599+
assert path.boundingRect() == rect
600+
# The sub-paths are reverted when needed, but the input ones are unchanged
601+
assert [left, top] == references
602+
603+
604+
ROUNDED_STYLE_SHEETS = (
605+
"border-radius: 10px; background-color: white;",
606+
"border: 2px solid black; border-radius: 8px;",
607+
"border: 1px solid gray; border-top-left-radius: 12px; background-color: white;",
608+
)
609+
610+
611+
@pytest.mark.parametrize("backing_store", (False, True), ids=("direct", "backing"))
612+
@pytest.mark.parametrize("style_sheet", ROUNDED_STYLE_SHEETS)
613+
def test_canvas_rounded_style_sheet(style_sheet, backing_store):
614+
"""A canvas style sheet with rounded borders is supported"""
615+
app = _ensure_app() # keep a reference alive for the duration of the test
616+
plot = QwtPlot()
617+
canvas = plot.canvas()
618+
canvas.setPaintAttribute(QwtPlotCanvas.BackingStore, backing_store)
619+
with _qt_exceptions() as errors:
620+
canvas.setStyleSheet(style_sheet)
621+
plot.resize(400, 300)
622+
plot.show()
623+
app.processEvents()
624+
plot.replot()
625+
image = canvas.grab().toImage()
626+
border_path = canvas.borderPath(canvas.rect())
627+
assert not errors, _format_exceptions(errors)
628+
assert not border_path.isEmpty()
629+
if "background-color: white" in style_sheet:
630+
# Rounded top left corner: filled with the background of the plot
631+
white = QG.QColor(QC.Qt.white).rgb()
632+
assert image.pixelColor(0, 0).rgb() != white
633+
assert image.pixelColor(image.width() // 2, image.height() // 2).rgb() == white
634+
plot.close()
635+
del app
636+
637+
570638
if __name__ == "__main__":
571639
for name in ("toPixmap", "toImage"):
572640
test_graphic_conversion_default_size(name)
@@ -586,4 +654,8 @@ def test_dyngrid_layout_vertical_expanding():
586654
test_painter_command_copy_is_independent()
587655
test_painter_command_copy_replay()
588656
test_dyngrid_layout_vertical_expanding()
657+
test_combine_path_list_rounded_corner()
658+
for sheet in ROUNDED_STYLE_SHEETS:
659+
for backing in (False, True):
660+
test_canvas_rounded_style_sheet(sheet, backing)
589661
print("OK")

0 commit comments

Comments
 (0)