"""
This module contains classes and functions for drawing.
:ref:`gewel_getting_started` is an introduction to gewel
that is useful if you are just getting started.
:ref:`code_samples` contains numerous examples of the use of
classes from this module to create simple animations.
"""
import bisect
import re
from abc import ABCMeta, abstractmethod
from contextlib import contextmanager
from dataclasses import dataclass
from enum import Enum
from io import BytesIO
from time import perf_counter
from typing import IO, Iterable, List, Optional, Protocol, Tuple, Union
import IPython.display
import cairocffi as cairo
import ipywidgets as widgets
import numpy as np
import gewel.color
import tvx
import tvx.utils
from gewel._timekeeper import TimekeeperProtocol, TimekeeperMixin
from gewel.color import BaseColor, ColorMap, TRANSPARENT, BACKGROUND, BLACK
from tvx import float_at_time
_total_device_drawing_time = 0.0
[docs]def reset_total_device_drawing_time():
"""
Reset the total device rendering time. See
:py:func:`~total_device_drawing_time` for more
details.
Returns
-------
"""
global _total_device_drawing_time
_total_device_drawing_time = 0.0
[docs]def total_device_drawing_time() -> float:
"""
Get the total time spent in rendering at the low
level. Normally used only for performance debugging.
This value begins at 0.0 before any rendering is done
and can be reset by calling
:py:func:`~reset_total_device_drawing_time`.
Returns
-------
float
Total device rendering time.
"""
return _total_device_drawing_time
@contextmanager
def _time_device_drawing():
global _total_device_drawing_time
start = perf_counter()
try:
yield
finally:
end = perf_counter()
_total_device_drawing_time += end - start
@contextmanager
def _xform_context(ctx: cairo.Context, xform: cairo.Matrix):
try:
ctx.save()
ctx.transform(xform)
yield ctx
finally:
ctx.restore()
@contextmanager
def _clipped_rectangle_context(ctx: cairo.Context, x0, y0, x1, y1):
try:
ctx.save()
ctx.move_to(x0, y0)
ctx.line_to(x0, y1)
ctx.line_to(x1, y1)
ctx.line_to(x1, y0)
ctx.close_path()
ctx.clip()
yield ctx
finally:
ctx.restore()
_radians_per_degree = np.pi / 180.0
[docs]@dataclass(init=False)
class Drawable(TimekeeperMixin, metaclass=ABCMeta):
"""
This is the abstract base class for all drawables.
Drawables typically have two distinct phases in their lifecycle,
`scripting` and `rendering`. Scripting sets up the object
and all the actions it will take during a scene. It is like writing
the script for the animation. Rendering animates the scene, producing
a final video of the scene. It is like filming a scene after
the script has been written.
For more details on the lifecycle of a :py:class:`~Drawable`, please refer to
:ref:`draw_lifecycle`.
Parameters
----------
z
Depth of the object. When a :py:class:`~Scene` is rendered, the
:py:class:`~Drawable` objects in the scene are rendered in ascending `z`
order. So if two objects in the scene overlap, the one with the
larger z will appear to be on top when the scene is rendered. If the
z value are equal, the rendering order is undefined. So if there
are two objects that are likely to overlap, it is recommended they
be given different `z` values to ensure consistent rendering.
alpha
The transparency/opacity of the object. It should be in the range
[0.0, 1.0]. 0.0 means fully transparent, so when the drawn nothing
appears. 1.0 is fully opaque, so nothing drawn at a lower `z` will
show through. If in between 0.0 and 1.0, the object is partially
transparent so objects with lower `z` will be partially visible through
it. The closer to 0.0 the value is, the more transparent it will be.
Note that :py:class:`~gewel.color.Color` objects have their own
alpha attribute. So if the color of a drawable is set to a transparent
or semi-transparent color, then other objects may show through it even
if the alpha of the object itself is set to 1.0.
"""
z: tvx.FloatOrTVF = 0.0 #: `z` depth of the object. Objects in a scene are rendered
# in order from lowest `z` depth to highest.
alpha: tvx.FloatOrTVF = 1.0 #: Transparency/opacity. A value of 0.0 means completely
# transparent. A value of 1.0 means completely opaque. Anything
# in-between means partially transparent, with values closer
# to zero being more transparent.
def __init__(self, z: tvx.FloatOrTVF = 0.0, alpha: tvx.FloatOrTVF = 1.0):
self.z = z
self.alpha = alpha
self._time = 0.0
@property
def time(self) -> float:
"""
The next-action time. See :ref:`draw_update_time` for more on next-action time.
Returns
-------
float
The next action time.
"""
return self._time
[docs] def set_time(self, t: float):
"""
Set the next-action time.
See :ref:`draw_update_time` for more on next-action time.
Parameters
----------
t
The new next-action time.
"""
self._time = t
[docs] @abstractmethod
def draw(self, ctx: cairo.Context, t: float) -> None:
"""
This method renders the drawable into pixels. :py:class:`~Drawable` objects
typically have time-varying behavior. For example, the `x` and `y` location
of the object in a scene may be time-varying floats (see the :py:class:`~tvx.Tvf`
class for details). It is the responsibility of the :py:meth:`~draw` method
to render the correct pixels for time passed in.
Note that this method is very rarely called directly by user code. Instead,
it is called by utility classes that render a :py:class:`~Scene` containing
a :py:class:`~Drawable` objects. Examples include
:py:class:`gewel.record.Mp4Recorder`, which renders a scene to an MP4
file, or :py:class:`gewel.player.Player` that creates an interactive
preview of a :py:class:`~Scene` or the :py:meth:`gewel.draw.Scene.__repr__`
method that render a scene into an IPython notebook widget.
Parameters
----------
ctx
The context in which to render. This is an object with low-level
drawing primitives that the :py:meth:`~draw` method relies on to
render at the pixel level.
t
The time at which to render. That is, we should render the object
as it is intended to appear at this time in the scene. Exactly what
that is is typically controlled by properties of the object that
are :py:class:`~tvx.Tvf` objects.
"""
pass
[docs] def fade_to(self, alpha: tvx.FloatOrTVF, duration: float, update_time: bool = True):
"""
Fade the object from it's current alpha to a new value. Alpha values range
from 0.0, which means completely transparent, to 1.0, which means completely opaque. Most
newly constructed :py:class:`~Drawable` classes that support alpha default to a value of 1.0.
See :ref:`fade_to_sample` for sample usage.
Parameters
----------
alpha
The new alpha value. Should be between 0.0 and 1.0, inclusive.
Values outside this range produce undefined behavior that may
change in future versions.
duration
The amount of time, in seconds, the fade from the current
alpha to the new alpha should take.
update_time
Should the object's time be updated. Normally this is `True`. Making
it `False` does not change the time, so that other updates can be
made on the same object at the same time it is fading. For example, :py:meth:`~move_to`
can be called to make the object move as it fades.
Returns
-------
None
"""
self.alpha = tvx.ramp(self.alpha, alpha, self.time, width=duration)
self._manage_time(duration, update_time)
def __getitem__(self, item: Union[float, slice]) -> 'ClipDrawable':
"""
Get the frame at a specific time or a clip
covering a range from start to end time.
Parameters
----------
time
Either a float, in which case the return value is a
frame at the given time, or a slice of the form ``start:end``,
in which case the return value is a clip of the scene covering
that range of time.
Returns
-------
ClipDrawable
A frame or a clip, depending on whether the argument was
a float or a slice.
"""
if isinstance(item, slice):
return ClipDrawable(self, item.start, item.stop)
else:
raise IndexError("Only two-element slice indices, are supported. Try d[start:stop].")
[docs] def translated(self, dx: tvx.FloatOrTVF, dy: tvx.FloatOrTVF, z: Optional[tvx.FloatOrTVF] = None) -> 'Drawable':
"""
Construct and return a new drawable that is translated by a relative amount
in the x and y directions.
Parameters
----------
dx
How far to translate in the `x` direction.
dy
How far to translate in the `y` direction.
z
The z depth of the resulting drawable. If not
supplied, the z depth of ``self`` is used.
Returns
-------
Drawable
A new drawable that renders as the original but translated.
"""
if z is None:
z = self.z
return TransformedDrawable(self, x0=dx, y0=dy, z=z)
[docs] def scaled(
self,
sx: tvx.FloatOrTVF,
sy: Optional[tvx.FloatOrTVF] = None,
z: Optional[tvx.FloatOrTVF] = None
) -> 'Drawable':
"""
Construct and return a new drawable that is scaled
in the x and y directions.
Parameters
----------
sx
Scale in the `x` direction.
sy
Scale in the `y` direction.
z
The z depth of the resulting drawable. If not
supplied, the z depth of ``self`` is used.
Returns
-------
Drawable
A new drawable that renders as the original but scaled.
"""
if sy is None:
sy = sx
if z is None:
z = self.z
return TransformedDrawable(self, xx=sx, yy=sy, z=z)
[docs] def rotated(self, radians: tvx.FloatOrTVF, z: Optional[tvx.FloatOrTVF] = None) -> 'Drawable':
"""
Return a version of the drawable that is rotated. See also
:py:meth:`~Drawable.rotated_degrees`.
Parameters
----------
radians
How much to rotate by, in radians.
z
The z depth of the resulting drawable. If not
supplied, the z depth of ``self`` is used.
Returns
-------
Drawable
A new drawable that renders as the original but rotated.
"""
if z is None:
z = self.z
return RotatedDrawable(self, radians=radians, z=z)
[docs] def rotated_degrees(self, degrees: tvx.FloatOrTVF, z: Optional[tvx.FloatOrTVF] = None) -> 'Drawable':
"""
Return a version of the drawable that is rotated. See also
:py:meth:`~Drawable.rotated`.
Parameters
----------
degrees
How much to rotate by, in degrees.
z
The z depth of the resulting drawable. If not
supplied, the z depth of ``self`` is used.
Returns
-------
Drawable
A new drawable that renders as the original but rotated.
"""
if z is None:
z = self.z
return RotatedDrawable(self, radians=degrees * _radians_per_degree, z=z)
[docs]class TimeWindowDrawable(Drawable):
"""
A wrapper class that makes a drawable visible only in a certain time
range.
Note that this class is rarely one you will want to use directly. More often
you will want to use the method :py:meth:`~Drawable.fade_to` in order to produce
an animation in which an objects appears and or disappears at various times. This
class is used internally inside methods like :py:meth:`~XYDrawable.move_to`.
This wrapper class takes a :py:class:`~Drawable` `d` at construction time,
draws itself exactly as `d` would between times `t0` and `t1`, but is completely
invisible at any time before `t0` or after `t1`.
Parameters
----------
d
A drawable that we only want to appear during a single specific time range.
t0
The beginning of the time range when `d` should appear. It will not be
visible before this time.
t1
The end of the time range when `d` should appear. It will not be visible
after this time.
z
The z-order of the drawable.
"""
def __init__(self, d: Drawable, t0: float, t1: float, z: tvx.FloatOrTVF = 0.0):
super().__init__(z=z)
self._d = d
self._t0 = t0
self._t1 = t1
[docs] def draw(self, ctx: cairo.Context, t: float) -> None:
if self._t0 <= t < self._t1:
self._d.draw(ctx, t)
[docs]@dataclass(init=False)
class XYDrawable(Drawable, metaclass=ABCMeta):
"""
An abstract base class for :py:class:`~Drawable`
objects that that have a specific location
specified by a point (`x`, `y`) and a rotation
specified by an angle `theta`. Most commonly used
drawable classes derive from this class.
Parameters
----------
x
x location
y
y location
theta
Angle of rotation
z
z depth
alpha
opacity. 0.0 = transparent; 1.0 = opaque
"""
x: tvx.FloatOrTVF = 0.0 #: `x` value of the object's location. May be time-varying.
y: tvx.FloatOrTVF = 0.0 #: `y` value of the object's location. May be time-varying.
theta: tvx.FloatOrTVF = 0.0 #: Angle of rotation in radians. May be time-varying.
def __init__(
self,
x: tvx.FloatOrTVF = 0.0,
y: tvx.FloatOrTVF = 0.0,
theta: tvx.FloatOrTVF = 0.0,
z: tvx.FloatOrTVF = 0.0,
alpha: tvx.FloatOrTVF = 1.0,
):
super().__init__(z=z, alpha=alpha)
self.x = x
self.y = y
self.theta = theta
[docs] def xy(self) -> Tuple[tvx.FloatOrTVF, tvx.FloatOrTVF]:
"""
Return the possibly time-varying values of `x` and `y`.
Returns
-------
Tuple[tvx.FloatOrTVF, tvx.FloatOrTVF]
The tuple ``(self.x, self.y)``.
"""
return self.x, self.y
_SCAFFOLD_PATH_COLOR = gewel.color.Color(0.5, 0.5, 0.5, 0.5)
_SCAFFOLD_POINT_COLOR = gewel.color.Color(0.25, 0.25, 1.0, 0.5)
_SCAFFOLD_OBJECT_COLOR = gewel.color.Color(1.0, 0.25, 0.25, 0.5)
[docs] def move_to(
self,
x: float, y: float, duration: float,
update_time: bool = True,
scaffold: bool = False
) -> Optional[Drawable]:
"""
Move to a new location over the course of a given amount of time.
This method is used in the scripting phase of the object's lifetime
to schedule it to move from one location on or off-screen to another.
This is one of the core methods used to produce animations.
The object follows a linear path parameterized by :math:`t` which
varies from 0 to 1 over the duration of motion. The position of
the object for any given value of :math:`t` is
.. math::
x = (1 - t) x_0 + t x_1
y = (1 - t) y_0 + t y_1
Note that when :math:`t = 0`, :math:`x = x_0` and :math:`y = y_0`, putting us
at the starting point. When :math:`t = 1`, :math:`x = x_1` and :math:`y = y_1`, putting us
at the final point.
See :ref:`move_to_sample` for sample usage.
Parameters
----------
x
The x coordinate to move to.
y
The y coordinate to move to.
duration
The amount of time, in seconds, the move from the current
location to the new location should take.
update_time
Should the object's time be updated. Normally this is `True`. Making
it `False` does not change the time, so that other updates can be
made on the same object at the same time it is moving. For example, :py:meth:`~rotate_to`
can be called to make the object spin as it moves.
scaffold
Should we also generate scaffolding to show the path that the object
will move along during animation? This is primarily for debugging purposes.
Returns
-------
:py:class:`~Drawable` or ``None``
``None`` if ``scaffold`` is ``False`` or a :py:class:`~Drawable` representing
the scaffolding if the ``scaffold`` is ``True``.
"""
x0, y0 = self.x, self.y
xt, yt = tvx.utils.path(x0, y0, x, y, start=self.time, duration=duration)
self.x = tvx.cut(self.x, self.time, xt)
self.y = tvx.cut(self.y, self.time, yt)
if scaffold:
path = PathDrawable([x0, x], [y0, y], color=XYDrawable._SCAFFOLD_PATH_COLOR)
start_marker = MarkerX(x0, y0, color=XYDrawable._SCAFFOLD_POINT_COLOR)
end_marker = MarkerX(x, y, color=XYDrawable._SCAFFOLD_POINT_COLOR)
object_marker = MarkerX(self.x, self.y, color=XYDrawable._SCAFFOLD_OBJECT_COLOR, z=1.0)
scaffold_scene = Scene([path, start_marker, end_marker, object_marker])
scaffold_scene = TimeWindowDrawable(scaffold_scene, self.time, self.time + duration)
scaffold_scene.wait_until(self.time + duration)
else:
scaffold_scene = None
self._manage_time(duration, update_time)
return scaffold_scene
[docs] def track(
self,
other: 'XYDrawable',
x_offset: tvx.FloatOrTVF = 0.0,
y_offset: tvx.FloatOrTVF = 0.0,
):
"""
Track the motion of another object at a given offset.
The offset can be either a constant or time-varying.
The tracking
begins at ``self``'s next-action time and continues
until either the scene ends or another action that
affects the location is taken.
See :ref:`track_sample` for sample code.
Parameters
----------
other
The object to offset from.
x_offset
The offset in the `x` direction.
y_offset
The offset in the `y` direction.
"""
self.x = tvx.cut(self.x, self.time, other.x + x_offset)
self.y = tvx.cut(self.y, self.time, other.y + y_offset)
[docs] def orbit(
self,
other: Union['XYDrawable', Tuple[tvx.FloatOrTVF, tvx.FloatOrTVF]],
x_amplitude: tvx.FloatOrTVF,
y_amplitude: tvx.FloatOrTVF,
orbit_duration: float,
phase: float = 0.0,
ccw: bool = False
):
"""
Orbit another object or a point. The orbit motion
begins at ``self``'s next-action time and continues
until either the scene ends or another action that
affects the location is taken.
See :ref:`orbit_sample` for sample code.
Parameters
----------
other
The object to orbit. If it is an :py:class:`~XYDrawable`, the
center of the orbit will be it's location. If it is a tuple of
two elements, those elements are the `x` and `y` center of the
orbit. In either case, the center of the orbit can be time-varying.
x_amplitude
The amplitude of the orbit in the `x` direction.
y_amplitude
The amplitude of the orbit in the `y` direction.
orbit_duration
How long it takes to complete one orbit, in seconds.
phase
The phase of the orbit in radians. This determines the location
of the orbiting object at the beginning of the orbit time.
ccw
If ``TRUE`` the orbit is in a counter-clockwise direction. Otherwise,
it is in a clockwise direction.
"""
if isinstance(other, XYDrawable):
cx, cy = other.xy()
else:
cx, cy = other
frequency = 1 / orbit_duration
x_orbit = cx + tvx.utils.sine_wave(frequency, x_amplitude, phase - np.pi / 2)
self.x = tvx.cut(self.x, self.time, x_orbit)
y_wave = tvx.utils.sine_wave(frequency, y_amplitude, phase)
y_orbit = cy + y_wave if ccw else cy - y_wave
self.y = tvx.cut(self.y, self.time, y_orbit)
[docs] def quadratic_move_to(
self, x1: float, y1: float, x2: float, y2: float,
duration: float, update_time: bool = True,
scaffold: bool = False
) -> Optional[Drawable]:
"""
Move to a new location over the course of a given amount of time,
following a quadratic path specified by the current position, a final
position, and a control point. This is similar to :py:meth:`~move_to`,
which moves an object along a linear path.
This method is used in the scripting phase of the object's lifetime
to schedule it to move from one location on or off-screen to another.
This is one of the core methods used to produce animations.
See :ref:`quad_move_to_sample` for sample code.
The object follows a quadratic path parameterized by :math:`t` which
varies from 0 to 1 over the duration of motion. The position of
the object for any given value of :math:`t` is
.. math::
x = (1 - t)^2 x_0 + 2 (1 - t) t x_1 + t^2 x_2
y = (1 - t)^2 y_0 + 2 (1 - t) t y_1 + t^2 y_2
Note that when :math:`t = 0`, :math:`x = x_0` and :math:`y = y_0`, putting us
at the starting point. When :math:`t = 1`, :math:`x = x_2` and :math:`y = y_2`, putting us
at the final point.
Parameters
----------
x1
The x coordinate of the control point.
y1
The y coordinate of the control point.
x2
The x coordinate to move to.
y2
The y coordinate to move to.
duration
The amount of time, in seconds, the move from the current
location to the new location should take.
update_time
Should the object's time be updated. Normally this is `True`. Making
it `False` does not change the time, so that other updates can be
made on the same object at the same time it is moving. For example, :py:meth:`~rotate_to`
can be called to make the object spin as it moves.
scaffold
Should we also generate scaffolding to show the path that the object
will move along during animation? This is primarily for debugging purposes.
Returns
-------
:py:class:`~Drawable` or ``None``
``None`` if ``scaffold`` is ``False`` or a :py:class:`~Drawable` representing
the scaffolding if the ``scaffold`` is ``True``.
"""
x0, y0 = self.x, self.y
xt, yt = tvx.utils.quadratic_path(x0, y0, x1, y1, x2, y2, start=self.time, duration=duration)
self.x = tvx.cut(self.x, self.time, xt)
self.y = tvx.cut(self.y, self.time, yt)
if scaffold:
path = QuadraticCurveDrawable(x0, y0, x1, y1, x2, y2, color=XYDrawable._SCAFFOLD_PATH_COLOR)
control_path = PathDrawable([x0, x1, x2], [y0, y1, y2], color=XYDrawable._SCAFFOLD_PATH_COLOR)
start_marker = MarkerX(x0, y0, color=XYDrawable._SCAFFOLD_POINT_COLOR)
control_marker = MarkerX(x1, y1, color=XYDrawable._SCAFFOLD_POINT_COLOR)
end_marker = MarkerX(x2, y2, color=XYDrawable._SCAFFOLD_POINT_COLOR)
object_marker = MarkerX(self.x, self.y, color=XYDrawable._SCAFFOLD_OBJECT_COLOR, z=1.0)
scaffold_scene = Scene([
path, control_path, start_marker, control_marker, end_marker, object_marker
])
scaffold_scene = TimeWindowDrawable(scaffold_scene, self.time, self.time + duration)
scaffold_scene.wait_until(self.time + duration)
else:
scaffold_scene = None
self._manage_time(duration, update_time)
return scaffold_scene
[docs] def bezier_move_to(
self, x1: float, y1: float, x2: float, y2: float, x3: float, y3: float,
duration: float, update_time: bool = True,
scaffold: bool = False
):
"""
Move to a new location over the course of a given amount of time,
following a cubic bezier path specified by the current position, a final
position, and two control points. This is similar to :py:meth:`~move_to`,
which moves an object along a linear path and :py:meth:`~quadratic_move_to`,
which moves along a quadratic path.
This method is used in the scripting phase of the object's lifetime
to schedule it to move from one location on or off-screen to another.
This is one of the core methods used to produce animations.
See :ref:`bezier_move_to_sample` for sample code.
The object follows a cubic path parameterized by :math:`t` which
varies from 0 to 1 over the duration of motion. The position of
the object for any given value of :math:`t` is
.. math::
x = (1 - t)^3 x_0 + 3 (1 - t)^2 t x_1 + 3 (1 - t) t^2 x_2 + t^3 x_3
y = (1 - t)^3 y_0 + 3 (1 - t)^2 t y_1 + 3 (1 - t) t^2 y_2 + t^3 x_3
Note that when :math:`t = 0`, :math:`x = x_0` and :math:`y = y_0`, putting us
at the starting point. When :math:`t = 1`, :math:`x = x_3` and :math:`y = y_3`, putting us
at the final point.
Parameters
----------
x1
The x coordinate of the first control point.
y1
The y coordinate of the first control point.
x2
The x coordinate the second control point.
y2
The y coordinate the second control point.
x3
The x coordinate to move to.
y3
The y coordinate to move to.
duration
The amount of time, in seconds, the move from the current
location to the new location should take.
update_time
Should the object's time be updated. Normally this is `True`. Making
it `False` does not change the time, so that other updates can be
made on the same object at the same time it is moving. For example, :py:meth:`~rotate_to`
can be called to make the object spin as it moves.
scaffold
Should we also generate scaffolding to show the path that the object
will move along during animation? This is primarily for debugging purposes.
Returns
-------
:py:class:`~Drawable` or ``None``
``None`` if ``scaffold`` is ``False`` or a :py:class:`~Drawable` representing
the scaffolding if the ``scaffold`` is ``True``.
"""
x0, y0 = self.x, self.y
xt, yt = tvx.utils.bezier_path(
x0, y0, x1, y1, x2, y2, x3, y3, start=self.time, duration=duration
)
self.x = tvx.cut(self.x, self.time, xt)
self.y = tvx.cut(self.y, self.time, yt)
if scaffold:
path = BezierDrawable(x0, y0, x1, y1, x2, y2, x3, y3, color=XYDrawable._SCAFFOLD_PATH_COLOR)
control_path = PathDrawable([x0, x1, x2, x3], [y0, y1, y2, y3], color=XYDrawable._SCAFFOLD_PATH_COLOR)
start_marker = MarkerX(x0, y0, color=XYDrawable._SCAFFOLD_POINT_COLOR)
control_marker_1 = MarkerX(x1, y1, color=XYDrawable._SCAFFOLD_POINT_COLOR)
control_marker_2 = MarkerX(x2, y2, color=XYDrawable._SCAFFOLD_POINT_COLOR)
end_marker = MarkerX(x3, y3, color=XYDrawable._SCAFFOLD_POINT_COLOR)
object_marker = MarkerX(self.x, self.y, color=XYDrawable._SCAFFOLD_OBJECT_COLOR, z=1.0)
scaffold_scene = Scene([
path, control_path, start_marker, control_marker_1, control_marker_2,
end_marker, object_marker
])
scaffold_scene = TimeWindowDrawable(scaffold_scene, self.time, self.time + duration)
scaffold_scene.wait_until(self.time + duration)
else:
scaffold_scene = None
self._manage_time(duration, update_time)
return scaffold_scene
[docs] def rotate_to(
self,
theta: float,
duration: float,
update_time: bool = True,
scaffold: bool = False
) -> Optional[Drawable]:
"""
Rotate to a new angle over the course of a given amount of time.
This method is used in the scripting phase of the object's lifetime
to schedule it to rotate the object.
This is one of the core methods used to produce animations.
See :ref:`rotate_to_sample` for sample usage.
Parameters
----------
theta
The angle to rotate to, expressed in radians. The drawable will be rotated from
it's current angle
to this value. When a drawable is first created it's angle is
normally set to zero, though individual class constructors may allow this
default to be overridden.
If you would prefer to specify angles in degrees instead of radians, see
:py:meth:`~XYDrawable.rotate_to_degrees`.
duration
The amount of time, in seconds, the rotation from the current
angle to the new angle should take.
update_time
Should the object's time be updated. Normally this is `True`. Making
it `False` does not change the time, so that other updates can be
made on the same object at the same time it is moving. For example, :py:meth:`~move_to`
can be called to make the object move as it rotates.
scaffold
Should we also generate scaffolding to show the path that the object
will move along during animation? This is primarily for debugging purposes.
Returns
-------
:py:class:`~Drawable` or `None`
`None` if `scaffold` is `False` or a :py:class:`~Drawable` representing
the scaffolding if the `scaffold` is `True`.
"""
theta0 = self.theta
theta_t = tvx.ramp(theta0, theta, self.time, duration)
self.theta = tvx.cut(self.theta, self.time, theta_t)
if scaffold:
start_marker = MarkerUpArrow(
self.x, self.y, theta=theta0,
width=20, height=50,
color=XYDrawable._SCAFFOLD_POINT_COLOR,
cross=True,
z=0.5
)
end_marker = MarkerUpArrow(
self.x, self.y, theta=theta,
width=20, height=50,
color=XYDrawable._SCAFFOLD_POINT_COLOR,
cross=True,
z=0.5
)
object_marker = MarkerUpArrow(
self.x, self.y, theta=self.theta,
width=20, height=50,
color=XYDrawable._SCAFFOLD_OBJECT_COLOR,
z=1.0
)
scaffold_scene = Scene([
start_marker, end_marker, object_marker
])
scaffold_scene = TimeWindowDrawable(scaffold_scene, self.time, self.time + duration)
scaffold_scene.wait_until(self.time + duration)
else:
scaffold_scene = None
self._manage_time(duration, update_time)
return scaffold_scene
[docs] def rotate_to_degrees(
self,
degrees: float,
duration: float,
update_time: bool = True,
scaffold: bool = False
) -> Optional[Drawable]:
"""
Rotate the object a certain number of degrees. See :py:meth:`~XYDrawable.rotate_to`
for more details and an example. The only difference here is that the angle of
rotation is expressed in degrees instead of radians.
Note that
.. code-block:: python
d.rotate_to_degrees(180, 1.0)
and
.. code-block:: python
import numpy as np
d.rotate_to(np.pi, 1.0)
are equivalent because 180 degrees is the same as pi radians.
See :ref:`rotate_to_degrees_sample` for sample usage.
Parameters
----------
degrees
The angle to rotate to, expressed in degrees. The drawable will be rotated from
it's current angle
to this value. When a drawable is first created it's angle is
normally set to zero, though individual class constructors may allow this
default to be overridden.
If you would prefer to specify angles in radians instead of degrees, see
:py:meth:`~XYDrawable.rotate_to`.
duration
The amount of time, in seconds, the rotation from the current
angle to the new angle should take.
update_time
Should the object's time be updated. Normally this is ``True``. Making
it ``False`` does not change the time, so that other updates can be
made on the same object at the same time it is moving. For example, :py:meth:`~move_to`
can be called to make the object move as it rotates.
scaffold
Should we also generate scaffolding to show the path that the object
will move along during animation? This is primarily for debugging purposes.
Returns
-------
:py:class:`~Drawable` or ``None``
``None`` if ``scaffold`` is ``False`` or a :py:class:`~Drawable` representing
the scaffolding if the ``scaffold`` is ``True``.
"""
return self.rotate_to(degrees * _radians_per_degree, duration, update_time, scaffold)
[docs] def current_xy(self, t: float) -> Tuple[float, float]:
"""
Compute the value of `x` and `y` at time `t`.
``self.x`` and ``self.y`` may be time-varying
values (See :py:mod:`tvx` for details).
This method returns the floating point values
computed for the specified time.
Parameters
----------
t
The time to evaluate the time-varying values
``self.x`` and ``self.y``
Returns
-------
Tuple[float, float]
A tuple containing the `x` and `y` values at the
current time.
"""
x = self.x(t)
y = self.y(t)
return x, y
[docs]class Background(Drawable):
"""
A background drawable. Unlike most subclasses of :py:class:`~Drawable`,
objects of this class have no fixed position or size. Instead, they
simply fill the entire rendering surface with a color. In order to ensure
that this is done behind all of the other objects in a :py:class:`~Scene`,
the default z value for a background is -1. For most other classes, the
default z is 0.
Parameters
----------
color
The color of the background.
z
The z value of the background.
"""
def __init__(self, color: Optional[BaseColor] = None, z: float = -1.0):
super().__init__(z)
if color is None:
color = BACKGROUND
self._color = color
[docs] def draw(self, ctx: cairo.Context, t: float) -> None:
with _time_device_drawing():
with ctx:
ctx.reset_clip()
ctx.set_source_rgba(*self._color.tuple(t, alpha_multiplier=float_at_time(self.alpha, t)))
ctx.paint()
# noinspection PyPropertyDefinition
[docs]class BoundedDrawableProtocol(Protocol):
"""
A protocol for drawables that have a location,
width, height, and angle of rotation.
"""
@property
def x(self) -> tvx.FloatOrTVF: ...
@property
def y(self) -> tvx.FloatOrTVF: ...
@property
def theta(self) -> tvx.FloatOrTVF: ...
@property
def centered(self) -> bool: ...
@property
def width(self) -> tvx.FloatOrTVF: ...
@property
def height(self) -> tvx.FloatOrTVF: ...
# noinspection PyPropertyDefinition
[docs]class AlphaDrawableProtocol(TimekeeperProtocol):
"""
A protocol for drawables that support alpha.
"""
@property
def alpha(self) -> tvx.FloatOrTVF: ...
@alpha.setter
def alpha(self, alpha: tvx.FloatOrTVF): ...
[docs]class BoundedMixin:
"""
A mixin that provides common :py:class:`~BoundedDrawableProtocol` functionality.
"""
def center(self: BoundedDrawableProtocol) -> Tuple[float, float]:
if self.centered:
return self.x, self.y
else:
return self.x + self.width / 2, self.y + self.height / 2
def local_xform(self: BoundedDrawableProtocol, t: float) -> cairo.Matrix:
x = float_at_time(self.x, t)
y = float_at_time(self.y, t)
theta = float_at_time(self.theta, t)
xf_rotate = cairo.Matrix.init_rotate(theta)
xf_translate = cairo.Matrix(x0=x, y0=y)
xf = xf_rotate * xf_translate
if self.centered:
width = float_at_time(self.width, t)
height = float_at_time(self.height, t)
xf_center = cairo.Matrix(x0=-width / 2, y0=-height / 2)
xf = xf_center * xf
return xf
[docs]@dataclass(init=False)
class Box(XYDrawable, BoundedMixin):
"""
A rectangular axis-aligned box.
Parameters
----------
x
x location of the box
y
y location of the box
width
width of the box
height
height of the box
color
color of the line segments around the permimeter
of the box
fill_color
color to fill the box
line_width
width of the line segments around the permimeter
of the box
z
z depth of the box
alpha
alpha value of the box
"""
width: tvx.FloatOrTVF = 0.0
height: tvx.FloatOrTVF = 0.0
color: BaseColor = TRANSPARENT
fill_color: BaseColor = TRANSPARENT
def __init__(
self,
x: tvx.FloatOrTVF,
y: tvx.FloatOrTVF,
width: tvx.FloatOrTVF,
height: tvx.FloatOrTVF,
color: BaseColor,
fill_color: BaseColor = TRANSPARENT,
line_width: tvx.FloatOrTVF = 1.0,
z: tvx.FloatOrTVF = 0.0,
alpha: tvx.FloatOrTVF = 1.0,
):
super().__init__(x=x, y=y, theta=0.0, z=z, alpha=alpha)
self.width = width
self.height = height
self.color = color
self.fill_color = fill_color
self.line_width = line_width
[docs] def draw(self, ctx: cairo.Context, t: float) -> None:
width = float_at_time(self.width, t)
if width == 0.0:
return
height = float_at_time(self.height, t)
if height == 0.0:
return
x0 = float_at_time(self.x, t)
y0 = float_at_time(self.y, t)
x1 = float_at_time(self.x, t) + width
y1 = float_at_time(self.y, t) + height
with _time_device_drawing():
if not self.fill_color.is_transparent():
ctx.move_to(x0, y0)
ctx.line_to(x0, y1)
ctx.line_to(x1, y1)
ctx.line_to(x1, y0)
ctx.close_path()
ctx.set_source_rgba(*self.fill_color.tuple(
t, alpha_multiplier=float_at_time(self.alpha, t)
))
ctx.clip()
ctx.paint()
ctx.reset_clip()
if not self.color.is_transparent():
ctx.set_line_width(float_at_time(self.line_width, t))
ctx.move_to(x0, y0)
ctx.line_to(x0, y1)
ctx.line_to(x1, y1)
ctx.line_to(x1, y0)
ctx.close_path()
ctx.set_source_rgba(*self.color.tuple(
t, alpha_multiplier=float_at_time(self.alpha, t)
))
ctx.stroke()
[docs] def rotate_to(
self,
theta: float,
duration: float,
update_time: bool = True,
scaffold: bool = False
) -> Optional[Drawable]:
"""
This method is not currently available on this class. Calling it will
raise an exception.
"""
raise NotImplementedError("A box is always axis aligned. It cannot be rotated.")
[docs] def contains(self, x: tvx.FloatOrTVF, y: tvx.FloatOrTVF) -> Union[bool, tvx.Tvb]:
"""
Does this box contain a point?
Parameters
----------
x
The x value of the test point. This may be a time-varying
value.
y
The y value of the test point. This may be a time-varying
value.
Returns
-------
A :py:class:`tvx.Tvb` that evaluates to `True` at any time at which the point
is inside the box. It evaluates to `False` at any time at which the point
is not inside the box.
If the point is on the boundary of the box, it is considered to be
contained.
"""
contains_expression = (
(self.x <= x) & (x <= self.x + self.width) &
(self.y <= y) & (y <= self.y + self.height)
)
return contains_expression
[docs]@dataclass(init=False)
class VectorDrawable(XYDrawable, metaclass=ABCMeta):
"""
The abstract base class for various :py:class:`~Drawable` classes
that are rendered using lines and/or curves, but not, for example,
bitmaps. Examples include the various marker classes such as
:py:class:`~MarkerX` and :py:class:`~MarkerPlus`.
This class holds basic data that such classes need, such as position,
angle, alpha, color, and line width. The :py:meth:`~draw` method
remains abstract. It is up to derived classes to define it.
This is an abstract class and will not normally be used directly by
users unless they are implementing a new marker class.
Parameters
----------
x
x location
y
y location
theta
angle of rotation
color
color
line_width
line width
z
depth
alpha
alpha
"""
color: BaseColor = BLACK
line_width: tvx.FloatOrTVF = 1.0
def __init__(
self,
x: tvx.FloatOrTVF, y: tvx.FloatOrTVF,
theta: tvx.FloatOrTVF = 0.0,
color: Optional[BaseColor] = None,
line_width: tvx.FloatOrTVF = 1.0,
z: tvx.FloatOrTVF = 0.0,
alpha: tvx.FloatOrTVF = 1.0,
):
super().__init__(x, y, theta=theta, z=z, alpha=alpha)
if color is None:
color = BLACK
self.color = color
self.line_width = line_width
[docs]@dataclass(init=False)
class PathDrawable(VectorDrawable):
"""
A class of drawable that renders a piecewise linear
path. It is currently only used in the rendering of scaffolding.
Parameters
----------
xs
The x coordinates of the points on the path.
ys
The y coordinates of the points on the path.
closed
`True` if we should connect the final point back
to the first point.
color
Color of the line.
line_width
Width of the line.
z
z order within a scene
"""
xs: List[tvx.FloatOrTVF] = None
ys: List[tvx.FloatOrTVF] = None
closed: bool = False
def __init__(
self,
xs: Iterable[tvx.FloatOrTVF], ys: Iterable[tvx.FloatOrTVF],
closed: bool = False,
color: Optional[BaseColor] = None,
line_width: tvx.FloatOrTVF = 1.0,
z: tvx.FloatOrTVF = 0.0,
):
xs = list(xs)
ys = list(ys)
self._init_done = False
super().__init__(xs[0], ys[0], 0.0, color, line_width, z)
self.xs = xs
self.ys = ys
self.closed = closed
self._init_done = True
@property
def x(self) -> tvx.FloatOrTVF:
return self.xs[0]
@x.setter
def x(self, value) -> None:
if self._init_done:
self.xs[0] = value
@property
def y(self) -> tvx.FloatOrTVF:
return self.ys[0]
@y.setter
def y(self, value) -> None:
if self._init_done:
self.ys[0] = value
[docs] def draw(self, ctx: cairo.Context, t: float) -> None:
x = float_at_time(self.x, t)
y = float_at_time(self.y, t)
to_x = [float_at_time(x, t) for x in self.xs[1:]]
to_y = [float_at_time(y, t) for y in self.ys[1:]]
with _time_device_drawing():
ctx.set_source_rgba(*self.color.tuple(
t, alpha_multiplier=float_at_time(self.alpha, t)
))
ctx.set_line_width(self.line_width)
ctx.move_to(x, y)
for x, y in zip(to_x, to_y):
ctx.line_to(x, y)
if self.closed:
ctx.close_path()
ctx.stroke()
[docs]@dataclass(init=False)
class BezierDrawable(VectorDrawable):
"""
A Bezier curve parameterized by four points.
This can be used to draw a Bezier curve as part
of an animation. It is also used to generate
scaffolding for :py:meth:`~XYDrawable.bezier_move_to`.
Note that, as is the case for most :py:class:`~Drawable`
classes, the properties that define its shape need not
be constants. They can be time-varying values, such as
properties of other :py:class:`~Drawable` objects that
are in motion.
See :ref:`partial_motion_linking_samples` for an example
of the use of this class.
Parameters
----------
x0
x coordinate of the start point
y0
y coordinate of the start point
x1
x coordinate of the first control point
y1
y coordinate of the first control point
x2
x coordinate of the second control point
y2
y coordinate of the second control point
x3
x coordinate of the end point
y3
y coordinate of the end point
color
color of the curve
line_width
width of the curve
z
z depth
"""
x0: tvx.FloatOrTVF = 0.0
y0: tvx.FloatOrTVF = 0.0
x1: tvx.FloatOrTVF = 0.0
y1: tvx.FloatOrTVF = 0.0
x2: tvx.FloatOrTVF = 0.0
y2: tvx.FloatOrTVF = 0.0
x3: tvx.FloatOrTVF = 0.0
y3: tvx.FloatOrTVF = 0.0
def __init__(
self,
x0: tvx.FloatOrTVF, y0: tvx.FloatOrTVF,
x1: tvx.FloatOrTVF, y1: tvx.FloatOrTVF,
x2: tvx.FloatOrTVF, y2: tvx.FloatOrTVF,
x3: tvx.FloatOrTVF, y3: tvx.FloatOrTVF,
color: Optional[BaseColor] = None,
line_width: tvx.FloatOrTVF = 1.0,
z: tvx.FloatOrTVF = 0.0,
):
super().__init__(x0, y0, 0.0, color, line_width, z)
self.x1 = x1
self.y1 = y1
self.x2 = x2
self.y2 = y2
self.x3 = x3
self.y3 = y3
[docs] def draw(self, ctx: cairo.Context, t: float) -> None:
with _time_device_drawing():
ctx.set_source_rgba(*self.color.tuple(
t, alpha_multiplier=float_at_time(self.alpha, t)
))
ctx.set_line_width(self.line_width)
ctx.set_line_cap(cairo.LINE_CAP_ROUND)
ctx.move_to(float_at_time(self.x, t), float_at_time(self.y, t))
ctx.curve_to(
float_at_time(self.x1, t), float_at_time(self.y1, t),
float_at_time(self.x2, t), float_at_time(self.y2, t),
float_at_time(self.x3, t), float_at_time(self.y3, t)
)
ctx.stroke()
[docs]class QuadraticCurveDrawable(BezierDrawable):
"""
A quadratic curve parameterized by three points.
This can be used to draw a quadratic curve as part
of an animation. It is also used to generate
scaffolding for :py:meth:`~XYDrawable.quadratic_move_to`.
Note that, as is the case for most :py:class:`~Drawable`
classes, the properties that define its shape need not
be constants. They can be time-varying values, such as
properties of other :py:class:`~Drawable` objects that
are in motion.
See :ref:`partial_motion_linking_samples` for an example
of the use of this class.
Parameters
----------
x0
x coordinate of the start point
y0
y coordinate of the start point
x1
x coordinate of the control point
y1
y coordinate of the control point
x2
x coordinate of the end point
y2
y coordinate of the end point
color
color of the curve
line_width
width of the curve
z
z depth
"""
def __init__(
self,
x0: tvx.FloatOrTVF, y0: tvx.FloatOrTVF,
x1: tvx.FloatOrTVF, y1: tvx.FloatOrTVF,
x2: tvx.FloatOrTVF, y2: tvx.FloatOrTVF,
color: Optional[BaseColor] = None,
line_width: tvx.FloatOrTVF = 1.0,
z: tvx.FloatOrTVF = 0.0,
):
# It is possible to create a cubic bezier curve
# that is actually quadratic and matches a given
# quadratic curve by putting the two middle control
# points 2/3 of the way from the end points to the
# center control point of the quadratic curve.
super().__init__(
x0, y0,
2.0 * x1 / 3.0 + x0 / 3.0, 2.0 * y1 / 3.0 + y0 / 3.0,
2.0 * x1 / 3.0 + x2 / 3.0, 2.0 * y1 / 3.0 + y2 / 3.0,
x2, y2,
color, line_width, z
)
[docs]@dataclass(init=False)
class MarkerBase(VectorDrawable, BoundedMixin, metaclass=ABCMeta):
"""
The abstract base class for various marker classes, such as :py:class:`~MarkerX`,
:py:class:`~MarkerPlus` and so on. This holds basic data that markers
tend to share, such as position, angle, alpha, color, and line width.
This is an abstract class and will not normally be used directly by
users unless they are implementing a new marker class.
The various kinds of markers available are illustrated in the sample
code in the :ref:`marker_sample` section.
Parameters
----------
x
x location
y
y location
width
width of the marker
height
height of the marker
theta
angle of the marker
color
color of the marker
line_width
line width
z
depth of the marker
alpha
alpha of the marker
"""
width: tvx.FloatOrTVF = 0.0
def __init__(
self,
x: tvx.FloatOrTVF = 0.0,
y: tvx.FloatOrTVF = 0.0,
width: tvx.FloatOrTVF = 16.0,
height: Optional[tvx.FloatOrTVF] = None,
theta: tvx.FloatOrTVF = 0.0,
color: Optional[BaseColor] = None,
line_width: tvx.FloatOrTVF = 1.0,
z: tvx.FloatOrTVF = 0.0,
alpha: tvx.FloatOrTVF = 1.0,
):
super().__init__(x, y, theta, color, line_width, z, alpha=alpha)
self.width = width
if height is None:
height = width
self.height = height
self.theta = theta
self.centered = True
def _pre_draw(self, ctx: cairo.Context, t: float):
ctx.set_line_width(self.line_width)
ctx.set_line_cap(cairo.LINE_CAP_ROUND)
ctx.set_source_rgba(*self.color.tuple(
t, alpha_multiplier=float_at_time(self.alpha, t)
))
[docs]class MarkerPlus(MarkerBase):
"""
A marker that has the shape of a plus sign (+). See :py:class:`~MarkerBase`
for more details on markers.
Parameters
----------
x
x location
y
y location
width
width of the marker
height
height of the marker
theta
angle of the marker
color
color of the marker
line_width
line width
z
depth of the marker
alpha
alpha of the marker
"""
[docs] def draw(self, ctx: cairo.Context, t: float) -> None:
width = float_at_time(self.width, t)
height = float_at_time(self.height, t)
with _time_device_drawing():
self._pre_draw(ctx, t)
xform = self.local_xform(t)
with _xform_context(ctx, xform):
ctx.move_to(0.0, height / 2)
ctx.line_to(width, height / 2)
ctx.move_to(width / 2, 0.0)
ctx.line_to(width / 2, height)
ctx.stroke()
[docs]class MarkerUpArrow(MarkerBase):
"""
A marker that has the shape of an upward-pointing arrow.
See :py:class:`~MarkerBase`
for more details on markers.
Parameters
----------
x
x location
y
y location
width
width of the marker
height
height of the marker
cross
If `True`, put a small crossing line at the center
theta
angle of the marker
color
color of the marker
line_width
line width
z
depth of the marker
alpha
alpha of the marker
"""
def __init__(
self,
x: tvx.FloatOrTVF = 0.0,
y: tvx.FloatOrTVF = 0.0,
width: tvx.FloatOrTVF = 16.0,
height: Optional[tvx.FloatOrTVF] = None,
cross: bool = False,
theta: tvx.FloatOrTVF = 0.0,
color: Optional[BaseColor] = None,
line_width: tvx.FloatOrTVF = 1.0,
z: tvx.FloatOrTVF = 0.0,
alpha: tvx.FloatOrTVF = 1.0,
):
if height is None:
height = 2 * width
super().__init__(
x, y, width, height, theta,
color, line_width, z, alpha
)
self._cross = cross
[docs] def draw(self, ctx: cairo.Context, t: float) -> None:
width = float_at_time(self.width, t)
height = float_at_time(self.height, t)
with _time_device_drawing():
self._pre_draw(ctx, t)
xform = self.local_xform(t)
with _xform_context(ctx, xform):
ctx.move_to(width / 2, 0.0)
ctx.line_to(width / 2, height)
ctx.move_to(0.0, height / 4)
ctx.line_to(width / 2, 0.0)
ctx.line_to(width, height / 4)
if self._cross:
ctx.move_to(width / 4, height / 2)
ctx.line_to(3 * width / 4, height / 2)
ctx.stroke()
[docs]class MarkerX(MarkerBase):
"""
A marker that is the shape of an X. See :py:class:`~MarkerBase`
for more details on markers.
Parameters
----------
x
x location
y
y location
width
width of the marker
height
height of the marker
theta
angle of the marker
color
color of the marker
line_width
line width
z
depth of the marker
alpha
alpha of the marker
"""
[docs] def draw(self, ctx: cairo.Context, t: float) -> None:
width = float_at_time(self.width, t)
height = float_at_time(self.height, t)
with _time_device_drawing():
self._pre_draw(ctx, t)
with _xform_context(ctx, self.local_xform(t)):
ctx.move_to(0.0, 0.0)
ctx.line_to(width, height)
ctx.move_to(0.0, height)
ctx.line_to(width, 0.0)
ctx.stroke()
[docs]class MarkerO(MarkerBase):
"""
A marker that is the shape of circle. See :py:class:`~MarkerBase`
for more details on markers.
Parameters
----------
x
x location
y
y location
width
width of the marker
height
height of the marker
theta
angle of the marker
color
color of the marker
line_width
line width
z
depth of the marker
alpha
alpha of the marker
"""
[docs] def draw(self, ctx: cairo.Context, t: float) -> None:
width = float_at_time(self.width, t)
height = float_at_time(self.height, t)
with _time_device_drawing():
self._pre_draw(ctx, t)
with _xform_context(ctx, self.local_xform(t)):
ctx.arc(width / 2, height / 2, width / 2, 0.0, 2 * np.pi)
ctx.stroke()
[docs]class MarkerDot(MarkerBase):
"""
A marker that is the shape of a filled circle. See :py:class:`~MarkerBase`
for more details on markers.
Parameters
----------
x
x location
y
y location
width
width of the marker
height
height of the marker
theta
angle of the marker
color
color of the marker
line_width
line width
z
depth of the marker
alpha
alpha of the marker
"""
[docs] def draw(self, ctx: cairo.Context, t: float) -> None:
width = float_at_time(self.width, t)
height = float_at_time(self.height, t)
with _time_device_drawing():
self._pre_draw(ctx, t)
with _xform_context(ctx, self.local_xform(t)):
ctx.arc(width / 2, height / 2, width / 2, 0.0, 2 * np.pi)
ctx.close_path()
ctx.clip()
ctx.paint()
ctx.reset_clip()
@dataclass(init=False)
class _BaseTextDrawable(XYDrawable):
"""
An abstract base class for drawables that render text.
"""
font_size: float = 12.0
color: BaseColor = gewel.color.BLACK
def __init__(
self,
x: tvx.FloatOrTVF,
y: tvx.FloatOrTVF,
font_size: float = 12.0,
color: BaseColor = gewel.color.BLACK,
z: tvx.FloatOrTVF = 0.0,
alpha: tvx.FloatOrTVF = 1
):
super().__init__(x, y, 0.0, z, alpha)
self.font_size = font_size
self.color = color
@abstractmethod
def text(self, t: float) -> str:
raise NotImplementedError(str(type(self)) + " is abstract.")
def draw(self, ctx: cairo.Context, t: float) -> None:
with _time_device_drawing():
ctx.move_to(float_at_time(self.x, t), float_at_time(self.y, t))
ctx.set_font_size(self.font_size)
ctx.set_source_rgba(*self.color.tuple(
t, alpha_multiplier=float_at_time(self.alpha, t)
))
ctx.show_text(self.text(t))
[docs]class ToyTextDrawable(_BaseTextDrawable):
"""
A very simple and all but deprecated text box that should
be replaced by :py:class:`~TextBox` in almost all cases.
"""
def __init__(
self,
x: tvx.FloatOrTVF,
y: tvx.FloatOrTVF,
text: str,
font_size: float = 12.0,
color: BaseColor = gewel.color.BLACK,
z: tvx.FloatOrTVF = 0.0,
alpha: tvx.FloatOrTVF = 1
):
super().__init__(x, y, font_size, color, z, alpha)
self._text = text
def text(self, t: float) -> str:
return self._text
[docs]class TimeClock(_BaseTextDrawable):
"""
A drawable that shows the current time.
This is commonly used while developing and debugging
animated scenes and then removed before the final version
is rendered.
The format of the time shown in `MM:SS.mmm' where `MM` is
minutes, `SS` is seconds, and `mmm` is milliseconds. If
the optional ``show_hours`` parameter is set to true, then
the format is `HH:MM:SS.mmm` where `HH` is hours.
See :ref:`update_time_samples` for an example of how this
class is typically used.
Parameters
----------
x
x location
y
y location
font_size
The side of the font to render the time.
color
The color of the the time.
show_hours
If ``True``, show hours, otherwise don't.
z
z depth
"""
def __init__(self,
x: tvx.FloatOrTVF,
y: tvx.FloatOrTVF,
font_size: float = 12,
color: BaseColor = gewel.color.BLACK,
show_hours: bool = False,
z: tvx.FloatOrTVF = 0.0):
super().__init__(x, y, font_size, color, z)
self._show_hours = show_hours
def text(self, t: float) -> str:
return tvx.utils.format_time(t, self._show_hours)
[docs]class TextJustification(Enum):
"""
An enumeration for specifying how to justify
text.
"""
LEFT = 1 #: Left justified.
RIGHT = 2 #: Right justified.
CENTER = 3 #: Centered.
[docs]class TextVerticalPosition(Enum):
"""
An enumeration for specifying the vertical position
of text.
"""
TOP = 1 #: Text appears at the top.
MIDDLE = 2 #: Text appears in the middle.
BOTTOM = 3 #: Text appears at the botton.
[docs]@dataclass(init=False)
class TextBox(ToyTextDrawable):
"""
A box containing text. The text size, justification, and
vertical position within the box are all configurable.
Parameters
----------
text
The text to put in the box. It may have newlines,
which will be rendered accordingly. It will also
wrap at white-space boundaries between words as needed
to avoid exceeding the width of the box.
x
The x position of the text box.
y
The y position of the text box.
width
The width of the text box.
height
The height of the text box.
font_size
The font size.
line_spacing
Line spacing, relative to font size. For exmaple,
2.0 means double spaced.
justification
Text justification, :py:attr:`~TextJustification.LEFT`,
:py:attr:`~TextJustification.RIGHT`, or
:py:attr:`~TextJustification.CENTER`.
vertical_position
Vertical position, :py:attr:`TextVerticalPosition.TOP`,
:py:attr:`TextVerticalPosition.BOTTOM`, or
:py:attr:`TextVerticalPosition.MIDDLE`.
color
Color of the text.
fill_color
Color to fill the box with.
line_color
Color of the line around the outside of the box.
line_width
Width of the line outside the box. 0.0 means no line.
z
z depth
alpha
alpha
"""
width: tvx.FloatOrTVF = 0.0
height: tvx.FloatOrTVF = 0.0
line_spacing: tvx.FloatOrTVF = 1.2
justification: TextJustification = TextJustification.LEFT
vertical_position: TextVerticalPosition = TextVerticalPosition.TOP
def __init__(
self,
text: str,
x: tvx.FloatOrTVF,
y: tvx.FloatOrTVF,
width: tvx.FloatOrTVF,
height: tvx.FloatOrTVF,
font_size: float = 12.0,
line_spacing: tvx.FloatOrTVF = 1.2,
justification: TextJustification = TextJustification.LEFT,
vertical_position: TextVerticalPosition = TextVerticalPosition.TOP,
color: BaseColor = gewel.color.BLACK,
fill_color: BaseColor = TRANSPARENT,
line_color: BaseColor = TRANSPARENT,
line_width: tvx.FloatOrTVF = 1,
z: tvx.FloatOrTVF = 0.0,
alpha: tvx.FloatOrTVF = 1
):
super().__init__(x, y, text, font_size, color, z, alpha)
self.width = width
self.height = height
self.line_spacing = line_spacing
self.justification = justification
self.vertical_position = vertical_position
self.fill_color = fill_color
self.line_color = line_color
self.line_width = line_width
# If we draw repeatedly at the same width,
# we're likely to not have to compute the lines
# to draw all over again.
#
# TODO: need to recompute splits if text size changes.
self._last_draw_location = (None, None)
self._last_draw_width = None
self._line_splits_for_width = None
[docs] def draw(self, ctx: cairo.Context, t: float) -> None:
box_height = float_at_time(self.height, t)
if box_height == 0.0:
return
box_width = float_at_time(self.width, t)
if box_width == 0.0:
return
x, y = float_at_time(self.x, t), float_at_time(self.y, t)
x1, y1 = x + box_width, y + box_height
with _time_device_drawing():
if box_width != self._last_draw_width or (x, y) != self._last_draw_location:
font_size = float_at_time(self.font_size, t)
self._line_splits_for_width = walk_text(
ctx, self.text(t),
x, y, box_width, font_size, self.line_spacing,
self.justification
)
self._last_draw_width = box_width
self._last_draw_location = (x, y)
if self.fill_color is not None:
if not self.fill_color.is_transparent():
ctx.move_to(x, y)
ctx.line_to(x, y1)
ctx.line_to(x1, y1)
ctx.line_to(x1, y)
ctx.close_path()
ctx.set_source_rgba(*self.fill_color.tuple(
t, alpha_multiplier=float_at_time(self.alpha, t)
))
ctx.clip()
ctx.paint()
ctx.reset_clip()
if not self.line_color.is_transparent():
ctx.set_line_width(float_at_time(self.line_width, t))
ctx.move_to(x, y)
ctx.line_to(x, y1)
ctx.line_to(x1, y1)
ctx.line_to(x1, y)
ctx.close_path()
ctx.set_source_rgba(*self.line_color.tuple(
t, alpha_multiplier=float_at_time(self.alpha, t)
))
ctx.stroke()
if not self.color.is_transparent():
# Figure out a top margin based on vertical
# position.
font_size = float_at_time(self.font_size, t)
text_height = (font_size + (len(self._line_splits_for_width) - 1) *
self.line_spacing * font_size)
if self.vertical_position == TextVerticalPosition.MIDDLE:
top_margin = (self.height - text_height - font_size) / 2
elif self.vertical_position == TextVerticalPosition.BOTTOM:
top_margin = self.height - text_height - font_size
else:
top_margin = 0.0
ctx.set_font_size(self.font_size)
ctx.set_source_rgba(*self.color.tuple(
t, alpha_multiplier=float_at_time(self.alpha, t)
))
for line_x, line_y, line in self._line_splits_for_width:
if line is not None:
ctx.move_to(line_x, line_y + top_margin)
ctx.show_text(line)
[docs]def walk_text(
ctx: cairo.Context,
text: str,
x: float, y: float,
box_width: float,
font_size: float,
line_spacing: float,
justification: TextJustification,
) -> List[Tuple[float, float, Optional[str]]]:
"""
A subroutine for chopping strings into lines to
be rendered. This is used as a subroutine by
text-rendering classes such as :py:class:`~TextBox`
and :py:class:`~Teleprompter`. It is only public
so that code in :py:mod:`gewel.contrib` can
access it when needed.
Parameters
----------
ctx
The low-level drawing context we intend to render
to.
text
The text to be rendered.
x
The x location.
y
The y location.
box_width
The width of the text in pixels. Lines will be
broken so that rendered text does not exceed this
width.
font_size
Font size.
line_spacing
Line spacing (relative to the font size, e.g. 2.0
means double spaced).
justification
The justification.
Returns
-------
List[Tuple[float, float, Optional[str]]]
A list of tuples, one per line of text. Each tuple has
three elements: the x position of the line, the y position
of the line, and the text that goes on that line.
"""
line_y = y + line_spacing * font_size
ctx.set_font_size(font_size)
line_count = 0
drawable_lines = []
lines = text.split('\n')
for line in lines:
words = line.split()
if len(words) > 0:
line_start = line.find(words[0])
ii = 0
while ii < len(words):
line_end = line_start
selected_width = 0
while ii < len(words):
next_end = line.find(words[ii], line_end) + len(words[ii])
x_bearing, y_bearing, width, height, x_advance, y_advance = \
ctx.text_extents(line[:next_end])
if width > box_width:
if line_end == line_start:
# A single long word was wider than the box.
selected_width = width
ii = ii + 1
line_end = next_end
break
else:
selected_width = width
ii = ii + 1
line_end = next_end
drawable_line = line[line_start:line_end]
if justification == TextJustification.LEFT:
x_offset = 0
elif justification == TextJustification.CENTER:
x_offset = (box_width - selected_width) / 2
else: # justification == TextJustification.RIGHT:
x_offset = box_width - selected_width
line_x = x + x_offset
drawable_lines.append((line_x, line_y, drawable_line))
line_count = line_count + 1
line_y = y + (line_count + 1) * line_spacing * font_size
line = str(line[line_end:])
if ii < len(words):
line_start = line.find(words[ii])
else:
# Blank line.
drawable_lines.append((x, line_y, None))
line_count = line_count + 1
line_y = y + (line_count + 1) * line_spacing * font_size
return drawable_lines
[docs]@dataclass(init=False)
class RotatedDrawable(Drawable):
"""
A drawable that is a rotated version of another. Typically
constructed by calling :py:meth:`~Drawable.rotated`
or :py:meth:`~Drawable.rotated_degrees`.
Parameters
----------
drawable
The drawable to rotate.
radians
How far to rotate the original drawable.
z
The z depth of the new drawable.
"""
drawable: Drawable = None
radians: tvx.FloatOrTVF = 0.0
def __init__(self, drawable: Drawable, radians: tvx.FloatOrTVF, z: tvx.FloatOrTVF = 0.0):
super().__init__(z=z)
self.drawable = drawable
self.radians = radians
[docs] def draw(self, ctx: cairo.Context, t: float) -> None:
xform = cairo.Matrix.init_rotate(float_at_time(self.radians, t))
with _xform_context(ctx, xform):
self.drawable.draw(ctx, t)
[docs]@dataclass(init=False)
class TranslatedDrawable(Drawable):
"""
A drawable that is a shifted version of another. Typically
constructed by calling :py:meth:`~Drawable.translated`.
Parameters
----------
drawable
The drawable to translate.
dx
How far to translate the original drawable in the `x` direction.
dy
How far to translate the original drawable in the `y` direction.
z
The z depth of the new drawable.
"""
drawable: Drawable = None
dx: tvx.FloatOrTVF = 0.0
dy: tvx.FloatOrTVF = 0.0
def __init__(self, drawable: Drawable, dx: tvx.FloatOrTVF = 0.0, dy: tvx.FloatOrTVF = 0.0, z: tvx.FloatOrTVF = 0.0):
super().__init__(z=z)
self.drawable = drawable
self.dx = dx
self.dy = dy
[docs] def draw(self, ctx: cairo.Context, t: float) -> None:
xform = cairo.Matrix(x0=float_at_time(self.dx, t), y0=float_at_time(self.dy, t))
with _xform_context(ctx, xform):
self.drawable.draw(ctx, t)
[docs]class PngDrawable(XYDrawable, BoundedMixin):
"""
A :py:class:`~Drawable` that renders a the contents
of a png file.
See :ref:`png_sample` for an example of how to use
this class.
Parameters
----------
png_path
The path to load the png from the local file system.
x
The x coordinate of the location at which to render
the image.
y
The y coordinate of the location at which to render
the image.
theta
The angle at which to render the image. In radians.
centered
If `True` then `x` and `y` specify the location of
the center of the image. If `False`, they specify
the location of the top left corner.
z
The depth at which to render. Drawables with higher `z` are
rendered on top of those with lower `z`.
"""
def __init__(
self,
png_path: str,
x: tvx.FloatOrTVF = 0.0,
y: tvx.FloatOrTVF = 0.0,
theta: tvx.FloatOrTVF = 0.0,
centered: bool = True,
z: tvx.FloatOrTVF = 0.0
):
image_surface = cairo.ImageSurface.create_from_png(png_path)
super().__init__(x, y, theta, z)
self.width = image_surface.get_width()
self.height = image_surface.get_height()
self.centered = centered
self._image_surface = image_surface
[docs] def draw(self, ctx: cairo.Context, t: float):
alpha = float_at_time(self.alpha, t)
with _time_device_drawing():
xform = self.local_xform(t)
with _xform_context(ctx, xform):
ctx.set_source_surface(self._image_surface)
ctx.paint_with_alpha(alpha)
[docs]@dataclass(init=False)
class ClippedDrawable(XYDrawable):
"""
A drawable that is a clipped version of another drawable.
The clipping region is an axis-aligned rectangle.
This class is used by :py:class:`~Teleprompter` to clip
the scrolling text to the bounds of the teleprompter.
Parameters
----------
d
The drawable to clip.
x
x position of the clipping region
y
y position of the clipping region
width
width of the clipping region
height
height of the clipping region
z
z depth
"""
d: Drawable = None
width: tvx.FloatOrTVF = 0.0
height: tvx.FloatOrTVF = 0.0
def __init__(
self,
d: Drawable,
x: tvx.FloatOrTVF,
y: tvx.FloatOrTVF,
width: tvx.FloatOrTVF,
height: tvx.FloatOrTVF,
z: tvx.FloatOrTVF = 0.0
):
super().__init__(x, y, z)
self.d = d
self.width = width
self.height = height
[docs] def draw(self, ctx: cairo.Context, t: float) -> None:
width = float_at_time(self.width, t)
if width == 0.0:
return
height = float_at_time(self.height, t)
if height == 0.0:
return
x = float_at_time(self.x, t)
y = float_at_time(self.y, t)
with _clipped_rectangle_context(ctx, x, y, x + width, y + height) as ctx:
self.d.draw(ctx, t)
[docs]class BaseScene(Drawable, metaclass=ABCMeta):
"""
A base class for :py:class:`~Scene`, :py:class:`~SceneSequence`,
and :py:class:`~ClipDrawable` that provides a little bit of shared
base functionality.
:meta private:
"""
def __init__(
self,
render_width: int = 640,
render_height: int = 480,
z: tvx.FloatOrTVF = 0.0
):
super().__init__(z=z)
self._render_height = render_height
self._render_width = render_width
@property
def render_width(self) -> int:
return self._render_width
@property
def render_height(self) -> int:
return self._render_height
def __repr__(self):
_scene_widget(self)
return "Scene - {:d} x {:d}".format(self._render_width, self._render_height)
[docs]class Scene(BaseScene):
"""
A scene is a collection of :py:class:`~Drawable` objects.
Normally, a scene is created at the very end of the scripting
phase of creating an animation. It is then previewed with a
:py:class:`~gewel.player.Player` or rendered to a file
with a :py:class:`gewel.record.Mp4Recorder` or similar.
See any of the sample code snippets in this API reference that
generate animations for uses of :py:class:`~Scene`. For example,
:py:meth:`~XYDrawable.move_to` or
:py:meth:`~XYDrawable.rotate_to`.
Parameters
----------
drawables
The drawables that are in the scene.
render_width
The width of the scene when rendered, in pixels.
render_height
The height of the scene when rendered, in pixels.
z
The z order of the scene, in case it is rendered
along with other scenes in a compound scene. Rarely
used.
"""
def __init__(
self,
drawables: Iterable[Drawable],
render_width: int = 640,
render_height: int = 480,
z: tvx.FloatOrTVF = 0.0
):
super().__init__(render_width, render_height, z)
self._drawables: List[Drawable] = list(drawables)
self.wait_for(self._drawables)
self._render_height = render_height
self._render_width = render_width
[docs] def draw(self, ctx: cairo.Context, t: float) -> None:
# Note that we have to sort each time we draw, rather
# than storing in alpha sorted list since z can vary with
# time.
for drawable in sorted(self._drawables, key=lambda d: float_at_time(d.z, t)):
drawable.draw(ctx, t)
def add(self, drawable: Drawable) -> None:
self._drawables.append(drawable)
self.wait_for(drawable)
def pop(self, index: int = -1) -> Drawable:
d = self._drawables.pop(index)
self.set_time(0.0)
if len(self._drawables):
if d.time == self.time:
self.wait_until(max([d.time for d in self._drawables]))
return d
[docs] def at(self, time: float) -> 'Frame':
"""
The scene at a specific instance in time. Normally this is
used to render the scene at the specified time, typically
inside a notebook where it will automatically render as the
output.
Parameters
----------
time
The time at which we want to view the scene.
Returns
-------
Frame
The scene at time ``t``.
"""
return Frame(self, time)
def __getitem__(self, item: Union[float, slice]) -> 'ClipDrawable':
"""
Get the frame at a specific time or a clip
covering a range from start to end time.
Parameters
----------
time
Either a float, in which case the return value is a
frame at the given time, or a slice of the form ``start:end``,
in which case the return value is a clip of the scene covering
that range of time.
Returns
-------
Union[ClipDrawable, Clip]
A frame or a clip, depending on whether the argument was
a float or a slice.
"""
if isinstance(item, slice):
return ClipDrawable(self, item.start, item.stop)
else:
return Frame(self, item)
[docs]@dataclass(init=False)
class ClipDrawable(Drawable):
"""
A scene that is a clip of another scene. If ``start`` is
negative or ``stop`` is greater than the length of the
scene, behavior is undefined.
Parameters
----------
base_drawable
The :py:class:`~Drawable` we want a clip of.
start
The start time of the clip.
stop
The end time of the clip.
z
The z order for the clip.
"""
base_drawable: Drawable = None
start: float = 0.0
stop: float = 0.0
def __init__(self, base_drawable: Drawable, start: float, stop: float, z: Optional[float] = None):
if z is None:
z = base_drawable.z
super().__init__(z=z, alpha=1.0)
self.base_drawable = base_drawable
self.start = start
self.stop = stop
self.set_time(max([0.0, min(stop, base_drawable.time) - self.start]))
[docs] def draw(self, ctx: cairo.Context, t: float) -> None:
scene_time = t + self.start
self.base_drawable.draw(ctx, scene_time)
[docs]class Frame:
"""
A single frame in a scene. Normally constructed using
``scene[frame_time]``. See :py:meth:`~Scene.__getitem__`.
Typically these are used inside notebooks to render an
image of a scene at a given time.
Parameters
----------
scene
The scene.
at_time
The time for the frame.
"""
def __init__(self, scene: BaseScene, at_time: float):
self._scene = scene
self._at_time = at_time
def to_png(self, target: Union[str, IO, None] = None) -> Optional[BytesIO]:
width = self._scene.render_width
height = self._scene.render_height
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
ctx = cairo.Context(surface)
self._scene.draw(ctx, self._at_time)
surface.flush()
return surface.write_to_png(target)
def __repr__(self):
png = self.to_png()
IPython.display.display(IPython.display.Image(png))
return "Scene at {:}".format(tvx.utils.format_time(self._at_time))
def _scene_widget(scene: BaseScene):
width = scene.render_width
height = scene.render_height
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
ctx = cairo.Context(surface)
@widgets.interact(
time=widgets.FloatSlider(
min=-0, max=scene.time, step=1.0 / 30, value=0.0,
description="Time (sec.):",
layout=widgets.Layout(width='600px')
)
)
def _show_image_for_time(time=(0, scene.time, 1.0 / 30)):
scene.draw(ctx, time)
surface.flush()
png = surface.write_to_png()
IPython.display.display(IPython.display.Image(png))
return _show_image_for_time
[docs]class SceneSequence(BaseScene):
def __init__(
self,
scenes: Iterable[Scene],
render_width: int = 640,
render_height: int = 480,
z: tvx.FloatOrTVF = 0.0,
):
super().__init__(render_width, render_height, z)
self._scenes = []
self._scene_start_times = [0.0]
for scene in scenes:
self.append(scene)
def append(self, scene: Scene):
self._scenes.append(scene)
self._scene_start_times.append(self._scene_start_times[-1] + scene.time)
self.wait(scene.time)
[docs] def draw(self, ctx: cairo.Context, t: float) -> None:
index = bisect.bisect_left(self._scene_start_times, t) - 1
# If t <= 0, we could have ended up at -1.
index = max(index, 0)
# If t is beyond the last scene use the end
# of the last scene.
index = min(index, len(self._scenes) - 1)
self._scenes[index].draw(ctx, t - self._scene_start_times[index])
def __len__(self):
return len(self._scenes)
[docs]class Teleprompter(XYDrawable):
"""
A class that animates scrolling text inside a rectangular box.
The typical use case is to script a voiceover that goes along
with whatever other objects are in the animation. The voiceover
artist can then read along with the teleprompter and record
their voice, which can then be added to the animation.
The teleprompter can be left in to make the video more accessible.
Teleprompters
can be kept in sync with other objects in the animation by
interleaving calls to :py:meth:`~Teleprompter.add_text` with
calls to :py:meth:`~Drawable:wait_for`,
:py:func:`~gewel.draw.sync` and related synchronization
calls.
See :ref:`teleprompter_sample` for an example that uses a
teleprompter.
"""
def __init__(
self,
x: tvx.FloatOrTVF, y: tvx.FloatOrTVF,
width: tvx.FloatOrTVF, height: tvx.FloatOrTVF,
font_size: float = 12.0,
line_spacing: tvx.FloatOrTVF = 1.5,
lines_per_second: float = 0.8,
color: BaseColor = gewel.color.BLACK,
fill_color: BaseColor = TRANSPARENT,
line_color: BaseColor = TRANSPARENT,
line_width: tvx.FloatOrTVF = 1,
z: tvx.FloatOrTVF = 0.0
):
super().__init__(x, y, z)
self._width = width
self._height = height
self._font_size = font_size
self._line_spacing = line_spacing
self._lines_per_second = lines_per_second
self._color = color
self._scroll_velocity = lines_per_second * line_spacing * font_size
self.set_time(0.0)
self._background = Box(
x, y, width, height,
color=gewel.color.TRANSPARENT,
fill_color=fill_color,
line_width=0.0,
z=-1.0
)
self._text_collection = Scene([], z=0.0)
self._border = Box(x, y, width, height, color=line_color, line_width=line_width, z=1.0)
scrolling_text = TranslatedDrawable(
self._text_collection,
dy=-self._scroll_velocity * tvx.time()
)
clipped_scrolling_text = ClippedDrawable(scrolling_text, x, y, width, height)
self._scene = Scene([self._background, clipped_scrolling_text, self._border])
# A dummy context for when we need to check text
# extents to determine how many lines will be
# needed to render some text.
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 64, 64)
self._dummy_ctx = cairo.Context(surface)
@property
def time(self):
return self._time
@staticmethod
def _script_to_text(script: str) -> str:
# Drop leading/trailing white space.
text = re.sub(r'(^[ \t\n]+)|([ \t\n]+$)', '', script)
# Wrap paragraphs into a single line.
text = re.sub(r'(^|[^ \t\n])[ \t]*\n[ \t]*([^ \t\n]|$)', r'\g<1> \g<2>', text)
# Remove multiple newlines and white space between paragraphs.
text = re.sub(r'([ \t]*\n)([ \t]*\n[ \t]*)+', '\n', text)
return text
[docs] def add_script(self, script: str, at_time: Optional[float] = None) -> None:
"""
Add to the the script in the teleprompter. This is like :py:meth:`~add_text`
except that it does some basic formatting. It is normally called with a
triple-quoted multi-line string. The formatting that is done is:
#. Leading and trailing whitespace is dropped.
#. Multiple consecutive lines not separated by a blank line are converted into
a single paragraph. This is useful because it lets you write paragraphs
without worrying about the width of the text box they will appear in and
where you need to wrap them.
#. Multiple blank lines between paragraphs are converted into a single paragraph
break.
Parameters
----------
script
The text to format and render into the teleprompter.
at_time
Optional time at which to start the text. If missing use the current
time of the teleprompter, which is the time the last text was rendered
or the time the teleprompter last waited for.
"""
self.add_text(self._script_to_text(script), at_time)
[docs] def add_text(self, text: str, at_time: Optional[float] = None) -> None:
"""
Add unformatted text to the teleprompter. In most cases, you should
use :py:meth:~`add_script` instead.
Parameters
----------
text
The text to add.
at_time
Optional time at which to start the text. If missing use the current
time of the teleprompter, which is the time the last text was rendered
or the time the teleprompter last waited for.
"""
if at_time is None:
at_time = self.time
x = self.x
y = self.y + self._scroll_velocity * at_time
# Offset the text so text at time zero starts about 1/3 from the top.
y = y + self._height / 3.0
text_box = TextBox(
text, x=x, y=y,
width=self._width, height=self._height,
font_size=self._font_size,
line_spacing=self._line_spacing,
color=self._color
)
self._text_collection.add(text_box)
lines = walk_text(
self._dummy_ctx, text,
x, y, self._width,
self._font_size, self._line_spacing,
TextJustification.LEFT
)
self.set_time(at_time + len(lines) / self._lines_per_second)
[docs] def draw(self, ctx: cairo.Context, t: float) -> None:
self._scene.draw(ctx, t)
[docs]@dataclass(init=False)
class ColorMapBox(XYDrawable, BoundedMixin):
width: tvx.FloatOrTVF = 0.0
height: tvx.FloatOrTVF = 0.0
color_map: ColorMap = TRANSPARENT
steps: int = 32
border_color: BaseColor = TRANSPARENT
def __init__(
self,
x: tvx.FloatOrTVF,
y: tvx.FloatOrTVF,
width: tvx.FloatOrTVF,
height: tvx.FloatOrTVF,
color_map: ColorMap,
steps: int = 32,
border_color: BaseColor = TRANSPARENT,
vertical: bool = True,
line_width: tvx.FloatOrTVF = 1.0,
theta: tvx.FloatOrTVF = 0.0,
z: tvx.FloatOrTVF = 0.0,
alpha: tvx.FloatOrTVF = 1.0,
):
super().__init__(x=x, y=y, theta=theta, z=z, alpha=alpha)
self.width = width
self.height = height
self.color_map = color_map
self.steps = steps
self._vertical = vertical
self.border_color = border_color
self.line_width = line_width
self.centered = False
@property
def vertical(self) -> bool:
return self._vertical
[docs] def draw(self, ctx: cairo.Context, t: float) -> None:
width = float_at_time(self.width, t)
if width == 0.0:
return
height = float_at_time(self.height, t)
if height == 0.0:
return
if self._vertical:
transpose_xform = cairo.Matrix()
else:
# We are horizontal, so we'll transpose
# x and y before we do any drawing.
transpose_xform = cairo.Matrix(xx=0, xy=1, yx=1, yy=0)
width, height = height, width
# Now we can draw as if we are vertical
with _xform_context(ctx, self.local_xform(t)):
with _xform_context(ctx, transpose_xform):
# There are self.steps color positions based on the midpoints of
# self.steps segments, each of which is the same color. There are
# self.steps + 1 values of y that bound the regions where these
# are drawn.
color_positions = np.linspace(
0.5 / self.steps,
1 - 0.5 / self.steps,
self.steps
)
ys = np.linspace(0.0, height, self.steps + 1)
original_position = self.color_map.position
x0 = 0.0
x1 = width
with _time_device_drawing():
for y0, y1, color_position in zip(ys[:-1], ys[1:], color_positions):
self.color_map.position = color_position
color_tuple = self.color_map.tuple(t, alpha_multiplier=self.alpha)
ctx.move_to(x0, y0 - 0.5)
ctx.line_to(x0, y1 + 0.5)
ctx.line_to(x1, y1 + 0.5)
ctx.line_to(x1, y0 - 0.5)
ctx.close_path()
ctx.set_source_rgba(*color_tuple)
ctx.clip()
ctx.paint()
ctx.reset_clip()
self.color_map.position = original_position
if not self.border_color.is_transparent():
ctx.set_line_width(float_at_time(self.line_width, t))
ctx.move_to(x0, ys[0])
ctx.line_to(x0, ys[-1])
ctx.line_to(x1, ys[-1])
ctx.line_to(x1, ys[0])
ctx.close_path()
ctx.set_source_rgba(*self.border_color.tuple(
t, alpha_multiplier=float_at_time(self.alpha, t)
))
ctx.stroke()
# Bring gewel._timekeeper functions into this package
# since this is the public place from which they will be used.
# Note that we also narrow the type signatures to take
# Drawables.
[docs]def sync(drawables: Iterable[Drawable]) -> None:
"""
Ensure that two or more :py:class:`~Drawable` objects
are in sync, meaning that all of them will wait for
whichever has the latest completing action before any
takes a next action.
Parameters
----------
drawables
The drawables to syncronize.
Returns
-------
"""
gewel._timekeeper.sync(drawables)
[docs]def all_wait_for(waiters: Iterable[Drawable], waited_on: Drawable) -> None:
"""
Have a group of :py:class:`~Drawable` objects wait on a single
:py:class:`~Drawable`. This is mostly just used as a
syntactic shortcut for cases where we want to synchronize
a collection of :py:class:`~Drawable` objects so that they
don't take their next action until some single :py:class:`~Drawable`
has completed it's current action. The call
.. code-block:: python
all_wait_for([d1, d2, d3], d0)
is equivalent to
.. code-block:: python
for d in [d1, d2, d3]:
d.wait_for(d0)
Parameters
----------
waiters
The :py:class:`~Drawable` objects that should wait.
waited_on
The :py:class:`~Drawable` to wait on.
Returns
-------
"""
gewel._timekeeper.all_wait_for(waiters, waited_on)