Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 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
34 changes: 31 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 @@ -170,15 +173,40 @@ def stroke(self):

def rotate(self, radians):
self.native.rotate(degrees(radians))
self.state.transform.postRotate(degrees(radians))

inverse = Matrix()
inverse.setRotate(-degrees(radians))
self.path.transform(inverse)

def scale(self, sx, sy):
self.native.scale(sx, sy)
self.state.transform.postScale(sx, sy)

inverse = Matrix()
inverse.setScale(1 / sx, 1 / sy)
self.path.transform(inverse)

def translate(self, tx, ty):
self.native.translate(tx, ty)
self.state.transform.postTranslate(tx, ty)

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
self.state.transform.postConcat(inverse)

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.
27 changes: 26 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,14 +186,36 @@ def stroke(self):
def rotate(self, radians):
self.native.rotate(degrees(radians))

# track transforms and adjust path
self.state.transform.rotateRadians(radians)
inverse = QTransform()
inverse.rotateRadians(-radians)
self._path = inverse.map(self._path)

def scale(self, sx, sy):
self.native.scale(sx, sy)

# track transforms and adjust path
self.state.transform.scale(sx, sy)
inverse = QTransform()
inverse.scale(1 / sx, 1 / sy)
self._path = inverse.map(self._path)

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

# track transforms and adjust path
self.state.transform.translate(tx, ty)
inverse = QTransform()
inverse.scale(-tx, -ty)
self._path = inverse.map(self._path)

def reset_transform(self):
transform = self.native.transform()
self.native.resetTransform()
self._path = transform.map(self._path)
inverse, _ = transform.inverted()
self.state.transform *= inverse

# Text
def write_text(self, text, x, y, font, baseline, 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.
24 changes: 24 additions & 0 deletions testbed/tests/widgets/test_canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,30 @@ 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.context.begin_path()
canvas.context.translate(100, 100)
for _ in range(12):
canvas.context.rect(50, 0, 10, 10)
canvas.context.scale(1.05, 1)
canvas.context.rotate(math.pi / 6)
canvas.context.fill()

# draw a series of line segments
canvas.context.begin_path()
canvas.context.move_to(25, 0)
for _ in range(12):
canvas.context.line_to(25, 0)
canvas.context.rotate(math.pi / 6)
canvas.context.fill(CORNFLOWERBLUE)

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


@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
57 changes: 40 additions & 17 deletions winforms/src/toga_winforms/widgets/canvas.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from copy import deepcopy
from math import degrees

import System.Windows.Forms as WinForms
Expand Down Expand Up @@ -42,22 +41,24 @@ class State:
we used it. And it would still need to be kept in a list.
"""

def __init__(self, matrix, brush, pen):
self.matrix = matrix
def __init__(self, graphics_state, brush, pen):
# This is the previous graphics state, so we can restore.
self.graphics_state = graphics_state
Comment thread
HalfWhitt marked this conversation as resolved.
Outdated
self.brush = brush
self.pen = pen
self.transform = Matrix()

@classmethod
def for_impl(cls, impl):
return cls(
matrix=Matrix(),
graphics_state=None,
brush=SolidBrush(BLACK),
pen=Pen(BLACK, impl.scale_in(2.0, rounding=None)),
)

def __deepcopy__(self, memo):
def new_state(self, graphics_state):
return type(self)(
matrix=self.matrix.Clone(),
graphics_state=graphics_state,
brush=self.brush.Clone(),
pen=self.pen.Clone(),
)
Expand Down Expand Up @@ -106,10 +107,14 @@ def state(self):
return self.states[-1]

def save(self):
self.states.append(deepcopy(self.state))
graphics_state = self.native.Save()
self.states.append(self.state.new_state(graphics_state))

def restore(self):
self.states.pop()
state = self.states.pop()
self.native.Restore(state.graphics_state)
for path in self.paths:
path.Transform(state.transform)

# Setting attributes

Expand Down Expand Up @@ -223,27 +228,48 @@ def fill(self, fill_rule):
path.FillMode = FillMode.Alternate
else: # Default to NONZERO
path.FillMode = FillMode.Winding
path.Transform(self.state.matrix)
self.native.FillPath(self.state.brush, path)

def stroke(self):
for path in self.paths:
path.Transform(self.state.matrix)
self.native.DrawPath(self.state.pen, path)

# Transformations

def rotate(self, radians):
self.state.matrix.Rotate(degrees(radians))
self.native.RotateTransform(degrees(radians))
self.state.transform.Rotate(degrees(radians))

inverse = Matrix()
inverse.Rotate(-degrees(radians))
for path in self.paths:
path.Transform(inverse)

def scale(self, sx, sy):
self.state.matrix.Scale(sx, sy)
self.native.ScaleTransform(sx, sy)
self.state.transform.Scale(sx, sy)

inverse = Matrix()
inverse.Scale(1 / sx, 1 / sy)
for path in self.paths:
path.Transform(inverse)

def translate(self, tx, ty):
self.state.matrix.Translate(tx, ty)
self.native.TranslateTransform(tx, ty)
self.state.transform.Translate(tx, ty)

inverse = Matrix()
inverse.Translate(-tx, -ty)
for path in self.paths:
path.Transform(inverse)

def reset_transform(self):
self.state.matrix.Reset()
matrix = self.native.Transform
matrix.Invert()
self.native.ResetTransform()
for path in self.paths:
path.Transform(matrix)

self.scale(self.impl.dpi_scale, self.impl.dpi_scale)

# Text
Expand Down Expand Up @@ -287,10 +313,7 @@ def _text_path(self, text, x, y, font, baseline, line_height):
)

def draw_image(self, image, x, y, width, height):
self.native.ResetTransform()
self.native.Transform = self.state.matrix
self.native.DrawImage(image._impl.native, x, y, width, height)
self.native.ResetTransform()


class Canvas(Box):
Expand Down
Loading