Skip to content
Draft
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
4681710
Work in progress commit.
corranwebster Feb 4, 2026
633e122
Initial backend implementation of Path objects.
corranwebster Feb 4, 2026
b8affe4
Add change log message.
corranwebster Feb 4, 2026
b6e8ea0
Fixes for Winforms.
corranwebster Feb 4, 2026
a730006
Initial working examples on Cocoa and Qt.
corranwebster Feb 4, 2026
e168f8e
Get core test back into working order.
corranwebster Feb 4, 2026
18dee6a
Do pragma no cover for now while we work out what API should look like.
corranwebster Feb 4, 2026
e7cba6b
Merge branch 'main' into canvas-backend-path
corranwebster Feb 5, 2026
d60628b
Add round rects to paths.
corranwebster Feb 5, 2026
add1771
Rename Path to Path2D; add core tests; expose implementations.
corranwebster Feb 5, 2026
4dd707c
Fixes from test failures; rework Path2d.compile.
corranwebster Feb 5, 2026
f1146c1
More fixes based on tests.
corranwebster Feb 5, 2026
a24d74c
Yet more attempted fixes; probe for macOS amd64 failures.
corranwebster Feb 5, 2026
74398a0
Further drilling down into issues.
corranwebster Feb 5, 2026
e940b18
Still refining for platforms with errors.
corranwebster Feb 5, 2026
4fcec9e
More work on getting tests to pass.
corranwebster Feb 5, 2026
45a3b52
Hopefully the last fixes needed for GTK and winforms.
corranwebster Feb 5, 2026
8433d22
Ensure subpath endpoints are also transformed on Windows.
corranwebster Feb 5, 2026
ed153fe
Hand sequential move_to commands on windows.
corranwebster Feb 5, 2026
04e80e3
Improve path object tests to get more coverage.
corranwebster Feb 5, 2026
9cab8fb
Fix problems with degenerate paths; better coverage
corranwebster Feb 5, 2026
9d41940
Path implementations never get called with non-None arguments.
corranwebster Feb 5, 2026
13d62f4
Documentation, a few other fixes.
corranwebster Feb 5, 2026
2be5a1d
Some fixes to Qt and Winforms backends.
corranwebster Feb 6, 2026
dba93b5
Fixes for docs; remove unused GTK methods; fix Winforms add_path.
corranwebster Feb 6, 2026
e946caf
Try transpose of Android matrix.
corranwebster Feb 6, 2026
afce63a
Add probe to see why line is not being run on Windows.
corranwebster Feb 6, 2026
032f24a
Remove #pragma directives; refactor add_path on windforms.
corranwebster Feb 6, 2026
041accd
Refine pragma: no cover; change copy behaviour based on comments in PR.
corranwebster Feb 7, 2026
3102edd
Skip a couple more lines.
corranwebster Feb 7, 2026
cfa8777
Merge branch 'main' into canvas-backend-path
corranwebster Feb 9, 2026
34f7cd2
Merge branch 'main' into canvas-backend-path
corranwebster Apr 17, 2026
c414af5
Fixes for core tests.
corranwebster Apr 19, 2026
471ff24
Fix coverage, initial tests for path in fill/stroke.
corranwebster Apr 19, 2026
f29bb01
Fix path tests.
corranwebster Apr 19, 2026
83290b1
Pass implementation when filling/stroking paths
corranwebster Apr 19, 2026
0e9e4aa
Merge branch 'main' into canvas-backend-path
corranwebster Apr 24, 2026
d34bf90
Fix bad manual merge.
corranwebster Apr 24, 2026
e503dd9
Error if path is provided for fill/stroke contexts.
corranwebster Apr 24, 2026
7c8763d
Fix fill/stroke args in test.
corranwebster Apr 24, 2026
1a97e7d
Fix docs and fix winforms coverage.
corranwebster Apr 24, 2026
aaae4ea
Use c_bool instead of c_int for call to CGPathAddArc().
corranwebster Apr 24, 2026
3aa25b1
Skip path tests on macOS Intel for now; other fixes.
corranwebster Apr 24, 2026
667c8fe
Try explicit type conversion for CGPathAddArc.
corranwebster Apr 25, 2026
3cb0531
Actually run path test on macOS intel.
corranwebster Apr 25, 2026
94c2e27
Detect bad inputs, fix tests and coverage appropriately.
corranwebster Apr 25, 2026
2a4cd3b
Fix branching.
corranwebster Apr 25, 2026
cc461d1
Always skip ellipses on intel macOS.
corranwebster Apr 25, 2026
a4e8e74
Fix segfault when adding empty path on macOS Intel.
corranwebster Apr 25, 2026
4695618
Try some more experiments.
corranwebster Apr 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion android/src/toga_android/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -72,6 +72,7 @@ def not_implemented(feature):
"NumberInput",
"OptionContainer",
"PasswordInput",
"Path2D",
"ProgressBar",
"ScrollContainer",
# "SplitContainer",
Expand Down
185 changes: 136 additions & 49 deletions android/src/toga_android/widgets/canvas.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -10,7 +10,7 @@
DashPathEffect,
Matrix,
Paint,
Path,
Path as NativePath,
)
from android.view import MotionEvent, View
from java import dynamic_proxy, jint
Expand All @@ -27,6 +27,103 @@
BLACK = jint(native_color(rgb(0, 0, 0)))


