From 855f7be853793b450d26b61f804dcd606267e39b Mon Sep 17 00:00:00 2001 From: Charles Whittington Date: Tue, 14 Apr 2026 23:36:44 -0400 Subject: [PATCH 1/9] Alter parameters --- core/src/toga/widgets/canvas/state.py | 63 +++++++--- core/tests/widgets/canvas/test_canvas.py | 10 +- .../tests/widgets/canvas/test_deprecations.py | 6 + .../widgets/canvas/test_draw_operations.py | 89 ++++++++------ .../widgets/canvas/test_state_objects.py | 112 +++++++++++------- examples/canvas/canvas/app.py | 4 +- examples/tutorial4/tutorial/app.py | 14 +-- 7 files changed, 183 insertions(+), 115 deletions(-) diff --git a/core/src/toga/widgets/canvas/state.py b/core/src/toga/widgets/canvas/state.py index d0f496bf0d..fb52b1fddd 100644 --- a/core/src/toga/widgets/canvas/state.py +++ b/core/src/toga/widgets/canvas/state.py @@ -4,7 +4,7 @@ from abc import ABC, abstractmethod from collections.abc import Iterable from contextlib import AbstractContextManager -from dataclasses import dataclass +from dataclasses import KW_ONLY, dataclass from math import pi from typing import TYPE_CHECKING, Any @@ -311,9 +311,10 @@ def round_rect( def fill( self, - color: ColorT | None = None, fill_rule: FillRule = FillRule.NONZERO, - ) -> Fill: + *, + fill_style: ColorT | None = None, + ) -> AbstractContextManager[Fill]: """Fill the current path. The fill can use either the @@ -331,31 +332,36 @@ def fill( :returns: The `Fill` [`DrawingAction`][toga.widgets.canvas.DrawingAction] for the operation. """ - fill = Fill(color, fill_rule) + fill = Fill(fill_rule, fill_style=fill_style) self._add_to_target(fill) self._redraw_with_warning_if_state() return fill def stroke( self, - color: ColorT | None = None, + *, + stroke_style: ColorT | None = None, line_width: float | None = None, line_dash: list[float] | None = None, - ) -> Stroke: + ) -> AbstractContextManager[Stroke]: """Draw the current path as a stroke. If used as a context manager, this begins a new path, and moves to the specified (`x`, `y`) coordinates (if both are specified). When the context is exited, the path is stroked. - :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, expressed as alternating lengths of dashes and spaces. The default is a solid line. + :param stroke_style: The color for the stroke. :returns: The `Stroke` [`DrawingAction`][toga.widgets.canvas.DrawingAction] for the operation. """ - stroke = Stroke(color, line_width, line_dash) + stroke = Stroke( + stroke_style=stroke_style, + line_width=line_width, + line_dash=line_dash, + ) self._add_to_target(stroke) self._redraw_with_warning_if_state() return stroke @@ -509,13 +515,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 += f" {extra}" warnings.warn(msg, DeprecationWarning, stacklevel=3) # Each of these CamelCase methods, when called on a State, added to that State. @@ -574,12 +582,18 @@ 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, + "The color parameter has been renamed to fill_style, and is a keyword-only " + "argument.", + ) target = self if isinstance(self, State) 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 @@ -592,13 +606,19 @@ 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, + "The color parameter has been renamed to stroke_style. It, as well as " + "line_width and line_dash, are all now keyword-only arguments.", + ) target = self if isinstance(self, State) else self.root_state 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)) @@ -814,16 +834,19 @@ def _draw(self, context: Any) -> None: @dataclass(repr=False) class Fill(State): - 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 = color_property() def __post_init__(self): super().__init__() 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) @@ -841,7 +864,9 @@ def _draw(self, context: Any) -> None: @dataclass(repr=False) class Stroke(State): - color: ColorT | None = color_property() + # Path parameter (positional/keyword) will go here. + _: KW_ONLY + stroke_style: ColorT | None = color_property() line_width: float | None = None line_dash: list[float] | None = None @@ -850,8 +875,8 @@ def __post_init__(self): 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: diff --git a/core/tests/widgets/canvas/test_canvas.py b/core/tests/widgets/canvas/test_canvas.py index dfc656d9f6..07de9917d4 100644 --- a/core/tests/widgets/canvas/test_canvas.py +++ b/core/tests/widgets/canvas/test_canvas.py @@ -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] diff --git a/core/tests/widgets/canvas/test_deprecations.py b/core/tests/widgets/canvas/test_deprecations.py index 1358451d80..87bd597ae3 100644 --- a/core/tests/widgets/canvas/test_deprecations.py +++ b/core/tests/widgets/canvas/test_deprecations.py @@ -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, @@ -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), diff --git a/core/tests/widgets/canvas/test_draw_operations.py b/core/tests/widgets/canvas/test_draw_operations.py index daa95d2824..6053339b61 100644 --- a/core/tests/widgets/canvas/test_draw_operations.py +++ b/core/tests/widgets/canvas/test_draw_operations.py @@ -41,60 +41,60 @@ def test_close_path(widget): # Defaults ( {}, - "color=None, fill_rule=FillRule.NONZERO", + "fill_rule=FillRule.NONZERO, fill_style=None", [("fill", {"fill_rule": FillRule.NONZERO})], - {"color": None, "fill_rule": FillRule.NONZERO}, + {"fill_rule": FillRule.NONZERO, "fill_style": None}, ), - # Color as string name + # Fill style as string name ( - {"color": REBECCAPURPLE}, - f"color={REBECCA_PURPLE_COLOR!r}, fill_rule=FillRule.NONZERO", + {"fill_style": REBECCAPURPLE}, + f"fill_rule=FillRule.NONZERO, fill_style={REBECCA_PURPLE_COLOR!r}", [ ("set fill style", REBECCA_PURPLE_COLOR), ("fill", {"fill_rule": FillRule.NONZERO}), ], - {"color": REBECCA_PURPLE_COLOR, "fill_rule": FillRule.NONZERO}, + {"fill_rule": FillRule.NONZERO, "fill_style": REBECCA_PURPLE_COLOR}, ), # Color as RGB object ( - {"color": REBECCA_PURPLE_COLOR}, - f"color={REBECCA_PURPLE_COLOR!r}, fill_rule=FillRule.NONZERO", + {"fill_style": REBECCA_PURPLE_COLOR}, + f"fill_rule=FillRule.NONZERO, fill_style={REBECCA_PURPLE_COLOR!r}", [ ("set fill style", REBECCA_PURPLE_COLOR), ("fill", {"fill_rule": FillRule.NONZERO}), ], - {"color": REBECCA_PURPLE_COLOR, "fill_rule": FillRule.NONZERO}, + {"fill_rule": FillRule.NONZERO, "fill_style": REBECCA_PURPLE_COLOR}, ), # Color explicitly not set ( - {"color": None}, - "color=None, fill_rule=FillRule.NONZERO", + {"fill_style": None}, + "fill_rule=FillRule.NONZERO, fill_style=None", [("fill", {"fill_rule": FillRule.NONZERO})], - {"color": None, "fill_rule": FillRule.NONZERO}, + {"fill_rule": FillRule.NONZERO, "fill_style": None}, ), # Explicit Non-Zero winding ( {"fill_rule": FillRule.NONZERO}, - "color=None, fill_rule=FillRule.NONZERO", + "fill_rule=FillRule.NONZERO, fill_style=None", [("fill", {"fill_rule": FillRule.NONZERO})], - {"color": None, "fill_rule": FillRule.NONZERO}, + {"fill_rule": FillRule.NONZERO, "fill_style": None}, ), # Even-Odd winding ( {"fill_rule": FillRule.EVENODD}, - "color=None, fill_rule=FillRule.EVENODD", + "fill_rule=FillRule.EVENODD, fill_style=None", [("fill", {"fill_rule": FillRule.EVENODD})], - {"color": None, "fill_rule": FillRule.EVENODD}, + {"fill_rule": FillRule.EVENODD, "fill_style": None}, ), # All args ( - {"color": REBECCAPURPLE, "fill_rule": FillRule.EVENODD}, - f"color={REBECCA_PURPLE_COLOR!r}, fill_rule=FillRule.EVENODD", + {"fill_rule": FillRule.EVENODD, "fill_style": REBECCAPURPLE}, + f"fill_rule=FillRule.EVENODD, fill_style={REBECCA_PURPLE_COLOR!r}", [ ("set fill style", REBECCA_PURPLE_COLOR), ("fill", {"fill_rule": FillRule.EVENODD}), ], - {"color": REBECCA_PURPLE_COLOR, "fill_rule": FillRule.EVENODD}, + {"fill_rule": FillRule.EVENODD, "fill_style": REBECCA_PURPLE_COLOR}, ), ], ) @@ -120,55 +120,70 @@ def test_fill(widget, kwargs, args_repr, draw_objs, attrs): # Defaults ( {}, - "color=None, line_width=None, line_dash=None", + "stroke_style=None, line_width=None, line_dash=None", [], - {"color": None, "line_width": None, "line_dash": None}, + {"stroke_style": None, "line_width": None, "line_dash": None}, ), # Color as string name ( - {"color": REBECCAPURPLE}, - f"color={REBECCA_PURPLE_COLOR!r}, line_width=None, line_dash=None", + {"stroke_style": REBECCAPURPLE}, + f"stroke_style={REBECCA_PURPLE_COLOR!r}, line_width=None, line_dash=None", [("set stroke style", REBECCA_PURPLE_COLOR)], - {"color": REBECCA_PURPLE_COLOR, "line_width": None, "line_dash": None}, + { + "stroke_style": REBECCA_PURPLE_COLOR, + "line_width": None, + "line_dash": None, + }, ), # Color as RGB object ( - {"color": REBECCA_PURPLE_COLOR}, - f"color={REBECCA_PURPLE_COLOR!r}, line_width=None, line_dash=None", + {"stroke_style": REBECCA_PURPLE_COLOR}, + f"stroke_style={REBECCA_PURPLE_COLOR!r}, line_width=None, line_dash=None", [("set stroke style", REBECCA_PURPLE_COLOR)], - {"color": REBECCA_PURPLE_COLOR, "line_width": None, "line_dash": None}, + { + "stroke_style": REBECCA_PURPLE_COLOR, + "line_width": None, + "line_dash": None, + }, ), # Color explicitly not set ( - {"color": None}, - "color=None, line_width=None, line_dash=None", + {"stroke_style": None}, + "stroke_style=None, line_width=None, line_dash=None", [], - {"color": None, "line_width": None, "line_dash": None}, + {"stroke_style": None, "line_width": None, "line_dash": None}, ), # Line width ( {"line_width": 4.5}, - "color=None, line_width=4.500, line_dash=None", + "stroke_style=None, line_width=4.500, line_dash=None", [("set line width", 4.5)], - {"color": None, "line_width": 4.5, "line_dash": None}, + {"stroke_style": None, "line_width": 4.5, "line_dash": None}, ), # Line dash ( {"line_dash": [2, 7]}, - "color=None, line_width=None, line_dash=[2, 7]", + "stroke_style=None, line_width=None, line_dash=[2, 7]", [("set line dash", [2, 7])], - {"color": None, "line_width": None, "line_dash": [2, 7]}, + {"stroke_style": None, "line_width": None, "line_dash": [2, 7]}, ), # All args ( - {"color": REBECCAPURPLE, "line_width": 4.5, "line_dash": [2, 7]}, - f"color={REBECCA_PURPLE_COLOR!r}, line_width=4.500, line_dash=[2, 7]", + {"stroke_style": REBECCAPURPLE, "line_width": 4.5, "line_dash": [2, 7]}, + ( + f"stroke_style={REBECCA_PURPLE_COLOR!r}, line_width=4.500, " + "line_dash=[2, 7]" + ), [ ("set stroke style", REBECCA_PURPLE_COLOR), ("set line width", 4.5), ("set line dash", [2, 7]), ], - {"color": REBECCA_PURPLE_COLOR, "line_width": 4.5, "line_dash": [2, 7]}, + { + "stroke_style": REBECCA_PURPLE_COLOR, + "line_width": 4.5, + "line_dash": [2, 7], + }, ), ], ) diff --git a/core/tests/widgets/canvas/test_state_objects.py b/core/tests/widgets/canvas/test_state_objects.py index 3074877dd7..f0a1e15eab 100644 --- a/core/tests/widgets/canvas/test_state_objects.py +++ b/core/tests/widgets/canvas/test_state_objects.py @@ -62,32 +62,35 @@ def test_closed_path(widget): # Defaults ( {}, - "color=None, fill_rule=FillRule.NONZERO", - {"color": None, "fill_rule": FillRule.NONZERO}, + "fill_rule=FillRule.NONZERO, fill_style=None", + { + "fill_rule": FillRule.NONZERO, + "fill_style": None, + }, ), - # Color + # Fill style ( - {"color": REBECCAPURPLE}, - (f"color={REBECCA_PURPLE_COLOR!r}, fill_rule=FillRule.NONZERO"), - {"color": REBECCA_PURPLE_COLOR, "fill_rule": FillRule.NONZERO}, + {"fill_style": REBECCAPURPLE}, + (f"fill_rule=FillRule.NONZERO, fill_style={REBECCA_PURPLE_COLOR!r}"), + {"fill_rule": FillRule.NONZERO, "fill_style": REBECCA_PURPLE_COLOR}, ), - # Explicitly don't set color + # Explicitly don't set fill style ( - {"color": None}, - "color=None, fill_rule=FillRule.NONZERO", - {"color": None, "fill_rule": FillRule.NONZERO}, + {"fill_style": None}, + "fill_rule=FillRule.NONZERO, fill_style=None", + {"fill_rule": FillRule.NONZERO, "fill_style": None}, ), # Fill Rule ( {"fill_rule": FillRule.EVENODD}, - "color=None, fill_rule=FillRule.EVENODD", - {"color": None, "fill_rule": FillRule.EVENODD}, + "fill_rule=FillRule.EVENODD, fill_style=None", + {"fill_style": None, "fill_rule": FillRule.EVENODD}, ), # All args ( - {"color": REBECCAPURPLE, "fill_rule": FillRule.EVENODD}, - f"color={REBECCA_PURPLE_COLOR!r}, fill_rule=FillRule.EVENODD", - {"color": REBECCA_PURPLE_COLOR, "fill_rule": FillRule.EVENODD}, + {"fill_rule": FillRule.EVENODD, "fill_style": REBECCAPURPLE}, + f"fill_rule=FillRule.EVENODD, fill_style={REBECCA_PURPLE_COLOR!r}", + {"fill_rule": FillRule.EVENODD, "fill_style": REBECCA_PURPLE_COLOR}, ), ], ) @@ -106,9 +109,11 @@ def test_fill(widget, kwargs, args_repr, properties): commands = [ "save", - ("set fill style", color) - if (color := properties["color"]) is not None - else None, + ( + ("set fill style", fill_style) + if (fill_style := properties["fill_style"]) is not None + else None + ), "begin path", ("line to", {"x": 30, "y": 40}), ("fill", {"fill_rule": properties["fill_rule"]}), @@ -127,40 +132,49 @@ def test_fill(widget, kwargs, args_repr, properties): # Defaults ( {}, - "color=None, line_width=None, line_dash=None", - {"color": None, "line_width": None, "line_dash": None}, + "stroke_style=None, line_width=None, line_dash=None", + {"stroke_style": None, "line_width": None, "line_dash": None}, ), # Color ( - {"color": REBECCAPURPLE}, - (f"color={REBECCA_PURPLE_COLOR!r}, line_width=None, line_dash=None"), - {"color": REBECCA_PURPLE_COLOR, "line_width": None, "line_dash": None}, + {"stroke_style": REBECCAPURPLE}, + f"stroke_style={REBECCA_PURPLE_COLOR!r}, line_width=None, line_dash=None", + { + "stroke_style": REBECCA_PURPLE_COLOR, + "line_width": None, + "line_dash": None, + }, ), - # Explicitly don't set color + # Explicitly don't set stroke_style ( - {"color": None}, - "color=None, line_width=None, line_dash=None", - {"color": None, "line_width": None, "line_dash": None}, + {"stroke_style": None}, + "stroke_style=None, line_width=None, line_dash=None", + {"stroke_style": None, "line_width": None, "line_dash": None}, ), # Line width ( {"line_width": 4.5}, - "color=None, line_width=4.500, line_dash=None", - {"color": None, "line_width": 4.5, "line_dash": None}, + "stroke_style=None, line_width=4.500, line_dash=None", + {"stroke_style": None, "line_width": 4.5, "line_dash": None}, ), # Line dash ( - { - "line_dash": [2, 7], - }, - "color=None, line_width=None, line_dash=[2, 7]", - {"color": None, "line_width": None, "line_dash": [2, 7]}, + {"line_dash": [2, 7]}, + "stroke_style=None, line_width=None, line_dash=[2, 7]", + {"stroke_style": None, "line_width": None, "line_dash": [2, 7]}, ), # All args ( - {"color": REBECCAPURPLE, "line_width": 4.5, "line_dash": [2, 7]}, - (f"color={REBECCA_PURPLE_COLOR!r}, line_width=4.500, line_dash=[2, 7]"), - {"color": REBECCA_PURPLE_COLOR, "line_width": 4.5, "line_dash": [2, 7]}, + {"stroke_style": REBECCAPURPLE, "line_width": 4.5, "line_dash": [2, 7]}, + ( + f"stroke_style={REBECCA_PURPLE_COLOR!r}, line_width=4.500, " + "line_dash=[2, 7]" + ), + { + "stroke_style": REBECCA_PURPLE_COLOR, + "line_width": 4.5, + "line_dash": [2, 7], + }, ), ], ) @@ -179,15 +193,21 @@ def test_stroke(widget, kwargs, args_repr, properties): commands = [ "save", - ("set stroke style", color) - if (color := properties["color"]) is not None - else None, - ("set line width", line_width) - if (line_width := properties["line_width"]) is not None - else None, - ("set line dash", line_dash) - if (line_dash := properties["line_dash"]) is not None - else None, + ( + ("set stroke style", stroke_style) + if (stroke_style := properties["stroke_style"]) is not None + else None + ), + ( + ("set line width", line_width) + if (line_width := properties["line_width"]) is not None + else None + ), + ( + ("set line dash", line_dash) + if (line_dash := properties["line_dash"]) is not None + else None + ), "begin path", ("line to", {"x": 30, "y": 40}), "stroke", diff --git a/examples/canvas/canvas/app.py b/examples/canvas/canvas/app.py index c7a06bc833..b053195b06 100644 --- a/examples/canvas/canvas/app.py +++ b/examples/canvas/canvas/app.py @@ -604,13 +604,13 @@ def get_style(self): def get_state(self): if self.state_selection.value == STROKE: return self.canvas.stroke( - color=str(self.color_selection.value), + stroke_style=str(self.color_selection.value), line_width=self.stroke_width_slider.value, line_dash=self.dash_patterns[self.dash_pattern_selection.value], ) return self.canvas.fill( - color=self.color_selection.value, fill_rule=FillRule[self.fill_rule_selection.value], + fill_style=self.color_selection.value, ) diff --git a/examples/tutorial4/tutorial/app.py b/examples/tutorial4/tutorial/app.py index 640b4142db..f8f379c9bc 100644 --- a/examples/tutorial4/tutorial/app.py +++ b/examples/tutorial4/tutorial/app.py @@ -28,7 +28,7 @@ def startup(self): self.main_window.show() def fill_head(self): - with self.canvas.fill(color=rgb(149, 119, 73)): + with self.canvas.fill(fill_style=rgb(149, 119, 73)): self.canvas.move_to(112, 103) self.canvas.line_to(112, 113) self.canvas.ellipse(73, 114, 39, 47, 0, 0, math.pi) @@ -47,7 +47,7 @@ def stroke_head(self): self.canvas.arc(82, 84, 30, 3 * math.pi / 2, 2 * math.pi) def draw_eyes(self): - with self.canvas.fill(color=WHITE): + with self.canvas.fill(fill_style=WHITE): self.canvas.arc(58, 92, 15) self.canvas.arc(88, 92, 15, math.pi, 3 * math.pi) @@ -63,19 +63,19 @@ def draw_eyes(self): def draw_horns(self): with self.canvas.stroke(line_width=4.0): - with self.canvas.fill(color=rgb(212, 212, 212)): + with self.canvas.fill(fill_style=rgb(212, 212, 212)): self.canvas.move_to(112, 99) self.canvas.quadratic_curve_to(145, 65, 139, 36) self.canvas.quadratic_curve_to(130, 60, 109, 75) with self.canvas.stroke(line_width=4.0): - with self.canvas.fill(color=rgb(212, 212, 212)): + with self.canvas.fill(fill_style=rgb(212, 212, 212)): self.canvas.move_to(35, 99) self.canvas.quadratic_curve_to(2, 65, 6, 36) self.canvas.quadratic_curve_to(17, 60, 37, 75) def draw_nostrils(self): - with self.canvas.fill(color=rgb(212, 212, 212)): + with self.canvas.fill(fill_style=rgb(212, 212, 212)): self.canvas.move_to(45, 145) self.canvas.bezier_curve_to(51, 123, 96, 123, 102, 145) self.canvas.ellipse(73, 114, 39, 47, 0, math.pi / 4, 3 * math.pi / 4) @@ -93,14 +93,14 @@ def draw_text(self): x = (150 - self.text_width) // 2 y = 175 - with self.canvas.stroke(color="REBECCAPURPLE", line_width=4.0): + with self.canvas.stroke(stroke_style="REBECCAPURPLE", line_width=4.0): self.text_border = self.canvas.rect( x - 5, y - 5, self.text_width + 10, text_height + 10, ) - with self.canvas.fill(color=rgb(149, 119, 73)): + with self.canvas.fill(fill_style=rgb(149, 119, 73)): self.text = self.canvas.write_text("Tiberius", x, y, font, Baseline.TOP) def draw_tiberius(self): From ab753a42c3a39032a311b76882e7a67806474789 Mon Sep 17 00:00:00 2001 From: Charles Whittington Date: Tue, 14 Apr 2026 23:55:13 -0400 Subject: [PATCH 2/9] Add change note and fix param listings --- changes/4330.misc.md | 1 + core/src/toga/widgets/canvas/state.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 changes/4330.misc.md diff --git a/changes/4330.misc.md b/changes/4330.misc.md new file mode 100644 index 0000000000..c0ea848745 --- /dev/null +++ b/changes/4330.misc.md @@ -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. diff --git a/core/src/toga/widgets/canvas/state.py b/core/src/toga/widgets/canvas/state.py index fb52b1fddd..2a091e2038 100644 --- a/core/src/toga/widgets/canvas/state.py +++ b/core/src/toga/widgets/canvas/state.py @@ -328,7 +328,7 @@ 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 color. :returns: The `Fill` [`DrawingAction`][toga.widgets.canvas.DrawingAction] for the operation. """ @@ -350,10 +350,10 @@ def stroke( (`x`, `y`) coordinates (if both are specified). When the context is exited, the path is stroked. + :param stroke_style: 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, expressed as alternating lengths of dashes and spaces. The default is a solid line. - :param stroke_style: The color for the stroke. :returns: The `Stroke` [`DrawingAction`][toga.widgets.canvas.DrawingAction] for the operation. """ From b3c00b3bf9c4c6d4f4f8dcf20a3929d012c8525c Mon Sep 17 00:00:00 2001 From: Charles Whittington Date: Wed, 15 Apr 2026 01:06:25 -0400 Subject: [PATCH 3/9] Fix testbed tests too --- testbed/tests/widgets/canvas/test_canvas.py | 110 ++++++++++---------- 1 file changed, 56 insertions(+), 54 deletions(-) diff --git a/testbed/tests/widgets/canvas/test_canvas.py b/testbed/tests/widgets/canvas/test_canvas.py index bce6c4150a..c821feed63 100644 --- a/testbed/tests/widgets/canvas/test_canvas.py +++ b/testbed/tests/widgets/canvas/test_canvas.py @@ -143,7 +143,7 @@ async def test_alt_drag( async def test_image_data(canvas, probe): """The canvas can be saved as an image.""" - with canvas.stroke(color=RED): + with canvas.stroke(stroke_style=RED): canvas.move_to(x=0, y=0) canvas.line_to(x=200, y=200) canvas.move_to(x=200, y=0) @@ -215,11 +215,11 @@ async def test_transparency(canvas, probe): # Draw a rectangle. move_to is implied canvas.begin_path() canvas.rect(x=20, y=20, width=120, height=120) - canvas.fill(color=REBECCAPURPLE) + canvas.fill(fill_style=REBECCAPURPLE) canvas.begin_path() canvas.rect(x=60, y=60, width=120, height=120) - canvas.fill(color=rgb(0x33, 0x66, 0x99, 0.5)) + canvas.fill(fill_style=rgb(0x33, 0x66, 0x99, 0.5)) await probe.redraw("Image with transparent content and background") assert_reference(probe, "transparency") @@ -248,21 +248,21 @@ async def test_paths(canvas, probe): # An empty path should not appear. canvas.begin_path() canvas.close_path() - canvas.stroke(RED) + canvas.stroke(stroke_style=RED) # A path containing only move_to commands should not appear. canvas.begin_path() canvas.move_to(140, 140) canvas.move_to(160, 160) - canvas.stroke(RED) + canvas.stroke(stroke_style=RED) # A path is not cleared after being stroked or filled. canvas.move_to(20, 10) canvas.line_to(60, 10) - canvas.stroke(color=CORNFLOWERBLUE, line_width=10) + canvas.stroke(stroke_style=CORNFLOWERBLUE, line_width=10) canvas.move_to(60, 10) canvas.line_to(100, 10) - canvas.fill(color=REBECCAPURPLE) + canvas.fill(fill_style=REBECCAPURPLE) canvas.line_to(140, 10) canvas.stroke() @@ -349,12 +349,12 @@ async def test_ellipse(canvas, probe): # Nucleus (filled circle) canvas.move_to(90, 100) canvas.ellipse(100, 100, 20, 20) - canvas.fill(color=RED) + canvas.fill(fill_style=RED) # Purple orbit canvas.begin_path() canvas.ellipse(100, 100, 90, 20, rotation=pi * 3 / 4) - canvas.stroke(color=REBECCAPURPLE) + canvas.stroke(stroke_style=REBECCAPURPLE) # Blue orbit (more than half a turn) canvas.begin_path() @@ -368,7 +368,7 @@ async def test_ellipse(canvas, probe): endangle=pi / 4, counterclockwise=True, ) - canvas.stroke(color=CORNFLOWERBLUE) + canvas.stroke(stroke_style=CORNFLOWERBLUE) # Yellow orbit (more than half a turn) canvas.begin_path() @@ -380,7 +380,7 @@ async def test_ellipse(canvas, probe): startangle=pi / 4, endangle=pi * 7 / 4, ) - canvas.stroke(color=GOLDENROD) + canvas.stroke(stroke_style=GOLDENROD) await probe.redraw("Atom should be drawn") assert_reference(probe, "ellipse", threshold=0.02) @@ -409,14 +409,14 @@ async def test_ellipse_path(canvas, probe): canvas.ellipse(**ellipse_args, startangle=radians(280), endangle=radians(340)) # Arc -> line canvas.line_to(180, 50) - canvas.stroke(RED) + canvas.stroke(stroke_style=RED) canvas.begin_path() canvas.move_to(180, 180) canvas.line_to(180, 160) # Line -> arc canvas.ellipse(**ellipse_args, startangle=radians(10), endangle=radians(60)) - canvas.stroke(CORNFLOWERBLUE) + canvas.stroke(stroke_style=CORNFLOWERBLUE) await probe.redraw("Broken ellipse with connected lines should be drawn") assert_reference(probe, "ellipse_path", threshold=0.02) @@ -428,7 +428,7 @@ async def test_rect(canvas, probe): # Draw a rectangle. move_to is implied canvas.begin_path() canvas.rect(x=20, y=60, width=160, height=100) - canvas.fill(color=REBECCAPURPLE) + canvas.fill(fill_style=REBECCAPURPLE) await probe.redraw("Filled rectangle should be drawn") assert_reference(probe, "rect") @@ -445,16 +445,16 @@ def __init__(self, x, y): # Draw a rounded rectangle. move_to is implied canvas.begin_path() canvas.round_rect(x=20, y=10, width=160, height=80, radii=[5, 30, Corner(50, 30)]) - canvas.fill(color=GOLDENROD) - canvas.stroke(color=REBECCAPURPLE) + canvas.fill(fill_style=GOLDENROD) + canvas.stroke(stroke_style=REBECCAPURPLE) # Draw a rounded rectangle with negative width, height canvas.begin_path() canvas.round_rect( x=190, y=180, width=-160, height=-80, radii=[0, 30, Corner(50, 60)] ) - canvas.fill(color=CORNFLOWERBLUE) - canvas.stroke(color=BLACK) + canvas.fill(fill_style=CORNFLOWERBLUE) + canvas.stroke(stroke_style=BLACK) await probe.redraw("Filled and stroked rounded rectangles should be drawn") assert_reference(probe, "round_rect", threshold=0.016) @@ -469,7 +469,7 @@ async def test_fill(canvas, probe): canvas.line_to(x=110, y=50) canvas.line_to(x=10, y=50) canvas.line_to(x=90, y=110) - canvas.fill(color=REBECCAPURPLE) + canvas.fill(fill_style=REBECCAPURPLE) # Same path (slightly offset), but with EVENODD winding. canvas.begin_path() @@ -478,7 +478,7 @@ async def test_fill(canvas, probe): canvas.line_to(x=190, y=130) canvas.line_to(x=90, y=130) canvas.line_to(x=170, y=190) - canvas.fill(color=CORNFLOWERBLUE, fill_rule=FillRule.EVENODD) + canvas.fill(fill_style=CORNFLOWERBLUE, fill_rule=FillRule.EVENODD) await probe.redraw("Stars should be drawn") assert_reference(probe, "fill") @@ -493,7 +493,7 @@ async def test_stroke(canvas, probe): canvas.line_to(x=180, y=180) canvas.line_to(x=100, y=180) canvas.close_path() - canvas.stroke(color=REBECCAPURPLE) + canvas.stroke(stroke_style=REBECCAPURPLE) # Draw an open path inside it canvas.begin_path() @@ -502,7 +502,7 @@ async def test_stroke(canvas, probe): canvas.line_to(x=90, y=40) canvas.line_to(x=150, y=160) canvas.line_to(x=110, y=160) - canvas.stroke(color=CORNFLOWERBLUE) + canvas.stroke(stroke_style=CORNFLOWERBLUE) await probe.redraw("Stroke should be drawn") assert_reference(probe, "stroke") @@ -517,8 +517,8 @@ async def test_stroke_and_fill(canvas, probe): canvas.line_to(x=180, y=180) canvas.line_to(x=100, y=180) canvas.close_path() - canvas.stroke(color=REBECCAPURPLE) - canvas.fill(color=CORNFLOWERBLUE) + canvas.stroke(stroke_style=REBECCAPURPLE) + canvas.fill(fill_style=CORNFLOWERBLUE) # Draw an open path inside it canvas.begin_path() @@ -527,8 +527,8 @@ async def test_stroke_and_fill(canvas, probe): canvas.line_to(x=90, y=40) canvas.line_to(x=150, y=160) canvas.line_to(x=110, y=160) - canvas.fill(color=GOLDENROD, fill_rule=FillRule.EVENODD) - canvas.stroke(color=REBECCAPURPLE) + canvas.fill(fill_style=GOLDENROD, fill_rule=FillRule.EVENODD) + canvas.stroke(stroke_style=REBECCAPURPLE) await probe.redraw("Stroke should be drawn") assert_reference(probe, "stroke_and_fill") @@ -545,7 +545,7 @@ async def test_closed_path_state(canvas, probe): canvas.line_to(x=100, y=180) # Draw it with a thick dashed line - canvas.stroke(color=REBECCAPURPLE, line_width=5, line_dash=[20, 30]) + canvas.stroke(stroke_style=REBECCAPURPLE, line_width=5, line_dash=[20, 30]) await probe.redraw("Closed path should be drawn with state") assert_reference(probe, "closed_path_state") @@ -555,7 +555,7 @@ async def test_fill_state(canvas, probe): """A fill path can be built with a state.""" # Build a filled parallelogram - with canvas.fill(color=REBECCAPURPLE): + with canvas.fill(fill_style=REBECCAPURPLE): canvas.move_to(x=20, y=20) canvas.line_to(x=100, y=20) canvas.line_to(x=180, y=180) @@ -568,12 +568,12 @@ async def test_fill_state(canvas, probe): async def test_stroke_state(canvas, probe): """A stroke can be drawn with a state.""" # Draw a thin line - with canvas.stroke(color=REBECCAPURPLE): + with canvas.stroke(stroke_style=REBECCAPURPLE): canvas.move_to(x=40, y=20) canvas.line_to(x=80, y=180) # Draw a thick dashed line - with canvas.stroke(line_width=20, line_dash=[20, 10], color=CORNFLOWERBLUE): + with canvas.stroke(stroke_style=CORNFLOWERBLUE, line_width=20, line_dash=[20, 10]): canvas.move_to(x=80, y=20) canvas.line_to(x=120, y=180) @@ -585,9 +585,11 @@ async def test_stroke_and_fill_state(canvas, probe): """A shape can be stroked and filled using states.""" # Draw a filled parallelogram - with canvas.fill(color=REBECCAPURPLE): + with canvas.fill(fill_style=REBECCAPURPLE): canvas.move_to(x=20, y=20) - with canvas.stroke(line_width=20, line_dash=[20, 10], color=CORNFLOWERBLUE): + with canvas.stroke( + stroke_style=CORNFLOWERBLUE, line_width=20, line_dash=[20, 10] + ): # canvas.move_to(x=20, y=20) canvas.line_to(x=100, y=20) canvas.line_to(x=180, y=180) @@ -599,12 +601,12 @@ async def test_stroke_and_fill_state(canvas, probe): async def test_nested_stroke_and_fill_state(canvas, probe): """Inner states don't override unsupplied attributes.""" - with canvas.fill(color=GOLDENROD): + with canvas.fill(fill_style=GOLDENROD): with canvas.fill(): # Should still be goldenrod canvas.rect(10, 10, 50, 50) - with canvas.stroke(color=REBECCAPURPLE, line_width=15, line_dash=[15, 14]): + with canvas.stroke(stroke_style=REBECCAPURPLE, line_width=15, line_dash=[15, 14]): with canvas.stroke(): # Should still be wide, dashed, and purple canvas.move_to(100, 10) @@ -620,19 +622,19 @@ async def test_transforms(canvas, probe): # Draw a rectangle after a horizontal translation canvas.translate(160, 20) canvas.rect(0, 0, 20, 60) - canvas.fill(color=CORNFLOWERBLUE) + canvas.fill(fill_style=CORNFLOWERBLUE) canvas.reset_transform() canvas.begin_path() canvas.rotate(pi / 4) canvas.rect(200, 0, 20, 60) - canvas.fill(color=REBECCAPURPLE) + canvas.fill(fill_style=REBECCAPURPLE) canvas.reset_transform() canvas.begin_path() canvas.scale(2, 5) canvas.rect(10, 10, 10, 10) - canvas.fill(color=GOLDENROD) + canvas.fill(fill_style=GOLDENROD) canvas.reset_transform() canvas.begin_path() @@ -659,7 +661,7 @@ async def test_transforms_mid_path(canvas, probe): canvas.rotate(math.pi / 6) canvas.fill() - canvas.stroke(GOLDENROD) + canvas.stroke(stroke_style=GOLDENROD) # draw a series of line segments canvas.begin_path() @@ -673,7 +675,7 @@ async def test_transforms_mid_path(canvas, probe): canvas.move_to(110, 100) canvas.scale(5, 1) canvas.ellipse(20, 100, 2, 20, 0, 0, 2 * pi) - canvas.stroke(CORNFLOWERBLUE) + canvas.stroke(stroke_style=CORNFLOWERBLUE) await probe.redraw("Transforms can be applied") assert_reference(probe, "transforms_mid_path", threshold=0.015) @@ -699,7 +701,7 @@ async def test_singular_transforms(canvas, probe): canvas.rotate(pi / 4) canvas.line_to(180, 20) - canvas.stroke(GOLDENROD, line_width=8) + canvas.stroke(stroke_style=GOLDENROD, line_width=8) # Same shape, but not flipped, using reset_transform() canvas.begin_path() @@ -717,7 +719,7 @@ async def test_singular_transforms(canvas, probe): canvas.rotate(pi / 4) canvas.line_to(180, 20) - canvas.stroke(CORNFLOWERBLUE, line_width=8) + canvas.stroke(stroke_style=CORNFLOWERBLUE, line_width=8) canvas.reset_transform() canvas.begin_path() @@ -750,14 +752,14 @@ async def test_write_text(canvas, probe): hello_font = Font("Droid Serif", 12) hello_size = canvas.measure_text(hello_text, hello_font) - with canvas.stroke(color=CORNFLOWERBLUE): + with canvas.stroke(stroke_style=CORNFLOWERBLUE): canvas.rect( 100 - (hello_size[0] // 2), 10, hello_size[0], hello_size[1], ) - with canvas.fill(color=REBECCAPURPLE): + with canvas.fill(fill_style=REBECCAPURPLE): canvas.write_text( hello_text, 100 - (hello_size[0] // 2), @@ -770,7 +772,7 @@ async def test_write_text(canvas, probe): world_font = Font("Endor", 22) world_size = canvas.measure_text(world_text, font=world_font) - with canvas.stroke(color=CORNFLOWERBLUE): + with canvas.stroke(stroke_style=CORNFLOWERBLUE): canvas.rect( 100 - (world_size[0] // 2), 100 - world_size[1], @@ -790,15 +792,15 @@ async def test_write_text(canvas, probe): toga_font = Font("Droid Serif", 45, weight=BOLD) toga_size = canvas.measure_text(toga_text, font=toga_font) - with canvas.stroke(color=CORNFLOWERBLUE): + with canvas.stroke(stroke_style=CORNFLOWERBLUE): canvas.rect( 100 - (toga_size[0] // 2), 150 - (toga_size[1] // 2), toga_size[0], toga_size[1], ) - with canvas.stroke(color=REBECCAPURPLE): - with canvas.fill(color=CORNFLOWERBLUE): + with canvas.stroke(stroke_style=REBECCAPURPLE): + with canvas.fill(fill_style=CORNFLOWERBLUE): canvas.write_text( toga_text, 100 - (toga_size[0] // 2), @@ -826,7 +828,7 @@ async def test_multiline_text(canvas, probe): # Vertical guidelines X = [10, 75, 140] - with canvas.stroke(color=RED): + with canvas.stroke(stroke_style=RED): for x in X: canvas.move_to(x, 0) canvas.line_to(x, canvas.style.height) @@ -836,7 +838,7 @@ def caption(baseline): # ALPHABETIC baseline y = 30 - with canvas.stroke(color=RED): + with canvas.stroke(stroke_style=RED): canvas.move_to(0, y) canvas.line_to(canvas.style.width, y) @@ -862,7 +864,7 @@ def caption(baseline): # Other baselines, with default font but specified size y = 130 - with canvas.stroke(color=RED): + with canvas.stroke(stroke_style=RED): canvas.move_to(0, y) canvas.line_to(canvas.style.width, y) font = Font(SYSTEM, 12) @@ -878,7 +880,7 @@ def caption(baseline): elif baseline == Baseline.BOTTOM: top = y - height - with canvas.stroke(color=CORNFLOWERBLUE): + with canvas.stroke(stroke_style=CORNFLOWERBLUE): canvas.rect(left, top, width, height) with canvas.fill(): @@ -933,7 +935,7 @@ async def test_write_text_and_path(canvas, probe): ) # now stroke the path, but *not* the text - canvas.stroke(CORNFLOWERBLUE) + canvas.stroke(stroke_style=CORNFLOWERBLUE) # start a new path so Fill state doesn't fill current path with black canvas.begin_path() @@ -963,7 +965,7 @@ async def test_draw_image_in_rect(canvas, probe): canvas.translate(-82, -46) canvas.draw_image(image, 10, 10, 72, 144) canvas.rect(10, 10, 72, 144) - canvas.stroke(REBECCAPURPLE) + canvas.stroke(stroke_style=REBECCAPURPLE) await probe.redraw("Image should be drawn") assert_reference(probe, "draw_image_in_rect", threshold=0.05) @@ -988,7 +990,7 @@ def draw_angle(canvas, angle, x): canvas.line_to(-half_width, height) canvas.stroke(line_width=line_width) - canvas.stroke(line_width=2, color=REBECCAPURPLE) + canvas.stroke(stroke_style=REBECCAPURPLE, line_width=2) # Left two should be mitered, right two should be beveled. # (Windows and Qt don't bevel, they just start truncating the miter.) From 3256d6ff736d2ba33fe8b55ae2de6928454bec8d Mon Sep 17 00:00:00 2001 From: Charles Whittington Date: Thu, 16 Apr 2026 19:51:07 -0400 Subject: [PATCH 4/9] Update docs, add tests, add color alias --- core/src/toga/widgets/canvas/state.py | 39 +++++++++++++++++-- .../tests/widgets/canvas/test_deprecations.py | 26 ++++++++++++- .../widgets/canvas/test_draw_operations.py | 26 ++++++++++++- docs/en/reference/api/widgets/canvas.md | 9 +++-- 4 files changed, 91 insertions(+), 9 deletions(-) diff --git a/core/src/toga/widgets/canvas/state.py b/core/src/toga/widgets/canvas/state.py index 2a091e2038..a965cc3510 100644 --- a/core/src/toga/widgets/canvas/state.py +++ b/core/src/toga/widgets/canvas/state.py @@ -555,6 +555,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 @@ -813,7 +814,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) @@ -832,6 +833,30 @@ def _draw(self, context: Any) -> None: context.restore() +class color_alias: + def __init__(self, attr_name, class_name): + self.attr_name = attr_name + self.class_name = class_name + + def __get__(self, action, action_class=None): + self._warn() + return getattr(action, self.attr_name) + + def __set__(self, action, value): + self._warn() + setattr(action, self.attr_name, value) + + def _warn(self): + warnings.warn( + ( + f"The {self.class_name}.color attribute has been renamed to " + f"{self.class_name}.{self.attr_name}" + ), + DeprecationWarning, + stacklevel=2, + ) + + @dataclass(repr=False) class Fill(State): # This will need to change to a pair of positional arguments in order to accommodate @@ -840,6 +865,10 @@ class Fill(State): _: KW_ONLY fill_style: ColorT | None = color_property() + # 4-2026: Backwards compatibility for Toga <= 0.5.4 + # Not type-hinted so that it won't be a field. + color = color_alias("fill_style", "Fill") + def __post_init__(self): super().__init__() @@ -850,13 +879,13 @@ def _draw(self, context: Any) -> None: 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() @@ -870,6 +899,10 @@ class Stroke(State): line_width: float | None = None line_dash: list[float] | None = None + # 4-2026: Backwards compatibility for Toga <= 0.5.4 + # Not type-hinted so that it won't be a field. + color = color_alias("stroke_style", "Stroke") + def __post_init__(self): super().__init__() diff --git a/core/tests/widgets/canvas/test_deprecations.py b/core/tests/widgets/canvas/test_deprecations.py index 87bd597ae3..d3f4f549ea 100644 --- a/core/tests/widgets/canvas/test_deprecations.py +++ b/core/tests/widgets/canvas/test_deprecations.py @@ -2,7 +2,7 @@ import toga import toga.widgets.canvas as canvas_module -from toga.colors import REBECCAPURPLE +from toga.colors import GOLDENROD, REBECCAPURPLE, Color from toga.constants import FillRule from toga.widgets.canvas import ( Arc, @@ -448,3 +448,27 @@ def test_deprecated_list_methods(widget): ("line to", {"x": 99, "y": 99}), "restore", ] + + +@pytest.mark.parametrize( + "ActionClass, attr_name", + [ + (Fill, "fill_style"), + (Stroke, "stroke_style"), + ], +) +def test_deprecated_color_attribute(ActionClass, attr_name): + """Fill and Stroke alias color to fill_style/stroke_style.""" + action = ActionClass() + + # Set color, check new fill_style/stroke_style + with pytest.deprecated_call(): + action.color = REBECCAPURPLE + + assert getattr(action, attr_name) == Color.parse(REBECCAPURPLE) + + # Set fill_style/stroke_style, check color + setattr(action, attr_name, GOLDENROD) + + with pytest.deprecated_call(): + assert action.color == Color.parse(GOLDENROD) diff --git a/core/tests/widgets/canvas/test_draw_operations.py b/core/tests/widgets/canvas/test_draw_operations.py index 6053339b61..eb77de86b4 100644 --- a/core/tests/widgets/canvas/test_draw_operations.py +++ b/core/tests/widgets/canvas/test_draw_operations.py @@ -6,7 +6,7 @@ from toga.constants import Baseline, FillRule from toga.fonts import SYSTEM, SYSTEM_DEFAULT_FONT_SIZE, Font from toga.images import Image -from toga.widgets.canvas import Arc, Ellipse +from toga.widgets.canvas import Arc, Ellipse, Fill, Stroke from toga_dummy.utils import assert_action_performed REBECCA_PURPLE_COLOR = rgb(102, 51, 153) @@ -114,6 +114,15 @@ def test_fill(widget, kwargs, args_repr, draw_objs, attrs): assert getattr(draw_op, name) == value +@pytest.mark.parametrize("use_method", [True, False]) +def test_fill_kw_only(widget, use_method): + """Providing fill_style positionally raises an error.""" + fill = widget.fill if use_method else Fill + + with pytest.raises(TypeError): + fill(FillRule.EVENODD, REBECCAPURPLE) + + @pytest.mark.parametrize( "kwargs, args_repr, draw_objs, attrs", [ @@ -208,6 +217,21 @@ def test_stroke(widget, kwargs, args_repr, draw_objs, attrs): assert getattr(draw_op, name) == value +@pytest.mark.parametrize("use_method", [True, False]) +def test_stroke_kw_only(widget, use_method): + """Providing any positional arguments raises an error.""" + stroke = widget.stroke if use_method else Stroke + + with pytest.raises(TypeError): + stroke(REBECCAPURPLE) + + with pytest.raises(TypeError): + stroke(REBECCAPURPLE, 4.5) + + with pytest.raises(TypeError): + stroke(REBECCAPURPLE, 4.5, [1, 0]) + + def test_move_to(widget): """A move to operation can be added.""" draw_op = widget.move_to(10, 20) diff --git a/docs/en/reference/api/widgets/canvas.md b/docs/en/reference/api/widgets/canvas.md index 454e3bf338..da2d08a175 100644 --- a/docs/en/reference/api/widgets/canvas.md +++ b/docs/en/reference/api/widgets/canvas.md @@ -13,7 +13,7 @@ canvas = toga.Canvas() canvas.begin_path() canvas.move_to(20, 20) canvas.line_to(160, 20) -canvas.stroke(color="orange") +canvas.stroke(stroke_style="orange") ``` Toga adds an additional layer of convenience to the base HTML5 API by providing context managers for operations that have a natural open/close life cycle. For example, the previous example could be replaced with: @@ -22,7 +22,8 @@ Toga adds an additional layer of convenience to the base HTML5 API by providing import toga canvas = toga.Canvas() -with canvas.stroke(color="orange", 20, 20): +with canvas.stroke(stroke_style="orange"): + canvas.move_to(20, 20) canvas.line_to(160, 20) ``` @@ -38,7 +39,7 @@ In this example, we create 2 filled drawing actions, then manipulate those objec import toga canvas = toga.Canvas() -with canvas.fill(color="red") as fill: +with canvas.fill(fill_style="red") as fill: circle = canvas.arc(x=50, y=50, radius=15) rect = canvas.rect(x=50, y=50, width=15, height=15) @@ -49,7 +50,7 @@ circle.y = 25 circle.radius = 5 # Change the fill color to blue -fill.color = "blue" +fill.fill_style = "blue" # Remove the rectangle from the canvas fill.drawing_actions.remove(rect) From 6adacbe8a1d3a2086e50db63fb62b7bc4beaa065 Mon Sep 17 00:00:00 2001 From: Charles Whittington Date: Thu, 16 Apr 2026 21:26:15 -0400 Subject: [PATCH 5/9] Apply suggestion from @freakboy3742 Co-authored-by: Russell Keith-Magee --- core/src/toga/widgets/canvas/state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/toga/widgets/canvas/state.py b/core/src/toga/widgets/canvas/state.py index a965cc3510..f3b8bd17c4 100644 --- a/core/src/toga/widgets/canvas/state.py +++ b/core/src/toga/widgets/canvas/state.py @@ -588,7 +588,7 @@ def Fill( "fill", x is not None or y is not None, "The color parameter has been renamed to fill_style, and is a keyword-only " - "argument.", + "argument." if color is not None else "", ) target = self if isinstance(self, State) else self.root_state From 234bdcca4b5f53e5c235ca370a4ef689847ae166 Mon Sep 17 00:00:00 2001 From: Charles Whittington Date: Fri, 17 Apr 2026 01:47:43 -0400 Subject: [PATCH 6/9] Make color an alias --- core/src/toga/widgets/canvas/drawingaction.py | 19 ---- core/src/toga/widgets/canvas/state.py | 102 +++++++++--------- .../tests/widgets/canvas/test_deprecations.py | 26 +---- .../widgets/canvas/test_draw_operations.py | 41 ++++++- 4 files changed, 88 insertions(+), 100 deletions(-) diff --git a/core/src/toga/widgets/canvas/drawingaction.py b/core/src/toga/widgets/canvas/drawingaction.py index 472a5843ad..9ff62662ce 100644 --- a/core/src/toga/widgets/canvas/drawingaction.py +++ b/core/src/toga/widgets/canvas/drawingaction.py @@ -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, @@ -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() diff --git a/core/src/toga/widgets/canvas/state.py b/core/src/toga/widgets/canvas/state.py index f3b8bd17c4..005dec3aba 100644 --- a/core/src/toga/widgets/canvas/state.py +++ b/core/src/toga/widgets/canvas/state.py @@ -4,10 +4,11 @@ from abc import ABC, abstractmethod from collections.abc import Iterable from contextlib import AbstractContextManager -from dataclasses import KW_ONLY, 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 @@ -29,7 +30,6 @@ Scale, Translate, WriteText, - color_property, ) from .geometry import CornerRadiusT @@ -42,6 +42,8 @@ # Make sure deprecation warnings are shown by default warnings.filterwarnings("default", category=DeprecationWarning) +NOT_PROVIDED = object() + class DrawingActionDispatch(ABC): @property @@ -313,7 +315,8 @@ def fill( self, fill_rule: FillRule = FillRule.NONZERO, *, - fill_style: ColorT | None = None, + fill_style: ColorT | None | object = NOT_PROVIDED, + color: ColorT | None | object = NOT_PROVIDED, ) -> AbstractContextManager[Fill]: """Fill the current path. @@ -328,11 +331,14 @@ def fill( :param fill_rule: `nonzero` is the non-zero winding rule; `evenodd` is the even-odd winding rule. - :param fill_style: 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(fill_rule, fill_style=fill_style) + fill = Fill(fill_rule, fill_style=fill_style, color=color) self._add_to_target(fill) self._redraw_with_warning_if_state() return fill @@ -340,7 +346,8 @@ def fill( def stroke( self, *, - stroke_style: 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]: @@ -350,15 +357,19 @@ def stroke( (`x`, `y`) coordinates (if both are specified). When the context is exited, the path is stroked. - :param stroke_style: 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( stroke_style=stroke_style, + color=color, line_width=line_width, line_dash=line_dash, ) @@ -515,15 +526,13 @@ def state(self) -> AbstractContextManager[State]: # 2026-02: Backwards compatibility for <= 0.5.3 ###################################################################### - def _warn_context_manager(self, old_name, new_name, coordinates, extra=""): + def _warn_context_manager(self, old_name, new_name, coordinates): 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 += f" {extra}" warnings.warn(msg, DeprecationWarning, stacklevel=3) # Each of these CamelCase methods, when called on a State, added to that State. @@ -583,13 +592,7 @@ 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, - "The color parameter has been renamed to fill_style, and is a keyword-only " - "argument." if color is not None else "", - ) + self._warn_context_manager("Fill", "fill", x is not None or y is not None) target = self if isinstance(self, State) else self.root_state with warnings.catch_warnings(): @@ -607,13 +610,7 @@ 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, - "The color parameter has been renamed to stroke_style. It, as well as " - "line_width and line_dash, are all now keyword-only arguments.", - ) + self._warn_context_manager("Stroke", "stroke", x is not None or y is not None) target = self if isinstance(self, State) else self.root_state with warnings.catch_warnings(): @@ -833,28 +830,19 @@ def _draw(self, context: Any) -> None: context.restore() -class color_alias: - def __init__(self, attr_name, class_name): - self.attr_name = attr_name - self.class_name = class_name - +class color_property: def __get__(self, action, action_class=None): - self._warn() - return getattr(action, self.attr_name) + 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): - self._warn() - setattr(action, self.attr_name, value) + if value is not None and value is not NOT_PROVIDED: + value = Color.parse(value) - def _warn(self): - warnings.warn( - ( - f"The {self.class_name}.color attribute has been renamed to " - f"{self.class_name}.{self.attr_name}" - ), - DeprecationWarning, - stacklevel=2, - ) + action._color = value @dataclass(repr=False) @@ -863,15 +851,18 @@ class Fill(State): # (path), (fill_rule), or (path, fill_rule) usage as in JavaScript. fill_rule: FillRule = FillRule.NONZERO _: KW_ONLY - fill_style: ColorT | None = color_property() + fill_style: ColorT | None | object = color_property() + color: InitVar[ColorT | None | object] = color_property() - # 4-2026: Backwards compatibility for Toga <= 0.5.4 - # Not type-hinted so that it won't be a field. - color = color_alias("fill_style", "Fill") - - 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.fill_style is not None: @@ -895,17 +886,20 @@ def _draw(self, context: Any) -> None: class Stroke(State): # Path parameter (positional/keyword) will go here. _: KW_ONLY - stroke_style: ColorT | None = color_property() + 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 - # 4-2026: Backwards compatibility for Toga <= 0.5.4 - # Not type-hinted so that it won't be a field. - color = color_alias("stroke_style", "Stroke") - - 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.stroke_style is not None: diff --git a/core/tests/widgets/canvas/test_deprecations.py b/core/tests/widgets/canvas/test_deprecations.py index d3f4f549ea..87bd597ae3 100644 --- a/core/tests/widgets/canvas/test_deprecations.py +++ b/core/tests/widgets/canvas/test_deprecations.py @@ -2,7 +2,7 @@ import toga import toga.widgets.canvas as canvas_module -from toga.colors import GOLDENROD, REBECCAPURPLE, Color +from toga.colors import REBECCAPURPLE from toga.constants import FillRule from toga.widgets.canvas import ( Arc, @@ -448,27 +448,3 @@ def test_deprecated_list_methods(widget): ("line to", {"x": 99, "y": 99}), "restore", ] - - -@pytest.mark.parametrize( - "ActionClass, attr_name", - [ - (Fill, "fill_style"), - (Stroke, "stroke_style"), - ], -) -def test_deprecated_color_attribute(ActionClass, attr_name): - """Fill and Stroke alias color to fill_style/stroke_style.""" - action = ActionClass() - - # Set color, check new fill_style/stroke_style - with pytest.deprecated_call(): - action.color = REBECCAPURPLE - - assert getattr(action, attr_name) == Color.parse(REBECCAPURPLE) - - # Set fill_style/stroke_style, check color - setattr(action, attr_name, GOLDENROD) - - with pytest.deprecated_call(): - assert action.color == Color.parse(GOLDENROD) diff --git a/core/tests/widgets/canvas/test_draw_operations.py b/core/tests/widgets/canvas/test_draw_operations.py index eb77de86b4..44c0d7a460 100644 --- a/core/tests/widgets/canvas/test_draw_operations.py +++ b/core/tests/widgets/canvas/test_draw_operations.py @@ -35,6 +35,8 @@ def test_close_path(widget): assert widget._impl.draw_instructions[1:-1] == ["close path"] +@pytest.mark.parametrize("alias_kwarg", [True, False]) +@pytest.mark.parametrize("alias_attr", [True, False]) @pytest.mark.parametrize( "kwargs, args_repr, draw_objs, attrs", [ @@ -98,8 +100,11 @@ def test_close_path(widget): ), ], ) -def test_fill(widget, kwargs, args_repr, draw_objs, attrs): +def test_fill(widget, alias_kwarg, alias_attr, kwargs, args_repr, draw_objs, attrs): """A primitive fill operation can be added.""" + if alias_kwarg and "fill_style" in kwargs: + kwargs["color"] = kwargs.pop("fill_style") + draw_op = widget.fill(**kwargs) assert_action_performed(widget, "redraw") @@ -110,6 +115,9 @@ def test_fill(widget, kwargs, args_repr, draw_objs, attrs): assert widget._impl.draw_instructions[1:-1] == ["save", *draw_objs, "restore"] # All the attributes can be retrieved. + if alias_attr and "fill_style" in attrs: + attrs["color"] = attrs.pop("fill_style") + for name, value in attrs.items(): assert getattr(draw_op, name) == value @@ -123,6 +131,8 @@ def test_fill_kw_only(widget, use_method): fill(FillRule.EVENODD, REBECCAPURPLE) +@pytest.mark.parametrize("alias_kwarg", [True, False]) +@pytest.mark.parametrize("alias_attr", [True, False]) @pytest.mark.parametrize( "kwargs, args_repr, draw_objs, attrs", [ @@ -196,8 +206,11 @@ def test_fill_kw_only(widget, use_method): ), ], ) -def test_stroke(widget, kwargs, args_repr, draw_objs, attrs): +def test_stroke(widget, alias_kwarg, alias_attr, kwargs, args_repr, draw_objs, attrs): """A primitive stroke operation can be added.""" + if alias_kwarg and "stroke_style" in kwargs: + kwargs["color"] = kwargs.pop("stroke_style") + draw_op = widget.stroke(**kwargs) assert_action_performed(widget, "redraw") @@ -213,6 +226,9 @@ def test_stroke(widget, kwargs, args_repr, draw_objs, attrs): ] # All the attributes can be retrieved. + if alias_attr and "stroke_style" in attrs: + attrs["color"] = attrs.pop("stroke_style") + for name, value in attrs.items(): assert getattr(draw_op, name) == value @@ -232,6 +248,27 @@ def test_stroke_kw_only(widget, use_method): stroke(REBECCAPURPLE, 4.5, [1, 0]) +@pytest.mark.parametrize( + "action", + [ + (Fill, "fill", "fill_style"), + (Stroke, "stroke", "stroke_style"), + ], +) +@pytest.mark.parametrize("use_method", [True, False]) +@pytest.mark.parametrize("value", [REBECCAPURPLE, None]) +def test_fill_stroke_duplicate_parameters(widget, action, use_method, value): + """Providing both color and fill_style/stroke_style raises an error.""" + ActionClass, method_name, attr_name = action + if use_method: + act = getattr(widget, method_name) + else: + act = ActionClass + + with pytest.raises(TypeError): + act(**{attr_name: value}, color=value) + + def test_move_to(widget): """A move to operation can be added.""" draw_op = widget.move_to(10, 20) From 40ead3de71a973d5e5a4f5052592677d1d44f01e Mon Sep 17 00:00:00 2001 From: Charles Whittington Date: Fri, 17 Apr 2026 01:57:28 -0400 Subject: [PATCH 7/9] Test more combinations of values; use constant --- core/tests/widgets/canvas/test_canvas.py | 6 +++--- core/tests/widgets/canvas/test_draw_operations.py | 14 +++++++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/core/tests/widgets/canvas/test_canvas.py b/core/tests/widgets/canvas/test_canvas.py index 07de9917d4..61822f2960 100644 --- a/core/tests/widgets/canvas/test_canvas.py +++ b/core/tests/widgets/canvas/test_canvas.py @@ -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 @@ -99,7 +99,7 @@ def test_closed_path(widget): def test_fill(widget): """A canvas can produce a Fill sub-state.""" - with widget.fill(fill_rule=FillRule.EVENODD, fill_style="rebeccapurple") 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 @@ -111,7 +111,7 @@ def test_fill(widget): def test_stroke(widget): """A canvas can produce a Stroke sub-state.""" with widget.stroke( - stroke_style="rebeccapurple", line_width=5, line_dash=[2, 7] + 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) diff --git a/core/tests/widgets/canvas/test_draw_operations.py b/core/tests/widgets/canvas/test_draw_operations.py index 44c0d7a460..3c6a15904e 100644 --- a/core/tests/widgets/canvas/test_draw_operations.py +++ b/core/tests/widgets/canvas/test_draw_operations.py @@ -256,8 +256,16 @@ def test_stroke_kw_only(widget, use_method): ], ) @pytest.mark.parametrize("use_method", [True, False]) -@pytest.mark.parametrize("value", [REBECCAPURPLE, None]) -def test_fill_stroke_duplicate_parameters(widget, action, use_method, value): +@pytest.mark.parametrize( + "values", + [ + (REBECCAPURPLE, REBECCAPURPLE), + (REBECCAPURPLE, None), + (None, REBECCAPURPLE), + (None, None), + ], +) +def test_fill_stroke_duplicate_parameters(widget, action, use_method, values): """Providing both color and fill_style/stroke_style raises an error.""" ActionClass, method_name, attr_name = action if use_method: @@ -266,7 +274,7 @@ def test_fill_stroke_duplicate_parameters(widget, action, use_method, value): act = ActionClass with pytest.raises(TypeError): - act(**{attr_name: value}, color=value) + act(**{attr_name: values[0]}, color=values[1]) def test_move_to(widget): From 452f4c6de32b2af747db8f0ea2586a4b0561d83f Mon Sep 17 00:00:00 2001 From: Charles Whittington Date: Fri, 17 Apr 2026 10:15:55 -0400 Subject: [PATCH 8/9] Split args onto multiple lins --- core/tests/widgets/canvas/test_canvas.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/tests/widgets/canvas/test_canvas.py b/core/tests/widgets/canvas/test_canvas.py index 61822f2960..851ad8e844 100644 --- a/core/tests/widgets/canvas/test_canvas.py +++ b/core/tests/widgets/canvas/test_canvas.py @@ -111,7 +111,9 @@ def test_fill(widget): def test_stroke(widget): """A canvas can produce a Stroke sub-state.""" with widget.stroke( - stroke_style=REBECCAPURPLE, line_width=5, line_dash=[2, 7] + 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) From 33d036da00177400bbfaabbca5b12ae2cd961f8f Mon Sep 17 00:00:00 2001 From: Charles Whittington Date: Fri, 17 Apr 2026 17:17:52 -0400 Subject: [PATCH 9/9] Preserve old signatures on State, warn about change --- core/src/toga/widgets/canvas/state.py | 87 +++++- .../tests/widgets/canvas/test_deprecations.py | 249 +++++++++++++++++- 2 files changed, 330 insertions(+), 6 deletions(-) diff --git a/core/src/toga/widgets/canvas/state.py b/core/src/toga/widgets/canvas/state.py index e50783dddf..e0570bf569 100644 --- a/core/src/toga/widgets/canvas/state.py +++ b/core/src/toga/widgets/canvas/state.py @@ -340,6 +340,9 @@ def fill( """ 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 @@ -374,6 +377,9 @@ def stroke( 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 @@ -527,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. @@ -593,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, fill_style=color) + # BaseState.fill still uses old signature too. + 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 @@ -611,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( - stroke_style=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)) @@ -704,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 ########################################################################### diff --git a/core/tests/widgets/canvas/test_deprecations.py b/core/tests/widgets/canvas/test_deprecations.py index ecf2fc1bf9..187f532bcc 100644 --- a/core/tests/widgets/canvas/test_deprecations.py +++ b/core/tests/widgets/canvas/test_deprecations.py @@ -2,7 +2,7 @@ import toga import toga.widgets.canvas as canvas_module -from toga.colors import REBECCAPURPLE +from toga.colors import REBECCAPURPLE, Color from toga.constants import FillRule from toga.widgets.canvas import ( Arc, @@ -32,6 +32,8 @@ assert_action_performed, ) +REBECCAPURPLE_COLOR = Color.parse(REBECCAPURPLE) + @pytest.mark.parametrize( "old_name, cls", @@ -181,6 +183,251 @@ def test_capitalized_canvas_methods_xy( assert substate.drawing_actions == [MoveTo(10, 20)] +@pytest.mark.parametrize( + "args, kwargs, attrs", + [ + ((), {}, {"fill_rule": FillRule.NONZERO, "fill_style": None}), + ( + (None, FillRule.NONZERO), + {}, + {"fill_rule": FillRule.NONZERO, "fill_style": None}, + ), + ( + (REBECCAPURPLE, FillRule.EVENODD), + {}, + {"fill_rule": FillRule.EVENODD, "fill_style": REBECCAPURPLE_COLOR}, + ), + ( + (REBECCAPURPLE,), + {"fill_rule": FillRule.EVENODD}, + {"fill_rule": FillRule.EVENODD, "fill_style": REBECCAPURPLE_COLOR}, + ), + ( + (), + {"color": None, "fill_rule": FillRule.NONZERO}, + {"fill_rule": FillRule.NONZERO, "fill_style": None}, + ), + ( + (), + {"color": REBECCAPURPLE, "fill_rule": FillRule.EVENODD}, + {"fill_rule": FillRule.EVENODD, "fill_style": REBECCAPURPLE_COLOR}, + ), + ], +) +def test_fill_signature_change(widget, args, kwargs, attrs): + """State.fill translates to new signature, and warns appropriately.""" + match = ( + r"Calling drawing methods on a state is deprecated\. To add actions " + r"to the currently active state, call drawing methods on the canvas\. " + r"Additionally, the Canvas\.fill\(\) method's color parameter can only be " + r"provided via keyword\. fill_rule is the only argument it accepts " + r"positionally\." + ) + + state = State() + with pytest.deprecated_call(match=match): + fill = state.fill(*args, **kwargs) + + # Check both fill_style *and* color + attrs["color"] = attrs["fill_style"] + + for name, value in attrs.items(): + assert getattr(fill, name) == value + + +@pytest.mark.parametrize( + "args, kwargs, attrs", + [ + ((), {}, {"fill_rule": FillRule.NONZERO, "fill_style": None}), + ((10, 15), {}, {"fill_rule": FillRule.NONZERO, "fill_style": None}), + ( + (10, 15, None, FillRule.NONZERO), + {}, + {"fill_rule": FillRule.NONZERO, "fill_style": None}, + ), + ( + (10, 15, REBECCAPURPLE, FillRule.EVENODD), + {}, + {"fill_rule": FillRule.EVENODD, "fill_style": REBECCAPURPLE_COLOR}, + ), + ( + ( + 10, + 15, + REBECCAPURPLE, + ), + {"fill_rule": FillRule.EVENODD}, + {"fill_rule": FillRule.EVENODD, "fill_style": REBECCAPURPLE_COLOR}, + ), + ( + (), + {"color": None, "fill_rule": FillRule.NONZERO}, + {"fill_rule": FillRule.NONZERO, "fill_style": None}, + ), + ( + (), + {"color": REBECCAPURPLE, "fill_rule": FillRule.EVENODD}, + {"fill_rule": FillRule.EVENODD, "fill_style": REBECCAPURPLE_COLOR}, + ), + ], +) +def test_Fill_signature_change(args, kwargs, attrs): + """State.Fill (capitalized) translates to new signature, and warns appropriately.""" + match = ( + r"The Fill\(\) drawing method has been renamed to fill\(\)" + # We don't need to retest whether or not the coordinate warning is generated + r"(, and no longer accepts x and y coordinates as parameters\. Instead, " + r"call move_to\(x, y\) after entering the fill context)?\. " + r"Additionally, the Canvas\.fill\(\) method's color parameter can only be " + r"provided via keyword\. fill_rule is the only argument it accepts " + r"positionally\." + ) + + state = State() + with pytest.deprecated_call(match=match): + fill = state.Fill(*args, **kwargs) + + # Check both fill_style *and* color + attrs["color"] = attrs["fill_style"] + + for name, value in attrs.items(): + assert getattr(fill, name) == value + + +@pytest.mark.parametrize( + "args, kwargs, attrs", + [ + ((), {}, {"stroke_style": None, "line_width": None, "line_dash": None}), + ( + (None, None), + {}, + {"stroke_style": None, "line_width": None, "line_dash": None}, + ), + ( + (REBECCAPURPLE, 10, [1, 0]), + {}, + { + "stroke_style": REBECCAPURPLE_COLOR, + "line_width": 10, + "line_dash": [1, 0], + }, + ), + ( + (REBECCAPURPLE,), + {"line_width": 10, "line_dash": [1, 0]}, + { + "stroke_style": REBECCAPURPLE_COLOR, + "line_width": 10, + "line_dash": [1, 0], + }, + ), + ( + (), + {"color": None, "line_width": None, "line_dash": None}, + {"stroke_style": None, "line_width": None, "line_dash": None}, + ), + ( + (), + {"color": REBECCAPURPLE, "line_width": 10, "line_dash": [1, 0]}, + { + "stroke_style": REBECCAPURPLE_COLOR, + "line_width": 10, + "line_dash": [1, 0], + }, + ), + ], +) +def test_stroke_signature_change(args, kwargs, attrs): + """State.stroke translates to new signature, and warns appropriately.""" + match = ( + r"Calling drawing methods on a state is deprecated\. To add actions " + r"to the currently active state, call drawing methods on the canvas\. " + r"Additionally, the Canvas\.stroke\(\) method's arguments can only be provided " + r"as keywords\. It does not accept any positional arguments\." + ) + + state = State() + with pytest.deprecated_call(match=match): + stroke = state.stroke(*args, **kwargs) + + # Check both stroke_style *and* color + attrs["color"] = attrs["stroke_style"] + + for name, value in attrs.items(): + assert getattr(stroke, name) == value + + +@pytest.mark.parametrize( + "args, kwargs, attrs", + [ + ((), {}, {"stroke_style": None, "line_width": None, "line_dash": None}), + ( + (10, 20, None, None), + {}, + {"stroke_style": None, "line_width": None, "line_dash": None}, + ), + ( + (10, 20, REBECCAPURPLE, 10, [1, 0]), + {}, + { + "stroke_style": REBECCAPURPLE_COLOR, + "line_width": 10, + "line_dash": [1, 0], + }, + ), + ( + ( + 10, + 20, + REBECCAPURPLE, + ), + {"line_width": 10, "line_dash": [1, 0]}, + { + "stroke_style": REBECCAPURPLE_COLOR, + "line_width": 10, + "line_dash": [1, 0], + }, + ), + ( + (), + {"color": None, "line_width": None, "line_dash": None}, + {"stroke_style": None, "line_width": None, "line_dash": None}, + ), + ( + (), + {"color": REBECCAPURPLE, "line_width": 10, "line_dash": [1, 0]}, + { + "stroke_style": REBECCAPURPLE_COLOR, + "line_width": 10, + "line_dash": [1, 0], + }, + ), + ], +) +def test_Stroke_signature_change(args, kwargs, attrs): + """State.Stroke (capitalized) translates to new signature, and warns + appropriately. + """ + match = ( + r"The Stroke\(\) drawing method has been renamed to stroke\(\)" + # We don't need to retest whether or not the coordinate warning is generated + r"(, and no longer accepts x and y coordinates as parameters\. Instead, " + r"call move_to\(x, y\) after entering the stroke context)?\. " + r"Additionally, the Canvas\.stroke\(\) method's arguments can only be provided " + r"as keywords\. It does not accept any positional arguments\." + ) + + state = State() + with pytest.deprecated_call(match=match): + stroke = state.Stroke(*args, **kwargs) + + # Check both fill_style *and* color + attrs["color"] = attrs["stroke_style"] + + for name, value in attrs.items(): + assert getattr(stroke, name) == value + + def test_closed_path_with_xy_but_not_entered(widget): """ClosedPath(x, y), if never entered, moves but doesn't begin a new path.""" with pytest.deprecated_call():