"""
.. 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 typing_extensions import override
from ._ctlseqs import cursor_forward
from .exceptions import TermImageError
from .geometry import RawSize, Size, _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 =================================================================
[docs]
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:
"""
[docs]
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 ======================================================================
[docs]
class Padding(metaclass=ABCMeta):
""":term:`Render output` padding.
Args:
fill: Determines the string with which render outputs will be padded.
May be any string that occupies exactly **one column** on a terminal screen,
or an empty string. If empty, the padding simply advances the cursor without
overwriting existing content on the 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"""
# Special Methods ==========================================================
def __init__(self, fill: str = " ") -> None:
# Subclasses are to be "immutable", `super()` is costlier
Padding.__setattr__(self, "fill", fill)
# Public Methods ===========================================================
[docs]
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)
[docs]
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
if 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 ""
else:
left_padding = cursor_forward(left)
right_padding = cursor_forward(right)
top_padding = f"{cursor_forward(width)}\n" * top if top else ""
bottom_padding = f"\n{cursor_forward(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
)
[docs]
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 ========================================================
[docs]
@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
[docs]
@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 ===========================================================
[docs]
@override
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(max(self.width, render_size[0]), max(self.height, render_size[1]))
[docs]
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 ========================================================
@override
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
[docs]
@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]
# Extension methods ========================================================
@override
def _get_exact_dimensions_(self, render_size: Size) -> tuple[int, int, int, int]:
return astuple(self)[:4]
# Exceptions ===================================================================
[docs]
class PaddingError(TermImageError):
"""Base exception class for padding errors."""
[docs]
class RelativePaddingDimensionError(PaddingError):
"""Raised when a padding operation is performed on an :py:class:`AlignedPadding`
instance with **relative** minimum render dimension(s).
"""