From 468171016173c6a38bab90d5bacaef47324db9aa Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Wed, 4 Feb 2026 07:04:08 +0000 Subject: [PATCH 01/46] Work in progress commit. --- android/src/toga_android/widgets/canvas.py | 168 ++++++++++++----- cocoa/src/toga_cocoa/libs/core_graphics.py | 71 +++++++ cocoa/src/toga_cocoa/widgets/canvas.py | 151 +++++++++++++-- gtk/src/toga_gtk/widgets/canvas.py | 126 ++++++++++++- qt/src/toga_qt/widgets/canvas.py | 185 +++++++++++++------ winforms/src/toga_winforms/widgets/canvas.py | 86 +++++++++ 6 files changed, 661 insertions(+), 126 deletions(-) diff --git a/android/src/toga_android/widgets/canvas.py b/android/src/toga_android/widgets/canvas.py index b30c8261ca..82e99e9d84 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,90 @@ BLACK = jint(native_color(rgb(0, 0, 0))) +class Path: + native: NativePath + + def __init__(self, path=None): + if path: + self.native = NativePath(path.native) + self._last_point = path._last_point + else: + self.native = NativePath() + + 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: + self.native.addPath(NativePath(path.native).transform(transform)) + self._last_point = transform.mapPoints([path._last_point])[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) + + class State(NamedTuple): fill: Paint stroke: Paint @@ -73,7 +157,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 @@ -92,34 +176,27 @@ def set_stroke_style(self, color): # Basic paths def begin_path(self): - self.path.reset() + self.path = Path() 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, @@ -132,42 +209,39 @@ 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) # 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 @@ -180,7 +254,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, @@ -198,7 +272,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) @@ -209,7 +283,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) @@ -217,7 +291,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/cocoa/src/toga_cocoa/libs/core_graphics.py b/cocoa/src/toga_cocoa/libs/core_graphics.py index f082238103..62bdb0a2ad 100644 --- a/cocoa/src/toga_cocoa/libs/core_graphics.py +++ b/cocoa/src/toga_cocoa/libs/core_graphics.py @@ -41,6 +41,16 @@ class CGAffineTransform(Structure): core_graphics.CGAffineTransformInvert.argtypes = [CGAffineTransform] core_graphics.CGAffineTransformMakeScale.restype = CGAffineTransform core_graphics.CGAffineTransformMakeScale.argtypes = [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.CGAffineTransformTranslate.restype = CGAffineTransform +core_graphics.CGAffineTransformTranslate.argtypes = [ + CGAffineTransform, + CGFloat, + CGFloat, +] ###################################################################### # CGImage.h @@ -211,6 +221,67 @@ class CGAffineTransform(Structure): core_graphics.CGContextAddPath.restype = c_void_p core_graphics.CGContextAddPath.argtypes = [CGContextRef, CGPathRef] +###################################################################### +# 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_int, +] +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, 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] + ###################################################################### # CGEvent.h diff --git a/cocoa/src/toga_cocoa/widgets/canvas.py b/cocoa/src/toga_cocoa/widgets/canvas.py index de809ba345..3be215924f 100644 --- a/cocoa/src/toga_cocoa/widgets/canvas.py +++ b/cocoa/src/toga_cocoa/widgets/canvas.py @@ -34,6 +34,104 @@ from .base import Widget +class Path: + def __init__(self, path=None): + if path is None: + self.native = core_graphics.CGPathCreateMutable() + else: + self.native = core_graphics.CGPathCreateMutableCopy(path.native) + + 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 = core_graphics.CGAffineTransformIdentity() + 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, core_graphics.CGAffineTransformIdentity(), x, y + ) + + def line_to(self, x, y): + core_graphics.CGPathAddLineToPoint( + self.native, core_graphics.CGAffineTransformIdentity(), x, y + ) + + def bezier_curve_to(self, cp1x, cp1y, cp2x, cp2y, x, y): + self._ensure_subpath(cp1x, cp1y) + core_graphics.CGPathAddCurveToPoint( + self.native, + core_graphics.CGAffineTransformIdentity(), + 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, core_graphics.CGAffineTransformIdentity(), 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, + core_graphics.CGAffineTransformIdentity(), + x, + y, + radius, + startangle, + endangle, + clockwise, + ) + + def ellipse( + self, + x, + y, + radiusx, + radiusy, + rotation, + startangle, + endangle, + counterclockwise, + ): + transform = core_graphics.CGAffineTransformTranslate( + core_graphics.CGAffineTransformRotate( + core_graphics.CGAffineTransformMakeScale(radiusx, radiusy), + rotation, + ), + x, + y, + ) + clockwise = not counterclockwise + core_graphics.CGPathAddArc( + self.native, transform, 0, 0, 1.0, startangle, endangle, clockwise + ) + + def rect(self, x, y, width, height): + rectangle = CGRectMake(x, y, width, height) + core_graphics.CGPathAddRect( + self.native, core_graphics.CGAffineTransformIdentity, rectangle + ) + + # extra utility methods + def is_empty(self): + return core_graphics.CGPathIsEmpty(self.native) + + @dataclass(slots=True) class State: # Core graphics holds onto its own state, which works great, except we need to hold @@ -127,10 +225,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 +258,48 @@ def rect(self, x, y, width, height): # 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): diff --git a/gtk/src/toga_gtk/widgets/canvas.py b/gtk/src/toga_gtk/widgets/canvas.py index 892514ad70..c2c3376d29 100644 --- a/gtk/src/toga_gtk/widgets/canvas.py +++ b/gtk/src/toga_gtk/widgets/canvas.py @@ -26,6 +26,106 @@ BLACK = native_color(rgb(0, 0, 0)) +class Path: + def __init__(self, path=None): + if path is None: + steps = [] + else: + steps = path._steps.copy() + self._steps = steps + # Cache if C-level path. + # Any change to the path invalidates it. + self._native_cached = None + + def _ensure_subpath(self, x, y): + if not self._steps: + self.move_to(x, y) + + 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 + + # extra utility methods + def is_empty(self): + return not self._steps + + def apply(self, context): + context.begin_path() + if self._native_cached: + # if we have a C-leve cache of the path, use it + context.native.add_path(self._native_cached) + else: + for method, *args in self._steps: + if method == "add_path": + path, transform = args + context.save() + try: + context.transform(transform) + path.apply(context) + finally: + context.restore() + else: + getattr(context, method)(*args) + # Cache the C-level path for reuse + self._native_cached = context.copy_path() + + @dataclass(slots=True) class State: # GTK doesn't track fill and stroke color separately. @@ -136,18 +236,30 @@ def rect(self, x, y, width, height): # 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() + self.begin_path() + path.apply(self) + self.native.fill() + self.native.add_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() + self.native.add_path(current_path) # Transformations @@ -167,6 +279,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/qt/src/toga_qt/widgets/canvas.py b/qt/src/toga_qt/widgets/canvas.py index ad14f97f7f..404a36b263 100644 --- a/qt/src/toga_qt/widgets/canvas.py +++ b/qt/src/toga_qt/widgets/canvas.py @@ -27,6 +27,99 @@ BLACK = native_color(rgb(0, 0, 0)) +class Path: + def __init__(self, path=None): + if path is None: + self.native = QPainterPath() + else: + self.native = QPainterPath(path.native) + + def _ensure_point(self, x, y): + if self.native.elementCount() == 0: + self.native.moveTo(x, y) + + def add_path(self, path, transform: QTransform | None = None): + if transform is None: + self.native.addPath(path.native) + else: + 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 arc_to(self, x1, y1, x2, y2, radius): + raise NotImplementedError() + + 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, w, h, radii): + raise NotImplementedError() + + class State: """Track transform and fill/stroke-related properties.""" @@ -58,6 +151,12 @@ def __init__(self, impl, native): def state(self): return self.states[-1] + @property + def path(self): + path = Path() + path.native = self._path + return path + # Context management def save(self): self.states.append(State(self.state)) @@ -90,43 +189,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, @@ -139,48 +219,35 @@ 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) # 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/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index ac6f0cf9c9..2bfbac93f5 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -33,6 +33,92 @@ BLACK = native_color(rgb(0, 0, 0)) +class Path: + # 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.native.PointCount: + start = self.native.PathPoints[0] + self.native.AddLine(self.native.GetLastPoint(), start) + self.move_to(start.X, start.Y) + + def move_to(self, x, y): + self.add_path(PointF(x, y)) + + def line_to(self, x, y): + self.current_path.AddLine(self.get_last_point(x, y), PointF(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), + ) + + # 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), + ) + + 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) + + 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() + + class State: """Represents a canvas state; can be saved and restored. From 633e12228657e400e90ea0e5f6953f7fb0e8fa50 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Wed, 4 Feb 2026 10:18:21 +0000 Subject: [PATCH 02/46] Initial backend implementation of Path objects. Shouldn't break anything. --- cocoa/src/toga_cocoa/libs/core_graphics.py | 123 ++++++----- iOS/src/toga_iOS/libs/core_graphics.py | 70 +++++- iOS/src/toga_iOS/widgets/canvas.py | 146 ++++++++++++- winforms/src/toga_winforms/widgets/canvas.py | 212 ++++++++----------- 4 files changed, 346 insertions(+), 205 deletions(-) diff --git a/cocoa/src/toga_cocoa/libs/core_graphics.py b/cocoa/src/toga_cocoa/libs/core_graphics.py index 62bdb0a2ad..3d10362ced 100644 --- a/cocoa/src/toga_cocoa/libs/core_graphics.py +++ b/cocoa/src/toga_cocoa/libs/core_graphics.py @@ -52,6 +52,66 @@ class CGAffineTransform(Structure): 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_int, +] +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, 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 @@ -214,74 +274,11 @@ 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 core_graphics.CGContextAddPath.argtypes = [CGContextRef, CGPathRef] -###################################################################### -# 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_int, -] -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, 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] - ###################################################################### # CGEvent.h diff --git a/iOS/src/toga_iOS/libs/core_graphics.py b/iOS/src/toga_iOS/libs/core_graphics.py index 5d9849fbbf..684d250da4 100644 --- a/iOS/src/toga_iOS/libs/core_graphics.py +++ b/iOS/src/toga_iOS/libs/core_graphics.py @@ -41,6 +41,74 @@ class CGAffineTransform(Structure): core_graphics.CGAffineTransformInvert.argtypes = [CGAffineTransform] core_graphics.CGAffineTransformMakeScale.restype = CGAffineTransform core_graphics.CGAffineTransformMakeScale.argtypes = [CGFloat, CGFloat] +core_graphics.CGAffineTransformRotate.restype = CGAffineTransform +core_graphics.CGAffineTransformRotate.argtypes = [CGAffineTransform, 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_int, +] +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, 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 +250,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 353a37a14c..07b8fef5a3 100644 --- a/iOS/src/toga_iOS/widgets/canvas.py +++ b/iOS/src/toga_iOS/widgets/canvas.py @@ -42,6 +42,104 @@ from toga_iOS.widgets.base import Widget +class Path: + def __init__(self, path=None): + if path is None: + self.native = core_graphics.CGPathCreateMutable() + else: + self.native = core_graphics.CGPathCreateMutableCopy(path.native) + + 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 = core_graphics.CGAffineTransformIdentity() + 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, core_graphics.CGAffineTransformIdentity(), x, y + ) + + def line_to(self, x, y): + core_graphics.CGPathAddLineToPoint( + self.native, core_graphics.CGAffineTransformIdentity(), x, y + ) + + def bezier_curve_to(self, cp1x, cp1y, cp2x, cp2y, x, y): + self._ensure_subpath(cp1x, cp1y) + core_graphics.CGPathAddCurveToPoint( + self.native, + core_graphics.CGAffineTransformIdentity(), + 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, core_graphics.CGAffineTransformIdentity(), 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, + core_graphics.CGAffineTransformIdentity(), + x, + y, + radius, + startangle, + endangle, + clockwise, + ) + + def ellipse( + self, + x, + y, + radiusx, + radiusy, + rotation, + startangle, + endangle, + counterclockwise, + ): + transform = core_graphics.CGAffineTransformTranslate( + core_graphics.CGAffineTransformRotate( + core_graphics.CGAffineTransformMakeScale(radiusx, radiusy), + rotation, + ), + x, + y, + ) + clockwise = not counterclockwise + core_graphics.CGPathAddArc( + self.native, transform, 0, 0, 1.0, startangle, endangle, clockwise + ) + + def rect(self, x, y, width, height): + rectangle = CGRectMake(x, y, width, height) + core_graphics.CGPathAddRect( + self.native, core_graphics.CGAffineTransformIdentity, rectangle + ) + + # extra utility methods + def is_empty(self): + return core_graphics.CGPathIsEmpty(self.native) + + @dataclass(slots=True) class State: # Core graphics holds onto its own state, which works great, except we need to hold @@ -186,23 +284,49 @@ def rect(self, x, y, width, height): core_graphics.CGContextAddRect(self.native, rectangle) # 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/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index 2bfbac93f5..4580a9fde2 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -34,38 +34,57 @@ class Path: - # 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 __init__(self, path=None): + if path is None: + self.native = GraphicsPath() + self._subpath_start = None + else: + self.native = GraphicsPath(path.native.PathPoints, path.native.PathTypes) + self._subpath_start = path._subpath_start + + def _ensure_path(self, x, y): + if self._subpath_start is None: + self.move_to(x, y) + + @property + def last_point(self): + last_point = self.native.GetLastPoint() + if last_point.IsEmpty(): + return self._subpath_start + else: + return last_point + def close_path(self): - if self.native.PointCount: - start = self.native.PathPoints[0] - self.native.AddLine(self.native.GetLastPoint(), start) - self.move_to(start.X, start.Y) + self.native.CloseFigure() def move_to(self, x, y): - self.add_path(PointF(x, y)) + if not self.native.GetLastPoint().IsEmpty(): + self.native.StartFigure() + self._subpath_start = PointF(x, y) def line_to(self, x, y): - self.current_path.AddLine(self.get_last_point(x, y), PointF(x, y)) + self._ensure_path(x, y) + self.native.AddLine(self.last_point, PointF(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), + self._ensure_path(cp1x, cp1y) + self.native.AddBezier( + self.last_point, PointF(cp1x, cp1y), PointF(cp2x, cp2y), PointF(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, + # 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), @@ -107,16 +126,14 @@ def ellipse( ) 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) + start = self._subpath_start + if start and self.native.GetLastPoint().IsEmpty(): + self.native.AddLine(start, start) + self.native.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.native.AddRectangle(rect) class State: @@ -167,36 +184,13 @@ 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.start_point = points[0] + self.path._subpath_start = points[0] # Context management @@ -230,54 +224,27 @@ def set_stroke_style(self, color): # Basic paths def begin_path(self): - self.paths = [] - self.add_path() + self.path = Path() - # 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, @@ -290,52 +257,42 @@ 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) # 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 @@ -398,17 +355,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() @@ -425,8 +377,9 @@ def _text_path(self, text, x, y, font, baseline, line_height): # Default to Baseline.ALPHABETIC top = y - font.metric("CellAscent") + path = Path() for line_num, line in enumerate(lines): - self.current_path.AddString( + path.native.AddString( line, font.native.FontFamily, font.native.Style.value__, @@ -434,6 +387,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) From b8affe4c0e06a2b2a23c47e407e26ea63e0ee442 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Wed, 4 Feb 2026 10:22:54 +0000 Subject: [PATCH 03/46] Add change log message. --- changes/4163.feature.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/4163.feature.md 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. From b6e8ea0614ecc1dbf8a3c8ed2a68182e3051fc70 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Wed, 4 Feb 2026 10:39:19 +0000 Subject: [PATCH 04/46] Fixes for Winforms. --- winforms/src/toga_winforms/widgets/canvas.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index 4580a9fde2..58e7f00db0 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -49,7 +49,7 @@ def _ensure_path(self, x, y): @property def last_point(self): last_point = self.native.GetLastPoint() - if last_point.IsEmpty(): + if last_point.IsEmpty: return self._subpath_start else: return last_point @@ -58,7 +58,7 @@ def close_path(self): self.native.CloseFigure() def move_to(self, x, y): - if not self.native.GetLastPoint().IsEmpty(): + if not self.native.GetLastPoint().IsEmpty: self.native.StartFigure() self._subpath_start = PointF(x, y) @@ -127,7 +127,7 @@ def ellipse( matrix.TransformPoints(points) start = self._subpath_start - if start and self.native.GetLastPoint().IsEmpty(): + if start and self.native.GetLastPoint().IsEmpty: self.native.AddLine(start, start) self.native.AddBeziers(points) From a7300061bcf194c34cb6890cd7766a7275d36943 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Wed, 4 Feb 2026 15:03:14 +0000 Subject: [PATCH 05/46] Initial working examples on Cocoa and Qt. --- cocoa/src/toga_cocoa/factory.py | 3 +- cocoa/src/toga_cocoa/libs/core_graphics.py | 20 +- cocoa/src/toga_cocoa/widgets/canvas.py | 40 +-- core/src/toga/widgets/canvas/__init__.py | 3 + core/src/toga/widgets/canvas/drawingaction.py | 16 +- core/src/toga/widgets/canvas/path.py | 263 ++++++++++++++++++ core/src/toga/widgets/canvas/state.py | 7 +- qt/src/toga_qt/factory.py | 3 +- qt/src/toga_qt/widgets/canvas.py | 3 +- .../testbed/resources/canvas/path_object.png | Bin 0 -> 15697 bytes testbed/tests/widgets/test_canvas.py | 34 +++ 11 files changed, 357 insertions(+), 35 deletions(-) create mode 100644 core/src/toga/widgets/canvas/path.py create mode 100644 testbed/src/testbed/resources/canvas/path_object.png diff --git a/cocoa/src/toga_cocoa/factory.py b/cocoa/src/toga_cocoa/factory.py index a28ec9f846..ccf2ecb3ef 100644 --- a/cocoa/src/toga_cocoa/factory.py +++ b/cocoa/src/toga_cocoa/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, Path from .widgets.dateinput import DateInput from .widgets.detailedlist import DetailedList from .widgets.divider import Divider @@ -76,6 +76,7 @@ def not_implemented(feature): "NumberInput", "OptionContainer", "PasswordInput", + "Path", "ProgressBar", "ScrollContainer", "Selection", diff --git a/cocoa/src/toga_cocoa/libs/core_graphics.py b/cocoa/src/toga_cocoa/libs/core_graphics.py index 3d10362ced..2770207691 100644 --- a/cocoa/src/toga_cocoa/libs/core_graphics.py +++ b/cocoa/src/toga_cocoa/libs/core_graphics.py @@ -39,12 +39,21 @@ class CGAffineTransform(Structure): core_graphics.CGAffineTransformIdentity = CGAffineTransform core_graphics.CGAffineTransformInvert.restype = CGAffineTransform core_graphics.CGAffineTransformInvert.argtypes = [CGAffineTransform] -core_graphics.CGAffineTransformMakeScale.restype = CGAffineTransform -core_graphics.CGAffineTransformMakeScale.argtypes = [CGFloat, CGFloat] +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, @@ -86,7 +95,12 @@ class CGAffineTransform(Structure): CGFloat, ] core_graphics.CGPathAddLineToPoint.restype = c_void_p -core_graphics.CGPathAddLineToPoint.argtypes = [CGMutablePathRef, CGFloat, CGFloat] +core_graphics.CGPathAddLineToPoint.argtypes = [ + CGMutablePathRef, + CGAffineTransform, + CGFloat, + CGFloat, +] core_graphics.CGPathAddQuadCurveToPoint.restype = c_void_p core_graphics.CGPathAddQuadCurveToPoint.argtypes = [ CGMutablePathRef, diff --git a/cocoa/src/toga_cocoa/widgets/canvas.py b/cocoa/src/toga_cocoa/widgets/canvas.py index 3be215924f..f4207d529e 100644 --- a/cocoa/src/toga_cocoa/widgets/canvas.py +++ b/cocoa/src/toga_cocoa/widgets/canvas.py @@ -33,6 +33,8 @@ from .base import Widget +IDENTITY = core_graphics.CGAffineTransformMake(1, 0, 0, 1, 0, 0) + class Path: def __init__(self, path=None): @@ -47,27 +49,25 @@ def _ensure_subpath(self, x, y): def add_path(self, path, transform=None): if transform is None: - transform = core_graphics.CGAffineTransformIdentity() + 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, core_graphics.CGAffineTransformIdentity(), x, y - ) + core_graphics.CGPathMoveToPoint(self.native, IDENTITY, x, y) def line_to(self, x, y): - core_graphics.CGPathAddLineToPoint( - self.native, core_graphics.CGAffineTransformIdentity(), 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, - core_graphics.CGAffineTransformIdentity(), + IDENTITY, cp1x, cp1y, cp2x, @@ -78,9 +78,7 @@ def bezier_curve_to(self, 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, core_graphics.CGAffineTransformIdentity(), cpx, cpy, x, y - ) + 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 @@ -88,7 +86,7 @@ def arc(self, x, y, radius, startangle, endangle, counterclockwise): clockwise = counterclockwise core_graphics.CGPathAddArc( self.native, - core_graphics.CGAffineTransformIdentity(), + IDENTITY, x, y, radius, @@ -108,24 +106,16 @@ def ellipse( endangle, counterclockwise, ): - transform = core_graphics.CGAffineTransformTranslate( - core_graphics.CGAffineTransformRotate( - core_graphics.CGAffineTransformMakeScale(radiusx, radiusy), - rotation, - ), - x, - y, - ) - clockwise = not 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, clockwise + 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, core_graphics.CGAffineTransformIdentity, rectangle - ) + core_graphics.CGPathAddRect(self.native, IDENTITY, rectangle) # extra utility methods def is_empty(self): diff --git a/core/src/toga/widgets/canvas/__init__.py b/core/src/toga/widgets/canvas/__init__.py index 4d606afaf4..5b98baf699 100644 --- a/core/src/toga/widgets/canvas/__init__.py +++ b/core/src/toga/widgets/canvas/__init__.py @@ -21,6 +21,7 @@ WriteText, ) from .geometry import arc_to_bezier, sweepangle +from .path import Path from .state import ClosedPathContext, FillContext, State, StrokeContext # Make sure deprecation warnings are shown by default @@ -67,6 +68,8 @@ def __getattr__(name): "Stroke", "Translate", "WriteText", + # Path-related + "Path", # States "ClosedPathContext", "State", diff --git a/core/src/toga/widgets/canvas/drawingaction.py b/core/src/toga/widgets/canvas/drawingaction.py index 3875138f3a..3befd34c82 100644 --- a/core/src/toga/widgets/canvas/drawingaction.py +++ b/core/src/toga/widgets/canvas/drawingaction.py @@ -19,6 +19,8 @@ if TYPE_CHECKING: from toga.colors import ColorT + from .path import Path + # Make sure deprecation warnings are shown by default filterwarnings("default", category=DeprecationWarning) @@ -143,12 +145,17 @@ def _draw(self, context: Any) -> None: class Fill(DrawingAction): color: ColorT | None = color_property() fill_rule: FillRule = FillRule.NONZERO + path: Path | None = None def _draw(self, context: Any) -> None: context.save() if self.color is not None: context.set_fill_style(self.color) - context.fill(self.fill_rule) + if self.path is None: + path_impl = None + else: + path_impl = self.path.impl + context.fill(self.fill_rule, path_impl) context.restore() @@ -157,6 +164,7 @@ class Stroke(DrawingAction): color: ColorT | None = color_property() line_width: float | None = None line_dash: list[float] | None = None + path: Path | None = None def _draw(self, context: Any) -> None: context.save() @@ -166,7 +174,11 @@ def _draw(self, context: Any) -> None: context.set_line_width(self.line_width) if self.line_dash is not None: context.set_line_dash(self.line_dash) - context.stroke() + if self.path is None: + path_impl = None + else: + path_impl = self.path.impl + context.stroke(path_impl) context.restore() diff --git a/core/src/toga/widgets/canvas/path.py b/core/src/toga/widgets/canvas/path.py new file mode 100644 index 0000000000..f2721866c4 --- /dev/null +++ b/core/src/toga/widgets/canvas/path.py @@ -0,0 +1,263 @@ +import copy +from collections.abc import Sequence +from dataclasses import dataclass +from math import pi +from typing import Self + +from toga.platform import get_platform_factory + +from .drawingaction import ( + Arc, + BezierCurveTo, + ClosePath, + DrawingAction, + Ellipse, + LineTo, + MoveTo, + QuadraticCurveTo, + Rect, +) + + +class Path: + def __init__(self, path: Self | None = None): + if path is None: + self.drawing_actions = [] + else: + self.drawing_actions = copy.deepcopy(path.drawing_actions) + self._action_target = self + self._impl = None + self.factory = get_platform_factory() + + @property + def impl(self): + if self._impl is None: + self._impl = self.compile() + return self._impl + + def add_path(self, path: Self, transform: Sequence[float] | None = None): + """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._redraw_with_warning_if_state() + 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. + """ + close_path = ClosePath() + self._action_target.drawing_actions.append(close_path) + self._redraw_with_warning_if_state() + 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._redraw_with_warning_if_state() + 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._redraw_with_warning_if_state() + 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._redraw_with_warning_if_state() + 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._redraw_with_warning_if_state() + 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._redraw_with_warning_if_state() + 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._redraw_with_warning_if_state() + 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._redraw_with_warning_if_state() + return rect + + def _redraw_with_warning_if_state(self): + pass + + def compile(self): + print("compiling") + impl = self.factory.Path() + for action in self.drawing_actions: + print(action) + action._draw(impl) + return impl + + +@dataclass(repr=False) +class AddPath(DrawingAction): + path: Path + 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 cc4a84905b..2bdde8f834 100644 --- a/core/src/toga/widgets/canvas/state.py +++ b/core/src/toga/widgets/canvas/state.py @@ -38,6 +38,7 @@ from toga.colors import ColorT from .canvas import Canvas + from .path import Path # Make sure deprecation warnings are shown by default warnings.filterwarnings("default", category=DeprecationWarning) @@ -254,6 +255,7 @@ def fill( self, color: ColorT | None = None, fill_rule: FillRule = FillRule.NONZERO, + path: Path | None = None, ) -> Fill: """Fill the current path. @@ -268,7 +270,7 @@ def fill( :returns: The `Fill` [`DrawingAction`][toga.widgets.canvas.DrawingAction] for the operation. """ - fill = Fill(color, fill_rule) + fill = Fill(color, fill_rule, path) self._action_target.append(fill) return fill @@ -277,6 +279,7 @@ def stroke( color: ColorT | None = None, line_width: float | None = None, line_dash: list[float] | None = None, + path: Path | None = None, ) -> Stroke: """Draw the current path as a stroke. @@ -287,7 +290,7 @@ def stroke( :returns: The `Stroke` [`DrawingAction`][toga.widgets.canvas.DrawingAction] for the operation. """ - stroke = Stroke(color, line_width, line_dash) + stroke = Stroke(color, line_width, line_dash, path) self._action_target.append(stroke) return stroke diff --git a/qt/src/toga_qt/factory.py b/qt/src/toga_qt/factory.py index 5ee8667681..e81c711d18 100644 --- a/qt/src/toga_qt/factory.py +++ b/qt/src/toga_qt/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, Path from .widgets.dateinput import DateInput from .widgets.detailedlist import DetailedList from .widgets.divider import Divider @@ -71,6 +71,7 @@ "NumberInput", "OptionContainer", "PasswordInput", + "Path", "ProgressBar", "SplitContainer", "Selection", diff --git a/qt/src/toga_qt/widgets/canvas.py b/qt/src/toga_qt/widgets/canvas.py index 404a36b263..1f453bbb92 100644 --- a/qt/src/toga_qt/widgets/canvas.py +++ b/qt/src/toga_qt/widgets/canvas.py @@ -38,10 +38,11 @@ def _ensure_point(self, x, y): if self.native.elementCount() == 0: self.native.moveTo(x, y) - def add_path(self, path, transform: QTransform | None = None): + 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): 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 0000000000000000000000000000000000000000..b2f1f689ff3426947f61eb3e5415a1dcb4bf53b1 GIT binary patch literal 15697 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 Date: Wed, 4 Feb 2026 16:49:35 +0000 Subject: [PATCH 06/46] Get core test back into working order. --- core/src/toga/widgets/canvas/path.py | 5 +- .../widgets/canvas/test_draw_operations.py | 108 ++++++++++++------ .../widgets/canvas/test_state_objects.py | 12 +- dummy/src/toga_dummy/widgets/canvas.py | 10 +- 4 files changed, 86 insertions(+), 49 deletions(-) diff --git a/core/src/toga/widgets/canvas/path.py b/core/src/toga/widgets/canvas/path.py index f2721866c4..1932a7757e 100644 --- a/core/src/toga/widgets/canvas/path.py +++ b/core/src/toga/widgets/canvas/path.py @@ -2,7 +2,6 @@ from collections.abc import Sequence from dataclasses import dataclass from math import pi -from typing import Self from toga.platform import get_platform_factory @@ -20,7 +19,7 @@ class Path: - def __init__(self, path: Self | None = None): + def __init__(self, path: "Path | None" = None): if path is None: self.drawing_actions = [] else: @@ -35,7 +34,7 @@ def impl(self): self._impl = self.compile() return self._impl - def add_path(self, path: Self, transform: Sequence[float] | None = None): + def add_path(self, path: "Path", transform: Sequence[float] | None = None): """Adds another path to the current path with an optional transform. :param path: The Path being added. diff --git a/core/tests/widgets/canvas/test_draw_operations.py b/core/tests/widgets/canvas/test_draw_operations.py index 967ab2f5ad..f75d5e3203 100644 --- a/core/tests/widgets/canvas/test_draw_operations.py +++ b/core/tests/widgets/canvas/test_draw_operations.py @@ -41,60 +41,72 @@ def test_close_path(widget): # Defaults ( {}, - "color=None, fill_rule=FillRule.NONZERO", - [("fill", {"fill_rule": FillRule.NONZERO})], - {"color": None, "fill_rule": FillRule.NONZERO}, + "color=None, fill_rule=FillRule.NONZERO, path=None", + [("fill", {"fill_rule": FillRule.NONZERO, "path": None})], + {"color": None, "fill_rule": FillRule.NONZERO, "path": None}, ), # Color as string name ( {"color": REBECCAPURPLE}, - f"color={REBECCA_PURPLE_COLOR!r}, fill_rule=FillRule.NONZERO", + f"color={REBECCA_PURPLE_COLOR!r}, fill_rule=FillRule.NONZERO, path=None", [ ("set fill style", REBECCA_PURPLE_COLOR), - ("fill", {"fill_rule": FillRule.NONZERO}), + ("fill", {"fill_rule": FillRule.NONZERO, "path": None}), ], - {"color": REBECCA_PURPLE_COLOR, "fill_rule": FillRule.NONZERO}, + { + "color": REBECCA_PURPLE_COLOR, + "fill_rule": FillRule.NONZERO, + "path": None, + }, ), # Color as RGB object ( {"color": REBECCA_PURPLE_COLOR}, - f"color={REBECCA_PURPLE_COLOR!r}, fill_rule=FillRule.NONZERO", + f"color={REBECCA_PURPLE_COLOR!r}, fill_rule=FillRule.NONZERO, path=None", [ ("set fill style", REBECCA_PURPLE_COLOR), - ("fill", {"fill_rule": FillRule.NONZERO}), + ("fill", {"fill_rule": FillRule.NONZERO, "path": None}), ], - {"color": REBECCA_PURPLE_COLOR, "fill_rule": FillRule.NONZERO}, + { + "color": REBECCA_PURPLE_COLOR, + "fill_rule": FillRule.NONZERO, + "path": None, + }, ), # Color explicitly not set ( {"color": None}, - "color=None, fill_rule=FillRule.NONZERO", - [("fill", {"fill_rule": FillRule.NONZERO})], - {"color": None, "fill_rule": FillRule.NONZERO}, + "color=None, fill_rule=FillRule.NONZERO, path=None", + [("fill", {"fill_rule": FillRule.NONZERO, "path": None})], + {"color": None, "fill_rule": FillRule.NONZERO, "path": None}, ), # Explicit Non-Zero winding ( {"fill_rule": FillRule.NONZERO}, - "color=None, fill_rule=FillRule.NONZERO", - [("fill", {"fill_rule": FillRule.NONZERO})], - {"color": None, "fill_rule": FillRule.NONZERO}, + "color=None, fill_rule=FillRule.NONZERO, path=None", + [("fill", {"fill_rule": FillRule.NONZERO, "path": None})], + {"color": None, "fill_rule": FillRule.NONZERO, "path": None}, ), # Even-Odd winding ( {"fill_rule": FillRule.EVENODD}, - "color=None, fill_rule=FillRule.EVENODD", - [("fill", {"fill_rule": FillRule.EVENODD})], - {"color": None, "fill_rule": FillRule.EVENODD}, + "color=None, fill_rule=FillRule.EVENODD, path=None", + [("fill", {"fill_rule": FillRule.EVENODD, "path": None})], + {"color": None, "fill_rule": FillRule.EVENODD, "path": None}, ), # All args ( {"color": REBECCAPURPLE, "fill_rule": FillRule.EVENODD}, - f"color={REBECCA_PURPLE_COLOR!r}, fill_rule=FillRule.EVENODD", + f"color={REBECCA_PURPLE_COLOR!r}, fill_rule=FillRule.EVENODD, path=None", [ ("set fill style", REBECCA_PURPLE_COLOR), - ("fill", {"fill_rule": FillRule.EVENODD}), + ("fill", {"fill_rule": FillRule.EVENODD, "path": None}), ], - {"color": REBECCA_PURPLE_COLOR, "fill_rule": FillRule.EVENODD}, + { + "color": REBECCA_PURPLE_COLOR, + "fill_rule": FillRule.EVENODD, + "path": None, + }, ), ], ) @@ -120,55 +132,79 @@ def test_fill(widget, kwargs, args_repr, draw_objs, attrs): # Defaults ( {}, - "color=None, line_width=None, line_dash=None", + "color=None, line_width=None, line_dash=None, path=None", [], - {"color": None, "line_width": None, "line_dash": None}, + {"color": None, "line_width": None, "line_dash": None, "path": None}, ), # Color as string name ( {"color": REBECCAPURPLE}, - f"color={REBECCA_PURPLE_COLOR!r}, line_width=None, line_dash=None", + ( + f"color={REBECCA_PURPLE_COLOR!r}, line_width=None, line_dash=None, " + "path=None" + ), [("set stroke style", REBECCA_PURPLE_COLOR)], - {"color": REBECCA_PURPLE_COLOR, "line_width": None, "line_dash": None}, + { + "color": REBECCA_PURPLE_COLOR, + "line_width": None, + "line_dash": None, + "path": None, + }, ), # Color as RGB object ( {"color": REBECCA_PURPLE_COLOR}, - f"color={REBECCA_PURPLE_COLOR!r}, line_width=None, line_dash=None", + ( + f"color={REBECCA_PURPLE_COLOR!r}, line_width=None, line_dash=None, " + "path=None" + ), [("set stroke style", REBECCA_PURPLE_COLOR)], - {"color": REBECCA_PURPLE_COLOR, "line_width": None, "line_dash": None}, + { + "color": REBECCA_PURPLE_COLOR, + "line_width": None, + "line_dash": None, + "path": None, + }, ), # Color explicitly not set ( {"color": None}, - "color=None, line_width=None, line_dash=None", + "color=None, line_width=None, line_dash=None, path=None", [], - {"color": None, "line_width": None, "line_dash": None}, + {"color": None, "line_width": None, "line_dash": None, "path": None}, ), # Line width ( {"line_width": 4.5}, - "color=None, line_width=4.500, line_dash=None", + "color=None, line_width=4.500, line_dash=None, path=None", [("set line width", 4.5)], - {"color": None, "line_width": 4.5, "line_dash": None}, + {"color": None, "line_width": 4.5, "line_dash": None, "path": None}, ), # Line dash ( {"line_dash": [2, 7]}, - "color=None, line_width=None, line_dash=[2, 7]", + "color=None, line_width=None, line_dash=[2, 7], path=None", [("set line dash", [2, 7])], - {"color": None, "line_width": None, "line_dash": [2, 7]}, + {"color": None, "line_width": None, "line_dash": [2, 7], "path": None}, ), # All args ( {"color": REBECCAPURPLE, "line_width": 4.5, "line_dash": [2, 7]}, - f"color={REBECCA_PURPLE_COLOR!r}, line_width=4.500, line_dash=[2, 7]", + ( + f"color={REBECCA_PURPLE_COLOR!r}, line_width=4.500, line_dash=[2, 7], " + "path=None" + ), [ ("set stroke style", REBECCA_PURPLE_COLOR), ("set line width", 4.5), ("set line dash", [2, 7]), ], - {"color": REBECCA_PURPLE_COLOR, "line_width": 4.5, "line_dash": [2, 7]}, + { + "color": REBECCA_PURPLE_COLOR, + "line_width": 4.5, + "line_dash": [2, 7], + "path": None, + }, ), ], ) @@ -184,7 +220,7 @@ def test_stroke(widget, kwargs, args_repr, draw_objs, attrs): assert widget._impl.draw_instructions[1:-1] == [ "save", *draw_objs, - "stroke", + ("stroke", {"path": None}), "restore", ] diff --git a/core/tests/widgets/canvas/test_state_objects.py b/core/tests/widgets/canvas/test_state_objects.py index 7a3e191066..dfc7daccbf 100644 --- a/core/tests/widgets/canvas/test_state_objects.py +++ b/core/tests/widgets/canvas/test_state_objects.py @@ -203,7 +203,7 @@ def test_fill(widget, kwargs, args_repr, has_move, properties): "begin path", ("move to", {"x": properties["x"], "y": properties["y"]}) if has_move else None, ("line to", {"x": 30, "y": 40}), - ("fill", {"fill_rule": properties["fill_rule"]}), + ("fill", {"fill_rule": properties["fill_rule"], "path": None}), "restore", ] @@ -373,7 +373,7 @@ def test_stroke(widget, kwargs, args_repr, has_move, properties): "begin path", ("move to", {"x": properties["x"], "y": properties["y"]}) if has_move else None, ("line to", {"x": 30, "y": 40}), - "stroke", + ("stroke", {"path": None}), "restore", ] @@ -415,7 +415,7 @@ def test_order_change(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}), @@ -448,7 +448,7 @@ def test_order_change(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}), @@ -476,7 +476,7 @@ def test_order_change(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}), @@ -523,7 +523,7 @@ def test_order_change(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/dummy/src/toga_dummy/widgets/canvas.py b/dummy/src/toga_dummy/widgets/canvas.py index c5c167df1f..015d56eef5 100644 --- a/dummy/src/toga_dummy/widgets/canvas.py +++ b/dummy/src/toga_dummy/widgets/canvas.py @@ -118,11 +118,13 @@ def rect(self, x, y, width, height): ) # 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 From 18dee6ada5350f070bd07e8305559c998b3bb16c Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Wed, 4 Feb 2026 17:01:57 +0000 Subject: [PATCH 07/46] Do pragma no cover for now while we work out what API should look like. --- core/src/toga/widgets/canvas/drawingaction.py | 4 ++-- core/src/toga/widgets/canvas/path.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/core/src/toga/widgets/canvas/drawingaction.py b/core/src/toga/widgets/canvas/drawingaction.py index 3befd34c82..900cb28c69 100644 --- a/core/src/toga/widgets/canvas/drawingaction.py +++ b/core/src/toga/widgets/canvas/drawingaction.py @@ -154,7 +154,7 @@ def _draw(self, context: Any) -> None: if self.path is None: path_impl = None else: - path_impl = self.path.impl + path_impl = self.path.impl # pragma: no cover context.fill(self.fill_rule, path_impl) context.restore() @@ -177,7 +177,7 @@ def _draw(self, context: Any) -> None: if self.path is None: path_impl = None else: - path_impl = self.path.impl + path_impl = self.path.impl # pragma: no cover context.stroke(path_impl) context.restore() diff --git a/core/src/toga/widgets/canvas/path.py b/core/src/toga/widgets/canvas/path.py index 1932a7757e..2634eb2c7a 100644 --- a/core/src/toga/widgets/canvas/path.py +++ b/core/src/toga/widgets/canvas/path.py @@ -1,3 +1,4 @@ +# pragma: no cover import copy from collections.abc import Sequence from dataclasses import dataclass From d60628bda6d79d58c1075cf25b66b926ca7e870b Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 5 Feb 2026 07:41:17 +0000 Subject: [PATCH 08/46] Add round rects to paths. --- android/src/toga_android/widgets/canvas.py | 5 ++++- cocoa/src/toga_cocoa/widgets/canvas.py | 3 +++ gtk/src/toga_gtk/widgets/canvas.py | 3 +++ iOS/src/toga_iOS/widgets/canvas.py | 3 +++ qt/src/toga_qt/widgets/canvas.py | 6 +++--- winforms/src/toga_winforms/widgets/canvas.py | 5 ++++- 6 files changed, 20 insertions(+), 5 deletions(-) diff --git a/android/src/toga_android/widgets/canvas.py b/android/src/toga_android/widgets/canvas.py index f97cf70ae9..f7c615e058 100644 --- a/android/src/toga_android/widgets/canvas.py +++ b/android/src/toga_android/widgets/canvas.py @@ -110,6 +110,9 @@ 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 @@ -224,7 +227,7 @@ def rect(self, 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 diff --git a/cocoa/src/toga_cocoa/widgets/canvas.py b/cocoa/src/toga_cocoa/widgets/canvas.py index e14808de6f..4dad2d2049 100644 --- a/cocoa/src/toga_cocoa/widgets/canvas.py +++ b/cocoa/src/toga_cocoa/widgets/canvas.py @@ -118,6 +118,9 @@ 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) diff --git a/gtk/src/toga_gtk/widgets/canvas.py b/gtk/src/toga_gtk/widgets/canvas.py index e7d9c18185..832dc8da5a 100644 --- a/gtk/src/toga_gtk/widgets/canvas.py +++ b/gtk/src/toga_gtk/widgets/canvas.py @@ -102,6 +102,9 @@ 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): + round_rect(self, x, y, width, height, radii) + # extra utility methods def is_empty(self): return not self._steps diff --git a/iOS/src/toga_iOS/widgets/canvas.py b/iOS/src/toga_iOS/widgets/canvas.py index 54202f44f6..b720c67807 100644 --- a/iOS/src/toga_iOS/widgets/canvas.py +++ b/iOS/src/toga_iOS/widgets/canvas.py @@ -136,6 +136,9 @@ def rect(self, x, y, width, height): self.native, core_graphics.CGAffineTransformIdentity, 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) diff --git a/qt/src/toga_qt/widgets/canvas.py b/qt/src/toga_qt/widgets/canvas.py index c1e4552d52..97ad099a49 100644 --- a/qt/src/toga_qt/widgets/canvas.py +++ b/qt/src/toga_qt/widgets/canvas.py @@ -117,8 +117,8 @@ def ellipse( def rect(self, x, y, width, height): self.native.addRect(x, y, width, height) - def round_rect(self, x, y, w, h, radii): - raise NotImplementedError() + def round_rect(self, x, y, width, height, radii): + round_rect(self, x, y, width, height, radii) class State: @@ -235,7 +235,7 @@ def rect(self, 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 diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index a54235f8bf..c5468f6949 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -135,6 +135,9 @@ def rect(self, x, y, width, height): rect = RectangleF(x, y, width, height) self.native.AddRectangle(rect) + def round_rect(self, x, y, width, height, radii): + round_rect(self, x, y, width, height, radii) + class State: """Represents a canvas state; can be saved and restored. @@ -272,7 +275,7 @@ def rect(self, 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 From add17713aa247bf31c1ae452c40f739dfddae576 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 5 Feb 2026 09:40:39 +0000 Subject: [PATCH 09/46] Rename Path to Path2D; add core tests; expose implementations. --- android/src/toga_android/factory.py | 3 +- android/src/toga_android/widgets/canvas.py | 6 +- cocoa/src/toga_cocoa/factory.py | 4 +- cocoa/src/toga_cocoa/widgets/canvas.py | 2 +- core/src/toga/widgets/canvas/__init__.py | 4 +- core/src/toga/widgets/canvas/drawingaction.py | 6 +- core/src/toga/widgets/canvas/path.py | 60 ++- core/src/toga/widgets/canvas/state.py | 6 +- core/tests/widgets/canvas/test_path.py | 487 ++++++++++++++++++ dummy/src/toga_dummy/factory.py | 3 +- dummy/src/toga_dummy/widgets/canvas.py | 104 ++++ gtk/src/toga_gtk/factory.py | 3 +- gtk/src/toga_gtk/widgets/canvas.py | 2 +- iOS/src/toga_iOS/factory.py | 3 +- iOS/src/toga_iOS/widgets/canvas.py | 2 +- qt/src/toga_qt/factory.py | 4 +- qt/src/toga_qt/widgets/canvas.py | 4 +- testbed/tests/widgets/test_canvas.py | 6 +- winforms/src/toga_winforms/factory.py | 3 +- winforms/src/toga_winforms/widgets/canvas.py | 6 +- 20 files changed, 678 insertions(+), 40 deletions(-) create mode 100644 core/tests/widgets/canvas/test_path.py diff --git a/android/src/toga_android/factory.py b/android/src/toga_android/factory.py index c7f16a0671..c7341b2be6 100644 --- a/android/src/toga_android/factory.py +++ b/android/src/toga_android/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 @@ -72,6 +72,7 @@ def not_implemented(feature): "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 f7c615e058..c3b4d27239 100644 --- a/android/src/toga_android/widgets/canvas.py +++ b/android/src/toga_android/widgets/canvas.py @@ -27,7 +27,7 @@ BLACK = jint(native_color(rgb(0, 0, 0))) -class Path: +class Path2D: native: NativePath def __init__(self, path=None): @@ -127,7 +127,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 @@ -179,7 +179,7 @@ def set_stroke_style(self, color): # Basic paths def begin_path(self): - self.path = Path() + self.path = Path2D() def close_path(self): self.path.close_path() diff --git a/cocoa/src/toga_cocoa/factory.py b/cocoa/src/toga_cocoa/factory.py index ccf2ecb3ef..6c4ad49d9d 100644 --- a/cocoa/src/toga_cocoa/factory.py +++ b/cocoa/src/toga_cocoa/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, Path +from .widgets.canvas import Canvas, Path2D from .widgets.dateinput import DateInput from .widgets.detailedlist import DetailedList from .widgets.divider import Divider @@ -76,7 +76,7 @@ def not_implemented(feature): "NumberInput", "OptionContainer", "PasswordInput", - "Path", + "Path2D", "ProgressBar", "ScrollContainer", "Selection", diff --git a/cocoa/src/toga_cocoa/widgets/canvas.py b/cocoa/src/toga_cocoa/widgets/canvas.py index 4dad2d2049..37df097ce9 100644 --- a/cocoa/src/toga_cocoa/widgets/canvas.py +++ b/cocoa/src/toga_cocoa/widgets/canvas.py @@ -37,7 +37,7 @@ IDENTITY = core_graphics.CGAffineTransformMake(1, 0, 0, 1, 0, 0) -class Path: +class Path2D: def __init__(self, path=None): if path is None: self.native = core_graphics.CGPathCreateMutable() diff --git a/core/src/toga/widgets/canvas/__init__.py b/core/src/toga/widgets/canvas/__init__.py index 5b98baf699..0663b22c3a 100644 --- a/core/src/toga/widgets/canvas/__init__.py +++ b/core/src/toga/widgets/canvas/__init__.py @@ -21,7 +21,7 @@ WriteText, ) from .geometry import arc_to_bezier, sweepangle -from .path import Path +from .path import Path2D from .state import ClosedPathContext, FillContext, State, StrokeContext # Make sure deprecation warnings are shown by default @@ -69,7 +69,7 @@ def __getattr__(name): "Translate", "WriteText", # Path-related - "Path", + "Path2D", # States "ClosedPathContext", "State", diff --git a/core/src/toga/widgets/canvas/drawingaction.py b/core/src/toga/widgets/canvas/drawingaction.py index 22dbf49b76..2e8bd41b00 100644 --- a/core/src/toga/widgets/canvas/drawingaction.py +++ b/core/src/toga/widgets/canvas/drawingaction.py @@ -22,7 +22,7 @@ if TYPE_CHECKING: from toga.colors import ColorT - from .path import Path + from .path import Path2D # Make sure deprecation warnings are shown by default filterwarnings("default", category=DeprecationWarning) @@ -148,7 +148,7 @@ def _draw(self, context: Any) -> None: class Fill(DrawingAction): color: ColorT | None = color_property() fill_rule: FillRule = FillRule.NONZERO - path: Path | None = None + path: Path2D | None = None def _draw(self, context: Any) -> None: context.save() @@ -167,7 +167,7 @@ class Stroke(DrawingAction): color: ColorT | None = color_property() line_width: float | None = None line_dash: list[float] | None = None - path: Path | None = None + path: Path2D | None = None def _draw(self, context: Any) -> None: context.save() diff --git a/core/src/toga/widgets/canvas/path.py b/core/src/toga/widgets/canvas/path.py index 2634eb2c7a..30f125aa59 100644 --- a/core/src/toga/widgets/canvas/path.py +++ b/core/src/toga/widgets/canvas/path.py @@ -1,6 +1,6 @@ # pragma: no cover import copy -from collections.abc import Sequence +from collections.abc import Iterable, Sequence from dataclasses import dataclass from math import pi @@ -16,11 +16,13 @@ MoveTo, QuadraticCurveTo, Rect, + RoundRect, ) +from .geometry import CornerRadiusT -class Path: - def __init__(self, path: "Path | None" = None): +class Path2D: + def __init__(self, path: "Path2D | None" = None): if path is None: self.drawing_actions = [] else: @@ -32,10 +34,10 @@ def __init__(self, path: "Path | None" = None): @property def impl(self): if self._impl is None: - self._impl = self.compile() + self.compile() return self._impl - def add_path(self, path: "Path", transform: Sequence[float] | None = None): + def add_path(self, path: "Path2D", transform: Sequence[float] | None = None): """Adds another path to the current path with an optional transform. :param path: The Path being added. @@ -242,21 +244,61 @@ def rect(self, x: float, y: float, width: float, height: float) -> Rect: self._redraw_with_warning_if_state() 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._redraw_with_warning_if_state() + return round_rect + def _redraw_with_warning_if_state(self): pass def compile(self): print("compiling") - impl = self.factory.Path() + self._impl = self.factory.Path2D() for action in self.drawing_actions: print(action) - action._draw(impl) - return impl + action._draw(self._impl) + return self._impl @dataclass(repr=False) class AddPath(DrawingAction): - path: Path + path: Path2D transform: Sequence[float] | None = None def _draw(self, context): diff --git a/core/src/toga/widgets/canvas/state.py b/core/src/toga/widgets/canvas/state.py index 4a39ffa1ff..3d9d74d33a 100644 --- a/core/src/toga/widgets/canvas/state.py +++ b/core/src/toga/widgets/canvas/state.py @@ -40,7 +40,7 @@ from toga.colors import ColorT from .canvas import Canvas - from .path import Path + from .path import Path2D # Make sure deprecation warnings are shown by default warnings.filterwarnings("default", category=DeprecationWarning) @@ -296,7 +296,7 @@ def fill( self, color: ColorT | None = None, fill_rule: FillRule = FillRule.NONZERO, - path: Path | None = None, + path: Path2D | None = None, ) -> Fill: """Fill the current path. @@ -320,7 +320,7 @@ def stroke( color: ColorT | None = None, line_width: float | None = None, line_dash: list[float] | None = None, - path: Path | None = None, + path: Path2D | None = None, ) -> Stroke: """Draw the current path as a stroke. diff --git a/core/tests/widgets/canvas/test_path.py b/core/tests/widgets/canvas/test_path.py new file mode 100644 index 0000000000..72beefdc55 --- /dev/null +++ b/core/tests/widgets/canvas/test_path.py @@ -0,0 +1,487 @@ +import pytest + +from toga.widgets.canvas import Path2D + + +@pytest.fixture() +def path(): + return Path2D() + + +def test_compile(path): + path.move_to(100, 50) + 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 doesn't change the instructions + path.line_to(100, 200) + assert path.impl.draw_instructions == [ + ("move to", {"x": 100, "y": 50}), + ("line to", {"x": 200, "y": 100}), + ] + + # until you explicitly compile + path.compile() + assert path.impl.draw_instructions == [ + ("move to", {"x": 100, "y": 50}), + ("line to", {"x": 200, "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/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 010c6d0149..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 diff --git a/gtk/src/toga_gtk/factory.py b/gtk/src/toga_gtk/factory.py index f2fbcc39f8..1f2175e13d 100644 --- a/gtk/src/toga_gtk/factory.py +++ b/gtk/src/toga_gtk/factory.py @@ -12,7 +12,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 @@ -71,6 +71,7 @@ def not_implemented(feature): "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 832dc8da5a..66f6a818ef 100644 --- a/gtk/src/toga_gtk/widgets/canvas.py +++ b/gtk/src/toga_gtk/widgets/canvas.py @@ -27,7 +27,7 @@ BLACK = native_color(rgb(0, 0, 0)) -class Path: +class Path2D: def __init__(self, path=None): if path is None: steps = [] diff --git a/iOS/src/toga_iOS/factory.py b/iOS/src/toga_iOS/factory.py index 88fc0c0685..49384e47cd 100644 --- a/iOS/src/toga_iOS/factory.py +++ b/iOS/src/toga_iOS/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 @@ def not_implemented(feature): "NumberInput", "OptionContainer", "PasswordInput", + "Path2D", "ProgressBar", "ScrollContainer", "Selection", diff --git a/iOS/src/toga_iOS/widgets/canvas.py b/iOS/src/toga_iOS/widgets/canvas.py index b720c67807..479c764252 100644 --- a/iOS/src/toga_iOS/widgets/canvas.py +++ b/iOS/src/toga_iOS/widgets/canvas.py @@ -43,7 +43,7 @@ from toga_iOS.widgets.base import Widget -class Path: +class Path2D: def __init__(self, path=None): if path is None: self.native = core_graphics.CGPathCreateMutable() diff --git a/qt/src/toga_qt/factory.py b/qt/src/toga_qt/factory.py index e81c711d18..9f7af5a722 100644 --- a/qt/src/toga_qt/factory.py +++ b/qt/src/toga_qt/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, Path + from .widgets.canvas import Canvas, Path2D from .widgets.dateinput import DateInput from .widgets.detailedlist import DetailedList from .widgets.divider import Divider @@ -71,7 +71,7 @@ "NumberInput", "OptionContainer", "PasswordInput", - "Path", + "Path2D", "ProgressBar", "SplitContainer", "Selection", diff --git a/qt/src/toga_qt/widgets/canvas.py b/qt/src/toga_qt/widgets/canvas.py index 97ad099a49..091821cd01 100644 --- a/qt/src/toga_qt/widgets/canvas.py +++ b/qt/src/toga_qt/widgets/canvas.py @@ -27,7 +27,7 @@ BLACK = native_color(rgb(0, 0, 0)) -class Path: +class Path2D: def __init__(self, path=None): if path is None: self.native = QPainterPath() @@ -154,7 +154,7 @@ def state(self): @property def path(self): - path = Path() + path = Path2D() path.native = self._path return path diff --git a/testbed/tests/widgets/test_canvas.py b/testbed/tests/widgets/test_canvas.py index 25d06e8cd4..f051d9bd14 100644 --- a/testbed/tests/widgets/test_canvas.py +++ b/testbed/tests/widgets/test_canvas.py @@ -23,7 +23,7 @@ from toga.fonts import BOLD from toga.images import Image as TogaImage from toga.style.pack import SYSTEM, Pack -from toga.widgets.canvas import Path +from toga.widgets.canvas import Path2D from .conftest import build_cleanup_test from .properties import ( # noqa: F401 @@ -1049,7 +1049,7 @@ async def test_draw_image_in_rect(canvas, probe): async def test_path_object(canvas, probe): - path = Path() + path = Path2D() # exercise all of the Path methods path.move_to(10, 15) @@ -1059,7 +1059,7 @@ async def test_path_object(canvas, probe): path.rect(5, 5, 50, 30) - path2 = Path() + path2 = Path2D() path2.move_to(100, 80) path2.quadratic_curve_to(100, 100, 120, 130) path2.quadratic_curve_to(150, 120, 150, 100) diff --git a/winforms/src/toga_winforms/factory.py b/winforms/src/toga_winforms/factory.py index f8ca3bef61..0fa9fd3f58 100644 --- a/winforms/src/toga_winforms/factory.py +++ b/winforms/src/toga_winforms/factory.py @@ -11,7 +11,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 @@ -68,6 +68,7 @@ def not_implemented(feature): "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 c5468f6949..d9b6fe66a8 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -33,7 +33,7 @@ BLACK = native_color(rgb(0, 0, 0)) -class Path: +class Path2D: def __init__(self, path=None): if path is None: self.native = GraphicsPath() @@ -227,7 +227,7 @@ def set_stroke_style(self, color): # Basic paths def begin_path(self): - self.path = Path() + self.path = Path2D() def close_path(self): self.path.close_path() @@ -383,7 +383,7 @@ def _text_path(self, text, x, y, font, baseline, line_height): # Default to Baseline.ALPHABETIC top = y - font.metric("CellAscent") - path = Path() + path = Path2D() for line_num, line in enumerate(lines): path.native.AddString( line, From 4dd707c1ceb3af2a527eae5d44aa455d65e659be Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 5 Feb 2026 10:12:38 +0000 Subject: [PATCH 10/46] Fixes from test failures; rework Path2d.compile. --- android/src/toga_android/widgets/canvas.py | 4 +- core/src/toga/widgets/canvas/path.py | 13 +++++++ core/tests/widgets/canvas/test_path.py | 17 +++++++-- gtk/src/toga_gtk/widgets/canvas.py | 5 ++- iOS/src/toga_iOS/libs/core_graphics.py | 18 ++++++++- iOS/src/toga_iOS/widgets/canvas.py | 40 ++++++++------------ winforms/src/toga_winforms/widgets/canvas.py | 3 +- 7 files changed, 66 insertions(+), 34 deletions(-) diff --git a/android/src/toga_android/widgets/canvas.py b/android/src/toga_android/widgets/canvas.py index c3b4d27239..8ce3b651fd 100644 --- a/android/src/toga_android/widgets/canvas.py +++ b/android/src/toga_android/widgets/canvas.py @@ -46,7 +46,9 @@ def add_path(self, path, transform=None): self.native.addPath(path.native) self._last_point = path._last_point else: - self.native.addPath(NativePath(path.native).transform(transform)) + native_path = NativePath(path.native) + native_path.transform(transform) + self.native.addPath(native_path) self._last_point = transform.mapPoints([path._last_point])[0] def close_path(self): diff --git a/core/src/toga/widgets/canvas/path.py b/core/src/toga/widgets/canvas/path.py index 30f125aa59..5e60ad2db2 100644 --- a/core/src/toga/widgets/canvas/path.py +++ b/core/src/toga/widgets/canvas/path.py @@ -47,6 +47,7 @@ def add_path(self, path: "Path2D", transform: Sequence[float] | None = None): """ add_path = AddPath(path, transform) self._action_target.drawing_actions.append(add_path) + self._recompilation_needed() self._redraw_with_warning_if_state() return add_path @@ -61,6 +62,7 @@ def close_path(self): """ close_path = ClosePath() self._action_target.drawing_actions.append(close_path) + self._recompilation_needed() self._redraw_with_warning_if_state() return close_path @@ -74,6 +76,7 @@ def move_to(self, x: float, y: float) -> MoveTo: """ move_to = MoveTo(x, y) self._action_target.drawing_actions.append(move_to) + self._recompilation_needed() self._redraw_with_warning_if_state() return move_to @@ -87,6 +90,7 @@ def line_to(self, x: float, y: float) -> LineTo: """ line_to = LineTo(x, y) self._action_target.drawing_actions.append(line_to) + self._recompilation_needed() self._redraw_with_warning_if_state() return line_to @@ -117,6 +121,7 @@ def bezier_curve_to( """ bezier_curve_to = BezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) self._action_target.drawing_actions.append(bezier_curve_to) + self._recompilation_needed() self._redraw_with_warning_if_state() return bezier_curve_to @@ -145,6 +150,7 @@ def quadratic_curve_to( """ quadratic_curve_to = QuadraticCurveTo(cpx, cpy, x, y) self._action_target.drawing_actions.append(quadratic_curve_to) + self._recompilation_needed() self._redraw_with_warning_if_state() return quadratic_curve_to @@ -177,6 +183,7 @@ def arc( """ arc = Arc(x, y, radius, startangle, endangle, counterclockwise, anticlockwise) self._action_target.drawing_actions.append(arc) + self._recompilation_needed() self._redraw_with_warning_if_state() return arc @@ -225,6 +232,7 @@ def ellipse( anticlockwise, ) self._action_target.drawing_actions.append(ellipse) + self._recompilation_needed() self._redraw_with_warning_if_state() return ellipse @@ -241,6 +249,7 @@ def rect(self, x: float, y: float, width: float, height: float) -> Rect: rect = Rect(x, y, width, height) self._action_target.drawing_actions.append(rect) + self._recompilation_needed() self._redraw_with_warning_if_state() return rect @@ -281,12 +290,16 @@ def round_rect( """ round_rect = RoundRect(x, y, width, height, radii) self._action_target.drawing_actions.append(round_rect) + self._recompilation_needed() self._redraw_with_warning_if_state() return round_rect def _redraw_with_warning_if_state(self): pass + def _recompilation_needed(self): + self._impl = None + def compile(self): print("compiling") self._impl = self.factory.Path2D() diff --git a/core/tests/widgets/canvas/test_path.py b/core/tests/widgets/canvas/test_path.py index 72beefdc55..287cc2300c 100644 --- a/core/tests/widgets/canvas/test_path.py +++ b/core/tests/widgets/canvas/test_path.py @@ -10,28 +10,37 @@ def path(): def test_compile(path): path.move_to(100, 50) - path.line_to(200, 100) + 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 doesn't change the instructions + # 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}), ] - # until you explicitly compile - path.compile() + # 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) diff --git a/gtk/src/toga_gtk/widgets/canvas.py b/gtk/src/toga_gtk/widgets/canvas.py index 66f6a818ef..38eae971d0 100644 --- a/gtk/src/toga_gtk/widgets/canvas.py +++ b/gtk/src/toga_gtk/widgets/canvas.py @@ -43,7 +43,7 @@ def _ensure_subpath(self, x, y): self.move_to(x, y) def add_path(self, path, transform=None): - self._steps.append(("add_path", path.steps.copy(), transform)) + self._steps.append(("add_path", path._steps.copy(), transform)) self._native_cached = None def close_path(self): @@ -103,7 +103,8 @@ def rect(self, x, y, width, height): self._native_cached = None def round_rect(self, x, y, width, height, radii): - 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 is_empty(self): diff --git a/iOS/src/toga_iOS/libs/core_graphics.py b/iOS/src/toga_iOS/libs/core_graphics.py index 684d250da4..1e931932d8 100644 --- a/iOS/src/toga_iOS/libs/core_graphics.py +++ b/iOS/src/toga_iOS/libs/core_graphics.py @@ -39,10 +39,21 @@ 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, @@ -84,7 +95,12 @@ class CGAffineTransform(Structure): CGFloat, ] core_graphics.CGPathAddLineToPoint.restype = c_void_p -core_graphics.CGPathAddLineToPoint.argtypes = [CGMutablePathRef, CGFloat, CGFloat] +core_graphics.CGPathAddLineToPoint.argtypes = [ + CGMutablePathRef, + CGAffineTransform, + CGFloat, + CGFloat, +] core_graphics.CGPathAddQuadCurveToPoint.restype = c_void_p core_graphics.CGPathAddQuadCurveToPoint.argtypes = [ CGMutablePathRef, diff --git a/iOS/src/toga_iOS/widgets/canvas.py b/iOS/src/toga_iOS/widgets/canvas.py index 479c764252..b6dcb9d844 100644 --- a/iOS/src/toga_iOS/widgets/canvas.py +++ b/iOS/src/toga_iOS/widgets/canvas.py @@ -42,6 +42,8 @@ ) from toga_iOS.widgets.base import Widget +IDENTITY = core_graphics.CGAffineTransformMake(1, 0, 0, 1, 0, 0) + class Path2D: def __init__(self, path=None): @@ -56,27 +58,25 @@ def _ensure_subpath(self, x, y): def add_path(self, path, transform=None): if transform is None: - transform = core_graphics.CGAffineTransformIdentity() + 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, core_graphics.CGAffineTransformIdentity(), x, y - ) + core_graphics.CGPathMoveToPoint(self.native, IDENTITY, x, y) def line_to(self, x, y): - core_graphics.CGPathAddLineToPoint( - self.native, core_graphics.CGAffineTransformIdentity(), 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, - core_graphics.CGAffineTransformIdentity(), + IDENTITY, cp1x, cp1y, cp2x, @@ -87,9 +87,7 @@ def bezier_curve_to(self, 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, core_graphics.CGAffineTransformIdentity(), cpx, cpy, x, y - ) + 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 @@ -97,7 +95,7 @@ def arc(self, x, y, radius, startangle, endangle, counterclockwise): clockwise = counterclockwise core_graphics.CGPathAddArc( self.native, - core_graphics.CGAffineTransformIdentity(), + IDENTITY, x, y, radius, @@ -117,24 +115,16 @@ def ellipse( endangle, counterclockwise, ): - transform = core_graphics.CGAffineTransformTranslate( - core_graphics.CGAffineTransformRotate( - core_graphics.CGAffineTransformMakeScale(radiusx, radiusy), - rotation, - ), - x, - y, - ) - clockwise = not 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, clockwise + 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, core_graphics.CGAffineTransformIdentity, rectangle - ) + core_graphics.CGPathAddRect(self.native, IDENTITY, rectangle) def round_rect(self, x, y, width, height, radii): round_rect(self, x, y, width, height, radii) diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index d9b6fe66a8..1dc6a6e205 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -58,7 +58,8 @@ def close_path(self): self.native.CloseFigure() def move_to(self, x, y): - if not self.native.GetLastPoint().IsEmpty: + last_point = self.native.GetLastPoint() + if not last_point.IsEmpty: self.native.StartFigure() self._subpath_start = PointF(x, y) From f1146c1f716868db23d7af0fcc301bf491abbe3a Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 5 Feb 2026 11:07:33 +0000 Subject: [PATCH 11/46] More fixes based on tests. --- android/src/toga_android/widgets/canvas.py | 17 +++++++++++++++-- gtk/src/toga_gtk/widgets/canvas.py | 4 ++-- winforms/src/toga_winforms/widgets/canvas.py | 3 ++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/android/src/toga_android/widgets/canvas.py b/android/src/toga_android/widgets/canvas.py index 8ce3b651fd..3183e50a9f 100644 --- a/android/src/toga_android/widgets/canvas.py +++ b/android/src/toga_android/widgets/canvas.py @@ -27,6 +27,18 @@ BLACK = jint(native_color(rgb(0, 0, 0))) +def matrix_from_transform(transform): + a, b, c, d, e, f = transform + matrix = Matrix() + matrix.MscaleX = a + matrix.MskewX = b + matrix.MskewY = c + matrix.MscaleY = d + matrix.MtransX = e + matrix.MtransY = f + return matrix + + class Path2D: native: NativePath @@ -47,9 +59,10 @@ def add_path(self, path, transform=None): self._last_point = path._last_point else: native_path = NativePath(path.native) - native_path.transform(transform) + matrix = matrix_from_transform(transform) + native_path.transform(matrix) self.native.addPath(native_path) - self._last_point = transform.mapPoints([path._last_point])[0] + self._last_point = matrix.mapPoints([path._last_point])[0] def close_path(self): self.native.close() diff --git a/gtk/src/toga_gtk/widgets/canvas.py b/gtk/src/toga_gtk/widgets/canvas.py index 38eae971d0..d38699cfd3 100644 --- a/gtk/src/toga_gtk/widgets/canvas.py +++ b/gtk/src/toga_gtk/widgets/canvas.py @@ -47,7 +47,7 @@ def add_path(self, path, transform=None): self._native_cached = None def close_path(self): - self._steps.append("close_path") + self._steps.append(("close_path",)) self._native_cached = None def move_to(self, x, y): @@ -113,7 +113,7 @@ def is_empty(self): def apply(self, context): context.begin_path() if self._native_cached: - # if we have a C-leve cache of the path, use it + # if we have a C-level cache of the path, use it context.native.add_path(self._native_cached) else: for method, *args in self._steps: diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index 1dc6a6e205..27d9c05e7f 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -128,7 +128,8 @@ def ellipse( matrix.TransformPoints(points) start = self._subpath_start - if start and self.native.GetLastPoint().IsEmpty: + last_point = self.native.GetLastPoint() + if start and last_point.IsEmpty: self.native.AddLine(start, start) self.native.AddBeziers(points) From a24d74ca61a1f715f4ce97b7f901ae1eb4c2a700 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 5 Feb 2026 11:57:55 +0000 Subject: [PATCH 12/46] Yet more attempted fixes; probe for macOS amd64 failures. --- android/src/toga_android/widgets/canvas.py | 7 +----- gtk/src/toga_gtk/widgets/canvas.py | 25 +++++++++++--------- testbed/tests/widgets/test_canvas.py | 12 ++++++++++ winforms/src/toga_winforms/widgets/canvas.py | 24 +++++++++++++++---- 4 files changed, 46 insertions(+), 22 deletions(-) diff --git a/android/src/toga_android/widgets/canvas.py b/android/src/toga_android/widgets/canvas.py index 3183e50a9f..9cec8d525b 100644 --- a/android/src/toga_android/widgets/canvas.py +++ b/android/src/toga_android/widgets/canvas.py @@ -30,12 +30,7 @@ def matrix_from_transform(transform): a, b, c, d, e, f = transform matrix = Matrix() - matrix.MscaleX = a - matrix.MskewX = b - matrix.MskewY = c - matrix.MscaleY = d - matrix.MtransX = e - matrix.MtransY = f + matrix.setValues([a, b, 0, c, d, 0, e, f, 1]) return matrix diff --git a/gtk/src/toga_gtk/widgets/canvas.py b/gtk/src/toga_gtk/widgets/canvas.py index d38699cfd3..478054d82a 100644 --- a/gtk/src/toga_gtk/widgets/canvas.py +++ b/gtk/src/toga_gtk/widgets/canvas.py @@ -116,20 +116,23 @@ def apply(self, context): # if we have a C-level cache of the path, use it context.native.add_path(self._native_cached) else: - for method, *args in self._steps: - if method == "add_path": - path, transform = args - context.save() - try: - context.transform(transform) - path.apply(context) - finally: - context.restore() - else: - getattr(context, method)(*args) + self._apply(self._steps, context) # Cache the C-level path for reuse self._native_cached = context.copy_path() + def _apply(self, steps, context): + for method, *args in self.steps: + if method == "add_path": + steps, transform = args + context.save() + try: + context.transform(transform) + self._apply(steps, context) + finally: + context.restore() + else: + getattr(context, method)(*args) + @dataclass(slots=True) class State: diff --git a/testbed/tests/widgets/test_canvas.py b/testbed/tests/widgets/test_canvas.py index f051d9bd14..7df2cf1ada 100644 --- a/testbed/tests/widgets/test_canvas.py +++ b/testbed/tests/widgets/test_canvas.py @@ -1052,30 +1052,42 @@ async def test_path_object(canvas, probe): path = Path2D() # exercise all of the Path methods + print("move_to") path.move_to(10, 15) + print("line_to") path.line_to(20, 30) + print("bezier") path.bezier_curve_to(20, 10, 40, 15, 50, 10) + print("close") path.close_path() + print("rect") path.rect(5, 5, 50, 30) path2 = Path2D() path2.move_to(100, 80) + print("quadratic") path2.quadratic_curve_to(100, 100, 120, 130) path2.quadratic_curve_to(150, 120, 150, 100) + print("arc") path2.arc(130, 100, 20, endangle=pi / 3) + print("add_path") path.add_path(path2, (0.5, 0.0, 0.0, 0.75, 30, 10)) path.move_to(150, 100) + print("ellipse") path.ellipse(150, 100, 20, 30, pi / 4, 0, pi, True) + print("transform") canvas.root_state.translate(100, 100) canvas.root_state.scale(0.5, 0.5) for _ in range(12): canvas.root_state.rotate(pi / 6) canvas.root_state.scale(0.95, 0.95) + print("draw", _) canvas.root_state.fill(CORNFLOWERBLUE, path=path) canvas.root_state.stroke(REBECCAPURPLE, path=path) + print("done") await probe.redraw("Image should be drawn") assert_reference(probe, "path_object", threshold=0.05) diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index 27d9c05e7f..37023a6eac 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -48,7 +48,11 @@ def _ensure_path(self, x, y): @property def last_point(self): - last_point = self.native.GetLastPoint() + try: + last_point = self.native.GetLastPoint() + except Exception as exc: + print(exc) + return self._subpath_start if last_point.IsEmpty: return self._subpath_start else: @@ -58,9 +62,14 @@ def close_path(self): self.native.CloseFigure() def move_to(self, x, y): - last_point = self.native.GetLastPoint() - if not last_point.IsEmpty: + try: + last_point = self.native.GetLastPoint() + except Exception as exc: + print(exc) self.native.StartFigure() + else: + if not last_point.IsEmpty: + self.native.StartFigure() self._subpath_start = PointF(x, y) def line_to(self, x, y): @@ -128,9 +137,14 @@ def ellipse( matrix.TransformPoints(points) start = self._subpath_start - last_point = self.native.GetLastPoint() - if start and last_point.IsEmpty: + try: + last_point = self.native.GetLastPoint() + except Exception as exc: + print(exc) self.native.AddLine(start, start) + else: + if start and last_point.IsEmpty: + self.native.AddLine(start, start) self.native.AddBeziers(points) def rect(self, x, y, width, height): From 74398a089ac56eb5fa97e8b60eff51c1f34dc219 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 5 Feb 2026 12:27:48 +0000 Subject: [PATCH 13/46] Further drilling down into issues. --- android/src/toga_android/widgets/canvas.py | 2 +- cocoa/src/toga_cocoa/widgets/canvas.py | 5 ++++- gtk/src/toga_gtk/widgets/canvas.py | 6 +++--- winforms/src/toga_winforms/widgets/canvas.py | 14 +++++++++++++- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/android/src/toga_android/widgets/canvas.py b/android/src/toga_android/widgets/canvas.py index 9cec8d525b..361c4a9060 100644 --- a/android/src/toga_android/widgets/canvas.py +++ b/android/src/toga_android/widgets/canvas.py @@ -57,7 +57,7 @@ def add_path(self, path, transform=None): matrix = matrix_from_transform(transform) native_path.transform(matrix) self.native.addPath(native_path) - self._last_point = matrix.mapPoints([path._last_point])[0] + self._last_point = tuple(matrix.mapPoints(list(path._last_point))) def close_path(self): self.native.close() diff --git a/cocoa/src/toga_cocoa/widgets/canvas.py b/cocoa/src/toga_cocoa/widgets/canvas.py index 37df097ce9..b89f6a3697 100644 --- a/cocoa/src/toga_cocoa/widgets/canvas.py +++ b/cocoa/src/toga_cocoa/widgets/canvas.py @@ -388,7 +388,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: + print(exc) @objc_method def isFlipped(self) -> bool: diff --git a/gtk/src/toga_gtk/widgets/canvas.py b/gtk/src/toga_gtk/widgets/canvas.py index 478054d82a..bf7485e9c4 100644 --- a/gtk/src/toga_gtk/widgets/canvas.py +++ b/gtk/src/toga_gtk/widgets/canvas.py @@ -121,13 +121,13 @@ def apply(self, context): self._native_cached = context.copy_path() def _apply(self, steps, context): - for method, *args in self.steps: + for method, *args in steps: if method == "add_path": - steps, transform = args + add_steps, transform = args context.save() try: context.transform(transform) - self._apply(steps, context) + self._apply(add_steps, context) finally: context.restore() else: diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index 37023a6eac..728fca3ee8 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -51,13 +51,23 @@ def last_point(self): try: last_point = self.native.GetLastPoint() except Exception as exc: - print(exc) + print("last_point", exc) + print(self.native, self.native.PointCount) return self._subpath_start if last_point.IsEmpty: return self._subpath_start else: return last_point + def add_path(self, path, transform=None): + if transform is None: + self.native.AddPath(path.native) + else: + native_path = GraphicsPath(path.native.PathPoints, path.native.PathTypes) + matrix = Matrix(*transform) + native_path.Transform(matrix) + self.native.AddPath(native_path, False) + def close_path(self): self.native.CloseFigure() @@ -66,6 +76,7 @@ def move_to(self, x, y): last_point = self.native.GetLastPoint() except Exception as exc: print(exc) + print(self.native, self.native.PointCount) self.native.StartFigure() else: if not last_point.IsEmpty: @@ -139,6 +150,7 @@ def ellipse( start = self._subpath_start try: last_point = self.native.GetLastPoint() + print(self.native, self.native.PointCount) except Exception as exc: print(exc) self.native.AddLine(start, start) From e940b18471bbd6f1ddbb6915ae0edadca14732b4 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 5 Feb 2026 13:28:57 +0000 Subject: [PATCH 14/46] Still refining for platforms with errors. --- android/src/toga_android/widgets/canvas.py | 5 +- gtk/src/toga_gtk/widgets/canvas.py | 2 +- winforms/src/toga_winforms/widgets/canvas.py | 59 ++++++++++---------- 3 files changed, 34 insertions(+), 32 deletions(-) diff --git a/android/src/toga_android/widgets/canvas.py b/android/src/toga_android/widgets/canvas.py index 361c4a9060..b5c14b794b 100644 --- a/android/src/toga_android/widgets/canvas.py +++ b/android/src/toga_android/widgets/canvas.py @@ -57,7 +57,10 @@ def add_path(self, path, transform=None): matrix = matrix_from_transform(transform) native_path.transform(matrix) self.native.addPath(native_path) - self._last_point = tuple(matrix.mapPoints(list(path._last_point))) + if path._last_point is not None: + self._last_point = tuple(matrix.mapPoints(list(path._last_point))) + else: + self._last_point = None def close_path(self): self.native.close() diff --git a/gtk/src/toga_gtk/widgets/canvas.py b/gtk/src/toga_gtk/widgets/canvas.py index bf7485e9c4..6962054c2b 100644 --- a/gtk/src/toga_gtk/widgets/canvas.py +++ b/gtk/src/toga_gtk/widgets/canvas.py @@ -118,7 +118,7 @@ def apply(self, context): else: self._apply(self._steps, context) # Cache the C-level path for reuse - self._native_cached = context.copy_path() + self._native_cached = context.native.copy_path() def _apply(self, steps, context): for method, *args in steps: diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index 728fca3ee8..e5f1fe7c02 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -38,9 +38,11 @@ def __init__(self, path=None): if path is None: self.native = GraphicsPath() self._subpath_start = None + self._subpath_end = None else: self.native = GraphicsPath(path.native.PathPoints, path.native.PathTypes) self._subpath_start = path._subpath_start + self._subpath_end = path._subpath_end def _ensure_path(self, x, y): if self._subpath_start is None: @@ -48,44 +50,40 @@ def _ensure_path(self, x, y): @property def last_point(self): - try: - last_point = self.native.GetLastPoint() - except Exception as exc: - print("last_point", exc) - print(self.native, self.native.PointCount) - return self._subpath_start - if last_point.IsEmpty: - return self._subpath_start - else: - return last_point + return self._subpath_end def add_path(self, path, transform=None): if transform is None: self.native.AddPath(path.native) + 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: + points = [path._subpath_start] + self._subpath_start = matrix.TransformPoints(points)[0] + if path._subpath_end is not None: + points = [path._subpath_end] + self._subpath_end = matrix.TransformPoints(points)[0] def close_path(self): - self.native.CloseFigure() + if self._subpath_start is not None: + self.native.CloseFigure() + self._subpath_end = self._subpath_start def move_to(self, x, y): - try: - last_point = self.native.GetLastPoint() - except Exception as exc: - print(exc) - print(self.native, self.native.PointCount) - self.native.StartFigure() + self._subpath_end = PointF(x, y) + if self._subpath_start is None: + self._subpath_start = self._subpath_start else: - if not last_point.IsEmpty: - self.native.StartFigure() - self._subpath_start = PointF(x, y) + self.native.StartFigure() 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) # Basic shapes @@ -97,6 +95,7 @@ def bezier_curve_to(self, cp1x, cp1y, cp2x, cp2y, x, y): PointF(cp2x, cp2y), PointF(x, y), ) + self._subpath_end = PointF(x, y) def quadratic_curve_to(self, cpx, cpy, x, y): # A Quadratic curve is a dimensionally reduced Bézier Cubic curve; @@ -116,6 +115,7 @@ def quadratic_curve_to(self, cpx, cpy, x, y): ), PointF(x, y), ) + self._subpath_end = PointF(x, y) def arc(self, x, y, radius, startangle, endangle, counterclockwise): self.ellipse(x, y, radius, radius, 0, startangle, endangle, counterclockwise) @@ -147,24 +147,23 @@ def ellipse( ) matrix.TransformPoints(points) - start = self._subpath_start - try: - last_point = self.native.GetLastPoint() - print(self.native, self.native.PointCount) - except Exception as exc: - print(exc) - self.native.AddLine(start, start) - else: - if start and last_point.IsEmpty: - self.native.AddLine(start, start) + self._ensure_path(points[0].X, points[0].Y) self.native.AddBeziers(points) + self._subpath_end = points[-1] 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) 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) class State: From 4fcec9e53c176716d4069b6fb18d896edbe26528 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 5 Feb 2026 14:00:05 +0000 Subject: [PATCH 15/46] More work on getting tests to pass. --- android/src/toga_android/widgets/canvas.py | 6 +++--- gtk/src/toga_gtk/widgets/canvas.py | 2 +- winforms/src/toga_winforms/widgets/canvas.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/android/src/toga_android/widgets/canvas.py b/android/src/toga_android/widgets/canvas.py index b5c14b794b..0666744abb 100644 --- a/android/src/toga_android/widgets/canvas.py +++ b/android/src/toga_android/widgets/canvas.py @@ -58,9 +58,9 @@ def add_path(self, path, transform=None): native_path.transform(matrix) self.native.addPath(native_path) if path._last_point is not None: - self._last_point = tuple(matrix.mapPoints(list(path._last_point))) - else: - self._last_point = None + points = list(path._last_point) + matrix.mapPoints(points) + self._last_point = points[0] def close_path(self): self.native.close() diff --git a/gtk/src/toga_gtk/widgets/canvas.py b/gtk/src/toga_gtk/widgets/canvas.py index 6962054c2b..d92cf89866 100644 --- a/gtk/src/toga_gtk/widgets/canvas.py +++ b/gtk/src/toga_gtk/widgets/canvas.py @@ -114,7 +114,7 @@ def apply(self, context): context.begin_path() if self._native_cached: # if we have a C-level cache of the path, use it - context.native.add_path(self._native_cached) + context.native.append_path(self._native_cached) else: self._apply(self._steps, context) # Cache the C-level path for reuse diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index e5f1fe7c02..95f6481fb8 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -76,7 +76,7 @@ def close_path(self): def move_to(self, x, y): self._subpath_end = PointF(x, y) if self._subpath_start is None: - self._subpath_start = self._subpath_start + self._subpath_start = self._subpath_end else: self.native.StartFigure() From 45a3b52e42dfef6a904ba4cd24582bb4292b744c Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 5 Feb 2026 14:24:29 +0000 Subject: [PATCH 16/46] Hopefully the last fixes needed for GTK and winforms. --- gtk/src/toga_gtk/widgets/canvas.py | 4 ++-- winforms/src/toga_winforms/widgets/canvas.py | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/gtk/src/toga_gtk/widgets/canvas.py b/gtk/src/toga_gtk/widgets/canvas.py index d92cf89866..d55cceb7c8 100644 --- a/gtk/src/toga_gtk/widgets/canvas.py +++ b/gtk/src/toga_gtk/widgets/canvas.py @@ -261,7 +261,7 @@ def fill(self, fill_rule, path=None): self.begin_path() path.apply(self) self.native.fill() - self.native.add_path(current_path) + self.native.append_path(current_path) def stroke(self, path=None): self.native.set_source_rgba(*self.state.stroke_style) @@ -271,7 +271,7 @@ def stroke(self, path=None): current_path = self.native.copy_path() path.apply(self) self.native.stroke() - self.native.add_path(current_path) + self.native.append_path(current_path) # Transformations diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index 95f6481fb8..c5c696ecc3 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -70,7 +70,10 @@ def add_path(self, path, transform=None): def close_path(self): if self._subpath_start is not None: - self.native.CloseFigure() + # 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): @@ -148,6 +151,7 @@ def ellipse( 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] From 8433d2269e86598bbe91b5fa9bb8f13bbcf95040 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 5 Feb 2026 14:54:45 +0000 Subject: [PATCH 17/46] Ensure subpath endpoints are also transformed on Windows. --- winforms/src/toga_winforms/widgets/canvas.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index c5c696ecc3..d163ff92bd 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -62,11 +62,13 @@ def add_path(self, path, transform=None): native_path.Transform(matrix) self.native.AddPath(native_path, False) if self._subpath_start is None and path._subpath_start is not None: - points = [path._subpath_start] - self._subpath_start = matrix.TransformPoints(points)[0] + points = Array[PointF]([path._subpath_start]) + matrix.TransformPoints(points) + self._subpath_start = points[0] if path._subpath_end is not None: - points = [path._subpath_end] - self._subpath_end = matrix.TransformPoints(points)[0] + 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: @@ -225,6 +227,10 @@ def transform_path(self, matrix): 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.path._subpath_end = points[0] # Context management From ed153fea6260d563c57e1831e789aa25767365d2 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 5 Feb 2026 15:17:25 +0000 Subject: [PATCH 18/46] Hand sequential move_to commands on windows. --- winforms/src/toga_winforms/widgets/canvas.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index d163ff92bd..0c6500169e 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -39,10 +39,12 @@ def __init__(self, path=None): self.native = GraphicsPath() self._subpath_start = None self._subpath_end = None + self._subpath_empty = True else: self.native = GraphicsPath(path.native.PathPoints, path.native.PathTypes) self._subpath_start = path._subpath_start self._subpath_end = path._subpath_end + self._subpath_empty = path._subpath_empty def _ensure_path(self, x, y): if self._subpath_start is None: @@ -80,15 +82,16 @@ def close_path(self): def move_to(self, x, y): self._subpath_end = PointF(x, y) - if self._subpath_start is None: - self._subpath_start = self._subpath_end - else: + 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 @@ -101,6 +104,7 @@ def bezier_curve_to(self, cp1x, cp1y, cp2x, cp2y, x, y): 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; @@ -121,6 +125,7 @@ def quadratic_curve_to(self, cpx, cpy, x, 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) @@ -156,6 +161,7 @@ def ellipse( 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) @@ -163,6 +169,7 @@ def rect(self, x, y, width, height): 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 @@ -170,6 +177,7 @@ def 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: From 04e80e364637bbdd984b1946e75794d4e4fc3286 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 5 Feb 2026 15:35:09 +0000 Subject: [PATCH 19/46] Improve path object tests to get more coverage. --- testbed/tests/widgets/test_canvas.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/testbed/tests/widgets/test_canvas.py b/testbed/tests/widgets/test_canvas.py index 7df2cf1ada..de734003df 100644 --- a/testbed/tests/widgets/test_canvas.py +++ b/testbed/tests/widgets/test_canvas.py @@ -1052,9 +1052,9 @@ async def test_path_object(canvas, probe): path = Path2D() # exercise all of the Path methods - print("move_to") - path.move_to(10, 15) - print("line_to") + print("line_to without point") + path.line_to(10, 15) + print("line_to with point") path.line_to(20, 30) print("bezier") path.bezier_curve_to(20, 10, 40, 15, 50, 10) @@ -1065,6 +1065,7 @@ async def test_path_object(canvas, probe): path.rect(5, 5, 50, 30) path2 = Path2D() + print("move_to") path2.move_to(100, 80) print("quadratic") path2.quadratic_curve_to(100, 100, 120, 130) @@ -1077,6 +1078,14 @@ async def test_path_object(canvas, probe): path.move_to(150, 100) print("ellipse") path.ellipse(150, 100, 20, 30, pi / 4, 0, pi, True) + 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) print("transform") canvas.root_state.translate(100, 100) From 9cab8fb9acc396c701863170e5f5151691620f5b Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 5 Feb 2026 16:06:59 +0000 Subject: [PATCH 20/46] Fix problems with degenerate paths; better coverage --- android/src/toga_android/widgets/canvas.py | 1 + cocoa/src/toga_cocoa/widgets/canvas.py | 2 +- gtk/src/toga_gtk/widgets/canvas.py | 13 ++++++++----- testbed/tests/widgets/test_canvas.py | 5 +++++ 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/android/src/toga_android/widgets/canvas.py b/android/src/toga_android/widgets/canvas.py index 0666744abb..48994f2084 100644 --- a/android/src/toga_android/widgets/canvas.py +++ b/android/src/toga_android/widgets/canvas.py @@ -43,6 +43,7 @@ def __init__(self, path=None): self._last_point = path._last_point else: self.native = NativePath() + self._last_point = None def _ensure_subpath(self, x, y): if self.native.isEmpty(): diff --git a/cocoa/src/toga_cocoa/widgets/canvas.py b/cocoa/src/toga_cocoa/widgets/canvas.py index b89f6a3697..abd8fdcf2b 100644 --- a/cocoa/src/toga_cocoa/widgets/canvas.py +++ b/cocoa/src/toga_cocoa/widgets/canvas.py @@ -390,7 +390,7 @@ class TogaCanvas(NSView): def drawRect_(self, rect: NSRect) -> None: try: self.interface.root_state._draw(Context(self.impl)) - except Exception as exc: + except Exception as exc: # pragma: no cover print(exc) @objc_method diff --git a/gtk/src/toga_gtk/widgets/canvas.py b/gtk/src/toga_gtk/widgets/canvas.py index d55cceb7c8..7965958a30 100644 --- a/gtk/src/toga_gtk/widgets/canvas.py +++ b/gtk/src/toga_gtk/widgets/canvas.py @@ -124,12 +124,15 @@ def _apply(self, steps, context): for method, *args in steps: if method == "add_path": add_steps, transform = args - context.save() - try: - context.transform(transform) + if transform is None: self._apply(add_steps, context) - finally: - context.restore() + else: + context.save() + try: + context.transform(transform) + self._apply(add_steps, context) + finally: + context.restore() else: getattr(context, method)(*args) diff --git a/testbed/tests/widgets/test_canvas.py b/testbed/tests/widgets/test_canvas.py index de734003df..8550921d56 100644 --- a/testbed/tests/widgets/test_canvas.py +++ b/testbed/tests/widgets/test_canvas.py @@ -1096,6 +1096,11 @@ async def test_path_object(canvas, probe): print("draw", _) canvas.root_state.fill(CORNFLOWERBLUE, path=path) canvas.root_state.stroke(REBECCAPURPLE, path=path) + + # stroke and fill an empty path + print("stroke and fill empty") + canvas.root_state.fill(path=Path2D()) + canvas.root_state.stroke(path=Path2D()) print("done") await probe.redraw("Image should be drawn") From 9d41940037ad5cd918b07c1cf570bd1270176998 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 5 Feb 2026 16:45:44 +0000 Subject: [PATCH 21/46] Path implementations never get called with non-None arguments. --- android/src/toga_android/widgets/canvas.py | 10 +++------- cocoa/src/toga_cocoa/widgets/canvas.py | 7 ++----- gtk/src/toga_gtk/widgets/canvas.py | 15 +++++---------- iOS/src/toga_iOS/widgets/canvas.py | 7 ++----- winforms/src/toga_winforms/widgets/canvas.py | 16 +++++----------- 5 files changed, 17 insertions(+), 38 deletions(-) diff --git a/android/src/toga_android/widgets/canvas.py b/android/src/toga_android/widgets/canvas.py index 48994f2084..6beb377d73 100644 --- a/android/src/toga_android/widgets/canvas.py +++ b/android/src/toga_android/widgets/canvas.py @@ -37,13 +37,9 @@ def matrix_from_transform(transform): class Path2D: native: NativePath - def __init__(self, path=None): - if path: - self.native = NativePath(path.native) - self._last_point = path._last_point - else: - self.native = NativePath() - self._last_point = None + def __init__(self): + self.native = NativePath() + self._last_point = None def _ensure_subpath(self, x, y): if self.native.isEmpty(): diff --git a/cocoa/src/toga_cocoa/widgets/canvas.py b/cocoa/src/toga_cocoa/widgets/canvas.py index abd8fdcf2b..c788905d76 100644 --- a/cocoa/src/toga_cocoa/widgets/canvas.py +++ b/cocoa/src/toga_cocoa/widgets/canvas.py @@ -38,11 +38,8 @@ class Path2D: - def __init__(self, path=None): - if path is None: - self.native = core_graphics.CGPathCreateMutable() - else: - self.native = core_graphics.CGPathCreateMutableCopy(path.native) + def __init__(self): + self.native = core_graphics.CGPathCreateMutable() def _ensure_subpath(self, x, y): if core_graphics.CGPathIsEmpty(self.native): diff --git a/gtk/src/toga_gtk/widgets/canvas.py b/gtk/src/toga_gtk/widgets/canvas.py index 7965958a30..2d95ff03cb 100644 --- a/gtk/src/toga_gtk/widgets/canvas.py +++ b/gtk/src/toga_gtk/widgets/canvas.py @@ -28,14 +28,8 @@ class Path2D: - def __init__(self, path=None): - if path is None: - steps = [] - else: - steps = path._steps.copy() - self._steps = steps - # Cache if C-level path. - # Any change to the path invalidates it. + def __init__(self): + self._steps = [] self._native_cached = None def _ensure_subpath(self, x, y): @@ -112,7 +106,7 @@ def is_empty(self): def apply(self, context): context.begin_path() - if self._native_cached: + 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: @@ -261,9 +255,9 @@ def fill(self, fill_rule, path=None): self.native.fill_preserve() else: current_path = self.native.copy_path() - self.begin_path() path.apply(self) self.native.fill() + # stroke clears path, so we are appending to an empty path self.native.append_path(current_path) def stroke(self, path=None): @@ -274,6 +268,7 @@ def stroke(self, path=None): 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 diff --git a/iOS/src/toga_iOS/widgets/canvas.py b/iOS/src/toga_iOS/widgets/canvas.py index b6dcb9d844..70a69ce818 100644 --- a/iOS/src/toga_iOS/widgets/canvas.py +++ b/iOS/src/toga_iOS/widgets/canvas.py @@ -46,11 +46,8 @@ class Path2D: - def __init__(self, path=None): - if path is None: - self.native = core_graphics.CGPathCreateMutable() - else: - self.native = core_graphics.CGPathCreateMutableCopy(path.native) + def __init__(self): + self.native = core_graphics.CGPathCreateMutable() def _ensure_subpath(self, x, y): if core_graphics.CGPathIsEmpty(self.native): diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index 0c6500169e..9e87395f89 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -34,17 +34,11 @@ class Path2D: - def __init__(self, path=None): - if path is None: - self.native = GraphicsPath() - self._subpath_start = None - self._subpath_end = None - self._subpath_empty = True - else: - self.native = GraphicsPath(path.native.PathPoints, path.native.PathTypes) - self._subpath_start = path._subpath_start - self._subpath_end = path._subpath_end - self._subpath_empty = path._subpath_empty + 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: From 13d62f4340143d52aac86c1540c1f0ec64704fe9 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 5 Feb 2026 17:13:32 +0000 Subject: [PATCH 22/46] Documentation, a few other fixes. --- core/src/toga/widgets/canvas/__init__.py | 3 ++- core/src/toga/widgets/canvas/path.py | 31 +++++++++++++++++------- docs/en/reference/api/widgets/canvas.md | 2 ++ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/core/src/toga/widgets/canvas/__init__.py b/core/src/toga/widgets/canvas/__init__.py index 0663b22c3a..61ce686e2d 100644 --- a/core/src/toga/widgets/canvas/__init__.py +++ b/core/src/toga/widgets/canvas/__init__.py @@ -21,7 +21,7 @@ WriteText, ) from .geometry import arc_to_bezier, sweepangle -from .path import Path2D +from .path import AddPath, Path2D from .state import ClosedPathContext, FillContext, State, StrokeContext # Make sure deprecation warnings are shown by default @@ -70,6 +70,7 @@ def __getattr__(name): "WriteText", # Path-related "Path2D", + "AddPath", # States "ClosedPathContext", "State", diff --git a/core/src/toga/widgets/canvas/path.py b/core/src/toga/widgets/canvas/path.py index 5e60ad2db2..d5b65f9e8a 100644 --- a/core/src/toga/widgets/canvas/path.py +++ b/core/src/toga/widgets/canvas/path.py @@ -22,6 +22,28 @@ class Path2D: + """An object that declares reusable shapes to draw on a Canvas + + `Path2D` shares many of the methods of the [`State`][`toga.widget.canvas.State`] + object that are used for constructing paths. Unlike paths built using `State` + 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 `State`, 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 = [] @@ -48,7 +70,6 @@ def add_path(self, path: "Path2D", transform: Sequence[float] | None = None): add_path = AddPath(path, transform) self._action_target.drawing_actions.append(add_path) self._recompilation_needed() - self._redraw_with_warning_if_state() return add_path def close_path(self): @@ -63,7 +84,6 @@ def close_path(self): close_path = ClosePath() self._action_target.drawing_actions.append(close_path) self._recompilation_needed() - self._redraw_with_warning_if_state() return close_path def move_to(self, x: float, y: float) -> MoveTo: @@ -77,7 +97,6 @@ def move_to(self, x: float, y: float) -> MoveTo: move_to = MoveTo(x, y) self._action_target.drawing_actions.append(move_to) self._recompilation_needed() - self._redraw_with_warning_if_state() return move_to def line_to(self, x: float, y: float) -> LineTo: @@ -122,7 +141,6 @@ def bezier_curve_to( bezier_curve_to = BezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) self._action_target.drawing_actions.append(bezier_curve_to) self._recompilation_needed() - self._redraw_with_warning_if_state() return bezier_curve_to def quadratic_curve_to( @@ -184,7 +202,6 @@ def arc( arc = Arc(x, y, radius, startangle, endangle, counterclockwise, anticlockwise) self._action_target.drawing_actions.append(arc) self._recompilation_needed() - self._redraw_with_warning_if_state() return arc def ellipse( @@ -233,7 +250,6 @@ def ellipse( ) self._action_target.drawing_actions.append(ellipse) self._recompilation_needed() - self._redraw_with_warning_if_state() return ellipse def rect(self, x: float, y: float, width: float, height: float) -> Rect: @@ -291,7 +307,6 @@ def round_rect( round_rect = RoundRect(x, y, width, height, radii) self._action_target.drawing_actions.append(round_rect) self._recompilation_needed() - self._redraw_with_warning_if_state() return round_rect def _redraw_with_warning_if_state(self): @@ -301,10 +316,8 @@ def _recompilation_needed(self): self._impl = None def compile(self): - print("compiling") self._impl = self.factory.Path2D() for action in self.drawing_actions: - print(action) action._draw(self._impl) return self._impl diff --git a/docs/en/reference/api/widgets/canvas.md b/docs/en/reference/api/widgets/canvas.md index f65323c523..9b7022db86 100644 --- a/docs/en/reference/api/widgets/canvas.md +++ b/docs/en/reference/api/widgets/canvas.md @@ -92,6 +92,8 @@ For detailed tutorials on the use of Canvas drawing instructions, see the MDN do options: inherited_members: True +::: toga.widgets.canvas.Path2D + ::: toga.widgets.canvas.DrawingAction ::: toga.widgets.canvas.ClosedPathContext From 2be5a1dffb4b23184c0e70bf9c0c4c34e3de9535 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Fri, 6 Feb 2026 10:06:28 +0000 Subject: [PATCH 23/46] Some fixes to Qt and Winforms backends. --- qt/src/toga_qt/widgets/canvas.py | 10 ++-------- winforms/src/toga_winforms/widgets/canvas.py | 6 ++++-- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/qt/src/toga_qt/widgets/canvas.py b/qt/src/toga_qt/widgets/canvas.py index 091821cd01..b214f41556 100644 --- a/qt/src/toga_qt/widgets/canvas.py +++ b/qt/src/toga_qt/widgets/canvas.py @@ -28,11 +28,8 @@ class Path2D: - def __init__(self, path=None): - if path is None: - self.native = QPainterPath() - else: - self.native = QPainterPath(path.native) + def __init__(self): + self.native = QPainterPath() def _ensure_point(self, x, y): if self.native.elementCount() == 0: @@ -74,9 +71,6 @@ def arc(self, x, y, radius, startangle, endangle, counterclockwise): -degrees(sweepangle(startangle, endangle, counterclockwise)), ) - def arc_to(self, x1, y1, x2, y2, radius): - raise NotImplementedError() - def ellipse( self, x, diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index 9e87395f89..2364625d05 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -57,11 +57,13 @@ def add_path(self, path, transform=None): 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: + if ( + self._subpath_start is None and path._subpath_start is not None + ): # pragma: no cover points = Array[PointF]([path._subpath_start]) matrix.TransformPoints(points) self._subpath_start = points[0] - if path._subpath_end is not None: + if path._subpath_end is not None: # pragma: no branch points = Array[PointF]([path._subpath_end]) matrix.TransformPoints(points) self._subpath_end = points[0] From dba93b57359351b51f5d788ff552783002f91886 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Fri, 6 Feb 2026 10:49:44 +0000 Subject: [PATCH 24/46] Fixes for docs; remove unused GTK methods; fix Winforms add_path. --- core/src/toga/widgets/canvas/drawingaction.py | 17 ++++++-- core/src/toga/widgets/canvas/path.py | 43 ++++++++++++++----- gtk/src/toga_gtk/widgets/canvas.py | 7 --- winforms/src/toga_winforms/widgets/canvas.py | 2 +- 4 files changed, 48 insertions(+), 21 deletions(-) diff --git a/core/src/toga/widgets/canvas/drawingaction.py b/core/src/toga/widgets/canvas/drawingaction.py index 2e8bd41b00..ccdcdbd108 100644 --- a/core/src/toga/widgets/canvas/drawingaction.py +++ b/core/src/toga/widgets/canvas/drawingaction.py @@ -67,24 +67,35 @@ class DrawingAction(ABC): [`append()`][toga.widgets.canvas.State.append] or [`insert()`][toga.widgets.canvas.State.append] methods. Their constructors take the same arguments as the corresponding [`State`][toga.widgets.canvas.State] - method, and their classes have the same names, but capitalized: + or [`Path2D`][toga.widgets.canvas.Path2D] methods, and their classes have the + same names, but capitalized: * [`toga.widgets.canvas.Arc`][toga.widgets.canvas.State.arc] - * [`toga.widgets.canvas.BeginPath`][toga.widgets.canvas.State.begin_path] * [`toga.widgets.canvas.BezierCurveTo`][toga.widgets.canvas.State.bezier_curve_to] * [`toga.widgets.canvas.ClosePath`][toga.widgets.canvas.State.close_path] * [`toga.widgets.canvas.Ellipse`][toga.widgets.canvas.State.ellipse] - * [`toga.widgets.canvas.Fill`][toga.widgets.canvas.State.fill] * [`toga.widgets.canvas.LineTo`][toga.widgets.canvas.State.line_to] * [`toga.widgets.canvas.MoveTo`][toga.widgets.canvas.State.move_to] * [`toga.widgets.canvas.QuadraticCurveTo`][toga.widgets.canvas.State.quadratic_curve_to] * [`toga.widgets.canvas.Rect`][toga.widgets.canvas.State.rect] + * [`toga.widgets.canvas.RoundRect`][toga.widgets.canvas.State.round_rect] + + The following `DrawingActions` can only be used with `State` objects and not + `Path2D`: + + * [`toga.widgets.canvas.BeginPath`][toga.widgets.canvas.State.begin_path] + * [`toga.widgets.canvas.Fill`][toga.widgets.canvas.State.fill] * [`toga.widgets.canvas.ResetTransform`][toga.widgets.canvas.State.reset_transform] * [`toga.widgets.canvas.Rotate`][toga.widgets.canvas.State.rotate] * [`toga.widgets.canvas.Scale`][toga.widgets.canvas.State.scale] * [`toga.widgets.canvas.Stroke`][toga.widgets.canvas.State.stroke] * [`toga.widgets.canvas.Translate`][toga.widgets.canvas.State.translate] * [`toga.widgets.canvas.WriteText`][toga.widgets.canvas.State.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 index d5b65f9e8a..7a489944a0 100644 --- a/core/src/toga/widgets/canvas/path.py +++ b/core/src/toga/widgets/canvas/path.py @@ -24,7 +24,7 @@ class Path2D: """An object that declares reusable shapes to draw on a Canvas - `Path2D` shares many of the methods of the [`State`][`toga.widget.canvas.State`] + `Path2D` shares many of the methods of the [`State`][toga.widgets.canvas.State] object that are used for constructing paths. Unlike paths built using `State` methods, a shape built using `Path2D` is saved and can be used repeatedly to draw the shape without having to repeat the construction. @@ -36,11 +36,11 @@ class Path2D: 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`] + 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 + name](https://developer.mozilla.org/en-US/docs/Web/API/Path2D) but with method names changed to match Python style. """ @@ -59,7 +59,9 @@ def impl(self): self.compile() return self._impl - def add_path(self, path: "Path2D", transform: Sequence[float] | None = None): + 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. @@ -309,17 +311,38 @@ def round_rect( self._recompilation_needed() return round_rect - def _redraw_with_warning_if_state(self): - pass + def compile(self): + """Compile a backend path for drawing. - def _recompilation_needed(self): - self._impl = None + 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): - def compile(self): + ``` 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) - return self._impl + + def _redraw_with_warning_if_state(self): + pass + + def _recompilation_needed(self): + self._impl = None @dataclass(repr=False) diff --git a/gtk/src/toga_gtk/widgets/canvas.py b/gtk/src/toga_gtk/widgets/canvas.py index 2d95ff03cb..bdeebe9dd1 100644 --- a/gtk/src/toga_gtk/widgets/canvas.py +++ b/gtk/src/toga_gtk/widgets/canvas.py @@ -32,10 +32,6 @@ def __init__(self): self._steps = [] self._native_cached = None - def _ensure_subpath(self, x, y): - if not self._steps: - self.move_to(x, y) - def add_path(self, path, transform=None): self._steps.append(("add_path", path._steps.copy(), transform)) self._native_cached = None @@ -101,9 +97,6 @@ def round_rect(self, x, y, width, height, radii): self._native_cached = None # extra utility methods - def is_empty(self): - return not self._steps - def apply(self, context): context.begin_path() if self._native_cached is not None: diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index 2364625d05..2b85b24337 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -50,7 +50,7 @@ def last_point(self): def add_path(self, path, transform=None): if transform is None: - self.native.AddPath(path.native) + self.native.AddPath(path.native, False) self._subpath_end = path._subpath_end else: native_path = GraphicsPath(path.native.PathPoints, path.native.PathTypes) From e946caf12ffa8d29eadc6fcda607efdd1c237d6e Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Fri, 6 Feb 2026 11:11:52 +0000 Subject: [PATCH 25/46] Try transpose of Android matrix. --- android/src/toga_android/widgets/canvas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/src/toga_android/widgets/canvas.py b/android/src/toga_android/widgets/canvas.py index 6beb377d73..c8f06abe65 100644 --- a/android/src/toga_android/widgets/canvas.py +++ b/android/src/toga_android/widgets/canvas.py @@ -30,7 +30,7 @@ def matrix_from_transform(transform): a, b, c, d, e, f = transform matrix = Matrix() - matrix.setValues([a, b, 0, c, d, 0, e, f, 1]) + matrix.setValues([a, c, e, b, d, f, 0, 0, 1]) return matrix From afce63a0f29c7cce4ecb6588356fcb640efb813a Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Fri, 6 Feb 2026 11:52:40 +0000 Subject: [PATCH 26/46] Add probe to see why line is not being run on Windows. --- winforms/src/toga_winforms/widgets/canvas.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index 2b85b24337..febaecac15 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -50,8 +50,14 @@ def last_point(self): def add_path(self, path, transform=None): if transform is None: - self.native.AddPath(path.native, False) - self._subpath_end = path._subpath_end + try: + self.native.AddPath(path.native, False) + self._subpath_end = path._subpath_end + except Exception as exc: + print(exc) + from warnings import warn + + warn("Drawing failed!", stacklevel=2) else: native_path = GraphicsPath(path.native.PathPoints, path.native.PathTypes) matrix = Matrix(*transform) From 032f24a103b77174ae93ee2e5b46d0b5d9097850 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Fri, 6 Feb 2026 15:09:35 +0000 Subject: [PATCH 27/46] Remove #pragma directives; refactor add_path on windforms. --- core/src/toga/widgets/canvas/path.py | 1 - winforms/src/toga_winforms/widgets/canvas.py | 19 +++++++------------ 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/core/src/toga/widgets/canvas/path.py b/core/src/toga/widgets/canvas/path.py index 7a489944a0..c52bd0b088 100644 --- a/core/src/toga/widgets/canvas/path.py +++ b/core/src/toga/widgets/canvas/path.py @@ -1,4 +1,3 @@ -# pragma: no cover import copy from collections.abc import Iterable, Sequence from dataclasses import dataclass diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index febaecac15..1e95a6b74d 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -49,27 +49,22 @@ 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: - try: - self.native.AddPath(path.native, False) - self._subpath_end = path._subpath_end - except Exception as exc: - print(exc) - from warnings import warn - - warn("Drawing failed!", stacklevel=2) + 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 + if self._subpath_start is None and path._subpath_start is not None: points = Array[PointF]([path._subpath_start]) matrix.TransformPoints(points) self._subpath_start = points[0] - if path._subpath_end is not None: # pragma: no branch + if path._subpath_end is not None: points = Array[PointF]([path._subpath_end]) matrix.TransformPoints(points) self._subpath_end = points[0] From 041accdb734ac5b3bc580f701673fb0f5cb7b312 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Sat, 7 Feb 2026 10:52:46 +0000 Subject: [PATCH 28/46] Refine pragma: no cover; change copy behaviour based on comments in PR. --- core/src/toga/widgets/canvas/drawingaction.py | 4 ++-- core/src/toga/widgets/canvas/path.py | 9 +-------- core/src/toga/widgets/canvas/state.py | 10 +++++++++- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/core/src/toga/widgets/canvas/drawingaction.py b/core/src/toga/widgets/canvas/drawingaction.py index ccdcdbd108..604d8078c6 100644 --- a/core/src/toga/widgets/canvas/drawingaction.py +++ b/core/src/toga/widgets/canvas/drawingaction.py @@ -168,7 +168,7 @@ def _draw(self, context: Any) -> None: if self.path is None: path_impl = None else: - path_impl = self.path.impl # pragma: no cover + path_impl = self.path.impl context.fill(self.fill_rule, path_impl) context.restore() @@ -191,7 +191,7 @@ def _draw(self, context: Any) -> None: if self.path is None: path_impl = None else: - path_impl = self.path.impl # pragma: no cover + path_impl = self.path.impl context.stroke(path_impl) context.restore() diff --git a/core/src/toga/widgets/canvas/path.py b/core/src/toga/widgets/canvas/path.py index c52bd0b088..6bdf662cc7 100644 --- a/core/src/toga/widgets/canvas/path.py +++ b/core/src/toga/widgets/canvas/path.py @@ -1,4 +1,3 @@ -import copy from collections.abc import Iterable, Sequence from dataclasses import dataclass from math import pi @@ -47,7 +46,7 @@ def __init__(self, path: "Path2D | None" = None): if path is None: self.drawing_actions = [] else: - self.drawing_actions = copy.deepcopy(path.drawing_actions) + self.drawing_actions = path.drawing_actions.copy() self._action_target = self self._impl = None self.factory = get_platform_factory() @@ -111,7 +110,6 @@ def line_to(self, x: float, y: float) -> LineTo: line_to = LineTo(x, y) self._action_target.drawing_actions.append(line_to) self._recompilation_needed() - self._redraw_with_warning_if_state() return line_to def bezier_curve_to( @@ -170,7 +168,6 @@ def quadratic_curve_to( quadratic_curve_to = QuadraticCurveTo(cpx, cpy, x, y) self._action_target.drawing_actions.append(quadratic_curve_to) self._recompilation_needed() - self._redraw_with_warning_if_state() return quadratic_curve_to def arc( @@ -267,7 +264,6 @@ def rect(self, x: float, y: float, width: float, height: float) -> Rect: rect = Rect(x, y, width, height) self._action_target.drawing_actions.append(rect) self._recompilation_needed() - self._redraw_with_warning_if_state() return rect def round_rect( @@ -337,9 +333,6 @@ def compile(self): for action in self.drawing_actions: action._draw(self._impl) - def _redraw_with_warning_if_state(self): - pass - def _recompilation_needed(self): self._impl = None diff --git a/core/src/toga/widgets/canvas/state.py b/core/src/toga/widgets/canvas/state.py index 3d9d74d33a..909fa00841 100644 --- a/core/src/toga/widgets/canvas/state.py +++ b/core/src/toga/widgets/canvas/state.py @@ -35,12 +35,12 @@ WriteText, ) from .geometry import CornerRadiusT +from .path import Path2D if TYPE_CHECKING: from toga.colors import ColorT from .canvas import Canvas - from .path import Path2D # Make sure deprecation warnings are shown by default warnings.filterwarnings("default", category=DeprecationWarning) @@ -308,9 +308,13 @@ def fill( :param fill_rule: `nonzero` is the non-zero winding rule; `evenodd` is the even-odd winding rule. :param color: The fill color. + :param path: An optional Path2D object to fill. :returns: The `Fill` [`DrawingAction`][toga.widgets.canvas.DrawingAction] for the operation. """ + if path is not None: + # copy the current state of the path. + path = Path2D(path) # pragma: no cover fill = Fill(color, fill_rule, path) self._action_target.append(fill) return fill @@ -328,9 +332,13 @@ def stroke( :param line_width: The width of the stroke. :param line_dash: The dash pattern to follow when drawing the line, expressed as alternating lengths of dashes and spaces. The default is a solid line. + :param path: An optional Path2D object to draw. :returns: The `Stroke` [`DrawingAction`][toga.widgets.canvas.DrawingAction] for the operation. """ + if path is not None: + # copy the current state of the path. + path = Path2D(path) # pragma: no cover stroke = Stroke(color, line_width, line_dash, path) self._action_target.append(stroke) return stroke From 3102edd83002ca409cf39c69f7c8908048d0f33a Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Sat, 7 Feb 2026 11:18:04 +0000 Subject: [PATCH 29/46] Skip a couple more lines. --- core/src/toga/widgets/canvas/drawingaction.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/toga/widgets/canvas/drawingaction.py b/core/src/toga/widgets/canvas/drawingaction.py index 604d8078c6..ccdcdbd108 100644 --- a/core/src/toga/widgets/canvas/drawingaction.py +++ b/core/src/toga/widgets/canvas/drawingaction.py @@ -168,7 +168,7 @@ def _draw(self, context: Any) -> None: if self.path is None: path_impl = None else: - path_impl = self.path.impl + path_impl = self.path.impl # pragma: no cover context.fill(self.fill_rule, path_impl) context.restore() @@ -191,7 +191,7 @@ def _draw(self, context: Any) -> None: if self.path is None: path_impl = None else: - path_impl = self.path.impl + path_impl = self.path.impl # pragma: no cover context.stroke(path_impl) context.restore() From c414af5f57135ff08fd255813ad89781812f9565 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Sun, 19 Apr 2026 10:19:38 +0100 Subject: [PATCH 30/46] Fixes for core tests. --- android/pyproject.toml | 3 ++ cocoa/pyproject.toml | 3 ++ core/src/toga/widgets/canvas/path.py | 8 ++++-- core/src/toga/widgets/canvas/state.py | 28 +++++++++++++------ .../tests/widgets/canvas/test_deprecations.py | 8 +++--- .../widgets/canvas/test_state_objects.py | 28 +++++++++++-------- dummy/pyproject.toml | 3 ++ gtk/pyproject.toml | 3 ++ iOS/pyproject.toml | 3 ++ qt/pyproject.toml | 3 ++ winforms/pyproject.toml | 3 ++ 11 files changed, 67 insertions(+), 26 deletions(-) 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/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/core/src/toga/widgets/canvas/path.py b/core/src/toga/widgets/canvas/path.py index 6bdf662cc7..1a125e9b6b 100644 --- a/core/src/toga/widgets/canvas/path.py +++ b/core/src/toga/widgets/canvas/path.py @@ -2,12 +2,11 @@ from dataclasses import dataclass from math import pi -from toga.platform import get_platform_factory +from toga.platform import get_factory from .drawingaction import ( Arc, BezierCurveTo, - ClosePath, DrawingAction, Ellipse, LineTo, @@ -49,7 +48,7 @@ def __init__(self, path: "Path2D | None" = None): self.drawing_actions = path.drawing_actions.copy() self._action_target = self self._impl = None - self.factory = get_platform_factory() + self.factory = get_factory() @property def impl(self): @@ -81,6 +80,9 @@ def close_path(self): :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() diff --git a/core/src/toga/widgets/canvas/state.py b/core/src/toga/widgets/canvas/state.py index 0dd2466cf5..782218cd2f 100644 --- a/core/src/toga/widgets/canvas/state.py +++ b/core/src/toga/widgets/canvas/state.py @@ -330,14 +330,15 @@ def fill( :param fill_rule: `nonzero` is the non-zero winding rule; `evenodd` is the even-odd winding rule. :param color: The fill color. - :param path: An optional Path2D object to fill. + :param path: An optional Path2D object to fill. Ignored when used as a + context manager. :returns: The `Fill` [`DrawingAction`][toga.widgets.canvas.DrawingAction] for the operation. """ if path is not None: # copy the current state of the path. - path = Path2D(path) # pragma: no cover - fill = Fill(color, fill_rule) + path = Path2D(path) + fill = Fill(color, fill_rule, path) self._add_to_target(fill) self._redraw_with_warning_if_state() return fill @@ -359,14 +360,15 @@ def stroke( :param line_width: The width of the stroke. :param line_dash: The dash pattern to follow when drawing the line, expressed as alternating lengths of dashes and spaces. The default is a solid line. - :param path: An optional Path2D object to draw. + :param path: An optional Path2D object to draw. Ignored when used as a + context manager. :returns: The `Stroke` [`DrawingAction`][toga.widgets.canvas.DrawingAction] for the operation. """ if path is not None: # copy the current state of the path. - path = Path2D(path) # pragma: no cover - stroke = Stroke(color, line_width, line_dash) + path = Path2D(path) + stroke = Stroke(color, line_width, line_dash, path) self._add_to_target(stroke) self._redraw_with_warning_if_state() return stroke @@ -835,6 +837,7 @@ def _draw(self, context: Any) -> None: class Fill(BaseState): color: ColorT | None = color_property() fill_rule: FillRule = FillRule.NONZERO + path: Path2D | None = None def __post_init__(self): super().__init__() @@ -853,8 +856,12 @@ def _draw(self, context: Any) -> None: action._draw(context) context.in_fill = False # Backwards compatibility for Toga <= 0.5.3 + # Ignore path when used as context manager + path = None + else: + path = self.path - context.fill(self.fill_rule) + context.fill(fill_rule=self.fill_rule, path=path) context.restore() @@ -863,6 +870,7 @@ class Stroke(BaseState): color: ColorT | None = color_property() line_width: float | None = None line_dash: list[float] | None = None + path: Path2D | None = None def __post_init__(self): super().__init__() @@ -885,6 +893,10 @@ 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 + else: + path = self.path - context.stroke() + context.stroke(path=path) context.restore() diff --git a/core/tests/widgets/canvas/test_deprecations.py b/core/tests/widgets/canvas/test_deprecations.py index 30a5ea8d8a..3f07fcf0fd 100644 --- a/core/tests/widgets/canvas/test_deprecations.py +++ b/core/tests/widgets/canvas/test_deprecations.py @@ -291,7 +291,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}), @@ -329,7 +329,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}), @@ -361,7 +361,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}), @@ -414,7 +414,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_state_objects.py b/core/tests/widgets/canvas/test_state_objects.py index 9ea7c8e8b2..5e14403223 100644 --- a/core/tests/widgets/canvas/test_state_objects.py +++ b/core/tests/widgets/canvas/test_state_objects.py @@ -62,31 +62,31 @@ def test_closed_path(widget): # Defaults ( {}, - "color=None, fill_rule=FillRule.NONZERO", + "color=None, fill_rule=FillRule.NONZERO, path=None", {"color": None, "fill_rule": FillRule.NONZERO}, ), # Color ( {"color": REBECCAPURPLE}, - (f"color={REBECCA_PURPLE_COLOR!r}, fill_rule=FillRule.NONZERO"), + (f"color={REBECCA_PURPLE_COLOR!r}, fill_rule=FillRule.NONZERO, path=None"), {"color": REBECCA_PURPLE_COLOR, "fill_rule": FillRule.NONZERO}, ), # Explicitly don't set color ( {"color": None}, - "color=None, fill_rule=FillRule.NONZERO", + "color=None, fill_rule=FillRule.NONZERO, path=None", {"color": None, "fill_rule": FillRule.NONZERO}, ), # Fill Rule ( {"fill_rule": FillRule.EVENODD}, - "color=None, fill_rule=FillRule.EVENODD", + "color=None, fill_rule=FillRule.EVENODD, path=None", {"color": None, "fill_rule": FillRule.EVENODD}, ), # All args ( {"color": REBECCAPURPLE, "fill_rule": FillRule.EVENODD}, - f"color={REBECCA_PURPLE_COLOR!r}, fill_rule=FillRule.EVENODD", + (f"color={REBECCA_PURPLE_COLOR!r}, fill_rule=FillRule.EVENODD, path=None"), {"color": REBECCA_PURPLE_COLOR, "fill_rule": FillRule.EVENODD}, ), ], @@ -127,25 +127,28 @@ def test_fill(widget, kwargs, args_repr, properties): # Defaults ( {}, - "color=None, line_width=None, line_dash=None", + "color=None, line_width=None, line_dash=None, path=None", {"color": None, "line_width": None, "line_dash": None}, ), # Color ( {"color": REBECCAPURPLE}, - (f"color={REBECCA_PURPLE_COLOR!r}, line_width=None, line_dash=None"), + ( + f"color={REBECCA_PURPLE_COLOR!r}, line_width=None, line_dash=None, " + "path=None" + ), {"color": REBECCA_PURPLE_COLOR, "line_width": None, "line_dash": None}, ), # Explicitly don't set color ( {"color": None}, - "color=None, line_width=None, line_dash=None", + "color=None, line_width=None, line_dash=None, path=None", {"color": None, "line_width": None, "line_dash": None}, ), # Line width ( {"line_width": 4.5}, - "color=None, line_width=4.500, line_dash=None", + "color=None, line_width=4.500, line_dash=None, path=None", {"color": None, "line_width": 4.5, "line_dash": None}, ), # Line dash @@ -153,13 +156,16 @@ def test_fill(widget, kwargs, args_repr, properties): { "line_dash": [2, 7], }, - "color=None, line_width=None, line_dash=[2, 7]", + "color=None, line_width=None, line_dash=[2, 7], path=None", {"color": None, "line_width": None, "line_dash": [2, 7]}, ), # All args ( {"color": REBECCAPURPLE, "line_width": 4.5, "line_dash": [2, 7]}, - (f"color={REBECCA_PURPLE_COLOR!r}, line_width=4.500, line_dash=[2, 7]"), + ( + f"color={REBECCA_PURPLE_COLOR!r}, line_width=4.500, line_dash=[2, 7], " + "path=None" + ), {"color": REBECCA_PURPLE_COLOR, "line_width": 4.5, "line_dash": [2, 7]}, ), ], 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/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/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/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/winforms/pyproject.toml b/winforms/pyproject.toml index 0c06d60ca0..f32a55a0b4 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.distutils.bdist_wheel] # This backend needs to be tagged `py3-none-win_arm64`. All the code in this backend is # pure Python, *but* it contains pre-compiled binary libraries to support WebView2. From 471ff2468cb4b965c4a51250d715deceb5856a85 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Sun, 19 Apr 2026 11:43:06 +0100 Subject: [PATCH 31/46] Fix coverage, initial tests for path in fill/stroke. --- core/src/toga/widgets/canvas/path.py | 3 +++ .../widgets/canvas/test_state_objects.py | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/core/src/toga/widgets/canvas/path.py b/core/src/toga/widgets/canvas/path.py index 1a125e9b6b..6a11a27e09 100644 --- a/core/src/toga/widgets/canvas/path.py +++ b/core/src/toga/widgets/canvas/path.py @@ -338,6 +338,9 @@ def compile(self): def _recompilation_needed(self): self._impl = None + def __repr__(self): + return f"{self.__class__.__name__}()" + @dataclass(repr=False) class AddPath(DrawingAction): diff --git a/core/tests/widgets/canvas/test_state_objects.py b/core/tests/widgets/canvas/test_state_objects.py index 5e14403223..686409d185 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, @@ -89,6 +90,15 @@ def test_closed_path(widget): (f"color={REBECCA_PURPLE_COLOR!r}, fill_rule=FillRule.EVENODD, path=None"), {"color": REBECCA_PURPLE_COLOR, "fill_rule": FillRule.EVENODD}, ), + # Path ignored in context manager + ( + {"color": REBECCAPURPLE, "fill_rule": FillRule.EVENODD, "path": Path2D()}, + ( + f"color={REBECCA_PURPLE_COLOR!r}, fill_rule=FillRule.EVENODD, " + "path=Path2D()" + ), + {"color": REBECCA_PURPLE_COLOR, "fill_rule": FillRule.EVENODD}, + ), ], ) def test_fill(widget, kwargs, args_repr, properties): @@ -168,6 +178,20 @@ def test_fill(widget, kwargs, args_repr, properties): ), {"color": REBECCA_PURPLE_COLOR, "line_width": 4.5, "line_dash": [2, 7]}, ), + # Path ignored in context manager + ( + { + "color": REBECCAPURPLE, + "line_width": 4.5, + "line_dash": [2, 7], + "path": Path2D(), + }, + ( + f"color={REBECCA_PURPLE_COLOR!r}, line_width=4.500, line_dash=[2, 7], " + "path=Path2D()" + ), + {"color": REBECCA_PURPLE_COLOR, "line_width": 4.5, "line_dash": [2, 7]}, + ), ], ) def test_stroke(widget, kwargs, args_repr, properties): From f29bb0135287f6796bec09231d9b3244a045b4df Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Sun, 19 Apr 2026 12:02:34 +0100 Subject: [PATCH 32/46] Fix path tests. --- testbed/tests/widgets/canvas/test_canvas.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/testbed/tests/widgets/canvas/test_canvas.py b/testbed/tests/widgets/canvas/test_canvas.py index f7ea779225..b0edb4fea3 100644 --- a/testbed/tests/widgets/canvas/test_canvas.py +++ b/testbed/tests/widgets/canvas/test_canvas.py @@ -1010,19 +1010,19 @@ async def test_path_object(canvas, probe): Path2D(path2) print("transform") - canvas.root_state.translate(100, 100) - canvas.root_state.scale(0.5, 0.5) + canvas.translate(100, 100) + canvas.scale(0.5, 0.5) for _ in range(12): - canvas.root_state.rotate(pi / 6) - canvas.root_state.scale(0.95, 0.95) + canvas.rotate(pi / 6) + canvas.scale(0.95, 0.95) print("draw", _) - canvas.root_state.fill(CORNFLOWERBLUE, path=path) - canvas.root_state.stroke(REBECCAPURPLE, path=path) + canvas.fill(CORNFLOWERBLUE, path=path) + canvas.stroke(REBECCAPURPLE, path=path) # stroke and fill an empty path print("stroke and fill empty") - canvas.root_state.fill(path=Path2D()) - canvas.root_state.stroke(path=Path2D()) + canvas.fill(path=Path2D()) + canvas.stroke(path=Path2D()) print("done") await probe.redraw("Image should be drawn") From 83290b1ec626aba15119a46810be507fd36a4867 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Sun, 19 Apr 2026 12:27:32 +0100 Subject: [PATCH 33/46] Pass implementation when filling/stroking paths --- core/src/toga/widgets/canvas/state.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/core/src/toga/widgets/canvas/state.py b/core/src/toga/widgets/canvas/state.py index 782218cd2f..f994dfb1d9 100644 --- a/core/src/toga/widgets/canvas/state.py +++ b/core/src/toga/widgets/canvas/state.py @@ -858,8 +858,10 @@ def _draw(self, context: Any) -> None: 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 = self.path + path = None context.fill(fill_rule=self.fill_rule, path=path) context.restore() @@ -895,8 +897,10 @@ def _draw(self, context: Any) -> None: 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 = self.path + path = None context.stroke(path=path) context.restore() From d34bf9077d3666a3caa23b72962b59d60008f3b5 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Fri, 24 Apr 2026 09:22:50 +0100 Subject: [PATCH 34/46] Fix bad manual merge. --- core/tests/widgets/canvas/test_state_objects.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/core/tests/widgets/canvas/test_state_objects.py b/core/tests/widgets/canvas/test_state_objects.py index ea51c33dab..4b4bdeacbb 100644 --- a/core/tests/widgets/canvas/test_state_objects.py +++ b/core/tests/widgets/canvas/test_state_objects.py @@ -168,10 +168,7 @@ def test_fill(widget, kwargs, args_repr, properties): # Explicitly don't set stroke_style ( {"stroke_style": None}, - ( - f"path=None, stroke_style={REBECCA_PURPLE_COLOR!r}, " - "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 From e503dd90f17f74f9f842a7271631cf9aa4217d48 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Fri, 24 Apr 2026 09:46:00 +0100 Subject: [PATCH 35/46] Error if path is provided for fill/stroke contexts. --- core/src/toga/widgets/canvas/state.py | 14 +++++++++++++ .../widgets/canvas/test_state_objects.py | 20 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/core/src/toga/widgets/canvas/state.py b/core/src/toga/widgets/canvas/state.py index 949b08ce81..5d735971ff 100644 --- a/core/src/toga/widgets/canvas/state.py +++ b/core/src/toga/widgets/canvas/state.py @@ -987,6 +987,13 @@ def _draw(self, context: Any) -> None: 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): @@ -1034,3 +1041,10 @@ def _draw(self, context: Any) -> None: 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_state_objects.py b/core/tests/widgets/canvas/test_state_objects.py index 4b4bdeacbb..dc31239113 100644 --- a/core/tests/widgets/canvas/test_state_objects.py +++ b/core/tests/widgets/canvas/test_state_objects.py @@ -143,6 +143,16 @@ 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", [ @@ -241,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: From 7c8763df3b90c1a3e6f5d739a840a3aaad87f683 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Fri, 24 Apr 2026 10:01:34 +0100 Subject: [PATCH 36/46] Fix fill/stroke args in test. --- testbed/tests/widgets/canvas/test_canvas.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/testbed/tests/widgets/canvas/test_canvas.py b/testbed/tests/widgets/canvas/test_canvas.py index 7e9283d471..5fe8ed0d50 100644 --- a/testbed/tests/widgets/canvas/test_canvas.py +++ b/testbed/tests/widgets/canvas/test_canvas.py @@ -1018,8 +1018,8 @@ async def test_path_object(canvas, probe): canvas.rotate(pi / 6) canvas.scale(0.95, 0.95) print("draw", _) - canvas.fill(CORNFLOWERBLUE, path=path) - canvas.stroke(REBECCAPURPLE, path=path) + canvas.fill(path=path, fill_style=CORNFLOWERBLUE) + canvas.stroke(path, color=REBECCAPURPLE) # stroke and fill an empty path print("stroke and fill empty") From 1a97e7d5f0d58b116af29fb5b0007f12105ccacb Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Fri, 24 Apr 2026 10:19:31 +0100 Subject: [PATCH 37/46] Fix docs and fix winforms coverage. --- core/src/toga/widgets/canvas/path.py | 4 ++-- docs/en/reference/api/widgets/canvas.md | 1 + winforms/src/toga_winforms/widgets/canvas.py | 14 +++++++++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/core/src/toga/widgets/canvas/path.py b/core/src/toga/widgets/canvas/path.py index 6a11a27e09..1124ea41c1 100644 --- a/core/src/toga/widgets/canvas/path.py +++ b/core/src/toga/widgets/canvas/path.py @@ -21,8 +21,8 @@ class Path2D: """An object that declares reusable shapes to draw on a Canvas - `Path2D` shares many of the methods of the [`State`][toga.widgets.canvas.State] - object that are used for constructing paths. Unlike paths built using `State` + `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. diff --git a/docs/en/reference/api/widgets/canvas.md b/docs/en/reference/api/widgets/canvas.md index 5f9b186714..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 diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index 1e95a6b74d..aca8c084a4 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -52,7 +52,9 @@ def add_path(self, path, transform=None): if path.native.PointCount == 0: # Nothing to do return - if transform is None: + 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: @@ -60,11 +62,17 @@ def add_path(self, path, transform=None): 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: + 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: + 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] From aaae4ea5e46b7ea9b3402ff025eb4377ce55b6a7 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Fri, 24 Apr 2026 13:31:50 +0100 Subject: [PATCH 38/46] Use c_bool instead of c_int for call to CGPathAddArc(). --- cocoa/src/toga_cocoa/libs/core_graphics.py | 2 +- iOS/src/toga_iOS/libs/core_graphics.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cocoa/src/toga_cocoa/libs/core_graphics.py b/cocoa/src/toga_cocoa/libs/core_graphics.py index 2770207691..6c4c407d79 100644 --- a/cocoa/src/toga_cocoa/libs/core_graphics.py +++ b/cocoa/src/toga_cocoa/libs/core_graphics.py @@ -81,7 +81,7 @@ class CGAffineTransform(Structure): CGFloat, CGFloat, CGFloat, - c_int, + c_bool, ] core_graphics.CGPathAddCurveToPoint.restype = c_void_p core_graphics.CGPathAddCurveToPoint.argtypes = [ diff --git a/iOS/src/toga_iOS/libs/core_graphics.py b/iOS/src/toga_iOS/libs/core_graphics.py index 1e931932d8..f44db9adf8 100644 --- a/iOS/src/toga_iOS/libs/core_graphics.py +++ b/iOS/src/toga_iOS/libs/core_graphics.py @@ -81,7 +81,7 @@ class CGAffineTransform(Structure): CGFloat, CGFloat, CGFloat, - c_int, + c_bool, ] core_graphics.CGPathAddCurveToPoint.restype = c_void_p core_graphics.CGPathAddCurveToPoint.argtypes = [ From 3aa25b1cb965ebabc07c05a7675642f3da1c4404 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Fri, 24 Apr 2026 14:02:01 +0100 Subject: [PATCH 39/46] Skip path tests on macOS Intel for now; other fixes. --- core/src/toga/widgets/canvas/path.py | 4 +-- iOS/src/toga_iOS/widgets/canvas.py | 2 +- testbed/tests/widgets/canvas/test_canvas.py | 31 ++++++++++++--------- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/core/src/toga/widgets/canvas/path.py b/core/src/toga/widgets/canvas/path.py index 1124ea41c1..1f489d7f44 100644 --- a/core/src/toga/widgets/canvas/path.py +++ b/core/src/toga/widgets/canvas/path.py @@ -29,9 +29,9 @@ class Path2D: To draw a `Path2D`, call `fill` or `stroke` with the path object as its `path` argument. - Like a `State`, a `Path2D` is built from a sequence of `DrawingAction` objects + 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 + 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. diff --git a/iOS/src/toga_iOS/widgets/canvas.py b/iOS/src/toga_iOS/widgets/canvas.py index 70a69ce818..77b21854b1 100644 --- a/iOS/src/toga_iOS/widgets/canvas.py +++ b/iOS/src/toga_iOS/widgets/canvas.py @@ -87,7 +87,7 @@ def quadratic_curve_to(self, cpx, cpy, x, y): 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 + # iOS Path is using a flipped coordinate system, so clockwise # is actually counterclockwise clockwise = counterclockwise core_graphics.CGPathAddArc( diff --git a/testbed/tests/widgets/canvas/test_canvas.py b/testbed/tests/widgets/canvas/test_canvas.py index 5fe8ed0d50..30622a745a 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 @@ -972,36 +973,41 @@ async def test_draw_image_in_rect(canvas, probe): assert_reference(probe, "draw_image_in_rect", threshold=0.05) +@pytest.mark.skipif( + condition=platform.system() == "Darwin" and platform.machine() == "x86_64", + reason="Calls to CGPathAddArc with counterclockwise True fail on Mac Intel", +) async def test_path_object(canvas, probe): path = Path2D() # exercise all of the Path methods - print("line_to without point") + # line_to without point path.line_to(10, 15) - print("line_to with point") + # line_to with point path.line_to(20, 30) - print("bezier") + # bezier path.bezier_curve_to(20, 10, 40, 15, 50, 10) - print("close") + # close path.close_path() - print("rect") + # rect path.rect(5, 5, 50, 30) path2 = Path2D() - print("move_to") + # move_to path2.move_to(100, 80) - print("quadratic") + # quadratic path2.quadratic_curve_to(100, 100, 120, 130) path2.quadratic_curve_to(150, 120, 150, 100) - print("arc") + # arc path2.arc(130, 100, 20, endangle=pi / 3) - print("add_path") + # add_path path.add_path(path2, (0.5, 0.0, 0.0, 0.75, 30, 10)) path.move_to(150, 100) - print("ellipse") + # 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 @@ -1011,7 +1017,7 @@ async def test_path_object(canvas, probe): # clone a path Path2D(path2) - print("transform") + # transform canvas.translate(100, 100) canvas.scale(0.5, 0.5) for _ in range(12): @@ -1022,10 +1028,9 @@ async def test_path_object(canvas, probe): canvas.stroke(path, color=REBECCAPURPLE) # stroke and fill an empty path - print("stroke and fill empty") canvas.fill(path=Path2D()) canvas.stroke(path=Path2D()) - print("done") + # done await probe.redraw("Image should be drawn") assert_reference(probe, "path_object", threshold=0.05) From 667c8fe79e7d012929365adcb1ef80e9e2bcabb2 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Sat, 25 Apr 2026 07:01:28 +0100 Subject: [PATCH 40/46] Try explicit type conversion for CGPathAddArc. --- cocoa/src/toga_cocoa/widgets/canvas.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cocoa/src/toga_cocoa/widgets/canvas.py b/cocoa/src/toga_cocoa/widgets/canvas.py index c788905d76..9ec1120a5a 100644 --- a/cocoa/src/toga_cocoa/widgets/canvas.py +++ b/cocoa/src/toga_cocoa/widgets/canvas.py @@ -108,7 +108,14 @@ def ellipse( 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 + self.native, + transform, + 0, + 0, + 1.0, + startangle, + endangle, + int(counterclockwise), ) def rect(self, x, y, width, height): From 3cb0531a44204698aa31ce6275a06b96a54e6471 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Sat, 25 Apr 2026 07:07:03 +0100 Subject: [PATCH 41/46] Actually run path test on macOS intel. --- testbed/tests/widgets/canvas/test_canvas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testbed/tests/widgets/canvas/test_canvas.py b/testbed/tests/widgets/canvas/test_canvas.py index 30622a745a..a10fc03b90 100644 --- a/testbed/tests/widgets/canvas/test_canvas.py +++ b/testbed/tests/widgets/canvas/test_canvas.py @@ -973,7 +973,7 @@ async def test_draw_image_in_rect(canvas, probe): assert_reference(probe, "draw_image_in_rect", threshold=0.05) -@pytest.mark.skipif( +@pytest.mark.xfail( condition=platform.system() == "Darwin" and platform.machine() == "x86_64", reason="Calls to CGPathAddArc with counterclockwise True fail on Mac Intel", ) From 94c2e27ef029b9e74d12f6f29913c56d4435dd9c Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Sat, 25 Apr 2026 07:56:19 +0100 Subject: [PATCH 42/46] Detect bad inputs, fix tests and coverage appropriately. --- cocoa/src/toga_cocoa/widgets/canvas.py | 26 +++++++++++++-------- testbed/tests/testbed.py | 3 +++ testbed/tests/widgets/canvas/test_canvas.py | 2 +- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/canvas.py b/cocoa/src/toga_cocoa/widgets/canvas.py index 9ec1120a5a..8c7b9410b9 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 @@ -107,16 +108,21 @@ def ellipse( 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), - ) + 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 + 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) 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 a10fc03b90..fc9cd82208 100644 --- a/testbed/tests/widgets/canvas/test_canvas.py +++ b/testbed/tests/widgets/canvas/test_canvas.py @@ -975,7 +975,7 @@ async def test_draw_image_in_rect(canvas, probe): @pytest.mark.xfail( condition=platform.system() == "Darwin" and platform.machine() == "x86_64", - reason="Calls to CGPathAddArc with counterclockwise True fail on Mac Intel", + reason="Calls to CGPathAddArc with counterclockwise True segfaults on Mac Intel", ) async def test_path_object(canvas, probe): path = Path2D() From 2a4cd3b9906e8a0dd11df1f5d9504ad9b2a2c0a9 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Sat, 25 Apr 2026 08:40:56 +0100 Subject: [PATCH 43/46] Fix branching. --- cocoa/src/toga_cocoa/widgets/canvas.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/canvas.py b/cocoa/src/toga_cocoa/widgets/canvas.py index 8c7b9410b9..ae89ed2e20 100644 --- a/cocoa/src/toga_cocoa/widgets/canvas.py +++ b/cocoa/src/toga_cocoa/widgets/canvas.py @@ -105,14 +105,18 @@ def ellipse( 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) - if counterclockwise and platform.machine == "x86_64": # pragma: no-cover-if-arm + 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, From cc461d168f31aab72b7cfd012706bd5832a5c63e Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Sat, 25 Apr 2026 09:17:25 +0100 Subject: [PATCH 44/46] Always skip ellipses on intel macOS. --- cocoa/src/toga_cocoa/widgets/canvas.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/canvas.py b/cocoa/src/toga_cocoa/widgets/canvas.py index ae89ed2e20..fc03ec846c 100644 --- a/cocoa/src/toga_cocoa/widgets/canvas.py +++ b/cocoa/src/toga_cocoa/widgets/canvas.py @@ -105,9 +105,7 @@ def ellipse( endangle, counterclockwise, ): - if ( - counterclockwise and platform.machine() == "x86_64" - ): # pragma: no-cover-if-arm + if platform.machine() == "x86_64": # pragma: no-cover-if-arm # Persistent segfaults in CGPathAddArc with counterclockwise True on intel # skip for now return From a4e8e745ee5ce7c1dfece43c5cd64451f89a7b04 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Sat, 25 Apr 2026 10:02:10 +0100 Subject: [PATCH 45/46] Fix segfault when adding empty path on macOS Intel. --- cocoa/src/toga_cocoa/widgets/canvas.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cocoa/src/toga_cocoa/widgets/canvas.py b/cocoa/src/toga_cocoa/widgets/canvas.py index fc03ec846c..527f0e5a97 100644 --- a/cocoa/src/toga_cocoa/widgets/canvas.py +++ b/cocoa/src/toga_cocoa/widgets/canvas.py @@ -47,6 +47,9 @@ def _ensure_subpath(self, x, y): self.move_to(x, y) def add_path(self, path, transform=None): + if path.is_empty(): + # No-op, adding empty path segfaults on intel + return if transform is None: transform = IDENTITY else: From 4695618a52fcca699c264b3d3aa2f6f5b6f3aac1 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Sat, 25 Apr 2026 10:15:37 +0100 Subject: [PATCH 46/46] Try some more experiments. --- cocoa/src/toga_cocoa/widgets/canvas.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/canvas.py b/cocoa/src/toga_cocoa/widgets/canvas.py index 527f0e5a97..a84b63bf4c 100644 --- a/cocoa/src/toga_cocoa/widgets/canvas.py +++ b/cocoa/src/toga_cocoa/widgets/canvas.py @@ -47,14 +47,13 @@ def _ensure_subpath(self, x, y): self.move_to(x, y) def add_path(self, path, transform=None): - if path.is_empty(): - # No-op, adding empty path segfaults on intel - return if transform is None: transform = IDENTITY else: transform = core_graphics.CGAffineTransformMake(*transform) - core_graphics.CGPathAddPath(self.native, transform, path.native) + # 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) @@ -108,7 +107,9 @@ def ellipse( endangle, counterclockwise, ): - if platform.machine() == "x86_64": # pragma: no-cover-if-arm + 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