393 lines
14 KiB
Python
393 lines
14 KiB
Python
"""
|
|
This module contains the code folding API.
|
|
|
|
"""
|
|
from __future__ import print_function
|
|
import logging
|
|
import sys
|
|
from pyqode.core.api.utils import TextBlockHelper
|
|
|
|
|
|
def print_tree(editor, file=sys.stdout, print_blocks=False):
|
|
"""
|
|
Prints the editor fold tree to stdout, for debugging purpose.
|
|
|
|
:param editor: CodeEdit instance.
|
|
:param file: file handle where the tree will be printed. Default is stdout.
|
|
:param print_blocks: True to print all blocks, False to only print blocks
|
|
that are fold triggers
|
|
"""
|
|
block = editor.document().firstBlock()
|
|
while block.isValid():
|
|
trigger = TextBlockHelper().is_fold_trigger(block)
|
|
trigger_state = TextBlockHelper().is_collapsed(block)
|
|
lvl = TextBlockHelper().get_fold_lvl(block)
|
|
visible = 'V' if block.isVisible() else 'I'
|
|
if trigger:
|
|
trigger = '+' if trigger_state else '-'
|
|
print('l%d:%s%s%s' %
|
|
(block.blockNumber() + 1, lvl, trigger, visible),
|
|
file=file)
|
|
elif print_blocks:
|
|
print('l%d:%s%s' %
|
|
(block.blockNumber() + 1, lvl, visible), file=file)
|
|
block = block.next()
|
|
|
|
|
|
def _logger():
|
|
return logging.getLogger(__name__)
|
|
|
|
|
|
class FoldDetector(object):
|
|
"""
|
|
Base class for fold detectors.
|
|
|
|
A fold detector takes care of detecting the text blocks fold levels that
|
|
are used by the FoldingPanel to render the document outline.
|
|
|
|
To use a FoldDetector, simply set it on a syntax_highlighter::
|
|
|
|
editor.syntax_highlighter.fold_detector = my_fold_detector
|
|
"""
|
|
def __init__(self):
|
|
#: Reference to the parent editor, automatically set by the syntax
|
|
#: highlighter before process any block.
|
|
self.editor = None
|
|
#: Fold level limit, any level greater or equal is skipped.
|
|
#: Default is sys.maxsize (i.e. all levels are accepted)
|
|
self.limit = sys.maxsize
|
|
|
|
def process_block(self, current_block, previous_block, text):
|
|
"""
|
|
Processes a block and setup its folding info.
|
|
|
|
This method call ``detect_fold_level`` and handles most of the tricky
|
|
corner cases so that all you have to do is focus on getting the proper
|
|
fold level foreach meaningful block, skipping the blank ones.
|
|
|
|
:param current_block: current block to process
|
|
:param previous_block: previous block
|
|
:param text: current block text
|
|
"""
|
|
prev_fold_level = TextBlockHelper.get_fold_lvl(previous_block)
|
|
if text.strip() == '':
|
|
# blank line always have the same level as the previous line,
|
|
fold_level = prev_fold_level
|
|
else:
|
|
fold_level = self.detect_fold_level(
|
|
previous_block, current_block)
|
|
if fold_level > self.limit:
|
|
fold_level = self.limit
|
|
|
|
if fold_level > prev_fold_level:
|
|
# apply on previous blank lines
|
|
block = current_block.previous()
|
|
while block.isValid() and block.text().strip() == '':
|
|
TextBlockHelper.set_fold_lvl(block, fold_level)
|
|
block = block.previous()
|
|
TextBlockHelper.set_fold_trigger(
|
|
block, True)
|
|
|
|
delta_abs = abs(fold_level - prev_fold_level)
|
|
if delta_abs > 1:
|
|
if fold_level > prev_fold_level:
|
|
# try to fix inconsistent fold level
|
|
_logger().debug(
|
|
'(l%d) inconsistent fold level, difference between '
|
|
'consecutive blocks cannot be greater than 1 (%d).',
|
|
current_block.blockNumber() + 1, delta_abs)
|
|
fold_level = prev_fold_level + 1
|
|
|
|
# update block fold level
|
|
if text.strip():
|
|
TextBlockHelper.set_fold_trigger(
|
|
previous_block, fold_level > prev_fold_level)
|
|
TextBlockHelper.set_fold_lvl(current_block, fold_level)
|
|
|
|
# user pressed enter at the beginning of a fold trigger line
|
|
# the previous blank line will keep the trigger state and the new line
|
|
# (which actually contains the trigger) must use the prev state (
|
|
# and prev state must then be reset).
|
|
prev = current_block.previous() # real prev block (may be blank)
|
|
if (prev and prev.isValid() and prev.text().strip() == '' and
|
|
TextBlockHelper.is_fold_trigger(prev)):
|
|
# prev line has the correct trigger fold state
|
|
TextBlockHelper.set_collapsed(
|
|
current_block, TextBlockHelper.is_collapsed(
|
|
prev))
|
|
# make empty line not a trigger
|
|
TextBlockHelper.set_fold_trigger(prev, False)
|
|
TextBlockHelper.set_collapsed(prev, False)
|
|
|
|
def detect_fold_level(self, prev_block, block):
|
|
"""
|
|
Detects the block fold level.
|
|
|
|
The default implementation is based on the block **indentation**.
|
|
|
|
.. note:: Blocks fold level must be contiguous, there cannot be
|
|
a difference greater than 1 between two successive block fold
|
|
levels.
|
|
|
|
:param prev_block: first previous **non-blank** block or None if this
|
|
is the first line of the document
|
|
:param block: The block to process.
|
|
:return: Fold level
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
|
|
class IndentFoldDetector(FoldDetector):
|
|
"""
|
|
Simple fold detector based on the line indentation level
|
|
"""
|
|
|
|
def detect_fold_level(self, prev_block, block):
|
|
"""
|
|
Detects fold level by looking at the block indentation.
|
|
|
|
:param prev_block: previous text block
|
|
:param block: current block to highlight
|
|
"""
|
|
text = block.text()
|
|
# round down to previous indentation guide to ensure contiguous block
|
|
# fold level evolution.
|
|
return (len(text) - len(text.lstrip())) // self.editor.tab_length
|
|
|
|
|
|
class CharBasedFoldDetector(FoldDetector):
|
|
"""
|
|
Fold detector based on trigger charachters (e.g. a { increase fold level
|
|
and } decrease fold level).
|
|
"""
|
|
def __init__(self, open_chars=('{'), close_chars=('}')):
|
|
super(CharBasedFoldDetector, self).__init__()
|
|
self.open_chars = open_chars
|
|
self.close_chars = close_chars
|
|
|
|
def detect_fold_level(self, prev_block, block):
|
|
if prev_block:
|
|
prev_text = prev_block.text().strip()
|
|
else:
|
|
prev_text = ''
|
|
text = block.text().strip()
|
|
if text in self.open_chars:
|
|
return TextBlockHelper.get_fold_lvl(prev_block) + 1
|
|
if prev_text.endswith(self.open_chars) and prev_text not in \
|
|
self.open_chars:
|
|
return TextBlockHelper.get_fold_lvl(prev_block) + 1
|
|
if self.close_chars in prev_text:
|
|
return TextBlockHelper.get_fold_lvl(prev_block) - 1
|
|
return TextBlockHelper.get_fold_lvl(prev_block)
|
|
|
|
|
|
class FoldScope(object):
|
|
"""
|
|
Utility class for manipulating fold-able code scope (fold/unfold,
|
|
get range, child and parent scopes and so on).
|
|
|
|
A scope is built from a fold trigger (QTextBlock).
|
|
"""
|
|
|
|
@property
|
|
def trigger_level(self):
|
|
"""
|
|
Returns the fold level of the block trigger
|
|
:return:
|
|
"""
|
|
return TextBlockHelper.get_fold_lvl(self._trigger)
|
|
|
|
@property
|
|
def scope_level(self):
|
|
"""
|
|
Returns the fold level of the first block of the foldable scope (
|
|
just after the trigger)
|
|
|
|
:return:
|
|
"""
|
|
return TextBlockHelper.get_fold_lvl(self._trigger.next())
|
|
|
|
@property
|
|
def collapsed(self):
|
|
"""
|
|
Returns True if the block is collasped, False if it is expanded.
|
|
|
|
"""
|
|
return TextBlockHelper.is_collapsed(self._trigger)
|
|
|
|
def __init__(self, block):
|
|
"""
|
|
Create a fold-able region from a fold trigger block.
|
|
|
|
:param block: The block **must** be a fold trigger.
|
|
:type block: QTextBlock
|
|
|
|
:raise: `ValueError` if the text block is not a fold trigger.
|
|
"""
|
|
if not TextBlockHelper.is_fold_trigger(block):
|
|
raise ValueError('Not a fold trigger')
|
|
self._trigger = block
|
|
|
|
def get_range(self, ignore_blank_lines=True):
|
|
"""
|
|
Gets the fold region range (start and end line).
|
|
|
|
.. note:: Start line do no encompass the trigger line.
|
|
|
|
:param ignore_blank_lines: True to ignore blank lines at the end of the
|
|
scope (the method will rewind to find that last meaningful block
|
|
that is part of the fold scope).
|
|
:returns: tuple(int, int)
|
|
"""
|
|
ref_lvl = self.trigger_level
|
|
first_line = self._trigger.blockNumber()
|
|
block = self._trigger.next()
|
|
last_line = block.blockNumber()
|
|
lvl = self.scope_level
|
|
if ref_lvl == lvl: # for zone set programmatically such as imports
|
|
# in pyqode.python
|
|
ref_lvl -= 1
|
|
while (block.isValid() and
|
|
TextBlockHelper.get_fold_lvl(block) > ref_lvl):
|
|
last_line = block.blockNumber()
|
|
block = block.next()
|
|
|
|
if ignore_blank_lines and last_line:
|
|
block = block.document().findBlockByNumber(last_line)
|
|
while block.blockNumber() and block.text().strip() == '':
|
|
block = block.previous()
|
|
last_line = block.blockNumber()
|
|
return first_line, last_line
|
|
|
|
def fold(self):
|
|
"""
|
|
Folds the region.
|
|
"""
|
|
start, end = self.get_range()
|
|
TextBlockHelper.set_collapsed(self._trigger, True)
|
|
block = self._trigger.next()
|
|
while block.blockNumber() <= end and block.isValid():
|
|
block.setVisible(False)
|
|
block = block.next()
|
|
|
|
def unfold(self):
|
|
"""
|
|
Unfolds the region.
|
|
"""
|
|
# set all direct child blocks which are not triggers to be visible
|
|
self._trigger.setVisible(True)
|
|
TextBlockHelper.set_collapsed(self._trigger, False)
|
|
for block in self.blocks(ignore_blank_lines=False):
|
|
block.setVisible(True)
|
|
for region in self.child_regions():
|
|
if not region.collapsed:
|
|
region.unfold()
|
|
else:
|
|
# leave it closed but open the last blank lines and the
|
|
# trigger line
|
|
start, bstart = region.get_range(ignore_blank_lines=True)
|
|
_, bend = region.get_range(ignore_blank_lines=False)
|
|
block = self._trigger.document().findBlockByNumber(start)
|
|
block.setVisible(True)
|
|
block = self._trigger.document().findBlockByNumber(bend)
|
|
while block.blockNumber() > bstart:
|
|
block.setVisible(True)
|
|
block = block.previous()
|
|
|
|
def blocks(self, ignore_blank_lines=True):
|
|
"""
|
|
This generator generates the list of blocks directly under the fold
|
|
region. This list does not contain blocks from child regions.
|
|
|
|
:param ignore_blank_lines: True to ignore last blank lines.
|
|
"""
|
|
start, end = self.get_range(ignore_blank_lines=ignore_blank_lines)
|
|
block = self._trigger.next()
|
|
ref_lvl = self.scope_level
|
|
while block.blockNumber() <= end and block.isValid():
|
|
lvl = TextBlockHelper.get_fold_lvl(block)
|
|
trigger = TextBlockHelper.is_fold_trigger(block)
|
|
if lvl == ref_lvl and not trigger:
|
|
yield block
|
|
block = block.next()
|
|
|
|
def child_regions(self):
|
|
"""
|
|
This generator generates the list of direct child regions.
|
|
"""
|
|
start, end = self.get_range()
|
|
block = self._trigger.next()
|
|
ref_lvl = self.scope_level
|
|
while block.blockNumber() <= end and block.isValid():
|
|
lvl = TextBlockHelper.get_fold_lvl(block)
|
|
trigger = TextBlockHelper.is_fold_trigger(block)
|
|
if lvl == ref_lvl and trigger:
|
|
yield FoldScope(block)
|
|
block = block.next()
|
|
|
|
def parent(self):
|
|
"""
|
|
Return the parent scope.
|
|
|
|
:return: FoldScope or None
|
|
"""
|
|
if TextBlockHelper.get_fold_lvl(self._trigger) > 0 and \
|
|
self._trigger.blockNumber():
|
|
block = self._trigger.previous()
|
|
ref_lvl = self.trigger_level - 1
|
|
while (block.blockNumber() and
|
|
(not TextBlockHelper.is_fold_trigger(block) or
|
|
TextBlockHelper.get_fold_lvl(block) > ref_lvl)):
|
|
block = block.previous()
|
|
try:
|
|
return FoldScope(block)
|
|
except ValueError:
|
|
return None
|
|
return None
|
|
|
|
def text(self, max_lines=sys.maxsize):
|
|
"""
|
|
Get the scope text, with a possible maximum number of lines.
|
|
|
|
:param max_lines: limit the number of lines returned to a maximum.
|
|
:return: str
|
|
"""
|
|
ret_val = []
|
|
block = self._trigger.next()
|
|
_, end = self.get_range()
|
|
while (block.isValid() and block.blockNumber() <= end and
|
|
len(ret_val) < max_lines):
|
|
ret_val.append(block.text())
|
|
block = block.next()
|
|
return '\n'.join(ret_val)
|
|
|
|
@staticmethod
|
|
def find_parent_scope(block):
|
|
"""
|
|
Find parent scope, if the block is not a fold trigger.
|
|
|
|
:param block: block from which the research will start
|
|
"""
|
|
# if we moved up for more than n lines, just give up otherwise this
|
|
# would take too much time.
|
|
limit = 5000
|
|
counter = 0
|
|
original = block
|
|
if not TextBlockHelper.is_fold_trigger(block):
|
|
# search level of next non blank line
|
|
while block.text().strip() == '' and block.isValid():
|
|
block = block.next()
|
|
ref_lvl = TextBlockHelper.get_fold_lvl(block) - 1
|
|
block = original
|
|
while (block.blockNumber() and counter < limit and
|
|
(not TextBlockHelper.is_fold_trigger(block) or
|
|
TextBlockHelper.get_fold_lvl(block) > ref_lvl)):
|
|
counter += 1
|
|
block = block.previous()
|
|
if counter < limit:
|
|
return block
|
|
return None
|
|
|
|
def __repr__(self):
|
|
return 'FoldScope(start=%r, end=%d)' % self.get_range()
|