如何在 QScintilla 中实现基于缩进的代码折叠?

How to implement indentation based code folding in QScintilla?

这里的最终目标是在 QScintilla 中实现基于缩进的代码折叠,类似于 SublimeText3 的方式。

首先,这是一个如何使用 QScintilla 机制手动提供折叠的小示例:

import sys

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

if __name__ == '__main__':
    app = QApplication(sys.argv)
    view = QsciScintilla()

    # http://www.scintilla.org/ScintillaDoc.html#Folding
    view.setFolding(QsciScintilla.BoxedTreeFoldStyle)

    lines = [
        (0, "def foo():"),
        (1, "    x = 10"),
        (1, "    y = 20"),
        (1, "    return x+y"),
        (-1, ""),
        (0, "def bar(x):"),
        (1, "    if x > 0:"),
        (2, "        print('this is')"),
        (2, "        print('branch1')"),
        (1, "    else:"),
        (2, "        print('and this')"),
        (2, "        print('is branch2')"),
        (-1, ""),
        (-1, ""),
        (-1, ""),
        (-1, "print('end')"),

    ]

    view.setText("\n".join([b for a, b in lines]))
    MASK = QsciScintilla.SC_FOLDLEVELNUMBERMASK

    for i, tpl in enumerate(lines):
        level, line = tpl
        if level >= 0:
            view.SendScintilla(view.SCI_SETFOLDLEVEL, i, level | QsciScintilla.SC_FOLDLEVELHEADERFLAG)
        else:
            view.SendScintilla(view.SCI_SETFOLDLEVEL, i, 0)

    view.show()
    app.exec_()

更深入的了解,可以查看官方文档:

文档参考:

正如我所说,我想像 Sublime 那样实现代码折叠,所以我创建了这个小 mcve 作为基本代码来玩弄:

import re
import time
from pathlib import Path

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


def lskip_nonewlines(text, pt):
    len_text = len(text)

    while True:
        if pt <= 0 or pt >= len_text:
            break
        if text[pt - 1] == "\n" or text[pt] == "\n":
            break
        pt -= 1

    return pt


def rskip_nonewlines(text, pt):
    len_text = len(text)

    while True:
        if pt <= 0 or pt >= len_text:
            break
        if text[pt] == "\n":
            break
        pt += 1

    return pt


class Region():
    __slots__ = ['a', 'b']

    def __init__(self, x, b=None):
        if b is None:
            if isinstance(x, int):
                self.a = x
                self.b = x
            elif isinstance(x, tuple):
                self.a = x[0]
                self.b = x[1]
            elif isinstance(x, Region):
                self.a = x.a
                self.b = x.b
            else:
                raise TypeError(f"Can't convert {x.__class__} to Region")
        else:
            self.a = x
            self.b = b

    def __str__(self):
        return "(" + str(self.a) + ", " + str(self.b) + ")"

    def __repr__(self):
        return "(" + str(self.a) + ", " + str(self.b) + ")"

    def __len__(self):
        return self.size()

    def __eq__(self, rhs):
        return isinstance(rhs, Region) and self.a == rhs.a and self.b == rhs.b

    def __lt__(self, rhs):
        lhs_begin = self.begin()
        rhs_begin = rhs.begin()

        if lhs_begin == rhs_begin:
            return self.end() < rhs.end()
        else:
            return lhs_begin < rhs_begin

    def __sub__(self, rhs):
        if self.end() < rhs.begin():
            return [self]
        elif self.begin() > rhs.end():
            return [self]
        elif rhs.contains(self):
            return []
        elif self.contains(rhs):
            return [Region(self.begin(), rhs.begin()), Region(rhs.end(), self.end())]
        elif rhs.begin() <= self.begin():
            return [Region(rhs.end(), self.end())]
        elif rhs.begin() > self.begin():
            return [Region(self.begin(), rhs.begin())]
        else:
            raise Exception("Unknown case")

    def empty(self):
        return self.a == self.b

    def begin(self):
        if self.a < self.b:
            return self.a
        else:
            return self.b

    def end(self):
        if self.a < self.b:
            return self.b
        else:
            return self.a

    def size(self):
        return abs(self.a - self.b)

    def contains(self, x):
        if isinstance(x, Region):
            return self.contains(x.a) and self.contains(x.b)
        else:
            return x >= self.begin() and x <= self.end()

    def cover(self, rhs):
        a = min(self.begin(), rhs.begin())
        b = max(self.end(), rhs.end())

        if self.a < self.b:
            return Region(a, b)
        else:
            return Region(b, a)

    def intersection(self, rhs):
        if self.end() <= rhs.begin():
            return Region(0)
        if self.begin() >= rhs.end():
            return Region(0)

        return Region(max(self.begin(), rhs.begin()), min(self.end(), rhs.end()))

    def intersects(self, rhs):
        lb = self.begin()
        le = self.end()
        rb = rhs.begin()
        re = rhs.end()

        return (
            (lb == rb and le == re) or
            (rb > lb and rb < le) or (re > lb and re < le) or
            (lb > rb and lb < re) or (le > rb and le < re)
        )


