Start work on screen implementation

This commit is contained in:
Kovid Goyal 2016-10-16 16:41:10 +05:30
parent 23bc25eb64
commit 0259d88f37
5 changed files with 1021 additions and 40 deletions

View File

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

View File

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

872
kitty/screen.py Normal file
View File

@ -0,0 +1,872 @@
#!/usr/bin/env python
# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
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 \
<http://www.ecma-international.org/publications/standards/Ecma-035.htm>`_
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.
"""

View File

@ -2,14 +2,15 @@
# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
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 = <space>
painter.drawText(x, y + self.baseline_offset, chr(char))

26
kitty/utils.py Normal file
View File

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