Source code for blessed.sequences

# -*- coding: utf-8 -*-
"""Module providing 'sequence awareness'."""
# std imports
import re
import math
import textwrap

# 3rd party
import six
from wcwidth import wcwidth

# local
from blessed._capabilities import CAPABILITIES_CAUSE_MOVEMENT

__all__ = ('Sequence', 'SequenceTextWrapper', 'iter_parse', 'measure_length')


class Termcap(object):
    """Terminal capability of given variable name and pattern."""

    def __init__(self, name, pattern, attribute):
        """
        Class initializer.

        :arg str name: name describing capability.
        :arg str pattern: regular expression string.
        :arg str attribute: :class:`~.Terminal` attribute used to build
            this terminal capability.
        """
        self.name = name
        self.pattern = pattern
        self.attribute = attribute
        self._re_compiled = None

    def __repr__(self):
        # pylint: disable=redundant-keyword-arg
        return '<Termcap {self.name}:{self.pattern!r}>'.format(self=self)

    @property
    def named_pattern(self):
        """Regular expression pattern for capability with named group."""
        # pylint: disable=redundant-keyword-arg
        return '(?P<{self.name}>{self.pattern})'.format(self=self)

    @property
    def re_compiled(self):
        """Compiled regular expression pattern for capability."""
        if self._re_compiled is None:
            self._re_compiled = re.compile(self.pattern)
        return self._re_compiled

    @property
    def will_move(self):
        """Whether capability causes cursor movement."""
        return self.name in CAPABILITIES_CAUSE_MOVEMENT

    def horizontal_distance(self, text):
        """
        Horizontal carriage adjusted by capability, may be negative.

        :rtype: int
        :arg str text: for capabilities *parm_left_cursor*,
            *parm_right_cursor*, provide the matching sequence
            text, its interpreted distance is returned.

        :returns: 0 except for matching '
        """
        value = {
            'cursor_left': -1,
            'backspace': -1,
            'cursor_right': 1,
            'tab': 8,
            'ascii_tab': 8,
        }.get(self.name, None)
        if value is not None:
            return value

        unit = {
            'parm_left_cursor': -1,
            'parm_right_cursor': 1
        }.get(self.name, None)
        if unit is not None:
            value = int(self.re_compiled.match(text).group(1))
            return unit * value

        return 0

    # pylint: disable=too-many-arguments
    @classmethod
    def build(cls, name, capability, attribute, nparams=0,
              numeric=99, match_grouped=False, match_any=False,
              match_optional=False):
        r"""
        Class factory builder for given capability definition.

        :arg str name: Variable name given for this pattern.
        :arg str capability: A unicode string representing a terminal
            capability to build for. When ``nparams`` is non-zero, it
            must be a callable unicode string (such as the result from
            ``getattr(term, 'bold')``.
        :arg str attribute: The terminfo(5) capability name by which this
            pattern is known.
        :arg int nparams: number of positional arguments for callable.
        :arg int numeric: Value to substitute into capability to when generating pattern
        :arg bool match_grouped: If the numeric pattern should be
            grouped, ``(\d+)`` when ``True``, ``\d+`` default.
        :arg bool match_any: When keyword argument ``nparams`` is given,
            *any* numeric found in output is suitable for building as
            pattern ``(\d+)``.  Otherwise, only the first matching value of
            range *(numeric - 1)* through *(numeric + 1)* will be replaced by
            pattern ``(\d+)`` in builder.
        :arg bool match_optional: When ``True``, building of numeric patterns
            containing ``(\d+)`` will be built as optional, ``(\d+)?``.
        :rtype: blessed.sequences.Termcap
        :returns: Terminal capability instance for given capability definition
        """
        _numeric_regex = r'\d+'
        if match_grouped:
            _numeric_regex = r'(\d+)'
        if match_optional:
            _numeric_regex = r'(\d+)?'
        numeric = 99 if numeric is None else numeric

        # basic capability attribute, not used as a callable
        if nparams == 0:
            return cls(name, re.escape(capability), attribute)

        # a callable capability accepting numeric argument
        _outp = re.escape(capability(*(numeric,) * nparams))
        if not match_any:
            for num in range(numeric - 1, numeric + 2):
                if str(num) in _outp:
                    pattern = _outp.replace(str(num), _numeric_regex)
                    return cls(name, pattern, attribute)

        if match_grouped:
            pattern = re.sub(r'(\d+)', lambda x: _numeric_regex, _outp)
        else:
            pattern = re.sub(r'\d+', lambda x: _numeric_regex, _outp)
        return cls(name, pattern, attribute)


