# -*- coding: utf-8 -*-
#
# Copyright © 2012 Pierre Raybaut
# Licensed under the terms of the MIT License
# (see spyderlib/__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 independant widget based on QTextEdit for the
IPython console plugin.
"""
import os
import re
import sre_constants
import textwrap
from xml.sax.saxutils import escape
from spyderlib.qt.QtGui import (QTextCursor, QTextDocument, QApplication,
QCursor, QToolTip)
from spyderlib.qt.QtCore import Qt, QPoint, QRegExp, SIGNAL
# Local imports
from spyderlib.baseconfig import _
from spyderlib.utils import encoding, sourcecode
from spyderlib.utils.misc import get_error_match
from spyderlib.utils.dochelpers import (getobj, getargspecfromtext,
getsignaturefromtext)
from spyderlib.py3compat import is_text_string, to_text_string, u
HISTORY_FILENAMES = []
class BaseEditMixin(object):
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
#------Calltips
def _format_signature(self, text):
formatted_lines = []
name = text.split('(')[0]
rows = textwrap.wrap(text, width=50,
subsequent_indent=' '*(len(name)+1))
for r in rows:
r = escape(r) # Escape most common html chars
r = r.replace(' ', ' ')
for char in ['=', ',', '(', ')', '*', '**']:
r = r.replace(char,
'' + \
char + '')
formatted_lines.append(r)
signature = '
'.join(formatted_lines)
return signature, rows
def show_calltip(self, title, text, signature=False, color='#2D62FF',
at_line=None, at_position=None):
"""Show calltip"""
if text is None or len(text) == 0:
return
# Saving cursor position:
if at_position is None:
at_position = self.get_position('cursor')
self.calltip_position = at_position
# Preparing text:
if signature:
text, wrapped_textlines = self._format_signature(text)
else:
if isinstance(text, list):
text = "\n ".join(text)
text = text.replace('\n', '
')
if len(text) > self.calltip_size:
text = text[:self.calltip_size] + " ..."
# Formatting text
font = self.font()
size = font.pointSize()
family = font.family()
format1 = '
'\
% (family, size, color)
format2 = '
'\
% (family, size-1 if size > 9 else size)
tiptext = format1 + ('%s
' % title) + '
' + \
format2 + text + "
"
# Showing tooltip at cursor position:
cx, cy = self.get_coordinates('cursor')
if at_line is not None:
cx = 5
cursor = QTextCursor(self.document().findBlockByNumber(at_line-1))
cy = self.cursorRect(cursor).top()
point = self.mapToGlobal(QPoint(cx, cy))
point.setX(point.x()+self.get_linenumberarea_width())
point.setY(point.y()+font.pointSize()+5)
if signature:
self.calltip_widget.show_tip(point, tiptext, wrapped_textlines)
else:
QToolTip.showText(point, tiptext)
#------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)
if eol_chars is not None and self.eol_chars is not None:
self.document().setModified(True)
self.eol_chars = eol_chars
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', replace '\n'
by correct end-of-line characters"""
utext = to_text_string(self.toPlainText())
lines = utext.splitlines()
linesep = self.get_line_separator()
txt = linesep.join(lines)
if utext.endswith('\n'):
txt += linesep
return txt
#------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 get_cursor_line_column(self):
"""Return cursor (line, column) numbers"""
cursor = self.textCursor()
return cursor.blockNumber(), cursor.columnNumber()
def get_cursor_line_number(self):
"""Return cursor line number"""
return self.textCursor().blockNumber()+1
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 clear_selection(self):
"""Clear current selection"""
cursor = self.textCursor()
cursor.clearSelection()
self.setTextCursor(cursor)
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*"""
# Taking into account the case when a file ends in an empty line,
# since splitlines doesn't return that line as the last element
# TODO: Make this function more efficient
try:
return to_text_string(self.toPlainText()).splitlines()[line_nb]
except IndexError:
return self.get_line_separator()
def get_text(self, position_from, position_to):
"""
Return text between *position_from* and *position_to*
Positions may be positions or 'sol', 'eol', 'sof', 'eof' or 'cursor'
"""
cursor = self.__select_text(position_from, position_to)
text = to_text_string(cursor.selectedText())
all_text = position_from == 'sof' and position_to == 'eof'
if text and not all_text:
while text.endswith("\n"):
text = text[:-1]
while text.endswith(u("\u2029")):
text = text[:-1]
return text
def get_character(self, position):
"""Return character at *position*"""
position = self.get_position(position)
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):
"""Insert text at cursor position"""
if not self.isReadOnly():
self.textCursor().insertText(text)
def replace_text(self, position_from, position_to, text):
cursor = self.__select_text(position_from, position_to)
cursor.removeSelectedText()
cursor.insertText(text)
def remove_text(self, position_from, position_to):
cursor = self.__select_text(position_from, position_to)
cursor.removeSelectedText()
def get_current_word(self):
"""Return current word, i.e. word at cursor position"""
cursor = self.textCursor()
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()
if is_space(QTextCursor.NextCharacter):
if is_space(QTextCursor.PreviousCharacter):
return
cursor.movePosition(QTextCursor.WordLeft)
cursor.select(QTextCursor.WordUnderCursor)
text = to_text_string(cursor.selectedText())
match = re.findall(r'([a-zA-Z\_]+[0-9a-zA-Z\_]*)', text)
if match:
return match[0]
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)
return to_text_string(cursor.selectedText())
def get_block_indentation(self, block_nb):
"""Return line indentation (character number)"""
text = to_text_string(self.document().findBlockByNumber(block_nb).text())
return len(text)-len(text.lstrip())
def get_selection_bounds(self):
"""Return selection bounds (block numbers)"""
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()])
#------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):
"""
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
"""
return to_text_string(self.textCursor().selectedText()).replace(u("\u2029"),
self.get_line_separator())
def remove_selected_text(self):
"""Delete selected text"""
self.textCursor().removeSelectedText()
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())
cursor.removeSelectedText()
if pattern is not None:
text = re.sub(to_text_string(pattern),
to_text_string(text), to_text_string(seltxt))
cursor.insertText(text)
cursor.endEditBlock()
#------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 = match.span()
fcursor = self.textCursor()
fcursor.setPosition(pos1)
fcursor.setPosition(pos2, QTextCursor.KeepAnchor)
return fcursor
def find_text(self, text, changed=True, forward=True, case=False,
words=False, regexp=False):
"""Find text"""
cursor = self.textCursor()
findflag = QTextDocument.FindFlag()
if not forward:
findflag = findflag | QTextDocument.FindBackward
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 not regexp:
text = re.escape(to_text_string(text))
pattern = QRegExp(r"\b%s\b" % text if words else text,
Qt.CaseSensitive if case else Qt.CaseInsensitive,
QRegExp.RegExp2)
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)
return True
return False
class TracebackLinksMixin(object):
QT_CLASS = 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():
self.emit(SIGNAL("go_to_error(QString)"), 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 InspectObjectMixin(object):
def __init__(self):
self.inspector = None
self.inspector_enabled = False
def set_inspector(self, inspector):
"""Set ObjectInspector DockWidget reference"""
self.inspector = inspector
self.inspector.set_shell(self)
def set_inspector_enabled(self, state):
self.inspector_enabled = state
def inspect_current_object(self):
text = ''
text1 = self.get_text('sol', 'cursor')
tl1 = re.findall(r'([a-zA-Z_]+[0-9a-zA-Z_\.]*)', text1)
if tl1 and text1.endswith(tl1[-1]):
text += tl1[-1]
text2 = self.get_text('cursor', 'eol')
tl2 = re.findall(r'([0-9a-zA-Z_\.]+[0-9a-zA-Z_\.]*)', text2)
if tl2 and text2.startswith(tl2[0]):
text += tl2[0]
if text:
self.show_object_info(text, force=True)
def show_object_info(self, text, call=False, force=False):
"""Show signature calltip and/or docstring in the Object Inspector"""
text = to_text_string(text) # Useful only for ExternalShellBase
# Show docstring
insp_enabled = self.inspector_enabled or force
if force and self.inspector is not None:
self.inspector.dockwidget.setVisible(True)
self.inspector.dockwidget.raise_()
if insp_enabled and (self.inspector is not None) and \
(self.inspector.dockwidget.isVisible()):
# ObjectInspector widget exists and is visible
self.inspector.set_shell(self)
self.inspector.set_object_text(text, ignore_unknown=False)
self.setFocus() # if inspector was not at top level, raising it to
# top will automatically give it focus because of
# the visibility_changed signal, so we must give
# focus back to shell
# Show calltip
if call and self.calltips:
# 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
self.show_calltip(_("Arguments"), tiptext,
signature=True, color='#2D62FF')
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
def __init__(self):
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 HISTORY_FILENAMES:
HISTORY_FILENAMES.append(self.history_filename)
text = self.SEPARATOR + text
encoding.write(text, self.history_filename, mode='ab')
self.emit(SIGNAL('append_to_history(QString,QString)'),
self.history_filename, text)