diff --git a/android/tests_backend/widgets/canvas.py b/android/tests_backend/widgets/canvas.py index c83854f6c9..aa0c42ef9e 100644 --- a/android/tests_backend/widgets/canvas.py +++ b/android/tests_backend/widgets/canvas.py @@ -13,7 +13,12 @@ class CanvasProbe(SimpleProbe): native_class = DrawHandlerView def reference_variant(self, reference): - if reference in {"multiline_text", "write_text", "write_text_and_path"}: + if reference in { + "multiline_text", + "write_text", + "write_text_and_path", + "deprecated_tutorial", + }: return f"{reference}-android" return reference diff --git a/changes/4159.removal.md b/changes/4159.removal.md new file mode 100644 index 0000000000..c40ea128d2 --- /dev/null +++ b/changes/4159.removal.md @@ -0,0 +1,7 @@ +There are a number of changes to the Canvas widget's drawing API: + +- Drawing methods can now be called directly on a Canvas. Calling them on States is now deprecated. +- The CamelCase context-manager drawing methods (`ClosedPath`, `Fill`, and `Stroke`) are deprecated; they are now unified with their standalone counterparts (`close_path`, `fill`, and `stroke`, respectively). For example, the `fill` method (lowercase) can be used as a normal method or as `with canvas.fill():`. +- List-like methods on States are deprecated; manipulate their `State.drawing_actions` lists directly, and manually call `redraw()` on the Canvas. +- `State.redraw()` is deprecated; call `Canvas.redraw()` instead. +- States no longer hold a reference to their Canvas; the `State.canvas` attribute is deprecated. (This is largely an internal detail, and unlikely to affect user code.) diff --git a/cocoa/tests_backend/widgets/canvas.py b/cocoa/tests_backend/widgets/canvas.py index 7845259bfa..0f0141fab4 100644 --- a/cocoa/tests_backend/widgets/canvas.py +++ b/cocoa/tests_backend/widgets/canvas.py @@ -22,7 +22,12 @@ def background_color(self): return TRANSPARENT def reference_variant(self, reference): - if reference in {"multiline_text", "write_text", "write_text_and_path"}: + if reference in { + "multiline_text", + "write_text", + "write_text_and_path", + "deprecated_tutorial", + }: # System font and default size is platform dependent. return f"{reference}-macOS" return reference diff --git a/core/src/toga/fonts.py b/core/src/toga/fonts.py index 47989dd78b..48644918f0 100644 --- a/core/src/toga/fonts.py +++ b/core/src/toga/fonts.py @@ -51,7 +51,7 @@ def __init__( """Construct a reference to a font. This class should be used when an API requires an explicit font reference (e.g. - [`State.write_text`][toga.widgets.canvas.State.write_text]). In all other + [`Canvas.write_text`][toga.Canvas.write_text]). In all other cases, fonts in Toga are controlled using the style properties linked below. diff --git a/core/src/toga/widgets/canvas/__init__.py b/core/src/toga/widgets/canvas/__init__.py index 4d606afaf4..6e230bcd4f 100644 --- a/core/src/toga/widgets/canvas/__init__.py +++ b/core/src/toga/widgets/canvas/__init__.py @@ -5,32 +5,35 @@ Arc, BeginPath, BezierCurveTo, - ClosePath, + DrawImage, DrawingAction, Ellipse, - Fill, LineTo, MoveTo, QuadraticCurveTo, Rect, ResetTransform, Rotate, + RoundRect, Scale, - Stroke, Translate, WriteText, ) from .geometry import arc_to_bezier, sweepangle -from .state import ClosedPathContext, FillContext, State, StrokeContext +from .state import ClosePath, Fill, State, Stroke # Make sure deprecation warnings are shown by default warnings.filterwarnings("default", category=DeprecationWarning) _deprecated_names = { - # Jan 2026: DrawingAction was named DrawingObject, and State was named Context, in - # Toga 0.5.3 and earlier. + # 2026-02: The following have different names than they did in Toga 0.5.3 and + # earlier. "DrawingObject": DrawingAction, "Context": State, + # No one should be using these directly anyway, but just in case... + "ClosedPathContext": ClosePath, + "FillContext": Fill, + "StrokeContext": Stroke, } @@ -54,24 +57,23 @@ def __getattr__(name): "Arc", "BeginPath", "BezierCurveTo", - "ClosePath", + "DrawImage", "Ellipse", - "Fill", "LineTo", "MoveTo", "QuadraticCurveTo", "Rect", "ResetTransform", "Rotate", + "RoundRect", "Scale", - "Stroke", "Translate", "WriteText", # States - "ClosedPathContext", "State", - "FillContext", - "StrokeContext", + "Fill", + "Stroke", + "ClosePath", # Geometry "arc_to_bezier", "sweepangle", diff --git a/core/src/toga/widgets/canvas/canvas.py b/core/src/toga/widgets/canvas/canvas.py index e2e5454c74..5072c44e9e 100644 --- a/core/src/toga/widgets/canvas/canvas.py +++ b/core/src/toga/widgets/canvas/canvas.py @@ -1,16 +1,15 @@ from __future__ import annotations import warnings -from contextlib import AbstractContextManager as ContextManager from typing import ( TYPE_CHECKING, Any, Literal, Protocol, ) +from weakref import WeakSet import toga -from toga.constants import FillRule from toga.fonts import ( SYSTEM, SYSTEM_DEFAULT_FONT_SIZE, @@ -19,10 +18,9 @@ from toga.handlers import wrapped_handler from ..base import StyleT, Widget -from .state import ClosedPathContext, FillContext, State, StrokeContext +from .state import DrawingActionDispatch, State if TYPE_CHECKING: - from toga.colors import ColorT from toga.images import ImageT # Make sure deprecation warnings are shown by default @@ -52,10 +50,13 @@ def __call__(self, widget: Canvas, width: int, height: int, **kwargs: Any) -> No """ -class Canvas(Widget): +class Canvas(Widget, DrawingActionDispatch): _MIN_WIDTH = 0 _MIN_HEIGHT = 0 + # 2026-02: Backwards compatibility for <= 0.5.3 + _instances: WeakSet = WeakSet() + def __init__( self, id: str | None = None, @@ -88,7 +89,7 @@ def __init__( :param on_alt_drag: Initial [`on_alt_drag`][toga.Canvas.on_alt_drag] handler. :param kwargs: Initial style properties. """ - self._state = State(canvas=self) + self._state = State() super().__init__(id, style, **kwargs) @@ -102,6 +103,9 @@ def __init__( self.on_alt_release = on_alt_release self.on_alt_drag = on_alt_drag + # 2026-02: Backwards compatibility for <= 0.5.3 + self._instances.add(self) + def _create(self) -> Any: return self.factory.Canvas(interface=self) @@ -126,6 +130,10 @@ def root_state(self) -> State: """The root state for the canvas.""" return self._state + ###################################################################### + # 2026-02: Backwards compatibility for <= 0.5.3 + ###################################################################### + @property def context(self) -> State: warnings.warn( @@ -135,96 +143,24 @@ def context(self) -> State: ) return self._state + ###################################################################### + # End backwards compatibility + ###################################################################### + + @property + def _action_target(self): + """Return the currently active state.""" + return self.root_state._active_state + def redraw(self) -> None: """Redraw the Canvas. - The Canvas will be automatically redrawn after adding or removing a drawing - object, or when the Canvas resizes. However, when you modify the properties of a - drawing object, you must call `redraw` manually. + The Canvas will be automatically redrawn after calling its drawing methods. + However, when you directly add, remove, or modify a drawing action, you must + call `redraw` manually. """ self._impl.redraw() - def Context(self) -> ContextManager[State]: - """Construct and yield a new sub-[`State`][toga.widgets.canvas.State] within - the root state of this Canvas. - - :return: Yields the new [`State`][toga.widgets.canvas.State] object. - """ - warnings.warn( - "Canvas.Context() is deprecated. Use Canvas.root_state.state() instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.root_state.state() - - def ClosedPath( - self, - x: float | None = None, - y: float | None = None, - ) -> ContextManager[ClosedPathContext]: - """Construct and yield a new - [`ClosedPathContext`][toga.widgets.canvas.ClosedPathContext] - state in the root state of this canvas. - - :param x: The x coordinate of the path's starting point. - :param y: The y coordinate of the path's starting point. - :return: Yields the new - [`ClosedPathContext`][toga.widgets.canvas.ClosedPathContext] state object. - """ - return self.root_state.ClosedPath(x, y) - - def Fill( - self, - x: float | None = None, - y: float | None = None, - color: ColorT | None = None, - fill_rule: FillRule = FillRule.NONZERO, - ) -> ContextManager[FillContext]: - """Construct and yield a new [`FillContext`][toga.widgets.canvas.FillContext] - in the root state of this canvas. - - A drawing operator that fills the path constructed in the state according to - the current fill rule. - - If both an x and y coordinate is provided, the drawing state will begin with - a `move_to` operation in that state. - - :param x: The x coordinate of the path's starting point. - :param y: The y coordinate of the path's starting point. - :param fill_rule: `nonzero` is the non-zero winding rule; `evenodd` is the - even-odd winding rule. - :param color: The fill color. - :return class: Yields the new [`FillContext`][toga.widgets.canvas.FillContext] - state object. - """ - return self.root_state.Fill(x, y, color, fill_rule) - - def Stroke( - self, - x: float | None = None, - y: float | None = None, - color: ColorT | None = None, - line_width: float | None = None, - line_dash: list[float] | None = None, - ) -> ContextManager[StrokeContext]: - """Construct and yield a new - [`StrokeContext`][toga.widgets.canvas.StrokeContext] in the - root state of this canvas. - - If both an x and y coordinate is provided, the drawing state will begin with - a `move_to` operation in that state. - - :param x: The x coordinate of the path's starting point. - :param y: The y coordinate of the path's starting point. - :param color: The color for the stroke. - :param line_width: The width of the stroke. - :param line_dash: The dash pattern to follow when drawing the line. Default is a - solid line. - :return: Yields the new - [`StrokeContext`][toga.widgets.canvas.StrokeContext] state object. - """ - return self.root_state.Stroke(x, y, color, line_width, line_dash) - @property def on_resize(self) -> OnResizeHandler: """The handler to invoke when the canvas is resized.""" @@ -320,7 +256,7 @@ def measure_text( line_height: float | None = None, ) -> tuple[float, float]: """Measure the size at which - [`State.write_text`][toga.widgets.canvas.State.write_text] + [`Canvas.write_text`][toga.Canvas.write_text] would render some text. :param text: The text to measure. Newlines will cause line breaks, but long diff --git a/core/src/toga/widgets/canvas/drawingaction.py b/core/src/toga/widgets/canvas/drawingaction.py index 6424bdc29b..472a5843ad 100644 --- a/core/src/toga/widgets/canvas/drawingaction.py +++ b/core/src/toga/widgets/canvas/drawingaction.py @@ -9,7 +9,7 @@ from warnings import filterwarnings, warn from toga.colors import Color -from toga.constants import Baseline, FillRule +from toga.constants import Baseline from toga.fonts import ( SYSTEM, SYSTEM_DEFAULT_FONT_SIZE, @@ -20,7 +20,7 @@ from .geometry import CornerRadiusT if TYPE_CHECKING: - from toga.colors import ColorT + from toga.constants import Baseline # Make sure deprecation warnings are shown by default filterwarnings("default", category=DeprecationWarning) @@ -57,32 +57,32 @@ def _determine_counterclockwise(anticlockwise, counterclockwise): class DrawingAction(ABC): """A drawing operation in a [`State`][toga.widgets.canvas.State]. - Every state drawing method creates a `DrawingAction`, adds it to the state, - and returns it. Each argument passed to the method becomes a property of the - `DrawingAction`, which can be modified as shown in the [Usage][] section. - - `DrawingActions` can also be created manually, then added to a state using the - [`append()`][toga.widgets.canvas.State.append] or - [`insert()`][toga.widgets.canvas.State.append] methods. Their constructors take - the same arguments as the corresponding [`State`][toga.widgets.canvas.State] - method, and their classes have the same names, but capitalized: - - * [`toga.widgets.canvas.Arc`][toga.widgets.canvas.State.arc] - * [`toga.widgets.canvas.BeginPath`][toga.widgets.canvas.State.begin_path] - * [`toga.widgets.canvas.BezierCurveTo`][toga.widgets.canvas.State.bezier_curve_to] - * [`toga.widgets.canvas.ClosePath`][toga.widgets.canvas.State.close_path] - * [`toga.widgets.canvas.Ellipse`][toga.widgets.canvas.State.ellipse] - * [`toga.widgets.canvas.Fill`][toga.widgets.canvas.State.fill] - * [`toga.widgets.canvas.LineTo`][toga.widgets.canvas.State.line_to] - * [`toga.widgets.canvas.MoveTo`][toga.widgets.canvas.State.move_to] - * [`toga.widgets.canvas.QuadraticCurveTo`][toga.widgets.canvas.State.quadratic_curve_to] - * [`toga.widgets.canvas.Rect`][toga.widgets.canvas.State.rect] - * [`toga.widgets.canvas.ResetTransform`][toga.widgets.canvas.State.reset_transform] - * [`toga.widgets.canvas.Rotate`][toga.widgets.canvas.State.rotate] - * [`toga.widgets.canvas.Scale`][toga.widgets.canvas.State.scale] - * [`toga.widgets.canvas.Stroke`][toga.widgets.canvas.State.stroke] - * [`toga.widgets.canvas.Translate`][toga.widgets.canvas.State.translate] - * [`toga.widgets.canvas.WriteText`][toga.widgets.canvas.State.write_text] + Every canvas drawing method creates a `DrawingAction`, adds it to the currently + active state, and returns it. Each argument passed to the method becomes a property + of the `DrawingAction`, which can be modified as shown in the [Usage][] section. + + `DrawingActions` can also be created manually, then added to a state's + [list of drawing actions][toga.widgets.canvas.State.drawing_actions]. Their + constructors take the same arguments as the corresponding [`Canvas`] + [toga.Canvas] drawing method, and their classes have the same names, but + capitalized: + + * [`toga.widgets.canvas.Arc`][toga.Canvas.arc] + * [`toga.widgets.canvas.BeginPath`][toga.Canvas.begin_path] + * [`toga.widgets.canvas.BezierCurveTo`][toga.Canvas.bezier_curve_to] + * [`toga.widgets.canvas.ClosePath`][toga.Canvas.close_path] + * [`toga.widgets.canvas.Ellipse`][toga.Canvas.ellipse] + * [`toga.widgets.canvas.Fill`][toga.Canvas.fill] + * [`toga.widgets.canvas.LineTo`][toga.Canvas.line_to] + * [`toga.widgets.canvas.MoveTo`][toga.Canvas.move_to] + * [`toga.widgets.canvas.QuadraticCurveTo`][toga.Canvas.quadratic_curve_to] + * [`toga.widgets.canvas.Rect`][toga.Canvas.rect] + * [`toga.widgets.canvas.ResetTransform`][toga.Canvas.reset_transform] + * [`toga.widgets.canvas.Rotate`][toga.Canvas.rotate] + * [`toga.widgets.canvas.Scale`][toga.Canvas.scale] + * [`toga.widgets.canvas.Stroke`][toga.Canvas.stroke] + * [`toga.widgets.canvas.Translate`][toga.Canvas.translate] + * [`toga.widgets.canvas.WriteText`][toga.Canvas.write_text] """ # noqa: E501 # Disable the line-too-long check as there is no way to properly render the list @@ -113,6 +113,11 @@ def __repr__(self) -> str: def _draw(self, context: Any) -> None: """Called by parent state to execute this drawing action.""" + def __contains__(self, other: DrawingAction): + return hasattr(self, "drawing_actions") and any( + action is other or other in action for action in self.drawing_actions + ) + class color_property: def __get__(self, action, action_class=None): @@ -137,42 +142,6 @@ def _draw(self, context: Any) -> None: context.begin_path() -class ClosePath(DrawingAction): - def _draw(self, context: Any) -> None: - context.close_path() - - -@dataclass(repr=False) -class Fill(DrawingAction): - color: ColorT | None = color_property() - fill_rule: FillRule = FillRule.NONZERO - - def _draw(self, context: Any) -> None: - context.save() - if self.color is not None: - context.set_fill_style(self.color) - context.fill(self.fill_rule) - context.restore() - - -@dataclass(repr=False) -class Stroke(DrawingAction): - color: ColorT | None = color_property() - line_width: float | None = None - line_dash: list[float] | None = None - - def _draw(self, context: Any) -> None: - context.save() - if self.color is not None: - context.set_stroke_style(self.color) - if self.line_width is not None: - context.set_line_width(self.line_width) - if self.line_dash is not None: - context.set_line_dash(self.line_dash) - context.stroke() - context.restore() - - @dataclass(repr=False) class MoveTo(DrawingAction): x: float diff --git a/core/src/toga/widgets/canvas/state.py b/core/src/toga/widgets/canvas/state.py index 2fe672eebe..d0f496bf0d 100644 --- a/core/src/toga/widgets/canvas/state.py +++ b/core/src/toga/widgets/canvas/state.py @@ -2,13 +2,12 @@ import warnings from abc import ABC, abstractmethod -from collections.abc import Iterable, Iterator -from contextlib import contextmanager +from collections.abc import Iterable +from contextlib import AbstractContextManager +from dataclasses import dataclass from math import pi from typing import TYPE_CHECKING, Any -import toga -from toga.colors import Color from toga.constants import Baseline, FillRule from toga.fonts import Font from toga.images import Image @@ -17,11 +16,9 @@ Arc, BeginPath, BezierCurveTo, - ClosePath, DrawImage, DrawingAction, Ellipse, - Fill, LineTo, MoveTo, QuadraticCurveTo, @@ -30,9 +27,9 @@ Rotate, RoundRect, Scale, - Stroke, Translate, WriteText, + color_property, ) from .geometry import CornerRadiusT @@ -40,6 +37,7 @@ from toga.colors import ColorT from .canvas import Canvas + from .drawingaction import DrawingAction # Make sure deprecation warnings are shown by default warnings.filterwarnings("default", category=DeprecationWarning) @@ -51,37 +49,49 @@ class DrawingActionDispatch(ABC): def _action_target(self): """The State that should receive the drawing actions.""" + def _add_to_target(self, drawing_action: DrawingAction): + if actions := self._action_target.drawing_actions: + last = actions[-1] + if isinstance(last, State): + # If the most recent drawing action is (potentially) a context manager, + # disable it so it can't be entered later, out of order. + last._can_be_entered = False + + actions.append(drawing_action) + ########################################################################### # Path manipulation ########################################################################### def begin_path(self) -> BeginPath: - """Start a new path in the canvas state. + """Start a new path. :returns: The `BeginPath` [`DrawingAction`][toga.widgets.canvas.DrawingAction] for the operation. """ begin_path = BeginPath() - self._action_target.append(begin_path) + self._add_to_target(begin_path) + self._redraw_with_warning_if_state() return begin_path def close_path(self) -> ClosePath: - """Close the current path in the canvas state. + """Close the current path. This closes the current path as a simple drawing operation. It should be paired - with a [`begin_path()`][toga.widgets.canvas.State.begin_path] operation; or, - to complete a complete closed path, use the - [`ClosedPath()`][toga.widgets.canvas.State.ClosedPath] context manager. + with a [`begin_path()`][toga.Canvas.begin_path] operation, or else used as a + context manager. If used as a context manager, it begins a path when entering, + and closes it upon exiting. :returns: The `ClosePath` [`DrawingAction`][toga.widgets.canvas.DrawingAction] for the operation. """ close_path = ClosePath() - self._action_target.append(close_path) + self._add_to_target(close_path) + self._redraw_with_warning_if_state() return close_path def move_to(self, x: float, y: float) -> MoveTo: - """Moves the current point of the canvas state without drawing. + """Moves the current point without drawing. :param x: The x coordinate of the new current point. :param y: The y coordinate of the new current point. @@ -89,11 +99,12 @@ def move_to(self, x: float, y: float) -> MoveTo: for the operation. """ move_to = MoveTo(x, y) - self._action_target.append(move_to) + self._add_to_target(move_to) + self._redraw_with_warning_if_state() return move_to def line_to(self, x: float, y: float) -> LineTo: - """Draw a line segment ending at a point in the canvas state. + """Draw a line segment ending at a point. :param x: The x coordinate for the end point of the line segment. :param y: The y coordinate for the end point of the line segment. @@ -101,7 +112,8 @@ def line_to(self, x: float, y: float) -> LineTo: for the operation. """ line_to = LineTo(x, y) - self._action_target.append(line_to) + self._add_to_target(line_to) + self._redraw_with_warning_if_state() return line_to def bezier_curve_to( @@ -113,7 +125,7 @@ def bezier_curve_to( x: float, y: float, ) -> BezierCurveTo: - """Draw a Bézier curve in the canvas state. + """Draw a Bézier curve. A Bézier curve requires three points. The first two are control points; the third is the end point for the curve. The starting point is the last point in @@ -130,7 +142,8 @@ def bezier_curve_to( [`DrawingAction`][toga.widgets.canvas.DrawingAction] for the operation. """ bezier_curve_to = BezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) - self._action_target.append(bezier_curve_to) + self._add_to_target(bezier_curve_to) + self._redraw_with_warning_if_state() return bezier_curve_to def quadratic_curve_to( @@ -140,7 +153,7 @@ def quadratic_curve_to( x: float, y: float, ) -> QuadraticCurveTo: - """Draw a quadratic curve in the canvas state. + """Draw a quadratic curve. A quadratic curve requires two points. The first point is a control point; the second is the end point. The starting point of the curve is the last point in @@ -157,7 +170,8 @@ def quadratic_curve_to( [`DrawingAction`][toga.widgets.canvas.DrawingAction] for the operation. """ quadratic_curve_to = QuadraticCurveTo(cpx, cpy, x, y) - self._action_target.append(quadratic_curve_to) + self._add_to_target(quadratic_curve_to) + self._redraw_with_warning_if_state() return quadratic_curve_to def arc( @@ -170,7 +184,7 @@ def arc( counterclockwise: bool | None = None, anticlockwise: bool | None = None, # DEPRECATED ) -> Arc: - """Draw a circular arc in the canvas state. + """Draw a circular arc. A full circle will be drawn by default; an arc can be drawn by specifying a start and end angle. @@ -188,7 +202,8 @@ def arc( for the operation. """ arc = Arc(x, y, radius, startangle, endangle, counterclockwise, anticlockwise) - self._action_target.append(arc) + self._add_to_target(arc) + self._redraw_with_warning_if_state() return arc def ellipse( @@ -203,7 +218,7 @@ def ellipse( counterclockwise: bool | None = None, anticlockwise: bool | None = None, # DEPRECATED ) -> Ellipse: - """Draw an elliptical arc in the canvas state. + """Draw an elliptical arc. A full ellipse will be drawn by default; an arc can be drawn by specifying a start and end angle. @@ -235,11 +250,12 @@ def ellipse( counterclockwise, anticlockwise, ) - self._action_target.append(ellipse) + self._add_to_target(ellipse) + self._redraw_with_warning_if_state() return ellipse def rect(self, x: float, y: float, width: float, height: float) -> Rect: - """Draw a rectangle in the canvas state. + """Draw a rectangle. :param x: The horizontal coordinate of the left of the rectangle. :param y: The vertical coordinate of the top of the rectangle. @@ -249,7 +265,8 @@ def rect(self, x: float, y: float, width: float, height: float) -> Rect: for the operation. """ rect = Rect(x, y, width, height) - self._action_target.append(rect) + self._add_to_target(rect) + self._redraw_with_warning_if_state() return rect def round_rect( @@ -288,7 +305,8 @@ def round_rect( for the operation. """ round_rect = RoundRect(x, y, width, height, radii) - self._action_target.append(round_rect) + self._add_to_target(round_rect) + self._redraw_with_warning_if_state() return round_rect def fill( @@ -303,6 +321,10 @@ def fill( [Even-Odd](https://en.wikipedia.org/wiki/Even-odd_rule) winding rule for filling paths. + 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 filled. + :param fill_rule: `nonzero` is the non-zero winding rule; `evenodd` is the even-odd winding rule. :param color: The fill color. @@ -310,7 +332,8 @@ def fill( for the operation. """ fill = Fill(color, fill_rule) - self._action_target.append(fill) + self._add_to_target(fill) + self._redraw_with_warning_if_state() return fill def stroke( @@ -321,6 +344,10 @@ def stroke( ) -> 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 @@ -329,7 +356,8 @@ def stroke( for the operation. """ stroke = Stroke(color, line_width, line_dash) - self._action_target.append(stroke) + self._add_to_target(stroke) + self._redraw_with_warning_if_state() return stroke ########################################################################### @@ -345,10 +373,10 @@ def write_text( baseline: Baseline = Baseline.ALPHABETIC, line_height: float | None = None, ) -> WriteText: - """Write text at a given position in the canvas state. + """Write text at a given position. Drawing text is effectively a series of path operations, so the text will have - the color and fill properties of the canvas state. + the current color and fill properties. :param text: The text to draw. Newlines will cause line breaks, but long lines will not be wrapped. @@ -362,7 +390,8 @@ def write_text( for the operation. """ write_text = WriteText(text, x, y, font, baseline, line_height) - self._action_target.append(write_text) + self._add_to_target(write_text) + self._redraw_with_warning_if_state() return write_text ########################################################################### @@ -377,7 +406,7 @@ def draw_image( width: float | None = None, height: float | None = None, ): - """Draw a Toga Image in the canvas state. + """Draw a Toga Image. The x, y coordinates specify the location of the bottom-left corner of the image. If supplied, the width and height specify the size @@ -403,25 +432,27 @@ def draw_image( no scaling will be done. """ draw_image = DrawImage(image, x, y, width, height) - self._action_target.append(draw_image) + self._add_to_target(draw_image) + self._redraw_with_warning_if_state() return draw_image ########################################################################### # Transformations ########################################################################### def rotate(self, radians: float) -> Rotate: - """Add a rotation to the canvas state. + """Add a rotation. :param radians: The angle to rotate clockwise in radians. :returns: The `Rotate` [`DrawingAction`][toga.widgets.canvas.DrawingAction] for the transformation. """ rotate = Rotate(radians) - self._action_target.append(rotate) + self._add_to_target(rotate) + self._redraw_with_warning_if_state() return rotate def scale(self, sx: float, sy: float) -> Scale: - """Add a scaling transformation to the canvas state. + """Add a scaling transformation. :param sx: Scale factor for the X dimension. A negative value flips the image horizontally. @@ -431,11 +462,12 @@ def scale(self, sx: float, sy: float) -> Scale: for the transformation. """ scale = Scale(sx, sy) - self._action_target.append(scale) + self._add_to_target(scale) + self._redraw_with_warning_if_state() return scale def translate(self, tx: float, ty: float) -> Translate: - """Add a translation to the canvas state. + """Add a translation. :param tx: Translation for the X dimension. :param ty: Translation for the Y dimension. @@ -443,107 +475,115 @@ def translate(self, tx: float, ty: float) -> Translate: for the transformation. """ translate = Translate(tx, ty) - self._action_target.append(translate) + self._add_to_target(translate) + self._redraw_with_warning_if_state() return translate def reset_transform(self) -> ResetTransform: - """Reset all transformations in the canvas state. + """Reset all transformations. :returns: A `ResetTransform` [`DrawingAction`][toga.widgets.canvas.DrawingAction]. """ reset_transform = ResetTransform() - self._action_target.append(reset_transform) + self._add_to_target(reset_transform) + self._redraw_with_warning_if_state() return reset_transform ########################################################################### # Sub-states of this state ########################################################################### - @contextmanager - def state(self) -> Iterator[State]: + def state(self) -> AbstractContextManager[State]: """Construct and yield a new sub-[`State`][toga.widgets.canvas.State] within - this state. + the current state. :return: Yields the new [`State`][toga.widgets.canvas.State] object. """ - state = State(canvas=self._canvas) - self._action_target.append(state) - yield state - self.redraw() + state = State() + self._add_to_target(state) + self._redraw_with_warning_if_state() + return state + + ###################################################################### + # 2026-02: Backwards compatibility for <= 0.5.3 + ###################################################################### + + 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." + ) + warnings.warn(msg, DeprecationWarning, stacklevel=3) + + # Each of these CamelCase methods, when called on a State, added to that State. + # However, when called on a Canvas, they added to that Canvas's root_state. So we + # call the drawing method on the target, suppressing warnings in case that target + # is a State. + + def Context(self) -> AbstractContextManager[State]: + self._warn_context_manager("Context", "state", False) + + target = self if isinstance(self, State) else self.root_state + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + return target.state() - def Context(self) -> Iterator[State]: - warnings.warn( - "State.Context() has been renamed to State.state()", - DeprecationWarning, - stacklevel=2, - ) - return self.state() - - @contextmanager def ClosedPath( self, x: float | None = None, y: float | None = None, - ) -> Iterator[ClosedPathContext]: - """Construct and yield a new `ClosedPath` - sub-state that will draw a closed path, starting from an origin. - - This is a context manager; it creates a new path and moves to the start - coordinate; when the state exits, the path is closed. For fine-grained control - of a path, you can use [`begin_path`][toga.widgets.canvas.State.begin_path] - and [`close_path`][toga.widgets.canvas.State.close_path]. - - :param x: The x coordinate of the path's starting point. - :param y: The y coordinate of the path's starting point. - :return: Yields the [`ClosedPathContext`][toga.widgets.canvas.ClosedPathContext] - state object. - """ - closed_path = ClosedPathContext(canvas=self.canvas, x=x, y=y) - self._action_target.append(closed_path) - yield closed_path + ) -> AbstractContextManager[ClosePath]: + self._warn_context_manager( + "ClosedPath", + "close_path", + x is not None or y is not None, + ) + + target = self if isinstance(self, State) else self.root_state + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + close_path = target.close_path() + if x is not None and y is not None: + # 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 + # standalone command, without ever entering it. Used this way, it acts like + # a move followed by close; it never *begins* a path, so it can close the + # final leg on a path that's already been going. + + # Therefore, unless and until the ClosePath is in fact entered, it needs to + # fulfill this edge case. We store x and y on it, and when it's drawn, if + # it hasn't been used as a context manager, it moves to these coordinates + # before calling context.close_path(). + close_path.x = x + close_path.y = y + + # The target.close_path() method already called a redraw, but we need to + # update it now that the ClosedPath knows about its coordinates. + self._redraw_with_warning_if_state() + + return close_path - @contextmanager def Fill( self, x: float | None = None, y: float | None = None, color: ColorT | None = None, fill_rule: FillRule = FillRule.NONZERO, - ) -> Iterator[FillContext]: - """Construct and yield a new `Fill` sub-state - within this state. - - This is a context manager; it creates a new path, and moves to the start - coordinate; when the state exits, the path is closed with a fill. For - fine-grained control of a path, you can use - [`begin_path`][toga.widgets.canvas.State.begin_path], - [`move_to`][toga.widgets.canvas.State.move_to], - [`close_path`][toga.widgets.canvas.State.close_path] and - [`fill`][toga.widgets.canvas.State.fill]. - - If both an x and y coordinate is provided, the drawing state will begin with - a `move_to` operation in that state. - - :param x: The x coordinate of the path's starting point. - :param y: The y coordinate of the path's starting point. - :param fill_rule: `nonzero` is the non-zero winding rule; `evenodd` is the - even-odd winding rule. - :param color: The fill color. - :return: Yields the new [`FilContext`][toga.widgets.canvas.FillContext] state - object. - """ - fill = FillContext( - canvas=self.canvas, - x=x, - y=y, - color=color, - fill_rule=fill_rule, - ) - self._action_target.append(fill) - yield fill + ) -> AbstractContextManager[Fill]: + 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(): + warnings.simplefilter("ignore", DeprecationWarning) + fill = target.fill(fill_rule=fill_rule, color=color) + if x is not None and y is not None: + fill.drawing_actions.append(MoveTo(x, y)) + return fill - @contextmanager def Stroke( self, x: float | None = None, @@ -551,61 +591,69 @@ def Stroke( color: ColorT | None = None, line_width: float | None = None, line_dash: list[float] | None = None, - ) -> Iterator[StrokeContext]: - """Construct and yield a new `Stroke` sub-state - within this state. - - This is a context manager; it creates a new path, and moves to the start - coordinate; when the state exits, the path is closed with a stroke. For - fine-grained control of a path, you can use - [`begin_path`][toga.widgets.canvas.State.begin_path], - [`move_to`][toga.widgets.canvas.State.move_to], - [`close_path`][toga.widgets.canvas.State.close_path] and - [`stroke`][toga.widgets.canvas.State.stroke]. - - If both an x and y coordinate is provided, the drawing state will begin with - a `move_to` operation in that state. - - :param x: The x coordinate of the path's starting point. - :param y: The y coordinate of the path's starting point. - :param color: The color for the stroke. - :param line_width: The width of the stroke. - :param line_dash: The dash pattern to follow when drawing the line. Default is a - solid line. - :return: Yields the new [`StrokeContext`][toga.widgets.canvas.StrokeContext] - state object. - """ - stroke = StrokeContext( - canvas=self.canvas, - x=x, - y=y, - color=color, - line_width=line_width, - line_dash=line_dash, - ) - self._action_target.append(stroke) - yield stroke + ) -> AbstractContextManager[Stroke]: + 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(): + warnings.simplefilter("ignore", DeprecationWarning) + stroke = target.stroke( + 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)) + return stroke + + def _redraw_without_warning(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + self.redraw() + + def _redraw_with_warning_if_state(self): + if isinstance(self, State): + # If a drawing method is called on a State, we need to warn about that, but + # then silence the additional warning that we'll cause when we internally + # call redraw(). + warnings.warn( + ( + "Calling drawing methods on a State is deprecated. To add actions " + "to the currently active state, call drawing methods on the canvas." + ), + DeprecationWarning, + stacklevel=3, + ) + self._redraw_without_warning() + else: + # On a canvas, proceed as usual. + self.redraw() + + ###################################################################### + # End Backwards compatibility + ###################################################################### class State(DrawingAction, DrawingActionDispatch): """A drawing state for a canvas. You should not create a [`State`][toga.widgets.canvas.State] directly; instead, - you should use the [`state()`][toga.widgets.canvas.State.state] method on an - existing state, or use [`Canvas.root_state`][toga.Canvas.root_state] to access the - root state of the canvas. + you should use the canvas's [`state()`][toga.Canvas.state] method. """ - def __init__(self, canvas: toga.Canvas, **kwargs: Any): - # kwargs used to support multiple inheritance - super().__init__(**kwargs) - self._canvas = canvas - self.drawing_actions: list[DrawingAction] = [] + drawing_actions: list[DrawingAction] + """The list of all drawing actions contained by this state. + + If you add or remove drawing actions to this list, you'll need to call + [`Canvas.redraw()`][toga.Canvas.redraw] for the changes to be rendered. + """ + + def __init__(self): + self.drawing_actions = [] + self._can_be_entered = True def _draw(self, context: Any) -> None: context.save() - for obj in self.drawing_actions: - obj._draw(context) + for action in self.drawing_actions: + action._draw(context) context.restore() @property @@ -613,246 +661,211 @@ def _action_target(self): # State itself holds its drawing actions. return self - ########################################################################### - # Methods to keep track of the canvas, automatically redraw it - ########################################################################### - @property - def canvas(self) -> Canvas: - """The canvas that is associated with this drawing state.""" - return self._canvas + def _active_state(self): + """Return the currently active state, either this or a sub-state.""" + if self.drawing_actions: + # If a sub-state is active, it must be the last action in the list; + # subsequent actions would be added to that sub-state (or a sub-state of + # it). + last = self.drawing_actions[-1] + if getattr(last, "_is_open", False): + return last._active_state - def redraw(self) -> None: - """Calls [`Canvas.redraw`][toga.Canvas.redraw] on the parent Canvas.""" - self.canvas.redraw() + return self + + def __enter__(self): + if not self._can_be_entered: + raise RuntimeError( + "A drawing context manager can only be entered once, and only before " + "any subsequent drawing actions are added." + ) + + self._is_open = True + self._can_be_entered = False + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self._is_open = False + # Don't suppress any exceptions + return False ########################################################################### - # Operations on drawing objects + # 2026-02: Backwards compatibility for Toga <= 0.5.3 ########################################################################### def __len__(self) -> int: - """Returns the number of drawing objects that are in this state.""" + self._warn_list_methods() return len(self.drawing_actions) def __getitem__(self, index: int) -> DrawingAction: - """Returns the drawing object at the given index.""" + self._warn_list_methods() return self.drawing_actions[index] def append(self, obj: DrawingAction) -> None: - """Append a drawing object to the state. - - :param obj: The drawing object to add to the state. - """ + self._warn_list_methods() self.drawing_actions.append(obj) - self.redraw() + self._redraw_without_warning() def insert(self, index: int, obj: DrawingAction) -> None: - """Insert a drawing object into the state at a specific index. - - :param index: The index at which the drawing object should be inserted. - :param obj: The drawing object to add to the state. - """ + self._warn_list_methods() self.drawing_actions.insert(index, obj) - self.redraw() + self._redraw_without_warning() def remove(self, obj: DrawingAction) -> None: - """Remove a drawing object from the state. - - :param obj: The drawing object to remove. - """ + self._warn_list_methods() self.drawing_actions.remove(obj) - self.redraw() + self._redraw_without_warning() def clear(self) -> None: - """Remove all drawing objects from the state.""" + self._warn_list_methods() self.drawing_actions.clear() - self.redraw() + self._redraw_without_warning() + @property + def canvas(self) -> Canvas: + warnings.warn( + "State objects no longer hold a reference to their canvas.", + DeprecationWarning, + stacklevel=2, + ) -class ClosedPathContext(State): - """A drawing state that will build a closed path, starting from an - origin. + from .canvas import Canvas - This is a context manager; it creates a new path and moves to the start coordinate; - when the state exits, the path is closed. For fine-grained control of a path, you - can use [`begin_path`][toga.widgets.canvas.State.begin_path], - [`move_to`][toga.widgets.canvas.State.move_to] and, - [`close_path`][toga.widgets.canvas.State.close_path]. + # Get the first that matches. + for canvas in Canvas._instances: + if self is canvas.root_state or self in canvas.root_state: + return canvas - If both an x and y coordinate is provided, the drawing state will begin with - a `move_to` operation in that state. + return None - You should not create a [`ClosedPathContext`][toga.widgets.canvas.ClosedPathContext] - state directly; instead, you should use the - [`ClosedPath()`][toga.widgets.canvas.State.ClosedPath] method on an existing - state. - """ + def redraw(self) -> None: + warnings.warn( + ( + "State.redraw() is deprecated. Call the canvas's redraw() method " + "instead." + ), + DeprecationWarning, + stacklevel=2, + ) - def __init__( - self, - canvas: toga.Canvas, - x: float | None = None, - y: float | None = None, - ): - super().__init__(canvas=canvas) - self.x = x - self.y = y + from .canvas import Canvas - def __repr__(self) -> str: - return f"{self.__class__.__name__}(x={self.x}, y={self.y})" + # Redraw any canvases that contain self; could be multiple. + for canvas in Canvas._instances: + if self is canvas.root_state or self in canvas.root_state: + canvas.redraw() - def _draw(self, context: Any) -> None: - context.save() - context.begin_path() - if self.x is not None and self.y is not None: - context.move_to(x=self.x, y=self.y) + def _warn_list_methods(self) -> None: + warnings.warn( + ( + "State's list-like methods (append, insert, remove, and clear), as " + "well as implementing len() and indexing, are deprecated. Manipulate " + "state.drawing_actions directly, and then call redraw() on the canvas." + ), + DeprecationWarning, + stacklevel=3, + ) - for obj in self.drawing_actions: - obj._draw(context) + ###################################################################### + # End backwards compatibility + ###################################################################### - context.close_path() - context.restore() +@dataclass(repr=False) +class ClosePath(State): + def __post_init__(self): + super().__init__() -class FillContext(ClosedPathContext): - """A drawing state that will apply a fill to any paths all objects in the - state. + # Backwards compatibility for Toga <= 0.5.4 + # See DrawingActionDispatch.ClosedPath for explanation + def __enter__(self): + super().__enter__() - The fill can use either the [Non-Zero](https://en.wikipedia.org/wiki/Nonzero-rule) - or [Even-Odd](https://en.wikipedia.org/wiki/Even-odd_rule) winding rule for - filling paths. + if hasattr(self, "x") and hasattr(self, "y"): + self.drawing_actions.append(MoveTo(self.x, self.y)) - This is a context manager; it creates a new path, and moves to the start coordinate; - when the state exits, the path is closed with a fill. For fine-grained control of - a path, you can use [`begin_path`][toga.widgets.canvas.State.begin_path], - [`move_to`][toga.widgets.canvas.State.move_to], - [`close_path`][toga.widgets.canvas.State.close_path] and - [`fill`][toga.widgets.canvas.State.fill]. + return self - If both an x and y coordinate is provided, the drawing state will begin with - a `move_to` operation in that state. + # End backwards compatibility - You should not create a [`FillContext`][toga.widgets.canvas.FillContext] state - directly; instead, you should use the [`Fill()`][toga.widgets.canvas.State.Fill] - method on an existing state. - """ + 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 - def __init__( - self, - canvas: toga.Canvas, - x: float | None = None, - y: float | None = None, - color: ColorT | None = None, - fill_rule: FillRule = FillRule.NONZERO, - ): - super().__init__(canvas=canvas, x=x, y=y) - self.color = color - self.fill_rule = fill_rule - - def __repr__(self) -> str: - return ( - f"{self.__class__.__name__}(x={self.x}, y={self.y}, " - f"color={self.color!r}, fill_rule={self.fill_rule})" - ) + # 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) + # End backwards compatibility + + context.close_path() + return - def _draw(self, context: Any) -> None: context.save() - context.in_fill = True # Backwards compatibility for Toga <= 0.5.3 - if self.color: - context.set_fill_style(self.color) context.begin_path() - if self.x is not None and self.y is not None: - context.move_to(x=self.x, y=self.y) - for obj in self.drawing_actions: - obj._draw(context) + for action in self.drawing_actions: + action._draw(context) - context.fill(self.fill_rule) - context.in_fill = False # Backwards compatibility for Toga <= 0.5.3 + context.close_path() context.restore() - @property - def color(self) -> Color | None: - """The fill color.""" - return self._color - - @color.setter - def color(self, value: ColorT | None) -> None: - if value is None: - self._color = None - else: - self._color = Color.parse(value) +@dataclass(repr=False) +class Fill(State): + color: ColorT | None = color_property() + fill_rule: FillRule = FillRule.NONZERO -class StrokeContext(ClosedPathContext): - """Construct a drawing state that will draw a stroke on all paths defined - within the state. + def __post_init__(self): + super().__init__() - This is a context manager; it creates a new path, and moves to the start coordinate; - when the state exits, the path is drawn with the stroke. For fine-grained control - of a path, you can use [`begin_path`][toga.widgets.canvas.State.begin_path], - [`move_to`][toga.widgets.canvas.State.move_to], - [`close_path`][toga.widgets.canvas.State.close_path] and - [`stroke`][toga.widgets.canvas.State.stroke]. + def _draw(self, context: Any) -> None: + context.save() + if self.color is not None: + context.set_fill_style(self.color) - If both an x and y coordinate is provided, the drawing state will begin with - a `move_to` operation in that state. + 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.begin_path() - You should not create a [`StrokeContext`][toga.widgets.canvas.StrokeContext] state - directly; instead, you should use the - [`Stroke()`][toga.widgets.canvas.State.Stroke] method on an existing state. - """ + for action in self.drawing_actions: + action._draw(context) - def __init__( - self, - canvas: toga.Canvas, - x: float | None = None, - y: float | None = None, - color: ColorT | None = None, - line_width: float | None = None, - line_dash: list[float] | None = None, - ): - super().__init__(canvas=canvas, x=x, y=y) - self.color = color - self.line_width = line_width - self.line_dash = line_dash - - def __repr__(self) -> str: - return ( - f"{self.__class__.__name__}(x={self.x}, y={self.y}, color={self.color!r}, " - f"line_width={self.line_width}, line_dash={self.line_dash!r})" - ) + context.in_fill = False # Backwards compatibility for Toga <= 0.5.3 + + context.fill(self.fill_rule) + context.restore() + + +@dataclass(repr=False) +class Stroke(State): + color: ColorT | None = color_property() + line_width: float | None = None + line_dash: list[float] | None = None + + def __post_init__(self): + super().__init__() def _draw(self, context: Any) -> None: context.save() - context.in_stroke = True # Backwards compatibility for Toga <= 0.5.3 if self.color is not None: context.set_stroke_style(self.color) if self.line_width is not None: context.set_line_width(self.line_width) if self.line_dash is not None: context.set_line_dash(self.line_dash) - context.begin_path() - if self.x is not None and self.y is not None: - context.move_to(x=self.x, y=self.y) + if hasattr(self, "_is_open") or self.drawing_actions: + # Was used as a context manager (or had drawing actions manually added) + context.in_stroke = True # Backwards compatibility for Toga <= 0.5.3 + context.begin_path() - for obj in self.drawing_actions: - obj._draw(context) + for action in self.drawing_actions: + action._draw(context) - context.stroke() + context.in_stroke = False # Backwards compatibility for Toga <= 0.5.3 - context.in_stroke = False # Backwards compatibility for Toga <= 0.5.3 + context.stroke() context.restore() - - @property - def color(self) -> Color | None: - """The color of the stroke.""" - return self._color - - @color.setter - def color(self, value: object) -> None: - if value is None: - self._color = None - else: - self._color = Color.parse(value) diff --git a/core/tests/widgets/canvas/test_canvas.py b/core/tests/widgets/canvas/test_canvas.py index 0c5859e807..dfc656d9f6 100644 --- a/core/tests/widgets/canvas/test_canvas.py +++ b/core/tests/widgets/canvas/test_canvas.py @@ -4,13 +4,7 @@ from toga.colors import rgb from toga.constants import FillRule from toga.fonts import SYSTEM, SYSTEM_DEFAULT_FONT_SIZE, Font -from toga.widgets.canvas import ( - ClosedPathContext, - DrawingAction, - FillContext, - State, - StrokeContext, -) +from toga.widgets.canvas import ClosePath, Fill, State, Stroke from toga_dummy.utils import assert_action_not_performed, assert_action_performed REBECCA_PURPLE_COLOR = rgb(102, 51, 153) @@ -97,40 +91,30 @@ def test_redraw(widget): def test_closed_path(widget): """A canvas can produce a ClosedPath sub-state.""" - with widget.ClosedPath(x=10, y=20) as closed_path: + with widget.close_path() as closed_path: # A fresh state has been created as a sub-state of the canvas. - assert isinstance(closed_path, ClosedPathContext) + assert isinstance(closed_path, ClosePath) assert closed_path is not widget.root_state - assert closed_path.x == 10 - assert closed_path.y == 20 def test_fill(widget): """A canvas can produce a Fill sub-state.""" - with widget.Fill( - x=10, y=20, color="rebeccapurple", fill_rule=FillRule.EVENODD - ) as fill: + with widget.fill(color="rebeccapurple", fill_rule=FillRule.EVENODD) as fill: # A fresh state has been created as a sub-state of the canvas. - assert isinstance(fill, FillContext) + assert isinstance(fill, Fill) assert fill is not widget.root_state - assert fill.x == 10 - assert fill.y == 20 assert fill.color == REBECCA_PURPLE_COLOR assert fill.fill_rule == FillRule.EVENODD def test_stroke(widget): """A canvas can produce a Stroke sub-state.""" - with widget.Stroke( - x=10, y=20, color="rebeccapurple", line_width=5, line_dash=[2, 7] - ) as stroke: + with widget.stroke(color="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, StrokeContext) + assert isinstance(stroke, Stroke) assert stroke is not widget.root_state - assert stroke.x == 10 - assert stroke.y == 20 assert stroke.color == REBECCA_PURPLE_COLOR assert stroke.line_width == 5.0 assert stroke.line_dash == [2, 7] @@ -189,47 +173,3 @@ def test_as_image(widget): image = widget.as_image() assert image is not None assert_action_performed(widget, "get image data") - - -def test_deprecated_class_names(): - """Deprecated names work, but issue a warning.""" - with pytest.warns(DeprecationWarning): - from toga.widgets.canvas import DrawingObject - - assert DrawingObject is DrawingAction - - with pytest.warns(DeprecationWarning): - from toga.widgets.canvas import Context - - assert Context is State - - # A completely bogus name still fails. - with pytest.raises(ImportError): - from toga.widgets.canvas import Nonexistent # noqa: F401 - - -def test_deprecated_attribute_names(widget): - with pytest.warns(DeprecationWarning): - context_property = widget.context - - assert context_property is widget.root_state - - # Create one sub-state first, to make sure we generate the new one in the right - # place — on the root state. - with widget.root_state.state() as state_1: - with pytest.warns(DeprecationWarning): - with widget.Context() as state_2: - pass - - assert widget.root_state.drawing_actions == [state_1, state_2] - - widget.root_state.clear() - - # Create one sub-state first, to make sure we generate the new one in the right - # place — on the root state. - with widget.root_state.state() as state_3: - with pytest.warns(DeprecationWarning): - with widget.root_state.Context() as state_4: - pass - - assert widget.root_state.drawing_actions == [state_3, state_4] diff --git a/core/tests/widgets/canvas/test_deprecations.py b/core/tests/widgets/canvas/test_deprecations.py new file mode 100644 index 0000000000..1358451d80 --- /dev/null +++ b/core/tests/widgets/canvas/test_deprecations.py @@ -0,0 +1,444 @@ +import pytest + +import toga +import toga.widgets.canvas as canvas_module +from toga.constants import FillRule +from toga.widgets.canvas import ( + Arc, + BeginPath, + BezierCurveTo, + ClosePath, + DrawImage, + DrawingAction, + Ellipse, + Fill, + LineTo, + MoveTo, + QuadraticCurveTo, + Rect, + ResetTransform, + Rotate, + RoundRect, + Scale, + State, + Stroke, + Translate, + WriteText, +) +from toga_dummy.utils import ( + EventLog, + assert_action_not_performed, + assert_action_performed, +) + + +@pytest.mark.parametrize( + "old_name, cls", + [ + ("DrawingObject", DrawingAction), + ("Context", State), + ("FillContext", Fill), + ("StrokeContext", Stroke), + ("ClosedPathContext", ClosePath), + ], +) +def test_deprecated_class_names(old_name, cls): + """Deprecated names work, but issue a warning.""" + with pytest.warns( + DeprecationWarning, + match=rf"{old_name} has been renamed to {cls.__name__}", + ): + old_cls = getattr(canvas_module, old_name) + + assert old_cls is cls + + +def test_invalid_class_name(): + # A completely bogus name still fails. + with pytest.raises(ImportError): + from toga.widgets.canvas import Nonexistent # noqa: F401 + + +def test_renamed_root_state(widget): + with pytest.deprecated_call(): + context_property = widget.context + + assert context_property is widget.root_state + + +@pytest.mark.parametrize( + "method_name, args, DrawingActionClass", + [ + ("begin_path", (), BeginPath), + ("close_path", (), ClosePath), + ("ClosedPath", (), ClosePath), # Deprecated alias + ("ClosedPath", (0, 0), ClosePath), # Deprecated alias with removed parameters + ("move_to", (0, 0), MoveTo), + ("line_to", (0, 0), LineTo), + ("bezier_curve_to", (0, 0, 0, 0, 0, 0), BezierCurveTo), + ("quadratic_curve_to", (0, 0, 0, 0), QuadraticCurveTo), + ("arc", (0, 0, 0), Arc), + ("ellipse", (0, 0, 0, 0), Ellipse), + ("rect", (0, 0, 0, 0), Rect), + ("round_rect", (0, 0, 0, 0, 0), RoundRect), + ("fill", (), Fill), + ("Fill", (), Fill), # Deprecated alias + ("Fill", (0, 0), Fill), # Deprecated alias with removed parameters + ("stroke", (), Stroke), + ("Stroke", (), Stroke), # Deprecated alias + ("Stroke", (0, 0), Stroke), # Deprecated alias with removed parameters + ("write_text", ("",), WriteText), + ("draw_image", None, DrawImage), + ("rotate", (0,), Rotate), + ("scale", (0, 0), Scale), + ("translate", (0, 0), Translate), + ("reset_transform", (), ResetTransform), + ("state", (), State), + ("Context", (), State), # Deprecated alias + ], +) +def test_state_drawing_methods(app, widget, method_name, args, DrawingActionClass): + """State drawing methods are deprecated, but still work.""" + with widget.state() as state: + pass + + if DrawingActionClass is DrawImage: + # Can't create image from path until app fixture is loaded. + args = (toga.Image("resources/sample.png"),) + + # Add to a state that's neither active nor root, to make sure the actions are going + # to the right place. + with pytest.deprecated_call(): + drawing_action = getattr(state, method_name)(*args) + + assert state.drawing_actions == [drawing_action] + assert isinstance(drawing_action, DrawingActionClass) + assert_action_performed(widget, "redraw") + + +def test_canvas_context_method(widget): + """canvas.Context is deprecated, and appends a state to the root state.""" + + # Create a sub-state to ensure the method appends to root, not the active state. + with widget.state() as active_state: + pass + + with pytest.deprecated_call( + # match=f"The Context() drawing method has been renamed to state()" + ): + with widget.Context() as context: + pass + + assert widget.root_state.drawing_actions == [active_state, context] + + +@pytest.mark.parametrize( + "args, kwargs, xy_warning, has_move", + [ + ((), {}, False, False), + ((10, 20), {}, True, True), + ((10,), {}, True, False), + ((), {"x": 10, "y": 20}, True, True), + ((), {"x": 10}, True, False), + ((), {"y": 20}, True, False), + ], +) +@pytest.mark.parametrize( + "method_name, new_name", + [ + ("ClosedPath", "close_path"), + ("Fill", "fill"), + ("Stroke", "stroke"), + ], +) +def test_capitalized_canvas_methods_xy( + widget, args, kwargs, xy_warning, has_move, method_name, new_name +): + """Capitalized methods accepting (x, y) are deprecated, and append to root state.""" + # Create a sub-state to ensure the method appends to root, not the active state. + with widget.state() as active_state: + pass + + match = rf"The {method_name}\(\) drawing method has been renamed to {new_name}\(\)" + if xy_warning: + match += ( + r", and no longer accepts x and y coordinates as parameters\. Instead, " + rf"call move_to\(x, y\) after entering the {new_name} context\." + ) + + with pytest.deprecated_call(): + with getattr(widget, method_name)(*args, **kwargs) as state: + pass + + assert widget.root_state.drawing_actions == [active_state, state] + if has_move: + assert state.drawing_actions == [MoveTo(10, 20)] + + +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(): + widget.ClosedPath(10, 20) + + # The first and last instructions save/restore the root state, and can be ignored. + assert widget._impl.draw_instructions[1:-1] == [ + ("move to", {"x": 10, "y": 20}), + "close path", + ] + + +def test_state_canvas_reference(widget): + """Retrieving a widget's state is deprecated.""" + state = widget.root_state + + # Make another canvas, just to be sure we get the right one. + _ = toga.Canvas() + + with pytest.deprecated_call(): + assert state.canvas == widget + + +def test_state_redraw(widget): + """State.redraw() is deprecated, but still works.""" + state = widget.root_state + + # Attach it to a second canvas. + other = toga.Canvas() + with pytest.deprecated_call(): + other.root_state.append(state) + # Clear the redraw from the append + EventLog.reset() + + # Check a canvas it's *not* attached to as well. + unrelated = toga.Canvas() + + with pytest.deprecated_call(): + state.redraw() + + assert_action_performed(widget, "redraw") + assert_action_performed(other, "redraw") + assert_action_not_performed(unrelated, "redraw") + + +def test_unattached_state(widget): + """An unattached state doesn't have a canvas or redraw anything.""" + state = State() + + with pytest.deprecated_call(): + assert state.canvas is None + + with pytest.deprecated_call(): + state.redraw() + + assert_action_not_performed(widget, "redraw") + + +@pytest.mark.parametrize( + "method_name, DrawingActionClass", + [ + ("ClosedPath", ClosePath), + ("Fill", Fill), + ("Stroke", Stroke), + ("Context", State), + ], +) +def test_deprecated_canvas_methods(widget, method_name, DrawingActionClass): + """The Canvas CamelCase methods are deprecated, and add to root state.""" + with widget.state() as state: + # Test within an open sub-state, to verify it adds to root state. + with pytest.deprecated_call(): + drawing_action = getattr(widget, method_name)() + + assert widget.root_state.drawing_actions == [state, drawing_action] + assert isinstance(drawing_action, DrawingActionClass) + assert_action_performed(widget, "redraw") + + +def test_deprecated_list_methods(widget): + """List-like state methods still work, but are deprecated.""" + + # Initially nothing on the state. + with pytest.deprecated_call(): + assert len(widget.root_state) == 0 + + # Set up an inner state that has contained operations, including a sub-state + widget.line_to(0, 0) + with widget.state() as state: + widget.line_to(10, 20) + second = widget.line_to(20, 30) + with widget.fill() as fill: + widget.line_to(25, 25) + widget.line_to(30, 40) + widget.line_to(40, 50) + widget.line_to(99, 99) + + # Counts are as expected + with pytest.deprecated_call(): + assert len(widget.root_state) == 3 + with pytest.deprecated_call(): + assert len(state) == 5 + with pytest.deprecated_call(): + assert len(fill) == 1 + + # Initial draw instructions are as expected + assert widget._impl.draw_instructions == [ + "save", + ("line to", {"x": 0, "y": 0}), + "save", + ("line to", {"x": 10, "y": 20}), + ("line to", {"x": 20, "y": 30}), + # Begin fill + "save", + "begin path", + ("line to", {"x": 25, "y": 25}), + ("fill", {"fill_rule": FillRule.NONZERO}), + "restore", + # End fill + ("line to", {"x": 30, "y": 40}), + ("line to", {"x": 40, "y": 50}), + "restore", + ("line to", {"x": 99, "y": 99}), + "restore", + ] + + with pytest.deprecated_call(): + # Remove the second draw instruction + state.remove(second) + # Drawing actions are as expected + with pytest.deprecated_call(): + assert len(widget.root_state) == 3 + for i, cls in enumerate([LineTo, State, LineTo]): + with pytest.deprecated_call(): + assert isinstance(widget.root_state[i], cls) + with pytest.deprecated_call(): + with pytest.raises(IndexError): + widget.root_state[3] + + with pytest.deprecated_call(): + assert len(state) == 4 + with pytest.deprecated_call(): + assert len(fill) == 1 + + # Draw instructions no longer have the second + assert widget._impl.draw_instructions == [ + "save", + ("line to", {"x": 0, "y": 0}), + "save", + ("line to", {"x": 10, "y": 20}), + # Begin fill + "save", + "begin path", + ("line to", {"x": 25, "y": 25}), + ("fill", {"fill_rule": FillRule.NONZERO}), + "restore", + # End fill + ("line to", {"x": 30, "y": 40}), + ("line to", {"x": 40, "y": 50}), + "restore", + ("line to", {"x": 99, "y": 99}), + "restore", + ] + + with pytest.deprecated_call(): + # Insert the second draw instruction at index 3 + state.insert(3, second) + + # Counts are as expected + with pytest.deprecated_call(): + assert len(widget.root_state) == 3 + with pytest.deprecated_call(): + assert len(state) == 5 + with pytest.deprecated_call(): + assert len(fill) == 1 + + # Draw instructions show the new position + assert widget._impl.draw_instructions == [ + "save", + ("line to", {"x": 0, "y": 0}), + "save", + ("line to", {"x": 10, "y": 20}), + # Begin fill + "save", + "begin path", + ("line to", {"x": 25, "y": 25}), + ("fill", {"fill_rule": FillRule.NONZERO}), + "restore", + # End fill + ("line to", {"x": 30, "y": 40}), + ("line to", {"x": 20, "y": 30}), + ("line to", {"x": 40, "y": 50}), + "restore", + ("line to", {"x": 99, "y": 99}), + "restore", + ] + + with pytest.deprecated_call(): + # Remove the fill state + state.remove(fill) + + # Counts are as expected + with pytest.deprecated_call(): + assert len(widget.root_state) == 3 + with pytest.deprecated_call(): + assert len(state) == 4 + with pytest.deprecated_call(): + assert len(fill) == 1 + + # Draw instructions show the new position + assert widget._impl.draw_instructions == [ + "save", + ("line to", {"x": 0, "y": 0}), + "save", + ("line to", {"x": 10, "y": 20}), + ("line to", {"x": 30, "y": 40}), + ("line to", {"x": 20, "y": 30}), + ("line to", {"x": 40, "y": 50}), + "restore", + ("line to", {"x": 99, "y": 99}), + "restore", + ] + + with pytest.deprecated_call(): + # Insert the fill state at a negative index + state.insert(-1, fill) + + # Draw instructions show the new position + assert widget._impl.draw_instructions == [ + "save", + ("line to", {"x": 0, "y": 0}), + "save", + ("line to", {"x": 10, "y": 20}), + ("line to", {"x": 30, "y": 40}), + ("line to", {"x": 20, "y": 30}), + # Begin fill + "save", + "begin path", + ("line to", {"x": 25, "y": 25}), + ("fill", {"fill_rule": FillRule.NONZERO}), + "restore", + # End fill + ("line to", {"x": 40, "y": 50}), + "restore", + ("line to", {"x": 99, "y": 99}), + "restore", + ] + + with pytest.deprecated_call(): + # Clear the state + state.clear() + + # Counts are as expected + with pytest.deprecated_call(): + assert len(widget.root_state) == 3 + with pytest.deprecated_call(): + assert len(state) == 0 + + # No draw instructions other than the outer state. + assert widget._impl.draw_instructions == [ + "save", + ("line to", {"x": 0, "y": 0}), + "save", + "restore", + ("line to", {"x": 99, "y": 99}), + "restore", + ] diff --git a/core/tests/widgets/canvas/test_draw_operations.py b/core/tests/widgets/canvas/test_draw_operations.py index a648ec73cd..daa95d2824 100644 --- a/core/tests/widgets/canvas/test_draw_operations.py +++ b/core/tests/widgets/canvas/test_draw_operations.py @@ -15,7 +15,7 @@ def test_begin_path(widget): """A begin path operation can be added.""" - draw_op = widget.root_state.begin_path() + draw_op = widget.begin_path() assert_action_performed(widget, "redraw") assert repr(draw_op) == "BeginPath()" @@ -26,7 +26,7 @@ def test_begin_path(widget): def test_close_path(widget): """A close path operation can be added.""" - draw_op = widget.root_state.close_path() + draw_op = widget.close_path() assert_action_performed(widget, "redraw") assert repr(draw_op) == "ClosePath()" @@ -100,7 +100,7 @@ def test_close_path(widget): ) def test_fill(widget, kwargs, args_repr, draw_objs, attrs): """A primitive fill operation can be added.""" - draw_op = widget.root_state.fill(**kwargs) + draw_op = widget.fill(**kwargs) assert_action_performed(widget, "redraw") assert repr(draw_op) == f"Fill({args_repr})" @@ -174,7 +174,7 @@ def test_fill(widget, kwargs, args_repr, draw_objs, attrs): ) def test_stroke(widget, kwargs, args_repr, draw_objs, attrs): """A primitive stroke operation can be added.""" - draw_op = widget.root_state.stroke(**kwargs) + draw_op = widget.stroke(**kwargs) assert_action_performed(widget, "redraw") assert repr(draw_op) == f"Stroke({args_repr})" @@ -195,7 +195,7 @@ def test_stroke(widget, kwargs, args_repr, draw_objs, attrs): def test_move_to(widget): """A move to operation can be added.""" - draw_op = widget.root_state.move_to(10, 20) + draw_op = widget.move_to(10, 20) assert_action_performed(widget, "redraw") assert repr(draw_op) == "MoveTo(x=10, y=20)" @@ -212,7 +212,7 @@ def test_move_to(widget): def test_line_to(widget): """A line to operation can be added.""" - draw_op = widget.root_state.line_to(10, 20) + draw_op = widget.line_to(10, 20) assert_action_performed(widget, "redraw") assert repr(draw_op) == "LineTo(x=10, y=20)" @@ -229,7 +229,7 @@ def test_line_to(widget): def test_bezier_curve_to(widget): """A Bézier curve to operation can be added.""" - draw_op = widget.root_state.bezier_curve_to(10, 20, 30, 40, 50, 60) + draw_op = widget.bezier_curve_to(10, 20, 30, 40, 50, 60) assert_action_performed(widget, "redraw") assert ( @@ -255,7 +255,7 @@ def test_bezier_curve_to(widget): def test_quadratic_curve_to(widget): """A Quadratic curve to operation can be added.""" - draw_op = widget.root_state.quadratic_curve_to(10, 20, 30, 40) + draw_op = widget.quadratic_curve_to(10, 20, 30, 40) assert_action_performed(widget, "redraw") assert repr(draw_op) == "QuadraticCurveTo(cpx=10, cpy=20, x=30, y=40)" @@ -400,7 +400,7 @@ def test_quadratic_curve_to(widget): ) def test_arc(widget, kwargs, args_repr, draw_kwargs): """An arc operation can be added.""" - draw_op = widget.root_state.arc(**kwargs) + draw_op = widget.arc(**kwargs) assert_action_performed(widget, "redraw") assert repr(draw_op) == f"Arc({args_repr})" @@ -564,7 +564,7 @@ def test_arc(widget, kwargs, args_repr, draw_kwargs): ) def test_ellipse(widget, kwargs, args_repr, draw_kwargs): """An ellipse operation can be added.""" - draw_op = widget.root_state.ellipse(**kwargs) + draw_op = widget.ellipse(**kwargs) assert_action_performed(widget, "redraw") assert repr(draw_op) == f"Ellipse({args_repr})" @@ -581,7 +581,7 @@ def test_ellipse(widget, kwargs, args_repr, draw_kwargs): def test_rect(widget): """A rect operation can be added.""" - draw_op = widget.root_state.rect(10, 20, 30, 40) + draw_op = widget.rect(10, 20, 30, 40) assert_action_performed(widget, "redraw") assert repr(draw_op) == "Rect(x=10, y=20, width=30, height=40)" @@ -600,7 +600,7 @@ def test_rect(widget): def test_round_rect(widget): """A rect operation can be added.""" - draw_op = widget.root_state.round_rect(10, 20, 30, 40, 5) + draw_op = widget.round_rect(10, 20, 30, 40, 5) assert_action_performed(widget, "redraw") assert repr(draw_op) == "RoundRect(x=10, y=20, width=30, height=40, radii=5)" @@ -726,7 +726,7 @@ def test_round_rect(widget): ) def test_write_text(widget, kwargs, instructions, args_repr, draw_attrs): """A write text operation can be added.""" - draw_op = widget.root_state.write_text(**kwargs) + draw_op = widget.write_text(**kwargs) assert_action_performed(widget, "redraw") assert repr(draw_op) == f"WriteText({args_repr})" @@ -747,7 +747,7 @@ def test_write_text(widget, kwargs, instructions, args_repr, draw_attrs): def test_rotate(widget): """A rotate operation can be added.""" - draw_op = widget.root_state.rotate(1.234) + draw_op = widget.rotate(1.234) assert_action_performed(widget, "redraw") assert repr(draw_op) == "Rotate(radians=1.234)" @@ -763,7 +763,7 @@ def test_rotate(widget): def test_scale(widget): """A scale operation can be added.""" - draw_op = widget.root_state.scale(1.234, 2.345) + draw_op = widget.scale(1.234, 2.345) assert_action_performed(widget, "redraw") assert repr(draw_op) == "Scale(sx=1.234, sy=2.345)" @@ -780,7 +780,7 @@ def test_scale(widget): def test_translate(widget): """A translate operation can be added.""" - draw_op = widget.root_state.translate(10, 20) + draw_op = widget.translate(10, 20) assert_action_performed(widget, "redraw") assert repr(draw_op) == "Translate(tx=10, ty=20)" @@ -797,7 +797,7 @@ def test_translate(widget): def test_reset_transform(widget): """A reset transform operation can be added.""" - draw_op = widget.root_state.reset_transform() + draw_op = widget.reset_transform() assert_action_performed(widget, "redraw") assert repr(draw_op) == "ResetTransform()" @@ -850,7 +850,7 @@ def test_reset_transform(widget): def test_draw_image(app, widget, kwargs, instructions, args_repr, draw_attrs): """An image can be drawn.""" image = Image(ABSOLUTE_FILE_PATH) - draw_op = widget.root_state.draw_image(image=image, **kwargs) + draw_op = widget.draw_image(image=image, **kwargs) assert_action_performed(widget, "redraw") assert repr(draw_op) == f"DrawImage(image={image!r}, {args_repr})" @@ -877,13 +877,13 @@ def test_anticlockwise_deprecated(widget, value): ) with pytest.warns(DeprecationWarning, match=match): - widget.root_state.arc(x=0, y=0, radius=10, anticlockwise=value) + widget.arc(x=0, y=0, radius=10, anticlockwise=value) with pytest.warns(DeprecationWarning, match=match): Arc(x=0, y=0, radius=10, anticlockwise=value) with pytest.warns(DeprecationWarning, match=match): - widget.root_state.ellipse(x=0, y=0, radiusx=10, radiusy=10, anticlockwise=value) + widget.ellipse(x=0, y=0, radiusx=10, radiusy=10, anticlockwise=value) with pytest.warns(DeprecationWarning, match=match): Ellipse(x=0, y=0, radiusx=10, radiusy=10, anticlockwise=value) @@ -901,15 +901,13 @@ def test_anticlockwise_invalid(widget, anti, counter): match = r"Received both 'anticlockwise' and 'counterclockwise' arguments" with pytest.raises(TypeError, match=match): - widget.root_state.arc( - x=0, y=0, radius=10, anticlockwise=anti, counterclockwise=counter - ) + widget.arc(x=0, y=0, radius=10, anticlockwise=anti, counterclockwise=counter) with pytest.raises(TypeError, match=match): Arc(x=0, y=0, radius=10, anticlockwise=anti, counterclockwise=counter) with pytest.raises(TypeError, match=match): - widget.root_state.ellipse( + widget.ellipse( x=0, y=0, radiusx=10, diff --git a/core/tests/widgets/canvas/test_state_objects.py b/core/tests/widgets/canvas/test_state_objects.py index 7a3e191066..3074877dd7 100644 --- a/core/tests/widgets/canvas/test_state_objects.py +++ b/core/tests/widgets/canvas/test_state_objects.py @@ -3,11 +3,11 @@ from toga.colors import REBECCAPURPLE, rgb from toga.constants import FillRule from toga.widgets.canvas import ( - ClosedPathContext, - FillContext, - LineTo, + ClosePath, + Fill, + Scale, State, - StrokeContext, + Stroke, ) from toga_dummy.utils import assert_action_performed @@ -16,8 +16,8 @@ def test_sub_state(widget): """A state can produce a sub-state.""" - with widget.root_state.state() as sub_state: - sub_state.line_to(30, 40) + with widget.state() as sub_state: + widget.line_to(30, 40) # A fresh state has been created as a sub-state of the canvas. assert isinstance(sub_state, State) assert sub_state is not widget.root_state @@ -33,59 +33,23 @@ def test_sub_state(widget): ] -@pytest.mark.parametrize( - "kwargs, args_repr, has_move, properties", - [ - # Defaults - ( - {}, - "x=None, y=None", - False, - {"x": None, "y": None}, - ), - # X only - ( - {"x": 10}, - "x=10, y=None", - False, - {"x": 10, "y": None}, - ), - # Y only - ( - {"y": 20}, - "x=None, y=20", - False, - {"x": None, "y": 20}, - ), - # X and Y - ( - {"x": 10, "y": 20}, - "x=10, y=20", - True, - {"x": 10, "y": 20}, - ), - ], -) -def test_closed_path(widget, kwargs, args_repr, has_move, properties): +def test_closed_path(widget): """A state can produce a ClosedPath sub-state.""" - with widget.root_state.ClosedPath(**kwargs) as closed_path: - closed_path.line_to(30, 40) + with widget.close_path() as closed_path: + widget.line_to(30, 40) # A fresh state has been created as a sub-state of the canvas. - assert isinstance(closed_path, ClosedPathContext) - assert repr(closed_path) == f"ClosedPathContext({args_repr})" + assert isinstance(closed_path, ClosePath) + assert repr(closed_path) == "ClosePath()" assert_action_performed(widget, "redraw") - # All the attributes can be retrieved. - for attr, value in properties.items(): - assert getattr(closed_path, attr) == value + # No attributes to test. # The first and last instructions can be ignored; they're the root canvas state assert widget._impl.draw_instructions[1:-1] == [ "save", "begin path", - ] + ([("move to", {"x": 10, "y": 20})] if has_move else []) + [ ("line to", {"x": 30, "y": 40}), "close path", "restore", @@ -93,267 +57,121 @@ def test_closed_path(widget, kwargs, args_repr, has_move, properties): @pytest.mark.parametrize( - "kwargs, args_repr, has_move, properties", + "kwargs, args_repr, properties", [ # Defaults ( {}, - "x=None, y=None, color=None, fill_rule=FillRule.NONZERO", - False, - { - "x": None, - "y": None, - "color": None, - "fill_rule": FillRule.NONZERO, - }, - ), - # X only - ( - {"x": 10}, - "x=10, y=None, color=None, fill_rule=FillRule.NONZERO", - False, - {"x": 10, "y": None, "color": None, "fill_rule": FillRule.NONZERO}, - ), - # Y only - ( - {"y": 20}, - "x=None, y=20, color=None, fill_rule=FillRule.NONZERO", - False, - {"x": None, "y": 20, "color": None, "fill_rule": FillRule.NONZERO}, - ), - # X and Y - ( - {"x": 10, "y": 20}, - "x=10, y=20, color=None, fill_rule=FillRule.NONZERO", - True, - {"x": 10, "y": 20, "color": None, "fill_rule": FillRule.NONZERO}, + "color=None, fill_rule=FillRule.NONZERO", + {"color": None, "fill_rule": FillRule.NONZERO}, ), # Color ( {"color": REBECCAPURPLE}, - ( - f"x=None, y=None, color={REBECCA_PURPLE_COLOR!r}, " - "fill_rule=FillRule.NONZERO" - ), - False, - { - "x": None, - "y": None, - "color": REBECCA_PURPLE_COLOR, - "fill_rule": FillRule.NONZERO, - }, + (f"color={REBECCA_PURPLE_COLOR!r}, fill_rule=FillRule.NONZERO"), + {"color": REBECCA_PURPLE_COLOR, "fill_rule": FillRule.NONZERO}, ), # Explicitly don't set color ( {"color": None}, - "x=None, y=None, color=None, fill_rule=FillRule.NONZERO", - False, - { - "x": None, - "y": None, - "color": None, - "fill_rule": FillRule.NONZERO, - }, + "color=None, fill_rule=FillRule.NONZERO", + {"color": None, "fill_rule": FillRule.NONZERO}, ), # Fill Rule ( - {"x": None, "y": None, "fill_rule": FillRule.EVENODD}, - "x=None, y=None, color=None, fill_rule=FillRule.EVENODD", - False, - { - "x": None, - "y": None, - "color": None, - "fill_rule": FillRule.EVENODD, - }, + {"fill_rule": FillRule.EVENODD}, + "color=None, fill_rule=FillRule.EVENODD", + {"color": None, "fill_rule": FillRule.EVENODD}, ), # All args ( - {"x": 10, "y": 20, "color": REBECCAPURPLE, "fill_rule": FillRule.EVENODD}, - f"x=10, y=20, color={REBECCA_PURPLE_COLOR!r}, fill_rule=FillRule.EVENODD", - True, - { - "x": 10, - "y": 20, - "color": REBECCA_PURPLE_COLOR, - "fill_rule": FillRule.EVENODD, - }, + {"color": REBECCAPURPLE, "fill_rule": FillRule.EVENODD}, + f"color={REBECCA_PURPLE_COLOR!r}, fill_rule=FillRule.EVENODD", + {"color": REBECCA_PURPLE_COLOR, "fill_rule": FillRule.EVENODD}, ), ], ) -def test_fill(widget, kwargs, args_repr, has_move, properties): +def test_fill(widget, kwargs, args_repr, properties): """A state can produce a Fill sub-state.""" - with widget.root_state.Fill(**kwargs) as fill: - fill.line_to(30, 40) + with widget.fill(**kwargs) as fill: + widget.line_to(30, 40) # A fresh state has been created as a sub-state of the canvas. - assert isinstance(fill, FillContext) - assert repr(fill) == f"FillContext({args_repr})" + assert isinstance(fill, Fill) + assert repr(fill) == f"Fill({args_repr})" # All the attributes can be retrieved. for attr, value in properties.items(): assert getattr(fill, attr) == value - # The first and last instructions can be ignored; they're the root canvas state commands = [ "save", ("set fill style", color) if (color := properties["color"]) is not None else None, "begin path", - ("move to", {"x": properties["x"], "y": properties["y"]}) if has_move else None, ("line to", {"x": 30, "y": 40}), ("fill", {"fill_rule": properties["fill_rule"]}), "restore", ] + # The first and last instructions can be ignored; they're the root canvas state assert widget._impl.draw_instructions[1:-1] == [ command for command in commands if command is not None ] @pytest.mark.parametrize( - "kwargs, args_repr, has_move, properties", + "kwargs, args_repr, properties", [ # Defaults ( {}, - "x=None, y=None, color=None, line_width=None, line_dash=None", - False, - { - "x": None, - "y": None, - "color": None, - "line_width": None, - "line_dash": None, - }, - ), - # X only - ( - {"x": 10}, - "x=10, y=None, color=None, line_width=None, line_dash=None", - False, - { - "x": 10, - "y": None, - "color": None, - "line_width": None, - "line_dash": None, - }, - ), - # Y only - ( - {"y": 20}, - "x=None, y=20, color=None, line_width=None, line_dash=None", - False, - { - "x": None, - "y": 20, - "color": None, - "line_width": None, - "line_dash": None, - }, - ), - # X and Y - ( - {"x": 10, "y": 20}, - "x=10, y=20, color=None, line_width=None, line_dash=None", - True, - { - "x": 10, - "y": 20, - "color": None, - "line_width": None, - "line_dash": None, - }, + "color=None, line_width=None, line_dash=None", + {"color": None, "line_width": None, "line_dash": None}, ), # Color ( {"color": REBECCAPURPLE}, - ( - f"x=None, y=None, color={REBECCA_PURPLE_COLOR!r}, " - f"line_width=None, line_dash=None" - ), - False, - { - "x": None, - "y": None, - "color": REBECCA_PURPLE_COLOR, - "line_width": None, - "line_dash": None, - }, + (f"color={REBECCA_PURPLE_COLOR!r}, line_width=None, line_dash=None"), + {"color": REBECCA_PURPLE_COLOR, "line_width": None, "line_dash": None}, ), # Explicitly don't set color ( {"color": None}, - "x=None, y=None, color=None, line_width=None, line_dash=None", - False, - { - "x": None, - "y": None, - "color": None, - "line_width": None, - "line_dash": None, - }, + "color=None, line_width=None, line_dash=None", + {"color": None, "line_width": None, "line_dash": None}, ), # Line width ( - {"x": None, "y": None, "line_width": 4.5}, - "x=None, y=None, color=None, line_width=4.5, line_dash=None", - False, - { - "x": None, - "y": None, - "color": None, - "line_width": 4.5, - "line_dash": None, - }, + {"line_width": 4.5}, + "color=None, line_width=4.500, line_dash=None", + {"color": None, "line_width": 4.5, "line_dash": None}, ), # Line dash ( - {"x": None, "y": None, "line_dash": [2, 7]}, - "x=None, y=None, color=None, line_width=None, line_dash=[2, 7]", - False, { - "x": None, - "y": None, - "color": None, - "line_width": None, "line_dash": [2, 7], }, + "color=None, line_width=None, line_dash=[2, 7]", + {"color": None, "line_width": None, "line_dash": [2, 7]}, ), # All args ( - { - "x": 10, - "y": 20, - "color": REBECCAPURPLE, - "line_width": 4.5, - "line_dash": [2, 7], - }, - ( - f"x=10, y=20, color={REBECCA_PURPLE_COLOR!r}, " - f"line_width=4.5, line_dash=[2, 7]" - ), - True, - { - "x": 10, - "y": 20, - "color": REBECCA_PURPLE_COLOR, - "line_width": 4.5, - "line_dash": [2, 7], - }, + {"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]}, ), ], ) -def test_stroke(widget, kwargs, args_repr, has_move, properties): +def test_stroke(widget, kwargs, args_repr, properties): """A state can produce a Stroke sub-state.""" - with widget.root_state.Stroke(**kwargs) as stroke: - stroke.line_to(30, 40) + with widget.stroke(**kwargs) as stroke: + widget.line_to(30, 40) # A fresh state has been created as a sub-state of the canvas. - assert isinstance(stroke, StrokeContext) - assert repr(stroke) == f"StrokeContext({args_repr})" + assert isinstance(stroke, Stroke) + assert repr(stroke) == f"Stroke({args_repr})" # All the attributes can be retrieved. for attr, value in properties.items(): @@ -371,7 +189,6 @@ def test_stroke(widget, kwargs, args_repr, has_move, properties): if (line_dash := properties["line_dash"]) is not None else None, "begin path", - ("move to", {"x": properties["x"], "y": properties["y"]}) if has_move else None, ("line to", {"x": 30, "y": 40}), "stroke", "restore", @@ -383,168 +200,95 @@ def test_stroke(widget, kwargs, args_repr, has_move, properties): ] -def test_order_change(widget): - """The order of state objects can be changed.""" - # Initially nothing on the state. - assert len(widget.root_state) == 0 - - # Set up an inner state that has contained operations, including a sub-state - widget.root_state.line_to(0, 0) - with widget.root_state.state() as state: - state.line_to(10, 20) - second = state.line_to(20, 30) - with state.Fill() as fill: - fill.line_to(25, 25) - state.line_to(30, 40) - state.line_to(40, 50) - widget.root_state.line_to(99, 99) - - # Counts are as expected - assert len(widget.root_state) == 3 - assert len(state) == 5 - assert len(fill) == 1 - - # Initial draw instructions are as expected - assert widget._impl.draw_instructions == [ - "save", - ("line to", {"x": 0, "y": 0}), - "save", - ("line to", {"x": 10, "y": 20}), - ("line to", {"x": 20, "y": 30}), - # Begin fill - "save", - "begin path", - ("line to", {"x": 25, "y": 25}), - ("fill", {"fill_rule": FillRule.NONZERO}), - "restore", - # End fill - ("line to", {"x": 30, "y": 40}), - ("line to", {"x": 40, "y": 50}), - "restore", - ("line to", {"x": 99, "y": 99}), - "restore", - ] +def assert_contents(container, contains: list, doesnt_contain: list): + """Assert that the container contains (and doesn't contain) specified objects.""" + for item in contains: + assert item in container + + for item in doesnt_contain: + assert item not in container + + +def test_contains(widget): + """Whether a drawing action is in a state can be tested.""" + with widget.stroke() as stroke: + reset_transform = widget.reset_transform() + with widget.fill() as fill: + line_to = widget.line_to(0, 0) + close_path = widget.close_path() + + scale = Scale(1, 1) + + # Assign a couple of shorthands for testing + root = widget.root_state + everything = [root, stroke, reset_transform, fill, line_to, close_path, scale] + + assert_contents( + widget.root_state, + contains=[stroke, reset_transform, fill, line_to, close_path], + doesnt_contain=[root, scale], + ) + + assert_contents( + stroke, + contains=[reset_transform, fill, line_to, close_path], + doesnt_contain=[root, stroke, scale], + ) + + assert_contents( + reset_transform, + contains=[], + doesnt_contain=everything, + ) + + assert_contents( + fill, + contains=[line_to], + doesnt_contain=[root, stroke, reset_transform, fill, close_path, scale], + ) + + assert_contents( + close_path, + contains=[], + doesnt_contain=everything, + ) + + assert_contents( + scale, + contains=[], + doesnt_contain=everything, + ) + + +NON_REENTRANT_MATCH = ( + r"A drawing context manager can only be entered once, and only before any " + r"subsequent drawing actions are added\." +) - # Remove the second draw instruction - state.remove(second) - # Drawing objects are as expected - assert len(widget.root_state) == 3 - for i, cls in enumerate([LineTo, State, LineTo]): - assert isinstance(widget.root_state[i], cls) - with pytest.raises(IndexError): - widget.root_state[3] +def test_enter_open_context(widget): + """Attempting to enter a currently open context is an error.""" + with widget.stroke() as stroke: + with pytest.raises(RuntimeError, match=NON_REENTRANT_MATCH): + with stroke: + pass - assert len(state) == 4 - assert len(fill) == 1 - # Draw instructions no longer have the second - assert widget._impl.draw_instructions == [ - "save", - ("line to", {"x": 0, "y": 0}), - "save", - ("line to", {"x": 10, "y": 20}), - # Begin fill - "save", - "begin path", - ("line to", {"x": 25, "y": 25}), - ("fill", {"fill_rule": FillRule.NONZERO}), - "restore", - # End fill - ("line to", {"x": 30, "y": 40}), - ("line to", {"x": 40, "y": 50}), - "restore", - ("line to", {"x": 99, "y": 99}), - "restore", - ] +def test_enter_closed_context(widget): + """Attempting to enter a previously open (now closed) context is an error.""" + with widget.stroke() as stroke: + pass - # Insert the second draw instruction at index 3 - state.insert(3, second) + with pytest.raises(RuntimeError, match=NON_REENTRANT_MATCH): + with stroke: + pass - # Counts are as expected - assert len(widget.root_state) == 3 - assert len(state) == 5 - assert len(fill) == 1 - # Draw instructions show the new position - assert widget._impl.draw_instructions == [ - "save", - ("line to", {"x": 0, "y": 0}), - "save", - ("line to", {"x": 10, "y": 20}), - # Begin fill - "save", - "begin path", - ("line to", {"x": 25, "y": 25}), - ("fill", {"fill_rule": FillRule.NONZERO}), - "restore", - # End fill - ("line to", {"x": 30, "y": 40}), - ("line to", {"x": 20, "y": 30}), - ("line to", {"x": 40, "y": 50}), - "restore", - ("line to", {"x": 99, "y": 99}), - "restore", - ] - - # Remove the fill state - state.remove(fill) - - # Counts are as expected - assert len(widget.root_state) == 3 - assert len(state) == 4 - assert len(fill) == 1 - - # Draw instructions show the new position - assert widget._impl.draw_instructions == [ - "save", - ("line to", {"x": 0, "y": 0}), - "save", - ("line to", {"x": 10, "y": 20}), - ("line to", {"x": 30, "y": 40}), - ("line to", {"x": 20, "y": 30}), - ("line to", {"x": 40, "y": 50}), - "restore", - ("line to", {"x": 99, "y": 99}), - "restore", - ] - - # Insert the fill state at a negative index - state.insert(-1, fill) - # Draw instructions show the new position - assert widget._impl.draw_instructions == [ - "save", - ("line to", {"x": 0, "y": 0}), - "save", - ("line to", {"x": 10, "y": 20}), - ("line to", {"x": 30, "y": 40}), - ("line to", {"x": 20, "y": 30}), - # Begin fill - "save", - "begin path", - ("line to", {"x": 25, "y": 25}), - ("fill", {"fill_rule": FillRule.NONZERO}), - "restore", - # End fill - ("line to", {"x": 40, "y": 50}), - "restore", - ("line to", {"x": 99, "y": 99}), - "restore", - ] +def test_enter_context_out_of_order(widget): + """Attempting to enter a context manager after making other actions is an error.""" + stroke = widget.stroke() + widget.rect(0, 0, 0, 0) - # Clear the state - state.clear() - - # Counts are as expected - assert len(widget.root_state) == 3 - assert len(state) == 0 - - # No draw instructions other than the outer state. - assert widget._impl.draw_instructions == [ - "save", - ("line to", {"x": 0, "y": 0}), - "save", - "restore", - ("line to", {"x": 99, "y": 99}), - "restore", - ] + with pytest.raises(RuntimeError, match=NON_REENTRANT_MATCH): + with stroke: + pass diff --git a/docs/en/reference/api/resources/font.md b/docs/en/reference/api/resources/font.md index b16d4c501c..574fa1dbdc 100644 --- a/docs/en/reference/api/resources/font.md +++ b/docs/en/reference/api/resources/font.md @@ -43,7 +43,7 @@ Font.register("Bahnschrift", "resources/Bahnschrift.ttf") Font.register("Bahnschrift", "resources/Bahnschrift.ttf", weight=BOLD) ``` -A small number of Toga APIs (e.g., [`State.write_text`][toga.widgets.canvas.State.write_text]) *do* require the use of [`Font`][toga.Font] instance. In these cases, you can instantiate a Font using similar properties to the ones used for widget styling: +A small number of Toga APIs (e.g., [`Canvas.write_text`][toga.Canvas.write_text]) *do* require the use of [`Font`][toga.Font] instance. In these cases, you can instantiate a Font using similar properties to the ones used for widget styling: ```python import toga @@ -54,7 +54,7 @@ my_font = toga.Font(SERIF, 14, weight=BOLD) # Use the font to write on a canvas. canvas = toga.Canvas() -canvas.root_tate.write_text("Hello", font=my_font) +canvas.write_text("Hello", font=my_font) ``` When constructing your own [`Font`][toga.Font] instance, ensure that the font family you provide is valid; otherwise an [`UnknownFontError`][toga.fonts.UnknownFontError] will be raised. diff --git a/docs/en/reference/api/widgets/canvas.md b/docs/en/reference/api/widgets/canvas.md index b72338a74d..454e3bf338 100644 --- a/docs/en/reference/api/widgets/canvas.md +++ b/docs/en/reference/api/widgets/canvas.md @@ -2,19 +2,18 @@ ## Usage -Canvas is a 2D vector graphics drawing area, whose API broadly follows the [HTML5 Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API). The Canvas provides a drawing `State`; drawing instructions are then added to that state by calling methods on the state. All positions and sizes are measured in [CSS pixels][css-units]. +Canvas is a 2D vector graphics drawing area, whose API broadly follows the [HTML5 Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API). Drawing methods are called directly on the Canvas. All positions and sizes are measured in [CSS pixels][css-units]. For example, the following code will draw an orange horizontal line: ```python import toga canvas = toga.Canvas() -state = canvas.root_state -state.begin_path() -state.move_to(20, 20) -state.line_to(160, 20) -state.stroke(color="orange") +canvas.begin_path() +canvas.move_to(20, 20) +canvas.line_to(160, 20) +canvas.stroke(color="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: @@ -23,39 +22,40 @@ Toga adds an additional layer of convenience to the base HTML5 API by providing import toga canvas = toga.Canvas() -with canvas.state.Stroke(20, 20, color="orange") as stroke: - stroke.line_to(160, 20) +with canvas.stroke(color="orange", 20, 20): + canvas.line_to(160, 20) ``` -Any argument provided to a drawing operation or state object becomes a property of that object. Those properties can be modified after creation, after which you should invoke [`Canvas.redraw`][toga.Canvas.redraw] to request a redraw of the canvas. +Internally, each drawing method creates a [`DrawingAction`][toga.widgets.canvas.DrawingAction] and stores it, building up a list of drawing instructions. Any argument provided to a drawing operation (including context managers) becomes a property of that `DrawingAction`. Those properties can be modified after creation, after which you should invoke [`Canvas.redraw`][toga.Canvas.redraw] to request a redraw of the canvas. -Drawing operations can also be added to or removed from a state using the `list` operations `append`, `insert`, `remove` and `clear`. In this case, [`Canvas.redraw`][toga.Canvas.redraw] will be called automatically. +The `DrawingAction`s that can double as context managers are all subclasses of [`State`][toga.widgets.canvas.State]. A state stores a list of its associated drawing instructions (those called within its context) as an attribute named [`drawing_actions`][toga.widgets.canvas.State.drawing_actions]. This can be modified like any other list (`append`, `insert`, `remove`, `clear`, etc.). As with modifying attributes, [`Canvas.redraw`][toga.Canvas.redraw] will need to be called to show the changes. For example, if you were drawing a bar chart where the height of the bars changed over time, you don't need to completely reset the canvas and redraw all the objects; you can use the same objects, only modifying the height of existing bars, or adding and removing bars as required. -In this example, we create 2 filled drawing objects, then manipulate those objects, requesting a redraw after each set of changes. +In this example, we create 2 filled drawing actions, then manipulate those objects, requesting a redraw after each set of changes. ```python import toga canvas = toga.Canvas() -with canvas.root_state.Fill(color="red") as fill: - circle = fill.arc(x=50, y=50, radius=15) - rect = fill.rect(x=50, y=50, width=15, height=15) +with canvas.fill(color="red") as fill: + circle = canvas.arc(x=50, y=50, radius=15) + rect = canvas.rect(x=50, y=50, width=15, height=15) -# We can then change the properties of the drawing objects. +# We can then change the properties of the drawing actions. # Make the circle smaller, and move it closer to the origin. circle.x = 25 circle.y = 25 circle.radius = 5 -canvas.redraw() # Change the fill color to blue fill.color = "blue" -canvas.redraw() # Remove the rectangle from the canvas -fill.remove(rect) +fill.drawing_actions.remove(rect) + +# Display the changes +canvas.redraw() ``` For detailed tutorials on the use of Canvas drawing instructions, see the MDN documentation for the [HTML5 Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API). Other than the change in naming conventions for methods - the HTML5 API uses `lowerCamelCase`, whereas the Toga API uses `snake_case` - both APIs are very similar. @@ -69,19 +69,13 @@ For detailed tutorials on the use of Canvas drawing instructions, see the MDN do ## Reference - + ::: toga.Canvas options: + inherited_members: True members: - - ClosedPath - - State - - Fill - - Stroke - - as_image - - root_state + # Attributes; no way *not* to list them first - enabled - - focus - - measure_text - on_activate - on_alt_drag - on_alt_press @@ -90,22 +84,40 @@ For detailed tutorials on the use of Canvas drawing instructions, see the MDN do - on_press - on_release - on_resize + - root_state + # Drawing methods + - begin_path + - close_path + - move_to + - line_to + - bezier_curve_to + - quadratic_curve_to + - arc + - ellipse + - rect + - fill + - stroke + - write_text + - draw_image + - rotate + - scale + - translate + - reset_transform + - state + # Other methods - redraw + - measure_text + - as_image + - focus ::: toga.widgets.canvas.State options: inherited_members: True - + ::: toga.widgets.canvas.DrawingAction -::: toga.widgets.canvas.ClosedPathContext - -::: toga.widgets.canvas.FillContext - -::: toga.widgets.canvas.StrokeContext - ::: toga.widgets.canvas.OnTouchHandler ::: toga.widgets.canvas.OnResizeHandler diff --git a/docs/en/tutorial/tutorial-4.md b/docs/en/tutorial/tutorial-4.md index 8536c1b13f..bf1c7718d4 100644 --- a/docs/en/tutorial/tutorial-4.md +++ b/docs/en/tutorial/tutorial-4.md @@ -2,7 +2,7 @@ One of the main capabilities needed to create many types of GUI applications is the ability to draw and manipulate lines, shapes, text, and other graphics. To do this in Toga, we use the Canvas Widget. -Utilizing the Canvas is as easy as determining the drawing operations you want to perform and then creating a new Canvas. All drawing objects that are created with one of the drawing operations are returned so that they can be modified or removed. +Utilizing the Canvas is as easy as determining the drawing operations you want to perform and then creating a new Canvas. All drawing actions that are created with one of the drawing operations are returned so that they can be modified or removed. 1. We first define the drawing operations we want to perform in a new function: diff --git a/examples/canvas/canvas/app.py b/examples/canvas/canvas/app.py index 03845844a4..c7a06bc833 100644 --- a/examples/canvas/canvas/app.py +++ b/examples/canvas/canvas/app.py @@ -437,39 +437,40 @@ def refresh_canvas(self, widget, **kwargs): self.render_drawing() def render_drawing(self): - self.canvas.root_state.clear() - self.canvas.root_state.translate( + self.canvas.root_state.drawing_actions.clear() + self.canvas.translate( self.width / 2 + self.x_translation, self.height / 2 + self.y_translation, ) - self.canvas.root_state.rotate(self.rotation) - self.canvas.root_state.scale( + self.canvas.rotate(self.rotation) + self.canvas.scale( self.scale_x_slider.value, self.scale_y_slider.value, ) - self.canvas.root_state.translate(-self.width / 2, -self.height / 2) - with self.get_state() as state: - self.draw_shape(state) - self.canvas.root_state.reset_transform() + self.canvas.translate(-self.width / 2, -self.height / 2) + with self.get_state(): + self.draw_shape() + self.canvas.reset_transform() - def draw_shape(self, state): + def draw_shape(self): # Scale to the smallest axis to maintain aspect ratio factor = min(self.width, self.height) drawing_instructions = self.drawing_shape_instructions.get( self.shape_selection.value, None ) if drawing_instructions is not None: - drawing_instructions(state, factor) + drawing_instructions(factor) - def draw_triangle(self, state, factor): + def draw_triangle(self, factor): # calculate offsets to centralize drawing in the bigger axis dx = self.x_middle - factor / 2 dy = self.y_middle - factor / 2 - with state.ClosedPath(dx + factor / 3, dy + factor / 3) as closer: - closer.line_to(dx + 2 * factor / 3, dy + 2 * factor / 3) - closer.line_to(dx + 2 * factor / 3, dy + factor / 3) + with self.canvas.close_path(): + self.canvas.move_to(dx + factor / 3, dy + factor / 3) + self.canvas.line_to(dx + 2 * factor / 3, dy + 2 * factor / 3) + self.canvas.line_to(dx + 2 * factor / 3, dy + factor / 3) - def draw_triangles(self, state, factor): + def draw_triangles(self, factor): # calculate offsets to centralize drawing in the bigger axis triangle_size = factor / 5 gap = factor / 12 @@ -479,54 +480,58 @@ def draw_triangles(self, state, factor): (gap, -2 * triangle_size), (-triangle_size, -triangle_size + gap), ]: - with state.state() as triangle: - triangle.translate(self.x_middle + x, self.y_middle + y) - triangle.move_to(0, 0) - triangle.line_to(2 * triangle_size, 0) - triangle.line_to(triangle_size, triangle_size) - triangle.line_to(0, 0) - - def draw_rectangle(self, state, factor): - state.rect( + with self.canvas.state(): + self.canvas.translate(self.x_middle + x, self.y_middle + y) + self.canvas.move_to(0, 0) + self.canvas.line_to(2 * triangle_size, 0) + self.canvas.line_to(triangle_size, triangle_size) + self.canvas.line_to(0, 0) + + def draw_rectangle(self, factor): + self.canvas.rect( self.x_middle - factor / 3, self.y_middle - factor / 6, 2 * factor / 3, factor / 3, ) - def draw_ellipse(self, state, factor): + def draw_ellipse(self, factor): rx = factor / 3 ry = factor / 4 - state.ellipse(self.width / 2, self.height / 2, rx, ry) + self.canvas.ellipse(self.width / 2, self.height / 2, rx, ry) - def draw_half_ellipse(self, state, factor): + def draw_half_ellipse(self, factor): rx = factor / 3 ry = factor / 4 - with state.ClosedPath(self.x_middle + rx, self.y_middle) as closer: - closer.ellipse(self.x_middle, self.y_middle, rx, ry, 0, 0, math.pi) + with self.canvas.close_path(): + self.canvas.move_to(self.x_middle + rx, self.y_middle) + self.canvas.ellipse(self.x_middle, self.y_middle, rx, ry, 0, 0, math.pi) - def draw_ice_cream(self, state, factor): + def draw_ice_cream(self, factor): dx = self.x_middle dy = self.y_middle - factor / 6 - with state.ClosedPath(dx - factor / 5, dy) as closer: - closer.arc(dx, dy, factor / 5, math.pi, 2 * math.pi) - closer.line_to(dx, dy + 2 * factor / 5) + with self.canvas.close_path(): + self.canvas.move_to(dx - factor / 5, dy) + self.canvas.arc(dx, dy, factor / 5, math.pi, 2 * math.pi) + self.canvas.line_to(dx, dy + 2 * factor / 5) - def draw_smile(self, state, factor): + def draw_smile(self, factor): dx = self.x_middle dy = self.y_middle - factor / 5 - with state.ClosedPath(dx - factor / 5, dy) as closer: - closer.quadratic_curve_to(dx, dy + 3 * factor / 5, dx + factor / 5, dy) - closer.quadratic_curve_to(dx, dy + factor / 5, dx - factor / 5, dy) - - def draw_sea(self, state, factor): - with state.ClosedPath( - self.x_middle - 1 * factor / 5, - self.y_middle - 1 * factor / 5, - ) as closer: - closer.bezier_curve_to( + with self.canvas.close_path(): + self.canvas.move_to(dx - factor / 5, dy) + self.canvas.quadratic_curve_to(dx, dy + 3 * factor / 5, dx + factor / 5, dy) + self.canvas.quadratic_curve_to(dx, dy + factor / 5, dx - factor / 5, dy) + + def draw_sea(self, factor): + with self.canvas.close_path(): + self.canvas.move_to( + self.x_middle - 1 * factor / 5, + self.y_middle - 1 * factor / 5, + ) + self.canvas.bezier_curve_to( self.x_middle - 1 * factor / 10, self.y_middle, self.x_middle + 1 * factor / 10, @@ -534,35 +539,35 @@ def draw_sea(self, state, factor): self.x_middle + 1 * factor / 5, self.y_middle - 1 * factor / 5, ) - closer.line_to( + self.canvas.line_to( self.x_middle + 1 * factor / 5, self.y_middle + 1 * factor / 5, ) - closer.line_to( + self.canvas.line_to( self.x_middle - 1 * factor / 5, self.y_middle + 1 * factor / 5, ) - def draw_star(self, state, factor): + def draw_star(self, factor): sides = 5 radius = factor / 5 rotation_angle = 4 * math.pi / sides - with state.ClosedPath(self.x_middle, self.y_middle - radius) as closer: + with self.canvas.close_path(): + self.canvas.move_to(self.x_middle, self.y_middle - radius) for i in range(1, sides): - closer.line_to( + self.canvas.line_to( self.x_middle + radius * math.sin(i * rotation_angle), self.y_middle - radius * math.cos(i * rotation_angle), ) - def draw_image(self, state, factor): - with state.state() as ctx: - ctx.draw_image( - self.image, - self.x_middle - self.image.width / 2, - self.y_middle - self.image.height / 2, - ) + def draw_image(self, factor): + self.canvas.draw_image( + self.image, + self.x_middle - self.image.width / 2, + self.y_middle - self.image.height / 2, + ) - def draw_instructions(self, state, factor): + def draw_instructions(self, factor): text = ( "Instructions:\n" "1. Use the controls to modify the image\n" @@ -580,7 +585,7 @@ def draw_instructions(self, state, factor): width, height = self.canvas.measure_text( text, font, self.line_height_slider.value ) - state.write_text( + self.canvas.write_text( text, self.x_middle - width / 2, self.y_middle, @@ -588,6 +593,7 @@ def draw_instructions(self, state, factor): Baseline.MIDDLE, self.line_height_slider.value, ) + self.canvas.redraw() def get_weight(self): return BOLD if self.bold_switch.value else NORMAL @@ -597,12 +603,12 @@ def get_style(self): def get_state(self): if self.state_selection.value == STROKE: - return self.canvas.Stroke( + return self.canvas.stroke( color=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( + return self.canvas.fill( color=self.color_selection.value, fill_rule=FillRule[self.fill_rule_selection.value], ) diff --git a/examples/tutorial4/tutorial/app.py b/examples/tutorial4/tutorial/app.py index 66cc3be083..640b4142db 100644 --- a/examples/tutorial4/tutorial/app.py +++ b/examples/tutorial4/tutorial/app.py @@ -28,70 +28,63 @@ def startup(self): self.main_window.show() def fill_head(self): - with self.canvas.Fill(color=rgb(149, 119, 73)) as head_filler: - head_filler.move_to(112, 103) - head_filler.line_to(112, 113) - head_filler.ellipse(73, 114, 39, 47, 0, 0, math.pi) - head_filler.line_to(35, 84) - head_filler.arc(65, 84, 30, math.pi, 3 * math.pi / 2) - head_filler.arc(82, 84, 30, 3 * math.pi / 2, 2 * math.pi) + with self.canvas.fill(color=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) + self.canvas.line_to(35, 84) + self.canvas.arc(65, 84, 30, math.pi, 3 * math.pi / 2) + self.canvas.arc(82, 84, 30, 3 * math.pi / 2, 2 * math.pi) def stroke_head(self): - with self.canvas.Stroke(line_width=4.0) as head_outline: - with head_outline.ClosedPath(112, 103) as closed_head: - closed_head.line_to(112, 113) - closed_head.ellipse(73, 114, 39, 47, 0, 0, math.pi) - closed_head.line_to(35, 84) - closed_head.arc(65, 84, 30, math.pi, 3 * math.pi / 2) - closed_head.arc(82, 84, 30, 3 * math.pi / 2, 2 * math.pi) + with self.canvas.stroke(line_width=4.0): + with self.canvas.close_path(): + self.canvas.move_to(112, 103) + self.canvas.line_to(112, 113) + self.canvas.ellipse(73, 114, 39, 47, 0, 0, math.pi) + self.canvas.line_to(35, 84) + self.canvas.arc(65, 84, 30, math.pi, 3 * math.pi / 2) + self.canvas.arc(82, 84, 30, 3 * math.pi / 2, 2 * math.pi) def draw_eyes(self): - with self.canvas.Fill(color=WHITE) as eye_whites: - eye_whites.arc(58, 92, 15) - eye_whites.arc(88, 92, 15, math.pi, 3 * math.pi) + with self.canvas.fill(color=WHITE): + self.canvas.arc(58, 92, 15) + self.canvas.arc(88, 92, 15, math.pi, 3 * math.pi) # Draw eyes separately to avoid miter join - with self.canvas.Stroke(line_width=4.0) as eye_outline: - eye_outline.arc(58, 92, 15) - with self.canvas.Stroke(line_width=4.0) as eye_outline: - eye_outline.arc(88, 92, 15, math.pi, 3 * math.pi) + with self.canvas.stroke(line_width=4.0): + self.canvas.arc(58, 92, 15) + with self.canvas.stroke(line_width=4.0): + self.canvas.arc(88, 92, 15, math.pi, 3 * math.pi) - with self.canvas.Fill() as eye_pupils: - eye_pupils.arc(58, 97, 3) - eye_pupils.arc(88, 97, 3) + with self.canvas.fill(): + self.canvas.arc(58, 97, 3) + self.canvas.arc(88, 97, 3) def draw_horns(self): - with self.canvas.root_state.state() as r_horn: - with r_horn.Fill(color=rgb(212, 212, 212)) as r_horn_filler: - r_horn_filler.move_to(112, 99) - r_horn_filler.quadratic_curve_to(145, 65, 139, 36) - r_horn_filler.quadratic_curve_to(130, 60, 109, 75) - with r_horn.Stroke(line_width=4.0) as r_horn_stroker: - r_horn_stroker.move_to(112, 99) - r_horn_stroker.quadratic_curve_to(145, 65, 139, 36) - r_horn_stroker.quadratic_curve_to(130, 60, 109, 75) - - with self.canvas.root_state.state() as l_horn: - with l_horn.Fill(color=rgb(212, 212, 212)) as l_horn_filler: - l_horn_filler.move_to(35, 99) - l_horn_filler.quadratic_curve_to(2, 65, 6, 36) - l_horn_filler.quadratic_curve_to(17, 60, 37, 75) - with l_horn.Stroke(line_width=4.0) as l_horn_stroker: - l_horn_stroker.move_to(35, 99) - l_horn_stroker.quadratic_curve_to(2, 65, 6, 36) - l_horn_stroker.quadratic_curve_to(17, 60, 37, 75) + with self.canvas.stroke(line_width=4.0): + with self.canvas.fill(color=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)): + 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)) as nose_filler: - nose_filler.move_to(45, 145) - nose_filler.bezier_curve_to(51, 123, 96, 123, 102, 145) - nose_filler.ellipse(73, 114, 39, 47, 0, math.pi / 4, 3 * math.pi / 4) - with self.canvas.Fill() as nostril_filler: - nostril_filler.arc(63, 140, 3) - nostril_filler.arc(83, 140, 3) - with self.canvas.Stroke(line_width=4.0) as nose_stroker: - nose_stroker.move_to(45, 145) - nose_stroker.bezier_curve_to(51, 123, 96, 123, 102, 145) + with self.canvas.fill(color=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) + with self.canvas.fill(): + self.canvas.arc(63, 140, 3) + self.canvas.arc(83, 140, 3) + with self.canvas.stroke(line_width=4.0): + self.canvas.move_to(45, 145) + self.canvas.bezier_curve_to(51, 123, 96, 123, 102, 145) def draw_text(self): font = toga.Font(family=SANS_SERIF, size=20) @@ -100,15 +93,15 @@ def draw_text(self): x = (150 - self.text_width) // 2 y = 175 - with self.canvas.Stroke(color="REBECCAPURPLE", line_width=4.0) as rect_stroker: - self.text_border = rect_stroker.rect( + with self.canvas.stroke(color="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)) as text_filler: - self.text = text_filler.write_text("Tiberius", x, y, font, Baseline.TOP) + with self.canvas.fill(color=rgb(149, 119, 73)): + self.text = self.canvas.write_text("Tiberius", x, y, font, Baseline.TOP) def draw_tiberius(self): self.fill_head() @@ -120,10 +113,9 @@ def draw_tiberius(self): def on_resize(self, widget, width, height, **kwargs): # On resize, center the text horizontally on the canvas. on_resize will be - # called when the canvas is initially created, when the drawing objects won't - # exist yet. Only attempt to reposition the text if there's a state object on - # the canvas. - if widget.root_state: + # called when the canvas is initially created, when the drawing actions won't + # exist yet. Only attempt to reposition the text if it's already been drawn. + if hasattr(self, "text"): left_pad = (width - self.text_width) // 2 self.text.x = left_pad self.text_border.x = left_pad - 5 diff --git a/gtk/tests_backend/widgets/canvas.py b/gtk/tests_backend/widgets/canvas.py index 7d1cd8ae5f..b55fced9af 100644 --- a/gtk/tests_backend/widgets/canvas.py +++ b/gtk/tests_backend/widgets/canvas.py @@ -16,7 +16,7 @@ def reference_variant(self, reference): return f"{reference}-gtk-wayland" else: return f"{reference}-gtk-x11" - elif reference in {"write_text", "write_text_and_path"}: + elif reference in {"write_text", "write_text_and_path", "deprecated_tutorial"}: return f"{reference}-gtk" else: return reference diff --git a/iOS/tests_backend/widgets/canvas.py b/iOS/tests_backend/widgets/canvas.py index b6167fa974..c82210ff51 100644 --- a/iOS/tests_backend/widgets/canvas.py +++ b/iOS/tests_backend/widgets/canvas.py @@ -25,7 +25,12 @@ class CanvasProbe(SimpleProbe): def reference_variant(self, reference): # System fonts and sizes are platform specific - if reference in {"multiline_text", "write_text", "write_text_and_path"}: + if reference in { + "multiline_text", + "write_text", + "write_text_and_path", + "deprecated_tutorial", + }: return f"{reference}-iOS" else: return reference diff --git a/qt/tests_backend/widgets/canvas.py b/qt/tests_backend/widgets/canvas.py index 5810db7a68..36ebd4f3d9 100644 --- a/qt/tests_backend/widgets/canvas.py +++ b/qt/tests_backend/widgets/canvas.py @@ -16,6 +16,7 @@ def reference_variant(self, reference): "multiline_text", "write_text", "write_text_and_path", + "deprecated_tutorial", "miter_join", }: return f"{reference}-qt" diff --git a/testbed/src/testbed/resources/canvas/deprecated_tutorial-android.png b/testbed/src/testbed/resources/canvas/deprecated_tutorial-android.png new file mode 100644 index 0000000000..9744b371fa Binary files /dev/null and b/testbed/src/testbed/resources/canvas/deprecated_tutorial-android.png differ diff --git a/testbed/src/testbed/resources/canvas/deprecated_tutorial-gtk.png b/testbed/src/testbed/resources/canvas/deprecated_tutorial-gtk.png new file mode 100644 index 0000000000..96dc274055 Binary files /dev/null and b/testbed/src/testbed/resources/canvas/deprecated_tutorial-gtk.png differ diff --git a/testbed/src/testbed/resources/canvas/deprecated_tutorial-iOS.png b/testbed/src/testbed/resources/canvas/deprecated_tutorial-iOS.png new file mode 100644 index 0000000000..ef047297d3 Binary files /dev/null and b/testbed/src/testbed/resources/canvas/deprecated_tutorial-iOS.png differ diff --git a/testbed/src/testbed/resources/canvas/deprecated_tutorial-macOS.png b/testbed/src/testbed/resources/canvas/deprecated_tutorial-macOS.png new file mode 100644 index 0000000000..cc156fe3a4 Binary files /dev/null and b/testbed/src/testbed/resources/canvas/deprecated_tutorial-macOS.png differ diff --git a/testbed/src/testbed/resources/canvas/deprecated_tutorial-qt.png b/testbed/src/testbed/resources/canvas/deprecated_tutorial-qt.png new file mode 100644 index 0000000000..5b9951ffcf Binary files /dev/null and b/testbed/src/testbed/resources/canvas/deprecated_tutorial-qt.png differ diff --git a/testbed/src/testbed/resources/canvas/deprecated_tutorial-winforms.png b/testbed/src/testbed/resources/canvas/deprecated_tutorial-winforms.png new file mode 100644 index 0000000000..dbaaaaa3ce Binary files /dev/null and b/testbed/src/testbed/resources/canvas/deprecated_tutorial-winforms.png differ diff --git a/testbed/tests/widgets/canvas/__init__.py b/testbed/tests/widgets/canvas/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testbed/tests/widgets/canvas/conftest.py b/testbed/tests/widgets/canvas/conftest.py new file mode 100644 index 0000000000..9ad843ff64 --- /dev/null +++ b/testbed/tests/widgets/canvas/conftest.py @@ -0,0 +1,79 @@ +from unittest.mock import Mock + +import pytest + +import toga +from toga.colors import WHITE + + +@pytest.fixture +def on_resize_handler(): + return Mock() + + +@pytest.fixture +def on_press_handler(): + return Mock() + + +@pytest.fixture +def on_drag_handler(): + return Mock() + + +@pytest.fixture +def on_release_handler(): + return Mock() + + +@pytest.fixture +def on_activate_handler(): + return Mock() + + +@pytest.fixture +def on_alt_press_handler(): + return Mock() + + +@pytest.fixture +def on_alt_drag_handler(): + return Mock() + + +@pytest.fixture +def on_alt_release_handler(): + return Mock() + + +@pytest.fixture +async def widget( + on_resize_handler, + on_press_handler, + on_activate_handler, + on_release_handler, + on_drag_handler, + on_alt_press_handler, + on_alt_release_handler, + on_alt_drag_handler, +): + return toga.Canvas( + on_resize=on_resize_handler, + on_press=on_press_handler, + on_activate=on_activate_handler, + on_release=on_release_handler, + on_drag=on_drag_handler, + on_alt_press=on_alt_press_handler, + on_alt_release=on_alt_release_handler, + on_alt_drag=on_alt_drag_handler, + flex=1, + ) + + +@pytest.fixture +async def canvas(widget, probe, on_resize_handler): + # Modify the base canvas fixture to make it more useful for drawing tests. + widget.style.background_color = WHITE + widget.style.width = 200 + widget.style.height = 200 + return widget diff --git a/testbed/tests/widgets/test_canvas.py b/testbed/tests/widgets/canvas/test_canvas.py similarity index 58% rename from testbed/tests/widgets/test_canvas.py rename to testbed/tests/widgets/canvas/test_canvas.py index f1f11e6ef2..bce6c4150a 100644 --- a/testbed/tests/widgets/test_canvas.py +++ b/testbed/tests/widgets/canvas/test_canvas.py @@ -2,7 +2,7 @@ import os from itertools import chain from math import pi, radians, tan -from unittest.mock import Mock, call +from unittest.mock import call import pytest from PIL import Image, ImageChops @@ -16,16 +16,15 @@ REBECCAPURPLE, RED, TRANSPARENT, - WHITE, rgb, ) from toga.constants import Baseline, FillRule from toga.fonts import BOLD from toga.images import Image as TogaImage -from toga.style.pack import SYSTEM, Pack +from toga.style.pack import SYSTEM -from .conftest import build_cleanup_test -from .properties import ( # noqa: F401 +from ..conftest import build_cleanup_test +from ..properties import ( # noqa: F401 test_background_color, test_background_color_reset, test_background_color_transparent, @@ -34,80 +33,6 @@ test_focus_noop, ) - -@pytest.fixture -def on_resize_handler(): - return Mock() - - -@pytest.fixture -def on_press_handler(): - return Mock() - - -@pytest.fixture -def on_drag_handler(): - return Mock() - - -@pytest.fixture -def on_release_handler(): - return Mock() - - -@pytest.fixture -def on_activate_handler(): - return Mock() - - -@pytest.fixture -def on_alt_press_handler(): - return Mock() - - -@pytest.fixture -def on_alt_drag_handler(): - return Mock() - - -@pytest.fixture -def on_alt_release_handler(): - return Mock() - - -@pytest.fixture -async def widget( - on_resize_handler, - on_press_handler, - on_activate_handler, - on_release_handler, - on_drag_handler, - on_alt_press_handler, - on_alt_release_handler, - on_alt_drag_handler, -): - return toga.Canvas( - on_resize=on_resize_handler, - on_press=on_press_handler, - on_activate=on_activate_handler, - on_release=on_release_handler, - on_drag=on_drag_handler, - on_alt_press=on_alt_press_handler, - on_alt_release=on_alt_release_handler, - on_alt_drag=on_alt_drag_handler, - style=Pack(flex=1), - ) - - -@pytest.fixture -async def canvas(widget, probe, on_resize_handler): - # Modify the base canvas fixture to make it more useful for drawing tests. - widget.style.background_color = WHITE - widget.style.width = 200 - widget.style.height = 200 - return widget - - test_cleanup = build_cleanup_test(toga.Canvas) @@ -218,12 +143,13 @@ async def test_alt_drag( async def test_image_data(canvas, probe): """The canvas can be saved as an image.""" - with canvas.Stroke(x=0, y=0, color=RED) as stroke: - stroke.line_to(x=200, y=200) - stroke.move_to(x=200, y=0) - stroke.line_to(x=0, y=200) + with canvas.stroke(color=RED): + canvas.move_to(x=0, y=0) + canvas.line_to(x=200, y=200) + canvas.move_to(x=200, y=0) + canvas.line_to(x=0, y=200) - stroke.rect(2, 2, 198, 198) + canvas.rect(2, 2, 198, 198) await probe.redraw("Test image has been drawn") @@ -287,13 +213,13 @@ async def test_transparency(canvas, probe): canvas.style.background_color = TRANSPARENT # Draw a rectangle. move_to is implied - canvas.root_state.begin_path() - canvas.root_state.rect(x=20, y=20, width=120, height=120) - canvas.root_state.fill(color=REBECCAPURPLE) + canvas.begin_path() + canvas.rect(x=20, y=20, width=120, height=120) + canvas.fill(color=REBECCAPURPLE) - canvas.root_state.begin_path() - canvas.root_state.rect(x=60, y=60, width=120, height=120) - canvas.root_state.fill(color=rgb(0x33, 0x66, 0x99, 0.5)) + canvas.begin_path() + canvas.rect(x=60, y=60, width=120, height=120) + canvas.fill(color=rgb(0x33, 0x66, 0x99, 0.5)) await probe.redraw("Image with transparent content and background") assert_reference(probe, "transparency") @@ -303,42 +229,42 @@ async def test_paths(canvas, probe): """A path can be drawn.""" # A filled path closes automatically. - canvas.root_state.begin_path() - canvas.root_state.move_to(20, 20) - canvas.root_state.line_to(140, 20) - canvas.root_state.line_to(20, 140) - canvas.root_state.fill() + canvas.begin_path() + canvas.move_to(20, 20) + canvas.line_to(140, 20) + canvas.line_to(20, 140) + canvas.fill() # A stroked path requires an explicit close. For an open stroke, see test_stroke. - canvas.root_state.begin_path() + canvas.begin_path() # When there are two consecutive move_tos, the first one should leave no trace. - canvas.root_state.move_to(140, 140) - canvas.root_state.move_to(180, 180) - canvas.root_state.line_to(180, 60) - canvas.root_state.line_to(60, 180) - canvas.root_state.close_path() - canvas.root_state.stroke() + canvas.move_to(140, 140) + canvas.move_to(180, 180) + canvas.line_to(180, 60) + canvas.line_to(60, 180) + canvas.close_path() + canvas.stroke() # An empty path should not appear. - canvas.root_state.begin_path() - canvas.root_state.close_path() - canvas.root_state.stroke(RED) + canvas.begin_path() + canvas.close_path() + canvas.stroke(RED) # A path containing only move_to commands should not appear. - canvas.root_state.begin_path() - canvas.root_state.move_to(140, 140) - canvas.root_state.move_to(160, 160) - canvas.root_state.stroke(RED) + canvas.begin_path() + canvas.move_to(140, 140) + canvas.move_to(160, 160) + canvas.stroke(RED) # A path is not cleared after being stroked or filled. - canvas.root_state.move_to(20, 10) - canvas.root_state.line_to(60, 10) - canvas.root_state.stroke(color=CORNFLOWERBLUE, line_width=10) - canvas.root_state.move_to(60, 10) - canvas.root_state.line_to(100, 10) - canvas.root_state.fill(color=REBECCAPURPLE) - canvas.root_state.line_to(140, 10) - canvas.root_state.stroke() + canvas.move_to(20, 10) + canvas.line_to(60, 10) + canvas.stroke(color=CORNFLOWERBLUE, line_width=10) + canvas.move_to(60, 10) + canvas.line_to(100, 10) + canvas.fill(color=REBECCAPURPLE) + canvas.line_to(140, 10) + canvas.stroke() await probe.redraw("Pair of triangles and a black line should be drawn") assert_reference(probe, "paths", threshold=0.02) @@ -347,15 +273,15 @@ async def test_paths(canvas, probe): async def test_bezier_curve(canvas, probe): """A Bézier curve can be drawn.""" - canvas.root_state.begin_path() - canvas.root_state.move_to(100, 44) - canvas.root_state.bezier_curve_to(100, 40, 92, 20, 60, 20) - canvas.root_state.bezier_curve_to(12, 20, 12, 80, 12, 80) - canvas.root_state.bezier_curve_to(12, 108, 44, 144, 100, 172) - canvas.root_state.bezier_curve_to(156, 144, 188, 108, 188, 80) - canvas.root_state.bezier_curve_to(188, 80, 188, 20, 140, 20) - canvas.root_state.bezier_curve_to(116, 20, 100, 40, 100, 44) - canvas.root_state.stroke() + canvas.begin_path() + canvas.move_to(100, 44) + canvas.bezier_curve_to(100, 40, 92, 20, 60, 20) + canvas.bezier_curve_to(12, 20, 12, 80, 12, 80) + canvas.bezier_curve_to(12, 108, 44, 144, 100, 172) + canvas.bezier_curve_to(156, 144, 188, 108, 188, 80) + canvas.bezier_curve_to(188, 80, 188, 20, 140, 20) + canvas.bezier_curve_to(116, 20, 100, 40, 100, 44) + canvas.stroke() await probe.redraw("Heart should be drawn") assert_reference(probe, "bezier_curve", threshold=0.03) @@ -364,15 +290,15 @@ async def test_bezier_curve(canvas, probe): async def test_quadratic_curve(canvas, probe): """A quadratic curve can be drawn.""" - canvas.root_state.begin_path() - canvas.root_state.move_to(100, 20) - canvas.root_state.quadratic_curve_to(20, 20, 20, 80) - canvas.root_state.quadratic_curve_to(20, 140, 60, 140) - canvas.root_state.quadratic_curve_to(60, 172, 28, 180) - canvas.root_state.quadratic_curve_to(76, 172, 110, 140) - canvas.root_state.quadratic_curve_to(180, 140, 180, 80) - canvas.root_state.quadratic_curve_to(180, 20, 100, 20) - canvas.root_state.stroke() + canvas.begin_path() + canvas.move_to(100, 20) + canvas.quadratic_curve_to(20, 20, 20, 80) + canvas.quadratic_curve_to(20, 140, 60, 140) + canvas.quadratic_curve_to(60, 172, 28, 180) + canvas.quadratic_curve_to(76, 172, 110, 140) + canvas.quadratic_curve_to(180, 140, 180, 80) + canvas.quadratic_curve_to(180, 20, 100, 20) + canvas.stroke() await probe.redraw("Quote bubble should be drawn") assert_reference(probe, "quadratic_curve", threshold=0.03) @@ -380,38 +306,38 @@ async def test_quadratic_curve(canvas, probe): async def test_arc(canvas, probe): """An arc can be drawn.""" - canvas.root_state.begin_path() + canvas.begin_path() # Face - canvas.root_state.arc(100, 100, 80) + canvas.arc(100, 100, 80) # Smile (exactly half a turn) - canvas.root_state.move_to(150, 100) - canvas.root_state.arc(100, 100, 50, 0, pi, counterclockwise=False) + canvas.move_to(150, 100) + canvas.arc(100, 100, 50, 0, pi, counterclockwise=False) # Hair (exactly half a turn, but in the opposite direction) - canvas.root_state.move_to(190, 100) - canvas.root_state.arc(100, 100, 90, 0, pi, counterclockwise=True) + canvas.move_to(190, 100) + canvas.arc(100, 100, 90, 0, pi, counterclockwise=True) # Left eye - canvas.root_state.move_to(70, 70) - canvas.root_state.arc(64, 70, 6) + canvas.move_to(70, 70) + canvas.arc(64, 70, 6) # Right eye - canvas.root_state.move_to(130, 70) - canvas.root_state.arc(124, 70, 6) + canvas.move_to(130, 70) + canvas.arc(124, 70, 6) - canvas.root_state.stroke() + canvas.stroke() # Left eyebrow (less than half a turn) - canvas.root_state.begin_path() - canvas.root_state.arc(64, 70, 12, pi * 3 / 4, pi * 6 / 4) - canvas.root_state.stroke() + canvas.begin_path() + canvas.arc(64, 70, 12, pi * 3 / 4, pi * 6 / 4) + canvas.stroke() # Right eyebrow (less than half a turn, crossing the zero angle) - canvas.root_state.begin_path() - canvas.root_state.arc(124, 70, 12, pi * 6 / 4, pi * 1 / 4) - canvas.root_state.stroke() + canvas.begin_path() + canvas.arc(124, 70, 12, pi * 6 / 4, pi * 1 / 4) + canvas.stroke() await probe.redraw("Smiley face should be drawn") assert_reference(probe, "arc", threshold=0.03) @@ -421,18 +347,18 @@ async def test_ellipse(canvas, probe): """An ellipse can be drawn.""" # Nucleus (filled circle) - canvas.root_state.move_to(90, 100) - canvas.root_state.ellipse(100, 100, 20, 20) - canvas.root_state.fill(color=RED) + canvas.move_to(90, 100) + canvas.ellipse(100, 100, 20, 20) + canvas.fill(color=RED) # Purple orbit - canvas.root_state.begin_path() - canvas.root_state.ellipse(100, 100, 90, 20, rotation=pi * 3 / 4) - canvas.root_state.stroke(color=REBECCAPURPLE) + canvas.begin_path() + canvas.ellipse(100, 100, 90, 20, rotation=pi * 3 / 4) + canvas.stroke(color=REBECCAPURPLE) # Blue orbit (more than half a turn) - canvas.root_state.begin_path() - canvas.root_state.ellipse( + canvas.begin_path() + canvas.ellipse( 100, 100, radiusx=20, @@ -442,11 +368,11 @@ async def test_ellipse(canvas, probe): endangle=pi / 4, counterclockwise=True, ) - canvas.root_state.stroke(color=CORNFLOWERBLUE) + canvas.stroke(color=CORNFLOWERBLUE) # Yellow orbit (more than half a turn) - canvas.root_state.begin_path() - canvas.root_state.ellipse( + canvas.begin_path() + canvas.ellipse( 100, 100, radiusx=20, @@ -454,7 +380,7 @@ async def test_ellipse(canvas, probe): startangle=pi / 4, endangle=pi * 7 / 4, ) - canvas.root_state.stroke(color=GOLDENROD) + canvas.stroke(color=GOLDENROD) await probe.redraw("Atom should be drawn") assert_reference(probe, "ellipse", threshold=0.02) @@ -463,7 +389,6 @@ async def test_ellipse(canvas, probe): async def test_ellipse_path(canvas, probe): """An elliptical arc can be connected to other segments of a path.""" - state = canvas.root_state ellipse_args = { "x": 100, "y": 100, @@ -473,25 +398,25 @@ async def test_ellipse_path(canvas, probe): } # Start of path -> arc - state.ellipse(**ellipse_args, startangle=radians(80), endangle=radians(160)) + canvas.ellipse(**ellipse_args, startangle=radians(80), endangle=radians(160)) # Arc -> arc - state.ellipse(**ellipse_args, startangle=radians(220), endangle=radians(260)) - state.stroke() + canvas.ellipse(**ellipse_args, startangle=radians(220), endangle=radians(260)) + canvas.stroke() - state.begin_path() - state.move_to(120, 20) + canvas.begin_path() + canvas.move_to(120, 20) # Move -> arc - state.ellipse(**ellipse_args, startangle=radians(280), endangle=radians(340)) + canvas.ellipse(**ellipse_args, startangle=radians(280), endangle=radians(340)) # Arc -> line - state.line_to(180, 50) - state.stroke(RED) + canvas.line_to(180, 50) + canvas.stroke(RED) - state.begin_path() - state.move_to(180, 180) - state.line_to(180, 160) + canvas.begin_path() + canvas.move_to(180, 180) + canvas.line_to(180, 160) # Line -> arc - state.ellipse(**ellipse_args, startangle=radians(10), endangle=radians(60)) - state.stroke(CORNFLOWERBLUE) + canvas.ellipse(**ellipse_args, startangle=radians(10), endangle=radians(60)) + canvas.stroke(CORNFLOWERBLUE) await probe.redraw("Broken ellipse with connected lines should be drawn") assert_reference(probe, "ellipse_path", threshold=0.02) @@ -501,9 +426,9 @@ async def test_rect(canvas, probe): """A rectangle can be drawn.""" # Draw a rectangle. move_to is implied - canvas.root_state.begin_path() - canvas.root_state.rect(x=20, y=60, width=160, height=100) - canvas.root_state.fill(color=REBECCAPURPLE) + canvas.begin_path() + canvas.rect(x=20, y=60, width=160, height=100) + canvas.fill(color=REBECCAPURPLE) await probe.redraw("Filled rectangle should be drawn") assert_reference(probe, "rect") @@ -518,20 +443,18 @@ def __init__(self, x, y): self.y = y # Draw a rounded rectangle. move_to is implied - canvas.root_state.begin_path() - canvas.root_state.round_rect( - x=20, y=10, width=160, height=80, radii=[5, 30, Corner(50, 30)] - ) - canvas.root_state.fill(color=GOLDENROD) - canvas.root_state.stroke(color=REBECCAPURPLE) + 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) # Draw a rounded rectangle with negative width, height - canvas.root_state.begin_path() - canvas.root_state.round_rect( + canvas.begin_path() + canvas.round_rect( x=190, y=180, width=-160, height=-80, radii=[0, 30, Corner(50, 60)] ) - canvas.root_state.fill(color=CORNFLOWERBLUE) - canvas.root_state.stroke(color=BLACK) + canvas.fill(color=CORNFLOWERBLUE) + canvas.stroke(color=BLACK) await probe.redraw("Filled and stroked rounded rectangles should be drawn") assert_reference(probe, "round_rect", threshold=0.016) @@ -540,22 +463,22 @@ def __init__(self, x, y): async def test_fill(canvas, probe): """A fill can be drawn with primitives.""" # Draw a closed path - canvas.root_state.begin_path() - canvas.root_state.move_to(x=60, y=10) - canvas.root_state.line_to(x=30, y=110) - canvas.root_state.line_to(x=110, y=50) - canvas.root_state.line_to(x=10, y=50) - canvas.root_state.line_to(x=90, y=110) - canvas.root_state.fill(color=REBECCAPURPLE) + canvas.begin_path() + canvas.move_to(x=60, y=10) + canvas.line_to(x=30, y=110) + 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) # Same path (slightly offset), but with EVENODD winding. - canvas.root_state.begin_path() - canvas.root_state.move_to(x=140, y=90) - canvas.root_state.line_to(x=110, y=190) - canvas.root_state.line_to(x=190, y=130) - canvas.root_state.line_to(x=90, y=130) - canvas.root_state.line_to(x=170, y=190) - canvas.root_state.fill(color=CORNFLOWERBLUE, fill_rule=FillRule.EVENODD) + canvas.begin_path() + canvas.move_to(x=140, y=90) + canvas.line_to(x=110, y=190) + 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) await probe.redraw("Stars should be drawn") assert_reference(probe, "fill") @@ -564,22 +487,22 @@ async def test_fill(canvas, probe): async def test_stroke(canvas, probe): """A stroke can be drawn with primitives.""" # Draw a closed path - canvas.root_state.begin_path() - canvas.root_state.move_to(x=20, y=20) - canvas.root_state.line_to(x=100, y=20) - canvas.root_state.line_to(x=180, y=180) - canvas.root_state.line_to(x=100, y=180) - canvas.root_state.close_path() - canvas.root_state.stroke(color=REBECCAPURPLE) + canvas.begin_path() + canvas.move_to(x=20, y=20) + canvas.line_to(x=100, y=20) + canvas.line_to(x=180, y=180) + canvas.line_to(x=100, y=180) + canvas.close_path() + canvas.stroke(color=REBECCAPURPLE) # Draw an open path inside it - canvas.root_state.begin_path() + canvas.begin_path() # At the start of a path, line_to is equivalent to move_to. - canvas.root_state.line_to(x=50, y=40) - canvas.root_state.line_to(x=90, y=40) - canvas.root_state.line_to(x=150, y=160) - canvas.root_state.line_to(x=110, y=160) - canvas.root_state.stroke(color=CORNFLOWERBLUE) + canvas.line_to(x=50, y=40) + 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) await probe.redraw("Stroke should be drawn") assert_reference(probe, "stroke") @@ -588,24 +511,24 @@ async def test_stroke(canvas, probe): async def test_stroke_and_fill(canvas, probe): "A shape drawn with primitives can be stroked and filled." # Draw a closed path - canvas.root_state.begin_path() - canvas.root_state.move_to(x=20, y=20) - canvas.root_state.line_to(x=100, y=20) - canvas.root_state.line_to(x=180, y=180) - canvas.root_state.line_to(x=100, y=180) - canvas.root_state.close_path() - canvas.root_state.stroke(color=REBECCAPURPLE) - canvas.root_state.fill(color=CORNFLOWERBLUE) + canvas.begin_path() + canvas.move_to(x=20, y=20) + canvas.line_to(x=100, y=20) + 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) # Draw an open path inside it - canvas.root_state.begin_path() + canvas.begin_path() # At the start of a path, line_to is equivalent to move_to. - canvas.root_state.line_to(x=50, y=40) - canvas.root_state.line_to(x=90, y=40) - canvas.root_state.line_to(x=150, y=160) - canvas.root_state.line_to(x=110, y=160) - canvas.root_state.fill(color=GOLDENROD, fill_rule=FillRule.EVENODD) - canvas.root_state.stroke(color=REBECCAPURPLE) + canvas.line_to(x=50, y=40) + 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) await probe.redraw("Stroke should be drawn") assert_reference(probe, "stroke_and_fill") @@ -615,13 +538,14 @@ async def test_closed_path_state(canvas, probe): """A closed path can be built with a state.""" # Build a parallelogram path - with canvas.root_state.ClosedPath(x=20, y=20) as path: - path.line_to(x=100, y=20) - path.line_to(x=180, y=180) - path.line_to(x=100, y=180) + with canvas.close_path(): + canvas.move_to(x=20, y=20) + canvas.line_to(x=100, y=20) + canvas.line_to(x=180, y=180) + canvas.line_to(x=100, y=180) # Draw it with a thick dashed line - canvas.root_state.stroke(color=REBECCAPURPLE, line_width=5, line_dash=[20, 30]) + canvas.stroke(color=REBECCAPURPLE, line_width=5, line_dash=[20, 30]) await probe.redraw("Closed path should be drawn with state") assert_reference(probe, "closed_path_state") @@ -631,10 +555,11 @@ async def test_fill_state(canvas, probe): """A fill path can be built with a state.""" # Build a filled parallelogram - with canvas.root_state.Fill(x=20, y=20, color=REBECCAPURPLE) as path: - path.line_to(x=100, y=20) - path.line_to(x=180, y=180) - path.line_to(x=100, y=180) + with canvas.fill(color=REBECCAPURPLE): + canvas.move_to(x=20, y=20) + canvas.line_to(x=100, y=20) + canvas.line_to(x=180, y=180) + canvas.line_to(x=100, y=180) await probe.redraw("Fill should be drawn with state") assert_reference(probe, "fill_state") @@ -643,14 +568,14 @@ 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.root_state.Stroke(x=40, y=20, color=REBECCAPURPLE) as stroke: - stroke.line_to(x=80, y=180) + with canvas.stroke(color=REBECCAPURPLE): + canvas.move_to(x=40, y=20) + canvas.line_to(x=80, y=180) # Draw a thick dashed line - with canvas.root_state.Stroke( - x=80, y=20, line_width=20, line_dash=[20, 10], color=CORNFLOWERBLUE - ) as stroke: - stroke.line_to(x=120, y=180) + with canvas.stroke(line_width=20, line_dash=[20, 10], color=CORNFLOWERBLUE): + canvas.move_to(x=80, y=20) + canvas.line_to(x=120, y=180) await probe.redraw("Stroke should be drawn with state") assert_reference(probe, "stroke_state") @@ -660,13 +585,13 @@ async def test_stroke_and_fill_state(canvas, probe): """A shape can be stroked and filled using states.""" # Draw a filled parallelogram - with canvas.root_state.Fill(x=20, y=20, color=REBECCAPURPLE) as fill: - with fill.Stroke( - line_width=20, line_dash=[20, 10], color=CORNFLOWERBLUE - ) as path: - path.line_to(x=100, y=20) - path.line_to(x=180, y=180) - path.line_to(x=100, y=180) + with canvas.fill(color=REBECCAPURPLE): + canvas.move_to(x=20, y=20) + with canvas.stroke(line_width=20, line_dash=[20, 10], color=CORNFLOWERBLUE): + # canvas.move_to(x=20, y=20) + canvas.line_to(x=100, y=20) + canvas.line_to(x=180, y=180) + canvas.line_to(x=100, y=180) await probe.redraw("Stroke and Fill should be drawn with state") assert_reference(probe, "stroke_and_fill_state") @@ -674,20 +599,16 @@ 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.root_state.Fill(color=GOLDENROD) as fill: - with fill.Fill() as inner_fill: + with canvas.fill(color=GOLDENROD): + with canvas.fill(): # Should still be goldenrod - inner_fill.rect(10, 10, 50, 50) - - with canvas.root_state.Stroke( - color=REBECCAPURPLE, - line_width=15, - line_dash=[15, 14], - ) as stroke: - with stroke.Stroke() as inner_stroke: + canvas.rect(10, 10, 50, 50) + + with canvas.stroke(color=REBECCAPURPLE, line_width=15, line_dash=[15, 14]): + with canvas.stroke(): # Should still be wide, dashed, and purple - inner_stroke.move_to(100, 10) - inner_stroke.line_to(100, 150) + canvas.move_to(100, 10) + canvas.line_to(100, 150) await probe.redraw("Nested stroke and fill states should be drawn") assert_reference(probe, "nested_stroke_and_fill_state") @@ -697,29 +618,29 @@ async def test_transforms(canvas, probe): """Transforms can be applied.""" # Draw a rectangle after a horizontal translation - canvas.root_state.translate(160, 20) - canvas.root_state.rect(0, 0, 20, 60) - canvas.root_state.fill(color=CORNFLOWERBLUE) - - canvas.root_state.reset_transform() - canvas.root_state.begin_path() - canvas.root_state.rotate(pi / 4) - canvas.root_state.rect(200, 0, 20, 60) - canvas.root_state.fill(color=REBECCAPURPLE) - - canvas.root_state.reset_transform() - canvas.root_state.begin_path() - canvas.root_state.scale(2, 5) - canvas.root_state.rect(10, 10, 10, 10) - canvas.root_state.fill(color=GOLDENROD) - - canvas.root_state.reset_transform() - canvas.root_state.begin_path() - canvas.root_state.translate(100, 60) - canvas.root_state.rotate(pi / 7 * 4) - canvas.root_state.scale(5, 2) - canvas.root_state.rect(2, 2, 10, 10) - canvas.root_state.fill() + canvas.translate(160, 20) + canvas.rect(0, 0, 20, 60) + canvas.fill(color=CORNFLOWERBLUE) + + canvas.reset_transform() + canvas.begin_path() + canvas.rotate(pi / 4) + canvas.rect(200, 0, 20, 60) + canvas.fill(color=REBECCAPURPLE) + + canvas.reset_transform() + canvas.begin_path() + canvas.scale(2, 5) + canvas.rect(10, 10, 10, 10) + canvas.fill(color=GOLDENROD) + + canvas.reset_transform() + canvas.begin_path() + canvas.translate(100, 60) + canvas.rotate(pi / 7 * 4) + canvas.scale(5, 2) + canvas.rect(2, 2, 10, 10) + canvas.fill() await probe.redraw("Transforms can be applied") assert_reference(probe, "transforms") @@ -729,30 +650,30 @@ async def test_transforms_mid_path(canvas, probe): """Transforms can be applied mid-path.""" # draw a series of rotated rectangles - canvas.root_state.begin_path() - canvas.root_state.translate(100, 100) - with canvas.root_state.state() as ctx: + canvas.begin_path() + canvas.translate(100, 100) + with canvas.state(): for _ in range(12): - ctx.rect(50, 0, 10, 10) - ctx.scale(1.1, 1) - ctx.rotate(math.pi / 6) + canvas.rect(50, 0, 10, 10) + canvas.scale(1.1, 1) + canvas.rotate(math.pi / 6) - canvas.root_state.fill() - canvas.root_state.stroke(GOLDENROD) + canvas.fill() + canvas.stroke(GOLDENROD) # draw a series of line segments - canvas.root_state.begin_path() - canvas.root_state.move_to(25, 0) + canvas.begin_path() + canvas.move_to(25, 0) for _ in range(12): - canvas.root_state.line_to(25, 0) - canvas.root_state.rotate(math.pi / 6) - canvas.root_state.translate(5, 3) - canvas.root_state.close_path() - canvas.root_state.reset_transform() - canvas.root_state.move_to(110, 100) - canvas.root_state.scale(5, 1) - canvas.root_state.ellipse(20, 100, 2, 20, 0, 0, 2 * pi) - canvas.root_state.stroke(CORNFLOWERBLUE) + canvas.line_to(25, 0) + canvas.rotate(math.pi / 6) + canvas.translate(5, 3) + canvas.close_path() + canvas.reset_transform() + canvas.move_to(110, 100) + canvas.scale(5, 1) + canvas.ellipse(20, 100, 2, 20, 0, 0, 2 * pi) + canvas.stroke(CORNFLOWERBLUE) await probe.redraw("Transforms can be applied") assert_reference(probe, "transforms_mid_path", threshold=0.015) @@ -760,56 +681,54 @@ async def test_transforms_mid_path(canvas, probe): async def test_singular_transforms(canvas, probe): """Singular transforms behave reasonably.""" - ctx = canvas.root_state - - ctx.begin_path() - with ctx.state() as ctx2: + canvas.begin_path() + with canvas.state(): # flip about the line x = y - ctx2.rotate(-pi / 2) - ctx2.scale(-1, 1) + canvas.rotate(-pi / 2) + canvas.scale(-1, 1) - ctx2.move_to(40, 20) - ctx2.line_to(80, 20) - ctx2.line_to(100, 30) + canvas.move_to(40, 20) + canvas.line_to(80, 20) + canvas.line_to(100, 30) - with ctx2.state() as ctx3: + with canvas.state(): # Apply a scale factor of zero - ctx3.scale(0.9, 0) - ctx3.line_to(180, 20) + canvas.scale(0.9, 0) + canvas.line_to(180, 20) - ctx2.rotate(pi / 4) - ctx2.line_to(180, 20) + canvas.rotate(pi / 4) + canvas.line_to(180, 20) - ctx2.stroke(GOLDENROD, line_width=8) + canvas.stroke(GOLDENROD, line_width=8) # Same shape, but not flipped, using reset_transform() - ctx.begin_path() + canvas.begin_path() - ctx.move_to(40, 20) - ctx.line_to(80, 20) - ctx.line_to(100, 30) + canvas.move_to(40, 20) + canvas.line_to(80, 20) + canvas.line_to(100, 30) # Apply a scale factor of zero - ctx.scale(0.9, 0) - ctx.line_to(180, 20) + canvas.scale(0.9, 0) + canvas.line_to(180, 20) # Total transform is singular - ctx.reset_transform() + canvas.reset_transform() - ctx.rotate(pi / 4) - ctx.line_to(180, 20) + canvas.rotate(pi / 4) + canvas.line_to(180, 20) - ctx.stroke(CORNFLOWERBLUE, line_width=8) + canvas.stroke(CORNFLOWERBLUE, line_width=8) - ctx.reset_transform() - ctx.begin_path() - ctx.scale(0, 0.9) - ctx.translate(50, 50) + canvas.reset_transform() + canvas.begin_path() + canvas.scale(0, 0.9) + canvas.translate(50, 50) - ctx.rect(0, 0, 25, 25) + canvas.rect(0, 0, 25, 25) # Should draw nothing. - ctx.fill() - ctx.stroke(line_width=10) + canvas.fill() + canvas.stroke(line_width=10) await probe.redraw("Transforms can be applied") assert_reference(probe, "singular_transforms") @@ -831,15 +750,15 @@ 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) as stroke: - stroke.rect( + with canvas.stroke(color=CORNFLOWERBLUE): + canvas.rect( 100 - (hello_size[0] // 2), 10, hello_size[0], hello_size[1], ) - with canvas.Fill(color=REBECCAPURPLE) as text_filler: - text_filler.write_text( + with canvas.fill(color=REBECCAPURPLE): + canvas.write_text( hello_text, 100 - (hello_size[0] // 2), 10, @@ -851,15 +770,15 @@ 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) as stroke: - stroke.rect( + with canvas.stroke(color=CORNFLOWERBLUE): + canvas.rect( 100 - (world_size[0] // 2), 100 - world_size[1], world_size[0], world_size[1], ) - with canvas.Stroke(line_width=1) as text_filler: - text_filler.write_text( + with canvas.stroke(line_width=1): + canvas.write_text( world_text, 100 - (world_size[0] // 2), 100, @@ -871,16 +790,16 @@ 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) as stroke: - stroke.rect( + with canvas.stroke(color=CORNFLOWERBLUE): + canvas.rect( 100 - (toga_size[0] // 2), 150 - (toga_size[1] // 2), toga_size[0], toga_size[1], ) - with canvas.Stroke(color=REBECCAPURPLE) as stroke: - with stroke.Fill(color=CORNFLOWERBLUE) as text_filler: - text_filler.write_text( + with canvas.stroke(color=REBECCAPURPLE): + with canvas.fill(color=CORNFLOWERBLUE): + canvas.write_text( toga_text, 100 - (toga_size[0] // 2), 150, @@ -907,42 +826,45 @@ async def test_multiline_text(canvas, probe): # Vertical guidelines X = [10, 75, 140] - with canvas.root_state.Stroke(color=RED) as guideline: + with canvas.stroke(color=RED): for x in X: - guideline.move_to(x, 0) - guideline.line_to(x, canvas.style.height) + canvas.move_to(x, 0) + canvas.line_to(x, canvas.style.height) def caption(baseline): return f"{baseline.name.capitalize()}\nTwo\nThree" # ALPHABETIC baseline y = 30 - guideline.move_to(0, y) - guideline.line_to(canvas.style.width, y) - with canvas.root_state.Fill() as text_filler: + with canvas.stroke(color=RED): + canvas.move_to(0, y) + canvas.line_to(canvas.style.width, y) + + with canvas.fill(): # Default baseline (ALPHABETIC), with default font and various sizes. x = X[0] for size in [8, 12, 16, 20]: text = f"{size:02d}" font = Font(SYSTEM, size) - text_filler.write_text(text, x, y, font) + canvas.write_text(text, x, y, font) x += canvas.measure_text(text, font)[0] + 5 # Empty text: this should have no effect on the image, but make sure it's # accepted. - text_filler.write_text("", X[1], y) + canvas.write_text("", X[1], y) # Explicit ALPHABETIC baseline, with default font and size but specified # line height. On most systems, this will go off the right edge of the canvas. line_height = 2.5 - text_filler.write_text( + canvas.write_text( caption(Baseline.ALPHABETIC), X[2], y, line_height=line_height ) # Other baselines, with default font but specified size y = 130 - guideline.move_to(0, y) - guideline.line_to(canvas.style.width, y) + with canvas.stroke(color=RED): + canvas.move_to(0, y) + canvas.line_to(canvas.style.width, y) font = Font(SYSTEM, 12) for i, baseline in enumerate([Baseline.BOTTOM, Baseline.MIDDLE, Baseline.TOP]): @@ -956,11 +878,11 @@ def caption(baseline): elif baseline == Baseline.BOTTOM: top = y - height - with canvas.root_state.Stroke(color=CORNFLOWERBLUE) as box: - box.rect(left, top, width, height) + with canvas.stroke(color=CORNFLOWERBLUE): + canvas.rect(left, top, width, height) - with canvas.root_state.Fill() as text_filler: - text_filler.write_text(text, left, y, font, baseline) + with canvas.fill(): + canvas.write_text(text, left, y, font, baseline) await probe.redraw("Multiple text blocks should be drawn") assert_reference(probe, "multiline_text") @@ -980,10 +902,10 @@ async def test_write_text_and_path(canvas, probe): hello_font = Font("Droid Serif", 24) hello_size = canvas.measure_text(hello_text, hello_font) - with canvas.Fill(BLACK) as fill: + with canvas.fill(BLACK): # start building a path - fill.begin_path() - fill.rect( + canvas.begin_path() + canvas.rect( 100 - (hello_size[0] // 2), 10, hello_size[0], @@ -992,7 +914,7 @@ async def test_write_text_and_path(canvas, probe): # Draw some text independent of the path # Uses fill color of black. - fill.write_text( + canvas.write_text( hello_text, 100 - (hello_size[0] // 2), 10, @@ -1001,20 +923,20 @@ async def test_write_text_and_path(canvas, probe): ) # continue building the path - fill.move_to( + canvas.move_to( 100 - (hello_size[0] // 2), 10, ) - fill.line_to( + canvas.line_to( 100 + (hello_size[0] // 2), 10 + hello_size[1], ) # now stroke the path, but *not* the text - fill.stroke(CORNFLOWERBLUE) + canvas.stroke(CORNFLOWERBLUE) # start a new path so Fill state doesn't fill current path with black - fill.begin_path() + canvas.begin_path() await probe.redraw("Text and path should be drawn independently") assert_reference(probe, "write_text_and_path", 0.04) @@ -1024,8 +946,8 @@ async def test_draw_image_at_point(canvas, probe): """Images can be drawn at a point.""" image = TogaImage("resources/sample.png") - canvas.root_state.begin_path() - canvas.root_state.draw_image(image, 10, 10) + canvas.begin_path() + canvas.draw_image(image, 10, 10) await probe.redraw("Image should be drawn") assert_reference(probe, "draw_image", threshold=0.05) @@ -1035,13 +957,13 @@ async def test_draw_image_in_rect(canvas, probe): """Images can be drawn in a rectangle.""" image = TogaImage("resources/sample.png") - canvas.root_state.begin_path() - canvas.root_state.translate(82, 46) - canvas.root_state.rotate(-pi / 6) - canvas.root_state.translate(-82, -46) - canvas.root_state.draw_image(image, 10, 10, 72, 144) - canvas.root_state.rect(10, 10, 72, 144) - canvas.root_state.stroke(REBECCAPURPLE) + canvas.begin_path() + canvas.translate(82, 46) + canvas.rotate(-pi / 6) + canvas.translate(-82, -46) + canvas.draw_image(image, 10, 10, 72, 144) + canvas.rect(10, 10, 72, 144) + canvas.stroke(REBECCAPURPLE) await probe.redraw("Image should be drawn") assert_reference(probe, "draw_image_in_rect", threshold=0.05) @@ -1056,17 +978,17 @@ def draw_angle(canvas, angle, x): angle = angle * pi / 180 half_width = height * tan(angle / 2) - with canvas.root_state.state() as ctx: + with canvas.state(): # Translate to the vertex - ctx.translate(x, 85) + canvas.translate(x, 85) - ctx.begin_path() - ctx.move_to(half_width, height) - ctx.line_to(0, 0) - ctx.line_to(-half_width, height) + canvas.begin_path() + canvas.move_to(half_width, height) + canvas.line_to(0, 0) + canvas.line_to(-half_width, height) - ctx.stroke(line_width=line_width) - ctx.stroke(line_width=2, color=REBECCAPURPLE) + canvas.stroke(line_width=line_width) + canvas.stroke(line_width=2, color=REBECCAPURPLE) # Left two should be mitered, right two should be beveled. # (Windows and Qt don't bevel, they just start truncating the miter.) diff --git a/testbed/tests/widgets/canvas/test_deprecated_code.py b/testbed/tests/widgets/canvas/test_deprecated_code.py new file mode 100644 index 0000000000..f8cc75d09f --- /dev/null +++ b/testbed/tests/widgets/canvas/test_deprecated_code.py @@ -0,0 +1,109 @@ +import math + +import pytest + +import toga +from toga.colors import WHITE, rgb +from toga.constants import SANS_SERIF, Baseline + +from .test_canvas import assert_reference + + +async def test_old_tutorial(canvas, probe): + """The previous code in tutorial 4 still renders correctly.""" + + # Shift the whole thing up a bit so we can see all of it. + canvas.translate(0, -20) + + with pytest.deprecated_call(): + # Fill head + + with canvas.Fill(color=rgb(149, 119, 73)) as head_filler: + head_filler.move_to(112, 103) + head_filler.line_to(112, 113) + head_filler.ellipse(73, 114, 39, 47, 0, 0, math.pi) + head_filler.line_to(35, 84) + head_filler.arc(65, 84, 30, math.pi, 3 * math.pi / 2) + head_filler.arc(82, 84, 30, 3 * math.pi / 2, 2 * math.pi) + + # Eyes + + with canvas.Fill(color=WHITE) as eye_whites: + eye_whites.arc(58, 92, 15) + eye_whites.arc(88, 92, 15, math.pi, 3 * math.pi) + + # Draw eyes separately to avoid miter join + with canvas.Stroke(line_width=4.0) as eye_outline: + eye_outline.arc(58, 92, 15) + with canvas.Stroke(line_width=4.0) as eye_outline: + eye_outline.arc(88, 92, 15, math.pi, 3 * math.pi) + + with canvas.Fill() as eye_pupils: + eye_pupils.arc(58, 97, 3) + eye_pupils.arc(88, 97, 3) + + # Horns + + with canvas.root_state.state() as r_horn: + with r_horn.Fill(color=rgb(212, 212, 212)) as r_horn_filler: + r_horn_filler.move_to(112, 99) + r_horn_filler.quadratic_curve_to(145, 65, 139, 36) + r_horn_filler.quadratic_curve_to(130, 60, 109, 75) + with r_horn.Stroke(line_width=4.0) as r_horn_stroker: + r_horn_stroker.move_to(112, 99) + r_horn_stroker.quadratic_curve_to(145, 65, 139, 36) + r_horn_stroker.quadratic_curve_to(130, 60, 109, 75) + + with canvas.root_state.state() as l_horn: + with l_horn.Fill(color=rgb(212, 212, 212)) as l_horn_filler: + l_horn_filler.move_to(35, 99) + l_horn_filler.quadratic_curve_to(2, 65, 6, 36) + l_horn_filler.quadratic_curve_to(17, 60, 37, 75) + with l_horn.Stroke(line_width=4.0) as l_horn_stroker: + l_horn_stroker.move_to(35, 99) + l_horn_stroker.quadratic_curve_to(2, 65, 6, 36) + l_horn_stroker.quadratic_curve_to(17, 60, 37, 75) + + # Nostrils + + with canvas.Fill(color=rgb(212, 212, 212)) as nose_filler: + nose_filler.move_to(45, 145) + nose_filler.bezier_curve_to(51, 123, 96, 123, 102, 145) + nose_filler.ellipse(73, 114, 39, 47, 0, math.pi / 4, 3 * math.pi / 4) + with canvas.Fill() as nostril_filler: + nostril_filler.arc(63, 140, 3) + nostril_filler.arc(83, 140, 3) + with canvas.Stroke(line_width=4.0) as nose_stroker: + nose_stroker.move_to(45, 145) + nose_stroker.bezier_curve_to(51, 123, 96, 123, 102, 145) + + # Outline head + + with canvas.Stroke(line_width=4.0) as head_outline: + with head_outline.ClosedPath(112, 103) as closed_head: + closed_head.line_to(112, 113) + closed_head.ellipse(73, 114, 39, 47, 0, 0, math.pi) + closed_head.line_to(35, 84) + closed_head.arc(65, 84, 30, math.pi, 3 * math.pi / 2) + closed_head.arc(82, 84, 30, 3 * math.pi / 2, 2 * math.pi) + + # Text + + font = toga.Font(family=SANS_SERIF, size=20) + text_width, text_height = canvas.measure_text("Tiberius", font) + + x = (150 - text_width) // 2 + y = 175 + + with canvas.Stroke(color="REBECCAPURPLE", line_width=4.0) as rect_stroker: + text_border = rect_stroker.rect( # noqa: F841 + x - 5, + y - 5, + text_width + 10, + text_height + 10, + ) + with canvas.Fill(color=rgb(149, 119, 73)) as text_filler: + text = text_filler.write_text("Tiberius", x, y, font, Baseline.TOP) # noqa: F841 + + await probe.redraw("Tiberus should be drawn") + assert_reference(probe, "deprecated_tutorial") diff --git a/testbed/tests/widgets/test_webview.py b/testbed/tests/widgets/test_webview.py index 3fcba9127f..306bf67a50 100644 --- a/testbed/tests/widgets/test_webview.py +++ b/testbed/tests/widgets/test_webview.py @@ -190,6 +190,7 @@ async def test_load_url(widget, probe, on_load): ) +@pytest.mark.flaky(retries=5, delay=1) async def test_static_content(widget, probe, on_load): """Static content can be loaded into the page.""" widget.set_content("https://example.com/", "
{'lorem ipsum ' * 200000}
" diff --git a/winforms/tests_backend/widgets/canvas.py b/winforms/tests_backend/widgets/canvas.py index a37627816f..12025eb184 100644 --- a/winforms/tests_backend/widgets/canvas.py +++ b/winforms/tests_backend/widgets/canvas.py @@ -14,6 +14,7 @@ def reference_variant(self, reference): "multiline_text", "write_text", "write_text_and_path", + "deprecated_tutorial", "miter_join", }: return f"{reference}-winforms"