summaryrefslogtreecommitdiffstats
path: root/powerline/renderer.py
blob: 31aca80e1d91f0c576e568ce9e46c5d1fe5ff888 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
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 '')