# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) """Mix-in classes These classes were created to be able to provide Spyder's regular text and console widget features to an independent widget based on QTextEdit for the IPython console plugin. """ # Standard library imports from __future__ import print_function import os import os.path as osp import re import sre_constants import sys import textwrap # Third party imports from qtpy.QtCore import QPoint, QRegularExpression, Qt from qtpy.QtGui import QCursor, QTextCursor, QTextDocument from qtpy.QtWidgets import QApplication from qtpy import QT_VERSION from spyder_kernels.utils.dochelpers import (getargspecfromtext, getobj, getsignaturefromtext) # Local imports from spyder.config.manager import CONF from spyder.py3compat import is_text_string, to_text_string from spyder.utils import encoding, sourcecode from spyder.utils import syntaxhighlighters as sh from spyder.utils.misc import get_error_match from spyder.utils.palette import QStylePalette from spyder.widgets.arraybuilder import ArrayBuilderDialog class BaseEditMixin(object): _PARAMETER_HIGHLIGHT_COLOR = QStylePalette.COLOR_ACCENT_4 _DEFAULT_TITLE_COLOR = QStylePalette.COLOR_ACCENT_4 _CHAR_HIGHLIGHT_COLOR = QStylePalette.COLOR_ACCENT_4 _DEFAULT_TEXT_COLOR = QStylePalette.COLOR_TEXT_2 _DEFAULT_LANGUAGE = 'python' _DEFAULT_MAX_LINES = 10 _DEFAULT_MAX_WIDTH = 60 _DEFAULT_COMPLETION_HINT_MAX_WIDTH = 52 _DEFAULT_MAX_HINT_LINES = 20 _DEFAULT_MAX_HINT_WIDTH = 85 # The following signals are used to indicate text changes on the editor. sig_will_insert_text = None sig_will_remove_selection = None sig_text_was_inserted = None _styled_widgets = set() def __init__(self): self.eol_chars = None self.calltip_size = 600 #------Line number area def get_linenumberarea_width(self): """Return line number area width""" # Implemented in CodeEditor, but needed for calltip/completion widgets return 0 def calculate_real_position(self, point): """ Add offset to a point, to take into account the Editor panels. This is reimplemented in CodeEditor, in other widgets it returns the same point. """ return point # --- Tooltips and Calltips def _calculate_position(self, at_line=None, at_point=None): """ Calculate a global point position `QPoint(x, y)`, for a given line, local cursor position, or local point. """ font = self.font() if at_point is not None: # Showing tooltip at point position margin = (self.document().documentMargin() / 2) + 1 cx = int(at_point.x() - margin) cy = int(at_point.y() - margin) elif at_line is not None: # Showing tooltip at line cx = 5 line = at_line - 1 cursor = QTextCursor(self.document().findBlockByNumber(line)) cy = int(self.cursorRect(cursor).top()) else: # Showing tooltip at cursor position cx, cy = self.get_coordinates('cursor') cx = int(cx) cy = int(cy - font.pointSize() / 2) # Calculate vertical delta # The needed delta changes with font size, so we use a power law if sys.platform == 'darwin': delta = int((font.pointSize() * 1.20) ** 0.98 + 4.5) elif os.name == 'nt': delta = int((font.pointSize() * 1.20) ** 1.05) + 7 else: delta = int((font.pointSize() * 1.20) ** 0.98) + 7 # delta = font.pointSize() + 5 # Map to global coordinates point = self.mapToGlobal(QPoint(cx, cy)) point = self.calculate_real_position(point) point.setY(point.y() + delta) return point def _update_stylesheet(self, widget): """Update the background stylesheet to make it lighter.""" # Update the stylesheet for a given widget at most once # because Qt is slow to repeatedly parse & apply CSS if id(widget) in self._styled_widgets: return self._styled_widgets.add(id(widget)) background = QStylePalette.COLOR_BACKGROUND_4 border = QStylePalette.COLOR_TEXT_4 name = widget.__class__.__name__ widget.setObjectName(name) css = ''' {0}#{0} {{ background-color:{1}; border: 1px solid {2}; }}'''.format(name, background, border) widget.setStyleSheet(css) def _get_inspect_shortcut(self): """ Queries the editor's config to get the current "Inspect" shortcut. """ value = CONF.get('shortcuts', 'editor/inspect current object') if value: if sys.platform == "darwin": value = value.replace('Ctrl', 'Cmd') return value def _format_text(self, title=None, signature=None, text=None, inspect_word=None, title_color=None, max_lines=None, max_width=_DEFAULT_MAX_WIDTH, display_link=False, text_new_line=False, with_html_format=False): """ Create HTML template for calltips and tooltips. This will display title and text as separate sections and add `...` ---------------------------------------- | `title` (with `title_color`) | ---------------------------------------- | `signature` | | | | `text` (ellided to `max_lines`) | | | ---------------------------------------- | Link or shortcut with `inspect_word` | ---------------------------------------- """ BASE_TEMPLATE = u'''
{main_text}
''' # Get current font properties font = self.font() font_family = font.family() title_size = font.pointSize() text_size = title_size - 1 if title_size > 9 else title_size text_color = self._DEFAULT_TEXT_COLOR template = '' if title: template += BASE_TEMPLATE.format( font_family=font_family, size=title_size, color=title_color, main_text=title, ) if text or signature: template += '
' if signature: signature = signature.strip('\r\n') template += BASE_TEMPLATE.format( font_family=font_family, size=text_size, color=text_color, main_text=signature, ) # Documentation/text handling if (text is None or not text.strip() or text.strip() == ''): text = 'No documentation available' else: text = text.strip() if not with_html_format: # All these replacements are need to properly divide the # text in actual paragraphs and wrap the text on each one paragraphs = (text .replace(u"\xa0", u" ") .replace("\n\n", "") .replace(".\n", ".") .replace("\n-", "-") .replace("-\n", "-") .replace("\n=", "=") .replace("=\n", "=") .replace("\n*", "*") .replace("*\n", "*") .replace("\n ", " ") .replace(" \n", " ") .replace("\n", " ") .replace("", "\n\n") .replace("", "\n").splitlines()) new_paragraphs = [] for paragraph in paragraphs: # Wrap text new_paragraph = textwrap.wrap(paragraph, width=max_width) # Remove empty lines at the beginning new_paragraph = [l for l in new_paragraph if l.strip()] # Merge paragraph text new_paragraph = '\n'.join(new_paragraph) # Add new paragraph new_paragraphs.append(new_paragraph) # Join paragraphs and split in lines for max_lines check paragraphs = '\n'.join(new_paragraphs) paragraphs = paragraphs.strip('\r\n') lines = paragraphs.splitlines() # Check that the first line is not empty if len(lines) > 0 and not lines[0].strip(): lines = lines[1:] else: lines = [l for l in text.split('\n') if l.strip()] # Limit max number of text displayed if max_lines: if len(lines) > max_lines: text = '\n'.join(lines[:max_lines]) + ' ...' else: text = '\n'.join(lines) text = text.replace('\n', '
') if text_new_line and signature: text = '
' + text template += BASE_TEMPLATE.format( font_family=font_family, size=text_size, color=text_color, main_text=text, ) help_text = '' if inspect_word: if display_link: help_text = ( '' 'Click anywhere in this tooltip for additional help' ''.format( font_size=text_size, font_family=font_family, ) ) else: shortcut = self._get_inspect_shortcut() if shortcut: base_style = ( f'background-color:{QStylePalette.COLOR_BACKGROUND_4};' f'color:{QStylePalette.COLOR_TEXT_1};' 'font-size:11px;' ) help_text = '' # ( # 'Press ' # '[' # '' # '{0}] for aditional ' # 'help'.format(shortcut, base_style) # ) if help_text and inspect_word: if display_link: template += ( '
' '
' f'' ''.format(font_family=font_family, size=text_size) ) + help_text + '
' else: template += ( '
' '
' '' '' + help_text + '
' ) return template def _format_signature(self, signatures, parameter=None, max_width=_DEFAULT_MAX_WIDTH, parameter_color=_PARAMETER_HIGHLIGHT_COLOR, char_color=_CHAR_HIGHLIGHT_COLOR, language=_DEFAULT_LANGUAGE): """ Create HTML template for signature. This template will include indent after the method name, a highlight color for the active parameter and highlights for special chars. Special chars depend on the language. """ language = getattr(self, 'language', language).lower() active_parameter_template = ( '' '{parameter}' '' ) chars_template = ( '{char}' '' ) def handle_sub(matchobj): """ Handle substitution of active parameter template. This ensures the correct highlight of the active parameter. """ match = matchobj.group(0) new = match.replace(parameter, active_parameter_template) return new if not isinstance(signatures, list): signatures = [signatures] new_signatures = [] for signature in signatures: # Remove duplicate spaces signature = ' '.join(signature.split()) # Replace initial spaces signature = signature.replace('( ', '(') # Process signature template if parameter and language == 'python': # Escape all possible regex characters # ( ) { } | [ ] . ^ $ * + escape_regex_chars = ['|', '.', '^', '$', '*', '+'] remove_regex_chars = ['(', ')', '{', '}', '[', ']'] regex_parameter = parameter for regex_char in escape_regex_chars + remove_regex_chars: if regex_char in escape_regex_chars: escape_char = r'\{char}'.format(char=regex_char) regex_parameter = regex_parameter.replace(regex_char, escape_char) else: regex_parameter = regex_parameter.replace(regex_char, '') parameter = parameter.replace(regex_char, '') pattern = (r'[\*|\(|\[|\s](' + regex_parameter + r')[,|\)|\]|\s|=]') formatted_lines = [] name = signature.split('(')[0] indent = ' ' * (len(name) + 1) rows = textwrap.wrap(signature, width=max_width, subsequent_indent=indent) for row in rows: if parameter and language == 'python': # Add template to highlight the active parameter row = re.sub(pattern, handle_sub, row) row = row.replace(' ', ' ') row = row.replace('span ', 'span ') row = row.replace('{}', '{{}}') if language and language == 'python': for char in ['(', ')', ',', '*', '**']: new_char = chars_template.format(char=char) row = row.replace(char, new_char) formatted_lines.append(row) title_template = '
'.join(formatted_lines) # Get current font properties font = self.font() font_size = font.pointSize() font_family = font.family() # Format title to display active parameter if parameter and language == 'python': title = title_template.format( font_size=font_size, font_family=font_family, color=parameter_color, parameter=parameter, ) else: title = title_template new_signatures.append(title) return '
'.join(new_signatures) def _check_signature_and_format(self, signature_or_text, parameter=None, inspect_word=None, max_width=_DEFAULT_MAX_WIDTH, language=_DEFAULT_LANGUAGE): """ LSP hints might provide docstrings instead of signatures. This method will check for multiple signatures (dict, type etc...) and format the text accordingly. """ open_func_char = '' has_signature = False has_multisignature = False language = getattr(self, 'language', language).lower() signature_or_text = signature_or_text.replace('\\*', '*') # Remove special symbols that could itefere with ''.format signature_or_text = signature_or_text.replace('{', '{') signature_or_text = signature_or_text.replace('}', '}') # Remove 'ufunc' signature if needed. See spyder-ide/spyder#11821 lines = [line for line in signature_or_text.split('\n') if 'ufunc' not in line] signature_or_text = '\n'.join(lines) if language == 'python': open_func_char = '(' has_multisignature = False if inspect_word: has_signature = signature_or_text.startswith(inspect_word) else: idx = signature_or_text.find(open_func_char) inspect_word = signature_or_text[:idx] has_signature = True if has_signature: name_plus_char = inspect_word + open_func_char all_lines = [] for line in lines: if (line.startswith(name_plus_char) and line.count(name_plus_char) > 1): sublines = line.split(name_plus_char) sublines = [name_plus_char + l for l in sublines] sublines = [l.strip() for l in sublines] else: sublines = [line] all_lines = all_lines + sublines lines = all_lines count = 0 for line in lines: if line.startswith(name_plus_char): count += 1 # Signature type has_signature = count == 1 has_multisignature = count > 1 and len(lines) > 1 if has_signature and not has_multisignature: for i, line in enumerate(lines): if line.strip() == '': break if i == 0: signature = lines[0] extra_text = None else: signature = '\n'.join(lines[:i]) extra_text = '\n'.join(lines[i:]) if signature: new_signature = self._format_signature( signatures=signature, parameter=parameter, max_width=max_width ) elif has_multisignature: signature = signature_or_text.replace(name_plus_char, '
' + name_plus_char) signature = signature[4:] # Remove the first line break signature = signature.replace('\n', ' ') signature = signature.replace(r'\\*', '*') signature = signature.replace(r'\*', '*') signature = signature.replace('
', '\n') signatures = signature.split('\n') signatures = [sig for sig in signatures if sig] # Remove empty new_signature = self._format_signature( signatures=signatures, parameter=parameter, max_width=max_width ) extra_text = None else: new_signature = None extra_text = signature_or_text return new_signature, extra_text, inspect_word def show_calltip(self, signature, parameter=None, documentation=None, language=_DEFAULT_LANGUAGE, max_lines=_DEFAULT_MAX_LINES, max_width=_DEFAULT_MAX_WIDTH, text_new_line=True): """ Show calltip. Calltips look like tooltips but will not disappear if mouse hovers them. They are useful for displaying signature information on methods and functions. """ # Find position of calltip point = self._calculate_position() signature = signature.strip() inspect_word = None language = getattr(self, 'language', language).lower() if language == 'python' and signature: inspect_word = signature.split('(')[0] # Check if documentation is better than signature, sometimes # signature has \n stripped for functions like print, type etc check_doc = ' ' if documentation: check_doc.join(documentation.split()).replace('\\*', '*') check_sig = ' '.join(signature.split()) if check_doc == check_sig: signature = documentation documentation = '' # Remove duplicate signature inside documentation if documentation: documentation = documentation.replace('\\*', '*') if signature.strip(): documentation = documentation.replace(signature + '\n', '') # Format res = self._check_signature_and_format(signature, parameter, inspect_word=inspect_word, language=language, max_width=max_width) new_signature, text, inspect_word = res text = self._format_text( signature=new_signature, inspect_word=inspect_word, display_link=False, text=documentation, max_lines=max_lines, max_width=max_width, text_new_line=text_new_line ) self._update_stylesheet(self.calltip_widget) # Show calltip self.calltip_widget.show_tip(point, text, []) self.calltip_widget.show() def show_tooltip(self, title=None, signature=None, text=None, inspect_word=None, title_color=_DEFAULT_TITLE_COLOR, at_line=None, at_point=None, display_link=False, max_lines=_DEFAULT_MAX_LINES, max_width=_DEFAULT_MAX_WIDTH, cursor=None, with_html_format=False, text_new_line=True, completion_doc=None): """Show tooltip.""" # Find position of calltip point = self._calculate_position( at_line=at_line, at_point=at_point, ) # Format text tiptext = self._format_text( title=title, signature=signature, text=text, title_color=title_color, inspect_word=inspect_word, display_link=display_link, max_lines=max_lines, max_width=max_width, with_html_format=with_html_format, text_new_line=text_new_line ) self._update_stylesheet(self.tooltip_widget) # Display tooltip self.tooltip_widget.show_tip(point, tiptext, cursor=cursor, completion_doc=completion_doc) def show_hint(self, text, inspect_word, at_point, max_lines=_DEFAULT_MAX_HINT_LINES, max_width=_DEFAULT_MAX_HINT_WIDTH, text_new_line=True, completion_doc=None): """Show code hint and crop text as needed.""" res = self._check_signature_and_format(text, max_width=max_width, inspect_word=inspect_word) html_signature, extra_text, _ = res point = self.get_word_start_pos(at_point) # Only display hover hint if there is documentation if extra_text is not None: # This is needed to get hover hints cursor = self.cursorForPosition(at_point) cursor.movePosition(QTextCursor.StartOfWord, QTextCursor.MoveAnchor) self._last_hover_cursor = cursor self.show_tooltip(signature=html_signature, text=extra_text, at_point=point, inspect_word=inspect_word, display_link=True, max_lines=max_lines, max_width=max_width, cursor=cursor, text_new_line=text_new_line, completion_doc=completion_doc) def hide_tooltip(self): """ Hide the tooltip widget. The tooltip widget is a special QLabel that looks like a tooltip, this method is here so it can be hidden as necessary. For example, when the user leaves the Linenumber area when hovering over lint warnings and errors. """ self._last_hover_cursor = None self._last_hover_word = None self._last_point = None self.tooltip_widget.hide() # ----- Required methods for the LSP def document_did_change(self, text=None): pass #------EOL characters def set_eol_chars(self, text): """Set widget end-of-line (EOL) characters from text (analyzes text)""" if not is_text_string(text): # testing for QString (PyQt API#1) text = to_text_string(text) eol_chars = sourcecode.get_eol_chars(text) is_document_modified = eol_chars is not None and self.eol_chars is not None self.eol_chars = eol_chars if is_document_modified: self.document().setModified(True) if self.sig_eol_chars_changed is not None: self.sig_eol_chars_changed.emit(eol_chars) self.document_did_change(text) def get_line_separator(self): """Return line separator based on current EOL mode""" if self.eol_chars is not None: return self.eol_chars else: return os.linesep def get_text_with_eol(self): """ Same as 'toPlainText', replacing '\n' by correct end-of-line characters. """ text = self.toPlainText() lines = text.splitlines() linesep = self.get_line_separator() text_with_eol = linesep.join(lines) if text.endswith('\n'): text_with_eol += linesep return text_with_eol #------Positions, coordinates (cursor, EOF, ...) def get_position(self, subject): """Get offset in character for the given subject from the start of text edit area""" cursor = self.textCursor() if subject == 'cursor': pass elif subject == 'sol': cursor.movePosition(QTextCursor.StartOfBlock) elif subject == 'eol': cursor.movePosition(QTextCursor.EndOfBlock) elif subject == 'eof': cursor.movePosition(QTextCursor.End) elif subject == 'sof': cursor.movePosition(QTextCursor.Start) else: # Assuming that input argument was already a position return subject return cursor.position() def get_coordinates(self, position): position = self.get_position(position) cursor = self.textCursor() cursor.setPosition(position) point = self.cursorRect(cursor).center() return point.x(), point.y() def _is_point_inside_word_rect(self, point): """ Check if the mouse is within the rect of the cursor current word. """ cursor = self.cursorForPosition(point) cursor.movePosition(QTextCursor.StartOfWord, QTextCursor.MoveAnchor) start_rect = self.cursorRect(cursor) cursor.movePosition(QTextCursor.EndOfWord, QTextCursor.MoveAnchor) end_rect = self.cursorRect(cursor) bounding_rect = start_rect.united(end_rect) return bounding_rect.contains(point) def get_word_start_pos(self, position): """ Find start position (lower bottom) of a word being hovered by mouse. """ cursor = self.cursorForPosition(position) cursor.movePosition(QTextCursor.StartOfWord, QTextCursor.MoveAnchor) rect = self.cursorRect(cursor) pos = QPoint(rect.left() + 4, rect.top()) return pos def get_last_hover_word(self): """Return the last (or active) hover word.""" return self._last_hover_word def get_last_hover_cursor(self): """Return the last (or active) hover cursor.""" return self._last_hover_cursor def get_cursor_line_column(self, cursor=None): """ Return `cursor` (line, column) numbers. If no `cursor` is provided, use the current text cursor. """ if cursor is None: cursor = self.textCursor() return cursor.blockNumber(), cursor.columnNumber() def get_cursor_line_number(self): """Return cursor line number""" return self.textCursor().blockNumber()+1 def get_position_line_number(self, line, col): """Get position offset from (line, col) coordinates.""" block = self.document().findBlockByNumber(line) cursor = QTextCursor(block) cursor.movePosition(QTextCursor.StartOfBlock) cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, n=col + 1) return cursor.position() def set_cursor_position(self, position): """Set cursor position""" position = self.get_position(position) cursor = self.textCursor() cursor.setPosition(position) self.setTextCursor(cursor) self.ensureCursorVisible() def move_cursor(self, chars=0): """Move cursor to left or right (unit: characters)""" direction = QTextCursor.Right if chars > 0 else QTextCursor.Left for _i in range(abs(chars)): self.moveCursor(direction, QTextCursor.MoveAnchor) def is_cursor_on_first_line(self): """Return True if cursor is on the first line""" cursor = self.textCursor() cursor.movePosition(QTextCursor.StartOfBlock) return cursor.atStart() def is_cursor_on_last_line(self): """Return True if cursor is on the last line""" cursor = self.textCursor() cursor.movePosition(QTextCursor.EndOfBlock) return cursor.atEnd() def is_cursor_at_end(self): """Return True if cursor is at the end of the text""" return self.textCursor().atEnd() def is_cursor_before(self, position, char_offset=0): """Return True if cursor is before *position*""" position = self.get_position(position) + char_offset cursor = self.textCursor() cursor.movePosition(QTextCursor.End) if position < cursor.position(): cursor.setPosition(position) return self.textCursor() < cursor def __move_cursor_anchor(self, what, direction, move_mode): assert what in ('character', 'word', 'line') if what == 'character': if direction == 'left': self.moveCursor(QTextCursor.PreviousCharacter, move_mode) elif direction == 'right': self.moveCursor(QTextCursor.NextCharacter, move_mode) elif what == 'word': if direction == 'left': self.moveCursor(QTextCursor.PreviousWord, move_mode) elif direction == 'right': self.moveCursor(QTextCursor.NextWord, move_mode) elif what == 'line': if direction == 'down': self.moveCursor(QTextCursor.NextBlock, move_mode) elif direction == 'up': self.moveCursor(QTextCursor.PreviousBlock, move_mode) def move_cursor_to_next(self, what='word', direction='left'): """ Move cursor to next *what* ('word' or 'character') toward *direction* ('left' or 'right') """ self.__move_cursor_anchor(what, direction, QTextCursor.MoveAnchor) #------Selection def extend_selection_to_next(self, what='word', direction='left'): """ Extend selection to next *what* ('word' or 'character') toward *direction* ('left' or 'right') """ self.__move_cursor_anchor(what, direction, QTextCursor.KeepAnchor) #------Text: get, set, ... def __select_text(self, position_from, position_to): position_from = self.get_position(position_from) position_to = self.get_position(position_to) cursor = self.textCursor() cursor.setPosition(position_from) cursor.setPosition(position_to, QTextCursor.KeepAnchor) return cursor def get_text_line(self, line_nb): """Return text line at line number *line_nb*""" block = self.document().findBlockByNumber(line_nb) cursor = QTextCursor(block) cursor.movePosition(QTextCursor.StartOfBlock) 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) 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) def get_text(self, position_from, position_to, remove_newlines=True): """Returns text between *position_from* and *position_to*. Positions may be integers or 'sol', 'eol', 'sof', 'eof' or 'cursor'. Unless position_from='sof' and position_to='eof' any trailing newlines in the string are removed. This was added as a workaround for spyder-ide/spyder#1546 and later caused spyder-ide/spyder#14374. The behaviour can be overridden by setting the optional parameter *remove_newlines* to False. TODO: Evaluate if this is still a problem and if the workaround can be moved closer to where the problem occurs. """ cursor = self.__select_text(position_from, position_to) text = to_text_string(cursor.selectedText()) if remove_newlines: remove_newlines = position_from != 'sof' or position_to != 'eof' if text and remove_newlines: while text.endswith("\n"): text = text[:-1] while text.endswith(u"\u2029"): text = text[:-1] return text def get_character(self, position, offset=0): """Return character at *position* with the given offset.""" position = self.get_position(position) + offset cursor = self.textCursor() cursor.movePosition(QTextCursor.End) if position < cursor.position(): cursor.setPosition(position) cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor) return to_text_string(cursor.selectedText()) else: return '' def insert_text(self, text, will_insert_text=True): """Insert text at cursor position""" if not self.isReadOnly(): if will_insert_text and self.sig_will_insert_text is not None: self.sig_will_insert_text.emit(text) self.textCursor().insertText(text) if self.sig_text_was_inserted is not None: self.sig_text_was_inserted.emit() self.document_did_change() def replace_text(self, position_from, position_to, text): cursor = self.__select_text(position_from, position_to) if self.sig_will_remove_selection is not None: start, end = self.get_selection_start_end(cursor) self.sig_will_remove_selection.emit(start, end) cursor.removeSelectedText() if self.sig_will_insert_text is not None: self.sig_will_insert_text.emit(text) cursor.insertText(text) if self.sig_text_was_inserted is not None: self.sig_text_was_inserted.emit() self.document_did_change() def remove_text(self, position_from, position_to): cursor = self.__select_text(position_from, position_to) if self.sig_will_remove_selection is not None: start, end = self.get_selection_start_end(cursor) self.sig_will_remove_selection.emit(start, end) cursor.removeSelectedText() self.document_did_change() def get_current_object(self): """ Return current object under cursor. Get the text of the current word plus all the characters to the left until a space is found. Used to get text to inspect for Help of elements following dot notation for example np.linalg.norm """ cursor = self.textCursor() cursor_pos = cursor.position() current_word = self.get_current_word(help_req=True) # Get max position to the left of cursor until space or no more # characters are left cursor.movePosition(QTextCursor.PreviousCharacter) while self.get_character(cursor.position()).strip(): cursor.movePosition(QTextCursor.PreviousCharacter) if cursor.atBlockStart(): break cursor_pos_left = cursor.position() # Get max position to the right of cursor until space or no more # characters are left cursor.setPosition(cursor_pos) while self.get_character(cursor.position()).strip(): cursor.movePosition(QTextCursor.NextCharacter) if cursor.atBlockEnd(): break cursor_pos_right = cursor.position() # Get text of the object under the cursor current_text = self.get_text( cursor_pos_left, cursor_pos_right).strip() current_object = current_word if current_text and current_word is not None: if current_word != current_text and current_word in current_text: current_object = ( current_text.split(current_word)[0] + current_word) return current_object def get_current_word_and_position(self, completion=False, help_req=False, valid_python_variable=True): """ Return current word, i.e. word at cursor position, and the start position. """ cursor = self.textCursor() cursor_pos = cursor.position() if cursor.hasSelection(): # Removes the selection and moves the cursor to the left side # of the selection: this is required to be able to properly # select the whole word under cursor (otherwise, the same word is # not selected when the cursor is at the right side of it): cursor.setPosition(min([cursor.selectionStart(), cursor.selectionEnd()])) else: # Checks if the first character to the right is a white space # and if not, moves the cursor one word to the left (otherwise, # if the character to the left do not match the "word regexp" # (see below), the word to the left of the cursor won't be # selected), but only if the first character to the left is not a # white space too. def is_space(move): curs = self.textCursor() curs.movePosition(move, QTextCursor.KeepAnchor) return not to_text_string(curs.selectedText()).strip() def is_special_character(move): """Check if a character is a non-letter including numbers.""" curs = self.textCursor() curs.movePosition(move, QTextCursor.KeepAnchor) text_cursor = to_text_string(curs.selectedText()).strip() return len( re.findall(r'([^\d\W]\w*)', text_cursor, re.UNICODE)) == 0 if help_req: if is_special_character(QTextCursor.PreviousCharacter): cursor.movePosition(QTextCursor.NextCharacter) elif is_special_character(QTextCursor.NextCharacter): cursor.movePosition(QTextCursor.PreviousCharacter) elif not completion: if is_space(QTextCursor.NextCharacter): if is_space(QTextCursor.PreviousCharacter): return cursor.movePosition(QTextCursor.WordLeft) else: if is_space(QTextCursor.PreviousCharacter): return if (is_special_character(QTextCursor.NextCharacter)): cursor.movePosition(QTextCursor.WordLeft) cursor.select(QTextCursor.WordUnderCursor) text = to_text_string(cursor.selectedText()) startpos = cursor.selectionStart() # Find a valid Python variable name if valid_python_variable: match = re.findall(r'([^\d\W]\w*)', text, re.UNICODE) if not match: # This is assumed in several places of our codebase, # so please don't change this return! return None else: text = match[0] if completion: text = text[:cursor_pos - startpos] return text, startpos def get_current_word(self, completion=False, help_req=False, valid_python_variable=True): """Return current word, i.e. word at cursor position.""" ret = self.get_current_word_and_position( completion=completion, help_req=help_req, valid_python_variable=valid_python_variable ) if ret is not None: return ret[0] def get_hover_word(self): """Return the last hover word that requested a hover hint.""" return self._last_hover_word def get_current_line(self): """Return current line's text.""" cursor = self.textCursor() cursor.select(QTextCursor.BlockUnderCursor) return to_text_string(cursor.selectedText()) def get_current_line_to_cursor(self): """Return text from prompt to cursor.""" return self.get_text(self.current_prompt_pos, 'cursor') def get_line_number_at(self, coordinates): """Return line number at *coordinates* (QPoint).""" cursor = self.cursorForPosition(coordinates) return cursor.blockNumber() + 1 def get_line_at(self, coordinates): """Return line at *coordinates* (QPoint).""" cursor = self.cursorForPosition(coordinates) cursor.select(QTextCursor.BlockUnderCursor) return to_text_string(cursor.selectedText()).replace(u'\u2029', '') def get_word_at(self, coordinates): """Return word at *coordinates* (QPoint).""" cursor = self.cursorForPosition(coordinates) cursor.select(QTextCursor.WordUnderCursor) if self._is_point_inside_word_rect(coordinates): word = to_text_string(cursor.selectedText()) else: word = '' return word def get_line_indentation(self, text): """Get indentation for given line.""" text = text.replace("\t", " "*self.tab_stop_width_spaces) return len(text)-len(text.lstrip()) def get_block_indentation(self, block_nb): """Return line indentation (character number).""" text = to_text_string(self.document().findBlockByNumber(block_nb).text()) return self.get_line_indentation(text) def get_selection_bounds(self, cursor=None): """Return selection bounds (block numbers).""" if cursor is None: cursor = self.textCursor() start, end = cursor.selectionStart(), cursor.selectionEnd() block_start = self.document().findBlock(start) block_end = self.document().findBlock(end) return sorted([block_start.blockNumber(), block_end.blockNumber()]) def get_selection_start_end(self, cursor=None): """Return selection start and end (line, column) positions.""" if cursor is None: cursor = self.textCursor() start, end = cursor.selectionStart(), cursor.selectionEnd() start_cursor = QTextCursor(cursor) start_cursor.setPosition(start) start_position = self.get_cursor_line_column(start_cursor) end_cursor = QTextCursor(cursor) end_cursor.setPosition(end) end_position = self.get_cursor_line_column(end_cursor) return start_position, end_position #------Text selection def has_selected_text(self): """Returns True if some text is selected.""" return bool(to_text_string(self.textCursor().selectedText())) def get_selected_text(self, cursor=None): """ Return text selected by current text cursor, converted in unicode. Replace the unicode line separator character \u2029 by the line separator characters returned by get_line_separator """ if cursor is None: cursor = self.textCursor() return to_text_string(cursor.selectedText()).replace(u"\u2029", self.get_line_separator()) def remove_selected_text(self): """Delete selected text.""" self.textCursor().removeSelectedText() # The next three lines are a workaround for a quirk of # QTextEdit. See spyder-ide/spyder#12663 and # https://bugreports.qt.io/browse/QTBUG-35861 cursor = self.textCursor() cursor.setPosition(cursor.position()) self.setTextCursor(cursor) self.document_did_change() def replace(self, text, pattern=None): """Replace selected text by *text*. If *pattern* is not None, replacing selected text using regular expression text substitution.""" cursor = self.textCursor() cursor.beginEditBlock() if pattern is not None: seltxt = to_text_string(cursor.selectedText()) if self.sig_will_remove_selection is not None: start, end = self.get_selection_start_end(cursor) self.sig_will_remove_selection.emit(start, end) cursor.removeSelectedText() if pattern is not None: text = re.sub(to_text_string(pattern), to_text_string(text), to_text_string(seltxt)) if self.sig_will_insert_text is not None: self.sig_will_insert_text.emit(text) cursor.insertText(text) if self.sig_text_was_inserted is not None: self.sig_text_was_inserted.emit() cursor.endEditBlock() self.document_did_change() #------Find/replace def find_multiline_pattern(self, regexp, cursor, findflag): """Reimplement QTextDocument's find method. Add support for *multiline* regular expressions.""" pattern = to_text_string(regexp.pattern()) text = to_text_string(self.toPlainText()) try: regobj = re.compile(pattern) except sre_constants.error: return if findflag & QTextDocument.FindBackward: # Find backward offset = min([cursor.selectionEnd(), cursor.selectionStart()]) text = text[:offset] matches = [_m for _m in regobj.finditer(text, 0, offset)] if matches: match = matches[-1] else: return else: # Find forward offset = max([cursor.selectionEnd(), cursor.selectionStart()]) match = regobj.search(text, offset) if match: pos1, pos2 = sh.get_span(match) fcursor = self.textCursor() fcursor.setPosition(pos1) fcursor.setPosition(pos2, QTextCursor.KeepAnchor) return fcursor def find_text(self, text, changed=True, forward=True, case=False, word=False, regexp=False): """Find text.""" cursor = self.textCursor() findflag = QTextDocument.FindFlag() # Get visible region to center cursor in case it's necessary. if getattr(self, 'get_visible_block_numbers', False): current_visible_region = self.get_visible_block_numbers() else: current_visible_region = None if not forward: findflag = findflag | QTextDocument.FindBackward if case: findflag = findflag | QTextDocument.FindCaseSensitively moves = [QTextCursor.NoMove] if forward: moves += [QTextCursor.NextWord, QTextCursor.Start] if changed: if to_text_string(cursor.selectedText()): new_position = min([cursor.selectionStart(), cursor.selectionEnd()]) cursor.setPosition(new_position) else: cursor.movePosition(QTextCursor.PreviousWord) else: moves += [QTextCursor.End] if regexp: text = to_text_string(text) else: text = re.escape(to_text_string(text)) pattern = QRegularExpression(u"\\b{}\\b".format(text) if word else text) if case: pattern.setPatternOptions(QRegularExpression.CaseInsensitiveOption) for move in moves: cursor.movePosition(move) if regexp and '\\n' in text: # Multiline regular expression found_cursor = self.find_multiline_pattern(pattern, cursor, findflag) else: # Single line find: using the QTextDocument's find function, # probably much more efficient than ours found_cursor = self.document().find(pattern, cursor, findflag) if found_cursor is not None and not found_cursor.isNull(): self.setTextCursor(found_cursor) # Center cursor if we move out of the visible region. if current_visible_region is not None: found_visible_region = self.get_visible_block_numbers() if current_visible_region != found_visible_region: current_visible_region = found_visible_region self.centerCursor() return True return False def is_editor(self): """Needs to be overloaded in the codeeditor where it will be True""" return False def get_number_matches(self, pattern, source_text='', case=False, regexp=False, word=False): """Get the number of matches for the searched text.""" pattern = to_text_string(pattern) if not pattern: return 0 if not regexp: pattern = re.escape(pattern) if not source_text: source_text = to_text_string(self.toPlainText()) if word: # match whole words only pattern = r'\b{pattern}\b'.format(pattern=pattern) try: re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE regobj = re.compile(pattern, flags=re_flags) except sre_constants.error: return None number_matches = 0 for match in regobj.finditer(source_text): number_matches += 1 return number_matches def get_match_number(self, pattern, case=False, regexp=False, word=False): """Get number of the match for the searched text.""" position = self.textCursor().position() source_text = self.get_text(position_from='sof', position_to=position) match_number = self.get_number_matches(pattern, source_text=source_text, case=case, regexp=regexp, word=word) return match_number # --- Array builder helper / See 'spyder/widgets/arraybuilder.py' def enter_array_inline(self): """Enter array builder inline mode.""" self._enter_array(True) def enter_array_table(self): """Enter array builder table mode.""" self._enter_array(False) def _enter_array(self, inline): """Enter array builder mode.""" offset = self.get_position('cursor') - self.get_position('sol') rect = self.cursorRect() dlg = ArrayBuilderDialog(self, inline, offset) # TODO: adapt to font size x = rect.left() x = int(x - 14) y = rect.top() + (rect.bottom() - rect.top())/2 y = int(y - dlg.height()/2 - 3) pos = QPoint(x, y) pos = self.calculate_real_position(pos) dlg.move(self.mapToGlobal(pos)) # called from editor if self.is_editor(): python_like_check = self.is_python_like() suffix = '\n' # called from a console else: python_like_check = True suffix = '' if python_like_check and dlg.exec_(): text = dlg.text() + suffix if text != '': cursor = self.textCursor() cursor.beginEditBlock() if self.sig_will_insert_text is not None: self.sig_will_insert_text.emit(text) cursor.insertText(text) if self.sig_text_was_inserted is not None: self.sig_text_was_inserted.emit() cursor.endEditBlock() self.document_did_change() class TracebackLinksMixin(object): """ """ QT_CLASS = None # This signal emits a parsed error traceback text so we can then # request opening the file that traceback comes from in the Editor. sig_go_to_error_requested = None def __init__(self): self.__cursor_changed = False self.setMouseTracking(True) #------Mouse events def mouseReleaseEvent(self, event): """Go to error""" self.QT_CLASS.mouseReleaseEvent(self, event) text = self.get_line_at(event.pos()) if get_error_match(text) and not self.has_selected_text(): if self.sig_go_to_error_requested is not None: self.sig_go_to_error_requested.emit(text) def mouseMoveEvent(self, event): """Show Pointing Hand Cursor on error messages""" text = self.get_line_at(event.pos()) if get_error_match(text): if not self.__cursor_changed: QApplication.setOverrideCursor(QCursor(Qt.PointingHandCursor)) self.__cursor_changed = True event.accept() return if self.__cursor_changed: QApplication.restoreOverrideCursor() self.__cursor_changed = False self.QT_CLASS.mouseMoveEvent(self, event) def leaveEvent(self, event): """If cursor has not been restored yet, do it now""" if self.__cursor_changed: QApplication.restoreOverrideCursor() self.__cursor_changed = False self.QT_CLASS.leaveEvent(self, event) class GetHelpMixin(object): def __init__(self): self.help_enabled = False def set_help_enabled(self, state): self.help_enabled = state def inspect_current_object(self): current_object = self.get_current_object() if current_object is not None: self.show_object_info(current_object, force=True) def show_object_info(self, text, call=False, force=False): """Show signature calltip and/or docstring in the Help plugin""" text = to_text_string(text) # Show docstring help_enabled = self.help_enabled or force if help_enabled: doc = { 'name': text, 'ignore_unknown': False, } self.sig_help_requested.emit(doc) # Show calltip if call and getattr(self, 'calltips', None): # Display argument list if this is a function call iscallable = self.iscallable(text) if iscallable is not None: if iscallable: arglist = self.get_arglist(text) name = text.split('.')[-1] argspec = signature = '' if isinstance(arglist, bool): arglist = [] if arglist: argspec = '(' + ''.join(arglist) + ')' else: doc = self.get__doc__(text) if doc is not None: # This covers cases like np.abs, whose docstring is # the same as np.absolute and because of that a # proper signature can't be obtained correctly argspec = getargspecfromtext(doc) if not argspec: signature = getsignaturefromtext(doc, name) if argspec or signature: if argspec: tiptext = name + argspec else: tiptext = signature # TODO: Select language and pass it to call self.show_calltip(tiptext) def get_last_obj(self, last=False): """ Return the last valid object on the current line """ return getobj(self.get_current_line_to_cursor(), last=last) class SaveHistoryMixin(object): INITHISTORY = None SEPARATOR = None HISTORY_FILENAMES = [] sig_append_to_history_requested = None def __init__(self, history_filename=''): self.history_filename = history_filename self.create_history_filename() def create_history_filename(self): """Create history_filename with INITHISTORY if it doesn't exist.""" if self.history_filename and not osp.isfile(self.history_filename): try: encoding.writelines(self.INITHISTORY, self.history_filename) except EnvironmentError: pass def add_to_history(self, command): """Add command to history""" command = to_text_string(command) if command in ['', '\n'] or command.startswith('Traceback'): return if command.endswith('\n'): command = command[:-1] self.histidx = None if len(self.history) > 0 and self.history[-1] == command: return self.history.append(command) text = os.linesep + command # When the first entry will be written in history file, # the separator will be append first: if self.history_filename not in self.HISTORY_FILENAMES: self.HISTORY_FILENAMES.append(self.history_filename) text = self.SEPARATOR + text # Needed to prevent errors when writing history to disk # See spyder-ide/spyder#6431. try: encoding.write(text, self.history_filename, mode='ab') except EnvironmentError: pass if self.sig_append_to_history_requested is not None: self.sig_append_to_history_requested.emit( self.history_filename, text) class BrowseHistory(object): def __init__(self): self.history = [] self.histidx = None self.hist_wholeline = False def browse_history(self, line, cursor_pos, backward): """ Browse history. Return the new text and wherever the cursor should move. """ if cursor_pos < len(line) and self.hist_wholeline: self.hist_wholeline = False tocursor = line[:cursor_pos] text, self.histidx = self.find_in_history(tocursor, self.histidx, backward) if text is not None: text = text.strip() if self.hist_wholeline: return text, True else: return tocursor + text, False return None, False def find_in_history(self, tocursor, start_idx, backward): """Find text 'tocursor' in history, from index 'start_idx'""" if start_idx is None: start_idx = len(self.history) # Finding text in history step = -1 if backward else 1 idx = start_idx if len(tocursor) == 0 or self.hist_wholeline: idx += step if idx >= len(self.history) or len(self.history) == 0: return "", len(self.history) elif idx < 0: idx = 0 self.hist_wholeline = True return self.history[idx], idx else: for index in range(len(self.history)): idx = (start_idx+step*(index+1)) % len(self.history) entry = self.history[idx] if entry.startswith(tocursor): return entry[len(tocursor):], idx else: return None, start_idx def reset_search_pos(self): """Reset the position from which to search the history""" self.histidx = None class BrowseHistoryMixin(BrowseHistory): def clear_line(self): """Clear current line (without clearing console prompt)""" self.remove_text(self.current_prompt_pos, 'eof') def browse_history(self, backward): """Browse history""" line = self.get_text(self.current_prompt_pos, 'eof') old_pos = self.get_position('cursor') cursor_pos = self.get_position('cursor') - self.current_prompt_pos if cursor_pos < 0: cursor_pos = 0 self.set_cursor_position(self.current_prompt_pos) text, move_cursor = super(BrowseHistoryMixin, self).browse_history( line, cursor_pos, backward) if text is not None: self.clear_line() self.insert_text(text) if not move_cursor: self.set_cursor_position(old_pos)