1# -*- coding: utf-8 -*-
2"""
3    pyte.screens
4    ~~~~~~~~~~~~
5
6    This module provides classes for terminal screens, currently
7    it contains three screens with different features:
8
9    * :class:`~pyte.screens.Screen` -- base screen implementation,
10      which handles all the core escape sequences, recognized by
11      :class:`~pyte.streams.Stream`.
12    * If you need a screen to keep track of the changed lines
13      (which you probably do need) -- use
14      :class:`~pyte.screens.DiffScreen`.
15    * If you also want a screen to collect history and allow
16      pagination -- :class:`pyte.screen.HistoryScreen` is here
17      for ya ;)
18
19    .. note:: It would be nice to split those features into mixin
20              classes, rather than subclasses, but it's not obvious
21              how to do -- feel free to submit a pull request.
22
23    :copyright: (c) 2011-2012 by Selectel.
24    :copyright: (c) 2012-2017 by pyte authors and contributors,
25                    see AUTHORS for details.
26    :license: LGPL, see LICENSE for more details.
27"""
28
29from __future__ import absolute_import, unicode_literals, division
30
31import copy
32import json
33import math
34import os
35import sys
36import unicodedata
37import warnings
38from collections import deque, namedtuple, defaultdict
39
40from wcwidth import wcwidth
41
42# There is no standard 2.X backport for ``lru_cache``.
43if sys.version_info >= (3, 2):
44    from functools import lru_cache
45    wcwidth = lru_cache(maxsize=4096)(wcwidth)
46
47from . import (
48    charsets as cs,
49    control as ctrl,
50    graphics as g,
51    modes as mo
52)
53from .compat import map, range, str
54from .streams import Stream
55
56
57#: A container for screen's scroll margins.
58Margins = namedtuple("Margins", "top bottom")
59
60#: A container for savepoint, created on :data:`~pyte.escape.DECSC`.
61Savepoint = namedtuple("Savepoint", [
62    "cursor",
63    "g0_charset",
64    "g1_charset",
65    "charset",
66    "origin",
67    "wrap"
68])
69
70
71class Char(namedtuple("Char", [
72    "data",
73    "fg",
74    "bg",
75    "bold",
76    "italics",
77    "underscore",
78    "strikethrough",
79    "reverse",
80])):
81    """A single styled on-screen character.
82
83    :param str data: unicode character. Invariant: ``len(data) == 1``.
84    :param str fg: foreground colour. Defaults to ``"default"``.
85    :param str bg: background colour. Defaults to ``"default"``.
86    :param bool bold: flag for rendering the character using bold font.
87                      Defaults to ``False``.
88    :param bool italics: flag for rendering the character using italic font.
89                         Defaults to ``False``.
90    :param bool underscore: flag for rendering the character underlined.
91                            Defaults to ``False``.
92    :param bool strikethrough: flag for rendering the character with a
93                               strike-through line. Defaults to ``False``.
94    :param bool reverse: flag for swapping foreground and background colours
95                         during rendering. Defaults to ``False``.
96    """
97    __slots__ = ()
98
99    def __new__(cls, data, fg="default", bg="default", bold=False,
100                italics=False, underscore=False,
101                strikethrough=False, reverse=False):
102        return super(Char, cls).__new__(cls, data, fg, bg, bold, italics,
103                                        underscore, strikethrough, reverse)
104
105
106class Cursor(object):
107    """Screen cursor.
108
109    :param int x: 0-based horizontal cursor position.
110    :param int y: 0-based vertical cursor position.
111    :param pyte.screens.Char attrs: cursor attributes (see
112        :meth:`~pyte.screens.Screen.select_graphic_rendition`
113        for details).
114    """
115    __slots__ = ("x", "y", "attrs", "hidden")
116
117    def __init__(self, x, y, attrs=Char(" ")):
118        self.x = x
119        self.y = y
120        self.attrs = attrs
121        self.hidden = False
122
123
124class StaticDefaultDict(dict):
125    """A :func:`dict` with a static default value.
126
127    Unlike :func:`collections.defaultdict` this implementation does not
128    implicitly update the mapping when queried with a missing key.
129
130    >>> d = StaticDefaultDict(42)
131    >>> d["foo"]
132    42
133    >>> d
134    {}
135    """
136    def __init__(self, default):
137        self.default = default
138
139    def __missing__(self, key):
140        return self.default
141
142
143class Screen(object):
144    """
145    A screen is an in-memory matrix of characters that represents the
146    screen display of the terminal. It can be instantiated on its own
147    and given explicit commands, or it can be attached to a stream and
148    will respond to events.
149
150    .. attribute:: buffer
151
152       A sparse ``lines x columns`` :class:`~pyte.screens.Char` matrix.
153
154    .. attribute:: dirty
155
156       A set of line numbers, which should be re-drawn. The user is responsible
157       for clearing this set when changes have been applied.
158
159       >>> screen = Screen(80, 24)
160       >>> screen.dirty.clear()
161       >>> screen.draw("!")
162       >>> list(screen.dirty)
163       [0]
164
165       .. versionadded:: 0.7.0
166
167    .. attribute:: cursor
168
169       Reference to the :class:`~pyte.screens.Cursor` object, holding
170       cursor position and attributes.
171
172    .. attribute:: margins
173
174       Margins determine which screen lines move during scrolling
175       (see :meth:`index` and :meth:`reverse_index`). Characters added
176       outside the scrolling region do not make the screen to scroll.
177
178       The value is ``None`` if margins are set to screen boundaries,
179       otherwise -- a pair 0-based top and bottom line indices.
180
181    .. attribute:: charset
182
183       Current charset number; can be either ``0`` or ``1`` for `G0`
184       and `G1` respectively, note that `G0` is activated by default.
185
186    .. note::
187
188       According to ``ECMA-48`` standard, **lines and columns are
189       1-indexed**, so, for instance ``ESC [ 10;10 f`` really means
190       -- move cursor to position (9, 9) in the display matrix.
191
192    .. versionchanged:: 0.4.7
193    .. warning::
194
195       :data:`~pyte.modes.LNM` is reset by default, to match VT220
196       specification. Unfortunatelly this makes :mod:`pyte` fail
197       ``vttest`` for cursor movement.
198
199    .. versionchanged:: 0.4.8
200    .. warning::
201
202       If `DECAWM` mode is set than a cursor will be wrapped to the
203       **beginning** of the next line, which is the behaviour described
204       in ``man console_codes``.
205
206    .. seealso::
207
208       `Standard ECMA-48, Section 6.1.1 \
209       <http://ecma-international.org/publications/standards/Ecma-048.htm>`_
210       for a description of the presentational component, implemented
211       by ``Screen``.
212    """
213    @property
214    def default_char(self):
215        """An empty character with default foreground and background colors."""
216        reverse = mo.DECSCNM in self.mode
217        return Char(data=" ", fg="default", bg="default", reverse=reverse)
218
219    def __init__(self, columns, lines):
220        self.savepoints = []
221        self.columns = columns
222        self.lines = lines
223        self.buffer = defaultdict(lambda: StaticDefaultDict(self.default_char))
224        self.dirty = set()
225        self.reset()
226
227    def __repr__(self):
228        return ("{0}({1}, {2})".format(self.__class__.__name__,
229                                       self.columns, self.lines))
230
231    @property
232    def display(self):
233        """A :func:`list` of screen lines as unicode strings."""
234        def render(line):
235            is_wide_char = False
236            for x in range(self.columns):
237                if is_wide_char:  # Skip stub
238                    is_wide_char = False
239                    continue
240                char = line[x].data
241                assert sum(map(wcwidth, char[1:])) == 0
242                is_wide_char = wcwidth(char[0]) == 2
243                yield char
244
245        return ["".join(render(self.buffer[y])) for y in range(self.lines)]
246
247    def reset(self):
248        """Reset the terminal to its initial state.
249
250        * Scrolling margins are reset to screen boundaries.
251        * Cursor is moved to home location -- ``(0, 0)`` and its
252          attributes are set to defaults (see :attr:`default_char`).
253        * Screen is cleared -- each character is reset to
254          :attr:`default_char`.
255        * Tabstops are reset to "every eight columns".
256        * All lines are marked as :attr:`dirty`.
257
258        .. note::
259
260           Neither VT220 nor VT102 manuals mention that terminal modes
261           and tabstops should be reset as well, thanks to
262           :manpage:`xterm` -- we now know that.
263        """
264        self.dirty.update(range(self.lines))
265        self.buffer.clear()
266        self.margins = None
267
268        self.mode = set([mo.DECAWM, mo.DECTCEM])
269
270        self.title = ""
271        self.icon_name = ""
272
273        self.charset = 0
274        self.g0_charset = cs.LAT1_MAP
275        self.g1_charset = cs.VT100_MAP
276
277        # From ``man terminfo`` -- "... hardware tabs are initially
278        # set every `n` spaces when the terminal is powered up. Since
279        # we aim to support VT102 / VT220 and linux -- we use n = 8.
280        self.tabstops = set(range(8, self.columns, 8))
281
282        self.cursor = Cursor(0, 0)
283        self.cursor_position()
284
285        self.saved_columns = None
286
287    def resize(self, lines=None, columns=None):
288        """Resize the screen to the given size.
289
290        If the requested screen size has more lines than the existing
291        screen, lines will be added at the bottom. If the requested
292        size has less lines than the existing screen lines will be
293        clipped at the top of the screen. Similarly, if the existing
294        screen has less columns than the requested screen, columns will
295        be added at the right, and if it has more -- columns will be
296        clipped at the right.
297
298        :param int lines: number of lines in the new screen.
299        :param int columns: number of columns in the new screen.
300
301        .. versionchanged:: 0.7.0
302
303           If the requested screen size is identical to the current screen
304           size, the method does nothing.
305        """
306        lines = lines or self.lines
307        columns = columns or self.columns
308
309        if lines == self.lines and columns == self.columns:
310            return  # No changes.
311
312        self.dirty.update(range(lines))
313
314        if lines < self.lines:
315            self.save_cursor()
316            self.cursor_position(0, 0)
317            self.delete_lines(self.lines - lines)  # Drop from the top.
318            self.restore_cursor()
319
320        if columns < self.columns:
321            for line in self.buffer.values():
322                for x in range(columns, self.columns):
323                    line.pop(x, None)
324
325        self.lines, self.columns = lines, columns
326        self.set_margins()
327
328    def set_margins(self, top=None, bottom=None):
329        """Select top and bottom margins for the scrolling region.
330
331        :param int top: the smallest line number that is scrolled.
332        :param int bottom: the biggest line number that is scrolled.
333        """
334        if top is None and bottom is None:
335            self.margins = None
336            return
337
338        margins = self.margins or Margins(0, self.lines - 1)
339
340        # Arguments are 1-based, while :attr:`margins` are zero
341        # based -- so we have to decrement them by one. We also
342        # make sure that both of them is bounded by [0, lines - 1].
343        if top is None:
344            top = margins.top
345        else:
346            top = max(0, min(top - 1, self.lines - 1))
347        if bottom is None:
348            bottom = margins.bottom
349        else:
350            bottom = max(0, min(bottom - 1, self.lines - 1))
351
352        # Even though VT102 and VT220 require DECSTBM to ignore
353        # regions of width less than 2, some programs (like aptitude
354        # for example) rely on it. Practicality beats purity.
355        if bottom - top >= 1:
356            self.margins = Margins(top, bottom)
357
358            # The cursor moves to the home position when the top and
359            # bottom margins of the scrolling region (DECSTBM) changes.
360            self.cursor_position()
361
362    def set_mode(self, *modes, **kwargs):
363        """Set (enable) a given list of modes.
364
365        :param list modes: modes to set, where each mode is a constant
366                           from :mod:`pyte.modes`.
367        """
368        # Private mode codes are shifted, to be distingiushed from non
369        # private ones.
370        if kwargs.get("private"):
371            modes = [mode << 5 for mode in modes]
372            if mo.DECSCNM in modes:
373                self.dirty.update(range(self.lines))
374
375        self.mode.update(modes)
376
377        # When DECOLM mode is set, the screen is erased and the cursor
378        # moves to the home position.
379        if mo.DECCOLM in modes:
380            self.saved_columns = self.columns
381            self.resize(columns=132)
382            self.erase_in_display(2)
383            self.cursor_position()
384
385        # According to VT520 manual, DECOM should also home the cursor.
386        if mo.DECOM in modes:
387            self.cursor_position()
388
389        # Mark all displayed characters as reverse.
390        if mo.DECSCNM in modes:
391            for line in self.buffer.values():
392                line.default = self.default_char
393                for x in line:
394                    line[x] = line[x]._replace(reverse=True)
395
396            self.select_graphic_rendition(7)  # +reverse.
397
398        # Make the cursor visible.
399        if mo.DECTCEM in modes:
400            self.cursor.hidden = False
401
402    def reset_mode(self, *modes, **kwargs):
403        """Reset (disable) a given list of modes.
404
405        :param list modes: modes to reset -- hopefully, each mode is a
406                           constant from :mod:`pyte.modes`.
407        """
408        # Private mode codes are shifted, to be distinguished from non
409        # private ones.
410        if kwargs.get("private"):
411            modes = [mode << 5 for mode in modes]
412            if mo.DECSCNM in modes:
413                self.dirty.update(range(self.lines))
414
415        self.mode.difference_update(modes)
416
417        # Lines below follow the logic in :meth:`set_mode`.
418        if mo.DECCOLM in modes:
419            if self.columns == 132 and self.saved_columns is not None:
420                self.resize(columns=self.saved_columns)
421                self.saved_columns = None
422            self.erase_in_display(2)
423            self.cursor_position()
424
425        if mo.DECOM in modes:
426            self.cursor_position()
427
428        if mo.DECSCNM in modes:
429            for line in self.buffer.values():
430                line.default = self.default_char
431                for x in line:
432                    line[x] = line[x]._replace(reverse=False)
433
434            self.select_graphic_rendition(27)  # -reverse.
435
436        # Hide the cursor.
437        if mo.DECTCEM in modes:
438            self.cursor.hidden = True
439
440    def define_charset(self, code, mode):
441        """Define ``G0`` or ``G1`` charset.
442
443        :param str code: character set code, should be a character
444                         from ``"B0UK"``, otherwise ignored.
445        :param str mode: if ``"("`` ``G0`` charset is defined, if
446                         ``")"`` -- we operate on ``G1``.
447
448        .. warning:: User-defined charsets are currently not supported.
449        """
450        if code in cs.MAPS:
451            if mode == "(":
452                self.g0_charset = cs.MAPS[code]
453            elif mode == ")":
454                self.g1_charset = cs.MAPS[code]
455
456    def shift_in(self):
457        """Select ``G0`` character set."""
458        self.charset = 0
459
460    def shift_out(self):
461        """Select ``G1`` character set."""
462        self.charset = 1
463
464    def draw(self, data):
465        """Display decoded characters at the current cursor position and
466        advances the cursor if :data:`~pyte.modes.DECAWM` is set.
467
468        :param str data: text to display.
469
470        .. versionchanged:: 0.5.0
471
472           Character width is taken into account. Specifically, zero-width
473           and unprintable characters do not affect screen state. Full-width
474           characters are rendered into two consecutive character containers.
475        """
476        data = data.translate(
477            self.g1_charset if self.charset else self.g0_charset)
478
479        for char in data:
480            char_width = wcwidth(char)
481
482            # If this was the last column in a line and auto wrap mode is
483            # enabled, move the cursor to the beginning of the next line,
484            # otherwise replace characters already displayed with newly
485            # entered.
486            if self.cursor.x == self.columns:
487                if mo.DECAWM in self.mode:
488                    self.dirty.add(self.cursor.y)
489                    self.carriage_return()
490                    self.linefeed()
491                elif char_width > 0:
492                    self.cursor.x -= char_width
493
494            # If Insert mode is set, new characters move old characters to
495            # the right, otherwise terminal is in Replace mode and new
496            # characters replace old characters at cursor position.
497            if mo.IRM in self.mode and char_width > 0:
498                self.insert_characters(char_width)
499
500            line = self.buffer[self.cursor.y]
501            if char_width == 1:
502                line[self.cursor.x] = self.cursor.attrs._replace(data=char)
503            elif char_width == 2:
504                # A two-cell character has a stub slot after it.
505                line[self.cursor.x] = self.cursor.attrs._replace(data=char)
506                if self.cursor.x + 1 < self.columns:
507                    line[self.cursor.x + 1] = self.cursor.attrs \
508                        ._replace(data="")
509            elif char_width == 0 and unicodedata.combining(char):
510                # A zero-cell character is combined with the previous
511                # character either on this or preceeding line.
512                if self.cursor.x:
513                    last = line[self.cursor.x - 1]
514                    normalized = unicodedata.normalize("NFC", last.data + char)
515                    line[self.cursor.x - 1] = last._replace(data=normalized)
516                elif self.cursor.y:
517                    last = self.buffer[self.cursor.y - 1][self.columns - 1]
518                    normalized = unicodedata.normalize("NFC", last.data + char)
519                    self.buffer[self.cursor.y - 1][self.columns - 1] = \
520                        last._replace(data=normalized)
521            else:
522                break  # Unprintable character or doesn't advance the cursor.
523
524            # .. note:: We can't use :meth:`cursor_forward()`, because that
525            #           way, we'll never know when to linefeed.
526            if char_width > 0:
527                self.cursor.x = min(self.cursor.x + char_width, self.columns)
528
529        self.dirty.add(self.cursor.y)
530
531    def set_title(self, param):
532        """Set terminal title.
533
534        .. note:: This is an XTerm extension supported by the Linux terminal.
535        """
536        self.title = param
537
538    def set_icon_name(self, param):
539        """Set icon name.
540
541        .. note:: This is an XTerm extension supported by the Linux terminal.
542        """
543        self.icon_name = param
544
545    def carriage_return(self):
546        """Move the cursor to the beginning of the current line."""
547        self.cursor.x = 0
548
549    def index(self):
550        """Move the cursor down one line in the same column. If the
551        cursor is at the last line, create a new line at the bottom.
552        """
553        top, bottom = self.margins or Margins(0, self.lines - 1)
554        if self.cursor.y == bottom:
555            # TODO: mark only the lines within margins?
556            self.dirty.update(range(self.lines))
557            for y in range(top, bottom):
558                self.buffer[y] = self.buffer[y + 1]
559            self.buffer.pop(bottom, None)
560        else:
561            self.cursor_down()
562
563    def reverse_index(self):
564        """Move the cursor up one line in the same column. If the cursor
565        is at the first line, create a new line at the top.
566        """
567        top, bottom = self.margins or Margins(0, self.lines - 1)
568        if self.cursor.y == top:
569            # TODO: mark only the lines within margins?
570            self.dirty.update(range(self.lines))
571            for y in range(bottom, top, -1):
572                self.buffer[y] = self.buffer[y - 1]
573            self.buffer.pop(top, None)
574        else:
575            self.cursor_up()
576
577    def linefeed(self):
578        """Perform an index and, if :data:`~pyte.modes.LNM` is set, a
579        carriage return.
580        """
581        self.index()
582
583        if mo.LNM in self.mode:
584            self.carriage_return()
585
586    def tab(self):
587        """Move to the next tab space, or the end of the screen if there
588        aren't anymore left.
589        """
590        for stop in sorted(self.tabstops):
591            if self.cursor.x < stop:
592                column = stop
593                break
594        else:
595            column = self.columns - 1
596
597        self.cursor.x = column
598
599    def backspace(self):
600        """Move cursor to the left one or keep it in its position if
601        it's at the beginning of the line already.
602        """
603        self.cursor_back()
604
605    def save_cursor(self):
606        """Push the current cursor position onto the stack."""
607        self.savepoints.append(Savepoint(copy.copy(self.cursor),
608                                         self.g0_charset,
609                                         self.g1_charset,
610                                         self.charset,
611                                         mo.DECOM in self.mode,
612                                         mo.DECAWM in self.mode))
613
614    def restore_cursor(self):
615        """Set the current cursor position to whatever cursor is on top
616        of the stack.
617        """
618        if self.savepoints:
619            savepoint = self.savepoints.pop()
620
621            self.g0_charset = savepoint.g0_charset
622            self.g1_charset = savepoint.g1_charset
623            self.charset = savepoint.charset
624
625            if savepoint.origin:
626                self.set_mode(mo.DECOM)
627            if savepoint.wrap:
628                self.set_mode(mo.DECAWM)
629
630            self.cursor = savepoint.cursor
631            self.ensure_hbounds()
632            self.ensure_vbounds(use_margins=True)
633        else:
634            # If nothing was saved, the cursor moves to home position;
635            # origin mode is reset. :todo: DECAWM?
636            self.reset_mode(mo.DECOM)
637            self.cursor_position()
638
639    def insert_lines(self, count=None):
640        """Insert the indicated # of lines at line with cursor. Lines
641        displayed **at** and below the cursor move down. Lines moved
642        past the bottom margin are lost.
643
644        :param count: number of lines to insert.
645        """
646        count = count or 1
647        top, bottom = self.margins or Margins(0, self.lines - 1)
648
649        # If cursor is outside scrolling margins it -- do nothin'.
650        if top <= self.cursor.y <= bottom:
651            self.dirty.update(range(self.cursor.y, self.lines))
652            for y in range(bottom, self.cursor.y - 1, -1):
653                if y + count <= bottom and y in self.buffer:
654                    self.buffer[y + count] = self.buffer[y]
655                self.buffer.pop(y, None)
656
657            self.carriage_return()
658
659    def delete_lines(self, count=None):
660        """Delete the indicated # of lines, starting at line with
661        cursor. As lines are deleted, lines displayed below cursor
662        move up. Lines added to bottom of screen have spaces with same
663        character attributes as last line moved up.
664
665        :param int count: number of lines to delete.
666        """
667        count = count or 1
668        top, bottom = self.margins or Margins(0, self.lines - 1)
669
670        # If cursor is outside scrolling margins -- do nothin'.
671        if top <= self.cursor.y <= bottom:
672            self.dirty.update(range(self.cursor.y, self.lines))
673            for y in range(self.cursor.y, bottom + 1):
674                if y + count <= bottom:
675                    if y + count in self.buffer:
676                        self.buffer[y] = self.buffer.pop(y + count)
677                else:
678                    self.buffer.pop(y, None)
679
680            self.carriage_return()
681
682    def insert_characters(self, count=None):
683        """Insert the indicated # of blank characters at the cursor
684        position. The cursor does not move and remains at the beginning
685        of the inserted blank characters. Data on the line is shifted
686        forward.
687
688        :param int count: number of characters to insert.
689        """
690        self.dirty.add(self.cursor.y)
691
692        count = count or 1
693        line = self.buffer[self.cursor.y]
694        for x in range(self.columns, self.cursor.x - 1, -1):
695            if x + count <= self.columns:
696                line[x + count] = line[x]
697            line.pop(x, None)
698
699    def delete_characters(self, count=None):
700        """Delete the indicated # of characters, starting with the
701        character at cursor position. When a character is deleted, all
702        characters to the right of cursor move left. Character attributes
703        move with the characters.
704
705        :param int count: number of characters to delete.
706        """
707        self.dirty.add(self.cursor.y)
708        count = count or 1
709
710        line = self.buffer[self.cursor.y]
711        for x in range(self.cursor.x, self.columns):
712            if x + count <= self.columns:
713                line[x] = line.pop(x + count, self.default_char)
714            else:
715                line.pop(x, None)
716
717    def erase_characters(self, count=None):
718        """Erase the indicated # of characters, starting with the
719        character at cursor position. Character attributes are set
720        cursor attributes. The cursor remains in the same position.
721
722        :param int count: number of characters to erase.
723
724        .. note::
725
726           Using cursor attributes for character attributes may seem
727           illogical, but if recall that a terminal emulator emulates
728           a type writer, it starts to make sense. The only way a type
729           writer could erase a character is by typing over it.
730        """
731        self.dirty.add(self.cursor.y)
732        count = count or 1
733
734        line = self.buffer[self.cursor.y]
735        for x in range(self.cursor.x,
736                       min(self.cursor.x + count, self.columns)):
737            line[x] = self.cursor.attrs
738
739    def erase_in_line(self, how=0, private=False):
740        """Erase a line in a specific way.
741
742        Character attributes are set to cursor attributes.
743
744        :param int how: defines the way the line should be erased in:
745
746            * ``0`` -- Erases from cursor to end of line, including cursor
747              position.
748            * ``1`` -- Erases from beginning of line to cursor,
749              including cursor position.
750            * ``2`` -- Erases complete line.
751        :param bool private: when ``True`` only characters marked as
752                             eraseable are affected **not implemented**.
753        """
754        self.dirty.add(self.cursor.y)
755        if how == 0:
756            interval = range(self.cursor.x, self.columns)
757        elif how == 1:
758            interval = range(self.cursor.x + 1)
759        elif how == 2:
760            interval = range(self.columns)
761
762        line = self.buffer[self.cursor.y]
763        for x in interval:
764            line[x] = self.cursor.attrs
765
766    def erase_in_display(self, how=0, private=False):
767        """Erases display in a specific way.
768
769        Character attributes are set to cursor attributes.
770
771        :param int how: defines the way the line should be erased in:
772
773            * ``0`` -- Erases from cursor to end of screen, including
774              cursor position.
775            * ``1`` -- Erases from beginning of screen to cursor,
776              including cursor position.
777            * ``2`` and ``3`` -- Erases complete display. All lines
778              are erased and changed to single-width. Cursor does not
779              move.
780        :param bool private: when ``True`` only characters marked as
781                             eraseable are affected **not implemented**.
782        """
783        if how == 0:
784            interval = range(self.cursor.y + 1, self.lines)
785        elif how == 1:
786            interval = range(self.cursor.y)
787        elif how == 2 or how == 3:
788            interval = range(self.lines)
789
790        self.dirty.update(interval)
791        for y in interval:
792            line = self.buffer[y]
793            for x in line:
794                line[x] = self.cursor.attrs
795
796        if how == 0 or how == 1:
797            self.erase_in_line(how)
798
799    def set_tab_stop(self):
800        """Set a horizontal tab stop at cursor position."""
801        self.tabstops.add(self.cursor.x)
802
803    def clear_tab_stop(self, how=0):
804        """Clear a horizontal tab stop.
805
806        :param int how: defines a way the tab stop should be cleared:
807
808            * ``0`` or nothing -- Clears a horizontal tab stop at cursor
809              position.
810            * ``3`` -- Clears all horizontal tab stops.
811        """
812        if how == 0:
813            # Clears a horizontal tab stop at cursor position, if it's
814            # present, or silently fails if otherwise.
815            self.tabstops.discard(self.cursor.x)
816        elif how == 3:
817            self.tabstops = set()  # Clears all horizontal tab stops.
818
819    def ensure_hbounds(self):
820        """Ensure the cursor is within horizontal screen bounds."""
821        self.cursor.x = min(max(0, self.cursor.x), self.columns - 1)
822
823    def ensure_vbounds(self, use_margins=None):
824        """Ensure the cursor is within vertical screen bounds.
825
826        :param bool use_margins: when ``True`` or when
827                                 :data:`~pyte.modes.DECOM` is set,
828                                 cursor is bounded by top and and bottom
829                                 margins, instead of ``[0; lines - 1]``.
830        """
831        if (use_margins or mo.DECOM in self.mode) and self.margins is not None:
832            top, bottom = self.margins
833        else:
834            top, bottom = 0, self.lines - 1
835
836        self.cursor.y = min(max(top, self.cursor.y), bottom)
837
838    def cursor_up(self, count=None):
839        """Move cursor up the indicated # of lines in same column.
840        Cursor stops at top margin.
841
842        :param int count: number of lines to skip.
843        """
844        top, _bottom = self.margins or Margins(0, self.lines - 1)
845        self.cursor.y = max(self.cursor.y - (count or 1), top)
846
847    def cursor_up1(self, count=None):
848        """Move cursor up the indicated # of lines to column 1. Cursor
849        stops at bottom margin.
850
851        :param int count: number of lines to skip.
852        """
853        self.cursor_up(count)
854        self.carriage_return()
855
856    def cursor_down(self, count=None):
857        """Move cursor down the indicated # of lines in same column.
858        Cursor stops at bottom margin.
859
860        :param int count: number of lines to skip.
861        """
862        _top, bottom = self.margins or Margins(0, self.lines - 1)
863        self.cursor.y = min(self.cursor.y + (count or 1), bottom)
864
865    def cursor_down1(self, count=None):
866        """Move cursor down the indicated # of lines to column 1.
867        Cursor stops at bottom margin.
868
869        :param int count: number of lines to skip.
870        """
871        self.cursor_down(count)
872        self.carriage_return()
873
874    def cursor_back(self, count=None):
875        """Move cursor left the indicated # of columns. Cursor stops
876        at left margin.
877
878        :param int count: number of columns to skip.
879        """
880        # Handle the case when we've just drawn in the last column
881        # and would wrap the line on the next :meth:`draw()` call.
882        if self.cursor.x == self.columns:
883            self.cursor.x -= 1
884
885        self.cursor.x -= count or 1
886        self.ensure_hbounds()
887
888    def cursor_forward(self, count=None):
889        """Move cursor right the indicated # of columns. Cursor stops
890        at right margin.
891
892        :param int count: number of columns to skip.
893        """
894        self.cursor.x += count or 1
895        self.ensure_hbounds()
896
897    def cursor_position(self, line=None, column=None):
898        """Set the cursor to a specific `line` and `column`.
899
900        Cursor is allowed to move out of the scrolling region only when
901        :data:`~pyte.modes.DECOM` is reset, otherwise -- the position
902        doesn't change.
903
904        :param int line: line number to move the cursor to.
905        :param int column: column number to move the cursor to.
906        """
907        column = (column or 1) - 1
908        line = (line or 1) - 1
909
910        # If origin mode (DECOM) is set, line number are relative to
911        # the top scrolling margin.
912        if self.margins is not None and mo.DECOM in self.mode:
913            line += self.margins.top
914
915            # Cursor is not allowed to move out of the scrolling region.
916            if not self.margins.top <= line <= self.margins.bottom:
917                return
918
919        self.cursor.x = column
920        self.cursor.y = line
921        self.ensure_hbounds()
922        self.ensure_vbounds()
923
924    def cursor_to_column(self, column=None):
925        """Move cursor to a specific column in the current line.
926
927        :param int column: column number to move the cursor to.
928        """
929        self.cursor.x = (column or 1) - 1
930        self.ensure_hbounds()
931
932    def cursor_to_line(self, line=None):
933        """Move cursor to a specific line in the current column.
934
935        :param int line: line number to move the cursor to.
936        """
937        self.cursor.y = (line or 1) - 1
938
939        # If origin mode (DECOM) is set, line number are relative to
940        # the top scrolling margin.
941        if mo.DECOM in self.mode:
942            self.cursor.y += self.margins.top
943
944            # FIXME: should we also restrict the cursor to the scrolling
945            # region?
946
947        self.ensure_vbounds()
948
949    def bell(self, *args):
950        """Bell stub -- the actual implementation should probably be
951        provided by the end-user.
952        """
953
954    def alignment_display(self):
955        """Fills screen with uppercase E's for screen focus and alignment."""
956        self.dirty.update(range(self.lines))
957        for y in range(self.lines):
958            for x in range(self.columns):
959                self.buffer[y][x] = self.buffer[y][x]._replace(data="E")
960
961    def select_graphic_rendition(self, *attrs):
962        """Set display attributes.
963
964        :param list attrs: a list of display attributes to set.
965        """
966        replace = {}
967
968        # Fast path for resetting all attributes.
969        if not attrs or attrs == (0, ):
970            self.cursor.attrs = self.default_char
971            return
972        else:
973            attrs = list(reversed(attrs))
974
975        while attrs:
976            attr = attrs.pop()
977            if attr == 0:
978                # Reset all attributes.
979                replace.update(self.default_char._asdict())
980            elif attr in g.FG_ANSI:
981                replace["fg"] = g.FG_ANSI[attr]
982            elif attr in g.BG:
983                replace["bg"] = g.BG_ANSI[attr]
984            elif attr in g.TEXT:
985                attr = g.TEXT[attr]
986                replace[attr[1:]] = attr.startswith("+")
987            elif attr in g.FG_AIXTERM:
988                replace.update(fg=g.FG_AIXTERM[attr], bold=True)
989            elif attr in g.BG_AIXTERM:
990                replace.update(bg=g.BG_AIXTERM[attr], bold=True)
991            elif attr in (g.FG_256, g.BG_256):
992                key = "fg" if attr == g.FG_256 else "bg"
993                try:
994                    n = attrs.pop()
995                    if n == 5:    # 256.
996                        m = attrs.pop()
997                        replace[key] = g.FG_BG_256[m]
998                    elif n == 2:  # 24bit.
999                        # This is somewhat non-standard but is nonetheless
1000                        # supported in quite a few terminals. See discussion
1001                        # here https://gist.github.com/XVilka/8346728.
1002                        replace[key] = "{0:02x}{1:02x}{2:02x}".format(
1003                            attrs.pop(), attrs.pop(), attrs.pop())
1004                except IndexError:
1005                    pass
1006
1007        self.cursor.attrs = self.cursor.attrs._replace(**replace)
1008
1009    def report_device_attributes(self, mode=0, **kwargs):
1010        """Report terminal identity.
1011
1012        .. versionadded:: 0.5.0
1013
1014        .. versionchanged:: 0.7.0
1015
1016           If ``private`` keyword argument is set, the method does nothing.
1017           This behaviour is consistent with VT220 manual.
1018        """
1019        # We only implement "primary" DA which is the only DA request
1020        # VT102 understood, see ``VT102ID`` in ``linux/drivers/tty/vt.c``.
1021        if mode == 0 and not kwargs.get("private"):
1022            self.write_process_input(ctrl.CSI + "?6c")
1023
1024    def report_device_status(self, mode):
1025        """Report terminal status or cursor position.
1026
1027        :param int mode: if 5 -- terminal status, 6 -- cursor position,
1028                         otherwise a noop.
1029
1030        .. versionadded:: 0.5.0
1031        """
1032        if mode == 5:    # Request for terminal status.
1033            self.write_process_input(ctrl.CSI + "0n")
1034        elif mode == 6:  # Request for cursor position.
1035            x = self.cursor.x + 1
1036            y = self.cursor.y + 1
1037
1038            # "Origin mode (DECOM) selects line numbering."
1039            if mo.DECOM in self.mode:
1040                y -= self.margins.top
1041            self.write_process_input(ctrl.CSI + "{0};{1}R".format(y, x))
1042
1043    def write_process_input(self, data):
1044        """Write data to the process running inside the terminal.
1045
1046        By default is a noop.
1047
1048        :param str data: text to write to the process ``stdin``.
1049
1050        .. versionadded:: 0.5.0
1051        """
1052
1053    def debug(self, *args, **kwargs):
1054        """Endpoint for unrecognized escape sequences.
1055
1056        By default is a noop.
1057        """
1058
1059
1060class DiffScreen(Screen):
1061    """
1062    A screen subclass, which maintains a set of dirty lines in its
1063    :attr:`dirty` attribute. The end user is responsible for emptying
1064    a set, when a diff is applied.
1065
1066    .. deprecated:: 0.7.0
1067
1068       The functionality contained in this class has been merged into
1069       :class:`~pyte.screens.Screen` and will be removed in 0.8.0.
1070       Please update your code accordingly.
1071    """
1072    def __init__(self, *args, **kwargs):
1073        warnings.warn(
1074            "The functionality of ``DiffScreen` has been merged into "
1075            "``Screen`` and will be removed in 0.8.0. Please update "
1076            "your code accordingly.", DeprecationWarning)
1077
1078        super(DiffScreen, self).__init__(*args, **kwargs)
1079
1080
1081History = namedtuple("History", "top bottom ratio size position")
1082
1083
1084class HistoryScreen(Screen):
1085    """A :class:~`pyte.screens.Screen` subclass, which keeps track
1086    of screen history and allows pagination. This is not linux-specific,
1087    but still useful; see page 462 of VT520 User's Manual.
1088
1089    :param int history: total number of history lines to keep; is split
1090                        between top and bottom queues.
1091    :param int ratio: defines how much lines to scroll on :meth:`next_page`
1092                      and :meth:`prev_page` calls.
1093
1094    .. attribute:: history
1095
1096       A pair of history queues for top and bottom margins accordingly;
1097       here's the overall screen structure::
1098
1099            [ 1: .......]
1100            [ 2: .......]  <- top history
1101            [ 3: .......]
1102            ------------
1103            [ 4: .......]  s
1104            [ 5: .......]  c
1105            [ 6: .......]  r
1106            [ 7: .......]  e
1107            [ 8: .......]  e
1108            [ 9: .......]  n
1109            ------------
1110            [10: .......]
1111            [11: .......]  <- bottom history
1112            [12: .......]
1113
1114    .. note::
1115
1116       Don't forget to update :class:`~pyte.streams.Stream` class with
1117       appropriate escape sequences -- you can use any, since pagination
1118       protocol is not standardized, for example::
1119
1120           Stream.escape["N"] = "next_page"
1121           Stream.escape["P"] = "prev_page"
1122    """
1123    _wrapped = set(Stream.events)
1124    _wrapped.update(["next_page", "prev_page"])
1125
1126    def __init__(self, columns, lines, history=100, ratio=.5):
1127        self.history = History(deque(maxlen=history),
1128                               deque(maxlen=history),
1129                               float(ratio),
1130                               history,
1131                               history)
1132
1133        super(HistoryScreen, self).__init__(columns, lines)
1134
1135    def _make_wrapper(self, event, handler):
1136        def inner(*args, **kwargs):
1137            self.before_event(event)
1138            result = handler(*args, **kwargs)
1139            self.after_event(event)
1140            return result
1141        return inner
1142
1143    def __getattribute__(self, attr):
1144        value = super(HistoryScreen, self).__getattribute__(attr)
1145        if attr in HistoryScreen._wrapped:
1146            return HistoryScreen._make_wrapper(self, attr, value)
1147        else:
1148            return value
1149
1150    def before_event(self, event):
1151        """Ensure a screen is at the bottom of the history buffer.
1152
1153        :param str event: event name, for example ``"linefeed"``.
1154        """
1155        if event not in ["prev_page", "next_page"]:
1156            while self.history.position < self.history.size:
1157                self.next_page()
1158
1159    def after_event(self, event):
1160        """Ensure all lines on a screen have proper width (:attr:`columns`).
1161
1162        Extra characters are truncated, missing characters are filled
1163        with whitespace.
1164
1165        :param str event: event name, for example ``"linefeed"``.
1166        """
1167        if event in ["prev_page", "next_page"]:
1168            for line in self.buffer.values():
1169                for x in line:
1170                    if x > self.columns:
1171                        line.pop(x)
1172
1173        # If we're at the bottom of the history buffer and `DECTCEM`
1174        # mode is set -- show the cursor.
1175        self.cursor.hidden = not (
1176            self.history.position == self.history.size and
1177            mo.DECTCEM in self.mode
1178        )
1179
1180    def _reset_history(self):
1181        self.history.top.clear()
1182        self.history.bottom.clear()
1183        self.history = self.history._replace(position=self.history.size)
1184
1185    def reset(self):
1186        """Overloaded to reset screen history state: history position
1187        is reset to bottom of both queues;  queues themselves are
1188        emptied.
1189        """
1190        super(HistoryScreen, self).reset()
1191        self._reset_history()
1192
1193    def erase_in_display(self, how=0):
1194        """Overloaded to reset history state."""
1195        super(HistoryScreen, self).erase_in_display(how)
1196
1197        if how == 3:
1198            self._reset_history()
1199
1200    def index(self):
1201        """Overloaded to update top history with the removed lines."""
1202        top, bottom = self.margins or Margins(0, self.lines - 1)
1203
1204        if self.cursor.y == bottom:
1205            self.history.top.append(self.buffer[top])
1206
1207        super(HistoryScreen, self).index()
1208
1209    def reverse_index(self):
1210        """Overloaded to update bottom history with the removed lines."""
1211        top, bottom = self.margins or Margins(0, self.lines - 1)
1212
1213        if self.cursor.y == top:
1214            self.history.bottom.append(self.buffer[bottom])
1215
1216        super(HistoryScreen, self).reverse_index()
1217
1218    def prev_page(self):
1219        """Move the screen page up through the history buffer. Page
1220        size is defined by ``history.ratio``, so for instance
1221        ``ratio = .5`` means that half the screen is restored from
1222        history on page switch.
1223        """
1224        if self.history.position > self.lines and self.history.top:
1225            mid = min(len(self.history.top),
1226                      int(math.ceil(self.lines * self.history.ratio)))
1227
1228            self.history.bottom.extendleft(
1229                self.buffer[y]
1230                for y in range(self.lines - 1, self.lines - mid - 1, -1))
1231            self.history = self.history \
1232                ._replace(position=self.history.position - mid)
1233
1234            for y in range(self.lines - 1, mid - 1, -1):
1235                self.buffer[y] = self.buffer[y - mid]
1236            for y in range(mid - 1, -1, -1):
1237                self.buffer[y] = self.history.top.pop()
1238
1239            self.dirty = set(range(self.lines))
1240
1241    def next_page(self):
1242        """Move the screen page down through the history buffer."""
1243        if self.history.position < self.history.size and self.history.bottom:
1244            mid = min(len(self.history.bottom),
1245                      int(math.ceil(self.lines * self.history.ratio)))
1246
1247            self.history.top.extend(self.buffer[y] for y in range(mid))
1248            self.history = self.history \
1249                ._replace(position=self.history.position + mid)
1250
1251            for y in range(self.lines - mid):
1252                self.buffer[y] = self.buffer[y + mid]
1253            for y in range(self.lines - mid, self.lines):
1254                self.buffer[y] = self.history.bottom.popleft()
1255
1256            self.dirty = set(range(self.lines))
1257
1258
1259class DebugEvent(namedtuple("Event", "name args kwargs")):
1260    """Event dispatched to :class:`~pyte.screens.DebugScreen`.
1261
1262    .. warning::
1263
1264       This is developer API with no backward compatibility guarantees.
1265       Use at your own risk!
1266    """
1267    @staticmethod
1268    def from_string(line):
1269        return DebugEvent(*json.loads(line))
1270
1271    def __str__(self):
1272        return json.dumps(self)
1273
1274    def __call__(self, screen):
1275        """Execute this event on a given ``screen``."""
1276        return getattr(screen, self.name)(*self.args, **self.kwargs)
1277
1278
1279class DebugScreen(object):
1280    r"""A screen which dumps a subset of the received events to a file.
1281
1282    >>> import io
1283    >>> with io.StringIO() as buf:
1284    ...     stream = Stream(DebugScreen(to=buf))
1285    ...     stream.feed("\x1b[1;24r\x1b[4l\x1b[24;1H\x1b[0;10m")
1286    ...     print(buf.getvalue())
1287    ...
1288    ... # doctest: +NORMALIZE_WHITESPACE
1289    ["set_margins", [1, 24], {}]
1290    ["reset_mode", [4], {}]
1291    ["cursor_position", [24, 1], {}]
1292    ["select_graphic_rendition", [0, 10], {}]
1293
1294    :param file to: a file-like object to write debug information to.
1295    :param list only: a list of events you want to debug (empty by
1296                      default, which means -- debug all events).
1297
1298    .. warning::
1299
1300       This is developer API with no backward compatibility guarantees.
1301       Use at your own risk!
1302    """
1303    def __init__(self, to=sys.stderr, only=()):
1304        self.to = to
1305        self.only = only
1306
1307    def only_wrapper(self, attr):
1308        def wrapper(*args, **kwargs):
1309            self.to.write(str(DebugEvent(attr, args, kwargs)))
1310            self.to.write(str(os.linesep))
1311
1312        return wrapper
1313
1314    def __getattribute__(self, attr):
1315        if attr not in Stream.events:
1316            return super(DebugScreen, self).__getattribute__(attr)
1317        elif not self.only or attr in self.only:
1318            return self.only_wrapper(attr)
1319        else:
1320            return lambda *args, **kwargs: None
1321