class View(QsciScintilla):

    # -------- MAGIC FUNCTIONS --------
    def __init__(self, parent=None):
        super().__init__(parent)
        self.tab_size = 4

        # Set multiselection defaults
        self.SendScintilla(QsciScintilla.SCI_SETMULTIPLESELECTION, True)
        self.SendScintilla(QsciScintilla.SCI_SETMULTIPASTE, 1)
        self.SendScintilla(QsciScintilla.SCI_SETADDITIONALSELECTIONTYPING, True)

    def __call__(self, prop, *args, **kwargs):
        args = [v.encode("utf-8") if isinstance(v, str) else v for v in args]
        kwargs = {
            k: (v.encode("utf-8") if isinstance(v, str) else v)
            for k, v in kwargs.items()
        }
        return self.SendScintilla(getattr(self, prop), *args, **kwargs)

    # -------- SublimeText API --------
    def size(self):
        return len(self.text())

    def substr(self, x):
        # x = point or region
        if isinstance(x, Region):
            return self.text()[x.begin():x.end()]
        else:
            s = self.text()[x:x + 1]
            if len(s) == 0:
                return "\x00"
            else:
                return s

    def line(self, x):
        region = Region(x)

        text = self.text()

        if region.a <= region.b:
            region.a = lskip_nonewlines(text, region.a)
            region.b = rskip_nonewlines(text, region.b)
        else:
            region.a = rskip_nonewlines(text, region.a)
            region.b = lskip_nonewlines(text, region.b)

        return Region(region.begin(), region.end())

    def full_line(self, x):
        region = Region(x)

        text = self.text()

        if region.a <= region.b:
            region.a = lskip_nonewlines(text, region.a)
            region.b = rskip_nonewlines(text, region.b)
            region.b = region.b + 1 if region.b < len(text) else region.b
        else:
            region.a = rskip_nonewlines(text, region.a)
            region.b = lskip_nonewlines(text, region.b)
            region.a = region.a + 1 if region.a < len(text) else region.a

        return Region(region.begin(), region.end())

    def indentation_level(self, pt):
        view = self
        r = view.full_line(pt)
        line = view.substr(r)

        if line == "\n":
            r = view.full_line(pt - 1)
            line = view.substr(r)

        num_line, index = view.lineIndexFromPosition(pt)

        if r.a <= 0 or r.a > view.size():
            return 0
        else:
            i = 0
            count = 0
            len_line = len(line)
            level = 0

            while True:
                if i >= len_line:
                    break
                if line[i] == " ":
                    i += 1
                    count += 1
                    if count == self.tab_size:
                        level += 1
                        count = 0
                elif line[i] == "\t":
                    level += 1
                else:
                    break

            if count != 0:
                level += 1
            return level


if __name__ == '__main__':
    import sys
    import textwrap

    app = QApplication(sys.argv)
    view = View()
    view.setText(textwrap.dedent("""\
                x - 0
            x - 3
            x - 3
                x - 4
            x - 3


    x - 1
     x - 2
      x - 2
        x - 2
            x - 3
            x - 3
                x - 4
            x - 3
    x - 1
                x - 4



x - 0
a
b
c
d
e
f
"""))

    view.show()
    app.exec_()

