Фрагменты в QScintilla - PullRequest
       43

Фрагменты в QScintilla

13 голосов
/ 20 апреля 2019

Рассмотрим этот mcve:

import math
import sys
import textwrap
import time
from pathlib import Path
from collections import defaultdict

from PyQt5.Qsci import QsciLexerCustom, QsciScintilla
from PyQt5.Qt import *

from pygments import lexers, styles, highlight, formatters
from pygments.lexer import Error, RegexLexer, Text, _TokenType
from pygments.style import Style


EXTRA_STYLES = {
    "monokai": {
        "background": "#272822",
        "caret": "#F8F8F0",
        "foreground": "#F8F8F2",
        "invisibles": "#F8F8F259",
        "lineHighlight": "#3E3D32",
        "selection": "#49483E",
        "findHighlight": "#FFE792",
        "findHighlightForeground": "#000000",
        "selectionBorder": "#222218",
        "activeGuide": "#9D550FB0",
        "misspelling": "#F92672",
        "bracketsForeground": "#F8F8F2A5",
        "bracketsOptions": "underline",
        "bracketContentsForeground": "#F8F8F2A5",
        "bracketContentsOptions": "underline",
        "tagsOptions": "stippled_underline",
    }
}


def convert_size(size_bytes):
    if size_bytes == 0:
        return "0B"
    size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
    i = int(math.floor(math.log(size_bytes, 1024)))
    p = math.pow(1024, i)
    s = round(size_bytes / p, 2)
    return f"{s} {size_name[i]}"


class ViewLexer(QsciLexerCustom):

    def __init__(self, lexer_name, style_name):
        super().__init__()

        # Lexer + Style
        self.pyg_style = styles.get_style_by_name(style_name)
        self.pyg_lexer = lexers.get_lexer_by_name(lexer_name, stripnl=False)
        self.cache = {
            0: ('root',)
        }
        self.extra_style = EXTRA_STYLES[style_name]

        # Generate QScintilla styles
        self.font = QFont("Consolas", 8, weight=QFont.Bold)
        self.token_styles = {}
        index = 0
        for k, v in self.pyg_style:
            self.token_styles[k] = index
            if v.get("color", None):
                self.setColor(QColor(f"#{v['color']}"), index)
            if v.get("bgcolor", None):
                self.setPaper(QColor(f"#{v['bgcolor']}"), index)

            self.setFont(self.font, index)
            index += 1

    def defaultPaper(self, style):
        return QColor(self.extra_style["background"])

    def language(self):
        return self.pyg_lexer.name

    def get_tokens_unprocessed(self, text, stack=('root',)):
        """
        Split ``text`` into (tokentype, text) pairs.

        ``stack`` is the inital stack (default: ``['root']``)
        """
        lexer = self.pyg_lexer
        pos = 0
        tokendefs = lexer._tokens
        statestack = list(stack)
        statetokens = tokendefs[statestack[-1]]
        while 1:
            for rexmatch, action, new_state in statetokens:
                m = rexmatch(text, pos)
                if m:
                    if action is not None:
                        if type(action) is _TokenType:
                            yield pos, action, m.group()
                        else:
                            for item in action(lexer, m):
                                yield item
                    pos = m.end()
                    if new_state is not None:
                        # state transition
                        if isinstance(new_state, tuple):
                            for state in new_state:
                                if state == '#pop':
                                    statestack.pop()
                                elif state == '#push':
                                    statestack.append(statestack[-1])
                                else:
                                    statestack.append(state)
                        elif isinstance(new_state, int):
                            # pop
                            del statestack[new_state:]
                        elif new_state == '#push':
                            statestack.append(statestack[-1])
                        else:
                            assert False, "wrong state def: %r" % new_state
                        statetokens = tokendefs[statestack[-1]]
                    break
            else:
                # We are here only if all state tokens have been considered
                # and there was not a match on any of them.
                try:
                    if text[pos] == '\n':
                        # at EOL, reset state to "root"
                        statestack = ['root']
                        statetokens = tokendefs['root']
                        yield pos, Text, u'\n'
                        pos += 1
                        continue
                    yield pos, Error, text[pos]
                    pos += 1
                except IndexError:
                    break

    def highlight_slow(self, start, end):
        style = self.pyg_style
        view = self.editor()
        code = view.text()[start:]
        tokensource = self.get_tokens_unprocessed(code)

        self.startStyling(start)
        for _, ttype, value in tokensource:
            self.setStyling(len(value), self.token_styles[ttype])

    def styleText(self, start, end):
        view = self.editor()
        t_start = time.time()
        self.highlight_slow(start, end)
        t_elapsed = time.time() - t_start
        len_text = len(view.text())
        text_size = convert_size(len_text)
        view.setWindowTitle(f"Text size: {len_text} - {text_size} Elapsed: {t_elapsed}s")

    def description(self, style_nr):
        return str(style_nr)


