diff --git a/android/pyproject.toml b/android/pyproject.toml index e934a16135..838081e646 100644 --- a/android/pyproject.toml +++ b/android/pyproject.toml @@ -108,6 +108,9 @@ WebView = "toga_android.widgets.webview:WebView" MainWindow = "toga_android.window:MainWindow" Window = "toga_android.window:Window" +# Canvas implementation +Path2D = "toga_android.widgets.canvas:Path2D" + [tool.setuptools_scm] root = ".." diff --git a/android/src/toga_android/factory.py b/android/src/toga_android/factory.py index 78fc80e7d4..0821004893 100644 --- a/android/src/toga_android/factory.py +++ b/android/src/toga_android/factory.py @@ -15,7 +15,7 @@ from .widgets.activityindicator import ActivityIndicator from .widgets.box import Box from .widgets.button import Button -from .widgets.canvas import Canvas +from .widgets.canvas import Canvas, Path2D from .widgets.dateinput import DateInput from .widgets.detailedlist import DetailedList from .widgets.divider import Divider @@ -80,6 +80,7 @@ def not_implemented(feature): # pragma: no cover "NumberInput", "OptionContainer", "PasswordInput", + "Path2D", "ProgressBar", "ScrollContainer", # "SplitContainer", diff --git a/android/src/toga_android/widgets/canvas.py b/android/src/toga_android/widgets/canvas.py index ddc68b3197..31d46d8cdd 100644 --- a/android/src/toga_android/widgets/canvas.py +++ b/android/src/toga_android/widgets/canvas.py @@ -1,7 +1,7 @@ import itertools import weakref from copy import deepcopy -from math import degrees +from math import cos, degrees, sin from typing import NamedTuple from android.graphics import ( @@ -10,7 +10,7 @@ DashPathEffect, Matrix, Paint, - Path, + Path as NativePath, ) from android.view import MotionEvent, View from java import dynamic_proxy, jint @@ -27,6 +27,103 @@ BLACK = jint(native_color(rgb(0, 0, 0))) +def matrix_from_transform(transform): + a, b, c, d, e, f = transform + matrix = Matrix() + matrix.setValues([a, c, e, b, d, f, 0, 0, 1]) + return matrix + + +class Path2D: + native: NativePath + + def __init__(self): + self.native = NativePath() + self._last_point = None + + def _ensure_subpath(self, x, y): + if self.native.isEmpty(): + self.native.moveTo(x, y) + + def add_path(self, path, transform=None): + if transform is None: + self.native.addPath(path.native) + self._last_point = path._last_point + else: + native_path = NativePath(path.native) + matrix = matrix_from_transform(transform) + native_path.transform(matrix) + self.native.addPath(native_path) + if path._last_point is not None: + points = list(path._last_point) + matrix.mapPoints(points) + self._last_point = points[0] + + def close_path(self): + self.native.close() + self._last_point = None + + def move_to(self, x, y): + self.native.moveTo(x, y) + self._last_point = (x, y) + + def line_to(self, x, y): + self._ensure_subpath(x, y) + self.native.lineTo(x, y) + self._last_point = (x, y) + + def bezier_curve_to(self, cp1x, cp1y, cp2x, cp2y, x, y): + self._ensure_subpath(cp1x, cp1y) + self.native.cubicTo(cp1x, cp1y, cp2x, cp2y, x, y) + self._last_point = (x, y) + + def quadratic_curve_to(self, cpx, cpy, x, y): + self._ensure_subpath(cpx, cpy) + self.native.quadTo(cpx, cpy, x, y) + self._last_point = (x, y) + + def arc(self, x, y, radius, startangle, endangle, counterclockwise): + self.ellipse(x, y, radius, radius, 0, startangle, endangle, counterclockwise) + self._last_point = (x + radius * cos(endangle), y + sin(endangle)) + + def ellipse( + self, + x, + y, + radiusx, + radiusy, + rotation, + startangle, + endangle, + counterclockwise, + ): + matrix = Matrix() + matrix.preTranslate(x, y) + matrix.preRotate(degrees(rotation)) + matrix.preScale(radiusx, radiusy) + matrix.preRotate(degrees(startangle)) + + coords = list( + itertools.chain( + *arc_to_bezier(sweepangle(startangle, endangle, counterclockwise)) + ) + ) + matrix.mapPoints(coords) + + self.line_to(coords[0], coords[1]) + i = 2 + while i < len(coords): + self.bezier_curve_to(*coords[i : i + 6]) + i += 6 + + def rect(self, x, y, width, height): + self.native.addRect(x, y, x + width, y + height, NativePath.Direction.CW) + self._last_point = (x, y) + + def round_rect(self, x, y, width, height, radii): + round_rect(self, x, y, width, height, radii) + + class State(NamedTuple): fill: Paint stroke: Paint @@ -40,7 +137,7 @@ class Context: def __init__(self, impl, native): self.native = native self.impl = impl - self.path = Path() + self.path = Path2D() # Backwards compatibility for Toga <= 0.5.3 self.in_fill = False @@ -74,7 +171,7 @@ def save(self): def restore(self): self.native.restore() # Transform active path to current coordinates - self.path.transform(self.state.transform) + self.path.native.transform(self.state.transform) self.states.pop() # Setting attributes @@ -93,34 +190,27 @@ def set_stroke_style(self, color): # Basic paths def begin_path(self): - self.path.reset() + self.path = Path2D() def close_path(self): - self.path.close() + self.path.close_path() def move_to(self, x, y): - self.path.moveTo(x, y) + self.path.move_to(x, y) def line_to(self, x, y): - self._ensure_subpath(x, y) - self.path.lineTo(x, y) - - def _ensure_subpath(self, x, y): - if self.path.isEmpty(): - self.move_to(x, y) + self.path.line_to(x, y) # Basic shapes def bezier_curve_to(self, cp1x, cp1y, cp2x, cp2y, x, y): - self._ensure_subpath(cp1x, cp1y) - self.path.cubicTo(cp1x, cp1y, cp2x, cp2y, x, y) + self.path.bezier_curve_to(cp1x, cp1y, cp2x, cp2y, x, y) def quadratic_curve_to(self, cpx, cpy, x, y): - self._ensure_subpath(cpx, cpy) - self.path.quadTo(cpx, cpy, x, y) + self.path.quadratic_curve_to(cpx, cpy, x, y) def arc(self, x, y, radius, startangle, endangle, counterclockwise): - self.ellipse(x, y, radius, radius, 0, startangle, endangle, counterclockwise) + self.path.arc(x, y, radius, startangle, endangle, counterclockwise) def ellipse( self, @@ -133,45 +223,42 @@ def ellipse( endangle, counterclockwise, ): - matrix = Matrix() - matrix.preTranslate(x, y) - matrix.preRotate(degrees(rotation)) - matrix.preScale(radiusx, radiusy) - matrix.preRotate(degrees(startangle)) - - coords = list( - itertools.chain( - *arc_to_bezier(sweepangle(startangle, endangle, counterclockwise)) - ) + self.path.ellipse( + x, + y, + radiusx, + radiusy, + rotation, + startangle, + endangle, + counterclockwise, ) - matrix.mapPoints(coords) - - self.line_to(coords[0], coords[1]) - i = 2 - while i < len(coords): - self.bezier_curve_to(*coords[i : i + 6]) - i += 6 def rect(self, x, y, width, height): - self.path.addRect(x, y, x + width, y + height, Path.Direction.CW) + self.path.rect(x, y, width, height) def round_rect(self, x, y, width, height, radii): - round_rect(self, x, y, width, height, radii) + self.path.round_rect(x, y, width, height, radii) # Drawing Paths - def fill(self, fill_rule): - self.path.setFillType( + def fill(self, fill_rule, path=None): + if path is None: + path = self.path + + path.native.setFillType( { - FillRule.EVENODD: Path.FillType.EVEN_ODD, - FillRule.NONZERO: Path.FillType.WINDING, - }.get(fill_rule, Path.FillType.WINDING) + FillRule.EVENODD: NativePath.FillType.EVEN_ODD, + FillRule.NONZERO: NativePath.FillType.WINDING, + }.get(fill_rule, NativePath.FillType.WINDING) ) - self.native.drawPath(self.path, self.state.fill) + self.native.drawPath(path.native, self.state.fill) - def stroke(self): + def stroke(self, path=None): # The stroke respects the canvas transform, so we don't need to scale it here. - self.native.drawPath(self.path, self.state.stroke) + if path is None: + path = self.path + self.native.drawPath(path.native, self.state.stroke) # Transformations @@ -184,7 +271,7 @@ def rotate(self, radians): # Transform active path to current coordinates inverse = Matrix() inverse.setRotate(-degrees(radians)) - self.path.transform(inverse) + self.path.native.transform(inverse) def scale(self, sx, sy): # Can't apply inverse transform if scale is 0, @@ -202,7 +289,7 @@ def scale(self, sx, sy): # Transform active path to current coordinates inverse = Matrix() inverse.setScale(1 / sx, 1 / sy) - self.path.transform(inverse) + self.path.native.transform(inverse) def translate(self, tx, ty): self.native.translate(tx, ty) @@ -213,7 +300,7 @@ def translate(self, tx, ty): # Transform active path to current coordinates inverse = Matrix() inverse.setTranslate(-tx, -ty) - self.path.transform(inverse) + self.path.native.transform(inverse) def reset_transform(self): self.native.setMatrix(None) @@ -221,7 +308,7 @@ def reset_transform(self): # current matrix needs to unwind all previous states # can't just ask for current total transform as `getMatrix` is deprecated for state in reversed(self.states): - self.path.transform(state.transform) + self.path.native.transform(state.transform) inverse = Matrix() # if we can't invert, ignore for now if state.transform.invert(inverse): # pragma: no branch diff --git a/changes/4163.feature.md b/changes/4163.feature.md new file mode 100644 index 0000000000..fcbc8c5187 --- /dev/null +++ b/changes/4163.feature.md @@ -0,0 +1 @@ +The `Canvas` widget has now has `Path2D` objects that can be used like HTML Canvas `Path2D` objects. diff --git a/cocoa/pyproject.toml b/cocoa/pyproject.toml index 14e908d5e8..ec91358170 100644 --- a/cocoa/pyproject.toml +++ b/cocoa/pyproject.toml @@ -108,6 +108,9 @@ WebView = "toga_cocoa.widgets.webview:WebView" MainWindow = "toga_cocoa.window:MainWindow" Window = "toga_cocoa.window:Window" +# Canvas implementation +Path2D = "toga_cocoa.widgets.canvas:Path2D" + [tool.setuptools_scm] root = ".." diff --git a/cocoa/src/toga_cocoa/factory.py b/cocoa/src/toga_cocoa/factory.py index e0873f03c3..dfca5074f3 100644 --- a/cocoa/src/toga_cocoa/factory.py +++ b/cocoa/src/toga_cocoa/factory.py @@ -17,7 +17,7 @@ from .widgets.activityindicator import ActivityIndicator from .widgets.box import Box from .widgets.button import Button -from .widgets.canvas import Canvas +from .widgets.canvas import Canvas, Path2D from .widgets.dateinput import DateInput from .widgets.detailedlist import DetailedList from .widgets.divider import Divider @@ -84,6 +84,7 @@ def not_implemented(feature): # pragma: no cover "NumberInput", "OptionContainer", "PasswordInput", + "Path2D", "ProgressBar", "ScrollContainer", "Selection", diff --git a/cocoa/src/toga_cocoa/libs/core_graphics.py b/cocoa/src/toga_cocoa/libs/core_graphics.py index f082238103..6c4c407d79 100644 --- a/cocoa/src/toga_cocoa/libs/core_graphics.py +++ b/cocoa/src/toga_cocoa/libs/core_graphics.py @@ -39,8 +39,92 @@ class CGAffineTransform(Structure): core_graphics.CGAffineTransformIdentity = CGAffineTransform core_graphics.CGAffineTransformInvert.restype = CGAffineTransform core_graphics.CGAffineTransformInvert.argtypes = [CGAffineTransform] +core_graphics.CGAffineTransformMake.restype = CGAffineTransform +core_graphics.CGAffineTransformMake.argtypes = [ + CGFloat, + CGFloat, + CGFloat, + CGFloat, + CGFloat, + CGFloat, +] core_graphics.CGAffineTransformMakeScale.restype = CGAffineTransform core_graphics.CGAffineTransformMakeScale.argtypes = [CGFloat, CGFloat] +core_graphics.CGAffineTransformRotate.restype = CGAffineTransform +core_graphics.CGAffineTransformRotate.argtypes = [CGAffineTransform, CGFloat] +core_graphics.CGAffineTransformScale.restype = CGAffineTransform +core_graphics.CGAffineTransformScale.argtypes = [CGAffineTransform, CGFloat, CGFloat] +core_graphics.CGAffineTransformTranslate.restype = CGAffineTransform +core_graphics.CGAffineTransformTranslate.argtypes = [ + CGAffineTransform, + CGFloat, + CGFloat, +] + +###################################################################### +# CGPath.h +CGPathRef = c_void_p +register_preferred_encoding(b"^{__CGPath=}", CGPathRef) +CGMutablePathRef = c_void_p +register_preferred_encoding(b"^{__CGMutablePath=}", CGMutablePathRef) + +core_graphics.CGPathCreateMutable.restype = CGMutablePathRef +core_graphics.CGPathCreateMutable.argtypes = [] +core_graphics.CGPathCreateMutableCopy.restype = CGMutablePathRef +core_graphics.CGPathCreateMutableCopy.argtypes = [CGPathRef] +core_graphics.CGPathAddArc.restype = c_void_p +core_graphics.CGPathAddArc.argtypes = [ + CGMutablePathRef, + CGAffineTransform, + CGFloat, + CGFloat, + CGFloat, + CGFloat, + CGFloat, + c_bool, +] +core_graphics.CGPathAddCurveToPoint.restype = c_void_p +core_graphics.CGPathAddCurveToPoint.argtypes = [ + CGMutablePathRef, + CGAffineTransform, + CGFloat, + CGFloat, + CGFloat, + CGFloat, + CGFloat, + CGFloat, +] +core_graphics.CGPathAddLineToPoint.restype = c_void_p +core_graphics.CGPathAddLineToPoint.argtypes = [ + CGMutablePathRef, + CGAffineTransform, + CGFloat, + CGFloat, +] +core_graphics.CGPathAddQuadCurveToPoint.restype = c_void_p +core_graphics.CGPathAddQuadCurveToPoint.argtypes = [ + CGMutablePathRef, + CGAffineTransform, + CGFloat, + CGFloat, + CGFloat, + CGFloat, +] +core_graphics.CGPathAddRect.restype = c_void_p +core_graphics.CGPathAddRect.argtypes = [CGMutablePathRef, CGAffineTransform, CGRect] +core_graphics.CGPathCloseSubpath.restype = c_void_p +core_graphics.CGPathCloseSubpath.argtypes = [c_void_p] +core_graphics.CGPathIsEmpty.restype = c_bool +core_graphics.CGPathIsEmpty.argtypes = [CGMutablePathRef] +core_graphics.CGPathMoveToPoint.restype = c_void_p +core_graphics.CGPathMoveToPoint.argtypes = [ + CGMutablePathRef, + CGAffineTransform, + CGFloat, + CGFloat, +] +core_graphics.CGPathAddPath.restype = c_void_p +core_graphics.CGPathAddPath.argtypes = [CGMutablePathRef, CGAffineTransform, CGPathRef] ###################################################################### # CGImage.h @@ -204,8 +288,6 @@ class CGAffineTransform(Structure): core_graphics.CGContextDrawImage.restype = c_void_p core_graphics.CGContextDrawImage.argtypes = [CGContextRef, CGRect, CGImageRef] -CGPathRef = c_void_p -register_preferred_encoding(b"^{__CGPath=}", CGPathRef) core_graphics.CGContextCopyPath.restype = CGPathRef core_graphics.CGContextCopyPath.argtypes = [CGContextRef] core_graphics.CGContextAddPath.restype = c_void_p diff --git a/cocoa/src/toga_cocoa/widgets/canvas.py b/cocoa/src/toga_cocoa/widgets/canvas.py index 9d1e11a257..a84b63bf4c 100644 --- a/cocoa/src/toga_cocoa/widgets/canvas.py +++ b/cocoa/src/toga_cocoa/widgets/canvas.py @@ -1,3 +1,4 @@ +import platform from collections.abc import Sequence from copy import copy from dataclasses import dataclass @@ -34,6 +35,112 @@ from .base import Widget +IDENTITY = core_graphics.CGAffineTransformMake(1, 0, 0, 1, 0, 0) + + +class Path2D: + def __init__(self): + self.native = core_graphics.CGPathCreateMutable() + + def _ensure_subpath(self, x, y): + if core_graphics.CGPathIsEmpty(self.native): + self.move_to(x, y) + + def add_path(self, path, transform=None): + if transform is None: + transform = IDENTITY + else: + transform = core_graphics.CGAffineTransformMake(*transform) + # adding empty path segfaults on intel + if not path.is_empty(): + core_graphics.CGPathAddPath(self.native, transform, path.native) + + def close_path(self): + core_graphics.CGPathCloseSubpath(self.native) + + def move_to(self, x, y): + core_graphics.CGPathMoveToPoint(self.native, IDENTITY, x, y) + + def line_to(self, x, y): + core_graphics.CGPathAddLineToPoint(self.native, IDENTITY, x, y) + + def bezier_curve_to(self, cp1x, cp1y, cp2x, cp2y, x, y): + self._ensure_subpath(cp1x, cp1y) + core_graphics.CGPathAddCurveToPoint( + self.native, + IDENTITY, + cp1x, + cp1y, + cp2x, + cp2y, + x, + y, + ) + + def quadratic_curve_to(self, cpx, cpy, x, y): + self._ensure_subpath(cpx, cpy) + core_graphics.CGPathAddQuadCurveToPoint(self.native, IDENTITY, cpx, cpy, x, y) + + def arc(self, x, y, radius, startangle, endangle, counterclockwise): + # Cocoa Path is using a flipped coordinate system, so clockwise + # is actually counterclockwise + clockwise = counterclockwise + core_graphics.CGPathAddArc( + self.native, + IDENTITY, + x, + y, + radius, + startangle, + endangle, + clockwise, + ) + + def ellipse( + self, + x, + y, + radiusx, + radiusy, + rotation, + startangle, + endangle, + counterclockwise, + ): + if ( + counterclockwise and platform.machine() == "x86_64" + ): # pragma: no-cover-if-arm + # Persistent segfaults in CGPathAddArc with counterclockwise True on intel + # skip for now + return + else: # pragma: no-cover-if-x86 + transform = core_graphics.CGAffineTransformMake(1, 0, 0, 1, x, y) + transform = core_graphics.CGAffineTransformRotate(transform, rotation) + transform = core_graphics.CGAffineTransformScale( + transform, radiusx, radiusy + ) + core_graphics.CGPathAddArc( + self.native, + transform, + 0, + 0, + 1.0, + startangle, + endangle, + int(counterclockwise), + ) + + def rect(self, x, y, width, height): + rectangle = CGRectMake(x, y, width, height) + core_graphics.CGPathAddRect(self.native, IDENTITY, rectangle) + + def round_rect(self, x, y, width, height, radii): + round_rect(self, x, y, width, height, radii) + + # extra utility methods + def is_empty(self): + return core_graphics.CGPathIsEmpty(self.native) + @dataclass(slots=True) class State: @@ -128,10 +235,7 @@ def quadratic_curve_to(self, cpx, cpy, x, y): def arc(self, x, y, radius, startangle, endangle, counterclockwise): # Cocoa Box Widget is using a flipped coordinate system, so clockwise # is actually counterclockwise - if counterclockwise: - clockwise = 1 - else: - clockwise = 0 + clockwise = counterclockwise core_graphics.CGContextAddArc( self.native, x, y, radius, startangle, endangle, clockwise ) @@ -163,22 +267,48 @@ def round_rect(self, x, y, width, height, radii): # Drawing Paths - def fill(self, fill_rule): + def fill(self, fill_rule, path=None): + current_path = core_graphics.CGContextCopyPath(self.native) + if path is not None: + if path.is_empty(): + # nothing to draw + return + # replace context path with path.native + core_graphics.CGContextBeginPath(self.native) + core_graphics.CGContextAddPath(self.native, path.native) + elif core_graphics.CGPathIsEmpty(current_path): + # nothing to draw + return + + # draw if fill_rule == FillRule.EVENODD: mode = CGPathDrawingMode(kCGPathEOFill) else: mode = CGPathDrawingMode(kCGPathFill) - if not core_graphics.CGContextIsPathEmpty(self.native): - path = core_graphics.CGContextCopyPath(self.native) - core_graphics.CGContextDrawPath(self.native, mode) - core_graphics.CGContextAddPath(self.native, path) - - def stroke(self): + core_graphics.CGContextDrawPath(self.native, mode) + + # restore original path + core_graphics.CGContextAddPath(self.native, current_path) + + def stroke(self, path=None): + current_path = core_graphics.CGContextCopyPath(self.native) + if path is not None: + if path.is_empty(): + # nothing to draw + return + # replace context path with path.native + core_graphics.CGContextBeginPath(self.native) + core_graphics.CGContextAddPath(self.native, path.native) + elif core_graphics.CGPathIsEmpty(current_path): + # nothing to draw + return + + # draw mode = CGPathDrawingMode(kCGPathStroke) - if not core_graphics.CGContextIsPathEmpty(self.native): - path = core_graphics.CGContextCopyPath(self.native) - core_graphics.CGContextDrawPath(self.native, mode) - core_graphics.CGContextAddPath(self.native, path) + core_graphics.CGContextDrawPath(self.native, mode) + + # restore original path + core_graphics.CGContextAddPath(self.native, current_path) # Transformations def rotate(self, radians): @@ -274,7 +404,10 @@ class TogaCanvas(NSView): @objc_method def drawRect_(self, rect: NSRect) -> None: - self.interface.root_state._draw(Context(self.impl)) + try: + self.interface.root_state._draw(Context(self.impl)) + except Exception as exc: # pragma: no cover + print(exc) @objc_method def isFlipped(self) -> bool: diff --git a/core/src/toga/widgets/canvas/__init__.py b/core/src/toga/widgets/canvas/__init__.py index 6e230bcd4f..e0bcd96496 100644 --- a/core/src/toga/widgets/canvas/__init__.py +++ b/core/src/toga/widgets/canvas/__init__.py @@ -20,6 +20,7 @@ WriteText, ) from .geometry import arc_to_bezier, sweepangle +from .path import AddPath, Path2D from .state import ClosePath, Fill, State, Stroke # Make sure deprecation warnings are shown by default @@ -69,6 +70,9 @@ def __getattr__(name): "Scale", "Translate", "WriteText", + # Path-related + "Path2D", + "AddPath", # States "State", "Fill", diff --git a/core/src/toga/widgets/canvas/drawingaction.py b/core/src/toga/widgets/canvas/drawingaction.py index d8c193b22a..7c993e0497 100644 --- a/core/src/toga/widgets/canvas/drawingaction.py +++ b/core/src/toga/widgets/canvas/drawingaction.py @@ -21,6 +21,7 @@ if TYPE_CHECKING: from toga.constants import Baseline + # Make sure deprecation warnings are shown by default filterwarnings("default", category=DeprecationWarning) @@ -60,28 +61,38 @@ class DrawingAction(ABC): active state, and returns it. Each argument passed to the method becomes a property of the `DrawingAction`, which can be modified as shown in the [Usage][] section. - `DrawingActions` can also be created manually, then added to a state's + `DrawingActions` can also be created manually, then added to a state using the [list of drawing actions][toga.widgets.canvas.state.BaseState.drawing_actions]. - Their constructors take the same arguments as the corresponding [`Canvas`] - [toga.Canvas] drawing method, and their classes have the same names, but - capitalized: + Their constructors take the same arguments as the corresponding + [`Canvas`][toga.Canvas] or [`Path2D`][toga.widgets.canvas.Path2D] methods, and + their classes have the same names, but capitalized: * [`toga.widgets.canvas.Arc`][toga.Canvas.arc] - * [`toga.widgets.canvas.BeginPath`][toga.Canvas.begin_path] * [`toga.widgets.canvas.BezierCurveTo`][toga.Canvas.bezier_curve_to] - * [`toga.widgets.canvas.ClosePath`][toga.Canvas.close_path] * [`toga.widgets.canvas.Ellipse`][toga.Canvas.ellipse] - * [`toga.widgets.canvas.Fill`][toga.Canvas.fill] * [`toga.widgets.canvas.LineTo`][toga.Canvas.line_to] * [`toga.widgets.canvas.MoveTo`][toga.Canvas.move_to] * [`toga.widgets.canvas.QuadraticCurveTo`][toga.Canvas.quadratic_curve_to] * [`toga.widgets.canvas.Rect`][toga.Canvas.rect] + * [`toga.widgets.canvas.RoundRect`][toga.Canvas.round_rect] + + The following `DrawingActions` can only be used with `State` objects and not + `Path2D`: + + * [`toga.widgets.canvas.BeginPath`][toga.Canvas.begin_path] + * [`toga.widgets.canvas.ClosePath`][toga.Canvas.close_path] + * [`toga.widgets.canvas.Fill`][toga.Canvas.fill] * [`toga.widgets.canvas.ResetTransform`][toga.Canvas.reset_transform] * [`toga.widgets.canvas.Rotate`][toga.Canvas.rotate] * [`toga.widgets.canvas.Scale`][toga.Canvas.scale] * [`toga.widgets.canvas.Stroke`][toga.Canvas.stroke] * [`toga.widgets.canvas.Translate`][toga.Canvas.translate] * [`toga.widgets.canvas.WriteText`][toga.Canvas.write_text] + + In addition, the `AddPath` `DrawingAction` can be used with `Path2D` objects + but not `State` objects: + + * [`toga.widgets.canvas.AddPath`][toga.widgets.canvas.Path2D.add_path] """ # noqa: E501 # Disable the line-too-long check as there is no way to properly render the list diff --git a/core/src/toga/widgets/canvas/path.py b/core/src/toga/widgets/canvas/path.py new file mode 100644 index 0000000000..1f489d7f44 --- /dev/null +++ b/core/src/toga/widgets/canvas/path.py @@ -0,0 +1,351 @@ +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +from math import pi + +from toga.platform import get_factory + +from .drawingaction import ( + Arc, + BezierCurveTo, + DrawingAction, + Ellipse, + LineTo, + MoveTo, + QuadraticCurveTo, + Rect, + RoundRect, +) +from .geometry import CornerRadiusT + + +class Path2D: + """An object that declares reusable shapes to draw on a Canvas + + `Path2D` shares many of the methods of the [`Canvas`][toga.widgets.canvas.Canvas] + object that are used for constructing paths. Unlike paths built using `Canvas` + methods, a shape built using `Path2D` is saved and can be used repeatedly to + draw the shape without having to repeat the construction. + + To draw a `Path2D`, call `fill` or `stroke` with the path object as its `path` + argument. + + Like a `Canvas`, a `Path2D` is built from a sequence of `DrawingAction` objects + which can be modified. The `Path2D` class builds a backend-specific "compiled" + representation to be used whenever it is drawn onto the `Canvas`. Most of the time + it is compiled transparently, but if the `DrawingAction` objects are modified + then the user has to call [`compile`][toga.widgets.canvas.Path2D.compile] + before redrawing to ensure that the changes are incorporated into the path. + + The `Path2D` class generally follows the API of the [HTML Canvas class of the same + name](https://developer.mozilla.org/en-US/docs/Web/API/Path2D) but with method + names changed to match Python style. + """ + + def __init__(self, path: "Path2D | None" = None): + if path is None: + self.drawing_actions = [] + else: + self.drawing_actions = path.drawing_actions.copy() + self._action_target = self + self._impl = None + self.factory = get_factory() + + @property + def impl(self): + if self._impl is None: + self.compile() + return self._impl + + def add_path( + self, path: "Path2D", transform: Sequence[float] | None = None + ) -> "AddPath": + """Adds another path to the current path with an optional transform. + + :param path: The Path being added. + :param transform: The Transform to apply to the added path. + :returns: The `AddPath` [`DrawingAction`][toga.widgets.canvas.DrawingAction] + for the operation. + """ + add_path = AddPath(path, transform) + self._action_target.drawing_actions.append(add_path) + self._recompilation_needed() + return add_path + + def close_path(self): + """Close the current path in the canvas state. + + This closes the current subpath by drawing a line from the current point + to the starting point of the current subpath. + + :returns: The `ClosePath` + [`DrawingAction`][toga.widgets.canvas.DrawingAction] for the operation. + """ + # delay import because of circular dependency + from .state import ClosePath + + close_path = ClosePath() + self._action_target.drawing_actions.append(close_path) + self._recompilation_needed() + return close_path + + def move_to(self, x: float, y: float) -> MoveTo: + """Moves the current point without drawing. + + :param x: The x coordinate of the new current point. + :param y: The y coordinate of the new current point. + :returns: The `MoveTo` [`DrawingAction`][toga.widgets.canvas.DrawingAction] + for the operation. + """ + move_to = MoveTo(x, y) + self._action_target.drawing_actions.append(move_to) + self._recompilation_needed() + return move_to + + def line_to(self, x: float, y: float) -> LineTo: + """Draw a line segment ending at a point. + + :param x: The x coordinate for the end point of the line segment. + :param y: The y coordinate for the end point of the line segment. + :returns: The `LineTo` [`DrawingAction`][toga.widgets.canvas.DrawingAction] + for the operation. + """ + line_to = LineTo(x, y) + self._action_target.drawing_actions.append(line_to) + self._recompilation_needed() + return line_to + + def bezier_curve_to( + self, + cp1x: float, + cp1y: float, + cp2x: float, + cp2y: float, + x: float, + y: float, + ) -> BezierCurveTo: + """Draw a Bézier curve. + + A Bézier curve requires three points. The first two are control points; the + third is the end point for the curve. The starting point is the last point in + the current path, which can be changed using `move_to()` before creating the + Bézier curve. + + :param cp1y: The y coordinate for the first control point of the Bézier curve. + :param cp1x: The x coordinate for the first control point of the Bézier curve. + :param cp2x: The x coordinate for the second control point of the Bézier curve. + :param cp2y: The y coordinate for the second control point of the Bézier curve. + :param x: The x coordinate for the end point. + :param y: The y coordinate for the end point. + :returns: The `BezierCurveTo` + [`DrawingAction`][toga.widgets.canvas.DrawingAction] for the operation. + """ + bezier_curve_to = BezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) + self._action_target.drawing_actions.append(bezier_curve_to) + self._recompilation_needed() + return bezier_curve_to + + def quadratic_curve_to( + self, + cpx: float, + cpy: float, + x: float, + y: float, + ) -> QuadraticCurveTo: + """Draw a quadratic curve. + + A quadratic curve requires two points. The first point is a control point; the + second is the end point. The starting point of the curve is the last point in + the current path, which can be changed using `moveTo()` before creating the + quadratic curve. + + :param cpx: The x axis of the coordinate for the control point of the quadratic + curve. + :param cpy: The y axis of the coordinate for the control point of the quadratic + curve. + :param x: The x axis of the coordinate for the end point. + :param y: The y axis of the coordinate for the end point. + :returns: The `QuadraticCurveTo` + [`DrawingAction`][toga.widgets.canvas.DrawingAction] for the operation. + """ + quadratic_curve_to = QuadraticCurveTo(cpx, cpy, x, y) + self._action_target.drawing_actions.append(quadratic_curve_to) + self._recompilation_needed() + return quadratic_curve_to + + def arc( + self, + x: float, + y: float, + radius: float, + startangle: float = 0.0, + endangle: float = 2 * pi, + counterclockwise: bool | None = None, + anticlockwise: bool | None = None, # DEPRECATED + ) -> Arc: + """Draw a circular arc. + + A full circle will be drawn by default; an arc can be drawn by specifying a + start and end angle. + + :param x: The X coordinate of the circle's center. + :param y: The Y coordinate of the circle's center. + :param startangle: The start angle in radians, measured clockwise from the + positive X axis. + :param endangle: The end angle in radians, measured clockwise from the positive + X axis. + :param counterclockwise: If true, the arc is swept counterclockwise. The default + is clockwise. + :param anticlockwise: **DEPRECATED** - Use `counterclockwise`. + :returns: The `Arc` [`DrawingAction`][toga.widgets.canvas.DrawingAction] + for the operation. + """ + arc = Arc(x, y, radius, startangle, endangle, counterclockwise, anticlockwise) + self._action_target.drawing_actions.append(arc) + self._recompilation_needed() + return arc + + def ellipse( + self, + x: float, + y: float, + radiusx: float, + radiusy: float, + rotation: float = 0.0, + startangle: float = 0.0, + endangle: float = 2 * pi, + counterclockwise: bool | None = None, + anticlockwise: bool | None = None, # DEPRECATED + ) -> Ellipse: + """Draw an elliptical arc. + + A full ellipse will be drawn by default; an arc can be drawn by specifying a + start and end angle. + + :param x: The X coordinate of the ellipse's center. + :param y: The Y coordinate of the ellipse's center. + :param radiusx: The ellipse's horizontal axis radius. + :param radiusy: The ellipse's vertical axis radius. + :param rotation: The ellipse's rotation in radians, measured clockwise around + its center. + :param startangle: The start angle in radians, measured clockwise from the + positive X axis. + :param endangle: The end angle in radians, measured clockwise from the positive + X axis. + :param counterclockwise: If true, the arc is swept counterclockwise. The default + is clockwise. + :param anticlockwise: **DEPRECATED** - Use `counterclockwise`. + :returns: The `Ellipse` [`DrawingAction`][toga.widgets.canvas.DrawingAction] + for the operation. + """ + ellipse = Ellipse( + x, + y, + radiusx, + radiusy, + rotation, + startangle, + endangle, + counterclockwise, + anticlockwise, + ) + self._action_target.drawing_actions.append(ellipse) + self._recompilation_needed() + return ellipse + + def rect(self, x: float, y: float, width: float, height: float) -> Rect: + """Draw a rectangle. + + :param x: The horizontal coordinate of the left of the rectangle. + :param y: The vertical coordinate of the top of the rectangle. + :param width: The width of the rectangle. + :param height: The height of the rectangle. + :returns: The `Rect` [`DrawingAction`][toga.widgets.canvas.DrawingAction] + for the operation. + """ + + rect = Rect(x, y, width, height) + self._action_target.drawing_actions.append(rect) + self._recompilation_needed() + 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.drawing_actions.append(round_rect) + self._recompilation_needed() + return round_rect + + def compile(self): + """Compile a backend path for drawing. + + This creates a object which implements the `Path2D` API for the current + backend. This is called automatically when needed after adding more + drawing actions to the `Path2D`, but needs to be manually called if the + `DrawingObject` instances are modified after being drawn or added to another + path (for example, when animating): + + ``` python + path = Path2D() + circle = path.arc(100, 100, 10) + + canvas.root_state.stroke(path) + + # update the radius + circle.radius = 20 + path.compile() + + # draw with new radius + canvas.root_state.stroke(path) + ``` + """ + self._impl = self.factory.Path2D() + for action in self.drawing_actions: + action._draw(self._impl) + + def _recompilation_needed(self): + self._impl = None + + def __repr__(self): + return f"{self.__class__.__name__}()" + + +@dataclass(repr=False) +class AddPath(DrawingAction): + path: Path2D + transform: Sequence[float] | None = None + + def _draw(self, context): + context.add_path(self.path.impl, self.transform) diff --git a/core/src/toga/widgets/canvas/state.py b/core/src/toga/widgets/canvas/state.py index e0570bf569..5d735971ff 100644 --- a/core/src/toga/widgets/canvas/state.py +++ b/core/src/toga/widgets/canvas/state.py @@ -32,6 +32,7 @@ WriteText, ) from .geometry import CornerRadiusT +from .path import Path2D if TYPE_CHECKING: from toga.colors import ColorT @@ -314,6 +315,7 @@ def round_rect( def fill( self, fill_rule: FillRule = FillRule.NONZERO, + path: Path2D | None = None, *, fill_style: ColorT | None | object = NOT_PROVIDED, color: ColorT | None | object = NOT_PROVIDED, @@ -331,6 +333,8 @@ def fill( :param fill_rule: `nonzero` is the non-zero winding rule; `evenodd` is the even-odd winding rule. + :param path: An optional Path2D object to fill. Ignored when used as a + context manager. :param fill_style: The fill style. At present, only accepts colors; gradients and patterns are not supported. :param color: Alias for fill_style. @@ -338,7 +342,10 @@ def fill( for the operation. :raises TypeError: If both `fill_style` and `color` are provided. """ - fill = Fill(fill_rule, fill_style=fill_style, color=color) + if path is not None: + # copy the current state of the path. + path = Path2D(path) + fill = Fill(fill_rule, path, fill_style=fill_style, color=color) self._add_to_target(fill) # Strictly speaking, this doesn't need a warning or redraw, since BaseState # overwrites this method with its own shimmed version. But we might as well be @@ -348,6 +355,7 @@ def fill( def stroke( self, + path: Path2D | None = None, *, stroke_style: ColorT | None | object = NOT_PROVIDED, color: ColorT | None | object = NOT_PROVIDED, @@ -360,6 +368,8 @@ def stroke( (`x`, `y`) coordinates (if both are specified). When the context is exited, the path is stroked. + :param path: An optional Path2D object to draw. Ignored when used as a + context manager. :param stroke_style: The stroke style. At present, only accepts colors; gradients and patterns are not supported. :param color: Alias for fill_style. @@ -370,11 +380,15 @@ def stroke( for the operation. :raises TypeError: If both `stroke_style` and `color` are provided. """ + if path is not None: + # copy the current state of the path. + path = Path2D(path) stroke = Stroke( stroke_style=stroke_style, color=color, line_width=line_width, line_dash=line_dash, + path=path, ) self._add_to_target(stroke) # Strictly speaking, this doesn't need a warning or redraw, since BaseState @@ -935,6 +949,7 @@ class Fill(BaseState): # This will need to change to a pair of positional arguments in order to accommodate # (path), (fill_rule), or (path, fill_rule) usage as in JavaScript. fill_rule: FillRule = FillRule.NONZERO + path: Path2D | None = None _: KW_ONLY fill_style: ColorT | None | object = color_property() color: InitVar[ColorT | None | object] = color_property() @@ -961,15 +976,29 @@ def _draw(self, context: Any) -> None: for action in self.drawing_actions: action._draw(context) - context.in_fill = False # 4-2026: Backwards compatibility for Toga <= 0.5.3 + context.in_fill = False # Backwards compatibility for Toga <= 0.5.3 + # Ignore path when used as context manager + path = None + elif self.path is not None: + path = self.path.impl + else: + path = None - context.fill(self.fill_rule) + context.fill(fill_rule=self.fill_rule, path=path) context.restore() + def __enter__(self): + if self.path is not None: + raise RuntimeError( + "The path must not be set when Fill is used as a context manager." + ) + return super().__enter__() + @dataclass(repr=False) class Stroke(BaseState): # Path parameter (positional/keyword) will go here. + path: Path2D | None = None _: KW_ONLY stroke_style: ColorT | None | object = color_property() color: InitVar[ColorT | None | object] = color_property() @@ -1003,6 +1032,19 @@ def _draw(self, context: Any) -> None: action._draw(context) context.in_stroke = False # Backwards compatibility for Toga <= 0.5.3 + # Ignore path when used as context manager + path = None + elif self.path is not None: + path = self.path.impl + else: + path = None - context.stroke() + context.stroke(path=path) context.restore() + + def __enter__(self): + if self.path is not None: + raise RuntimeError( + "The path must not be set when Stroke is used as a context manager." + ) + return super().__enter__() diff --git a/core/tests/widgets/canvas/test_deprecations.py b/core/tests/widgets/canvas/test_deprecations.py index 187f532bcc..095d1d2842 100644 --- a/core/tests/widgets/canvas/test_deprecations.py +++ b/core/tests/widgets/canvas/test_deprecations.py @@ -544,7 +544,7 @@ def test_deprecated_list_methods(widget): "save", "begin path", ("line to", {"x": 25, "y": 25}), - ("fill", {"fill_rule": FillRule.NONZERO}), + ("fill", {"fill_rule": FillRule.NONZERO, "path": None}), "restore", # End fill ("line to", {"x": 30, "y": 40}), @@ -582,7 +582,7 @@ def test_deprecated_list_methods(widget): "save", "begin path", ("line to", {"x": 25, "y": 25}), - ("fill", {"fill_rule": FillRule.NONZERO}), + ("fill", {"fill_rule": FillRule.NONZERO, "path": None}), "restore", # End fill ("line to", {"x": 30, "y": 40}), @@ -614,7 +614,7 @@ def test_deprecated_list_methods(widget): "save", "begin path", ("line to", {"x": 25, "y": 25}), - ("fill", {"fill_rule": FillRule.NONZERO}), + ("fill", {"fill_rule": FillRule.NONZERO, "path": None}), "restore", # End fill ("line to", {"x": 30, "y": 40}), @@ -667,7 +667,7 @@ def test_deprecated_list_methods(widget): "save", "begin path", ("line to", {"x": 25, "y": 25}), - ("fill", {"fill_rule": FillRule.NONZERO}), + ("fill", {"fill_rule": FillRule.NONZERO, "path": None}), "restore", # End fill ("line to", {"x": 40, "y": 50}), diff --git a/core/tests/widgets/canvas/test_draw_operations.py b/core/tests/widgets/canvas/test_draw_operations.py index 3c6a15904e..d5e07c9d06 100644 --- a/core/tests/widgets/canvas/test_draw_operations.py +++ b/core/tests/widgets/canvas/test_draw_operations.py @@ -43,60 +43,85 @@ def test_close_path(widget): # Defaults ( {}, - "fill_rule=FillRule.NONZERO, fill_style=None", - [("fill", {"fill_rule": FillRule.NONZERO})], - {"fill_rule": FillRule.NONZERO, "fill_style": None}, + "fill_rule=FillRule.NONZERO, path=None, fill_style=None", + [("fill", {"fill_rule": FillRule.NONZERO, "path": None})], + { + "fill_rule": FillRule.NONZERO, + "path": None, + "fill_style": None, + }, ), # Fill style as string name ( {"fill_style": REBECCAPURPLE}, - f"fill_rule=FillRule.NONZERO, fill_style={REBECCA_PURPLE_COLOR!r}", + ( + "fill_rule=FillRule.NONZERO, path=None, " + f"fill_style={REBECCA_PURPLE_COLOR!r}" + ), [ ("set fill style", REBECCA_PURPLE_COLOR), - ("fill", {"fill_rule": FillRule.NONZERO}), + ("fill", {"fill_rule": FillRule.NONZERO, "path": None}), ], - {"fill_rule": FillRule.NONZERO, "fill_style": REBECCA_PURPLE_COLOR}, + { + "fill_rule": FillRule.NONZERO, + "path": None, + "fill_style": REBECCA_PURPLE_COLOR, + }, ), # Color as RGB object ( {"fill_style": REBECCA_PURPLE_COLOR}, - f"fill_rule=FillRule.NONZERO, fill_style={REBECCA_PURPLE_COLOR!r}", + ( + "fill_rule=FillRule.NONZERO, path=None, " + f"fill_style={REBECCA_PURPLE_COLOR!r}" + ), [ ("set fill style", REBECCA_PURPLE_COLOR), - ("fill", {"fill_rule": FillRule.NONZERO}), + ("fill", {"fill_rule": FillRule.NONZERO, "path": None}), ], - {"fill_rule": FillRule.NONZERO, "fill_style": REBECCA_PURPLE_COLOR}, + { + "fill_rule": FillRule.NONZERO, + "path": None, + "fill_style": REBECCA_PURPLE_COLOR, + }, ), # Color explicitly not set ( {"fill_style": None}, - "fill_rule=FillRule.NONZERO, fill_style=None", - [("fill", {"fill_rule": FillRule.NONZERO})], - {"fill_rule": FillRule.NONZERO, "fill_style": None}, + "fill_rule=FillRule.NONZERO, path=None, fill_style=None", + [("fill", {"fill_rule": FillRule.NONZERO, "path": None})], + {"fill_rule": FillRule.NONZERO, "path": None, "fill_style": None}, ), # Explicit Non-Zero winding ( {"fill_rule": FillRule.NONZERO}, - "fill_rule=FillRule.NONZERO, fill_style=None", - [("fill", {"fill_rule": FillRule.NONZERO})], - {"fill_rule": FillRule.NONZERO, "fill_style": None}, + "fill_rule=FillRule.NONZERO, path=None, fill_style=None", + [("fill", {"fill_rule": FillRule.NONZERO, "path": None})], + {"fill_rule": FillRule.NONZERO, "path": None, "fill_style": None}, ), # Even-Odd winding ( {"fill_rule": FillRule.EVENODD}, - "fill_rule=FillRule.EVENODD, fill_style=None", - [("fill", {"fill_rule": FillRule.EVENODD})], - {"fill_rule": FillRule.EVENODD, "fill_style": None}, + "fill_rule=FillRule.EVENODD, path=None, fill_style=None", + [("fill", {"fill_rule": FillRule.EVENODD, "path": None})], + {"fill_rule": FillRule.EVENODD, "path": None, "fill_style": None}, ), # All args ( {"fill_rule": FillRule.EVENODD, "fill_style": REBECCAPURPLE}, - f"fill_rule=FillRule.EVENODD, fill_style={REBECCA_PURPLE_COLOR!r}", + ( + "fill_rule=FillRule.EVENODD, path=None, " + f"fill_style={REBECCA_PURPLE_COLOR!r}" + ), [ ("set fill style", REBECCA_PURPLE_COLOR), - ("fill", {"fill_rule": FillRule.EVENODD}), + ("fill", {"fill_rule": FillRule.EVENODD, "path": None}), ], - {"fill_rule": FillRule.EVENODD, "fill_style": REBECCA_PURPLE_COLOR}, + { + "fill_rule": FillRule.EVENODD, + "path": None, + "fill_style": REBECCA_PURPLE_COLOR, + }, ), ], ) @@ -128,7 +153,7 @@ def test_fill_kw_only(widget, use_method): fill = widget.fill if use_method else Fill with pytest.raises(TypeError): - fill(FillRule.EVENODD, REBECCAPURPLE) + fill(FillRule.EVENODD, None, REBECCAPURPLE) @pytest.mark.parametrize("alias_kwarg", [True, False]) @@ -139,14 +164,17 @@ def test_fill_kw_only(widget, use_method): # Defaults ( {}, - "stroke_style=None, line_width=None, line_dash=None", + "path=None, stroke_style=None, line_width=None, line_dash=None", [], {"stroke_style": None, "line_width": None, "line_dash": None}, ), # Color as string name ( {"stroke_style": REBECCAPURPLE}, - f"stroke_style={REBECCA_PURPLE_COLOR!r}, line_width=None, line_dash=None", + ( + f"path=None, stroke_style={REBECCA_PURPLE_COLOR!r}, " + "line_width=None, line_dash=None" + ), [("set stroke style", REBECCA_PURPLE_COLOR)], { "stroke_style": REBECCA_PURPLE_COLOR, @@ -157,7 +185,10 @@ def test_fill_kw_only(widget, use_method): # Color as RGB object ( {"stroke_style": REBECCA_PURPLE_COLOR}, - f"stroke_style={REBECCA_PURPLE_COLOR!r}, line_width=None, line_dash=None", + ( + f"path=None, stroke_style={REBECCA_PURPLE_COLOR!r}, " + "line_width=None, line_dash=None" + ), [("set stroke style", REBECCA_PURPLE_COLOR)], { "stroke_style": REBECCA_PURPLE_COLOR, @@ -168,21 +199,21 @@ def test_fill_kw_only(widget, use_method): # Color explicitly not set ( {"stroke_style": None}, - "stroke_style=None, line_width=None, line_dash=None", + "path=None, stroke_style=None, line_width=None, line_dash=None", [], {"stroke_style": None, "line_width": None, "line_dash": None}, ), # Line width ( {"line_width": 4.5}, - "stroke_style=None, line_width=4.500, line_dash=None", + "path=None, stroke_style=None, line_width=4.500, line_dash=None", [("set line width", 4.5)], {"stroke_style": None, "line_width": 4.5, "line_dash": None}, ), # Line dash ( {"line_dash": [2, 7]}, - "stroke_style=None, line_width=None, line_dash=[2, 7]", + "path=None, stroke_style=None, line_width=None, line_dash=[2, 7]", [("set line dash", [2, 7])], {"stroke_style": None, "line_width": None, "line_dash": [2, 7]}, ), @@ -190,8 +221,8 @@ def test_fill_kw_only(widget, use_method): ( {"stroke_style": REBECCAPURPLE, "line_width": 4.5, "line_dash": [2, 7]}, ( - f"stroke_style={REBECCA_PURPLE_COLOR!r}, line_width=4.500, " - "line_dash=[2, 7]" + f"path=None, stroke_style={REBECCA_PURPLE_COLOR!r}, " + "line_width=4.500, line_dash=[2, 7]" ), [ ("set stroke style", REBECCA_PURPLE_COLOR), @@ -221,7 +252,7 @@ def test_stroke(widget, alias_kwarg, alias_attr, kwargs, args_repr, draw_objs, a assert widget._impl.draw_instructions[1:-1] == [ "save", *draw_objs, - "stroke", + ("stroke", {"path": None}), "restore", ] @@ -235,17 +266,17 @@ def test_stroke(widget, alias_kwarg, alias_attr, kwargs, args_repr, draw_objs, a @pytest.mark.parametrize("use_method", [True, False]) def test_stroke_kw_only(widget, use_method): - """Providing any positional arguments raises an error.""" + """Providing any positional arguments other than Path raises an error.""" stroke = widget.stroke if use_method else Stroke with pytest.raises(TypeError): - stroke(REBECCAPURPLE) + stroke(None, REBECCAPURPLE) with pytest.raises(TypeError): - stroke(REBECCAPURPLE, 4.5) + stroke(None, REBECCAPURPLE, 4.5) with pytest.raises(TypeError): - stroke(REBECCAPURPLE, 4.5, [1, 0]) + stroke(None, REBECCAPURPLE, 4.5, [1, 0]) @pytest.mark.parametrize( diff --git a/core/tests/widgets/canvas/test_path.py b/core/tests/widgets/canvas/test_path.py new file mode 100644 index 0000000000..287cc2300c --- /dev/null +++ b/core/tests/widgets/canvas/test_path.py @@ -0,0 +1,496 @@ +import pytest + +from toga.widgets.canvas import Path2D + + +@pytest.fixture() +def path(): + return Path2D() + + +def test_compile(path): + path.move_to(100, 50) + draw_op = path.line_to(200, 100) + + assert path.impl.draw_instructions == [ + ("move to", {"x": 100, "y": 50}), + ("line to", {"x": 200, "y": 100}), + ] + + # adding more to the path prompts recompilation + path.line_to(100, 200) + assert path.impl.draw_instructions == [ + ("move to", {"x": 100, "y": 50}), + ("line to", {"x": 200, "y": 100}), + ("line to", {"x": 100, "y": 200}), + ] + + # but if you change an operation it doesn't change the path + draw_op.x = 150 + assert path.impl.draw_instructions == [ + ("move to", {"x": 100, "y": 50}), + ("line to", {"x": 200, "y": 100}), + ("line to", {"x": 100, "y": 200}), + ] + + # unless you explicitly recompile + path.compile() + assert path.impl.draw_instructions == [ + ("move to", {"x": 100, "y": 50}), + ("line to", {"x": 150, "y": 100}), + ("line to", {"x": 100, "y": 200}), + ] + + +def test_create_path_from_path(path): + path.move_to(100, 50) + path.line_to(200, 100) + + new_path = Path2D(path) + new_path.compile() + + assert new_path.impl.draw_instructions == [ + ("move to", {"x": 100, "y": 50}), + ("line to", {"x": 200, "y": 100}), + ] + + +def test_close_path(path): + """A close path operation can be added.""" + draw_op = path.close_path() + + assert repr(draw_op) == "ClosePath()" + + # The first and last instructions save/restore the root state, and can be ignored. + assert path.impl.draw_instructions == ["close path"] + + +@pytest.mark.parametrize( + "transform, args_repr", + [ + (None, "transform=None"), + ((1, 2, 3, 4, 5, 6), "transform=(1, 2, 3, 4, 5, 6)"), + ], +) +def test_add_path(path, transform, args_repr): + """An add path operation can be added.""" + other_path = Path2D() + other_path.move_to(100, 50) + other_path.line_to(200, 100) + + draw_op = path.add_path(other_path, transform) + + assert repr(draw_op) == f"AddPath(path={other_path!r}, {args_repr})" + + assert path.impl.draw_instructions == [ + ("add path", {"path": other_path.impl, "transform": transform}), + ] + + +def test_move_to(path): + """A move to operation can be added.""" + draw_op = path.move_to(10, 20) + + assert repr(draw_op) == "MoveTo(x=10, y=20)" + + assert path.impl.draw_instructions == [ + ("move to", {"x": 10, "y": 20}), + ] + + # All the attributes can be retrieved. + assert draw_op.x == 10 + assert draw_op.y == 20 + + +def test_line_to(path): + """A line to operation can be added.""" + draw_op = path.line_to(10, 20) + + assert repr(draw_op) == "LineTo(x=10, y=20)" + + assert path.impl.draw_instructions == [ + ("line to", {"x": 10, "y": 20}), + ] + + # All the attributes can be retrieved. + assert draw_op.x == 10 + assert draw_op.y == 20 + + +def test_bezier_curve_to(path): + """A Bézier curve to operation can be added.""" + draw_op = path.bezier_curve_to(10, 20, 30, 40, 50, 60) + + assert ( + repr(draw_op) == "BezierCurveTo(cp1x=10, cp1y=20, cp2x=30, cp2y=40, x=50, y=60)" + ) + + assert path.impl.draw_instructions == [ + ( + "bezier curve to", + {"cp1x": 10, "cp1y": 20, "cp2x": 30, "cp2y": 40, "x": 50, "y": 60}, + ), + ] + + # All the attributes can be retrieved. + assert draw_op.cp1x == 10 + assert draw_op.cp1y == 20 + assert draw_op.cp2x == 30 + assert draw_op.cp2y == 40 + assert draw_op.x == 50 + assert draw_op.y == 60 + + +def test_quadratic_curve_to(path): + """A Quadratic curve to operation can be added.""" + draw_op = path.quadratic_curve_to(10, 20, 30, 40) + + assert repr(draw_op) == "QuadraticCurveTo(cpx=10, cpy=20, x=30, y=40)" + + assert path.impl.draw_instructions == [ + ( + "quadratic curve to", + {"cpx": 10, "cpy": 20, "x": 30, "y": 40}, + ), + ] + + # All the attributes can be retrieved. + assert draw_op.cpx == 10 + assert draw_op.cpy == 20 + assert draw_op.x == 30 + assert draw_op.y == 40 + + +@pytest.mark.parametrize( + "kwargs, args_repr, draw_kwargs", + [ + # Defaults + ( + {"x": 10, "y": 20, "radius": 30}, + ( + "x=10, y=20, radius=30, startangle=0.000, " + "endangle=6.283, counterclockwise=False" + ), + { + "x": 10, + "y": 20, + "radius": 30, + "startangle": 0.0, + "endangle": pytest.approx(6.283185), + "counterclockwise": False, + }, + ), + # Start angle + ( + {"x": 10, "y": 20, "radius": 30, "startangle": 1.234}, + ( + "x=10, y=20, radius=30, startangle=1.234, " + "endangle=6.283, counterclockwise=False" + ), + { + "x": 10, + "y": 20, + "radius": 30, + "startangle": pytest.approx(1.234), + "endangle": pytest.approx(6.283185), + "counterclockwise": False, + }, + ), + # End angle + ( + { + "x": 10, + "y": 20, + "radius": 30, + "endangle": 2.345, + }, + ( + "x=10, y=20, radius=30, startangle=0.000, " + "endangle=2.345, counterclockwise=False" + ), + { + "x": 10, + "y": 20, + "radius": 30, + "startangle": 0.0, + "endangle": pytest.approx(2.345), + "counterclockwise": False, + }, + ), + # Counterclockwise explicitly False + ( + { + "x": 10, + "y": 20, + "radius": 30, + "counterclockwise": False, + }, + ( + "x=10, y=20, radius=30, startangle=0.000, " + "endangle=6.283, counterclockwise=False" + ), + { + "x": 10, + "y": 20, + "radius": 30, + "startangle": 0.0, + "endangle": pytest.approx(6.283185), + "counterclockwise": False, + }, + ), + # Counterclockwise explicitly False + ( + { + "x": 10, + "y": 20, + "radius": 30, + "counterclockwise": True, + }, + ( + "x=10, y=20, radius=30, startangle=0.000, " + "endangle=6.283, counterclockwise=True" + ), + { + "x": 10, + "y": 20, + "radius": 30, + "startangle": 0.0, + "endangle": pytest.approx(6.283185), + "counterclockwise": True, + }, + ), + # All args + ( + { + "x": 10, + "y": 20, + "radius": 30, + "startangle": 1.234, + "endangle": 2.345, + "counterclockwise": True, + }, + ( + "x=10, y=20, radius=30, startangle=1.234, " + "endangle=2.345, counterclockwise=True" + ), + { + "x": 10, + "y": 20, + "radius": 30, + "startangle": pytest.approx(1.234), + "endangle": pytest.approx(2.345), + "counterclockwise": True, + }, + ), + ], +) +def test_arc(path, kwargs, args_repr, draw_kwargs): + """An arc operation can be added.""" + draw_op = path.arc(**kwargs) + + assert repr(draw_op) == f"Arc({args_repr})" + + assert path.impl.draw_instructions == [ + ("arc", draw_kwargs), + ] + + # All the attributes can be retrieved. + for attr, value in draw_kwargs.items(): + assert getattr(draw_op, attr) == value + + +@pytest.mark.parametrize( + "kwargs, args_repr, draw_kwargs", + [ + # Defaults + ( + {"x": 10, "y": 20, "radiusx": 30, "radiusy": 40}, + ( + "x=10, y=20, radiusx=30, radiusy=40, " + "rotation=0.000, startangle=0.000, endangle=6.283, " + "counterclockwise=False" + ), + { + "x": 10, + "y": 20, + "radiusx": 30, + "radiusy": 40, + "rotation": 0.0, + "startangle": 0.0, + "endangle": pytest.approx(6.283185), + "counterclockwise": False, + }, + ), + # Rotation + ( + {"x": 10, "y": 20, "radiusx": 30, "radiusy": 40, "rotation": 1.234}, + ( + "x=10, y=20, radiusx=30, radiusy=40, " + "rotation=1.234, startangle=0.000, endangle=6.283, " + "counterclockwise=False" + ), + { + "x": 10, + "y": 20, + "radiusx": 30, + "radiusy": 40, + "rotation": pytest.approx(1.234), + "startangle": 0.0, + "endangle": pytest.approx(6.283185), + "counterclockwise": False, + }, + ), + # Start angle + ( + {"x": 10, "y": 20, "radiusx": 30, "radiusy": 40, "startangle": 2.345}, + ( + "x=10, y=20, radiusx=30, radiusy=40, " + "rotation=0.000, startangle=2.345, endangle=6.283, " + "counterclockwise=False" + ), + { + "x": 10, + "y": 20, + "radiusx": 30, + "radiusy": 40, + "rotation": 0.0, + "startangle": pytest.approx(2.345), + "endangle": pytest.approx(6.283185), + "counterclockwise": False, + }, + ), + # End angle + ( + {"x": 10, "y": 20, "radiusx": 30, "radiusy": 40, "endangle": 3.456}, + ( + "x=10, y=20, radiusx=30, radiusy=40, " + "rotation=0.000, startangle=0.000, endangle=3.456, " + "counterclockwise=False" + ), + { + "x": 10, + "y": 20, + "radiusx": 30, + "radiusy": 40, + "rotation": 0.0, + "startangle": 0.0, + "endangle": pytest.approx(3.456), + "counterclockwise": False, + }, + ), + # Counterclockwise explicitly False + ( + {"x": 10, "y": 20, "radiusx": 30, "radiusy": 40, "counterclockwise": False}, + ( + "x=10, y=20, radiusx=30, radiusy=40, " + "rotation=0.000, startangle=0.000, endangle=6.283, " + "counterclockwise=False" + ), + { + "x": 10, + "y": 20, + "radiusx": 30, + "radiusy": 40, + "rotation": 0.0, + "startangle": 0.0, + "endangle": pytest.approx(6.283185), + "counterclockwise": False, + }, + ), + # Counterclockwise + ( + {"x": 10, "y": 20, "radiusx": 30, "radiusy": 40, "counterclockwise": True}, + ( + "x=10, y=20, radiusx=30, radiusy=40, " + "rotation=0.000, startangle=0.000, endangle=6.283, " + "counterclockwise=True" + ), + { + "x": 10, + "y": 20, + "radiusx": 30, + "radiusy": 40, + "rotation": 0.0, + "startangle": 0.0, + "endangle": pytest.approx(6.283185), + "counterclockwise": True, + }, + ), + # All args + ( + { + "x": 10, + "y": 20, + "radiusx": 30, + "radiusy": 40, + "rotation": 1.234, + "startangle": 2.345, + "endangle": 3.456, + "counterclockwise": True, + }, + ( + "x=10, y=20, radiusx=30, radiusy=40, " + "rotation=1.234, startangle=2.345, endangle=3.456, " + "counterclockwise=True" + ), + { + "x": 10, + "y": 20, + "radiusx": 30, + "radiusy": 40, + "rotation": pytest.approx(1.234), + "startangle": pytest.approx(2.345), + "endangle": pytest.approx(3.456), + "counterclockwise": True, + }, + ), + ], +) +def test_ellipse(path, kwargs, args_repr, draw_kwargs): + """An ellipse operation can be added.""" + draw_op = path.ellipse(**kwargs) + + assert repr(draw_op) == f"Ellipse({args_repr})" + + assert path.impl.draw_instructions == [ + ("ellipse", draw_kwargs), + ] + + # All the attributes can be retrieved. + for attr, value in draw_kwargs.items(): + assert getattr(draw_op, attr) == value + + +def test_rect(path): + """A rect operation can be added.""" + draw_op = path.rect(10, 20, 30, 40) + + assert repr(draw_op) == "Rect(x=10, y=20, width=30, height=40)" + + assert path.impl.draw_instructions == [ + ("rect", {"x": 10, "y": 20, "width": 30, "height": 40}), + ] + + # 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 + + +def test_round_rect(path): + """A rect operation can be added.""" + draw_op = path.round_rect(10, 20, 30, 40, 5) + + assert repr(draw_op) == "RoundRect(x=10, y=20, width=30, height=40, radii=5)" + + assert path.impl.draw_instructions == [ + ("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 diff --git a/core/tests/widgets/canvas/test_state_objects.py b/core/tests/widgets/canvas/test_state_objects.py index 89826e0b99..dc31239113 100644 --- a/core/tests/widgets/canvas/test_state_objects.py +++ b/core/tests/widgets/canvas/test_state_objects.py @@ -5,6 +5,7 @@ from toga.widgets.canvas import ( ClosePath, Fill, + Path2D, Scale, State, Stroke, @@ -12,6 +13,7 @@ from toga_dummy.utils import assert_action_performed REBECCA_PURPLE_COLOR = rgb(102, 51, 153) +EMPTY_PATH = Path2D() def test_sub_state(widget): @@ -62,35 +64,50 @@ def test_closed_path(widget): # Defaults ( {}, - "fill_rule=FillRule.NONZERO, fill_style=None", + "fill_rule=FillRule.NONZERO, path=None, fill_style=None", { "fill_rule": FillRule.NONZERO, + "path": None, "fill_style": None, }, ), # Fill style ( {"fill_style": REBECCAPURPLE}, - (f"fill_rule=FillRule.NONZERO, fill_style={REBECCA_PURPLE_COLOR!r}"), - {"fill_rule": FillRule.NONZERO, "fill_style": REBECCA_PURPLE_COLOR}, + ( + "fill_rule=FillRule.NONZERO, path=None, " + f"fill_style={REBECCA_PURPLE_COLOR!r}" + ), + { + "fill_rule": FillRule.NONZERO, + "path": None, + "fill_style": REBECCA_PURPLE_COLOR, + }, ), # Explicitly don't set fill style ( {"fill_style": None}, - "fill_rule=FillRule.NONZERO, fill_style=None", - {"fill_rule": FillRule.NONZERO, "fill_style": None}, + "fill_rule=FillRule.NONZERO, path=None, fill_style=None", + {"fill_rule": FillRule.NONZERO, "path": None, "fill_style": None}, ), # Fill Rule ( {"fill_rule": FillRule.EVENODD}, - "fill_rule=FillRule.EVENODD, fill_style=None", - {"fill_style": None, "fill_rule": FillRule.EVENODD}, + "fill_rule=FillRule.EVENODD, path=None, fill_style=None", + {"fill_style": None, "path": None, "fill_rule": FillRule.EVENODD}, ), # All args ( {"fill_rule": FillRule.EVENODD, "fill_style": REBECCAPURPLE}, - f"fill_rule=FillRule.EVENODD, fill_style={REBECCA_PURPLE_COLOR!r}", - {"fill_rule": FillRule.EVENODD, "fill_style": REBECCA_PURPLE_COLOR}, + ( + "fill_rule=FillRule.EVENODD, path=None, " + f"fill_style={REBECCA_PURPLE_COLOR!r}" + ), + { + "fill_rule": FillRule.EVENODD, + "path": None, + "fill_style": REBECCA_PURPLE_COLOR, + }, ), ], ) @@ -116,7 +133,7 @@ def test_fill(widget, kwargs, args_repr, properties): ), "begin path", ("line to", {"x": 30, "y": 40}), - ("fill", {"fill_rule": properties["fill_rule"]}), + ("fill", {"fill_rule": properties["fill_rule"], "path": None}), "restore", ] @@ -126,19 +143,32 @@ def test_fill(widget, kwargs, args_repr, properties): ] +def test_fill_context_path_error(widget): + """Fill should error if used as context when path is not None.""" + with pytest.raises( + RuntimeError, + match=r"The path must not be set when Fill is used as a context manager\.", + ): + with widget.fill(path=Path2D()): + pass + + @pytest.mark.parametrize( "kwargs, args_repr, properties", [ # Defaults ( {}, - "stroke_style=None, line_width=None, line_dash=None", + "path=None, stroke_style=None, line_width=None, line_dash=None", {"stroke_style": None, "line_width": None, "line_dash": None}, ), # Color ( {"stroke_style": REBECCAPURPLE}, - f"stroke_style={REBECCA_PURPLE_COLOR!r}, line_width=None, line_dash=None", + ( + f"path=None, stroke_style={REBECCA_PURPLE_COLOR!r}, " + "line_width=None, line_dash=None" + ), { "stroke_style": REBECCA_PURPLE_COLOR, "line_width": None, @@ -148,29 +178,30 @@ def test_fill(widget, kwargs, args_repr, properties): # Explicitly don't set stroke_style ( {"stroke_style": None}, - "stroke_style=None, line_width=None, line_dash=None", + ("path=None, stroke_style=None, line_width=None, line_dash=None"), {"stroke_style": None, "line_width": None, "line_dash": None}, ), # Line width ( {"line_width": 4.5}, - "stroke_style=None, line_width=4.500, line_dash=None", + "path=None, stroke_style=None, line_width=4.500, line_dash=None", {"stroke_style": None, "line_width": 4.5, "line_dash": None}, ), # Line dash ( {"line_dash": [2, 7]}, - "stroke_style=None, line_width=None, line_dash=[2, 7]", + "path=None, stroke_style=None, line_width=None, line_dash=[2, 7]", {"stroke_style": None, "line_width": None, "line_dash": [2, 7]}, ), # All args ( {"stroke_style": REBECCAPURPLE, "line_width": 4.5, "line_dash": [2, 7]}, ( - f"stroke_style={REBECCA_PURPLE_COLOR!r}, line_width=4.500, " - "line_dash=[2, 7]" + f"path=None, stroke_style={REBECCA_PURPLE_COLOR!r}, " + "line_width=4.500, line_dash=[2, 7]" ), { + "path": None, "stroke_style": REBECCA_PURPLE_COLOR, "line_width": 4.5, "line_dash": [2, 7], @@ -210,7 +241,7 @@ def test_stroke(widget, kwargs, args_repr, properties): ), "begin path", ("line to", {"x": 30, "y": 40}), - "stroke", + ("stroke", {"path": None}), "restore", ] @@ -220,6 +251,16 @@ def test_stroke(widget, kwargs, args_repr, properties): ] +def test_stroke_context_path_error(widget): + """Stroke should error if used as context when path is not None.""" + with pytest.raises( + RuntimeError, + match=r"The path must not be set when Stroke is used as a context manager\.", + ): + with widget.stroke(Path2D()): + pass + + def assert_contents(container, contains: list, doesnt_contain: list): """Assert that the container contains (and doesn't contain) specified objects.""" for item in contains: diff --git a/docs/en/reference/api/widgets/canvas.md b/docs/en/reference/api/widgets/canvas.md index 3abeaba6e5..b2108fd583 100644 --- a/docs/en/reference/api/widgets/canvas.md +++ b/docs/en/reference/api/widgets/canvas.md @@ -96,6 +96,7 @@ For detailed tutorials on the use of Canvas drawing instructions, see the MDN do - arc - ellipse - rect + - round_rect - fill - stroke - write_text @@ -113,6 +114,8 @@ For detailed tutorials on the use of Canvas drawing instructions, see the MDN do ::: toga.widgets.canvas.state.BaseState +::: toga.widgets.canvas.Path2D + ::: toga.widgets.canvas.DrawingAction ::: toga.widgets.canvas.OnTouchHandler diff --git a/dummy/pyproject.toml b/dummy/pyproject.toml index 113ce46309..253505d9d6 100644 --- a/dummy/pyproject.toml +++ b/dummy/pyproject.toml @@ -107,6 +107,9 @@ WebView = "toga_dummy.widgets.webview:WebView" MainWindow = "toga_dummy.window:MainWindow" Window = "toga_dummy.window:Window" +# Canvas implementation +Path2D = "toga_dummy.widgets.canvas:Path2D" + # Widget is also required for testing purposes # Real backends shouldn't expose Widget. Widget = "toga_dummy.widgets.base:Widget" diff --git a/dummy/src/toga_dummy/factory.py b/dummy/src/toga_dummy/factory.py index 6feff3f1d5..f9ba192af2 100644 --- a/dummy/src/toga_dummy/factory.py +++ b/dummy/src/toga_dummy/factory.py @@ -14,7 +14,7 @@ from .widgets.base import Widget from .widgets.box import Box from .widgets.button import Button -from .widgets.canvas import Canvas +from .widgets.canvas import Canvas, Path2D from .widgets.dateinput import DateInput from .widgets.detailedlist import DetailedList from .widgets.divider import Divider @@ -75,6 +75,7 @@ def not_implemented(feature): "NumberInput", "OptionContainer", "PasswordInput", + "Path2D", "ProgressBar", "ScrollContainer", "Selection", diff --git a/dummy/src/toga_dummy/widgets/canvas.py b/dummy/src/toga_dummy/widgets/canvas.py index 6d934c46ff..5b8d4ff9b1 100644 --- a/dummy/src/toga_dummy/widgets/canvas.py +++ b/dummy/src/toga_dummy/widgets/canvas.py @@ -6,6 +6,110 @@ from .base import Widget +class Path2D: + def __init__(self, path=None): + if path is None: + self.draw_instructions = [] + else: + self.draw_instructions = path.draw_instructions.copy() + + def add_path(self, path, transform=None): + self.draw_instructions.append( + ("add path", {"path": path, "transform": transform}) + ) + + def close_path(self): + self.draw_instructions.append("close path") + + def move_to(self, x, y): + self.draw_instructions.append(("move to", {"x": x, "y": y})) + + def line_to(self, x, y): + self.draw_instructions.append(("line to", {"x": x, "y": y})) + + # Basic shapes + def bezier_curve_to(self, cp1x, cp1y, cp2x, cp2y, x, y): + self.draw_instructions.append( + ( + "bezier curve to", + { + "cp1x": cp1x, + "cp1y": cp1y, + "cp2x": cp2x, + "cp2y": cp2y, + "x": x, + "y": y, + }, + ) + ) + + def quadratic_curve_to(self, cpx, cpy, x, y): + self.draw_instructions.append( + ( + "quadratic curve to", + {"cpx": cpx, "cpy": cpy, "x": x, "y": y}, + ) + ) + + def arc(self, x, y, radius, startangle, endangle, counterclockwise): + self.draw_instructions.append( + ( + "arc", + { + "x": x, + "y": y, + "radius": radius, + "startangle": startangle, + "endangle": endangle, + "counterclockwise": counterclockwise, + }, + ) + ) + + def ellipse( + self, + x, + y, + radiusx, + radiusy, + rotation, + startangle, + endangle, + counterclockwise, + ): + self.draw_instructions.append( + ( + "ellipse", + { + "x": x, + "y": y, + "radiusx": radiusx, + "radiusy": radiusy, + "rotation": rotation, + "startangle": startangle, + "endangle": endangle, + "counterclockwise": counterclockwise, + }, + ) + ) + + def rect(self, x, y, width, height): + self.draw_instructions.append( + ( + "rect", + {"x": x, "y": y, "width": width, "height": height}, + ) + ) + + def round_rect(self, x, y, width, height, radii): + self.draw_instructions.append( + ( + "round rect", + {"x": x, "y": y, "width": width, "height": height, "radii": radii}, + ) + ) + + class Context: def __init__(self, impl): self.impl = impl @@ -126,11 +230,13 @@ def round_rect(self, x, y, width, height, radii): ) # Drawing Paths - def fill(self, fill_rule): - self.impl.draw_instructions.append(("fill", {"fill_rule": fill_rule})) + def fill(self, fill_rule, path=None): + self.impl.draw_instructions.append( + ("fill", {"fill_rule": fill_rule, "path": path}) + ) - def stroke(self): - self.impl.draw_instructions.append("stroke") + def stroke(self, path=None): + self.impl.draw_instructions.append(("stroke", {"path": path})) # Transformations diff --git a/gtk/pyproject.toml b/gtk/pyproject.toml index 94c6dee0f3..59de0fcd72 100644 --- a/gtk/pyproject.toml +++ b/gtk/pyproject.toml @@ -110,6 +110,9 @@ WebView = "toga_gtk.widgets.webview:WebView" MainWindow = "toga_gtk.window:MainWindow" Window = "toga_gtk.window:Window" +# Canvas implementation +Path2D = "toga_gtk.widgets.canvas:Path2D" + [tool.setuptools_scm] root = ".." diff --git a/gtk/src/toga_gtk/factory.py b/gtk/src/toga_gtk/factory.py index c8fdc1cdb1..873fca9a5a 100644 --- a/gtk/src/toga_gtk/factory.py +++ b/gtk/src/toga_gtk/factory.py @@ -14,7 +14,7 @@ from .widgets.activityindicator import ActivityIndicator from .widgets.box import Box from .widgets.button import Button -from .widgets.canvas import Canvas +from .widgets.canvas import Canvas, Path2D from .widgets.dateinput import DateInput from .widgets.detailedlist import DetailedList from .widgets.divider import Divider @@ -79,6 +79,7 @@ def not_implemented(feature): # pragma: no cover "NumberInput", "OptionContainer", "PasswordInput", + "Path2D", "ProgressBar", "ScrollContainer", "Selection", diff --git a/gtk/src/toga_gtk/widgets/canvas.py b/gtk/src/toga_gtk/widgets/canvas.py index 874e408bbd..bdeebe9dd1 100644 --- a/gtk/src/toga_gtk/widgets/canvas.py +++ b/gtk/src/toga_gtk/widgets/canvas.py @@ -27,6 +27,103 @@ BLACK = native_color(rgb(0, 0, 0)) +class Path2D: + def __init__(self): + self._steps = [] + self._native_cached = None + + def add_path(self, path, transform=None): + self._steps.append(("add_path", path._steps.copy(), transform)) + self._native_cached = None + + def close_path(self): + self._steps.append(("close_path",)) + self._native_cached = None + + def move_to(self, x, y): + self._steps.append(("move_to", x, y)) + self._native_cached = None + + def line_to(self, x, y): + self._steps.append(("line_to", x, y)) + self._native_cached = None + + def bezier_curve_to(self, cp1x, cp1y, cp2x, cp2y, x, y): + self._steps.append(("bezier_curve_to", cp1x, cp1y, cp2x, cp2y, x, y)) + self._native_cached = None + + def quadratic_curve_to(self, cpx, cpy, x, y): + self._steps.append(("quadratic_curve_to", cpx, cpy, x, y)) + self._native_cached = None + + def arc(self, x, y, radius, startangle, endangle, counterclockwise): + self._steps.append( + ("arc", x, y, radius, startangle, endangle, counterclockwise) + ) + self._native_cached = None + + def ellipse( + self, + x, + y, + radiusx, + radiusy, + rotation, + startangle, + endangle, + counterclockwise, + ): + self._steps.append( + ( + "ellipse", + x, + y, + radiusx, + radiusy, + rotation, + startangle, + endangle, + counterclockwise, + ) + ) + self._native_cached = None + + def rect(self, x, y, width, height): + self._steps.append(("rect", x, y, width, height)) + self._native_cached = None + + def round_rect(self, x, y, width, height, radii): + self._steps.append(("round_rect", x, y, width, height, radii)) + self._native_cached = None + + # extra utility methods + def apply(self, context): + context.begin_path() + if self._native_cached is not None: + # if we have a C-level cache of the path, use it + context.native.append_path(self._native_cached) + else: + self._apply(self._steps, context) + # Cache the C-level path for reuse + self._native_cached = context.native.copy_path() + + def _apply(self, steps, context): + for method, *args in steps: + if method == "add_path": + add_steps, transform = args + if transform is None: + self._apply(add_steps, context) + else: + context.save() + try: + context.transform(transform) + self._apply(add_steps, context) + finally: + context.restore() + else: + getattr(context, method)(*args) + + @dataclass(slots=True) class State: # GTK doesn't track fill and stroke color separately. @@ -141,18 +238,31 @@ def round_rect(self, x, y, width, height, radii): # Drawing Paths - def fill(self, fill_rule): + def fill(self, fill_rule, path=None): self.native.set_source_rgba(*self.state.fill_style) if fill_rule == FillRule.EVENODD: self.native.set_fill_rule(cairo.FILL_RULE_EVEN_ODD) else: self.native.set_fill_rule(cairo.FILL_RULE_WINDING) + if path is None: + self.native.fill_preserve() + else: + current_path = self.native.copy_path() + path.apply(self) + self.native.fill() + # stroke clears path, so we are appending to an empty path + self.native.append_path(current_path) - self.native.fill_preserve() - - def stroke(self): + def stroke(self, path=None): self.native.set_source_rgba(*self.state.stroke_style) - self.native.stroke_preserve() + if path is None: + self.native.stroke_preserve() + else: + current_path = self.native.copy_path() + path.apply(self) + self.native.stroke() + # stroke clears path, so we are appending to an empty path + self.native.append_path(current_path) # Transformations @@ -172,6 +282,10 @@ def scale(self, sx, sy): def translate(self, tx, ty): self.native.translate(tx, ty) + def transform(self, transform): + matrix = cairo.Matrix(*transform) + self.native.transform(matrix) + def reset_transform(self): self.native.set_matrix(self.original_transform_matrix) diff --git a/iOS/pyproject.toml b/iOS/pyproject.toml index 963a7f439d..ced99d23ec 100644 --- a/iOS/pyproject.toml +++ b/iOS/pyproject.toml @@ -108,6 +108,9 @@ WebView = "toga_iOS.widgets.webview:WebView" MainWindow = "toga_iOS.window:MainWindow" Window = "toga_iOS.window:Window" +# Canvas implementation +Path2D = "toga_iOS.widgets.canvas:Path2D" + [tool.setuptools_scm] root = ".." diff --git a/iOS/src/toga_iOS/factory.py b/iOS/src/toga_iOS/factory.py index 16211c6d56..c5a74c5a83 100644 --- a/iOS/src/toga_iOS/factory.py +++ b/iOS/src/toga_iOS/factory.py @@ -18,7 +18,7 @@ from .widgets.activityindicator import ActivityIndicator from .widgets.box import Box from .widgets.button import Button -from .widgets.canvas import Canvas +from .widgets.canvas import Canvas, Path2D from .widgets.dateinput import DateInput from .widgets.detailedlist import DetailedList from .widgets.divider import Divider @@ -89,6 +89,7 @@ def not_implemented(feature): # pragma: no cover "NumberInput", "OptionContainer", "PasswordInput", + "Path2D", "ProgressBar", "ScrollContainer", "Selection", diff --git a/iOS/src/toga_iOS/libs/core_graphics.py b/iOS/src/toga_iOS/libs/core_graphics.py index 5d9849fbbf..f44db9adf8 100644 --- a/iOS/src/toga_iOS/libs/core_graphics.py +++ b/iOS/src/toga_iOS/libs/core_graphics.py @@ -39,8 +39,92 @@ class CGAffineTransform(Structure): core_graphics.CGAffineTransformIdentity = CGAffineTransform core_graphics.CGAffineTransformInvert.restype = CGAffineTransform core_graphics.CGAffineTransformInvert.argtypes = [CGAffineTransform] +core_graphics.CGAffineTransformMake.restype = CGAffineTransform +core_graphics.CGAffineTransformMake.argtypes = [ + CGFloat, + CGFloat, + CGFloat, + CGFloat, + CGFloat, + CGFloat, +] core_graphics.CGAffineTransformMakeScale.restype = CGAffineTransform core_graphics.CGAffineTransformMakeScale.argtypes = [CGFloat, CGFloat] +core_graphics.CGAffineTransformRotate.restype = CGAffineTransform +core_graphics.CGAffineTransformRotate.argtypes = [CGAffineTransform, CGFloat] +core_graphics.CGAffineTransformScale.restype = CGAffineTransform +core_graphics.CGAffineTransformScale.argtypes = [CGAffineTransform, CGFloat, CGFloat] +core_graphics.CGAffineTransformTranslate.restype = CGAffineTransform +core_graphics.CGAffineTransformTranslate.argtypes = [ + CGAffineTransform, + CGFloat, + CGFloat, +] + +###################################################################### +# CGPath.h +CGPathRef = c_void_p +register_preferred_encoding(b"^{__CGPath=}", CGPathRef) +CGMutablePathRef = c_void_p +register_preferred_encoding(b"^{__CGMutablePath=}", CGMutablePathRef) + +core_graphics.CGPathCreateMutable.restype = CGMutablePathRef +core_graphics.CGPathCreateMutable.argtypes = [] +core_graphics.CGPathCreateMutableCopy.restype = CGMutablePathRef +core_graphics.CGPathCreateMutableCopy.argtypes = [CGPathRef] +core_graphics.CGPathAddArc.restype = c_void_p +core_graphics.CGPathAddArc.argtypes = [ + CGMutablePathRef, + CGAffineTransform, + CGFloat, + CGFloat, + CGFloat, + CGFloat, + CGFloat, + c_bool, +] +core_graphics.CGPathAddCurveToPoint.restype = c_void_p +core_graphics.CGPathAddCurveToPoint.argtypes = [ + CGMutablePathRef, + CGAffineTransform, + CGFloat, + CGFloat, + CGFloat, + CGFloat, + CGFloat, + CGFloat, +] +core_graphics.CGPathAddLineToPoint.restype = c_void_p +core_graphics.CGPathAddLineToPoint.argtypes = [ + CGMutablePathRef, + CGAffineTransform, + CGFloat, + CGFloat, +] +core_graphics.CGPathAddQuadCurveToPoint.restype = c_void_p +core_graphics.CGPathAddQuadCurveToPoint.argtypes = [ + CGMutablePathRef, + CGAffineTransform, + CGFloat, + CGFloat, + CGFloat, + CGFloat, +] +core_graphics.CGPathAddRect.restype = c_void_p +core_graphics.CGPathAddRect.argtypes = [CGMutablePathRef, CGAffineTransform, CGRect] +core_graphics.CGPathCloseSubpath.restype = c_void_p +core_graphics.CGPathCloseSubpath.argtypes = [c_void_p] +core_graphics.CGPathIsEmpty.restype = c_bool +core_graphics.CGPathIsEmpty.argtypes = [CGMutablePathRef] +core_graphics.CGPathMoveToPoint.restype = c_void_p +core_graphics.CGPathMoveToPoint.argtypes = [ + CGMutablePathRef, + CGAffineTransform, + CGFloat, + CGFloat, +] +core_graphics.CGPathAddPath.restype = c_void_p +core_graphics.CGPathAddPath.argtypes = [CGMutablePathRef, CGAffineTransform, CGPathRef] ###################################################################### # CGImage.h @@ -182,8 +266,6 @@ class CGAffineTransform(Structure): core_graphics.CGContextDrawImage.restype = c_void_p core_graphics.CGContextDrawImage.argtypes = [CGContextRef, CGRect, CGImageRef] -CGPathRef = c_void_p -register_preferred_encoding(b"^{__CGPath=}", CGPathRef) core_graphics.CGContextCopyPath.restype = CGPathRef core_graphics.CGContextCopyPath.argtypes = [CGContextRef] core_graphics.CGContextAddPath.restype = c_void_p diff --git a/iOS/src/toga_iOS/widgets/canvas.py b/iOS/src/toga_iOS/widgets/canvas.py index 3344d55761..77b21854b1 100644 --- a/iOS/src/toga_iOS/widgets/canvas.py +++ b/iOS/src/toga_iOS/widgets/canvas.py @@ -42,6 +42,94 @@ ) from toga_iOS.widgets.base import Widget +IDENTITY = core_graphics.CGAffineTransformMake(1, 0, 0, 1, 0, 0) + + +class Path2D: + def __init__(self): + self.native = core_graphics.CGPathCreateMutable() + + def _ensure_subpath(self, x, y): + if core_graphics.CGPathIsEmpty(self.native): + self.move_to(x, y) + + def add_path(self, path, transform=None): + if transform is None: + transform = IDENTITY + else: + transform = core_graphics.CGAffineTransformMake(*transform) + core_graphics.CGPathAddPath(self.native, transform, path.native) + + def close_path(self): + core_graphics.CGPathCloseSubpath(self.native) + + def move_to(self, x, y): + core_graphics.CGPathMoveToPoint(self.native, IDENTITY, x, y) + + def line_to(self, x, y): + core_graphics.CGPathAddLineToPoint(self.native, IDENTITY, x, y) + + def bezier_curve_to(self, cp1x, cp1y, cp2x, cp2y, x, y): + self._ensure_subpath(cp1x, cp1y) + core_graphics.CGPathAddCurveToPoint( + self.native, + IDENTITY, + cp1x, + cp1y, + cp2x, + cp2y, + x, + y, + ) + + def quadratic_curve_to(self, cpx, cpy, x, y): + self._ensure_subpath(cpx, cpy) + core_graphics.CGPathAddQuadCurveToPoint(self.native, IDENTITY, cpx, cpy, x, y) + + def arc(self, x, y, radius, startangle, endangle, counterclockwise): + # iOS Path is using a flipped coordinate system, so clockwise + # is actually counterclockwise + clockwise = counterclockwise + core_graphics.CGPathAddArc( + self.native, + IDENTITY, + x, + y, + radius, + startangle, + endangle, + clockwise, + ) + + def ellipse( + self, + x, + y, + radiusx, + radiusy, + rotation, + startangle, + endangle, + counterclockwise, + ): + transform = core_graphics.CGAffineTransformMake(1, 0, 0, 1, x, y) + transform = core_graphics.CGAffineTransformRotate(transform, rotation) + transform = core_graphics.CGAffineTransformScale(transform, radiusx, radiusy) + core_graphics.CGPathAddArc( + self.native, transform, 0, 0, 1.0, startangle, endangle, counterclockwise + ) + + def rect(self, x, y, width, height): + rectangle = CGRectMake(x, y, width, height) + core_graphics.CGPathAddRect(self.native, IDENTITY, rectangle) + + def round_rect(self, x, y, width, height, radii): + round_rect(self, x, y, width, height, radii) + + # extra utility methods + def is_empty(self): + return core_graphics.CGPathIsEmpty(self.native) + @dataclass(slots=True) class State: @@ -186,23 +274,49 @@ def round_rect(self, x, y, width, height, radii): round_rect(self, x, y, width, height, radii) # Drawing Paths - def fill(self, fill_rule): + + def fill(self, fill_rule, path=None): + current_path = core_graphics.CGContextCopyPath(self.native) + if path is not None: + if path.is_empty(): + # nothing to draw + return + # replace context path with path.native + core_graphics.CGContextBeginPath(self.native) + core_graphics.CGContextAddPath(self.native, path.native) + elif core_graphics.CGPathIsEmpty(current_path): + # nothing to draw + return + + # draw if fill_rule == FillRule.EVENODD: mode = CGPathDrawingMode(kCGPathEOFill) else: mode = CGPathDrawingMode(kCGPathFill) - if not core_graphics.CGContextIsPathEmpty(self.native): - path = core_graphics.CGContextCopyPath(self.native) - core_graphics.CGContextDrawPath(self.native, mode) - core_graphics.CGContextAddPath(self.native, path) - - def stroke(self): + core_graphics.CGContextDrawPath(self.native, mode) + + # restore original path + core_graphics.CGContextAddPath(self.native, current_path) + + def stroke(self, path=None): + current_path = core_graphics.CGContextCopyPath(self.native) + if path is not None: + if path.is_empty(): + # nothing to draw + return + # replace context path with path.native + core_graphics.CGContextBeginPath(self.native) + core_graphics.CGContextAddPath(self.native, path.native) + elif core_graphics.CGPathIsEmpty(current_path): + # nothing to draw + return + + # draw mode = CGPathDrawingMode(kCGPathStroke) + core_graphics.CGContextDrawPath(self.native, mode) - if not core_graphics.CGContextIsPathEmpty(self.native): - path = core_graphics.CGContextCopyPath(self.native) - core_graphics.CGContextDrawPath(self.native, mode) - core_graphics.CGContextAddPath(self.native, path) + # restore original path + core_graphics.CGContextAddPath(self.native, current_path) # Transformations def rotate(self, radians): diff --git a/qt/pyproject.toml b/qt/pyproject.toml index 47b755b54e..2103320f08 100644 --- a/qt/pyproject.toml +++ b/qt/pyproject.toml @@ -101,6 +101,9 @@ WebView = "toga_qt.widgets.webview:WebView" MainWindow = "toga_qt.window:MainWindow" Window = "toga_qt.window:Window" +# Canvas implementation +Path2D = "toga_qt.widgets.canvas:Path2D" + [tool.setuptools_scm] root = ".." diff --git a/qt/src/toga_qt/factory.py b/qt/src/toga_qt/factory.py index 5099f24932..cba0bffa22 100644 --- a/qt/src/toga_qt/factory.py +++ b/qt/src/toga_qt/factory.py @@ -16,7 +16,7 @@ from .widgets.activityindicator import ActivityIndicator from .widgets.box import Box from .widgets.button import Button - from .widgets.canvas import Canvas + from .widgets.canvas import Canvas, Path2D from .widgets.dateinput import DateInput from .widgets.detailedlist import DetailedList from .widgets.divider import Divider @@ -81,6 +81,7 @@ "NumberInput", "OptionContainer", "PasswordInput", + "Path2D", "ProgressBar", "SplitContainer", "Selection", diff --git a/qt/src/toga_qt/widgets/canvas.py b/qt/src/toga_qt/widgets/canvas.py index 25ec09d82c..8e5a79af65 100644 --- a/qt/src/toga_qt/widgets/canvas.py +++ b/qt/src/toga_qt/widgets/canvas.py @@ -27,6 +27,94 @@ BLACK = native_color(rgb(0, 0, 0)) +class Path2D: + def __init__(self): + self.native = QPainterPath() + + def _ensure_point(self, x, y): + if self.native.elementCount() == 0: + self.native.moveTo(x, y) + + def add_path(self, path, transform=None): + if transform is None: + self.native.addPath(path.native) + else: + transform = QTransform(*transform) + self.native.addPath(transform.map(path.native)) + + def close_path(self): + self.native.closeSubpath() + + def move_to(self, x, y): + self.native.moveTo(x, y) + + def line_to(self, x, y): + self._ensure_point(x, y) + self.native.lineTo(x, y) + + def bezier_curve_to(self, cp1x, cp1y, cp2x, cp2y, x, y): + self.native.cubicTo(cp1x, cp1y, cp2x, cp2y, x, y) + + def quadratic_curve_to(self, cpx, cpy, x, y): + self.native.quadTo(cpx, cpy, x, y) + + def arc(self, x, y, radius, startangle, endangle, counterclockwise): + self._ensure_point(x + radius * cos(startangle), y + radius * sin(startangle)) + + # Qt measures angles counterclockwise from x-axis and in degrees + self.native.arcTo( + x - radius, + y - radius, + radius * 2, + radius * 2, + -degrees(startangle), + -degrees(sweepangle(startangle, endangle, counterclockwise)), + ) + + def ellipse( + self, + x, + y, + radiusx, + radiusy, + rotation, + startangle, + endangle, + counterclockwise, + ): + # Draw the ellipse unrotated and at origin + transform = QTransform() + transform.translate(x, y) + transform.rotate(degrees(rotation)) + transform.scale(radiusx, radiusy) + transform.rotate(degrees(startangle)) + + # Note: we can *almost* do this using arcTo, but arcTo doesn't support rotation + # (it must be axis-aligned), and attempts at manually rotating the points after + # creation are awkward: easier just to use geometry routines. + points = [ + transform.map(QPointF(x, y)) + for (x, y) in arc_to_bezier( + sweepangle(startangle, endangle, counterclockwise) + ) + ] + + # draw a line to the start point unless this is the first point of the path + start = points.pop(0) + self._ensure_point(start.x(), start.y()) + self.native.lineTo(start) + + for i in range(0, len(points), 3): + cp1, cp2, end = points[i : i + 3] + self.native.cubicTo(cp1, cp2, end) + + def rect(self, x, y, width, height): + self.native.addRect(x, y, width, height) + + def round_rect(self, x, y, width, height, radii): + round_rect(self, x, y, width, height, radii) + + class State: """Track transform and fill/stroke-related properties.""" @@ -62,6 +150,12 @@ def __init__(self, impl, native): def state(self): return self.states[-1] + @property + def path(self): + path = Path2D() + path.native = self._path + return path + # Context management def save(self): self.states.append(State(self.state)) @@ -94,43 +188,24 @@ def begin_path(self): self._path = QPainterPath() def close_path(self): - self._path.closeSubpath() + self.path.close_path() def move_to(self, x, y): - self._path.moveTo(x, y) + self.path.move_to(x, y) def line_to(self, x, y): - if self._path.elementCount() == 0: - self._path.moveTo(x, y) - else: - self._path.lineTo(x, y) + self.path.line_to(x, y) # Basic shapes def bezier_curve_to(self, cp1x, cp1y, cp2x, cp2y, x, y): - self._path.cubicTo(cp1x, cp1y, cp2x, cp2y, x, y) + self.path.bezier_curve_to(cp1x, cp1y, cp2x, cp2y, x, y) def quadratic_curve_to(self, cpx, cpy, x, y): - self._path.quadTo(cpx, cpy, x, y) + self.path.quadratic_curve_to(cpx, cpy, x, y) def arc(self, x, y, radius, startangle, endangle, counterclockwise): - if self._path.elementCount() == 0: - # if this is the first point of the path, don't draw a line - # to the start point - self._path.moveTo( - x + radius * cos(startangle), - y + radius * sin(startangle), - ) - - # Qt measures angles counterclockwise from x-axis and in degrees - self._path.arcTo( - x - radius, - y - radius, - radius * 2, - radius * 2, - -degrees(startangle), - -degrees(sweepangle(startangle, endangle, counterclockwise)), - ) + self.path.arc(x, y, radius, startangle, endangle, counterclockwise) def ellipse( self, @@ -143,51 +218,38 @@ def ellipse( endangle, counterclockwise, ): - # Draw the ellipse unrotated and at origin - transform = QTransform() - transform.translate(x, y) - transform.rotate(degrees(rotation)) - transform.scale(radiusx, radiusy) - transform.rotate(degrees(startangle)) - - # Note: we can *almost* do this using arcTo, but arcTo doesn't support rotation - # (it must be axis-aligned), and attempts at manually rotating the points after - # creation are awkward: easier just to use geometry routines. - points = [ - transform.map(QPointF(x, y)) - for (x, y) in arc_to_bezier( - sweepangle(startangle, endangle, counterclockwise) - ) - ] - - # draw a line to the start point unless this is the first point of the path - start = points.pop(0) - if self._path.elementCount() == 0: - self._path.moveTo(start) - else: - self._path.lineTo(start) - - for i in range(0, len(points), 3): - cp1, cp2, end = points[i : i + 3] - self._path.cubicTo(cp1, cp2, end) + self.path.ellipse( + x, + y, + radiusx, + radiusy, + rotation, + startangle, + endangle, + counterclockwise, + ) def rect(self, x, y, width, height): - self._path.addRect(x, y, width, height) + self.path.rect(x, y, width, height) def round_rect(self, x, y, width, height, radii): - round_rect(self, x, y, width, height, radii) + self.path.round_rect(x, y, width, height, radii) # Drawing Paths - def fill(self, fill_rule): + def fill(self, fill_rule, path=None): + if path is None: + path = self.path if fill_rule == FillRule.EVENODD: - self._path.setFillRule(Qt.FillRule.OddEvenFill) + path.native.setFillRule(Qt.FillRule.OddEvenFill) else: - self._path.setFillRule(Qt.FillRule.WindingFill) - self.native.fillPath(self._path, self.state.fill_style) + path.native.setFillRule(Qt.FillRule.WindingFill) + self.native.fillPath(path.native, self.state.fill_style) - def stroke(self): - self.native.strokePath(self._path, self.state.stroke) + def stroke(self, path=None): + if path is None: + path = self.path + self.native.strokePath(path.native, self.state.stroke) # Transformations def rotate(self, radians): diff --git a/testbed/src/testbed/resources/canvas/path_object.png b/testbed/src/testbed/resources/canvas/path_object.png new file mode 100644 index 0000000000..b2f1f689ff Binary files /dev/null and b/testbed/src/testbed/resources/canvas/path_object.png differ diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index 7fb893acb5..e9a7ca4705 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -180,6 +180,9 @@ def main(main_package_name, backend_override=None): "no-cover-if-netcore": ( "os_environ.get('TOGA_WINFORMS_USE_NETFX', '') != '1'" ), + # MacOS platform architectures + "no-cover-if-x86": "platform_machine == 'x86_64'", + "no-cover-if-arm": "platform_machine != 'x86_64'", }, ) cov.start() diff --git a/testbed/tests/widgets/canvas/test_canvas.py b/testbed/tests/widgets/canvas/test_canvas.py index c821feed63..fc9cd82208 100644 --- a/testbed/tests/widgets/canvas/test_canvas.py +++ b/testbed/tests/widgets/canvas/test_canvas.py @@ -1,5 +1,6 @@ import math import os +import platform from itertools import chain from math import pi, radians, tan from unittest.mock import call @@ -22,6 +23,7 @@ from toga.fonts import BOLD from toga.images import Image as TogaImage from toga.style.pack import SYSTEM +from toga.widgets.canvas import Path2D from ..conftest import build_cleanup_test from ..properties import ( # noqa: F401 @@ -971,6 +973,69 @@ async def test_draw_image_in_rect(canvas, probe): assert_reference(probe, "draw_image_in_rect", threshold=0.05) +@pytest.mark.xfail( + condition=platform.system() == "Darwin" and platform.machine() == "x86_64", + reason="Calls to CGPathAddArc with counterclockwise True segfaults on Mac Intel", +) +async def test_path_object(canvas, probe): + path = Path2D() + + # exercise all of the Path methods + # line_to without point + path.line_to(10, 15) + # line_to with point + path.line_to(20, 30) + # bezier + path.bezier_curve_to(20, 10, 40, 15, 50, 10) + # close + path.close_path() + + # rect + path.rect(5, 5, 50, 30) + + path2 = Path2D() + # move_to + path2.move_to(100, 80) + # quadratic + path2.quadratic_curve_to(100, 100, 120, 130) + path2.quadratic_curve_to(150, 120, 150, 100) + # arc + path2.arc(130, 100, 20, endangle=pi / 3) + + # add_path + path.add_path(path2, (0.5, 0.0, 0.0, 0.75, 30, 10)) + path.move_to(150, 100) + # ellipse + path.ellipse(150, 100, 20, 30, pi / 4, 0, pi, True) + # rounded rectangle + path.round_rect(180, 140, 10, 10, 3) + + # add an empty path with and without transformation + path.add_path(Path2D()) + path.add_path(Path2D(), (1, 0, 0, 1, 10, 10)) + + # clone a path + Path2D(path2) + + # transform + canvas.translate(100, 100) + canvas.scale(0.5, 0.5) + for _ in range(12): + canvas.rotate(pi / 6) + canvas.scale(0.95, 0.95) + print("draw", _) + canvas.fill(path=path, fill_style=CORNFLOWERBLUE) + canvas.stroke(path, color=REBECCAPURPLE) + + # stroke and fill an empty path + canvas.fill(path=Path2D()) + canvas.stroke(path=Path2D()) + # done + + await probe.redraw("Image should be drawn") + assert_reference(probe, "path_object", threshold=0.05) + + async def test_miter_join(canvas, probe): """Lines are joined with a miter, down to about 11.5º.""" diff --git a/winforms/pyproject.toml b/winforms/pyproject.toml index 21d20bc36a..00848f1d81 100644 --- a/winforms/pyproject.toml +++ b/winforms/pyproject.toml @@ -110,6 +110,9 @@ WebView = "toga_winforms.widgets.webview:WebView" MainWindow = "toga_winforms.window:MainWindow" Window = "toga_winforms.window:Window" +# Canvas implementation +Path2D = "toga_winforms.widgets.canvas:Path2D" + [tool.setuptools_scm] root = ".." diff --git a/winforms/src/toga_winforms/factory.py b/winforms/src/toga_winforms/factory.py index 09bad744d6..2a4f4dbaa2 100644 --- a/winforms/src/toga_winforms/factory.py +++ b/winforms/src/toga_winforms/factory.py @@ -13,7 +13,7 @@ from .widgets.activityindicator import ActivityIndicator from .widgets.box import Box from .widgets.button import Button -from .widgets.canvas import Canvas +from .widgets.canvas import Canvas, Path2D from .widgets.dateinput import DateInput from .widgets.detailedlist import DetailedList from .widgets.divider import Divider @@ -77,6 +77,7 @@ def not_implemented(feature): # pragma: no cover "NumberInput", "OptionContainer", "PasswordInput", + "Path2D", "ProgressBar", "ScrollContainer", "Selection", diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index 8031232af2..aca8c084a4 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -33,6 +33,158 @@ BLACK = native_color(rgb(0, 0, 0)) +class Path2D: + def __init__(self): + self.native = GraphicsPath() + self._subpath_start = None + self._subpath_end = None + self._subpath_empty = True + + def _ensure_path(self, x, y): + if self._subpath_start is None: + self.move_to(x, y) + + @property + def last_point(self): + return self._subpath_end + + def add_path(self, path, transform=None): + if path.native.PointCount == 0: + # Nothing to do + return + if transform is None: # pragma: no cover + # This shouldn't happen if using path via Canvas, + # but do something sensible if being used at impl level + self.native.AddPath(path.native, False) + self._subpath_end = path._subpath_end + else: + native_path = GraphicsPath(path.native.PathPoints, path.native.PathTypes) + matrix = Matrix(*transform) + native_path.Transform(matrix) + self.native.AddPath(native_path, False) + if ( + self._subpath_start is None and path._subpath_start is not None + ): # pragma: no cover + # This shouldn't happen if using path via Canvas, + # but do something sensible if being used at impl level + points = Array[PointF]([path._subpath_start]) + matrix.TransformPoints(points) + self._subpath_start = points[0] + if path._subpath_end is not None: # pragma: no branch + # This should always happen if using path via Canvas, + # but might not if being used at impl level + points = Array[PointF]([path._subpath_end]) + matrix.TransformPoints(points) + self._subpath_end = points[0] + + def close_path(self): + if self._subpath_start is not None: + # We don't use current_path.CloseFigure, because that causes the dash + # pattern to start on the last segment of the path rather than the first + # one. + self.line_to(self._subpath_start.X, self._subpath_start.Y) + self._subpath_end = self._subpath_start + + def move_to(self, x, y): + self._subpath_end = PointF(x, y) + self._subpath_start = self._subpath_end + if not self._subpath_empty: + self.native.StartFigure() + self._subpath_empty = True + + def line_to(self, x, y): + self._ensure_path(x, y) + self.native.AddLine(self.last_point, PointF(x, y)) + self._subpath_end = PointF(x, y) + self._subpath_empty = False + + # Basic shapes + + def bezier_curve_to(self, cp1x, cp1y, cp2x, cp2y, x, y): + self._ensure_path(cp1x, cp1y) + self.native.AddBezier( + self.last_point, + PointF(cp1x, cp1y), + PointF(cp2x, cp2y), + PointF(x, y), + ) + self._subpath_end = PointF(x, y) + self._subpath_empty = False + + def quadratic_curve_to(self, cpx, cpy, x, y): + # A Quadratic curve is a dimensionally reduced Bézier Cubic curve; + # we can convert the single Quadratic control point into the + # 2 control points required for the cubic Bézier. + self._ensure_path(cpx, cpy) + x0, y0 = (self.last_point.X, self.last_point.Y) + self.native.AddBezier( + self.last_point, + PointF( + x0 + 2 / 3 * (cpx - x0), + y0 + 2 / 3 * (cpy - y0), + ), + PointF( + x + 2 / 3 * (cpx - x), + y + 2 / 3 * (cpy - y), + ), + PointF(x, y), + ) + self._subpath_end = PointF(x, y) + self._subpath_empty = False + + def arc(self, x, y, radius, startangle, endangle, counterclockwise): + self.ellipse(x, y, radius, radius, 0, startangle, endangle, counterclockwise) + + def ellipse( + self, + x, + y, + radiusx, + radiusy, + rotation, + startangle, + endangle, + counterclockwise, + ): + matrix = Matrix() + matrix.Translate(x, y) + matrix.Rotate(degrees(rotation)) + matrix.Scale(radiusx, radiusy) + matrix.Rotate(degrees(startangle)) + + points = Array[PointF]( + [ + PointF(x, y) + for x, y in arc_to_bezier( + sweepangle(startangle, endangle, counterclockwise) + ) + ] + ) + matrix.TransformPoints(points) + + self._ensure_path(points[0].X, points[0].Y) + self.native.AddLine(PointF(self._subpath_end.X, self._subpath_end.Y), points[0]) + self.native.AddBeziers(points) + self._subpath_end = points[-1] + self._subpath_empty = False + + def rect(self, x, y, width, height): + rect = RectangleF(x, y, width, height) + self.native.AddRectangle(rect) + if self._subpath_start is None: + self._subpath_start = PointF(x, y) + self._subpath_end = PointF(x, y) + self._subpath_empty = False + + def round_rect(self, x, y, width, height, radii): + set_start = self._subpath_start is None + round_rect(self, x, y, width, height, radii) + if set_start: + self._subpath_start = PointF(x, y) + self._subpath_end = PointF(x, y) + self._subpath_empty = False + + class State: """Represents a canvas state; can be saved and restored. @@ -81,36 +233,17 @@ def __init__(self, impl, native): self.in_stroke = False # Windows path management - @property - def current_path(self): - return self.paths[-1] - - def add_path(self, start_point=None): - self.paths.append(GraphicsPath()) - self.start_point = start_point - - # Because the GraphicsPath API works in terms of segments rather than points, it has - # no equivalent to move_to, and we must save that point manually. In all other - # situations, we can get the last point from the GraphicsPath itself. - # - # default_x and default_y should be set as described in the HTML spec under "ensure - # there is a subpath". - def get_last_point(self, default_x, default_y): - if self.current_path.PointCount: - return self.current_path.GetLastPoint() - elif self.start_point: - return self.start_point - else: - return PointF(default_x, default_y) - def transform_path(self, matrix): """Transform the current path using a matrix.""" - for path in self.paths: - path.Transform(matrix) - if self.start_point: - points = Array[PointF]([self.start_point]) + self.path.native.Transform(matrix) + if (start := self.path._subpath_start) is not None: + points = Array[PointF]([start]) + matrix.TransformPoints(points) + self.path._subpath_start = points[0] + if (end := self.path._subpath_end) is not None: + points = Array[PointF]([end]) matrix.TransformPoints(points) - self.start_point = points[0] + self.path._subpath_end = points[0] # Context management @@ -144,54 +277,27 @@ def set_stroke_style(self, color): # Basic paths def begin_path(self): - self.paths = [] - self.add_path() + self.path = Path2D() - # We don't use current_path.CloseFigure, because that causes the dash pattern to - # start on the last segment of the path rather than the first one. def close_path(self): - if self.current_path.PointCount: - start = self.current_path.PathPoints[0] - self.current_path.AddLine(self.current_path.GetLastPoint(), start) - self.move_to(start.X, start.Y) + self.path.close_path() def move_to(self, x, y): - self.add_path(PointF(x, y)) + self.path.move_to(x, y) def line_to(self, x, y): - self.current_path.AddLine(self.get_last_point(x, y), PointF(x, y)) + self.path.line_to(x, y) # Basic shapes def bezier_curve_to(self, cp1x, cp1y, cp2x, cp2y, x, y): - self.current_path.AddBezier( - self.get_last_point(cp1x, cp1y), - PointF(cp1x, cp1y), - PointF(cp2x, cp2y), - PointF(x, y), - ) + self.path.bezier_curve_to(cp1x, cp1y, cp2x, cp2y, x, y) - # A Quadratic curve is a dimensionally reduced Bézier Cubic curve; - # we can convert the single Quadratic control point into the - # 2 control points required for the cubic Bézier. def quadratic_curve_to(self, cpx, cpy, x, y): - last_point = self.get_last_point(cpx, cpy) - x0, y0 = (last_point.X, last_point.Y) - self.current_path.AddBezier( - last_point, - PointF( - x0 + 2 / 3 * (cpx - x0), - y0 + 2 / 3 * (cpy - y0), - ), - PointF( - x + 2 / 3 * (cpx - x), - y + 2 / 3 * (cpy - y), - ), - PointF(x, y), - ) + self.path.quadratic_curve_to(cpx, cpy, x, y) def arc(self, x, y, radius, startangle, endangle, counterclockwise): - self.ellipse(x, y, radius, radius, 0, startangle, endangle, counterclockwise) + self.path.arc(x, y, radius, startangle, endangle, counterclockwise) def ellipse( self, @@ -204,55 +310,45 @@ def ellipse( endangle, counterclockwise, ): - matrix = Matrix() - matrix.Translate(x, y) - matrix.Rotate(degrees(rotation)) - matrix.Scale(radiusx, radiusy) - matrix.Rotate(degrees(startangle)) - - points = Array[PointF]( - [ - PointF(x, y) - for x, y in arc_to_bezier( - sweepangle(startangle, endangle, counterclockwise) - ) - ] + self.path.ellipse( + x, + y, + radiusx, + radiusy, + rotation, + startangle, + endangle, + counterclockwise, ) - matrix.TransformPoints(points) - - start = self.start_point - if start and not self.current_path.PointCount: - self.current_path.AddLine(start, start) - self.current_path.AddBeziers(points) def rect(self, x, y, width, height): - self.add_path() - rect = RectangleF(x, y, width, height) - self.current_path.AddRectangle(rect) - self.add_path() + self.path.rect(x, y, width, height) def round_rect(self, x, y, width, height, radii): - round_rect(self, x, y, width, height, radii) + self.path.round_rect(x, y, width, height, radii) # Drawing Paths - def fill(self, fill_rule): + def fill(self, fill_rule, path=None): if self.state.singular: # draw nothing return - for path in self.paths: - if fill_rule == FillRule.EVENODD: - path.FillMode = FillMode.Alternate - else: # Default to NONZERO - path.FillMode = FillMode.Winding - self.native.FillPath(self.state.brush, path) - - def stroke(self): + if path is None: + path = self.path + + if fill_rule == FillRule.EVENODD: + path.native.FillMode = FillMode.Alternate + else: # Default to NONZERO + path.native.FillMode = FillMode.Winding + self.native.FillPath(self.state.brush, path.native) + + def stroke(self, path=None): if self.state.singular: # draw nothing return - for path in self.paths: - self.native.DrawPath(self.state.pen, path) + if path is None: + path = self.path + self.native.DrawPath(self.state.pen, path.native) # Transformations @@ -315,17 +411,12 @@ def reset_transform(self): # Text def write_text(self, text, x, y, font, baseline, line_height): - # Writing text should not affect current path, so save current paths - current_paths = self.paths - # new path for text - self.begin_path() - self._text_path(text, x, y, font, baseline, line_height) + # Writing text should not affect current path, so create a separate path + path = self._text_path(text, x, y, font, baseline, line_height) if self.in_fill: - self.fill(FillRule.NONZERO) + self.fill(FillRule.NONZERO, path) if self.in_stroke: - self.stroke() - # restore previous current paths - this is a bit hacky - self.paths = current_paths + self.stroke(path) def _text_path(self, text, x, y, font, baseline, line_height): lines = text.splitlines() @@ -342,8 +433,9 @@ def _text_path(self, text, x, y, font, baseline, line_height): # Default to Baseline.ALPHABETIC top = y - font.metric("CellAscent") + path = Path2D() for line_num, line in enumerate(lines): - self.current_path.AddString( + path.native.AddString( line, font.native.FontFamily, font.native.Style.value__, @@ -351,6 +443,7 @@ def _text_path(self, text, x, y, font, baseline, line_height): PointF(x, top + (scaled_line_height * line_num)), self.impl.string_format, ) + return path def draw_image(self, image, x, y, width, height): self.native.DrawImage(image._impl.native, x, y, width, height)