"""
.. Core of the Renderable API
"""
from __future__ import annotations
__all__ = ("Renderable", "RenderableData", "OptionalPaddingT")
import sys
from abc import ABCMeta, abstractmethod
from collections.abc import Callable
from time import perf_counter_ns, sleep
from types import MappingProxyType
from typing_extensions import Any, ClassVar, Literal, TextIO, TypeVar, overload
import term_image
from .. import geometry
from .._ctlseqs import HIDE_CURSOR, SHOW_CURSOR, cursor_down, cursor_forward, cursor_up
from ..geometry import Size
from ..padding import AlignedPadding, ExactPadding, Padding
from ..utils import arg_value_error_range, get_terminal_size
from . import _types
from ._enum import FrameCount, FrameDuration, Seek
from ._exceptions import (
IndefiniteSeekError,
NonAnimatedRenderableError,
RenderableError,
RenderSizeOutofRangeError,
)
from ._types import ArgsNamespace, DataNamespace, Frame, RenderArgs, RenderData
try:
import termios
except ImportError:
OS_IS_UNIX = False
else:
OS_IS_UNIX = True
T = TypeVar("T")
RenderableMetaT = TypeVar("RenderableMetaT", bound="RenderableMeta")
OptionalPaddingT = TypeVar("OptionalPaddingT", bound="Padding | None")
class RenderableMeta(ABCMeta):
"""Base metaclass of the Renderable API.
Implements certain internal/private aspects of the API.
"""
Args: type[ArgsNamespace] | None
_Data_: type[DataNamespace] | None
_ALL_DEFAULT_ARGS: MappingProxyType[type[Renderable], ArgsNamespace]
_RENDER_DATA_MRO: MappingProxyType[type[Renderable], type[DataNamespace]]
_ALL_EXPORTED_ATTRS: tuple[str, ...]
def __new__(
cls: type[RenderableMetaT],
name: str,
bases: tuple[type, ...],
namespace: dict[str, Any],
*,
_base: bool = False,
**kwargs: Any,
) -> RenderableMetaT:
if not _base and not any(issubclass(base, Renderable) for base in bases):
raise RenderableError(f"{name!r} is not a subclass of 'Renderable'")
new_cls = super().__new__(cls, name, bases, namespace, **kwargs)
all_default_args: dict[type[Renderable], ArgsNamespace] = {}
render_data_mro: dict[type[Renderable], type[DataNamespace]] = {}
all_exported_descendant_attrs: set[str] = set() # removes duplicates
if not _base: # Subclass of `Renderable`
for mro_cls in new_cls.__mro__:
if not issubclass(mro_cls, Renderable):
continue
if mro_cls is not new_cls:
if mro_cls.Args:
all_default_args[mro_cls] = mro_cls._ALL_DEFAULT_ARGS[mro_cls]
if mro_cls._Data_:
render_data_mro[mro_cls] = mro_cls._Data_
try:
all_exported_descendant_attrs.update(
mro_cls.__dict__["_EXPORTED_DESCENDANT_ATTRS_"]
)
except KeyError:
pass
new_cls._ALL_DEFAULT_ARGS = MappingProxyType(all_default_args)
new_cls._RENDER_DATA_MRO = MappingProxyType(render_data_mro)
new_cls._ALL_EXPORTED_ATTRS = tuple(
all_exported_descendant_attrs.union(namespace.get("_EXPORTED_ATTRS_", ()))
)
new_cls.Args = new_cls._Data_ = None
return new_cls
[docs]
class Renderable(metaclass=RenderableMeta, _base=True):
"""A renderable.
Args:
frame_count: Number of frames. If it's a
* positive integer, the number of frames is as given.
* :py:class:`~term_image.renderable.FrameCount` enum member, see the
member's description.
If equal to 1 (one), the renderable is non-animated.
Otherwise, it is animated.
frame_duration: The duration of a frame. If it's a
* positive integer, it implies a static duration (in **milliseconds**)
i.e the same duration applies to every frame.
* :py:class:`~term_image.renderable.FrameDuration` enum member, see the
member's description.
This argument is ignored if *frame_count* equals 1 (one)
i.e the renderable is non-animated.
Raises:
ValueError: An argument has an invalid value.
ATTENTION:
This is an abstract base class. Hence, only **concrete** subclasses can be
instantiated.
.. seealso::
:ref:`renderable-ext-api`
:py:class:`Renderable`\\ 's Extension API
"""
# Class Attributes =========================================================
# Initialized by `RenderableMeta` and may be updated by `ArgsNamespaceMeta`
Args: ClassVar[type[ArgsNamespace] | None]
""":term:`Render class`\\ -specific render arguments.
This is either:
- a render argument namespace class (subclass of :py:class:`ArgsNamespace`)
associated [#an-ass]_ with the render class, or
- :py:data:`None`, if the render class has no render arguments.
If this is a class, an instance of it (or a subclass thereof) is contained within
any :py:class:`RenderArgs` instance associated [#ra-ass]_ with the render class or
any of its subclasses. Also, an instance of this class (or a subclass of it) is
returned by :py:meth:`render_args[render_cls]
<term_image.renderable.RenderArgs.__getitem__>`; where *render_args* is
an instance of :py:class:`~term_image.renderable.RenderArgs` as previously
described and *render_cls* is the render class with which this namespace class
is associated [#an-ass]_.
.. collapse:: Example
>>> class Foo(Renderable):
... pass
...
... class FooArgs(ArgsNamespace, render_cls=Foo):
... foo: str | None = None
...
>>> Foo.Args is FooArgs
True
>>>
>>> # default
>>> foo_args = Foo.Args()
>>> foo_args
FooArgs(foo=None)
>>> foo_args.foo is None
True
>>>
>>> render_args = RenderArgs(Foo)
>>> render_args[Foo]
FooArgs(foo=None)
>>>
>>> # non-default
>>> foo_args = Foo.Args("FOO")
>>> foo_args
FooArgs(foo='FOO')
>>> foo_args.foo
'FOO'
>>>
>>> render_args = RenderArgs(Foo, foo_args.update(foo="bar"))
>>> render_args[Foo]
FooArgs(foo='bar')
On the other hand, if this is :py:data:`None`, it implies the render class has no
render arguments.
.. collapse:: Example
>>> class Bar(Renderable):
... pass
...
>>> Bar.Args is None
True
>>> render_args = RenderArgs(Bar)
>>> render_args[Bar]
Traceback (most recent call last):
...
NoArgsNamespaceError: 'Bar' has no render arguments
"""
# Initialized by `RenderableMeta` and may be updated by `DataNamespaceMeta`
_Data_: ClassVar[type[DataNamespace] | None]
""":term:`Render class`\\ -specific render data.
This is either:
- a render data namespace class (subclass of :py:class:`DataNamespace`)
associated [#dn-ass]_ with the render class, or
- :py:data:`None`, if the render class has no render data.
If this is a class, an instance of it (or a subclass thereof) is contained within
any :py:class:`RenderData` instance associated [#rd-ass]_ with the render class or
any of its subclasses. Also, an instance of this class (or a subclass of it) is
returned by :py:meth:`render_data[render_cls]
<term_image.renderable.RenderData.__getitem__>`; where *render_data* is
an instance of :py:class:`~term_image.renderable.RenderData` as previously
described and *render_cls* is the render class with which this namespace class
is associated [#dn-ass]_.
.. collapse:: Example
>>> class Foo(Renderable):
... pass
...
... class _Data_(DataNamespace, render_cls=Foo):
... foo: str | None
...
>>> Foo._Data_ is FooData
True
>>>
>>> foo_data = Foo._Data_()
>>> foo_data
<FooData: foo=<uninitialized>>
>>> foo_data.foo
Traceback (most recent call last):
...
UninitializedDataFieldError: The render data field 'foo' of 'Foo' has not \
been initialized
>>>
>>> foo_data.foo = "FOO"
>>> foo_data
<FooData: foo='FOO'>
>>> foo_data.foo
'FOO'
>>>
>>> render_data = RenderData(Foo)
>>> render_data[Foo]
<FooData: foo=<uninitialized>>
>>>
>>> render_data[Foo].foo = "bar"
>>> render_data[Foo]
<FooData: foo='bar'>
On the other hand, if this is :py:data:`None`, it implies the render class has no
render data.
.. collapse:: Example
>>> class Bar(Renderable):
... pass
...
>>> Bar._Data_ is None
True
>>>
>>> render_data = RenderData(Bar)
>>> render_data[Bar]
Traceback (most recent call last):
...
NoDataNamespaceError: 'Bar' has no render data
.. seealso::
:py:class:`~term_image.renderable.RenderableData`
Render data for :py:class:`~term_image.renderable.Renderable`.
"""
_EXPORTED_ATTRS_: ClassVar[tuple[str, ...]]
"""Exported attributes.
This specifies class attributes defined by the class (not a parent) on itself
**but not** its subclasses which should be exported to definitions of the class
in subprocesses.
These attributes are typically assigned using ``__class__.*`` within methods.
NOTE:
* Defining this is optional.
* The attributes are exported for a class if and only if they are defined
on that class when starting a subprocess.
* The attributes are exported only for subprocesses started via
:py:class:`multiprocessing.Process`.
TIP:
This can be used to export "private" attributes of the class across
subprocesses.
"""
_EXPORTED_DESCENDANT_ATTRS_: ClassVar[tuple[str, ...]]
"""Exported :term:`descendant` attributes.
This specifies class attributes defined by the class (not a parent) on itself
**and** its subclasses (i.e :term:`descendant` attributes) which should be exported
to definitions of the class and its subclasses in subprocesses.
These attributes are typically assigned using ``cls.*`` within class methods.
This extends the exported descendant attributes of parent classes i.e all
exported descendant attributes of a class are also exported for its subclasses.
NOTE:
* Defining this is optional.
* The attributes are exported for a class if and only if they are defined
on that class when starting a subprocess.
* The attributes are exported only for subprocesses started via
:py:class:`multiprocessing.Process`.
TIP:
This can be used to export "private" :term:`descendant` attributes of the
class across subprocesses.
"""
_ALL_DEFAULT_ARGS: ClassVar[MappingProxyType[type[Renderable], ArgsNamespace]]
_RENDER_DATA_MRO: ClassVar[MappingProxyType[type[Renderable], type[DataNamespace]]]
_ALL_EXPORTED_ATTRS: ClassVar[tuple[str, ...]]
# Instance Attributes ======================================================
animated: bool
"""``True`` if the renderable is :term:`animated`. Otherwise, ``False``."""
_frame: int
_frame_count: int | FrameCount
_frame_duration: int | FrameDuration
# Special Methods ==========================================================
def __init__(
self,
frame_count: int | FrameCount,
frame_duration: int | FrameDuration,
) -> None:
if isinstance(frame_count, int) and frame_count < 1:
raise arg_value_error_range("frame_count", frame_count)
if frame_count != 1:
if isinstance(frame_duration, int) and frame_duration <= 0:
raise arg_value_error_range("frame_duration", frame_duration)
self._frame_duration = frame_duration
self.animated = frame_count != 1
self._frame_count = frame_count
self._frame = 0
[docs]
def __iter__(self) -> term_image.render.RenderIterator:
"""Returns a render iterator.
Returns:
A render iterator (with frame caching disabled and all other optional
arguments to :py:class:`~term_image.render.RenderIterator` being the
default values).
Raises:
NonAnimatedRenderableError: The renderable is non-animated.
:term:`Animated` renderables are iterable i.e they can be used with various
means of iteration such as the ``for`` statement and iterable unpacking.
"""
from term_image.render import RenderIterator
try:
return RenderIterator(self, cache=False)
except ValueError:
raise NonAnimatedRenderableError(
"Non-animated renderables are not iterable"
) from None
def __repr__(self) -> str:
return f"<{type(self).__name__}: frame_count={self._frame_count}>"
[docs]
def __str__(self) -> str:
""":term:`Renders` the current frame with default arguments and no padding.
Returns:
The frame :term:`render output`.
Raises:
RenderError: An error occurred during :term:`rendering`.
"""
return self._init_render_(self._render_)[0].render_output
# Properties ===============================================================
@property
def frame_count(self) -> int | Literal[FrameCount.INDEFINITE]:
"""Frame count
GET:
Returns either
* the number of frames the renderable has, or
* :py:attr:`~term_image.renderable.FrameCount.INDEFINITE`.
"""
if self._frame_count is FrameCount.POSTPONED:
self._frame_count = self._get_frame_count_()
return self._frame_count
@property
def frame_duration(self) -> int | FrameDuration:
"""Frame duration
GET:
Returns
* a positive integer, a static duration (in **milliseconds**) i.e the
same duration applies to every frame; or
* :py:attr:`~term_image.renderable.FrameDuration.DYNAMIC`.
SET:
If the value is
* a positive integer, it implies a static duration (in **milliseconds**)
i.e the same duration applies to every frame.
* :py:attr:`~term_image.renderable.FrameDuration.DYNAMIC`, see the
enum member's description.
Raises:
NonAnimatedRenderableError: The renderable is non-animated.
"""
try:
return self._frame_duration
except AttributeError:
if not self.animated:
raise NonAnimatedRenderableError(
"Non-animated renderables have no frame duration"
) from None
raise
@frame_duration.setter
def frame_duration(self, duration: int | FrameDuration) -> None:
if not self.animated:
raise NonAnimatedRenderableError(
"Cannot set frame duration for a non-animated renderable"
)
if isinstance(duration, int) and duration <= 0:
raise arg_value_error_range("frame_duration", duration)
self._frame_duration = duration
@property
def render_size(self) -> geometry.Size:
""":term:`Render size`
GET:
Returns the size of the renderable's :term:`render output`.
"""
return self._get_render_size_()
# Public Methods ===========================================================
[docs]
def draw(
self,
render_args: RenderArgs | None = None,
padding: Padding = AlignedPadding(0, -2),
*,
animate: bool = True,
loops: int = -1,
cache: bool | int = 100,
check_size: bool = True,
allow_scroll: bool = False,
hide_cursor: bool = True,
echo_input: bool = False,
) -> None:
"""Draws the current frame or an animation to standard output.
Args:
render_args: Render arguments.
padding: :term:`Render output` padding.
animate: Whether to enable animation for :term:`animated` renderables.
If disabled, only the current frame is drawn.
loops: See :py:class:`~term_image.render.RenderIterator`
(applies to **animations only**).
cache: See :py:class:`~term_image.render.RenderIterator`.
(applies to **animations only**).
check_size: Whether to validate the padded :term:`render size` of
**non-animations**.
allow_scroll: Whether to validate the padded :term:`render height` of
**non-animations**. Ignored if *check_size* is ``False``.
hide_cursor: Whether to hide the cursor **while drawing**.
echo_input: Whether to display input **while drawing** (applies on **Unix
only**).
.. note::
* If disabled (default), input is not read/consumed, it's just not
displayed.
* If enabled, echoed input may affect cursor positioning and
therefore, the output (especially for animations).
Raises:
RenderSizeOutofRangeError: The padded :term:`render size` can not fit into
the :term:`terminal size`.
IncompatibleRenderArgsError: Incompatible render arguments.
RenderError: An error occurred during :term:`rendering`.
If *check_size* is ``True`` (or it's an animation),
* the padded :term:`render width` must not be greater than the
:term:`terminal width`.
* and *allow_scroll* is ``False`` (or it's an animation), the padded
:term:`render height` must not be greater than the :term:`terminal height`.
NOTE:
* *hide_cursor* and *echo_input* apply if and only if the output stream
is connected to a terminal.
* For animations (i.e animated renderables with *animate* = ``True``),
the padded :term:`render size` is always validated.
* Animations with **definite** frame count, **by default**, are infinitely
looped but can be terminated with :py:data:`~signal.SIGINT`
(``CTRL + C``), **without** raising :py:class:`KeyboardInterrupt`.
"""
animation = self.animated and animate
output = sys.stdout
not_echo_input = OS_IS_UNIX and not echo_input and output.isatty()
hide_cursor = hide_cursor and output.isatty()
# Validate size and get render data and args
render_data: RenderData
real_render_args: RenderArgs
(render_data, real_render_args), padding = self._init_render_(
lambda *args: args,
render_args,
padding,
iteration=animation,
finalize=False,
check_size=animation or check_size,
allow_scroll=not animation and allow_scroll,
)
if not_echo_input:
output_fd = output.fileno()
old_attr = termios.tcgetattr(output_fd)
new_attr = termios.tcgetattr(output_fd)
new_attr[3] &= ~termios.ECHO
try:
if hide_cursor:
output.write(HIDE_CURSOR)
if not_echo_input:
termios.tcsetattr(output_fd, termios.TCSAFLUSH, new_attr)
if animation:
self._animate_(
render_data, real_render_args, padding, loops, cache, output
)
else:
frame = self._render_(render_data, real_render_args)
padded_size = padding.get_padded_size(frame.render_size)
render = (
frame.render_output
if frame.render_size == padded_size
else padding.pad(frame.render_output, frame.render_size)
)
try:
output.write(render)
output.flush()
except KeyboardInterrupt:
self._handle_interrupted_draw_(
render_data, real_render_args, output
)
raise
finally:
output.write("\n")
if hide_cursor:
output.write(SHOW_CURSOR)
output.flush()
if not_echo_input:
termios.tcsetattr(output_fd, termios.TCSANOW, old_attr)
render_data.finalize()
[docs]
def render(
self,
render_args: RenderArgs | None = None,
padding: Padding = ExactPadding(),
) -> Frame:
""":term:`Renders` the current frame.
Args:
render_args: Render arguments.
padding: :term:`Render output` padding.
Returns:
The rendered frame.
Raises:
IncompatibleRenderArgsError: Incompatible render arguments.
RenderError: An error occurred during :term:`rendering`.
"""
frame, padding = self._init_render_(self._render_, render_args, padding)
padded_size = padding.get_padded_size(frame.render_size)
return (
frame
if frame.render_size == padded_size
else Frame(
frame.number,
frame.duration,
padded_size,
padding.pad(frame.render_output, frame.render_size),
)
)
[docs]
def seek(self, offset: int, whence: Seek = Seek.START) -> int:
"""Sets the current frame number.
Args:
offset: Frame offset (relative to *whence*).
whence: Reference position for *offset*.
Returns:
The new current frame number.
Raises:
IndefiniteSeekError: The renderable has
:py:attr:`~term_image.renderable.FrameCount.INDEFINITE` frame count.
ValueError: *offset* is out of range.
The value range for *offset* depends on *whence*:
.. list-table::
:align: left
:header-rows: 1
:widths: auto
* - *whence*
- Valid value range for *offset*
* - :py:attr:`~term_image.renderable.Seek.START`
- ``0`` <= *offset* < :py:attr:`frame_count`
* - :py:attr:`~term_image.renderable.Seek.CURRENT`
- -:py:meth:`tell` <= *offset* < :py:attr:`frame_count` - :py:meth:`tell`
* - :py:attr:`~term_image.renderable.Seek.END`
- -:py:attr:`frame_count` < *offset* <= ``0``
"""
frame_count = self.frame_count
if frame_count is FrameCount.INDEFINITE:
raise IndefiniteSeekError(
"Cannot seek a renderable with INDEFINITE frame count"
)
frame = (
offset
if whence is Seek.START
else self._frame + offset
if whence is Seek.CURRENT
else frame_count + offset - 1
)
if not 0 <= frame < frame_count:
raise arg_value_error_range(
"offset",
offset,
(
f"whence={whence.name}, frame_count={frame_count}"
+ (f", current={self._frame}" if whence is Seek.CURRENT else "")
),
)
self._frame = frame
return frame
[docs]
def tell(self) -> int:
"""Returns the current frame number.
Returns:
Zero, if the renderable is non-animated or has
:py:attr:`~term_image.renderable.FrameCount.INDEFINITE` frame count.
Otherwise, the current frame number.
"""
return self._frame
# Extension methods ========================================================
[docs]
def _animate_(
self,
render_data: RenderData,
render_args: RenderArgs,
padding: Padding,
loops: int,
cache: bool | int,
output: TextIO,
) -> None:
"""Animates frames of a renderable.
Args:
render_data: Render data.
render_args: Render arguments associated with the renderable's class.
output: The text I/O stream to which rendered frames will be written.
All other parameters are the same as for :py:meth:`draw`, except that
*padding* must have **absolute** dimensions if it's an instance of
:py:class:`~term_image.padding.AlignedPadding`.
This is called by :py:meth:`draw` for animations.
NOTE:
* The base implementation does not finalize *render_data*.
* :term:`Render size` validation is expected to have been performed by
the caller.
* When called by :py:meth:`draw` (at least, the base implementation),
*loops* and *cache* wouldn't have been validated.
"""
from term_image.render import RenderIterator
render_size: Size = render_data[Renderable].size
height = render_size.height
pad_left, _, _, pad_bottom = padding._get_exact_dimensions_(render_size)
render_iter = RenderIterator._from_render_data_(
self,
render_data,
render_args,
padding,
loops,
False if loops == 1 else cache,
finalize=False,
)
cursor_to_bottom = cursor_down(height + pad_bottom - 1)
cursor_to_next_render_line = f"\n{cursor_forward(pad_left)}"
cursor_to_render_top_left = (
f"\r{cursor_up(height - 1)}{cursor_forward(pad_left)}"
)
write = output.write
flush = output.flush
try:
# first frame
try:
frame = next(render_iter)
except StopIteration: # `INDEFINITE` frame count
return
try:
write(frame.render_output)
flush()
except KeyboardInterrupt:
self._handle_interrupted_draw_(render_data, render_args, output)
return
else:
write(
f"\r{cursor_up(height + pad_bottom - 1)}{cursor_forward(pad_left)}"
)
flush()
# Padding has been drawn with the first frame, only the actual render is
# needed henceforth.
render_iter.set_padding(ExactPadding())
# render next frame during previous frame's duration
duration_ms = frame.duration
start_ns = perf_counter_ns()
for frame in render_iter: # Render next frame
# left-over of previous frame's duration
sleep(
max(0, duration_ms * 10**6 - (perf_counter_ns() - start_ns))
/ 10**9
)
# clear previous frame, if necessary
self._clear_frame_(render_data, render_args, pad_left + 1, output)
# draw next frame
try:
write(frame.render_output.replace("\n", cursor_to_next_render_line))
flush()
except KeyboardInterrupt:
self._handle_interrupted_draw_(render_data, render_args, output)
return
write(cursor_to_render_top_left)
flush()
# render next frame during previous frame's duration
start_ns = perf_counter_ns()
duration_ms = frame.duration
# left-over of last frame's duration
sleep(
max(0, duration_ms * 10**6 - (perf_counter_ns() - start_ns)) / 10**9
)
except KeyboardInterrupt:
pass
finally:
render_iter.close()
# Move the cursor to the last line to prevent "overlaid" output in a
# terminal
write(cursor_to_bottom)
flush()
[docs]
def _clear_frame_(
self,
render_data: RenderData,
render_args: RenderArgs,
cursor_x: int,
output: TextIO,
) -> None:
"""Clears the previous frame of an animation, if necessary.
Args:
render_data: Render data.
render_args: Render arguments.
cursor_x: Column/horizontal position of the cursor at the point of calling
this method.
.. note:: The position is **1-based** i.e the leftmost column on the
screen is at position 1 (one).
output: The text I/O stream to which frames of the animation are being
written.
Called by the base implementation of :py:meth:`_animate_` just before drawing
the next frame of an animation.
Upon calling this method, the cursor should be positioned at the top-left-most
cell of the region occupied by the frame render output on the terminal screen.
Upon return, ensure the cursor is at the same position it was at the point of
calling this method (at least logically, since *output* shouldn't be flushed
yet).
The base implementation does nothing.
NOTE:
* This is required only if drawing the next frame doesn't inherently
overwrite the previous frame.
* This is only meant (and should only be used) as a last resort since
clearing the previous frame before drawing the next may result in
visible flicker.
* Ensure whatever this method does doesn't result in the screen being
scrolled.
TIP:
To reduce flicker, it's advisable to **not** flush *output*. It will be
flushed after writing the next frame.
"""
[docs]
@classmethod
def _finalize_render_data_(cls, render_data: RenderData) -> None:
"""Finalizes render data.
Args:
render_data: Render data.
Typically, an overriding method should
* finalize the data generated by :py:meth:`_get_render_data_` of the
**same class**, if necessary,
* call the overridden method, passing on *render_data*.
NOTE:
* It's recommended to call :py:meth:`RenderData.finalize()
<term_image.renderable.RenderData.finalize>`
instead as that assures a single invocation of this method.
* Any definition of this method should be safe for multiple invocations on
the same :py:class:`~term_image.renderable.RenderData` instance, just in
case.
.. seealso::
:py:meth:`_get_render_data_`,
:py:meth:`RenderData.finalize()
<term_image.renderable.RenderData.finalize>`,
the *finalize* parameter of :py:meth:`_init_render_`.
"""
[docs]
def _get_frame_count_(self) -> int | Literal[FrameCount.INDEFINITE]:
"""Implements :py:attr:`~term_image.renderable.FrameCount.POSTPONED` frame
count evaluation.
Returns:
The frame count of the renderable. See :py:attr:`frame_count`.
.. note::
Returning :py:attr:`~term_image.renderable.FrameCount.POSTPONED` or
``1`` (one) is invalid and may result in unexpected/undefined behaviour
across various interfaces defined by this library (and those derived
from them), since re-postponing evaluation is unsupported and the
renderable would have been taken to be animated.
The base implementation raises :py:class:`NotImplementedError`.
"""
raise NotImplementedError("POSTPONED frame count evaluation isn't implemented")
[docs]
def _get_render_data_(self, *, iteration: bool) -> RenderData:
"""Generates data required for rendering that's based on internal or
external state.
Args:
iteration: Whether the render operation requiring the data involves a
sequence of :term:`renders` (most likely of different frames), or it's
a one-off render.
Returns:
The generated render data.
The render data should include **copies** of any **variable/mutable**
internal/external state required for rendering and other data generated from
constant state but which should **persist** throughout a render operation
(which may involve consecutive/repeated renders of one or more frames).
May also be used to "allocate" and initialize storage for mutable/variable
data specific to a render operation.
Typically, an overriding method should
* call the overridden method,
* update the namespace for its defining class (i.e
:py:meth:`render_data[__class__]
<term_image.renderable.RenderData.__getitem__>`) within the
:py:class:`~term_image.renderable.RenderData` instance returned by the
overridden method,
* return the same :py:class:`~term_image.renderable.RenderData` instance.
IMPORTANT:
The :py:class:`~term_image.renderable.RenderData` instance returned must be
associated [#rd-ass]_ with the type of the renderable on which this method
is called i.e ``type(self)``. This is always the case for the base
implementation of this method.
NOTE:
This method being called doesn't mean the data generated will be used
immediately.
.. seealso::
:py:class:`~term_image.renderable.RenderData`,
:py:meth:`_finalize_render_data_`,
:py:meth:`~term_image.renderable.Renderable._init_render_`.
"""
render_data = RenderData(type(self))
renderable_data: RenderableData = render_data[Renderable]
renderable_data.update(
size=self._get_render_size_(),
frame_offset=self._frame,
seek_whence=Seek.START,
iteration=iteration,
)
if self.animated:
renderable_data.duration = self._frame_duration
return render_data
[docs]
@abstractmethod
def _get_render_size_(self) -> geometry.Size:
"""Returns the renderable's :term:`render size`.
Returns:
The size of the renderable's :term:`render output`.
The base implementation raises :py:class:`NotImplementedError`.
NOTE:
Both dimensions are expected to be positive.
.. seealso:: :py:attr:`render_size`
"""
raise NotImplementedError
[docs]
def _handle_interrupted_draw_(
self, render_data: RenderData, render_args: RenderArgs, output: TextIO
) -> None:
"""Performs any special handling necessary when an interruption occurs while
writing a :term:`render output` to a stream.
Args:
render_data: Render data.
render_args: Render arguments.
output: The text I/O stream to which the render output was being written.
Called by the base implementations of :py:meth:`draw` (for non-animations)
and :py:meth:`_animate_` when :py:class:`KeyboardInterrupt` is raised while
writing a render output.
The base implementation does nothing.
NOTE:
*output* should be flushed by this method.
HINT:
For a renderable that uses SGR sequences in its render output, this method
may write ``CSI 0 m`` to *output*.
"""
# *render_args*, no *padding*; or neither
@overload
def _init_render_(
self,
renderer: Callable[[RenderData, RenderArgs], T],
render_args: RenderArgs | None = None,
*,
iteration: bool = False,
finalize: bool = True,
check_size: bool = False,
allow_scroll: bool = False,
) -> tuple[T, None]:
...
# both *render_args* and *padding*
@overload
def _init_render_(
self,
renderer: Callable[[RenderData, RenderArgs], T],
render_args: RenderArgs | None,
padding: OptionalPaddingT,
*,
iteration: bool = False,
finalize: bool = True,
check_size: bool = False,
allow_scroll: bool = False,
) -> tuple[T, OptionalPaddingT]:
...
# *padding*, no *render_args*
@overload
def _init_render_(
self,
renderer: Callable[[RenderData, RenderArgs], T],
*,
padding: OptionalPaddingT,
iteration: bool = False,
finalize: bool = True,
check_size: bool = False,
allow_scroll: bool = False,
) -> tuple[T, OptionalPaddingT]:
...
[docs]
def _init_render_(
self,
renderer: Callable[[RenderData, RenderArgs], T],
render_args: RenderArgs | None = None,
padding: Padding | None = None,
*,
iteration: bool = False,
finalize: bool = True,
check_size: bool = False,
allow_scroll: bool = False,
) -> tuple[T, Padding | None]:
"""Initiates a render operation.
Args:
renderer: Performs a render operation or extracts render data and arguments
for a render operation to be performed later on.
render_args: Render arguments.
padding (:py:data:`OptionalPaddingT`): :term:`Render output` padding.
iteration: Whether the render operation involves a sequence of renders
(most likely of different frames), or it's a one-off render.
finalize: Whether to finalize the render data passed to *renderer*
immediately *renderer* returns.
check_size: Whether to validate the [padded] :term:`render size` of
**non-animations**.
allow_scroll: Whether to validate the [padded] :term:`render height` of
**non-animations**. Ignored if *check_size* is ``False``.
Returns:
A tuple containing
* The return value of *renderer*.
* *padding* (with equivalent **absolute** dimensions if it's an instance of
:py:class:`~term_image.padding.AlignedPadding`).
Raises:
IncompatibleRenderArgsError: Incompatible render arguments.
RenderSizeOutofRangeError: *check_size* is ``True`` and the [padded]
:term:`render size` cannot fit into the :term:`terminal size`.
:rtype: tuple[T, :py:data:`OptionalPaddingT`]
After preparing render data and processing arguments, *renderer* is called with
the following positional arguments:
1. Render data associated with **the renderable's class**
2. Render arguments associated with **the renderable's class** and initialized
with *render_args*
Any exception raised by *renderer* is propagated.
IMPORTANT:
Beyond this method (i.e any context from *renderer* onwards), use of any
variable state (internal or external) should be avoided if possible.
Any variable state (internal or external) required for rendering should
be provided via :py:meth:`_get_render_data_`.
If at all any variable state has to be used and is not
reasonable/practicable to be provided via :py:meth:`_get_render_data_`,
it should be read only once during a single render and passed to any
nested/subsequent calls that require the value of that state during the
same render.
This is to prevent inconsistency in data used for the same render which may
result in unexpected output.
"""
if not (render_args and render_args.render_cls is type(self)):
# Validate compatibility (and convert, if compatible)
render_args = RenderArgs(type(self), render_args)
terminal_size = get_terminal_size()
render_data = self._get_render_data_(iteration=iteration)
try:
if padding and isinstance(padding, AlignedPadding) and padding.relative:
padding = padding.resolve(terminal_size)
if check_size:
render_size: Size = render_data[Renderable].size
width, height = (
padding.get_padded_size(render_size) if padding else render_size
)
terminal_width, terminal_height = terminal_size
if width > terminal_width:
raise RenderSizeOutofRangeError(
f"{'Padded render' if padding else 'Render'} width out of "
f"range (got: {width}; terminal_width={terminal_width})"
)
if not allow_scroll and height > terminal_height:
raise RenderSizeOutofRangeError(
f"{'Padded render' if padding else 'Render'} height out of "
f"range (got: {height}; terminal_height={terminal_height})"
)
return renderer(render_data, render_args), padding
finally:
if finalize:
render_data.finalize()
[docs]
@abstractmethod
def _render_(self, render_data: RenderData, render_args: RenderArgs) -> Frame:
""":term:`Renders` a frame.
Args:
render_data: Render data.
render_args: Render arguments.
Returns:
The rendered frame.
* The :py:attr:`~term_image.renderable.Frame.render_size` field =
:py:attr:`render_data[Renderable].size
<term_image.renderable.RenderableData.size>`.
* The :py:attr:`~term_image.renderable.Frame.render_output` field holds the
:term:`render output`. This string should:
* contain as many lines as ``render_size.height`` i.e exactly
``render_size.height - 1`` occurrences of ``\\n`` (the newline
sequence).
* occupy exactly ``render_size.height`` lines and ``render_size.width``
columns on each line when drawn onto a terminal screen, **at least**
when the render **size** it not greater than the terminal size on
either axis.
.. tip::
If for any reason, the output behaves differently when the render
**height** is greater than the terminal height, the behaviour, along
with any possible alternatives or workarounds, should be duely noted.
This doesn't apply to the **width**.
* **not** end with ``\\n`` (the newline sequence).
* As for the :py:attr:`~term_image.renderable.Frame.duration` field, if
the renderable is:
* **animated**; the value should be determined from the frame data
source (or a default/fallback value, if undeterminable), if
:py:attr:`render_data[Renderable].duration
<term_image.renderable.RenderableData.duration>` is
:py:attr:`~term_image.renderable.FrameDuration.DYNAMIC`.
Otherwise, it should be equal to
:py:attr:`render_data[Renderable].duration
<term_image.renderable.RenderableData.duration>`.
* **non-animated**; the value range is unspecified i.e it may be given
any value.
Raises:
StopIteration: End of iteration for an animated renderable with
:py:attr:`~term_image.renderable.FrameCount.INDEFINITE` frame count.
RenderError: An error occurred while rendering.
NOTE:
:py:class:`StopIteration` may be raised if and only if
:py:attr:`render_data[Renderable].iteration
<term_image.renderable.RenderableData.iteration>` is ``True``.
Otherwise, it would be out of place.
.. seealso:: :py:class:`~term_image.renderable.RenderableData`.
"""
raise NotImplementedError
# NOTE: The position of these is critical, as they're required for the
# creation of render argument and data namespace classes.
_types.RenderableMeta = RenderableMeta # type: ignore[attr-defined]
RenderArgs.__init__(_types.BASE_RENDER_ARGS, Renderable)
[docs]
class RenderableData(DataNamespace, render_cls=Renderable):
"""RenderableData()
Render data namespace for :py:class:`~term_image.renderable.Renderable`.
.. seealso::
:py:attr:`~term_image.renderable.Renderable._Data_`
Render class-specific render data.
:py:meth:`~term_image.renderable.Renderable._render_`
Renders a frame of a renderable.
"""
size: geometry.Size
""":term:`Render size`
See :py:meth:`~term_image.renderable.Renderable._render_`.
"""
frame_offset: int
"""Frame number/offset
If the :py:attr:`~term_image.renderable.Renderable.frame_count` of the
renderable (that generated the data) is:
* *definite* (i.e an integer); the value of this field is a **non-negative**
integer **less than the frame count**, the number of the frame to be rendered.
* :py:attr:`~term_image.renderable.FrameCount.INDEFINITE`, the value range and
interpretation of this field depends on the value of :py:attr:`iteration`
and :py:attr:`seek_whence`.
If :py:attr:`iteration` is ``False``, the value is always **zero** and
anything (such as a placeholder frame) may be rendered, as renderables with
:py:attr:`~term_image.renderable.FrameCount.INDEFINITE` frame count are
typically meant for iteration/animation.
If :py:attr:`iteration` is ``True`` and :py:attr:`seek_whence` is:
* :py:attr:`~term_image.renderable.Seek.CURRENT`, the value of this field
may be:
* **zero**, denoting that the next frame on the stream should be rendered.
* **positive**, denoting that the stream should be seeked **forward** by
:py:attr:`frame_offset` frames and then the new next frame should be
rendered.
* **negative**, denoting that the stream should be seeked **backward** by
-:py:attr:`frame_offset` frames and then the new next frame should be
rendered.
* :py:attr:`~term_image.renderable.Seek.START`, the value of this field
may be:
* **zero**, denoting that the stream should be seeked to its beginning
and then the first frame should be rendered.
* **positive**, denoting that the stream should be seeked to the
(:py:attr:`frame_offset`)th frame **after the first** and then the new
next frame should be rendered.
* :py:attr:`~term_image.renderable.Seek.END`, the value of this field
may be:
* **zero**, denoting that the stream should be seeked to its end
and then the last frame should be rendered.
* **negative**, denoting that the stream should be seeked to the
(-:py:attr:`frame_offset`)th frame **before the last** and then the new
next frame should be rendered.
If the end of the stream cannot be determined (yet), such as with a live
source, the furthest available frame in the **forward** direction should
be taken to be the end.
.. note::
* If any seek operation is not supported by the underlying source, it
should be ignored and the next frame on the stream should be rendered.
* If forward seek is supported but the offset is out of the range of
available frames, the stream should be seeked to the furthest available
frame in the forward direction if its end cannot be determined (yet),
such as with a live source.
Otherwise i.e if the offset is determined to be beyond the end of the
stream, :py:class:`StopIteration` should be raised
(see :py:meth:`~term_image.renderable.Renderable._render_`).
* If backward seek is supported but the offset is out of the range of
available frames, the stream should be seeked to its beginning or the
furthest available frame in the backward direction.
.. tip::
A :term:`render class` that implements
:py:attr:`~term_image.renderable.FrameCount.INDEFINITE` frame count should
specify which seek operations it supports and any necessary details.
"""
seek_whence: Seek
"""Reference position for :py:attr:`frame_offset`
If the :py:attr:`~term_image.renderable.Renderable.frame_count` of the
renderable (that generated the data) is *definite*, or
:py:attr:`~term_image.renderable.FrameCount.INDEFINITE` but
:py:attr:`iteration` is ``False``; the value of this
field is always :py:attr:`~term_image.renderable.Seek.START`.
Otherwise i.e if :py:attr:`~term_image.renderable.Renderable.frame_count`
is :py:attr:`~term_image.renderable.FrameCount.INDEFINITE` and
:py:attr:`iteration` is ``True``, it may be any member of
:py:class:`~term_image.renderable.Seek`.
"""
duration: int | FrameDuration
"""Frame duration
The possible values and their respective interpretations are the same as for
:py:attr:`~term_image.renderable.Renderable.frame_duration`.
See :py:meth:`~term_image.renderable.Renderable._render_` for usage details.
ATTENTION:
This field is left **uninitialized** for render data generated by/for
**non-animated** renderables.
"""
iteration: bool
""":term:`Render` operation kind
``True`` if the render is part of a render operation involving a sequence of
renders (most likely of different frames). Otherwise i.e if it's a one-off
render, ``False``.
"""