def matrix_from_transform(transform):
a, b, c, d, e, f = transform
matrix = Matrix()
matrix.setValues([a, c, e, b, d, f, 0, 0, 1])
return matrix


class Path2D:
native: NativePath

def __init__(self):
self.native = NativePath()
self._last_point = None

def _ensure_subpath(self, x, y):
if self.native.isEmpty():
self.native.moveTo(x, y)

def add_path(self, path, transform=None):
if transform is None:
self.native.addPath(path.native)
self._last_point = path._last_point
else:
native_path = NativePath(path.native)
matrix = matrix_from_transform(transform)
native_path.transform(matrix)
self.native.addPath(native_path)
if path._last_point is not None:
points = list(path._last_point)
matrix.mapPoints(points)
self._last_point = points[0]

def close_path(self):
self.native.close()
self._last_point = None

def move_to(self, x, y):
self.native.moveTo(x, y)
self._last_point = (x, y)

def line_to(self, x, y):
self._ensure_subpath(x, y)
self.native.lineTo(x, y)
self._last_point = (x, y)

def bezier_curve_to(self, cp1x, cp1y, cp2x, cp2y, x, y):
self._ensure_subpath(cp1x, cp1y)
self.native.cubicTo(cp1x, cp1y, cp2x, cp2y, x, y)
self._last_point = (x, y)

def quadratic_curve_to(self, cpx, cpy, x, y):
self._ensure_subpath(cpx, cpy)
self.native.quadTo(cpx, cpy, x, y)
self._last_point = (x, y)

def arc(self, x, y, radius, startangle, endangle, counterclockwise):
self.ellipse(x, y, radius, radius, 0, startangle, endangle, counterclockwise)
self._last_point = (x + radius * cos(endangle), y + sin(endangle))

def ellipse(
self,
x,
y,
radiusx,
radiusy,
rotation,
startangle,
endangle,
counterclockwise,
):
matrix = Matrix()
matrix.preTranslate(x, y)
matrix.preRotate(degrees(rotation))
matrix.preScale(radiusx, radiusy)
matrix.preRotate(degrees(startangle))

coords = list(
itertools.chain(
*arc_to_bezier(sweepangle(startangle, endangle, counterclockwise))
)
)
matrix.mapPoints(coords)

self.line_to(coords[0], coords[1])
i = 2
while i < len(coords):
self.bezier_curve_to(*coords[i : i + 6])
i += 6

def rect(self, x, y, width, height):
self.native.addRect(x, y, x + width, y + height, NativePath.Direction.CW)
self._last_point = (x, y)

def round_rect(self, x, y, width, height, radii):
round_rect(self, x, y, width, height, radii)


class State(NamedTuple):
fill: Paint
stroke: Paint
Expand All @@ -40,7 +137,7 @@ class Context:
def __init__(self, impl, native):
self.native = native
self.impl = impl
self.path = Path()
self.path = Path2D()

# Backwards compatibility for Toga <= 0.5.3
self.in_fill = False
Expand Down Expand Up @@ -73,7 +170,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
Expand All @@ -92,34 +189,27 @@ def set_stroke_style(self, color):
# Basic paths

def begin_path(self):
self.path.reset()
self.path = Path2D()

