Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
fbc73d5
Remove backreference, combine drawing actions, move methods
HalfWhitt Feb 3, 2026
3b5ef7f
Add change note
HalfWhitt Feb 3, 2026
45eecae
Fix coverage for 3.14
HalfWhitt Feb 3, 2026
592fbde
Switch to WeakSet
HalfWhitt Feb 6, 2026
502b41f
Switch to platform-specific images
HalfWhitt Feb 6, 2026
743ad6c
Merge branch 'main' into canvas-switcheroo
HalfWhitt Feb 6, 2026
defc23a
Correct platform-specific image name
HalfWhitt Feb 6, 2026
4b64f14
Update new tests
HalfWhitt Feb 6, 2026
ab679ef
Merge branch 'main' into canvas-switcheroo
HalfWhitt Feb 7, 2026
2fc60da
Update new test
HalfWhitt Feb 7, 2026
29b26fb
Add Android reference image
HalfWhitt Feb 7, 2026
b84617e
Update Qt image
HalfWhitt Feb 7, 2026
241a6cd
Use resized version:
HalfWhitt Feb 8, 2026
37869c9
Cleanups & fixes
HalfWhitt Feb 8, 2026
0029a84
Fix link
HalfWhitt Feb 8, 2026
2b5a10f
Wording fix
HalfWhitt Feb 11, 2026
afb54b2
Add more explanation of State
HalfWhitt Feb 11, 2026
bb6b359
Simplify active state lookup
HalfWhitt Feb 20, 2026
ac3bca8
Raise exception when states are re-entered
HalfWhitt Feb 20, 2026
ec73572
Close context manages when adding an action.
HalfWhitt Feb 24, 2026
55e4680
Add _can_be_entered attribute
HalfWhitt Feb 24, 2026
127f155
More comprehensive contains testing
HalfWhitt Feb 25, 2026
ac3a0e9
Deprecate x and y in context managers
HalfWhitt Feb 26, 2026
1b47c46
Actually suppress warnings
HalfWhitt Feb 27, 2026
73a1baa
Merge branch 'main' into canvas-switcheroo
HalfWhitt Apr 12, 2026
6a16ded
Update pre-commit hook
HalfWhitt Apr 12, 2026
860881a
Accommodate Togat Chart ClosedPath usage
HalfWhitt Apr 12, 2026
51098e2
Fix tutorials, drawing_objects references, and changenote clarification
HalfWhitt Apr 14, 2026
44f02f1
Mark static web tests as flaky...
freakboy3742 Apr 15, 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
6 changes: 6 additions & 0 deletions changes/4159.removal.md
Comment thread
HalfWhitt marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
There are a number of changes to the Canvas widget's drawing API:
- Drawing methods can now be called directly on a Canvas. Calling them on States is now deprecated.
- The CamelCase context-manager drawing methods are deprecated; they are now unified with their standalone counterparts. For example, the `fill` method (lowercase) can be used as a normal method or as `with canvas.fill():`.
- List-like methods on States are deprecated; manipulate their `State.drawing_actions` lists directly.
- `State.redraw()` is deprecated; call `Canvas.redraw()` instead.
- States no longer hold a reference to their Canvas; the `State.canvas` attribute is deprecated. (This is largely an internal detail, and unlikely to affect user code.)
2 changes: 1 addition & 1 deletion core/src/toga/fonts.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def __init__(
"""Construct a reference to a font.

This class should be used when an API requires an explicit font reference (e.g.
[`State.write_text`][toga.widgets.canvas.State.write_text]). In all other
[`Canvas.write_text`][toga.Canvas.write_text]). In all other
cases, fonts in Toga are controlled
using the style properties linked below.

Expand Down
24 changes: 12 additions & 12 deletions core/src/toga/widgets/canvas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,34 @@
Arc,
BeginPath,
BezierCurveTo,
ClosePath,
DrawImage,
DrawingAction,
Ellipse,
Fill,
LineTo,
MoveTo,
QuadraticCurveTo,
Rect,
ResetTransform,
Rotate,
Scale,
Stroke,
Translate,
WriteText,
)
from .geometry import arc_to_bezier, sweepangle
from .state import ClosedPathContext, FillContext, State, StrokeContext
from .state import ClosePath, Fill, State, Stroke

# Make sure deprecation warnings are shown by default
warnings.filterwarnings("default", category=DeprecationWarning)

_deprecated_names = {
# Jan 2026: DrawingAction was named DrawingObject, and State was named Context, in
# Toga 0.5.3 and earlier.
# 2026-02: The following have different names than they did in Toga 0.5.3 and
# earlier.
"DrawingObject": DrawingAction,
"Context": State,
# No one should be using these directly anyway, but just in case...
"ClosedPathContext": ClosePath,
"FillContext": Fill,
"StrokeContext": Stroke,
}


Expand All @@ -54,24 +56,22 @@ def __getattr__(name):
"Arc",
"BeginPath",
"BezierCurveTo",
"ClosePath",
"DrawImage",
"Ellipse",
"Fill",
"LineTo",
"MoveTo",
"QuadraticCurveTo",
"Rect",
"ResetTransform",
"Rotate",
"Scale",
"Stroke",
"Translate",
"WriteText",
# States
"ClosedPathContext",
"State",
"FillContext",
"StrokeContext",
"Fill",
"Stroke",
"ClosePath",
# Geometry
"arc_to_bezier",
"sweepangle",
Expand Down
132 changes: 41 additions & 91 deletions core/src/toga/widgets/canvas/canvas.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
from __future__ import annotations

import warnings
from contextlib import AbstractContextManager as ContextManager
from typing import (
TYPE_CHECKING,
Any,
Literal,
Protocol,
)
from weakref import ref

import toga
from toga.constants import FillRule
from toga.fonts import (
SYSTEM,
SYSTEM_DEFAULT_FONT_SIZE,
Expand All @@ -19,10 +18,9 @@
from toga.handlers import wrapped_handler

from ..base import StyleT, Widget
from .state import ClosedPathContext, FillContext, State, StrokeContext
from .state import DrawingActionDispatch, State

if TYPE_CHECKING:
from toga.colors import ColorT
from toga.images import ImageT

# Make sure deprecation warnings are shown by default
Expand Down Expand Up @@ -52,10 +50,13 @@ def __call__(self, widget: Canvas, width: int, height: int, **kwargs: Any) -> No
"""


class Canvas(Widget):
class Canvas(Widget, DrawingActionDispatch):
_MIN_WIDTH = 0
_MIN_HEIGHT = 0

# 2026-02: Backwards compatibility for <= 0.5.3
_instances: list[ref] = []
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.

Order doesn't matter, so a weakref.WeakSet may provide a nicer API than a list of weakrefs?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Also has the advantage of a faster keyed lookup, and no duplication of items.

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.

Thank you, that's not a thing I knew about! I don't think we'll ever have to worry about checking membership or about duplication, but the fact that it auto-prunes itself is great.


def __init__(
self,
id: str | None = None,
Expand Down Expand Up @@ -88,7 +89,7 @@ def __init__(
:param on_alt_drag: Initial [`on_alt_drag`][toga.Canvas.on_alt_drag] handler.
:param kwargs: Initial style properties.
"""
self._state = State(canvas=self)
self._state = State()

super().__init__(id, style, **kwargs)

Expand All @@ -102,6 +103,9 @@ def __init__(
self.on_alt_release = on_alt_release
self.on_alt_drag = on_alt_drag

# 2026-02: Backwards compatibility for <= 0.5.3
self._instances.append(ref(self))

def _create(self) -> Any:
return self.factory.Canvas(interface=self)

Expand All @@ -126,6 +130,10 @@ def root_state(self) -> State:
"""The root state for the canvas."""
return self._state

######################################################################
# 2026-02: Backwards compatibility for <= 0.5.3
######################################################################

@property
def context(self) -> State:
warnings.warn(
Expand All @@ -135,96 +143,38 @@ def context(self) -> State:
)
return self._state

######################################################################
# End backwards compatibility
######################################################################

@property
def _action_target(self):
"""Return the currently active state."""
state = self.root_state

while state.drawing_actions:
for action in reversed(state.drawing_actions):
# Look through its drawing actions, from the bottom up.
if getattr(action, "_is_open", False):
# If it's currently open as a context manager, assign it to state
# and break out of the for loop.
state = action
break
# If none of the drawing actions were open, break out of the while loop.
else:
break
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.

This may fall into the category of premature optimisation, but this has the feel of something that should be looked up rather than searched for. Worst-case performance is quadratic in the number of things being drawn. I think walking the chain of open states should be fine.

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.

I have a definite preference for the first - the second seems like a recipe for confusion - but I realise there may be backwards compatibility concerns here as well.

At least as of what this PR currently proposes, calling drawing methods on states at all is deprecated. Therefore its behavior is kept the same as before — it adds to the state whose method is called. Canvas has only now gained drawing methods (at least for the first time in several years), and is now the "proper" place to use such methods. So there's no need for it to behave exactly like the now-deprecated style.

This may fall into the category of premature optimisation, but this has the feel of something that should be looked up rather than searched for. Worst-case performance is quadratic in the number of things being drawn. I think walking the chain of open states should be fine.

Hm... I hadn't really thought of thousands+, I suppose. That's a fair point. This is almost making me wonder if State should hold onto a reference to its Canvas(es?), but which is set upon being added, rather than in the initializer. That way enter/exit methods could push/pull to a list held by the Canvas. Is that roughly what you mean by "walking the chain of open states"?

I suppose we could do something similar even without the state being able to relay information back to its Canvas. If every drawing method that makes a state also adds that state to the list, that would work — it's just that then, instead of automatically targeting the last item, you'd have to start at the end and pop them off until you get to one that's actually been entered (as opposed to being used as a "standalone" command):

with canvas.fill():  # Added to list
    canvas.stroke()  # Added to list
    canvas.move_to(...)  # Pops unopened stroke, then adds to fill

Alongside all this, there's always the possibility of someone doing something "out of order" like:

fill = canvas.fill()

with canvas.stroke():
    with fill:
        # Instructions here 

Or even entering the same state multiple times (unless we build in a check to not allow that). When it comes to doing things outside the intended usage, I'm not sure where exactly we should draw the line between handling gracefully / raising an exception / declaring undefined behavior.

Copy link
Copy Markdown
Contributor

@corranwebster corranwebster Feb 14, 2026

Choose a reason for hiding this comment

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

Some thoughts:

  • I'd hope that the canvas can handle something like a choropleth map of the US by state (or even county), for example, and each point on a region boundary is a line_to object in the current design (although those would ideally be paths in the future).

  • it's not the end of the world to have back references to a parent object, but if you can avoid it the design is more flexible

  • yes, something where you had a shared list of open states that states could add and remove themselves from would work, but there are other possibilities

  • for example, a simple rule like: for any state/fill/stroke/etc.

    1. if the last drawing action is not an open state/fill/stroke, the action target is self,
    2. otherwise, the action target is the action target of the last drawing action.

    Not as efficient as looking at the end of a list or a cached value on the Canvas, but probably not too bad.

Code for the last would look something like (with recursion removed):

@property
def _action_target(self):
    state = self
    while state.drawing_actions and getattr(state.drawing_actions[-1], '_is_open', False):
        state = state.drawing_actions[-1]
    return state

This could solve the particular difference between the behaviour of Canvas and State; it has a bit of overhead if things are deeply nested, but it shouldn't ever be quadratic in the number of things being drawn.

Other than examples like the "out-of-order" example above, the behaviour is fairly clear. And in that example I think it may be possible to raise an error by doing something like:

  • when adding a new drawing action to a state, check if the previous action is a state/fill/stroke/etc. and if it is, set _is_open to False
  • in the __enter__ method, ensure that _is_open is not set, otherwise raise (either a True or False value is a problem - False means you've entered a closed context, while True would mean a double-entering of the context)

So when canvas.stroke() is called, it sets fill._is_open to False and then with fill raises an error.

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.

  • it's not the end of the world to have back references to a parent object, but if you can avoid it the design is more flexible

Yeah, conceptually I like the idea of states being self-contained. Although with the addition of Path2d, the ability for them to be portable / reusable is a lot less needed / concretely useful.

  • for example, a simple rule like: for any state/fill/stroke/etc.

    1. if the last drawing action is not an open state/fill/stroke, the action target is self,
    2. otherwise, the action target is the action target of the last drawing action.

    Not as efficient as looking at the end of a list or a cached value on the Canvas, but probably not too bad.

I somehow hadn't clocked the fact that if one of a state's drawing actions is a currently open state, it would have to be the last drawing action, because any subsequent actions should be getting added to the now-open state... that's a very good point, and saves a lot of iteration.

This could solve the particular difference between the behaviour of Canvas and State; it has a bit of overhead if things are deeply nested, but it shouldn't ever be quadratic in the number of things being drawn.

I don't think that's a problem we need to solve, if one of those is being deprecated and the other one is being added. The one being deprecated (state's target) needs to maintain compatibility with old code, while the one being added (canvas's target) needs to behave how we want the API to work going forward. There's no need for them to match.

Other than examples like the "out-of-order" example above, the behaviour is fairly clear. And in that example I think it may be possible to raise an error by doing something like:

  • when adding a new drawing action to a state, check if the previous action is a state/fill/stroke/etc. and if it is, set _is_open to False

  • in the __enter__ method, ensure that _is_open is not set, otherwise raise (either a True or False value is a problem - False means you've entered a closed context, while True would mean a double-entering of the context)

So when canvas.stroke() is called, it sets fill._is_open to False and then with fill raises an error.

Yeah, that all sounds reasonable.

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.

  1. if the last drawing action is not an open state/fill/stroke, the action target is self,
  2. otherwise, the action target is the action target of the last drawing action.
  • in the __enter__ method, ensure that _is_open is not set, otherwise raise (either a True or False value is a problem - False means you've entered a closed context, while True would mean a double-entering of the context)

Just pushed an update that simplifies the active state lookup as described above, and raises an error if a context manager is reentered.

  • when adding a new drawing action to a state, check if the previous action is a state/fill/stroke/etc. and if it is, set _is_open to False

On second thought, I don't think there's any way to do this with the "standard" API (that is, drawing methods of Canvas). And if a user does it by adding a drawing action directly to the State's list of drawing actions, then:

  • We have no good way to check that, as long as we're exposing drawing_actions directly as a list; and
  • I think we can be a little less careful about warnings of "improper" use in the nonlinear API. Especially once the docs page is restructured, this will mean the user has already clicked on and read the "advanced usage" section... which should point out that mixing linear and nonlinear usage can cause weird or undefined behavior.

Copy link
Copy Markdown
Contributor

@corranwebster corranwebster Feb 21, 2026

Choose a reason for hiding this comment

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

On second thought, I don't think there's any way to do this with the "standard" API (that is, drawing methods of Canvas). And if a user does it by adding a drawing action directly to the State's list of drawing actions, then:

  • We have no good way to check that, as long as we're exposing drawing_actions directly as a list; and
  • I think we can be a little less careful about warnings of "improper" use in the nonlinear API. Especially once the docs page is restructured, this will mean the user has already clicked on and read the "advanced usage" section... which should point out that mixing linear and nonlinear usage can cause weird or undefined behavior.

So my particular concern was with people doing things like this:

fill = context.fill()
with fill:
    # draw some stuff; gets filled at the end
    context.arc(...)

vs.

fill = context.fill()  # fill happens here
context.rect(...)
with fill:
    # draw some stuff; no fill at the end, context manager has no effect
    context.arc(...)

It would be nice to error in the second case since it's not doing what the user might expect and could be confusing. I think that could be achieved by doing something like the following in State:

def _append_action(self, drawing_action):
    if self._action_target.drawing_actions:
        self._action_target.drawing_actions[-1].is_open = False
    self._action_target.drawing_actions.append(drawing_action)

and then calling this from rect and all other methods that draw:

def rect(self, ...):
    rect = Rect(...)
    self._append_action(rect)

But since the second example doesn't have bad side-effects and is rather just confusing, then this is not essential: the current behaviour after the changes you've made is OK.

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.

Ah, I see what you mean. I wasn't even thinking about using the drawing methods, but saving them first and entering them later. I tweaked your approach a little, because we only want to set the attribute if the drawing action in question is (potentially) a context manager. Thanks for pointing this out.

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.

Oh, hm. It's a little more finicky than that. "Wasn't used as a context manager" and "Was used as a context manager and then closed" need to be differentiated, so the action knows how to draw itself...


return state

def redraw(self) -> None:
"""Redraw the Canvas.

The Canvas will be automatically redrawn after adding or removing a drawing
object, or when the Canvas resizes. However, when you modify the properties of a
drawing object, you must call `redraw` manually.
The Canvas will be automatically redrawn after calling its drawing methods.
However, when you directly add, remove, or modify a drawing action, you must
call `redraw` manually.
"""
self._impl.redraw()

def Context(self) -> ContextManager[State]:
"""Construct and yield a new sub-[`State`][toga.widgets.canvas.State] within
the root state of this Canvas.

:return: Yields the new [`State`][toga.widgets.canvas.State] object.
"""
warnings.warn(
"Canvas.Context() is deprecated. Use Canvas.root_state.state() instead.",
DeprecationWarning,
stacklevel=2,
)
return self.root_state.state()

def ClosedPath(
self,
x: float | None = None,
y: float | None = None,
) -> ContextManager[ClosedPathContext]:
"""Construct and yield a new
[`ClosedPathContext`][toga.widgets.canvas.ClosedPathContext]
state in the root state of this canvas.

:param x: The x coordinate of the path's starting point.
:param y: The y coordinate of the path's starting point.
:return: Yields the new
[`ClosedPathContext`][toga.widgets.canvas.ClosedPathContext] state object.
"""
return self.root_state.ClosedPath(x, y)

def Fill(
self,
x: float | None = None,
y: float | None = None,
color: ColorT | None = None,
fill_rule: FillRule = FillRule.NONZERO,
) -> ContextManager[FillContext]:
"""Construct and yield a new [`FillContext`][toga.widgets.canvas.FillContext]
in the root state of this canvas.

A drawing operator that fills the path constructed in the state according to
the current fill rule.

If both an x and y coordinate is provided, the drawing state will begin with
a `move_to` operation in that state.

:param x: The x coordinate of the path's starting point.
:param y: The y coordinate of the path's starting point.
:param fill_rule: `nonzero` is the non-zero winding rule; `evenodd` is the
even-odd winding rule.
:param color: The fill color.
:return class: Yields the new [`FillContext`][toga.widgets.canvas.FillContext]
state object.
"""
return self.root_state.Fill(x, y, color, fill_rule)

def Stroke(
self,
x: float | None = None,
y: float | None = None,
color: ColorT | None = None,
line_width: float | None = None,
line_dash: list[float] | None = None,
) -> ContextManager[StrokeContext]:
"""Construct and yield a new
[`StrokeContext`][toga.widgets.canvas.StrokeContext] in the
root state of this canvas.

If both an x and y coordinate is provided, the drawing state will begin with
a `move_to` operation in that state.

:param x: The x coordinate of the path's starting point.
:param y: The y coordinate of the path's starting point.
:param color: The color for the stroke.
:param line_width: The width of the stroke.
:param line_dash: The dash pattern to follow when drawing the line. Default is a
solid line.
:return: Yields the new
[`StrokeContext`][toga.widgets.canvas.StrokeContext] state object.
"""
return self.root_state.Stroke(x, y, color, line_width, line_dash)

@property
def on_resize(self) -> OnResizeHandler:
"""The handler to invoke when the canvas is resized."""
Expand Down Expand Up @@ -320,7 +270,7 @@ def measure_text(
line_height: float | None = None,
) -> tuple[float, float]:
"""Measure the size at which
[`State.write_text`][toga.widgets.canvas.State.write_text]
[`Canvas.write_text`][toga.Canvas.write_text]
would render some text.

:param text: The text to measure. Newlines will cause line breaks, but long
Expand Down
Loading
Loading