Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions android/src/toga_android/widgets/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think should be 5 (actually probably sqrt(24)) given how it is measured differently?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, I pushed this before reading that.

Took me a sec to figure out where you got sqrt(24) from, but thank you, that makes sense. (For some reason I had it in my head that the difference would depend on the angle, but right, we're picking the specific angle for the cutoff.)

Copy link
Copy Markdown
Member Author

@HalfWhitt HalfWhitt Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My initial intuition was partly correct. That starts truncating at the same angle that HTML stops mitering, which I guess it as good as we can do.

I wish we could make it consitently stop drawing at the same distance that's the bevel cutoff for the HTML spec. But we can't (for all but one angle), because a fixed distance where Qt measures it (along the stroke edge) doesn't stay consistent with where the miter limit should be when measured up from the inside corner:

Qt miter comparison animated

(The green measurement on the upper right is the Qt limit drawn at sqrt(24), and the bit that's sliding up and down is where the HTML spec puts the miter limit at each angle.)

Copy link
Copy Markdown
Member Author

@HalfWhitt HalfWhitt Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, wait, I just noticed where this comment is. Android does things properly, it just has a different default value. It's Qt that's the especially weird one.


self.states = [State(fill, stroke, Matrix())]
self.reset_transform()
Expand Down
1 change: 1 addition & 0 deletions changes/4162.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The Android backend's Canvas implementation now has the same default corner-mitering threshold as the HTML canvas specification.
1 change: 1 addition & 0 deletions changes/4162.misc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The Qt backend's Canvas implementation's handling of miters is now closer to that of the HTML spec.
2 changes: 2 additions & 0 deletions docs/en/reference/api/widgets/canvas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions qt/src/toga_qt/widgets/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 6 additions & 1 deletion qt/tests_backend/widgets/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
89 changes: 60 additions & 29 deletions testbed/tests/widgets/test_canvas.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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")

Expand All @@ -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")

Expand All @@ -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")

Expand All @@ -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")

Expand All @@ -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)
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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)
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 6 additions & 1 deletion winforms/tests_backend/widgets/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down