class View(QsciScintilla):

    def __init__(self, lexer_name, style_name):
        super().__init__()
        view = self

        # -------- Lexer --------
        self.setEolMode(QsciScintilla.EolUnix)
        self.lexer = ViewLexer(lexer_name, style_name)
        self.setLexer(self.lexer)

        # -------- Shortcuts --------
        self.text_size = 1
        self.s1 = QShortcut(f"ctrl+1", view, self.reduce_text_size)
        self.s2 = QShortcut(f"ctrl+2", view, self.increase_text_size)
        # self.gen_text()

        # # -------- Multiselection --------
        self.SendScintilla(view.SCI_SETMULTIPLESELECTION, True)
        self.SendScintilla(view.SCI_SETMULTIPASTE, 1)
        self.SendScintilla(view.SCI_SETADDITIONALSELECTIONTYPING, True)

        # -------- Extra settings --------
        self.set_extra_settings(EXTRA_STYLES[style_name])

    def get_line_separator(self):
        m = self.eolMode()
        if m == QsciScintilla.EolWindows:
            eol = '\r\n'
        elif m == QsciScintilla.EolUnix:
            eol = '\n'
        elif m == QsciScintilla.EolMac:
            eol = '\r'
        else:
            eol = ''
        return eol

    def set_extra_settings(self, dct):
        self.setIndentationGuidesBackgroundColor(QColor(0, 0, 255, 0))
        self.setIndentationGuidesForegroundColor(QColor(0, 255, 0, 0))

        if "caret" in dct:
            self.setCaretForegroundColor(QColor(dct["caret"]))

        if "line_highlight" in dct:
            self.setCaretLineBackgroundColor(QColor(dct["line_highlight"]))

        if "brackets_background" in dct:
            self.setMatchedBraceBackgroundColor(QColor(dct["brackets_background"]))

        if "brackets_foreground" in dct:
            self.setMatchedBraceForegroundColor(QColor(dct["brackets_foreground"]))

        if "selection" in dct:
            self.setSelectionBackgroundColor(QColor(dct["selection"]))

        if "background" in dct:
            c = QColor(dct["background"])
            self.resetFoldMarginColors()
            self.setFoldMarginColors(c, c)

    def increase_text_size(self):
        self.text_size *= 2
        self.gen_text()

    def reduce_text_size(self):
        if self.text_size == 1:
            return
        self.text_size //= 2
        self.gen_text()

    def gen_text(self):
        content = Path(__file__).read_text()
        while len(content) < self.text_size:
            content *= 2
        self.setText(content[:self.text_size])


if __name__ == '__main__':
    app = QApplication(sys.argv)
    view = View("python", "monokai")
    view.setText(textwrap.dedent("""\
        '''
        Ctrl+1 = You'll decrease the size of existing text
        Ctrl+2 = You'll increase the size of existing text

        Warning: Check the window title to see how long it takes rehighlighting
        '''
    """))
    view.resize(800, 600)
    view.show()
    app.exec_()

Для запуска необходимо установить:

QScintilla==2.10.8
Pygments==2.3.1
PyQt5==5.12

Я пытаюсь понять, как использовать фрагменты в виджете QScintilla, и сейчас основная проблема, которую мне нужно решить, - это производительность при работе с немалыми документами.

Мне бы хотелось, чтобы редактор стал отзывчивым и пригодным для использования при работе с большими документами (> = 100 КБ), но я не очень хорошо знаю, какой подход я должен использовать здесь. Для тестирования производительности вы можете использовать Ctrl + 1 или Ctrl + 2 , и текст виджета будет соответственно уменьшен / увеличен.

Когда я говорю «отзывчивый», я имею в виду, что вычисление подсветки видимого экрана больше не должно занимать [1-2] frame / highglight <=> [17-34] мс / highlight ( при скорости 60 кадров в секунду), поэтому при наборе текста вы не почувствуете замедления.

