【问题标题】:Pygments in QScintillaQScintilla 中的 Pygments
【发布时间】:2019-09-10 13:12:57
【问题描述】:

考虑一下这个 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 小部件上使用 pygments,现在我需要解决的主要问题是处理非小型文档时的性能

我希望编辑器在处理大型文档 (>=100kb) 时具有响应性和可用性,但我不太清楚我应该在这里采取什么方法。为了测试性能,您可以使用 Ctrl+1Ctrl+2 并且小部件文本将减少/分别增加。

当我说“响应式”时,我的意思是 可见屏幕 的突出显示计算应该不再需要 [1-2]frame/highglight [17-34]ms/highlight (假设 60fps)所以在打字时你不会感到任何减速。

注意:正如您在上面的 mcve 中看到的,我已经包含了 pygments 标记器,因此您可以使用它......感觉就像为了实现“实时突出显示”我需要使用以某种聪明的方式进行记忆/缓存,但我正在努力弄清楚我需要缓存哪些数据以及缓存它的最佳方式是什么......:/

演示:

在上面的演示中,您可以看到使用这种天真的突出显示编辑器很快就会变得无法使用,在我的笔记本电脑中重新突出显示 32kb 的文本块仍然会提供交互式帧速率,但如果高于该值,编辑器就会完全无法使用。

注意事项:

  • 最典型的情况是在没有选择的情况下在可见屏幕上输入/编码时发生
  • 您可能正在编辑遍布整个文档的多个选择,这意味着您不知道这些选择是否在可见屏幕附近。例如,在 Sublime 中,当您按下 Alt+F3 时,您会选择光标下的所有出现
  • 在上面的 sn-p 中,我使用了 python 词法分析器,但算法不应该过多地关注那个。 Pygments 支持约 300 个词法分析器
  • 如果可见屏幕位于文件末尾并且其中一个选项恰好位于屏幕开头,则会发生最坏的情况...如果您需要重新突出显示您需要的整个文档找到替代方法,即使这意味着“突出显示”在第一遍时不正确
  • 最重要的是性能和正确性...也就是说,如果您给了足够的时间,整个文档应该会正确突出显示

参考:

以下文档并非针对此特定问题,但它们讨论了缓存和语法突出显示的可能策略:

【问题讨论】:

  • 相关:code.visualstudio.com/blogs/2017/02/08/… 讲述了语法高亮的工作原理。
  • 仅供参考 - this question is being discussed on Meta 所以它可能会得到比平时更多的“关注”。
  • 由于这显然不是一个最小的例子,我认为它更适合 codereview.stackexchange.com。
  • @BPL 我将其解读为优化此特定代码(这绝对是要求代码审查),而不是一般算法问题。如果这是真正的问题,那么代码应该被大大减少。事实并非如此,这就是为什么在某些人看来,您只是在要求他们为您编写代码。您称之为“hacky”的最佳答案只是因为您对 SO 的单个 Q/A 想要太多。这就是为什么它应该被最小化并且问题的范围受到限制,或者它应该在 codereview 上。
  • 这里的实际问题是什么?我在问题文本中找不到一个问号。也许只需添加类似“问题:......你在问什么?”这样的段落

标签: python pyqt5 scintilla pygments qscintilla


【解决方案1】:

如果您乐于编写自己的语法荧光笔,这里有一种可以显着加快速度的方法。 您可以通过 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 一样快。 p>

请注意,此荧光笔是单通道的——例如,它不会以与未知变量名称不同的方式突出显示已知变量名称。如果你想这样做,问题就会变得相当困难。


¹:这个 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_endline_no_of;可能有一些 Pygments 方法可以做到这一点。

这个解决方案比你的解决方案至少有一个好处:它支持多行 cmets。

【讨论】:

  • @BPL 前者——替换 Pygments。好吧,我想技术上你可以使用任何你有源代码的语法荧光笔并使用它,转储变量的状态并根据需要加载它。
  • 我不确定如何澄清。您了解哪些部分?
  • @BPL 你可以实现去抖动,不管(因此,加粗)。我会尝试弄清楚并更好地解释。
  • @BPL stackstack kwarg 是我所说的“上下文”,您可以将它传递给函数。你比我更熟悉这个库,而且我的代码几乎肯定不会工作。
  • 逐行突出显示,并调整get_tokens_unprocessed 以在最后以某种方式输出state,以便您可以存储它,然后将其反馈回下一行。然后,更改后,您只需要重新计算更改的行和所有后续行的突出显示,直到state 停止更改。
