Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
309103e
Fix mid-path transforms
corranwebster Jan 19, 2026
470601e
Fix tests removed by bad merge.
corranwebster Jan 19, 2026
d922f32
Use @skip_backends.
corranwebster Jan 19, 2026
7a4f5a9
Fixes to tests, Qt improvements, Android backend.
corranwebster Jan 19, 2026
fea893b
Fixes for android, use correct backend name.
corranwebster Jan 19, 2026
364fef0
Fix initial reset transform on Android.
corranwebster Jan 20, 2026
7002651
Merge branch 'main' into fix-context-path-transform
corranwebster Jan 20, 2026
e49aaf4
Fix android method capitalisation.
corranwebster Jan 20, 2026
bb6c7e0
Fix mis-spelled android method name.
corranwebster Jan 20, 2026
130038a
Refactor Canvas on winforms to use internal graphics state.
corranwebster Jan 20, 2026
ffecac1
Should use native instead of impl.
corranwebster Jan 20, 2026
7de753d
Correct behaviour of reset_tranform to match HTMl Canvas.
corranwebster Jan 20, 2026
9e00344
Fixes to matrix inversion on Qt and Android.
corranwebster Jan 20, 2026
5b1bd43
More fixes for Qt and Android.
corranwebster Jan 20, 2026
887a7a6
Handle coverage of non-branching on reset
corranwebster Jan 20, 2026
eafb5e8
Attempt at fix for #2206 for Winforms.
corranwebster Jan 20, 2026
d99ca42
Windows test *almost* meets the threshold, bump it slightly.
corranwebster Jan 20, 2026
56fb7ca
Add a scale factor to make directionality clearer.
corranwebster Jan 20, 2026
e18ace8
Better changelog; update thresholds for windows and new image.
corranwebster Jan 20, 2026
53a6ebf
Try to catch exception on windows; add docstring for new test.
corranwebster Jan 20, 2026
7c9a301
Correctly invert matrix on winforms backend.
corranwebster Jan 20, 2026
fdd6803
Feedback from PR reviews, beef up tests, fix a couple of bugs.
corranwebster Jan 21, 2026
e38420a
Fix a bug in reset_transform in Winforms backend.
corranwebster Jan 21, 2026
1f9d647
Also need to transform start point on winforms.
corranwebster Jan 21, 2026
8730d7f
Matrices transform points, not the other way around on winforms.
corranwebster Jan 21, 2026
c6a2f23
Missed a couple of places with start point transfroms on windows.
corranwebster Jan 21, 2026
d37c126
I think reset_transform on winforms had the inverses reversed.
corranwebster Jan 21, 2026
e2cbc62
Add a probe around start point scaling.
corranwebster Jan 21, 2026
9e6c3e3
Start point isn't modified in place; try setting it explicitly.
corranwebster Jan 21, 2026
5dda076
Use an Array of points on winforms.
corranwebster Jan 22, 2026
e4cb48f
Clean-up and geenralize fix for start points.
corranwebster Jan 22, 2026
364194b
Unify path transforming code on winforms.
corranwebster Jan 22, 2026
4d61d61
Make stroke width stretching clearer.
corranwebster Jan 23, 2026
512f208
Add a test for singular transforms.
corranwebster Jan 27, 2026
0396c72
Get Qt canvas backend to pass tests.
corranwebster Jan 27, 2026
69638e2
Merge branch 'main' into fix-context-path-transform
corranwebster Jan 27, 2026
65d766e
Update from re-name; fix android and winforms tests.
corranwebster Jan 27, 2026
5c39a5c
Change test to also take path where we are scaling x by 0.
corranwebster Jan 27, 2026
b8c51b8
Turn off drawing on winforms when singular; handle GTK 0 scaling.
corranwebster Jan 27, 2026
a386e77
Add a change note about scaling by 0.
corranwebster Jan 27, 2026
0c87003
Fixes suggested from review.
corranwebster Feb 2, 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
52 changes: 49 additions & 3 deletions android/src/toga_android/widgets/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,17 @@
class State(NamedTuple):
fill: Paint
stroke: Paint
transform: Matrix

