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:
And of course, a proper identer should also work when using multiselection (which is already enabled in the above mcve), example below:
You can see how the indentation folding levels are updated perfectly/efficiently on each document's change in Sublime
Setup of my box:
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
[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_()
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With