[docs]class SequenceTextWrapper(textwrap.TextWrapper): """Docstring overridden.""" def __init__(self, width, term, **kwargs): """ Class initializer. This class supports the :meth:`~.Terminal.wrap` method. """ self.term = term textwrap.TextWrapper.__init__(self, width, **kwargs)
[docs] def _wrap_chunks(self, chunks): """ Sequence-aware variant of :meth:`textwrap.TextWrapper._wrap_chunks`. :raises ValueError: ``self.width`` is not a positive integer :rtype: list :returns: text chunks adjusted for width This simply ensures that word boundaries are not broken mid-sequence, as standard python textwrap would incorrectly determine the length of a string containing sequences, and may also break consider sequences part of a "word" that may be broken by hyphen (``-``), where this implementation corrects both. """ lines = [] if self.width <= 0 or not isinstance(self.width, int): raise ValueError( "invalid width {0!r}({1!r}) (must be integer > 0)" .format(self.width, type(self.width))) term = self.term drop_whitespace = not hasattr(self, 'drop_whitespace' ) or self.drop_whitespace chunks.reverse() while chunks: cur_line = [] cur_len = 0 if lines: indent = self.subsequent_indent else: indent = self.initial_indent width = self.width - len(indent) if drop_whitespace and ( Sequence(chunks[-1], term).strip() == '' and lines): del chunks[-1] while chunks: chunk_len = Sequence(chunks[-1], term).length() if cur_len + chunk_len <= width: cur_line.append(chunks.pop()) cur_len += chunk_len else: break if chunks and Sequence(chunks[-1], term).length() > width: self._handle_long_word(chunks, cur_line, cur_len, width) if drop_whitespace and ( cur_line and Sequence(cur_line[-1], term).strip() == ''): del cur_line[-1] if cur_line: lines.append(indent + u''.join(cur_line)) return lines
[docs] def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width): """ Sequence-aware :meth:`textwrap.TextWrapper._handle_long_word`. This simply ensures that word boundaries are not broken mid-sequence, as standard python textwrap would incorrectly determine the length of a string containing sequences, and may also break consider sequences part of a "word" that may be broken by hyphen (``-``), where this implementation corrects both. """ # Figure out when indent is larger than the specified width, and make # sure at least one character is stripped off on every pass if width < 1: space_left = 1 else: space_left = width - cur_len # If we're allowed to break long words, then do so: put as much # of the next chunk onto the current line as will fit. if self.break_long_words: term = self.term chunk = reversed_chunks[-1] idx = nxt = 0 for text, _ in iter_parse(term, chunk): nxt += len(text) if Sequence(chunk[:nxt], term).length() > space_left: break idx = nxt cur_line.append(chunk[:idx]) reversed_chunks[-1] = chunk[idx:] # Otherwise, we have to preserve the long word intact. Only add # it to the current line if there's nothing already there -- # that minimizes how much we violate the width constraint. elif not cur_line: cur_line.append(reversed_chunks.pop())
# If we're not allowed to break long words, and there's already # text on the current line, do nothing. Next time through the # main loop of _wrap_chunks(), we'll wind up here again, but # cur_len will be zero, so the next line will be entirely # devoted to the long word that we can't handle right now. SequenceTextWrapper.__doc__ = textwrap.TextWrapper.__doc__
[docs]class Sequence(six.text_type): """ A "sequence-aware" version of the base :class:`str` class. This unicode-derived class understands the effect of escape sequences of printable length, allowing a properly implemented :meth:`rjust`, :meth:`ljust`, :meth:`center`, and :meth:`length`. """ def __new__(cls, sequence_text, term): # pylint: disable = missing-return-doc, missing-return-type-doc """ Class constructor. :arg str sequence_text: A string that may contain sequences. :arg blessed.Terminal term: :class:`~.Terminal` instance. """ new = six.text_type.__new__(cls, sequence_text) new._term = term return new
[docs] def ljust(self, width, fillchar=u' '): """ Return string containing sequences, left-adjusted. :arg int width: Total width given to left-adjust ``text``. If unspecified, the width of the attached terminal is used (default). :arg str fillchar: String for padding right-of ``text``. :returns: String of ``text``, left-aligned by ``width``. :rtype: str """ rightside = fillchar * int( (max(0.0, float(width - self.length()))) / float(len(fillchar))) return u''.join((self, rightside))
[docs] def rjust(self, width, fillchar=u' '): """ Return string containing sequences, right-adjusted. :arg int width: Total width given to right-adjust ``text``. If unspecified, the width of the attached terminal is used (default). :arg str fillchar: String for padding left-of ``text``. :returns: String of ``text``, right-aligned by ``width``. :rtype: str """ leftside = fillchar * int( (max(0.0, float(width - self.length()))) / float(len(fillchar))) return u''.join((leftside, self))
[docs] def center(self, width, fillchar=u' '): """ Return string containing sequences, centered. :arg int width: Total width given to center ``text``. If unspecified, the width of the attached terminal is used (default). :arg str fillchar: String for padding left and right-of ``text``. :returns: String of ``text``, centered by ``width``. :rtype: str """ split = max(0.0, float(width) - self.length()) / 2 leftside = fillchar * int( (max(0.0, math.floor(split))) / float(len(fillchar))) rightside = fillchar * int( (max(0.0, math.ceil(split))) / float(len(fillchar))) return u''.join((leftside, self, rightside))
[docs] def length(self): r""" Return the printable length of string containing sequences. Strings containing ``term.left`` or ``\b`` will cause "overstrike", but a length less than 0 is not ever returned. So ``_\b+`` is a length of 1 (displays as ``+``), but ``\b`` alone is simply a length of 0. Some characters may consume more than one cell, mainly those CJK Unified Ideographs (Chinese, Japanese, Korean) defined by Unicode as half or full-width characters. For example: >>> from blessed import Terminal >>> from blessed.sequences import Sequence >>> term = Terminal() >>> msg = term.clear + term.red(u'コンニチハ') >>> Sequence(msg, term).length() 10 .. note:: Although accounted for, strings containing sequences such as ``term.clear`` will not give accurate returns, it is not considered lengthy (a length of 0). """ # because control characters may return -1, "clip" their length to 0. return sum(max(wcwidth(w_char), 0) for w_char in self.padd(strip=True))
[docs] def strip(self, chars=None): """ Return string of sequences, leading and trailing whitespace removed. :arg str chars: Remove characters in chars instead of whitespace. :rtype: str :returns: string of sequences with leading and trailing whitespace removed. """ return self.strip_seqs().strip(chars)
[docs] def lstrip(self, chars=None): """ Return string of all sequences and leading whitespace removed. :arg str chars: Remove characters in chars instead of whitespace. :rtype: str :returns: string of sequences with leading removed. """ return self.strip_seqs().lstrip(chars)
[docs] def rstrip(self, chars=None): """ Return string of all sequences and trailing whitespace removed. :arg str chars: Remove characters in chars instead of whitespace. :rtype: str :returns: string of sequences with trailing removed. """ return self.strip_seqs().rstrip(chars)
[docs] def strip_seqs(self): """ Return ``text`` stripped of only its terminal sequences. :rtype: str :returns: Text with terminal sequences removed """ return self.padd(strip=True)
[docs] def padd(self, strip=False): """ Return non-destructive horizontal movement as destructive spacing. :arg bool strip: Strip terminal sequences :rtype: str :returns: Text adjusted for horizontal movement """ outp = '' for text, cap in iter_parse(self._term, self): if not cap: outp += text continue value = cap.horizontal_distance(text) if value > 0: outp += ' ' * value elif value < 0: outp = outp[:value] elif not strip: outp += text return outp
[docs]def iter_parse(term, text): """ Generator yields (text, capability) for characters of ``text``. value for ``capability`` may be ``None``, where ``text`` is :class:`str` of length 1. Otherwise, ``text`` is a full matching sequence of given capability. """ for match in term._caps_compiled_any.finditer(text): # pylint: disable=protected-access name = match.lastgroup value = match.group(name) if name == 'MISMATCH': yield (value, None) else: yield value, term.caps[name]
[docs]def measure_length(text, term): """ .. deprecated:: 1.12.0. :rtype: int :returns: Length of the first sequence in the string """ try: text, capability = next(iter_parse(term, text)) if capability: return len(text) except StopIteration: return 0 return 0