def __deepcopy__(self, memo):
return type(self)(Paint(self.fill), Paint(self.stroke))
return type(self)(Paint(self.fill), Paint(self.stroke), Matrix())


class Context:
def __init__(self, impl, native):
self.native = native
self.impl = impl
self.path = Path()
self.reset_transform()

# Backwards compatibility for Toga <= 0.5.3
self.in_fill = False
Expand All @@ -57,7 +57,8 @@ def __init__(self, impl, native):
stroke.setStrokeWidth(2.0)
stroke.setColor(BLACK)

self.states = [State(fill, stroke)]
self.states = [State(fill, stroke, Matrix())]
self.reset_transform()

@property
def state(self):
Expand All @@ -71,6 +72,8 @@ def save(self):

def restore(self):
self.native.restore()
# Transform active path to current coordinates
self.path.transform(self.state.transform)
self.states.pop()

# Setting attributes
Expand Down Expand Up @@ -171,14 +174,57 @@ def stroke(self):
def rotate(self, radians):
self.native.rotate(degrees(radians))

# Update state transform
self.state.transform.postRotate(degrees(radians))

# Transform active path to current coordinates
inverse = Matrix()
inverse.setRotate(-degrees(radians))
self.path.transform(inverse)

def scale(self, sx, sy):
# Can't apply inverse transform if scale is 0,
# so use a small epsilon which will almost be the same
if sx == 0:
sx = 2**-24
if sy == 0:
sy = 2**-24

self.native.scale(sx, sy)

# Update state transform
self.state.transform.postScale(sx, sy)

# Transform active path to current coordinates
inverse = Matrix()
inverse.setScale(1 / sx, 1 / sy)
self.path.transform(inverse)

def translate(self, tx, ty):
self.native.translate(tx, ty)

# Update state transform
self.state.transform.postTranslate(tx, ty)

# Transform active path to current coordinates
inverse = Matrix()
inverse.setTranslate(-tx, -ty)
self.path.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)
inverse = Matrix()
# if we can't invert, ignore for now
if state.transform.invert(inverse): # pragma: no branch
# Update current state transform
self.state.transform.postConcat(inverse)

# Rescale to standard units
self.scale(self.impl.dpi_scale, self.impl.dpi_scale)

# Text
Expand Down
1 change: 1 addition & 0 deletions changes/2206.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The Android, Qt and Winforms canvases now match the behavior of the HTML Canvas when a transform is applied while preparing a path.
1 change: 1 addition & 0 deletions changes/4110.removal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The behaviour of the Canvas widget when a drawing was scaled by a factor of 0 in either axis was unspecified and varied across backends and HTML Canvas implementations. The Canvas widget now follows a consistent, reasonable behavior when this happens which should produce similar results on all backends. On some backends, to avoid errors or other drawing problems, a very small scale factor is used instead of 0 when asked to scale by 0.
7 changes: 7 additions & 0 deletions gtk/src/toga_gtk/widgets/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,13 @@ def rotate(self, radians):
self.native.rotate(radians)

def scale(self, sx, sy):
# Cairo throws an exception if scale is 0,
# so use a small epsilon which will almost be the same
if sx == 0:
sx = 2**-24
if sy == 0:
sy = 2**-24

self.native.scale(sx, sy)

def translate(self, tx, ty):
Expand Down
42 changes: 41 additions & 1 deletion qt/src/toga_qt/widgets/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@


class State:
"""Doesn't need to track transform, only fill/stroke-related properties."""
"""Track transform and fill/stroke-related properties."""

def __init__(self, former_state=None):
self.transform = QTransform()
if former_state:
self.fill_style = former_state.fill_style
self.stroke = QPen(former_state.stroke)
Expand Down Expand Up @@ -63,6 +64,8 @@ def save(self):
self.native.save()

def restore(self):
# Transform active path to current coordinates
self._path = self.state.transform.map(self._path)
self.states.pop()
self.native.restore()

Expand Down Expand Up @@ -183,15 +186,52 @@ def stroke(self):
def rotate(self, radians):
self.native.rotate(degrees(radians))

# Update state transform
self.state.transform.rotateRadians(radians)

# Transform active path to current coordinates
inverse = QTransform()
inverse.rotateRadians(-radians)
self._path = inverse.map(self._path)