在上面的代码片段中,您可以看到我试图复制一些 Sublime 函数。如果我的测试没有错,indentation_level 应该提供与 Sublime View.

提供的输出相同的输出

问题:您将如何修改上述代码片段以提供像 Sublime 一样基于缩进的代码折叠?

在这里你可以看到一个 Sublime 是如何工作的例子:

当然,在使用多选(在上面的 mcve 中已经启用)时,正确的标识符也应该起作用,示例如下:

您可以看到缩进折叠级别是如何更新的 perfectly/efficiently 在 Sublime 中每个文档的更改

我的盒子设置:

Ps。我在 Internet 上找到了一段非常有趣的代码,它运行良好,https://github.com/pyQode/pyqode.core/blob/master/pyqode/core/api/folding.py 问题是该代码旨在用于 QPlainTextEditQSyntaxHighlighter,所以我不知道非常好如何调整它以在 QScinScintilla 小部件

中工作

[删除了之前的答案,因为根据最后一个问题编辑它可能具有的唯一价值是历史;如果您仍然好奇,请参考编辑历史]

最后,优化版本 — 捆绑了 80 千行示例文本以展示其性能。

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


def set_fold(prev, line, fold, full):
    if (prev[0] >= 0):
        fmax = max(fold, prev[1])
        for iter in range(prev[0], line + 1):
            view.SendScintilla(view.SCI_SETFOLDLEVEL, iter,
                fmax | (0, view.SC_FOLDLEVELHEADERFLAG)[iter + 1 < full])

def line_empty(line):
    return view.SendScintilla(view.SCI_GETLINEENDPOSITION, line) \
        <= view.SendScintilla(view.SCI_GETLINEINDENTPOSITION, line)

def modify(position, modificationType, text, length, linesAdded,
           line, foldLevelNow, foldLevelPrev, token, annotationLinesAdded):
    full = view.SC_MOD_INSERTTEXT | view.SC_MOD_DELETETEXT
    if (~modificationType & full == full):
        return
    prev = [-1, 0]
    full = view.SendScintilla(view.SCI_GETLINECOUNT)
    lbgn = view.SendScintilla(view.SCI_LINEFROMPOSITION, position)
    lend = view.SendScintilla(view.SCI_LINEFROMPOSITION, position + length)
    for iter in range(max(lbgn - 1, 0), -1, -1):
        if ((iter == 0) or not line_empty(iter)):
            lbgn = iter
            break
    for iter in range(min(lend + 1, full), full + 1):
        if ((iter == full) or not line_empty(iter)):
            lend = min(iter + 1, full)
            break
    for iter in range(lbgn, lend):
        if (line_empty(iter)):
            if (prev[0] == -1):
                prev[0] = iter
        else:
            fold = view.SendScintilla(view.SCI_GETLINEINDENTATION, iter)
            fold //= view.SendScintilla(view.SCI_GETTABWIDTH)
            set_fold(prev, iter - 1, fold, full)
            set_fold([iter, fold], iter, fold, full)
            prev = [-1, fold]
    set_fold(prev, lend - 1, 0, full)


if __name__ == '__main__':
    import sys
    import textwrap

    app = QApplication(sys.argv)
    view = QsciScintilla()
    view.SendScintilla(view.SCI_SETMULTIPLESELECTION, True)
    view.SendScintilla(view.SCI_SETMULTIPASTE, 1)
    view.SendScintilla(view.SCI_SETADDITIONALSELECTIONTYPING, True)
    view.SendScintilla(view.SCI_SETINDENTATIONGUIDES, view.SC_IV_REAL);
    view.SendScintilla(view.SCI_SETTABWIDTH, 4)
    view.setFolding(view.BoxedFoldStyle)
    view.SCN_MODIFIED.connect(modify)

    NUM_CHUNKS = 20000
    chunk = textwrap.dedent("""\
        x = 1
            x = 2
            x = 3
    """)
    view.setText("\n".join([chunk for i in range(NUM_CHUNKS)]))
    view.show()
    app.exec_()