Implement the fast draw path for single width characters

This commit is contained in:
Kovid Goyal 2016-10-18 11:04:30 +05:30
parent 63d228f21b
commit 355bfce189
6 changed files with 232 additions and 44 deletions

View File

@ -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]

View File

@ -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()

91
kitty/tracker.py Normal file
View File

@ -0,0 +1,91 @@
#!/usr/bin/env python
# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
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

View File

@ -2,9 +2,32 @@
# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
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)

53
kitty_tests/screen.py Normal file
View File

@ -0,0 +1,53 @@
#!/usr/bin/env python
# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
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),)})

View File

@ -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)