From 2153e56020c8ab9bd5483de98f85668b5752fa23 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 21 Oct 2016 17:19:33 +0530 Subject: [PATCH] Render to an offscreen buffer That way we can move to threaded rendering if needed for improved performance --- kitty/render.py | 274 ++++++++++++++++++++++++++++++++++++++++++++++++ kitty/term.py | 254 +++++--------------------------------------- 2 files changed, 300 insertions(+), 228 deletions(-) create mode 100644 kitty/render.py diff --git a/kitty/render.py b/kitty/render.py new file mode 100644 index 000000000..132a70d02 --- /dev/null +++ b/kitty/render.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPL v3 Copyright: 2016, Kovid Goyal + +from functools import lru_cache +from collections import Counter, deque, defaultdict +from itertools import chain + +from PyQt5.QtCore import QObject, pyqtSignal, Qt, QTimer, QRect +from PyQt5.QtGui import QPixmap, QRegion, QPainter, QPen, QColor, QFontMetrics, QFont + +from .config import build_ansi_color_tables, fg_color_table, bg_color_table +from .data_types import Cursor, COL_SHIFT, COL_MASK, as_color +from .screen import wrap_cursor_position +from .tracker import merge_ranges +from .utils import set_current_font_metrics + + +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 Renderer(QObject): + + update_required = pyqtSignal() + relayout_lines = pyqtSignal(object, object) + cells_per_line = 80 + lines_per_screen = 24 + last_painted_cursor_at = 0, 0 + _has_focus = True + + def __init__(self, screen, dpix, dpiy, parent=None): + QObject.__init__(self, parent) + self.dpix, self.dpiy = dpix, dpiy + self.screen = screen + screen.change_default_color.connect(self.change_default_color) + self.bufpix = QPixmap(10, 10) + self.pending_changes = deque() + self.debounce_update_timer = t = QTimer(self) + t.setSingleShot(True) + t.setInterval(20) + t.timeout.connect(self.do_render) + self.cursor = Cursor() + + def apply_opts(self, opts): + pixmap_for_text.cache_clear() + build_ansi_color_tables(opts) + self.opts = opts + self.default_bg = self.original_bg = QColor(opts.background) + self.default_fg = self.original_fg = QColor(opts.foreground).getRgb()[:3] + self.current_font = f = QFont(opts.font_family) + f.setPointSizeF(opts.font_size) + self.font_metrics = fm = QFontMetrics(f) + 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) + + def resize(self, size): + self.bufpix = QPixmap(size) + self.bufpix.fill(self.default_bg) + previous, self.cells_per_line = self.cells_per_line, size.width() // self.cell_width + previousl, self.lines_per_screen = self.lines_per_screen, size.height() // self.cell_height + self.hmargin = (size.width() - self.cells_per_line * self.cell_width) // 2 + self.vmargin = (size.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 + 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.dirtied() + + def dirtied(self): + self.update_screen({'screen': True}) + + def size(self): + return self.bufpix.size() + + def render(self, painter): + painter.drawPixmap(0, 0, self.bufpix) + + def change_default_color(self, which, val): + if which in ('fg', 'bg'): + if not val: + setattr(self, 'default_' + which, getattr(self, 'original_' + which)) + self.dirtied() + else: + val = QColor(val) + if val.isValid(): + if which == 'fg': + self.default_fg = val.getRgb()[:3] + else: + self.default_bg = val + self.dirtied() + + def update_screen(self, changes): + self.pending_changes.append(changes) + if not self.debounce_update_timer.isActive(): + self.debounce_update_timer.start() + + def wrap_cursor_pos(self): + self.cursorx, self.cursory = wrap_cursor_position(self.cursor.x, self.cursor.y, self.lines_per_screen, self.cells_per_line) + + def set_has_focus(self, yes): + if yes != self._has_focus: + self._has_focus = yes + self.wrap_cursor_pos() + self.update_screen({'screen': False, 'lines': set(), 'cells': {self.cursory: {(self.cursorx, self.cursorx)}}}) + + def line(self, lnum): + return self.screen.line(lnum) + + def common_bg_color(self): + c = Counter() + for rdiv in range(1, 4): + lnum = int(self.lines_per_screen * rdiv / 4) + for cdiv in range(1, 4): + cnum = int(self.cells_per_line * cdiv / 4) + bgcol = self.line(lnum).bgcolor(cnum) + c[bgcol] += 1 + return c.most_common(1)[0][0] + + def do_render(self): + dirty_lines = set() + dirty_cell_ranges = defaultdict(set) + screen_dirtied = False + + while self.pending_changes: + c = self.pending_changes.popleft() + self.cursor = c.get('cursor') or self.cursor + if not screen_dirtied: + if c['screen']: + screen_dirtied = True + continue + dirty_lines |= c['lines'] + for l, ranges in c['cells'].items(): + if l not in dirty_lines: + for r in ranges: + dirty_cell_ranges[l].add(r) + + if screen_dirtied: + dirty_cell_ranges = {} + dirty_lines = tuple(range(self.lines_per_screen)) + else: + dirty_cell_ranges = {l: tuple(merge_ranges(r)) for l, r in dirty_cell_ranges.items() if l not in dirty_lines} + + self.paint(dirty_lines, dirty_cell_ranges) + self.update_required.emit() + + def calculate_dirty_region(self, dirty_lines, dirty_cell_ranges): + ans = QRegion() + for lnum in dirty_lines: + ans += QRect(self.hmargin, self.line_positions[lnum], self.cell_width * self.cells_per_line, self.cell_height) + for lnum, ranges in dirty_cell_ranges.items(): + y = self.line_positions[lnum] + for start, stop in ranges: + for cnum in range(start, stop + 1): + ans += QRect(self.cell_positions[cnum], y, self.cell_width, self.cell_height) + return ans + + def paint(self, dirty_lines, dirty_cell_ranges): + self.current_bgcol = self.common_bg_color() + bg = self.default_bg + if self.current_bgcol & 0xff: + cbg = as_color(self.current_bgcol, bg_color_table()) + if cbg: + bg = QColor(*cbg) + self.wrap_cursor_pos() + self.cursor_painted = False + self.old_cursorx, self.old_cursory = self.last_painted_cursor_at + self.cursor_moved = self.old_cursorx != self.cursorx or self.old_cursory != self.cursory + region = self.calculate_dirty_region(dirty_lines, dirty_cell_ranges) + if self.cursor_moved: + r = QRect(self.cell_positions[self.old_cursorx], self.line_positions[self.old_cursory], self.cell_width, self.cell_height) + if region.contains(r): + self.cursor_moved = False + else: + region += r + + p = QPainter(self.bufpix) + p.save() + p.setClipRegion(region) + p.fillRect(self.bufpix.rect(), bg) + p.restore() + + for lnum in dirty_lines: + self.paint_line(p, lnum, range(self.cells_per_line)) + + for lnum, ranges in dirty_cell_ranges.items(): + self.paint_line(p, lnum, chain.from_iterable(range(start, stop + 1) for start, stop in ranges)) + + if not self.cursor_painted: + self.paint_cell(p, self.line(self.cursory), self.cursory, self.cursorx) + + if self.cursor_moved: + self.paint_cell(p, self.line(self.old_cursory), self.old_cursory, self.old_cursorx) + + p.end() + + def paint_line(self, painter, lnum, cell_range): + line = self.line(lnum) + for cnum in cell_range: + self.paint_cell(painter, line, lnum, cnum) + + def paint_cell(self, painter, line, lnum, cnum): + paint_cursor = False + if not self.cursor_painted: + self.cursor_painted = paint_cursor = lnum == self.cursory and cnum == self.cursorx + ch, attrs, colors = line.basic_cell_data(cnum) + x, y = self.cell_positions[cnum], self.line_positions[lnum] + bgcol = colors >> COL_SHIFT + if bgcol != self.current_bgcol: + 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 paint_cursor: + self.paint_cursor(painter, cnum, lnum) + if ch == 0 or ch == 32: + # An empty cell + pass + else: + text = chr(ch) + line.combining_chars.get(cnum, '') + p = pixmap_for_text(text, colors, self.default_fg, self.current_font, self.cell_width * 2, self.cell_height, self.baseline_offset) + painter.drawPixmap(x, y, p) + + def paint_cursor(self, painter, x, y): + self.last_painted_cursor_at = x, y + r = QRect(self.cell_positions[x], self.line_positions[y], self.cell_width, self.cell_height) + cc = self.cursor_color + if self.cursor.color: + q = QColor(self.cursor.color) + if q.isValid(): + cc = q + cc.setAlphaF(self.opts.cursor_opacity) + + def width(w=2, vert=True): + dpi = self.dpix if vert else self.dpiy + return int(w * dpi / 72) + + if self._has_focus: + cs = self.cursor.shape or self.opts.cursor_shape + if cs == 'block': + painter.fillRect(r, cc) + elif cs == 'beam': + w = width(1.5) + painter.fillRect(r.left(), r.top(), w, self.cell_height, cc) + elif cs == 'underline': + y = r.top() + self.font_metrics.underlinePos() + self.baseline_offset + w = width(vert=False) + painter.fillRect(r.left(), min(y, r.bottom() - w), self.cell_width, w, cc) + else: + painter.setPen(QPen(cc)) + painter.drawRect(r) diff --git a/kitty/term.py b/kitty/term.py index 05caff858..96084a4e4 100644 --- a/kitty/term.py +++ b/kitty/term.py @@ -2,47 +2,20 @@ # vim:fileencoding=utf-8 # License: GPL v3 Copyright: 2016, Kovid Goyal -from functools import lru_cache -from itertools import product -from collections import Counter -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.QtCore import pyqtSignal, QTimer, Qt +from PyQt5.QtGui import QPainter 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, COL_SHIFT, COL_MASK, as_color -from .utils import set_current_font_metrics +from .config import Options from .tracker import ChangeTracker -from .screen import wrap_cursor_position from .keys import key_event_to_data from .screen import Screen +from .render import Renderer 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) @@ -50,230 +23,53 @@ class TerminalWidget(QWidget): 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.setAttribute(Qt.WA_OpaquePaintEvent, True) self.setAutoFillBackground(False) - 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 change_default_color'.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.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() + + self.tracker = ChangeTracker(self) + 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.renderer = Renderer(self.screen, self.logicalDpiX(), self.logicalDpiY(), self) + self.tracker.dirtied.connect(self.renderer.update_screen) + self.renderer.update_required.connect(self.update_required) + self.renderer.relayout_lines.connect(self.relayout_lines) + self.apply_opts(opts) + + def update_required(self): + self.update() 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.default_bg = self.original_bg = pal.color(pal.Window) - self.default_fg = self.original_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.renderer.apply_opts(opts) self.do_layout() - def change_default_color(self, which, val): - if which in ('fg', 'bg'): - if not val: - setattr(self, 'default_' + which, getattr(self, 'original_' + which)) - self.update() - else: - val = QColor(val) - if val.isValid(): - if which == 'fg': - self.default_fg = val.getRgb()[:3] - else: - self.default_bg = val - self.update() - 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.renderer.resize(self.size()) 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 common_bg_color(self): - c = Counter() - for rdiv in range(1, 4): - lnum = int(self.lines_per_screen * rdiv / 4) - for cdiv in range(1, 4): - cnum = int(self.cells_per_line * cdiv / 4) - bgcol = self.screen.line(lnum).bgcolor(cnum) - c[bgcol] += 1 - return c.most_common(1)[0][0] - def paintEvent(self, ev): - if self.size() != self.layout_size: + if self.size() != self.renderer.size(): return - r = ev.region() - self.current_bgcol = self.common_bg_color() - bg = self.default_bg - if self.current_bgcol & 0xff: - cbg = as_color(self.current_bgcol, bg_color_table()) - if cbg: - bg = QColor(*cbg) - p = QPainter(self) - p.fillRect(self.rect(), bg) - - for lnum, cnum in self.dirty_cells(r): - try: - self.paint_cell(p, cnum, lnum) - except Exception: - pass - if not self.cursor.hidden: - x, y = wrap_cursor_position(self.cursor.x, self.cursor.y, len(self.line_positions), len(self.cell_positions)) - cr = QRect(self.cell_positions[x], self.line_positions[y], self.cell_width, self.cell_height) - if r.intersects(cr): - self.paint_cell(p, x, y, True) + self.renderer.render(p) p.end() - def paint_cursor(self, painter, x, y): - r = QRect(self.cell_positions[x], self.line_positions[y], self.cell_width, self.cell_height) - self.last_drew_cursor_at = x, y - cc = self.cursor_color - if self.cursor.color: - q = QColor(self.cursor.color) - if q.isValid(): - cc = q - cc.setAlphaF(self.opts.cursor_opacity) - - def width(w=2, vert=True): - dpi = self.logicalDpiX() if vert else self.logicalDpiY() - return int(w * dpi / 72) - - if self.hasFocus(): - cs = self.cursor.shape or self.opts.cursor_shape - if cs == 'block': - painter.fillRect(r, cc) - elif cs == 'beam': - w = width(1.5) - painter.fillRect(r.left(), r.top(), w, self.cell_height, cc) - elif cs == 'underline': - y = r.top() + self.font_metrics.underlinePos() + self.baseline_offset - w = width(vert=False) - painter.fillRect(r.left(), min(y, r.bottom() - w), self.cell_width, w, cc) - else: - painter.setPen(QPen(cc)) - painter.drawRect(r) - - def paint_cell(self, painter: QPainter, col: int, row: int, draw_cursor: bool=False) -> None: - line = self.screen.line(row) - ch, attrs, colors = line.basic_cell_data(col) - x, y = self.cell_positions[col], self.line_positions[row] - bgcol = colors >> COL_SHIFT - if bgcol != self.current_bgcol: - 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 draw_cursor: - self.paint_cursor(painter, col, row) - 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.default_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: @@ -303,9 +99,11 @@ class TerminalWidget(QWidget): def focusInEvent(self, ev): if self.screen.enable_focus_tracking: self.send_data_to_child.emit(b'\x1b[I') + self.renderer.set_has_focus(True) return QWidget.focusInEvent(self, ev) def focusOutEvent(self, ev): if self.screen.enable_focus_tracking: self.send_data_to_child.emit(b'\x1b[O') + self.renderer.set_has_focus(False) return QWidget.focusOutEvent(self, ev)