From 183745615a711f0aaf72bafe6f2ef2a480c9f16e Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Tue, 3 Feb 2026 13:07:10 +0000 Subject: [PATCH 1/9] Initial commit of rounded rectangle support. --- android/src/toga_android/widgets/canvas.py | 5 +- changes/4161.feature.md | 1 + cocoa/src/toga_cocoa/widgets/canvas.py | 12 +- core/src/toga/widgets/canvas/__init__.py | 4 +- core/src/toga/widgets/canvas/drawingaction.py | 15 +++ core/src/toga/widgets/canvas/geometry.py | 83 ++++++++++++ core/src/toga/widgets/canvas/state.py | 43 +++++- .../widgets/canvas/test_draw_operations.py | 20 +++ core/tests/widgets/canvas/test_helpers.py | 122 +++++++++++++++++- dummy/src/toga_dummy/widgets/canvas.py | 8 ++ gtk/src/toga_gtk/widgets/canvas.py | 12 +- iOS/src/toga_iOS/widgets/canvas.py | 12 +- qt/src/toga_qt/widgets/canvas.py | 5 +- .../testbed/resources/canvas/round_rect.png | Bin 0 -> 6711 bytes testbed/tests/widgets/test_canvas.py | 28 ++++ winforms/src/toga_winforms/widgets/canvas.py | 5 +- 16 files changed, 351 insertions(+), 24 deletions(-) create mode 100644 changes/4161.feature.md create mode 100644 testbed/src/testbed/resources/canvas/round_rect.png diff --git a/android/src/toga_android/widgets/canvas.py b/android/src/toga_android/widgets/canvas.py index b30c8261ca..27dd4f21fb 100644 --- a/android/src/toga_android/widgets/canvas.py +++ b/android/src/toga_android/widgets/canvas.py @@ -19,7 +19,7 @@ from toga.colors import rgb from toga.constants import Baseline, FillRule -from toga.widgets.canvas import arc_to_bezier, sweepangle +from toga.widgets.canvas import arc_to_bezier, round_rect, sweepangle from ..colors import native_color from .base import Widget, suppress_reference_error @@ -154,6 +154,9 @@ def ellipse( def rect(self, x, y, width, height): self.path.addRect(x, y, x + width, y + height, Path.Direction.CW) + def round_rect(self, x, y, width, height, radii): + round_rect(self, x, y, width, height, radii) + # Drawing Paths def fill(self, fill_rule): diff --git a/changes/4161.feature.md b/changes/4161.feature.md new file mode 100644 index 0000000000..9b6585f0e1 --- /dev/null +++ b/changes/4161.feature.md @@ -0,0 +1 @@ +The Canvas widget can now draw rounded rectangles. diff --git a/cocoa/src/toga_cocoa/widgets/canvas.py b/cocoa/src/toga_cocoa/widgets/canvas.py index de809ba345..3d2b7a2c5a 100644 --- a/cocoa/src/toga_cocoa/widgets/canvas.py +++ b/cocoa/src/toga_cocoa/widgets/canvas.py @@ -8,6 +8,7 @@ from toga.colors import BLACK, TRANSPARENT, Color from toga.constants import Baseline, FillRule +from toga.widgets.canvas import round_rect from toga_cocoa.colors import native_color from toga_cocoa.libs import ( CGFloat, @@ -149,18 +150,17 @@ def ellipse( self.save() self.translate(x, y) self.rotate(rotation) - if radiusx >= radiusy: - self.scale(1, radiusy / radiusx) - self.arc(0, 0, radiusx, startangle, endangle, counterclockwise) - else: - self.scale(radiusx / radiusy, 1) - self.arc(0, 0, radiusy, startangle, endangle, counterclockwise) + self.scale(radiusx, radiusy) + self.arc(0, 0, 1.0, startangle, endangle, counterclockwise) self.restore() def rect(self, x, y, width, height): rectangle = CGRectMake(x, y, width, height) core_graphics.CGContextAddRect(self.native, rectangle) + def round_rect(self, x, y, width, height, radii): + round_rect(self, x, y, width, height, radii) + # Drawing Paths def fill(self, fill_rule): diff --git a/core/src/toga/widgets/canvas/__init__.py b/core/src/toga/widgets/canvas/__init__.py index 4d606afaf4..7b1c868b17 100644 --- a/core/src/toga/widgets/canvas/__init__.py +++ b/core/src/toga/widgets/canvas/__init__.py @@ -20,7 +20,7 @@ Translate, WriteText, ) -from .geometry import arc_to_bezier, sweepangle +from .geometry import arc_to_bezier, get_round_rect_radii, round_rect, sweepangle from .state import ClosedPathContext, FillContext, State, StrokeContext # Make sure deprecation warnings are shown by default @@ -74,5 +74,7 @@ def __getattr__(name): "StrokeContext", # Geometry "arc_to_bezier", + "get_round_rect_radii", + "round_rect", "sweepangle", ] diff --git a/core/src/toga/widgets/canvas/drawingaction.py b/core/src/toga/widgets/canvas/drawingaction.py index 3875138f3a..6424bdc29b 100644 --- a/core/src/toga/widgets/canvas/drawingaction.py +++ b/core/src/toga/widgets/canvas/drawingaction.py @@ -1,6 +1,7 @@ from __future__ import annotations from abc import ABC, abstractmethod +from collections.abc import Iterable from dataclasses import InitVar, dataclass, fields, is_dataclass from enum import Enum from math import pi @@ -16,6 +17,8 @@ ) from toga.images import Image +from .geometry import CornerRadiusT + if TYPE_CHECKING: from toga.colors import ColorT @@ -298,6 +301,18 @@ def _draw(self, context: Any) -> None: context.rect(self.x, self.y, self.width, self.height) +@dataclass(repr=False) +class RoundRect(DrawingAction): + x: float + y: float + width: float + height: float + radii: float | CornerRadiusT | Iterable[float | CornerRadiusT] + + def _draw(self, context: Any) -> None: + context.round_rect(self.x, self.y, self.width, self.height, self.radii) + + @dataclass(repr=False) class WriteText(DrawingAction): text: str diff --git a/core/src/toga/widgets/canvas/geometry.py b/core/src/toga/widgets/canvas/geometry.py index f3a0a7b95a..2df7a06c0b 100644 --- a/core/src/toga/widgets/canvas/geometry.py +++ b/core/src/toga/widgets/canvas/geometry.py @@ -1,4 +1,14 @@ +from collections.abc import Iterable from math import cos, pi, sin, tan +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class CornerRadiusT(Protocol): + """Protocol for objects that can be used as a corner radius.""" + + x: float + y: float def sweepangle(startangle: float, endangle: float, counterclockwise: bool) -> float: @@ -69,6 +79,79 @@ def arc_to_bezier(sweepangle: float) -> list[tuple[float, float]]: return result +def get_round_rect_radii( + w: float, h: float, radii: float | CornerRadiusT | Iterable[float | CornerRadiusT] +) -> list[tuple[int | float, int | float]]: + """Determine the corner radii for a rounded rectangle. + + This implements the procedure described here: + https://html.spec.whatwg.org/multipage/canvas.html#dom-context-2d-roundrect + + :param w: The width of the rounded rectangle. + :param h: The height of the rounded rectangle. + :param radii: The radii: a float. + :returns: list of radii [ul, ur, ll, lr] for upper and lower left and right + where each item is a 2-tuple of (rx, ry), the radius for x and y + directions. + """ + if isinstance(radii, (int, float, CornerRadiusT)): + radii = [radii] + corners = [(r, r) if isinstance(r, (int, float)) else (r.x, r.y) for r in radii] + if len(corners) == 1: + corners *= 4 + elif len(corners) == 2: + corners = [corners[0], corners[1], corners[1], corners[0]] + elif len(corners) == 3: + corners = [corners[0], corners[1], corners[1], corners[2]] + elif len(corners) != 4: + raise ValueError( + f"Invalid radii: {corners!r}, expected length between 1 and 4 items" + ) + # get corners + ul, ur, ll, lr = corners + + # ensure radii are smaller than sides + top = ul[0] + ur[0] + bottom = ll[0] + lr[0] + horizontal = max(top, bottom, abs(w)) + left = ul[1] + ll[1] + right = ur[1] + lr[1] + vertical = max(left, right, abs(h)) + + scale = min(abs(w) / horizontal, abs(h) / vertical) + sign_x = w / abs(w) if w != 0 else 1 + sign_y = h / abs(h) if h != 0 else 1 + corners = [(sign_x * x * scale, sign_y * y * scale) for x, y in corners] + return corners + + +def round_rect(context, x, y, w, h, radii): + """Given a native context draw a rounded rectangle. + + This implements the procedure described here: + https://html.spec.whatwg.org/multipage/canvas.html#dom-context-2d-roundrect + + The native context needs to implement at least move_to, line_to and ellipse. + + :param x: The width of the rounded rectangle. + :param y: The height of the rounded rectangle. + :param w: The width of the rounded rectangle. + :param h: The height of the rounded rectangle. + :param radii: The radii: a float. + """ + ul, ur, ll, lr = get_round_rect_radii(w, h, radii) + context.move_to(x + ul[0], y) + context.line_to(x + w - ur[0], y) + context.ellipse(x + w - ur[0], y + ur[1], *ur, 0, -pi / 2, 0, False) + context.line_to(x + w, y + h - lr[1]) + context.ellipse(x + w - lr[0], y + h - lr[1], *lr, 0, 0, pi / 2, False) + context.line_to(x + ll[0], y + h) + context.ellipse(x + ll[0], y + h - ll[1], *ll, 0, pi / 2, pi, False) + context.line_to(x, y + ul[1]) + context.ellipse(x + ul[0], y + ul[1], *ul, 0, pi, 3 * pi / 2, False) + context.move_to(x, y) + + def transform(x: float, y: float, matrix: list[int]) -> tuple[float, float]: return ( x * matrix[0] + y * matrix[1], diff --git a/core/src/toga/widgets/canvas/state.py b/core/src/toga/widgets/canvas/state.py index cc4a84905b..2fe672eebe 100644 --- a/core/src/toga/widgets/canvas/state.py +++ b/core/src/toga/widgets/canvas/state.py @@ -2,7 +2,7 @@ import warnings from abc import ABC, abstractmethod -from collections.abc import Iterator +from collections.abc import Iterable, Iterator from contextlib import contextmanager from math import pi from typing import TYPE_CHECKING, Any @@ -28,11 +28,13 @@ Rect, ResetTransform, Rotate, + RoundRect, Scale, Stroke, Translate, WriteText, ) +from .geometry import CornerRadiusT if TYPE_CHECKING: from toga.colors import ColorT @@ -250,6 +252,45 @@ def rect(self, x: float, y: float, width: float, height: float) -> Rect: self._action_target.append(rect) return rect + def round_rect( + self, + x: float, + y: float, + width: float, + height: float, + radii: float | CornerRadiusT | Iterable[float | CornerRadiusT], + ) -> RoundRect: + """Draw a rounded rectangle in the canvas state. + + Corner radii can be provided as: + - a single numerical radius for both x and y radius for all corners + - an object with attributes "x" and "y" for the x and y radius for all corners + - a list of 1 to 4 of the above + + If the list has: + - length 1, then the item gives the radius of all corners + - length 2, then the upper left and lower right corners use the first radius, + and upper right and lower left use the second radius + - length 3, then the upper left corner uses the first radius, the upper right + and lower left use the second radius, and the lower right corner uses the + third radius + - length 4, then the radii are given in order upper left, upper right, lower + left, lower right + + If the radii are too large for the width or height, then they will be scaled. + + :param x: The horizontal coordinate of the left of the rounded rectangle. + :param y: The vertical coordinate of the top of the rounded rectangle. + :param width: The width of the rounded rectangle. + :param height: The height of the roundedrectangle. + :param radii: The corner radii of the rounded rectangle. + :returns: The `RoundRect` [`DrawingAction`][toga.widgets.canvas.DrawingAction] + for the operation. + """ + round_rect = RoundRect(x, y, width, height, radii) + self._action_target.append(round_rect) + return round_rect + def fill( self, color: ColorT | None = None, diff --git a/core/tests/widgets/canvas/test_draw_operations.py b/core/tests/widgets/canvas/test_draw_operations.py index 967ab2f5ad..84406e8327 100644 --- a/core/tests/widgets/canvas/test_draw_operations.py +++ b/core/tests/widgets/canvas/test_draw_operations.py @@ -598,6 +598,26 @@ def test_rect(widget): assert draw_op.height == 40 +def test_round_rect(widget): + """A rect operation can be added.""" + draw_op = widget.root_state.round_rect(10, 20, 30, 40, 5) + + assert_action_performed(widget, "redraw") + assert repr(draw_op) == "RoundRect(x=10, y=20, width=30, height=40, radii=5)" + + # The first and last instructions save/restore the root state, and can be ignored. + assert widget._impl.draw_instructions[1:-1] == [ + ("rect", {"x": 10, "y": 20, "width": 30, "height": 40, "radii": 5}), + ] + + # All the attributes can be retrieved. + assert draw_op.x == 10 + assert draw_op.y == 20 + assert draw_op.width == 30 + assert draw_op.height == 40 + assert draw_op.radii == 5 + + SYSTEM_FONT_IMPL = Font(SYSTEM, SYSTEM_DEFAULT_FONT_SIZE)._impl diff --git a/core/tests/widgets/canvas/test_helpers.py b/core/tests/widgets/canvas/test_helpers.py index 6149134ecc..5d06409da9 100644 --- a/core/tests/widgets/canvas/test_helpers.py +++ b/core/tests/widgets/canvas/test_helpers.py @@ -2,7 +2,13 @@ from pytest import approx -from toga.widgets.canvas import arc_to_bezier, sweepangle +from toga.widgets.canvas import ( + arc_to_bezier, + get_round_rect_radii, + round_rect, + sweepangle, +) +from toga_dummy.widgets.canvas import Canvas, Context def test_sweepangle(): @@ -216,3 +222,117 @@ def test_arc_to_bezier(): (1.0, 0.0), ], ) + + +def test_round_rect(): + canvas = Canvas(None) + canvas.draw_instructions = [] + context = Context(canvas) + + round_rect(context, 10, 20, 30, 40, 5) + assert canvas.draw_instructions == [ + ("move to", {"x": 15, "y": 20}), + ("line to", {"x": 35, "y": 20}), + ( + "ellipse", + { + "x": 35, + "y": 25, + "radiusx": 5, + "radiusy": 5, + "rotation": 0, + "startangle": -pi / 2, + "endangle": 0, + "counterclockwise": False, + }, + ), + ("line to", {"x": 40, "y": 55}), + ( + "ellipse", + { + "x": 35, + "y": 55, + "radiusx": 5, + "radiusy": 5, + "rotation": 0, + "startangle": 0, + "endangle": pi / 2, + "counterclockwise": False, + }, + ), + ("line to", {"x": 15, "y": 60}), + ( + "ellipse", + { + "x": 15, + "y": 55, + "radiusx": 5, + "radiusy": 5, + "rotation": 0, + "startangle": pi / 2, + "endangle": pi, + "counterclockwise": False, + }, + ), + ("line to", {"x": 10, "y": 25}), + ( + "ellipse", + { + "x": 15, + "y": 25, + "radiusx": 5, + "radiusy": 5, + "rotation": 0, + "startangle": pi, + "endangle": 3 * pi / 2, + "counterclockwise": False, + }, + ), + ("move to", {"x": 10, "y": 20}), + ] + + +def assert_get_round_rect_radii(w, h, radii, expected): + actual = get_round_rect_radii(w, h, radii) + assert actual == expected + + +def test_get_round_rect_radii(): + class Radius: + x: float + y: float + + def __init__(self, x, y): + self.x = x + self.y = y + + # argument handling + assert_get_round_rect_radii(20, 30, 5, [(5, 5), (5, 5), (5, 5), (5, 5)]) + assert_get_round_rect_radii( + 20, 30, 5.5, [(5.5, 5.5), (5.5, 5.5), (5.5, 5.5), (5.5, 5.5)] + ) + assert_get_round_rect_radii(20, 30, [5], [(5, 5), (5, 5), (5, 5), (5, 5)]) + assert_get_round_rect_radii(20, 30, [5, 6], [(5, 5), (6, 6), (6, 6), (5, 5)]) + assert_get_round_rect_radii(20, 30, [5, 6, 7], [(5, 5), (6, 6), (6, 6), (7, 7)]) + assert_get_round_rect_radii(20, 30, [5, 6, 7, 8], [(5, 5), (6, 6), (7, 7), (8, 8)]) + assert_get_round_rect_radii(20, 30, Radius(5, 6), [(5, 6), (5, 6), (5, 6), (5, 6)]) + assert_get_round_rect_radii( + 20, 30, [Radius(5, 6)], [(5, 6), (5, 6), (5, 6), (5, 6)] + ) + assert_get_round_rect_radii( + 20, 30, [Radius(5, 6), 7], [(5, 6), (7, 7), (7, 7), (5, 6)] + ) + + # scaling needed + assert_get_round_rect_radii(10, 20, 10, [(5, 5), (5, 5), (5, 5), (5, 5)]) + assert_get_round_rect_radii( + 20, 20, Radius(5, 20), [(2.5, 10), (2.5, 10), (2.5, 10), (2.5, 10)] + ) + + # negative width and/or height + assert_get_round_rect_radii(-20, 30, 5, [(-5, 5), (-5, 5), (-5, 5), (-5, 5)]) + assert_get_round_rect_radii(20, -30, 5, [(5, -5), (5, -5), (5, -5), (5, -5)]) + + # degenerate cases + assert_get_round_rect_radii(0, 20, 5, [(0, 0), (0, 0), (0, 0), (0, 0)]) + assert_get_round_rect_radii(20, 0, 5, [(0, 0), (0, 0), (0, 0), (0, 0)]) diff --git a/dummy/src/toga_dummy/widgets/canvas.py b/dummy/src/toga_dummy/widgets/canvas.py index c5c167df1f..b4ddf21549 100644 --- a/dummy/src/toga_dummy/widgets/canvas.py +++ b/dummy/src/toga_dummy/widgets/canvas.py @@ -117,6 +117,14 @@ def rect(self, x, y, width, height): ) ) + def round_rect(self, x, y, width, height, radii): + self.impl.draw_instructions.append( + ( + "rect", + {"x": x, "y": y, "width": width, "height": height, "radii": radii}, + ) + ) + # Drawing Paths def fill(self, fill_rule): self.impl.draw_instructions.append(("fill", {"fill_rule": fill_rule})) diff --git a/gtk/src/toga_gtk/widgets/canvas.py b/gtk/src/toga_gtk/widgets/canvas.py index 892514ad70..839e27c6cc 100644 --- a/gtk/src/toga_gtk/widgets/canvas.py +++ b/gtk/src/toga_gtk/widgets/canvas.py @@ -10,6 +10,7 @@ from toga.constants import Baseline, FillRule from toga.fonts import SYSTEM_DEFAULT_FONT_SIZE from toga.handlers import WeakrefCallable +from toga.widgets.canvas import round_rect from toga_gtk.colors import native_color from toga_gtk.libs import ( GTK_VERSION, @@ -122,18 +123,17 @@ def ellipse( self.native.save() self.native.translate(x, y) self.native.rotate(rotation) - if radiusx >= radiusy: - self.native.scale(1, radiusy / radiusx) - self.arc(0, 0, radiusx, startangle, endangle, counterclockwise) - else: - self.native.scale(radiusx / radiusy, 1) - self.arc(0, 0, radiusy, startangle, endangle, counterclockwise) + self.native.scale(radiusx, radiusy) + self.arc(0, 0, 1.0, startangle, endangle, counterclockwise) self.native.identity_matrix() self.native.restore() def rect(self, x, y, width, height): self.native.rectangle(x, y, width, height) + def round_rect(self, x, y, width, height, radii): + round_rect(self, x, y, width, height, radii) + # Drawing Paths def fill(self, fill_rule): diff --git a/iOS/src/toga_iOS/widgets/canvas.py b/iOS/src/toga_iOS/widgets/canvas.py index 353a37a14c..f8ec180e6b 100644 --- a/iOS/src/toga_iOS/widgets/canvas.py +++ b/iOS/src/toga_iOS/widgets/canvas.py @@ -20,6 +20,7 @@ from toga.colors import BLACK, TRANSPARENT, Color from toga.constants import Baseline, FillRule +from toga.widgets.canvas import round_rect from toga_iOS.colors import native_color from toga_iOS.images import nsdata_to_bytes from toga_iOS.libs import ( @@ -173,18 +174,17 @@ def ellipse( self.save() self.translate(x, y) self.rotate(rotation) - if radiusx >= radiusy: - self.scale(1, radiusy / radiusx) - self.arc(0, 0, radiusx, startangle, endangle, counterclockwise) - else: - self.scale(radiusx / radiusy, 1) - self.arc(0, 0, radiusy, startangle, endangle, counterclockwise) + self.scale(radiusx, radiusy) + self.arc(0, 0, 1.0, startangle, endangle, counterclockwise) self.restore() def rect(self, x, y, width, height): rectangle = CGRectMake(x, y, width, height) core_graphics.CGContextAddRect(self.native, rectangle) + def round_rect(self, x, y, width, height, radii): + round_rect(self, x, y, width, height, radii) + # Drawing Paths def fill(self, fill_rule): if fill_rule == FillRule.EVENODD: diff --git a/qt/src/toga_qt/widgets/canvas.py b/qt/src/toga_qt/widgets/canvas.py index ad14f97f7f..05eb350fa0 100644 --- a/qt/src/toga_qt/widgets/canvas.py +++ b/qt/src/toga_qt/widgets/canvas.py @@ -16,7 +16,7 @@ from toga.colors import rgb from toga.constants import Baseline, FillRule -from toga.widgets.canvas.geometry import arc_to_bezier, sweepangle +from toga.widgets.canvas import arc_to_bezier, round_rect, sweepangle from ..colors import native_color from .base import Widget @@ -170,6 +170,9 @@ def ellipse( def rect(self, x, y, width, height): self._path.addRect(x, y, width, height) + def round_rect(self, x, y, width, height, radii): + round_rect(self, x, y, width, height, radii) + # Drawing Paths def fill(self, fill_rule): diff --git a/testbed/src/testbed/resources/canvas/round_rect.png b/testbed/src/testbed/resources/canvas/round_rect.png new file mode 100644 index 0000000000000000000000000000000000000000..190e6ebec073e8f883637074afcd5a86630e736b GIT binary patch literal 6711 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 literal 0 HcmV?d00001 diff --git a/testbed/tests/widgets/test_canvas.py b/testbed/tests/widgets/test_canvas.py index 351b8ae924..48e4a40abd 100644 --- a/testbed/tests/widgets/test_canvas.py +++ b/testbed/tests/widgets/test_canvas.py @@ -509,6 +509,34 @@ async def test_rect(canvas, probe): assert_reference(probe, "rect") +async def test_round_rect(canvas, probe): + "A rounded rectangle can be drawn" + + class Corner: + def __init__(self, x, y): + self.x = x + self.y = y + + # Draw a rounded rectangle. move_to is implied + canvas.root_state.begin_path() + canvas.root_state.round_rect( + x=20, y=10, width=160, height=80, radii=[5, 30, Corner(50, 30)] + ) + canvas.root_state.fill(color=GOLDENROD) + canvas.root_state.stroke(color=REBECCAPURPLE) + + # Draw a rounded rectangle with negative width, height + canvas.root_state.begin_path() + canvas.root_state.round_rect( + x=190, y=180, width=-160, height=-80, radii=[0, 30, Corner(50, 60)] + ) + canvas.root_state.fill(color=CORNFLOWERBLUE) + canvas.root_state.stroke(color=BLACK) + + await probe.redraw("Filled and stroked rounded rectangles should be drawn") + assert_reference(probe, "round_rect") + + async def test_fill(canvas, probe): "A fill can be drawn with primitives" # Draw a closed path diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index ac6f0cf9c9..bbacad8fd1 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -25,7 +25,7 @@ from toga.colors import TRANSPARENT, rgb from toga.constants import Baseline, FillRule from toga.handlers import WeakrefCallable -from toga.widgets.canvas import arc_to_bezier, sweepangle +from toga.widgets.canvas import arc_to_bezier, round_rect, sweepangle from toga_winforms.colors import native_color from .box import Box @@ -231,6 +231,9 @@ def rect(self, x, y, width, height): self.current_path.AddRectangle(rect) self.add_path() + def round_rect(self, x, y, width, height, radii): + round_rect(self, x, y, width, height, radii) + # Drawing Paths def fill(self, fill_rule): From 7b6f401d1d4dac26f65ba6b50c770387d4028a0e Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Tue, 3 Feb 2026 13:25:04 +0000 Subject: [PATCH 2/9] Add tests for invalid lengths, and better error messages. --- core/src/toga/widgets/canvas/geometry.py | 20 +++++++++++--------- core/tests/widgets/canvas/test_helpers.py | 17 ++++++++++++++++- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/core/src/toga/widgets/canvas/geometry.py b/core/src/toga/widgets/canvas/geometry.py index 2df7a06c0b..04a1bbfe36 100644 --- a/core/src/toga/widgets/canvas/geometry.py +++ b/core/src/toga/widgets/canvas/geometry.py @@ -96,18 +96,20 @@ def get_round_rect_radii( """ if isinstance(radii, (int, float, CornerRadiusT)): radii = [radii] - corners = [(r, r) if isinstance(r, (int, float)) else (r.x, r.y) for r in radii] - if len(corners) == 1: - corners *= 4 - elif len(corners) == 2: - corners = [corners[0], corners[1], corners[1], corners[0]] - elif len(corners) == 3: - corners = [corners[0], corners[1], corners[1], corners[2]] - elif len(corners) != 4: + else: + radii = list(radii) + if len(radii) == 1: + radii *= 4 + elif len(radii) == 2: + radii = [radii[0], radii[1], radii[1], radii[0]] + elif len(radii) == 3: + radii = [radii[0], radii[1], radii[1], radii[2]] + elif len(radii) != 4: raise ValueError( - f"Invalid radii: {corners!r}, expected length between 1 and 4 items" + f"Invalid radii: {radii!r}, expected length between 1 and 4 items" ) # get corners + corners = [(r, r) if isinstance(r, (int, float)) else (r.x, r.y) for r in radii] ul, ur, ll, lr = corners # ensure radii are smaller than sides diff --git a/core/tests/widgets/canvas/test_helpers.py b/core/tests/widgets/canvas/test_helpers.py index 5d06409da9..6d2c9cb5e3 100644 --- a/core/tests/widgets/canvas/test_helpers.py +++ b/core/tests/widgets/canvas/test_helpers.py @@ -1,6 +1,6 @@ from math import pi -from pytest import approx +from pytest import approx, raises from toga.widgets.canvas import ( arc_to_bezier, @@ -336,3 +336,18 @@ def __init__(self, x, y): # degenerate cases assert_get_round_rect_radii(0, 20, 5, [(0, 0), (0, 0), (0, 0), (0, 0)]) assert_get_round_rect_radii(20, 0, 5, [(0, 0), (0, 0), (0, 0), (0, 0)]) + + # radii length + with raises( + ValueError, + match=r"Invalid radii: \[\], expected length between 1 and 4 items", + ): + get_round_rect_radii(20, 30, []) + + with raises( + ValueError, + match=( + r"Invalid radii: \[1, 2, 3, 4, 5\], expected length between 1 and 4 items" + ), + ): + get_round_rect_radii(20, 30, [1, 2, 3, 4, 5]) From d3ded37b6549a3ea09c75ee513596c6e8af81b89 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Tue, 3 Feb 2026 13:43:42 +0000 Subject: [PATCH 3/9] Fixes for tests, Gtk backend, and add a bugfix note. --- changes/4161.bugfix.md | 1 + gtk/src/toga_gtk/widgets/canvas.py | 5 +++++ .../testbed/resources/canvas/round_rect.png | Bin 6711 -> 6712 bytes 3 files changed, 6 insertions(+) create mode 100644 changes/4161.bugfix.md diff --git a/changes/4161.bugfix.md b/changes/4161.bugfix.md new file mode 100644 index 0000000000..7ac775f9b2 --- /dev/null +++ b/changes/4161.bugfix.md @@ -0,0 +1 @@ +There are no longer any zero division errors when drawing an ellipse in a Canvas on macOS, iOS or Gtk backends. diff --git a/gtk/src/toga_gtk/widgets/canvas.py b/gtk/src/toga_gtk/widgets/canvas.py index 839e27c6cc..10dcce3d79 100644 --- a/gtk/src/toga_gtk/widgets/canvas.py +++ b/gtk/src/toga_gtk/widgets/canvas.py @@ -123,6 +123,11 @@ def ellipse( self.native.save() self.native.translate(x, y) self.native.rotate(rotation) + # use very small radii instead of 0 + if radiusx == 0: + radiusx = 2**-24 + if radiusy == 0: + radiusy = 2**-24 self.native.scale(radiusx, radiusy) self.arc(0, 0, 1.0, startangle, endangle, counterclockwise) self.native.identity_matrix() diff --git a/testbed/src/testbed/resources/canvas/round_rect.png b/testbed/src/testbed/resources/canvas/round_rect.png index 190e6ebec073e8f883637074afcd5a86630e736b..a93f7d84039a1cb91efb5edb1769da2cf38aa9c5 100644 GIT binary patch delta 398 zcmV;90dfAfG`KX7B!6~kLqkwWLqi}?a&Km7Y-Iodc${^Sy-Pw-7{-5UQk2qALqvl^ zg@Zv9L}S_DA`xbg)pEUEHSptduM*N4v@{3}QA=M#|3R%mYe5hcK}2(7UrWgCJsK#X z=W_1*<2mPfdGB+<9nI=FyJH4&`JxqzgagZol|bhkZanx2;(yh2+nilkh|+d_Kj2dJ zeoXahf3M~0SvPDQTwTIbx@i@KWg4QJMN{#vaKE0>)`gFSLzWCEe53SaonIBF>-?$M zipS=_t$GI1O`L3EJ!9pBgTlkPY)Su{M}3LU$S*BQ9h5zUO^gU(0wgK1L6#z8QuC5o zYu|XyH%~!4x_`V)%2?t^lM!!7)RJ^Hq^3kSNuUHb*Xh-d9x>;i)>v!Vf&7)7VW)&Kwi delta 397 zcmV;80doGhG`BR6B!6{jLqkwWLqi}?a&Km7Y-Iodc${^Sy-Pw-7{-5UQIz3OLqvmv zMT15ZL}S_DA|Ymw)uPw0z>mwlN=R$a(jYWMEqx9B2ek&R1wl{*5zUQ#Eg`q}sGx+N z%en85=bY!|z0U=EFstRvu6fAii$*x$cdtg*+&%Buao}VUM}JH+^@UIn9OINriq#>fdz2zzqblGd0eEJb`$^O9M6 z-&D=FL_s{7ynl7d7~)8i5wBO&l5|Z Date: Tue, 3 Feb 2026 13:59:52 +0000 Subject: [PATCH 4/9] Relax the threshold for image matching slightly. --- testbed/tests/widgets/test_canvas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testbed/tests/widgets/test_canvas.py b/testbed/tests/widgets/test_canvas.py index 48e4a40abd..ace21fc082 100644 --- a/testbed/tests/widgets/test_canvas.py +++ b/testbed/tests/widgets/test_canvas.py @@ -534,7 +534,7 @@ def __init__(self, x, y): canvas.root_state.stroke(color=BLACK) await probe.redraw("Filled and stroked rounded rectangles should be drawn") - assert_reference(probe, "round_rect") + assert_reference(probe, "round_rect", threshold=0.016) async def test_fill(canvas, probe): From ed203dcf74a2d95fef54401d0a808c1ae542006a Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Wed, 4 Feb 2026 06:47:11 +0000 Subject: [PATCH 5/9] Update core/src/toga/widgets/canvas/geometry.py Co-authored-by: Russell Keith-Magee --- core/src/toga/widgets/canvas/geometry.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/src/toga/widgets/canvas/geometry.py b/core/src/toga/widgets/canvas/geometry.py index 04a1bbfe36..b91dfad4f2 100644 --- a/core/src/toga/widgets/canvas/geometry.py +++ b/core/src/toga/widgets/canvas/geometry.py @@ -80,7 +80,9 @@ def arc_to_bezier(sweepangle: float) -> list[tuple[float, float]]: def get_round_rect_radii( - w: float, h: float, radii: float | CornerRadiusT | Iterable[float | CornerRadiusT] + w: float, + h: float, + radii: float | CornerRadiusT | Iterable[float | CornerRadiusT], ) -> list[tuple[int | float, int | float]]: """Determine the corner radii for a rounded rectangle. From 8517642838b994a61d29e18e0af65cdbfaea68c6 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Wed, 4 Feb 2026 07:24:58 +0000 Subject: [PATCH 6/9] Fixes from PR review. --- android/src/toga_android/widgets/canvas.py | 2 +- core/tests/widgets/canvas/test_helpers.py | 76 +++++++++----------- gtk/src/toga_gtk/widgets/canvas.py | 2 +- iOS/src/toga_iOS/widgets/canvas.py | 2 +- qt/src/toga_qt/widgets/canvas.py | 2 +- winforms/src/toga_winforms/widgets/canvas.py | 2 +- 6 files changed, 40 insertions(+), 46 deletions(-) diff --git a/android/src/toga_android/widgets/canvas.py b/android/src/toga_android/widgets/canvas.py index 27dd4f21fb..0a281d256b 100644 --- a/android/src/toga_android/widgets/canvas.py +++ b/android/src/toga_android/widgets/canvas.py @@ -19,7 +19,7 @@ from toga.colors import rgb from toga.constants import Baseline, FillRule -from toga.widgets.canvas import arc_to_bezier, round_rect, sweepangle +from toga.widgets.canvas.geometry import arc_to_bezier, round_rect, sweepangle from ..colors import native_color from .base import Widget, suppress_reference_error diff --git a/core/tests/widgets/canvas/test_helpers.py b/core/tests/widgets/canvas/test_helpers.py index 6d2c9cb5e3..ede3ddd955 100644 --- a/core/tests/widgets/canvas/test_helpers.py +++ b/core/tests/widgets/canvas/test_helpers.py @@ -1,8 +1,8 @@ from math import pi -from pytest import approx, raises +from pytest import approx, mark, raises -from toga.widgets.canvas import ( +from toga.widgets.canvas.geometry import ( arc_to_bezier, get_round_rect_radii, round_rect, @@ -292,51 +292,45 @@ def test_round_rect(): ] -def assert_get_round_rect_radii(w, h, radii, expected): - actual = get_round_rect_radii(w, h, radii) - assert actual == expected - +class Radius: + x: float + y: float -def test_get_round_rect_radii(): - class Radius: - x: float - y: float + def __init__(self, x, y): + self.x = x + self.y = y - def __init__(self, x, y): - self.x = x - self.y = y +@mark.parametrize( + "w, h, radii, expected", # argument handling - assert_get_round_rect_radii(20, 30, 5, [(5, 5), (5, 5), (5, 5), (5, 5)]) - assert_get_round_rect_radii( - 20, 30, 5.5, [(5.5, 5.5), (5.5, 5.5), (5.5, 5.5), (5.5, 5.5)] - ) - assert_get_round_rect_radii(20, 30, [5], [(5, 5), (5, 5), (5, 5), (5, 5)]) - assert_get_round_rect_radii(20, 30, [5, 6], [(5, 5), (6, 6), (6, 6), (5, 5)]) - assert_get_round_rect_radii(20, 30, [5, 6, 7], [(5, 5), (6, 6), (6, 6), (7, 7)]) - assert_get_round_rect_radii(20, 30, [5, 6, 7, 8], [(5, 5), (6, 6), (7, 7), (8, 8)]) - assert_get_round_rect_radii(20, 30, Radius(5, 6), [(5, 6), (5, 6), (5, 6), (5, 6)]) - assert_get_round_rect_radii( - 20, 30, [Radius(5, 6)], [(5, 6), (5, 6), (5, 6), (5, 6)] - ) - assert_get_round_rect_radii( - 20, 30, [Radius(5, 6), 7], [(5, 6), (7, 7), (7, 7), (5, 6)] - ) - - # scaling needed - assert_get_round_rect_radii(10, 20, 10, [(5, 5), (5, 5), (5, 5), (5, 5)]) - assert_get_round_rect_radii( - 20, 20, Radius(5, 20), [(2.5, 10), (2.5, 10), (2.5, 10), (2.5, 10)] - ) - - # negative width and/or height - assert_get_round_rect_radii(-20, 30, 5, [(-5, 5), (-5, 5), (-5, 5), (-5, 5)]) - assert_get_round_rect_radii(20, -30, 5, [(5, -5), (5, -5), (5, -5), (5, -5)]) + [ + (20, 30, 5, [(5, 5), (5, 5), (5, 5), (5, 5)]), + (20, 30, 5.5, [(5.5, 5.5), (5.5, 5.5), (5.5, 5.5), (5.5, 5.5)]), + (20, 30, [5], [(5, 5), (5, 5), (5, 5), (5, 5)]), + (20, 30, [5, 6], [(5, 5), (6, 6), (6, 6), (5, 5)]), + (20, 30, [5, 6, 7], [(5, 5), (6, 6), (6, 6), (7, 7)]), + (20, 30, [5, 6, 7, 8], [(5, 5), (6, 6), (7, 7), (8, 8)]), + (20, 30, Radius(5, 6), [(5, 6), (5, 6), (5, 6), (5, 6)]), + (20, 30, [Radius(5, 6)], [(5, 6), (5, 6), (5, 6), (5, 6)]), + (20, 30, [Radius(5, 6), 7], [(5, 6), (7, 7), (7, 7), (5, 6)]), + # scaling needed + (10, 20, 10, [(5, 5), (5, 5), (5, 5), (5, 5)]), + (20, 20, Radius(5, 20), [(2.5, 10), (2.5, 10), (2.5, 10), (2.5, 10)]), + # negative width and/or height + (-20, 30, 5, [(-5, 5), (-5, 5), (-5, 5), (-5, 5)]), + (20, -30, 5, [(5, -5), (5, -5), (5, -5), (5, -5)]), + # degenerate cases + (0, 20, 5, [(0, 0), (0, 0), (0, 0), (0, 0)]), + (20, 0, 5, [(0, 0), (0, 0), (0, 0), (0, 0)]), + ], +) +def test_get_round_rect_radii(w, h, radii, expected): + actual = get_round_rect_radii(w, h, radii) + assert actual == expected - # degenerate cases - assert_get_round_rect_radii(0, 20, 5, [(0, 0), (0, 0), (0, 0), (0, 0)]) - assert_get_round_rect_radii(20, 0, 5, [(0, 0), (0, 0), (0, 0), (0, 0)]) +def test_get_round_rect_radii_errors(): # radii length with raises( ValueError, diff --git a/gtk/src/toga_gtk/widgets/canvas.py b/gtk/src/toga_gtk/widgets/canvas.py index 10dcce3d79..874e408bbd 100644 --- a/gtk/src/toga_gtk/widgets/canvas.py +++ b/gtk/src/toga_gtk/widgets/canvas.py @@ -10,7 +10,7 @@ from toga.constants import Baseline, FillRule from toga.fonts import SYSTEM_DEFAULT_FONT_SIZE from toga.handlers import WeakrefCallable -from toga.widgets.canvas import round_rect +from toga.widgets.canvas.geometry import round_rect from toga_gtk.colors import native_color from toga_gtk.libs import ( GTK_VERSION, diff --git a/iOS/src/toga_iOS/widgets/canvas.py b/iOS/src/toga_iOS/widgets/canvas.py index f8ec180e6b..3344d55761 100644 --- a/iOS/src/toga_iOS/widgets/canvas.py +++ b/iOS/src/toga_iOS/widgets/canvas.py @@ -20,7 +20,7 @@ from toga.colors import BLACK, TRANSPARENT, Color from toga.constants import Baseline, FillRule -from toga.widgets.canvas import round_rect +from toga.widgets.canvas.geometry import round_rect from toga_iOS.colors import native_color from toga_iOS.images import nsdata_to_bytes from toga_iOS.libs import ( diff --git a/qt/src/toga_qt/widgets/canvas.py b/qt/src/toga_qt/widgets/canvas.py index 05eb350fa0..17d5a12c0b 100644 --- a/qt/src/toga_qt/widgets/canvas.py +++ b/qt/src/toga_qt/widgets/canvas.py @@ -16,7 +16,7 @@ from toga.colors import rgb from toga.constants import Baseline, FillRule -from toga.widgets.canvas import arc_to_bezier, round_rect, sweepangle +from toga.widgets.canvas.geometry import arc_to_bezier, round_rect, sweepangle from ..colors import native_color from .base import Widget diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index bbacad8fd1..8031232af2 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -25,7 +25,7 @@ from toga.colors import TRANSPARENT, rgb from toga.constants import Baseline, FillRule from toga.handlers import WeakrefCallable -from toga.widgets.canvas import arc_to_bezier, round_rect, sweepangle +from toga.widgets.canvas.geometry import arc_to_bezier, round_rect, sweepangle from toga_winforms.colors import native_color from .box import Box From 21a6bed9ea7a7c0877dad04f7346d8856810884e Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Wed, 4 Feb 2026 07:27:39 +0000 Subject: [PATCH 7/9] Forgot one file in previous commit. --- core/src/toga/widgets/canvas/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/core/src/toga/widgets/canvas/__init__.py b/core/src/toga/widgets/canvas/__init__.py index 7b1c868b17..4d606afaf4 100644 --- a/core/src/toga/widgets/canvas/__init__.py +++ b/core/src/toga/widgets/canvas/__init__.py @@ -20,7 +20,7 @@ Translate, WriteText, ) -from .geometry import arc_to_bezier, get_round_rect_radii, round_rect, sweepangle +from .geometry import arc_to_bezier, sweepangle from .state import ClosedPathContext, FillContext, State, StrokeContext # Make sure deprecation warnings are shown by default @@ -74,7 +74,5 @@ def __getattr__(name): "StrokeContext", # Geometry "arc_to_bezier", - "get_round_rect_radii", - "round_rect", "sweepangle", ] From 36399f499e8201ef1e0692ca45e3fc8b694b612c Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Wed, 4 Feb 2026 07:33:26 +0000 Subject: [PATCH 8/9] Noticed an issue with docstrings and one more missed file. --- cocoa/src/toga_cocoa/widgets/canvas.py | 2 +- core/src/toga/widgets/canvas/geometry.py | 38 ++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/canvas.py b/cocoa/src/toga_cocoa/widgets/canvas.py index 3d2b7a2c5a..9d1e11a257 100644 --- a/cocoa/src/toga_cocoa/widgets/canvas.py +++ b/cocoa/src/toga_cocoa/widgets/canvas.py @@ -8,7 +8,7 @@ from toga.colors import BLACK, TRANSPARENT, Color from toga.constants import Baseline, FillRule -from toga.widgets.canvas import round_rect +from toga.widgets.canvas.geometry import round_rect from toga_cocoa.colors import native_color from toga_cocoa.libs import ( CGFloat, diff --git a/core/src/toga/widgets/canvas/geometry.py b/core/src/toga/widgets/canvas/geometry.py index b91dfad4f2..965547d0b6 100644 --- a/core/src/toga/widgets/canvas/geometry.py +++ b/core/src/toga/widgets/canvas/geometry.py @@ -89,9 +89,26 @@ def get_round_rect_radii( This implements the procedure described here: https://html.spec.whatwg.org/multipage/canvas.html#dom-context-2d-roundrect + Corner radii can be provided as: + - a single numerical radius for both x and y radius for all corners + - an object with attributes "x" and "y" for the x and y radius for all corners + - a list of 1 to 4 of the above + + If the list has: + - length 1, then the item gives the radius of all corners + - length 2, then the upper left and lower right corners use the first radius, + and upper right and lower left use the second radius + - length 3, then the upper left corner uses the first radius, the upper right + and lower left use the second radius, and the lower right corner uses the + third radius + - length 4, then the radii are given in order upper left, upper right, lower + left, lower right + + If the radii are too large for the width or height, then they will be scaled. + :param w: The width of the rounded rectangle. :param h: The height of the rounded rectangle. - :param radii: The radii: a float. + :param radii: The corner radii of the rounded rectangle. :returns: list of radii [ul, ur, ll, lr] for upper and lower left and right where each item is a 2-tuple of (rx, ry), the radius for x and y directions. @@ -137,11 +154,28 @@ def round_rect(context, x, y, w, h, radii): The native context needs to implement at least move_to, line_to and ellipse. + Corner radii can be provided as: + - a single numerical radius for both x and y radius for all corners + - an object with attributes "x" and "y" for the x and y radius for all corners + - a list of 1 to 4 of the above + + If the list has: + - length 1, then the item gives the radius of all corners + - length 2, then the upper left and lower right corners use the first radius, + and upper right and lower left use the second radius + - length 3, then the upper left corner uses the first radius, the upper right + and lower left use the second radius, and the lower right corner uses the + third radius + - length 4, then the radii are given in order upper left, upper right, lower + left, lower right + + If the radii are too large for the width or height, then they will be scaled. + :param x: The width of the rounded rectangle. :param y: The height of the rounded rectangle. :param w: The width of the rounded rectangle. :param h: The height of the rounded rectangle. - :param radii: The radii: a float. + :param radii: The corner radii of the rounded rectangle. """ ul, ur, ll, lr = get_round_rect_radii(w, h, radii) context.move_to(x + ul[0], y) From da44655273aa0d780a700c9848378ceb5f8c4043 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Wed, 4 Feb 2026 07:38:05 +0000 Subject: [PATCH 9/9] Fix dummy backend and tests. --- core/tests/widgets/canvas/test_draw_operations.py | 2 +- dummy/src/toga_dummy/widgets/canvas.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/tests/widgets/canvas/test_draw_operations.py b/core/tests/widgets/canvas/test_draw_operations.py index 84406e8327..a648ec73cd 100644 --- a/core/tests/widgets/canvas/test_draw_operations.py +++ b/core/tests/widgets/canvas/test_draw_operations.py @@ -607,7 +607,7 @@ def test_round_rect(widget): # The first and last instructions save/restore the root state, and can be ignored. assert widget._impl.draw_instructions[1:-1] == [ - ("rect", {"x": 10, "y": 20, "width": 30, "height": 40, "radii": 5}), + ("round rect", {"x": 10, "y": 20, "width": 30, "height": 40, "radii": 5}), ] # All the attributes can be retrieved. diff --git a/dummy/src/toga_dummy/widgets/canvas.py b/dummy/src/toga_dummy/widgets/canvas.py index b4ddf21549..6d934c46ff 100644 --- a/dummy/src/toga_dummy/widgets/canvas.py +++ b/dummy/src/toga_dummy/widgets/canvas.py @@ -120,7 +120,7 @@ def rect(self, x, y, width, height): def round_rect(self, x, y, width, height, radii): self.impl.draw_instructions.append( ( - "rect", + "round rect", {"x": x, "y": y, "width": width, "height": height, "radii": radii}, ) )