Repository: AnonymouX47/term-image (language: python)

Issue:
The Renderable, Render and Padding APIs
- Adds `.renderable` subpackage.
  - `Renderable`
    - `_Data_`
  - `RenderArgs`
    - `Namespace`
  - `RenderData`
    - `Namespace`
  - `Frame`
  - `FrameCount`
  - `FrameDuration`
  - `Seek`
  - `RenderableError`
  - `IndefiniteSeekError`
  - `RenderError`
  - `RenderSizeOutOfRangeError`
  - `RenderArgsDataError`
  - `RenderArgsError`
  - `RenderDataError`
  - `IncompatibleArgsNamespaceError`
  - `IncompatibleRenderArgsError`
  - `NoArgsNamespaceError`
  - `NoDataNamespaceError`
  - `NonAnimatedFrameDurationError`
  - `UninitializedDataFieldError`
  - `UnknownArgsFieldError`
  - `UnknownDataFieldError`
- Adds `.render` subpackage.
  - `RenderIterator`
  - `RenderIteratorError`
  - `FinalizedIteratorError`
- Adds `.padding` submodule.
  - `Padding`
  - `AlignedPadding`
  - `ExactPadding`
  - `HAlign`
  - `VAlign`
  - `PaddingError`
  - `RelativePaddingDimensionsError`
