"""A two-colour gradient: a fill value, not a drawing method.
``canvas.rect(pos, size, fill=Gradient(top, bottom))`` is how a 2D drawing
surface fills a shape with a ramp instead of a flat colour. Passing the ramp as
a *value* is what keeps the vocabulary to one entry point per primitive: there
is one ``rect``, and what it is filled with is an argument, so a caller that
computes its fill (flat here, a ramp there) writes one call rather than
branching between two method names.
It lives in core because all three 2D drawing surfaces have to read it and one
of them (the headless ``DrawLog``) is a core module, so the type cannot sit
anywhere that core is unable to import.
The shape is deliberately small: two colours and the axis they run along, which
is what a drawing surface can turn into vertex colours on its own. Anything
richer (interior stops, a radial ramp, a shader) is a different mechanism and
does not belong behind this name.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
__all__ = ["Gradient", "GradientDirection", "fill_corner_colours", "reject_gradient"]
#: A colour as the drawing surfaces take one: an RGB or RGBA tuple of floats.
type ColourValue = tuple[float, ...]
[docs]
class GradientDirection(StrEnum):
"""The axis a :class:`Gradient` runs along, in the filled shape's own space."""
VERTICAL = "vertical"
"""``start`` at the top edge, ``end`` at the bottom edge."""
HORIZONTAL = "horizontal"
"""``start`` at the left edge, ``end`` at the right edge."""
[docs]
@dataclass(frozen=True, slots=True)
class Gradient:
"""A linear ramp from ``start`` to ``end`` across the shape it fills.
Example::
canvas.rect((0, 0), (w, h), fill=Gradient((0.1, 0.1, 0.3), (0.6, 0.2, 0.1)))
canvas.rect((0, 0), (w, h), fill=Gradient(left, right, "horizontal"))
Both colours are ordinary drawing colours: RGB or RGBA floats in 0-1, which
the surface normalises exactly as it normalises a flat ``fill``. The
direction accepts a :class:`GradientDirection` member or its plain string.
"""
start: ColourValue
end: ColourValue
direction: GradientDirection = GradientDirection.VERTICAL
[docs]
def __post_init__(self) -> None:
try:
direction = GradientDirection(self.direction)
except ValueError:
legal = ", ".join(repr(str(d)) for d in GradientDirection)
raise ValueError(f"Gradient direction must be one of {legal}, got {self.direction!r}") from None
object.__setattr__(self, "direction", direction)
[docs]
def corner_colours(self) -> tuple[ColourValue, ColourValue, ColourValue, ColourValue]:
"""The colour of each corner of the rectangle this fills.
In the order a drawing surface builds a quad: top-left, top-right,
bottom-right, bottom-left. The one place the ramp becomes per-corner
colour, so every surface puts the same colour on the same corner.
"""
if self.direction is GradientDirection.HORIZONTAL:
return (self.start, self.end, self.end, self.start)
return (self.start, self.start, self.end, self.end)
[docs]
def reject_gradient(fill, primitive: str) -> None:
"""Raise when a gradient reaches a primitive that can only paint a flat colour.
A rectangle is the one shape whose corners a two-colour ramp lands on
unambiguously, so it is the one shape that takes a :class:`Gradient` fill.
Anywhere else the argument is a mistake, and a mistake that would otherwise
surface as a colour-arity error from deep inside the vertex path.
"""
if isinstance(fill, Gradient):
raise TypeError(
f"{primitive}() fills with a colour, not a Gradient: only rect() ramps. "
"Fill a rect with the Gradient and clip it to the shape, or pass one of its end colours."
)
[docs]
def fill_corner_colours(fill) -> tuple[ColourValue, ColourValue, ColourValue, ColourValue]:
"""The colour of each corner of a rectangle filled with ``fill``.
A flat colour repeats; a :class:`Gradient` ramps. Every drawing surface calls
this and then normalises the four results, so none of them can put the ramp
on a different corner from the others.
"""
if isinstance(fill, Gradient):
return fill.corner_colours()
return (fill, fill, fill, fill)