diff --git a/android/src/toga_android/widgets/canvas.py b/android/src/toga_android/widgets/canvas.py index b30c8261ca..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, 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 @@ -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.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/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..9d1e11a257 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.geometry 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/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..965547d0b6 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,117 @@ 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 + + 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 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. + """ + if isinstance(radii, (int, float, CornerRadiusT)): + radii = [radii] + 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: {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 + 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. + + 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 corner radii of the rounded rectangle. + """ + 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..a648ec73cd 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] == [ + ("round 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..ede3ddd955 100644 --- a/core/tests/widgets/canvas/test_helpers.py +++ b/core/tests/widgets/canvas/test_helpers.py @@ -1,8 +1,14 @@ from math import pi -from pytest import approx +from pytest import approx, mark, raises -from toga.widgets.canvas import arc_to_bezier, sweepangle +from toga.widgets.canvas.geometry 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,126 @@ 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}), + ] + + +class Radius: + x: float + y: float + + def __init__(self, x, y): + self.x = x + self.y = y + + +@mark.parametrize( + "w, h, radii, expected", + # argument handling + [ + (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 + + +def test_get_round_rect_radii_errors(): + # 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]) diff --git a/dummy/src/toga_dummy/widgets/canvas.py b/dummy/src/toga_dummy/widgets/canvas.py index c5c167df1f..6d934c46ff 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( + ( + "round 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..874e408bbd 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.geometry import round_rect from toga_gtk.colors import native_color from toga_gtk.libs import ( GTK_VERSION, @@ -122,18 +123,22 @@ 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) + # 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() 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..3344d55761 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.geometry 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..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.geometry import arc_to_bezier, sweepangle +from toga.widgets.canvas.geometry 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 0000000000..a93f7d8403 Binary files /dev/null and b/testbed/src/testbed/resources/canvas/round_rect.png differ diff --git a/testbed/tests/widgets/test_canvas.py b/testbed/tests/widgets/test_canvas.py index 351b8ae924..ace21fc082 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", threshold=0.016) + + 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..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, sweepangle +from toga.widgets.canvas.geometry 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):