# -*- 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)