diff --git a/AUTHORS b/AUTHORS index 5b2f5a5..5934494 100644 --- a/AUTHORS +++ b/AUTHORS @@ -17,3 +17,4 @@ Contributors - Byron Roosa - Andrew Crozier - @eight04 +- Martin Di Paola @eldipa diff --git a/benchmark.py b/benchmark.py index 75657db..b5bfc7d 100644 --- a/benchmark.py +++ b/benchmark.py @@ -10,6 +10,15 @@ ..................... ls.input: Mean +- std dev: 644 ns +- 23 ns + $ BENCHMARK=tests/captured/ls.input GEOMETRY=1024x1024 python benchmark.py -o results.json + ..................... + ls.input: Mean +- std dev: 644 ns +- 23 ns + + Environment variables: + + BENCHMARK: the input file to feed pyte's Stream and render on the Screen + GEOMETRY: the dimensions of the screen with format "x" (default 24x80) + :copyright: (c) 2016-2021 by pyte authors and contributors, see AUTHORS for details. :license: LGPL, see LICENSE for more details. @@ -27,21 +36,63 @@ import pyte - -def make_benchmark(path, screen_cls): - with io.open(path, "rt", encoding="utf-8") as handle: +def setup(path, screen_cls, columns, lines, optimize_conf): + with io.open(path, "rb") as handle: data = handle.read() - stream = pyte.Stream(screen_cls(80, 24)) + extra_args = {} + if optimize_conf: + extra_args = { + 'track_dirty_lines': False, + 'disable_display_graphic': True, + } + + screen = screen_cls(columns, lines, **extra_args) + stream = pyte.ByteStream(screen) + + return data, screen, stream + +def make_stream_feed_benchmark(path, screen_cls, columns, lines, optimize_conf): + data, _, stream = setup(path, screen_cls, columns, lines, optimize_conf) return partial(stream.feed, data) +def make_screen_display_benchmark(path, screen_cls, columns, lines, optimize_conf): + data, screen, stream = setup(path, screen_cls, columns, lines, optimize_conf) + stream.feed(data) + return lambda: screen.display + +def make_screen_reset_benchmark(path, screen_cls, columns, lines, optimize_conf): + data, screen, stream = setup(path, screen_cls, columns, lines, optimize_conf) + stream.feed(data) + return screen.reset + +def make_screen_resize_half_benchmark(path, screen_cls, columns, lines, optimize_conf): + data, screen, stream = setup(path, screen_cls, columns, lines, optimize_conf) + stream.feed(data) + return partial(screen.resize, lines=lines//2, columns=columns//2) if __name__ == "__main__": benchmark = os.environ["BENCHMARK"] - sys.argv.extend(["--inherit-environ", "BENCHMARK"]) + lines, columns = map(int, os.environ.get("GEOMETRY", "24x80").split('x')) + optimize_conf = int(os.environ.get("OPTIMIZECONF", "0")) + sys.argv.extend(["--inherit-environ", "BENCHMARK,GEOMETRY,OPTIMIZECONF"]) runner = Runner() - for screen_cls in [pyte.Screen, pyte.DiffScreen, pyte.HistoryScreen]: - name = os.path.basename(benchmark) + "->" + screen_cls.__name__ - runner.bench_func(name, make_benchmark(benchmark, screen_cls)) + metadata = { + 'input_file': benchmark, + 'columns': columns, + 'lines': lines, + 'optimize_conf': optimize_conf + } + + benchmark_name = os.path.basename(benchmark) + for screen_cls in [pyte.Screen, pyte.HistoryScreen]: + screen_cls_name = screen_cls.__name__ + for make_test in (make_stream_feed_benchmark, make_screen_display_benchmark, make_screen_reset_benchmark, make_screen_resize_half_benchmark): + scenario = make_test.__name__[5:-10] # remove make_ and _benchmark + + name = f"[{scenario} {lines}x{columns}] {benchmark_name}->{screen_cls_name}" + metadata.update({'scenario': scenario, 'screen_cls': screen_cls_name}) + runner.bench_func(name, make_test(benchmark, screen_cls, columns, lines, optimize_conf), metadata=metadata) + diff --git a/full_benchmark.sh b/full_benchmark.sh new file mode 100755 index 0000000..02a88dd --- /dev/null +++ b/full_benchmark.sh @@ -0,0 +1,51 @@ +#!/usr/bin/bash + +if [ "$#" != "1" -a "$#" != "2" ]; then + echo "Usage benchmark.sh " + echo "Usage benchmark.sh tracemalloc" + exit 1 +fi + +if [ "$2" = "tracemalloc" ]; then + tracemalloc="--tracemalloc" +elif [ "$2" = "" ]; then + tracemalloc="" +else + echo "Usage benchmark.sh " + echo "Usage benchmark.sh tracemalloc" + exit 1 +fi + +outputfile=$1 + +if [ ! -f benchmark.py ]; then + echo "File benchmark.py missing. Are you in the home folder of pyte project?" + exit 1 +fi + +for inputfile in $(ls -1 tests/captured/*.input); do + export GEOMETRY=24x80 + echo "$inputfile - $GEOMETRY" + echo "======================" + BENCHMARK=$inputfile python benchmark.py $tracemalloc --append $outputfile + + export GEOMETRY=240x800 + echo "$inputfile - $GEOMETRY" + echo "======================" + BENCHMARK=$inputfile python benchmark.py $tracemalloc --append $outputfile + + export GEOMETRY=2400x8000 + echo "$inputfile - $GEOMETRY" + echo "======================" + BENCHMARK=$inputfile python benchmark.py $tracemalloc --append $outputfile + + export GEOMETRY=24x8000 + echo "$inputfile - $GEOMETRY" + echo "======================" + BENCHMARK=$inputfile python benchmark.py $tracemalloc --append $outputfile + + export GEOMETRY=2400x80 + echo "$inputfile - $GEOMETRY" + echo "======================" + BENCHMARK=$inputfile python benchmark.py $tracemalloc --append $outputfile +done diff --git a/pyte/screens.py b/pyte/screens.py index 5e7f759..636c138 100644 --- a/pyte/screens.py +++ b/pyte/screens.py @@ -20,11 +20,12 @@ how to do -- feel free to submit a pull request. :copyright: (c) 2011-2012 by Selectel. - :copyright: (c) 2012-2017 by pyte authors and contributors, + :copyright: (c) 2012-2022 by pyte authors and contributors, see AUTHORS for details. :license: LGPL, see LICENSE for more details. """ +import collections import copy import json import math @@ -32,8 +33,9 @@ import sys import unicodedata import warnings -from collections import deque, namedtuple, defaultdict +from collections import deque, namedtuple from functools import lru_cache +from bisect import bisect_left, bisect_right from wcwidth import wcwidth @@ -60,9 +62,7 @@ "wrap" ]) - -class Char(namedtuple("Char", [ - "data", +CharStyle = namedtuple("CharStyle", [ "fg", "bg", "bold", @@ -71,10 +71,167 @@ class Char(namedtuple("Char", [ "strikethrough", "reverse", "blink", -])): - """A single styled on-screen character. +]) + +class LineStats(namedtuple("_LineStats", [ + "empty", + "chars", + "columns", + "occupancy", + "min", + "max", + "span", + ])): + """ + :class:`~pyte.screens.LineStats` contains some useful statistics + about a single line in the screen to understand how the terminal program + draw on it and how :class:`~pyte.screens.Screen` makes use of the line. + + The basic statistic is the character count over the total count + of columns of the screen. The line is implemented as a sparse + buffer so space characters are not really stored and this ratio + reflects how many non-space chars are. The ratio is also known + as occupancy. + + A ratio close to 0 means that the line is mostly empty + and it will consume little memory and the screen's algorithms + will run faster; close to 1 means that it is mostly full and it will + have the opposite effects. + + For non-empty lines, the second statistic useful is its range. + The range of a line is the minimum and maximum x coordinates + where we find a non-space char. + For a screen of 80 columns, a range of [10 - 20] means that + the chars up to the x=10 are spaces and the chars after x=20 + are spaces too. + The length of the range, also known as the span, is calculated. + The chars/span ratio gives you how densely packed is the range. + + A ratio close to 0 means that the chars are sparse within the range + so it is too fragmented and the screen's algorithms will have to jump + between the chars and the gaps having a lower performance. + A ratio close to 1 means that they are highly packed and the screen + will have a better performance. + + With the ratios char/columns and chars/span one can understand + the balance of sparsity, its distribution and how it will impact + on the memory and execution time. + + .. note:: + + This is not part of the stable API so it may change + between version of pyte. + """ + + def __repr__(self): + if self.empty: + return "chars: {0: >3}/{1} ({2:.2f})".format( + self.chars, self.columns, self.occupancy, + ) + else: + return "chars: {0: >3}/{1} ({2:.2f}); range: [{3: >3} - {4: >3}], len: {5: >3} ({6:.2f})".format( + self.chars, self.columns, self.occupancy, + self.min, self.max, self.span, self.chars/self.span + ) + +class BufferStats(namedtuple("_BufferStats", [ + "empty", + "entries", + "columns", + "lines", + "falses", + "blanks", + "occupancy", + "min", + "max", + "span", + "line_stats", + ])): + """ + :class:`~pyte.screens.BufferStats` has some statistics about + the buffer of the screen, a 2d sparse matrix representation of the screen. + + The sparse implementation means that empty lines are not stored + in the buffer explicitly. + + The stats count the real lines (aka entries) and the ratio entries + over total lines that the screen has (aka occupancy). + + A ratio close to 0 means that the buffer is mostly empty + and it will consume little memory and the screen's algorithms + will run faster; close to 1 means that it is mostly full and it will + have the opposite effects. + + The buffer may have entries for empty lines in two forms: + + - falses lines: empty lines that are the same as the buffer's default + and therefore should not be in the buffer at all + - empty lines: non-empty lines but full of spaces. It is suspicious + because a line full of spaces should not have entries within but + there are legit cases when this is not true: for example when + the terminal program erase some chars typing space chars with + a non-default cursor attributes. + + Both counts are part of the stats with their falses/entries + and blanks/entries ratios. + + For non-empty buffers the minimum and maximum y-coordinates + are part of the stats. From there, the range and the span (length) + are calculated as well the entries/span ratio to see how densely + packed are the lines. + See :class:`~pyte.screens.LineStats` for more about these stats. + + After the buffer's stats, the stats of each non-empty line in the buffer + follows. See :class:`~pyte.screens.LineStats` for that. + + .. note:: + + This is not part of the stable API so it may change + between version of pyte. + """ + + def __repr__(self): + total_chars = sum(stats.chars for _, stats in self.line_stats) + bstats = "total chars: {0: >3}/{1} ({2:.2f}%)\n".format( + total_chars, self.columns*self.lines, + total_chars/(self.columns*self.lines) + ) + + if self.empty: + return bstats + \ + "line entries: {0: >3}/{1} ({2:.2f}), falses: {3:> 3} ({4:.2f}), blanks: {5:> 3} ({6:.2f})\n{7}".format( + self.entries, self.lines, self.occupancy, + self.falses, self.falses/self.entries, + self.blanks, self.blanks/self.entries, + "\n".join("{0: >3}: {1}".format(x, stats) for x, stats in self.line_stats) + ) + else: + return bstats + \ + "line entries: {0: >3}/{1} ({2:.2f}), falses: {3:> 3} ({4:.2f}), blanks: {5:> 3} ({6:.2f}); range: [{7: >3} - {8: >3}], len: {9: >3} ({10:.2f})\n{11}".format( + self.entries, self.lines, self.occupancy, + self.falses, self.falses/self.entries, + self.blanks, self.blanks/self.entries, + self.min, self.max, self.span, self.entries/self.span, + "\n".join("{0: >3}: {1}".format(x, stats) for x, stats in self.line_stats) + ) + + +class Char: + """ + A single styled on-screen character. The character is made + of an unicode character (data), its width and its style. :param str data: unicode character. Invariant: ``len(data) == 1``. + :param bool width: the width in terms of cells to display this char. + :param CharStyle style: the style of the character. + + The :meth:`~pyte.screens.Char.from_attributes` allows to create + a new :class:`~pyte.screens.Char` object + setting each attribute, one by one, without requiring and explicit + :class:`~pyte.screens.CharStyle` object. + + The supported attributes are: + :param str fg: foreground colour. Defaults to ``"default"``. :param str bg: background colour. Defaults to ``"default"``. :param bool bold: flag for rendering the character using bold font. @@ -89,15 +246,106 @@ class Char(namedtuple("Char", [ during rendering. Defaults to ``False``. :param bool blink: flag for rendering the character blinked. Defaults to ``False``. + + The attributes data, width and style of :class:`~pyte.screens.Char` + must be considered read-only. Any modification is undefined. + If you want to modify a :class:`~pyte.screens.Char`, use the public + interface of :class:`~pyte.screens.Screen`. """ - __slots__ = () + __slots__ = ( + "data", + "width", + "style", + ) - def __new__(cls, data, fg="default", bg="default", bold=False, - italics=False, underscore=False, + # List the properties of this Char instance including its style's properties + # The order of this _fields is maintained for backward compatibility + _fields = ("data",) + CharStyle._fields + ("width",) + + def __init__(self, data, width, style): + self.data = data + self.width = width + self.style = style + + @classmethod + def from_attributes(cls, data=" ", fg="default", bg="default", bold=False, italics=False, underscore=False, strikethrough=False, reverse=False, blink=False): - return super(Char, cls).__new__(cls, data, fg, bg, bold, italics, - underscore, strikethrough, reverse, - blink) + style = CharStyle(fg, bg, bold, italics, underscore, strikethrough, reverse, blink) + return Char(data, wcwidth(data), style) + + @property + def fg(self): + return self.style.fg + + @property + def bg(self): + return self.style.bg + + @property + def bold(self): + return self.style.bold + + @property + def italics(self): + return self.style.italics + + @property + def underscore(self): + return self.style.underscore + + @property + def strikethrough(self): + return self.style.strikethrough + + @property + def reverse(self): + return self.style.reverse + + @property + def blink(self): + return self.style.blink + + def copy_and_change(self, **kargs): + fields = self._asdict() + fields.update(kargs) + return Char(**fields) + + def copy(self): + return Char(self.data, self.width, self.style) + + def as_dict(self): + return {name: getattr(self, name) for name in self._fields} + + def __eq__(self, other): + if not isinstance(other, Char): + raise TypeError() + + return all(getattr(self, name) == getattr(other, name) for name in self._fields) + + def __ne__(self, other): + if not isinstance(other, Char): + raise TypeError() + + return any(getattr(self, name) != getattr(other, name) for name in self._fields) + + def __repr__(self): + r = "'%s'" % self.data + attrs = [] + if self.fg != "default": + attrs.append("fg=%s" % self.fg) + if self.bg != "default": + attrs.append("bg=%s" % self.bg) + + for attrname in ['bold', 'italics', 'underscore', + 'strikethrough', 'reverse', 'blink']: + val = getattr(self, attrname) + if val: + attrs.append("%s=%s" % (attrname, val)) + + if attrs: + r += " (" + (", ".join(attrs)) + ")" + + return r class Cursor: @@ -111,31 +359,200 @@ class Cursor: """ __slots__ = ("x", "y", "attrs", "hidden") - def __init__(self, x, y, attrs=Char(" ")): + def __init__(self, x, y, attrs): self.x = x self.y = y self.attrs = attrs self.hidden = False -class StaticDefaultDict(dict): - """A :func:`dict` with a static default value. +class Line(dict): + """A line or row of the screen. - Unlike :func:`collections.defaultdict` this implementation does not - implicitly update the mapping when queried with a missing key. + This dict subclass implements a sparse array for 0-based + indexed characters that represents a single line or row of the screen. - >>> d = StaticDefaultDict(42) - >>> d["foo"] - 42 - >>> d - {} + :param pyte.screens.Char default: a :class:`~pyte.screens.Char` instance + to be used as default. See :meth:`~pyte.screens.Line.char_at` + for details. """ + __slots__ = ('default', ) def __init__(self, default): self.default = default - def __missing__(self, key): - return self.default + def write_data(self, x, data, width, style): + """ + Update the char at the position x with the new data, width and style. + If no char is at that position, a new char is created and added + to the line. + """ + if x in self: + char = self[x] + char.data = data + char.width = width + char.style = style + else: + self[x] = Char(data, width, style) + + def char_at(self, x): + """ + Return the character at the given position x. If no char exists, + create a new one and add it to the line before returning it. + + This is a shortcut of `line.setdefault(x, line.default.copy())` + but avoids the copy if the char already exists. + """ + try: + return self[x] + except KeyError: + self[x] = char = self.default.copy() + return char + + def stats(self, screen): + """ + Return a :class:`~pyte.screens.LineStats` object with the statistics + of the line. + + .. note:: + + This is not part of the stable API so it may change + between version of pyte. + """ + return LineStats( + empty=not bool(self), + chars=len(self), + columns=screen.columns, + occupancy=len(self)/screen.columns, + min=min(self) if self else None, + max=max(self) if self else None, + span=(max(self) - min(self)) if self else None + ) + +class Buffer(dict): + """A 2d matrix representation of the screen. + + This dict subclass implements a sparse array for 0-based + indexed lines that represents the screen. Each line is then + a sparse array for the characters in the same row (see + :class:`~pyte.screens.Line`). + + :param pyte.screens.Screen screen: a :class:`~pyte.screens.Screen` instance + to be used when a default line needs to be created. + See :meth:`~pyte.screens.Buffer.line_at` for details. + """ + __slots__ = ('_screen', ) + def __init__(self, screen): + self._screen = screen + + def line_at(self, y): + """ + Return the line at the given position y. If no line exists, + create a new one and add it to the buffer before returning it. + + This is a shortcut of `buffer.setdefault(y, screen.default_line())` + but avoids the copy if the line already exists. + """ + try: + return self[y] + except KeyError: + self[y] = line = self._screen.default_line() + return line + +class LineView: + """ + A read-only view of an horizontal line of the screen. + :param pyte.screens.Line line: a :class:`~pyte.screens.Line` instance + + Modifications to the internals of the screen is still possible through + this :class:`~pyte.screens.LineView` however any modification + will result in an undefined behaviour. Don't do that. + + See :class:`~pyte.screens.BufferView`. + """ + __slots__ = ("_line",) + def __init__(self, line): + self._line = line + + def __getitem__(self, x): + try: + return self._line[x] + except KeyError: + return self._line.default + + def __eq__(self, other): + if not isinstance(other, LineView): + raise TypeError() + + return self._line == other._line + + def __ne__(self, other): + if not isinstance(other, LineView): + raise TypeError() + + return self._line == other._line + + +class BufferView: + """ + A read-only view of the screen. + + :param pyte.screens.Screen screen: a :class:`~pyte.screens.Screen` instance + + Modifications to the internals of the screen is still possible through + this :class:`~pyte.screens.BufferView` however any modification + will result in an undefined behaviour. Don't do that. + + Any modification to the screen must be done through its methods + (principally :meth:`~pyte.screens.Screen.draw`). + + This view allows the user to iterate over the lines and chars of + the buffer to query their attributes. + + As an example: + + view = screen.buffer # get a BufferView + for y in view: + line = view[y] # get a LineView (do it once per y line) + for x in line: + char = line[x] # get a Char + print(char.data, char.fg, char.bg) # access to char's attrs + """ + __slots__ = ("_buffer", "_screen") + def __init__(self, screen): + self._screen = screen + self._buffer = screen._buffer + + def __getitem__(self, y): + try: + line = self._buffer[y] + except KeyError: + line = Line(self._screen.default_char) + + return LineView(line) + + def __len__(self): + return self._screen.lines + +class _NullSet(collections.abc.MutableSet): + """Implementation of a set that it is always empty.""" + def __contains__(self, x): + return False + + def __iter__(self): + return iter(set()) + + def __len__(self): + return 0 + + def add(self, x): + return + + def discard(self, x): + return + + def update(self, it): + return class Screen: """ @@ -144,9 +561,30 @@ class Screen: and given explicit commands, or it can be attached to a stream and will respond to events. + :param int columns: count of columns for the screen (width). + :param int lines: count of lines for the screen (height). + + :param bool track_dirty_lines: track which lines were modified + (see `dirty` attribute). If it is false do not track any line. + Defaults to True. + + :param bool disable_display_graphic: disables the modification + of cursor attributes disabling :meth:`~pyte.screens.Screen.select_graphic_rendition`. + Defaults to False. + + .. note:: + + If you don't need the functionality, setting `track_dirty_lines` + to False and `disable_display_graphic` to True can + make :class:`~pyte.screens.Screen` to work faster and consume less + resources. + .. attribute:: buffer - A sparse ``lines x columns`` :class:`~pyte.screens.Char` matrix. + A ``lines x columns`` :class:`~pyte.screens.Char` matrix view of + the screen. Under the hood :class:`~pyte.screens.Screen` implements + a sparse matrix but `screen.buffer` returns a dense view. + See :class:`~pyte.screens.BufferView` .. attribute:: dirty @@ -159,6 +597,9 @@ class Screen: >>> list(screen.dirty) [0] + If `track_dirty_lines` was set to false, this `dirty` set will be + always empty. + .. versionadded:: 0.7.0 .. attribute:: cursor @@ -172,8 +613,8 @@ class Screen: (see :meth:`index` and :meth:`reverse_index`). Characters added outside the scrolling region do not make the screen to scroll. - The value is ``None`` if margins are set to screen boundaries, - otherwise -- a pair 0-based top and bottom line indices. + The margins are a pair 0-based top and bottom line indices + set to screen boundaries by default. .. attribute:: charset @@ -210,15 +651,26 @@ class Screen: @property def default_char(self): """An empty character with default foreground and background colors.""" - reverse = mo.DECSCNM in self.mode - return Char(data=" ", fg="default", bg="default", reverse=reverse) + style = self._default_style_reversed if mo.DECSCNM in self.mode else self._default_style + return Char(" ", wcwidth(" "), style) - def __init__(self, columns, lines): + def default_line(self): + return Line(self.default_char) + + def __init__(self, columns, lines, track_dirty_lines=True, disable_display_graphic=False): self.savepoints = [] self.columns = columns self.lines = lines - self.buffer = defaultdict(lambda: StaticDefaultDict(self.default_char)) - self.dirty = set() + self._buffer = Buffer(self) + self.dirty = set() if track_dirty_lines else _NullSet() + self.disabled_display_graphic = disable_display_graphic + + self._default_style = CharStyle( + fg="default", bg="default", bold=False, + italics=False, underscore=False, + strikethrough=False, reverse=False, blink=False) + self._default_style_reversed = self._default_style._replace(reverse=True) + self.reset() def __repr__(self): @@ -226,20 +678,111 @@ def __repr__(self): self.columns, self.lines)) @property - def display(self): - """A :func:`list` of screen lines as unicode strings.""" - def render(line): + def buffer(self): + return BufferView(self) + + def stats(self): + """ + Return the statistcs of the buffer. + + .. note:: + + This is not part of the stable API so it may change + between version of pyte. + """ + buffer = self._buffer + return BufferStats( + empty=not bool(buffer), + entries=len(buffer), + columns=self.columns, + lines=self.lines, + falses=len([line for line in buffer.values() if not line]), + blanks=len([line for line in buffer.values() if all(char.data == " " for char in line.values())]), + occupancy=len(buffer)/self.lines, + min=min(buffer) if buffer else None, + max=max(buffer) if buffer else None, + span=(max(buffer) - min(buffer)) if buffer else None, + line_stats=[(x, line.stats(self)) for x, line in sorted(buffer.items())] + ) + + def compressed_display(self, lstrip=False, rstrip=False, tfilter=False, bfilter=False): + """A :func:`list` of screen lines as unicode strings with optionally + the possibility to compress its output striping space and filtering + empty lines. + + :param bool lstrip: strip the left space of each line. + :param bool rstrip: strip the right space of each line. + :param bool tfilter: filter the top whole empty lines. + :param bool bfilter: filter the bottom whole empty lines. + + .. note:: + + The strip of left/right spaces on each line and/or the filter + of top/bottom whole empty lines is implemented in an opportunistic + fashion and it may not strip/filter fully the spaces and/or lines. + + This method is meant to be an optimization over + :meth:`~pyte.screens.Screen.display` for displaying + large mostly-empty screens. + + For left-written texts, + `compressed_display(rstrip=True, tfilter=True, bfilter=True)` compress + the display without losing meaning. + + For right-written texts, + `compressed_display(lstrip=True, tfilter=True, bfilter=True)` compress + the display without losing meaning. + """ + # screen.default_char is always the space character + # We can skip the lookup of it and set the padding char + # directly + empty_line_padding = "" if (lstrip or rstrip) else " " + padding = " " + + non_empty_y = sorted(self._buffer.items()) + prev_y = non_empty_y[0][0]-1 if tfilter and non_empty_y else -1 + output = [] + columns = self.columns + for y, line in non_empty_y: + empty_lines = y - (prev_y + 1) + if empty_lines: + output.extend([empty_line_padding * columns] * empty_lines) + prev_y = y + + non_empty_x = sorted(line.items()) is_wide_char = False - for x in range(self.columns): + prev_x = non_empty_x[0][0]-1 if lstrip and non_empty_x else -1 + display_line = [] + for x, cell in non_empty_x: + gap = x - (prev_x + 1) + if gap: + display_line.append(padding * gap) + + prev_x = x + if is_wide_char: # Skip stub is_wide_char = False continue - char = line[x].data - assert sum(map(wcwidth, char[1:])) == 0 - is_wide_char = wcwidth(char[0]) == 2 - yield char + char = cell.data + is_wide_char = cell.width == 2 + display_line.append(char) + + gap = columns - (prev_x + 1) + if gap and not rstrip: + display_line.append(padding * gap) - return ["".join(render(self.buffer[y])) for y in range(self.lines)] + output.append("".join(display_line)) + + empty_lines = self.lines - (prev_y + 1) + if empty_lines and not bfilter: + output.extend([empty_line_padding * columns] * empty_lines) + + return output + + @property + def display(self): + """A :func:`list` of screen lines as unicode strings.""" + return self.compressed_display() def reset(self): """Reset the terminal to its initial state. @@ -259,8 +802,8 @@ def reset(self): :manpage:`xterm` -- we now know that. """ self.dirty.update(range(self.lines)) - self.buffer.clear() - self.margins = None + self._buffer.clear() + self.margins = Margins(0, self.lines - 1) self.mode = set([mo.DECAWM, mo.DECTCEM]) @@ -276,7 +819,7 @@ def reset(self): # we aim to support VT102 / VT220 and linux -- we use n = 8. self.tabstops = set(range(8, self.columns, 8)) - self.cursor = Cursor(0, 0) + self.cursor = Cursor(0, 0, self.default_char.copy()) self.cursor_position() self.saved_columns = None @@ -315,9 +858,12 @@ def resize(self, lines=None, columns=None): self.restore_cursor() if columns < self.columns: - for line in self.buffer.values(): - for x in range(columns, self.columns): - line.pop(x, None) + for line in self._buffer.values(): + pop = line.pop + non_empty_x = sorted(line) + begin = bisect_left(non_empty_x, columns) + + list(map(pop, non_empty_x[begin:])) self.lines, self.columns = lines, columns self.set_margins() @@ -330,10 +876,10 @@ def set_margins(self, top=None, bottom=None): """ # XXX 0 corresponds to the CSI with no parameters. if (top is None or top == 0) and bottom is None: - self.margins = None + self.margins = Margins(0, self.lines - 1) return - margins = self.margins or Margins(0, self.lines - 1) + margins = self.margins # Arguments are 1-based, while :attr:`margins` are zero # based -- so we have to decrement them by one. We also @@ -386,10 +932,10 @@ def set_mode(self, *modes, **kwargs): # Mark all displayed characters as reverse. if mo.DECSCNM in modes: - for line in self.buffer.values(): - line.default = self.default_char - for x in line: - line[x] = line[x]._replace(reverse=True) + for line in self._buffer.values(): + line.default.style = line.default.style._replace(reverse=True) + for char in line.values(): + char.style = char.style._replace(reverse=True) self.select_graphic_rendition(7) # +reverse. @@ -424,10 +970,10 @@ def reset_mode(self, *modes, **kwargs): self.cursor_position() if mo.DECSCNM in modes: - for line in self.buffer.values(): - line.default = self.default_char - for x in line: - line[x] = line[x]._replace(reverse=False) + for line in self._buffer.values(): + line.default.style = line.default.style._replace(reverse=False) + for char in line.values(): + char.style = char.style._replace(reverse=False) self.select_graphic_rendition(27) # -reverse. @@ -474,6 +1020,32 @@ def draw(self, data): data = data.translate( self.g1_charset if self.charset else self.g0_charset) + # Fetch these attributes to avoid a lookup on each iteration + # of the for-loop. + # These attributes are expected to be constant across all the + # execution of self.draw() + columns = self.columns + cursor = self.cursor + buffer = self._buffer + attrs = cursor.attrs + mode = self.mode + style = attrs.style + + # Note: checking for IRM here makes sense because it would be + # checked on every char in data otherwise. + # Checking DECAWM, on the other hand, not necessary is a good + # idea because it only matters if cursor_x == columns (unlikely) + is_IRM_set = mo.IRM in mode + DECAWM = mo.DECAWM + + # The following are attributes expected to change infrequently + # so we fetch them here and update accordingly if necessary + cursor_x = cursor.x + cursor_y = cursor.y + line = buffer.line_at(cursor_y) + + write_data = line.write_data + char_at = line.char_at for char in data: char_width = wcwidth(char) @@ -481,50 +1053,86 @@ def draw(self, data): # enabled, move the cursor to the beginning of the next line, # otherwise replace characters already displayed with newly # entered. - if self.cursor.x == self.columns: - if mo.DECAWM in self.mode: - self.dirty.add(self.cursor.y) + if cursor_x >= columns: + if DECAWM in mode: + self.dirty.add(cursor_y) self.carriage_return() self.linefeed() + + # carriage_return implies cursor.x = 0 so we update cursor_x + # This also puts the cursor_x back into the screen if before + # cursor_x was outside (cursor_x > columns). See the comments + # at the end of the for-loop + cursor_x = 0 + + # linefeed may update cursor.y so we update cursor_y and + # the current line accordingly. + cursor_y = cursor.y + line = buffer.line_at(cursor_y) + write_data = line.write_data + char_at = line.char_at elif char_width > 0: - self.cursor.x -= char_width + # Move the cursor_x back enough to make room for + # the new char. + # This indirectly fixes the case of cursor_x > columns putting + # the cursor_x back to the screen. + cursor_x = columns - char_width + else: + # Ensure that cursor_x = min(cursor_x, columns) in the case + # that wcwidth returned 0 or negative and the flow didn't enter + # in any of the branches above. + # See the comments at the end of the for-loop + cursor_x = columns # If Insert mode is set, new characters move old characters to # the right, otherwise terminal is in Replace mode and new # characters replace old characters at cursor position. - if mo.IRM in self.mode and char_width > 0: + if is_IRM_set and char_width > 0: + # update the real cursor so insert_characters() can use + # an updated (and correct) value of it + cursor.x = cursor_x self.insert_characters(char_width) - line = self.buffer[self.cursor.y] if char_width == 1: - line[self.cursor.x] = self.cursor.attrs._replace(data=char) + write_data(cursor_x, char, char_width, style) elif char_width == 2: # A two-cell character has a stub slot after it. - line[self.cursor.x] = self.cursor.attrs._replace(data=char) - if self.cursor.x + 1 < self.columns: - line[self.cursor.x + 1] = self.cursor.attrs \ - ._replace(data="") + write_data(cursor_x, char, char_width, style) + if cursor_x + 1 < columns: + write_data(cursor_x+1, "", 0, style) elif char_width == 0 and unicodedata.combining(char): # A zero-cell character is combined with the previous # character either on this or preceding line. - if self.cursor.x: - last = line[self.cursor.x - 1] + # Because char's width is zero, this will not change the width + # of the previous character. + if cursor_x: + last = char_at(cursor_x - 1) normalized = unicodedata.normalize("NFC", last.data + char) - line[self.cursor.x - 1] = last._replace(data=normalized) - elif self.cursor.y: - last = self.buffer[self.cursor.y - 1][self.columns - 1] + last.data = normalized + elif cursor_y: + last = buffer.line_at(cursor_y - 1).char_at(columns - 1) normalized = unicodedata.normalize("NFC", last.data + char) - self.buffer[self.cursor.y - 1][self.columns - 1] = \ - last._replace(data=normalized) + last.data = normalized else: break # Unprintable character or doesn't advance the cursor. # .. note:: We can't use :meth:`cursor_forward()`, because that # way, we'll never know when to linefeed. - if char_width > 0: - self.cursor.x = min(self.cursor.x + char_width, self.columns) - - self.dirty.add(self.cursor.y) + # + # Note: cursor_x may leave outside the screen if cursor_x > columns + # but this is going to be fixed in the next iteration or at the end + # of the draw() method + cursor_x += char_width + + self.dirty.add(cursor_y) + + # Update the real cursor fixing the cursor_x to be + # within the limits of the screen + if cursor_x > columns: + cursor.x = columns + else: + cursor.x = cursor_x + cursor.y = cursor_y def set_title(self, param): """Set terminal title. @@ -548,13 +1156,29 @@ def index(self): """Move the cursor down one line in the same column. If the cursor is at the last line, create a new line at the bottom. """ - top, bottom = self.margins or Margins(0, self.lines - 1) + top, bottom = self.margins if self.cursor.y == bottom: + buffer = self._buffer + pop = buffer.pop + + non_empty_y = sorted(buffer) + begin = bisect_left(non_empty_y, top + 1) + end = bisect_right(non_empty_y, bottom, begin) + + # the top line must be unconditionally removed + # this pop is required because it may happen that + # the next line (top + 1) is empty and therefore + # the for-loop above didn't overwrite the line before + # (top + 1 - 1, aka top) + pop(top, None) + + to_move = non_empty_y[begin:end] + for y in to_move: + buffer[y-1] = pop(y) + # TODO: mark only the lines within margins? + # we could mark "(y-1, y) for y in to_move" self.dirty.update(range(self.lines)) - for y in range(top, bottom): - self.buffer[y] = self.buffer[y + 1] - self.buffer.pop(bottom, None) else: self.cursor_down() @@ -562,13 +1186,30 @@ def reverse_index(self): """Move the cursor up one line in the same column. If the cursor is at the first line, create a new line at the top. """ - top, bottom = self.margins or Margins(0, self.lines - 1) + top, bottom = self.margins if self.cursor.y == top: + buffer = self._buffer + pop = buffer.pop + + non_empty_y = sorted(buffer) + begin = bisect_left(non_empty_y, top) + end = bisect_right(non_empty_y, bottom - 1, begin) + + # the bottom line must be unconditionally removed + # this pop is required because it may happen that + # the previous line (bottom - 1) is empty and therefore + # the for-loop above didn't overwrite the line after + # (bottom - 1 + 1, aka bottom) + pop(bottom, None) + + to_move = non_empty_y[begin:end] + for y in reversed(to_move): + buffer[y+1] = pop(y) + # TODO: mark only the lines within margins? + # we could mark "(y+1, y) for y in to_move" self.dirty.update(range(self.lines)) - for y in range(bottom, top, -1): - self.buffer[y] = self.buffer[y - 1] - self.buffer.pop(top, None) + else: self.cursor_up() @@ -585,14 +1226,16 @@ def tab(self): """Move to the next tab space, or the end of the screen if there aren't anymore left. """ - for stop in sorted(self.tabstops): - if self.cursor.x < stop: - column = stop - break + tabstops = sorted(self.tabstops) + + # use bisect_right because self.cursor.x must not + # be included + at = bisect_right(tabstops, self.cursor.x) + if at == len(tabstops): + # no tabstops found, set the x to the end of the screen + self.cursor.x = self.columns - 1 else: - column = self.columns - 1 - - self.cursor.x = column + self.cursor.x = tabstops[at] def backspace(self): """Move cursor to the left one or keep it in its position if @@ -602,7 +1245,7 @@ def backspace(self): def save_cursor(self): """Push the current cursor position onto the stack.""" - self.savepoints.append(Savepoint(copy.copy(self.cursor), + self.savepoints.append(Savepoint(copy.deepcopy(self.cursor), self.g0_charset, self.g1_charset, self.charset, @@ -642,15 +1285,26 @@ def insert_lines(self, count=None): :param count: number of lines to insert. """ count = count or 1 - top, bottom = self.margins or Margins(0, self.lines - 1) + top, bottom = self.margins # If cursor is outside scrolling margins it -- do nothin'. if top <= self.cursor.y <= bottom: self.dirty.update(range(self.cursor.y, self.lines)) - for y in range(bottom, self.cursor.y - 1, -1): - if y + count <= bottom and y in self.buffer: - self.buffer[y + count] = self.buffer[y] - self.buffer.pop(y, None) + + # the following algorithm is similar to the one found + # in insert_characters except that operates over + # the lines (y range) and not the chars (x range) + buffer = self._buffer + pop = buffer.pop + non_empty_y = sorted(buffer) + move_begin = bisect_left(non_empty_y, self.cursor.y) + drop_begin = bisect_left(non_empty_y, (bottom + 1) - count, move_begin) + margin_begin = bisect_left(non_empty_y, bottom + 1, drop_begin) + + list(map(pop, non_empty_y[drop_begin:margin_begin])) # drop + + for y in reversed(non_empty_y[move_begin:drop_begin]): + buffer[y + count] = pop(y) # move self.carriage_return() @@ -663,17 +1317,23 @@ def delete_lines(self, count=None): :param int count: number of lines to delete. """ count = count or 1 - top, bottom = self.margins or Margins(0, self.lines - 1) + top, bottom = self.margins # If cursor is outside scrolling margins -- do nothin'. if top <= self.cursor.y <= bottom: self.dirty.update(range(self.cursor.y, self.lines)) - for y in range(self.cursor.y, bottom + 1): - if y + count <= bottom: - if y + count in self.buffer: - self.buffer[y] = self.buffer.pop(y + count) - else: - self.buffer.pop(y, None) + + buffer = self._buffer + pop = buffer.pop + non_empty_y = sorted(buffer) + drop_begin = bisect_left(non_empty_y, self.cursor.y) + margin_begin = bisect_left(non_empty_y, bottom + 1, drop_begin) + move_begin = bisect_left(non_empty_y, self.cursor.y + count, drop_begin, margin_begin) + + list(map(pop, non_empty_y[drop_begin:move_begin])) # drop + + for y in non_empty_y[move_begin:margin_begin]: + buffer[y - count] = pop(y) # move self.carriage_return() @@ -688,11 +1348,41 @@ def insert_characters(self, count=None): self.dirty.add(self.cursor.y) count = count or 1 - line = self.buffer[self.cursor.y] - for x in range(self.columns, self.cursor.x - 1, -1): - if x + count <= self.columns: - line[x + count] = line[x] - line.pop(x, None) + line = self._buffer.get(self.cursor.y) + + # if there is no line (aka the line is empty), then don't do + # anything as insert_characters only moves the chars within + # the line but does not write anything new. + if not line: + return + + pop = line.pop + + # Note: the following is optimized for the case of long lines + # that are not very densely populated, the amount of count + # to insert is small and the cursor is not very close to the right + # end. + non_empty_x = sorted(line) + move_begin = bisect_left(non_empty_x, self.cursor.x) + drop_begin = bisect_left(non_empty_x, self.columns - count, move_begin) + + # cursor.x + # | + # V to_move to_drop + # |---------------|-------| + # 0 1 x 3 4 5 count = 2 (x means empty) + # + list(map(pop, non_empty_x[drop_begin:])) # drop + + # cursor.x + # | + # V moved + # |---------------| + # x x 0 1 x 3 count = 2 (x means empty) + for x in reversed(non_empty_x[move_begin:drop_begin]): + line[x + count] = pop(x) # move + + def delete_characters(self, count=None): """Delete the indicated # of characters, starting with the @@ -703,14 +1393,33 @@ def delete_characters(self, count=None): :param int count: number of characters to delete. """ self.dirty.add(self.cursor.y) + count = count or 1 + line = self._buffer.get(self.cursor.y) - line = self.buffer[self.cursor.y] - for x in range(self.cursor.x, self.columns): - if x + count <= self.columns: - line[x] = line.pop(x + count, self.default_char) - else: - line.pop(x, None) + # if there is no line (aka the line is empty), then don't do + # anything as delete_characters only moves the chars within + # the line but does not write anything new except a default char + if not line: + return + + pop = line.pop + + non_empty_x = sorted(line) + drop_begin = bisect_left(non_empty_x, self.cursor.x) + move_begin = bisect_left(non_empty_x, self.cursor.x + count, drop_begin) + + list(map(pop, non_empty_x[drop_begin:move_begin])) # drop + + # cursor.x + # | + # V to drop to_move + # |-------|---------------| + # 0 1 x 3 4 x count = 2 (x means empty) + # x x x 3 4 x after the drop + # x 3 4 x x x after the move + for x in non_empty_x[move_begin:]: + line[x - count] = pop(x) # move def erase_characters(self, count=None): """Erase the indicated # of characters, starting with the @@ -729,10 +1438,32 @@ def erase_characters(self, count=None): self.dirty.add(self.cursor.y) count = count or 1 - line = self.buffer[self.cursor.y] - for x in range(self.cursor.x, - min(self.cursor.x + count, self.columns)): - line[x] = self.cursor.attrs + line = self._buffer.line_at(self.cursor.y) + + # If the line's default char is equivalent to our cursor, overwriting + # a char in the line is equivalent to delete it if from the line + if line.default == self.cursor.attrs: + pop = line.pop + non_empty_x = sorted(line) + begin = bisect_left(non_empty_x, self.cursor.x) + end = bisect_left(non_empty_x, self.cursor.x + count, begin) + + list(map(pop, non_empty_x[begin:end])) + + # the line may end up being empty, delete it from the buffer (*) + if not line: + del self._buffer[self.cursor.y] + + else: + write_data = line.write_data + data = self.cursor.attrs.data + width = self.cursor.attrs.width + style = self.cursor.attrs.style + # a full range scan is required and not a sparse scan + # because we were asked to *write* on that full range + for x in range(self.cursor.x, + min(self.cursor.x + count, self.columns)): + write_data(x, data, width, style) def erase_in_line(self, how=0, private=False): """Erase a line in a specific way. @@ -751,15 +1482,37 @@ def erase_in_line(self, how=0, private=False): """ self.dirty.add(self.cursor.y) if how == 0: - interval = range(self.cursor.x, self.columns) + low, high = self.cursor.x, self.columns elif how == 1: - interval = range(self.cursor.x + 1) + low, high = 0, (self.cursor.x + 1) elif how == 2: - interval = range(self.columns) + low, high = 0, self.columns + + line = self._buffer.line_at(self.cursor.y) + + # If the line's default char is equivalent to our cursor, overwriting + # a char in the line is equivalent to delete it if from the line + if line.default == self.cursor.attrs: + pop = line.pop + non_empty_x = sorted(line) + begin = bisect_left(non_empty_x, low) + end = bisect_left(non_empty_x, high, begin) - line = self.buffer[self.cursor.y] - for x in interval: - line[x] = self.cursor.attrs + list(map(pop, non_empty_x[begin:end])) + + # the line may end up being empty, delete it from the buffer (*) + if not line: + del self._buffer[self.cursor.y] + + else: + write_data = line.write_data + data = self.cursor.attrs.data + width = self.cursor.attrs.width + style = self.cursor.attrs.style + # a full range scan is required and not a sparse scan + # because we were asked to *write* on that full range + for x in range(low, high): + write_data(x, data, width, style) def erase_in_display(self, how=0, *args, **kwargs): """Erases display in a specific way. @@ -785,17 +1538,45 @@ def erase_in_display(self, how=0, *args, **kwargs): parameter causing the stream to assume a ``0`` second parameter. """ if how == 0: - interval = range(self.cursor.y + 1, self.lines) + top, bottom = self.cursor.y + 1, self.lines elif how == 1: - interval = range(self.cursor.y) + top, bottom = 0, self.cursor.y elif how == 2 or how == 3: - interval = range(self.lines) + top, bottom = 0, self.lines - self.dirty.update(interval) - for y in interval: - line = self.buffer[y] - for x in line: - line[x] = self.cursor.attrs + buffer = self._buffer + + self.dirty.update(range(top, bottom)) + + # if we were requested to clear the whole screen and + # the cursor's attrs are the same than the screen's default + # then this is equivalent to delete all the lines from the buffer + if (how == 2 or how == 3) and self.default_char == self.cursor.attrs: + buffer.clear() + return + + # Remove the lines from the buffer as this is equivalent + # to overwrite each char in them with the space character + # (screen.default_char). + # If a deleted line is then requested, a new line will + # be added with screen.default_char as its default char + if self.default_char == self.cursor.attrs: + pop = buffer.pop + non_empty_y = sorted(buffer) + begin = bisect_left(non_empty_y, top) # inclusive + end = bisect_left(non_empty_y, bottom, begin) # exclusive + + list(map(pop, non_empty_y[begin:end])) + + else: + data = self.cursor.attrs.data + width = self.cursor.attrs.width + style = self.cursor.attrs.style + for y in range(top, bottom): + line = buffer.line_at(y) + write_data = line.write_data + for x in range(0, self.columns): + write_data(x, data, width, style) if how == 0 or how == 1: self.erase_in_line(how) @@ -818,7 +1599,7 @@ def clear_tab_stop(self, how=0): # present, or silently fails if otherwise. self.tabstops.discard(self.cursor.x) elif how == 3: - self.tabstops = set() # Clears all horizontal tab stops. + self.tabstops.clear() # Clears all horizontal tab stops. def ensure_hbounds(self): """Ensure the cursor is within horizontal screen bounds.""" @@ -832,7 +1613,7 @@ def ensure_vbounds(self, use_margins=None): cursor is bounded by top and and bottom margins, instead of ``[0; lines - 1]``. """ - if (use_margins or mo.DECOM in self.mode) and self.margins is not None: + if (use_margins or mo.DECOM in self.mode): top, bottom = self.margins else: top, bottom = 0, self.lines - 1 @@ -845,7 +1626,7 @@ def cursor_up(self, count=None): :param int count: number of lines to skip. """ - top, _bottom = self.margins or Margins(0, self.lines - 1) + top, _bottom = self.margins self.cursor.y = max(self.cursor.y - (count or 1), top) def cursor_up1(self, count=None): @@ -863,7 +1644,7 @@ def cursor_down(self, count=None): :param int count: number of lines to skip. """ - _top, bottom = self.margins or Margins(0, self.lines - 1) + _top, bottom = self.margins self.cursor.y = min(self.cursor.y + (count or 1), bottom) def cursor_down1(self, count=None): @@ -913,7 +1694,7 @@ def cursor_position(self, line=None, column=None): # If origin mode (DECOM) is set, line number are relative to # the top scrolling margin. - if self.margins is not None and mo.DECOM in self.mode: + if mo.DECOM in self.mode: line += self.margins.top # Cursor is not allowed to move out of the scrolling region. @@ -958,19 +1739,28 @@ def bell(self, *args): def alignment_display(self): """Fills screen with uppercase E's for screen focus and alignment.""" self.dirty.update(range(self.lines)) + style = self._default_style for y in range(self.lines): + line = self._buffer.line_at(y) for x in range(self.columns): - self.buffer[y][x] = self.buffer[y][x]._replace(data="E") + line.write_data(x, "E", wcwidth("E"), style) def select_graphic_rendition(self, *attrs): """Set display attributes. :param list attrs: a list of display attributes to set. + + .. note:: + + If `disable_display_graphic` was set, this method + set the cursor's attributes to the default char's attributes + ignoring all the parameters. + Equivalent to `screen.select_graphic_rendition(0)`. """ replace = {} # Fast path for resetting all attributes. - if not attrs or attrs == (0, ): + if not attrs or attrs == (0, ) or self.disabled_display_graphic: self.cursor.attrs = self.default_char return else: @@ -980,7 +1770,7 @@ def select_graphic_rendition(self, *attrs): attr = attrs.pop() if attr == 0: # Reset all attributes. - replace.update(self.default_char._asdict()) + replace.update(self.default_char.style._asdict()) elif attr in g.FG_ANSI: replace["fg"] = g.FG_ANSI[attr] elif attr in g.BG: @@ -1008,7 +1798,7 @@ def select_graphic_rendition(self, *attrs): except IndexError: pass - self.cursor.attrs = self.cursor.attrs._replace(**replace) + self.cursor.attrs.style = self.cursor.attrs.style._replace(**replace) def report_device_attributes(self, mode=0, **kwargs): """Report terminal identity. @@ -1127,14 +1917,17 @@ class HistoryScreen(Screen): _wrapped = set(Stream.events) _wrapped.update(["next_page", "prev_page"]) - def __init__(self, columns, lines, history=100, ratio=.5): + def __init__(self, columns, lines, history=100, ratio=.5, + track_dirty_lines=True, disable_display_graphic=False): self.history = History(deque(maxlen=history), deque(maxlen=history), float(ratio), history, history) - super(HistoryScreen, self).__init__(columns, lines) + super(HistoryScreen, self).__init__(columns, lines, + track_dirty_lines=track_dirty_lines, + disable_display_graphic=disable_display_graphic) def _make_wrapper(self, event, handler): def inner(*args, **kwargs): @@ -1169,10 +1962,13 @@ def after_event(self, event): :param str event: event name, for example ``"linefeed"``. """ if event in ["prev_page", "next_page"]: - for line in self.buffer.values(): - for x in line: - if x > self.columns: - line.pop(x) + columns = self.columns + for line in self._buffer.values(): + pop = line.pop + non_empty_x = sorted(line) + begin = bisect_left(non_empty_x, columns) + + list(map(pop, non_empty_x[begin:])) # If we're at the bottom of the history buffer and `DECTCEM` # mode is set -- show the cursor. @@ -1203,7 +1999,7 @@ def erase_in_display(self, how=0, *args, **kwargs): def index(self): """Overloaded to update top history with the removed lines.""" - top, bottom = self.margins or Margins(0, self.lines - 1) + top, bottom = self.margins if self.cursor.y == bottom: self.history.top.append(self.buffer[top]) @@ -1212,7 +2008,7 @@ def index(self): def reverse_index(self): """Overloaded to update bottom history with the removed lines.""" - top, bottom = self.margins or Margins(0, self.lines - 1) + top, bottom = self.margins if self.cursor.y == top: self.history.bottom.append(self.buffer[bottom]) @@ -1229,18 +2025,66 @@ def prev_page(self): mid = min(len(self.history.top), int(math.ceil(self.lines * self.history.ratio))) + bufferview = self.buffer + buffer = self._buffer + pop = buffer.pop + self.history.bottom.extendleft( - self.buffer[y] - for y in range(self.lines - 1, self.lines - mid - 1, -1)) + bufferview[y] + for y in range(self.lines - 1, self.lines - mid - 1, -1) + ) + self.history = self.history \ ._replace(position=self.history.position - mid) - for y in range(self.lines - 1, mid - 1, -1): - self.buffer[y] = self.buffer[y - mid] + non_empty_y = sorted(buffer) + end = bisect_left(non_empty_y, self.lines - mid) + + to_move = reversed(non_empty_y[:end]) + + # to_move + # |---------------| + # 0 1 x 3 4 5 mid = 2 (x means empty) + # + # 0 1 0 1 4 3 (first for-loop without the inner loop: the "4" is wrong) + # + # 0 1 0 1 x 3 (first for-loop with the inner loop: the "4" is removed) + # + # P P 0 1 x 3 (after third for-loop, P are from history.top) + next_y = self.lines - mid + for y in to_move: + # Notice how if (y + 1) == (next_y) then you know + # that no empty lines are in between this y and the next one + # and therefore the range() loop gets empty. + # In other cases, (y + 1) < (next_y) + for z in range(y + 1 + mid, next_y + mid): + pop(z, None) + + # it may look weird but the current "y" is the "next_y" + # of the next iteration because we are iterating to_move + # backwards + next_y = y + buffer[y + mid] = buffer[y] + + # between the last moved line and the begin of the page + # we may have lines that should be emptied + for z in range(0 + mid, next_y + mid): + pop(z, None) + for y in range(mid - 1, -1, -1): - self.buffer[y] = self.history.top.pop() + line = self.history.top.pop()._line + if line: + # note: empty lines are not added as they are + # the default for non-existent entries in buffer + buffer[y] = line + else: + # because empty lines are not added we need to ensure + # that the old lines in that position become empty + # anyways (aka, we remove the old ones) + pop(y, None) - self.dirty = set(range(self.lines)) + self.dirty.clear() + self.dirty.update(range(self.lines)) def next_page(self): """Move the screen page down through the history buffer.""" @@ -1248,16 +2092,58 @@ def next_page(self): mid = min(len(self.history.bottom), int(math.ceil(self.lines * self.history.ratio))) - self.history.top.extend(self.buffer[y] for y in range(mid)) + bufferview = self.buffer + buffer = self._buffer + pop = buffer.pop + + self.history.top.extend( + bufferview[y] + for y in range(mid) + ) + self.history = self.history \ ._replace(position=self.history.position + mid) - for y in range(self.lines - mid): - self.buffer[y] = self.buffer[y + mid] + non_empty_y = sorted(buffer) + begin = bisect_left(non_empty_y, mid) + + to_move = non_empty_y[begin:] + + # to_move + # |---------------| + # 0 1 2 x 4 5 mid = 2 + # + # 2 1 4 5 4 5 + # + # 2 3 4 5 P P (final result) + + prev_y = mid - 1 + for y in to_move: + # Notice how if (prev_y + 1) == (y) then you know + # that no empty lines are in between and therefore + # the range() loop gets empty. + # In other cases, (prev_y + 1) > (y) + for z in range(prev_y + 1 - mid, y - mid): + pop(z, None) + + prev_y = y + buffer[y - mid] = buffer[y] + + for z in range(prev_y + 1 - mid, self.lines - mid): + pop(z, None) + for y in range(self.lines - mid, self.lines): - self.buffer[y] = self.history.bottom.popleft() + line = self.history.bottom.popleft()._line + if line: + buffer[y] = line + else: + # because empty lines are not added we need to ensure + # that the old lines in that position become empty + # anyways (aka, we remove the old ones) + pop(y, None) - self.dirty = set(range(self.lines)) + self.dirty.clear() + self.dirty.update(range(self.lines)) class DebugEvent(namedtuple("Event", "name args kwargs")): diff --git a/requirements_dev.txt b/requirements_dev.txt index c1ee84d..8c54e74 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,4 @@ pytest -pyperf == 1.7.1 +pyperf >= 2.3.0 wcwidth wheel diff --git a/tests/helpers/asserts.py b/tests/helpers/asserts.py new file mode 100644 index 0000000..f359480 --- /dev/null +++ b/tests/helpers/asserts.py @@ -0,0 +1,80 @@ +from wcwidth import wcwidth +def consistency_asserts(screen): + # Ensure that all the cells in the buffer, if they have + # a data of 2 or more code points, they all sum up 0 width + # In other words, the width of the cell is determinated by the + # width of the first code point. + for y in range(screen.lines): + for x in range(screen.columns): + data = screen.buffer[y][x].data + assert sum(map(wcwidth, data[1:])) == 0 + + + # Ensure consistency between the real width (computed here + # with wcwidth(...)) and the char.width attribute + for y in range(screen.lines): + for x in range(screen.columns): + char = screen.buffer[y][x] + if char.data: + assert wcwidth(char.data[0]) == char.width + else: + assert char.data == "" + assert char.width == 0 + + # we check that no char is outside of the buffer + # we need to check the internal _buffer for this and do an educated + # check + non_empty_y = list(screen._buffer.keys()) + min_y = min(non_empty_y) if non_empty_y else 0 + max_y = max(non_empty_y) if non_empty_y else screen.lines - 1 + + assert 0 <= min_y <= max_y < screen.lines + + for line in screen._buffer.values(): + non_empty_x = list(line.keys()) + min_x = min(non_empty_x) if non_empty_x else 0 + max_x = max(non_empty_x) if non_empty_x else screen.columns - 1 + + assert 0 <= min_x <= max_x < screen.columns + + +def splice(seq, at, count, padding, margins=None): + ''' Take a sequence and add count padding objects at the + given position "at". + If count is negative, instead of adding, remove + objects at the given position and append the same + amount at the end. + + If margins=(low, high) are given, operate between + the low and the high indexes of the sequence. + These are 0-based indexes, both inclusive. + ''' + + assert count != 0 + assert isinstance(seq, list) + + low, high = margins if margins else (0, len(seq) - 1) + + if not (low <= at <= high): + return list(seq) + + low_part = seq[:low] + high_part = seq[high+1:] + + middle = seq[low:high+1] + at = at - low # "at" now is an index of middle, not of seq. + + if count < 0: # remove mode + count = abs(count) + l = len(middle) + del middle[at:at+count] + middle += padding * (l - len(middle)) + else: # insert mode + middle = middle[:at] + padding * count + middle[at:] + del middle[-count:] + + new = low_part + middle + high_part + assert len(new) == len(seq) + return new + + diff --git a/tests/test_diff.py b/tests/test_diff.py index b8cc836..5ce16e8 100644 --- a/tests/test_diff.py +++ b/tests/test_diff.py @@ -1,6 +1,10 @@ import pyte from pyte import modes as mo +import sys, os +sys.path.append(os.path.join(os.path.dirname(__file__), "helpers")) +from asserts import consistency_asserts + def test_mark_whole_screen(): # .. this is straightforward -- make sure we have a dirty attribute @@ -45,6 +49,7 @@ def test_mark_single_line(): getattr(screen, method)() assert len(screen.dirty) == 1 assert screen.cursor.y in screen.dirty + consistency_asserts(screen) def test_modes(): @@ -72,6 +77,7 @@ def test_index(): screen.cursor_to_line(24) screen.index() assert screen.dirty == set(range(screen.lines)) + consistency_asserts(screen) def test_reverse_index(): @@ -81,12 +87,14 @@ def test_reverse_index(): # a) not at the top margin -- whole screen is dirty. screen.reverse_index() assert screen.dirty == set(range(screen.lines)) + consistency_asserts(screen) # b) nothing is marked dirty. screen.dirty.clear() screen.cursor_to_line(screen.lines // 2) screen.reverse_index() assert not screen.dirty + consistency_asserts(screen) def test_insert_delete_lines(): @@ -97,6 +105,7 @@ def test_insert_delete_lines(): screen.dirty.clear() getattr(screen, method)() assert screen.dirty == set(range(screen.cursor.y, screen.lines)) + consistency_asserts(screen) def test_erase_in_display(): @@ -107,20 +116,24 @@ def test_erase_in_display(): screen.dirty.clear() screen.erase_in_display() assert screen.dirty == set(range(screen.cursor.y, screen.lines)) + consistency_asserts(screen) # b) from the beginning of the screen to cursor. screen.dirty.clear() screen.erase_in_display(1) assert screen.dirty == set(range(0, screen.cursor.y + 1)) + consistency_asserts(screen) # c) whole screen. screen.dirty.clear() screen.erase_in_display(2) assert screen.dirty == set(range(0, screen.lines)) + consistency_asserts(screen) screen.dirty.clear() screen.erase_in_display(3) assert screen.dirty == set(range(0, screen.lines)) + consistency_asserts(screen) def test_draw_wrap(): @@ -132,6 +145,7 @@ def test_draw_wrap(): screen.draw("g") assert screen.cursor.y == 0 screen.dirty.clear() + consistency_asserts(screen) # now write one more character which should cause wrapping screen.draw("h") @@ -139,6 +153,7 @@ def test_draw_wrap(): # regression test issue #36 where the wrong line was marked as # dirty assert screen.dirty == set([0, 1]) + consistency_asserts(screen) def test_draw_multiple_chars_wrap(): @@ -147,3 +162,4 @@ def test_draw_multiple_chars_wrap(): screen.draw("1234567890") assert screen.cursor.y == 1 assert screen.dirty == set([0, 1]) + consistency_asserts(screen) diff --git a/tests/test_history.py b/tests/test_history.py index 52084ec..93c6058 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -1,8 +1,10 @@ -import os +import os, sys import pyte from pyte import control as ctrl, modes as mo +sys.path.append(os.path.join(os.path.dirname(__file__), "helpers")) +from asserts import consistency_asserts def chars(history_lines, columns): return ["".join(history_lines[y][x].data for x in range(columns)) @@ -46,9 +48,9 @@ def test_reverse_index(): # Filling the screen with line numbers, so it's easier to # track history contents. - for idx in range(len(screen.buffer)): + for idx in range(screen.lines): screen.draw(str(idx)) - if idx != len(screen.buffer) - 1: + if idx != screen.lines - 1: screen.linefeed() assert not screen.history.top @@ -57,19 +59,19 @@ def test_reverse_index(): screen.cursor_position() # a) first index, expecting top history to be updated. - line = screen.buffer[-1] + line = screen.buffer[screen.lines-1] screen.reverse_index() assert screen.history.bottom assert screen.history.bottom[0] == line # b) second index. - line = screen.buffer[-1] + line = screen.buffer[screen.lines-1] screen.reverse_index() assert len(screen.history.bottom) == 2 assert screen.history.bottom[1] == line # c) rotation. - for _ in range(len(screen.buffer) ** screen.lines): + for _ in range(screen.history.size * 2): screen.reverse_index() assert len(screen.history.bottom) == 50 @@ -96,6 +98,7 @@ def test_prev_page(): "39 ", " " ] + consistency_asserts(screen) assert chars(screen.history.top, screen.columns)[-4:] == [ "33 ", @@ -114,6 +117,7 @@ def test_prev_page(): "37 ", "38 " ] + consistency_asserts(screen) assert chars(screen.history.top, screen.columns)[-4:] == [ "31 ", @@ -138,6 +142,7 @@ def test_prev_page(): "35 ", "36 ", ] + consistency_asserts(screen) assert len(screen.history.bottom) == 4 assert chars(screen.history.bottom, screen.columns) == [ @@ -165,6 +170,7 @@ def test_prev_page(): "49 ", " " ] + consistency_asserts(screen) screen.prev_page() assert screen.history.position == 47 @@ -175,6 +181,7 @@ def test_prev_page(): "46 ", "47 " ] + consistency_asserts(screen) assert len(screen.history.bottom) == 3 assert chars(screen.history.bottom, screen.columns) == [ @@ -200,6 +207,7 @@ def test_prev_page(): "39 ", " " ] + consistency_asserts(screen) screen.prev_page() assert screen.history.position == 37 @@ -209,6 +217,7 @@ def test_prev_page(): "36 ", "37 " ] + consistency_asserts(screen) assert len(screen.history.bottom) == 3 assert chars(screen.history.bottom, screen.columns) == [ @@ -235,6 +244,7 @@ def test_prev_page(): "49 ", " " ] + consistency_asserts(screen) screen.cursor_to_line(screen.lines // 2) @@ -250,6 +260,7 @@ def test_prev_page(): "4 ", "5 " ] + consistency_asserts(screen) while screen.history.position < screen.history.size: screen.next_page() @@ -262,6 +273,7 @@ def test_prev_page(): "49 ", " " ] + consistency_asserts(screen) # e) same with cursor near the middle of the screen. screen = pyte.HistoryScreen(5, 5, history=50) @@ -282,6 +294,7 @@ def test_prev_page(): "49 ", " " ] + consistency_asserts(screen) screen.cursor_to_line(screen.lines // 2 - 2) @@ -297,6 +310,7 @@ def test_prev_page(): "4 ", "5 " ] + consistency_asserts(screen) while screen.history.position < screen.history.size: screen.next_page() @@ -310,6 +324,131 @@ def test_prev_page(): "49 ", " " ] + consistency_asserts(screen) + + +def test_prev_page_large_sparse(): + # like test_prev_page, this test does the same checks + # but uses a larger screen and it does not write on every + # line. + # Because screen.buffer is optimized to not have entries + # for empty lines, this setup may uncover bugs that + # test_prev_page cannot + screen = pyte.HistoryScreen(4, 8, history=16) + screen.set_mode(mo.LNM) + + assert screen.history.position == 16 + + # Filling the screen with line numbers but only + # if they match the following sequence. + # This is to leave some empty lines in between + # to test the sparsity of the buffer. + FB = [2, 5, 8, 13, 18] + for idx in range(19): + if idx in FB: + screen.draw(str(idx)) + screen.linefeed() + + assert screen.history.top + assert not screen.history.bottom + assert screen.history.position == 16 + assert screen.display == [ + " ", + "13 ", + " ", + " ", + " ", + " ", + "18 ", + " ", + ] + consistency_asserts(screen) + + assert chars(screen.history.top, screen.columns) == [ + " ", + " ", + "2 ", + " ", + " ", + "5 ", + " ", + " ", + "8 ", + " ", + " ", + " ", + ] + + # a) first page up. + screen.prev_page() + assert screen.history.position == 12 + assert len(screen.buffer) == screen.lines + assert screen.display == [ + "8 ", + " ", + " ", + " ", + " ", + "13 ", + " ", + " ", + ] + consistency_asserts(screen) + + assert chars(screen.history.top, screen.columns) == [ + " ", + " ", + "2 ", + " ", + " ", + "5 ", + " ", + " ", + ] + + assert len(screen.history.bottom) == 4 + assert chars(screen.history.bottom, screen.columns) == [ + " ", + " ", + "18 ", + " ", + ] + + # b) second page up. + screen.prev_page() + assert screen.history.position == 8 + assert len(screen.buffer) == screen.lines + assert screen.display == [ + " ", + "5 ", + " ", + " ", + "8 ", + " ", + " ", + " ", + ] + consistency_asserts(screen) + + assert len(screen.history.bottom) == 8 + assert chars(screen.history.bottom, screen.columns) == [ + " ", + "13 ", + " ", + " ", + " ", + " ", + "18 ", + " ", + ] + + # c) third page up? + # TODO this seems to not work as the remaining lines in the history + # are not moved into the buffer. This is because the condition + # + # if self.history.position > self.lines and self.history.top: + # .... + # This bug/issue is present on 0.8.1 def test_next_page(): @@ -332,6 +471,7 @@ def test_next_page(): "24 ", " " ] + consistency_asserts(screen) # a) page up -- page down. screen.prev_page() @@ -346,6 +486,7 @@ def test_next_page(): "24 ", " " ] + consistency_asserts(screen) # b) double page up -- page down. screen.prev_page() @@ -366,6 +507,7 @@ def test_next_page(): "21 ", "22 " ] + consistency_asserts(screen) # c) double page up -- double page down screen.prev_page() @@ -381,6 +523,111 @@ def test_next_page(): "21 ", "22 " ] + consistency_asserts(screen) + +def test_next_page_large_sparse(): + screen = pyte.HistoryScreen(5, 8, history=16) + screen.set_mode(mo.LNM) + + assert screen.history.position == 16 + + # Filling the screen with line numbers but only + # if they match the following sequence. + # This is to leave some empty lines in between + # to test the sparsity of the buffer. + FB = [2, 5, 8, 13, 18] + for idx in range(19): + if idx in FB: + screen.draw(str(idx)) + screen.linefeed() + + assert screen.history.top + assert not screen.history.bottom + assert screen.history.position == 16 + assert screen.display == [ + " ", + "13 ", + " ", + " ", + " ", + " ", + "18 ", + " ", + ] + consistency_asserts(screen) + + # a) page up -- page down. + screen.prev_page() + screen.next_page() + assert screen.history.top + assert not screen.history.bottom + assert screen.history.position == 16 + assert screen.display == [ + " ", + "13 ", + " ", + " ", + " ", + " ", + "18 ", + " ", + ] + consistency_asserts(screen) + + # b) double page up -- page down. + screen.prev_page() + screen.prev_page() + screen.next_page() + assert screen.history.position == 12 + assert screen.history.top + assert chars(screen.history.bottom, screen.columns) == [ + " ", + " ", + "18 ", + " ", + ] + assert chars(screen.history.top, screen.columns) == [ + " ", + " ", + "2 ", + " ", + " ", + "5 ", + " ", + " ", + ] + + assert screen.display == [ + "8 ", + " ", + " ", + " ", + " ", + "13 ", + " ", + " ", + ] + consistency_asserts(screen) + + # c) page down -- double page up -- double page down + screen.next_page() + screen.prev_page() + screen.prev_page() + screen.next_page() + screen.next_page() + assert screen.history.position == 16 + assert len(screen.buffer) == screen.lines + assert screen.display == [ + " ", + "13 ", + " ", + " ", + " ", + " ", + "18 ", + " ", + ] + consistency_asserts(screen) def test_ensure_width(monkeypatch): @@ -402,13 +649,14 @@ def test_ensure_width(monkeypatch): "0024 ", " " ] + consistency_asserts(screen) # Shrinking the screen should truncate the displayed lines following lines. screen.resize(5, 3) stream.feed(ctrl.ESC + "P") # Inequality because we have an all-empty last line. - assert all(len(l) <= 3 for l in screen.history.bottom) + assert all(len(l._line) <= 3 for l in screen.history.bottom) assert screen.display == [ "001", # 18 "001", # 19 @@ -416,6 +664,7 @@ def test_ensure_width(monkeypatch): "002", # 21 "002" # 22 ] + consistency_asserts(screen) def test_not_enough_lines(): @@ -436,6 +685,7 @@ def test_not_enough_lines(): "4 ", " " ] + consistency_asserts(screen) screen.prev_page() assert not screen.history.top @@ -448,6 +698,7 @@ def test_not_enough_lines(): "3 ", "4 ", ] + consistency_asserts(screen) screen.next_page() assert screen.history.top @@ -459,6 +710,7 @@ def test_not_enough_lines(): "4 ", " " ] + consistency_asserts(screen) def test_draw(monkeypatch): @@ -479,6 +731,7 @@ def test_draw(monkeypatch): "24 ", " " ] + consistency_asserts(screen) # a) doing a pageup and then a draw -- expecting the screen # to scroll to the bottom before drawing anything. @@ -494,6 +747,7 @@ def test_draw(monkeypatch): "24 ", "x " ] + consistency_asserts(screen) def test_cursor_is_hidden(monkeypatch): diff --git a/tests/test_input_output.py b/tests/test_input_output.py index 4d25c03..9838826 100644 --- a/tests/test_input_output.py +++ b/tests/test_input_output.py @@ -1,5 +1,5 @@ import json -import os.path +import os.path, sys import pytest @@ -8,6 +8,8 @@ captured_dir = os.path.join(os.path.dirname(__file__), "captured") +sys.path.append(os.path.join(os.path.dirname(__file__), "helpers")) +from asserts import consistency_asserts @pytest.mark.parametrize("name", [ "cat-gpl3", "find-etc", "htop", "ls", "mc", "top", "vi" @@ -23,3 +25,26 @@ def test_input_output(name): stream = pyte.ByteStream(screen) stream.feed(input) assert screen.display == output + consistency_asserts(screen) + +@pytest.mark.parametrize("name", [ + "cat-gpl3", "find-etc", "htop", "ls", "mc", "top", "vi" +]) +def test_input_output_history(name): + with open(os.path.join(captured_dir, name + ".input"), "rb") as handle: + input = handle.read() + + with open(os.path.join(captured_dir, name + ".output")) as handle: + output = json.load(handle) + + screen = pyte.HistoryScreen(80, 24, history=72) + stream = pyte.ByteStream(screen) + stream.feed(input) + screen.prev_page() + screen.prev_page() + screen.prev_page() + screen.next_page() + screen.next_page() + screen.next_page() + assert screen.display == output + consistency_asserts(screen) diff --git a/tests/test_screen.py b/tests/test_screen.py index b6ba90d..ed49883 100644 --- a/tests/test_screen.py +++ b/tests/test_screen.py @@ -1,25 +1,45 @@ -import copy +import copy, sys, os, itertools import pytest import pyte from pyte import modes as mo, control as ctrl, graphics as g -from pyte.screens import Char +from pyte.screens import Char as _orig_Char, CharStyle + +sys.path.append(os.path.join(os.path.dirname(__file__), "helpers")) +from asserts import consistency_asserts, splice + +# Implement the old API of Char so we don't have to change +# all the tests +class Char(_orig_Char): + def __init__(self, data=" ", fg="default", bg="default", bold=False, italics=False, underscore=False, + strikethrough=False, reverse=False, blink=False, width=1): + self.data = data + self.width = width + self.style = CharStyle(fg, bg, bold, italics, underscore, strikethrough, reverse, blink) # Test helpers. -def update(screen, lines, colored=[]): +def update(screen, lines, colored=[], write_spaces=True): """Updates a given screen object with given lines, colors each line from ``colored`` in "red" and returns the modified screen. """ + base_style = Char().style + red_style = base_style._replace(fg="red") for y, line in enumerate(lines): for x, char in enumerate(line): if y in colored: - attrs = {"fg": "red"} + style = red_style else: - attrs = {} - screen.buffer[y][x] = Char(data=char, **attrs) + style = base_style + # Note: this hack is only for testing purposes. + # Modifying the screen's buffer is not allowed. + if char == ' ' and not write_spaces: + # skip, leave the default char in the screen + pass + else: + screen._buffer.line_at(y).write_data(x, char, 1, style) return screen @@ -207,10 +227,13 @@ def test_attributes_reset(): def test_resize(): screen = pyte.Screen(2, 2) + assert screen.margins == (0, 1) screen.set_mode(mo.DECOM) screen.set_margins(0, 1) + assert screen.margins == (0, 1) assert screen.columns == screen.lines == 2 assert tolist(screen) == [[screen.default_char, screen.default_char]] * 2 + consistency_asserts(screen) screen.resize(3, 3) assert screen.columns == screen.lines == 3 @@ -218,11 +241,13 @@ def test_resize(): [screen.default_char, screen.default_char, screen.default_char] ] * 3 assert mo.DECOM in screen.mode - assert screen.margins is None + assert screen.margins == (0, 2) + consistency_asserts(screen) screen.resize(2, 2) assert screen.columns == screen.lines == 2 assert tolist(screen) == [[screen.default_char, screen.default_char]] * 2 + consistency_asserts(screen) # Quirks: # a) if the current display is narrower than the requested size, @@ -230,12 +255,14 @@ def test_resize(): screen = update(pyte.Screen(2, 2), ["bo", "sh"], [None, None]) screen.resize(2, 3) assert screen.display == ["bo ", "sh "] + consistency_asserts(screen) # b) if the current display is wider than the requested size, # columns should be removed from the right... screen = update(pyte.Screen(2, 2), ["bo", "sh"], [None, None]) screen.resize(2, 1) assert screen.display == ["b", "s"] + consistency_asserts(screen) # c) if the current display is shorter than the requested # size, new rows should be added on the bottom. @@ -243,12 +270,14 @@ def test_resize(): screen.resize(3, 2) assert screen.display == ["bo", "sh", " "] + consistency_asserts(screen) # d) if the current display is taller than the requested # size, rows should be removed from the top. screen = update(pyte.Screen(2, 2), ["bo", "sh"], [None, None]) screen.resize(1, 2) assert screen.display == ["sh"] + consistency_asserts(screen) def test_resize_same(): @@ -256,6 +285,7 @@ def test_resize_same(): screen.dirty.clear() screen.resize(2, 2) assert not screen.dirty + consistency_asserts(screen) def test_set_mode(): @@ -312,10 +342,12 @@ def test_draw(): assert screen.display == ["abc", " ", " "] assert (screen.cursor.y, screen.cursor.x) == (0, 3) + consistency_asserts(screen) # ... one` more character -- now we got a linefeed! screen.draw("a") assert (screen.cursor.y, screen.cursor.x) == (1, 1) + consistency_asserts(screen) # ``DECAWM`` is off. screen = pyte.Screen(3, 3) @@ -326,11 +358,13 @@ def test_draw(): assert screen.display == ["abc", " ", " "] assert (screen.cursor.y, screen.cursor.x) == (0, 3) + consistency_asserts(screen) # No linefeed is issued on the end of the line ... screen.draw("a") assert screen.display == ["aba", " ", " "] assert (screen.cursor.y, screen.cursor.x) == (0, 3) + consistency_asserts(screen) # ``IRM`` mode is on, expecting new characters to move the old ones # instead of replacing them. @@ -338,10 +372,12 @@ def test_draw(): screen.cursor_position() screen.draw("x") assert screen.display == ["xab", " ", " "] + consistency_asserts(screen) screen.cursor_position() screen.draw("y") assert screen.display == ["yxa", " ", " "] + consistency_asserts(screen) def test_draw_russian(): @@ -350,6 +386,7 @@ def test_draw_russian(): stream = pyte.Stream(screen) stream.feed("Нерусский текст") assert screen.display == ["Нерусский текст "] + consistency_asserts(screen) def test_draw_multiple_chars(): @@ -357,6 +394,7 @@ def test_draw_multiple_chars(): screen.draw("foobar") assert screen.cursor.x == 6 assert screen.display == ["foobar "] + consistency_asserts(screen) def test_draw_utf8(): @@ -365,6 +403,7 @@ def test_draw_utf8(): stream = pyte.ByteStream(screen) stream.feed(b"\xE2\x80\x9D") assert screen.display == ["”"] + consistency_asserts(screen) def test_draw_width2(): @@ -372,6 +411,7 @@ def test_draw_width2(): screen = pyte.Screen(10, 1) screen.draw("コンニチハ") assert screen.cursor.x == screen.columns + consistency_asserts(screen) def test_draw_width2_line_end(): @@ -379,6 +419,7 @@ def test_draw_width2_line_end(): screen = pyte.Screen(10, 1) screen.draw(" コンニチハ") assert screen.cursor.x == screen.columns + consistency_asserts(screen) @pytest.mark.xfail @@ -387,12 +428,14 @@ def test_draw_width2_irm(): screen.draw("コ") assert screen.display == ["コ"] assert tolist(screen) == [[Char("コ"), Char(" ")]] + consistency_asserts(screen) # Overwrite the stub part of a width 2 character. screen.set_mode(mo.IRM) screen.cursor_to_column(screen.columns) screen.draw("x") assert screen.display == [" x"] + consistency_asserts(screen) def test_draw_width0_combining(): @@ -401,17 +444,20 @@ def test_draw_width0_combining(): # a) no prev. character screen.draw("\N{COMBINING DIAERESIS}") assert screen.display == [" ", " "] + consistency_asserts(screen) screen.draw("bad") # b) prev. character is on the same line screen.draw("\N{COMBINING DIAERESIS}") assert screen.display == ["bad̈ ", " "] + consistency_asserts(screen) # c) prev. character is on the prev. line screen.draw("!") screen.draw("\N{COMBINING DIAERESIS}") assert screen.display == ["bad̈!̈", " "] + consistency_asserts(screen) def test_draw_width0_irm(): @@ -422,6 +468,7 @@ def test_draw_width0_irm(): screen.draw("\N{ZERO WIDTH SPACE}") screen.draw("\u0007") # DELETE. assert screen.display == [" " * screen.columns] + consistency_asserts(screen) def test_draw_width0_decawm_off(): @@ -429,11 +476,13 @@ def test_draw_width0_decawm_off(): screen.reset_mode(mo.DECAWM) screen.draw(" コンニチハ") assert screen.cursor.x == screen.columns + consistency_asserts(screen) # The following should not advance the cursor. screen.draw("\N{ZERO WIDTH SPACE}") screen.draw("\u0007") # DELETE. assert screen.cursor.x == screen.columns + consistency_asserts(screen) def test_draw_cp437(): @@ -446,6 +495,7 @@ def test_draw_cp437(): stream.feed("α ± ε".encode("cp437")) assert screen.display == ["α ± ε"] + consistency_asserts(screen) def test_draw_with_carriage_return(): @@ -465,12 +515,14 @@ def test_draw_with_carriage_return(): "pcrm sem ;ps aux|grep -P 'httpd|fcgi'|grep -v grep", "}'|xargs kill -9;/etc/init.d/httpd startssl " ] + consistency_asserts(screen) def test_display_wcwidth(): screen = pyte.Screen(10, 1) screen.draw("コンニチハ") assert screen.display == ["コンニチハ"] + consistency_asserts(screen) def test_carriage_return(): @@ -479,32 +531,41 @@ def test_carriage_return(): screen.carriage_return() assert screen.cursor.x == 0 + consistency_asserts(screen) def test_index(): screen = update(pyte.Screen(2, 2), ["wo", "ot"], colored=[1]) + assert (screen.cursor.y, screen.cursor.x) == (0, 0) + assert screen.display == ["wo", "ot"] # a) indexing on a row that isn't the last should just move # the cursor down. screen.index() assert (screen.cursor.y, screen.cursor.x) == (1, 0) + assert screen.display == ["wo", "ot"] assert tolist(screen) == [ [Char("w"), Char("o")], [Char("o", fg="red"), Char("t", fg="red")] ] + consistency_asserts(screen) # b) indexing on the last row should push everything up and # create a new row at the bottom. screen.index() assert screen.cursor.y == 1 + assert screen.display == ["ot", " "] assert tolist(screen) == [ [Char("o", fg="red"), Char("t", fg="red")], [screen.default_char, screen.default_char] ] + consistency_asserts(screen) # c) same with margins screen = update(pyte.Screen(2, 5), ["bo", "sh", "th", "er", "oh"], colored=[1, 2]) + # note: margins are 0-based inclusive indexes for top and bottom + # however, set_margins are 1-based inclusive indexes screen.set_margins(2, 4) screen.cursor.y = 3 @@ -519,6 +580,7 @@ def test_index(): [screen.default_char, screen.default_char], [Char("o"), Char("h")], ] + consistency_asserts(screen) # ... and again ... screen.index() @@ -531,6 +593,7 @@ def test_index(): [screen.default_char, screen.default_char], [Char("o"), Char("h")], ] + consistency_asserts(screen) # ... and again ... screen.index() @@ -543,6 +606,7 @@ def test_index(): [screen.default_char, screen.default_char], [Char("o"), Char("h")], ] + consistency_asserts(screen) # look, nothing changes! screen.index() @@ -555,6 +619,110 @@ def test_index(): [screen.default_char, screen.default_char], [Char("o"), Char("h")], ] + consistency_asserts(screen) + + +def test_index_sparse(): + screen = update(pyte.Screen(5, 5), + ["wo ", + " ", + " o t ", + " ", + "x z", + ], + colored=[2], + write_spaces=False) + assert (screen.cursor.y, screen.cursor.x) == (0, 0) + assert screen.display == [ + "wo ", + " ", + " o t ", + " ", + "x z", + ] + + # a) indexing on a row that isn't the last should just move + # the cursor down. + screen.index() + assert (screen.cursor.y, screen.cursor.x) == (1, 0) + assert screen.display == [ + "wo ", + " ", + " o t ", + " ", + "x z", + ] + assert tolist(screen) == [ + [Char("w"), Char("o"),] + [screen.default_char] * 3, + [screen.default_char] * 5, + [screen.default_char, Char("o", fg="red"), screen.default_char, Char("t", fg="red"), screen.default_char], + [screen.default_char] * 5, + [Char("x")] + [screen.default_char] * 3 + [Char("z")], + ] + consistency_asserts(screen) + + # b) indexing on the last row should push everything up and + # create a new row at the bottom. + screen.index() + screen.index() + screen.index() + screen.index() + assert screen.cursor.y == 4 + assert screen.display == [ + " ", + " o t ", + " ", + "x z", + " ", + ] + assert tolist(screen) == [ + [screen.default_char] * 5, + [screen.default_char, Char("o", fg="red"), screen.default_char, Char("t", fg="red"), screen.default_char], + [screen.default_char] * 5, + [Char("x")] + [screen.default_char] * 3 + [Char("z")], + [screen.default_char] * 5, + ] + consistency_asserts(screen) + + # again + screen.index() + assert screen.cursor.y == 4 + assert screen.display == [ + " o t ", + " ", + "x z", + " ", + " ", + ] + assert tolist(screen) == [ + [screen.default_char, Char("o", fg="red"), screen.default_char, Char("t", fg="red"), screen.default_char], + [screen.default_char] * 5, + [Char("x")] + [screen.default_char] * 3 + [Char("z")], + [screen.default_char] * 5, + [screen.default_char] * 5, + ] + consistency_asserts(screen) + + # leave the screen cleared + screen.index() + screen.index() + screen.index() + assert (screen.cursor.y, screen.cursor.x) == (4, 0) + assert screen.display == [ + " ", + " ", + " ", + " ", + " ", + ] + assert tolist(screen) == [ + [screen.default_char] * 5, + [screen.default_char] * 5, + [screen.default_char] * 5, + [screen.default_char] * 5, + [screen.default_char] * 5, + ] + consistency_asserts(screen) def test_reverse_index(): @@ -568,6 +736,7 @@ def test_reverse_index(): [screen.default_char, screen.default_char], [Char("w", fg="red"), Char("o", fg="red")] ] + consistency_asserts(screen) # b) once again ... screen.reverse_index() @@ -576,6 +745,7 @@ def test_reverse_index(): [screen.default_char, screen.default_char], [screen.default_char, screen.default_char], ] + consistency_asserts(screen) # c) same with margins screen = update(pyte.Screen(2, 5), ["bo", "sh", "th", "er", "oh"], @@ -594,6 +764,7 @@ def test_reverse_index(): [Char("t", fg="red"), Char("h", fg="red")], [Char("o"), Char("h")], ] + consistency_asserts(screen) # ... and again ... screen.reverse_index() @@ -606,6 +777,7 @@ def test_reverse_index(): [Char("s"), Char("h")], [Char("o"), Char("h")], ] + consistency_asserts(screen) # ... and again ... screen.reverse_index() @@ -618,6 +790,7 @@ def test_reverse_index(): [screen.default_char, screen.default_char], [Char("o"), Char("h")], ] + consistency_asserts(screen) # look, nothing changes! screen.reverse_index() @@ -630,7 +803,106 @@ def test_reverse_index(): [screen.default_char, screen.default_char], [Char("o"), Char("h")], ] + consistency_asserts(screen) + +def test_reverse_index_sparse(): + screen = update(pyte.Screen(5, 5), + ["wo ", + " ", + " o t ", + " ", + "x z", + ], + colored=[2], + write_spaces=False) + assert (screen.cursor.y, screen.cursor.x) == (0, 0) + assert screen.display == [ + "wo ", + " ", + " o t ", + " ", + "x z", + ] + consistency_asserts(screen) + + # a) reverse indexing on the first row should push rows down + # and create a new row at the top. + screen.reverse_index() + assert (screen.cursor.y, screen.cursor.x) == (0, 0) + assert screen.display == [ + " ", + "wo ", + " ", + " o t ", + " ", + ] + assert tolist(screen) == [ + [screen.default_char] * 5, + [Char("w"), Char("o"),] + [screen.default_char] * 3, + [screen.default_char] * 5, + [screen.default_char, Char("o", fg="red"), screen.default_char, Char("t", fg="red"), screen.default_char], + [screen.default_char] * 5, + ] + consistency_asserts(screen) + + # again + screen.reverse_index() + assert (screen.cursor.y, screen.cursor.x) == (0, 0) + assert screen.display == [ + " ", + " ", + "wo ", + " ", + " o t ", + ] + assert tolist(screen) == [ + [screen.default_char] * 5, + [screen.default_char] * 5, + [Char("w"), Char("o"),] + [screen.default_char] * 3, + [screen.default_char] * 5, + [screen.default_char, Char("o", fg="red"), screen.default_char, Char("t", fg="red"), screen.default_char], + ] + consistency_asserts(screen) + + # again + screen.reverse_index() + assert (screen.cursor.y, screen.cursor.x) == (0, 0) + assert screen.display == [ + " ", + " ", + " ", + "wo ", + " ", + ] + assert tolist(screen) == [ + [screen.default_char] * 5, + [screen.default_char] * 5, + [screen.default_char] * 5, + [Char("w"), Char("o"),] + [screen.default_char] * 3, + [screen.default_char] * 5, + ] + consistency_asserts(screen) + + # leave the screen cleared + screen.reverse_index() + screen.reverse_index() + assert (screen.cursor.y, screen.cursor.x) == (0, 0) + assert screen.display == [ + " ", + " ", + " ", + " ", + " ", + ] + assert tolist(screen) == [ + [screen.default_char] * 5, + [screen.default_char] * 5, + [screen.default_char] * 5, + [screen.default_char] * 5, + [screen.default_char] * 5, + ] + consistency_asserts(screen) def test_linefeed(): screen = update(pyte.Screen(2, 2), ["bo", "sh"], [None, None]) @@ -641,12 +913,14 @@ def test_linefeed(): screen.cursor.x, screen.cursor.y = 1, 0 screen.linefeed() assert (screen.cursor.y, screen.cursor.x) == (1, 0) + consistency_asserts(screen) # b) LNM off. screen.reset_mode(mo.LNM) screen.cursor.x, screen.cursor.y = 1, 0 screen.linefeed() assert (screen.cursor.y, screen.cursor.x) == (1, 1) + consistency_asserts(screen) def test_linefeed_margins(): @@ -655,8 +929,10 @@ def test_linefeed_margins(): screen.set_margins(3, 27) screen.cursor_position() assert (screen.cursor.y, screen.cursor.x) == (0, 0) + consistency_asserts(screen) screen.linefeed() assert (screen.cursor.y, screen.cursor.x) == (1, 0) + consistency_asserts(screen) def test_tabstops(): @@ -683,6 +959,7 @@ def test_tabstops(): assert screen.cursor.x == 9 screen.tab() assert screen.cursor.x == 9 + consistency_asserts(screen) def test_clear_tabstops(): @@ -697,6 +974,7 @@ def test_clear_tabstops(): screen.clear_tab_stop() assert screen.tabstops == set([1]) + consistency_asserts(screen) screen.set_tab_stop() screen.clear_tab_stop(0) @@ -710,6 +988,7 @@ def test_clear_tabstops(): screen.clear_tab_stop(3) assert not screen.tabstops + consistency_asserts(screen) def test_backspace(): @@ -720,6 +999,7 @@ def test_backspace(): screen.cursor.x = 1 screen.backspace() assert screen.cursor.x == 0 + consistency_asserts(screen) def test_save_cursor(): @@ -761,6 +1041,7 @@ def test_save_cursor(): assert screen.cursor.attrs != screen.default_char assert screen.cursor.attrs == Char(" ", underscore=True) + consistency_asserts(screen) def test_restore_cursor_with_none_saved(): @@ -784,6 +1065,7 @@ def test_restore_cursor_out_of_bounds(): screen.restore_cursor() assert (screen.cursor.y, screen.cursor.x) == (2, 2) + consistency_asserts(screen) # b) origin mode is on. screen.resize(10, 10) @@ -796,6 +1078,7 @@ def test_restore_cursor_out_of_bounds(): screen.restore_cursor() assert (screen.cursor.y, screen.cursor.x) == (2, 4) + consistency_asserts(screen) def test_insert_lines(): @@ -810,6 +1093,38 @@ def test_insert_lines(): [Char("s"), Char("a"), Char("m")], [Char("i", fg="red"), Char("s", fg="red"), Char(" ", fg="red")], ] + consistency_asserts(screen) + + + screen.insert_lines(1) + assert (screen.cursor.y, screen.cursor.x) == (0, 0) + assert screen.display == [" ", " ", "sam"] + assert tolist(screen) == [ + [screen.default_char] * 3, + [screen.default_char] * 3, + [Char("s"), Char("a"), Char("m")] + ] + consistency_asserts(screen) + + screen.insert_lines(1) + assert (screen.cursor.y, screen.cursor.x) == (0, 0) + assert screen.display == [" ", " ", " "] + assert tolist(screen) == [ + [screen.default_char] * 3, + [screen.default_char] * 3, + [screen.default_char] * 3, + ] + consistency_asserts(screen) + + screen.insert_lines(1) + assert (screen.cursor.y, screen.cursor.x) == (0, 0) + assert screen.display == [" ", " ", " "] + assert tolist(screen) == [ + [screen.default_char] * 3, + [screen.default_char] * 3, + [screen.default_char] * 3, + ] + consistency_asserts(screen) screen = update(pyte.Screen(3, 3), ["sam", "is ", "foo"], colored=[1]) screen.insert_lines(2) @@ -821,6 +1136,55 @@ def test_insert_lines(): [screen.default_char] * 3, [Char("s"), Char("a"), Char("m")] ] + consistency_asserts(screen) + + screen = update(pyte.Screen(3, 5), [ + "sam", + "", # an empty string will be interpreted as a full empty line + "foo", + "bar", + "baz" + ], + colored=[2, 3]) + + screen.insert_lines(2) + + assert (screen.cursor.y, screen.cursor.x) == (0, 0) + assert screen.display == [" ", " ", "sam", " ", "foo"] + assert tolist(screen) == [ + [screen.default_char] * 3, + [screen.default_char] * 3, + [Char("s"), Char("a"), Char("m")], + [screen.default_char] * 3, + [Char("f", fg="red"), Char("o", fg="red"), Char("o", fg="red")], + ] + consistency_asserts(screen) + + screen.insert_lines(1) + + assert (screen.cursor.y, screen.cursor.x) == (0, 0) + assert screen.display == [" ", " ", " ", "sam", " "] + assert tolist(screen) == [ + [screen.default_char] * 3, + [screen.default_char] * 3, + [screen.default_char] * 3, + [Char("s"), Char("a"), Char("m")], + [screen.default_char] * 3, + ] + consistency_asserts(screen) + + screen.insert_lines(1) + + assert (screen.cursor.y, screen.cursor.x) == (0, 0) + assert screen.display == [" ", " ", " ", " ", "sam"] + assert tolist(screen) == [ + [screen.default_char] * 3, + [screen.default_char] * 3, + [screen.default_char] * 3, + [screen.default_char] * 3, + [Char("s"), Char("a"), Char("m")], + ] + consistency_asserts(screen) # b) with margins screen = update(pyte.Screen(3, 5), ["sam", "is ", "foo", "bar", "baz"], @@ -838,6 +1202,7 @@ def test_insert_lines(): [Char("f", fg="red"), Char("o", fg="red"), Char("o", fg="red")], [Char("b"), Char("a"), Char("z")], ] + consistency_asserts(screen) screen = update(pyte.Screen(3, 5), ["sam", "is ", "foo", "bar", "baz"], colored=[2, 3]) @@ -854,6 +1219,7 @@ def test_insert_lines(): [Char("b", fg="red"), Char("a", fg="red"), Char("r", fg="red")], [Char("b"), Char("a"), Char("z")], ] + consistency_asserts(screen) screen.insert_lines(2) assert (screen.cursor.y, screen.cursor.x) == (1, 0) @@ -865,6 +1231,7 @@ def test_insert_lines(): [Char("b", fg="red"), Char("a", fg="red"), Char("r", fg="red")], [Char("b"), Char("a"), Char("z")], ] + consistency_asserts(screen) # c) with margins -- trying to insert more than we have available screen = update(pyte.Screen(3, 5), ["sam", "is ", "foo", "bar", "baz"], @@ -882,6 +1249,7 @@ def test_insert_lines(): [screen.default_char] * 3, [Char("b"), Char("a"), Char("z")], ] + consistency_asserts(screen) # d) with margins -- trying to insert outside scroll boundaries; # expecting nothing to change @@ -899,6 +1267,7 @@ def test_insert_lines(): [Char("b", fg="red"), Char("a", fg="red"), Char("r", fg="red")], [Char("b"), Char("a"), Char("z")], ] + consistency_asserts(screen) def test_delete_lines(): @@ -913,6 +1282,7 @@ def test_delete_lines(): [Char("f"), Char("o"), Char("o")], [screen.default_char] * 3, ] + consistency_asserts(screen) screen.delete_lines(0) @@ -923,6 +1293,29 @@ def test_delete_lines(): [screen.default_char] * 3, [screen.default_char] * 3, ] + consistency_asserts(screen) + + screen.delete_lines(0) + + assert (screen.cursor.y, screen.cursor.x) == (0, 0) + assert screen.display == [" ", " ", " "] + assert tolist(screen) == [ + [screen.default_char] * 3, + [screen.default_char] * 3, + [screen.default_char] * 3, + ] + consistency_asserts(screen) + + screen.delete_lines(0) + + assert (screen.cursor.y, screen.cursor.x) == (0, 0) + assert screen.display == [" ", " ", " "] + assert tolist(screen) == [ + [screen.default_char] * 3, + [screen.default_char] * 3, + [screen.default_char] * 3, + ] + consistency_asserts(screen) # b) with margins screen = update(pyte.Screen(3, 5), ["sam", "is ", "foo", "bar", "baz"], @@ -940,6 +1333,7 @@ def test_delete_lines(): [screen.default_char] * 3, [Char("b"), Char("a"), Char("z")], ] + consistency_asserts(screen) screen = update(pyte.Screen(3, 5), ["sam", "is ", "foo", "bar", "baz"], colored=[2, 3]) @@ -956,6 +1350,7 @@ def test_delete_lines(): [screen.default_char] * 3, [Char("b"), Char("a"), Char("z")], ] + consistency_asserts(screen) # c) with margins -- trying to delete more than we have available screen = update(pyte.Screen(3, 5), @@ -978,6 +1373,7 @@ def test_delete_lines(): [screen.default_char] * 3, [Char("b"), Char("a"), Char("z")], ] + consistency_asserts(screen) # d) with margins -- trying to delete outside scroll boundaries; # expecting nothing to change @@ -996,6 +1392,7 @@ def test_delete_lines(): [Char("b", fg="red"), Char("a", fg="red"), Char("r", fg="red")], [Char("b"), Char("a"), Char("z")], ] + consistency_asserts(screen) def test_insert_characters(): @@ -1006,24 +1403,58 @@ def test_insert_characters(): cursor = copy.copy(screen.cursor) screen.insert_characters(2) assert (screen.cursor.y, screen.cursor.x) == (cursor.y, cursor.x) + assert screen.display == [" s", "is ", "foo", "bar"] assert tolist(screen)[0] == [ screen.default_char, screen.default_char, Char("s", fg="red") ] + consistency_asserts(screen) # b) now inserting from the middle of the line screen.cursor.y, screen.cursor.x = 2, 1 screen.insert_characters(1) + assert screen.display == [" s", "is ", "f o", "bar"] assert tolist(screen)[2] == [Char("f"), screen.default_char, Char("o")] + consistency_asserts(screen) # c) inserting more than we have screen.cursor.y, screen.cursor.x = 3, 1 screen.insert_characters(10) + assert screen.display == [" s", "is ", "f o", "b "] assert tolist(screen)[3] == [ Char("b"), screen.default_char, screen.default_char ] + assert screen.display == [" s", "is ", "f o", "b "] + consistency_asserts(screen) + + # insert 1 at the begin of the previously edited line + screen.cursor.y, screen.cursor.x = 3, 0 + screen.insert_characters(1) + assert tolist(screen)[3] == [ + screen.default_char, Char("b"), screen.default_char, + ] + consistency_asserts(screen) + + # insert before the end of the line + screen.cursor.y, screen.cursor.x = 3, 2 + screen.insert_characters(1) + assert tolist(screen)[3] == [ + screen.default_char, Char("b"), screen.default_char, + ] + consistency_asserts(screen) + + # insert enough to push outside the screen the remaining char + screen.cursor.y, screen.cursor.x = 3, 0 + screen.insert_characters(2) + assert tolist(screen)[3] == [ + screen.default_char, screen.default_char, screen.default_char, + ] + + assert screen.display == [" s", "is ", "f o", " "] + consistency_asserts(screen) + # d) 0 is 1 screen = update(pyte.Screen(3, 3), ["sam", "is ", "foo"], colored=[0]) @@ -1033,6 +1464,7 @@ def test_insert_characters(): screen.default_char, Char("s", fg="red"), Char("a", fg="red") ] + consistency_asserts(screen) screen = update(pyte.Screen(3, 3), ["sam", "is ", "foo"], colored=[0]) screen.cursor_position() @@ -1041,8 +1473,49 @@ def test_insert_characters(): screen.default_char, Char("s", fg="red"), Char("a", fg="red") ] + consistency_asserts(screen) + # ! extreme cases. + screen = update(pyte.Screen(5, 1), ["12345"], colored=[0]) + screen.cursor.x = 1 + screen.insert_characters(3) + assert (screen.cursor.y, screen.cursor.x) == (0, 1) + assert screen.display == ["1 2"] + assert tolist(screen)[0] == [ + Char("1", fg="red"), + screen.default_char, + screen.default_char, + screen.default_char, + Char("2", fg="red"), + ] + consistency_asserts(screen) + + screen.insert_characters(1) + assert (screen.cursor.y, screen.cursor.x) == (0, 1) + assert screen.display == ["1 "] + assert tolist(screen)[0] == [ + Char("1", fg="red"), + screen.default_char, + screen.default_char, + screen.default_char, + screen.default_char, + ] + consistency_asserts(screen) + + screen.cursor.x = 0 + screen.insert_characters(5) + assert (screen.cursor.y, screen.cursor.x) == (0, 0) + assert screen.display == [" "] + assert tolist(screen)[0] == [ + screen.default_char, + screen.default_char, + screen.default_char, + screen.default_char, + screen.default_char, + ] + consistency_asserts(screen) + def test_delete_characters(): screen = update(pyte.Screen(3, 3), ["sam", "is ", "foo"], colored=[0]) screen.delete_characters(2) @@ -1052,16 +1525,33 @@ def test_delete_characters(): Char("m", fg="red"), screen.default_char, screen.default_char ] + consistency_asserts(screen) screen.cursor.y, screen.cursor.x = 2, 2 screen.delete_characters() assert (screen.cursor.y, screen.cursor.x) == (2, 2) assert screen.display == ["m ", "is ", "fo "] + consistency_asserts(screen) + + screen.cursor.y, screen.cursor.x = 1, 1 + screen.delete_characters(0) + assert (screen.cursor.y, screen.cursor.x) == (1, 1) + assert screen.display == ["m ", "i ", "fo "] + consistency_asserts(screen) + # try to erase spaces screen.cursor.y, screen.cursor.x = 1, 1 screen.delete_characters(0) assert (screen.cursor.y, screen.cursor.x) == (1, 1) assert screen.display == ["m ", "i ", "fo "] + consistency_asserts(screen) + + # try to erase a whole line + screen.cursor.y, screen.cursor.x = 1, 0 + screen.delete_characters(0) + assert (screen.cursor.y, screen.cursor.x) == (1, 0) + assert screen.display == ["m ", " ", "fo "] + consistency_asserts(screen) # ! extreme cases. screen = update(pyte.Screen(5, 1), ["12345"], colored=[0]) @@ -1076,6 +1566,7 @@ def test_delete_characters(): screen.default_char, screen.default_char ] + consistency_asserts(screen) screen = update(pyte.Screen(5, 1), ["12345"], colored=[0]) screen.cursor.x = 2 @@ -1089,6 +1580,7 @@ def test_delete_characters(): screen.default_char, screen.default_char ] + consistency_asserts(screen) screen = update(pyte.Screen(5, 1), ["12345"], colored=[0]) screen.delete_characters(4) @@ -1101,6 +1593,19 @@ def test_delete_characters(): screen.default_char, screen.default_char ] + consistency_asserts(screen) + + screen.delete_characters(2) + assert (screen.cursor.y, screen.cursor.x) == (0, 0) + assert screen.display == [" "] + assert tolist(screen)[0] == [ + screen.default_char, + screen.default_char, + screen.default_char, + screen.default_char, + screen.default_char + ] + consistency_asserts(screen) def test_erase_character(): @@ -1114,16 +1619,60 @@ def test_erase_character(): screen.default_char, Char("m", fg="red") ] + consistency_asserts(screen) screen.cursor.y, screen.cursor.x = 2, 2 screen.erase_characters() assert (screen.cursor.y, screen.cursor.x) == (2, 2) assert screen.display == [" m", "is ", "fo "] + consistency_asserts(screen) + + screen.cursor.y, screen.cursor.x = 1, 1 + screen.erase_characters(0) + assert (screen.cursor.y, screen.cursor.x) == (1, 1) + assert screen.display == [" m", "i ", "fo "] + consistency_asserts(screen) + # erase the same erased char as before screen.cursor.y, screen.cursor.x = 1, 1 screen.erase_characters(0) assert (screen.cursor.y, screen.cursor.x) == (1, 1) assert screen.display == [" m", "i ", "fo "] + consistency_asserts(screen) + + # erase the whole line + screen.cursor.y, screen.cursor.x = 1, 0 + screen.erase_characters(0) + assert (screen.cursor.y, screen.cursor.x) == (1, 0) + assert screen.display == [" m", " ", "fo "] + consistency_asserts(screen) + + # erase 2 chars of an already-empty line with a cursor having a different + # attribute + screen.select_graphic_rendition(31) # red foreground + screen.cursor.y, screen.cursor.x = 1, 0 + screen.erase_characters(2) + assert (screen.cursor.y, screen.cursor.x) == (1, 0) + assert screen.display == [" m", " ", "fo "] + assert tolist(screen)[1] == [ + Char(" ", fg='red'), + Char(" ", fg='red'), + screen.default_char + ] + consistency_asserts(screen) + + # erase 1 chars of a non-empty line with a cursor having a different + # attribute + screen.cursor.y, screen.cursor.x = 2, 1 + screen.erase_characters(1) + assert (screen.cursor.y, screen.cursor.x) == (2, 1) + assert screen.display == [" m", " ", "f "] + assert tolist(screen)[2] == [ + Char("f"), + Char(" ", fg='red'), + screen.default_char + ] + consistency_asserts(screen) # ! extreme cases. screen = update(pyte.Screen(5, 1), ["12345"], colored=[0]) @@ -1138,6 +1687,7 @@ def test_erase_character(): screen.default_char, Char("5", "red") ] + consistency_asserts(screen) screen = update(pyte.Screen(5, 1), ["12345"], colored=[0]) screen.cursor.x = 2 @@ -1151,6 +1701,7 @@ def test_erase_character(): screen.default_char, screen.default_char ] + consistency_asserts(screen) screen = update(pyte.Screen(5, 1), ["12345"], colored=[0]) screen.erase_characters(4) @@ -1163,7 +1714,20 @@ def test_erase_character(): screen.default_char, Char("5", fg="red") ] + consistency_asserts(screen) + screen.cursor.x = 2 + screen.erase_characters(4) + assert (screen.cursor.y, screen.cursor.x) == (0, 2) + assert screen.display == [" "] + assert tolist(screen)[0] == [ + screen.default_char, + screen.default_char, + screen.default_char, + screen.default_char, + screen.default_char, + ] + consistency_asserts(screen) def test_erase_in_line(): screen = update(pyte.Screen(5, 5), @@ -1171,7 +1735,7 @@ def test_erase_in_line(): "s foo", "but a", "re yo", - "u? "], colored=[0]) + "u? "], colored=[0, 1]) screen.cursor_position(1, 3) # a) erase from cursor to the end of line @@ -1189,6 +1753,60 @@ def test_erase_in_line(): screen.default_char, screen.default_char ] + consistency_asserts(screen) + + # erase from cursor to the end of line (again, same place) + screen.erase_in_line(0) + assert (screen.cursor.y, screen.cursor.x) == (0, 2) + assert screen.display == ["sa ", + "s foo", + "but a", + "re yo", + "u? "] + assert tolist(screen)[0] == [ + Char("s", fg="red"), + Char("a", fg="red"), + screen.default_char, + screen.default_char, + screen.default_char + ] + consistency_asserts(screen) + + # erase from cursor to the end of line (again but from the middle of a line)) + screen.cursor.y = 1 + screen.erase_in_line(0) + assert (screen.cursor.y, screen.cursor.x) == (1, 2) + assert screen.display == ["sa ", + "s ", + "but a", + "re yo", + "u? "] + assert tolist(screen)[1] == [ + Char("s", fg="red"), + Char(" ", fg="red"), # this space comes from the setup, not from the erase + screen.default_char, + screen.default_char, + screen.default_char + ] + consistency_asserts(screen) + + # erase from cursor to the end of line erasing the whole line + screen.cursor.x = 0 + screen.erase_in_line(0) + assert (screen.cursor.y, screen.cursor.x) == (1, 0) + assert screen.display == ["sa ", + " ", + "but a", + "re yo", + "u? "] + assert tolist(screen)[1] == [ + screen.default_char, + screen.default_char, + screen.default_char, + screen.default_char, + screen.default_char + ] + consistency_asserts(screen) # b) erase from the beginning of the line to the cursor screen = update(screen, @@ -1197,6 +1815,8 @@ def test_erase_in_line(): "but a", "re yo", "u? "], colored=[0]) + screen.cursor.x = 2 + screen.cursor.y = 0 screen.erase_in_line(1) assert (screen.cursor.y, screen.cursor.x) == (0, 2) assert screen.display == [" i", @@ -1211,6 +1831,7 @@ def test_erase_in_line(): Char(" ", fg="red"), Char("i", fg="red") ] + consistency_asserts(screen) # c) erase the entire line screen = update(screen, @@ -1227,7 +1848,39 @@ def test_erase_in_line(): "re yo", "u? "] assert tolist(screen)[0] == [screen.default_char] * 5 + consistency_asserts(screen) + # d) erase with a non-default attributes cursor + screen.select_graphic_rendition(31) # red foreground + + screen.cursor.y = 1 + screen.erase_in_line(2) + assert (screen.cursor.y, screen.cursor.x) == (1, 2) + assert screen.display == [" ", + " ", + "but a", + "re yo", + "u? "] + assert tolist(screen)[1] == [Char(" ", fg="red")] * 5 + consistency_asserts(screen) + + screen.cursor.y = 2 + screen.erase_in_line(1) + assert (screen.cursor.y, screen.cursor.x) == (2, 2) + assert screen.display == [" ", + " ", + " a", + "re yo", + "u? "] + assert tolist(screen)[2] == [ + Char(" ", fg="red"), + Char(" ", fg="red"), + Char(" ", fg="red"), + screen.default_char, + Char("a"), + ] + + consistency_asserts(screen) def test_erase_in_display(): screen = update(pyte.Screen(5, 5), @@ -1256,6 +1909,7 @@ def test_erase_in_display(): [screen.default_char] * 5, [screen.default_char] * 5 ] + consistency_asserts(screen) # b) erase from the beginning of the display to the cursor, # including it @@ -1281,6 +1935,7 @@ def test_erase_in_display(): Char(" ", fg="red"), Char("a", fg="red")], ] + consistency_asserts(screen) # c) erase the while display screen.erase_in_display(2) @@ -1291,6 +1946,7 @@ def test_erase_in_display(): " ", " "] assert tolist(screen) == [[screen.default_char] * 5] * 5 + consistency_asserts(screen) # d) erase with private mode screen = update(pyte.Screen(5, 5), @@ -1305,6 +1961,7 @@ def test_erase_in_display(): " ", " ", " "] + consistency_asserts(screen) # e) erase with extra args screen = update(pyte.Screen(5, 5), @@ -1320,6 +1977,7 @@ def test_erase_in_display(): " ", " ", " "] + consistency_asserts(screen) # f) erase with extra args and private screen = update(pyte.Screen(5, 5), @@ -1334,7 +1992,75 @@ def test_erase_in_display(): " ", " ", " "] + consistency_asserts(screen) + + # erase from the beginning of the display to the cursor, + # including it, but with the cursor having a non-default attribute + screen = update(screen, + ["sam i", + "s foo", + "but a", + "re yo", + "u? "], colored=[2, 3]) + + screen.cursor.x = 2 + screen.cursor.y = 2 + screen.select_graphic_rendition(31) # red foreground + screen.erase_in_display(1) + assert (screen.cursor.y, screen.cursor.x) == (2, 2) + assert screen.display == [" ", + " ", + " a", + "re yo", + "u? "] + assert tolist(screen)[:3] == [ + [Char(" ", fg="red")] * 5, + [Char(" ", fg="red")] * 5, + [Char(" ", fg="red"), + Char(" ", fg="red"), + Char(" ", fg="red"), + Char(" ", fg="red"), + Char("a", fg="red")], + ] + consistency_asserts(screen) + + screen.erase_in_display(3) + assert (screen.cursor.y, screen.cursor.x) == (2, 2) + assert screen.display == [" ", + " ", + " ", + " ", + " "] + assert tolist(screen) == [ + [Char(" ", fg="red")] * 5, + [Char(" ", fg="red")] * 5, + [Char(" ", fg="red")] * 5, + [Char(" ", fg="red")] * 5, + [Char(" ", fg="red")] * 5, + ] + consistency_asserts(screen) + # erase a clean screen (reset) from the begin to cursor + screen.reset() + screen.cursor.y = 2 + screen.cursor.x = 2 + screen.select_graphic_rendition(31) # red foreground + + screen.erase_in_display(1) + assert (screen.cursor.y, screen.cursor.x) == (2, 2) + assert screen.display == [" ", + " ", + " ", + " ", + " "] + assert tolist(screen) == [ + [Char(" ", fg="red")] * 5, + [Char(" ", fg="red")] * 5, + [Char(" ", fg="red")] * 3 + [screen.default_char] * 2, + [screen.default_char] * 5, + [screen.default_char] * 5, + ] + consistency_asserts(screen) def test_cursor_up(): screen = pyte.Screen(10, 10) @@ -1402,6 +2128,7 @@ def test_cursor_back_last_column(): screen.cursor_back(5) assert screen.cursor.x == (screen.columns - 1) - 5 + consistency_asserts(screen) def test_cursor_forward(): @@ -1462,6 +2189,7 @@ def test_unicode(): stream.feed("тест".encode("utf-8")) assert screen.display == ["тест", " "] + consistency_asserts(screen) def test_alignment_display(): @@ -1477,6 +2205,7 @@ def test_alignment_display(): "b ", " ", " "] + consistency_asserts(screen) screen.alignment_display() @@ -1485,12 +2214,13 @@ def test_alignment_display(): "EEEEE", "EEEEE", "EEEEE"] + consistency_asserts(screen) def test_set_margins(): screen = pyte.Screen(10, 10) - assert screen.margins is None + assert screen.margins == (0, 9) # a) ok-case screen.set_margins(1, 5) @@ -1503,7 +2233,7 @@ def test_set_margins(): # c) no margins provided -- reset to full screen. screen.set_margins() - assert screen.margins is None + assert screen.margins == (0, 9) def test_set_margins_zero(): @@ -1512,7 +2242,7 @@ def test_set_margins_zero(): screen.set_margins(1, 5) assert screen.margins == (0, 4) screen.set_margins(0) - assert screen.margins is None + assert screen.margins == (0, 23) def test_hide_cursor(): @@ -1583,3 +2313,292 @@ def test_screen_set_icon_name_title(): screen.set_title(text) assert screen.title == text + + +def test_fuzzy_insert_characters(): + columns = 7 + + # test different one-line screen scenarios with a mix + # of empty and non-empty chars + for mask in itertools.product('x ', repeat=columns): + # make each 'x' a different letter so we can spot subtle errors + line = [c if m == 'x' else ' ' for m, c in zip(mask, 'ABCDEFGHIJK')] + assert len(line) == columns + original = list(line) + for count in [1, 2, columns//2, columns-1, columns, columns+1]: + for at in [0, 1, columns//2, columns-count, columns-count+1, columns-1]: + if at < 0: + continue + + for margins in [None, (1, columns-2)]: + screen = update(pyte.Screen(columns, 1), [line], write_spaces=False) + + if margins: + # set_margins is 1-based indexes + screen.set_margins(top=margins[0]+1, bottom=margins[1]+1) + + screen.cursor.x = at + screen.insert_characters(count) + + # screen.insert_characters are not margins-aware so they + # will ignore any margin set. Therefore the expected + # line should also ignore them + expected_line = splice(line, at, count, [" "], margins=None) + expected_line = ''.join(expected_line) + + assert screen.display == [expected_line], "At {}, cnt {}, (m {}), initial line: {}".format(at, count, margins, line) + consistency_asserts(screen) + + # map the chars to Char objects + expected_line = [screen.default_char if c == ' ' else Char(c) for c in expected_line] + assert tolist(screen)[0] == expected_line, "At {}, cnt {}, (m {}), initial line: {}".format(at, count, margins, line) + + # ensure that the line that we used for the tests was not modified + # so the tests used the correct line object (otherwise the tests + # are invalid) + assert original == line + + + +def test_fuzzy_delete_characters(): + columns = 7 + + # test different one-line screen scenarios with a mix + # of empty and non-empty chars + for mask in itertools.product('x ', repeat=columns): + line = [c if m == 'x' else ' ' for m, c in zip(mask, 'ABCDEFGHIJK')] + assert len(line) == columns + original = list(line) + for count in [1, 2, columns//2, columns-1, columns, columns+1]: + for at in [0, 1, columns//2, columns-count, columns-count+1, columns-1]: + if at < 0: + continue + for margins in [None, (1, columns-2)]: + screen = update(pyte.Screen(columns, 1), [line], write_spaces=False) + + if margins: + # set_margins is 1-based indexes + screen.set_margins(top=margins[0]+1, bottom=margins[1]+1) + + screen.cursor.x = at + screen.delete_characters(count) + + # screen.delete_characters are not margins-aware so they + # will ignore any margin set. Therefore the expected + # line should also ignore them + expected_line = splice(line, at, (-1)*count, [" "], margins=None) + expected_line = ''.join(expected_line) + + assert screen.display == [expected_line], "At {}, cnt {}, (m {}), initial line: {}".format(at, count, margins, line) + consistency_asserts(screen) + + # map the chars to Char objects + expected_line = [screen.default_char if c == ' ' else Char(c) for c in expected_line] + assert tolist(screen)[0] == expected_line, "At {}, cnt {}, (m {}), initial line: {}".format(at, count, margins, line) + + # ensure that the line that we used for the tests was not modified + # so the tests used the correct line object (otherwise the tests + # are invalid) + assert original == line + + + + +def test_fuzzy_erase_characters(): + columns = 7 + + # test different one-line screen scenarios with a mix + # of empty and non-empty chars + for mask in itertools.product('x ', repeat=columns): + line = [c if m == 'x' else ' ' for m, c in zip(mask, 'ABCDEFGHIJK')] + assert len(line) == columns + original = list(line) + for count in [1, 2, columns//2, columns-1, columns, columns+1]: + for at in [0, 1, columns//2, columns-count, columns-count+1, columns-1]: + if at < 0: + continue + for margins in [None, (1, columns-2)]: + screen = update(pyte.Screen(columns, 1), [line], write_spaces=False) + + if margins: + # set_margins is 1-based indexes + screen.set_margins(top=margins[0]+1, bottom=margins[1]+1) + + screen.cursor.x = at + screen.erase_characters(count) + + expected_line = list(line) + expected_line[at:at+count] = [" "] * (min(at+count, columns) - at) + expected_line = ''.join(expected_line) + + assert screen.display == [expected_line], "At {}, cnt {}, (m {}), initial line: {}".format(at, count, margins, line) + consistency_asserts(screen) + + # map the chars to Char objects + expected_line = [screen.default_char if c == ' ' else Char(c) for c in expected_line] + assert tolist(screen)[0] == expected_line, "At {}, cnt {}, (m {}), initial line: {}".format(at, count, margins, line) + + # ensure that the line that we used for the tests was not modified + # so the tests used the correct line object (otherwise the tests + # are invalid) + assert original == line + + +def test_fuzzy_insert_lines(): + rows = 7 + + # test different screen scenarios with a mix + # of empty and non-empty lines + for masks in itertools.product(['x x', ' '], repeat=rows): + # make each line different + lines = [m if m == ' ' else '%c %c' % (c,c) for m, c in zip(masks, "ABCDEFGHIJK")] + assert len(lines) == rows + original = list(lines) + for count in [1, 2, rows//2, rows-1, rows, rows+1]: + for at in [0, 1, rows//2, rows-count, rows-count+1, rows-1]: + if at < 0: + continue + for margins in [None, (1, rows-2)]: + screen = update(pyte.Screen(3, rows), lines, write_spaces=False) + + if margins: + # set_margins is 1-based indexes + screen.set_margins(top=margins[0]+1, bottom=margins[1]+1) + + screen.cursor.y = at + screen.insert_lines(count) + + expected_lines = splice(lines, at, count, [" "], margins) + + assert screen.display == expected_lines, "At {}, cnt {}, (m {}), initial lines: {}".format(at, count, margins, lines) + consistency_asserts(screen) + + # map the chars to Char objects + expected_lines = [[screen.default_char if c == ' ' else Char(c) for c in l] for l in expected_lines] + assert tolist(screen) == expected_lines, "At {}, cnt {}, (m {}), initial lines: {}".format(at, count, margins, lines) + + # ensure that the line that we used for the tests was not modified + # so the tests used the correct line object (otherwise the tests + # are invalid) + assert original == lines + + + +def test_fuzzy_delete_lines(): + rows = 7 + + # test different screen scenarios with a mix + # of empty and non-empty lines + for masks in itertools.product(['x x', ' '], repeat=rows): + lines = [m if m == ' ' else '%c %c' % (c,c) for m, c in zip(masks, "ABCDEFGHIJK")] + assert len(lines) == rows + original = list(lines) + for count in [1, 2, rows//2, rows-1, rows, rows+1]: + for at in [0, 1, rows//2, rows-count, rows-count+1, rows-1]: + if at < 0: + continue + for margins in [None, (1, rows-2)]: + screen = update(pyte.Screen(3, rows), lines, write_spaces=False) + + if margins: + # set_margins is 1-based indexes + screen.set_margins(top=margins[0]+1, bottom=margins[1]+1) + + screen.cursor.y = at + screen.delete_lines(count) + + expected_lines = splice(lines, at, (-1)*count, [" "], margins) + + assert screen.display == expected_lines, "At {}, cnt {}, (m {}), initial lines: {}".format(at, count, margins, lines) + consistency_asserts(screen) + + # map the chars to Char objects + expected_lines = [[screen.default_char if c == ' ' else Char(c) for c in l] for l in expected_lines] + assert tolist(screen) == expected_lines, "At {}, cnt {}, (m {}), initial lines: {}".format(at, count, margins, lines) + + # ensure that the line that we used for the tests was not modified + # so the tests used the correct line object (otherwise the tests + # are invalid) + assert original == lines + + +def test_compressed_display(): + screen = update(pyte.Screen(4, 5), [ + " ", + " a ", + " ", + " bb", + " ", + ], write_spaces=False) + + assert screen.display == [ + " ", + " a ", + " ", + " bb", + " ", + ] + + assert screen.compressed_display() == [ + " ", + " a ", + " ", + " bb", + " ", + ] + + assert screen.compressed_display(lstrip=True) == [ + "", + "a ", + "", + "bb", + "", + ] + + assert screen.compressed_display(rstrip=True) == [ + "", + " a", + "", + " bb", + "", + ] + + assert screen.compressed_display(lstrip=True, rstrip=True) == [ + "", + "a", + "", + "bb", + "", + ] + + assert screen.compressed_display(tfilter=True) == [ + " a ", + " ", + " bb", + " ", + ] + + assert screen.compressed_display(bfilter=True) == [ + " ", + " a ", + " ", + " bb", + ] + + assert screen.compressed_display(tfilter=True, bfilter=True) == [ + " a ", + " ", + " bb", + ] + + assert screen.compressed_display(tfilter=True, bfilter=True, rstrip=True) == [ + " a", + "", + " bb", + ] + + assert screen.compressed_display(tfilter=True, bfilter=True, lstrip=True) == [ + "a ", + "", + "bb", + ] diff --git a/tests/test_stream.py b/tests/test_stream.py index 7a3ad92..0d05df7 100644 --- a/tests/test_stream.py +++ b/tests/test_stream.py @@ -1,10 +1,12 @@ -import io +import io, sys, os import pytest import pyte from pyte import charsets as cs, control as ctrl, escape as esc +sys.path.append(os.path.join(os.path.dirname(__file__), "helpers")) +from asserts import consistency_asserts class counter: def __init__(self): @@ -227,6 +229,7 @@ def test_define_charset(): stream = pyte.Stream(screen) stream.feed(ctrl.ESC + "(B") assert screen.display[0] == " " * 3 + consistency_asserts(screen) def test_non_utf8_shifts(): @@ -305,6 +308,7 @@ def test_byte_stream_define_charset_unknown(): stream.feed((ctrl.ESC + "(Z").encode()) assert screen.display[0] == " " * 3 assert screen.g0_charset == default_g0_charset + consistency_asserts(screen) @pytest.mark.parametrize("charset,mapping", cs.MAPS.items()) @@ -315,6 +319,7 @@ def test_byte_stream_define_charset(charset, mapping): stream.feed((ctrl.ESC + "(" + charset).encode()) assert screen.display[0] == " " * 3 assert screen.g0_charset == mapping + consistency_asserts(screen) def test_byte_stream_select_other_charset():