diff --git a/android/src/toga_android/widgets/canvas.py b/android/src/toga_android/widgets/canvas.py index 614c802ea8..cf124a95c2 100644 --- a/android/src/toga_android/widgets/canvas.py +++ b/android/src/toga_android/widgets/canvas.py @@ -1,4 +1,5 @@ -from math import degrees, pi +import itertools +from math import degrees from android.graphics import ( Bitmap, @@ -14,7 +15,7 @@ from org.beeware.android import DrawHandlerView, IDrawHandler from travertino.size import at_least -from toga.widgets.canvas import Baseline, FillRule +from toga.widgets.canvas import Baseline, FillRule, arc_to_bezier, sweepangle from ..colors import native_color from .base import Widget @@ -101,25 +102,8 @@ def quadratic_curve_to(self, cpx, cpy, x, y, path, **kwargs): path.quadTo(cpx, cpy, x, y) def arc(self, x, y, radius, startangle, endangle, anticlockwise, path, **kwargs): - sweepangle = endangle - startangle - if anticlockwise: - if sweepangle > 0: - sweepangle -= 2 * pi - else: - if sweepangle < 0: - sweepangle += 2 * pi - - # HTML says sweep angles should be clamped at +/- 360 degrees, but Android uses - # mod 360 instead, so 360 would cause the circle to completely disappear. - limit = 359.999 # Must be less than 360 in 32-bit floating point. - path.arcTo( - x - radius, - y - radius, - x + radius, - y + radius, - degrees(startangle), - max(-limit, min(degrees(sweepangle), limit)), - False, # forceMoveTo + self.ellipse( + x, y, radius, radius, 0, startangle, endangle, anticlockwise, path, **kwargs ) def ellipse( @@ -136,19 +120,23 @@ def ellipse( **kwargs, ): matrix = Matrix() - matrix.postScale(radiusx, radiusy) - matrix.postRotate(degrees(rotation)) - matrix.postTranslate(x, y) - - # Creating the ellipse as a separate path and then using addPath would make it a - # disconnected contour. And there's no way to extract the segments from a path - # until getPathIterator in API level 34. So this is the simplest solution I - # could find. - inverse = Matrix() - matrix.invert(inverse) - path.transform(inverse) - self.arc(0, 0, 1, startangle, endangle, anticlockwise, path) - path.transform(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, anticlockwise)) + ) + ) + matrix.mapPoints(coords) + + self.line_to(coords[0], coords[1], path, **kwargs) + i = 2 + while i < len(coords): + self.bezier_curve_to(*coords[i : i + 6], path, **kwargs) + i += 6 def rect(self, x, y, width, height, path, **kwargs): path.addRect(x, y, x + width, y + height, Path.Direction.CW) diff --git a/changes/2184.misc.rst b/changes/2184.misc.rst new file mode 100644 index 0000000000..14eaa9459c --- /dev/null +++ b/changes/2184.misc.rst @@ -0,0 +1 @@ +Fixed issues with elliptical arcs on WinForms and Android diff --git a/core/src/toga/widgets/canvas.py b/core/src/toga/widgets/canvas.py index 6592862416..e30eae5767 100644 --- a/core/src/toga/widgets/canvas.py +++ b/core/src/toga/widgets/canvas.py @@ -3,7 +3,7 @@ import warnings from abc import ABC, abstractmethod from contextlib import contextmanager -from math import pi +from math import cos, pi, sin, tan from typing import Protocol from travertino.colors import Color @@ -1628,3 +1628,78 @@ def stroke( DeprecationWarning, ) return self.Stroke(color=color, line_width=line_width, line_dash=line_dash) + + +def sweepangle(startangle, endangle, anticlockwise): + """Returns an arc length in the range [-2 * pi, 2 * pi], where positive numbers are + clockwise. Based on the "ellipse method steps" in the HTML spec.""" + + if anticlockwise: + if endangle - startangle <= -2 * pi: + return -2 * pi + else: + if endangle - startangle >= 2 * pi: + return 2 * pi + + startangle %= 2 * pi + endangle %= 2 * pi + sweepangle = endangle - startangle + if anticlockwise: + if sweepangle > 0: + sweepangle -= 2 * pi + else: + if sweepangle < 0: + sweepangle += 2 * pi + + return sweepangle + + +# Based on https://stackoverflow.com/a/30279817 +def arc_to_bezier(sweepangle): + """Approximates an arc of a unit circle as a sequence of Bezier segments. + + :param sweepangle: Length of the arc in radians, where positive numbers are + clockwise. + :returns: [(1, 0), (cp1x, cp1y), (cp2x, cp2y), (x, y), ...], where each group of 3 + points has the same meaning as in the bezier_curve_to method, and there are + between 1 and 4 groups.""" + + matrices = [ + [1, 0, 0, 1], # 0 degrees + [0, -1, 1, 0], # 90 + [-1, 0, 0, -1], # 180 + [0, 1, -1, 0], # 270 + ] + + if sweepangle < 0: # Anticlockwise + sweepangle *= -1 + for matrix in matrices: + matrix[2] *= -1 + matrix[3] *= -1 + + result = [(1.0, 0.0)] + for matrix in matrices: + if sweepangle < 0: + break + + phi = min(sweepangle, pi / 2) + k = 4 / 3 * tan(phi / 4) + result += [ + transform(x, y, matrix) + for x, y in [ + (1, k), + (cos(phi) + k * sin(phi), sin(phi) - k * cos(phi)), + (cos(phi), sin(phi)), + ] + ] + + sweepangle -= pi / 2 + + return result + + +def transform(x, y, matrix): + return ( + x * matrix[0] + y * matrix[1], + x * matrix[2] + y * matrix[3], + ) diff --git a/core/tests/widgets/canvas/test_helpers.py b/core/tests/widgets/canvas/test_helpers.py new file mode 100644 index 0000000000..e3c414ce33 --- /dev/null +++ b/core/tests/widgets/canvas/test_helpers.py @@ -0,0 +1,218 @@ +from math import pi + +from pytest import approx + +from toga.widgets.canvas import arc_to_bezier, sweepangle + + +def test_sweepangle(): + # Zero start angles + for value in [0, 1, pi, 2 * pi]: + assert sweepangle(0, value, False) == approx(value) + + for value in [2.1 * pi, 3 * pi, 4 * pi, 5 * pi]: + assert sweepangle(0, value, False) == approx(2 * pi) + + # Non-zero start angles + assert sweepangle(pi, 2 * pi, False) == approx(pi) + assert sweepangle(pi, 2.5 * pi, False) == approx(1.5 * pi) + assert sweepangle(pi, 3 * pi, False) == approx(2 * pi) + assert sweepangle(pi, 3.1 * pi, False) == approx(2 * pi) + + # Zero crossings + assert sweepangle(0, 2 * pi, False) == approx(2 * pi) + assert sweepangle(0, -2 * pi, False) == approx(0) + assert sweepangle(0, 1.9 * pi, False) == approx(1.9 * pi) + assert sweepangle(0, 2.1 * pi, False) == approx(2 * pi) + assert sweepangle(0, -1.9 * pi, False) == approx(0.1 * pi) + assert sweepangle(0, -2.1 * pi, False) == approx(1.9 * pi) + assert sweepangle(pi, 0, False) == approx(pi) + assert sweepangle(pi, 2 * pi, False) == approx(pi) + assert sweepangle(pi, 0.1 * pi, False) == approx(1.1 * pi) + assert sweepangle(pi, 2.1 * pi, False) == approx(1.1 * pi) + + # Zero crossings, anticlockwise + assert sweepangle(0, 2 * pi, True) == approx(0) + assert sweepangle(0, -2 * pi, True) == approx(-2 * pi) + assert sweepangle(0, 1.9 * pi, True) == approx(-0.1 * pi) + assert sweepangle(0, 2.1 * pi, True) == approx(-1.9 * pi) + assert sweepangle(0, -1.9 * pi, True) == approx(-1.9 * pi) + assert sweepangle(0, -2.1 * pi, True) == approx(-2 * pi) + assert sweepangle(pi, 0, True) == approx(-pi) + assert sweepangle(pi, 2 * pi, True) == approx(-pi) + assert sweepangle(pi, 0.1 * pi, True) == approx(-0.9 * pi) + assert sweepangle(pi, 2.1 * pi, True) == approx(-0.9 * pi) + + +def assert_arc_to_bezier(sweepangle, expected): + actual = arc_to_bezier(sweepangle) + for a, e in zip(actual, expected): + assert a == approx(e, abs=0.000001) + + +def test_arc_to_bezier(): + assert_arc_to_bezier( + 0, + [ + (1.0, 0.0), + (1.0, 0.0), + (1.0, 0.0), + (1.0, 0.0), + ], + ) + + assert_arc_to_bezier( + 0.25 * pi, + [ + (1.0, 0.0), + (1.0, 0.2652164), + (0.8946431, 0.5195704), + (0.7071067, 0.7071067), + ], + ) + assert_arc_to_bezier( + -0.25 * pi, + [ + (1.0, 0.0), + (1.0, -0.2652164), + (0.8946431, -0.5195704), + (0.7071067, -0.7071067), + ], + ) + + assert_arc_to_bezier( + 0.5 * pi, + [ + (1.0, 0.0), + (1.0, 0.5522847), + (0.5522847, 1.0), + (0.0, 1.0), + ], + ) + assert_arc_to_bezier( + -0.5 * pi, + [ + (1.0, 0.0), + (1.0, -0.5522847), + (0.5522847, -1.0), + (0.0, -1.0), + ], + ) + + assert_arc_to_bezier( + 0.75 * pi, + [ + (1.0, 0.0), + (1.0, 0.5522847), + (0.5522847, 1.0), + (0.0, 1.0), + (-0.2652164, 1.0), + (-0.5195704, 0.8946431), + (-0.7071067, 0.7071067), + ], + ) + assert_arc_to_bezier( + -0.75 * pi, + [ + (1.0, 0.0), + (1.0, -0.5522847), + (0.5522847, -1.0), + (0.0, -1.0), + (-0.2652164, -1.0), + (-0.5195704, -0.8946431), + (-0.7071067, -0.7071067), + ], + ) + + assert_arc_to_bezier( + 1 * pi, + [ + (1.0, 0.0), + (1.0, 0.5522847), + (0.5522847, 1.0), + (0.0, 1.0), + (-0.5522847, 1.0), + (-1.0, 0.5522847), + (-1.0, 0.0), + ], + ) + assert_arc_to_bezier( + -1 * pi, + [ + (1.0, 0.0), + (1.0, -0.5522847), + (0.5522847, -1.0), + (0.0, -1.0), + (-0.5522847, -1.0), + (-1.0, -0.5522847), + (-1.0, 0.0), + ], + ) + + assert_arc_to_bezier( + 1.5 * pi, + [ + (1.0, 0.0), + (1.0, 0.5522847), + (0.5522847, 1.0), + (0.0, 1.0), + (-0.5522847, 1.0), + (-1.0, 0.5522847), + (-1.0, 0.0), + (-1.0, -0.5522847), + (-0.5522847, -1.0), + (0.0, -1.0), + ], + ) + assert_arc_to_bezier( + -1.5 * pi, + [ + (1.0, 0.0), + (1.0, -0.5522847), + (0.5522847, -1.0), + (0.0, -1.0), + (-0.5522847, -1.0), + (-1.0, -0.5522847), + (-1.0, 0.0), + (-1.0, 0.5522847), + (-0.5522847, 1.0), + (0.0, 1.0), + ], + ) + + assert_arc_to_bezier( + 2 * pi, + [ + (1.0, 0.0), + (1.0, 0.5522847), + (0.5522847, 1.0), + (0.0, 1.0), + (-0.5522847, 1.0), + (-1.0, 0.5522847), + (-1.0, 0.0), + (-1.0, -0.5522847), + (-0.5522847, -1.0), + (0.0, -1.0), + (0.5522847, -1.0), + (1.0, -0.5522847), + (1.0, 0.0), + ], + ) + assert_arc_to_bezier( + -2 * pi, + [ + (1.0, 0.0), + (1.0, -0.5522847), + (0.5522847, -1.0), + (0.0, -1.0), + (-0.5522847, -1.0), + (-1.0, -0.5522847), + (-1.0, 0.0), + (-1.0, 0.5522847), + (-0.5522847, 1.0), + (0.0, 1.0), + (0.5522847, 1.0), + (1.0, 0.5522847), + (1.0, 0.0), + ], + ) diff --git a/examples/tutorial4/tutorial/app.py b/examples/tutorial4/tutorial/app.py index a5f0528d81..5c1752b33a 100644 --- a/examples/tutorial4/tutorial/app.py +++ b/examples/tutorial4/tutorial/app.py @@ -50,9 +50,13 @@ def draw_eyes(self): with self.canvas.Fill(color=WHITE) as eye_whites: eye_whites.arc(58, 92, 15) eye_whites.arc(88, 92, 15, math.pi, 3 * math.pi) + + # Draw eyes separately to avoid miter join with self.canvas.Stroke(line_width=4.0) as eye_outline: eye_outline.arc(58, 92, 15) + with self.canvas.Stroke(line_width=4.0) as eye_outline: eye_outline.arc(88, 92, 15, math.pi, 3 * math.pi) + with self.canvas.Fill() as eye_pupils: eye_pupils.arc(58, 97, 3) eye_pupils.arc(88, 97, 3) diff --git a/testbed/src/testbed/app.py b/testbed/src/testbed/app.py index 506c70b5aa..bddb6e0ac7 100644 --- a/testbed/src/testbed/app.py +++ b/testbed/src/testbed/app.py @@ -25,12 +25,12 @@ def startup(self): shortcut=toga.Key.MOD_1 + "1", group=group, ) - # A command with no tooltip, in the default group + # A command with no tooltip, in the default group, with a non-printable shortcut self.cmd2 = toga.Command( self.cmd_action, "No Tooltip", icon=toga.Icon.DEFAULT_ICON, - shortcut=toga.Key.MOD_1 + "2", + shortcut=toga.Key.MOD_1 + toga.Key.DOWN, ) # A command without an icon, in the default group self.cmd3 = toga.Command( diff --git a/testbed/src/testbed/resources/canvas/ellipse_path.png b/testbed/src/testbed/resources/canvas/ellipse_path.png new file mode 100644 index 0000000000..cf520147a0 Binary files /dev/null and b/testbed/src/testbed/resources/canvas/ellipse_path.png differ diff --git a/testbed/tests/widgets/test_canvas.py b/testbed/tests/widgets/test_canvas.py index ad0bc4f9ef..717a7a8d17 100644 --- a/testbed/tests/widgets/test_canvas.py +++ b/testbed/tests/widgets/test_canvas.py @@ -1,5 +1,5 @@ import math -from math import pi +from math import pi, radians from unittest.mock import Mock, call import pytest @@ -311,6 +311,8 @@ async def test_paths(canvas, probe): # A stroked path requires an explicit close. For an open stroke, see test_stroke. canvas.context.begin_path() + # When there are two consecutive move_tos, the first one should leave no trace. + canvas.context.move_to(140, 140) canvas.context.move_to(180, 180) canvas.context.line_to(180, 60) canvas.context.line_to(60, 180) @@ -322,6 +324,12 @@ async def test_paths(canvas, probe): canvas.context.close_path() canvas.context.stroke(RED) + # A path containing only move_to commands should not appear. + canvas.context.begin_path() + canvas.context.move_to(140, 140) + canvas.context.move_to(160, 160) + canvas.context.stroke(RED) + await probe.redraw("Pair of triangles should be drawn") assert_reference(probe, "paths", threshold=0.04) @@ -439,6 +447,37 @@ async def test_ellipse(canvas, probe): assert_reference(probe, "ellipse", threshold=0.04) +async def test_ellipse_path(canvas, probe): + "An elliptical arc can be connected to other segments of a path" + + context = canvas.context + ellipse_args = dict(x=100, y=100, radiusx=70, radiusy=40, rotation=radians(30)) + + # Start of path -> arc + context.ellipse(**ellipse_args, startangle=radians(80), endangle=radians(160)) + # Arc -> arc + context.ellipse(**ellipse_args, startangle=radians(220), endangle=radians(260)) + context.stroke() + + context.begin_path() + context.move_to(120, 20) + # Move -> arc + context.ellipse(**ellipse_args, startangle=radians(280), endangle=radians(340)) + # Arc -> line + context.line_to(180, 50) + context.stroke(RED) + + context.begin_path() + context.move_to(180, 180) + context.line_to(180, 160) + # Line -> arc + context.ellipse(**ellipse_args, startangle=radians(10), endangle=radians(60)) + context.stroke(CORNFLOWERBLUE) + + await probe.redraw("Broken ellipse with connected lines should be drawn") + assert_reference(probe, "ellipse_path", threshold=0.04) + + async def test_rect(canvas, probe): "A rectangle can be drawn" diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 465f469775..94486b38fc 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -6,6 +6,7 @@ import System.Windows.Forms as WinForms from System import Environment, Threading +from System.ComponentModel import InvalidEnumArgumentException from System.Media import SystemSounds from System.Net import SecurityProtocolType, ServicePointManager from System.Windows.Threading import Dispatcher @@ -147,7 +148,16 @@ def create_menus(self): item = WinForms.ToolStripMenuItem(cmd.text) item.Click += WeakrefCallable(cmd._impl.winforms_handler) if cmd.shortcut is not None: - item.ShortcutKeys = toga_to_winforms_key(cmd.shortcut) + try: + item.ShortcutKeys = toga_to_winforms_key(cmd.shortcut) + except ( + ValueError, + InvalidEnumArgumentException, + ) as e: # pragma: no cover + # Make this a non-fatal warning, because different backends may + # accept different shortcuts. + print(f"WARNING: invalid shortcut {cmd.shortcut!r}: {e}") + item.Enabled = cmd.enabled cmd._impl.native.append(item) diff --git a/winforms/src/toga_winforms/keys.py b/winforms/src/toga_winforms/keys.py index a8f204cc78..01df70a0e1 100644 --- a/winforms/src/toga_winforms/keys.py +++ b/winforms/src/toga_winforms/keys.py @@ -1,32 +1,21 @@ import operator +import re from functools import reduce -from string import ascii_uppercase import System.Windows.Forms as WinForms from toga.keys import Key -WINFORMS_NON_PRINTABLES_MAP = { +WINFORMS_MODIFIERS = { Key.MOD_1: WinForms.Keys.Control, Key.MOD_2: WinForms.Keys.Alt, + Key.SHIFT: WinForms.Keys.Shift, } -WINFORMS_NON_PRINTABLES_MAP.update( - { - getattr(Key, modifier.upper()): getattr(WinForms.Keys, modifier.title()) - for modifier in ["shift", "up", "down", "left", "right", "home"] - } -) WINFORMS_KEYS_MAP = { Key.PLUS.value: WinForms.Keys.Oemplus, Key.MINUS.value: WinForms.Keys.OemMinus, } -WINFORMS_KEYS_MAP.update( - { - getattr(Key, letter).value: getattr(WinForms.Keys, letter) - for letter in ascii_uppercase - } -) WINFORMS_KEYS_MAP.update( {str(digit): getattr(WinForms.Keys, f"D{digit}") for digit in range(10)} ) @@ -34,14 +23,19 @@ def toga_to_winforms_key(key): codes = [] - for modifier, modifier_code in WINFORMS_NON_PRINTABLES_MAP.items(): + for modifier, modifier_code in WINFORMS_MODIFIERS.items(): if modifier.value in key: codes.append(modifier_code) key = key.replace(modifier.value, "") try: codes.append(WINFORMS_KEYS_MAP[key]) - except KeyError: # pragma: no cover - raise ValueError(f"unknown key: {key!r}") from None + except KeyError: + if match := re.fullmatch(r"<(.+)>", key): + key = match[1] + try: + codes.append(getattr(WinForms.Keys, key.title())) + except AttributeError: # pragma: no cover + raise ValueError(f"unknown key: {key!r}") from None return reduce(operator.or_, codes) diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index 25ac1132e0..3f4576655e 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -1,6 +1,7 @@ -from math import degrees, pi +from math import degrees import System.Windows.Forms as WinForms +from System import Array from System.Drawing import ( Bitmap, Graphics, @@ -21,7 +22,7 @@ from System.Drawing.Imaging import ImageFormat from System.IO import MemoryStream -from toga.widgets.canvas import Baseline, FillRule +from toga.widgets.canvas import Baseline, FillRule, arc_to_bezier, sweepangle from toga_winforms.colors import native_color from ..libs.wrapper import WeakrefCallable @@ -37,35 +38,39 @@ def __init__(self): def clear_paths(self): self.paths = [] - self.start_point = None - self.at_start_point = False + self.add_path() @property def current_path(self): - if len(self.paths) == 0: - self.add_path() return self.paths[-1] - def add_path(self): + 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 - # nowhere to save the starting point of each figure before we use it. In all other + # 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.at_start_point: - self.at_start_point = False - return self.start_point - elif self.current_path.PointCount: + if self.current_path.PointCount: return self.current_path.GetLastPoint() - else: - # Since we're returning start_point for immediate use, we don't set - # at_start_point here. - self.start_point = PointF(default_x, default_y) + elif self.start_point: return self.start_point + else: + return PointF(default_x, default_y) + + def print_path(self, path=None): # pragma: no cover + if path is None: + path = self.current_path + print( + "\n".join( + str((ptype, point.X, point.Y)) + for ptype, point in zip(path.PathTypes, path.PathPoints) + ) + ) class Canvas(Box): @@ -153,17 +158,15 @@ def begin_path(self, draw_context, **kwargs): # 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, draw_context, **kwargs): - start = draw_context.start_point - if start: + if draw_context.current_path.PointCount: + start = draw_context.current_path.PathPoints[0] draw_context.current_path.AddLine( - draw_context.get_last_point(start.X, start.Y), start + draw_context.current_path.GetLastPoint(), start ) self.move_to(start.X, start.Y, draw_context) def move_to(self, x, y, draw_context, **kwargs): - draw_context.current_path.StartFigure() - draw_context.start_point = PointF(x, y) - draw_context.at_start_point = True + draw_context.add_path(PointF(x, y)) def line_to(self, x, y, draw_context, **kwargs): draw_context.current_path.AddLine( @@ -202,16 +205,18 @@ def quadratic_curve_to(self, cpx, cpy, x, y, draw_context, **kwargs): def arc( self, x, y, radius, startangle, endangle, anticlockwise, draw_context, **kwargs ): - sweepangle = endangle - startangle - if anticlockwise: - if sweepangle > 0: - sweepangle -= 2 * pi - else: - if sweepangle < 0: - sweepangle += 2 * pi - - rect = RectangleF(x - radius, y - radius, 2 * radius, 2 * radius) - draw_context.current_path.AddArc(rect, degrees(startangle), degrees(sweepangle)) + self.ellipse( + x, + y, + radius, + radius, + 0, + startangle, + endangle, + anticlockwise, + draw_context, + **kwargs, + ) def ellipse( self, @@ -226,33 +231,32 @@ def ellipse( draw_context, **kwargs, ): - # Transformations apply not to individual points, but to entire GraphicsPath - # objects, so we must create a separate one for this shape. - draw_context.add_path() - - # The current transform will be applied when the path is filled or stroked, so - # make sure we don't apply it now. - self.push_context(draw_context) - draw_context.matrix.Reset() - - self.translate(x, y, draw_context) - self.rotate(rotation, draw_context) - if radiusx >= radiusy: - self.scale(1, radiusy / radiusx, draw_context) - self.arc(0, 0, radiusx, startangle, endangle, anticlockwise, draw_context) - else: - self.scale(radiusx / radiusy, 1, draw_context) - self.arc(0, 0, radiusy, startangle, endangle, anticlockwise, draw_context) - - draw_context.current_path.Transform(draw_context.matrix) + 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, anticlockwise) + ) + ] + ) + matrix.TransformPoints(points) - # Set up a fresh GraphicsPath for the next operation. - self.pop_context(draw_context) - draw_context.add_path() + start = draw_context.start_point + if start and not draw_context.current_path.PointCount: + draw_context.current_path.AddLine(start, start) + draw_context.current_path.AddBeziers(points) def rect(self, x, y, width, height, draw_context, **kwargs): + draw_context.add_path() rect = RectangleF(x, y, width, height) draw_context.current_path.AddRectangle(rect) + draw_context.add_path() # Drawing Paths @@ -265,7 +269,7 @@ def fill(self, color, fill_rule, draw_context, **kwargs): path.FillMode = FillMode.Winding path.Transform(draw_context.matrix) draw_context.graphics.FillPath(brush, path) - draw_context.paths.clear() + draw_context.clear_paths() def stroke(self, color, line_width, line_dash, draw_context, **kwargs): pen = Pen(native_color(color), self.scale_in(line_width, rounding=None)) @@ -275,7 +279,7 @@ def stroke(self, color, line_width, line_dash, draw_context, **kwargs): for path in draw_context.paths: path.Transform(draw_context.matrix) draw_context.graphics.DrawPath(pen, path) - draw_context.paths.clear() + draw_context.clear_paths() # Transformations diff --git a/winforms/tests_backend/widgets/canvas.py b/winforms/tests_backend/widgets/canvas.py index 43b84bfe21..465bf40fc8 100644 --- a/winforms/tests_backend/widgets/canvas.py +++ b/winforms/tests_backend/widgets/canvas.py @@ -32,10 +32,12 @@ async def mouse_activate(self, x, y, **kwargs): self.native.OnMouseUp(self.mouse_event(x, y, clicks=2, **kwargs)) async def mouse_drag(self, x1, y1, x2, y2, **kwargs): + # Without a mouse button pressed, a move event should be ignored. + move_event = self.mouse_event((x1 + x2) // 2, (y1 + y2) // 2, **kwargs) + self.native.OnMouseMove(move_event) + self.native.OnMouseDown(self.mouse_event(x1, y1, **kwargs)) - self.native.OnMouseMove( - self.mouse_event((x1 + x2) // 2, (y1 + y2) // 2, **kwargs) - ) + self.native.OnMouseMove(move_event) self.native.OnMouseUp(self.mouse_event(x2, y2, **kwargs)) async def alt_mouse_press(self, x, y):