Skip to content
Merged
1 change: 1 addition & 0 deletions changes/4330.misc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The parameters of `Canvas`'s `fill` and `stroke` methods now more closely align with their Javascript versions; additional parameters accepted by Toga must be provided as keyword arguments.
19 changes: 0 additions & 19 deletions core/src/toga/widgets/canvas/drawingaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from typing import TYPE_CHECKING, Any
from warnings import filterwarnings, warn

from toga.colors import Color
from toga.constants import Baseline
from toga.fonts import (
SYSTEM,
Expand Down Expand Up @@ -119,24 +118,6 @@ def __contains__(self, other: DrawingAction):
)


class color_property:
def __get__(self, action, action_class=None):
if action is None:
return self

return action._color

def __set__(self, action, value):
if value is self or value is None:
# value is self when no argument is supplied in the dataclass constructor;
# this is how we define a default value for the hidden attribute.
value = None
else:
value = Color.parse(value)

action._color = value


class BeginPath(DrawingAction):
def _draw(self, context: Any) -> None:
context.begin_path()
Expand Down
94 changes: 73 additions & 21 deletions core/src/toga/widgets/canvas/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
from abc import ABC, abstractmethod
from collections.abc import Iterable
from contextlib import AbstractContextManager
from dataclasses import dataclass
from dataclasses import KW_ONLY, InitVar, dataclass
from math import pi
from typing import TYPE_CHECKING, Any

from toga.colors import Color
from toga.constants import Baseline, FillRule
from toga.fonts import Font
from toga.images import Image
Expand All @@ -29,7 +30,6 @@
Scale,
Translate,
WriteText,
color_property,
)
from .geometry import CornerRadiusT

Expand All @@ -42,6 +42,8 @@
# Make sure deprecation warnings are shown by default
warnings.filterwarnings("default", category=DeprecationWarning)

NOT_PROVIDED = object()


