kitty/kitty/term.py

256 lines
11 KiB
Python

#!/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 itertools import product
from typing import Tuple, Iterator
from PyQt5.QtCore import pyqtSignal, QTimer, QRect, Qt
from PyQt5.QtGui import QColor, QPainter, QFont, QFontMetrics, QRegion, QPen, QPixmap
from PyQt5.QtWidgets import QWidget, QApplication
from .config import build_ansi_color_tables, Options, fg_color_table, bg_color_table
from .data_types import Cursor, HAS_BG_MASK, COL_SHIFT, COL_MASK, as_color
from .utils import set_current_font_metrics
from .tracker import ChangeTracker
from .screen import wrap_cursor_position
from .keys import key_event_to_data
from .screen import Screen
from pyte.streams import Stream, DebugStream
from pyte import modes as mo
def ascii_width(fm: QFontMetrics) -> int:
ans = 0
for i in range(32, 128):
ans = max(ans, fm.widthChar(chr(i)))
return ans
@lru_cache(maxsize=2**11)
def pixmap_for_text(text, color, default_fg, font, w, h, baseline):
p = QPixmap(w, h)
p.fill(Qt.transparent)
fg = as_color(color & COL_MASK, fg_color_table()) or default_fg
painter = QPainter(p)
painter.setFont(font)
painter.setPen(QPen(QColor(*fg)))
painter.setRenderHints(QPainter.TextAntialiasing | QPainter.Antialiasing)
painter.drawText(0, baseline, text)
painter.end()
return p
class TerminalWidget(QWidget):
relayout_lines = pyqtSignal(object, object)
write_to_child = pyqtSignal(object)
title_changed = pyqtSignal(object)
icon_changed = pyqtSignal(object)
send_data_to_child = pyqtSignal(object)
cells_per_line = 80
lines_per_screen = 24
def __init__(self, opts: Options, parent: QWidget=None, dump_commands: bool=False):
QWidget.__init__(self, parent)
self.cursor = Cursor()
self.tracker = ChangeTracker(self)
self.tracker.dirtied.connect(self.update_screen)
sclass = DebugStream if dump_commands else Stream
self.screen = Screen(opts, self.tracker, parent=self)
for s in 'write_to_child title_changed icon_changed'.split():
getattr(self.screen, s).connect(getattr(self, s))
self.stream = sclass(self.screen)
self.feed = self.stream.feed
self.last_drew_cursor_at = (0, 0)
self.setFocusPolicy(Qt.WheelFocus)
self.setAutoFillBackground(True)
self.apply_opts(opts)
self.debounce_resize_timer = t = QTimer(self)
t.setSingleShot(True)
t.setInterval(50)
t.timeout.connect(self.do_layout)
self.debounce_update_timer = t = QTimer(self)
t.setSingleShot(True)
t.setInterval(20)
t.timeout.connect(self.do_update_screen)
self.pending_update = QRegion()
def apply_opts(self, opts):
self.screen.apply_opts(opts)
self.opts = opts
pixmap_for_text.cache_clear()
pal = self.palette()
pal.setColor(pal.Window, QColor(opts.background))
pal.setColor(pal.WindowText, QColor(opts.foreground))
self.setPalette(pal)
self.current_bg = pal.color(pal.Window)
self.current_fg = pal.color(pal.WindowText).getRgb()[:3]
build_ansi_color_tables(opts)
self.current_font = f = QFont(opts.font_family)
f.setPointSizeF(opts.font_size)
self.setFont(f)
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.cursor_color = c = QColor(opts.cursor)
c.setAlphaF(opts.cursor_opacity)
self.do_layout()
def do_layout(self):
previous, self.cells_per_line = self.cells_per_line, self.width() // self.cell_width
previousl, self.lines_per_screen = self.lines_per_screen, self.height() // self.cell_height
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))
self.cell_positions = tuple(self.hmargin + i * self.cell_width for i in range(self.cells_per_line))
self.line_width = self.cells_per_line * self.cell_width
self.layout_size = self.size()
if (previous, previousl) != (self.cells_per_line, self.lines_per_screen):
self.screen.resize(self.lines_per_screen, self.cells_per_line)
self.relayout_lines.emit(self.cells_per_line, self.lines_per_screen)
self.update()
def resizeEvent(self, ev):
self.debounce_resize_timer.start()
def update_screen(self, changes):
self.cursor = changes['cursor'] or self.cursor
if changes['screen']:
self.pending_update += self.rect()
else:
cell_positions, line_positions, cell_width, cell_height = self.cell_positions, self.line_positions, self.cell_width, self.cell_height
old_x, old_y = self.last_drew_cursor_at
rects = []
for lnum in changes['lines']:
try:
rects.append(QRect(cell_positions[0], line_positions[lnum], self.line_width, cell_height))
except IndexError:
continue
old_cursor_added = old_y in changes['lines']
cursor_added = self.cursor.y in changes['lines']
for lnum, ranges in changes['cells'].items():
for start, stop in ranges:
try:
rects.append(QRect(cell_positions[start], line_positions[lnum], cell_width * (stop - start + 1), cell_height))
except IndexError:
continue
if not old_cursor_added and old_y == lnum and (start <= old_x <= stop):
old_cursor_added = True
if not cursor_added and self.cursor.y == lnum and (start <= self.cursor.x <= stop):
cursor_added = True
rects.sort(key=lambda r: (r.y(), r.x()))
for r in rects:
self.pending_update += r
if not cursor_added:
try:
self.pending_update += QRect(cell_positions[self.cursor.x], line_positions[self.cursor.y], cell_width, cell_height)
except IndexError:
pass
if self.cursor.y == old_y and self.cursor.x == old_x:
old_cursor_added = True
if not old_cursor_added:
try:
self.pending_update += QRect(cell_positions[old_x], line_positions[old_y], cell_width, cell_height)
except IndexError:
pass
if not self.debounce_update_timer.isActive():
self.debounce_update_timer.start()
def do_update_screen(self):
if not self.pending_update.isEmpty():
self.update(self.pending_update)
self.pending_update = QRegion()
def dirty_cells(self, region: QRegion) -> Iterator[Tuple[int]]:
lines = (l for l in range(self.lines_per_screen) if region.intersects(QRect(
self.hmargin, self.line_positions[l], self.cell_width * self.cells_per_line, self.cell_height)))
cells = (c for c in range(self.cells_per_line) if region.intersects(QRect(
self.cell_positions[c], self.vmargin, self.cell_width, self.cell_height * self.lines_per_screen)))
return product(lines, cells)
def paintEvent(self, ev):
if self.size() != self.layout_size:
return
r = ev.region()
p = QPainter(self)
try:
self.paint_cursor(p, r)
except Exception:
import traceback
traceback.print_exc()
for lnum, cnum in self.dirty_cells(r):
try:
self.paint_cell(p, cnum, lnum)
except Exception:
pass
def paint_cursor(self, painter, region):
if self.cursor.hidden:
return
x, y = wrap_cursor_position(self.cursor.x, self.cursor.y, len(self.line_positions), len(self.cell_positions))
r = QRect(self.cell_positions[x], self.line_positions[y], self.cell_width, self.cell_height)
if not region.intersects(r):
return
self.last_drew_cursor_at = x, y
line = self.screen.line(y)
colors = line.basic_cell_data(y)[2]
if colors & HAS_BG_MASK:
bg = as_color(colors >> COL_SHIFT, bg_color_table())
if bg is not None:
painter.fillRect(r, QColor(*bg))
if self.hasFocus():
painter.fillRect(r, self.cursor_color)
else:
painter.setPen(QPen(self.cursor_color))
painter.drawRect(r)
def paint_cell(self, painter: QPainter, col: int, row: int) -> None:
line = self.screen.line(row)
ch, attrs, colors = line.basic_cell_data(col)
x, y = self.cell_positions[col], self.line_positions[row]
if colors & HAS_BG_MASK and (col != self.last_drew_cursor_at[0] or row != self.last_drew_cursor_at[1]):
bg = as_color(colors >> COL_SHIFT, bg_color_table())
if bg is not None:
r = QRect(x, y, self.cell_width, self.cell_height)
painter.fillRect(r, QColor(*bg))
if ch == 0 or ch == 32:
# An empty cell
pass
else:
text = chr(ch) + line.combining_chars.get(col, '')
p = pixmap_for_text(text, colors, self.current_fg, self.current_font, self.cell_width * 2, self.cell_height, self.baseline_offset)
painter.drawPixmap(x, y, p)
def keyPressEvent(self, ev):
mods = ev.modifiers()
if mods & Qt.ControlModifier and mods & Qt.ShiftModifier:
ev.accept()
return # Terminal shortcuts
data = key_event_to_data(ev, mods)
if data:
self.send_data_to_child.emit(data)
ev.accept()
return
return QWidget.keyPressEvent(self, ev)
def mousePressEvent(self, ev):
if ev.button() == Qt.MiddleButton:
c = QApplication.clipboard()
if c.supportsSelection():
text = c.text(c.Selection)
if text:
text = text.encode('utf-8')
if self.screen.in_bracketed_paste_mode():
text = mo.BRACKETED_PASTE_START + text + mo.BRACKETED_PASTE_END
self.send_data_to_child.emit(text)
ev.accept()
return
return QWidget.mousePressEvent(self, ev)