diff --git a/spyder/plugins/completion/providers/languageserver/providers/document.py b/spyder/plugins/completion/providers/languageserver/providers/document.py index ac2427b7087..c8a84034d49 100644 --- a/spyder/plugins/completion/providers/languageserver/providers/document.py +++ b/spyder/plugins/completion/providers/languageserver/providers/document.py @@ -251,15 +251,10 @@ def folding_range_request(self, params): @handles(CompletionRequestTypes.DOCUMENT_FOLDING_RANGE) def process_folding_range(self, result, req_id): - results = [] - for folding_range in result: - start_line = folding_range['startLine'] - end_line = folding_range['endLine'] - results.append((start_line, end_line)) if req_id in self.req_reply: self.req_reply[req_id]( CompletionRequestTypes.DOCUMENT_FOLDING_RANGE, - {'params': results}) + {'params': result}) @send_notification(method=CompletionRequestTypes.DOCUMENT_WILL_SAVE) def document_will_save_notification(self, params): diff --git a/spyder/plugins/debugger/utils/breakpointsmanager.py b/spyder/plugins/debugger/utils/breakpointsmanager.py index 16930e6272c..4aa3870226f 100644 --- a/spyder/plugins/debugger/utils/breakpointsmanager.py +++ b/spyder/plugins/debugger/utils/breakpointsmanager.py @@ -81,7 +81,7 @@ def __init__(self, editor): # Debugger panel (Breakpoints) self.debugger_panel = DebuggerPanel(self) editor.panels.register(self.debugger_panel) - self.debugger_panel.order_in_zone = -1 + self.debugger_panel.order_in_zone = 1 self.update_panel_visibility() # Load breakpoints diff --git a/spyder/plugins/editor/api/decoration.py b/spyder/plugins/editor/api/decoration.py index ef792a841b4..29b4e09f2b2 100644 --- a/spyder/plugins/editor/api/decoration.py +++ b/spyder/plugins/editor/api/decoration.py @@ -23,7 +23,7 @@ QTextCharFormat) # Local imports -from spyder.utils.palette import QStylePalette, SpyderPalette +from spyder.utils.palette import SpyderPalette # DRAW_ORDERS are used for make some decorations appear in top of others, @@ -37,9 +37,10 @@ DRAW_ORDERS = {'on_bottom': 0, 'current_cell': 1, - 'codefolding': 2, + 'folding_areas': 2, 'current_line': 3, - 'on_top': 4} + 'folded_regions': 4, + 'on_top': 5} class TextDecoration(QTextEdit.ExtraSelection): diff --git a/spyder/plugins/editor/api/editorextension.py b/spyder/plugins/editor/api/editorextension.py index a7ff89d7567..f9a0ed74f49 100644 --- a/spyder/plugins/editor/api/editorextension.py +++ b/spyder/plugins/editor/api/editorextension.py @@ -16,11 +16,6 @@ """ -import logging - - -logger = logging.getLogger(__name__) - class EditorExtension(object): """ @@ -87,9 +82,6 @@ def __init__(self): self._editor = None self._on_close = False - def __del__(self): - logger.debug('%s.__del__', type(self)) - def on_install(self, editor): """ Installs the extension on the editor. diff --git a/spyder/plugins/editor/extensions/snippets.py b/spyder/plugins/editor/extensions/snippets.py index 594870504f0..05cf08b0adc 100644 --- a/spyder/plugins/editor/extensions/snippets.py +++ b/spyder/plugins/editor/extensions/snippets.py @@ -11,9 +11,10 @@ import functools # Third party imports -from qtpy.QtGui import QTextCursor, QColor -from qtpy.QtCore import Qt, QMutex, QMutexLocker from diff_match_patch import diff_match_patch +from qtpy.QtCore import QMutex, QMutexLocker, Qt +from qtpy.QtGui import QTextCursor, QColor +from superqt.utils import qdebounced try: from rtree import index @@ -89,7 +90,6 @@ def __init__(self): self.starting_position = None self.modification_lock = QMutex() self.event_lock = QMutex() - self.update_lock = QMutex() self.node_position = {} self.snippets_map = {} self.undo_stack = [] @@ -98,7 +98,7 @@ def __init__(self): self.index = index.Index() def on_state_changed(self, state): - """Connect/disconnect sig_key_pressed signal.""" + """Connect/disconnect editor signals.""" if state: self.editor.sig_key_pressed.connect(self._on_key_pressed) self.editor.sig_insert_completion.connect(self.insert_snippet) @@ -589,7 +589,8 @@ def _find_lowest_common_ancestor(self, start_node, end_node): @lock def remove_selection(self, selection_start, selection_end): - self._remove_selection(selection_start, selection_end) + if self.is_snippet_active: + self._remove_selection(selection_start, selection_end) def _remove_selection(self, selection_start, selection_end): start_node, _, _ = self._find_node_by_position(*selection_start) @@ -712,8 +713,10 @@ def _find_node_by_position(self, line, col): text_ids = set([id(token) for token in nearest_text.tokens]) if node_id not in text_ids: current_node = nearest_text.tokens[-1] + return current_node, nearest_snippet, nearest_text + @qdebounced(timeout=20) def cursor_changed(self, line, col): if not rtree_available: return @@ -722,14 +725,16 @@ def cursor_changed(self, line, col): self.inserting_snippet = False return - node, nearest_snippet, _ = self._find_node_by_position(line, col) - if node is None: - ignore = self.editor.is_undoing or self.editor.is_redoing - if not ignore: - self.reset() - else: - if nearest_snippet is not None: - self.active_snippet = nearest_snippet.number + if self.is_snippet_active: + node, nearest_snippet, _ = self._find_node_by_position(line, col) + + if node is None: + ignore = self.editor.is_undoing or self.editor.is_redoing + if not ignore: + self.reset() + else: + if nearest_snippet is not None: + self.active_snippet = nearest_snippet.number def reset(self, partial_reset=False): self.node_number = 0 diff --git a/spyder/plugins/editor/panels/codefolding.py b/spyder/plugins/editor/panels/codefolding.py index 00fc9a9a9ac..f51e31c6c75 100644 --- a/spyder/plugins/editor/panels/codefolding.py +++ b/spyder/plugins/editor/panels/codefolding.py @@ -10,32 +10,32 @@ """ This module contains the marker panel. -Adapted from pyqode/core/panels/folding.py of the -`PyQode project `_. +Adapted from pyqode/core/panels/folding.py of the PyQode project +https://github.com/pyQode/pyQode + Original file: - +https://github.com/pyQode/pyqode.core/blob/master/pyqode/core/panels/folding.py """ # Standard library imports from math import ceil -import sys # Third party imports from intervaltree import IntervalTree from qtpy.QtCore import Signal, QSize, QPointF, QRectF, QRect, Qt -from qtpy.QtWidgets import QApplication, QStyleOptionViewItem, QStyle -from qtpy.QtGui import (QTextBlock, QColor, QFontMetricsF, QPainter, - QLinearGradient, QPen, QPalette, QResizeEvent, - QCursor) +from qtpy.QtGui import (QTextBlock, QFontMetricsF, QPainter, QLinearGradient, + QPen, QResizeEvent, QCursor, QTextCursor) +from qtpy.QtWidgets import QApplication # Local imports -from spyder.plugins.editor.panels.utils import FoldingRegion -from spyder.plugins.editor.api.decoration import TextDecoration, DRAW_ORDERS from spyder.plugins.editor.api.panel import Panel +from spyder.config.gui import is_dark_interface +from spyder.plugins.editor.api.decoration import TextDecoration, DRAW_ORDERS +from spyder.plugins.editor.panels.utils import FoldingRegion from spyder.plugins.editor.utils.editor import (TextHelper, DelayJobRunner, drift_color) from spyder.utils.icon_manager import ima -from spyder.utils.palette import QStylePalette +from spyder.widgets.mixins import EOL_SYMBOLS class FoldingPanel(Panel): @@ -53,59 +53,36 @@ class FoldingPanel(Panel): collapse_all_triggered = Signal() expand_all_triggered = Signal() - @property - def native_icons(self): - """ - Defines whether the panel will use native indicator icons or - use custom ones. - - If you want to use custom indicator icons, you must first - set this flag to False. - """ - return self.native_icons - - @native_icons.setter - def native_icons(self, value): - self._native_icons = value - # propagate changes to every clone - if self.editor: - for clone in self.editor.clones: - try: - clone.modes.get(self.__class__).native_icons = value - except KeyError: - # this should never happen since we're working with clones - pass - - @property - def indicators_icons(self): - """ - Gets/sets the icons for the fold indicators. + def __init__(self): + Panel.__init__(self) - The list of indicators is interpreted as follow:: + self.collapsed_icon = ima.icon('folding.arrow_right') + self.uncollapsed_icon = ima.icon('folding.arrow_down') - (COLLAPSED_OFF, COLLAPSED_ON, EXPANDED_OFF, EXPANDED_ON) + self._block_nbr = -1 + self._highlight_caret = False + self.highlight_caret_scope = False - To use this property you must first set `native_icons` to False. + #: the list of deco used to highlight the current fold region ( + #: surrounding regions are darker) + self._scope_decos = [] - :returns: tuple(str, str, str, str) - """ - return self._indicators_icons + #: the list of folded block decorations + self._block_decos = {} - @indicators_icons.setter - def indicators_icons(self, value): - if len(value) != 4: - raise ValueError('The list of custom indicators must contains 4 ' - 'strings') - self._indicators_icons = value - if self.editor: - # propagate changes to every clone - for clone in self.editor.clones: - try: - clone.modes.get( - self.__class__).indicators_icons = value - except KeyError: - # this should never happen since we're working with clones - pass + self.setMouseTracking(True) + self.scrollable = True + self._mouse_over_line = None + self._current_scope = None + self._display_folding = False + self._key_pressed = False + self._highlight_runner = DelayJobRunner(delay=250) + self.current_tree = IntervalTree() + self.root = FoldingRegion(None, None) + self.folding_regions = {} + self.folding_status = {} + self.folding_levels = {} + self.folding_nesting = {} @property def highlight_caret_scope(self): @@ -140,45 +117,6 @@ def highlight_caret_scope(self, value): # clones pass - def __init__(self): - Panel.__init__(self) - self._native_icons = False - self._indicators_icons = ( - 'folding.arrow_right_off', - 'folding.arrow_right_on', - 'folding.arrow_down_off', - 'folding.arrow_down_on' - ) - self._block_nbr = -1 - self._highlight_caret = False - self.highlight_caret_scope = False - self._indic_size = 16 - #: the list of deco used to highlight the current fold region ( - #: surrounding regions are darker) - self._scope_decos = [] - #: the list of folded blocs decorations - self._block_decos = {} - self.setMouseTracking(True) - self.scrollable = True - self._mouse_over_line = None - self._current_scope = None - self._prev_cursor = None - self.context_menu = None - self.action_collapse = None - self.action_expand = None - self.action_collapse_all = None - self.action_expand_all = None - self._original_background = None - self._display_folding = False - self._key_pressed = False - self._highlight_runner = DelayJobRunner(delay=250) - self.current_tree = IntervalTree() - self.root = FoldingRegion(None, None) - self.folding_regions = {} - self.folding_status = {} - self.folding_levels = {} - self.folding_nesting = {} - def update_folding(self, folding_info): """Update folding panel folding ranges.""" if folding_info is None: @@ -187,6 +125,8 @@ def update_folding(self, folding_info): (self.current_tree, self.root, self.folding_regions, self.folding_nesting, self.folding_levels, self.folding_status) = folding_info + + self._clear_block_decos() self.update() def sizeHint(self): @@ -201,49 +141,82 @@ def _draw_collapsed_indicator(self, line_number, top_position, block, painter, mouse_hover=False): if line_number in self.folding_regions: collapsed = self.folding_status[line_number] - line_end = self.folding_regions[line_number] - mouse_over = self._mouse_over_line == line_number + if not mouse_hover: - self._draw_fold_indicator( - top_position, mouse_over, collapsed, painter) + self._draw_fold_indicator(top_position, collapsed, painter) + if collapsed: if mouse_hover: - self._draw_fold_indicator( - top_position, mouse_over, collapsed, painter) - # check if the block already has a decoration, - # it might have been folded by the parent - # editor/document in the case of cloned editor - for deco_line in self._block_decos: - deco = self._block_decos[deco_line] - if deco.block == block: - # no need to add a deco, just go to the - # next block - break - else: - self._add_fold_decoration(block, line_end) + self._draw_fold_indicator(top_position, collapsed, painter) elif not mouse_hover: for deco_line in list(self._block_decos.keys()): deco = self._block_decos[deco_line] - # check if the block decoration has been removed, it + + # Check if the block decoration has been removed, it # might have been unfolded by the parent # editor/document in the case of cloned editor if deco.block == block: # remove it and self._block_decos.pop(deco_line) - self.editor.decorations.remove(deco) + self.editor.decorations.remove(deco, key='folded') del deco break + def highlight_folded_regions(self): + """Highlight folded regions on the editor's visible buffer.""" + first_block_nb, last_block_nb = self.editor.get_buffer_block_numbers() + + # This can happen at startup, and when it does, we don't need to move + # pass this point. + if first_block_nb == last_block_nb: + return + + for block_number in range(first_block_nb, last_block_nb): + block = self.editor.document().findBlockByNumber(block_number) + line_number = block_number + 1 + + if line_number in self.folding_regions: + collapsed = self.folding_status[line_number] + + # Check if a block is folded by UI inspection. + # This is necesary because the algorithm that detects the + # currently folded regions may fail, for instance, after + # pasting a big chunk of code. + ui_collapsed = ( + block.isVisible() and not block.next().isVisible() + ) + + if collapsed != ui_collapsed: + collapsed = ui_collapsed + self.folding_status[line_number] = ui_collapsed + + if collapsed: + # Check if the block already has a decoration, + # it might have been folded by the parent + # editor/document in the case of cloned editor + for deco_line in self._block_decos: + deco = self._block_decos[deco_line] + if deco.block == block: + # no need to add a deco, just go to the + # next block + break + else: + line_end = self.folding_regions[line_number] + self._add_fold_decoration(block, line_end) + def paintEvent(self, event): - # Paints the fold indicators and the possible fold region background - # on the folding panel. - super(FoldingPanel, self).paintEvent(event) + """ + Paint fold indicators on the folding panel and possible folding region + background on the editor. + """ + super().paintEvent(event) painter = QPainter(self) self.paint_cell(painter) + + # Draw collapsed indicators if not self._display_folding and not self._key_pressed: - if any(self.folding_status.values()): - for info in self.editor.visible_blocks: - top_position, line_number, block = info + for top_position, line_number, block in self.editor.visible_blocks: + if self.folding_status.get(line_number): self._draw_collapsed_indicator( line_number, top_position, block, painter, mouse_hover=True) @@ -262,7 +235,8 @@ def paintEvent(self, event): # folding panel and make some text modifications # that trigger a folding recomputation. pass - # Draw fold triggers + + # Draw all fold indicators for top_position, line_number, block in self.editor.visible_blocks: self._draw_collapsed_indicator( line_number, top_position, block, painter, mouse_hover=False) @@ -300,14 +274,16 @@ def _draw_rect(self, rect, painter): """ c = self.editor.sideareas_color grad = QLinearGradient(rect.topLeft(), rect.topRight()) - if sys.platform == 'darwin': - grad.setColorAt(0, c.lighter(100)) - grad.setColorAt(1, c.lighter(110)) - outline = c.darker(110) - else: + + if is_dark_interface(): grad.setColorAt(0, c.lighter(110)) grad.setColorAt(1, c.lighter(130)) outline = c.darker(100) + else: + grad.setColorAt(0, c.darker(105)) + grad.setColorAt(1, c.darker(115)) + outline = c.lighter(110) + painter.fillRect(rect, grad) painter.setPen(QPen(outline)) painter.drawLine(rect.topLeft() + @@ -327,42 +303,24 @@ def _draw_rect(self, rect, painter): rect.bottomLeft() - QPointF(0, 1)) - def _draw_fold_indicator(self, top, mouse_over, collapsed, painter): + def _draw_fold_indicator(self, top, collapsed, painter): """ Draw the fold indicator/trigger (arrow). :param top: Top position - :param mouse_over: Whether the mouse is over the indicator :param collapsed: Whether the trigger is collapsed or not. :param painter: QPainter """ - rect = QRect(0, top, self.sizeHint().width(), - self.sizeHint().height()) - if self._native_icons: - opt = QStyleOptionViewItem() - - opt.rect = rect - opt.state = (QStyle.State_Active | - QStyle.State_Item | - QStyle.State_Children) - if not collapsed: - opt.state |= QStyle.State_Open - if mouse_over: - opt.state |= (QStyle.State_MouseOver | - QStyle.State_Enabled | - QStyle.State_Selected) - opt.palette.setBrush(QPalette.Window, - self.palette().highlight()) - opt.rect.translate(-2, 0) - self.style().drawPrimitive(QStyle.PE_IndicatorBranch, - opt, painter, self) + rect = QRect( + 0, top, self.sizeHint().width() + 2, self.sizeHint().height() + 2 + ) + + if collapsed: + icon = self.collapsed_icon else: - index = 0 - if not collapsed: - index = 2 - if mouse_over: - index += 1 - ima.icon(self._indicators_icons[index]).paint(painter, rect) + icon = self.uncollapsed_icon + + icon.paint(painter, rect) def find_parent_scope(self, block): """Find parent scope, if the block is not a fold trigger.""" @@ -392,10 +350,12 @@ def _get_scope_highlight_color(self): and for darker ones will be a lighter color """ color = self.editor.sideareas_color - if color.lightness() < 128: + + if is_dark_interface(): color = drift_color(color, 130) else: color = drift_color(color, 105) + return color def _decorate_block(self, start, end): @@ -407,7 +367,7 @@ def _decorate_block(self, start, end): end (int) end line of the decoration """ color = self._get_scope_highlight_color() - draw_order = DRAW_ORDERS.get('codefolding') + draw_order = DRAW_ORDERS.get('folding_areas') d = TextDecoration(self.editor.document(), start_line=max(0, start - 1), end_line=end, @@ -446,10 +406,12 @@ def mouseMoveEvent(self, event): super(FoldingPanel, self).mouseMoveEvent(event) th = TextHelper(self.editor) line = th.line_nbr_from_position(event.pos().y()) + if line >= 0: block = self.editor.document().findBlockByNumber(line) block = self.find_parent_scope(block) line_number = block.blockNumber() + if line_number in self.folding_regions: if self._mouse_over_line is None: # mouse enter fold scope @@ -482,6 +444,7 @@ def mouseMoveEvent(self, event): self._highlight_runner.cancel_requests() self._mouse_over_line = None QApplication.restoreOverrideCursor() + self.repaint() def enterEvent(self, event): @@ -514,18 +477,18 @@ def _add_fold_decoration(self, block, end_line): """ start_line = block.blockNumber() text = self.editor.get_text_region(start_line + 1, end_line) - draw_order = DRAW_ORDERS.get('codefolding') + draw_order = DRAW_ORDERS.get('folded_regions') + deco = TextDecoration(block, draw_order=draw_order) deco.signals.clicked.connect(self._on_fold_deco_clicked) deco.tooltip = text deco.block = block deco.select_line() - deco.set_outline(drift_color( - self._get_scope_highlight_color(), 110)) deco.set_background(self._get_scope_highlight_color()) - deco.set_foreground(QColor(QStylePalette.COLOR_TEXT_4)) + deco.set_full_width(flag=True, clear=True) + self._block_decos[start_line] = deco - self.editor.decorations.add(deco) + self.editor.decorations.add(deco, key='folded') def _get_block_until_line(self, block, end_line): while block.blockNumber() <= end_line and block.isValid(): @@ -534,31 +497,39 @@ def _get_block_until_line(self, block, end_line): return block def fold_region(self, block, start_line, end_line): - """Fold region spanned by *start_line* and *end_line*.""" + """Fold region spanned by `start_line` and `end_line`.""" + # Note: The block passed to this method is the first one that needs to + # be hidden. + initial_block = self.editor.document().findBlockByNumber( + start_line - 1) + self._add_fold_decoration(initial_block, end_line) + while block.blockNumber() < end_line and block.isValid(): block.setVisible(False) block = block.next() - return block def unfold_region(self, block, start_line, end_line): - """Unfold region spanned by *start_line* and *end_line*.""" + """Unfold region spanned by `start_line` and `end_line`.""" if start_line - 1 in self._block_decos: deco = self._block_decos[start_line - 1] self._block_decos.pop(start_line - 1) - self.editor.decorations.remove(deco) + self.editor.decorations.remove(deco, key='folded') while block.blockNumber() < end_line and block.isValid(): current_line = block.blockNumber() block.setVisible(True) get_next = True - if (current_line in self.folding_regions - and current_line != start_line): + + if ( + current_line in self.folding_regions + and current_line != start_line + ): block_end = self.folding_regions[current_line] if self.folding_status[current_line]: # Skip setting visible blocks until the block is done get_next = False block = self._get_block_until_line(block, block_end - 1) - # pass + if get_next: block = block.next() @@ -569,8 +540,10 @@ def toggle_fold_trigger(self, block): :param block: The QTextBlock to expand/collapse """ start_line = block.blockNumber() + if start_line not in self.folding_regions: return + end_line = self.folding_regions[start_line] if self.folding_status[start_line]: self.unfold_region(block, start_line, end_line) @@ -581,6 +554,7 @@ def toggle_fold_trigger(self, block): self.fold_region(block, start_line, end_line) self.folding_status[start_line] = True self._clear_scope_decos() + self._refresh_editor_and_scrollbars() def mousePressEvent(self, event): @@ -600,59 +574,165 @@ def on_state_changed(self, state): """ if state: self.editor.sig_key_pressed.connect(self._on_key_pressed) + self.editor.sig_delete_requested.connect(self._expand_selection) + if self._highlight_caret: self.editor.cursorPositionChanged.connect( self._highlight_caret_scope) self._block_nbr = -1 - self.editor.new_text_set.connect(self._clear_block_deco) + + self.editor.new_text_set.connect(self._clear_block_decos) else: + self.editor.sig_key_pressed.disconnect(self._on_key_pressed) + self.editor.sig_delete_requested.disconnect(self._expand_selection) + if self._highlight_caret: self.editor.cursorPositionChanged.disconnect( self._highlight_caret_scope) self._block_nbr = -1 - self.editor.new_text_set.disconnect(self._clear_block_deco) + + self.editor.new_text_set.disconnect(self._clear_block_decos) + + def _in_folded_block(self): + """Check if the current block is folded.""" + cursor = self.editor.textCursor() + + if cursor.hasSelection(): + block_start = self.editor.document().findBlock( + cursor.selectionStart() + ) + block_end = self.editor.document().findBlock(cursor.selectionEnd()) + + if ( + # The start block needs to be among the folded ones. + block_start.blockNumber() in self._block_decos + # This covers the case when there's some text selected in the + # folded line or when it's selected in its entirety. For the + # latter, Qt returns the next block as the final one, which + # is not visible. + and (block_start == block_end or not block_end.isVisible()) + ): + return True + else: + return False + else: + current_block = self.editor.document().findBlock(cursor.position()) + return current_block.blockNumber() in self._block_decos def _on_key_pressed(self, event): """ - Override key press to select the current scope if the user wants - to deleted a folded scope (without selecting it). + Handle key press events in order to select a whole folded scope if the + user wants to remove it. + + Notes + ----- + We don't handle Key_Delete here because it's behind a shortcut in + CodeEditor. So, the event associated to that key doesn't go through its + keyPressEvent. + + Instead, CodeEditor emits sig_delete_requested in the method that gets + called when Key_Delete is pressed, and in several other places, which + is handled by _expand_selection below. """ - delete_request = event.key() in {Qt.Key_Delete, Qt.Key_Backspace} + # This doesn't apply if there are not folded regions + if not self._block_decos: + return + + if self._in_folded_block(): + # We prevent the following events to change folded blocks to make + # them appear as read-only to users. + # See the last comments in spyder-ide/spyder#21669 for the details + # of this decision. + if ( + # When Tab or Shift+Tab are pressed + event.key() in [Qt.Key_Tab, Qt.Key_Backtab] + # When text is trying to be written + or event.text() and event.key() != Qt.Key_Backspace + ): + event.accept() + return + + delete_pressed = event.key() == Qt.Key_Backspace + + enter_pressed = False cursor = self.editor.textCursor() if cursor.hasSelection(): if event.key() == Qt.Key_Return: - delete_request = True - - if event.text() or delete_request: - self._key_pressed = True - if cursor.hasSelection(): - # change selection to encompass the whole scope. - positions_to_check = (cursor.selectionStart(), - cursor.selectionEnd()) + enter_pressed = True + + # Delete a folded scope when pressing delete or enter + if delete_pressed or enter_pressed: + self._expand_selection() + + def _expand_selection(self): + """ + Expand selection to encompass a whole folded scope in case the + current selection starts and/or ends in one, or the cursor is over a + block deco. + """ + if not self._block_decos: + return + + cursor = self.editor.textCursor() + self._key_pressed = True + + # If there's no selected text, select the current line but only if + # it corresponds to a block deco. That allows us to remove the folded + # region associated to it when typing Delete or Backspace on the line. + # Otherwise, the editor ends up in an inconsistent state. + if not cursor.hasSelection(): + current_block = self.editor.document().findBlock(cursor.position()) + + if current_block.blockNumber() in self._block_decos: + cursor.select(QTextCursor.LineUnderCursor) else: - positions_to_check = (cursor.position(), ) - for pos in positions_to_check: - block = self.editor.document().findBlock(pos) - start_line = block.blockNumber() + 2 - if (start_line in self.folding_regions and - self.folding_status[start_line]): - end_line = self.folding_regions[start_line] - if delete_request and cursor.hasSelection(): - tc = TextHelper(self.editor).select_lines( - start_line, end_line) - if tc.selectionStart() > cursor.selectionStart(): - start = cursor.selectionStart() - else: - start = tc.selectionStart() - if tc.selectionEnd() < cursor.selectionEnd(): - end = cursor.selectionEnd() - else: - end = tc.selectionEnd() - tc.setPosition(start) - tc.setPosition(end, tc.KeepAnchor) - self.editor.setTextCursor(tc) - self._key_pressed = False + self._key_pressed = False + return + + # Get positions to check if we need to expand the current selection to + # cover a folded region too. + start_pos = cursor.selectionStart() + end_pos = cursor.selectionEnd() + + # A selection can end in an eol when calling CodeEditor.delete_line, + # for instance. In that case, we need to remove it for the code below + # to work as expected. + if cursor.selectedText()[-1] in EOL_SYMBOLS: + end_pos -= 1 + + positions_to_check = (start_pos, end_pos) + for pos in positions_to_check: + block = self.editor.document().findBlock(pos) + start_line = block.blockNumber() + 1 + + if ( + start_line in self.folding_regions + and self.folding_status[start_line] + ): + end_line = self.folding_regions[start_line] + 1 + + if cursor.hasSelection(): + tc = TextHelper(self.editor).select_lines( + start_line, end_line) + + if tc.selectionStart() > cursor.selectionStart(): + start = cursor.selectionStart() + else: + start = tc.selectionStart() + + if tc.selectionEnd() < cursor.selectionEnd(): + end = cursor.selectionEnd() + else: + end = tc.selectionEnd() + + tc.setPosition(start) + tc.setPosition(end, tc.KeepAnchor) + + self.editor.setTextCursor(tc) + + self._update_block_decos(start_pos, end_pos) + self._key_pressed = False def _refresh_editor_and_scrollbars(self): """ @@ -676,7 +756,7 @@ def collapse_all(self): Collapses all triggers and makes all blocks with fold level > 0 invisible. """ - self._clear_block_deco() + self._clear_block_decos() block = self.editor.document().firstBlock() while block.isValid(): line_number = block.blockNumber() @@ -690,13 +770,34 @@ def collapse_all(self): self.editor.setTextCursor(tc) self.collapse_all_triggered.emit() - def _clear_block_deco(self): + def _clear_block_decos(self): """Clear the folded block decorations.""" - for deco_line in self._block_decos: - deco = self._block_decos[deco_line] - self.editor.decorations.remove(deco) + self.editor.decorations.remove_key('folded') self._block_decos = {} + def _update_block_decos(self, start_pos, end_pos): + """ + Update block decorations in case some are going to be removed by the + user. + + Parameters + ---------- + start_pos: int + Start cursor position of the selection that's going to remove or + replace text in the editor + end_pos: int + End cursor position of the same selection. + """ + start_line = self.editor.document().findBlock(start_pos).blockNumber() + end_line = self.editor.document().findBlock(end_pos).blockNumber() + + for deco_line in self._block_decos.copy(): + if start_line <= deco_line <= end_line: + deco = self._block_decos[deco_line] + self._block_decos.pop(deco_line) + self.editor.decorations.remove(deco, key='folded') + self.folding_status[deco_line + 1] = False + def expand_all(self): """Expands all fold triggers.""" block = self.editor.document().firstBlock() @@ -706,7 +807,7 @@ def expand_all(self): end_line = self.folding_regions[line_number] self.unfold_region(block, line_number, end_line) block = block.next() - self._clear_block_deco() + self._clear_block_decos() self._refresh_editor_and_scrollbars() self.expand_all_triggered.emit() diff --git a/spyder/plugins/editor/panels/scrollflag.py b/spyder/plugins/editor/panels/scrollflag.py index 4db246920bc..54824177e7c 100644 --- a/spyder/plugins/editor/panels/scrollflag.py +++ b/spyder/plugins/editor/panels/scrollflag.py @@ -3,18 +3,21 @@ # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) + """ -This module contains the Scroll Flag panel +Scroll flag panel for the editor. """ # Standard library imports -import sys +import logging from math import ceil +import sys # Third party imports -from qtpy.QtCore import QSize, Qt, QTimer -from qtpy.QtGui import QPainter, QColor, QCursor -from qtpy.QtWidgets import (QStyle, QStyleOptionSlider, QApplication) +from qtpy.QtCore import QSize, Qt, QThread +from qtpy.QtGui import QColor, QCursor, QPainter +from qtpy.QtWidgets import QApplication, QStyle, QStyleOptionSlider +from superqt.utils import qdebounced # Local imports from spyder.plugins.completion.api import DiagnosticSeverity @@ -22,9 +25,13 @@ from spyder.plugins.editor.utils.editor import is_block_safe +# For logging +logger = logging.getLogger(__name__) + +# Time to wait before refreshing flags REFRESH_RATE = 1000 -# Maximum number of flags to print in a file +# Maximum number of flags to paint in a file MAX_FLAGS = 1000 @@ -50,13 +57,14 @@ def __init__(self): self._slider_range_brush = QColor(Qt.gray) self._slider_range_brush.setAlphaF(.5) - self._update_list_timer = QTimer(self) - self._update_list_timer.setSingleShot(True) - self._update_list_timer.timeout.connect(self.update_flags) - # Dictionary with flag lists self._dict_flag_list = {} + # Thread to update flags on it. + self._update_flags_thread = QThread(None) + self._update_flags_thread.run = self._update_flags + self._update_flags_thread.finished.connect(self.update) + def on_install(self, editor): """Manages install setup of the pane.""" super().on_install(editor) @@ -80,16 +88,21 @@ def on_install(self, editor): editor.sig_alt_left_mouse_pressed.connect(self.mousePressEvent) editor.sig_alt_mouse_moved.connect(self.mouseMoveEvent) editor.sig_leave_out.connect(self.update) - editor.sig_flags_changed.connect(self.delayed_update_flags) + editor.sig_flags_changed.connect(self.update_flags) editor.sig_theme_colors_changed.connect(self.update_flag_colors) + # This prevents that flags are updated while the user is moving the + # cursor, e.g. when typing. + editor.sig_cursor_position_changed.connect(self.update_flags) + @property def slider(self): """This property holds whether the vertical scrollbar is visible.""" return self.editor.verticalScrollBar().isVisible() def closeEvent(self, event): - self._update_list_timer.stop() + self._update_flags_thread.quit() + self._update_flags_thread.wait() super().closeEvent(event) def sizeHint(self): @@ -105,25 +118,11 @@ def update_flag_colors(self, color_dict): self._facecolors[name] = QColor(color) self._edgecolors[name] = self._facecolors[name].darker(120) - def delayed_update_flags(self): - """ - This function is called every time a flag is changed. - There is no need of updating the flags thousands of time by second, - as it is quite resources-heavy. This limits the calls to REFRESH_RATE. - """ - if self._update_list_timer.isActive(): - return - - self._update_list_timer.start(REFRESH_RATE) - + @qdebounced(timeout=REFRESH_RATE) def update_flags(self): - """ - Update flags list. + """Update flags list in a thread.""" + logger.debug("Updating current flags") - This parses the entire file, which can take a lot of time for - large files. Save all the flags in lists for painting during - paint events. - """ self._dict_flag_list = { 'error': [], 'warning': [], @@ -131,6 +130,12 @@ def update_flags(self): 'breakpoint': [], } + # Run this computation in a different thread to prevent freezing + # the interface + self._update_flags_thread.start() + + def _update_flags(self): + """Update flags list.""" editor = self.editor block = editor.document().firstBlock() while block.isValid(): @@ -138,7 +143,6 @@ def update_flags(self): data = block.userData() if data: if data.code_analysis: - # Paint the errors and warnings for _, _, severity, _ in data.code_analysis: if severity == DiagnosticSeverity.ERROR: flag_type = 'error' @@ -157,8 +161,6 @@ def update_flags(self): block = block.next() - self.update() - def paintEvent(self, event): """ Override Qt method. diff --git a/spyder/plugins/editor/panels/tests/test_scrollflag.py b/spyder/plugins/editor/panels/tests/test_scrollflag.py index f2115f7a532..a917b291b89 100644 --- a/spyder/plugins/editor/panels/tests/test_scrollflag.py +++ b/spyder/plugins/editor/panels/tests/test_scrollflag.py @@ -14,7 +14,6 @@ from qtpy.QtGui import QFont # Local imports -from spyder.config.base import running_in_ci from spyder.plugins.editor.widgets.codeeditor import CodeEditor from spyder.plugins.debugger.utils.breakpointsmanager import BreakpointsManager diff --git a/spyder/plugins/editor/panels/utils.py b/spyder/plugins/editor/panels/utils.py index 9f058b25f4a..1fd8abff811 100644 --- a/spyder/plugins/editor/panels/utils.py +++ b/spyder/plugins/editor/panels/utils.py @@ -13,9 +13,9 @@ # Third-party imports from intervaltree import IntervalTree -# --------------------- Code Folding Panel ------------------------------------ - +# ---- For the code folding panel +# ----------------------------------------------------------------------------- class FoldingRegion: """Internal representation of a code folding region.""" @@ -98,6 +98,12 @@ def values(self): values = dict.values(self) return [x.status for x in values] + def get(self, key): + try: + return self.__getitem__(key) + except KeyError: + return False + def __getitem__(self, key): value = dict.__getitem__(self, key) return value.status @@ -129,20 +135,32 @@ def merge_interval(parent, node): return node -def merge_folding(ranges, current_tree, root): +def merge_folding(ranges, file_lines, eol, current_tree, root): """Compare previous and current code folding tree information.""" # Leave this import here to avoid importing Numpy (which is used by # textdistance) too early at startup. import textdistance folding_ranges = [] - for starting_line, ending_line, text in ranges: - if ending_line > starting_line: - starting_line += 1 - ending_line += 1 - folding_repr = FoldingRegion(text, (starting_line, ending_line)) - folding_ranges.append((starting_line, ending_line, folding_repr)) + for folding_range in ranges: + start_line = folding_range['startLine'] + end_line = folding_range['endLine'] + + if end_line > start_line: + # Get text region that corresponds to the folding range between + # start_line and end_line + lines_in_region = file_lines[start_line:end_line + 1] + text_region = eol.join(lines_in_region) + + # Create data structure to represent the region + start_line += 1 + end_line += 1 + folding_region = FoldingRegion(text_region, (start_line, end_line)) + + # Add the region to the list of ranges + folding_ranges.append((start_line, end_line, folding_region)) + # Compare new and current folding trees tree = IntervalTree.from_tuples(folding_ranges) changes = tree - current_tree deleted = current_tree - tree @@ -154,6 +172,7 @@ def merge_folding(ranges, current_tree, root): changed_entry = next(changes_iter, None) non_merged = 0 + # Update tree while deleted_entry is not None and changed_entry is not None: deleted_entry_i = deleted_entry.data changed_entry_i = changed_entry.data diff --git a/spyder/plugins/editor/utils/decoration.py b/spyder/plugins/editor/utils/decoration.py index 9897ff638a8..634a07d0778 100644 --- a/spyder/plugins/editor/utils/decoration.py +++ b/spyder/plugins/editor/utils/decoration.py @@ -51,7 +51,7 @@ def __init__(self, editor): self.update_timer.timeout.connect( self._update) - def add(self, decorations): + def add(self, decorations, key="misc"): """ Add text decorations on a CodeEditor instance. @@ -63,15 +63,19 @@ def add(self, decorations): Returns: int: Amount of decorations added. """ - current_decorations = self._decorations["misc"] + if key != "misc" and self._decorations.get(key) is None: + self._decorations[key] = [] + + current_decorations = self._decorations[key] added = 0 + if isinstance(decorations, list): not_repeated = set(decorations) - set(current_decorations) current_decorations.extend(list(not_repeated)) - self._decorations["misc"] = current_decorations + self._decorations[key] = current_decorations added = len(not_repeated) elif decorations not in current_decorations: - self._decorations["misc"].append(decorations) + self._decorations[key].append(decorations) added = 1 if added > 0: @@ -83,7 +87,7 @@ def add_key(self, key, decorations): self._decorations[key] = decorations self.update() - def remove(self, decoration): + def remove(self, decoration, key="misc"): """ Removes a text decoration from the editor. @@ -94,10 +98,10 @@ def remove(self, decoration): several decorations """ try: - self._decorations["misc"].remove(decoration) + self._decorations[key].remove(decoration) self.update() return True - except ValueError: + except (ValueError, KeyError): return False def remove_key(self, key): diff --git a/spyder/plugins/editor/widgets/base.py b/spyder/plugins/editor/widgets/base.py index 5f40b989f88..ded45f28b2c 100644 --- a/spyder/plugins/editor/widgets/base.py +++ b/spyder/plugins/editor/widgets/base.py @@ -866,28 +866,6 @@ def extend_selection_to_complete_lines(self): QTextCursor.KeepAnchor) self.setTextCursor(cursor) - def delete_line(self, cursor=None): - """Delete current line.""" - if cursor is None: - cursor = self.textCursor() - if self.has_selected_text(): - self.extend_selection_to_complete_lines() - start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() - cursor.setPosition(start_pos) - else: - start_pos = end_pos = cursor.position() - cursor.beginEditBlock() - cursor.setPosition(start_pos) - cursor.movePosition(QTextCursor.StartOfBlock) - while cursor.position() <= end_pos: - cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) - if cursor.atEnd(): - break - cursor.movePosition(QTextCursor.NextBlock, QTextCursor.KeepAnchor) - cursor.removeSelectedText() - cursor.endEditBlock() - self.ensureCursorVisible() - def set_selection(self, start, end): cursor = self.textCursor() cursor.setPosition(start) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 75d4105cc08..d0ca6d78972 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -208,6 +208,9 @@ class CodeEditor(LSPMixin, TextEditBaseWidget): # Used to request saving a file sig_save_requested = Signal() + # Used to signal that a text deletion was triggered + sig_delete_requested = Signal() + def __init__(self, parent=None): super().__init__(parent=parent) @@ -255,12 +258,17 @@ def __init__(self, parent=None): self._last_hover_pattern_text = None # 79-col edge line - self.edge_line = self.panels.register(EdgeLine(), - Panel.Position.FLOATING) + self.edge_line = self.panels.register( + EdgeLine(), + Panel.Position.FLOATING + ) # indent guides - self.indent_guides = self.panels.register(IndentationGuide(), - Panel.Position.FLOATING) + self.indent_guides = self.panels.register( + IndentationGuide(), + Panel.Position.FLOATING + ) + # Blanks enabled self.blanks_enabled = False @@ -273,11 +281,15 @@ def __init__(self, parent=None): self.background = QColor('white') # Folding - self.panels.register(FoldingPanel()) + self.folding_panel = self.panels.register(FoldingPanel()) # Line number area management self.linenumberarea = self.panels.register(LineNumberArea()) + # Set order for the left panels + self.linenumberarea.order_in_zone = 2 + self.folding_panel.order_in_zone = 0 # Debugger panel is 1 + # Class and Method/Function Dropdowns self.classfuncdropdown = self.panels.register( ClassFunctionDropdown(), @@ -330,8 +342,11 @@ def __init__(self, parent=None): self.found_results_color = QColor(SpyderPalette.COLOR_OCCURRENCE_4) # Scrollbar flag area - self.scrollflagarea = self.panels.register(ScrollFlagArea(), - Panel.Position.RIGHT) + self.scrollflagarea = self.panels.register( + ScrollFlagArea(), + Panel.Position.RIGHT + ) + self.panels.refresh() self.document_id = id(self) @@ -896,14 +911,11 @@ def setup_editor(self, self.set_strip_mode(strip_mode) - # ---- Debug panel - # ------------------------------------------------------------------------- # ---- Set different attributes # ------------------------------------------------------------------------- def set_folding_panel(self, folding): """Enable/disable folding panel.""" - folding_panel = self.panels.get(FoldingPanel) - folding_panel.setVisible(folding) + self.folding_panel.setVisible(folding) def set_tab_mode(self, enable): """ @@ -1280,14 +1292,41 @@ def next_cursor_position(self, position=None, @Slot() def delete(self): """Remove selected text or next character.""" + self.sig_delete_requested.emit() + if not self.has_selected_text(): cursor = self.textCursor() if not cursor.atEnd(): cursor.setPosition( - self.next_cursor_position(), QTextCursor.KeepAnchor) + self.next_cursor_position(), QTextCursor.KeepAnchor + ) self.setTextCursor(cursor) + self.remove_selected_text() + def delete_line(self): + """Delete current line.""" + cursor = self.textCursor() + + if self.has_selected_text(): + self.extend_selection_to_complete_lines() + start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() + cursor.setPosition(start_pos) + else: + start_pos = end_pos = cursor.position() + + cursor.setPosition(start_pos) + cursor.movePosition(QTextCursor.StartOfBlock) + while cursor.position() <= end_pos: + cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + if cursor.atEnd(): + break + cursor.movePosition(QTextCursor.NextBlock, QTextCursor.KeepAnchor) + + self.setTextCursor(cursor) + self.delete() + self.ensureCursorVisible() + # ---- Scrolling # ------------------------------------------------------------------------- def scroll_line_down(self): @@ -1462,6 +1501,7 @@ def highlight_found_results(self, pattern, word=False, regexp=False, regobj = re.compile(pattern, flags=re_flags) except sre_constants.error: return + extra_selections = [] self.found_results = [] has_unicode = len(text) != qstring_length(text) @@ -1811,10 +1851,16 @@ def paste(self): # Align multiline text based on first line cursor = self.textCursor() cursor.beginEditBlock() - cursor.removeSelectedText() + + # We use delete here instead of cursor.removeSelectedText so that + # removing folded regions works as expected. + if cursor.hasSelection(): + self.delete() + cursor.setPosition(cursor.selectionStart()) cursor.setPosition(cursor.block().position(), QTextCursor.KeepAnchor) + preceding_text = cursor.selectedText() first_line_selected, *remaining_lines = (text + eol_chars).splitlines() first_line = preceding_text + first_line_selected @@ -1893,6 +1939,7 @@ def cut(self): return start, end = self.get_selection_start_end() self.sig_will_remove_selection.emit(start, end) + self.sig_delete_requested.emit() TextEditBaseWidget.cut(self) self._save_clipboard_indentation() self.sig_text_was_inserted.emit() @@ -1943,6 +1990,10 @@ def go_to_line(self, line, start_column=0, end_column=0, word=''): end_column=end_column, move=True, word=word) + # This is necessary to update decoratios after moving to `line` (some + # tests fail without it). + self.update_decorations_timer.start() + def exec_gotolinedialog(self): """Execute the GoToLineDialog dialog box""" dlg = GoToLineDialog(self) @@ -2005,6 +2056,9 @@ def update_decorations(self): if self.underline_errors_enabled: self.underline_errors() + if self.folding_supported and self.code_folding: + self.highlight_folded_regions() + # This is required to update decorations whether there are or not # underline errors in the visible portion of the screen. # See spyder-ide/spyder#14268. @@ -4029,25 +4083,27 @@ def move_line_down(self): def __move_line_or_selection(self, after_current_line=True): cursor = self.textCursor() + # Unfold any folded code block before moving lines up/down - folding_panel = self.panels.get('FoldingPanel') fold_start_line = cursor.blockNumber() + 1 block = cursor.block().next() - if fold_start_line in folding_panel.folding_status: - fold_status = folding_panel.folding_status[fold_start_line] + if fold_start_line in self.folding_panel.folding_status: + fold_status = self.folding_panel.folding_status[fold_start_line] if fold_status: - folding_panel.toggle_fold_trigger(block) + self.folding_panel.toggle_fold_trigger(block) if after_current_line: # Unfold any folded region when moving lines down fold_start_line = cursor.blockNumber() + 2 block = cursor.block().next().next() - if fold_start_line in folding_panel.folding_status: - fold_status = folding_panel.folding_status[fold_start_line] + if fold_start_line in self.folding_panel.folding_status: + fold_status = self.folding_panel.folding_status[ + fold_start_line + ] if fold_status: - folding_panel.toggle_fold_trigger(block) + self.folding_panel.toggle_fold_trigger(block) else: # Unfold any folded region when moving lines up block = cursor.block() @@ -4061,9 +4117,9 @@ def __move_line_or_selection(self, after_current_line=True): # Find the innermost code folding region for the current position enclosing_regions = sorted(list( - folding_panel.current_tree[fold_start_line])) + self.folding_panel.current_tree[fold_start_line])) - folding_status = folding_panel.folding_status + folding_status = self.folding_panel.folding_status if len(enclosing_regions) > 0: for region in enclosing_regions: fold_start_line = region.begin @@ -4071,10 +4127,11 @@ def __move_line_or_selection(self, after_current_line=True): if fold_start_line in folding_status: fold_status = folding_status[fold_start_line] if fold_status: - folding_panel.toggle_fold_trigger(block) + self.folding_panel.toggle_fold_trigger(block) self._TextEditBaseWidget__move_line_or_selection( - after_current_line=after_current_line) + after_current_line=after_current_line + ) def mouseMoveEvent(self, event): """Underline words when pressing """ diff --git a/spyder/plugins/editor/widgets/codeeditor/lsp_mixin.py b/spyder/plugins/editor/widgets/codeeditor/lsp_mixin.py index b406deb6373..98657acecfc 100644 --- a/spyder/plugins/editor/widgets/codeeditor/lsp_mixin.py +++ b/spyder/plugins/editor/widgets/codeeditor/lsp_mixin.py @@ -28,7 +28,7 @@ from three_merge import merge # Local imports -from spyder.config.base import get_debug_level, running_under_pytest +from spyder.config.base import running_under_pytest from spyder.plugins.completion.api import ( CompletionRequestTypes, TextDocumentSyncKind, @@ -78,6 +78,10 @@ def wrapper(self, *args, **kwargs): return wrapper +class LSPHandleError(Exception): + """Error raised if there is an error handling an LSP response.""" + + @class_register class LSPMixin: # -- LSP constants @@ -143,7 +147,8 @@ def __init__(self, *args, **kwargs): # Code Folding self.code_folding = True self.update_folding_thread = QThread(None) - self.update_folding_thread.finished.connect(self.finish_code_folding) + self.update_folding_thread.finished.connect( + self._finish_update_folding) # Autoformat on save self.format_on_save = False @@ -233,22 +238,13 @@ def emit_request(self, method, params, requires_response): self.language.lower(), method, params ) - def log_lsp_handle_errors(self, message): + def manage_lsp_handle_errors(self, message): """ - Log errors when handling LSP responses. - - This works when debugging is on or off. + Actions to take when we get errors while handling LSP responses. """ - if get_debug_level() > 0: - # We log the error normally when running on debug mode. - logger.error(message, exc_info=True) - else: - # We need this because logger.error activates our error - # report dialog but it doesn't show the entire traceback - # there. So we intentionally leave an error in this call - # to get the entire stack info generated by it, which - # gives the info we need from users. - logger.error("%", 1, stack_info=True) + # Raise exception so that handle response errors can be reported to + # Github + raise LSPHandleError(message) # ---- Configuration and start/stop # ------------------------------------------------------------------------- @@ -403,7 +399,7 @@ def process_symbols(self, params): # before the response can be processed. return except Exception: - self.log_lsp_handle_errors("Error when processing symbols") + self.manage_lsp_handle_errors("Error when processing symbols") finally: self.symbols_in_sync = True @@ -541,7 +537,7 @@ def set_errors(self): # before the response can be processed. return except Exception: - self.log_lsp_handle_errors("Error when processing linting") + self.manage_lsp_handle_errors("Error when processing linting") def underline_errors(self): """Underline errors and warnings.""" @@ -557,7 +553,7 @@ def underline_errors(self): # before the response can be processed. return except Exception: - self.log_lsp_handle_errors("Error when processing linting") + self.manage_lsp_handle_errors("Error when processing linting") def finish_code_analysis(self): """Finish processing code analysis results.""" @@ -772,7 +768,7 @@ def sort_key(completion): # before the response can be processed. return except Exception: - self.log_lsp_handle_errors("Error when processing completions") + self.manage_lsp_handle_errors("Error when processing completions") @schedule_request(method=CompletionRequestTypes.COMPLETION_RESOLVE) def resolve_completion_item(self, item): @@ -792,7 +788,7 @@ def handle_completion_item_resolution(self, response): # before the response can be processed. return except Exception: - self.log_lsp_handle_errors( + self.manage_lsp_handle_errors( "Error when handling completion item resolution" ) @@ -853,7 +849,7 @@ def process_signatures(self, params): # before the response can be processed. return except Exception: - self.log_lsp_handle_errors("Error when processing signature") + self.manage_lsp_handle_errors("Error when processing signature") # ---- Hover/Cursor # ------------------------------------------------------------------------- @@ -928,7 +924,7 @@ def handle_hover_response(self, contents): # before the response can be processed. return except Exception: - self.log_lsp_handle_errors("Error when processing hover") + self.manage_lsp_handle_errors("Error when processing hover") # ---- Go To Definition # ------------------------------------------------------------------------- @@ -974,7 +970,7 @@ def handle_go_to_definition(self, position): # before the response can be processed. return except Exception: - self.log_lsp_handle_errors( + self.manage_lsp_handle_errors( "Error when processing go to definition" ) @@ -1089,8 +1085,8 @@ def handle_document_formatting(self, edits): # before the response can be processed. return except Exception: - self.log_lsp_handle_errors( - "Error when processing document " "formatting" + self.manage_lsp_handle_errors( + "Error when processing document formatting" ) finally: # Remove read-only parenthesis and highlight document modification @@ -1112,8 +1108,8 @@ def handle_document_range_formatting(self, edits): # before the response can be processed. return except Exception: - self.log_lsp_handle_errors( - "Error when processing document " "selection formatting" + self.manage_lsp_handle_errors( + "Error when processing document selection formatting" ) finally: # Remove read-only parenthesis and highlight document modification @@ -1233,8 +1229,7 @@ def update_whitespace_count(self, line, column): def cleanup_folding(self): """Cleanup folding pane.""" - folding_panel = self.panels.get(FoldingPanel) - folding_panel.folding_regions = {} + self.folding_panel.folding_regions = {} @schedule_request(method=CompletionRequestTypes.DOCUMENT_FOLDING_RANGE) def request_folding(self): @@ -1251,36 +1246,19 @@ def handle_folding_range(self, response): if ranges is None: return - # Compute extended_ranges here because get_text_region ends up - # calling paintEvent and that method can't be called in a - # thread due to Qt restrictions. - try: - extended_ranges = [] - for start, end in ranges: - text_region = self.get_text_region(start, end) - extended_ranges.append((start, end, text_region)) - except RuntimeError: - # This is triggered when a codeeditor instance was removed - # before the response can be processed. - return - except Exception: - self.log_lsp_handle_errors("Error when processing folding") - finally: - self.folding_in_sync = True - - # Update folding in a thread + # Update folding info in a thread self.update_folding_thread.run = functools.partial( - self.update_and_merge_folding, extended_ranges - ) + self._update_folding_info, ranges) self.update_folding_thread.start() - def update_and_merge_folding(self, extended_ranges): - """Update and merge new folding information.""" + def _update_folding_info(self, ranges): + """Update folding information with new data from the LSP.""" try: - folding_panel = self.panels.get(FoldingPanel) + lines = self.toPlainText().splitlines() current_tree, root = merge_folding( - extended_ranges, folding_panel.current_tree, folding_panel.root + ranges, lines, self.get_line_separator(), + self.folding_panel.current_tree, self.folding_panel.root ) folding_info = collect_folding_regions(root) @@ -1290,23 +1268,28 @@ def update_and_merge_folding(self, extended_ranges): # before the response can be processed. return except Exception: - self.log_lsp_handle_errors("Error when processing folding") + self.manage_lsp_handle_errors("Error when processing folding") - def finish_code_folding(self): - """Finish processing code folding.""" - folding_panel = self.panels.get(FoldingPanel) + def highlight_folded_regions(self): + self.folding_panel.highlight_folded_regions() + def _finish_update_folding(self): + """Finish updating code folding.""" # Check if we actually have folding info to update before trying to do # it. # Fixes spyder-ide/spyder#19514 if self._folding_info is not None: - folding_panel.update_folding(self._folding_info) + self.folding_panel.update_folding(self._folding_info) + + self.highlight_folded_regions() # Update indent guides, which depend on folding if self.indent_guides._enabled and len(self.patch) > 0: line, column = self.get_cursor_line_column() self.update_whitespace_count(line, column) + self.folding_in_sync = True + # ---- Save/close file # ------------------------------------------------------------------------- @schedule_request(method=CompletionRequestTypes.DOCUMENT_DID_SAVE, diff --git a/spyder/plugins/editor/widgets/codeeditor/tests/test_decorations.py b/spyder/plugins/editor/widgets/codeeditor/tests/test_decorations.py index bbe43615aaa..4af4407dafe 100644 --- a/spyder/plugins/editor/widgets/codeeditor/tests/test_decorations.py +++ b/spyder/plugins/editor/widgets/codeeditor/tests/test_decorations.py @@ -173,7 +173,7 @@ def test_update_decorations_when_scrolling(qtbot): # Only one call to _update should be done, after releasing the key. qtbot.wait(editor.UPDATE_DECORATIONS_TIMEOUT + 100) - assert _update.call_count == 4 + assert _update.call_count == 5 # Simulate continuously pressing the up arrow key. for __ in range(200): @@ -181,7 +181,7 @@ def test_update_decorations_when_scrolling(qtbot): # Only one call to _update should be done, after releasing the key. qtbot.wait(editor.UPDATE_DECORATIONS_TIMEOUT + 100) - assert _update.call_count == 5 + assert _update.call_count == 6 if __name__ == "__main__": diff --git a/spyder/plugins/editor/widgets/codeeditor/tests/test_folding.py b/spyder/plugins/editor/widgets/codeeditor/tests/test_folding.py index 20ac0aebbcc..ef0f721a4bd 100644 --- a/spyder/plugins/editor/widgets/codeeditor/tests/test_folding.py +++ b/spyder/plugins/editor/widgets/codeeditor/tests/test_folding.py @@ -7,15 +7,21 @@ """Tests for the folding features.""" # Standard library imports -import os +import sys # Third party imports from flaky import flaky import pytest +import pytestqt from qtpy.QtCore import Qt +from qtpy.QtGui import QTextCursor +# Local imports +from spyder.config.base import running_in_ci -# ---Fixtures----------------------------------------------------------------- + +# ---- Code to test +# ----------------------------------------------------------------------------- text = """ def myfunc2(): x = [0, 1, 2, 3, @@ -54,16 +60,19 @@ def myfunc2(): }""" +# ---- Tests +# ----------------------------------------------------------------------------- @pytest.mark.order(2) @flaky(max_runs=5) def test_folding(completions_codeeditor, qtbot): - code_editor, _ = completions_codeeditor + code_editor, __ = completions_codeeditor code_editor.toggle_code_folding(True) code_editor.insert_text(text) folding_panel = code_editor.panels.get('FoldingPanel') # Wait for the update thread to finish - qtbot.wait(3000) + qtbot.waitSignal(code_editor.update_folding_thread.finished) + qtbot.waitUntil(lambda: code_editor.folding_in_sync) folding_regions = folding_panel.folding_regions folding_levels = folding_panel.folding_levels @@ -79,7 +88,6 @@ def test_folding(completions_codeeditor, qtbot): @pytest.mark.order(2) @flaky(max_runs=5) -@pytest.mark.skipif(os.name == 'nt', reason="Hangs on Windows") def test_unfold_when_searching(search_codeeditor, qtbot): editor, finder = search_codeeditor editor.toggle_code_folding(True) @@ -88,7 +96,8 @@ def test_unfold_when_searching(search_codeeditor, qtbot): editor.insert_text(text) # Wait for the update thread to finish - qtbot.wait(3000) + qtbot.waitSignal(editor.update_folding_thread.finished) + qtbot.waitUntil(lambda: editor.folding_in_sync) line_search = editor.document().findBlockByLineNumber(3) @@ -107,15 +116,15 @@ def test_unfold_when_searching(search_codeeditor, qtbot): @pytest.mark.order(2) @flaky(max_runs=5) -@pytest.mark.skipif(os.name == 'nt', reason="Hangs on Windows") -def test_unfold_goto(search_codeeditor, qtbot): - editor, finder = search_codeeditor +def test_unfold_goto(completions_codeeditor, qtbot): + editor, __ = completions_codeeditor editor.toggle_code_folding(True) editor.insert_text(text) folding_panel = editor.panels.get('FoldingPanel') # Wait for the update thread to finish - qtbot.wait(3000) + qtbot.waitSignal(editor.update_folding_thread.finished) + qtbot.waitUntil(lambda: editor.folding_in_sync) line_goto = editor.document().findBlockByLineNumber(5) @@ -128,3 +137,223 @@ def test_unfold_goto(search_codeeditor, qtbot): editor.go_to_line(6) assert line_goto.isVisible() editor.toggle_code_folding(False) + + +@flaky(max_runs=5) +@pytest.mark.order(2) +@pytest.mark.skipif( + running_in_ci() and sys.platform.startswith("linux"), + reason="Fails on Linux and CIs" +) +def test_delete_folded_line(completions_codeeditor, qtbot): + editor, __ = completions_codeeditor + editor.toggle_code_folding(True) + editor.insert_text(text) + folding_panel = editor.panels.get('FoldingPanel') + + def fold_and_delete_region(key): + # Wait for the update thread to finish + qtbot.waitSignal(editor.update_folding_thread.finished) + qtbot.waitUntil(lambda: editor.folding_in_sync) + + # fold region + folded_line = editor.document().findBlockByLineNumber(5) + folding_panel.toggle_fold_trigger( + editor.document().findBlockByLineNumber(2) + ) + qtbot.waitUntil(lambda: folding_panel.folding_status.get(2)) + assert not folded_line.isVisible() + + editor.go_to_line(2) + if key == Qt.Key_Delete: + move = QTextCursor.MoveAnchor + else: + move = QTextCursor.KeepAnchor + editor.moveCursor(QTextCursor.NextCharacter, move) + + # Press Delete or Backspace in folded line + qtbot.keyPress(editor, key) + + # Remove folded line with Delete key + fold_and_delete_region(Qt.Key_Delete) + + # Check entire folded region was removed + assert "myfunc2" not in editor.toPlainText() + assert "print" not in editor.toPlainText() + assert editor.blockCount() == 31 + + # Press Ctrl+Z + qtbot.keyClick(editor, Qt.Key_Z, Qt.ControlModifier) + + # Remove folded line with Backspace key + fold_and_delete_region(Qt.Key_Backspace) + + # Check entire folded region was removed + assert "myfunc2" not in editor.toPlainText() + assert "print" not in editor.toPlainText() + assert editor.blockCount() == 31 + + # Press Ctrl+Z again + qtbot.keyClick(editor, Qt.Key_Z, Qt.ControlModifier) + + # Wait for the update thread to finish + qtbot.waitSignal(editor.update_folding_thread.finished) + qtbot.waitUntil(lambda: editor.folding_in_sync) + + # Check the folded region was restored + assert "myfunc2" in editor.toPlainText() + assert "print" in editor.toPlainText() + assert editor.blockCount() == 36 + + # Check first folding was computed correctly again + assert folding_panel.folding_regions.get(2) == 6 + + editor.toggle_code_folding(False) + + +@flaky(max_runs=5) +@pytest.mark.order(2) +@pytest.mark.skipif( + running_in_ci() and sys.platform.startswith("linux"), + reason="Fails on Linux and CIs" +) +def test_delete_selections_with_folded_lines(completions_codeeditor, qtbot): + editor, __ = completions_codeeditor + editor.toggle_code_folding(True) + editor.insert_text(text) + folding_panel = editor.panels.get('FoldingPanel') + + def remove_selection_with_fold_line(start_line, nlines_to_select): + # Wait for the update thread to finish + qtbot.waitSignal(editor.update_folding_thread.finished) + qtbot.waitUntil(lambda: editor.folding_in_sync) + + # fold region + folded_line = editor.document().findBlockByLineNumber(5) + folding_panel.toggle_fold_trigger( + editor.document().findBlockByLineNumber(2) + ) + qtbot.waitUntil(lambda: folding_panel.folding_status.get(2)) + assert not folded_line.isVisible() + + # Create a selection + editor.go_to_line(start_line) + + visible_lines = 0 + for i in range(editor.blockCount()): + if i < start_line: + continue + editor.moveCursor(QTextCursor.NextBlock, QTextCursor.KeepAnchor) + if editor.textCursor().block().isVisible(): + visible_lines += 1 + if visible_lines == nlines_to_select: + break + + editor.moveCursor(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + + # Press Delete key + qtbot.keyPress(editor, Qt.Key_Delete) + + def restore_initial_state(): + # Press Ctrl+Z + qtbot.keyClick(editor, Qt.Key_Z, Qt.ControlModifier) + + # Wait for the update thread to finish + qtbot.waitSignal(editor.update_folding_thread.finished) + qtbot.waitUntil(lambda: editor.folding_in_sync) + + # Check the folded region was restored + assert "myfunc2" in editor.toPlainText() + assert editor.blockCount() == 36 + + # Remove a selection that ends in the folded region + remove_selection_with_fold_line(start_line=1, nlines_to_select=1) + + # Check folded region was removed + assert "print" not in editor.toPlainText() + assert editor.blockCount() == 30 + + restore_initial_state() + + # Remove a selection that starts wtih the folded region + remove_selection_with_fold_line(start_line=2, nlines_to_select=2) + + # Check folded region was removed + assert "print" not in editor.toPlainText() + assert "responses" not in editor.toPlainText() + assert editor.blockCount() == 30 + + restore_initial_state() + + # Remove a selection that has a folded region in the middle + remove_selection_with_fold_line(start_line=1, nlines_to_select=4) + + # Check folded region was removed + assert "print" not in editor.toPlainText() + assert "responses" not in editor.toPlainText() + assert "100" not in editor.toPlainText() + assert editor.blockCount() == 28 + + editor.toggle_code_folding(False) + + +@flaky(max_runs=5) +@pytest.mark.order(2) +def test_preserve_folded_regions_after_paste(completions_codeeditor, qtbot): + editor, __ = completions_codeeditor + editor.toggle_code_folding(True) + editor.insert_text(text) + folding_panel = editor.panels.get('FoldingPanel') + + def copy_region(start_line, nlines_to_select): + editor.go_to_line(start_line) + for __ in range(nlines_to_select): + editor.moveCursor(QTextCursor.NextBlock, QTextCursor.KeepAnchor) + editor.moveCursor(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + editor.copy() + + def paste_region(start_line, nenters, paste_line=None): + editor.go_to_line(start_line) + for __ in range(nenters): + qtbot.keyPress(editor, Qt.Key_Return) + if paste_line: + editor.go_to_line(paste_line) + editor.paste() + + # Wait for the update thread to finish + qtbot.waitSignal(editor.update_folding_thread.finished) + qtbot.waitUntil(lambda: editor.folding_in_sync) + + # Fold last region + folding_panel.toggle_fold_trigger( + editor.document().findBlockByLineNumber(34) + ) + assert folding_panel.folding_status[34] + + # First region to copy/paste (code has a syntax error). + copy_region(start_line=7, nlines_to_select=13) + paste_region(start_line=7, nenters=2, paste_line=8) + + # Check folding was preserved + editor.go_to_line(49) + qtbot.waitUntil(lambda: folding_panel.folding_status.get(49)) + + # Second region to copy/paste + copy_region(start_line=10, nlines_to_select=12) + paste_region(start_line=22, nenters=1, paste_line=22) + + # Make the code syntactically correct + qtbot.keyClicks(editor, "}") + + # Check folding decos are not updated out of the visible buffer + with pytest.raises(pytestqt.exceptions.TimeoutError): + qtbot.waitUntil( + lambda: folding_panel.folding_status.get(62), + timeout=3000 + ) + + # Show expected folded line to get folding updated. + editor.go_to_line(62) + + # Check folding was preserved + qtbot.waitUntil(lambda: folding_panel.folding_status.get(62)) diff --git a/spyder/plugins/editor/widgets/codeeditor/tests/test_hints_and_calltips.py b/spyder/plugins/editor/widgets/codeeditor/tests/test_hints_and_calltips.py index 3ae459447fc..6c67f6d479b 100644 --- a/spyder/plugins/editor/widgets/codeeditor/tests/test_hints_and_calltips.py +++ b/spyder/plugins/editor/widgets/codeeditor/tests/test_hints_and_calltips.py @@ -16,7 +16,7 @@ import pytest # Local imports -from spyder.config.base import running_in_ci +from spyder.config.base import running_in_ci, running_in_ci_with_conda from spyder.plugins.editor.extensions.closebrackets import ( CloseBracketsExtension ) @@ -169,10 +169,6 @@ def test_get_hints(qtbot, completions_codeeditor, params, capsys): assert expected_output_text in output_text code_editor.tooltip_widget.hide() - # This checks that code_editor.log_lsp_handle_errors was not called - captured = capsys.readouterr() - assert captured.err == '' - @pytest.mark.order(2) @pytest.mark.skipif(sys.platform == 'darwin', reason='Fails on Mac') @@ -215,7 +211,15 @@ def test_get_hints_not_triggered(qtbot, completions_codeeditor, text): @pytest.mark.order(2) -@pytest.mark.skipif(sys.platform == 'darwin', reason='Fails on Mac') +@pytest.mark.skipif( + ( + sys.platform == "darwin" + or ( + sys.platform.startswith("linux") and not running_in_ci_with_conda() + ) + ), + reason="Fails on Linux with pip packages and Mac", +) @pytest.mark.parametrize( "params", [ @@ -270,7 +274,15 @@ def test_get_hints_for_builtins(qtbot, completions_codeeditor, params): @pytest.mark.order(2) -@pytest.mark.skipif(sys.platform == 'darwin', reason='Fails on Mac') +@pytest.mark.skipif( + ( + sys.platform == "darwin" + or ( + sys.platform.startswith("linux") and not running_in_ci_with_conda() + ) + ), + reason="Fails on Linux with pip packages and Mac", +) @pytest.mark.parametrize( "params", [ diff --git a/spyder/plugins/editor/widgets/editorstack/tests/test_save.py b/spyder/plugins/editor/widgets/editorstack/tests/test_save.py index ccbd138f1b1..4b29c4a0f4c 100644 --- a/spyder/plugins/editor/widgets/editorstack/tests/test_save.py +++ b/spyder/plugins/editor/widgets/editorstack/tests/test_save.py @@ -528,7 +528,7 @@ def test_save_as_lsp_calls(completions_editor, mocker, qtbot, tmpdir): """ Test that EditorStack.save_as() sends the expected LSP requests. - Regression test for spyder-ide/spyder#13085 and spyder-ide/spyder#20047 + Regression test for spyder-ide/spyder#13085 and spyder-ide/spyder#20047. """ file_path, editorstack, code_editor, completion_plugin = completions_editor @@ -572,8 +572,9 @@ def foo(x): qtbot.waitUntil(symbols_and_folding_processed, timeout=5000) # Check response by LSP - assert code_editor.handle_folding_range.call_args == \ - mocker.call({'params': [(1, 3)]}) + assert code_editor.handle_folding_range.call_args == mocker.call( + {"params": [{"startLine": 1, "endLine": 3}]} + ) symbols = [ { @@ -664,8 +665,14 @@ def bar(): # responded to the requests). # Check that LSP responded with updated folding and symbols information - assert code_editor.handle_folding_range.call_args == \ - mocker.call({'params': [(1, 5), (7, 9)]}) + assert code_editor.handle_folding_range.call_args == mocker.call( + { + "params": [ + {"startLine": 1, "endLine": 5}, + {"startLine": 7, "endLine": 9}, + ] + } + ) # There must be 7 symbols (2 functions and 5 variables) assert len(code_editor.process_symbols.call_args.args[0]['params']) == 7 diff --git a/spyder/utils/icon_manager.py b/spyder/utils/icon_manager.py index a69c98c6835..73ad5fef8c3 100644 --- a/spyder/utils/icon_manager.py +++ b/spyder/utils/icon_manager.py @@ -346,10 +346,8 @@ def __init__(self): 'spyder.memory_profiler': [('mdi.eye',), {'color': self.MAIN_FG_COLOR}], 'spyder.line_profiler': [('mdi.eye',), {'color': self.MAIN_FG_COLOR}], 'symbol_find': [('mdi.at',), {'color': self.MAIN_FG_COLOR}], - 'folding.arrow_right_off': [('mdi.menu-right',), {'color': SpyderPalette.GROUP_3}], - 'folding.arrow_right_on': [('mdi.menu-right',), {'color': self.MAIN_FG_COLOR}], - 'folding.arrow_down_off': [('mdi.menu-down',), {'color': SpyderPalette.GROUP_3}], - 'folding.arrow_down_on': [('mdi.menu-down',), {'color': self.MAIN_FG_COLOR}], + 'folding.arrow_right': [('mdi.chevron-right',), {'color': self.MAIN_FG_COLOR}], + 'folding.arrow_down': [('mdi.chevron-down',), {'color': self.MAIN_FG_COLOR}], 'lspserver.down': [('mdi.close',), {'color': self.MAIN_FG_COLOR}], 'lspserver.ready': [('mdi.check',), {'color': self.MAIN_FG_COLOR}], 'dependency_ok': [('mdi.check',), {'color': SpyderPalette.COLOR_SUCCESS_2}], diff --git a/spyder/widgets/mixins.py b/spyder/widgets/mixins.py index 5495856b6d3..7519733af94 100644 --- a/spyder/widgets/mixins.py +++ b/spyder/widgets/mixins.py @@ -102,7 +102,8 @@ class BaseEditMixin(object): def __init__(self): self.eol_chars = None - #------Line number area + # ---- Line number area + # ------------------------------------------------------------------------- def get_linenumberarea_width(self): """Return line number area width""" # Implemented in CodeEditor, but needed for calltip/completion widgets @@ -117,7 +118,8 @@ def calculate_real_position(self, point): """ return point - # --- Tooltips and Calltips + # ---- Tooltips and Calltips + # ------------------------------------------------------------------------- def _calculate_position(self, at_line=None, at_point=None): """ Calculate a global point position `QPoint(x, y)`, for a given @@ -732,11 +734,13 @@ def hide_calltip(self): """Hide the calltip widget.""" self.calltip_widget.hide() - # ----- Required methods for the LSP + # ---- Required methods for the LSP + # ------------------------------------------------------------------------- def document_did_change(self, text=None): pass - #------EOL characters + # ---- EOL characters + # ------------------------------------------------------------------------- def set_eol_chars(self, text=None, eol_chars=None): """ Set widget end-of-line (EOL) characters. @@ -785,7 +789,8 @@ def get_text_with_eol(self): text = text.replace(symbol, linesep) return text - #------Positions, coordinates (cursor, EOF, ...) + # ---- Positions, coordinates (cursor, EOF, ...) + # ------------------------------------------------------------------------- def get_position(self, subject): """Get offset in character for the given subject from the start of text edit area""" @@ -930,7 +935,8 @@ def move_cursor_to_next(self, what='word', direction='left'): """ self.__move_cursor_anchor(what, direction, QTextCursor.MoveAnchor) - #------Selection + # ---- Selection + # ------------------------------------------------------------------------- def extend_selection_to_next(self, what='word', direction='left'): """ Extend selection to next *what* ('word' or 'character') @@ -938,8 +944,8 @@ def extend_selection_to_next(self, what='word', direction='left'): """ self.__move_cursor_anchor(what, direction, QTextCursor.KeepAnchor) - #------Text: get, set, ... - + # ---- Text: get, set, ... + # ------------------------------------------------------------------------- def _select_text(self, position_from, position_to): """Select text and return cursor.""" position_from = self.get_position(position_from) @@ -957,18 +963,24 @@ def get_text_line(self, line_nb): cursor.movePosition(QTextCursor.EndOfBlock, mode=QTextCursor.KeepAnchor) return to_text_string(cursor.selectedText()) - def get_text_region(self, start_line, end_line): - """Return text lines spanned from *start_line* to *end_line*.""" - start_block = self.document().findBlockByNumber(start_line) - end_block = self.document().findBlockByNumber(end_line) + def get_text_region(self, start_line, end_line, lines=None): + """ + Return text in a given region. + + Parameters + ---------- + start_line: int + Start line of the region. + end_line: int + End line of the region. + lines: list, optional (default None) + File lines. + """ + if lines is None: + lines = self.toPlainText().splitlines() - start_cursor = QTextCursor(start_block) - start_cursor.movePosition(QTextCursor.StartOfBlock) - end_cursor = QTextCursor(end_block) - end_cursor.movePosition(QTextCursor.EndOfBlock) - end_position = end_cursor.position() - start_cursor.setPosition(end_position, mode=QTextCursor.KeepAnchor) - return self.get_selected_text(start_cursor) + lines_in_region = lines[start_line:end_line + 1] + return self.get_line_separator().join(lines_in_region) def get_text(self, position_from, position_to, remove_newlines=True): """Returns text between *position_from* and *position_to*. @@ -1240,6 +1252,8 @@ def get_selection_start_end(self, cursor=None): end_position = self.get_cursor_line_column(end_cursor) return start_position, end_position + # ---- Text selection + # ------------------------------------------------------------------------- def get_selection_offsets(self, cursor=None): """Return selection start and end offset positions.""" if cursor is None: @@ -1247,7 +1261,6 @@ def get_selection_offsets(self, cursor=None): start, end = cursor.selectionStart(), cursor.selectionEnd() return start, end - #------Text selection def has_selected_text(self): """Returns True if some text is selected.""" return bool(to_text_string(self.textCursor().selectedText())) @@ -1261,8 +1274,9 @@ def get_selected_text(self, cursor=None): """ if cursor is None: cursor = self.textCursor() - return to_text_string(cursor.selectedText()).replace(u"\u2029", - self.get_line_separator()) + return to_text_string(cursor.selectedText()).replace( + u"\u2029", self.get_line_separator() + ) def remove_selected_text(self): """Delete selected text.""" @@ -1302,8 +1316,8 @@ def replace(self, text, pattern=None): self.sig_text_was_inserted.emit() cursor.endEditBlock() - - #------Find/replace + # ---- Find/replace + # ------------------------------------------------------------------------- def find_multiline_pattern(self, regexp, cursor, findflag): """Reimplement QTextDocument's find method. @@ -1440,7 +1454,8 @@ def get_match_number(self, pattern, case=False, regexp=False, word=False): word=word) return match_number - # --- Array builder helper / See 'spyder/widgets/arraybuilder.py' + # ---- Array builder helper / See 'spyder/widgets/arraybuilder.py' + # ------------------------------------------------------------------------- def enter_array_inline(self): """Enter array builder inline mode.""" self._enter_array(True) @@ -1500,7 +1515,6 @@ def __init__(self): self.anchor = None self.setMouseTracking(True) - #------Mouse events def mouseReleaseEvent(self, event): """Go to error or link in anchor.""" self.QT_CLASS.mouseReleaseEvent(self, event)