Примечание: Как вы можете видеть в приведенном выше mcve, я включил токенизатор pygments, чтобы вы могли поэкспериментировать с ним ... такое ощущение, что для достижения "выделения в реальном времени" мне нужно было бы использовать Запоминание / кэширование каким-то умным способом, но я изо всех сил пытаюсь выяснить, какие данные мне нужно кэшировать и как лучше всего их кэшировать ...: /

Демо-версия:

enter image description here

В вышеприведенной демонстрации, которую вы можете увидеть, используя эту наивную подсветку, редактор станет непригодным для использования очень скоро, на моем ноутбуке подсветка фрагментов текста размером 32 КБ по-прежнему дает интерактивную частоту кадров, но с чем-то большим, чем этот редактор становится совершенно непригодным для использования.

ОТХОДОВ:

  • Наиболее типичный случай происходит, когда вы печатаете / кодируете на видимом экране без выбора
  • Может случиться, что вы редактируете несколько выборок, разбросанных по всему документу, что означает, что вы не будете знать, находятся ли эти выборки рядом с видимым экраном или нет. Например, в Sublime при нажатии Alt+F3 вы выбираете все вхождения под курсором
  • В приведенном выше фрагменте кода я использовал лексер python, но алгоритм не должен слишком фокусироваться на этом. Поддержка фрагментов ~ 300 лексеров в конце
  • Наихудший сценарий произошел бы, если видимый экран находится в конце файла, и один из выбранных вариантов происходит в начале экрана ... В случае, если вам нужно заново выделить весь документ, который вам нужен найти альтернативный способ, даже если это означает, что «выделение» некорректно при первом проходе
  • Самое важное - это производительность, но также и корректность ... то есть, если вы уделите достаточно времени, весь документ должен быть выделен правильно

СПИСОК ЛИТЕРАТУРЫ:

Следующие документы не относятся конкретно к этой конкретной проблеме, но в них говорится о возможных стратегиях кэширования и выделения синтаксиса:

Ответы [ 2 ]

19 голосов
/ 26 апреля 2019

В highlight_slow вы получаете значения start и end, но игнорируете конечное значение. В результате, каждый раз, когда вы набираете один символ, код повторно выделяет весь остаток буфера. Вот почему, если вы печатаете в конце длинного буфера, время очень быстрое - около .1 - .2 мс - но если вы печатаете в начале, оно очень медленное.

Мышление только с точки зрения правильного выделения, в большинстве случаев (по крайней мере, с Python), когда вы вводите новый символ, только текущая строка должна быть изменена. Иногда, например, если вы запускаете определение функции или открываете скобку, может потребоваться стилизовать несколько строк. Только когда вы открываете или закрываете многострочную строку """ или ''' - остальная часть буфера нуждается в рестайлинге.

Если вы включите start и end в свои журналы, вы увидите, что большую часть времени, когда вы печатаете, они охватывают очень маленький диапазон. Если вы измените одну строку вашего highlight_code метода с

code = view.text()[start:]

до

code = view.text()[start:end]

вы увидите, что метод почти всегда занимает менее миллисекунды и почти всегда корректно выделяет подсветку.

Из того, что я смог сказать, это неправильно понимает стиль, когда используются многострочные кавычки. Однако в вашем текущем коде есть та же проблема: попробуйте открыть многострочную строку, набрать ввод и продолжить строку на следующей строке. Вторая строка будет выделена как код. Qscintilla немного вводит вас в заблуждение, давая start, который не включает начало многострочной кавычки. Это не пытается быть идеальным, хотя - документы говорят

На самом деле, QScintilla говорит: «Эй, я думаю, вы должны изменить стиль текста между символом в начале позиции до символа в конце позиции». Вы можете свободно игнорировать это предложение.

Правильная обработка цитирования мутлилина будет немного сложнее! Если бы это был я, и я хотел, чтобы что-то работало быстро, я бы, вероятно, помешал бы нажатию клавиши, чтобы обновить подсветку для всего буфера и использовать ее, когда все выглядит неправильно.

1 голос
/ 28 апреля 2019

Если вы счастливы написать свою собственную подсветку синтаксиса, вот возможный способ значительно ускорить его. Вы можете сделать это с Pygments без особых усилий;см. нижнюю часть ответа для одного из возможных способов сделать это.

Подсветка синтаксиса проста.Он имеет небольшую внутреннюю структуру данных, представляющую текущий контекст, который он обновляет по мере продвижения.Итак, для следующего кода Python:

import time

def sleep_ms(ms):
    """sleeps for a length of time
    given in milliseconds"""

    time.sleep(
        ms / 1000
    )

sleep_ms(1000)
syntax error

его контекст может измениться так, как он проходит через токены¹:

>>> [nothing]
>>> IMPORT
    IMPORT modulename
>>> [nothing]
>>> DEF
    DEF functionname
    DEF functionname, OPENPAREN
    DEF functionname, OPENPAREN
    DEF functionname ARGLIST
    DEF functionname ARGLIST COLON
>>> FUNCBODY 4s
    FUNCBODY 4s, DOUBLE_MLSTR
>>> FUNCBODY 4s, DOUBLE_MLSTR
    FUNCBODY 4s
>>> FUNCBODY 4s
>>> FUNCBODY 4s, varname
    FUNCBODY 4s, varname ATTR
    FUNCBODY 4s, varname ATTR attrname
    FUNCBODY 4s, varname ATTR attrname, OPENPAREN
>>> FUNCBODY 4s, varname ATTR attrname, OPENPAREN
>>> FUNCBODY 4s, varname ATTR attrname, OPENPAREN, varname
    FUNCBODY 4s, varname ATTR attrname, OPENPAREN, TRUEDIV varname
    FUNCBODY 4s, varname ATTR attrname, OPENPAREN, TRUEDIV varname intliteral
>>> FUNCBODY 4s, FUNCCALL
>>> FUNCBODY 4s
>>> [nothing]
    varname
    varname, OPENPAREN
    varname, OPENPAREN, intliteral
    FUNCCALL
>>> [nothing]
    varname
    ERROR

Если вы кешируете конечные контексты каждой строки,затем вы можете начать подсветку синтаксиса со строки, которая изменилась, и продолжать до тех пор, пока не доберетесь до строки, контекст которой совпадает с кэшированным;вам не нужно пересчитывать весь файл, но если вы добавите что-то вроде """, то он будет пересчитан до конца.Если вы доберетесь до ERROR, тогда вы можете просто остановиться там;нет смысла пересчитывать подсветку синтаксиса после синтаксической ошибки, потому что вы не знаете, что означает в контексте .(Для начальной версии, когда вы открываете файл, вы можете предположить, что после синтаксической ошибки нет контекста; эта эвристика, кажется, работает достаточно хорошо.)

