From 355bfce1894ce26f6e312aac885f092d1a9ed633 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 18 Oct 2016 11:04:30 +0530 Subject: [PATCH] Implement the fast draw path for single width characters --- kitty/data_types.py | 3 ++ kitty/screen.py | 102 +++++++++++++++++++++++----------------- kitty/tracker.py | 91 +++++++++++++++++++++++++++++++++++ kitty_tests/__init__.py | 23 +++++++++ kitty_tests/screen.py | 53 +++++++++++++++++++++ test.py | 4 +- 6 files changed, 232 insertions(+), 44 deletions(-) create mode 100644 kitty/tracker.py create mode 100644 kitty_tests/screen.py diff --git a/kitty/data_types.py b/kitty/data_types.py index 5f3126776..7baabc0e6 100644 --- a/kitty/data_types.py +++ b/kitty/data_types.py @@ -187,6 +187,9 @@ class Line: def width(self, i): return (self.char[i] >> ATTRS_SHIFT) & 0b11 + def char_at(self, i): + return chr(self.char[i] & CHAR_MASK) + def set_char(self, i: int, ch: str, width: int=1, cursor: Cursor=None) -> None: if cursor is None: c = self.char[i] diff --git a/kitty/screen.py b/kitty/screen.py index b5bc21b08..2a00d9bf6 100644 --- a/kitty/screen.py +++ b/kitty/screen.py @@ -36,20 +36,17 @@ class Screen(QObject): See standard ECMA-48, Section 6.1.1 http://www.ecma-international.org/publications/standards/Ecma-048.htm for a description of the presentational component, implemented by ``Screen``. """ - cursor_changed = pyqtSignal(object) - cursor_position_changed = pyqtSignal(object, object, object) - update_screen = pyqtSignal() + title_changed = pyqtSignal(object) icon_changed = pyqtSignal(object) - update_line_range = pyqtSignal(object, object) - update_cell_range = pyqtSignal(object, object, object) - line_added_to_history = pyqtSignal() write_to_child = pyqtSignal(object) _notify_cursor_position = True - def __init__(self, opts, columns: int=80, lines: int=24, parent=None): + def __init__(self, opts, tracker, columns: int=80, lines: int=24, parent=None): QObject.__init__(self, parent) self.write_process_input = self.write_to_child.emit + for attr in 'cursor_changed cursor_position_changed update_screen update_line_range update_cell_range line_added_to_history'.split(): + setattr(self, attr, getattr(tracker, attr)) self.savepoints = deque() self.columns = columns self.lines = lines @@ -69,7 +66,7 @@ class Screen(QObject): def notify_cursor_position(self, x, y): if self._notify_cursor_position: - self.cursor_position_changed.emit(self.cursor, x, y) + self.cursor_position_changed(self.cursor, x, y) @property def display(self) -> Sequence[str]: @@ -108,7 +105,7 @@ class Screen(QObject): self.tabstops = set(range(7, self.columns, 8)) self.cursor = Cursor(0, 0) - self.cursor_changed.emit(self.cursor) + self.cursor_changed(self.cursor) self.cursor_position() def resize(self, lines: int, columns: int): @@ -193,13 +190,13 @@ class Screen(QObject): for line in self.linebuf: for i in range(len(line)): line.reverse[i] = True - self.update_screen.emit() + self.update_screen() self.select_graphic_rendition(7) # +reverse. # Make the cursor visible. if mo.DECTCEM in modes and self.cursor.hidden: self.cursor.hidden = False - self.cursor_changed.emit(self.cursor) + self.cursor_changed(self.cursor) def reset_mode(self, *modes, private=False): """Resets (disables) a given list of modes. @@ -228,13 +225,13 @@ class Screen(QObject): for line in self.linebuf: for i in range(len(line)): line.reverse[i] = False - self.update_screen.emit() + self.update_screen() self.select_graphic_rendition(27) # -reverse. # Hide the cursor. if mo.DECTCEM in modes and not self.cursor.hidden: self.cursor.hidden = True - self.cursor_changed.emit(self.cursor) + self.cursor_changed(self.cursor) def define_charset(self, code, mode): """Defines ``G0`` or ``G1`` charset. @@ -293,57 +290,76 @@ class Screen(QObject): return "".join(self.g0_charset[b] for b in data) def _fast_draw(self, data: str) -> None: - while data: - pass # TODO: Implement me + do_insert = mo.IRM in self.mode + pos = 0 + while pos < len(data): + space_left_in_line = self.columns - self.cursor.x + len_left = len(data) - pos + if space_left_in_line < 1: + if mo.DECAWM in self.mode: + self.carriage_return() + self.linefeed() + self.linebuf[self.cursor.y].continued = True + space_left_in_line = self.columns + else: + space_left_in_line = min(len_left, self.columns) + self.cursor.x = self.columns - space_left_in_line + write_sz = min(len_left, space_left_in_line) + line = self.linebuf[self.cursor.y] + if do_insert: + line.right_shift(self.cursor.x, write_sz) + line.set_text(data, pos, write_sz, self.cursor) + pos += write_sz + cx = self.cursor.x + self.cursor.x += write_sz + right = self.columns - 1 if do_insert else max(0, min(self.cursor.x - 1, self.columns - 1)) + self.update_cell_range(self.cursor.y, cx, right) def _draw_char(self, char: str, char_width: int) -> None: # If this was the last column in a line and auto wrap mode is # enabled, move the cursor to the beginning of the next line, # otherwise replace characters already displayed with newly # entered. - if self.cursor.x + char_width > self.columns - 1: + space_left_in_line = self.columns - self.cursor.x + if space_left_in_line < char_width: if mo.DECAWM in self.mode: self.carriage_return() self.linefeed() self.linebuf[self.cursor.y].continued = True + space_left_in_line = self.columns else: - extra = self.cursor.x + char_width + 1 - self.columns - self.cursor.x -= extra + self.cursor.x = self.columns - char_width + space_left_in_line = char_width - # 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. - do_insert = mo.IRM in self.mode and char_width > 0 - if do_insert: - self.insert_characters(char_width) + do_insert = mo.IRM in self.mode cx = self.cursor.x line = self.linebuf[self.cursor.y] - if char_width: - line.char[cx], line.width[cx] = ord(char), char_width - if char_width > 1: - for i in range(1, char_width): - line.char[cx + i] = line.width[cx + i] = 0 - line.apply_cursor(self.cursor, cx, char_width) + if char_width > 0: + if do_insert: + line.right_shift(self.cursor.x, char_width) + line.set_char(cx, char, char_width, self.cursor) + if char_width == 2: + line.set_char(cx, '\0', 0, self.cursor) elif unicodedata.combining(char): # A zero-cell character is combined with the previous # character either on this or preceeding line. if cx: - last = chr(line.char[cx - 1]) + last = line.char_at(cx - 1) normalized = unicodedata.normalize("NFC", last + char) - line.char[cx - 1] = ord(normalized) + line.set_char(cx - 1, normalized[0]) elif self.cursor.y: lline = self.linebuf[self.cursor.y - 1] - last = chr(lline.char[self.columns - 1]) + last = chr(lline.char_at(self.columns - 1)) normalized = unicodedata.normalize("NFC", last + char) - lline.char[self.columns - 1] = ord(normalized) + lline.set_char(self.columns - 1, normalized[0]) # .. 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 - 1) - if not do_insert: - self.update_cell_range(self.cursor.y, cx, self.cursor.x) + self.cursor.x = min(self.cursor.x + char_width, self.columns) + right = self.columns - 1 if do_insert else max(0, min(self.cursor.x - 1, self.columns - 1)) + self.update_cell_range(self.cursor.y, cx, right) def draw(self, data: bytes) -> None: """ Displays decoded characters at the current cursor position and @@ -395,8 +411,8 @@ class Screen(QObject): if self.cursor.y == bottom: self.tophistorybuf.append(self.linebuf.pop(top)) self.linebuf.insert(bottom, Line(self.columns)) - self.line_added_to_history.emit() - self.update_screen.emit() + self.line_added_to_history() + self.update_screen() else: self.cursor_down() @@ -409,7 +425,7 @@ class Screen(QObject): if self.cursor.y == top: self.linebuf.pop(bottom) self.linebuf.insert(top, Line(self.columns)) - self.update_screen.emit() + self.update_screen() else: self.cursor_up() @@ -473,7 +489,7 @@ class Screen(QObject): self.set_mode(mo.DECAWM) self.cursor = savepoint.cursor - self.cursor_changed.emit(self.cursor) + self.cursor_changed(self.cursor) self.ensure_bounds(use_margins=True) else: # If nothing was saved, the cursor moves to home position; @@ -498,7 +514,7 @@ class Screen(QObject): min(bottom + 1, self.cursor.y + count)): self.linebuf.pop(bottom) self.linebuf.insert(line, Line(self.columns)) - self.update_line_range.emit(self.cursor.y, bottom) + self.update_line_range(self.cursor.y, bottom) self.carriage_return() @@ -518,7 +534,7 @@ class Screen(QObject): for _ in range(min(bottom - self.cursor.y + 1, count)): self.linebuf.pop(self.cursor.y) self.linebuf.insert(bottom, Line(self.columns)) - self.update_line_range.emit(self.cursor.y, bottom) + self.update_line_range(self.cursor.y, bottom) self.carriage_return() diff --git a/kitty/tracker.py b/kitty/tracker.py new file mode 100644 index 000000000..338af435e --- /dev/null +++ b/kitty/tracker.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPL v3 Copyright: 2016, Kovid Goyal + +from collections import defaultdict +from operator import itemgetter +from typing import Set, Tuple, Iterator + +from PyQt5.QtCore import QObject, pyqtSignal, Qt + +from .data_types import Cursor + + +def merge_ranges(ranges: Set[Tuple[int]]) -> Iterator[Tuple[int]]: + if ranges: + sorted_intervals = sorted(ranges, key=itemgetter(0)) + # low and high represent the bounds of the current run of merges + low, high = sorted_intervals[0] + + for iv in sorted_intervals[1:]: + if iv[0] <= high: # new interval overlaps current run + high = max(high, iv[1]) # merge with the current run + else: # current run is over + yield low, high # yield accumulated interval + low, high = iv # start new run + + yield low, high # end the final run + + +class ChangeTracker(QObject): + + dirtied = pyqtSignal(object) + mark_dirtied = pyqtSignal() + + def __init__(self, parent=None): + QObject.__init__(self, parent) + self.reset() + self.mark_dirtied.connect(self.consolidate_changes, type=Qt.QueuedConnection) + + def reset(self): + self._dirty = False + self.changed_cursor = None + self.changed_cells = defaultdict(set) + self.changed_lines = set() + self.screen_changed = False + self.history_line_added_count = 0 + + def dirty(self): + if not self._dirty: + self._dirty = True + self.mark_dirtied.emit() + + def cursor_changed(self, cursor: Cursor) -> None: + self.changed_cursor = cursor + self.dirty() + + def cursor_position_changed(self, cursor: Cursor, x: int, y: int) -> None: + self.changed_cursor = cursor + self.changed_cells[y].add((x, x)) + self.dirty() + + def update_screen(self): + self.screen_changed = True + self.dirty() + + def update_line_range(self, first_line, last_line): + self.changed_lines |= set(range(first_line, last_line + 1)) + self.dirty() + + def update_cell_range(self, y, first_cell, last_cell): + self.changed_cells[y].add((first_cell, last_cell)) + self.dirty() + + def line_added_to_history(self): + self.history_line_added_count += 1 + self.dirty() + + def consolidate_changes(self): + if self.screen_changed: + self.changed_cells.clear(), self.changed_lines.clear() + else: + if self.changed_lines: + for y in self.changed_lines: + del self.changed_cells[y] + for y, cell_ranges in self.changed_cells.items(): + self.changed_cells[y] = tuple(merge_ranges(cell_ranges)) + changes = {'screen': self.screen_changed, 'cursor': self.changed_cursor, 'lines': self.changed_lines, + 'cells': self.changed_cells, 'history_line_added_count': self.history_line_added_count} + self.reset() + self.dirtied.emit(changes) + return changes diff --git a/kitty_tests/__init__.py b/kitty_tests/__init__.py index 8f01f5575..fd6fee6dc 100644 --- a/kitty_tests/__init__.py +++ b/kitty_tests/__init__.py @@ -2,9 +2,32 @@ # vim:fileencoding=utf-8 # License: GPL v3 Copyright: 2016, Kovid Goyal +from collections import defaultdict from unittest import TestCase +from kitty.screen import Screen +from kitty.tracker import ChangeTracker +from kitty.config import defaults + class BaseTest(TestCase): ae = TestCase.assertEqual + + def create_screen(self, cols=5, lines=5, history_size=5): + t = ChangeTracker() + opts = defaults._replace(scrollback_lines=history_size) + s = Screen(opts, t, columns=cols, lines=lines) + return s, t + + def assertChanges(self, t, ignore='', **expected_changes): + actual_changes = t.consolidate_changes() + ignore = frozenset(ignore.split()) + for k, v in actual_changes.items(): + if isinstance(v, defaultdict): + v = dict(v) + if k not in ignore: + if k in expected_changes: + self.ae(expected_changes[k], v) + else: + self.assertFalse(v) diff --git a/kitty_tests/screen.py b/kitty_tests/screen.py new file mode 100644 index 000000000..9d82b0b30 --- /dev/null +++ b/kitty_tests/screen.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPL v3 Copyright: 2016, Kovid Goyal + +from . import BaseTest + +from kitty.screen import mo + + +class TestScreen(BaseTest): + + def test_draw_fast(self): + # Test in line-wrap, non-insert mode + s, t = self.create_screen() + s.draw(b'a' * 5) + self.ae(str(s.linebuf[0]), 'a' * 5) + self.ae(s.cursor.x, 5), self.ae(s.cursor.y, 0) + self.assertChanges(t, ignore='cursor', cells={0: ((0, 4),)}) + s.draw(b'b' * 7) + self.assertTrue(s.linebuf[1].continued) + self.assertTrue(s.linebuf[2].continued) + self.ae(str(s.linebuf[0]), 'a' * 5) + self.ae(str(s.linebuf[1]), 'b' * 5) + self.ae(str(s.linebuf[2]), 'b' * 2 + ' ' * 3) + self.ae(s.cursor.x, 2), self.ae(s.cursor.y, 2) + self.assertChanges(t, ignore='cursor', cells={1: ((0, 4),), 2: ((0, 1),)}) + s.draw(b'c' * 15) + self.ae(str(s.linebuf[0]), 'b' * 5) + self.ae(str(s.linebuf[1]), 'bbccc') + + # Now test without line-wrap + s.reset(), t.reset() + s.reset_mode(mo.DECAWM) + s.draw(b'0123456789') + self.ae(str(s.linebuf[0]), '56789') + self.ae(s.cursor.x, 5), self.ae(s.cursor.y, 0) + self.assertChanges(t, ignore='cursor', cells={0: ((0, 4),)}) + s.draw(b'ab') + self.ae(str(s.linebuf[0]), '567ab') + self.ae(s.cursor.x, 5), self.ae(s.cursor.y, 0) + self.assertChanges(t, ignore='cursor', cells={0: ((3, 4),)}) + + # Now test in insert mode + s.reset(), t.reset() + s.set_mode(mo.IRM) + s.draw(b'12345' * 5) + s.cursor_back(5) + self.ae(s.cursor.x, 0), self.ae(s.cursor.y, 4) + t.reset() + s.draw(b'ab') + self.ae(str(s.linebuf[4]), 'ab123') + self.ae((s.cursor.x, s.cursor.y), (2, 4)) + self.assertChanges(t, ignore='cursor', cells={4: ((0, 4),)}) diff --git a/test.py b/test.py index f34d4f595..47eae0f1d 100755 --- a/test.py +++ b/test.py @@ -86,7 +86,9 @@ def run_cli(suite, verbosity=4): r = unittest.TextTestRunner r.resultclass = unittest.TextTestResult init_env() - result = r(verbosity=verbosity).run(suite) + runner = r(verbosity=verbosity) + runner.tb_locals = True + result = runner.run(suite) if not result.wasSuccessful(): raise SystemExit(1)