Pygments在QScintilla中的应用

15

考虑这个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]帧/高亮 <=> [17-34]ms/高亮(假设为60fps),因此在输入时您不会感到任何减速。

注意:正如您在上面的mcve中看到的,我已包含了Pygments tokenizer,因此您可以玩弄它…感觉上,为了实现“实时高亮”,我需要以某种聪明的方式使用备忘录化/缓存,但我很难弄清楚需要缓存的数据以及最佳缓存方式… :/

Demo:

enter image description here

在上面的演示中,您可以看到使用这种天真高亮语法,编辑器很快将变得无法使用,在我的笔记本电脑上重新突出显示32kb文本块仍然具有交互帧速率,但超过此大小后,编辑器将完全无法使用。

注意事项:

  • 最典型的情况是在没有选择的情况下在可见屏幕上进行打字/编码
  • 可能会发生您正在编辑分布在整个文档中的多个选择,这意味着您不知道这些选择是否靠近可见屏幕。例如,在Sublime中,当您按Alt+F3时,将选择鼠标下的所有实例
  • 在上面的片段中,我使用了Python词法分析器,但算法不应该过于关注特定的词法分析器。毕竟,Pygments支持大约300个词法分析器
  • 最糟糕的情况将发生在可见屏幕位于文件末尾并且其中一个选择恰好位于屏幕开头的情况下…如果需要重新突出显示整个文档,则需要找到另一种方法,即使这意味着“突出显示”在第一遍通行中不正确
  • 最重要的是性能,但也要正确…也就是说,如果给足够的时间,整个文档应该变成正确的高亮

参考资料:

下面的文档与此特定问题无关,但它们谈论了可能


2
相关:https://code.visualstudio.com/blogs/2017/02/08/syntax-highlighting-optimizations 介绍了如何实现高效的语法高亮。 - ivan_pozdeev
15
仅供参考 - 此问题正在Meta上讨论,因此可能会比平常受到更多的“关注”。 - Makoto
7
既然这不是一个最简示例,我认为将它发布到codereview.stackexchange.com会更加合适。 - Greg Schmit
4
我理解为优化这段特定的代码(需要进行代码审查),而不是一般的算法问题。如果这就是真正的问题,那么代码应该被大幅度精简。它没有被精简的事实是为什么有些人觉得你只是让他们为你写代码。最佳答案被称为“hacky”,但那只是因为你在SO上期望太多了。这就是为什么它应该被最小化并限制问题的范围,或者应该放在codereview上。 - Greg Schmit
5
这里的实际问题是什么?我在问题文本中没有找到任何问号。或许可以添加一个段落,类似于“问题:...你在问什么?” - hyde
显示剩余5条评论
2个回答

