"""Sub-module providing sequence-formatting functions."""
from __future__ import annotations
# std imports
import os
import pkgutil
import warnings
import importlib
from typing import TYPE_CHECKING, Set, List, Tuple, Union, Callable
# local
from blessed.colorspace import CGA_COLORS, X11_COLORNAMES_TO_RGB
from blessed._capabilities import CAPABILITY_DATABASE
if TYPE_CHECKING: # pragma: no cover
# local
from blessed.terminal import Terminal
# 3rd party
import jinxed
import jinxed.terminfo
[docs]
def _build_known_capability_names() -> 'frozenset[str]':
"""Return frozenset of all terminal capability names known to jinxed."""
caps: set[str] = set(jinxed.terminfo.BOOL_CAPS)
caps.update(jinxed.terminfo.NUM_CAPS)
for _, modname, _ in pkgutil.iter_modules(jinxed.terminfo.__path__):
if modname.startswith('_'):
continue
mod = importlib.import_module(f'jinxed.terminfo.{modname}')
caps.update(getattr(mod, 'STR_CAPS', {}).keys())
# Also include attribute names from blessed's own capability database.
for _name, (attr, _) in CAPABILITY_DATABASE.items():
caps.add(attr)
return frozenset(caps)
_KNOWN_CAPABILITY_NAMES = _build_known_capability_names()
_NOWARN_UNKNOWN_CAPS = bool(os.environ.get('BLESSED_NOWARN_UNKNOWN_CAPS'))
[docs]
def _make_colors() -> Set[str]:
"""
Return set of valid colors and their derivatives.
:rtype: set
:returns: Color names with prefixes
"""
colors = set()
# basic CGA foreground color, background, high intensity, and bold
# background ('iCE colors' in my day).
for cga_color in CGA_COLORS:
colors.add(cga_color)
colors.add(f'on_{cga_color}')
colors.add(f'bright_{cga_color}')
colors.add(f'on_bright_{cga_color}')
# foreground and background VGA color
for vga_color in X11_COLORNAMES_TO_RGB:
colors.add(vga_color)
colors.add(f'on_{vga_color}')
return colors
#: Valid colors and their background (on), bright, and bright-background
#: derivatives.
COLORS: Set[str] = _make_colors()
#: Attributes that may be compounded with colors, by underscore, such as
#: 'reverse_indigo'.
COMPOUNDABLES: Set[str] = set('bold underline reverse blink dim italic standout'.split())
[docs]
class ParameterizingString(str):
r"""
A Unicode string which can be called as a parameterizing termcap.
For example::
>>> from blessed import Terminal
>>> term = Terminal()
>>> color = ParameterizingString(term.color, term.normal, 'color')
>>> color(9)('color #9')
'\x1b[91mcolor #9\x1b(B\x1b[m'
"""
def __new__(cls, cap: str, normal: str = '',
name: str = '<not specified>') -> ParameterizingString:
"""
Class constructor accepting 3 positional arguments.
:arg str cap: parameterized string suitable for jinxed.tparm()
:arg str normal: terminating sequence for this capability (optional).
:arg str name: name of this terminal capability (optional).
"""
new = str.__new__(cls, cap)
new._normal = normal
new._name = name
return new
[docs]
def __call__(self, *args: object) -> "FormattingString":
"""
Returning :class:`FormattingString` instance for given parameters.
Return evaluated terminal capability (self), receiving arguments
``*args``, followed by the terminating sequence (self.normal) into
a :class:`FormattingString` capable of being called.
:raises TypeError: Mismatch between capability and arguments
:raises jinxed.error: :func:`jinxed.tparm` raised an exception
:rtype: :class:`FormattingString` or :class:`NullCallableString`
:returns: Callable string for given parameters
"""
try:
# Re-encode the cap, because tparm() takes a bytestring in Python
# 3. However, appear to be a plain Unicode string otherwise so
# concats work.
attr = jinxed.tparm(self.encode('latin1'), *args).decode('latin1')
return FormattingString(attr, self._normal)
except TypeError as err:
# If the first non-int (i.e. incorrect) arg was a string, suggest
# something intelligent:
if args and isinstance(args[0], str):
raise TypeError(
f"Unknown terminal capability, {self._name!r}, or, TypeError "
f"for arguments {args!r}: {err}") from err
# Somebody passed a non-string; I don't feel confident
# guessing what they were trying to do.
raise
except jinxed.error as err: # pragma: no cover
# ignore 'tparm() returned NULL', you won't get any styling,
# even if does_styling is True. This happens on win32 platforms
# with http://www.lfd.uci.edu/~gohlke/pythonlibs/#curses installed
if "tparm() returned NULL" not in str(err):
raise
return NullCallableString()
[docs]
class ParameterizingProxyString(str):
r"""
A Unicode string which can be called to proxy missing termcap entries.
.. deprecated 1.40::
All previously-proxied terminfo(5) capabilities are now provided directly by the capability
database in jinxed. This class is unused by blessed but kept for API compatibility.
This class mirrors the behavior of :class:`ParameterizingString`, except that instead of a
capability name, receives a format string, and callable to filter the given positional ``*args``
of :meth:`ParameterizingProxyString.__call__` into a terminal sequence.
For example::
>>> from blessed import Terminal
>>> term = Terminal('screen')
>>> hpa = ParameterizingString(term.hpa, term.normal, 'hpa')
>>> hpa(9)
''
>>> fmt = '\x1b[{0}G'
>>> fmt_arg = lambda *arg: (arg[0] + 1,)
>>> hpa = ParameterizingProxyString((fmt, fmt_arg), term.normal, 'hpa')
>>> hpa(9)
'\x1b[10G'
"""
def __new__(cls, fmt_pair: Tuple[str, Callable[..., Tuple[object, ...]]],
normal: str = '', name: str = '<not specified>') -> ParameterizingProxyString:
"""
Class constructor accepting 4 positional arguments.
:arg tuple fmt_pair: Two element tuple containing:
- format string suitable for displaying terminal sequences
- callable suitable for receiving __call__ arguments for formatting string
:arg str normal: terminating sequence for this capability (optional).
:arg str name: name of this terminal capability (optional).
"""
assert isinstance(fmt_pair, tuple), fmt_pair
assert callable(fmt_pair[1]), fmt_pair[1]
new = str.__new__(cls, fmt_pair[0])
new._fmt_args = fmt_pair[1]
new._normal = normal
new._name = name
return new
[docs]
def __call__(self, *args: object) -> "FormattingString":
"""
Returning :class:`FormattingString` instance for given parameters.
Arguments are determined by the capability. For example, ``hpa``
(move_x) receives only a single integer, whereas ``cup`` (move)
receives two integers. See documentation in terminfo(5) for the
given capability.
:rtype: FormattingString
:returns: Callable string for given parameters
"""
return FormattingString(self.format(*self._fmt_args(*args)), self._normal)
[docs]
class NullCallableString(str):
"""
A dummy callable Unicode alternative to :class:`FormattingString`.
This is used for colors on terminals that do not support colors, it is just a basic form of
unicode that may also act as a callable.
"""
def __new__(cls) -> NullCallableString:
"""Class constructor."""
return str.__new__(cls, '')
[docs]
def __call__(self, *args: str) -> str:
"""
Allow empty string to be callable, returning given string, if any.
When called with an int as the first arg, return an empty Unicode. An int is a good hint
that I am a :class:`ParameterizingString`, as there are only about half a dozen string-
returning capabilities listed in terminfo(5) which accept non-int arguments, they are seldom
used.
When called with a non-int as the first arg (no no args at all), return the first arg,
acting in place of :class:`FormattingString` without any attributes.
"""
if not args or isinstance(args[0], int):
# As a NullCallableString, even when provided with a parameter,
# such as t.color(5), we must also still be callable, fe:
#
# >>> t.color(5)('shmoo')
#
# is actually simplified result of NullCallable()() on terminals
# without color support, so turtles all the way down: we return
# another instance.
return NullCallableString()
return ''.join(args)
[docs]
def get_proxy_string( # pylint: disable=unused-argument
term: 'Terminal', attr: str) -> None:
"""
Proxy and return callable string for proxied attributes.
.. deprecated 1.40::
All previously-proxied terminfo(5) capabilities are now provided directly by the capability
database in jinxed as "patches" in
https://github.com/Rockhopper-Technologies/jinxed/blob/main/terminals.toml.
This function always returns ``None``.
"""
return None
[docs]
def split_compound(compound: str) -> List[str]:
"""
Split compound formatting string into segments.
>>> split_compound('bold_underline_bright_blue_on_red')
['bold', 'underline', 'bright_blue', 'on_red']
:arg str compound: a string that may contain compounds, separated by
underline (``_``).
:rtype: list
:returns: List of formatting string segments
"""
merged_segs: List[str] = []
# These occur only as prefixes, so they can always be merged:
mergeable_prefixes = ['on', 'bright', 'on_bright']
for segment in compound.split('_'):
if merged_segs and merged_segs[-1] in mergeable_prefixes:
merged_segs[-1] += f'_{segment}'
else:
merged_segs.append(segment)
return merged_segs
[docs]
def resolve_capability(term: 'Terminal', attr: str) -> str:
"""
Resolve a raw terminal capability using :func:`tigetstr`.
:arg Terminal term: :class:`~.Terminal` instance.
:arg str attr: terminal capability name.
:returns: string of the given terminal capability named by ``attr``,
which may be empty ('') if not found or not supported by the
given :attr:`~.Terminal.kind`.
:rtype: str
"""
if not term.does_styling:
return ''
capname = term._sugar.get(attr, attr) # pylint: disable=protected-access
val = term._jinxed_term.tigetstr(capname) # pylint: disable=protected-access
if val is None:
if capname not in _KNOWN_CAPABILITY_NAMES and not _NOWARN_UNKNOWN_CAPS:
warnings.warn(
f"unknown terminal capability: {capname!r}",
stacklevel=4,
)
return ''
# Decode sequences as latin1, as they are always 8-bit bytes, so when
# b'\xff' is returned, this is decoded as '\xff'.
return val.decode('latin1')
[docs]
def resolve_color(term: 'Terminal', color: str) -> Union[NullCallableString, FormattingString]:
"""
Resolve a simple color name to a callable capability.
This function supports :func:`resolve_attribute`.
:arg Terminal term: :class:`~.Terminal` instance.
:arg str color: any string found in set :const:`COLORS`.
:returns: a string class instance which emits the terminal sequence
for the given color, and may be used as a callable to wrap the
given string with such sequence.
:returns: :class:`NullCallableString` when
:attr:`~.Terminal.number_of_colors` is 0,
otherwise :class:`FormattingString`.
:rtype: :class:`NullCallableString` or :class:`FormattingString`
"""
# pylint: disable=protected-access
if term.number_of_colors == 0:
return NullCallableString()
# fg/bg capabilities terminals that support 0-256+ colors.
vga_color_cap = (term._background_color if 'on_' in color else
term._foreground_color)
base_color = color.rsplit('_', 1)[-1]
if base_color in CGA_COLORS:
# curses constants go up to only 7, so add an offset to get at the
# bright colors at 8-15:
offset = 8 if 'bright_' in color else 0
base_color = color.rsplit('_', 1)[-1]
fmt_attr = vga_color_cap(getattr(jinxed, f'COLOR_{base_color.upper()}') + offset)
return FormattingString(fmt_attr, term.normal)
assert base_color in X11_COLORNAMES_TO_RGB, (
'color not known', base_color)
rgb = X11_COLORNAMES_TO_RGB[base_color]
# downconvert X11 colors to CGA, EGA, or VGA color spaces
if term.number_of_colors <= 256:
fmt_attr = vga_color_cap(term.rgb_downconvert(*rgb))
return FormattingString(fmt_attr, term.normal)
# Modern 24-bit color terminals are written pretty basically. The
# foreground and background sequences are:
# - ^[38;2;<r>;<g>;<b>m
# - ^[48;2;<r>;<g>;<b>m
assert term.number_of_colors == 1 << 24
return FormattingString(
f'\x1b[{("48" if "on_" in color else "38")};2;{rgb.red};{rgb.green};{rgb.blue}m',
term.normal
)
[docs]
def resolve_attribute(term: 'Terminal', attr: str) -> Union[ParameterizingString, FormattingString]:
"""
Resolve a terminal attribute name into a capability class.
:arg Terminal term: :class:`~.Terminal` instance.
:arg str attr: Sugary, ordinary, or compound formatted terminal
capability, such as "red_on_white", "normal", "red", or
"bold_on_black".
:returns: a string class instance which emits the terminal sequence
for the given terminal capability, or may be used as a callable to
wrap the given string with such sequence.
:returns: :class:`NullCallableString` when
:attr:`~.Terminal.number_of_colors` is 0,
otherwise :class:`FormattingString`.
:rtype: :class:`NullCallableString` or :class:`FormattingString`
"""
if attr in COLORS:
return resolve_color(term, attr)
# A direct compoundable, such as `bold' or `on_red'.
if attr in COMPOUNDABLES:
sequence = resolve_capability(term, attr)
return FormattingString(sequence, term.normal)
# Given `bold_on_red', resolve to ('bold', 'on_red'), RECURSIVE
# call for each compounding section, joined and returned as
# a completed completed FormattingString.
formatters = split_compound(attr)
if all((fmt in COLORS or fmt in COMPOUNDABLES) for fmt in formatters):
resolution = (resolve_attribute(term, fmt) for fmt in formatters)
return FormattingString(''.join(resolution), term.normal)
# otherwise, this is our end-game: given a sequence such as 'csr'
# (change scrolling region), return a ParameterizingString instance,
# that when called, performs and returns the final string after curses
# capability lookup is performed.
tparm_capseq = resolve_capability(term, attr)
return ParameterizingString(tparm_capseq, term.normal, attr)