Эта подсветка синтаксиса может быть смехотворно точной, илипросто «достаточно хорошо», практически без ощутимой разницы в скорости между ними.Специфичные для языка маркеры могут быть даже динамически связанными плагинами, и все равно это будет достаточно быстро!Кроме того, если вы добавите debouncing для выделения последующих строк, достаточно быстро набрать """""" и набрать "" или 42, независимо от размера файла.

Обратите внимание, что этот подсветчик однопроходный - например, он не выделяет известные имена переменных иначе, чем неизвестные.Если вы захотите это сделать, проблема станет значительно сложнее.


¹: этот пример подсветки Python является «смехотворно точным»;Я бы, наверное, не пошел с чем-то подобным, если бы у меня было ограничение по времени.Тем не менее, я спланировал это в своей голове и, по крайней мере, сейчас, мог бы объяснить это подробно, если потребуется.


Ваш код требует на удивление мало изменений для работы с этой техникой.

  • Измените начало вашего get_tokens_unprocessed на:

        def get_tokens_unprocessed(self, text, stack=('root',), mutate_stack=False):
            """
            Split ``text`` into (tokentype, text) pairs.
    
            ``stack`` is the inital stack (default: ``['root']``)
            """
            lexer = self.pyg_lexer
            pos = 0
            tokendefs = lexer._tokens
            if not mutate_stack:
                statestack = list(stack)
            statetokens = tokendefs[statestack[-1]]
    
  • Найдите способ определения номера строки.
  • В цикле highlight_slow сделайте что-то вроде этого (кроме лучшего):

            stack = list(self.cache[line_no_of(start)])
            tokensource = self.get_tokens_unprocessed(code, stack, True)
    
            self.startStyling(start)
            pos = start;
            for _, ttype, value in tokensource:
                self.setStyling(len(value), self.token_styles[ttype])
                pos += len(value)
                if is_line_end(pos):
                    if pos >= end and stack == self.cache[line_no_of(start)]:
                        break
                    self.cache[line_no_of(start)] = tuple(stack)
    

    Очевидно, код должен быть лучше, чем этот, и вам нужно будет найти какой-нибудь эффективный способреализация is_line_end и line_no_of;вероятно, есть несколько способов сделать это с помощью Pygments.

Это решение уже имеет по крайней мере одно преимущество перед вашим: оно поддерживает многострочные комментарии.

...