【问题标题】:How to implement indentation based code folding in QScintilla?如何在 QScintilla 中实现基于缩进的代码折叠?
【发布时间】:2019-08-20 22:05:52
【问题描述】:

这里的最终目标是在 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_()

在上面的 sn-p 中,您可以看到我尝试复制一些 Sublime 功能。如果我的测试没有错,indentation_level 应该提供与 Sublime View 提供的输出相同的输出。

问题:您将如何修改上述 sn-p 以提供像 Sublime 那样基于缩进的代码折叠?

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

当然,在使用多选(在上面的 mcve 中已经启用)时,适当的识别器也应该可以工作,示例如下:

您可以在 Sublime 中看到每个文档的更改如何完美/有效地更新缩进折叠级别

我的盒子的设置:

  • win7
  • Python 3.6.4 (x86)
  • PyQt5==5.12
  • QScintilla==2.10.8

附言。我在互联网上发现了一段很好用的有趣代码,https://github.com/pyQode/pyqode.core/blob/master/pyqode/core/api/folding.py 问题是该代码适用于QPlainTextEditQSyntaxHighlighter,所以我不太清楚如何调整它在QScinScintilla 小部件中工作

【问题讨论】:

    标签: python pyqt sublimetext3 qscintilla


    【解决方案1】:

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

    最后是优化版——捆绑了 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_()
    

    【讨论】:

    • @BPL 是的,我已经看到你的问题,但我不确定我是否能及时回答 =( 空行很容易实现,我会在这里发表评论做。
    • 好吧,我不怪你,另一个问题很棘手......但如果你对这个主题感兴趣,请不要阻止自己发表想法、建议或其他任何东西。 ..即使一开始它不是完美的答案,我们也可以逐渐完善它,直到变得足够好。我首先发布这个问题的原因是因为我不清楚“高级”策略应该是什么,我认为聪明的人可以对此提供反馈......我没有收到 500 赏金的事实不是即使是一条评论也证明这个问题是一个很好的谜题;)
    • 关于这里的空行问题...酷,像往常一样...我将成为这里的测试人员,提供有关可用性/错误的反馈,... ;)
    • 顺便说一句,我不知道在给予 500 赏金后你不能再悬赏这个问题了……猜猜下次我会自己打开一扇门,给予 400/450 的赏金以防万一我想回馈那些“一开始”没有解决的问题:)
    猜你喜欢
    • 2019-04-29
    • 2018-10-15
    • 2011-11-06
    • 2011-01-24
    • 2010-12-27
    • 2013-03-09
    • 2017-10-22
    • 2010-11-08
    • 2014-08-30
    相关资源
    最近更新 更多