diff --git a/kitty/boss.py b/kitty/boss.py new file mode 100644 index 000000000..1c4d95454 --- /dev/null +++ b/kitty/boss.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPL v3 Copyright: 2016, Kovid Goyal + +from collections import deque + +from PyQt5.QtCore import QObject + +from .term import TerminalWidget + +class Boss(QObject): + + def __init__(self, opts, parent=None): + self.linebuf = deque(maxlen=max(1000, opts.scrollback_lines)) + self.term = TerminalWidget(opts, self.linebuf, parent) + self.term.relayout_lines.connect(self.relayout_lines) + + def apply_opts(self, opts): + if opts.scrollback_lines != self.linebuf.maxlen: + self.linebuf, old = deque(maxlen=max(1000, opts.scrollback_lines)), self.linebuf + self.linebuf.extend(old) + self.term.apply_opts(opts) + + def relayout_lines(self): + pass diff --git a/kitty/config.py b/kitty/config.py index 0b06b289b..96f1434a4 100644 --- a/kitty/config.py +++ b/kitty/config.py @@ -4,13 +4,33 @@ import re from collections import namedtuple +from typing import Tuple -from PyQt5.QtGui import QFont, QFontInfo +from PyQt5.QtGui import QFont, QFontInfo, QColor key_pat = re.compile(r'([a-zA-Z][a-zA-Z0-9_-]*)\s+(.+)$') defaults = {} + +def to_qcolor(x): + ans = QColor(x) + if not ans.isValid(): + raise ValueError('{} is not a valid color'.format(x)) + return ans + + +def to_font_size(x): + return max(6, float(x)) + +type_map = { + 'scrollback_lines': int, + 'font_size': to_font_size, +} +for name in 'foreground foreground_bold background cursor'.split(): + type_map[name] = to_qcolor + + for line in ''' term xterm-termite foreground #dddddd @@ -18,7 +38,8 @@ foreground_bold #ffffff cursor #dddddd background #000000 font_family monospace -font_size system +font_size 10.0 +scrollback_lines 10000 # black color0 #000000 @@ -58,12 +79,15 @@ color15 #ffffff m = key_pat.match(line) if m is not None: key, val = m.groups() + tm = type_map.get(key) + if tm is not None: + val = tm(val) defaults[key] = val Options = namedtuple('Defaults', ','.join(defaults.keys())) defaults = Options(**defaults) -def load_config(path): +def load_config(path: str) -> Options: if not path: return defaults ans = defaults._asdict() @@ -80,10 +104,24 @@ def load_config(path): if m is not None: key, val = m.groups() if key in ans: + tm = type_map.get(key) + if tm is not None: + val = tm(val) ans[key] = val return Options(**ans) -def validate_font(opts): +def validate_font(opts: Options): if not QFontInfo(QFont(opts.font_family)).fixedPitch(): raise ValueError('The font specified in the configuration "{}" is not a monospace font'.format(opts.font_family)) + + +def build_ansi_color_tables(opts: Options) -> Tuple[dict, dict]: + fg = {30 + i: getattr(opts, 'color{}'.format(i)) for i in range(8)} + fg[39] = opts.foreground + fg.update({90 + i: getattr(opts, 'color{}'.format(i + 8)) for i in range(8)}) + fg[99] = opts.foreground_bold + bg = {40 + i: getattr(opts, 'color{}'.format(i)) for i in range(8)} + bg[49] = opts.background + bg.update({100 + i: getattr(opts, 'color{}'.format(i + 8)) for i in range(8)}) + return fg, bg diff --git a/kitty/data_types.py b/kitty/data_types.py index 16ece98c3..66bb8642d 100644 --- a/kitty/data_types.py +++ b/kitty/data_types.py @@ -2,7 +2,10 @@ # vim:fileencoding=utf-8 # License: GPL v3 Copyright: 2016, Kovid Goyal +from typing import Tuple, Dict, Union + from numpy import zeros, dtype +from PyQt5.QtGui import QColor color_type = dtype([('type', 'u1'), ('r', 'u1'), ('g', 'u1'), ('b', 'u1')]) @@ -22,3 +25,11 @@ class Line: self.strikethrough = zeros(sz, bool) self.decoration = zeros(sz, 'u1') self.decoration_fg = zeros(sz, color_type) + + +def as_color(entry: Tuple[int, int, int, int], color_table: Dict[int, QColor]) -> Union[QColor, None]: + t, r, g, b = entry + if t == 1: + return color_table.get(r) + if t == 2: + return QColor(r, g, b) diff --git a/kitty/main.py b/kitty/main.py index 57c6a872f..81a050198 100644 --- a/kitty/main.py +++ b/kitty/main.py @@ -14,7 +14,7 @@ from PyQt5.QtWidgets import QApplication, QMainWindow, QMessageBox from .config import load_config, validate_font from .constants import appname, str_version, config_dir -from .term import TerminalWidget +from .boss import Boss class MainWindow(QMainWindow): @@ -24,8 +24,8 @@ class MainWindow(QMainWindow): self.setWindowTitle(appname) sys.excepthook = self.on_unhandled_error self.handle_unix_signals() - self.terminal = TerminalWidget(opts, self) - self.setCentralWidget(self.terminal) + self.boss = Boss(opts, self) + self.setCentralWidget(self.boss.term) def on_unhandled_error(self, etype, value, tb): if etype == KeyboardInterrupt: @@ -35,7 +35,6 @@ class MainWindow(QMainWindow): msg = str(value) except Exception: msg = repr(value) - msg = '

' + msg + '
' + _('Click "Show details" for more information') QMessageBox.critical(self, _('Unhandled exception'), msg) def handle_unix_signals(self): diff --git a/kitty/term.py b/kitty/term.py index 2d28d6a05..3939d5306 100644 --- a/kitty/term.py +++ b/kitty/term.py @@ -2,10 +2,114 @@ # vim:fileencoding=utf-8 # License: GPL v3 Copyright: 2016, Kovid Goyal +from typing import Tuple, Iterator, Union + +from PyQt5.QtCore import pyqtSignal, QTimer, Qt, QRect +from PyQt5.QtGui import QColor, QPainter, QFont, QFontMetrics, QRegion, QPen from PyQt5.QtWidgets import QWidget +from .config import build_ansi_color_tables +from .data_types import Line, as_color + + +def ascii_width(fm: QFontMetrics) -> int: + ans = 0 + for i in range(32, 128): + ans = max(ans, fm.widthChar(chr(i))) + return ans + class TerminalWidget(QWidget): - def __init__(self, opts, parent=None): + relayout_lines = pyqtSignal(object, object) + cells_per_line = 80 + + def __init__(self, opts, linebuf, parent=None): QWidget.__init__(self, parent) + self.linebuf = linebuf + 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) + + def apply_opts(self, opts): + self.opts = opts + 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) + self.ansi_fg, self.ansi_bg = build_ansi_color_tables(opts) + 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) + 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 + 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.layout_size = self.size() + self.update() + + def resizeEvent(self, ev): + self.debounce_resize_timer.start() + + def dirty_lines(self, region: QRegion) -> Iterator[Tuple[int, QRegion]]: + w = self.width() - 2 * self.hmargin + for i, y in enumerate(self.line_positions): + ir = region.intersected(QRect(self.hmargin, y, w, self.cell_height)) + if not ir.isEmpty(): + yield i, ir + + def dirty_cells(self, y: int, line_region: QRegion) -> Iterator[int]: + for i, x in enumerate(self.cell_positions): + if line_region.intersects(QRect(x, y, self.cell_width, self.cell_height)): + yield i + + def line(self, screen_line: int) -> Union[Line, None]: + try: + lpos = len(self.linebuf) - self.lines_per_screen + return self.linebuf[lpos] + except IndexError: + pass + + def paintEvent(self, ev): + if self.size() != self.layout_size: + return + r = ev.region() + p = QPainter(self) + for lnum, line_region in self.dirty_lines(r): + line = self.line(lnum) + if line is not None: + ypos = self.line_positions[lnum] + for cnum in self.dirty_cells(ypos, line_region): + p.save() + self.paint_cell(p, line, cnum, ypos) + p.restore() + + def paint_cell(self, painter: QPainter, line: Line, col: int, y: int) -> None: + char = line.char[col] + if not char: + return + x = self.cell_positions[col] + r = QRect(x, y, self.cell_width, self.cell_height) + t, r, g, b = line.fg[col] + fg = as_color(line.fg[col], self.ansi_fg) + if fg is not None: + painter.setPen(QPen(fg)) + bg = as_color(line.bg[col], self.ansi_bg) + if bg is not None: + painter.fillRect(r, bg) + painter.drawText(r, Qt.AlignHCenter | Qt.AlignBaseline | Qt.TextSingleLine, char)