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 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
177 changes: 153 additions & 24 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,26 @@ 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)
# Strictly speaking, this doesn't need a warning or redraw, since BaseState
# overwrites this method with its own shimmed version. But we might as well be
# as helpful as possible.
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,15 +360,26 @@ 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)
# Strictly speaking, this doesn't need a warning or redraw, since BaseState
# overwrites this method with its own shimmed version. But we might as well be
# as helpful as possible.
self._redraw_with_warning_if_state()
return stroke

Expand Down Expand Up @@ -510,13 +533,15 @@ def state(self) -> AbstractContextManager[State]:
# 2026-02: Backwards compatibility for <= 0.5.3
######################################################################

def _warn_context_manager(self, old_name, new_name, coordinates):
def _warn_context_manager(self, old_name, new_name, coordinates, extra=""):
msg = f"The {old_name}() drawing method has been renamed to {new_name}()"
if coordinates:
msg += (
", and no longer accepts x and y coordinates as parameters. Instead, "
f"call move_to(x, y) after entering the {new_name} context."
)
if extra:
msg = msg.removesuffix(".") + f". {extra}"
warnings.warn(msg, DeprecationWarning, stacklevel=3)

# Each of these CamelCase methods, when called on a state, added to that state.
Expand Down Expand Up @@ -548,6 +573,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 @@ -575,12 +601,22 @@ def Fill(
color: ColorT | None = None,
fill_rule: FillRule = FillRule.NONZERO,
) -> AbstractContextManager[Fill]:
self._warn_context_manager("Fill", "fill", x is not None or y is not None)
self._warn_context_manager(
"Fill",
"fill",
x is not None or y is not None,
extra=(
"Additionally, the Canvas.fill() method's color parameter can only be "
"provided via keyword. fill_rule is the only argument it accepts "
"positionally."
),
)

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)
# BaseState.fill still uses old signature too.
Comment thread
HalfWhitt marked this conversation as resolved.
fill = target.fill(color=color, fill_rule=fill_rule)
if x is not None and y is not None:
fill.drawing_actions.append(MoveTo(x, y))
return fill
Expand All @@ -593,13 +629,24 @@ def Stroke(
line_width: float | None = None,
line_dash: list[float] | None = None,
) -> AbstractContextManager[Stroke]:
self._warn_context_manager("Stroke", "stroke", x is not None or y is not None)
self._warn_context_manager(
"Stroke",
"stroke",
x is not None or y is not None,
extra=(
"Additionally, the Canvas.stroke() method's arguments can only be "
"provided as keywords. It does not accept any positional arguments."
),
)

target = self if isinstance(self, BaseState) else self.root_state
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
# BaseState.fill still uses old signature too.
stroke = target.stroke(
color=color, line_width=line_width, line_dash=line_dash
color=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 @@ -686,6 +733,54 @@ def __exit__(self, exc_type, exc_val, exc_tb):
# Don't suppress any exceptions
return False

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

# These preserve the old signature, and warn about the new one.

def fill(
self,
color: ColorT | None = None,
fill_rule: FillRule = FillRule.NONZERO,
) -> AbstractContextManager[Fill]:
fill = Fill(fill_rule=fill_rule, fill_style=color)
self._add_to_target(fill)
warnings.warn(
(
"Calling drawing methods on a state is deprecated. To add actions "
"to the currently active state, call drawing methods on the canvas. "
"Additionally, the Canvas.fill() method's color parameter can only be "
"provided via keyword. fill_rule is the only argument it accepts "
"positionally."
),
DeprecationWarning,
stacklevel=2,
)
self._redraw_without_warning()
return fill

def stroke(
self,
color: ColorT | None = None,
line_width: float | None = None,
line_dash: list[float] | None = None,
) -> AbstractContextManager[Stroke]:
stroke = Stroke(stroke_style=color, line_width=line_width, line_dash=line_dash)
self._add_to_target(stroke)
warnings.warn(
(
"Calling drawing methods on a state is deprecated. To add actions "
"to the currently active state, call drawing methods on the canvas. "
"Additionally, the Canvas.stroke() method's arguments can only be "
"provided as keywords. It does not accept any positional arguments."
),
DeprecationWarning,
stacklevel=2,
)
self._redraw_without_warning()
return stroke

###########################################################################
# 2026-02: Backwards compatibility for Toga <= 0.5.3
###########################################################################
Expand Down Expand Up @@ -801,7 +896,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 +915,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
Loading