def close_path(self):
self.path.close()
self.path.close_path()

def move_to(self, x, y):
self.path.moveTo(x, y)
self.path.move_to(x, y)

def line_to(self, x, y):
self._ensure_subpath(x, y)
self.path.lineTo(x, y)

def _ensure_subpath(self, x, y):
if self.path.isEmpty():
self.move_to(x, y)
self.path.line_to(x, y)

# Basic shapes

def bezier_curve_to(self, cp1x, cp1y, cp2x, cp2y, x, y):
self._ensure_subpath(cp1x, cp1y)
self.path.cubicTo(cp1x, cp1y, cp2x, cp2y, x, y)
self.path.bezier_curve_to(cp1x, cp1y, cp2x, cp2y, x, y)

def quadratic_curve_to(self, cpx, cpy, x, y):
self._ensure_subpath(cpx, cpy)
self.path.quadTo(cpx, cpy, x, y)
self.path.quadratic_curve_to(cpx, cpy, x, y)

def arc(self, x, y, radius, startangle, endangle, counterclockwise):
self.ellipse(x, y, radius, radius, 0, startangle, endangle, counterclockwise)
self.path.arc(x, y, radius, startangle, endangle, counterclockwise)

def ellipse(
self,
Expand All @@ -132,45 +222,42 @@ def ellipse(
endangle,
counterclockwise,
):
matrix = Matrix()
matrix.preTranslate(x, y)
matrix.preRotate(degrees(rotation))
matrix.preScale(radiusx, radiusy)
matrix.preRotate(degrees(startangle))

coords = list(
itertools.chain(
*arc_to_bezier(sweepangle(startangle, endangle, counterclockwise))
)
self.path.ellipse(
x,
y,
radiusx,
radiusy,
rotation,
startangle,
endangle,
counterclockwise,
)
matrix.mapPoints(coords)

self.line_to(coords[0], coords[1])
i = 2
while i < len(coords):
self.bezier_curve_to(*coords[i : i + 6])
i += 6

def rect(self, x, y, width, height):
self.path.addRect(x, y, x + width, y + height, Path.Direction.CW)
self.path.rect(x, y, width, height)

def round_rect(self, x, y, width, height, radii):
round_rect(self, x, y, width, height, radii)
self.path.round_rect(x, y, width, height, radii)

# Drawing Paths

def fill(self, fill_rule):
self.path.setFillType(
def fill(self, fill_rule, path=None):
if path is None:
path = self.path

path.native.setFillType(
{
FillRule.EVENODD: Path.FillType.EVEN_ODD,
FillRule.NONZERO: Path.FillType.WINDING,
}.get(fill_rule, Path.FillType.WINDING)
FillRule.EVENODD: NativePath.FillType.EVEN_ODD,
FillRule.NONZERO: NativePath.FillType.WINDING,
}.get(fill_rule, NativePath.FillType.WINDING)
)
self.native.drawPath(self.path, self.state.fill)
self.native.drawPath(path.native, self.state.fill)

def stroke(self):
def stroke(self, path=None):
# The stroke respects the canvas transform, so we don't need to scale it here.
self.native.drawPath(self.path, self.state.stroke)
if path is None:
path = self.path
self.native.drawPath(path.native, self.state.stroke)

# Transformations

Expand All @@ -183,7 +270,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,
Expand All @@ -201,7 +288,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)
Expand All @@ -212,15 +299,15 @@ 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)

# 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
Expand Down
1 change: 1 addition & 0 deletions changes/4163.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The `Canvas` widget has now has `Path2D` objects that can be used like HTML Canvas `Path2D` objects.
3 changes: 2 additions & 1 deletion cocoa/src/toga_cocoa/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from .widgets.activityindicator import ActivityIndicator
from .widgets.box import Box
from .widgets.button import Button
from .widgets.canvas import Canvas
from .widgets.canvas import Canvas, Path2D
from .widgets.dateinput import DateInput
from .widgets.detailedlist import DetailedList
from .widgets.divider import Divider
Expand Down Expand Up @@ -76,6 +76,7 @@ def not_implemented(feature):
"NumberInput",
"OptionContainer",
"PasswordInput",
"Path2D",
"ProgressBar",
"ScrollContainer",
"Selection",
Expand Down
Loading
Loading