diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 16:40:16 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 16:40:16 +0000 |
commit | 3f25952c13d5847d510c0cae22a8ba876638d570 (patch) | |
tree | 02f505f016ed5a1029277dcae520d5e2a75906fb /powerline/renderer.py | |
parent | Initial commit. (diff) | |
download | powerline-3f25952c13d5847d510c0cae22a8ba876638d570.tar.xz powerline-3f25952c13d5847d510c0cae22a8ba876638d570.zip |
Adding upstream version 2.8.3.upstream/2.8.3upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'powerline/renderer.py')
-rw-r--r-- | powerline/renderer.py | 606 |
1 files changed, 606 insertions, 0 deletions
diff --git a/powerline/renderer.py b/powerline/renderer.py new file mode 100644 index 0000000..31aca80 --- /dev/null +++ b/powerline/renderer.py @@ -0,0 +1,606 @@ +# vim:fileencoding=utf-8:noet +from __future__ import (unicode_literals, division, absolute_import, print_function) + +import sys +import os +import re +import operator + +from itertools import chain + +from powerline.theme import Theme +from powerline.lib.unicode import unichr, strwidth_ucs_2, strwidth_ucs_4 + + +NBSP = ' ' + + +np_control_character_translations = dict(( + # Control characters: ^@ … ^Y + (i1, '^' + unichr(i1 + 0x40)) for i1 in range(0x20) +)) +'''Control character translations + +Dictionary that maps characters in range 0x00–0x1F (inclusive) to strings +``'^@'``, ``'^A'`` and so on. + +.. note: maps tab to ``^I`` and newline to ``^J``. +''' + +np_invalid_character_translations = dict(( + # Invalid unicode characters obtained using 'surrogateescape' error + # handler. + (i2, '<{0:02x}>'.format(i2 - 0xDC00)) for i2 in range(0xDC80, 0xDD00) +)) +'''Invalid unicode character translations + +When using ``surrogateescape`` encoding error handling method characters in +range 0x80–0xFF (inclusive) are transformed into unpaired surrogate escape +unicode codepoints 0xDC80–0xDD00. This dictionary maps such characters to +``<80>``, ``<81>``, and so on: in Python-3 they cannot be printed or +converted to UTF-8 because UTF-8 standard does not allow surrogate escape +characters, not even paired ones. Python-2 contains a bug that allows such +action, but printing them in any case makes no sense. +''' + +# XXX: not using `r` because it makes no sense. +np_invalid_character_re = re.compile('(?<![\uD800-\uDBFF])[\uDC80-\uDD00]') +'''Regex that finds unpaired surrogate escape characters + +Search is only limited to the ones obtained from ``surrogateescape`` error +handling method. This regex is only used for UCS-2 Python variants because +in this case characters above 0xFFFF are represented as surrogate escapes +characters and are thus subject to partial transformation if +``np_invalid_character_translations`` translation table is used. +''' + +np_character_translations = np_control_character_translations.copy() +'''Dictionary that contains non-printable character translations + +In UCS-4 versions of Python this is a union of +``np_invalid_character_translations`` and ``np_control_character_translations`` +dictionaries. In UCS-2 for technical reasons ``np_invalid_character_re`` is used +instead and this dictionary only contains items from +``np_control_character_translations``. +''' + +translate_np = ( + ( + lambda s: ( + np_invalid_character_re.subn( + lambda match: ( + np_invalid_character_translations[ord(match.group(0))] + ), s + )[0].translate(np_character_translations) + ) + ) if sys.maxunicode < 0x10FFFF else ( + lambda s: ( + s.translate(np_character_translations) + ) + ) +) +'''Function that translates non-printable characters into printable strings + +Is used to translate control characters and surrogate escape characters +obtained from ``surrogateescape`` encoding errors handling method into some +printable sequences. See documentation for +``np_invalid_character_translations`` and +``np_control_character_translations`` for more details. +''' + + +def construct_returned_value(rendered_highlighted, segments, width, output_raw, output_width): + if not (output_raw or output_width): + return rendered_highlighted + else: + return ( + (rendered_highlighted,) + + ((''.join((segment['_rendered_raw'] for segment in segments)),) if output_raw else ()) + + ((width,) if output_width else ()) + ) + + +class Renderer(object): + '''Object that is responsible for generating the highlighted string. + + :param dict theme_config: + Main theme configuration. + :param local_themes: + Local themes. Is to be used by subclasses from ``.get_theme()`` method, + base class only records this parameter to a ``.local_themes`` attribute. + :param dict theme_kwargs: + Keyword arguments for ``Theme`` class constructor. + :param PowerlineLogger pl: + Object used for logging. + :param int ambiwidth: + Width of the characters with east asian width unicode attribute equal to + ``A`` (Ambiguous). + :param dict options: + Various options. Are normally not used by base renderer, but all options + are recorded as attributes. + ''' + + segment_info = { + 'environ': os.environ, + 'getcwd': getattr(os, 'getcwdu', os.getcwd), + 'home': os.environ.get('HOME'), + } + '''Basic segment info + + Is merged with local segment information by :py:meth:`get_segment_info` + method. Keys: + + ``environ`` + Object containing environment variables. Must define at least the + following methods: ``.__getitem__(var)`` that raises ``KeyError`` in + case requested environment variable is not present, ``.get(var, + default=None)`` that works like ``dict.get`` and be able to be passed to + ``Popen``. + + ``getcwd`` + Function that returns current working directory. Will be called without + any arguments, should return ``unicode`` or (in python-2) regular + string. + + ``home`` + String containing path to home directory. Should be ``unicode`` or (in + python-2) regular string or ``None``. + ''' + + character_translations = {} + '''Character translations for use in escape() function. + + See documentation of ``unicode.translate`` for details. + ''' + + def __init__(self, + theme_config, + local_themes, + theme_kwargs, + pl, + ambiwidth=1, + **options): + self.__dict__.update(options) + self.theme_config = theme_config + theme_kwargs['pl'] = pl + self.pl = pl + if theme_config.get('use_non_breaking_spaces', True): + self.character_translations = self.character_translations.copy() + self.character_translations[ord(' ')] = NBSP + self.theme = Theme(theme_config=theme_config, **theme_kwargs) + self.local_themes = local_themes + self.theme_kwargs = theme_kwargs + self.width_data = { + 'N': 1, # Neutral + 'Na': 1, # Narrow + 'A': ambiwidth, # Ambiguous + 'H': 1, # Half-width + 'W': 2, # Wide + 'F': 2, # Fullwidth + } + + strwidth = lambda self, s: ( + (strwidth_ucs_2 if sys.maxunicode < 0x10FFFF else strwidth_ucs_4)( + self.width_data, s) + ) + '''Function that returns string width. + + Is used to calculate the place given string occupies when handling + ``width`` argument to ``.render()`` method. Must take east asian width + into account. + + :param unicode string: + String whose width will be calculated. + + :return: unsigned integer. + ''' + + def get_theme(self, matcher_info): + '''Get Theme object. + + Is to be overridden by subclasses to support local themes, this variant + only returns ``.theme`` attribute. + + :param matcher_info: + Parameter ``matcher_info`` that ``.render()`` method received. + Unused. + ''' + return self.theme + + def shutdown(self): + '''Prepare for interpreter shutdown. The only job it is supposed to do + is calling ``.shutdown()`` method for all theme objects. Should be + overridden by subclasses in case they support local themes. + ''' + self.theme.shutdown() + + def get_segment_info(self, segment_info, mode): + '''Get segment information. + + Must return a dictionary containing at least ``home``, ``environ`` and + ``getcwd`` keys (see documentation for ``segment_info`` attribute). This + implementation merges ``segment_info`` dictionary passed to + ``.render()`` method with ``.segment_info`` attribute, preferring keys + from the former. It also replaces ``getcwd`` key with function returning + ``segment_info['environ']['PWD']`` in case ``PWD`` variable is + available. + + :param dict segment_info: + Segment information that was passed to ``.render()`` method. + + :return: dict with segment information. + ''' + r = self.segment_info.copy() + r['mode'] = mode + if segment_info: + r.update(segment_info) + if 'PWD' in r['environ']: + r['getcwd'] = lambda: r['environ']['PWD'] + return r + + def render_above_lines(self, **kwargs): + '''Render all segments in the {theme}/segments/above list + + Rendering happens in the reversed order. Parameters are the same as in + .render() method. + + :yield: rendered line. + ''' + + theme = self.get_theme(kwargs.get('matcher_info', None)) + for line in range(theme.get_line_number() - 1, 0, -1): + yield self.render(side=None, line=line, **kwargs) + + def render(self, mode=None, width=None, side=None, line=0, output_raw=False, output_width=False, segment_info=None, matcher_info=None, hl_args=None): + '''Render all segments. + + When a width is provided, low-priority segments are dropped one at + a time until the line is shorter than the width, or only segments + with a negative priority are left. If one or more segments with + ``"width": "auto"`` are provided they will fill the remaining space + until the desired width is reached. + + :param str mode: + Mode string. Affects contents (colors and the set of segments) of + rendered string. + :param int width: + Maximum width text can occupy. May be exceeded if there are too much + non-removable segments. + :param str side: + One of ``left``, ``right``. Determines which side will be rendered. + If not present all sides are rendered. + :param int line: + Line number for which segments should be obtained. Is counted from + zero (botmost line). + :param bool output_raw: + Changes the output: if this parameter is ``True`` then in place of + one string this method outputs a pair ``(colored_string, + colorless_string)``. + :param bool output_width: + Changes the output: if this parameter is ``True`` then in place of + one string this method outputs a pair ``(colored_string, + string_width)``. Returns a three-tuple if ``output_raw`` is also + ``True``: ``(colored_string, colorless_string, string_width)``. + :param dict segment_info: + Segment information. See also :py:meth:`get_segment_info` method. + :param matcher_info: + Matcher information. Is processed in :py:meth:`get_segment_info` + method. + :param dict hl_args: + Additional arguments to pass on the :py:meth:`hl` and + :py:meth`hlstyle` methods. They are ignored in the default + implementation, but renderer-specific overrides can make use of + them as run-time "configuration" information. + ''' + theme = self.get_theme(matcher_info) + return self.do_render( + mode=mode, + width=width, + side=side, + line=line, + output_raw=output_raw, + output_width=output_width, + segment_info=self.get_segment_info(segment_info, mode), + theme=theme, + hl_args=hl_args + ) + + def compute_divider_widths(self, theme): + return { + 'left': { + 'hard': self.strwidth(theme.get_divider('left', 'hard')), + 'soft': self.strwidth(theme.get_divider('left', 'soft')), + }, + 'right': { + 'hard': self.strwidth(theme.get_divider('right', 'hard')), + 'soft': self.strwidth(theme.get_divider('right', 'soft')), + }, + } + + hl_join = staticmethod(''.join) + '''Join a list of rendered segments into a resulting string + + This method exists to deal with non-string render outputs, so `segments` + may actually be not an iterable with strings. + + :param list segments: + Iterable containing rendered segments. By “rendered segments” + :py:meth:`Renderer.hl` output is meant. + + :return: Results of joining these segments. + ''' + + def do_render(self, mode, width, side, line, output_raw, output_width, segment_info, theme, hl_args): + '''Like Renderer.render(), but accept theme in place of matcher_info + ''' + segments = list(theme.get_segments(side, line, segment_info, mode)) + + current_width = 0 + + self._prepare_segments(segments, output_width or width) + + hl_args = hl_args or dict() + + if not width: + # No width specified, so we don’t need to crop or pad anything + if output_width: + current_width = self._render_length(theme, segments, self.compute_divider_widths(theme)) + return construct_returned_value(self.hl_join([ + segment['_rendered_hl'] + for segment in self._render_segments(theme, segments, hl_args) + ]) + self.hlstyle(**hl_args), segments, current_width, output_raw, output_width) + + divider_widths = self.compute_divider_widths(theme) + + # Create an ordered list of segments that can be dropped + segments_priority = sorted((segment for segment in segments if segment['priority'] is not None), key=lambda segment: segment['priority'], reverse=True) + no_priority_segments = filter(lambda segment: segment['priority'] is None, segments) + current_width = self._render_length(theme, segments, divider_widths) + if current_width > width: + for segment in chain(segments_priority, no_priority_segments): + if segment['truncate'] is not None: + segment['contents'] = segment['truncate'](self.pl, current_width - width, segment) + + segments_priority = iter(segments_priority) + if current_width > width and len(segments) > 100: + # When there are too many segments use faster, but less correct + # algorithm for width computation + diff = current_width - width + for segment in segments_priority: + segments.remove(segment) + diff -= segment['_len'] + if diff <= 0: + break + current_width = self._render_length(theme, segments, divider_widths) + if current_width > width: + # When there are not too much use more precise, but much slower + # width computation. It also finishes computations in case + # previous variant did not free enough space. + for segment in segments_priority: + segments.remove(segment) + current_width = self._render_length(theme, segments, divider_widths) + if current_width <= width: + break + del segments_priority + + # Distribute the remaining space on spacer segments + segments_spacers = [segment for segment in segments if segment['expand'] is not None] + if segments_spacers: + distribute_len, distribute_len_remainder = divmod(width - current_width, len(segments_spacers)) + for segment in segments_spacers: + segment['contents'] = ( + segment['expand']( + self.pl, + distribute_len + (1 if distribute_len_remainder > 0 else 0), + segment)) + distribute_len_remainder -= 1 + # `_len` key is not needed anymore, but current_width should have an + # actual value for various bindings. + current_width = width + elif output_width: + current_width = self._render_length(theme, segments, divider_widths) + + rendered_highlighted = self.hl_join([ + segment['_rendered_hl'] + for segment in self._render_segments(theme, segments, hl_args) + ]) + if rendered_highlighted: + rendered_highlighted += self.hlstyle(**hl_args) + + return construct_returned_value(rendered_highlighted, segments, current_width, output_raw, output_width) + + def _prepare_segments(self, segments, calculate_contents_len): + '''Translate non-printable characters and calculate segment width + ''' + for segment in segments: + segment['contents'] = translate_np(segment['contents']) + if calculate_contents_len: + for segment in segments: + if segment['literal_contents'][1]: + segment['_contents_len'] = segment['literal_contents'][0] + else: + segment['_contents_len'] = self.strwidth(segment['contents']) + + def _render_length(self, theme, segments, divider_widths): + '''Update segments lengths and return them + ''' + segments_len = len(segments) + ret = 0 + divider_spaces = theme.get_spaces() + prev_segment = theme.EMPTY_SEGMENT + try: + first_segment = next(iter(( + segment + for segment in segments + if not segment['literal_contents'][1] + ))) + except StopIteration: + first_segment = None + try: + last_segment = next(iter(( + segment + for segment in reversed(segments) + if not segment['literal_contents'][1] + ))) + except StopIteration: + last_segment = None + for index, segment in enumerate(segments): + side = segment['side'] + segment_len = segment['_contents_len'] + if not segment['literal_contents'][1]: + if side == 'left': + if segment is not last_segment: + compare_segment = next(iter(( + segment + for segment in segments[index + 1:] + if not segment['literal_contents'][1] + ))) + else: + compare_segment = theme.EMPTY_SEGMENT + else: + compare_segment = prev_segment + + divider_type = 'soft' if compare_segment['highlight']['bg'] == segment['highlight']['bg'] else 'hard' + + outer_padding = int(bool( + segment is first_segment + if side == 'left' else + segment is last_segment + )) * theme.outer_padding + + draw_divider = segment['draw_' + divider_type + '_divider'] + segment_len += outer_padding + if draw_divider: + segment_len += divider_widths[side][divider_type] + divider_spaces + prev_segment = segment + + segment['_len'] = segment_len + ret += segment_len + return ret + + def _render_segments(self, theme, segments, hl_args, render_highlighted=True): + '''Internal segment rendering method. + + This method loops through the segment array and compares the + foreground/background colors and divider properties and returns the + rendered statusline as a string. + + The method always renders the raw segment contents (i.e. without + highlighting strings added), and only renders the highlighted + statusline if render_highlighted is True. + ''' + segments_len = len(segments) + divider_spaces = theme.get_spaces() + prev_segment = theme.EMPTY_SEGMENT + try: + first_segment = next(iter(( + segment + for segment in segments + if not segment['literal_contents'][1] + ))) + except StopIteration: + first_segment = None + try: + last_segment = next(iter(( + segment + for segment in reversed(segments) + if not segment['literal_contents'][1] + ))) + except StopIteration: + last_segment = None + + for index, segment in enumerate(segments): + side = segment['side'] + if not segment['literal_contents'][1]: + if side == 'left': + if segment is not last_segment: + compare_segment = next(iter(( + segment + for segment in segments[index + 1:] + if not segment['literal_contents'][1] + ))) + else: + compare_segment = theme.EMPTY_SEGMENT + else: + compare_segment = prev_segment + outer_padding = int(bool( + segment is first_segment + if side == 'left' else + segment is last_segment + )) * theme.outer_padding * ' ' + divider_type = 'soft' if compare_segment['highlight']['bg'] == segment['highlight']['bg'] else 'hard' + + divider_highlighted = '' + contents_raw = segment['contents'] + contents_highlighted = '' + draw_divider = segment['draw_' + divider_type + '_divider'] + + segment_hl_args = {} + segment_hl_args.update(segment['highlight']) + segment_hl_args.update(hl_args) + + # XXX Make sure self.hl() calls are called in the same order + # segments are displayed. This is needed for Vim renderer to work. + if draw_divider: + divider_raw = self.escape(theme.get_divider(side, divider_type)) + if side == 'left': + contents_raw = outer_padding + contents_raw + (divider_spaces * ' ') + else: + contents_raw = (divider_spaces * ' ') + contents_raw + outer_padding + + if divider_type == 'soft': + divider_highlight_group_key = 'highlight' if segment['divider_highlight_group'] is None else 'divider_highlight' + divider_fg = segment[divider_highlight_group_key]['fg'] + divider_bg = segment[divider_highlight_group_key]['bg'] + else: + divider_fg = segment['highlight']['bg'] + divider_bg = compare_segment['highlight']['bg'] + + if side == 'left': + if render_highlighted: + contents_highlighted = self.hl(self.escape(contents_raw), **segment_hl_args) + divider_highlighted = self.hl(divider_raw, divider_fg, divider_bg, False, **hl_args) + segment['_rendered_raw'] = contents_raw + divider_raw + segment['_rendered_hl'] = contents_highlighted + divider_highlighted + else: + if render_highlighted: + divider_highlighted = self.hl(divider_raw, divider_fg, divider_bg, False, **hl_args) + contents_highlighted = self.hl(self.escape(contents_raw), **segment_hl_args) + segment['_rendered_raw'] = divider_raw + contents_raw + segment['_rendered_hl'] = divider_highlighted + contents_highlighted + else: + if side == 'left': + contents_raw = outer_padding + contents_raw + else: + contents_raw = contents_raw + outer_padding + + contents_highlighted = self.hl(self.escape(contents_raw), **segment_hl_args) + segment['_rendered_raw'] = contents_raw + segment['_rendered_hl'] = contents_highlighted + prev_segment = segment + else: + segment['_rendered_raw'] = ' ' * segment['literal_contents'][0] + segment['_rendered_hl'] = segment['literal_contents'][1] + yield segment + + def escape(self, string): + '''Method that escapes segment contents. + ''' + return string.translate(self.character_translations) + + def hlstyle(fg=None, bg=None, attrs=None, **kwargs): + '''Output highlight style string. + + Assuming highlighted string looks like ``{style}{contents}`` this method + should output ``{style}``. If it is called without arguments this method + is supposed to reset style to its default. + ''' + raise NotImplementedError + + def hl(self, contents, fg=None, bg=None, attrs=None, **kwargs): + '''Output highlighted chunk. + + This implementation just outputs :py:meth:`hlstyle` joined with + ``contents``. + ''' + return self.hlstyle(fg, bg, attrs, **kwargs) + (contents or '') |