diff --git a/android/src/toga_android/widgets/canvas.py b/android/src/toga_android/widgets/canvas.py index b30c8261ca..5ff1bd0d87 100644 --- a/android/src/toga_android/widgets/canvas.py +++ b/android/src/toga_android/widgets/canvas.py @@ -56,6 +56,7 @@ def __init__(self, impl, native): stroke.setStyle(Paint.Style.STROKE) stroke.setStrokeWidth(2.0) stroke.setColor(BLACK) + stroke.setStrokeMiter(10.0) self.states = [State(fill, stroke, Matrix())] self.reset_transform() diff --git a/changes/4162.bugfix.md b/changes/4162.bugfix.md new file mode 100644 index 0000000000..69984e7473 --- /dev/null +++ b/changes/4162.bugfix.md @@ -0,0 +1 @@ +The Android backend's Canvas implementation now has the same default corner-mitering threshold as the HTML canvas specification. diff --git a/changes/4162.misc.md b/changes/4162.misc.md new file mode 100644 index 0000000000..54dcdd4c86 --- /dev/null +++ b/changes/4162.misc.md @@ -0,0 +1 @@ +The Qt backend's Canvas implementation's handling of miters is now closer to that of the HTML spec. diff --git a/docs/en/reference/api/widgets/canvas.md b/docs/en/reference/api/widgets/canvas.md index f65323c523..8e5a8ffcb6 100644 --- a/docs/en/reference/api/widgets/canvas.md +++ b/docs/en/reference/api/widgets/canvas.md @@ -62,6 +62,8 @@ For detailed tutorials on the use of Canvas drawing instructions, see the MDN do ## Notes +- Toga does not guarantee pixel perfect rendering of Canvas content across all platforms. Most drawing instructions will appear identical across all platforms, and in the worst case, any given set of drawing instructions should result in a fundamentally similar image. However, text and other complex curve and line geometries (such as miters on tight corners) will result in minor discrepancies between platforms. Color rendition can also vary slightly between platforms depending on the color profiles of the device being used to render the canvas. + - The Canvas API allows the use of handlers to respond to mouse/pointer events. These event handlers differentiate between "primary" and "alternate" modes of activation. When a mouse is in use, alternate activation will usually be interpreted as a "right click"; however, platforms may not implement an alternate activation mode. To ensure cross-platform compatibility, applications should not use the alternate press handlers as the sole mechanism for accessing critical functionality. ## Reference diff --git a/qt/src/toga_qt/widgets/canvas.py b/qt/src/toga_qt/widgets/canvas.py index ad14f97f7f..9c83257cd5 100644 --- a/qt/src/toga_qt/widgets/canvas.py +++ b/qt/src/toga_qt/widgets/canvas.py @@ -40,6 +40,10 @@ def __init__(self, former_state=None): self.stroke = QPen(BLACK) self.stroke.setCapStyle(Qt.PenCapStyle.FlatCap) self.stroke.setWidth(2.0) + self.stroke.setJoinStyle(Qt.MiterJoin) + # Qt measures miter length along the edge of the stroke, from where the + # bevel would end to the point. + self.stroke.setMiterLimit(4.899) # sqrt(24) class Context: diff --git a/qt/tests_backend/widgets/canvas.py b/qt/tests_backend/widgets/canvas.py index 80d51e8af7..5810db7a68 100644 --- a/qt/tests_backend/widgets/canvas.py +++ b/qt/tests_backend/widgets/canvas.py @@ -12,7 +12,12 @@ class CanvasProbe(SimpleProbe): native_class = TogaCanvas def reference_variant(self, reference): - if reference in {"multiline_text", "write_text", "write_text_and_path"}: + if reference in { + "multiline_text", + "write_text", + "write_text_and_path", + "miter_join", + }: return f"{reference}-qt" else: return reference diff --git a/testbed/src/testbed/resources/canvas/miter_join-qt.png b/testbed/src/testbed/resources/canvas/miter_join-qt.png new file mode 100644 index 0000000000..6dcf10063c Binary files /dev/null and b/testbed/src/testbed/resources/canvas/miter_join-qt.png differ diff --git a/testbed/src/testbed/resources/canvas/miter_join-winforms.png b/testbed/src/testbed/resources/canvas/miter_join-winforms.png new file mode 100644 index 0000000000..0b41120ba9 Binary files /dev/null and b/testbed/src/testbed/resources/canvas/miter_join-winforms.png differ diff --git a/testbed/src/testbed/resources/canvas/miter_join.png b/testbed/src/testbed/resources/canvas/miter_join.png new file mode 100644 index 0000000000..3be7873437 Binary files /dev/null and b/testbed/src/testbed/resources/canvas/miter_join.png differ diff --git a/testbed/tests/widgets/test_canvas.py b/testbed/tests/widgets/test_canvas.py index 351b8ae924..2063e016ae 100644 --- a/testbed/tests/widgets/test_canvas.py +++ b/testbed/tests/widgets/test_canvas.py @@ -1,7 +1,7 @@ import math import os from itertools import chain -from math import pi, radians +from math import pi, radians, tan from unittest.mock import Mock, call import pytest @@ -112,7 +112,7 @@ async def canvas(widget, probe, on_resize_handler): async def test_resize(widget, probe, on_resize_handler): - "Resizing the widget causes on-resize events" + """Resizing the widget causes on-resize events.""" # Make the canvas visible against window background. widget.style.background_color = CORNFLOWERBLUE @@ -166,7 +166,7 @@ async def test_activate( on_release_handler, on_activate_handler, ): - "Activation events trigger handlers" + """Activation events trigger handlers.""" await probe.mouse_activate(20, 30) await probe.redraw("Activate has been handled") @@ -182,7 +182,7 @@ async def test_drag( on_drag_handler, on_release_handler, ): - "A drag event triggers a handler" + """A drag event triggers a handler.""" await probe.mouse_drag(20, 40, 70, 90) await probe.redraw("Drag has been handled") @@ -192,7 +192,7 @@ async def test_drag( async def test_alt_press(canvas, probe, on_alt_press_handler, on_alt_release_handler): - "An alternate press event triggers a handler" + """An alternate press event triggers a handler.""" await probe.alt_mouse_press(20, 40) await probe.redraw("Alt press has been handled") @@ -207,7 +207,7 @@ async def test_alt_drag( on_alt_drag_handler, on_alt_release_handler, ): - "A drag event triggers a handler" + """A drag event triggers a handler.""" await probe.alt_mouse_drag(20, 40, 70, 90) await probe.redraw("Alternate drag has been handled") @@ -217,7 +217,7 @@ async def test_alt_drag( async def test_image_data(canvas, probe): - "The canvas can be saved as an image" + """The canvas can be saved as an image.""" with canvas.Stroke(x=0, y=0, color=RED) as stroke: stroke.line_to(x=200, y=200) stroke.move_to(x=200, y=0) @@ -245,7 +245,7 @@ async def test_image_data(canvas, probe): def assert_reference(probe, reference, threshold=0.01): """Assert that the canvas currently matches a reference image, within an - RMS threshold""" + RMS threshold.""" # Get the canvas image. image = probe.get_image() scaled_image = image.resize((200, 200)) @@ -283,7 +283,7 @@ def save(): async def test_transparency(canvas, probe): - "Transparency is preserved in captured images" + """Transparency is preserved in captured images.""" canvas.style.background_color = TRANSPARENT # Draw a rectangle. move_to is implied @@ -300,7 +300,7 @@ async def test_transparency(canvas, probe): async def test_paths(canvas, probe): - "A path can be drawn" + """A path can be drawn.""" # A filled path closes automatically. canvas.root_state.begin_path() @@ -345,7 +345,7 @@ async def test_paths(canvas, probe): async def test_bezier_curve(canvas, probe): - "A Bézier curve can be drawn" + """A Bézier curve can be drawn.""" canvas.root_state.begin_path() canvas.root_state.move_to(100, 44) @@ -362,7 +362,7 @@ async def test_bezier_curve(canvas, probe): async def test_quadratic_curve(canvas, probe): - "A quadratic curve can be drawn" + """A quadratic curve can be drawn.""" canvas.root_state.begin_path() canvas.root_state.move_to(100, 20) @@ -379,7 +379,7 @@ async def test_quadratic_curve(canvas, probe): async def test_arc(canvas, probe): - "An arc can be drawn" + """An arc can be drawn.""" canvas.root_state.begin_path() # Face @@ -418,7 +418,7 @@ async def test_arc(canvas, probe): async def test_ellipse(canvas, probe): - "An ellipse can be drawn" + """An ellipse can be drawn.""" # Nucleus (filled circle) canvas.root_state.move_to(90, 100) @@ -461,7 +461,7 @@ async def test_ellipse(canvas, probe): async def test_ellipse_path(canvas, probe): - "An elliptical arc can be connected to other segments of a path" + """An elliptical arc can be connected to other segments of a path.""" state = canvas.root_state ellipse_args = { @@ -498,7 +498,7 @@ async def test_ellipse_path(canvas, probe): async def test_rect(canvas, probe): - "A rectangle can be drawn" + """A rectangle can be drawn.""" # Draw a rectangle. move_to is implied canvas.root_state.begin_path() @@ -510,7 +510,7 @@ async def test_rect(canvas, probe): async def test_fill(canvas, probe): - "A fill can be drawn with primitives" + """A fill can be drawn with primitives.""" # Draw a closed path canvas.root_state.begin_path() canvas.root_state.move_to(x=60, y=10) @@ -534,7 +534,7 @@ async def test_fill(canvas, probe): async def test_stroke(canvas, probe): - "A stroke can be drawn with primitives" + """A stroke can be drawn with primitives.""" # Draw a closed path canvas.root_state.begin_path() canvas.root_state.move_to(x=20, y=20) @@ -584,7 +584,7 @@ async def test_stroke_and_fill(canvas, probe): async def test_closed_path_state(canvas, probe): - "A closed path can be built with a state" + """A closed path can be built with a state.""" # Build a parallelogram path with canvas.root_state.ClosedPath(x=20, y=20) as path: @@ -600,7 +600,7 @@ async def test_closed_path_state(canvas, probe): async def test_fill_state(canvas, probe): - "A fill path can be built with a state" + """A fill path can be built with a state.""" # Build a filled parallelogram with canvas.root_state.Fill(x=20, y=20, color=REBECCAPURPLE) as path: @@ -613,7 +613,7 @@ async def test_fill_state(canvas, probe): async def test_stroke_state(canvas, probe): - "A stroke can be drawn with a state" + """A stroke can be drawn with a state.""" # Draw a thin line with canvas.root_state.Stroke(x=40, y=20, color=REBECCAPURPLE) as stroke: stroke.line_to(x=80, y=180) @@ -629,7 +629,7 @@ async def test_stroke_state(canvas, probe): async def test_stroke_and_fill_state(canvas, probe): - "A shape can be stroked and filled using states" + """A shape can be stroked and filled using states.""" # Draw a filled parallelogram with canvas.root_state.Fill(x=20, y=20, color=REBECCAPURPLE) as fill: @@ -666,7 +666,7 @@ async def test_nested_stroke_and_fill_state(canvas, probe): async def test_transforms(canvas, probe): - "Transforms can be applied" + """Transforms can be applied.""" # Draw a rectangle after a horizontal translation canvas.root_state.translate(160, 20) @@ -698,7 +698,7 @@ async def test_transforms(canvas, probe): async def test_transforms_mid_path(canvas, probe): - "Transforms can be applied mid-path" + """Transforms can be applied mid-path.""" # draw a series of rotated rectangles canvas.root_state.begin_path() @@ -731,7 +731,7 @@ async def test_transforms_mid_path(canvas, probe): async def test_singular_transforms(canvas, probe): - "Singular transforms behave reasonably" + """Singular transforms behave reasonably.""" ctx = canvas.root_state ctx.begin_path() @@ -792,7 +792,7 @@ async def test_singular_transforms(canvas, probe): reason="Canvas tests are unstable outside of CI. Manual inspection may be required", ) async def test_write_text(canvas, probe): - "Text can be measured and written" + """Text can be measured and written.""" # Use fonts which look different from the system fonts on all platforms. Font.register("Droid Serif", "resources/fonts/DroidSerif-Regular.ttf") @@ -875,7 +875,7 @@ async def test_write_text(canvas, probe): reason="may fail outside of a GitHub runner environment", ) async def test_multiline_text(canvas, probe): - "Multiline text can be measured and written" + """Multiline text can be measured and written.""" # Vertical guidelines X = [10, 75, 140] @@ -993,7 +993,7 @@ async def test_write_text_and_path(canvas, probe): async def test_draw_image_at_point(canvas, probe): - "Images can be drawn at a point." + """Images can be drawn at a point.""" image = TogaImage("resources/sample.png") canvas.root_state.begin_path() @@ -1004,7 +1004,7 @@ async def test_draw_image_at_point(canvas, probe): async def test_draw_image_in_rect(canvas, probe): - "Images can be drawn in a rectangle." + """Images can be drawn in a rectangle.""" image = TogaImage("resources/sample.png") canvas.root_state.begin_path() @@ -1017,3 +1017,34 @@ async def test_draw_image_in_rect(canvas, probe): await probe.redraw("Image should be drawn") assert_reference(probe, "draw_image_in_rect", threshold=0.05) + + +async def test_miter_join(canvas, probe): + """Lines are joined with a miter, down to about 11.5º.""" + + def draw_angle(canvas, angle, x): + height = 110 + line_width = 15 + angle = angle * pi / 180 + half_width = height * tan(angle / 2) + + with canvas.root_state.state() as ctx: + # Translate to the vertex + ctx.translate(x, 85) + + ctx.begin_path() + ctx.move_to(half_width, height) + ctx.line_to(0, 0) + ctx.line_to(-half_width, height) + + ctx.stroke(line_width=line_width) + ctx.stroke(line_width=2, color=REBECCAPURPLE) + + # Left two should be mitered, right two should be beveled. + # (Windows and Qt don't bevel, they just start truncating the miter.) + for i, angle in enumerate([15, 12, 11, 8]): + x = 30 + i * 50 + draw_angle(canvas, angle, x) + + await probe.redraw("Image should be drawn") + assert_reference(probe, "miter_join", threshold=0.025) diff --git a/tox.ini b/tox.ini index 16bf7d56a6..e503fe130c 100644 --- a/tox.ini +++ b/tox.ini @@ -105,8 +105,8 @@ deps = ./core commands = !lint-!all-!live-!en : build_md_translations {posargs} en --source-code=core{/}src --source-code=travertino{/}src --source-code=android{/}src - lint : markdown-checker --dir {[docs]docs_dir} --func check_broken_urls lint : pyspelling + lint : markdown-checker --dir {[docs]docs_dir} --func check_broken_urls live : live_serve_en {posargs} core{/}src --source-code=core{/}src --source-code=travertino{/}src --source-code=android{/}src all : build_md_translations {posargs} en --source-code=core{/}src --source-code=travertino{/}src --source-code=android{/}src en : build_md_translations {posargs} en --source-code=core{/}src --source-code=travertino{/}src --source-code=android{/}src diff --git a/winforms/tests_backend/widgets/canvas.py b/winforms/tests_backend/widgets/canvas.py index d1eae208a3..a37627816f 100644 --- a/winforms/tests_backend/widgets/canvas.py +++ b/winforms/tests_backend/widgets/canvas.py @@ -10,7 +10,12 @@ class CanvasProbe(SimpleProbe): native_class = Panel def reference_variant(self, reference): - if reference in {"multiline_text", "write_text", "write_text_and_path"}: + if reference in { + "multiline_text", + "write_text", + "write_text_and_path", + "miter_join", + }: return f"{reference}-winforms" return reference