diff --git a/kitty/boss.py b/kitty/boss.py index 6544a8ea6..a7482cc8e 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -2,11 +2,9 @@ # vim:fileencoding=utf-8 # License: GPL v3 Copyright: 2016, Kovid Goyal -from collections import deque - from PyQt5.QtCore import QObject -from .data_types import Line, rewrap_lines +from .screen import Screen from .term import TerminalWidget @@ -14,19 +12,13 @@ class Boss(QObject): def __init__(self, opts, parent=None): QObject.__init__(self, parent) - self.linebuf = deque(maxlen=max(1000, opts.scrollback_lines)) - self.term = TerminalWidget(opts, self.linebuf, parent) + self.screen = Screen(opts, parent=self) + self.term = TerminalWidget(opts, self.screen.linebuf, parent) self.term.relayout_lines.connect(self.relayout_lines) def apply_opts(self, opts): - if opts.scrollback_lines != self.linebuf.maxlen: - self.linebuf = deque(self.linebuf, maxlen=max(1000, opts.scrollback_lines)) - self.term.linebuf = self.linebuf + self.screen.apply_opts(opts) self.term.apply_opts(opts) - def relayout_lines(self, previous, cells_per_line): - if previous == cells_per_line: - return - old = self.linebuf.copy() - self.linebuf.clear() - self.linebuf.extend(rewrap_lines(old, cells_per_line)) + def relayout_lines(self, previous, cells_per_line, previousl, lines_per_screen): + self.screen.resize(lines_per_screen, cells_per_line) diff --git a/kitty/data_types.py b/kitty/data_types.py index 8e90f2fe3..f7d3761b1 100644 --- a/kitty/data_types.py +++ b/kitty/data_types.py @@ -17,32 +17,79 @@ def get_zeroes(sz: int) -> Tuple[array.array]: get_zeroes.ans = ( array.array('B', repeat(0, sz)), array.array(code, repeat(0, sz)), + array.array(code, repeat(32, sz)), ) return get_zeroes.ans get_zeroes.current_size = None +class Cursor: + + __slots__ = ("x", "y", "hidden", 'fg', 'bg', 'bold', 'italic', 'reverse', 'strikethrough', 'decoration', 'decoration_fg') + + def __init__(self, x: int=0, y: int=0): + self.x = x + self.y = y + self.hidden = False + self.fg = self.bg = self.decoration_fg = 0 + self.bold = self.italic = self.reverse = self.strikethrough = False + self.decoration = 0 + + def copy(self): + ans = Cursor(self.x, self.y) + ans.hidden = self.hidden + ans.fg, ans.bg, ans.decoration_fg = self.fg, self.bg, self.decoration_fg + ans.bold, ans.italic, ans.reverse, ans.strikethrough = self.bold, self.italic, self.reverse, self.strikethrough + return ans + + class Line: __slots__ = 'char fg bg bold italic reverse strikethrough decoration decoration_fg width'.split() continued = False - def __init__(self, sz: int): - z1, z4 = get_zeroes(sz) - self.char = z4[:] - self.fg = z4[:] - self.bg = z4[:] - self.bold = z1[:] - self.italic = z1[:] - self.reverse = z1[:] - self.strikethrough = z1[:] - self.decoration = z1[:] - self.decoration_fg = z4[:] - self.width = z1[:] + def __init__(self, sz: int, other=None): + if other is None: + z1, z4, spaces = get_zeroes(sz) + self.char = spaces[:] + self.fg = z4[:] + self.bg = z4[:] + self.bold = z1[:] + self.italic = z1[:] + self.reverse = z1[:] + self.strikethrough = z1[:] + self.decoration = z1[:] + self.decoration_fg = z4[:] + self.width = z1[:] + else: + self.char = other.char[:] + self.fg = other.fg[:] + self.bg = other.bg[:] + self.bold = other.bold[:] + self.italic = other.italic[:] + self.reverse = other.reverse[:] + self.strikethrough = other.strikethrough[:] + self.decoration = other.decoration[:] + self.decoration_fg = other.decoration_fg[:] + self.width = other.width[:] + + def __eq__(self, other): + if not isinstance(other, Line): + return False + for x in self.__slots__: + if getattr(self, x) != getattr(other, x): + return False + return self.continued == other.continued + + def __ne__(self, other): + return not self.__eq__(other) def __len__(self): return len(self.char) + def copy(self): + return Line(len(self.char), self) + def copy_char(self, src: int, to, dest: int) -> None: to.char[dest] = self.char[src] to.fg[dest] = self.fg[src] @@ -55,8 +102,53 @@ class Line: to.decoration_fg[dest] = self.decoration_fg[src] to.width[dest] = self.width[src] + def apply_cursor(self, c: Cursor, at: int=0, num: int=1, clear_char=False, char=' ') -> None: + if num < 2: + self.fg[at] = c.fg + self.bg[at] = c.bg + self.bold[at] = c.bold + self.italic[at] = c.italic + self.reverse[at] = c.reverse + self.strikethrough[at] = c.strikethrough + self.decoration[at] = c.decoration + self.decoration_fg[at] = c.decoration_fg + if clear_char: + self.width[at], self.char[at] = 1, ord(char) + else: + num = min(len(self) - at, num) + at = slice(at, at + num) + self.fg[at] = repeat(c.fg, num) + self.bg[at] = repeat(c.bg, num) + self.bold[at] = repeat(c.bold, num) + self.italic[at] = repeat(c.italic, num) + self.reverse[at] = repeat(c.reverse, num) + self.strikethrough[at] = repeat(c.strikethrough, num) + self.decoration[at] = repeat(c.decoration, num) + self.decoration_fg[at] = repeat(c.decoration_fg, num) + if clear_char: + self.width[at], self.char[at] = repeat(1, num), repeat(ord(char), num) + + def copy_slice(self, src, dest, num): + src, dest = slice(src, src + num), slice(dest, dest + num) + for a in (self.char, self.fg, self.bg, self.bold, self.italic, self.reverse, self.strikethrough, self.decoration, self.decoration_fg, self.width): + a[dest] = a[src] + + def right_shift(self, at: int, num: int) -> None: + src_start, dest_start = at, at + num + ls = len(self) + dnum = min(ls - dest_start, ls) + if dnum: + self.copy_slice(src_start, dest_start, dnum) + + def left_shift(self, at: int, num: int) -> None: + src_start, dest_start = at + num, at + ls = len(self) + snum = min(ls - src_start, ls) + if snum: + self.copy_slice(src_start, dest_start, snum) + def __str__(self) -> str: - return ''.join(map(ord, self.char)).rstrip('\0') + return ''.join(map(ord, filter(None, self.char))) def __repr__(self) -> str: return repr(str(self)) diff --git a/kitty/screen.py b/kitty/screen.py new file mode 100644 index 000000000..8ae7cd33e --- /dev/null +++ b/kitty/screen.py @@ -0,0 +1,872 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPL v3 Copyright: 2016, Kovid Goyal + + +import codecs +import unicodedata +from collections import deque, namedtuple +from typing import Sequence + +from PyQt5.QtCore import QObject, pyqtSignal + +from pyte import charsets as cs, control as ctrl, graphics as g, modes as mo +from .data_types import Line, Cursor, rewrap_lines +from .utils import wcwidth + + +#: A container for screen's scroll margins. +Margins = namedtuple("Margins", "top bottom") + +#: A container for savepoint, created on :data:`~pyte.escape.DECSC`. +Savepoint = namedtuple("Savepoint", [ + "cursor", + "g0_charset", + "g1_charset", + "charset", + "use_utf8", + "origin", + "wrap" +]) + + +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() + _notify_cursor_position = True + + def __init__(self, opts, columns: int=80, lines: int=24, parent=None): + QObject.__init__(self, parent) + self.savepoints = deque() + self.columns = columns + self.lines = lines + self.linebuf = [] + sz = max(1000, opts.scrollback_lines) + self.tophistorybuf = deque(maxlen=sz) + self.reset() + + def apply_opts(self, opts): + sz = max(1000, opts.scrollback_lines) + if sz != self.tophistorybuf.maxlen: + self.tophistorybuf = deque(self.tophistorybuf, maxlen=sz) + + def __repr__(self): + return ("{0}({1}, {2})".format(self.__class__.__name__, + self.columns, self.lines)) + + def notify_cursor_position(self, x, y): + if self._notify_cursor_position: + self.cursor_position_changed.emit(self.cursor, x, y) + + @property + def display(self) -> Sequence[str]: + return [str(l) for l in self.linebuf] + + def reset(self): + """Resets the terminal to its initial state. + + * Scroll margins are reset to screen boundaries. + * Cursor is moved to home location -- ``(0, 0)`` and its + attributes are set to defaults (see :attr:`default_char`). + * Screen is cleared -- each character is reset to + :attr:`default_char`. + * Tabstops are reset to "every eight columns". + + .. note:: + + Neither VT220 nor VT102 manuals mentioned that terminal modes + and tabstops should be reset as well, thanks to + :manpage:`xterm` -- we now know that. + """ + self.linebuf.clear() + self.linebuf[:] = (Line(self.columns) for i in range(self.lines)) + self.mode = {mo.DECAWM, mo.DECTCEM} + self.margins = Margins(0, self.lines - 1) + + self.charset = 0 + self.g0_charset = cs.LAT1_MAP + self.g1_charset = cs.VT100_MAP + self.use_utf8 = True + self.utf8_decoder = codecs.getincrementaldecoder("utf-8")("replace") + + # From ``man terminfo`` -- "... hardware tabs are initially + # set every `n` spaces when the terminal is powered up. Since + # we aim to support VT102 / VT220 and linux -- we use n = 8. + self.tabstops = set(range(7, self.columns, 8)) + + self.cursor = Cursor(0, 0) + self.cursor_changed.emit(self.cursor) + self.cursor_position() + + def resize(self, lines: int, columns: int): + """Resize the screen to the given dimensions. + + .. note:: According to `xterm`, we should also reset origin + mode and screen margins, see ``xterm/screen.c:1761``. + + """ + for hb in (self.tophistorybuf, ): + old = hb.copy() + hb.clear(), hb.extend(rewrap_lines(old, columns)) + old_lines = self.linebuf[:] + self.linebuf.clear() + self.lines, self.columns = lines, columns + self.linebuf[:] = rewrap_lines(old_lines, self.columns) + extra = len(self.linebuf) - self.lines + if extra > 0: + self.tophistorybuf.extend(self.linebuf[:extra]) + del self.linebuf[:extra] + self.margins = Margins(0, self.lines - 1) + self.reset_mode(mo.DECOM) + self.ensure_bounds() + + def set_margins(self, top=None, bottom=None): + """Selects top and bottom margins for the scrolling region. + + Margins determine which screen lines move during scrolling + (see :meth:`index` and :meth:`reverse_index`). Characters added + outside the scrolling region do not cause the screen to scroll. + + :param int top: the smallest line number that is scrolled. + :param int bottom: the biggest line number that is scrolled. + """ + if top is None or bottom is None: + return + + # Arguments are 1-based, while :attr:`margins` are zero based -- + # so we have to decrement them by one. We also make sure that + # both of them is bounded by [0, lines - 1]. + top = max(0, min(top - 1, self.lines - 1)) + bottom = max(0, min(bottom - 1, self.lines - 1)) + + # Even though VT102 and VT220 require DECSTBM to ignore regions + # of width less than 2, some programs (like aptitude for example) + # rely on it. Practicality beats purity. + if bottom - top >= 1: + self.margins = Margins(top, bottom) + + # The cursor moves to the home position when the top and + # bottom margins of the scrolling region (DECSTBM) changes. + self.cursor_position() + + def set_mode(self, *modes, private=False): + """Sets (enables) a given list of modes. + + :param list modes: modes to set, where each mode is a constant + from :mod:`pyte.modes`. + """ + # Private mode codes are shifted, to be distingiushed from non + # private ones. + if private: + modes = [mode << 5 for mode in modes] + + self.mode.update(modes) + + # When DECOLM mode is set, the screen is erased and the cursor + # moves to the home position. + if mo.DECCOLM in modes: + # self.resize(columns=132) Disabled since we only allow resizing + # by the user + self.erase_in_display(2) + self.cursor_position() + + # According to `vttest`, DECOM should also home the cursor, see + # vttest/main.c:303. + if mo.DECOM in modes: + self.cursor_position() + + # Mark all displayed characters as reverse. + if mo.DECSCNM in modes: + for line in self.linebuf: + for i in range(len(line)): + line.reverse[i] = True + self.update_screen.emit() + 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) + + def reset_mode(self, *modes, private=False): + """Resets (disables) a given list of modes. + + :param list modes: modes to reset -- hopefully, each mode is a + constant from :mod:`pyte.modes`. + """ + # Private mode codes are shifted, to be distinguished from non + # private ones. + if private: + modes = [mode << 5 for mode in modes] + + self.mode.difference_update(modes) + + # Lines below follow the logic in :meth:`set_mode`. + if mo.DECCOLM in modes: + # self.resize(columns=80) Disabled since we only allow resizing by + # the user + self.erase_in_display(2) + self.cursor_position() + + if mo.DECOM in modes: + self.cursor_position() + + if mo.DECSCNM in modes: + for line in self.linebuf: + for i in range(len(line)): + line.reverse[i] = False + self.update_screen.emit() + 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) + + def define_charset(self, code, mode): + """Defines ``G0`` or ``G1`` charset. + + :param str code: character set code, should be a character + from ``b"B0UK"``, otherwise ignored. + :param str mode: if ``"("`` ``G0`` charset is defined, if + ``")"`` -- we operate on ``G1``. + + .. warning:: User-defined charsets are currently not supported. + """ + if code in cs.MAPS: + if mode == b"(": + self.g0_charset = cs.MAPS[code] + elif mode == b")": + self.g1_charset = cs.MAPS[code] + + def shift_in(self): + """Selects ``G0`` character set.""" + self.charset = 0 + + def shift_out(self): + """Selects ``G1`` character set.""" + self.charset = 1 + + def select_other_charset(self, code): + """Selects other (non G0 or G1) charset. + + :param str code: character set code, should be a character from + ``b"@G8"``, otherwise ignored. + + .. note:: We currently follow ``"linux"`` and only use this + command to switch from ISO-8859-1 to UTF-8 and back. + + .. seealso:: + + `Standard ECMA-35, Section 15.4 \ + `_ + for a description of VTXXX character set machinery. + """ + if code == b"@": + self.use_utf8 = False + self.utf8_decoder.reset() + elif code in b"G8": + self.use_utf8 = True + + def _decode(self, data): + """Decodes bytes to text according to the selected charset. + + :param bytes data: bytes to decode. + """ + if self.charset: + return "".join(self.g1_charset[b] for b in data) + if self.use_utf8: + return self.utf8_decoder.decode(data) + return "".join(self.g0_charset[b] for b in data) + + def draw(self, data: bytes): + """ Displays decoded characters at the current cursor position and + creates new lines as need if DECAWM is set. """ + orig_x, orig_y = self.cursor.x, self.cursor.y + self._notify_cursor_position = False + try: + for char in self._decode(data): + if unicodedata.category(char) in ('Cc', 'Cf', 'Cn', 'Cs'): + continue + char_width = wcwidth(char) + + # 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: + if mo.DECAWM in self.mode: + self.carriage_return() + self.linefeed() + self.linebuf[self.cursor.y].continued = True + else: + extra = self.cursor.x + char_width + 1 - self.columns + self.cursor.x -= extra + + # 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) + + 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) + 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]) + normalized = unicodedata.normalize("NFC", last + char) + line.char[cx - 1] = ord(normalized) + elif self.cursor.y: + lline = self.linebuf[self.cursor.y - 1] + last = chr(lline.char[self.columns - 1]) + normalized = unicodedata.normalize("NFC", last + char) + lline.char[self.columns - 1] = ord(normalized) + + # .. 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) + finally: + self._notify_cursor_position = True + if orig_x != self.cursor.x or orig_y != self.cursor.y: + self.notify_cursor_position(orig_x, orig_y) + + def set_title(self, param): + """Sets terminal title. + + .. note:: This is an XTerm extension supported by the Linux terminal. + """ + self.title_changed.emit(self._decode(param)) + + def set_icon_name(self, param): + """Sets icon name. + + .. note:: This is an XTerm extension supported by the Linux terminal. + """ + self.icon_changed.emit(self._decode(param)) + + def carriage_return(self): + """Move the cursor to the beginning of the current line.""" + x, self.cursor.x = self.cursor.x, 0 + if x != self.cursor.x: + self.notify_cursor_position(x, self.cursor.y) + + 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 + + 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() + else: + self.cursor_down() + + 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 + + if self.cursor.y == top: + self.linebuf.pop(bottom) + self.linebuf.insert(top, Line(self.columns)) + self.update_screen.emit() + else: + self.cursor_up() + + def linefeed(self): + """Performs an index and, if :data:`~pyte.modes.LNM` is set, a + carriage return. + """ + self.index() + + if mo.LNM in self.mode: + self.carriage_return() + + self.ensure_bounds() + + 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 + else: + column = self.columns - 1 + + if column != self.cursor.x: + x, self.cursor.x = self.cursor.x, column + self.notify_cursor_position(x, self.cursor.y) + + def backspace(self): + """Move cursor to the left one or keep it in it's position if + it's at the beginning of the line already. + """ + self.cursor_back() + + def save_cursor(self): + """Push the current cursor position onto the stack.""" + self.savepoints.append(Savepoint(self.cursor.copy(), + self.g0_charset, + self.g1_charset, + self.charset, + self.use_utf8, + mo.DECOM in self.mode, + mo.DECAWM in self.mode)) + + def restore_cursor(self): + """Set the current cursor position to whatever cursor is on top + of the stack. + """ + if self.savepoints: + savepoint = self.savepoints.pop() + + self.g0_charset = savepoint.g0_charset + self.g1_charset = savepoint.g1_charset + self.charset = savepoint.charset + self.use_utf8 = savepoint.use_utf8 + + if savepoint.origin: + self.set_mode(mo.DECOM) + if savepoint.wrap: + self.set_mode(mo.DECAWM) + + self.cursor = savepoint.cursor + self.cursor_changed.emit(self.cursor) + self.ensure_bounds(use_margins=True) + else: + # If nothing was saved, the cursor moves to home position; + # origin mode is reset. TODO: DECAWM? + self.reset_mode(mo.DECOM) + self.cursor_position() + + def insert_lines(self, count=1): + """Inserts the indicated # of lines at line with cursor. Lines + displayed **at** and below the cursor move down. Lines moved + past the bottom margin are lost. + + :param count: number of lines to delete. + """ + count = count or 1 + top, bottom = self.margins + + # If cursor is outside scrolling margins -- do nothin'. + if top <= self.cursor.y <= bottom: + # v +1, because range() is exclusive. + for line in range(self.cursor.y, + 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.carriage_return() + + def delete_lines(self, count=1): + """Deletes the indicated # of lines, starting at line with + cursor. As lines are deleted, lines displayed below cursor + move up. + + :param int count: number of lines to delete. + """ + count = count or 1 + top, bottom = self.margins + + # If cursor is outside scrolling margins it -- do nothin'. + if top <= self.cursor.y <= bottom: + # v -- +1 to include the bottom margin. + 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.carriage_return() + + def insert_characters(self, count=1): + """Inserts the indicated # of blank characters at the cursor + position. The cursor does not move and remains at the beginning + of the inserted blank characters. Data on the line is shifted + forward. + + :param int count: number of characters to insert. + """ + count = count or 1 + top, bottom = self.margins + + y = self.cursor.y + if top <= y <= bottom: + x = self.cursor.x + num = min(self.columns - x, count) + line = self.linebuf[y] + line.right_shift(x, num) + line.apply_cursor(self.cursor, x, num, clear_char=True) + self.update_cell_range(y, x, self.columns) + + def delete_characters(self, count=1): + """Deletes the indicated # of characters, starting with the + character at cursor position. When a character is deleted, all + characters to the right of cursor move left. Character attributes + move with the characters. + + :param int count: number of characters to delete. + """ + count = count or 1 + top, bottom = self.margins + + y = self.cursor.y + if top <= y <= bottom: + x = self.cursor.x + num = min(self.columns - x, count) + line = self.linebuf[y] + line.left_shift(x, num) + line.apply_cursor(self.cursor, self.columns - num, num, clear_char=True) + + def erase_characters(self, count=None): + """Erases the indicated # of characters, starting with the + character at cursor position. Character attributes are set + cursor attributes. The cursor remains in the same position. + + :param int count: number of characters to erase. + + .. warning:: + + Even though *ALL* of the VTXXX manuals state that character + attributes **should be reset to defaults**, ``libvte``, + ``xterm`` and ``ROTE`` completely ignore this. Same applies + to all ``erase_*()`` and ``delete_*()`` methods. + """ + count = count or 1 + + for column in range(self.cursor.x, + min(self.cursor.x + count, self.columns)): + self.buffer[self.cursor.y][column] = self.cursor.attrs + + def erase_in_line(self, how=0, private=False): + """Erases a line in a specific way. + + :param int how: defines the way the line should be erased in: + + * ``0`` -- Erases from cursor to end of line, including cursor + position. + * ``1`` -- Erases from beginning of line to cursor, + including cursor position. + * ``2`` -- Erases complete line. + :param bool private: when ``True`` character attributes are left + unchanged **not implemented**. + """ + if how == 0: + # a) erase from the cursor to the end of line, including + # the cursor, + interval = range(self.cursor.x, self.columns) + elif how == 1: + # b) erase from the beginning of the line to the cursor, + # including it, + interval = range(self.cursor.x + 1) + elif how == 2: + # c) erase the entire line. + interval = range(self.columns) + + for column in interval: + self.buffer[self.cursor.y][column] = self.cursor.attrs + + def erase_in_display(self, how=0, private=False): + """Erases display in a specific way. + + :param int how: defines the way the line should be erased in: + + * ``0`` -- Erases from cursor to end of screen, including + cursor position. + * ``1`` -- Erases from beginning of screen to cursor, + including cursor position. + * ``2`` -- Erases complete display. All lines are erased + and changed to single-width. Cursor does not move. + :param bool private: when ``True`` character attributes are left + unchanged **not implemented**. + """ + if how == 0: + # a) erase from cursor to the end of the display, including + # the cursor, + interval = range(self.cursor.y + 1, self.lines) + elif how == 1: + # b) erase from the beginning of the display to the cursor, + # including it, + interval = range(self.cursor.y) + elif how == 2: + # c) erase the whole display. + interval = range(self.lines) + + for line in interval: + self.buffer[line][:] = \ + (self.cursor.attrs for _ in range(self.columns)) + + # In case of 0 or 1 we have to erase the line with the cursor. + if how == 0 or how == 1: + self.erase_in_line(how) + + def set_tab_stop(self): + """Sets a horizontal tab stop at cursor position.""" + self.tabstops.add(self.cursor.x) + + def clear_tab_stop(self, how=0): + """Clears a horizontal tab stop. + + :param int how: defines a way the tab stop should be cleared: + + * ``0`` or nothing -- Clears a horizontal tab stop at cursor + position. + * ``3`` -- Clears all horizontal tab stops. + """ + if how == 0: + # Clears a horizontal tab stop at cursor position, if it's + # present, or silently fails if otherwise. + self.tabstops.discard(self.cursor.x) + elif how == 3: + self.tabstops = set() # Clears all horizontal tab stops. + + def ensure_bounds(self, use_margins=False): + """Ensure that current cursor position is within screen bounds. + + :param bool use_margins: when ``True`` or when + :data:`~pyte.modes.DECOM` is set, + cursor is bounded by top and and bottom + margins, instead of ``[0; lines - 1]``. + """ + if use_margins or mo.DECOM in self.mode: + top, bottom = self.margins + else: + top, bottom = 0, self.lines - 1 + + self.cursor.x = max(0, min(self.cursor.x, self.columns - 1)) + self.cursor.y = max(top, min(self.cursor.y, bottom)) + + def cursor_up(self, count=None): + """Moves cursor up the indicated # of lines in same column. + Cursor stops at top margin. + + :param int count: number of lines to skip. + """ + self.cursor.y -= count or 1 + self.ensure_bounds(use_margins=True) + + def cursor_up1(self, count=None): + """Moves cursor up the indicated # of lines to column 1. Cursor + stops at bottom margin. + + :param int count: number of lines to skip. + """ + self.cursor_up(count) + self.carriage_return() + + def cursor_down(self, count=None): + """Moves cursor down the indicated # of lines in same column. + Cursor stops at bottom margin. + + :param int count: number of lines to skip. + """ + self.cursor.y += count or 1 + self.ensure_bounds(use_margins=True) + + def cursor_down1(self, count=None): + """Moves cursor down the indicated # of lines to column 1. + Cursor stops at bottom margin. + + :param int count: number of lines to skip. + """ + self.cursor_down(count) + self.carriage_return() + + def cursor_back(self, count=None): + """Moves cursor left the indicated # of columns. Cursor stops + at left margin. + + :param int count: number of columns to skip. + """ + self.cursor.x -= count or 1 + self.ensure_bounds() + + def cursor_forward(self, count=None): + """Moves cursor right the indicated # of columns. Cursor stops + at right margin. + + :param int count: number of columns to skip. + """ + self.cursor.x += count or 1 + self.ensure_bounds() + + def cursor_position(self, line=None, column=None): + """Set the cursor to a specific `line` and `column`. + + Cursor is allowed to move out of the scrolling region only when + :data:`~pyte.modes.DECOM` is reset, otherwise -- the position + doesn't change. + + :param int line: line number to move the cursor to. + :param int column: column number to move the cursor to. + """ + column = (column or 1) - 1 + line = (line or 1) - 1 + + # If origin mode (DECOM) is set, line number are relative to + # the top scrolling margin. + if mo.DECOM in self.mode: + line += self.margins.top + + # Cursor is not allowed to move out of the scrolling region. + if not self.margins.top <= line <= self.margins.bottom: + return + + self.cursor.x, self.cursor.y = column, line + self.ensure_bounds() + + def cursor_to_column(self, column=None): + """Moves cursor to a specific column in the current line. + + :param int column: column number to move the cursor to. + """ + self.cursor.x = (column or 1) - 1 + self.ensure_bounds() + + def cursor_to_line(self, line=None): + """Moves cursor to a specific line in the current column. + + :param int line: line number to move the cursor to. + """ + self.cursor.y = (line or 1) - 1 + + # If origin mode (DECOM) is set, line number are relative to + # the top scrolling margin. + if mo.DECOM in self.mode: + self.cursor.y += self.margins.top + + # TODO: should we also restrict the cursor to the scrolling + # region? + + self.ensure_bounds() + + def bell(self, *args): + """Bell stub -- the actual implementation should probably be + provided by the end-user. + """ + + def alignment_display(self): + """Fills screen with uppercase E's for screen focus and alignment.""" + for line in self.buffer: + for column, char in enumerate(line): + line[column] = char._replace(data="E") + + def select_graphic_rendition(self, *attrs): + """Set display attributes. + + :param list attrs: a list of display attributes to set. + """ + replace = {} + + if not attrs: + attrs = [0] + else: + attrs = list(reversed(attrs)) + + while attrs: + attr = attrs.pop() + if attr in g.FG_ANSI: + replace["fg"] = g.FG_ANSI[attr] + elif attr in g.BG: + replace["bg"] = g.BG_ANSI[attr] + elif attr in g.TEXT: + attr = g.TEXT[attr] + replace[attr[1:]] = attr.startswith("+") + elif not attr: + replace = self.default_char._asdict() + elif attr in g.FG_AIXTERM: + replace.update(fg=g.FG_AIXTERM[attr], bold=True) + elif attr in g.BG_AIXTERM: + replace.update(bg=g.BG_AIXTERM[attr], bold=True) + elif attr in (g.FG_256, g.BG_256): + key = "fg" if attr == g.FG_256 else "bg" + n = attrs.pop() + try: + if n == 5: # 256. + m = attrs.pop() + replace[key] = g.FG_BG_256[m] + elif n == 2: # 24bit. + # This is somewhat non-standard but is nonetheless + # supported in quite a few terminals. See discussion + # here https://gist.github.com/XVilka/8346728. + replace[key] = "{0:02x}{1:02x}{2:02x}".format( + attrs.pop(), attrs.pop(), attrs.pop()) + except IndexError: + pass + + self.cursor.attrs = self.cursor.attrs._replace(**replace) + + def report_device_attributes(self, mode=0, **kwargs): + """Reports terminal identity. + + .. versionadded:: 0.5.0 + """ + # We only implement "primary" DA which is the only DA request + # VT102 understood, see ``VT102ID`` in ``linux/drivers/tty/vt.c``. + if mode == 0: + self.write_process_input(ctrl.CSI + b"?6c") + + def report_device_status(self, mode): + """Reports terminal status or cursor position. + + :param int mode: if 5 -- terminal status, 6 -- cursor position, + otherwise a noop. + + .. versionadded:: 0.5.0 + """ + if mode == 5: # Request for terminal status. + self.write_process_input(ctrl.CSI + b"0n") + elif mode == 6: # Request for cursor position. + x = self.cursor.x + 1 + y = self.cursor.y + 1 + + # "Origin mode (DECOM) selects line numbering." + if mo.DECOM in self.mode: + y -= self.margins.top + self.write_process_input( + ctrl.CSI + "{0};{1}R".format(y, x).encode()) + + def write_process_input(self, data): + """Writes data to the process running inside the terminal. + + By default is a noop. + + :param bytes data: data to write to the process ``stdin``. + + .. versionadded:: 0.5.0 + """ + + def debug(self, *args, **kwargs): + """Endpoint for unrecognized escape sequences. + + By default is a noop. + """ diff --git a/kitty/term.py b/kitty/term.py index 08fddeeba..c22b14452 100644 --- a/kitty/term.py +++ b/kitty/term.py @@ -2,14 +2,15 @@ # vim:fileencoding=utf-8 # License: GPL v3 Copyright: 2016, Kovid Goyal -from typing import Tuple, Iterator, Union +from typing import Tuple, Iterator, Union, Sequence from PyQt5.QtCore import pyqtSignal, QTimer, QRect from PyQt5.QtGui import QColor, QPainter, QFont, QFontMetrics, QRegion, QPen from PyQt5.QtWidgets import QWidget -from .config import build_ansi_color_tables +from .config import build_ansi_color_tables, Options from .data_types import Line, as_color +from .utils import set_current_font_metrics def ascii_width(fm: QFontMetrics) -> int: @@ -21,11 +22,11 @@ def ascii_width(fm: QFontMetrics) -> int: class TerminalWidget(QWidget): - relayout_lines = pyqtSignal(object, object) + relayout_lines = pyqtSignal(object, object, object, object) cells_per_line = 80 - scroll_amount = 0 + lines_per_screen = 24 - def __init__(self, opts, linebuf, parent=None): + def __init__(self, opts: Options, linebuf: Sequence[Line], parent: QWidget=None): QWidget.__init__(self, parent) self.linebuf = linebuf self.setAutoFillBackground(True) @@ -50,14 +51,15 @@ class TerminalWidget(QWidget): self.font_metrics = fm = QFontMetrics(self.font()) self.cell_height = fm.lineSpacing() self.cell_width = ascii_width(fm) + set_current_font_metrics(fm, self.cell_width) self.baseline_offset = fm.ascent() self.do_layout() def do_layout(self): previous, self.cells_per_line = self.cells_per_line, self.width() // self.cell_width - if previous != self.cells_per_line: - self.relayout_lines.emit(previous, self.cells_per_line) - self.lines_per_screen = self.height() // self.cell_height + previousl, self.lines_per_screen = self.lines_per_screen, self.height() // self.cell_height + if (previous, previousl) != (self.cells_per_line, self.lines_per_screen): + self.relayout_lines.emit(previous, self.cells_per_line, previousl, self.lines_per_screen) self.hmargin = (self.width() - self.cells_per_line * self.cell_width) // 2 self.vmargin = (self.height() % self.cell_height) // 2 self.line_positions = tuple(self.vmargin + i * self.cell_height for i in range(self.lines_per_screen)) @@ -82,10 +84,7 @@ class TerminalWidget(QWidget): def line(self, screen_line: int) -> Union[Line, None]: try: - if self.lines_per_screen > len(self.linebuf): - return self.linebuf[screen_line] - lpos = max(0, len(self.linebuf) - self.lines_per_screen - self.scroll_amount) - return self.linebuf[lpos] + return self.linebuf[screen_line] except IndexError: pass @@ -113,5 +112,5 @@ class TerminalWidget(QWidget): r = QRect(x, y, self.cell_width, self.cell_height) painter.fillRect(r, bg) char = line.char[col] - if char: + if char not in (0, 32): # 32 = painter.drawText(x, y + self.baseline_offset, chr(char)) diff --git a/kitty/utils.py b/kitty/utils.py new file mode 100644 index 000000000..ab8f2b8f5 --- /dev/null +++ b/kitty/utils.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPL v3 Copyright: 2016, Kovid Goyal + +from functools import lru_cache + +from PyQt5.QtGui import QFontMetrics + +current_font_metrics = cell_width = None + + +@lru_cache(maxsize=2**13) +def wcwidth(c: str) -> int: + if current_font_metrics is None: + return 1 + w = current_font_metrics.widthChar(c) + cells, extra = divmod(w, cell_width) + if extra > 0.1 * cell_width: + cells += 1 + return cells + + +def set_current_font_metrics(fm: QFontMetrics, cw: int) -> None: + global current_font_metrics, cell_width + current_font_metrics, cell_width = fm, cw + wcwidth.cache_clear()