Skip to content
Merged
5 changes: 4 additions & 1 deletion android/src/toga_android/widgets/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions changes/4161.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
There are no longer any zero division errors when drawing an ellipse in a Canvas on macOS, iOS or Gtk backends.
1 change: 1 addition & 0 deletions changes/4161.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The Canvas widget can now draw rounded rectangles.
12 changes: 6 additions & 6 deletions cocoa/src/toga_cocoa/widgets/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down
4 changes: 3 additions & 1 deletion core/src/toga/widgets/canvas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -74,5 +74,7 @@ def __getattr__(name):
"StrokeContext",
# Geometry
"arc_to_bezier",
"get_round_rect_radii",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be exposed as public API? It's definitely a useful internal utility, but does it need to be exposed like this?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related note: the other geometry helpers are only exported from __init__ because of the off chance that someone was already importing any of them from toga.widgets.canvas, which used to be all one file. But we should probably deprecate and remove those too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be exposed as public API? It's definitely a useful internal utility, but does it need to be exposed like this?

I exposed it because it may be useful for backends which do implement the round rect in an HTML Canvas compatible way (which may only the web backend when it has its canvas class written) where the processing of the arguments is the main task. But yes, more generally I was a bit surprised that this was part of the Canvas API.

I'll remove the new items from the public API and fix the imports in the backends so that they import from ...canvas.geometry directly.

"round_rect",
"sweepangle",
]
15 changes: 15 additions & 0 deletions core/src/toga/widgets/canvas/drawingaction.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -16,6 +17,8 @@
)
from toga.images import Image

from .geometry import CornerRadiusT

if TYPE_CHECKING:
from toga.colors import ColorT

Expand Down Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions core/src/toga/widgets/canvas/geometry.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -69,6 +79,81 @@ 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]
Comment thread
corranwebster marked this conversation as resolved.
Outdated
) -> 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]
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.

: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],
Expand Down
43 changes: 42 additions & 1 deletion core/src/toga/widgets/canvas/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,11 +28,13 @@
Rect,
ResetTransform,
Rotate,
RoundRect,
Scale,
Stroke,
Translate,
WriteText,
)
from .geometry import CornerRadiusT

if TYPE_CHECKING:
from toga.colors import ColorT
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions core/tests/widgets/canvas/test_draw_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
Loading
Loading