How to implement indentation based code folding in QScintilla?
Asked Answered
E

1

18

The end goal here is to implement indentation based code folding in QScintilla similarly to the way SublimeText3 does.

First of all, here's a little example of how you'd manually provide folding using QScintilla mechanisms:

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_()

To know more in depth about it, you can check the official docs:

Doc references:

As I said, I'd like to implement code folding like Sublime does, so I've created this little mcve as a base code to toy around:

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_()

In the above snippet you can see I've tried to replicate some of the Sublime functions. If my tests are not wrong, the indentation_level should provide the same output than the one provided by Sublime View.

QUESTION: How would you modify the above snippet to provide indentation based code folding like Sublime's?

Here you can see an example how Sublime works:

enter image description here

And of course, a proper identer should also work when using multiselection (which is already enabled in the above mcve), example below:

enter image description here

You can see how the indentation folding levels are updated perfectly/efficiently on each document's change in Sublime

Setup of my box:

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

Ps. I've found a nice interesting piece of code on the internet that works quite well, https://github.com/pyQode/pyqode.core/blob/master/pyqode/core/api/folding.py problem is that code is intended to work on a QPlainTextEdit and QSyntaxHighlighter so I don't know very well how to adjust it to work in a QScinScintilla widget

Eckblad answered 29/3, 2019 at 22:44 Comment(0)
C
10

[erased the previous answer, since in the light of the last question edit the only value it might probably have is historical; refer to the edit history if you`re still curious]

Finally, the optimized version — bundled with 80 kilolines of sample text to show off its performance.

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_()
Conchiolin answered 6/4, 2019 at 22:58 Comment(4)
@Eckblad Yup, I`ve seen your question, but I`m not sure if I can answer in time =( Empty lines are quite easy to implement, I`ll drop a comment here when I do.Conchiolin
Well, I don't blame you, the other question is quite tricky... but in case you're interested about that subject please don't stop yourself about posting ideas, suggestions or whatever... even if it's not the perfect answer at first we can refine it gradually till becomes good enough. Reason why I posted that question in the first place is because is unclear to me what the "high-level" strategy should be and I thought smart people could give feedback about it... The fact with 500 bounties I haven't received not even a single comment is proof that the question is a nice puzzle ;)Eckblad
About the empty lines issues here... cool, as usual... I'll be the tester here giving feedback about usability/bugs, ... ;)Eckblad
Btw, I didn't know after giving 500 bounties you can't bounty the question any longer... guess next time I'll leave an open door myself giving bounties of 400/450 in case i want to rebounty questions that haven't been solved at "first" :)Eckblad

© 2022 - 2024 — McMap. All rights reserved.