class DrawingActionDispatch(ABC):
@property
Expand Down Expand Up @@ -311,8 +313,10 @@ def round_rect(

def fill(
self,
color: ColorT | None = None,
fill_rule: FillRule = FillRule.NONZERO,
*,
fill_style: ColorT | None | object = NOT_PROVIDED,
color: ColorT | None | object = NOT_PROVIDED,
Comment thread
freakboy3742 marked this conversation as resolved.
) -> AbstractContextManager[Fill]:
"""Fill the current path.

Expand All @@ -327,18 +331,23 @@ def fill(

:param fill_rule: `nonzero` is the non-zero winding rule; `evenodd` is the
even-odd winding rule.
:param color: The fill color.
:param fill_style: The fill style. At present, only accepts colors; gradients
and patterns are not supported.
:param color: Alias for fill_style.
:returns: The `Fill` [`DrawingAction`][toga.widgets.canvas.DrawingAction]
for the operation.
:raises TypeError: If both `fill_style` and `color` are provided.
"""
fill = Fill(color, fill_rule)
fill = Fill(fill_rule, fill_style=fill_style, color=color)
self._add_to_target(fill)
self._redraw_with_warning_if_state()
return fill

def stroke(
self,
color: ColorT | None = None,
*,
stroke_style: ColorT | None | object = NOT_PROVIDED,
color: ColorT | None | object = NOT_PROVIDED,
line_width: float | None = None,
line_dash: list[float] | None = None,
) -> AbstractContextManager[Stroke]:
Expand All @@ -348,14 +357,22 @@ def stroke(
(`x`, `y`) coordinates (if both are specified). When the context is exited, the
path is stroked.

:param color: The color for the stroke.
:param stroke_style: The stroke style. At present, only accepts colors;
gradients and patterns are not supported.
:param color: Alias for fill_style.
:param line_width: The width of the stroke.
:param line_dash: The dash pattern to follow when drawing the line, expressed as
alternating lengths of dashes and spaces. The default is a solid line.
:returns: The `Stroke` [`DrawingAction`][toga.widgets.canvas.DrawingAction]
for the operation.
:raises TypeError: If both `stroke_style` and `color` are provided.
"""
stroke = Stroke(color, line_width, line_dash)
stroke = Stroke(
stroke_style=stroke_style,
color=color,
line_width=line_width,
line_dash=line_dash,
)
self._add_to_target(stroke)
self._redraw_with_warning_if_state()
return stroke
Expand Down Expand Up @@ -548,6 +565,7 @@ def ClosedPath(
warnings.simplefilter("ignore", DeprecationWarning)
close_path = target.close_path()
if x is not None and y is not None:
# 4-2026: Backwards compatibility for Toga <= 0.5.4 / Toga Chart <= 0.2.1
# This is a weird one. The straightforward approach would be to simply add a
# MoveTo to the close_path.drawing_actions. However, while ClosedPath was
# documented as a context manager, TogaChart (up to 0.2.1) uses it as a
Expand Down Expand Up @@ -580,7 +598,7 @@ def Fill(
target = self if isinstance(self, BaseState) else self.root_state
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
fill = target.fill(fill_rule=fill_rule, color=color)
fill = target.fill(fill_rule=fill_rule, fill_style=color)
if x is not None and y is not None:
fill.drawing_actions.append(MoveTo(x, y))
return fill
Expand All @@ -599,7 +617,7 @@ def Stroke(
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
stroke = target.stroke(
color=color, line_width=line_width, line_dash=line_dash
stroke_style=color, line_width=line_width, line_dash=line_dash
)
if x is not None and y is not None:
stroke.drawing_actions.append(MoveTo(x, y))
Expand Down Expand Up @@ -801,7 +819,7 @@ def _draw(self, context: Any) -> None:
if not (hasattr(self, "_is_open") or self.drawing_actions):
# Wasn't used as a context manager, nor had drawing actions manually added

# Backwards compatibility for Toga <= 0.5.4
# 4-2026: Backwards compatibility for Toga <= 0.5.4
# See DrawingActionDispatch.ClosedPath for explanation
if hasattr(self, "x") and hasattr(self, "y"):
context.move_to(self.x, self.y)
Expand All @@ -820,46 +838,80 @@ def _draw(self, context: Any) -> None:
context.restore()


class color_property:
def __get__(self, action, action_class=None):
if action is None:
# This is what's returned in the constructor, if nothing is provided.
return NOT_PROVIDED

return action._color

def __set__(self, action, value):
if value is not None and value is not NOT_PROVIDED:
value = Color.parse(value)

action._color = value


@dataclass(repr=False)
class Fill(BaseState):
color: ColorT | None = color_property()
# This will need to change to a pair of positional arguments in order to accommodate
# (path), (fill_rule), or (path, fill_rule) usage as in JavaScript.
fill_rule: FillRule = FillRule.NONZERO
_: KW_ONLY
fill_style: ColorT | None | object = color_property()
color: InitVar[ColorT | None | object] = color_property()

def __post_init__(self):
def __post_init__(self, color):
super().__init__()

if self.fill_style is not NOT_PROVIDED and color is not NOT_PROVIDED:
raise TypeError("Both fill_style and color provided")

if self.fill_style is NOT_PROVIDED:
self.fill_style = None if color is NOT_PROVIDED else color

def _draw(self, context: Any) -> None:
context.save()
if self.color is not None:
context.set_fill_style(self.color)
if self.fill_style is not None:
context.set_fill_style(self.fill_style)

if hasattr(self, "_is_open") or self.drawing_actions:
# Was used as a context manager (or had drawing actions manually added)
context.in_fill = True # Backwards compatibility for Toga <= 0.5.3
context.in_fill = True # 4-2026: Backwards compatibility for Toga <= 0.5.3
context.begin_path()

for action in self.drawing_actions:
action._draw(context)

context.in_fill = False # Backwards compatibility for Toga <= 0.5.3
context.in_fill = False # 4-2026: Backwards compatibility for Toga <= 0.5.3

context.fill(self.fill_rule)
context.restore()


@dataclass(repr=False)
class Stroke(BaseState):
color: ColorT | None = color_property()
# Path parameter (positional/keyword) will go here.
_: KW_ONLY
stroke_style: ColorT | None | object = color_property()
color: InitVar[ColorT | None | object] = color_property()
line_width: float | None = None
line_dash: list[float] | None = None

def __post_init__(self):
def __post_init__(self, color):
super().__init__()

if self.stroke_style is not NOT_PROVIDED and color is not NOT_PROVIDED:
raise TypeError("Both stroke_style and color provided")

if self.stroke_style is NOT_PROVIDED:
self.stroke_style = None if color is NOT_PROVIDED else color

def _draw(self, context: Any) -> None:
context.save()
if self.color is not None:
context.set_stroke_style(self.color)
if self.stroke_style is not None:
context.set_stroke_style(self.stroke_style)
if self.line_width is not None:
context.set_line_width(self.line_width)
if self.line_dash is not None:
Expand Down
12 changes: 7 additions & 5 deletions core/tests/widgets/canvas/test_canvas.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import pytest

import toga
from toga.colors import rgb
from toga.colors import REBECCAPURPLE, rgb
from toga.constants import FillRule
from toga.fonts import SYSTEM, SYSTEM_DEFAULT_FONT_SIZE, Font
from toga.widgets.canvas import ClosePath, Fill, State, Stroke
Expand Down Expand Up @@ -99,23 +99,25 @@ def test_closed_path(widget):

def test_fill(widget):
"""A canvas can produce a Fill sub-state."""
with widget.fill(color="rebeccapurple", fill_rule=FillRule.EVENODD) as fill:
with widget.fill(fill_rule=FillRule.EVENODD, fill_style=REBECCAPURPLE) as fill:
# A fresh state has been created as a sub-state of the canvas.
assert isinstance(fill, Fill)
assert fill is not widget.root_state

assert fill.color == REBECCA_PURPLE_COLOR
assert fill.fill_style == REBECCA_PURPLE_COLOR
assert fill.fill_rule == FillRule.EVENODD


def test_stroke(widget):
"""A canvas can produce a Stroke sub-state."""
with widget.stroke(color="rebeccapurple", line_width=5, line_dash=[2, 7]) as stroke:
with widget.stroke(
stroke_style=REBECCAPURPLE, line_width=5, line_dash=[2, 7]
) as stroke:
# A fresh state has been created as a sub-state of the canvas.
assert isinstance(stroke, Stroke)
assert stroke is not widget.root_state

assert stroke.color == REBECCA_PURPLE_COLOR
assert stroke.stroke_style == REBECCA_PURPLE_COLOR
assert stroke.line_width == 5.0
assert stroke.line_dash == [2, 7]

Expand Down
6 changes: 6 additions & 0 deletions core/tests/widgets/canvas/test_deprecations.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import toga
import toga.widgets.canvas as canvas_module
from toga.colors import REBECCAPURPLE
from toga.constants import FillRule
from toga.widgets.canvas import (
Arc,
Expand Down Expand Up @@ -84,9 +85,14 @@ def test_renamed_root_state(widget):
("fill", (), Fill),
("Fill", (), Fill), # Deprecated alias
("Fill", (0, 0), Fill), # Deprecated alias with removed parameters
# Deprecated alias with all arguments
("Fill", (0, 0, REBECCAPURPLE, FillRule.EVENODD), Fill),
("stroke", (), Stroke),
("Stroke", (), Stroke), # Deprecated alias
("Stroke", (0, 0), Stroke), # Deprecated alias with removed parameters
# Deprecated alias with all arguments
("Stroke", (0, 0, REBECCAPURPLE, 0, [0, 0, 0, 0]), Stroke),
("Stroke", (0, 0), Stroke), # Deprecated alias with removed parameters
("write_text", ("",), WriteText),
("draw_image", None, DrawImage),
("rotate", (0,), Rotate),
Expand Down
Loading