def scale(self, sx, sy):
# Can't apply inverse transform if scale is 0,
# so use a small epsilon which will almost be the same
if sx == 0:
sx = 2**-24
if sy == 0:
sy = 2**-24

self.native.scale(sx, sy)

# Update state transform
self.state.transform.scale(sx, sy)

# Transform active path to current coordinates
inverse = QTransform()
inverse.scale(1 / sx, 1 / sy)
self._path = inverse.map(self._path)

def translate(self, tx, ty):
self.native.translate(tx, ty)

# Update state transform
self.state.transform.translate(tx, ty)

# Transform active path to current coordinates
inverse = QTransform()
inverse.translate(-tx, -ty)
self._path = inverse.map(self._path)

def reset_transform(self):
transform = self.native.transform()
self.native.resetTransform()

# Update state transform
inverse, _ = transform.inverted()
self._path = transform.map(self._path)
self.state.transform *= inverse

# Text
def write_text(self, text, x, y, font, baseline, line_height):
left_offset, top_offset, _, bottom_offset, scaled_line_height = (
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
90 changes: 90 additions & 0 deletions testbed/tests/widgets/test_canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,96 @@ async def test_transforms(canvas, probe):
assert_reference(probe, "transforms")


async def test_transforms_mid_path(canvas, probe):
"Transforms can be applied mid-path"

# draw a series of rotated rectangles
canvas.root_state.begin_path()
canvas.root_state.translate(100, 100)
with canvas.root_state.state() as ctx:
for _ in range(12):
ctx.rect(50, 0, 10, 10)
ctx.scale(1.1, 1)
ctx.rotate(math.pi / 6)

canvas.root_state.fill()
canvas.root_state.stroke(GOLDENROD)

# draw a series of line segments
canvas.root_state.begin_path()
canvas.root_state.move_to(25, 0)
for _ in range(12):
canvas.root_state.line_to(25, 0)
canvas.root_state.rotate(math.pi / 6)
canvas.root_state.translate(5, 3)
canvas.root_state.close_path()
canvas.root_state.reset_transform()
canvas.root_state.move_to(110, 100)
canvas.root_state.scale(5, 1)
canvas.root_state.ellipse(20, 100, 2, 20, 0, 0, 2 * pi)
canvas.root_state.stroke(CORNFLOWERBLUE)

await probe.redraw("Transforms can be applied")
assert_reference(probe, "transforms_mid_path", threshold=0.015)


async def test_singular_transforms(canvas, probe):
"Singular transforms behave reasonably"
ctx = canvas.root_state

ctx.begin_path()
with ctx.state() as ctx2:
# flip about the line x = y
ctx2.rotate(-pi / 2)
ctx2.scale(-1, 1)

ctx2.move_to(40, 20)
ctx2.line_to(80, 20)
ctx2.line_to(100, 30)

with ctx2.state() as ctx3:
# Apply a scale factor of zero
ctx3.scale(0.9, 0)
ctx3.line_to(180, 20)

ctx2.rotate(pi / 4)
ctx2.line_to(180, 20)

ctx2.stroke(GOLDENROD, line_width=8)

# Same shape, but not flipped, using reset_transform()
ctx.begin_path()

ctx.move_to(40, 20)
ctx.line_to(80, 20)
ctx.line_to(100, 30)

# Apply a scale factor of zero
ctx.scale(0.9, 0)
ctx.line_to(180, 20)
# Total transform is singular
ctx.reset_transform()

ctx.rotate(pi / 4)
ctx.line_to(180, 20)

ctx.stroke(CORNFLOWERBLUE, line_width=8)

ctx.reset_transform()
ctx.begin_path()
ctx.scale(0, 0.9)
ctx.translate(50, 50)

ctx.rect(0, 0, 25, 25)

# Should draw nothing.
ctx.fill()
ctx.stroke(line_width=10)

await probe.redraw("Transforms can be applied")
assert_reference(probe, "singular_transforms")


@pytest.mark.xfail(
condition=os.environ.get("RUNNING_IN_CI") != "true",
reason="Canvas tests are unstable outside of CI. Manual inspection may be required",
Expand Down
Loading