- Updates the documentation glossary.
- Adds `sphinxcontrib-prettyspecialmethods` documentation dependency.


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4002e3a6..575e4588 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - `term_image.geometry` submodule ([#96]).
   - `term_image.geometry.Size`.
   - `term_image.geometry.RawSize` ([5537037]).
+- `term_image.renderable` subpackage ([#97]).
+  - `Renderable`, `RenderArgs`, `RenderData`, `Frame`, etc.
+- `term_image.render` subpackage ([#97]).
+  - `RenderIterator`, etc.
+- `term_image.padding` submodule ([#97]).
+  - `Padding`, `AlignedPadding`, `ExactPadding`, etc.
 
 ### Changed
 - `term_image.utils.get_cell_size()` now returns `term_image.geometry.Size` instances in place of tuples ([#96]).
diff --git a/Makefile b/Makefile
index 4fa314fe..b1a1b626 100644
--- a/Makefile
+++ b/Makefile
@@ -63,6 +63,10 @@ pytest := pytest -v
 
 test-top := tests/test_top_level.py
 test-geometry := tests/test_geometry.py
+test-padding := tests/test_padding.py
+test-renderable-renderable := tests/renderable/test_renderable.py
+test-renderable-types := tests/renderable/test_types.py
+test-render-iterator := tests/render/test_iterator.py
 test-base := tests/test_image/test_base.py
 test-block := tests/test_image/test_block.py
 test-kitty := tests/test_image/test_kitty.py
@@ -72,17 +76,19 @@ test-others := tests/test_image/test_others.py
 test-iterator := tests/test_iterator.py
 test-urwid := tests/test_widget/test_urwid.py
 
+test-renderable := $(test-renderable-renderable) $(test-renderable-types)
+test-render := $(test-render-iterator)
 test-text := $(test-block)
 test-graphics := $(test-kitty) $(test-iterm2)
 test-image := $(test-base) $(test-text) $(test-graphics) $(test-others)
 test-widget := $(test-urwid)
-test := $(test-top) $(test-geometry) $(test-image) $(test-iterator) $(test-widget)
+test := $(test-top) $(test-geometry) $(test-padding) $(test-renderable) $(test-render) $(test-image) $(test-iterator) $(test-widget)
 test-all := $(test) $(test-url)
 
 ## Targets
 
-test-all test test-image test-text test-graphics test-widget \
-test-top test-geometry test-base test-block test-kitty test-iterm2 test-url test-others test-iterator test-urwid:
+test-all test test-renderable test-render test-text test-graphics test-image test-widget \
+test-top test-geometry test-padding test-base test-block test-kitty test-iterm2 test-url test-others test-iterator test-urwid:
 	$(pytest) $($@)
 
 
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 635d54d7..e3a480fb 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,6 +1,7 @@
 sphinx==6.1.2
 furo==2022.12.7
 sphinx-toolbox==3.4.0
+sphinxcontrib-prettyspecialmethods==0.1.0
 urwid==2.1.2
 
 # `sphinx-toolbox` requires `cachecontrol`, which is incompatible with `urllib3>=2`
diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst
index 2ffb7efc..41ea58ef 100644
--- a/docs/source/api/index.rst
+++ b/docs/source/api/index.rst
@@ -20,8 +20,11 @@ API Reference
    :caption: Sub-sections:
 
    toplevel
+   renderable
+   render
    image
    widget
+   padding
    geometry
    exceptions
    utils
diff --git a/docs/source/api/padding.rst b/docs/source/api/padding.rst
new file mode 100644
index 00000000..ad7fc4ef
--- /dev/null
+++ b/docs/source/api/padding.rst
@@ -0,0 +1,85 @@
+``padding`` Module
+==================
+
+.. module:: term_image.padding
+
+Classes
+-------
+
+.. automodulesumm:: term_image.padding
+   :autosummary-sections: Classes
+   :autosummary-no-titles:
+
+
+.. autoclass:: Padding
+
+|
+
+.. autoclass:: AlignedPadding
+
+|
+
+.. autoclass:: ExactPadding
+
+|
+
+
+Enumerations
+------------
+
+.. automodulesumm:: term_image.padding
+   :autosummary-sections: Enumerations
+   :autosummary-no-titles:
+
+
+.. autoclass:: HAlign
+   :autosummary-sections: None
+
+|
+
+.. autoclass:: VAlign
+   :autosummary-sections: None
+
+|
+
+
+Exceptions
+----------
+
+.. automodulesumm:: term_image.padding
+   :autosummary-sections: Exceptions
+   :autosummary-no-titles:
+
+
+.. autoexception:: PaddingError
+.. autoexception:: RelativePaddingDimensionError
+
+|
+
+
+Extension API
+-------------
+
+.. note::
+   The following definitions are provided and required only for extended use of the
+   interfaces defined above.
+
+   Everything required for normal usage should typically be exposed in the public API.
+
+.. _padding-ext-api:
+
+Padding
+^^^^^^^
+.. class:: Padding
+   :noindex:
+
+   See :py:class:`Padding` for the public API.
+
+   .. note::
+      Instances of subclasses should be immutable to avoid inconsistent results in
+      asynchronous render operations that perform padding.
+
+   .. autoclasssumm:: Padding
+      :autosummary-members: _get_exact_dimensions_
+
+   .. automethod:: _get_exact_dimensions_
diff --git a/docs/source/api/render.rst b/docs/source/api/render.rst
new file mode 100644
index 00000000..5edfdc69
--- /dev/null
+++ b/docs/source/api/render.rst
@@ -0,0 +1,66 @@
+``render`` Module
+=================
+
+.. module:: term_image.render
+
+Classes
+-------
+
+.. automodulesumm:: term_image.render
+   :autosummary-sections: Classes
+   :autosummary-no-titles:
+
+
+.. autoclass:: RenderIterator
+
+|
+
+
+Exceptions
+----------
+
+.. automodulesumm:: term_image.render
+   :autosummary-sections: Exceptions
+   :autosummary-no-titles:
+
+
+.. autoexception:: RenderIteratorError
+.. autoexception:: FinalizedIteratorError
+
+|
+
+
+Extension API
+-------------
+
+.. note::
+   The following definitions are provided and required only for extended use of the
+   interfaces defined above.
+
+   Everything required for normal usage should typically be exposed in the public API.
+
+.. _render-iterator-ext-api:
+
+RenderIterator
+^^^^^^^^^^^^^^
+.. class:: RenderIterator
+   :noindex:
+
+   See :py:class:`RenderIterator` for the public API.
+
+   .. autoclasssumm:: RenderIterator
+      :autosummary-members: _from_render_data_
+
+   .. automethod:: _from_render_data_
+
+|
+
+.. rubric:: Footnotes
+
+.. [#ri-nf]
+   The frame to be rendered **next** is:
+
+   * the first frame, if no seek operation or render has occured;
+   * otherwise, the frame after that which was rendered **last**, if no seek operation
+     has occured since the last render;
+   * otherwise, the frame set by the **latest** seek operation since the last render.
diff --git a/docs/source/api/renderable.rst b/docs/source/api/renderable.rst
new file mode 100644
index 00000000..18e9eebe
--- /dev/null
+++ b/docs/source/api/renderable.rst
@@ -0,0 +1,525 @@
+``renderable`` Module
+=====================
+
+.. module:: term_image.renderable
+
+Classes
+-------
+
+.. automodulesumm:: term_image.renderable
+   :autosummary-no-titles:
+   :autosummary-members: Renderable, RenderArgs, Frame
+
+
+.. autoclass:: Renderable
+   :special-members: __iter__, __str__
+
+|
+
+.. autoclass:: RenderArgs
+   :autosummary-exclude-members:
+   :special-members: __eq__, __getitem__, __hash__, __iter__
+   :inherited-members: render_cls
+   :exclude-members: Namespace
+
+   .. rubric:: Footnotes
+
+   .. [#ra1]
+      An set of render arguments (an instance of this class) is compatible with its
+      associated [#ra2]_ :term:`render class` and its subclasses.
+   .. [#ra2]
+      The associated :term:`render class` of a set of render arguments (an instance of
+      this class) is *render_cls*, accessible via :py:attr:`render_cls`.
+
+|
+
+.. autoclass:: term_image.renderable.RenderArgs.Namespace
+   :special-members: __eq__, __hash__, __or__, __pos__, __ror__
+   :inherited-members: get_render_cls
+
+   .. seealso:: :ref:`args-namespace`.
+
+   .. rubric:: Footnotes
+
+   .. [#ran1]
+      A render argument namespace class, along with its subclasses (that inherit its
+      fields and are not associated with another render class) and their instances,
+      is associated with the :term:`render class` that defines (not inherits) that
+      subclass as its :ref:`Args <renderable-args>` attribute **at its creation**.
+      The render class is accessible via
+      :py:meth:`~term_image.renderable.RenderArgs.Namespace.get_render_cls`.
+   .. [#ran2]
+      A render argument namespace is compatible with its associated [#ran1]_
+      :term:`render class` and the subclasses thereof.
+
+|
+
+.. autoclass:: Frame
+   :special-members: __str__
+
+|
+
+Enumerations
+------------
+
+.. automodulesumm:: term_image.renderable
+   :autosummary-sections: Enumerations
+   :autosummary-no-titles:
+
+
+.. autoclass:: FrameCount
+   :autosummary-sections: None
+
+|
+
+.. autoclass:: FrameDuration
+   :autosummary-sections: None
+
+|
+
+.. autoclass:: Seek
+   :autosummary-sections: None
+   :undoc-members:
+
+|
+
+Exceptions
+----------
+
+.. automodulesumm:: term_image.renderable
+   :autosummary-sections: Exceptions
+   :autosummary-no-titles:
+
+
+.. autoexception:: RenderableError
+.. autoexception:: IndefiniteSeekError
+.. autoexception:: RenderError
+.. autoexception:: RenderSizeOutofRangeError
+.. autoexception:: RenderArgsDataError
+.. autoexception:: RenderArgsError
+.. autoexception:: RenderDataError
+.. autoexception:: IncompatibleArgsNamespaceError
+.. autoexception:: IncompatibleRenderArgsError
+.. autoexception:: NoArgsNamespaceError
+.. autoexception:: NoDataNamespaceError
+.. autoexception:: NonAnimatedFrameDurationError
+.. autoexception:: UninitializedDataFieldError
+.. autoexception:: UnknownArgsFieldError
+.. autoexception:: UnknownDataFieldError
+
+|
+
+Extension API
+-------------
+
+.. note::
+   The following interfaces are provided and required only to extend the Renderable API
+   i.e to create custom renderables or extend any of those provided by this library.
+
+   Everything required for normal usage should typically be exposed in the public API.
+
+   For performance reasons, all implementations of these interfaces within this library
+   perform no form of argument validation, except stated otherwise. The same should apply
+   to any extension or override of these interfaces. All arguments are and should be
+   expected to be valid. Hence, arguments should be validated beforehand if necessary.
+
+   In the same vein, return values of any of these interfaces will not be validated by
+   the callers before use.
+
+.. _renderable-ext-api:
+
+Renderable
+^^^^^^^^^^
+
+.. py:class:: Renderable
+   :noindex:
+
+   See :py:class:`Renderable` for the public API.
+
+   .. autoclasssumm:: Renderable
+      :autosummary-members:
+        _EXPORTED_ATTRS_,
+        _EXPORTED_DESCENDANT_ATTRS_,
+        _animate_,
+        _clear_frame_,
+        _finalize_render_data_,
+        _get_frame_count_,
+        _get_render_data_,
+        _handle_interrupted_draw_,
+        _init_render_,
+        _render_,
+        _Data_,
+
+   .. autoattribute:: _EXPORTED_ATTRS_
+   .. autoattribute:: _EXPORTED_DESCENDANT_ATTRS_
+   .. automethod:: _animate_
+   .. automethod:: _clear_frame_
+   .. automethod:: _finalize_render_data_
+   .. automethod:: _get_frame_count_
+   .. automethod:: _get_render_data_
+   .. automethod:: _get_render_size_
+   .. automethod:: _handle_interrupted_draw_
+   .. automethod:: _init_render_
+   .. automethod:: _render_
+
+.. autoclass:: term_image.renderable.Renderable._Data_
+
+|
+
+.. _renderable-args:
+
+Renderable.Args
+"""""""""""""""
+.. py:attribute:: Renderable.Args
+   :noindex:
+   :type: typing.ClassVar[type[RenderArgs.Namespace] | None]
+
+   :term:`Render class`\ -specific render arguments.
+
+   If this is a class, it defines the render arguments of the render class defining
+   this attribute.
+
+   An instance of this class (``Args``), is contained in any :py:class:`RenderArgs`
+   instance associated [#ra2]_ with the render class defining this attribute or any
+   of its subclasses.
+
+   Also, an instance of this class (``Args``) is returned by
+   :py:meth:`render_args[render_cls] <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 defining this attribute.
+
+   .. collapse:: Example
+
+      >>> class Foo(Renderable):
+      ...     class Args(RenderArgs.Namespace):
+      ...         foo: str | None = None
+      ...
+      >>> # default
+      >>> Foo.Args()
+      Foo.Args(foo=None)
+      >>> render_args = RenderArgs(Foo)
+      >>> render_args[Foo]
+      Foo.Args(foo=None)
+      >>>
+      >>> # non-default
+      >>> foo_args = Foo.Args("FOO")
+      >>> foo_args
+      Foo.Args(foo='FOO')
+      >>> render_args = RenderArgs(Foo, foo_args.update(foo="bar"))
+      >>> render_args[Foo]
+      Foo.Args(foo='bar')
+
+   On the other hand, if this is ``None``, it implies the render class defines no
+   render arguments.
+
+   .. collapse:: Example
+
+      >>> class Bar(Renderable):
+      ...     pass
+      ...
+      >>> assert Bar.Args is None
+      >>> render_args = RenderArgs(Bar)
+      >>> render_args[Bar]
+      Traceback (most recent call last):
+      ...
+      NoArgsNamespaceError: 'Bar' defines no render arguments
+
+   .. note::
+
+      * If this attribute is not defined at creation of a render class, it's set to
+        ``None``.
+      * If this attribute is intended to be a class, it must be defined before the
+        creation of the render class.
+      * Modifying this attribute after creation of the render class neither associates
+        nor disassociates a render argument namespace class.
+
+|
+
+.. _renderable-data:
+
+Renderable._Data_
+"""""""""""""""""
+.. py:attribute:: Renderable._Data_
+   :noindex:
+   :type: typing.ClassVar[type[RenderData.Namespace] | None]
+
+   :term:`Render class`\ -specific render data.
+
+   If this is a class, it defines the render data of the render class defining
+   this attribute.
+
+   An instance of this class (``_Data_``), is contained in any :py:class:`RenderData`
+   instance associated [#rd1]_ with the render class defining this attribute or any
+   of its subclasses.
+
+   Also, an instance of this class (``_Data_``) is returned by
+   :py:meth:`render_data[render_cls] <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 defining this attribute.
+
+   .. collapse:: Example
+
+      >>> class Foo(Renderable):
+      ...     class _Data_(RenderData.Namespace):
+      ...         foo: str | None
+      ...
+      >>> foo_data = Foo._Data_()
+      >>> foo_data
+      <Foo._Data_: 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
+      <Foo._Data_: foo='FOO'>
+      >>> assert foo_data.foo == "FOO"
+      >>>
+      >>> render_data = RenderData(Foo)
+      >>> render_data[Foo]
+      <Foo._Data_: foo=<uninitialized>>
+      >>>
+      >>> render_data[Foo].foo = "bar"
+      >>> render_data[Foo]
+      <Foo._Data_: foo='bar'>
+
+   On the other hand, if this is ``None``, it implies the render class defines no
+   render data.
+
+   .. collapse:: Example
+
+      >>> class Bar(Renderable):
+      ...     pass
+      ...
+      >>> assert Bar._Data_ is None
+      >>>
+      >>> render_data = RenderData(Bar)
+      >>> render_data[Bar]
+      Traceback (most recent call last):
+        ...
+      NoDataNamespaceError: 'Bar' defines no render data
+
+   .. note::
+
+      * If this attribute is not defined at creation of a render class, it's set to
+        ``None``.
+      * If this attribute is intended to be a class, it must be defined before the
+        creation of the render class.
+      * Modifying this attribute after creation of the render class neither associates
+        nor disassociates a render data namespace class.
+
+   .. seealso:: :py:class:`Renderable._Data_ <term_image.renderable.Renderable._Data_>`.
+
+|
+
+.. _args-namespace:
+
+RenderArgs.Namespace
+^^^^^^^^^^^^^^^^^^^^
+
+.. py:class:: RenderArgs.Namespace
+   :noindex:
+
+   See :py:class:`RenderArgs.Namespace <term_image.renderable.RenderArgs.Namespace>`
+   for the public API.
+
+   .. rubric:: Subclassing
+
+   * Every subclass must **either** define or inherit fields.
+   * Every **direct** subclass of this class must define fields.
+   * Directly subclassing multiple namespace classes is invalid.
+   * A subclass' constructor must not have required parameters.
+
+   .. rubric:: Defining Fields
+
+   Fields are defined as **annotated** class attributes.
+   All annotated attributes of this class are taken to be fields.
+   Every such attribute must be assigned a value which is taken to be the default
+   value of the field.
+
+   .. collapse:: Example
+
+      >>> class Args(RenderArgs.Namespace):
+      ...     foo: str = "FOO"
+      ...     bar: str = "BAR"
+      ...
+      >>> Args.get_fields()
+      mappingproxy({'foo': 'FOO', 'bar': 'BAR'})
+
+   The attribute annotations are only used to identify render argument fields,
+   they're never evaluated or used otherwise by any part of the Renderable API.
+   The field names will be unbound from their assigned values (the default field
+   values) during the creation of the subclass.
+
+   .. rubric:: Inheriting Fields
+
+   Fields may be inherited from any **concrete** render argument namespace
+   class (i.e any **strict** subclass of this class) by subclassing it.
+   By default, every **indirect** subclass (i.e subclass of a subclass) of this
+   class inherits the fields and associated [#ran1]_ render class (if any), of its
+   parent.
+
+   .. collapse:: Example
+
+      >>> class Args1(RenderArgs.Namespace):
+      ...     foo: str = "FOO"
+      ...
+      >>> Args1.get_fields()
+      mappingproxy({'foo': 'FOO'})
+      >>> assert Args1.get_render_cls() is None
+      >>>
+      >>> class Args2(Args1):
+      ...     pass
+      ...
+      >>> Args2.get_fields()
+      mappingproxy({'foo': 'FOO'})
+      >>> assert Args1.get_render_cls() is None
+      >>> assert Args2.get_render_cls() is None
+      >>>
+      >>> class Foo(Renderable):
+      ...     Args = Args2
+      ...
+      >>> assert Args1.get_render_cls() is None
+      >>> assert Args2.get_render_cls() is Foo
+      >>>
+      >>> class Args3(Args2):
+      ...     pass
+      ...
+      >>> Args3.get_fields()
+      mappingproxy({'foo': 'FOO'})
+      >>> assert Args1.get_render_cls() is None
+      >>> assert Args2.get_render_cls() is Foo
+      >>> assert Args3.get_render_cls() is Foo
+
+   To disable inheritance of fields and associated [#ran1]_ render class, the
+   ``inherit=False`` keyword argument should be passed in the class definition
+   header, in which case the new subclass must define fields.
+
+   .. collapse:: Example
+
+      >>> class Args1(RenderArgs.Namespace):
+      ...     foo: str = "FOO"
+      ...
+      >>> Args1.get_fields()
+      mappingproxy({'foo': 'FOO'})
+      >>> assert Args1.get_render_cls() is None
+      >>>
+      >>> class Args2(Args1, inherit=False):
+      ...     pass
+      ...
+      Traceback (most recent call last):
+        ...
+      RenderArgsDataError: No field defined or to inherit
+      >>>
+      >>> class Args2(Args1, inherit=False):
+      ...     bar: str = "BAR"
+      ...
+      >>> Args2.get_fields()
+      mappingproxy({'bar': 'BAR'})
+      >>> assert Args1.get_render_cls() is None
+      >>> assert Args2.get_render_cls() is None
+      >>>
+      >>> class Foo(Renderable):
+      ...     Args = Args1
+      ...
+      >>> assert Args1.get_render_cls() is Foo
+      >>> assert Args2.get_render_cls() is None
+      >>>
+      >>> class Args3(Args1, inherit=False):
+      ...     baz: str = "BAZ"
+      ...
+      >>> Args3.get_fields()
+      mappingproxy({'baz': 'BAZ'})
+      >>> assert Args1.get_render_cls() is Foo
+      >>> assert Args2.get_render_cls() is None
+      >>> assert Args3.get_render_cls() is None
+
+   When a subclass is associated [#ran1]_ with a render class, all existing
+   subclasses **that inherited its fields** and haven't been associated with
+   another render class automatically inherit its associated render class.
+
+   .. collapse:: Example
+
+      >>> class Args1(RenderArgs.Namespace):
+      ...     foo: str = "FOO"
+      ...
+      >>> class Args2(Args1):
+      ...     pass
+      ...
+      >>> class Args3(Args2):
+      ...     pass
+      ...
+      >>> assert Args1.get_render_cls() is None
+      >>> assert Args2.get_render_cls() is None
+      >>> assert Args3.get_render_cls() is None
+      >>>
+      >>> class Bar(Renderable):
+      ...     Args = Args3
+      ...
+      >>> assert Args1.get_render_cls() is None
+      >>> assert Args2.get_render_cls() is None
+      >>> assert Args3.get_render_cls() is Bar
+      >>>
+      >>> class Foo(Renderable):
+      ...     Args = Args1
+      ...
+      >>> assert Args1.get_render_cls() is Foo
+      >>> assert Args2.get_render_cls() is Foo
+      >>> assert Args3.get_render_cls() is Bar
+
+   .. important::
+
+      Due to the design and implementation of the API, field values (including defaults)
+      should:
+
+      * (and are **expected** to) be **immutable**.
+
+        Otherwise, such may yield unexpected behaviour during render operations or
+        unexpected render outputs, **if an object used as a field value is modified**,
+        as:
+
+        * a namespace containing a mutable field value (or a set of render arguments
+          containing such a namespace) may be in use in an asynchronous render
+          operation,
+        * different sets of render arguments may contain the same namespace, and
+        * different namespaces may contain the same object as the field values.
+
+      * be **hashable**.
+
+        Otherwise, the namespace and any containing set of render arguments will also
+        not be hashable.
+
+   .. seealso:: :ref:`renderable-args`.
+
+|
+
+RenderData
+^^^^^^^^^^
+
+.. autoclass:: RenderData
+   :autosummary-exclude-members:
+   :special-members: __getitem__, __iter__
+   :inherited-members: render_cls
+   :exclude-members: Namespace
+
+   .. rubric:: Footnotes
+
+   .. [#rd1]
+      The associated :term:`render class` of a set of render data (an instance of this
+      class) is *render_cls*, accessible via :py:attr:`render_cls`.
+
+|
+
+.. autoclass:: term_image.renderable.RenderData.Namespace
+   :inherited-members: get_render_cls
+
+   .. rubric:: Footnotes
+
+   .. [#rdn1]
+      A render data namespace class, along with its subclasses (that inherit its fields
+      and are not associated with another render class) and their instances, is
+      associated with the :term:`render class` that defines (not inherits) the namespace
+      class as its :ref:`_Data_ <renderable-data>` attribute **at its creation**.
+      The associated render class is accessible via
+      :py:meth:`~term_image.renderable.RenderData.Namespace.get_render_cls`.
diff --git a/docs/source/conf.py b/docs/source/conf.py
index f882acba..713f330c 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -3,6 +3,8 @@
 # For the full list of configuration options, see the documentation:
 # https://www.sphinx-doc.org/en/master/usage/configuration.html
 
+from sphinxcontrib import prettyspecialmethods
+
 from term_image import __version__, utils
 from term_image.image.common import ImageMeta
 from term_image.image.iterm2 import ITerm2ImageMeta
@@ -29,6 +31,8 @@
     "sphinx_toolbox.github",
     "sphinx_toolbox.sidebar_links",
     "sphinx_toolbox.more_autosummary",
+    "sphinx_toolbox.collapse",
+    "sphinxcontrib.prettyspecialmethods",
 ]
 
 # -- Options for HTML output -------------------------------------------------
@@ -68,6 +72,8 @@
 
 # -- Event handlers -------------------------------------------------------------
 
+# # -- autodocsumm -----------------------------------------------------------
+
 
 def autodocssumm_grouper(app, what, name, obj, section, parent):
     from enum import EnumMeta
@@ -84,7 +90,18 @@ def autodocssumm_grouper(app, what, name, obj, section, parent):
     if isinstance(obj, type) and issubclass(obj, Warning):
         return "Warnings"
     if isinstance(parent, type):
-        obj = vars(parent)[name.rpartition(".")[2]]
+        short_name = name.rpartition(".")[2]
+        # Can't use `getattr()` because of data descriptors that may also be defined
+        # on the metaclass (such as with `Class[Instance]Property`)
+        for cls in parent.mro():
+            try:
+                obj = vars(cls)[short_name]
+                break
+            except KeyError as e:
+                err = e
+        else:
+            raise err
+
         if isinstance(obj, utils.ClassProperty):
             return "Class Properties"
         if isinstance(obj, utils.ClassInstanceProperty):
@@ -107,6 +124,9 @@ def autodocssumm_grouper(app, what, name, obj, section, parent):
             return "Special Attributes"
 
 
+# # -- Setup Function ------------------------------------------------------------------
+
+
 def setup(app):
     app.connect("autodocsumm-grouper", autodocssumm_grouper)
 
@@ -119,3 +139,30 @@ def setup(app):
     for attr, value in tuple(vars(meta).items()):
         if isinstance(value, utils.ClassPropertyBase):
             delattr(meta, attr)
+
+# # -- prettyspecialmethods ------------------------------------------------------
+
+
+def reflected_binary_op_transformer(op):
+    from sphinxcontrib.prettyspecialmethods import Text, emphasis, inline, patch_node
+
+    def xf(name_node, parameters_node):
+        return inline(
+            "",
+            "",
+            emphasis("", parameters_node.children[0].astext()),
+            Text(" "),
+            patch_node(name_node, op, ()),
+            Text(" "),
+            emphasis("", "self"),
+        )
+
+    return xf
+
+
+def skip_undoc_special_methods(*args, **kwargs):
+    pass
+
+
+prettyspecialmethods.SPECIAL_METHODS["__ror__"] = reflected_binary_op_transformer("|")
+prettyspecialmethods.show_special_methods = skip_undoc_special_methods
diff --git a/docs/source/glossary.rst b/docs/source/glossary.rst
index bbb9c92f..9ba3af3e 100644
--- a/docs/source/glossary.rst
+++ b/docs/source/glossary.rst
@@ -19,19 +19,9 @@ Below are definitions of terms used across the API, exception messages and the d
       .. seealso:: :ref:`active-terminal`
 
    alignment
-      The position of a primary :term:`render` output within its :term:`padding`.
-
-      .. seealso:: :ref:`alignment`
-
    horizontal alignment
-      The horizontal position of a primary :term:`render` output within its :term:`padding width`.
-
-      .. seealso:: :ref:`alignment`
-
    vertical alignment
-      The vertical position of a primary :term:`render` output within its :term:`padding height`.
-
-      .. seealso:: :ref:`alignment`
+      ...
 
    alpha threshold
       Alpha ratio/value above which a pixel is taken as **opaque** (applies only to :ref:`text-based`).
@@ -59,45 +49,42 @@ Below are definitions of terms used across the API, exception messages and the d
       The height of the area used in :term:`automatic sizing`.
 
    render
+   renders
    rendered
    rendering
-      The process of encoding pixel data into a byte/character **string** (possibly including escape sequences to reproduce colour and transparency).
+      The process of encoding visual/textual data into a byte/character **string** (possibly including terminal control sequences), the result of which is called a :term:`render output`.
 
-      This string is also called the **primary** render output and **excludes** :term:`padding`.
+      .. seealso:: :py:meth:`~term_image.renderable.Renderable._render_`
 
+   render class
+   render classes
+      :py:class:`~term_image.renderable.Renderable` or a subclass of it.
+
+   render output
+   render outputs
+      A **string** produced by :term:`rendering` or by padding another render output.
+
+      .. seealso:: :py:attr:`Frame.render_output <term_image.renderable.Frame.render_output>`
+
+   render size
    rendered size
-      The amount of space (columns and lines) that'll be occupied by a primary :term:`render` output **when drawn (written) onto a terminal screen**.
+      The amount of space (columns and lines) that'll be occupied by a :term:`render output` **when drawn (written) onto a terminal screen**.
 
-   .. seealso:: :py:attr:`~term_image.image.BaseImage.rendered_size`
+      .. seealso:: :py:attr:`Frame.render_size <term_image.renderable.Frame.render_size>`
 
+   render width
    rendered width
-      The amount of **columns** that'll be occupied by a primary :term:`render` output **when drawn (written) onto a terminal screen**.
-
-   .. seealso:: :py:attr:`~term_image.image.BaseImage.rendered_width`
+      The amount of **columns** that'll be occupied by a :term:`render output` **when drawn (written) onto a terminal screen**. Also the horizontal component of a :term:`render size`.
 
+   render height
    rendered height
-      The amount of **lines** that'll be occupied by a primary :term:`render` output **when drawn (written) onto a terminal screen**.
-
-   .. seealso:: :py:attr:`~term_image.image.BaseImage.rendered_height`
+      The amount of **lines** that'll be occupied by a :term:`render output` **when drawn (written) onto a terminal screen**. Also the vertical component of a :term:`render size`.
 
    padding
-      Amount of lines and columns within which to fit a primary :term:`render` output.
-
-      .. seealso:: :ref:`padding`
-
+   padding size
    padding width
-      Amount of **columns** within which to fit a primary :term:`render` output.
-
-      Excess columns on either or both sides of the render output (depending on the :term:`horizontal alignment`) will be filled with spaces.
-
-      .. seealso:: :ref:`padding`
-
    padding height
-      Amount of **lines** within which to fit a primary :term:`render` output.
-
-      Excess lines on either or both sides of the render output (depending on the :term:`vertical alignment`) will be filled with spaces.
-
-      .. seealso:: :ref:`padding`
+      ...
 
    pixel ratio
       The aspect ratio with which one rendered pixel is drawn/displayed on the terminal screen.
diff --git a/src/term_image/exceptions.py b/src/term_image/exceptions.py
index 3abf7194..c731bd67 100644
--- a/src/term_image/exceptions.py
+++ b/src/term_image/exceptions.py
@@ -14,15 +14,11 @@ class TermImageError(Exception):
 
 
 class InvalidSizeError(TermImageError):
-    """Raised for invalid image sizes."""
+    """Raised for invalid sizes."""
 
 
 class RenderError(TermImageError):
-    """Raised for errors that occur **during** :term:`rendering`.
-
-    If the direct cause of the error is an exception, it should typically be attached
-    as the context of this exception i.e ``raise RenderError(...) from exc``.
-    """
+    """..."""
 
 
 class StyleError(TermImageError):
diff --git a/src/term_image/padding.py b/src/term_image/padding.py
new file mode 100644
index 00000000..d75a11bb
--- /dev/null
+++ b/src/term_image/padding.py
@@ -0,0 +1,487 @@
+"""
+.. The Padding API
+"""
+
+from __future__ import annotations
+
+__all__ = (
+    "Padding",
+    "AlignedPadding",
+    "ExactPadding",
+    "HAlign",
+    "VAlign",
+    "PaddingError",
+    "RelativePaddingDimensionError",
+)
+
+import os
+from abc import ABCMeta, abstractmethod
+from dataclasses import astuple, dataclass
+from enum import IntEnum, auto
+
+from .exceptions import TermImageError
+from .geometry import RawSize, Size
+from .utils import arg_value_error_range
+
+# Variables ====================================================================
+
+
+_ALIGN_RATIOS = ((0, 1), (1, 2), (1, 1))
+"""Ratios of *left* and *top* exact padding dimensions for aligned padding.
+
+::
+
+   dim = (padded_dim - render_dim) * RATIOS[i][0] // RATIOS[i][1]
+
+where ``i`` = ``{h|v}_align``.
+"""
+
+
+# Enumerations =================================================================
+
+
+class HAlign(IntEnum):
+    """Horizontal alignment enumeration"""
+
+    LEFT = 0
+    """Left horizontal alignment
+
+    :meta hide-value:
+    """
+
+    CENTER = auto()
+    """Center horizontal alignment
+
+    :meta hide-value:
+    """
+
+    RIGHT = auto()
+    """Right horizontal alignment
+
+    :meta hide-value:
+    """
+
+
+class VAlign(IntEnum):
+    """Vertical alignment enumeration"""
+
+    TOP = 0
+    """Top vertical alignment
+
+    :meta hide-value:
+    """
+
+    MIDDLE = auto()
+    """Middle vertical alignment
+
+    :meta hide-value:
+    """
+
+    BOTTOM = auto()
+    """Bottom vertical alignment
+
+    :meta hide-value:
+    """
+
+
+# Classes ======================================================================
+
+
+class Padding(metaclass=ABCMeta):
+    """:term:`Render output` padding.
+
+    Args:
+        fill: Fill string; the string with which render outputs will be padded.
+
+          May be any string that occupies exactly **one column** when written to a
+          terminal screen.
+
+    ATTENTION:
+        This is an abstract base class. Hence, only **concrete** subclasses can be
+        instantiated.
+
+    .. seealso::
+
+       :ref:`padding-ext-api`
+          :py:class:`Padding`\\ 's Extension API
+    """
+
+    # Class Attributes =========================================================
+
+    __slots__ = ("fill",)
+
+    # Instance Attributes ======================================================
+
+    fill: str
+    """Fill string
+
+    This is the string with which :term:`render outputs` will be padded.
+    """
+
+    # Special Methods ==========================================================
+
+    def __init__(self, fill: str = " ") -> None:
+        __class__.__setattr__(self, "fill", fill)  # Subclasses are to be "immutable"
+
+    # Public Methods ===========================================================
+
+    def get_padded_size(self, render_size: Size) -> Size:
+        """Computes an expected padded :term:`render size`.
+
+        Args:
+            render_size: Render size.
+
+        Returns:
+            The size of the :term:`render output` that would be produced by using
+            this instance to pad a render output **with the given size**.
+        """
+        left, top, right, bottom = self._get_exact_dimensions_(render_size)
+        width, height = render_size
+
+        return Size(left + width + right, top + height + bottom)
+
+    def pad(self, render: str, render_size: Size) -> str:
+        """Pads a :term:`render output`.
+
+        Args:
+            render: A render output, in the form specified to be returned by
+              :py:meth:`Renderable._render_()
+              <term_image.renderable.Renderable._render_>`.
+            render_size: :term:`Render size` of *render*.
+
+        Returns:
+            The padded render output.
+
+            This is also in the form specified to be returned by
+            :py:meth:`Renderable._render_()
+            <term_image.renderable.Renderable._render_>`, provided *render* is.
+        """
+        left, top, right, bottom = self._get_exact_dimensions_(render_size)
+        width = left + render_size.width + right
+        horizontal = left or right
+        vertical = top or bottom
+        fill = self.fill
+
+        left_padding = fill * left
+        right_padding = fill * right
+        top_padding = f"{fill * width}\n" * top if top else ""
+        bottom_padding = f"\n{fill * width}" * bottom if bottom else ""
+
+        return (
+            "".join(
+                (
+                    top_padding,
+                    left_padding,
+                    (
+                        render.replace("\n", f"{right_padding}\n{left_padding}")
+                        if horizontal
+                        else render
+                    ),
+                    right_padding,
+                    bottom_padding,
+                )
+            )
+            if horizontal or vertical
+            else render
+        )
+
+    def to_exact(self, render_size: Size) -> ExactPadding:
+        """Converts the padding to an exact padding for the given :term:`render size`.
+
+        Args:
+            render_size: :term:`Render size`.
+
+        Returns:
+            An equivalent exact padding, **with respect to the given render size**
+            i.e one that would produce the same result as the padding being converted,
+            **for the given render size**.
+
+        This is useful to avoid recomputing the exact padding dimensions for **the
+        same render size**.
+        """
+        return (
+            self
+            if isinstance(self, ExactPadding)
+            else ExactPadding(*self._get_exact_dimensions_(render_size), self.fill)
+        )
+
+    # Extension methods ========================================================
+
+    @abstractmethod
+    def _get_exact_dimensions_(self, render_size: Size) -> tuple[int, int, int, int]:
+        """Returns the exact padding dimensions for the given :term:`render size`.
+
+        Args:
+            render_size: :term:`Render size`.
+
+        Returns:
+            Returns the exact padding dimensions, ``(left, top, right, bottom)``.
+
+        This is called to implement operations in the public API.
+        """
+        raise NotImplementedError
+
+
+@dataclass(frozen=True)
+class AlignedPadding(Padding):
+    """Aligned :term:`render output` padding.
+
+    Args:
+        width: Minimum :term:`render width`.
+        height: Minimum :term:`render height`.
+        h_align: Horizontal alignment.
+        v_align: Vertical alignment.
+
+    If *width* or *height* is:
+
+    * positive, it is **absolute** and used as-is.
+    * non-positive, it is **relative** to the corresponding terminal dimension
+      (**at the point of resolution**) and equivalent to the absolute dimension
+      ``max(terminal_dimension + relative_dimension, 1)``.
+
+    The *padded render dimension* (i.e the dimension of a :term:`render output` after
+    it's padded) on each axis is given by::
+
+       padded_dimension = max(render_dimension, absolute_minimum_dimension)
+
+    In words... If the **absolute** *minimum render dimension* on an axis is less than
+    or equal to the corresponding *render dimension*, there is no padding on that axis
+    and the *padded render dimension* on that axis is equal to the *render dimension*.
+    Otherwise, the render output will be padded along that axis and the *padded render
+    dimension* on that axis is equal to the *minimum render dimension*.
+
+    The amount of padding to each side depends on the alignment, defined by *h_align*
+    and *v_align*.
+
+    IMPORTANT:
+        :py:class:`RelativePaddingDimensionError` is raised if any padding-related
+        computation/operation (basically, calling any method other than
+        :py:meth:`resolve`) is performed on an instance with **relative** *minimum
+        render dimension(s)* i.e if :py:attr:`relative` is ``True``.
+
+    NOTE:
+        Any interface receiving an instance with **relative** dimension(s) should
+        typically resolve it/them upon reception.
+
+    TIP:
+        * Instances are immutable and hashable.
+        * Instances with equal fields compare equal.
+    """
+
+    # Class Attributes =========================================================
+
+    __slots__ = ("width", "height", "h_align", "v_align", "relative")
+
+    # Instance Attributes ======================================================
+
+    width: int
+    """Minimum :term:`render width`"""
+
+    height: int
+    """Minimum :term:`render height`"""
+
+    h_align: HAlign
+    """Horizontal alignment"""
+
+    v_align: VAlign
+    """Vertical alignment"""
+
+    fill: str
+
+    relative: bool
+    """``True`` if either or both *minimum render dimension(s)* is/are relative i.e
+    non-positive. Otherwise, ``False``.
+    """
+
+    # Special Methods ==========================================================
+
+    def __init__(
+        self,
+        width: int,
+        height: int,
+        h_align: HAlign = HAlign.CENTER,
+        v_align: VAlign = VAlign.MIDDLE,
+        fill: str = " ",
+    ):
+        super().__init__(fill)
+
+        _setattr = super().__setattr__
+        _setattr("width", width)
+        _setattr("height", height)
+        _setattr("h_align", h_align)
+        _setattr("v_align", v_align)
+        _setattr("relative", not width > 0 < height)
+
+    def __repr__(self) -> str:
+        return "{}(width={}, height={}, h_align={}, v_align={}, fill={!r})".format(
+            type(self).__name__,
+            self.width,
+            self.height,
+            self.h_align.name,
+            self.v_align.name,
+            self.fill,
+        )
+
+    # Properties ===============================================================
+
+    @property
+    def size(self) -> RawSize:
+        """Minimum :term:`render size`
+
+        GET:
+            Returns the *minimum render dimensions*.
+        """
+        return RawSize(self.width, self.height)
+
+    # Public Methods ===========================================================
+
+    def get_padded_size(self, render_size: Size) -> Size:
+        """Computes an expected padded :term:`render size`.
+
+        See :py:meth:`Padding.get_padded_size`.
+
+        Raises:
+            RelativePaddingDimensionError: Relative *minimum render dimension(s)*.
+        """
+        if self.relative:
+            raise RelativePaddingDimensionError("Relative minimum render dimension(s)")
+
+        return Size(*map(max, (self.width, self.height), render_size))  # type: ignore
+
+    def resolve(self, terminal_size: os.terminal_size) -> AlignedPadding:
+        """Resolves **relative** *minimum render dimensions*.
+
+        Args:
+            terminal_size: The terminal size against which to resolve relative
+              dimensions.
+
+        Returns:
+            An instance with equivalent **absolute** dimensions.
+        """
+        if not self.relative:
+            return self
+
+        width, height, *args, _ = astuple(self)
+        terminal_width, terminal_height = terminal_size
+        if width <= 0:
+            width = max(terminal_width + width, 1)
+        if height <= 0:
+            height = max(terminal_height + height, 1)
+
+        return type(self)(width, height, *args)
+
+    # Extension methods ========================================================
+
+    def _get_exact_dimensions_(self, render_size: Size) -> tuple[int, int, int, int]:
+        if self.relative:
+            raise RelativePaddingDimensionError("Relative minimum render dimension(s)")
+
+        width, height, h_align, v_align = astuple(self)[:4]
+        render_width, render_height = render_size
+
+        if width > render_width:
+            padding_width = width - render_width
+            numerator, denominator = _ALIGN_RATIOS[h_align]
+            left = padding_width * numerator // denominator
+            right = padding_width - left
+        else:
+            left = right = 0
+
+        if height > render_height:
+            padding_height = height - render_height
+            numerator, denominator = _ALIGN_RATIOS[v_align]
+            top = padding_height * numerator // denominator
+            bottom = padding_height - top
+        else:
+            top = bottom = 0
+
+        return left, top, right, bottom
+
+
+@dataclass(frozen=True)
+class ExactPadding(Padding):
+    """Exact :term:`render output` padding.
+
+    Args:
+        left: Left padding dimension
+        top: Top padding dimension.
+        right: Right padding dimension
+        bottom: Bottom padding dimension
+
+    Raises:
+        ValueError: A dimension is negative.
+
+    Pads a render output on each side by the specified amount of lines or columns.
+
+    TIP:
+        * Instances are immutable and hashable.
+        * Instances with equal fields compare equal.
+    """
+
+    # Class Attributes =========================================================
+
+    __slots__ = ("left", "top", "right", "bottom")
+
+    # Instance Attributes ======================================================
+
+    left: int
+    """Left padding dimension"""
+
+    top: int
+    """Top padding dimension"""
+
+    right: int
+    """Right padding dimension"""
+
+    bottom: int
+    """Bottom padding dimension"""
+
+    fill: str
+
+    # Special Methods ==========================================================
+
+    def __init__(
+        self,
+        left: int = 0,
+        top: int = 0,
+        right: int = 0,
+        bottom: int = 0,
+        fill: str = " ",
+    ) -> None:
+        super().__init__(fill)
+
+        _setattr = super().__setattr__
+        for name in ("left", "top", "right", "bottom"):
+            value = locals()[name]
+            if value < 0:
+                raise arg_value_error_range(name, value)
+            _setattr(name, value)
+
+    # Properties ===============================================================
+
+    @property
+    def dimensions(self) -> tuple[int, int, int, int]:
+        """Padding dimensions
+
+        GET:
+            Returns the padding dimensions, ``(left, top, right, bottom)``.
+        """
+        return astuple(self)[:4]  # type: ignore
+
+    # Extension methods ========================================================
+
+    def _get_exact_dimensions_(self, render_size: Size) -> tuple[int, int, int, int]:
+        return astuple(self)[:4]  # type: ignore
+
+
+# Exceptions ===================================================================
+
+
+class PaddingError(TermImageError):
+    """Base exception class for padding errors."""
+
+
+class RelativePaddingDimensionError(PaddingError):
+    """Raised when a padding operation is performed on an :py:class:`AlignedPadding`
+    instance with **relative** minimum render dimension(s).
+    """
diff --git a/src/term_image/render/__init__.py b/src/term_image/render/__init__.py
new file mode 100644
index 00000000..85f12a1a
--- /dev/null
+++ b/src/term_image/render/__init__.py
@@ -0,0 +1,9 @@
+"""
+.. The Render API
+"""
+
+from __future__ import annotations
+
+__all__ = ("RenderIterator", "RenderIteratorError", "FinalizedIteratorError")
+
+from ._iterator import FinalizedIteratorError, RenderIterator, RenderIteratorError
diff --git a/src/term_image/render/_iterator.py b/src/term_image/render/_iterator.py
new file mode 100644
index 00000000..5aaa7278
--- /dev/null
+++ b/src/term_image/render/_iterator.py
@@ -0,0 +1,643 @@
+"""
+.. The RenderIterator API
+"""
+
+from __future__ import annotations
+
+__all__ = ("RenderIterator", "RenderIteratorError", "FinalizedIteratorError")
+
+from typing import Generator
+
+from ..exceptions import TermImageError
+from ..geometry import Size
+from ..padding import AlignedPadding, ExactPadding, Padding
+from ..renderable import (
+    Frame,
+    FrameCount,
+    FrameDuration,
+    Renderable,
+    RenderArgs,
+    RenderData,
+    Seek,
+)
+from ..utils import (
+    arg_type_error,
+    arg_value_error,
+    arg_value_error_msg,
+    arg_value_error_range,
+    get_terminal_size,
+)
+
+# Classes ======================================================================
+
+
+class RenderIterator:
+    """An iterator for efficient iteration over :term:`rendered` frames of an
+    :term:`animated` renderable.
+
+    Args:
+        renderable: An animated renderable.
+        render_args: Render arguments.
+        padding: :term:`Render output` padding.
+        loops: The number of times to go over all frames.
+
+          * ``< 0`` -> loop infinitely.
+          * ``0`` -> invalid.
+          * ``> 0`` -> loop the given number of times.
+
+          .. note::
+            The value is ignored and taken to be ``1`` (one), if *renderable* has
+            :py:class:`~term_image.renderable.FrameCount.INDEFINITE` frame count.
+
+        cache: Determines if :term:`rendered` frames are cached.
+
+          If the value is ``True`` or a positive integer greater than or equal to the
+          frame count of *renderable*, caching is enabled. Otherwise i.e ``False`` or
+          a positive integer less than the frame count, caching is disabled.
+
+          .. note::
+            The value is ignored and taken to be ``False``, if *renderable* has
+            :py:class:`~term_image.renderable.FrameCount.INDEFINITE` frame count.
+
+    Raises:
+        TypeError: An argument is of an inappropriate type.
+        ValueError: An argument is of an appropriate type but has an
+          unexpected/invalid value.
+        IncompatibleRenderArgsError: Incompatible render arguments.
+
+    The iterator yields a :py:class:`~term_image.renderable.Frame` instance on every
+    iteration.
+
+    NOTE:
+        * Seeking the underlying renderable
+          (via :py:meth:`Renderable.seek() <term_image.renderable.Renderable.seek>`)
+          does not affect an iterator, use :py:meth:`RenderIterator.seek` instead.
+          Likewise, the iterator does not modify the underlying renderable's current
+          frame number.
+        * Changes to the underlying renderable's :term:`render size` does not affect
+          an iterator's :term:`render outputs`, use :py:meth:`set_render_size` instead.
+        * Changes to the underlying renderable's
+          :py:attr:`~term_image.renderable.Renderable.frame_duration` does not affect
+          the value yiedled by an iterator, the value when initializing the iterator
+          is what it will use.
+
+    .. seealso::
+
+       :py:meth:`Renderable.__iter__() <term_image.renderable.Renderable.__iter__>`
+          Renderables are iterable
+
+       :ref:`render-iterator-ext-api`
+          :py:class:`RenderIterator`\\ 's Extension API
+    """
+
+    # Instance Attributes ======================================================
+
+    loop: int
+    """Iteration loop countdown
+
+    * A negative integer, if iteration is infinite.
+    * Otherwise, the current iteration loop countdown value.
+
+      * Starts from the value of the *loops* constructor argument,
+      * decreases by one upon rendering the first frame of every loop after the
+        first,
+      * and ends at zero after the iterator is exhausted.
+
+    NOTE:
+        Modifying this doesn't affect the iterator.
+    """
+
+    # Special Methods ==========================================================
+
+    def __init__(
+        self,
+        renderable: Renderable,
+        render_args: RenderArgs | None = None,
+        padding: Padding = ExactPadding(),
+        loops: int = 1,
+        cache: bool | int = 100,
+    ) -> None:
+        self._init(renderable, render_args, padding, loops, cache)
+        self._iterator, self._padding = renderable._init_render_(
+            self._iterate, render_args, padding, iteration=True, finalize=False
+        )
+        self._finalize_data = True
+        next(self._iterator)
+
+    def __del__(self) -> None:
+        try:
+            self.close()
+        except AttributeError:
+            pass
+
+    def __iter__(self) -> RenderIterator:
+        return self
+
+    def __next__(self) -> Frame:
+        try:
+            return next(self._iterator)
+        except StopIteration:
+            self.close()
+            raise StopIteration("Iteration has ended") from None
+        except AttributeError:
+            if self._closed:
+                raise StopIteration("This iterator has been finalized") from None
+            else:
+                self.close()
+                raise
+        except Exception:
+            self.close()
+            raise
+
+    def __repr__(self) -> str:
+        return (
+            f"<{type(self).__name__}: "
+            f"type(renderable)={type(self._renderable).__name__}, "
+            f"frame_count={self._renderable.frame_count}, loops={self._loops}, "
+            f"loop={self.loop}, cached={self._cached}>"
+        )
+
+    # Public Methods ===========================================================
+
+    def close(self) -> None:
+        """Finalizes the iterator and releases resources used.
+
+        NOTE:
+            This method is automatically called when the iterator is exhausted or
+            garbage-collected but it's recommended to call it manually if iteration
+            is ended prematurely (i.e before the iterator itself is exhausted),
+            especially if frames are cached.
+
+            This method is safe for multiple invokations.
+        """
+        if not self._closed:
+            self._iterator.close()
+            del self._iterator
+            if self._finalize_data:
+                self._render_data.finalize()
+            del self._render_data
+            self._closed = True
+
+    def seek(self, offset: int, whence: Seek = Seek.START) -> None:
+        """Sets the frame to be rendered on the next iteration, without affecting
+        the loop count.
+
+        Args:
+            offset: Frame offset (relative to *whence*).
+            whence: Reference position for *offset*.
+
+        Raises:
+            FinalizedIteratorError: The iterator has been finalized.
+            TypeError: An argument is of an inappropriate type.
+            ValueError: *offset* is out of range.
+
+        The value range for *offset* depends on the
+        :py:attr:`~term_image.renderable.Renderable.frame_count` of the underlying
+        renderable and *whence*:
+
+        .. list-table:: *definite* frame count
+           :align: left
+           :header-rows: 1
+           :width: 90%
+           :widths: auto
+
+           * - *whence*
+             - Valid value range for *offset*
+
+           * - :py:attr:`~term_image.renderable.Seek.START`
+             - ``0`` <= *offset*
+               < :py:attr:`~term_image.renderable.Renderable.frame_count`
+           * - :py:attr:`~term_image.renderable.Seek.CURRENT`
+             - -*next_frame_number* [#ri-nf]_ <= *offset*
+               < :py:attr:`~term_image.renderable.Renderable.frame_count`
+               - *next_frame_number*
+           * - :py:attr:`~term_image.renderable.Seek.END`
+             - -:py:attr:`~term_image.renderable.Renderable.frame_count` < *offset*
+               <= ``0``
+
+        .. list-table:: :py:attr:`~term_image.renderable.FrameCount.INDEFINITE` frame
+           count
+           :align: left
+           :header-rows: 1
+           :width: 90%
+           :widths: auto
+
+           * - *whence*
+             - Valid value range for *offset*
+
+           * - :py:attr:`~term_image.renderable.Seek.START`
+             - ``0`` <= *offset*
+           * - :py:attr:`~term_image.renderable.Seek.CURRENT`
+             - *any value*
+           * - :py:attr:`~term_image.renderable.Seek.END`
+             - *offset* <= ``0``
+
+        NOTE:
+            If the underlying renderable has *definite* frame count, seek operations
+            have **immeditate** effect. Hence, multiple consecutive seek operations,
+            starting with any kind and followed by one or more with *whence* =
+            :py:attr:`~term_image.renderable.Seek.CURRENT`, between any two
+            consecutive renders have a **cummulative** effect. In particular, any seek
+            operation with *whence* = :py:attr:`~term_image.renderable.Seek.CURRENT`
+            is relative to the frame to be rendered next [#ri-nf]_.
+
+            .. collapse:: Example
+
+               >>> animated_renderable.frame_count
+               10
+               >>> render_iter = RenderIterator(animated_renderable)  # next = 0
+               >>> render_iter.seek(5)  # next = 5
+               >>> next(render_iter).number  # next = 5 + 1 = 6
+               5
+               >>> # cummulative
+               >>> render_iter.seek(2, Seek.CURRENT)  # next = 6 + 2 = 8
+               >>> render_iter.seek(-4, Seek.CURRENT)  # next = 8 - 4 = 4
+               >>> next(render_iter).number  # next = 4 + 1 = 5
+               4
+               >>> # cummulative
+               >>> render_iter.seek(7)  # next = 7
+               >>> render_iter.seek(1, Seek.CURRENT)  # next = 7 + 1 = 8
+               >>> render_iter.seek(-5, Seek.CURRENT)  # next = 8 - 5 = 3
+               >>> next(render_iter).number  # next = 3 + 1 = 4
+               3
+               >>> # NOT cummulative
+               >>> render_iter.seek(3, Seek.CURRENT)  # next = 4 + 3 = 7
+               >>> render_iter.seek(2)  # next = 2
+               >>> next(render_iter).number  # next = 2 + 1 = 3
+               2
+
+            On the other hand, if the underlying renderable has
+            :py:attr:`~term_image.renderable.FrameCount.INDEFINITE` frame count, seek
+            operations don't take effect **until the next render**. Hence, multiple
+            consecutive seek operations between any two consecutive renders do **not**
+            have a **cummulative** effect; rather, only **the last one** takes effect.
+            In particular, any seek operation with *whence* =
+            :py:attr:`~term_image.renderable.Seek.CURRENT` is relative to the frame
+            after that which was rendered last.
+
+            .. collapse:: Example
+
+               >>> animated_renderable.frame_count is FrameCount.INDEFINITE
+               True
+               >>> # iterating normally without seeking
+               >>> [frame.render_output for frame in animated_renderable]
+               ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', ...]
+               >>>
+               >>> # Assuming the renderable implements all kinds of seek operations
+               >>> render_iter = RenderIterator(animated_renderable)  # next = 0
+               >>> render_iter.seek(5)  # next = 5
+               >>> next(render_iter).render_output  # next = 5 + 1 = 6
+               '5'
+               >>> render_iter.seek(2, Seek.CURRENT)  # next = 6 + 2 = 8
+               >>> render_iter.seek(-4, Seek.CURRENT)  # next = 6 - 4 = 2
+               >>> next(render_iter).render_output  # next = 2 + 1 = 3
+               '2'
+               >>> render_iter.seek(7)  # next = 7
+               >>> render_iter.seek(3, Seek.CURRENT)  # next = 3 + 3 = 6
+               >>> next(render_iter).render_output  # next = 6 + 1 = 7
+               '6'
+
+            A renderable with :py:attr:`~term_image.renderable.FrameCount.INDEFINITE`
+            frame count may not support/implement all kinds of seek operations or any
+            at all. If the underlying renderable doesn't support/implement a given
+            seek operation, the seek operation should simply have no effect on
+            iteration i.e the next frame should be the one after that which was
+            rendered last. See each :term:`render class` that implements
+            :py:attr:`~term_image.renderable.FrameCount.INDEFINITE` frame count for
+            the seek operations it supports and any other specific related details.
+        """
+        if self._closed:
+            raise FinalizedIteratorError("This iterator has been finalized") from None
+        if not isinstance(offset, int):
+            raise arg_type_error("offset", offset)
+
+        frame_count = self._renderable.frame_count
+        renderable_data = self._render_data[Renderable]
+        if frame_count is FrameCount.INDEFINITE:
+            if whence is Seek.START and offset < 0 or whence is Seek.END and offset > 0:
+                raise arg_value_error_range("offset", offset, f"whence={whence.name}")
+            renderable_data.update(frame_offset=offset, seek_whence=whence)
+        else:
+            frame = (
+                offset
+                if whence is Seek.START
+                else renderable_data.frame_offset + 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", next={renderable_data.frame_offset}"
+                            if whence is Seek.CURRENT
+                            else ""
+                        )
+                    ),
+                )
+            renderable_data.update(frame_offset=frame, seek_whence=Seek.START)
+
+    def set_frame_duration(self, duration: int | FrameDuration) -> None:
+        """Sets the frame duration.
+
+        Args:
+            duration: Frame duration (see
+              :py:attr:`~term_image.renderable.Renderable.frame_duration`).
+
+        Raises:
+            FinalizedIteratorError: The iterator has been finalized.
+            ValueError: *duration* is out of range.
+
+        NOTE:
+            Takes effect from the next [#ri-nf]_ rendered frame.
+        """
+        if self._closed:
+            raise FinalizedIteratorError("This iterator has been finalized") from None
+
+        if isinstance(duration, int) and duration <= 0:
+            raise arg_value_error_range("duration", duration)
+
+        self._render_data[Renderable].duration = duration
+
+    def set_padding(self, padding: Padding) -> None:
+        """Sets the :term:`render output` padding.
+
+        Args:
+            padding: Render output padding.
+
+        Raises:
+            FinalizedIteratorError: The iterator has been finalized.
+            TypeError: An argument is of an inappropriate type.
+
+        NOTE:
+            Takes effect from the next [#ri-nf]_ rendered frame.
+        """
+        if self._closed:
+            raise FinalizedIteratorError("This iterator has been finalized") from None
+
+        if not isinstance(padding, Padding):
+            raise arg_type_error("padding", padding)
+
+        self._padding = (
+            padding.resolve(get_terminal_size())
+            if isinstance(padding, AlignedPadding) and padding.relative
+            else padding
+        )
+        self._padded_size = padding.get_padded_size(self._render_data[Renderable].size)
+
+    def set_render_args(self, render_args: RenderArgs) -> None:
+        """Sets the render arguments.
+
+        Args:
+            render_args: Render arguments.
+
+        Raises:
+            FinalizedIteratorError: The iterator has been finalized.
+            TypeError: An argument is of an inappropriate type.
+            IncompatibleRenderArgsError: Incompatible render arguments.
+
+        NOTE:
+            Takes effect from the next [#ri-nf]_ rendered frame.
+        """
+        if self._closed:
+            raise FinalizedIteratorError("This iterator has been finalized") from None
+
+        if not isinstance(render_args, RenderArgs):
+            raise arg_type_error("render_args", render_args)
+
+        render_cls = type(self._renderable)
+        self._render_args = (
+            render_args
+            if render_args.render_cls is render_cls
+            # Validate compatibility (and convert, if compatible)
+            else RenderArgs(render_cls, render_args)
+        )
+
+    def set_render_size(self, render_size: Size) -> None:
+        """Sets the :term:`render size`.
+
+        Args:
+            render_size: Render size.
+
+        Raises:
+            FinalizedIteratorError: The iterator has been finalized.
+            TypeError: An argument is of an inappropriate type.
+
+        NOTE:
+            Takes effect from the next [#ri-nf]_ rendered frame.
+        """
+        if self._closed:
+            raise FinalizedIteratorError("This iterator has been finalized") from None
+
+        if not isinstance(render_size, Size):
+            raise arg_type_error("render_size", render_size)
+
+        self._render_data[Renderable].size = render_size
+        self._padded_size = self._padding.get_padded_size(render_size)
+
+    # Extension methods ========================================================
+
+    @classmethod
+    def _from_render_data_(
+        cls,
+        renderable: Renderable,
+        render_data: RenderData,
+        render_args: RenderArgs | None = None,
+        padding: Padding = ExactPadding(),
+        *args,
+        finalize: bool = True,
+        **kwargs,
+    ) -> RenderIterator:
+        """Constructs an iterator with pre-generated render data.
+
+        Args:
+            renderable: An animated renderable.
+            render_data: Render data.
+            render_args: Render arguments.
+            args: Other positional arguments accepted by the class constructor.
+            finalize: Whether *render_data* is finalized along with the iterator.
+            kwargs: Other keyword arguments accepted by the class constructor.
+
+        Returns:
+            A new iterator instance.
+
+        Raises the same exceptions as the class constructor.
+
+        NOTE:
+            *render_data* may be modified by the iterator or the underlying renderable.
+        """
+        new = cls.__new__(cls)
+        new._init(renderable, render_args, padding, *args, **kwargs)
+
+        if not isinstance(render_data, RenderData):
+            raise arg_type_error("render_data", render_data)
+        if render_data.render_cls is not type(renderable):
+            raise arg_value_error_msg(
+                "Invalid render data for renderable of type "
+                f"{type(renderable).__name__!r}",
+                render_data,
+            )
+        if render_data.finalized:
+            raise ValueError("The render data has been finalized")
+        if not render_data[Renderable].iteration:
+            raise arg_value_error_msg("Invalid render data for iteration", render_data)
+
+        if not (render_args and render_args.render_cls is type(renderable)):
+            # Validate compatibility (and convert, if compatible)
+            render_args = RenderArgs(type(renderable), render_args)
+
+        if not isinstance(finalize, bool):
+            raise arg_type_error("finalize", finalize)
+
+        new._padding = (
+            padding.resolve(get_terminal_size())
+            if isinstance(padding, AlignedPadding) and padding.relative
+            else padding
+        )
+        new._iterator = new._iterate(render_data, render_args)
+        new._finalize_data = finalize
+        next(new._iterator)
+
+        return new
+
+    # Private Methods ==========================================================
+
+    def _init(
+        self,
+        renderable: Renderable,
+        render_args: RenderArgs | None = None,
+        padding: Padding = ExactPadding(),
+        loops: int = 1,
+        cache: bool | int = 100,
+    ) -> None:
+        """Partially initializes an instance.
+
+        Performs the part of the initialization common to all constructors.
+        """
+        if not isinstance(renderable, Renderable):
+            raise arg_type_error("renderable", renderable)
+        if not renderable.animated:
+            raise arg_value_error_msg("'renderable' is not animated", renderable)
+
+        if render_args and not isinstance(render_args, RenderArgs):
+            raise arg_type_error("render_args", render_args)
+
+        if not isinstance(padding, Padding):
+            raise arg_type_error("padding", padding)
+
+        if not isinstance(loops, int):
+            raise arg_type_error("loops", loops)
+        if not loops:
+            raise arg_value_error("loops", loops)
+
+        if not isinstance(cache, int):  # `bool` is a subclass of `int`
+            raise arg_type_error("cache", cache)
+        if False is not cache <= 0:
+            raise arg_value_error_range("cache", cache)
+
+        indefinite = renderable.frame_count is FrameCount.INDEFINITE
+        self._closed = False
+        self._renderable = renderable
+        self.loop = self._loops = 1 if indefinite else loops
+        self._cached = (
+            False
+            if indefinite
+            else cache
+            if isinstance(cache, bool)
+            else renderable.frame_count <= cache
+        )
+
+    def _iterate(
+        self,
+        render_data: RenderData,
+        render_args: RenderArgs,
+    ) -> Generator[Frame, None, None]:
+        """Performs the actual render iteration operation."""
+        # Instance init completion
+        self._render_data = render_data
+        self._render_args = render_args
+        renderable_data = render_data[Renderable]
+        self._padded_size = self._padding.get_padded_size(renderable_data.size)
+
+        # Setup
+        renderable = self._renderable
+        frame_count = renderable.frame_count
+        if frame_count is FrameCount.INDEFINITE:
+            frame_count = 1
+        definite = frame_count > 1
+        loop = self.loop
+        cache = [(None,) * 4] * frame_count if self._cached else None
+        CURRENT = Seek.CURRENT
+        renderable_data.frame_offset = 0
+
+        yield Frame(0, None, Size(1, 1), " ")
+
+        # Render iteration
+        frame_no = renderable_data.frame_offset * definite
+        while loop:
+            while frame_no < frame_count:
+                frame, *frame_details = cache[frame_no] if cache else (None,)
+
+                if not frame or frame_details != [
+                    renderable_data.size,
+                    renderable_data.duration,
+                    self._render_args,
+                ]:
+                    # NOTE: Re-render is required even when only `duration` changes
+                    # and the new value is *static* because frame duration may affect
+                    # the render output of some renderables.
+                    try:
+                        frame = renderable._render_(render_data, self._render_args)
+                    except StopIteration:
+                        if not definite:
+                            self.loop = 0
+                            return
+                        raise
+
+                    if cache:
+                        cache[frame_no] = (
+                            frame,
+                            renderable_data.size,
+                            renderable_data.duration,
+                            self._render_args,
+                        )
+
+                if self._padded_size != frame.render_size:
+                    frame = Frame(
+                        frame.number,
+                        frame.duration,
+                        self._padded_size,
+                        self._padding.pad(frame.render_output, frame.render_size),
+                    )
+
+                if definite:
+                    renderable_data.frame_offset += 1
+                elif (
+                    renderable_data.frame_offset
+                    or renderable_data.seek_whence != CURRENT
+                ):  # was seeked
+                    renderable_data.update(frame_offset=0, seek_whence=CURRENT)
+
+                yield frame
+
+                if definite:
+                    frame_no = renderable_data.frame_offset
+
+            # INDEFINITE can never reach here
+            frame_no = renderable_data.frame_offset = 0
+            if loop > 0:  # Avoid infinitely large negative numbers
+                self.loop = loop = loop - 1
+
+
+# Exceptions ===================================================================
+
+
+class RenderIteratorError(TermImageError):
+    """Base exception class for errors specific to :py:class:`RenderIterator`."""
+
+
+class FinalizedIteratorError(RenderIteratorError):
+    """Raised if certain operations are attempted on a finalized itterator."""
diff --git a/src/term_image/renderable/__init__.py b/src/term_image/renderable/__init__.py
new file mode 100644
index 00000000..70b690bb
--- /dev/null
+++ b/src/term_image/renderable/__init__.py
@@ -0,0 +1,55 @@
+"""
+.. The Renderable API
+"""
+
+from __future__ import annotations
+
+__all__ = (
+    "Renderable",
+    "RenderArgs",
+    "RenderData",
+    "Frame",
+    "FrameCount",
+    "FrameDuration",
+    "Seek",
+    "RenderableError",
+    "IndefiniteSeekError",
+    "RenderError",
+    "RenderSizeOutofRangeError",
+    "RenderArgsDataError",
+    "RenderArgsError",
+    "RenderDataError",
+    "IncompatibleArgsNamespaceError",
+    "IncompatibleRenderArgsError",
+    "NoArgsNamespaceError",
+    "NoDataNamespaceError",
+    "NonAnimatedFrameDurationError",
+    "UninitializedDataFieldError",
+    "UnknownArgsFieldError",
+    "UnknownDataFieldError",
+)
+
+from ._enum import FrameCount, FrameDuration, Seek
+from ._exceptions import (
+    IndefiniteSeekError,
+    NonAnimatedFrameDurationError,
+    RenderableError,
+    RenderError,
+    RenderSizeOutofRangeError,
+)
+from ._renderable import Renderable
+from ._types import (
+    Frame,
+    IncompatibleArgsNamespaceError,
+    IncompatibleRenderArgsError,
+    NoArgsNamespaceError,
+    NoDataNamespaceError,
+    RenderArgs,
+    RenderArgsDataError,
+    RenderArgsError,
+    RenderData,
+    RenderDataError,
+    UninitializedDataFieldError,
+    UnknownArgsFieldError,
+    UnknownDataFieldError,
+)
diff --git a/src/term_image/renderable/_enum.py b/src/term_image/renderable/_enum.py
new file mode 100644
index 00000000..598d0a74
--- /dev/null
+++ b/src/term_image/renderable/_enum.py
@@ -0,0 +1,79 @@
+"""
+.. Enumerations for the Renderable API
+"""
+
+from __future__ import annotations
+
+__all__ = ("FrameCount", "FrameDuration", "Seek")
+
+import os
+from enum import Enum, IntEnum, auto
+
+
+class FrameCount(Enum):
+    """Frame count enumeration
+
+    .. seealso::
+        :py:attr:`~term_image.renderable.Renderable.frame_count`,
+        :py:meth:`~term_image.renderable.Renderable.seek`,
+        :py:meth:`~term_image.renderable.Renderable.tell`.
+    """
+
+    INDEFINITE = auto()
+    """Indicates a renderable has a streaming source.
+
+    In other words, it's either the renderable has an infinite amount of frames or the
+    exact amount cannot be predetermined.
+
+    :meta hide-value:
+    """
+
+    POSTPONED = auto()
+    """Indicates lazy evaluation of frame count.
+
+    Evaluation of frame count is postponed until
+    :py:attr:`~term_image.renderable.Renderable.frame_count` is invoked.
+
+    .. seealso:: :py:meth:`~term_image.renderable.Renderable._get_frame_count_`.
+
+    :meta hide-value:
+    """
+
+
+class FrameDuration(Enum):
+    """Frame duration enumeration
+
+    .. seealso:: :py:attr:`~term_image.renderable.Renderable.frame_duration`.
+    """
+
+    DYNAMIC = auto()
+    """Dynamic frame duration
+
+    The duration of each frame is determined at render-time.
+
+    :meta hide-value:
+    """
+
+
+class Seek(IntEnum):
+    """Relative seek enumeration
+
+    TIP:
+        * Each member's value is that of the corresponding
+          :py:data:`os.SEEK_* <os.SEEK_SET>` constant.
+        * Every member can be used directly as an integer since
+          :py:class:`enum.IntEnum` is a base class.
+
+    .. seealso::
+       :py:meth:`Renderable.seek`,
+       :py:meth:`RenderIterator.seek() <term_image.render.RenderIterator.seek>`
+    """
+
+    START = SET = os.SEEK_SET
+    """Start position"""
+
+    CURRENT = CUR = os.SEEK_CUR
+    """Current position"""
+
+    END = os.SEEK_END
+    """End position"""
diff --git a/src/term_image/renderable/_exceptions.py b/src/term_image/renderable/_exceptions.py
new file mode 100644
index 00000000..27ef796d
--- /dev/null
+++ b/src/term_image/renderable/_exceptions.py
@@ -0,0 +1,50 @@
+"""
+.. Exceptions for the Renderable API core
+"""
+
+__all__ = (
+    "RenderableError",
+    "IndefiniteSeekError",
+    "NonAnimatedFrameDurationError",
+    "RenderError",
+    "RenderSizeOutofRangeError",
+)
+
+from ..exceptions import TermImageError
+
+
+class RenderableError(TermImageError):
+    """Base exception class for errors specific to the :py:mod:`Renderable API
+    <term_image.renderable>` and other APIs extending it.
+
+    Raised for errors that occur during the creation of :term:`render classes`.
+    """
+
+
+class IndefiniteSeekError(RenderableError):
+    """Raised when a seek is attempted on a renderable with
+    :py:attr:`~term_image.renderable.FrameCount.INDEFINITE` frame count.
+    """
+
+
+class NonAnimatedFrameDurationError(RenderableError):
+    """Raised when attempting to set
+    :py:attr:`~term_image.renderable.Renderable.frame_duration` on a non-animated
+    renderable.
+    """
+
+
+class RenderError(RenderableError):
+    """Base exception class for errors that occur **during** :term:`rendering`.
+
+    If the direct cause of the error is an exception, it should typically be attached
+    as the context of this exception, as in::
+
+       raise SubclassOfRenderError("...") from exception
+    """
+
+
+class RenderSizeOutofRangeError(RenderableError):
+    """Raised when the :term:`render size` of a renderable is beyond an expected
+    range.
+    """
diff --git a/src/term_image/renderable/_renderable.py b/src/term_image/renderable/_renderable.py
new file mode 100644
index 00000000..0386bc10
--- /dev/null
+++ b/src/term_image/renderable/_renderable.py
@@ -0,0 +1,1183 @@
+"""
+.. Core of the Renderable API
+"""
+
+from __future__ import annotations
+
+__all__ = ("Renderable",)
+
+import sys
+from abc import ABCMeta, abstractmethod
+from time import perf_counter_ns, sleep
+from types import MappingProxyType
+from typing import Any, Callable, ClassVar, TextIO
+
+import term_image
+
+from .. import geometry
+from ..ctlseqs import HIDE_CURSOR, SHOW_CURSOR, cursor_down, cursor_forward, cursor_up
+from ..padding import AlignedPadding, ExactPadding, Padding
+from ..utils import arg_type_error, arg_value_error_range, get_terminal_size
+from . import _types
+from ._enum import FrameCount, FrameDuration, Seek
+from ._exceptions import (
+    IndefiniteSeekError,
+    NonAnimatedFrameDurationError,
+    RenderableError,
+    RenderSizeOutofRangeError,
+)
+from ._types import Frame, RenderArgs, RenderData
+
+try:
+    import termios
+except ImportError:
+    OS_IS_UNIX = False
+else:
+    OS_IS_UNIX = True
+
+
+class RenderableMeta(ABCMeta):
+    """Base metaclass of the Renderable API.
+
+    Implements certain internal/private aspects of the API.
+    """
+
+    def __new__(cls, name, bases, namespace, _base: bool = False, **kwargs):
+        if not _base and not any(issubclass(base, Renderable) for base in bases):
+            raise RenderableError(f"{name!r} is not a subclass of 'Renderable'")
+
+        try:
+            args_cls = namespace["Args"]
+        except KeyError:
+            args_cls = None
+
+        if args_cls is not None:
+            if not isinstance(args_cls, type):
+                raise arg_type_error(f"'{name}.Args'", args_cls)
+            if (
+                not issubclass(args_cls, RenderArgs.Namespace)
+                or args_cls is RenderArgs.Namespace
+            ):
+                raise RenderableError(
+                    f"'{name}.Args' is not a strict subclass of 'RenderArgs.Namespace'"
+                )
+            if args_cls._RENDER_CLS:
+                raise RenderableError(
+                    f"'{name}.Args' is already associated with render class "
+                    f"{args_cls._RENDER_CLS.__name__!r}"
+                )
+
+        try:
+            data_cls = namespace["_Data_"]
+        except KeyError:
+            data_cls = None
+
+        if data_cls is not None:
+            if not isinstance(data_cls, type):
+                raise arg_type_error(f"'{name}._Data_'", data_cls)
+            if (
+                not issubclass(data_cls, RenderData.Namespace)
+                or data_cls is RenderData.Namespace
+            ):
+                raise RenderableError(
+                    f"'{name}._Data_' is not a strict subclass of "
+                    "'RenderData.Namespace'"
+                )
+            if data_cls._RENDER_CLS:
+                raise RenderableError(
+                    f"'{name}._Data_' is already associated with render class "
+                    f"{data_cls._RENDER_CLS.__name__!r}"
+                )
+
+        new_cls = super().__new__(cls, name, bases, namespace, **kwargs)
+
+        if args_cls:
+            args_cls._RENDER_CLS = new_cls
+        else:
+            new_cls.Args = None
+
+        if data_cls:
+            data_cls._RENDER_CLS = new_cls
+        else:
+            new_cls._Data_ = None
+
+        if _base:  # Renderable
+            all_default_args = {}
+            render_data_mro = {new_cls: new_cls._Data_}
+            all_exported_descendant_attrs = frozenset()
+        else:
+            all_default_args = {new_cls: args_cls()} if args_cls else {}
+            render_data_mro = {new_cls: data_cls} if data_cls else {}
+            all_exported_descendant_attrs = set()  # remove duplicates
+
+            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._RENDER_DATA_MRO[mro_cls]
+                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_", ()))
+        )
+
+        return new_cls
+
+
+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:
+        TypeError: An argument is of an inappropriate type.
+        ValueError: An argument is of an appropriate type but has an
+          unexpected/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 =========================================================
+
+    _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.
+    """
+
+    # Instance Attributes ======================================================
+
+    animated: bool
+    """``True`` if the renderable is :term:`animated`. Otherwise, ``False``."""
+
+    # Special Methods ==========================================================
+
+    def __init__(
+        self,
+        frame_count: int | FrameCount,
+        frame_duration: int | FrameDuration,
+    ) -> None:
+        if isinstance(frame_count, int):
+            if frame_count < 1:
+                raise arg_value_error_range("frame_count", frame_count)
+        elif not isinstance(frame_count, FrameCount):
+            raise arg_type_error("frame_count", frame_count)
+
+        if frame_count == 1:
+            self.__frame_duration = None
+        else:
+            if isinstance(frame_duration, int):
+                if frame_duration <= 0:
+                    raise arg_value_error_range("frame_duration", frame_duration)
+            elif not isinstance(frame_duration, FrameDuration):
+                raise arg_type_error("frame_duration", frame_duration)
+
+            self.__frame_duration = frame_duration
+
+        self.animated = frame_count != 1
+        self.__frame_count = frame_count
+        self.__frame = 0
+
+    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:
+            ValueError: 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
+
+        return RenderIterator(self, cache=False)
+
+    def __repr__(self) -> str:
+        return f"<{type(self).__name__}: frame_count={self.__frame_count}>"
+
+    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 occured during :term:`rendering`.
+        """
+        return self._init_render_(self._render_)[0].render_output
+
+    # Properties ===============================================================
+
+    @property
+    def frame_count(self) -> int | FrameCount:
+        """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 | None:
+        """Frame duration
+
+        GET:
+            Returns
+
+            * ``None``, if the renderable is non-animated.
+            * Otherwise,
+
+              * 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 renderable is
+
+            * :term:`animated` and 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.
+
+            * non-animated,
+              :py:class:`~term_image.renderable.NonAnimatedFrameDurationError`
+              is raised.
+        """
+        return self.__frame_duration
+
+    @frame_duration.setter
+    def frame_duration(self, duration: int | FrameDuration) -> None:
+        if not self.animated:
+            raise NonAnimatedFrameDurationError(
+                "Cannot set frame duration for a non-animated renderable"
+            )
+
+        if isinstance(duration, int):
+            if duration <= 0:
+                raise arg_value_error_range("frame_duration", duration)
+        elif not isinstance(duration, FrameDuration):
+            raise arg_type_error("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 ===========================================================
+
+    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:
+            TypeError: An argument is of an inappropriate type.
+            RenderSizeOutofRangeError: The padded :term:`render size` can not fit into
+              the :term:`terminal size`.
+            IncompatibleRenderArgsError: Incompatible render arguments.
+            RenderError: An error occured 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`.
+        """
+        if render_args and not isinstance(render_args, RenderArgs):
+            raise arg_type_error("render_args", render_args)
+        if not isinstance(padding, Padding):
+            raise arg_type_error("padding", padding)
+        if self.animated and not isinstance(animate, bool):
+            raise arg_type_error("animate", animate)
+
+        # Validation of *loops* and *cache* is delegated to `RenderIterator`.
+
+        if not isinstance(check_size, bool):
+            raise arg_type_error("check_size", check_size)
+        if not isinstance(allow_scroll, bool):
+            raise arg_type_error("allow_scroll", allow_scroll)
+
+        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
+        (render_data, 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, render_args, padding, loops, cache, output)
+            else:
+                frame = self._render_(render_data, 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, 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()
+
+    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:
+            TypeError: An argument is of an inappropriate type.
+            IncompatibleRenderArgsError: Incompatible render arguments.
+            RenderError: An error occured during :term:`rendering`.
+        """
+        if render_args and not isinstance(render_args, RenderArgs):
+            raise arg_type_error("render_args", render_args)
+        if not isinstance(padding, Padding):
+            raise arg_type_error("padding", padding)
+
+        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),
+            )
+        )
+
+    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.
+            TypeError: An argument is of an inappropriate type.
+            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"
+            )
+        if not isinstance(offset, int):
+            raise arg_type_error("offset", offset)
+
+        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
+
+    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 ========================================================
+
+    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
+
+        width, height = render_data[__class__].size
+        pad_left, _, _, pad_bottom = padding._get_exact_dimensions_(
+            render_data[__class__].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 "overlayed" output in a
+            # terminal
+            write(cursor_to_bottom)
+            flush()
+
+    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.
+        """
+
+    @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 overriden 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 invokation of this method.
+            * Any definition of this method should be safe for multiple invokations 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_`.
+        """
+
+    def _get_frame_count_(self) -> int | FrameCount:
+        """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")
+
+    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 overriden 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
+          overriden method,
+        * return the same :py:class:`~term_image.renderable.RenderData` instance.
+
+        IMPORTANT:
+            The :py:class:`~term_image.renderable.RenderData` instance returned must be
+            associated [#rd1]_ 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))
+        render_data[__class__].update(
+            size=self._get_render_size_(),
+            frame_offset=self.__frame,
+            seek_whence=Seek.START,
+            duration=self.__frame_duration,
+            iteration=iteration,
+        )
+
+        return render_data
+
+    @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
+
+    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*.
+        """
+
+    def _init_render_(
+        self,
+        renderer: Callable[[RenderData, RenderArgs], Any],
+        render_args: RenderArgs | None = None,
+        padding: Padding | None = None,
+        *,
+        iteration: bool = False,
+        finalize: bool = True,
+        check_size: bool = False,
+        allow_scroll: bool = False,
+    ) -> tuple[Any, 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: :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`.
+
+        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 inconsitency 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 = render_data[__class__].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()
+
+    @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.
+
+            * *render_size* = :py:attr:`render_data[Renderable].size
+              <term_image.renderable.Renderable._Data_.size>`.
+            * The :py:attr:`~term_image.renderable.Frame.render_output` field holds the
+              :term:`render output`. This string should:
+
+              * contain as many lines as :py:attr:`render_size.height
+                <term_image.geometry.Size.height>` i.e exactly
+                ``render_size.height - 1`` occurences of ``\\n`` (the newline sequence).
+              * occupy exactly :py:attr:`render_size.height
+                <term_image.geometry.Size.height>` lines and
+                :py:attr:`render_size.width <term_image.geometry.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).
+
+            * The value of the :py:attr:`~term_image.renderable.Frame.duration` field
+              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.Renderable._Data_.duration>` is
+              :py:attr:`~term_image.renderable.FrameDuration.DYNAMIC`.
+              Otherwise, it should be equal to
+              :py:attr:`render_data[Renderable].duration
+              <term_image.renderable.Renderable._Data_.duration>`.
+
+        Raises:
+            StopIteration: End of iteration for an animated renderable with
+              :py:attr:`~term_image.renderable.FrameCount.INDEFINITE` frame count.
+            RenderError: An error occured while rendering.
+
+        NOTE:
+            :py:class:`StopIteration` may be raised if and only if
+            :py:attr:`render_data[Renderable].iteration
+            <term_image.renderable.Renderable._Data_.iteration>` is ``True``.
+            Otherwise, it would be out of place.
+
+        .. seealso:: :py:class:`~term_image.renderable.Renderable._Data_`.
+        """
+        raise NotImplementedError
+
+    # Inner classes ============================================================
+
+    class _Data_(RenderData.Namespace):
+        """_Data_()
+
+        Render data namespace for :py:class:`~term_image.renderable.Renderable`.
+
+        .. seealso::
+
+           :ref:`renderable-data`,
+           :py:meth:`~term_image.renderable.Renderable._render_`.
+        """
+
+        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 | None
+        """Frame duration
+
+        The possible values and their interpretation are the same as for
+        :py:attr:`~term_image.renderable.Renderable.frame_duration`.
+        See :py:meth:`~term_image.renderable.Renderable._render_`.
+        """
+
+        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``.
+        """
+
+
+_types.Renderable = Renderable
+_types.RenderableMeta = RenderableMeta
+_types.BASE_RENDER_ARGS.__init__(Renderable)
diff --git a/src/term_image/renderable/_types.py b/src/term_image/renderable/_types.py
new file mode 100644
index 00000000..5b6d3a23
--- /dev/null
+++ b/src/term_image/renderable/_types.py
@@ -0,0 +1,1237 @@
+"""
+.. Custom data types for the Renderable API
+"""
+
+from __future__ import annotations
+
+__all__ = (
+    "Frame",
+    "RenderArgs",
+    "RenderData",
+    "RenderArgsDataError",
+    "RenderArgsError",
+    "RenderDataError",
+    "IncompatibleArgsNamespaceError",
+    "IncompatibleRenderArgsError",
+    "NoArgsNamespaceError",
+    "NoDataNamespaceError",
+    "UninitializedDataFieldError",
+    "UnknownArgsFieldError",
+    "UnknownDataFieldError",
+)
+
+from inspect import Parameter, signature
+from types import MappingProxyType
+from typing import Any, ClassVar, Iterator, Mapping, NamedTuple, Type
+
+from .. import geometry
+from ..utils import arg_type_error, arg_type_error_msg
+from ._exceptions import RenderableError
+
+# Classes ======================================================================
+
+
+class Frame(NamedTuple):
+    """A rendered frame.
+
+    TIP:
+        - Instances are immutable and hashable.
+        - Instances with equal fields compare equal.
+
+    WARNING:
+        Even though this class inherits from :py:class:`tuple`, the class and its
+        instances should not be used as such, because:
+
+        * this is an implementation detail,
+        * the number or order of fields may change.
+
+        Any change to this aspect of the interface may happen without notice and will
+        not be considered a breaking change.
+    """
+
+    number: int
+    """Frame number
+
+    The number of the rendered frame (a non-negative integer), if the frame belongs to
+    a renderable with *definite* frame count. Otherwise, the value range and meaning
+    of this field is unspecified.
+    """
+
+    duration: int | None
+    """Frame duration (in **milliseconds**)
+
+    ``None``, if the frame belongs to a non-animated renderable. Otherwise, a positive
+    integer.
+    """
+
+    render_size: geometry.Size
+    """Frame :term:`render size`"""
+
+    render_output: str
+    """Frame :term:`render output`"""
+
+    def __str__(self) -> str:
+        """Returns the frame :term:`render output`.
+
+        Returns:
+            The frame render output, :py:attr:`render_output`.
+        """
+        return self.render_output
+
+
+class RenderArgsData:
+    """Render arguments/data baseclass."""
+
+    __slots__ = ("render_cls", "_namespaces")
+
+    def __init__(
+        self,
+        render_cls: type[Renderable],
+        namespaces: dict[type[Renderable], RenderArgsData.Namespace],
+    ) -> None:
+        self.render_cls = render_cls
+        self._namespaces = MappingProxyType(namespaces)
+
+    class _NamespaceMeta(type):
+        """Metaclass of render argument/data namespaces."""
+
+        _FIELDS: MappingProxyType[str, Any]
+
+        # Set by `RenderableMeta` for associated instances
+        _RENDER_CLS: type[Renderable] | None = None
+
+        def __new__(
+            cls,
+            name,
+            bases,
+            namespace,
+            *,
+            inherit: bool = True,
+            _base: bool = False,
+            **kwargs,
+        ):
+            if _base:
+                namespace["__slots__"] = ()
+            else:
+                namespace_bases = [cls for cls in bases if isinstance(cls, __class__)]
+                if len(namespace_bases) > 1:
+                    raise RenderArgsDataError("Multiple namespace baseclasses")
+
+                base_has_fields = hasattr(namespace_bases[0], "_FIELDS")
+                inheriting = base_has_fields and inherit
+                fields = namespace.get("__annotations__", ())
+
+                if inheriting:
+                    if fields:
+                        raise RenderArgsDataError(
+                            "Cannot both inherit and define fields"
+                        )
+                else:
+                    if not fields:
+                        raise RenderArgsDataError("No field defined or to inherit")
+
+                    namespace["_FIELDS"] = MappingProxyType(dict.fromkeys(fields))
+                    if base_has_fields:
+                        namespace["_RENDER_CLS"] = None
+
+                for attr in fields:
+                    namespace.pop(attr, None)
+                namespace["__slots__"] = tuple(fields)
+
+            new_cls = super().__new__(cls, name, bases, namespace, **kwargs)
+
+            if not _base:
+                for method_name in ("__new__", "__init__"):
+                    method = getattr(new_cls, method_name)
+                    parameters = iter(signature(method).parameters.items())
+
+                    next(parameters)  # skip first parameter (cls, self)
+                    for param_name, param in parameters:
+                        if param.default is Parameter.empty and (
+                            Parameter.VAR_POSITIONAL
+                            is not param.kind
+                            is not Parameter.VAR_KEYWORD
+                        ):
+                            raise TypeError(
+                                f"'{new_cls.__qualname__}.{method_name}' has a "
+                                f"required parameter {param_name!r}"
+                            )
+
+            return new_cls
+
+    class Namespace(metaclass=_NamespaceMeta, _base=True):
+        """:term:`Render class`\\ -specific argument/data namespace."""
+
+        def __new__(cls, *args, **kwargs):
+            if cls._RENDER_CLS is None:
+                raise TypeError(
+                    "Cannot instantiate a render argument/data namespace class "
+                    "that is not associated with a render class"
+                )
+
+            return super().__new__(cls)
+
+        def __init__(self, fields: Mapping[str, Any]) -> None:
+            setattr_ = __class__.__setattr__  # Subclass(es) redefine `__setattr__()`
+            for name in type(self)._FIELDS:
+                setattr_(self, name, fields[name])
+
+        def __delattr__(self, _):
+            raise AttributeError("Cannot delete field")
+
+        @classmethod
+        def get_render_cls(cls) -> Type[Renderable] | None:
+            """Returns the associated :term:`render class`.
+
+            Returns:
+                The associated render class, if the namespace class has been
+                associated. Otherwise, ``None``.
+            """
+            return cls._RENDER_CLS
+
+
+class RenderArgs(RenderArgsData):
+    """RenderArgs(render_cls, /, *namespaces)
+    RenderArgs(render_cls, init_render_args, /, *namespaces)
+
+    Render arguments.
+
+    Args:
+        render_cls: A :term:`render class`.
+        init_render_args (RenderArgs | None): A set of render arguments.
+          If not ``None``,
+
+          * it must be compatible [#ra1]_ with *render_cls*,
+          * it'll be used to initialize the render arguments.
+
+        namespaces: Render argument namespaces compatible [#ran2]_ with *render_cls*.
+
+          .. note:: If multiple namespaces associated with the same :term:`render
+             class` are given, the last of them takes precedence.
+
+    Raises:
+        TypeError: An argument is of an inappropriate type.
+        IncompatibleRenderArgsError: *init_render_args* is incompatible [#ra1]_ with
+          *render_cls*.
+        IncompatibleArgsNamespaceError: Incompatible [#ran2]_ render argument namespace.
+
+    A set of render arguments (an instance of this class) is basically a container of
+    render argument namespaces (instances of :py:class:`RenderArgs.Namespace`); one for
+    each :term:`render class`, which defines render arguments, in the Method Resolution
+    Order of its associated [#ra2]_ render class.
+
+    The namespace for each render class is derived from the following sources, in
+    [descending] order of precedence:
+
+    * *namespaces*
+    * *init_render_args*
+    * default render argument namespaces
+
+    NOTE:
+        Instances are immutable but updated copies can be created via :py:meth:`update`.
+
+    TIP:
+        See the ``Args`` attribute (subclass of :py:class:`RenderArgs.Namespace`)
+        of :term:`render classes`, if not ``None``, for their respective render
+        argument namespaces.
+
+    .. Completed in /docs/source/api/renderable.rst
+    """
+
+    # Class Attributes =========================================================
+
+    __slots__ = ()
+
+    __interned: ClassVar[dict[type[Renderable], RenderArgs]] = {}
+
+    # Instance Attributes ======================================================
+
+    render_cls: Type[Renderable]
+    """The associated :term:`render class`"""
+
+    # Special Methods ==========================================================
+
+    def __new__(
+        cls,
+        render_cls: type[Renderable],
+        init_or_namespace: RenderArgs | RenderArgs.Namespace | None = None,
+        *namespaces: RenderArgs.Namespace,
+    ) -> RenderArgs:
+        if init_or_namespace is None:
+            init_render_args = None
+        elif isinstance(init_or_namespace, cls):
+            init_render_args = init_or_namespace
+        elif isinstance(init_or_namespace, __class__.Namespace):
+            init_render_args = None
+            namespaces = (init_or_namespace, *namespaces)
+        else:
+            raise arg_type_error_msg(
+                "Invalid type for the second argument", init_or_namespace
+            )
+
+        if init_render_args:
+            if not isinstance(render_cls, RenderableMeta):
+                raise arg_type_error("render_cls", render_cls)
+
+            if not issubclass(render_cls, init_render_args.render_cls):
+                raise IncompatibleRenderArgsError(
+                    f"'init_render_args' (associated with "
+                    f"{init_render_args.render_cls.__name__!r}) is incompatible with "
+                    f"{render_cls.__name__!r} "
+                )
+
+        # has default values only
+        if not namespaces and (
+            not init_render_args
+            or init_render_args is BASE_RENDER_ARGS
+            or cls.__interned.get(init_render_args.render_cls) is init_render_args
+        ):
+            try:
+                return cls.__interned[render_cls]
+            except KeyError:
+                pass
+
+        if (
+            init_render_args
+            and init_render_args.render_cls is render_cls
+            and not namespaces
+        ):
+            return init_render_args
+
+        return super().__new__(cls)
+
+    def __init__(
+        self,
+        render_cls: type[Renderable],
+        init_or_namespace: RenderArgs | RenderArgs.Namespace | None = None,
+        *namespaces: RenderArgs.Namespace,
+    ) -> None:
+        # `init_or_namespace` is validated in `__new__()`.
+        # `render_cls` is validated in `__new__()`, if and only if `init_or_namespace`
+        # is a `RenderArgs` instance.
+
+        if init_or_namespace is None:
+            init_render_args = None
+        elif isinstance(init_or_namespace, type(self)):
+            init_render_args = init_or_namespace
+        else:
+            init_render_args = None
+            namespaces = (init_or_namespace, *namespaces)
+
+        # has default values only
+        if not namespaces and (
+            not init_render_args
+            or init_render_args is BASE_RENDER_ARGS
+            or (
+                type(self).__interned.get(init_render_args.render_cls)
+                is init_render_args
+            )
+        ):
+            if render_cls in type(self).__interned:  # has been initialized
+                return
+            intern = True
+        else:
+            intern = False
+
+        if init_render_args:
+            if init_render_args.render_cls is render_cls and not namespaces:
+                return
+        # `render_cls` wasn't validated in `__new__()`
+        elif not isinstance(render_cls, RenderableMeta):
+            raise arg_type_error("render_cls", render_cls)
+
+        namespaces_dict = render_cls._ALL_DEFAULT_ARGS.copy()
+
+        # if non-default
+        if init_render_args and BASE_RENDER_ARGS is not init_render_args is not (
+            type(self).__interned.get(init_render_args.render_cls)
+        ):
+            namespaces_dict.update(init_render_args._namespaces)
+
+        for index, namespace in enumerate(namespaces):
+            if not isinstance(namespace, __class__.Namespace):
+                raise arg_type_error(f"namespaces[{index}]", namespace)
+            if namespace._RENDER_CLS not in render_cls._ALL_DEFAULT_ARGS:
+                raise IncompatibleArgsNamespaceError(
+                    f"'namespaces[{index}]' (associated with "
+                    f"{namespace._RENDER_CLS.__name__!r}) is incompatible with "
+                    f"{render_cls.__name__!r} "
+                )
+            namespaces_dict[namespace._RENDER_CLS] = namespace
+
+        super().__init__(render_cls, namespaces_dict)
+
+        if intern:
+            type(self).__interned[render_cls] = self
+
+    def __init_subclass__(cls, **kwargs) -> None:
+        cls.__interned = {}
+        super().__init_subclass__(**kwargs)
+
+    def __eq__(self, other: RenderArgs) -> bool:
+        """Compares this set of render arguments with another.
+
+        Args:
+            other: Another set of render arguments.
+
+        Returns:
+            ``True`` if both are associated with the same :term:`render class`
+            and have equal argument values. Otherwise, ``False``.
+        """
+        if isinstance(other, type(self)):
+            return (
+                self is other
+                or self.render_cls is other.render_cls
+                and self._namespaces == other._namespaces
+            )
+
+        return NotImplemented
+
+    def __getitem__(self, render_cls: Type[Renderable]) -> RenderArgs.Namespace:
+        """Returns a constituent namespace.
+
+        Args:
+            render_cls: A :term:`render class` of which :py:attr:`render_cls` is a
+              subclass (which may be :py:attr:`render_cls` itself) and which defines
+              render arguments.
+
+        Returns:
+            The constituent namespace associated with *render_cls*.
+
+        Raises:
+            TypeError: An argument is of an inappropriate type.
+            ValueError: :py:attr:`render_cls` is not a subclass of *render_cls*.
+            NoArgsNamespaceError: *render_cls* defines no render arguments.
+        """
+        try:
+            return self._namespaces[render_cls]
+        except (TypeError, KeyError):
+            if not isinstance(render_cls, RenderableMeta):
+                raise arg_type_error("render_cls", render_cls) from None
+
+            if issubclass(self.render_cls, render_cls):
+                raise NoArgsNamespaceError(
+                    f"{render_cls.__name__!r} defines no render arguments"
+                ) from None
+
+            raise ValueError(
+                f"{self.render_cls.__name__!r} is not a subclass of "
+                f"{render_cls.__name__!r}"
+            ) from None
+
+    def __hash__(self) -> int:
+        """Computes the hash of the render arguments.
+
+        Returns:
+            The computed hash.
+
+        IMPORTANT:
+            Like tuples, an instance is hashable if and only if the constituent
+            namespaces are hashable.
+        """
+        # Namespaces are always in the same order, wrt their respective associated
+        # render classes, for all instances associated with the same render class.
+        return hash((self.render_cls, tuple(self._namespaces.values())))
+
+    def __iter__(self) -> Iterator[RenderArgs.Namespace]:
+        """Returns an iterator that yields the constituent namespaces.
+
+        Returns:
+            An iterator that yields the constituent namespaces.
+
+        WARNING:
+            The number and order of namespaces is guaranteed to be the same across all
+            instances associated [#ra2]_ with the same :term:`render class` but beyond
+            this, should not be relied upon as the details (such as the specific
+            number or order) may change without notice.
+
+            The order is an implementation detail of the Renderable API and the number
+            should be considered alike with respect to the associated render class.
+        """
+        return iter(self._namespaces.values())
+
+    def __repr__(self) -> str:
+        return "".join(
+            (
+                f"{type(self).__name__}({self.render_cls.__name__}",
+                ", " if self._namespaces else "",
+                ", ".join(map(repr, self._namespaces.values())),
+                ")",
+            )
+        )
+
+    # Public Methods ===========================================================
+
+    def convert(self, render_cls: Type[Renderable]) -> RenderArgs:
+        """Converts the set of render arguments to one for a related render class.
+
+        Args:
+            render_cls: A :term:`render class` of which :py:attr:`render_cls` is a
+              parent or child (which may be :py:attr:`render_cls` itself).
+
+        Returns:
+            A set of render arguments associated [#ra2]_ with *render_cls* and
+            initialized with all constituent namespaces (of this set of render
+            arguments, *self*) that are compatible [#ran2]_ with *render_cls*.
+
+        Raises:
+            TypeError: An argument is of an inappropriate type.
+            ValueError: *render_cls* is not a parent or child of :py:attr:`render_cls`.
+        """
+        if render_cls is self.render_cls:
+            return self
+
+        if not isinstance(render_cls, RenderableMeta):
+            raise arg_type_error("render_cls", render_cls)
+
+        if issubclass(render_cls, self.render_cls):
+            return type(self)(render_cls, self)
+
+        if issubclass(self.render_cls, render_cls):
+            render_cls_args_mro = render_cls._ALL_DEFAULT_ARGS
+            return type(self)(
+                render_cls,
+                *[
+                    namespace
+                    for cls, namespace in self._namespaces.items()
+                    if cls in render_cls_args_mro
+                ],
+            )
+
+        raise ValueError(
+            f"{render_cls.__name__!r} is not a parent or child of "
+            f"{self.render_cls.__name__!r}"
+        )
+
+    def update(
+        self,
+        render_cls_or_namespace: Type[Renderable] | RenderArgs.Namespace,
+        *namespaces: RenderArgs.Namespace,
+        **fields: Any,
+    ) -> RenderArgs:
+        """update(namespace, /, *namespaces) -> RenderArgs
+        update(render_cls, /, **fields) -> RenderArgs
+
+        Replaces or updates render argument namespaces.
+
+        Args:
+            namespaces: Render argument namespaces compatible [#ran2]_ with
+              :py:attr:`render_cls`.
+
+              .. note:: If multiple namespaces associated with the same :term:`render
+                 class` are given, the last of them takes precedence.
+
+            render_cls (Type[Renderable]): A :term:`render class` of which
+              :py:attr:`render_cls` is a subclass (which may be :py:attr:`render_cls`
+              itself) and which defines render arguments.
+            fields: Render argument fields.
+
+              The keywords must be names of render argument fields for *render_cls*.
+
+        Returns:
+            For the **first** form, an instance with the namespaces for the respective
+            associated :term:`render classes` of the given namespaces replaced.
+
+            For the **second** form, an instance with the given render argument fields
+            for *render_cls* updated, if any.
+
+        Raises:
+            TypeError: An argument is of an inappropriate type.
+            TypeError: The arguments given do not conform to any of the expected forms.
+
+        Propagates exceptions raised by:
+
+        * the class constructor, for the **first** form.
+        * :py:meth:`__getitem__` and :py:meth:`RenderArgs.Namespace.update`, for the
+          **second** form.
+        """
+        if isinstance(render_cls_or_namespace, RenderableMeta):
+            if namespaces:
+                raise TypeError(
+                    "No other positional argument is expected when the first argument "
+                    "is a render class"
+                )
+            render_cls = render_cls_or_namespace
+        elif isinstance(render_cls_or_namespace, __class__.Namespace):
+            if fields:
+                raise TypeError(
+                    "No keyword argument is expected when the first argument is "
+                    "a render argument namespace"
+                )
+            render_cls = None
+            namespaces = (render_cls_or_namespace, *namespaces)
+        else:
+            raise arg_type_error_msg(
+                "Invalid type for the first argument", render_cls_or_namespace
+            )
+
+        return type(self)(
+            self.render_cls,
+            self,
+            *((self[render_cls].update(**fields),) if render_cls else namespaces),
+        )
+
+    # Inner Classes ============================================================
+
+    class _NamespaceMeta(RenderArgsData._NamespaceMeta):
+        """Metaclass of render argument namespaces."""
+
+        def __new__(cls, name, bases, namespace, *, _base: bool = False, **kwargs):
+            if not _base:
+                try:
+                    defaults = {
+                        name: namespace[name]
+                        for name in namespace.get("__annotations__", ())
+                    }
+                except KeyError as e:
+                    raise RenderArgsError(
+                        f"Field {e.args[0]!r} has no default value"
+                    ) from None
+
+            new_cls = super().__new__(
+                cls, name, bases, namespace, _base=_base, **kwargs
+            )
+
+            if not _base and "_FIELDS" in new_cls.__dict__:
+                new_cls._FIELDS = MappingProxyType(defaults)
+
+            return new_cls
+
+    class Namespace(RenderArgsData.Namespace, metaclass=_NamespaceMeta, _base=True):
+        """Namespace(*values, **fields)
+
+        :term:`Render class`\\ -specific render argument namespace.
+
+        Args:
+            values: Render argument field values.
+
+              The values are mapped to fields in the order in which the fields were
+              defined.
+
+            fields: Render argument fields.
+
+              The keywords must be names of render argument fields for the associated
+              [#ran1]_ render class.
+
+        Raises:
+            TypeError: The [sub]class being instantiated is not associated [#ran1]_
+              with a render class.
+            TypeError: More values (positional arguments) than there are fields.
+            UnknownArgsFieldError: Unknown field name(s).
+            TypeError: Multiple values given for a field.
+
+        If no value is given for a field, its default value is used.
+
+        NOTE:
+            * Fields are exposed as instance attributes.
+            * Instances are immutable but updated copies can be created via
+              :py:meth:`update`.
+            * Each subclass may be associated [#ran1]_ with **only one** render class.
+
+        .. Completed in /docs/source/api/renderable.rst
+        """
+
+        def __init__(self, *values: Any, **fields: Any) -> None:
+            default_fields = type(self)._FIELDS
+
+            if len(values) > len(default_fields):
+                raise TypeError(
+                    f"{type(self)._RENDER_CLS.__name__!r} defines "
+                    f"{len(default_fields)} render argument field(s) but "
+                    f"{len(values)} values were given"
+                )
+            value_fields = dict(zip(default_fields, values))
+
+            unknown = fields.keys() - default_fields.keys()
+            if unknown:
+                raise UnknownArgsFieldError(
+                    f"Unknown render argument fields {tuple(unknown)} for "
+                    f"{type(self)._RENDER_CLS.__name__!r}"
+                )
+            multiple = fields.keys() & value_fields.keys()
+            if multiple:
+                raise TypeError(
+                    f"Got multiple values for render argument fields "
+                    f"{tuple(multiple)} of {type(self)._RENDER_CLS.__name__!r}"
+                )
+
+            super().__init__({**default_fields, **value_fields, **fields})
+
+        def __repr__(self) -> str:
+            return "".join(
+                (
+                    f"{type(self)._RENDER_CLS.__name__}.Args(",
+                    ", ".join(
+                        f"{name}={getattr(self, name)!r}" for name in type(self)._FIELDS
+                    ),
+                    ")",
+                )
+            )
+
+        def __getattr__(self, attr):
+            raise UnknownArgsFieldError(
+                f"Unknown render argument field {attr!r} for "
+                f"{type(self)._RENDER_CLS.__name__!r}"
+            )
+
+        def __setattr__(self, *_):
+            raise AttributeError(
+                "Cannot modify render argument fields, use the `update()` method "
+                "of the namespace or the containing `RenderArgs` instance, as "
+                "applicable, instead"
+            )
+
+        def __eq__(self, other: RenderArgs.Namespace) -> bool:
+            """Compares the namespace with another.
+
+            Args:
+                other: Another render argument namespace.
+
+            Returns:
+                ``True`` if both operands are associated with the same
+                :term:`render class` and have equal field values.
+                Otherwise, ``False``.
+            """
+            if type(other) is type(self):
+                return self is other or all(
+                    getattr(self, name) == getattr(other, name)
+                    for name in type(self)._FIELDS
+                )
+
+            return NotImplemented
+
+        def __hash__(self) -> int:
+            """Computes the hash of the namespace.
+
+            Returns:
+                The computed hash.
+
+            IMPORTANT:
+                Like tuples, an instance is hashable if and only if the field values
+                are hashable.
+            """
+            # Field names and their order is the same for all instances associated
+            # with the same render class.
+            return hash(
+                (
+                    type(self)._RENDER_CLS,
+                    tuple([getattr(self, field) for field in type(self)._FIELDS]),
+                )
+            )
+
+        def __or__(self, other: RenderArgs.Namespace | RenderArgs) -> RenderArgs:
+            """Derives a set of render arguments from the combination of both operands.
+
+            Args:
+                other: Another render argument namespace or a set of render arguments.
+
+            Returns:
+                A set of render arguments associated with the **most derived** one
+                of the associated :term:`render classes` of both operands.
+
+            Raises:
+                IncompatibleArgsNamespaceError: *other* is a render argument namespace
+                  and neither operand is compatible [#ra1]_ [#ran2]_ with the
+                  associated :term:`render class` of the other.
+                IncompatibleRenderArgsError: *other* is a set of render arguments
+                  and neither operand is compatible [#ra1]_ [#ran2]_ with the
+                  associated :term:`render class` of the other.
+
+            NOTE:
+                * If *other* is a render argument namespace associated with the
+                  same :term:`render class` as *self*, *other* takes precedence.
+                * If *other* is a set of render arguments that contains a namespace
+                  associated with the same :term:`render class` as *self*, *self* takes
+                  precedence.
+            """
+            self_render_cls = type(self)._RENDER_CLS
+
+            if isinstance(other, __class__):
+                other_render_cls = type(other)._RENDER_CLS
+                if self_render_cls is other_render_cls:
+                    return RenderArgs(other_render_cls, other)
+                if issubclass(self_render_cls, other_render_cls):
+                    return RenderArgs(self_render_cls, self, other)
+                if issubclass(other_render_cls, self_render_cls):
+                    return RenderArgs(other_render_cls, self, other)
+                raise IncompatibleArgsNamespaceError(
+                    f"A render argument namespace for {other_render_cls.__name__!r} "
+                    "cannot be combined with a render argument namespace for "
+                    f"{self_render_cls.__name__!r}."
+                )
+
+            if isinstance(other, RenderArgs):
+                other_render_cls = other.render_cls
+                if issubclass(self_render_cls, other_render_cls):
+                    return RenderArgs(self_render_cls, other, self)
+                if issubclass(other_render_cls, self_render_cls):
+                    return RenderArgs(other_render_cls, other, self)
+                raise IncompatibleRenderArgsError(
+                    f"A set of render arguments for {other_render_cls.__name__!r} "
+                    "cannot be combined with a render argument namespace for "
+                    f"{self_render_cls.__name__!r}."
+                )
+
+            return NotImplemented
+
+        def __pos__(self) -> RenderArgs:
+            """Creates a set of render arguments from the namespace.
+
+            Returns:
+                A set of render arguments associated with the same :term:`render class`
+                as the namespace and initialized with the namespace.
+
+            TIP:
+                ``+namespace`` is shorthand for
+                :py:meth:`namespace.to_render_args() <to_render_args>`.
+            """
+            return RenderArgs(type(self)._RENDER_CLS, self)
+
+        def __ror__(self, other: RenderArgs.Namespace | RenderArgs) -> RenderArgs:
+            """Same as :py:meth:`__or__` but with reflected operands.
+
+            NOTE:
+                Unlike :py:meth:`__or__`, if *other* is a render argument namespace
+                associated with the same :term:`render class` as *self*, *self* takes
+                precedence.
+            """
+            # Not commutative
+            if isinstance(other, __class__) and (
+                type(self)._RENDER_CLS is type(other)._RENDER_CLS
+            ):
+                return RenderArgs(type(self)._RENDER_CLS, self)
+
+            return self.__or__(other)  # All other cases are commutative
+
+        def as_dict(self) -> dict[str, Any]:
+            """Copies the namespace as a dictionary.
+
+            Returns:
+                A dictionary mapping field names to their values.
+
+            WARNING:
+                The number and order of fields is guaranteed to be the same for a
+                subclass that defines fields, its subclasses (that inherit its fields)
+                and all their instances but beyond this, should not be relied upon as
+                the details (such as the specific number or order) may change without
+                notice.
+
+                The order is an implementation detail of the Render Arguments/Data API
+                and the number should be considered an implementation detail of the
+                specific namespace subclass.
+            """
+            return {name: getattr(self, name) for name in type(self)._FIELDS}
+
+        @classmethod
+        def get_fields(cls) -> Mapping[str, Any]:
+            """Returns the field definitions.
+
+            Returns:
+                A mapping of field names to their default values.
+
+            WARNING:
+                The number and order of fields is guaranteed to be the same for a
+                subclass that defines fields, its subclasses (that inherit its fields)
+                and all their instances but beyond this, should not be relied upon as
+                the details (such as the specific number or order) may change without
+                notice.
+
+                The order is an implementation detail of the Render Arguments/Data API
+                and the number should be considered an implementation detail of the
+                specific namespace subclass.
+            """
+            return cls._FIELDS
+
+        def to_render_args(
+            self, render_cls: type[Renderable] | None = None
+        ) -> RenderArgs:
+            """Creates a set of render arguments from the namespace.
+
+            Args:
+                render_cls: A :term:`render class`, with which the namespace is
+                  compatible [#ran2]_.
+
+            Returns:
+                A set of render arguments associated with *render_cls* (or the
+                associated [#ran1]_ render class of the namespace, if ``None``) and
+                initialized with the namespace.
+
+            Propagates exceptions raised by the
+            :py:class:`~term_image.renderable.RenderArgs` constructor, as applicable.
+
+            .. seealso:: :py:meth:`__pos__`.
+            """
+            return RenderArgs(render_cls or type(self)._RENDER_CLS, self)
+
+        def update(self, **fields: Any) -> RenderArgs.Namespace:
+            """Updates render argument fields.
+
+            Args:
+                fields: Render argument fields.
+
+            Returns:
+                A namespace with the given fields updated.
+
+            Raises:
+                UnknownArgsFieldError: Unknown field name(s).
+            """
+            if not fields:
+                return self
+
+            unknown = fields.keys() - type(self)._FIELDS.keys()
+            if unknown:
+                raise UnknownArgsFieldError(
+                    f"Unknown render argument field(s) {tuple(unknown)} for "
+                    f"{type(self)._RENDER_CLS.__name__!r}"
+                )
+
+            new = type(self).__new__(type(self))
+            new_fields = self.as_dict()
+            new_fields.update(fields)
+            super(__class__, new).__init__(new_fields)
+
+            return new
+
+
+class RenderData(RenderArgsData):
+    """Render data.
+
+    Args:
+        render_cls: A :term:`render class`.
+
+    An instance of this class is basically a container of render data namespaces
+    (instances of :py:class:`RenderData.Namespace`); one for each :term:`render class`,
+    which defines render data, in the Method Resolution Order of its associated
+    [#rd1]_ render class.
+
+    NOTE:
+        * Instances are immutable but the constituent namespaces are mutable.
+        * Instances and their contents shouldn't be copied by any means because
+          finalizing an instance may invalidate all other copies.
+        * Instances should always be explicitly finalized as soon as they're no longer
+          needed.
+
+    TIP:
+        See the ``_Data_`` attribute (subclass of :py:class:`RenderData.Namespace`)
+        of :term:`render classes`, if not ``None``, for their respective render
+        data namespaces.
+    """
+
+    # Class Attributes =========================================================
+
+    __slots__ = ("finalized",)
+
+    # Instance Attributes ======================================================
+
+    finalized: bool
+    """Finalization status"""
+
+    render_cls: Type[Renderable]
+    """The associated :term:`render class`"""
+
+    # Special Methods ==========================================================
+
+    def __init__(self, render_cls: type[Renderable]) -> None:
+        super().__init__(
+            render_cls,
+            {cls: data_cls() for cls, data_cls in render_cls._RENDER_DATA_MRO.items()},
+        )
+        self.finalized = False
+
+    def __del__(self):
+        try:
+            self.finalize()
+        except AttributeError:  # Unsuccesful initialization
+            pass
+
+    def __getitem__(self, render_cls: Type[Renderable]) -> RenderData.Namespace:
+        """Returns a constituent namespace.
+
+        Args:
+            render_cls: A :term:`render class` of which :py:attr:`render_cls` is a
+              subclass (which may be :py:attr:`render_cls` itself) and which defines
+              render data.
+
+        Returns:
+            The constituent namespace associated with *render_cls*.
+
+        Raises:
+            TypeError: An argument is of an inappropriate type.
+            ValueError: :py:attr:`render_cls` is not a subclass of *render_cls*.
+            NoDataNamespaceError: *render_cls* defines no render data.
+        """
+        try:
+            return self._namespaces[render_cls]
+        except (TypeError, KeyError):
+            if not isinstance(render_cls, RenderableMeta):
+                raise arg_type_error("render_cls", render_cls) from None
+
+            if issubclass(self.render_cls, render_cls):
+                raise NoDataNamespaceError(
+                    f"{render_cls.__name__!r} defines no render data"
+                ) from None
+
+            raise ValueError(
+                f"{self.render_cls.__name__!r} is not a subclass of "
+                f"{render_cls.__name__!r}"
+            ) from None
+
+    def __iter__(self) -> Iterator[RenderData.Namespace]:
+        """Returns an iterator that yields the constituent namespaces.
+
+        Returns:
+            An iterator that yields the constituent namespaces.
+
+        WARNING:
+            The number and order of namespaces is guaranteed to be the same across all
+            instances associated [#rd1]_ with the same :term:`render class` but beyond
+            this, should not be relied upon as the details (such as the specific
+            number or order) may change without notice.
+
+            The order is an implementation detail of the Renderable API and the number
+            should be considered alike with respect to the associated render class.
+        """
+        return iter(self._namespaces.values())
+
+    def __repr__(self) -> str:
+        return "".join(
+            (
+                f"<{type(self).__name__}({self.render_cls.__name__})",
+                ": " if self._namespaces else "",
+                ", ".join(map(repr, self._namespaces.values())),
+                ">",
+            )
+        )
+
+    # Public Methods ===========================================================
+
+    def finalize(self) -> None:
+        """Finalizes the render data.
+
+        Calls :py:meth:`~term_image.renderable.Renderable._finalize_render_data_`
+        of :py:attr:`render_cls`.
+
+        NOTE:
+            This method is safe for multiple invokations on the same instance.
+        """
+        if not self.finalized:
+            try:
+                self.render_cls._finalize_render_data_(self)
+            finally:
+                self.finalized = True
+
+    # Inner Classes ============================================================
+
+    class Namespace(RenderArgsData.Namespace, _base=True):
+        """Namespace()
+
+        :term:`Render class`\\ -specific render data namespace.
+
+        Raises:
+            TypeError: The [sub]class being instantiated is not associated [#rdn1]_
+              with a render class.
+
+        Subclassing, defining (and inheriting) fields is just as it is for
+        :ref:`args-namespace`, except that values assigned to the class attributes
+        are not used.
+
+        Every field is **uninitialized** immediately after instantiation of a
+        namespace. The fields are expected to be initialized within the
+        :py:meth:`~term_image.renderable.Renderable._get_render_data_` method of the
+        render class associated [#rdn1]_ with the namespace or at some other point
+        during a render operation, if neccessary.
+
+        NOTE:
+            * Fields are exposed as instance attributes.
+            * Instances are mutable and fields can be updated **in-place**, either
+              individually by assignment to an attribute reference or in batch via
+              :py:meth:`update`.
+            * An instance shouldn't be copied by any means because finalizing its
+              containing :py:class:`RenderData` instance may invalidate all copies of
+              the namespace.
+            * Each subclass may be associated [#rdn1]_ with **only one** render class.
+
+        .. Completed in /docs/source/api/renderable.rst
+        """
+
+        def __init__(self) -> None:
+            pass
+
+        def __repr__(self) -> str:
+            fields_repr = {}
+            for name in type(self)._FIELDS:
+                try:
+                    fields_repr[name] = repr(getattr(self, name))
+                except UninitializedDataFieldError:
+                    fields_repr[name] = "<uninitialized>"
+
+            return "".join(
+                (
+                    f"<{type(self)._RENDER_CLS.__name__}._Data_: ",
+                    ", ".join(
+                        f"{name}={value_repr}"
+                        for name, value_repr in fields_repr.items()
+                    ),
+                    ">",
+                )
+            )
+
+        def __getattr__(self, attr):
+            if attr in type(self)._FIELDS:
+                raise UninitializedDataFieldError(
+                    f"The render data field {attr!r} of "
+                    f"{type(self)._RENDER_CLS.__name__!r} has not been initialized"
+                )
+
+            raise UnknownDataFieldError(
+                f"Unknown render data field {attr!r} for "
+                f"{type(self)._RENDER_CLS.__name__!r}"
+            )
+
+        def __setattr__(self, attr, value):
+            try:
+                super().__setattr__(attr, value)
+            except AttributeError:
+                raise UnknownDataFieldError(
+                    f"Unknown render data field {attr!r} for "
+                    f"{type(self)._RENDER_CLS.__name__!r}"
+                ) from None
+
+        def as_dict(self) -> dict[str, Any]:
+            """Copies the namespace as a dictionary.
+
+            Returns:
+                A dictionary mapping field names to their current values.
+
+            Raises:
+              UninitializedDataFieldError: A field has not been initialized.
+
+            WARNING:
+                The number and order of fields is guaranteed to be the same for a
+                subclass that defines fields, its subclasses (that inherit its fields)
+                and all their instances but beyond this, should not be relied upon as
+                the details (such as the specific number or order) may change without
+                notice.
+
+                The order is an implementation detail of the Render Arguments/Data API
+                and the number should be considered an implementation detail of the
+                specific namespace subclass.
+            """
+            return {name: getattr(self, name) for name in type(self)._FIELDS}
+
+        @classmethod
+        def get_fields(cls) -> tuple[str]:
+            """Returns the field names.
+
+            Returns:
+                A tuple of field names.
+
+            WARNING:
+                The number and order of fields is guaranteed to be the same for a
+                subclass that defines fields, its subclasses (that inherit its fields)
+                and all their instances but beyond this, should not be relied upon as
+                the details (such as the specific number or order) may change without
+                notice.
+
+                The order is an implementation detail of the Render Arguments/Data API
+                and the number should be considered an implementation detail of the
+                specific namespace subclass.
+            """
+            return tuple(cls._FIELDS)
+
+        def update(self, **fields: Any) -> None:
+            """Updates render data fields.
+
+            Args:
+                fields: Render data fields.
+
+            Raises:
+                UnknownDataFieldError: Unknown field name(s).
+            """
+            if fields:
+                unknown = fields.keys() - type(self)._FIELDS.keys()
+                if unknown:
+                    raise UnknownDataFieldError(
+                        f"Unknown render data field(s) {tuple(unknown)} for "
+                        f"{type(self)._RENDER_CLS.__name__!r}"
+                    )
+
+                setattr_ = super().__setattr__
+                for field in fields.items():
+                    setattr_(*field)
+
+
+# Exceptions ===================================================================
+
+
+class RenderArgsDataError(RenderableError):
+    """Base exception class for errors specific to :py:class:`RenderArgs` and
+    :py:class:`RenderData`.
+
+    Raised for errors that occur during the creation of render argument/data
+    namespace classes.
+    """
+
+
+class RenderArgsError(RenderArgsDataError):
+    """Base exception class for errors specific to :py:class:`RenderArgs`.
+
+    Raised for errors that occur during the creation of render argument namespace
+    classes.
+    """
+
+
+class RenderDataError(RenderArgsDataError):
+    """Base exception class for errors specific to :py:class:`RenderData`."""
+
+
+class IncompatibleArgsNamespaceError(RenderArgsError):
+    """Raised when a given render argument namespace is incompatible [#ran2]_ with a
+    certain :term:`render class`.
+    """
+
+
+class IncompatibleRenderArgsError(RenderArgsError):
+    """Raised when a given set of render arguments is incompatible [#ra1]_ with a
+    certain :term:`render class`.
+    """
+
+
+class NoArgsNamespaceError(RenderArgsError):
+    """Raised when an attempt is made to get a render argument namespace for a
+    :term:`render class` that defines no render arguments.
+    """
+
+
+class NoDataNamespaceError(RenderDataError):
+    """Raised when an attempt is made to get a render data namespace for a
+    :term:`render class` that defines no render data.
+    """
+
+
+class UninitializedDataFieldError(RenderDataError, AttributeError):
+    """Raised when an attempt is made to access a render data field that hasn't been
+    initialized i.e for which a value hasn't been set.
+    """
+
+
+class UnknownArgsFieldError(RenderArgsError, AttributeError):
+    """Raised when an attempt is made to access or modify an unknown render argument
+    field.
+    """
+
+
+class UnknownDataFieldError(RenderDataError, AttributeError):
+    """Raised when an attempt is made to access or modify an unknown render data
+    field.
+    """
+
+
+# Variables ====================================================================
+
+
+BASE_RENDER_ARGS = RenderArgs.__new__(RenderArgs, None)
+
+# Updated from `._renderable`
+Renderable = "Renderable"
+RenderableMeta = "RenderableMeta"
diff --git a/src/term_image/utils.py b/src/term_image/utils.py
index 18177ef5..fb694242 100644
--- a/src/term_image/utils.py
+++ b/src/term_image/utils.py
@@ -287,17 +287,17 @@ def unix_only_wrapper(*args, **kwargs):
 
 def arg_type_error(arg: str, value: Any, got_extra: str = "") -> TypeError:
     return TypeError(
-        f"Invalid type for {arg!r} (got: {type(value).__name__}; {got_extra})"
+        f"Invalid type for {arg!r} (got: {type(value).__qualname__}; {got_extra})"
         if got_extra
-        else f"Invalid type for {arg!r} (got: {type(value).__name__})"
+        else f"Invalid type for {arg!r} (got: {type(value).__qualname__})"
     )
 
 
 def arg_type_error_msg(msg: str, value: Any, got_extra: str = "") -> TypeError:
     return TypeError(
-        f"{msg} (got: {type(value).__name__}; {got_extra})"
+        f"{msg} (got: {type(value).__qualname__}; {got_extra})"
         if got_extra
-        else f"{msg} (got: {type(value).__name__})"
+        else f"{msg} (got: {type(value).__qualname__})"
     )
 
 


Test patch:
diff --git a/tests/render/__init__.py b/tests/render/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/render/test_iterator.py b/tests/render/test_iterator.py
new file mode 100644
index 00000000..45b22043
--- /dev/null
+++ b/tests/render/test_iterator.py
@@ -0,0 +1,1157 @@
+from __future__ import annotations
+
+from itertools import zip_longest
+from typing import Iterator
+
+import pytest
+
+from term_image.geometry import Size
+from term_image.padding import AlignedPadding, ExactPadding
+from term_image.render import FinalizedIteratorError, RenderIterator
+from term_image.renderable import (
+    FrameCount,
+    FrameDuration,
+    IncompatibleRenderArgsError,
+    Renderable,
+    RenderArgs,
+    RenderData,
+    Seek,
+)
+
+from ..renderable.test_renderable import (
+    CacheSpace,
+    Char,
+    DummyFrame,
+    FrameFill,
+    IndefiniteSpace,
+    Space,
+)
+
+# NOTE: Always keep in mind that frames are not actually rendered by `RenderIterator`.
+# Hence, avoid testing the original data (fields defined by `Frame`) of rendered frames
+# when what is actually required is contained in the render data and/or render args
+# (which the `DummyFrame` subclass exposes).
+#
+# Cases where original frame data may be tested include (but may not be limited to):
+#
+# - when the frame returned may not be the original rendered by the renderable such as
+#   when the render output is padded.
+# - when comparing re-rendered frames such as with backward seeks or loop repetition.
+#
+# Also, any test that involves testing a **re-rendered** (via backward seek or loop
+# repetition) frame or any field(s) of it (including extras
+# added by `DummyFrame`) in a **definite** iteration must parametrize *cache* i.e test
+# for both `cache=False` and `cache=True`. Any other test should be okay with the
+# default (if not specifically a caching test).
+
+# Renderables ==================================================================
+
+
+class CacheChar(Char):
+    def __init__(self, *args):
+        self.n_renders = 0
+        super().__init__(*args)
+
+    def _render_(self, *args):
+        self.n_renders += 1
+        return super()._render_(*args)
+
+
+class IndefiniteFrameFill(Renderable):
+    size = Size(1, 1)
+
+    def _get_render_size_(self):
+        return self.size
+
+    def __init__(self, size: Size):
+        super().__init__(FrameCount.INDEFINITE, 1)
+        self.size = size
+        self.__frame_count = 10
+
+    def _render_(self, render_data, render_args):
+        data = render_data[Renderable]
+        width, height = data.size
+        frame_number = next(render_data[__class__].frames) if data.iteration else 0
+        return DummyFrame(
+            data.frame_offset,
+            1 if data.duration is FrameDuration.DYNAMIC else data.duration,
+            data.size,
+            "\n".join((str(frame_number) * width,) * height),
+            renderable=self,
+            render_data=render_data,
+            render_args=render_args,
+        )
+
+    def _get_render_data_(self, *, iteration):
+        render_data = super()._get_render_data_(iteration=iteration)
+        render_data[__class__].frames = (
+            iter(range(self.__frame_count)) if iteration else None
+        )
+        return render_data
+
+    class _Data_(RenderData.Namespace):
+        frames: Iterator[int] | None
+
+
+class CacheFrameFill(FrameFill):
+    def __init__(self, *args):
+        self.n_renders = 0
+        super().__init__(*args)
+
+    def _render_(self, *args):
+        self.n_renders += 1
+        return super()._render_(*args)
+
+
+space = Space(1, 1)
+anim_space = Space(2, 1)
+indefinite_space = IndefiniteSpace(1)
+anim_char = Char(2, 1)
+frame_fill = FrameFill(Size(1, 1))
+indefinite_frame_fill = IndefiniteFrameFill(Size(1, 1))
+
+
+# Utils ========================================================================
+
+
+def get_loop_frames(renderable, cache=Ellipsis):
+    frame_count = renderable.frame_count
+    render_iter = (
+        RenderIterator(renderable, loops=2)
+        if cache is Ellipsis
+        else RenderIterator(renderable, loops=2, cache=cache)
+    )
+    loop_1_frames = [next(render_iter) for _ in range(frame_count)]
+    loop_2_frames = [next(render_iter) for _ in range(frame_count)]
+
+    return loop_1_frames, loop_2_frames
+
+
+# Tests ========================================================================
+
+# # Constructor ==================================================================
+
+
+def test_args():
+    with pytest.raises(TypeError, match="'renderable'"):
+        RenderIterator(Ellipsis)
+
+    with pytest.raises(TypeError, match="'render_args'"):
+        RenderIterator(anim_space, Ellipsis)
+
+    with pytest.raises(TypeError, match="'padding'"):
+        RenderIterator(anim_space, padding=Ellipsis)
+
+    with pytest.raises(TypeError, match="'loops'"):
+        RenderIterator(anim_space, loops=Ellipsis)
+
+    with pytest.raises(TypeError, match="'cache'"):
+        RenderIterator(anim_space, cache=Ellipsis)
+
+
+class TestRenderable:
+    def test_non_animated(self):
+        with pytest.raises(ValueError, match="not animated"):
+            RenderIterator(space)
+
+    @pytest.mark.parametrize("renderable", [anim_space, frame_fill])
+    def test_animated(self, renderable):
+        render_iter = RenderIterator(renderable)
+        frame = next(render_iter)
+        assert frame.renderable is renderable
+        assert frame.render_data.render_cls is type(renderable)
+        assert frame.render_args.render_cls is type(renderable)
+
+
+class TestRenderArgs:
+    def test_default(self):
+        render_iter = RenderIterator(anim_char)
+        for frame in render_iter:
+            assert frame.render_args == RenderArgs(Char)
+
+    def test_non_default(self):
+        render_iter = RenderIterator(anim_char, +Char.Args(char="#"))
+        for frame in render_iter:
+            assert frame.render_args == +Char.Args(char="#")
+
+    def test_incompatible(self):
+        with pytest.raises(IncompatibleRenderArgsError):
+            RenderIterator(anim_space, RenderArgs(FrameFill))
+
+
+class TestPadding:
+    def test_default(self):
+        render_iter = RenderIterator(frame_fill)
+        for index, frame in enumerate(render_iter):
+            assert frame.render_size == Size(1, 1)
+            assert frame.render_output == str(index)
+
+    def test_padded(self):
+        render_iter = RenderIterator(frame_fill, padding=ExactPadding(1, 1, 1, 1))
+        for index, frame in enumerate(render_iter):
+            assert frame.render_size == Size(3, 3)
+            assert frame.render_output == f"   \n {index} \n   "
+
+    def test_unpadded(self):
+        render_iter = RenderIterator(
+            FrameFill(Size(5, 5)), padding=ExactPadding(0, 0, 0, 0)
+        )
+        for index, frame in enumerate(render_iter):
+            assert frame.render_size == Size(5, 5)
+            assert frame.render_output == "\n".join((str(index) * 5,) * 5)
+
+
+class TestLoops:
+    def test_invalid(self):
+        with pytest.raises(ValueError, match="'loops'"):
+            RenderIterator(anim_space, loops=0)
+
+    class TestDefinite:
+        def test_default(self):
+            render_iter = RenderIterator(anim_space)
+            assert len(tuple(render_iter)) == 2
+
+        @pytest.mark.parametrize("loops", [1, 10])
+        def test_finite(self, loops):
+            render_iter = RenderIterator(anim_space, loops=loops)
+            assert len(tuple(render_iter)) == 2 * loops
+
+        @pytest.mark.parametrize("cache", [False, True])
+        def test_repetition(self, cache):
+            for loop_1_frame, loop_2_frame in zip(*get_loop_frames(frame_fill, cache)):
+                assert loop_1_frame == loop_2_frame
+
+    class TestIndefinite:
+        def test_default(self):
+            render_iter = RenderIterator(indefinite_space)
+            assert len(tuple(render_iter)) == 1
+
+        @pytest.mark.parametrize("loops", [1, 10, -1, -10])
+        def test_ignored(self, loops):
+            render_iter = RenderIterator(indefinite_space, loops=loops)
+            assert len(tuple(render_iter)) == 1
+
+
+class TestCache:
+    @pytest.mark.parametrize("cache", [0, -1, -100])
+    def test_invalid_cache_size(self, cache):
+        with pytest.raises(ValueError, match="'cache'"):
+            RenderIterator(anim_space, cache=cache)
+
+    class TestDefinite:
+        @pytest.mark.parametrize(
+            "n_frames,cached,n_renders",
+            [(99, True, 99), (100, True, 100), (101, False, 202)],
+        )
+        def test_default(self, n_frames, cached, n_renders):
+            cache_space = CacheSpace(n_frames, 1)
+            render_iter = RenderIterator(cache_space, loops=2)
+            assert render_iter._cached is cached
+            tuple(render_iter)
+            assert cache_space.n_renders == n_renders
+
+        def test_false(self):
+            cache_frame_fill = CacheFrameFill(Size(1, 1))
+            render_iter = RenderIterator(cache_frame_fill, loops=2, cache=False)
+            assert render_iter._cached is False
+
+            # First loop
+            frames = []
+            for _ in range(10):
+                frames.append(next(render_iter))
+                assert cache_frame_fill.n_renders == len(frames)
+
+            # Second loop
+            for n_renders, frame in enumerate(frames, 11):
+                assert next(render_iter) is not frame
+                assert cache_frame_fill.n_renders == n_renders
+
+        class TestTrue:
+            def test_without_padding(self):
+                cache_frame_fill = CacheFrameFill(Size(1, 1))
+                render_iter = RenderIterator(cache_frame_fill, loops=2, cache=True)
+                assert render_iter._cached is True
+
+                # First loop
+                frames = []
+                for _ in range(10):
+                    frames.append(next(render_iter))
+                    assert cache_frame_fill.n_renders == len(frames)
+
+                # Second loop
+                for frame in frames:
+                    assert next(render_iter) is frame
+                    assert cache_frame_fill.n_renders == 10
+
+            def test_with_padding(self):
+                cache_frame_fill = CacheFrameFill(Size(1, 1))
+                render_iter = RenderIterator(
+                    cache_frame_fill, padding=ExactPadding(1), loops=2, cache=True
+                )
+                assert render_iter._cached is True
+
+                # First loop
+                frames = []
+                for _ in range(10):
+                    frames.append(next(render_iter))
+                    assert cache_frame_fill.n_renders == len(frames)
+
+                # Second loop
+                for frame in frames:
+                    assert next(render_iter) == frame
+                    assert cache_frame_fill.n_renders == 10
+
+        @pytest.mark.parametrize(
+            "cache,cached,n_renders", [(9, False, 20), (10, True, 10), (11, True, 10)]
+        )
+        def test_cache_size(self, cache, cached, n_renders):
+            cache_frame_fill = CacheFrameFill(Size(1, 1))
+            render_iter = RenderIterator(cache_frame_fill, loops=2, cache=cache)
+            assert render_iter._cached is cached
+            tuple(render_iter)
+            assert cache_frame_fill.n_renders == n_renders
+
+    class TestIndefinite:
+        @pytest.mark.parametrize("n_frames", [99, 100, 101])
+        def test_default(self, n_frames):
+            render_iter = RenderIterator(IndefiniteSpace(n_frames))
+            assert render_iter._cached is False
+
+        @pytest.mark.parametrize("cache", [False, True, 9, 10, 11])
+        def test_ignored(self, cache):
+            render_iter = RenderIterator(indefinite_frame_fill, cache=cache)
+            assert render_iter._cached is False
+
+
+# # Attributes ===================================================================
+
+
+class TestLoop:
+    class TestDefinite:
+        class TestStart:
+            def test_default(self):
+                render_iter = RenderIterator(anim_space)
+                assert render_iter.loop == 1
+
+            @pytest.mark.parametrize("loops", [10, -10])
+            def test_non_default(self, loops):
+                render_iter = RenderIterator(anim_space, loops=loops)
+                assert render_iter.loop == loops
+
+        def test_end(self):
+            render_iter = RenderIterator(anim_space)
+            tuple(render_iter)
+            assert render_iter.loop == 0
+
+            render_iter = RenderIterator(anim_space, loops=10)
+            tuple(render_iter)
+            assert render_iter.loop == 0
+
+        class TestCountdown:
+            def test_finite(self):
+                render_iter = RenderIterator(anim_space, loops=10)
+                assert render_iter.loop == 10
+
+                for value in range(10, 0, -1):
+                    for _ in range(2):
+                        next(render_iter)
+                        assert render_iter.loop == value
+
+                with pytest.raises(StopIteration):
+                    next(render_iter)
+                assert render_iter.loop == 0
+
+            def test_infinite(self):
+                render_iter = RenderIterator(anim_space, loops=-1)
+                assert render_iter.loop == -1
+
+                for _ in range(10):
+                    for _ in range(2):
+                        next(render_iter)
+                        assert render_iter.loop == -1
+
+        def test_after_seek(self):
+            render_iter = RenderIterator(frame_fill, loops=2)
+
+            render_iter.seek(9)
+            next(render_iter)
+            assert render_iter.loop == 2
+
+            render_iter.seek(0)
+            next(render_iter)
+            assert render_iter.loop == 2
+
+            render_iter.seek(9)
+            next(render_iter)
+            assert render_iter.loop == 2
+
+            next(render_iter)
+            assert render_iter.loop == 1
+
+    class TestIndefinite:
+        class TestStart:
+            def test_default(self):
+                render_iter = RenderIterator(indefinite_space)
+                assert render_iter.loop == 1
+
+            @pytest.mark.parametrize("loops", [10, -10])
+            def test_non_default(self, loops):
+                render_iter = RenderIterator(indefinite_space, loops=loops)
+                assert render_iter.loop == 1
+
+        class TestEnd:
+            def test_default(self):
+                render_iter = RenderIterator(indefinite_space)
+                tuple(render_iter)
+                assert render_iter.loop == 0
+
+            @pytest.mark.parametrize("loops", [10, -10])
+            def test_non_default(self, loops):
+                render_iter = RenderIterator(indefinite_space, loops=loops)
+                tuple(render_iter)
+                assert render_iter.loop == 0
+
+        def test_countdown(self):
+            render_iter = RenderIterator(indefinite_space)
+            assert render_iter.loop == 1
+
+            next(render_iter)
+            assert render_iter.loop == 1
+
+            with pytest.raises(StopIteration):
+                next(render_iter)
+            assert render_iter.loop == 0
+
+
+# # Methods ======================================================================
+
+
+def test_iter():
+    render_iter = RenderIterator(anim_space)
+    assert iter(render_iter) is render_iter
+
+
+class TestNext:
+    anim_space_seeked = Space(2, 1)
+    anim_space_seeked.seek(1)
+
+    @pytest.mark.parametrize("renderable", [anim_space, anim_space_seeked])
+    def test_definite(self, renderable):
+        render_iter = RenderIterator(renderable)
+
+        for number in range(2):
+            frame = next(render_iter)
+            assert frame.data.frame_offset == number
+            assert frame.data.seek_whence is Seek.START
+
+        with pytest.raises(StopIteration, match="ended"):
+            next(render_iter)
+        with pytest.raises(StopIteration, match="finalized"):
+            next(render_iter)
+
+    def test_indefinite(self):
+        render_iter = RenderIterator(IndefiniteSpace(2))
+
+        frame = next(render_iter)
+        assert frame.data.frame_offset == 0
+        assert frame.data.seek_whence is Seek.START
+
+        frame = next(render_iter)
+        assert frame.data.frame_offset == 0
+        assert frame.data.seek_whence is Seek.CURRENT
+
+        with pytest.raises(StopIteration, match="ended"):
+            next(render_iter)
+        with pytest.raises(StopIteration, match="finalized"):
+            next(render_iter)
+
+    def test_error(self):
+        class ErrorSpace(Space):
+            def _render_(self, render_data, render_args):
+                if render_data[Renderable].frame_offset == 1:
+                    assert False
+                return super()._render_(render_data, render_args)
+
+        render_iter = RenderIterator(ErrorSpace(2, 1))
+        next(render_iter)
+        with pytest.raises(AssertionError):
+            next(render_iter)
+        with pytest.raises(StopIteration, match="finalized"):
+            next(render_iter)
+
+    def test_finalized(self):
+        render_iter = RenderIterator(anim_space)
+        render_iter.close()
+        with pytest.raises(StopIteration, match="finalized"):
+            next(render_iter)
+
+
+def test_close():
+    render_iter = RenderIterator(anim_space)
+    render_data = render_iter._render_data
+    assert render_data.finalized is False
+
+    render_iter.close()
+
+    assert not hasattr(render_iter, "_render_data")
+    assert render_data.finalized is True
+
+
+class TestSeek:
+    def test_args(self):
+        render_iter = RenderIterator(anim_space)
+        with pytest.raises(TypeError, match="offset"):
+            render_iter.seek(Ellipsis)
+
+    class TestDefinite:
+        anim_space_seeked = Space(2, 1)
+        anim_space_seeked.seek(1)
+
+        @pytest.mark.parametrize("renderable", [anim_space, anim_space_seeked])
+        def test_start(self, renderable):
+            render_iter = RenderIterator(renderable)
+            data = render_iter._render_data[Renderable]
+            assert data.frame_offset == 0
+            assert data.seek_whence is Seek.START
+
+        @pytest.mark.parametrize(
+            "whence,offset,frame_no",
+            [
+                (Seek.START, 0, 0),
+                (Seek.START, 1, 1),
+                (Seek.START, 9, 9),
+                (Seek.CURRENT, -5, 0),
+                (Seek.CURRENT, -1, 4),
+                (Seek.CURRENT, 0, 5),
+                (Seek.CURRENT, 1, 6),
+                (Seek.CURRENT, 4, 9),
+                (Seek.END, -9, 0),
+                (Seek.END, -1, 8),
+                (Seek.END, 0, 9),
+            ],
+        )
+        def test_in_range(self, whence, offset, frame_no):
+            render_iter = RenderIterator(frame_fill)
+            data = render_iter._render_data[Renderable]
+            render_iter.seek(4)  # For CURRENT-relative
+            next(render_iter)  # next = 5
+
+            render_iter.seek(offset, whence)
+            assert data.frame_offset == frame_no
+            assert data.seek_whence is Seek.START
+
+            next(render_iter)
+            assert data.frame_offset == frame_no + 1
+            assert data.seek_whence is Seek.START
+
+        @pytest.mark.parametrize(
+            "whence,offset",
+            [
+                (Seek.START, -1),
+                (Seek.START, 10),
+                (Seek.CURRENT, -6),
+                (Seek.CURRENT, 5),
+                (Seek.END, -10),
+                (Seek.END, 1),
+            ],
+        )
+        def test_out_of_range(self, whence, offset):
+            render_iter = RenderIterator(frame_fill)
+            data = render_iter._render_data[Renderable]
+            render_iter.seek(4)  # For CURRENT-relative
+            next(render_iter)  # next = 5
+
+            with pytest.raises(ValueError, match="'offset'"):
+                render_iter.seek(offset, whence)
+
+            assert data.frame_offset == 5
+            assert data.seek_whence is Seek.START
+
+        @pytest.mark.parametrize(
+            "seek_ops,final_offset,final_whence",
+            [
+                # Non-cummulative
+                ([(5, Seek.START), (3, Seek.START)], 3, Seek.START),
+                ([(2, Seek.START), (-3, Seek.END)], 6, Seek.START),
+                ([(-2, Seek.END), (2, Seek.START)], 2, Seek.START),
+                ([(-5, Seek.END), (-2, Seek.END)], 7, Seek.START),
+                # # next=5
+                ([(3, Seek.CURRENT), (6, Seek.START)], 6, Seek.START),
+                ([(-3, Seek.CURRENT), (6, Seek.START)], 6, Seek.START),
+                ([(2, Seek.CURRENT), (-5, Seek.END)], 4, Seek.START),
+                ([(-2, Seek.CURRENT), (-5, Seek.END)], 4, Seek.START),
+                # Cummulative
+                ([(5, Seek.START), (3, Seek.CURRENT)], 8, Seek.START),
+                ([(7, Seek.START), (-3, Seek.CURRENT)], 4, Seek.START),
+                ([(-5, Seek.END), (-2, Seek.CURRENT)], 2, Seek.START),
+                ([(-7, Seek.END), (2, Seek.CURRENT)], 4, Seek.START),
+                # # next=5
+                ([(2, Seek.CURRENT), (2, Seek.CURRENT)], 9, Seek.START),
+                ([(3, Seek.CURRENT), (-3, Seek.CURRENT)], 5, Seek.START),
+                ([(-3, Seek.CURRENT), (3, Seek.CURRENT)], 5, Seek.START),
+                ([(-2, Seek.CURRENT), (-2, Seek.CURRENT)], 1, Seek.START),
+            ],
+        )
+        def test_consecutive_seek(self, seek_ops, final_offset, final_whence):
+            render_iter = RenderIterator(frame_fill)
+            data = render_iter._render_data[Renderable]
+            render_iter.seek(4)  # For initially CURRENT-relative
+            next(render_iter)  # next = 5
+
+            for offset, whence in seek_ops:
+                render_iter.seek(offset, whence)
+
+            assert data.frame_offset == final_offset
+            assert data.seek_whence is final_whence
+
+        @pytest.mark.parametrize(
+            "frames_before,offset,frames_after",
+            [(0, 9, 1), (3, 7, 3), (7, 4, 6), (10, 0, 10)],
+        )
+        def test_end_after_seek(self, frames_before, offset, frames_after):
+            render_iter = RenderIterator(frame_fill)
+
+            for _ in range(frames_before):
+                next(render_iter)
+            render_iter.seek(offset)
+            for _ in range(frames_after):
+                next(render_iter)
+
+            with pytest.raises(StopIteration, match="ended"):
+                next(render_iter)
+
+        def test_uncached_frames(self):
+            render_iter = RenderIterator(frame_fill, cache=False)
+            frames = [next(render_iter) for _ in range(10)]
+            render_iter.seek(0)
+            for old_frame, new_frame in zip_longest(frames, render_iter):
+                assert old_frame is not new_frame
+
+        def test_cached_frames(self):
+            render_iter = RenderIterator(frame_fill, cache=True)
+            frames = [next(render_iter) for _ in range(10)]
+            render_iter.seek(0)
+            for old_frame, new_frame in zip_longest(frames, render_iter):
+                assert old_frame is new_frame
+
+        def test_previously_skipped_frame_is_cached(self):
+            render_iter = RenderIterator(frame_fill, cache=True)
+            render_iter.seek(9)
+            next(render_iter)
+
+            render_iter.seek(5)
+            frame_5 = next(render_iter)
+
+            render_iter.seek(9)
+            next(render_iter)
+
+            render_iter.seek(5)
+            assert next(render_iter) is frame_5
+
+    class TestIndefinite:
+        def test_start(self):
+            render_iter = RenderIterator(indefinite_space)
+            data = render_iter._render_data[Renderable]
+            assert data.frame_offset == 0
+            assert data.seek_whence is Seek.START
+
+        @pytest.mark.parametrize(
+            "whence,offset",
+            [
+                (Seek.START, 0),
+                (Seek.START, 1),
+                (Seek.START, 9),
+                (Seek.CURRENT, -5),
+                (Seek.CURRENT, -1),
+                (Seek.CURRENT, 0),
+                (Seek.CURRENT, 1),
+                (Seek.CURRENT, 4),
+                (Seek.END, -9),
+                (Seek.END, -1),
+                (Seek.END, 0),
+                # out of range of available frames but valid seek ops
+                (Seek.START, 10),
+                (Seek.CURRENT, -6),
+                (Seek.CURRENT, 5),
+                (Seek.END, -10),
+            ],
+        )
+        def test_in_range(self, whence, offset):
+            render_iter = RenderIterator(indefinite_frame_fill)
+            data = render_iter._render_data[Renderable]
+            render_iter.seek(4)  # For CURRENT-relative
+            next(render_iter)  # next = 5
+
+            render_iter.seek(offset, whence)
+            assert data.frame_offset == offset
+            assert data.seek_whence is whence
+
+            next(render_iter)
+            assert data.frame_offset == 0
+            assert data.seek_whence is Seek.CURRENT
+
+        @pytest.mark.parametrize("whence,offset", [(Seek.START, -1), (Seek.END, 1)])
+        def test_out_of_range(self, whence, offset):
+            render_iter = RenderIterator(indefinite_frame_fill)
+            data = render_iter._render_data[Renderable]
+            render_iter.seek(4)  # For CURRENT-relative
+            next(render_iter)  # next = 5
+
+            with pytest.raises(ValueError, match="'offset'"):
+                render_iter.seek(offset, whence)
+
+            assert data.frame_offset == 0
+            assert data.seek_whence is Seek.CURRENT
+
+        @pytest.mark.parametrize(
+            "seek_ops,final_offset,final_whence",
+            [
+                # All non-cummulative
+                ([(5, Seek.START), (3, Seek.START)], 3, Seek.START),
+                ([(2, Seek.START), (-3, Seek.END)], -3, Seek.END),
+                ([(5, Seek.START), (3, Seek.CURRENT)], 3, Seek.CURRENT),
+                ([(7, Seek.START), (-3, Seek.CURRENT)], -3, Seek.CURRENT),
+                ([(-2, Seek.END), (2, Seek.START)], 2, Seek.START),
+                ([(-5, Seek.END), (-2, Seek.END)], -2, Seek.END),
+                ([(-5, Seek.END), (-2, Seek.CURRENT)], -2, Seek.CURRENT),
+                ([(-7, Seek.END), (2, Seek.CURRENT)], 2, Seek.CURRENT),
+                ([(3, Seek.CURRENT), (6, Seek.START)], 6, Seek.START),
+                ([(-3, Seek.CURRENT), (6, Seek.START)], 6, Seek.START),
+                ([(2, Seek.CURRENT), (-5, Seek.END)], -5, Seek.END),
+                ([(-2, Seek.CURRENT), (-5, Seek.END)], -5, Seek.END),
+                ([(2, Seek.CURRENT), (2, Seek.CURRENT)], 2, Seek.CURRENT),
+                ([(3, Seek.CURRENT), (-3, Seek.CURRENT)], -3, Seek.CURRENT),
+                ([(-3, Seek.CURRENT), (3, Seek.CURRENT)], 3, Seek.CURRENT),
+                ([(-2, Seek.CURRENT), (-2, Seek.CURRENT)], -2, Seek.CURRENT),
+            ],
+        )
+        def test_consecutive_seek(self, seek_ops, final_offset, final_whence):
+            render_iter = RenderIterator(indefinite_frame_fill)
+            data = render_iter._render_data[Renderable]
+            render_iter.seek(4)  # For initially CURRENT-relative
+            next(render_iter)  # next = 5
+
+            for offset, whence in seek_ops:
+                render_iter.seek(offset, whence)
+
+            assert data.frame_offset == final_offset
+            assert data.seek_whence is final_whence
+
+    def test_finalized(self):
+        render_iter = RenderIterator(anim_space)
+        render_iter.close()
+        with pytest.raises(FinalizedIteratorError, match="finalized"):
+            render_iter.seek(0)
+
+
+class TestSetFrameDuration:
+    anim_space = Space(5, 1)
+
+    @pytest.mark.parametrize("duration", [0, -1])
+    def test_arg_duration(self, duration):
+        render_iter = RenderIterator(self.anim_space)
+        with pytest.raises(ValueError, match="duration"):
+            render_iter.set_frame_duration(duration)
+
+    def test_iteration(self):
+        render_iter = RenderIterator(self.anim_space)
+        assert next(render_iter).data.duration == 1
+
+        render_iter.set_frame_duration(10)
+        assert next(render_iter).data.duration == 10
+        assert next(render_iter).data.duration == 10
+
+        render_iter.set_frame_duration(FrameDuration.DYNAMIC)
+        assert next(render_iter).data.duration is FrameDuration.DYNAMIC
+        assert next(render_iter).data.duration is FrameDuration.DYNAMIC
+
+    def test_before_seek(self):
+        render_iter = RenderIterator(self.anim_space)
+        assert next(render_iter).data.duration == 1
+
+        render_iter.set_frame_duration(10)
+        render_iter.seek(3)
+        assert next(render_iter).data.duration == 10
+
+    def test_after_seek(self):
+        render_iter = RenderIterator(self.anim_space)
+        assert next(render_iter).data.duration == 1
+
+        render_iter.seek(3)
+        render_iter.set_frame_duration(10)
+        assert next(render_iter).data.duration == 10
+
+    def test_cache_update(self):
+        cache_space = CacheSpace(2, 1)
+        render_iter = RenderIterator(cache_space, cache=True)
+        old_frame = next(render_iter)
+        assert cache_space.n_renders == 1
+        assert old_frame.data.duration == 1
+
+        render_iter.seek(0)
+        assert next(render_iter) is old_frame
+        assert cache_space.n_renders == 1
+
+        render_iter.seek(0)
+        render_iter.set_frame_duration(10)
+        new_frame = next(render_iter)
+        assert cache_space.n_renders == 2
+        assert new_frame is not old_frame
+        assert new_frame.data.duration == 10
+
+        render_iter.seek(0)
+        assert next(render_iter) is new_frame
+        assert cache_space.n_renders == 2
+
+    def test_finalized(self):
+        render_iter = RenderIterator(anim_space)
+        render_iter.close()
+        with pytest.raises(FinalizedIteratorError, match="finalized"):
+            render_iter.set_frame_duration(10)
+
+
+class TestSetPadding:
+    anim_char = Char(5, 1)
+    render_args = +Char.Args(char="#")
+
+    def test_args(self):
+        render_iter = RenderIterator(self.anim_char, self.render_args)
+        with pytest.raises(TypeError, match="padding"):
+            render_iter.set_padding(Ellipsis)
+
+    def test_iteration(self):
+        render_iter = RenderIterator(self.anim_char, self.render_args)
+        frame = next(render_iter)
+        assert frame.render_size == Size(1, 1)
+        assert frame.render_output == "#"
+
+        render_iter.set_padding(AlignedPadding(3, 1))
+        frame = next(render_iter)
+        assert frame.render_size == Size(3, 1)
+        assert frame.render_output == " # "
+        frame = next(render_iter)
+        assert frame.render_size == Size(3, 1)
+        assert frame.render_output == " # "
+
+        render_iter.set_padding(ExactPadding(1, 1, 1, 1))
+        frame = next(render_iter)
+        assert frame.render_size == Size(3, 3)
+        assert frame.render_output == "   \n # \n   "
+        frame = next(render_iter)
+        assert frame.render_size == Size(3, 3)
+        assert frame.render_output == "   \n # \n   "
+
+    def test_before_seek(self):
+        render_iter = RenderIterator(self.anim_char, self.render_args)
+        frame = next(render_iter)
+        assert frame.render_size == Size(1, 1)
+        assert frame.render_output == "#"
+
+        render_iter.set_padding(AlignedPadding(3, 1))
+        render_iter.seek(3)
+        frame = next(render_iter)
+        assert frame.render_size == Size(3, 1)
+        assert frame.render_output == " # "
+
+    def test_after_seek(self):
+        render_iter = RenderIterator(self.anim_char, self.render_args)
+        frame = next(render_iter)
+        assert frame.render_size == Size(1, 1)
+        assert frame.render_output == "#"
+
+        render_iter.seek(3)
+        render_iter.set_padding(AlignedPadding(3, 1))
+        frame = next(render_iter)
+        assert frame.render_size == Size(3, 1)
+        assert frame.render_output == " # "
+
+    def test_cache_not_updated(self):
+        cache_frame_fill = CacheFrameFill(Size(1, 1))
+        render_iter = RenderIterator(
+            cache_frame_fill, padding=ExactPadding(1), cache=True
+        )
+
+        old_frame = next(render_iter)
+        assert cache_frame_fill.n_renders == 1
+        assert old_frame.render_size == Size(2, 1)
+        assert old_frame.render_output == " 0"
+
+        render_iter.seek(0)
+        render_iter.set_padding(AlignedPadding(3, 1))
+
+        new_frame = next(render_iter)
+        assert cache_frame_fill.n_renders == 1
+        assert new_frame != old_frame
+        assert new_frame.render_size == Size(3, 1)
+        assert new_frame.render_output == " 0 "
+
+    def test_finalized(self):
+        render_iter = RenderIterator(anim_space)
+        render_iter.close()
+        with pytest.raises(FinalizedIteratorError, match="finalized"):
+            render_iter.set_padding(ExactPadding())
+
+
+class TestSetRenderArgs:
+    anim_char = Char(5, 1)
+
+    def test_args(self):
+        render_iter = RenderIterator(self.anim_char)
+        with pytest.raises(TypeError, match="render_args"):
+            render_iter.set_render_args(Ellipsis)
+        with pytest.raises(IncompatibleRenderArgsError):
+            render_iter.set_render_args(RenderArgs(Space))
+
+    def test_iteration(self):
+        render_iter = RenderIterator(self.anim_char)
+        assert next(render_iter).render_args == RenderArgs(Char)
+
+        render_iter.set_render_args(+Char.Args(char="#"))
+        assert next(render_iter).render_args == +Char.Args(char="#")
+        assert next(render_iter).render_args == +Char.Args(char="#")
+
+        render_iter.set_render_args(+Char.Args(char="$"))
+        assert next(render_iter).render_args == +Char.Args(char="$")
+        assert next(render_iter).render_args == +Char.Args(char="$")
+
+    def test_before_seek(self):
+        render_iter = RenderIterator(self.anim_char)
+        assert next(render_iter).render_args == RenderArgs(Char)
+
+        render_iter.set_render_args(+Char.Args(char="#"))
+        render_iter.seek(3)
+        assert next(render_iter).render_args == +Char.Args(char="#")
+
+    def test_after_seek(self):
+        render_iter = RenderIterator(self.anim_char)
+        assert next(render_iter).render_args == RenderArgs(Char)
+
+        render_iter.seek(3)
+        render_iter.set_render_args(+Char.Args(char="#"))
+        assert next(render_iter).render_args == +Char.Args(char="#")
+
+    def test_cache_update(self):
+        cache_char = CacheChar(2, 1)
+        render_iter = RenderIterator(cache_char, cache=True)
+        old_frame = next(render_iter)
+        assert cache_char.n_renders == 1
+        assert old_frame.render_args == RenderArgs(CacheChar)
+
+        render_iter.seek(0)
+        assert cache_char.n_renders == 1
+        assert next(render_iter) is old_frame
+
+        render_iter.seek(0)
+        render_iter.set_render_args(+Char.Args(char="#"))
+        new_frame = next(render_iter)
+        assert cache_char.n_renders == 2
+        assert new_frame is not old_frame
+        assert new_frame.render_args == RenderArgs(CacheChar, Char.Args(char="#"))
+
+        render_iter.seek(0)
+        assert next(render_iter) is new_frame
+        assert cache_char.n_renders == 2
+
+    def test_finalized(self):
+        render_iter = RenderIterator(anim_space)
+        render_iter.close()
+        with pytest.raises(FinalizedIteratorError, match="finalized"):
+            render_iter.set_render_args(RenderArgs(Space))
+
+
+class TestSetRenderSize:
+    anim_space = Space(5, 1)
+
+    def test_args(self):
+        render_iter = RenderIterator(self.anim_space)
+        with pytest.raises(TypeError, match="render_size"):
+            render_iter.set_render_size(Ellipsis)
+
+    def test_iteration(self):
+        render_iter = RenderIterator(self.anim_space)
+        assert next(render_iter).data.size == Size(1, 1)
+
+        render_iter.set_render_size(Size(3, 1))
+        assert next(render_iter).data.size == Size(3, 1)
+        assert next(render_iter).data.size == Size(3, 1)
+
+        render_iter.set_render_size(Size(3, 3))
+        assert next(render_iter).data.size == Size(3, 3)
+        assert next(render_iter).data.size == Size(3, 3)
+
+    def test_before_seek(self):
+        render_iter = RenderIterator(self.anim_space)
+        assert next(render_iter).data.size == Size(1, 1)
+
+        render_iter.set_render_size(Size(3, 1))
+        render_iter.seek(3)
+        assert next(render_iter).data.size == Size(3, 1)
+
+    def test_after_seek(self):
+        render_iter = RenderIterator(self.anim_space)
+        assert next(render_iter).data.size == Size(1, 1)
+
+        render_iter.seek(3)
+        render_iter.set_render_size(Size(3, 1))
+        assert next(render_iter).data.size == Size(3, 1)
+
+    def test_cache_update(self):
+        cache_space = CacheSpace(2, 1)
+        render_iter = RenderIterator(cache_space, cache=True)
+        old_frame = next(render_iter)
+        assert cache_space.n_renders == 1
+        assert old_frame.data.size == Size(1, 1)
+
+        render_iter.seek(0)
+        assert next(render_iter) is old_frame
+        assert cache_space.n_renders == 1
+
+        render_iter.seek(0)
+        render_iter.set_render_size(Size(3, 1))
+        new_frame = next(render_iter)
+        assert cache_space.n_renders == 2
+        assert new_frame is not old_frame
+        assert new_frame.data.size == Size(3, 1)
+
+        render_iter.seek(0)
+        assert next(render_iter) is new_frame
+        assert cache_space.n_renders == 2
+
+    def test_finalized(self):
+        render_iter = RenderIterator(anim_space)
+        render_iter.close()
+        with pytest.raises(FinalizedIteratorError, match="finalized"):
+            render_iter.set_render_size(Size(1, 1))
+
+
+class TestFromRenderData:
+    def test_args(self):
+        render_data = anim_space._get_render_data_(iteration=True)
+        finalized_render_data = anim_space._get_render_data_(iteration=True)
+        finalized_render_data.finalize()
+
+        with pytest.raises(TypeError, match="'renderable'"):
+            RenderIterator._from_render_data_(Ellipsis, render_data)
+        with pytest.raises(ValueError, match="not animated"):
+            RenderIterator._from_render_data_(space, render_data)
+
+        with pytest.raises(TypeError, match="'render_data'"):
+            RenderIterator._from_render_data_(anim_space, Ellipsis)
+        with pytest.raises(ValueError, match="'Space'"):
+            RenderIterator._from_render_data_(
+                anim_space, anim_char._get_render_data_(iteration=True)
+            )
+        with pytest.raises(ValueError, match="iteration"):
+            RenderIterator._from_render_data_(
+                anim_space, anim_space._get_render_data_(iteration=False)
+            )
+        with pytest.raises(ValueError, match="finalized"):
+            RenderIterator._from_render_data_(anim_space, finalized_render_data)
+
+        with pytest.raises(TypeError, match="'render_args'"):
+            RenderIterator._from_render_data_(
+                anim_space, render_data, render_args=Ellipsis
+            )
+        with pytest.raises(IncompatibleRenderArgsError):
+            RenderIterator._from_render_data_(
+                anim_space, render_data, render_args=RenderArgs(FrameFill)
+            )
+
+        with pytest.raises(TypeError, match="'padding'"):
+            RenderIterator._from_render_data_(anim_space, render_data, padding=Ellipsis)
+
+        with pytest.raises(TypeError, match="'finalize'"):
+            RenderIterator._from_render_data_(
+                anim_space, render_data, finalize=Ellipsis
+            )
+
+    def test_render_data(self):
+        render_data = frame_fill._get_render_data_(iteration=True)
+        render_iter = RenderIterator._from_render_data_(frame_fill, render_data)
+        assert next(render_iter).render_data is render_data
+
+    class TestFinalize:
+        def test_default(self):
+            render_data = anim_space._get_render_data_(iteration=True)
+            render_iter = RenderIterator._from_render_data_(anim_space, render_data)
+            render_iter.close()
+            assert render_data.finalized is True
+
+        @pytest.mark.parametrize("finalize", [False, True])
+        def test_non_default(self, finalize):
+            render_data = anim_space._get_render_data_(iteration=True)
+            render_iter = RenderIterator._from_render_data_(
+                anim_space, render_data, finalize=finalize
+            )
+            render_iter.close()
+            assert render_data.finalized is finalize
+
+    class TestRenderArgs:
+        def test_default(self):
+            render_iter = RenderIterator._from_render_data_(
+                anim_char, anim_char._get_render_data_(iteration=True)
+            )
+            for frame in render_iter:
+                assert frame.render_args == RenderArgs(Char)
+
+        def test_non_default(self):
+            render_iter = RenderIterator._from_render_data_(
+                anim_char,
+                anim_char._get_render_data_(iteration=True),
+                +Char.Args(char="#"),
+            )
+            for frame in render_iter:
+                assert frame.render_args == +Char.Args(char="#")
+
+    class TestPadding:
+        def test_default(self):
+            render_iter = RenderIterator._from_render_data_(
+                frame_fill, frame_fill._get_render_data_(iteration=True)
+            )
+            for index, frame in enumerate(render_iter):
+                assert frame.render_size == Size(1, 1)
+                assert frame.render_output == str(index)
+
+        def test_padded(self):
+            render_iter = RenderIterator._from_render_data_(
+                frame_fill,
+                frame_fill._get_render_data_(iteration=True),
+                padding=ExactPadding(1, 1, 1, 1),
+            )
+            for index, frame in enumerate(render_iter):
+                assert frame.render_size == Size(3, 3)
+                assert frame.render_output == f"   \n {index} \n   "
+
+        def test_unpadded(self):
+            frame_fill = FrameFill(Size(5, 5))
+            render_iter = RenderIterator._from_render_data_(
+                frame_fill,
+                frame_fill._get_render_data_(iteration=True),
+                padding=ExactPadding(0, 0, 0, 0),
+            )
+            for index, frame in enumerate(render_iter):
+                assert frame.render_size == Size(5, 5)
+                assert frame.render_output == "\n".join((str(index) * 5,) * 5)
+
+
+# # Others =======================================================================
+
+
+class TestFrameCount:
+    class TestDefinite:
+        @pytest.mark.parametrize("n_frames", [2, 10])
+        def test_unseeked(self, n_frames):
+            render_iter = RenderIterator(Space(n_frames, 1))
+            assert len(tuple(render_iter)) == n_frames
+
+        @pytest.mark.parametrize("frame_number", [2, 4])
+        def test_seeked(self, frame_number):
+            anim_space = Space(5, 1)
+            anim_space.seek(frame_number)
+            render_iter = RenderIterator(anim_space)
+            assert len(tuple(render_iter)) == 5
+
+    @pytest.mark.parametrize("n_frames", [2, 10])
+    def test_indefinite(self, n_frames):
+        render_iter = RenderIterator(IndefiniteSpace(n_frames))
+        assert len(tuple(render_iter)) == n_frames
diff --git a/tests/renderable/__init__.py b/tests/renderable/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/renderable/test_renderable.py b/tests/renderable/test_renderable.py
new file mode 100644
index 00000000..e5aaca10
--- /dev/null
+++ b/tests/renderable/test_renderable.py
@@ -0,0 +1,1952 @@
+from __future__ import annotations
+
+import io
+import sys
+from contextlib import contextmanager
+from tempfile import TemporaryFile
+from types import MappingProxyType, SimpleNamespace
+from typing import Any, Iterator
+
+import pytest
+
+from term_image.ctlseqs import HIDE_CURSOR, SHOW_CURSOR
+from term_image.geometry import Size
+from term_image.padding import AlignedPadding, ExactPadding, HAlign, VAlign
+from term_image.render import RenderIterator
+from term_image.renderable import (
+    Frame,
+    FrameCount,
+    FrameDuration,
+    IncompatibleRenderArgsError,
+    IndefiniteSeekError,
+    NonAnimatedFrameDurationError,
+    Renderable,
+    RenderableError,
+    RenderArgs,
+    RenderData,
+    RenderSizeOutofRangeError,
+    Seek,
+)
+
+from .. import get_terminal_size
+
+try:
+    import pty
+    import termios
+except ImportError:
+    OS_IS_UNIX = False
+else:
+    OS_IS_UNIX = True
+
+STDOUT = io.StringIO()
+COLUMNS, LINES = get_terminal_size()
+
+# NOTE: Always keep in mind that frames are not actually rendered by `RenderIterator`.
+# Hence, avoid testing the original data (fields defined by `Frame`) of rendered frames
+# when what is actually required is contained in the render data and/or render args
+# (which the `DummyFrame` subclass exposes).
+#
+# Cases where original frame data may be tested include (but may not be limited to):
+#
+# - when the frame returned may not be the original rendered by the renderable such as
+#   when the render output is padded.
+
+# ========================== Renderables ==========================
+
+
+class Space(Renderable):
+    size = Size(1, 1)
+
+    def _get_render_size_(self):
+        return self.size
+
+    def _render_(self, render_data, render_args):
+        data = render_data[Renderable]
+        width, height = data.size
+        return DummyFrame(
+            data.frame_offset,
+            1 if data.duration is FrameDuration.DYNAMIC else data.duration,
+            data.size,
+            "\n".join((" " * width,) * height),
+            renderable=self,
+            render_data=render_data,
+            render_args=render_args,
+        )
+
+
+class IndefiniteSpace(Space):
+    def __init__(self, frame_count):
+        super().__init__(FrameCount.INDEFINITE, 1)
+        self.__frame_count = frame_count
+
+    def _render_(self, render_data, render_args):
+        if render_data[Renderable].iteration:
+            next(render_data[__class__].frames)
+        return super()._render_(render_data, render_args)
+
+    def _get_render_data_(self, *, iteration):
+        render_data = super()._get_render_data_(iteration=iteration)
+        render_data[__class__].frames = (
+            iter(range(self.__frame_count)) if iteration else None
+        )
+        return render_data
+
+    class _Data_(RenderData.Namespace):
+        frames: Iterator[int] | None
+
+
+class CacheSpace(Space):
+    def __init__(self, *args):
+        self.n_renders = 0
+        super().__init__(*args)
+
+    def _render_(self, *args):
+        self.n_renders += 1
+        return super()._render_(*args)
+
+
+class Char(Renderable):
+    size = Size(1, 1)
+
+    def _get_render_size_(self):
+        return self.size
+
+    def _render_(self, render_data, render_args):
+        data = render_data[Renderable]
+        width, height = data.size
+        return DummyFrame(
+            data.frame_offset,
+            1 if data.duration is FrameDuration.DYNAMIC else data.duration,
+            data.size,
+            "\n".join((render_args[Char].char * width,) * height),
+            renderable=self,
+            render_data=render_data,
+            render_args=render_args,
+        )
+
+    class Args(RenderArgs.Namespace):
+        char: str = " "
+
+
+class FrameFill(Renderable):
+    size = Size(1, 1)
+
+    def _get_render_size_(self):
+        return self.size
+
+    def __init__(self, size: Size):
+        super().__init__(10, 1)
+        self.size = size
+
+    def _render_(self, render_data, render_args):
+        data = render_data[Renderable]
+        width, height = data.size
+        return DummyFrame(
+            data.frame_offset,
+            1 if data.duration is FrameDuration.DYNAMIC else data.duration,
+            data.size,
+            "\n".join((str(data.frame_offset) * width,) * height),
+            renderable=self,
+            render_data=render_data,
+            render_args=render_args,
+        )
+
+
+# ========================== Utils ==========================
+
+
+class DummyFrame(Frame):
+    _extra_data = {}
+
+    def __new__(cls, *args, renderable, render_data, render_args):
+        new = super().__new__(cls, *args)
+        __class__._extra_data[id(new)] = (
+            renderable,
+            render_data,
+            render_args,
+            SimpleNamespace(**render_data[Renderable].as_dict()),
+        )
+        return new
+
+    def __del__(self):
+        del __class__._extra_data[id(self)]
+
+    renderable = property(lambda self: __class__._extra_data[id(self)][0])
+    render_data = property(lambda self: __class__._extra_data[id(self)][1])
+    render_args = property(lambda self: __class__._extra_data[id(self)][2])
+    data = property(lambda self: __class__._extra_data[id(self)][3])
+
+
+@contextmanager
+def capture_stdout():
+    STDOUT.seek(0)
+    STDOUT.truncate()
+    sys.stdout = STDOUT
+    try:
+        yield
+    finally:
+        STDOUT.seek(0)
+        STDOUT.truncate()
+
+
+@contextmanager
+def capture_stdout_pty():
+    def write(string):
+        type(slave).write(slave, string)
+        buffer.write(string)
+
+    master, slave = (open(fd, mode) for fd, mode in zip(pty.openpty(), "rw"))
+    buffer = io.StringIO()
+    slave.write = write
+    sys.stdout = slave
+
+    with master, slave:
+        yield master, buffer
+
+
+def anim_n_eol(render_height, padded_height, frame_count, loops):
+    return (
+        # first frame of the first loop, with padding
+        (padded_height - 1)
+        # remaining frames of the first loop
+        + ((render_height - 1) * (frame_count - 1))
+        # frames of the remaining loops
+        + ((render_height - 1) * frame_count * (loops - 1))
+    )
+
+
+# ========================== Tests ==========================
+
+# # Metaclass / Subclass Construction ============================================
+
+
+class TestMeta:
+    def test_not_a_subclass(self):
+        with pytest.raises(RenderableError, match="'Foo' is not a subclass"):
+
+            class Foo(metaclass=type(Renderable)):
+                pass
+
+    class TestRenderArgs:
+        def test_base(self):
+            assert "_ALL_DEFAULT_ARGS" in Renderable.__dict__
+            assert isinstance(Renderable._ALL_DEFAULT_ARGS, MappingProxyType)
+            assert Renderable._ALL_DEFAULT_ARGS == {}
+
+        def test_invalid_type(self):
+            with pytest.raises(TypeError, match="'Foo.Args'"):
+
+                class Foo(Renderable):
+                    Args = Ellipsis
+
+        def test_not_a_subclass(self):
+            with pytest.raises(
+                RenderableError,
+                match="'Foo.Args' .* subclass of 'RenderArgs.Namespace'",
+            ):
+
+                class Foo(Renderable):
+                    class Args:
+                        pass
+
+        def test_already_associated(self):
+            class Foo(Renderable):
+                class Args(RenderArgs.Namespace):
+                    foo: None = None
+
+            with pytest.raises(
+                RenderableError, match="'Bar.Args' .* associated with .* 'Foo'"
+            ):
+
+                class Bar(Renderable):
+                    class Args(Foo.Args):
+                        pass
+
+        def test_no_args(self):
+            class Foo(Renderable):
+                pass
+
+            assert Foo.Args is None
+            assert "_ALL_DEFAULT_ARGS" in Foo.__dict__
+            assert isinstance(Foo._ALL_DEFAULT_ARGS, MappingProxyType)
+            assert Foo._ALL_DEFAULT_ARGS == Renderable._ALL_DEFAULT_ARGS
+
+        def test_args_none(self):
+            class Foo(Renderable):
+                Args = None
+
+            assert Foo.Args is None
+            assert "_ALL_DEFAULT_ARGS" in Foo.__dict__
+            assert isinstance(Foo._ALL_DEFAULT_ARGS, MappingProxyType)
+            assert Foo._ALL_DEFAULT_ARGS == Renderable._ALL_DEFAULT_ARGS
+
+        def test_has_args(self):
+            class Args(RenderArgs.Namespace):
+                foo: None = None
+
+            Foo = type(Renderable)("Foo", (Renderable,), {"Args": Args})
+
+            assert Foo.Args is Args
+            assert "_ALL_DEFAULT_ARGS" in Foo.__dict__
+            assert isinstance(Foo._ALL_DEFAULT_ARGS, MappingProxyType)
+            assert Foo._ALL_DEFAULT_ARGS == {
+                **Renderable._ALL_DEFAULT_ARGS,
+                Foo: Foo.Args(),
+            }
+
+        def test_association(self):
+            class Args(RenderArgs.Namespace):
+                foo: None = None
+
+            assert Args.get_render_cls() is None
+
+            Foo = type(Renderable)("Foo", (Renderable,), {"Args": Args})
+            assert Args.get_render_cls() is Foo
+
+        class TestInheritance:
+            class A(Renderable):
+                class Args(RenderArgs.Namespace):
+                    a: None = None
+
+            def test_child_with_no_args(self):
+                class B(self.A):
+                    pass
+
+                assert B._ALL_DEFAULT_ARGS == {
+                    **Renderable._ALL_DEFAULT_ARGS,
+                    self.A: self.A.Args(),
+                }
+
+            def test_parent_with_no_args(self):
+                class B(Renderable):
+                    pass
+
+                class C(B):
+                    class Args(RenderArgs.Namespace):
+                        c: None = None
+
+                assert C._ALL_DEFAULT_ARGS == {
+                    **Renderable._ALL_DEFAULT_ARGS,
+                    C: C.Args(),
+                }
+
+            def test_multi_level(self):
+                class B(self.A):
+                    class Args(RenderArgs.Namespace):
+                        b: None = None
+
+                assert B._ALL_DEFAULT_ARGS == {
+                    **Renderable._ALL_DEFAULT_ARGS,
+                    self.A: self.A.Args(),
+                    B: B.Args(),
+                }
+
+                class C(B):
+                    class Args(RenderArgs.Namespace):
+                        c: None = None
+
+                assert C._ALL_DEFAULT_ARGS == {
+                    **Renderable._ALL_DEFAULT_ARGS,
+                    self.A: self.A.Args(),
+                    B: B.Args(),
+                    C: C.Args(),
+                }
+
+            def test_multiple(self):
+                class B(Renderable):
+                    class Args(RenderArgs.Namespace):
+                        b: None = None
+
+                class C(self.A, B):
+                    class Args(RenderArgs.Namespace):
+                        c: None = None
+
+                assert C._ALL_DEFAULT_ARGS == {
+                    **Renderable._ALL_DEFAULT_ARGS,
+                    self.A: self.A.Args(),
+                    B: B.Args(),
+                    C: C.Args(),
+                }
+
+            def test_complex(self):
+                class B(self.A):
+                    class Args(RenderArgs.Namespace):
+                        b: None = None
+
+                class C(self.A):
+                    class Args(RenderArgs.Namespace):
+                        c: None = None
+
+                class D(B, C):
+                    class Args(RenderArgs.Namespace):
+                        d: None = None
+
+                class E(Renderable):
+                    class Args(RenderArgs.Namespace):
+                        e: None = None
+
+                class F(D, E):
+                    class Args(RenderArgs.Namespace):
+                        f: None = None
+
+                assert F._ALL_DEFAULT_ARGS == {
+                    **Renderable._ALL_DEFAULT_ARGS,
+                    self.A: self.A.Args(),
+                    B: B.Args(),
+                    C: C.Args(),
+                    D: D.Args(),
+                    E: E.Args(),
+                    F: F.Args(),
+                }
+
+        def test_optimization_default_namespaces_interned(self):
+            class A(Renderable):
+                class Args(RenderArgs.Namespace):
+                    a: None = None
+
+            class B(A):
+                class Args(RenderArgs.Namespace):
+                    b: None = None
+
+            class C(B):
+                class Args(RenderArgs.Namespace):
+                    c: None = None
+
+            assert A._ALL_DEFAULT_ARGS[A] is B._ALL_DEFAULT_ARGS[A]
+            assert A._ALL_DEFAULT_ARGS[A] is C._ALL_DEFAULT_ARGS[A]
+            assert B._ALL_DEFAULT_ARGS[B] is C._ALL_DEFAULT_ARGS[B]
+
+    class TestRenderData:
+        def test_base(self):
+            assert "_RENDER_DATA_MRO" in Renderable.__dict__
+            assert isinstance(Renderable._RENDER_DATA_MRO, MappingProxyType)
+            assert Renderable._RENDER_DATA_MRO == {Renderable: Renderable._Data_}
+
+        def test_invalid_type(self):
+            with pytest.raises(TypeError, match="'Foo._Data_'"):
+
+                class Foo(Renderable):
+                    _Data_ = Ellipsis
+
+        def test_not_a_subclass(self):
+            with pytest.raises(
+                RenderableError,
+                match="'Foo._Data_' .* subclass of 'RenderData.Namespace'",
+            ):
+
+                class Foo(Renderable):
+                    class _Data_:
+                        pass
+
+        def test_already_associated(self):
+            class Foo(Renderable):
+                class _Data_(RenderData.Namespace):
+                    foo: None = None
+
+            with pytest.raises(
+                RenderableError, match="'Bar._Data_' .* associated with .* 'Foo'"
+            ):
+
+                class Bar(Renderable):
+                    class _Data_(Foo._Data_):
+                        pass
+
+        def test_no_data(self):
+            class Foo(Renderable):
+                pass
+
+            assert Foo._Data_ is None
+            assert "_RENDER_DATA_MRO" in Foo.__dict__
+            assert isinstance(Foo._RENDER_DATA_MRO, MappingProxyType)
+            assert Foo._RENDER_DATA_MRO == Renderable._RENDER_DATA_MRO
+
+        def test_data_none(self):
+            class Foo(Renderable):
+                _Data_ = None
+
+            assert Foo._Data_ is None
+            assert "_RENDER_DATA_MRO" in Foo.__dict__
+            assert isinstance(Foo._RENDER_DATA_MRO, MappingProxyType)
+            assert Foo._RENDER_DATA_MRO == Renderable._RENDER_DATA_MRO
+
+        def test_has_data(self):
+            class _Data_(RenderData.Namespace):
+                foo: None = None
+
+            Foo = type(Renderable)("Foo", (Renderable,), {"_Data_": _Data_})
+
+            assert Foo._Data_ is _Data_
+            assert "_RENDER_DATA_MRO" in Foo.__dict__
+            assert isinstance(Foo._RENDER_DATA_MRO, MappingProxyType)
+            assert Foo._RENDER_DATA_MRO == {
+                **Renderable._RENDER_DATA_MRO,
+                Foo: Foo._Data_,
+            }
+
+        def test_association(self):
+            class _Data_(RenderData.Namespace):
+                foo: None = None
+
+            assert _Data_.get_render_cls() is None
+
+            Foo = type(Renderable)("Foo", (Renderable,), {"_Data_": _Data_})
+
+            assert _Data_.get_render_cls() is Foo
+
+        class TestInheritance:
+            class A(Renderable):
+                class _Data_(RenderData.Namespace):
+                    a: None = None
+
+            def test_child_with_no_data(self):
+                class B(self.A):
+                    pass
+
+                assert B._RENDER_DATA_MRO == {
+                    **Renderable._RENDER_DATA_MRO,
+                    self.A: self.A._Data_,
+                }
+
+            def test_parent_with_no_data(self):
+                class B(Renderable):
+                    pass
+
+                class C(B):
+                    class _Data_(RenderData.Namespace):
+                        c: None = None
+
+                assert C._RENDER_DATA_MRO == {
+                    **Renderable._RENDER_DATA_MRO,
+                    C: C._Data_,
+                }
+
+            def test_multi_level(self):
+                class B(self.A):
+                    class _Data_(RenderData.Namespace):
+                        b: None = None
+
+                assert B._RENDER_DATA_MRO == {
+                    **Renderable._RENDER_DATA_MRO,
+                    self.A: self.A._Data_,
+                    B: B._Data_,
+                }
+
+                class C(B):
+                    class _Data_(RenderData.Namespace):
+                        c: None = None
+
+                assert C._RENDER_DATA_MRO == {
+                    **Renderable._RENDER_DATA_MRO,
+                    self.A: self.A._Data_,
+                    B: B._Data_,
+                    C: C._Data_,
+                }
+
+            def test_multiple(self):
+                class B(Renderable):
+                    class _Data_(RenderData.Namespace):
+                        b: None = None
+
+                class C(self.A, B):
+                    class _Data_(RenderData.Namespace):
+                        c: None = None
+
+                assert C._RENDER_DATA_MRO == {
+                    **Renderable._RENDER_DATA_MRO,
+                    self.A: self.A._Data_,
+                    B: B._Data_,
+                    C: C._Data_,
+                }
+
+            def test_complex(self):
+                class B(self.A):
+                    class _Data_(RenderData.Namespace):
+                        b: None = None
+
+                class C(self.A):
+                    class _Data_(RenderData.Namespace):
+                        c: None = None
+
+                class D(B, C):
+                    class _Data_(RenderData.Namespace):
+                        d: None = None
+
+                class E(Renderable):
+                    class _Data_(RenderData.Namespace):
+                        e: None = None
+
+                class F(D, E):
+                    class _Data_(RenderData.Namespace):
+                        f: None = None
+
+                assert F._RENDER_DATA_MRO == {
+                    **Renderable._RENDER_DATA_MRO,
+                    self.A: self.A._Data_,
+                    B: B._Data_,
+                    C: C._Data_,
+                    D: D._Data_,
+                    E: E._Data_,
+                    F: F._Data_,
+                }
+
+    class TestExportedAttrs:
+        class A(Renderable):
+            _EXPORTED_ATTRS_ = ("a",)
+            _EXPORTED_DESCENDANT_ATTRS_ = ("A",)
+
+        def test_base(self):
+            assert isinstance(Renderable._ALL_EXPORTED_ATTRS, tuple)
+            assert Renderable._ALL_EXPORTED_ATTRS == ()
+
+        def test_cls(self):
+            assert isinstance(self.A._ALL_EXPORTED_ATTRS, tuple)
+            assert sorted(self.A._ALL_EXPORTED_ATTRS) == sorted(("a", "A"))
+
+        def test_inheritance(self):
+            class B(self.A):
+                _EXPORTED_ATTRS_ = ("b",)
+                _EXPORTED_DESCENDANT_ATTRS_ = ("B",)
+
+            assert sorted(B._ALL_EXPORTED_ATTRS) == sorted(("b", "A", "B"))
+
+            class C(B):
+                _EXPORTED_ATTRS_ = ("c",)
+                _EXPORTED_DESCENDANT_ATTRS_ = ("C",)
+
+            assert sorted(C._ALL_EXPORTED_ATTRS) == sorted(("c", "A", "B", "C"))
+
+        def test_multiple_inheritance(self):
+            class B(Renderable):
+                _EXPORTED_ATTRS_ = ("b",)
+                _EXPORTED_DESCENDANT_ATTRS_ = ("B",)
+
+            class C(self.A, B):
+                _EXPORTED_ATTRS_ = ("c",)
+                _EXPORTED_DESCENDANT_ATTRS_ = ("C",)
+
+            assert sorted(C._ALL_EXPORTED_ATTRS) == sorted(("c", "A", "B", "C"))
+
+            class C(B, self.A):
+                _EXPORTED_ATTRS_ = ("c",)
+                _EXPORTED_DESCENDANT_ATTRS_ = ("C",)
+
+            assert sorted(C._ALL_EXPORTED_ATTRS) == sorted(("c", "A", "B", "C"))
+
+        class TestConflict:
+            class A(Renderable):
+                _EXPORTED_ATTRS_ = ("a",)
+                _EXPORTED_DESCENDANT_ATTRS_ = ("A",)
+
+            def test_cls_vs_base(self):
+                class B(self.A):
+                    _EXPORTED_ATTRS_ = ("a",)
+                    _EXPORTED_DESCENDANT_ATTRS_ = ("A",)
+
+                assert sorted(B._ALL_EXPORTED_ATTRS) == sorted(("a", "A"))
+
+            def test_cls_vs_base_of_base(self):
+                class B(self.A):
+                    _EXPORTED_ATTRS_ = ("b",)
+                    _EXPORTED_DESCENDANT_ATTRS_ = ("B",)
+
+                class C(B):
+                    _EXPORTED_ATTRS_ = ("a",)
+                    _EXPORTED_DESCENDANT_ATTRS_ = ("A",)
+
+                assert sorted(C._ALL_EXPORTED_ATTRS) == sorted(("a", "A", "B"))
+
+            def test_base_vs_base(self):
+                class B(Renderable):
+                    _EXPORTED_ATTRS_ = ("a",)
+                    _EXPORTED_DESCENDANT_ATTRS_ = ("A",)
+
+                class C(self.A, B):
+                    _EXPORTED_ATTRS_ = ("c",)
+                    _EXPORTED_DESCENDANT_ATTRS_ = ("C",)
+
+                assert sorted(C._ALL_EXPORTED_ATTRS) == sorted(("c", "A", "C"))
+
+                class C(B, self.A):
+                    _EXPORTED_ATTRS_ = ("c",)
+                    _EXPORTED_DESCENDANT_ATTRS_ = ("C",)
+
+                assert sorted(C._ALL_EXPORTED_ATTRS) == sorted(("c", "A", "C"))
+
+            def test_specific_vs_descendant(self):
+                class B(self.A):
+                    _EXPORTED_ATTRS_ = ("A",)
+
+                assert sorted(B._ALL_EXPORTED_ATTRS) == sorted(("A",))
+
+
+# # Constructor ==================================================================
+
+
+class TestInit:
+    @pytest.mark.parametrize("n_frames", [0, -1, -100])
+    def test_invalid_frame_count(self, n_frames):
+        with pytest.raises(ValueError, match="'frame_count'"):
+            Space(n_frames, 1)
+
+    class TestFrameDuration:
+        @pytest.mark.parametrize("duration", [0, -1, -100])
+        def test_invalid(self, duration):
+            with pytest.raises(ValueError, match="'frame_duration'"):
+                Space(2, duration)
+
+        def test_ignored_for_non_animated(self):
+            Space(1, -1)
+
+
+# # Attributes ===================================================================
+
+
+class TestAnimated:
+    def test_non_animated(self):
+        assert Space(1, 1).animated is False
+
+    @pytest.mark.parametrize("n_frames", [2, *FrameCount])
+    def test_animated(self, n_frames):
+        assert Space(n_frames, 1).animated is True
+
+
+# # Properties ===================================================================
+
+
+class TestFrameCount:
+    @pytest.mark.parametrize("n_frames", [1, 2, FrameCount.INDEFINITE])
+    def test_non_postponed(self, n_frames):
+        assert Space(n_frames, 1).frame_count == n_frames
+
+    class TestPostponed:
+        class PostponedSpace(Space):
+            def __init__(self, frame_count):
+                super().__init__(FrameCount.POSTPONED, 1)
+                self.__frame_count = frame_count
+
+            def _get_frame_count_(self):
+                return self.__frame_count
+
+        def test_not_implemented(self):
+            space = Space(FrameCount.POSTPONED, 1)
+            with pytest.raises(NotImplementedError):
+                space.frame_count
+
+        @pytest.mark.parametrize("n_frames", [2, FrameCount.INDEFINITE])
+        def test_implemented(self, n_frames):
+            assert self.PostponedSpace(n_frames).frame_count == n_frames
+
+
+class TestFrameDuration:
+    @pytest.mark.parametrize("duration", [1, 100, FrameDuration.DYNAMIC])
+    def test_get(self, duration):
+        assert Space(1, duration).frame_duration is None
+        assert Space(2, duration).frame_duration == duration
+
+    class TestSet:
+        space = Space(1, 1)
+
+        @pytest.mark.parametrize("duration", [100, FrameDuration.DYNAMIC, 0, -100])
+        def test_non_animated(self, duration):
+            with pytest.raises(NonAnimatedFrameDurationError):
+                self.space.frame_duration = duration
+
+            assert self.space.frame_duration is None
+
+        class TestAnimated:
+            anim_space = Space(2, 1)
+
+            @pytest.mark.parametrize("duration", [2, 100, FrameDuration.DYNAMIC])
+            def test_valid(self, duration):
+                self.anim_space.frame_duration = duration
+                assert self.anim_space.frame_duration == duration
+
+            @pytest.mark.parametrize("duration", [0, -1, -100])
+            def test_invalid(self, duration):
+                with pytest.raises(ValueError, match="'frame_duration'"):
+                    self.anim_space.frame_duration = duration
+
+
+def test_render_size():
+    space = Space(1, 1)
+    assert space.render_size == Size(1, 1)
+
+    space.size = Size(100, 100)
+    assert space.render_size == Size(100, 100)
+
+
+# # Methods ======================================================================
+
+
+class TestIter:
+    def test_non_animated(self):
+        char = Char(1, 1)
+        with pytest.raises(ValueError, match="not animated"):
+            iter(char)
+
+    def test_animated(self):
+        anim_char = Char(2, 1)
+        render_iter = iter(anim_char)
+        assert isinstance(render_iter, RenderIterator)
+        assert render_iter.loop == 1  # loop count
+
+        frame = next(render_iter)
+        assert frame.renderable is anim_char  # renderable
+        assert frame.render_data.render_cls is Char  # render data
+        assert frame.render_args == RenderArgs(Char)  # default render args
+        assert frame.render_size == Size(1, 1)  # no padding
+        assert frame.render_output == " "  # no padding
+
+        render_iter.seek(0)
+        assert next(render_iter) is not frame  # no caching
+
+
+class TestStr:
+    class RenderChar(Char):
+        def _render_(self, render_data, render_args):
+            self.render_data = render_data
+            self.render_args = render_args
+            return super()._render_(render_data, render_args)
+
+    def test_default_render_args(self):
+        render_char = self.RenderChar(1, 1)
+        str(render_char)
+        assert render_char.render_args == RenderArgs(self.RenderChar)
+
+    @pytest.mark.parametrize("offset", [0, 5, 9])
+    def test_frame_number(self, offset):
+        anim_render_char = self.RenderChar(10, 1)
+        anim_render_char.seek(offset)
+        str(anim_render_char)
+        assert anim_render_char.render_data[Renderable].frame_offset == offset
+
+
+class TestDraw:
+    """See also: `TestInitRender` and `TestAnimate`."""
+
+    class TestNonAnimation:
+        class RenderChar(Char):
+            def _render_(self, render_data, render_args):
+                self.render_data = render_data
+                self.render_args = render_args
+                return super()._render_(render_data, render_args)
+
+        space = Space(1, 1)
+        anim_space = Space(2, 1)
+        char = Char(1, 1)
+
+        @capture_stdout()
+        def test_args_ignored_for_non_animated(self):
+            self.space.draw(loops=0)
+            self.space.draw(cache=-1)
+
+        @capture_stdout()
+        def test_args_ignored_for_animated_when_animate_is_false(self):
+            self.anim_space.draw(animate=False, loops=0)
+            self.anim_space.draw(animate=False, cache=-1)
+
+        @capture_stdout()
+        def test_default(self):
+            render_char = self.RenderChar(1, 1)
+            render_char.draw()
+            assert render_char.render_args == RenderArgs(self.RenderChar)
+            assert STDOUT.getvalue().count("\n") == LINES - 2
+            assert STDOUT.getvalue().endswith("\n")
+
+        class TestRenderArgs:
+            class RenderChar(Char):
+                def _render_(self, render_data, render_args):
+                    self.render_args = render_args
+                    return super()._render_(render_data, render_args)
+
+            def test_incompatible(self):
+                with pytest.raises(IncompatibleRenderArgsError):
+                    self.RenderChar(1, 1).draw(RenderArgs(Space))
+
+            @capture_stdout()
+            def test_compatible(self):
+                render_char = self.RenderChar(1, 1)
+                char_args = Char.Args("#")
+                render_char.draw(+char_args)
+                assert render_char.render_args == RenderArgs(self.RenderChar, char_args)
+                assert STDOUT.getvalue().count("\n") == LINES - 2
+                assert STDOUT.getvalue().endswith("\n")
+
+        @capture_stdout()
+        def test_padding(self):
+            self.space.draw(padding=AlignedPadding(3, 3))
+            assert STDOUT.getvalue().count("\n") == 3
+            assert STDOUT.getvalue().endswith("\n")
+
+        def test_animate(self):
+            with capture_stdout():
+                self.space.draw()
+                output = STDOUT.getvalue()
+                assert output.count("\n") == LINES - 2
+
+            with capture_stdout():
+                self.space.draw(animate=True)
+                assert output == STDOUT.getvalue()
+
+            with capture_stdout():
+                self.space.draw(animate=False)
+                assert output == STDOUT.getvalue()
+
+            with capture_stdout():
+                self.anim_space.draw(animate=False)
+                assert output == STDOUT.getvalue()
+
+        class TestCheckSize:
+            space = Space(1, 1)
+            space_large = Space(1, 1)
+            space_large.size = Size(COLUMNS + 1, 1)
+            padding = AlignedPadding(COLUMNS + 1, 1)
+
+            @capture_stdout()
+            def test_default(self):
+                with pytest.raises(
+                    RenderSizeOutofRangeError, match="Padded render width"
+                ):
+                    self.space_large.draw()
+                with pytest.raises(
+                    RenderSizeOutofRangeError, match="Padded render width"
+                ):
+                    self.space.draw(padding=self.padding)
+
+            @capture_stdout()
+            def test_true(self):
+                with pytest.raises(
+                    RenderSizeOutofRangeError, match="Padded render width"
+                ):
+                    self.space_large.draw(check_size=True)
+                with pytest.raises(
+                    RenderSizeOutofRangeError, match="Padded render width"
+                ):
+                    self.space.draw(padding=self.padding, check_size=True)
+
+            @capture_stdout()
+            def test_false(self):
+                self.space_large.draw(check_size=False)
+                self.space.draw(padding=self.padding, check_size=False)
+
+        class TestAllowScroll:
+            space = Space(1, 1)
+            space_large = Space(1, 1)
+            space_large.size = Size(1, LINES + 1)
+            padding = AlignedPadding(1, LINES + 1)
+
+            @capture_stdout()
+            def test_default(self):
+                with pytest.raises(
+                    RenderSizeOutofRangeError, match="Padded render height"
+                ):
+                    self.space_large.draw()
+                with pytest.raises(
+                    RenderSizeOutofRangeError, match="Padded render height"
+                ):
+                    self.space.draw(padding=self.padding)
+
+            @capture_stdout()
+            def test_false(self):
+                with pytest.raises(
+                    RenderSizeOutofRangeError, match="Padded render height"
+                ):
+                    self.space_large.draw(allow_scroll=False)
+                with pytest.raises(
+                    RenderSizeOutofRangeError, match="Padded render height"
+                ):
+                    self.space.draw(padding=self.padding, allow_scroll=False)
+
+            @capture_stdout()
+            def test_true(self):
+                self.space_large.draw(allow_scroll=True)
+                self.space.draw(padding=self.padding, allow_scroll=True)
+
+        @pytest.mark.skipif(not OS_IS_UNIX, reason="Not supported on non-Unix")
+        class TestEchoInput:
+            class EchoSpace(Space):
+                def __init__(self):
+                    super().__init__(1, 1)
+
+                def _render_(self, *args):
+                    attr = termios.tcgetattr(sys.stdout.fileno())
+                    self.echoed = bool(attr[3] & termios.ECHO)
+                    return super()._render_(*args)
+
+            @capture_stdout_pty()
+            def test_default(self):
+                echo_space = self.EchoSpace()
+                echo_space.draw()
+                assert echo_space.echoed is False
+
+            @pytest.mark.parametrize("echo_input", [False, True])
+            @capture_stdout_pty()
+            def test_non_default(self, echo_input):
+                echo_space = self.EchoSpace()
+                echo_space.draw(echo_input=echo_input)
+                assert echo_space.echoed is echo_input
+
+        @pytest.mark.parametrize("offset", [0, 5, 9])
+        def test_frame_number(self, offset):
+            anim_render_char = self.RenderChar(10, 1)
+            anim_render_char.seek(offset)
+            anim_render_char.draw(animate=False)
+            assert anim_render_char.render_data[Renderable].frame_offset == offset
+
+        class TestHandleInterruptedDraw:
+            class InterruptedChar(Char):
+                interrupted = False
+
+                def __init__(self):
+                    super().__init__(1, 1)
+
+                def _handle_interrupted_draw_(self, render_data, render_args, output):
+                    self.interrupted_args = SimpleNamespace(
+                        render_data=render_data,
+                        render_args=render_args,
+                        output=output,
+                    )
+                    self.interrupted = True
+
+            def interrupted_write(string):
+                raise KeyboardInterrupt
+
+            interrupted_io_1 = io.StringIO()
+            interrupted_io_1.write = interrupted_write
+            interrupted_io_2 = io.StringIO()
+            interrupted_io_2.write = interrupted_write
+
+            @capture_stdout()
+            def test_uninterrupted(self):
+                interrupted_char = self.InterruptedChar()
+                interrupted_char.draw(RenderArgs(self.InterruptedChar), ExactPadding())
+                assert interrupted_char.interrupted is False
+
+            @pytest.mark.parametrize(
+                "render_args,output",
+                [
+                    (RenderArgs(InterruptedChar), interrupted_io_1),
+                    (RenderArgs(InterruptedChar, Char.Args("#")), interrupted_io_2),
+                ],
+            )
+            def test_interrupted(self, render_args, output):
+                interrupted_char = self.InterruptedChar()
+                sys.stdout = output
+
+                try:
+                    interrupted_char.draw(render_args)
+                except KeyboardInterrupt:
+                    pass
+
+                assert interrupted_char.interrupted is True
+                assert (
+                    interrupted_char.interrupted_args.render_data.render_cls
+                    is self.InterruptedChar
+                )
+                assert interrupted_char.interrupted_args.render_args is render_args
+                assert interrupted_char.interrupted_args.output is output
+
+    class TestAnimation:
+        class AnimateChar(Char):
+            def _animate_(
+                self, render_data, render_args, padding, loops, cache, output
+            ):
+                self.anim_args = SimpleNamespace(
+                    render_data=render_data,
+                    render_args=render_args,
+                    padding=padding,
+                    loops=loops,
+                    cache=cache,
+                    output=output,
+                )
+
+        @capture_stdout()
+        def test_default(self):
+            animate_char = self.AnimateChar(2, 1)
+            animate_char.draw()
+            assert animate_char.anim_args.render_data.render_cls is self.AnimateChar
+            assert animate_char.anim_args.render_args == RenderArgs(self.AnimateChar)
+            assert animate_char.anim_args.padding == AlignedPadding(COLUMNS, LINES - 2)
+            assert animate_char.anim_args.loops == -1
+            assert animate_char.anim_args.cache == 100
+            assert animate_char.anim_args.output is sys.stdout
+            assert STDOUT.getvalue().count("\n") == 1
+            assert STDOUT.getvalue().endswith("\n")
+
+        @capture_stdout()
+        def test_non_default(self):
+            animate_char = self.AnimateChar(2, 1)
+            render_args = RenderArgs(self.AnimateChar, Char.Args("#"))
+            animate_char.draw(+Char.Args("#"), ExactPadding(), loops=2, cache=False)
+            assert animate_char.anim_args.render_data.render_cls is self.AnimateChar
+            assert animate_char.anim_args.render_args == render_args
+            assert animate_char.anim_args.padding == ExactPadding()
+            assert animate_char.anim_args.loops == 2
+            assert animate_char.anim_args.cache is False
+            assert animate_char.anim_args.output is sys.stdout
+            assert STDOUT.getvalue().count("\n") == 1
+            assert STDOUT.getvalue().endswith("\n")
+
+        def test_incompatible_render_args(self):
+            char = Char(2, 1)
+            with pytest.raises(IncompatibleRenderArgsError):
+                char.draw(RenderArgs(Space))
+
+        def test_animate(self):
+            anim_space = Space(2, 1)
+
+            with capture_stdout():
+                anim_space.draw(loops=1)
+                output = STDOUT.getvalue()
+                assert output.count("\n") == anim_n_eol(1, LINES - 2, 1, 1) + 1
+
+            with capture_stdout():
+                anim_space.draw(animate=True, loops=1)
+                assert output == STDOUT.getvalue()
+
+            with capture_stdout():
+                anim_space.draw(animate=False, loops=1)
+                assert output != STDOUT.getvalue()
+                assert (
+                    STDOUT.getvalue().count("\n") == anim_n_eol(1, LINES - 2, 1, 1) + 1
+                )
+
+        class TestCheckSize:
+            anim_space = Space(2, 1)
+            anim_space_large = Space(2, 1)
+            anim_space_large.size = Size(COLUMNS + 1, 1)
+            padding = AlignedPadding(COLUMNS + 1, 1)
+
+            @capture_stdout()
+            def test_default(self):
+                with pytest.raises(
+                    RenderSizeOutofRangeError, match="Padded render width"
+                ):
+                    self.anim_space_large.draw()
+                with pytest.raises(
+                    RenderSizeOutofRangeError, match="Padded render width"
+                ):
+                    self.anim_space.draw(padding=self.padding)
+
+            @capture_stdout()
+            def test_true(self):
+                with pytest.raises(
+                    RenderSizeOutofRangeError, match="Padded render width"
+                ):
+                    self.anim_space_large.draw(check_size=True)
+                with pytest.raises(
+                    RenderSizeOutofRangeError, match="Padded render width"
+                ):
+                    self.anim_space.draw(padding=self.padding, check_size=True)
+
+            @capture_stdout()
+            def test_false(self):
+                with pytest.raises(
+                    RenderSizeOutofRangeError, match="Padded render width"
+                ):
+                    self.anim_space_large.draw(check_size=False)
+                with pytest.raises(
+                    RenderSizeOutofRangeError, match="Padded render width"
+                ):
+                    self.anim_space.draw(padding=self.padding, check_size=False)
+
+        class TestAllowScroll:
+            anim_space = Space(2, 1)
+            anim_space_large = Space(2, 1)
+            anim_space_large.size = Size(1, LINES + 1)
+            padding = AlignedPadding(1, LINES + 1)
+
+            @capture_stdout()
+            def test_default(self):
+                with pytest.raises(
+                    RenderSizeOutofRangeError, match="Padded render height"
+                ):
+                    self.anim_space_large.draw()
+                with pytest.raises(
+                    RenderSizeOutofRangeError, match="Padded render height"
+                ):
+                    self.anim_space.draw(padding=self.padding)
+
+            @capture_stdout()
+            def test_false(self):
+                with pytest.raises(
+                    RenderSizeOutofRangeError, match="Padded render height"
+                ):
+                    self.anim_space_large.draw(allow_scroll=False)
+                with pytest.raises(
+                    RenderSizeOutofRangeError, match="Padded render height"
+                ):
+                    self.anim_space.draw(padding=self.padding, allow_scroll=False)
+
+            @capture_stdout()
+            def test_true(self):
+                with pytest.raises(
+                    RenderSizeOutofRangeError, match="Padded render height"
+                ):
+                    self.anim_space_large.draw(allow_scroll=True)
+                with pytest.raises(
+                    RenderSizeOutofRangeError, match="Padded render height"
+                ):
+                    self.anim_space.draw(padding=self.padding, allow_scroll=True)
+
+        @pytest.mark.skipif(not OS_IS_UNIX, reason="Not supported on non-Unix")
+        class TestEchoInput:
+            class AnimEchoSpace(Space):
+                def __init__(self):
+                    super().__init__(2, 1)
+
+                def _animate_(self, *args, **kwargs):
+                    attr = termios.tcgetattr(sys.stdout.fileno())
+                    self.echoed = bool(attr[3] & termios.ECHO)
+
+            @capture_stdout_pty()
+            def test_default(self):
+                anim_echo_space = self.AnimEchoSpace()
+                anim_echo_space.draw(loops=1)
+                assert anim_echo_space.echoed is False
+
+            @pytest.mark.parametrize("echo_input", [False, True])
+            @capture_stdout_pty()
+            def test_non_default(self, echo_input):
+                anim_echo_space = self.AnimEchoSpace()
+                anim_echo_space.draw(loops=1, echo_input=echo_input)
+                assert anim_echo_space.echoed is echo_input
+
+    @pytest.mark.skipif(not OS_IS_UNIX, reason="Cannot test on non-Unix")
+    @pytest.mark.parametrize("renderable", [Space(1, 1), Space(2, 1)])
+    class TestHideCursor:
+        class TestInTerminal:
+            def test_default(self, renderable):
+                with capture_stdout_pty() as (_, buffer):
+                    renderable.draw(loops=1)
+                    output = buffer.getvalue()
+                    assert output.startswith(HIDE_CURSOR)
+                    assert output.count(HIDE_CURSOR) == 1
+                    assert output.endswith(SHOW_CURSOR)
+                    assert output.count(SHOW_CURSOR) == 1
+
+            @pytest.mark.parametrize("hide_cursor", [False, True])
+            def test_non_default(self, renderable, hide_cursor):
+                with capture_stdout_pty() as (_, buffer):
+                    renderable.draw(loops=1, hide_cursor=hide_cursor)
+                    output = buffer.getvalue()
+                    assert (output.startswith(HIDE_CURSOR)) is hide_cursor
+                    assert output.count(HIDE_CURSOR) == hide_cursor
+                    assert (output.endswith(SHOW_CURSOR)) is hide_cursor
+                    assert output.count(SHOW_CURSOR) == hide_cursor
+
+        @pytest.mark.parametrize("hide_cursor", [False, True])
+        @capture_stdout()
+        def test_not_in_terminal(self, renderable, hide_cursor):
+            renderable.draw(loops=1, hide_cursor=hide_cursor)
+            output = STDOUT.getvalue()
+            assert HIDE_CURSOR not in output
+            assert SHOW_CURSOR not in output
+
+
+class TestRender:
+    """Just ensures the arguments is passed on and used appropriately.
+
+    See alse: `TestInitRender`.
+    """
+
+    char = Char(1, 1)
+
+    def test_default(self):
+        frame = self.char.render()
+        assert frame.render_data.render_cls is Char
+        assert frame.render_args == RenderArgs(Char)
+        assert frame == self.char.render(None)
+        assert frame == self.char.render(padding=ExactPadding())
+        assert frame == self.char.render(padding=AlignedPadding(1, 1))
+
+    class TestRenderArgs:
+        char = Char(1, 1)
+
+        def test_incompatible(self):
+            with pytest.raises(IncompatibleRenderArgsError):
+                self.char.render(RenderArgs(Space))
+
+        def test_compatible(self):
+            frame = self.char.render(+Char.Args("#"))
+            assert frame.render_args == +Char.Args("#")
+
+    def test_padding(self):
+        frame = self.char.render(padding=AlignedPadding(3, 3))
+        assert frame.render_size == Size(3, 3)
+        assert frame.render_output == "   \n   \n   "
+
+    @pytest.mark.parametrize("offset", [0, 5, 9])
+    def test_frame_number(self, offset):
+        anim_char = Char(10, 1)
+        anim_char.seek(offset)
+        assert anim_char.render().data.frame_offset == offset
+
+
+class TestSeekTell:
+    indefinite_space = Space(FrameCount.INDEFINITE, 1)
+
+    class TestDefinite:
+        anim_space = Space(10, 1)
+
+        @pytest.mark.parametrize(
+            "whence,offset,frame_no",
+            [
+                (Seek.START, 0, 0),
+                (Seek.START, 1, 1),
+                (Seek.START, 9, 9),
+                (Seek.CURRENT, -5, 0),
+                (Seek.CURRENT, -1, 4),
+                (Seek.CURRENT, 0, 5),
+                (Seek.CURRENT, 1, 6),
+                (Seek.CURRENT, 4, 9),
+                (Seek.END, -9, 0),
+                (Seek.END, -1, 8),
+                (Seek.END, 0, 9),
+            ],
+        )
+        def test_in_range(self, whence, offset, frame_no):
+            self.anim_space.seek(5)  # For CURRENT-relative
+            assert self.anim_space.tell() == 5
+
+            assert self.anim_space.seek(offset, whence) == frame_no
+            assert self.anim_space.tell() == frame_no
+
+        @pytest.mark.parametrize(
+            "whence,offset",
+            [
+                (Seek.START, -1),
+                (Seek.START, 10),
+                (Seek.CURRENT, -6),
+                (Seek.CURRENT, 5),
+                (Seek.END, -10),
+                (Seek.END, 1),
+            ],
+        )
+        def test_out_of_range(self, whence, offset):
+            self.anim_space.seek(5)  # For CURRENT-relative
+            assert self.anim_space.tell() == 5
+
+            with pytest.raises(ValueError, match="'offset'"):
+                self.anim_space.seek(offset, whence)
+
+            assert self.anim_space.tell() == 5
+
+    @pytest.mark.parametrize("offset", [-1, 0, 1])
+    @pytest.mark.parametrize("whence", Seek)
+    def test_indefinite(self, offset, whence):
+        assert self.indefinite_space.tell() == 0
+
+        with pytest.raises(IndefiniteSeekError):
+            self.indefinite_space.seek(offset, whence)
+
+        assert self.indefinite_space.tell() == 0
+
+
+class TestAnimate:
+    class AnimateChar(Char):
+        def _render_(self, render_data, render_args):
+            assert render_data is self.render_data
+            assert render_args is self.render_args
+            return super()._render_(render_data, render_args)
+
+    animate_char = AnimateChar(2, 1)
+    render_data = animate_char._get_render_data_(iteration=True)
+    render_data[Renderable].duration = 0
+    render_args = RenderArgs(AnimateChar)
+    padding = ExactPadding()
+
+    anim_space = Space(2, 1)
+    with capture_stdout():
+        anim_space._animate_(
+            anim_space._get_render_data_(iteration=True),
+            RenderArgs(Space),
+            padding,
+            1,
+            False,
+            STDOUT,
+        )
+        expected_output = STDOUT.getvalue()
+
+    @pytest.mark.parametrize(
+        "render_data",
+        [
+            animate_char._get_render_data_(iteration=True),
+            animate_char._get_render_data_(iteration=True),
+        ],
+    )
+    @capture_stdout()
+    def test_render_data(self, render_data):
+        self.animate_char.render_data = render_data
+        self.animate_char.render_args = self.render_args
+        self.animate_char._animate_(
+            render_data,
+            self.render_args,
+            self.padding,
+            1,
+            False,
+            STDOUT,
+        )
+        assert not STDOUT.getvalue().endswith("\n")
+
+    @pytest.mark.parametrize(
+        "render_args",
+        [RenderArgs(AnimateChar), RenderArgs(AnimateChar, Char.Args("#"))],
+    )
+    @capture_stdout()
+    def test_render_args(self, render_args):
+        self.animate_char.render_data = self.render_data
+        self.animate_char.render_args = render_args
+        self.animate_char._animate_(
+            self.render_data,
+            render_args,
+            self.padding,
+            1,
+            False,
+            STDOUT,
+        )
+        assert not STDOUT.getvalue().endswith("\n")
+
+    @pytest.mark.parametrize(
+        "padding,padded_height", [(ExactPadding(), 1), (AlignedPadding(3, 3), 3)]
+    )
+    @capture_stdout()
+    def test_padding(self, padding, padded_height):
+        self.anim_space._animate_(
+            self.anim_space._get_render_data_(iteration=True),
+            RenderArgs(Space),
+            padding,
+            1,
+            False,
+            STDOUT,
+        )
+        assert STDOUT.getvalue().count("\n") == anim_n_eol(1, padded_height, 2, 1)
+        assert not STDOUT.getvalue().endswith("\n")
+
+    @pytest.mark.parametrize("loops", [1, 2, 10])
+    class TestLoops:
+        anim_space = Space(2, 1)
+        render_data = anim_space._get_render_data_(iteration=True)
+        render_data[Renderable].duration = 0
+        definite_args = (render_data, RenderArgs(Space), AlignedPadding(3, 3))
+        indefinite_args = (RenderArgs(IndefiniteSpace), AlignedPadding(3, 3))
+
+        @capture_stdout()
+        def test_definite(self, loops):
+            self.anim_space._animate_(*self.definite_args, loops, False, STDOUT)
+            assert STDOUT.getvalue().count("\n") == anim_n_eol(1, 3, 2, loops)
+            assert not STDOUT.getvalue().endswith("\n")
+
+        @capture_stdout()
+        def test_indefinite(self, loops):
+            indefinite_space = IndefiniteSpace(2)
+            render_data = indefinite_space._get_render_data_(iteration=True)
+            render_data[Renderable].duration = 0
+            indefinite_space._animate_(
+                render_data, *self.indefinite_args, loops, False, STDOUT
+            )
+            assert STDOUT.getvalue().count("\n") == anim_n_eol(1, 3, 2, 1)
+            assert not STDOUT.getvalue().endswith("\n")
+
+    @pytest.mark.parametrize(
+        "cache, n_renders", [(False, 8), (True, 4), (3, 8), (4, 4), (5, 4)]
+    )
+    def test_cache(self, cache, n_renders):
+        cache_space = CacheSpace(4, 1)
+        render_data = cache_space._get_render_data_(iteration=True)
+        render_data[Renderable].duration = 0
+        cache_space._animate_(
+            render_data, RenderArgs(Space), AlignedPadding(3, 3), 2, cache, STDOUT
+        )
+        assert cache_space.n_renders == n_renders
+
+    # The *newline* argument to `TemporaryFile` prevents any "\r" written to the file
+    # from being read back as "\n".
+    @pytest.mark.parametrize("output", [io.StringIO(), TemporaryFile("w+", newline="")])
+    def test_output(self, output):
+        self.anim_space._animate_(
+            self.anim_space._get_render_data_(iteration=True),
+            RenderArgs(Space),
+            self.padding,
+            1,
+            False,
+            output,
+        )
+        output.seek(0)
+        assert output.read() == self.expected_output
+
+    @pytest.mark.parametrize("n_frames", [2, 3, 10])
+    class TestFrameCount:
+        definite_args = (RenderArgs(Space), AlignedPadding(3, 3))
+        indefinite_args = (RenderArgs(IndefiniteSpace), AlignedPadding(3, 3))
+
+        @capture_stdout()
+        def test_definite(self, n_frames):
+            anim_space = Space(n_frames, 1)
+            render_data = anim_space._get_render_data_(iteration=True)
+            render_data[Renderable].duration = 0
+            anim_space._animate_(render_data, *self.definite_args, 1, False, STDOUT)
+            assert STDOUT.getvalue().count("\n") == anim_n_eol(1, 3, n_frames, 1)
+            assert not STDOUT.getvalue().endswith("\n")
+
+        @capture_stdout()
+        def test_indefinite(self, n_frames):
+            indefinite_space = IndefiniteSpace(n_frames)
+            render_data = indefinite_space._get_render_data_(iteration=True)
+            render_data[Renderable].duration = 0
+            indefinite_space._animate_(
+                render_data, *self.indefinite_args, 1, False, STDOUT
+            )
+            assert STDOUT.getvalue().count("\n") == anim_n_eol(1, 3, n_frames, 1)
+            assert not STDOUT.getvalue().endswith("\n")
+
+    class TestClearFrame:
+        class ClearFrameChar(Char):
+            n_clears = 0
+
+            def _clear_frame_(self, render_data, render_args, cursor_x, output):
+                assert render_data is self.clear_frame_args.render_data
+                assert render_args is self.clear_frame_args.render_args
+                assert cursor_x == self.clear_frame_args.cursor_x
+                assert output is self.clear_frame_args.output
+
+                # The next frame to be drawn should already be rendered.
+                # Hence, the next frame to be rendered by the iterator (the value
+                # of `frame_offset`) will be the frame after that, which is also
+                # **two frames after the frame to be cleared**. Hence, the `-2`.
+                cleared_frame_offset = render_data[Renderable].frame_offset - 2
+                if cleared_frame_offset < 0:  # frame from previous loop
+                    cleared_frame_offset += self.frame_count
+
+                # Each time `_clear_frame_()` is called, it **should've** been called
+                # exactly `n_clears = x + <multiple-of-frame-count>` times earlier
+                # where `x` is the offset of the frame to be cleared (see above).
+                # Note that `n_clears` is incremented after this. Hence, the value
+                # at this point is the number of previous calls.
+                assert cleared_frame_offset == self.n_clears % self.frame_count
+
+                self.n_clears += 1
+
+        @pytest.mark.parametrize(
+            "n_frames,render_args,cursor_x,loops,output",
+            [
+                (2, RenderArgs(ClearFrameChar), 1, 1, STDOUT),
+                (10, RenderArgs(ClearFrameChar, Char.Args("#")), 3, 2, io.StringIO()),
+            ],
+        )
+        @capture_stdout()
+        def test(self, n_frames, render_args, cursor_x, loops, output):
+            clear_frame_char = self.ClearFrameChar(n_frames, 1)
+            render_data = clear_frame_char._get_render_data_(iteration=True)
+            render_data[Renderable].duration = 0
+            clear_frame_char.clear_frame_args = SimpleNamespace(
+                render_data=render_data,
+                render_args=render_args,
+                cursor_x=cursor_x,
+                output=output,
+            )
+
+            clear_frame_char._animate_(
+                render_data,
+                render_args,
+                ExactPadding(cursor_x - 1),
+                loops,
+                True,
+                output,
+            )
+            # The overall last frame is not cleared.
+            assert clear_frame_char.n_clears == n_frames * loops - 1
+
+    class TestHandleInterruptedDraw:
+        class InterruptedFrameFill(FrameFill):
+            interrupted = False
+
+            def __init__(self):
+                # The width is crucial for the markers for the write method wrappers
+                super().__init__(Size(10, 1))
+
+            def _handle_interrupted_draw_(self, render_data, render_args, output):
+                self.interrupted_args = SimpleNamespace(
+                    render_data=render_data,
+                    render_args=render_args,
+                    output=output,
+                )
+                self.interrupted = True
+
+            class Args(RenderArgs.Namespace):
+                foo: bool = False
+
+        def wrap_write(write_method, marker=None):
+            def write_wrapper(string):
+                if not marker or marker in string:
+                    raise KeyboardInterrupt
+                return write_method(string)
+
+            return write_wrapper
+
+        frame_0_io = io.StringIO()
+        frame_0_io.write = wrap_write(frame_0_io.write, "0000000000")
+        frame_9_io = io.StringIO()
+        frame_9_io.write = wrap_write(frame_9_io.write, "9999999999")
+
+        @capture_stdout()
+        def test_uninterrupted(self):
+            interrupted_frame_fill = self.InterruptedFrameFill()
+            render_data = interrupted_frame_fill._get_render_data_(iteration=True)
+            render_data[Renderable].duration = 0
+
+            interrupted_frame_fill._animate_(
+                render_data,
+                RenderArgs(self.InterruptedFrameFill),
+                ExactPadding(),
+                1,
+                True,
+                STDOUT,
+            )
+            assert interrupted_frame_fill.interrupted is False
+
+        @pytest.mark.parametrize(
+            "render_args,output,frame_interrupted",
+            [
+                (RenderArgs(InterruptedFrameFill), frame_0_io, 0),
+                (+InterruptedFrameFill.Args(True), frame_9_io, 9),
+            ],
+        )
+        @capture_stdout()
+        def test_interrupted(self, render_args, output, frame_interrupted):
+            interrupted_frame_fill = self.InterruptedFrameFill()
+            render_data = interrupted_frame_fill._get_render_data_(iteration=True)
+            render_data[Renderable].duration = 0
+
+            interrupted_frame_fill._animate_(
+                render_data,
+                render_args,
+                ExactPadding(),
+                1,
+                True,
+                output,
+            )
+            assert interrupted_frame_fill.interrupted is True
+            assert interrupted_frame_fill.interrupted_args.render_data is render_data
+            assert interrupted_frame_fill.interrupted_args.render_args is render_args
+            assert interrupted_frame_fill.interrupted_args.output is output
+            assert render_data[Renderable].frame_offset == frame_interrupted + 1
+
+
+class TestGetRenderData:
+    anim_space = Space(10, 1)
+
+    def test_render_data(self):
+        render_data = self.anim_space._get_render_data_(iteration=False)
+        assert render_data.render_cls is Space
+        # Serves as a reminder to add tests for new fields
+        assert len(render_data[Renderable].as_dict()) == 5
+
+    @pytest.mark.parametrize("render_size", [Size(2, 2), Size(10, 10)])
+    def test_size(self, render_size):
+        self.anim_space.size = render_size
+        render_data = self.anim_space._get_render_data_(iteration=False)
+        size = render_data[Renderable].size
+        assert size == render_size
+
+    class TestFrameOffsetAndSeekWhence:
+        @pytest.mark.parametrize("offset", [0, 5, 9])
+        def test_definite(self, offset):
+            anim_space = Space(10, 1)
+            anim_space.seek(offset)
+            render_data = anim_space._get_render_data_(iteration=False)
+            assert render_data[Renderable].frame_offset == offset
+            assert render_data[Renderable].seek_whence == Seek.START
+
+        def test_indefinite(self):
+            render_data = IndefiniteSpace(2)._get_render_data_(iteration=False)
+            assert render_data[Renderable].frame_offset == 0
+            assert render_data[Renderable].seek_whence == Seek.START
+
+    @pytest.mark.parametrize("duration", [1, 100, FrameDuration.DYNAMIC])
+    def test_duration(self, duration):
+        self.anim_space.frame_duration = duration
+        render_data = self.anim_space._get_render_data_(iteration=False)
+        duration = render_data[Renderable].duration
+        assert duration == duration
+
+    @pytest.mark.parametrize("iteration", [False, True])
+    def test_iteration(self, iteration):
+        render_data = self.anim_space._get_render_data_(iteration=iteration)
+        assert render_data[Renderable].iteration is iteration
+
+
+class TestInitRender:
+    class TestRenderer:
+        space = Space(1, 1)
+        char = Char(1, 1)
+
+        class Foo(Renderable):
+            _render_ = None
+
+            def _get_render_size_(self):
+                return Size(1, 1)
+
+            def __init__(self, data):
+                super().__init__(1, 1)
+                self.__data = data
+
+            def _get_render_data_(self, *, iteration):
+                render_data = super()._get_render_data_(iteration=iteration)
+                render_data[__class__].foo = self.__data
+                return render_data
+
+            class _Data_(RenderData.Namespace):
+                foo: Any
+
+        @pytest.mark.parametrize("renderable", [space, char])
+        def test_arguments(self, renderable):
+            renderer_args = renderable._init_render_(lambda *args: args)[0]
+            assert isinstance(renderer_args, tuple)
+            assert len(renderer_args) == 2
+
+            render_data, render_args = renderer_args
+            assert isinstance(render_data, RenderData)
+            assert render_data.render_cls is type(renderable)
+            assert isinstance(render_args, RenderArgs)
+            assert render_args.render_cls is type(renderable)
+
+        # See also: `TestInitRender.TestIteration`
+        @pytest.mark.parametrize("data", [None, Ellipsis, " ", []])
+        def test_render_data(self, data):
+            render_data = self.Foo(data)._init_render_(lambda *args: args)[0][0]
+
+            assert isinstance(render_data, RenderData)
+            assert render_data.render_cls is self.Foo
+            assert render_data[self.Foo].foo is data
+
+        # See `TestInitRender.TestRenderArgs`
+
+        @pytest.mark.parametrize("value", [None, Ellipsis, " ", []])
+        def test_return_value(self, value):
+            assert self.space._init_render_(lambda *_: value)[0] is value
+
+    class TestRenderArgs:
+        char = Char(1, 1)
+
+        def test_default(self):
+            assert self.char._init_render_(lambda *args: args)[0][1] == RenderArgs(Char)
+
+        @pytest.mark.parametrize(
+            "in_args, out_args",
+            [
+                (None, RenderArgs(Char)),
+                (RenderArgs(Renderable), RenderArgs(Char)),
+                (RenderArgs(Char), RenderArgs(Char)),
+                (+Char.Args("#"), +Char.Args("#")),
+            ],
+        )
+        def test_compatible(self, in_args, out_args):
+            render_args = self.char._init_render_(lambda *args: args, in_args)[0][1]
+            assert render_args == out_args
+
+        def test_incompatible(self):
+            with pytest.raises(IncompatibleRenderArgsError):
+                self.char._init_render_(lambda *_: None, RenderArgs(Space))
+
+    class TestPadding:
+        space = Space(1, 1)
+
+        def test_default(self):
+            assert self.space._init_render_(lambda *_: None)[1] is None
+
+        def test_none(self):
+            assert self.space._init_render_(lambda *_: None, padding=None)[1] is None
+
+        @pytest.mark.parametrize(
+            "orig_padding", [ExactPadding(), ExactPadding(1, 2, 3, 4, "#")]
+        )
+        def test_exact(self, orig_padding):
+            padding = self.space._init_render_(lambda *_: None, padding=orig_padding)[1]
+            assert padding is orig_padding
+
+        @pytest.mark.parametrize(
+            "orig_padding",
+            [
+                AlignedPadding(1, 2, HAlign.LEFT, VAlign.TOP),
+                AlignedPadding(3, 3, fill="#"),
+                AlignedPadding(20, 10, HAlign.RIGHT, VAlign.BOTTOM),
+            ],
+        )
+        def test_aligned_absolute(self, orig_padding):
+            padding = self.space._init_render_(lambda *_: None, padding=orig_padding)[1]
+            assert padding is orig_padding
+
+        @pytest.mark.parametrize(
+            "orig_padding",
+            [
+                AlignedPadding(0, -1, HAlign.LEFT, VAlign.TOP),
+                AlignedPadding(1, 0, fill="#"),
+                AlignedPadding(-1, 10, HAlign.RIGHT, VAlign.BOTTOM),
+            ],
+        )
+        def test_aligned_relative(self, orig_padding):
+            padding = self.space._init_render_(lambda *_: None, padding=orig_padding)[1]
+            assert padding == orig_padding.resolve(get_terminal_size())
+
+    class TestIteration:
+        space = Space(1, 1)
+
+        def test_default(self):
+            render_data = self.space._init_render_(lambda *args: args)[0][0]
+            assert render_data[Renderable].iteration is False
+
+        @pytest.mark.parametrize("iteration", [False, True])
+        def test_non_default(self, iteration):
+            render_data = self.space._init_render_(  # fmt: skip
+                lambda *args: args, iteration=iteration
+            )[0][0]
+            assert render_data[Renderable].iteration is iteration
+
+    class TestFinalize:
+        space = Space(1, 1)
+
+        def test_default(self):
+            render_data = self.space._init_render_(lambda *args: args)[0][0]
+            assert render_data.finalized
+
+        @pytest.mark.parametrize("finalize", [False, True])
+        def test_non_default(self, finalize):
+            render_data = self.space._init_render_(  # fmt: skip
+                lambda *args: args, finalize=finalize
+            )[0][0]
+            assert render_data.finalized is finalize
+
+    class TestCheckSize:
+        class TestRenderWidth:
+            anim_space = Space(2, 1)
+
+            def test_in_range(self):
+                self.anim_space.size = Size(COLUMNS, 1)
+                self.anim_space._init_render_(lambda *_: None, check_size=True)
+
+            def test_out_of_range(self):
+                self.anim_space.size = Size(COLUMNS + 1, 1)
+
+                # Default
+                self.anim_space._init_render_(lambda *_: None)
+
+                # False
+                self.anim_space._init_render_(lambda *_: None, check_size=False)
+
+                # True
+                with pytest.raises(RenderSizeOutofRangeError, match="Render width"):
+                    self.anim_space._init_render_(lambda *_: None, check_size=True)
+
+        class TestPaddedWidth:
+            anim_space = Space(2, 1)
+
+            def test_in_range(self):
+                self.anim_space._init_render_(
+                    lambda *_: None,
+                    padding=AlignedPadding(COLUMNS, 1),
+                    check_size=True,
+                )
+
+            def test_out_of_range(self):
+                padding = AlignedPadding(COLUMNS + 1, 1)
+
+                # Default
+                self.anim_space._init_render_(lambda *_: None, padding=padding)
+
+                # False
+                self.anim_space._init_render_(
+                    lambda *_: None, padding=padding, check_size=False
+                )
+
+                # True
+                with pytest.raises(
+                    RenderSizeOutofRangeError, match="Padded render width"
+                ):
+                    self.anim_space._init_render_(
+                        lambda *_: None,
+                        padding=padding,
+                        check_size=True,
+                    )
+
+    class TestAllowScroll:
+        class TestRenderHeight:
+            anim_space = Space(2, 1)
+
+            def test_in_range(self):
+                self.anim_space.size = Size(1, LINES)
+                self.anim_space._init_render_(
+                    lambda *_: None, check_size=True, allow_scroll=False
+                )
+
+            def test_out_of_range(self):
+                self.anim_space.size = Size(1, LINES + 1)
+
+                # Default
+                with pytest.raises(RenderSizeOutofRangeError, match="Render height"):
+                    self.anim_space._init_render_(lambda *_: None, check_size=True)
+
+                # False
+                with pytest.raises(RenderSizeOutofRangeError, match="Render height"):
+                    self.anim_space._init_render_(
+                        lambda *_: None, check_size=True, allow_scroll=False
+                    )
+
+                # True
+                self.anim_space._init_render_(
+                    lambda *_: None, check_size=True, allow_scroll=True
+                )
+
+                # ignored when check_size is False
+                self.anim_space._init_render_(
+                    lambda *_: None, check_size=False, allow_scroll=False
+                )
+
+        class TestPaddedHeight:
+            anim_space = Space(2, 1)
+
+            def test_in_range(self):
+                self.anim_space._init_render_(
+                    lambda *_: None,
+                    padding=AlignedPadding(1, LINES),
+                    check_size=True,
+                    allow_scroll=False,
+                )
+
+            def test_out_of_range(self):
+                padding = AlignedPadding(1, LINES + 1)
+
+                # Default
+                with pytest.raises(
+                    RenderSizeOutofRangeError, match="Padded render height"
+                ):
+                    self.anim_space._init_render_(
+                        lambda *_: None, padding=padding, check_size=True
+                    )
+
+                # False
+                with pytest.raises(
+                    RenderSizeOutofRangeError, match="Padded render height"
+                ):
+                    self.anim_space._init_render_(
+                        lambda *_: None,
+                        padding=padding,
+                        check_size=True,
+                        allow_scroll=False,
+                    )
+
+                # True
+                self.anim_space._init_render_(
+                    lambda *_: None,
+                    padding=padding,
+                    check_size=True,
+                    allow_scroll=True,
+                )
+
+                # ignored when check_size is False
+                self.anim_space._init_render_(
+                    lambda *_: None,
+                    padding=padding,
+                    check_size=False,
+                    allow_scroll=False,
+                )
diff --git a/tests/renderable/test_types.py b/tests/renderable/test_types.py
new file mode 100644
index 00000000..904ed36f
--- /dev/null
+++ b/tests/renderable/test_types.py
@@ -0,0 +1,1368 @@
+from typing import Any
+
+import pytest
+
+from term_image.geometry import Size
+from term_image.renderable import (
+    Frame,
+    IncompatibleArgsNamespaceError,
+    IncompatibleRenderArgsError,
+    NoArgsNamespaceError,
+    NoDataNamespaceError,
+    Renderable,
+    RenderArgs,
+    RenderArgsDataError,
+    RenderArgsError,
+    RenderData,
+    UninitializedDataFieldError,
+    UnknownArgsFieldError,
+    UnknownDataFieldError,
+)
+from term_image.renderable._types import RenderArgsData
+
+# Renderables ==================================================================
+
+
+class Foo(Renderable):
+    @classmethod
+    def _finalize_render_data_(cls, render_data):
+        render_data[__class__].bar = render_data[__class__].foo
+        super()._finalize_render_data_(render_data)
+
+    class Args(RenderArgs.Namespace):
+        foo: Any = "FOO"
+        bar: Any = "BAR"
+
+    class _Data_(RenderData.Namespace):
+        foo: Any
+        bar: Any
+
+
+# Utils ========================================================================
+
+
+class NamespaceBase(RenderArgsData.Namespace, _base=True):
+    def __init__(self, fields=None):
+        super().__init__(fields or {})
+
+
+# Tests ========================================================================
+
+
+class TestFrame:
+    args = (0, 1, Size(1, 1), " ")
+
+    class TestFields:
+        args = (0, 1, Size(1, 1), " ")
+
+        @pytest.mark.parametrize("number", [0, 10])
+        def test_number(self, number):
+            frame = Frame(number, *self.args[1:])
+            assert frame.number is number
+
+        @pytest.mark.parametrize("duration", [1, 100])
+        def test_duration(self, duration):
+            frame = Frame(*self.args[:1], duration, *self.args[2:])
+            assert frame.duration is duration
+
+        @pytest.mark.parametrize("size", [Size(1, 3), Size(100, 30)])
+        def test_size(self, size):
+            frame = Frame(*self.args[:2], size, *self.args[3:])
+            assert frame.render_size is size
+
+        @pytest.mark.parametrize("render", [" ", " " * 10])
+        def test_render(self, render):
+            frame = Frame(*self.args[:3], render)
+            assert frame.render_output is render
+
+    @pytest.mark.parametrize("render", [" ", " " * 10])
+    def test_str(self, render):
+        frame = Frame(*self.args[:3], render)
+        assert str(frame) is render
+
+    @pytest.mark.parametrize("field", Frame._fields)
+    def test_immutability(self, field):
+        frame = Frame(*self.args)
+        with pytest.raises(AttributeError):
+            setattr(frame, field, Ellipsis)
+
+        with pytest.raises(AttributeError):
+            delattr(frame, field)
+
+    @pytest.mark.parametrize("args", [args, (9, 100, Size(10, 10), " " * 10)])
+    def test_equality(self, args):
+        assert Frame(*args) == Frame(*args)
+
+    @pytest.mark.parametrize(
+        "args1, args2",
+        [
+            (args, (9, 100, Size(10, 10), " " * 10)),
+            (args, (99, 1000, Size(100, 100), " " * 100)),
+        ],
+    )
+    def test_unequality(self, args1, args2):
+        assert Frame(*args1) != Frame(*args2)
+
+
+class TestNamespaceMeta:
+    def test_multiple_namespace_bases(self):
+        class A(NamespaceBase):
+            a: None
+
+        class B(NamespaceBase):
+            b: None
+
+        with pytest.raises(RenderArgsDataError, match="Multiple .* baseclasses"):
+
+            class C(A, B):
+                pass
+
+    class TestFields:
+        def test_no_field(self):
+            with pytest.raises(RenderArgsDataError, match="No field"):
+
+                class Namespace(NamespaceBase):
+                    pass
+
+        def test_define(self):
+            class Namespace(NamespaceBase):
+                foo: None
+                bar: None
+
+            assert Namespace.__dict__["__slots__"] == ("foo", "bar")
+            assert Namespace._FIELDS == {"foo": None, "bar": None}
+
+        def test_inherit(self):
+            class A(NamespaceBase):
+                a: None
+                b: None
+
+            class B(A):
+                pass
+
+            assert B.__dict__["__slots__"] == ()
+            assert B._FIELDS == {"a": None, "b": None}
+
+        def test_inherit_and_define(self):
+            class A(NamespaceBase):
+                a: None
+                b: None
+
+            with pytest.raises(RenderArgsDataError, match="inherit and define"):
+
+                class B(A):
+                    c: None
+                    d: None
+
+        def test_inherit_false(self):
+            class A(NamespaceBase):
+                a: None
+                b: None
+
+            class B(A, inherit=False):
+                c: None
+                d: None
+
+            assert B.__dict__["__slots__"] == ("c", "d")
+            assert B._FIELDS == {"c": None, "d": None}
+
+            with pytest.raises(RenderArgsDataError, match="No field"):
+
+                class B(A, inherit=False):
+                    pass
+
+    class TestRenderCls:
+        def test_association(self):
+            class Namespace(NamespaceBase):
+                foo: None
+
+            assert Namespace.get_render_cls() is None
+
+            Namespace._RENDER_CLS = Renderable
+            assert Namespace.get_render_cls() is Renderable
+
+        class TestInheritTrue:
+            def test_subclass_before_association(self):
+                class A(NamespaceBase):
+                    a: None
+
+                class B(A):
+                    pass
+
+                assert B.get_render_cls() is None
+
+                A._RENDER_CLS = Renderable
+                assert B.get_render_cls() is Renderable
+
+            def test_subclass_after_association(self):
+                class A(NamespaceBase):
+                    a: None
+
+                A._RENDER_CLS = Renderable
+
+                class B(A):
+                    pass
+
+                assert B.get_render_cls() is Renderable
+
+        class TestInheritFalse:
+            def test_subclass_before_association(self):
+                class A(NamespaceBase):
+                    a: None
+
+                class B(A, inherit=False):
+                    b: None
+
+                assert B.get_render_cls() is None
+
+                A._RENDER_CLS = Renderable
+                assert B.get_render_cls() is None
+
+            def test_subclass_after_association(self):
+                class A(NamespaceBase):
+                    a: None
+
+                A._RENDER_CLS = Renderable
+
+                class B(A, inherit=False):
+                    b: None
+
+                assert B.get_render_cls() is None
+
+    class TestRequiredConstructorParameters:
+        def test_new(self):
+            with pytest.raises(TypeError, match="__new__.* required parameter"):
+
+                class Namespace(NamespaceBase):  # noqa: F811
+                    foo: None
+
+                    def __new__(self, foo):
+                        pass
+
+        def test_init(self):
+            with pytest.raises(TypeError, match="__init__.* required parameter"):
+
+                class Namespace(NamespaceBase):
+                    foo: None
+
+                    def __init__(self, foo):
+                        pass
+
+
+class TestNamespace:
+    class Namespace(NamespaceBase):
+        _RENDER_CLS = Renderable
+        foo: int
+        bar: int
+
+    def test_cannot_instantiate_unassociated(self):
+        class Namespace(NamespaceBase):
+            foo: None
+
+        with pytest.raises(TypeError, match="Cannot instantiate"):
+            Namespace()
+
+    def test_delattr(self):
+        namespace = self.Namespace(dict(foo=1, bar=2))
+        with pytest.raises(AttributeError, match="Cannot delete"):
+            del namespace.foo
+        with pytest.raises(AttributeError, match="Cannot delete"):
+            del namespace.bar
+
+    def test_get_render_cls(self):
+        class Namespace(NamespaceBase):
+            foo: None
+
+        assert Namespace.get_render_cls() is None
+
+        Namespace._RENDER_CLS = Renderable
+        assert Namespace.get_render_cls() is Renderable
+
+        namespace = Namespace(dict(foo=None))
+        assert namespace.get_render_cls() is Renderable
+
+
+class TestRenderArgs:
+    def test_args(self):
+        with pytest.raises(TypeError, match="'render_cls'"):
+            RenderArgs(Ellipsis)
+
+        with pytest.raises(TypeError, match="second argument"):
+            RenderArgs(Renderable, Ellipsis)
+
+        with pytest.raises(TypeError, match=r"'namespaces\[0\]'"):
+            RenderArgs(Renderable, RenderArgs(Renderable), Ellipsis)
+
+        with pytest.raises(TypeError, match=r"'namespaces\[1\]'"):
+            RenderArgs(Foo, Foo.Args(), Ellipsis)
+
+    def test_base(self):
+        render_args = RenderArgs(Renderable)
+        assert render_args.render_cls is Renderable
+        assert set(render_args) == set()
+
+        with pytest.raises(NoArgsNamespaceError):
+            render_args[Renderable]
+
+    def test_render_cls(self):
+        render_args = RenderArgs(Foo)
+        assert render_args.render_cls is Foo
+
+    class TestNamespaces:
+        class TestCompatibility:
+            class A(Renderable):
+                class Args(RenderArgs.Namespace):
+                    foo: None = None
+
+            class B(A):
+                class Args(RenderArgs.Namespace):
+                    foo: None = None
+
+            class C(A):
+                class Args(RenderArgs.Namespace):
+                    foo: None = None
+
+            @pytest.mark.parametrize(
+                "cls1, cls2", [(A, A), (B, A), (B, B), (C, A), (C, C)]
+            )
+            def test_compatible(self, cls1, cls2):
+                assert RenderArgs(cls1, cls2.Args()) == RenderArgs(cls1)
+
+            @pytest.mark.parametrize("cls1, cls2", [(A, B), (A, C), (B, C), (C, B)])
+            def test_incompatible(self, cls1, cls2):
+                with pytest.raises(IncompatibleArgsNamespaceError):
+                    RenderArgs(cls1, cls2.Args())
+
+        def test_default(self):
+            render_args = RenderArgs(Foo)
+            assert render_args[Foo] == Foo.Args()
+
+        def test_non_default(self):
+            namespace = Foo.Args("bar", "foo")
+            render_args = RenderArgs(Foo, namespace)
+            assert render_args[Foo] is namespace
+
+        def test_multiple_with_same_render_cls(self):
+            namespace_foo = Foo.Args(foo="bar")
+            namespace_bar = Foo.Args(bar="foo")
+
+            render_args = RenderArgs(Foo, namespace_foo, namespace_bar)
+            assert render_args[Foo] is namespace_bar
+
+            render_args = RenderArgs(Foo, namespace_bar, namespace_foo)
+            assert render_args[Foo] is namespace_foo
+
+    class TestInitRenderArgs:
+        class TestCompatibility:
+            class A(Renderable):
+                pass
+
+            class B(A):
+                pass
+
+            class C(A):
+                pass
+
+            @pytest.mark.parametrize(
+                "cls1, cls2",
+                [
+                    (A, Renderable),
+                    (A, A),
+                    (B, Renderable),
+                    (B, A),
+                    (B, B),
+                    (C, Renderable),
+                    (C, A),
+                    (C, C),
+                ],
+            )
+            def test_compatible(self, cls1, cls2):
+                assert RenderArgs(cls1, RenderArgs(cls2)) == RenderArgs(cls1)
+
+            @pytest.mark.parametrize(
+                "cls1, cls2",
+                [
+                    (Renderable, A),
+                    (Renderable, B),
+                    (Renderable, C),
+                    (A, B),
+                    (A, C),
+                    (B, C),
+                    (C, B),
+                ],
+            )
+            def test_incompatible(self, cls1, cls2):
+                init_render_args = RenderArgs(cls2)
+                with pytest.raises(IncompatibleRenderArgsError):
+                    RenderArgs(cls1, init_render_args)
+
+        def test_default(self):
+            render_args = RenderArgs(Foo)
+            assert render_args[Foo] == Foo.Args()
+            assert render_args == RenderArgs(Foo, None)
+
+        def test_non_default(self):
+            init_render_args = RenderArgs(Foo, Foo.Args("bar", "foo"))
+            render_args = RenderArgs(Foo, init_render_args)
+            assert render_args == init_render_args
+            assert render_args[Foo] == Foo.Args("bar", "foo")
+            assert render_args[Foo] is init_render_args[Foo]
+
+    def test_eq(self):
+        class Bar(Renderable):
+            class Args(RenderArgs.Namespace):
+                bar: str = "BAR"
+
+        class Baz(Bar):
+            class Args(RenderArgs.Namespace):
+                baz: str = "BAZ"
+
+        bar_args = RenderArgs(Bar)
+        assert bar_args == RenderArgs(Bar)
+        assert bar_args == RenderArgs(Bar, Bar.Args())
+
+        bar_args_bar = RenderArgs(Bar, Bar.Args("bar"))
+        assert bar_args_bar == RenderArgs(Bar, Bar.Args("bar"))
+        assert bar_args_bar != bar_args
+
+        baz_args = RenderArgs(Baz)
+        assert baz_args == RenderArgs(Baz)
+        assert baz_args == RenderArgs(Baz, Bar.Args())
+        assert baz_args == RenderArgs(Baz, Baz.Args())
+        assert baz_args == RenderArgs(Baz, Bar.Args(), Baz.Args())
+        assert baz_args == RenderArgs(Baz, Baz.Args(), Bar.Args())
+        assert baz_args != bar_args
+        assert baz_args != bar_args_bar
+
+        baz_args_bar = RenderArgs(Baz, Bar.Args("bar"))
+        assert baz_args_bar == RenderArgs(Baz, Bar.Args("bar"))
+        assert baz_args_bar == RenderArgs(Baz, Bar.Args("bar"), Baz.Args())
+        assert baz_args_bar == RenderArgs(Baz, Baz.Args(), Bar.Args("bar"))
+        assert baz_args_bar != bar_args
+        assert baz_args_bar != bar_args_bar
+        assert baz_args_bar != baz_args
+
+        baz_args_baz = RenderArgs(Baz, Baz.Args("baz"))
+        assert baz_args_baz == RenderArgs(Baz, Baz.Args("baz"))
+        assert baz_args_baz == RenderArgs(Baz, Bar.Args(), Baz.Args("baz"))
+        assert baz_args_baz == RenderArgs(Baz, Baz.Args("baz"), Bar.Args())
+        assert baz_args_baz != bar_args
+        assert baz_args_baz != bar_args_bar
+        assert baz_args_baz != baz_args
+        assert baz_args_baz != baz_args_bar
+
+        baz_args_bar_baz = RenderArgs(Baz, Bar.Args("bar"), Baz.Args("baz"))
+        assert baz_args_bar_baz == RenderArgs(Baz, Bar.Args("bar"), Baz.Args("baz"))
+        assert baz_args_bar_baz == RenderArgs(Baz, Baz.Args("baz"), Bar.Args("bar"))
+        assert baz_args_bar_baz != bar_args
+        assert baz_args_bar_baz != bar_args_bar
+        assert baz_args_bar_baz != baz_args
+        assert baz_args_bar_baz != baz_args_bar
+        assert baz_args_bar_baz != baz_args_baz
+
+    def test_getitem(self):
+        class Bar(Foo):
+            pass
+
+        class Baz(Bar):
+            pass
+
+        render_args = RenderArgs(Bar)
+
+        with pytest.raises(TypeError, match="'render_cls'"):
+            render_args[Ellipsis]
+
+        with pytest.raises(TypeError, match="'render_cls'"):
+            render_args[[]]
+
+        with pytest.raises(NoArgsNamespaceError):
+            render_args[Bar]
+
+        with pytest.raises(ValueError, match="'Bar' is not a subclass of 'Baz'"):
+            render_args[Baz]
+
+        assert isinstance(render_args[Foo], Foo.Args)
+
+    def test_hash(self):
+        foo_args_default = RenderArgs(Foo)
+        assert hash(foo_args_default) == hash(RenderArgs(Foo))
+
+        foo_args_foo_bar = RenderArgs(Foo, Foo.Args(foo="bar", bar="foo"))
+        assert hash(foo_args_foo_bar) == hash(
+            RenderArgs(Foo, Foo.Args(foo="bar", bar="foo"))
+        )
+
+        foo_args = RenderArgs(Foo, Foo.Args(foo=[]))
+        with pytest.raises(TypeError):
+            hash(foo_args)
+
+    def test_iter(self):
+        class A(Renderable):
+            class Args(RenderArgs.Namespace):
+                foo: None = None
+
+        class B(A):
+            class Args(RenderArgs.Namespace):
+                foo: None = None
+
+        class C(B):
+            class Args(RenderArgs.Namespace):
+                foo: None = None
+
+        assert {*RenderArgs(A)} == {A.Args()}
+        assert {*RenderArgs(B)} == {A.Args(), B.Args()}
+        assert {*RenderArgs(C)} == {A.Args(), B.Args(), C.Args()}
+
+    class TestConvert:
+        class A(Renderable):
+            class Args(RenderArgs.Namespace):
+                a: str = "a"
+
+        class B(Renderable):
+            class Args(RenderArgs.Namespace):
+                b: str = "b"
+
+        class C(A, B):
+            class Args(RenderArgs.Namespace):
+                c: str = "c"
+
+        class D(C):
+            class Args(RenderArgs.Namespace):
+                d: str = "d"
+
+        args = (A.Args("1"), B.Args("2"), C.Args("3"), D.Args("4"))
+
+        def test_args(self):
+            render_args = RenderArgs(Foo)
+            with pytest.raises(TypeError, match="'render_cls'"):
+                render_args.convert(Ellipsis)
+
+        def test_same_render_cls(self):
+            render_args = RenderArgs(Foo, Foo.Args("bar", "foo"))
+            assert render_args.convert(Foo) is render_args
+
+        def test_child(self):
+            render_args_a = RenderArgs(self.A, self.args[0])
+            render_args_b = RenderArgs(self.B, self.args[1])
+            render_args_c = RenderArgs(self.C, *self.args[:3])
+
+            assert render_args_a.convert(self.D) == RenderArgs(self.D, self.args[0])
+            assert render_args_b.convert(self.D) == RenderArgs(self.D, self.args[1])
+            assert render_args_c.convert(self.D) == RenderArgs(self.D, *self.args[:3])
+
+        def test_parent(self):
+            render_args = RenderArgs(self.D, *self.args)
+            assert render_args.convert(self.A) == RenderArgs(self.A, self.args[0])
+            assert render_args.convert(self.B) == RenderArgs(self.B, self.args[1])
+            assert render_args.convert(self.C) == RenderArgs(self.C, *self.args[:3])
+
+        def test_non_parent_child(self):
+            render_args = RenderArgs(self.A)
+            with pytest.raises(ValueError, match="'B' is not a parent or child of 'A'"):
+                render_args.convert(self.B)
+
+            render_args = RenderArgs(self.B)
+            with pytest.raises(ValueError, match="'A' is not a parent or child of 'B'"):
+                render_args.convert(self.A)
+
+    class TestUpdate:
+        def test_args(self):
+            class Bar(Renderable):
+                pass
+
+            class Baz(Bar):
+                pass
+
+            render_args = RenderArgs(Foo)
+
+            with pytest.raises(TypeError, match="first argument"):
+                render_args.update(Ellipsis)
+
+            with pytest.raises(TypeError, match="positional argument"):
+                render_args.update(Foo, Ellipsis)
+
+            with pytest.raises(TypeError, match="keyword argument"):
+                render_args.update(Foo.Args(), foo=Ellipsis)
+
+            # propagated
+
+            with pytest.raises(TypeError, match=r"'namespaces\[1\]'"):
+                render_args.update(Foo.Args(), Ellipsis)
+
+            with pytest.raises(ValueError, match="not a subclass"):
+                render_args.update(Bar)
+
+            with pytest.raises(NoArgsNamespaceError):
+                RenderArgs(Baz).update(Bar)
+
+            with pytest.raises(UnknownArgsFieldError, match="'x'"):
+                render_args.update(Foo, x=Ellipsis)
+
+        def test_namespaces(self):
+            render_args = RenderArgs(Foo)
+            namespace = Foo.Args(foo="bar", bar="foo")
+            assert render_args.update(namespace) == RenderArgs(Foo, namespace)
+            assert render_args.update(namespace)[Foo] is namespace
+
+            render_args = +Foo.Args(foo="bar")
+            namespace = Foo.Args(bar="foo")
+            assert render_args.update(Foo.Args()) == RenderArgs(Foo)
+            assert render_args.update(namespace) == RenderArgs(Foo, namespace)
+            assert render_args.update(namespace)[Foo] is namespace
+
+            render_args = +Foo.Args(bar="foo")
+            namespace = Foo.Args(foo="bar")
+            assert render_args.update(Foo.Args()) == RenderArgs(Foo)
+            assert render_args.update(namespace) == RenderArgs(Foo, namespace)
+            assert render_args.update(namespace)[Foo] is namespace
+
+        def test_multiple_namespaces_with_same_render_cls(self):
+            args_foo = Foo.Args(foo="bar")
+            args_bar = Foo.Args(bar="foo")
+            render_args = RenderArgs(Foo)
+
+            assert render_args.update(args_foo, args_bar)[Foo] is args_bar
+            assert render_args.update(args_bar, args_foo)[Foo] is args_foo
+
+        def test_fields(self):
+            render_args = +Foo.Args(foo="bar")
+            assert render_args.update(Foo) == render_args
+            assert render_args.update(Foo, foo="bar") == render_args
+            assert render_args.update(Foo, foo="foo") == +Foo.Args(foo="foo")
+            assert render_args.update(Foo, bar="foo") == +Foo.Args(foo="bar", bar="foo")
+
+    def test_immutability(self):
+        render_args = RenderArgs(Foo)
+        with pytest.raises(TypeError):
+            render_args[Foo] = Foo.Args()
+
+    def test_namespace_source_precedence(self):
+        class A(Renderable):
+            class Args(RenderArgs.Namespace):
+                a: int = 1
+
+        class B(A):
+            class Args(RenderArgs.Namespace):
+                b: int = 2
+
+        class C(B):
+            class Args(RenderArgs.Namespace):
+                c: int = 3
+
+        init_render_args = RenderArgs(B, A.Args(10), B.Args(20))
+        namespace = A.Args(100)
+        render_args = RenderArgs(C, init_render_args, namespace)
+
+        assert render_args[A] is namespace
+        assert render_args[B] is init_render_args[B]
+        assert render_args[C] is RenderArgs(C)[C]  # default
+
+    class TestOptimizations:
+        class TestDefaultsInterned:
+            class Bar(Foo):
+                pass
+
+            def test_true_positives(self):
+                assert RenderArgs(Renderable) is RenderArgs(Renderable)
+                assert RenderArgs(Foo) is RenderArgs(Foo)
+
+                render_args = RenderArgs(self.Bar)
+
+                assert render_args is RenderArgs(self.Bar)
+                assert render_args is RenderArgs(self.Bar, RenderArgs(Renderable))
+                assert render_args is RenderArgs(self.Bar, RenderArgs(Foo))
+                assert render_args is RenderArgs(self.Bar, render_args)
+
+            def test_likely_false_positives(self):
+                class Baz(Foo):
+                    pass
+
+                with pytest.raises(IncompatibleRenderArgsError):
+                    RenderArgs(self.Bar, RenderArgs(Baz))
+
+            def test_subclass(self):
+                class SubRenderArgs(RenderArgs):
+                    pass
+
+                render_args = SubRenderArgs(Renderable)
+                assert isinstance(render_args, SubRenderArgs)
+                assert render_args is SubRenderArgs(Renderable)
+
+                render_args = SubRenderArgs(Foo)
+                assert isinstance(render_args, SubRenderArgs)
+                assert render_args is SubRenderArgs(Foo)
+
+        def test_init_render_args_with_same_render_cls_and_without_namespaces(self):
+            class Bar(Foo):
+                pass
+
+            namespace = Foo.Args(foo="bar", bar="foo")
+
+            foo_render_args = RenderArgs(Foo, namespace)
+            assert RenderArgs(Foo, foo_render_args) is foo_render_args
+
+            bar_render_args = RenderArgs(Bar, namespace)
+            assert RenderArgs(Bar, bar_render_args) is bar_render_args
+
+        # Actually interned by `RenderableMeta`; testing that they're being used
+        def test_default_namespaces_interned(self):
+            class A(Renderable):
+                class Args(RenderArgs.Namespace):
+                    a: None = None
+
+            class B(A):
+                class Args(RenderArgs.Namespace):
+                    b: None = None
+
+            class C(B):
+                class Args(RenderArgs.Namespace):
+                    c: None = None
+
+            assert RenderArgs(A)[A] is RenderArgs(B)[A]
+            assert RenderArgs(A)[A] is RenderArgs(C)[A]
+            assert RenderArgs(B)[B] is RenderArgs(C)[B]
+
+
+class TestArgsNamespaceMeta:
+    class TestFields:
+        def test_no_default(self):
+            with pytest.raises(RenderArgsError, match="'bar' .* no default"):
+
+                class Namespace(RenderArgs.Namespace):
+                    foo: None = None
+                    bar: None
+                    baz: None = None
+
+        def test_defaults(self):
+            class Namespace(RenderArgs.Namespace):
+                foo: str = "FOO"
+                bar: str = "BAR"
+
+            Namespace.get_fields() == dict(foo="FOO", bar="BAR")
+
+
+class TestArgsNamespace:
+    class Bar(Renderable):
+        class Args(RenderArgs.Namespace):
+            foo: str = "FOO"
+            bar: str = "BAR"
+
+    Namespace = Bar.Args
+
+    class TestConstructor:
+        class Bar(Renderable):
+            class Args(RenderArgs.Namespace):
+                foo: str = "FOO"
+                bar: str = "BAR"
+                baz: str = "BAZ"
+
+        Namespace = Bar.Args
+
+        def test_args(self):
+            with pytest.raises(TypeError, match="'Bar' defines 3 .* 4 .* given"):
+                self.Namespace("", "", "", "")
+
+            with pytest.raises(UnknownArgsFieldError, match="'dude'"):
+                self.Namespace(dude="dude")
+
+            with pytest.raises(TypeError, match=r"Got multiple .* \('foo',\)"):
+                self.Namespace("foo", foo="foo")
+
+        def test_default(self):
+            namespace = self.Namespace()
+            assert namespace.as_dict() == dict(foo="FOO", bar="BAR", baz="BAZ")
+
+        def test_render_args(self):
+            namespace = self.Namespace("foo")
+            assert namespace.as_dict() == dict(foo="foo", bar="BAR", baz="BAZ")
+
+            namespace = self.Namespace("foo", "bar")
+            assert namespace.as_dict() == dict(foo="foo", bar="bar", baz="BAZ")
+
+            namespace = self.Namespace("foo", "bar", "baz")
+            assert namespace.as_dict() == dict(foo="foo", bar="bar", baz="baz")
+
+        def test_render_kwargs(self):
+            namespace = self.Namespace(foo="foo")
+            assert namespace.as_dict() == dict(foo="foo", bar="BAR", baz="BAZ")
+
+            namespace = self.Namespace(bar="bar")
+            assert namespace.as_dict() == dict(foo="FOO", bar="bar", baz="BAZ")
+
+            namespace = self.Namespace(baz="baz")
+            assert namespace.as_dict() == dict(foo="FOO", bar="BAR", baz="baz")
+
+            namespace = self.Namespace(foo="foo", bar="bar")
+            assert namespace.as_dict() == dict(foo="foo", bar="bar", baz="BAZ")
+
+            namespace = self.Namespace(foo="foo", baz="baz")
+            assert namespace.as_dict() == dict(foo="foo", bar="BAR", baz="baz")
+
+            namespace = self.Namespace(bar="bar", baz="baz")
+            assert namespace.as_dict() == dict(foo="FOO", bar="bar", baz="baz")
+
+            namespace = self.Namespace(foo="foo", bar="bar", baz="baz")
+            assert namespace.as_dict() == dict(foo="foo", bar="bar", baz="baz")
+
+            # Out of order
+            namespace = self.Namespace(bar="bar", baz="baz", foo="foo")
+            assert namespace.as_dict() == dict(foo="foo", bar="bar", baz="baz")
+
+        def test_render_args_kwargs(self):
+            namespace = self.Namespace("foo", bar="bar")
+            assert namespace.as_dict() == dict(foo="foo", bar="bar", baz="BAZ")
+
+            namespace = self.Namespace("foo", baz="baz")
+            assert namespace.as_dict() == dict(foo="foo", bar="BAR", baz="baz")
+
+            namespace = self.Namespace("foo", bar="bar", baz="baz")
+            assert namespace.as_dict() == dict(foo="foo", bar="bar", baz="baz")
+
+            namespace = self.Namespace("foo", "bar", baz="baz")
+            assert namespace.as_dict() == dict(foo="foo", bar="bar", baz="baz")
+
+    def test_getattr(self):
+        namespace = self.Namespace()
+        assert namespace.foo == "FOO"
+        assert namespace.bar == "BAR"
+        with pytest.raises(UnknownArgsFieldError, match="'baz' for 'Bar'"):
+            namespace.baz
+
+    def test_setattr(self):
+        namespace = self.Namespace()
+        with pytest.raises(AttributeError):
+            namespace.foo = "bar"
+        with pytest.raises(AttributeError):
+            namespace.bar = "foo"
+
+    def test_eq(self):
+        namespace_default = self.Namespace()
+        assert namespace_default == self.Namespace()
+        assert namespace_default == self.Namespace("FOO", "BAR")
+
+        namespace_foo = self.Namespace(foo="foo")
+        assert namespace_foo == self.Namespace("foo", "BAR")
+        assert namespace_foo == self.Namespace(foo="foo")
+        assert namespace_foo != namespace_default
+
+        namespace_bar = self.Namespace(bar="bar")
+        assert namespace_bar == self.Namespace("FOO", "bar")
+        assert namespace_bar == self.Namespace(bar="bar")
+        assert namespace_bar != namespace_default
+        assert namespace_bar != namespace_foo
+
+        namespace_foo_bar = self.Namespace("foo", "bar")
+        assert namespace_foo_bar == self.Namespace("foo", "bar")
+        assert namespace_foo_bar == self.Namespace(foo="foo", bar="bar")
+        assert namespace_foo_bar != namespace_default
+        assert namespace_foo_bar != namespace_foo
+        assert namespace_foo_bar != namespace_bar
+
+    def test_hash(self):
+        namespace_default = self.Namespace()
+        assert hash(namespace_default) == hash(self.Namespace())
+
+        namespace_foo_bar = self.Namespace("foo", "bar")
+        assert hash(namespace_foo_bar) == hash(self.Namespace("foo", "bar"))
+
+        namespace = self.Namespace([])
+        with pytest.raises(TypeError):
+            hash(namespace)
+
+    class TestOr:
+        class A(Renderable):
+            class Args(RenderArgs.Namespace):
+                a: int = 0
+
+        class B(A):
+            class Args(RenderArgs.Namespace):
+                b: int = 0
+
+        class C(A):
+            class Args(RenderArgs.Namespace):
+                c: int = 0
+
+        def test_invalid_type(self):
+            A = TestArgsNamespace.TestOr.A
+            a = A.Args()
+
+            with pytest.raises(TypeError):
+                a | Ellipsis
+
+        class TestNamespace:
+            def test_same_render_cls(self):
+                A = TestArgsNamespace.TestOr.A
+                a1, a2 = A.Args(1), A.Args(2)
+
+                assert a2 | a1 == RenderArgs(A, a1)
+                assert a1 | a2 == RenderArgs(A, a2)
+
+            # Render class of *other* is a child of that of *self*
+            def test_compatible_child_render_cls(self):
+                A = TestArgsNamespace.TestOr.A
+                B = TestArgsNamespace.TestOr.B
+                C = TestArgsNamespace.TestOr.C
+                a, b, c = A.Args(1), B.Args(2), C.Args(3)
+
+                assert a | b == RenderArgs(B, a, b)
+                assert a | c == RenderArgs(C, a, c)
+
+            # Render class of *other* is a parent of that of *self*
+            def test_compatible_parent_render_cls(self):
+                A = TestArgsNamespace.TestOr.A
+                B = TestArgsNamespace.TestOr.B
+                C = TestArgsNamespace.TestOr.C
+                a, b, c = A.Args(1), B.Args(2), C.Args(3)
+
+                assert b | a == RenderArgs(B, a, b)
+                assert c | a == RenderArgs(C, a, c)
+
+            def test_incompatible_render_cls(self):
+                B = TestArgsNamespace.TestOr.B
+                C = TestArgsNamespace.TestOr.C
+                b, c = B.Args(2), C.Args(3)
+
+                with pytest.raises(IncompatibleArgsNamespaceError):
+                    b | c
+
+                with pytest.raises(IncompatibleArgsNamespaceError):
+                    c | b
+
+        class TestRenderArgs:
+            # Render class of *other* is a child of that of *self*
+            def test_compatible_child_render_cls(self):
+                A = TestArgsNamespace.TestOr.A
+                B = TestArgsNamespace.TestOr.B
+                C = TestArgsNamespace.TestOr.C
+                a, b, c = A.Args(1), B.Args(2), C.Args(3)
+
+                assert a | RenderArgs(B, b) == RenderArgs(B, a, b)
+                assert a | RenderArgs(C, c) == RenderArgs(C, a, c)
+
+            # Render class of *other* is a parent of that of *self*
+            def test_compatible_parent_render_cls(self):
+                A = TestArgsNamespace.TestOr.A
+                B = TestArgsNamespace.TestOr.B
+                C = TestArgsNamespace.TestOr.C
+                a, b, c = A.Args(1), B.Args(2), C.Args(3)
+
+                assert b | RenderArgs(A, a) == RenderArgs(B, a, b)
+                assert c | RenderArgs(A, a) == RenderArgs(C, a, c)
+
+            def test_incompatible_render_cls(self):
+                B = TestArgsNamespace.TestOr.B
+                C = TestArgsNamespace.TestOr.C
+                b, c = B.Args(2), C.Args(3)
+
+                with pytest.raises(IncompatibleRenderArgsError):
+                    b | RenderArgs(C)
+
+                with pytest.raises(IncompatibleRenderArgsError):
+                    c | RenderArgs(B)
+
+    def test_pos(self):
+        assert +self.Namespace() == RenderArgs(self.Bar)
+        assert +self.Namespace("bar", "foo") == RenderArgs(
+            self.Bar, self.Namespace("bar", "foo")
+        )
+
+    class TestRor:
+        """The reflected operation is invoked if either:
+
+        * type(RHS) is a subclass of type(LHS) and provides a different implementation
+          of the reflected operation, or
+        * type(LHS) does not implement the [non-reflected] operation against type(RHS)
+          i.e `LHS.__non_reflected__(RHS)` returns `NotImplemented`.
+        """
+
+        def test_invalid_type(self):
+            class A(Renderable):
+                class Args(RenderArgs.Namespace):
+                    a: int = 0
+
+            a = A.Args()
+            with pytest.raises(TypeError):
+                Ellipsis | a
+
+        class TestNamespace:
+            def test_same_render_cls(self):
+                class A(Renderable):
+                    class Args(RenderArgs.Namespace):
+                        a: int = 0
+
+                    class SubArgs(Args):
+                        def __ror__(self, other):  # See docstring of `TestRor`
+                            return super().__ror__(other)
+
+                a, a_sub = A.Args(1), A.SubArgs(2)
+                assert a | a_sub == RenderArgs(A, a_sub)
+
+            # Render class of *other* is a child of that of *self*
+            def test_compatible_child_render_cls(self):
+                class ArgsC(RenderArgs.Namespace):
+                    c: int = 0
+
+                class ArgsB(ArgsC, inherit=False):
+                    b: int = 0
+
+                class ArgsA(ArgsC, inherit=False):
+                    a: int = 0
+
+                    def __ror__(self, other):  # See docstring of `TestRor`
+                        return super().__ror__(other)
+
+                class A(Renderable):
+                    Args = ArgsA
+
+                class B(A):
+                    Args = ArgsB
+
+                class C(A):
+                    Args = ArgsC
+
+                a, b, c = A.Args(1), B.Args(2), C.Args(3)
+                assert b | a == RenderArgs(B, a, b)
+                assert c | a == RenderArgs(C, a, c)
+
+            # Render class of *other* is a parent of that of *self*
+            def test_compatible_parent_render_cls(self):
+                class A(Renderable):
+                    class Args(RenderArgs.Namespace):
+                        a: int = 0
+
+                class B(A):
+                    class Args(A.Args, inherit=False):
+                        b: int = 0
+
+                        def __ror__(self, other):  # See docstring of `TestRor`
+                            return super().__ror__(other)
+
+                class C(A):
+                    class Args(A.Args, inherit=False):
+                        c: int = 0
+
+                        def __ror__(self, other):  # See docstring of `TestRor`
+                            return super().__ror__(other)
+
+                a, b, c = A.Args(1), B.Args(2), C.Args(3)
+                assert a | b == RenderArgs(B, a, b)
+                assert a | c == RenderArgs(C, a, c)
+
+            def test_incompatible_render_cls(self):
+                class B(Renderable):
+                    class Args(RenderArgs.Namespace):
+                        b: int = 0
+
+                class C(Renderable):
+                    class Args(B.Args, inherit=False):
+                        c: int = 0
+
+                        def __ror__(self, other):  # See docstring of `TestRor`
+                            return super().__ror__(other)
+
+                b, c = B.Args(2), C.Args(3)
+                with pytest.raises(IncompatibleArgsNamespaceError):
+                    b | c
+
+        class TestRenderArgs:
+            class A(Renderable):
+                class Args(RenderArgs.Namespace):
+                    a: int = 0
+
+            class B(A):
+                class Args(RenderArgs.Namespace):
+                    b: int = 0
+
+            class C(A):
+                class Args(RenderArgs.Namespace):
+                    c: int = 0
+
+            # Render class of *other* is a child of that of *self*
+            def test_compatible_child_render_cls(self):
+                A, B, C = self.A, self.B, self.C
+                a, b, c = A.Args(1), B.Args(2), C.Args(3)
+
+                assert RenderArgs(B, b) | a == RenderArgs(B, a, b)
+                assert RenderArgs(C, c) | a == RenderArgs(C, a, c)
+
+            # Render class of *other* is a parent of that of *self*
+            def test_compatible_parent_render_cls(self):
+                A, B, C = self.A, self.B, self.C
+                a, b, c = A.Args(1), B.Args(2), C.Args(3)
+
+                assert RenderArgs(A, a) | b == RenderArgs(B, a, b)
+                assert RenderArgs(A, a) | c == RenderArgs(C, a, c)
+
+            def test_incompatible_render_cls(self):
+                B, C = self.B, self.C
+                b, c = B.Args(2), C.Args(3)
+
+                with pytest.raises(IncompatibleRenderArgsError):
+                    RenderArgs(C) | b
+
+                with pytest.raises(IncompatibleRenderArgsError):
+                    RenderArgs(B) | c
+
+    def test_as_dict(self):
+        namespace = self.Namespace()
+        assert namespace.as_dict() == dict(foo="FOO", bar="BAR")
+
+        namespace = self.Namespace("bar", "foo")
+        assert namespace.as_dict() == dict(foo="bar", bar="foo")
+
+    def test_get_fields(self):
+        namespace = self.Namespace()
+        assert self.Namespace.get_fields() == dict(foo="FOO", bar="BAR")
+        assert namespace.get_fields() == dict(foo="FOO", bar="BAR")
+
+    class TestToRenderArgs:
+        class Bar(Foo):
+            class Args(RenderArgs.Namespace):
+                bar: str = "BAR"
+
+        class Baz(Bar):
+            class Args(RenderArgs.Namespace):
+                baz: str = "BAZ"
+
+        args_default = Bar.Args()
+        args = Bar.Args("bar")
+
+        def test_default(self):
+            assert self.args_default.to_render_args() == RenderArgs(self.Bar)
+            assert self.args_default.to_render_args(None) == RenderArgs(self.Bar)
+
+            assert self.args.to_render_args() == RenderArgs(self.Bar, self.args)
+            assert self.args.to_render_args(None) == RenderArgs(self.Bar, self.args)
+
+        def test_compatible(self):
+            assert self.args_default.to_render_args(self.Bar) == RenderArgs(self.Bar)
+            assert self.args_default.to_render_args(self.Baz) == RenderArgs(self.Baz)
+
+            assert self.args.to_render_args(self.Bar) == RenderArgs(self.Bar, self.args)
+            assert self.args.to_render_args(self.Baz) == RenderArgs(self.Baz, self.args)
+
+        def test_incompatible(self):
+            with pytest.raises(IncompatibleArgsNamespaceError):
+                self.args_default.to_render_args(Foo)
+
+            with pytest.raises(IncompatibleArgsNamespaceError):
+                self.args.to_render_args(Foo)
+
+    def test_update(self):
+        namespace = self.Namespace()
+
+        assert namespace.update().as_dict() == dict(foo="FOO", bar="BAR")
+        assert namespace.update(foo="bar").as_dict() == dict(foo="bar", bar="BAR")
+        assert namespace.update(bar="foo").as_dict() == dict(foo="FOO", bar="foo")
+        assert namespace.update(foo="bar", bar="foo").as_dict() == dict(
+            foo="bar", bar="foo"
+        )
+
+        with pytest.raises(UnknownArgsFieldError, match="'baz'"):
+            namespace.update(baz=Ellipsis)
+
+
+class TestRenderData:
+    foo_render_data = RenderData(Foo)
+
+    def test_base(self):
+        render_data = RenderData(Renderable)
+        assert render_data.render_cls is Renderable
+        assert isinstance(render_data[Renderable], Renderable._Data_)
+
+    def test_render_cls(self):
+        assert self.foo_render_data.render_cls is Foo
+
+    def test_finalized(self):
+        render_data = RenderData(Foo)
+        assert render_data.finalized is False
+
+        render_data[Foo].update(foo="FOO", bar="BAR")
+        assert render_data.finalized is False
+
+        render_data.finalize()
+        assert render_data.finalized is True
+
+    def test_getitem(self):
+        class Bar(Foo):
+            pass
+
+        class Baz(Bar):
+            pass
+
+        render_data = RenderData(Bar)
+
+        with pytest.raises(TypeError, match="'render_cls'"):
+            render_data[Ellipsis]
+
+        with pytest.raises(TypeError, match="'render_cls'"):
+            render_data[[]]
+
+        with pytest.raises(NoDataNamespaceError):
+            render_data[Bar]
+
+        with pytest.raises(ValueError, match="'Bar' is not a subclass of 'Baz'"):
+            render_data[Baz]
+
+        assert isinstance(render_data[Foo], Foo._Data_)
+        assert isinstance(render_data[Renderable], Renderable._Data_)
+
+    # Currently depends on implementation detail (the order or namespaces) but I don't
+    # really see any reasonable/neat way around that for now
+    def test_iter(self):
+        class A(Renderable):
+            class _Data_(RenderData.Namespace):
+                foo: None = None
+
+        class B(A):
+            class _Data_(RenderData.Namespace):
+                foo: None = None
+
+        class C(B):
+            class _Data_(RenderData.Namespace):
+                foo: None = None
+
+        a_data, base_data = tuple(RenderData(A))
+        assert isinstance(a_data, A._Data_)
+        assert isinstance(base_data, Renderable._Data_)
+
+        b_data, a_data, base_data = tuple(RenderData(B))
+        assert isinstance(a_data, A._Data_)
+        assert isinstance(b_data, B._Data_)
+        assert isinstance(base_data, Renderable._Data_)
+
+        c_data, b_data, a_data, base_data = tuple(RenderData(C))
+        assert isinstance(a_data, A._Data_)
+        assert isinstance(b_data, B._Data_)
+        assert isinstance(c_data, C._Data_)
+        assert isinstance(base_data, Renderable._Data_)
+
+    def test_finalize(self):
+        render_data = RenderData(Foo)
+        render_data[Foo].update(foo="FOO", bar="BAR")
+        assert render_data[Foo].foo == "FOO"
+        assert render_data[Foo].bar == "BAR"
+
+        render_data.finalize()
+        assert render_data[Foo].foo == "FOO"
+        assert render_data[Foo].bar == "FOO"
+
+        # Calls `_finalize_render_data_` only the first time
+
+        render_data[Foo].foo = "bar"
+        assert render_data[Foo].foo == "bar"
+        assert render_data[Foo].bar == "FOO"
+
+        render_data.finalize()
+        assert render_data[Foo].foo == "bar"
+        assert render_data[Foo].bar == "FOO"
+
+
+class TestDataNamespace:
+    class Bar(Renderable):
+        class _Data_(RenderData.Namespace):
+            foo: str
+            bar: str
+
+    Namespace = Bar._Data_
+
+    class TestGetattrAndSetattr:
+        class Bar(Renderable):
+            class _Data_(RenderData.Namespace):
+                foo: str
+                bar: str
+
+        Namespace = Bar._Data_
+
+        def test_known(self):
+            namespace = self.Namespace()
+            with pytest.raises(UninitializedDataFieldError, match="'foo' of 'Bar'"):
+                namespace.foo
+            with pytest.raises(UninitializedDataFieldError, match="'bar' of 'Bar'"):
+                namespace.bar
+
+            namespace.foo = "FOO"
+            assert namespace.foo == "FOO"
+            with pytest.raises(UninitializedDataFieldError, match="'bar' of 'Bar'"):
+                namespace.bar
+
+            namespace.bar = "BAR"
+            assert namespace.foo == "FOO"
+            assert namespace.bar == "BAR"
+
+            namespace.foo = "bar"
+            namespace.bar = "foo"
+            assert namespace.foo == "bar"
+            assert namespace.bar == "foo"
+
+        def test_unknown_get(self):
+            namespace = self.Namespace()
+            with pytest.raises(UnknownDataFieldError, match="'baz' for 'Bar'"):
+                namespace.baz
+
+        def test_unknown_set(self):
+            namespace = self.Namespace()
+            with pytest.raises(UnknownDataFieldError, match="'baz' for 'Bar'"):
+                namespace.baz = Ellipsis
+
+    def test_as_dict(self):
+        namespace = self.Namespace()
+
+        with pytest.raises(UninitializedDataFieldError):
+            namespace.as_dict()
+
+        namespace.foo = "FOO"
+        with pytest.raises(UninitializedDataFieldError):
+            namespace.as_dict()
+
+        namespace.bar = "BAR"
+        assert namespace.as_dict() == dict(foo="FOO", bar="BAR")
+
+        namespace.foo = "bar"
+        namespace.bar = "foo"
+        assert namespace.as_dict() == dict(foo="bar", bar="foo")
+
+    def test_get_fields(self):
+        namespace = self.Namespace()
+        assert self.Namespace.get_fields() == ("foo", "bar")
+        assert namespace.get_fields() == ("foo", "bar")
+
+    class TestUpdate:
+        class Bar(Renderable):
+            class _Data_(RenderData.Namespace):
+                foo: str
+                bar: str
+
+        Namespace = Bar._Data_
+
+        def test_known(self):
+            namespace = self.Namespace()
+            with pytest.raises(UninitializedDataFieldError):
+                namespace.foo
+            with pytest.raises(UninitializedDataFieldError):
+                namespace.bar
+
+            namespace.update()
+            with pytest.raises(UninitializedDataFieldError):
+                namespace.foo
+            with pytest.raises(UninitializedDataFieldError):
+                namespace.bar
+
+            namespace.update(foo="FOO")
+            assert namespace.foo == "FOO"
+            with pytest.raises(UninitializedDataFieldError):
+                namespace.bar
+
+            namespace.update(bar="BAR")
+            assert namespace.as_dict() == dict(foo="FOO", bar="BAR")
+
+            namespace.update()
+            assert namespace.as_dict() == dict(foo="FOO", bar="BAR")
+
+            namespace.update(foo="bar", bar="foo")
+            assert namespace.as_dict() == dict(foo="bar", bar="foo")
+
+        def test_unknown(self):
+            namespace = self.Namespace()
+            with pytest.raises(UnknownDataFieldError, match=r"\('baz',\) for 'Bar'"):
+                namespace.update(baz=Ellipsis)
diff --git a/tests/test_padding.py b/tests/test_padding.py
new file mode 100644
index 00000000..5ebfe50b
--- /dev/null
+++ b/tests/test_padding.py
@@ -0,0 +1,415 @@
+import os
+
+import pytest
+
+from term_image.geometry import RawSize, Size
+from term_image.padding import (
+    AlignedPadding,
+    ExactPadding,
+    HAlign,
+    Padding,
+    RelativePaddingDimensionError,
+    VAlign,
+)
+
+
+class ConcretePadding(Padding):
+    def __init__(self, dimensions=(0,) * 4, fill=" "):
+        super().__init__(fill)
+        self.dimensions = dimensions
+
+    def _get_exact_dimensions_(self, render_size):
+        return self.dimensions
+
+
+class TestPadding:
+    def test_is_abstract(self):
+        with pytest.raises(TypeError, match="abstract"):
+            Padding()
+
+    def test_fill(self):
+        assert ConcretePadding().fill == " "
+        assert ConcretePadding(fill="#").fill == "#"
+
+    @pytest.mark.parametrize(
+        "dimensions,render_size,padded_size",
+        [
+            ((0, 0, 0, 0), Size(1, 1), Size(1, 1)),
+            ((1, 0, 0, 0), Size(1, 1), Size(2, 1)),
+            ((0, 1, 0, 0), Size(1, 1), Size(1, 2)),
+            ((0, 0, 1, 0), Size(1, 1), Size(2, 1)),
+            ((0, 0, 0, 1), Size(1, 1), Size(1, 2)),
+            ((1, 1, 1, 1), Size(1, 1), Size(3, 3)),
+            ((1, 1, 1, 1), Size(1, 2), Size(3, 4)),
+            ((1, 1, 1, 1), Size(2, 1), Size(4, 3)),
+            ((1, 1, 1, 1), Size(2, 2), Size(4, 4)),
+        ],
+    )
+    def test_get_padded_size(self, dimensions, render_size, padded_size):
+        padding = ConcretePadding(dimensions)
+        assert padding.get_padded_size(render_size) == padded_size
+
+    class TestPad:
+        @pytest.mark.parametrize(
+            "dimensions,padded_render",
+            [
+                ((0, 0, 0, 0), "#"),
+                ((1, 0, 0, 0), " #"),
+                ((0, 1, 0, 0), " \n#"),
+                ((0, 0, 1, 0), "# "),
+                ((0, 0, 0, 1), "#\n "),
+                ((1, 1, 1, 1), "   \n # \n   "),
+            ],
+        )
+        def test_dimensions(self, dimensions, padded_render):
+            padding = ConcretePadding(dimensions)
+            assert padding.pad("#", Size(1, 1)) == padded_render
+
+        @pytest.mark.parametrize(
+            "render_size,render,padded_render",
+            [
+                (Size(1, 1), "#", "   \n # \n   "),
+                (Size(1, 2), "#\n#", "   \n # \n # \n   "),
+                (Size(2, 1), "##", "    \n ## \n    "),
+                (Size(2, 2), "##\n##", "    \n ## \n ## \n    "),
+            ],
+        )
+        def test_render_and_render_size(self, render_size, render, padded_render):
+            padding = ConcretePadding((1, 1, 1, 1))
+            assert padding.pad(render, render_size) == padded_render
+
+        @pytest.mark.parametrize(
+            "fill,padded_render",
+            [
+                ("+", "+++\n+#+\n+++"),
+                ("-", "---\n-#-\n---"),
+            ],
+        )
+        def test_fill(self, fill, padded_render):
+            padding = ConcretePadding((1, 1, 1, 1), fill)
+            assert padding.pad("#", Size(1, 1)) == padded_render
+
+    @pytest.mark.parametrize(
+        "dimensions,fill",
+        [
+            ((0, 0, 0, 0), " "),
+            ((1, 1, 1, 1), "#"),
+            ((1, 2, 3, 4), "*"),
+        ],
+    )
+    def test_to_exact(self, dimensions, fill):
+        padding = ConcretePadding(dimensions, fill)
+        assert padding.to_exact(Size(1, 1)) == ExactPadding(*dimensions, fill)
+
+
+class TestAlignedPadding:
+    @pytest.mark.parametrize("width", [1, 10, 0, -10])
+    def test_width(self, width):
+        assert AlignedPadding(width, 1).width == width
+
+    @pytest.mark.parametrize("height", [1, 10, 0, -10])
+    def test_height(self, height):
+        assert AlignedPadding(1, height).height == height
+
+    class TestHAlign:
+        def test_default(self):
+            assert AlignedPadding(1, 1).h_align is HAlign.CENTER
+
+        @pytest.mark.parametrize("h_align", HAlign)
+        def test_non_default(self, h_align):
+            assert AlignedPadding(1, 1, h_align).h_align is h_align
+
+    class TestVAlign:
+        def test_default(self):
+            assert AlignedPadding(1, 1).v_align is VAlign.MIDDLE
+
+        @pytest.mark.parametrize("v_align", VAlign)
+        def test_non_default(self, v_align):
+            assert AlignedPadding(1, 1, v_align=v_align).v_align is v_align
+            assert AlignedPadding(1, 1, v_align=v_align).v_align is v_align
+
+    def test_fill(self):
+        assert AlignedPadding(1, 1).fill == " "
+        assert AlignedPadding(1, 1, fill="#").fill == "#"
+
+    @pytest.mark.parametrize(
+        "width,height,relative",
+        [
+            (1, 1, False),
+            (10, 1, False),
+            (1, 5, False),
+            (10, 5, False),
+            (0, 1, True),
+            (1, -1, True),
+            (0, 0, True),
+            (-5, -10, True),
+        ],
+    )
+    def test_relative(self, width, height, relative):
+        assert AlignedPadding(width, height).relative is relative
+
+    @pytest.mark.parametrize("width,height", [(1, 10), (-1, -10)])
+    def test_size(self, width, height):
+        assert AlignedPadding(width, height).size == RawSize(width, height)
+
+    class TestGetPaddedSize:
+        @pytest.mark.parametrize("width,height", [(0, 0), (-1, 10)])
+        def test_relative(self, width, height):
+            padding = AlignedPadding(width, height)
+            with pytest.raises(RelativePaddingDimensionError):
+                padding.get_padded_size(Size(1, 1))
+
+        @pytest.mark.parametrize(
+            "width,height,render_size,padded_size",
+            [
+                (1, 1, Size(1, 1), Size(1, 1)),
+                (2, 1, Size(1, 1), Size(2, 1)),
+                (1, 2, Size(1, 1), Size(1, 2)),
+                (2, 2, Size(1, 1), Size(2, 2)),
+                (2, 2, Size(2, 2), Size(2, 2)),
+                (2, 1, Size(2, 2), Size(2, 2)),
+                (1, 2, Size(2, 2), Size(2, 2)),
+                (1, 1, Size(2, 2), Size(2, 2)),
+                (1, 1, Size(2, 2), Size(2, 2)),
+                (3, 1, Size(1, 1), Size(3, 1)),
+                (1, 3, Size(1, 1), Size(1, 3)),
+                (3, 3, Size(1, 1), Size(3, 3)),
+                (1, 1, Size(3, 3), Size(3, 3)),
+                (3, 3, Size(3, 3), Size(3, 3)),
+                (100, 200, Size(1, 1), Size(100, 200)),
+                (1, 1, Size(200, 100), Size(200, 100)),
+            ],
+        )
+        def test_absolute(self, width, height, render_size, padded_size):
+            padding = AlignedPadding(width, height)
+            assert padding.get_padded_size(render_size) == padded_size
+
+            padding = AlignedPadding(width, height, HAlign.LEFT, VAlign.TOP)
+            assert padding.get_padded_size(render_size) == padded_size
+
+            padding = AlignedPadding(width, height, HAlign.RIGHT, VAlign.BOTTOM)
+            assert padding.get_padded_size(render_size) == padded_size
+
+    class TestResolve:
+        terminal_size = os.terminal_size((80, 30))
+
+        @pytest.mark.parametrize("dimension", [1, 100])
+        def test_absolute(self, dimension):
+            padding = AlignedPadding(dimension, dimension)
+            assert padding.resolve(self.terminal_size) is padding
+
+        @pytest.mark.parametrize(
+            "relative,absolute",
+            zip((0, -1, -78, -79, -80, -100), (80, 79, 2, 1, 1, 1)),
+        )
+        def test_relative_width(self, relative, absolute):
+            padding = AlignedPadding(relative, 1).resolve(self.terminal_size)
+            assert padding.width == absolute
+
+        @pytest.mark.parametrize(
+            "relative,absolute",
+            zip((0, -1, -28, -29, -30, -100), (30, 29, 2, 1, 1, 1)),
+        )
+        def test_relative_height(self, relative, absolute):
+            padding = AlignedPadding(1, relative).resolve(self.terminal_size)
+            assert padding.height == absolute
+
+        @pytest.mark.parametrize(
+            "relative,absolute",
+            zip(
+                zip((0, -1, -78, -79, -80, -100), (0, -1, -28, -29, -30, -100)),
+                zip((80, 79, 2, 1, 1, 1), (30, 29, 2, 1, 1, 1)),
+            ),
+        )
+        def test_relative_both(self, relative, absolute):
+            padding = AlignedPadding(*relative).resolve(self.terminal_size)
+            assert padding.size == absolute
+
+        @pytest.mark.parametrize("h_align", HAlign)
+        def test_h_align(self, h_align):
+            padding = AlignedPadding(0, 0, h_align).resolve(self.terminal_size)
+            assert padding.h_align == h_align
+
+        @pytest.mark.parametrize("v_align", VAlign)
+        def test_v_align(self, v_align):
+            padding = AlignedPadding(0, 0, v_align=v_align).resolve(self.terminal_size)
+            assert padding.v_align == v_align
+
+        @pytest.mark.parametrize("fill", ["#", "*"])
+        def test_fill(self, fill):
+            padding = AlignedPadding(0, 0, fill=fill).resolve(self.terminal_size)
+            assert padding.fill == fill
+
+    class TestGetExactDimensions:
+        @pytest.mark.parametrize("width,height", [(0, 0), (-1, 10)])
+        def test_relative(self, width, height):
+            padding = AlignedPadding(width, height)
+            with pytest.raises(RelativePaddingDimensionError):
+                padding._get_exact_dimensions_(Size(1, 1))
+
+        class TestHorizontal:
+            @pytest.mark.parametrize(
+                "width,left,right", [(1, 0, 0), (5, 0, 0), (7, 0, 2), (8, 0, 3)]
+            )
+            def test_left(self, width, left, right):
+                padding = AlignedPadding(width, 1, HAlign.LEFT)
+                assert padding._get_exact_dimensions_(Size(5, 5)) == (left, 0, right, 0)
+
+            @pytest.mark.parametrize(
+                "width,left,right", [(1, 0, 0), (5, 0, 0), (7, 1, 1), (8, 1, 2)]
+            )
+            def test_center(self, width, left, right):
+                padding = AlignedPadding(width, 1, HAlign.CENTER)
+                assert padding._get_exact_dimensions_(Size(5, 5)) == (left, 0, right, 0)
+
+            @pytest.mark.parametrize(
+                "width,left,right", [(1, 0, 0), (5, 0, 0), (7, 2, 0), (8, 3, 0)]
+            )
+            def test_right(self, width, left, right):
+                padding = AlignedPadding(width, 1, HAlign.RIGHT)
+                assert padding._get_exact_dimensions_(Size(5, 5)) == (left, 0, right, 0)
+
+        class TestVertical:
+            @pytest.mark.parametrize(
+                "height,top,bottom", [(1, 0, 0), (5, 0, 0), (7, 0, 2), (8, 0, 3)]
+            )
+            def test_top(self, height, top, bottom):
+                padding = AlignedPadding(1, height, v_align=VAlign.TOP)
+                assert padding._get_exact_dimensions_(Size(5, 5)) == (0, top, 0, bottom)
+
+            @pytest.mark.parametrize(
+                "height,top,bottom", [(1, 0, 0), (5, 0, 0), (7, 1, 1), (8, 1, 2)]
+            )
+            def test_middle(self, height, top, bottom):
+                padding = AlignedPadding(1, height, v_align=VAlign.MIDDLE)
+                assert padding._get_exact_dimensions_(Size(5, 5)) == (0, top, 0, bottom)
+
+            @pytest.mark.parametrize(
+                "height,top,bottom", [(1, 0, 0), (5, 0, 0), (7, 2, 0), (8, 3, 0)]
+            )
+            def test_bottom(self, height, top, bottom):
+                padding = AlignedPadding(1, height, v_align=VAlign.BOTTOM)
+                assert padding._get_exact_dimensions_(Size(5, 5)) == (0, top, 0, bottom)
+
+    def test_immutability(self):
+        padding = AlignedPadding(1, 1)
+        # Known
+        with pytest.raises(AttributeError):
+            padding.fill = Ellipsis
+        # Unknown
+        with pytest.raises(AttributeError):
+            padding.foo = Ellipsis
+
+    @pytest.mark.parametrize(
+        "args1,kwargs1,args2,kwargs2",
+        [
+            ((1, 1), {}, (1, 1), {}),
+            ((-1, 1), {}, (-1, 1), {}),
+            ((0, 0), {}, (0, 0, HAlign.CENTER, VAlign.MIDDLE, " "), {}),
+            ((1, 2, HAlign.LEFT, VAlign.BOTTOM), {}) * 2,
+            (
+                (1, 2, HAlign.LEFT, VAlign.BOTTOM),
+                {},
+                (),
+                dict(width=1, height=2, h_align=HAlign.LEFT, v_align=VAlign.BOTTOM),
+            ),
+            ((5, 2), {}, (5,), dict(height=2)),
+            ((1, 1), {}, (1, 1), dict(fill=" ")),
+            ((1, 1, HAlign.CENTER, VAlign.MIDDLE, "#"), {}, (1, 1), dict(fill="#")),
+            ((1, 1), dict(fill="*"), (1, 1), dict(fill="*")),
+        ],
+    )
+    def test_equality(self, args1, kwargs1, args2, kwargs2):
+        assert AlignedPadding(*args1, **kwargs1) == AlignedPadding(*args2, **kwargs2)
+
+    @pytest.mark.parametrize(
+        "args1,kwargs1,args2,kwargs2",
+        [
+            ((1, 1), {}, (1, 0, HAlign.CENTER, VAlign.MIDDLE, " "), {}),
+            ((1, 2), {}, (2, 2), {}),
+            ((1, 2), {}, (1, 3), {}),
+            ((1, 2), {}, (1, 2, HAlign.LEFT, VAlign.MIDDLE), {}),
+            ((1, 2), {}, (1, 2, HAlign.CENTER, VAlign.BOTTOM), {}),
+            ((1, 1), {}, (1, 1), dict(fill="#")),
+            ((1, 1), dict(fill="#"), (1, 1), dict(fill="*")),
+        ],
+    )
+    def test_unequality(self, args1, kwargs1, args2, kwargs2):
+        assert AlignedPadding(*args1, **kwargs1) != AlignedPadding(*args2, **kwargs2)
+
+
+class TestExactPadding:
+    @pytest.mark.parametrize("field_name", ["left", "top", "right", "bottom"])
+    def test_non_negative_dimensions_only(self, field_name):
+        with pytest.raises(ValueError, match=f"{field_name!r}"):
+            ExactPadding(**{field_name: -1})
+
+    def test_left(self):
+        assert ExactPadding().left == 0
+        assert ExactPadding(10).left == 10
+
+    def test_top(self):
+        assert ExactPadding().top == 0
+        assert ExactPadding(top=10).top == 10
+
+    def test_right(self):
+        assert ExactPadding().right == 0
+        assert ExactPadding(right=10).right == 10
+
+    def test_bottom(self):
+        assert ExactPadding().bottom == 0
+        assert ExactPadding(bottom=10).bottom == 10
+
+    def test_fill(self):
+        assert ExactPadding(1, 1).fill == " "
+        assert ExactPadding(1, 1, fill="#").fill == "#"
+
+    @pytest.mark.parametrize("dimensions", [(0, 0, 0, 0), (1, 2, 3, 4), (5, 5, 5, 5)])
+    def test_dimensions(self, dimensions):
+        padding = ExactPadding(*dimensions)
+        assert padding.dimensions == dimensions
+
+    @pytest.mark.parametrize("dimensions", [(0, 0, 0, 0), (1, 2, 3, 4), (5, 5, 5, 5)])
+    def test_get_exact_dimensions(self, dimensions):
+        padding = ExactPadding(*dimensions)
+        assert padding._get_exact_dimensions_(Size(5, 5)) == dimensions
+
+    def test_immutability(self):
+        padding = ExactPadding()
+        # Known
+        with pytest.raises(AttributeError):
+            padding.fill = Ellipsis
+        # Unknown
+        with pytest.raises(AttributeError):
+            padding.foo = Ellipsis
+
+    @pytest.mark.parametrize(
+        "args1,kwargs1,args2,kwargs2",
+        [
+            ((), {}, (), {}),
+            ((), {}, (0, 0, 0, 0, " "), {}),
+            ((1, 2, 3, 4), {}, (1, 2, 3, 4), {}),
+            ((1, 2, 3, 4), {}, (), dict(right=3, bottom=4, top=2, left=1)),
+            ((5, 2), {}, (5,), dict(top=2)),
+            ((), {}, (), dict(fill=" ")),
+            ((0, 0, 0, 0, "#"), {}, (), dict(fill="#")),
+            ((), dict(fill="*"), (), dict(fill="*")),
+        ],
+    )
+    def test_equality(self, args1, kwargs1, args2, kwargs2):
+        assert ExactPadding(*args1, **kwargs1) == ExactPadding(*args2, **kwargs2)
+
+    @pytest.mark.parametrize(
+        "args1,kwargs1,args2,kwargs2",
+        [
+            ((), {}, (0, 0, 1, 0, " "), {}),
+            ((1, 2, 3, 4), {}, (2, 2, 3, 4), {}),
+            ((1, 2, 3, 4), {}, (1, 3, 3, 4), {}),
+            ((1, 2, 3, 4), {}, (1, 2, 4, 4), {}),
+            ((1, 2, 3, 4), {}, (1, 2, 3, 5), {}),
+            ((), {}, (), dict(fill="#")),
+            ((), dict(fill="#"), (), dict(fill="*")),
+        ],
+    )
+    def test_unequality(self, args1, kwargs1, args2, kwargs2):
+        assert ExactPadding(*args1, **kwargs1) != ExactPadding(*args2, **kwargs2)