【解决方案2】:

highlight_slow 中,您收到startend 值,但您忽略了最终值。因此,每当您键入单个字符时,代码都会重新突出显示缓冲区的其余部分。这就是为什么,如果您在长缓冲区的末尾键入,时间会非常快 - 大约 0.1 - .2 毫秒 - 但如果您在开头键入,则非常慢。

只考虑正确的突出显示,在大多数情况下(至少在 Python 中)当你引入一个新字符时,只需要重新设置当前行的样式。有时,如果您开始一个函数定义或打开一个括号,可能需要设置多行样式。只有当您打开或关闭多行 """''' 字符串时 - 缓冲区的其余部分才需要重新设置样式。

如果您在日志记录中包含startend,您会发现大多数情况下,当您键入它们时,它们的范围非常小。如果您将 highlight_code 方法的一行从

code = view.text()[start:]

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

您会看到该方法现在几乎总是需要亚毫秒级的时间,而且它几乎总是能正确突出显示。

据我所知,这只会在涉及多行引号时导致样式错误。但是,您当前的代码有同样的问题:尝试打开一个多行字符串,输入 enter,然后在下一行继续该字符串。第二行将突出显示为代码。 Qscintilla 通过提供不包括多行引号开头的 start 来让您误入歧途。不过,它并没有试图做到完美——文档说

事实上,QScintilla 说:“嘿,我认为你应该重新设置起始位置字符和结束位置字符之间的文本样式”。您完全可以忽略此建议。

正确处理多行引用会有点棘手!如果是我,并且我想让某些东西快速工作,我可能会通过击键来刷新整个缓冲区的突出显示,并在出现问题时使用它。

【讨论】:

  • 你说你需要处理的主要问题是性能。我建议的更改使您的代码可以快速使用,而不会使其行为更不正确。您的问题中没有提到多行问题,这只是我注意到的。如果您需要帮助弄清楚如何使用多种语言进行质量更高的突出显示,以及您的编辑器尚不具备的功能(例如多项选择),我建议您将这些因素添加到您的问题中。
  • 这是一个有趣的问题!回想起来应该很明显,您并没有错过如此明显的东西,但是-在我的辩护中,我们经常错过明显的东西;)如果有时间,我可能会在本周末更多地研究您指出的资源。
  • 我一直在思考这个问题!我同意我的回答并没有解决您真正想要的问题,尽管我仍然认为这不是对原始问题的答案。周末我一直在修修补补,我有一些想法,但在赏金到期之前我没有时间将它们变成有用的形式。我确实计划在一周内投入更多时间,我会用我的想法更新我的答案,但我不能保证最终结果会让你满意:)
  • 看来我没有错 :) 。最后,似乎这个无效的答案只是为了获得一些回购以及获得赏金......好吧,我不怪你,这证明在某些情况下SO在某种程度上被打破了。也就是说,如果你再次对这个话题感兴趣并想出一个很好的有效答案,我很乐意奖励它 500 赏金......也就是说,我首先需要确认这样的答案会让我满意。无论如何,这对我来说是一次很好的经历,我一开始就不应该在这个困难的话题上给予这么多的赏金,我的错;D
  • 当您删除所有 cmets 时,我有点吃惊,坦率地说,这仍然困扰着我 - 从其他人的 cmets 中删除上下文会使他们看起来很奇怪,而且我不清楚动机。所以我觉得不太愿意回到它。事实上,在接下来的一周里,我确实在这方面花了很多时间,但这确实是一个难题:) 如果你愿意,我可以发布另一个答案,概述我发现的一些事情。特别是,依赖styleText 方法是行不通的。
猜你喜欢
  • 2013-04-14
  • 2011-03-26
  • 1970-01-01
  • 1970-01-01
  • 2013-02-16
  • 2016-10-11
  • 2014-12-06
  • 2022-12-14
  • 1970-01-01
相关资源
最近更新 更多