20
highlight_slow 中,你接收了 startend 的值,但是你忽略了 end 值。因此,每当你输入一个字符时,代码都会重新突出显示其余的缓冲区。这就是为什么,如果你在一个长缓冲区的末尾输入,时间非常快——约为 0.1-0.2 毫秒——但如果你在开头输入,速度就会很慢。
仅考虑正确的突出显示,在大多数情况下(至少在 Python 中),引入一个新字符时只需要重新突出显示当前行。有时,例如当你开始定义一个函数或打开一个括号时,可能需要样式化多行文本。只有当你打开或关闭多行字符串 """''' 时,才需要重新突出显示缓冲区的其余部分。
如果在日志中包含 startend,你会发现大多数情况下,它们所涉及的范围非常小。如果你将 highlight_code 方法中的一行改为:
code = view.text()[start:]

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

你将会看到,这个方法现在几乎总是需要不到一毫秒的时间,并且几乎总是正确地进行高亮。

根据我的观察,只有当多行引用涉及时,才会出现样式错误。然而,你当前的代码也存在同样的问题:尝试打开一个多行字符串,在第一行输入回车并在下一行继续输入字符串,第二行将被视为代码高亮显示。 Qscintilla 在这里有点误导你,因为它给出的 start 不包括多行引用的开头。它并不试图做到完美——文档中已经说明:

事实上,QScintilla 表示:“嘿,我认为你应该重新对从位置 start 到位置 end 的字符之间的文本进行样式设置”。你完全可以忽略这个建议。

正确处理多行引用可能会有些棘手!如果换做是我,而且我想快速得到一些可行的结果,我可能会实现一个按键来刷新整个缓冲区的高亮显示,并在出现问题时使用它。


22
您说您需要解决的主要问题是性能。我建议的更改使您的代码在不增加行为不正确的情况下变得可用快。多行问题未在您的问题中提到,这只是我注意到的。如果您想获得有关如何使用多种语言进行更高质量的突出显示的帮助,并具有您的编辑器尚未具备的功能(例如多重选择),我建议将这些因素添加到您的问题中。 - Nathan Vērzemnieks
1
这是一个有趣的问题!回想起来,你没有错过如此明显的东西本应该很明显,但是 - 为了辩护,我们经常会忽略显而易见的事情 ;) 如果我有时间,我可能会更深入地研究你指出的资源。 - Nathan Vērzemnieks
2
我一直在思考那个问题!我同意我的答案并没有解决你真正想要的内容,尽管我仍然认为它不是对原始问题的坏回答。在周末,我进行了更多的试验,有了一些想法,但在奖励到期之前,我没有时间将它们形成有用的形式。我计划在本周投入更多时间,并更新我的答案,但我不能保证最终结果会让你满意 :) - Nathan Vērzemnieks
看来我没有错 :) 。最后,这个无效的答案似乎只是为了赚取一些声望和赏金... 好吧,我不怪你,这证明了在某些情况下SO有点失灵。话虽如此,如果你再次对这个主题感兴趣,并提出一个好的有效答案,我将很乐意用500个赏金奖励你... 话说,我需要先确认这样的答案是否能满足我的需求。无论如何,这对我来说是一个不错的经历,我本不应该在第一时间就给予这个难题那么多的赏金,我的错 ;D - BPL
当你删除了所有评论时,我有点吃惊,坦白地说,这仍然让我感到不安-从其他人的评论中剥夺上下文会使它们看起来很奇怪,而且动机对我来说也不清楚。所以我感觉没那么想回来了。实际上,在接下来的一周里,我确实花了相当多的时间在这个问题上,但这确实是一个难题 :) 如果您愿意,我可以发布另一个答案,概述我发现的一些事情。特别是,仅依赖styleText方法是行不通的。 - Nathan Vērzemnieks
哦,对不起...我不想通过删除所有我的评论来打扰你...通常我的原则是当我创建一个主题时,我会尽可能地清除所有离题的评论,只留下主题相关的评论。通常我会警告我与之聊天的用户(通过评论)也这样做...但在这种情况下,我忘记在这里做同样的事情了。仅供记录,我诚实地打算再奖励500个赏金,但请让我明确一点,我会非常严格地评判答案并在进行测试后再这样做...但问题是,我对这个难题主题非常感兴趣 ;) - BPL

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,则可以停止;超过语法错误的语法高亮重新计算是没有意义的,因为您不知道上下文的含义。 (对于打开文件时的初始版本,您可以假设在语法错误后没有上下文;这种启发式方法似乎已经足够好了。)
这个语法高亮器有可能非常准确,或者只是“足够好”,两者之间几乎没有可察觉的速度差异。特定于语言的高亮器甚至可以是动态链接插件,它仍然是相当快的!此外,如果为后续行的高亮添加防抖动,则快速输入""""""将与输入""42一样快,无论文件大小如何。
请注意,此代码高亮器是单次扫描的 - 它不会将已知变量名与未知变量名区分开来进行高亮显示。如果您希望这样做,问题就会变得更加困难。

¹:这个Python代码高亮器非常准确,但如果我有时间限制的话可能不会选择它。尽管如此,我已经在脑海中计划好了,并且目前至少可以详细解释一下。


使用这种技术,您的代码只需要进行很少的更改即可正常工作。

  • Change the beginning of your get_tokens_unprocessed to:

        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]]
    
  • Find some way of detecting the line number.
  • In highlight_slow's loop, do something like this (except better):

            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)
    

    Obviously, the code would have to be better than this, and you'd have to find some efficient way of implementing is_line_end and line_no_of; there's probably some Pygments way of doing this.

这个解决方案已经比你的解决方案更有优势了:它支持多行注释。

@BPL 前者 - 替换 Pygments。嗯,我想从技术上讲,你可以使用任何你拥有源代码的语法高亮器,并将其转储为必要的变量状态并加载它。 - wizzwizz4
1
我不确定如何澄清。你理解哪些部分? - wizzwizz4
@BPL 你可以无论如何实现去抖动(因此,加粗)。我会尝试弄清楚并更好地解释。 - wizzwizz4
@BPL stackstack kwarg是我所谈论的“上下文”,您可以将其传递到函数中。 您比我更熟悉该库,我的代码几乎肯定不起作用。 - wizzwizz4
逐行突出显示,并调整 get_tokens_unprocessed 以在结尾处输出 state,以便您可以将其存储并反馈给下一行。然后,在更改后,您只需要重新计算更改行及其后续行的突出显示,直到 state 不再改变。 - wizzwizz4

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接