call_tip_widget.py
273 lines
| 10.4 KiB
| text/x-python
|
PythonLexer
epatters
|
r2666 | # Standard library imports | ||
import re | ||||
from textwrap import dedent | ||||
Evan Patterson
|
r3304 | from unicodedata import category | ||
epatters
|
r2666 | |||
epatters
|
r2602 | # System library imports | ||
Evan Patterson
|
r3304 | from IPython.external.qt import QtCore, QtGui | ||
epatters
|
r2602 | |||
class CallTipWidget(QtGui.QLabel): | ||||
""" Shows call tips by parsing the current text of Q[Plain]TextEdit. | ||||
""" | ||||
#-------------------------------------------------------------------------- | ||||
epatters
|
r2687 | # 'QObject' interface | ||
epatters
|
r2602 | #-------------------------------------------------------------------------- | ||
epatters
|
r2982 | def __init__(self, text_edit): | ||
epatters
|
r2602 | """ Create a call tip manager that is attached to the specified Qt | ||
text edit widget. | ||||
""" | ||||
epatters
|
r2982 | assert isinstance(text_edit, (QtGui.QTextEdit, QtGui.QPlainTextEdit)) | ||
super(CallTipWidget, self).__init__(None, QtCore.Qt.ToolTip) | ||||
epatters
|
r2602 | |||
epatters
|
r2964 | self._hide_timer = QtCore.QBasicTimer() | ||
epatters
|
r2982 | self._text_edit = text_edit | ||
epatters
|
r2964 | |||
epatters
|
r2982 | self.setFont(text_edit.document().defaultFont()) | ||
epatters
|
r2602 | self.setForegroundRole(QtGui.QPalette.ToolTipText) | ||
self.setBackgroundRole(QtGui.QPalette.ToolTipBase) | ||||
self.setPalette(QtGui.QToolTip.palette()) | ||||
self.setAlignment(QtCore.Qt.AlignLeft) | ||||
self.setIndent(1) | ||||
self.setFrameStyle(QtGui.QFrame.NoFrame) | ||||
self.setMargin(1 + self.style().pixelMetric( | ||||
QtGui.QStyle.PM_ToolTipLabelFrameWidth, None, self)) | ||||
self.setWindowOpacity(self.style().styleHint( | ||||
Evan Patterson
|
r3305 | QtGui.QStyle.SH_ToolTipLabel_Opacity, None, self, None) / 255.0) | ||
epatters
|
r2602 | |||
epatters
|
r2744 | def eventFilter(self, obj, event): | ||
epatters
|
r2982 | """ Reimplemented to hide on certain key presses and on text edit focus | ||
epatters
|
r2744 | changes. | ||
""" | ||||
epatters
|
r2982 | if obj == self._text_edit: | ||
epatters
|
r2744 | etype = event.type() | ||
epatters
|
r2962 | |||
if etype == QtCore.QEvent.KeyPress: | ||||
key = event.key() | ||||
if key in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return): | ||||
self.hide() | ||||
elif key == QtCore.Qt.Key_Escape: | ||||
self.hide() | ||||
return True | ||||
epatters
|
r2744 | elif etype == QtCore.QEvent.FocusOut: | ||
self.hide() | ||||
epatters
|
r2964 | elif etype == QtCore.QEvent.Enter: | ||
self._hide_timer.stop() | ||||
elif etype == QtCore.QEvent.Leave: | ||||
epatters
|
r3309 | self._leave_event_hide() | ||
epatters
|
r2964 | |||
epatters
|
r2982 | return super(CallTipWidget, self).eventFilter(obj, event) | ||
epatters
|
r2744 | |||
epatters
|
r2964 | def timerEvent(self, event): | ||
""" Reimplemented to hide the widget when the hide timer fires. | ||||
""" | ||||
if event.timerId() == self._hide_timer.timerId(): | ||||
self._hide_timer.stop() | ||||
self.hide() | ||||
epatters
|
r2687 | #-------------------------------------------------------------------------- | ||
# 'QWidget' interface | ||||
#-------------------------------------------------------------------------- | ||||
epatters
|
r2964 | def enterEvent(self, event): | ||
""" Reimplemented to cancel the hide timer. | ||||
""" | ||||
epatters
|
r2982 | super(CallTipWidget, self).enterEvent(event) | ||
epatters
|
r2964 | self._hide_timer.stop() | ||
epatters
|
r2602 | def hideEvent(self, event): | ||
epatters
|
r2744 | """ Reimplemented to disconnect signal handlers and event filter. | ||
epatters
|
r2602 | """ | ||
epatters
|
r2982 | super(CallTipWidget, self).hideEvent(event) | ||
self._text_edit.cursorPositionChanged.disconnect( | ||||
self._cursor_position_changed) | ||||
self._text_edit.removeEventFilter(self) | ||||
epatters
|
r2687 | |||
epatters
|
r2964 | def leaveEvent(self, event): | ||
""" Reimplemented to start the hide timer. | ||||
""" | ||||
epatters
|
r2982 | super(CallTipWidget, self).leaveEvent(event) | ||
epatters
|
r3309 | self._leave_event_hide() | ||
epatters
|
r2964 | |||
epatters
|
r2602 | def paintEvent(self, event): | ||
""" Reimplemented to paint the background panel. | ||||
""" | ||||
painter = QtGui.QStylePainter(self) | ||||
option = QtGui.QStyleOptionFrame() | ||||
epatters
|
r3307 | option.initFrom(self) | ||
epatters
|
r2602 | painter.drawPrimitive(QtGui.QStyle.PE_PanelTipLabel, option) | ||
painter.end() | ||||
epatters
|
r2982 | super(CallTipWidget, self).paintEvent(event) | ||
epatters
|
r2602 | |||
epatters
|
r3031 | def setFont(self, font): | ||
""" Reimplemented to allow use of this method as a slot. | ||||
""" | ||||
super(CallTipWidget, self).setFont(font) | ||||
epatters
|
r2602 | def showEvent(self, event): | ||
epatters
|
r2744 | """ Reimplemented to connect signal handlers and event filter. | ||
epatters
|
r2602 | """ | ||
epatters
|
r2982 | super(CallTipWidget, self).showEvent(event) | ||
self._text_edit.cursorPositionChanged.connect( | ||||
self._cursor_position_changed) | ||||
self._text_edit.installEventFilter(self) | ||||
epatters
|
r2602 | |||
#-------------------------------------------------------------------------- | ||||
# 'CallTipWidget' interface | ||||
#-------------------------------------------------------------------------- | ||||
Fernando Perez
|
r3051 | def show_call_info(self, call_line=None, doc=None, maxlines=20): | ||
""" Attempts to show the specified call line and docstring at the | ||||
current cursor location. The docstring is possibly truncated for | ||||
epatters
|
r2666 | length. | ||
""" | ||||
Fernando Perez
|
r3051 | if doc: | ||
match = re.match("(?:[^\n]*\n){%i}" % maxlines, doc) | ||||
if match: | ||||
doc = doc[:match.end()] + '\n[Documentation continues...]' | ||||
else: | ||||
doc = '' | ||||
Bernardo B. Marques
|
r4872 | |||
Fernando Perez
|
r3051 | if call_line: | ||
doc = '\n\n'.join([call_line, doc]) | ||||
y-p
|
r8841 | return self.show_tip(self._format_tooltip(doc)) | ||
epatters
|
r2666 | |||
epatters
|
r2602 | def show_tip(self, tip): | ||
""" Attempts to show the specified tip at the current cursor location. | ||||
""" | ||||
epatters
|
r2881 | # Attempt to find the cursor position at which to show the call tip. | ||
epatters
|
r2982 | text_edit = self._text_edit | ||
epatters
|
r2602 | document = text_edit.document() | ||
cursor = text_edit.textCursor() | ||||
search_pos = cursor.position() - 1 | ||||
Bernardo B. Marques
|
r4872 | self._start_position, _ = self._find_parenthesis(search_pos, | ||
epatters
|
r2602 | forward=False) | ||
if self._start_position == -1: | ||||
return False | ||||
epatters
|
r2881 | |||
# Set the text and resize the widget accordingly. | ||||
epatters
|
r2602 | self.setText(tip) | ||
epatters
|
r2758 | self.resize(self.sizeHint()) | ||
Bernardo B. Marques
|
r4872 | |||
epatters
|
r2881 | # Locate and show the widget. Place the tip below the current line | ||
Puneeth Chaganti
|
r6215 | # unless it would be off the screen. In that case, decide the best | ||
# location based trying to minimize the area that goes off-screen. | ||||
epatters
|
r2963 | padding = 3 # Distance in pixels between cursor bounds and tip box. | ||
epatters
|
r2881 | cursor_rect = text_edit.cursorRect(cursor) | ||
screen_rect = QtGui.qApp.desktop().screenGeometry(text_edit) | ||||
point = text_edit.mapToGlobal(cursor_rect.bottomRight()) | ||||
epatters
|
r2963 | point.setY(point.y() + padding) | ||
epatters
|
r2881 | tip_height = self.size().height() | ||
Puneeth Chaganti
|
r6215 | tip_width = self.size().width() | ||
vertical = 'bottom' | ||||
horizontal = 'Right' | ||||
epatters
|
r2881 | if point.y() + tip_height > screen_rect.height(): | ||
Puneeth Chaganti
|
r6215 | point_ = text_edit.mapToGlobal(cursor_rect.topRight()) | ||
# If tip is still off screen, check if point is in top or bottom | ||||
# half of screen. | ||||
if point_.y() - tip_height < padding: | ||||
# If point is in upper half of screen, show tip below it. | ||||
# otherwise above it. | ||||
if 2*point.y() < screen_rect.height(): | ||||
vertical = 'bottom' | ||||
else: | ||||
vertical = 'top' | ||||
else: | ||||
vertical = 'top' | ||||
if point.x() + tip_width > screen_rect.width(): | ||||
point_ = text_edit.mapToGlobal(cursor_rect.topRight()) | ||||
# If tip is still off-screen, check if point is in the right or | ||||
# left half of the screen. | ||||
if point_.x() - tip_width < padding: | ||||
if 2*point.x() < screen_rect.width(): | ||||
horizontal = 'Right' | ||||
else: | ||||
horizontal = 'Left' | ||||
else: | ||||
horizontal = 'Left' | ||||
pos = getattr(cursor_rect, '%s%s' %(vertical, horizontal)) | ||||
point = text_edit.mapToGlobal(pos()) | ||||
if vertical == 'top': | ||||
epatters
|
r2963 | point.setY(point.y() - tip_height - padding) | ||
Puneeth Chaganti
|
r6215 | if horizontal == 'Left': | ||
point.setX(point.x() - tip_width - padding) | ||||
epatters
|
r2881 | self.move(point) | ||
epatters
|
r2758 | self.show() | ||
epatters
|
r2602 | return True | ||
Bernardo B. Marques
|
r4872 | |||
epatters
|
r2602 | #-------------------------------------------------------------------------- | ||
# Protected interface | ||||
#-------------------------------------------------------------------------- | ||||
def _find_parenthesis(self, position, forward=True): | ||||
""" If 'forward' is True (resp. False), proceed forwards | ||||
(resp. backwards) through the line that contains 'position' until an | ||||
unmatched closing (resp. opening) parenthesis is found. Returns a | ||||
tuple containing the position of this parenthesis (or -1 if it is | ||||
not found) and the number commas (at depth 0) found along the way. | ||||
""" | ||||
commas = depth = 0 | ||||
epatters
|
r2982 | document = self._text_edit.document() | ||
Evan Patterson
|
r3304 | char = document.characterAt(position) | ||
# Search until a match is found or a non-printable character is | ||||
# encountered. | ||||
while category(char) != 'Cc' and position > 0: | ||||
epatters
|
r2602 | if char == ',' and depth == 0: | ||
commas += 1 | ||||
elif char == ')': | ||||
if forward and depth == 0: | ||||
break | ||||
depth += 1 | ||||
elif char == '(': | ||||
if not forward and depth == 0: | ||||
break | ||||
depth -= 1 | ||||
position += 1 if forward else -1 | ||||
Evan Patterson
|
r3304 | char = document.characterAt(position) | ||
epatters
|
r2602 | else: | ||
position = -1 | ||||
return position, commas | ||||
epatters
|
r3309 | def _leave_event_hide(self): | ||
""" Hides the tooltip after some time has passed (assuming the cursor is | ||||
not over the tooltip). | ||||
epatters
|
r2964 | """ | ||
epatters
|
r3309 | if (not self._hide_timer.isActive() and | ||
# If Enter events always came after Leave events, we wouldn't need | ||||
# this check. But on Mac OS, it sometimes happens the other way | ||||
# around when the tooltip is created. | ||||
QtGui.qApp.topLevelAt(QtGui.QCursor.pos()) != self): | ||||
epatters
|
r2964 | self._hide_timer.start(300, self) | ||
y-p
|
r8841 | def _format_tooltip(self,doc): | ||
import textwrap | ||||
# make sure a long argument list does not make | ||||
# the first row overflow the width of the actual tip body | ||||
rows = doc.split("\n") | ||||
max_text_width = max(80, max([len(x) for x in rows[1:]])) | ||||
rows= textwrap.wrap(rows[0],max_text_width) + rows[1:] | ||||
doc = "\n".join(rows) | ||||
return doc | ||||
epatters
|
r2744 | #------ Signal handlers ---------------------------------------------------- | ||
def _cursor_position_changed(self): | ||||
epatters
|
r2602 | """ Updates the tip based on user cursor movement. | ||
""" | ||||
epatters
|
r2982 | cursor = self._text_edit.textCursor() | ||
epatters
|
r2602 | if cursor.position() <= self._start_position: | ||
self.hide() | ||||
else: | ||||
position, commas = self._find_parenthesis(self._start_position + 1) | ||||
if position != -1: | ||||
self.hide() | ||||