266 lines
10 KiB
Python
266 lines
10 KiB
Python
|
# Standard library imports
|
||
|
import re
|
||
|
from unicodedata import category
|
||
|
|
||
|
# System library imports
|
||
|
from qtpy import QtCore, QtGui, QtWidgets
|
||
|
|
||
|
|
||
|
class CallTipWidget(QtWidgets.QLabel):
|
||
|
""" Shows call tips by parsing the current text of Q[Plain]TextEdit.
|
||
|
"""
|
||
|
|
||
|
#--------------------------------------------------------------------------
|
||
|
# 'QObject' interface
|
||
|
#--------------------------------------------------------------------------
|
||
|
|
||
|
def __init__(self, text_edit):
|
||
|
""" Create a call tip manager that is attached to the specified Qt
|
||
|
text edit widget.
|
||
|
"""
|
||
|
assert isinstance(text_edit, (QtWidgets.QTextEdit, QtWidgets.QPlainTextEdit))
|
||
|
super(CallTipWidget, self).__init__(None, QtCore.Qt.ToolTip)
|
||
|
|
||
|
self._hide_timer = QtCore.QBasicTimer()
|
||
|
self._text_edit = text_edit
|
||
|
|
||
|
self.setFont(text_edit.document().defaultFont())
|
||
|
self.setForegroundRole(QtGui.QPalette.ToolTipText)
|
||
|
self.setBackgroundRole(QtGui.QPalette.ToolTipBase)
|
||
|
self.setPalette(QtWidgets.QToolTip.palette())
|
||
|
|
||
|
self.setAlignment(QtCore.Qt.AlignLeft)
|
||
|
self.setIndent(1)
|
||
|
self.setFrameStyle(QtWidgets.QFrame.NoFrame)
|
||
|
self.setMargin(1 + self.style().pixelMetric(
|
||
|
QtWidgets.QStyle.PM_ToolTipLabelFrameWidth, None, self))
|
||
|
self.setWindowOpacity(self.style().styleHint(
|
||
|
QtWidgets.QStyle.SH_ToolTipLabel_Opacity, None, self, None) / 255.0)
|
||
|
self.setWordWrap(True)
|
||
|
|
||
|
def eventFilter(self, obj, event):
|
||
|
""" Reimplemented to hide on certain key presses and on text edit focus
|
||
|
changes.
|
||
|
"""
|
||
|
if obj == self._text_edit:
|
||
|
etype = event.type()
|
||
|
|
||
|
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
|
||
|
|
||
|
elif etype == QtCore.QEvent.FocusOut:
|
||
|
self.hide()
|
||
|
|
||
|
elif etype == QtCore.QEvent.Enter:
|
||
|
self._hide_timer.stop()
|
||
|
|
||
|
elif etype == QtCore.QEvent.Leave:
|
||
|
self._leave_event_hide()
|
||
|
|
||
|
return super(CallTipWidget, self).eventFilter(obj, event)
|
||
|
|
||
|
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()
|
||
|
|
||
|
#--------------------------------------------------------------------------
|
||
|
# 'QWidget' interface
|
||
|
#--------------------------------------------------------------------------
|
||
|
|
||
|
def enterEvent(self, event):
|
||
|
""" Reimplemented to cancel the hide timer.
|
||
|
"""
|
||
|
super(CallTipWidget, self).enterEvent(event)
|
||
|
self._hide_timer.stop()
|
||
|
|
||
|
def hideEvent(self, event):
|
||
|
""" Reimplemented to disconnect signal handlers and event filter.
|
||
|
"""
|
||
|
super(CallTipWidget, self).hideEvent(event)
|
||
|
# This fixes issue jupyter/qtconsole#383
|
||
|
try:
|
||
|
self._text_edit.cursorPositionChanged.disconnect(
|
||
|
self._cursor_position_changed)
|
||
|
except TypeError:
|
||
|
pass
|
||
|
self._text_edit.removeEventFilter(self)
|
||
|
|
||
|
def leaveEvent(self, event):
|
||
|
""" Reimplemented to start the hide timer.
|
||
|
"""
|
||
|
super(CallTipWidget, self).leaveEvent(event)
|
||
|
self._leave_event_hide()
|
||
|
|
||
|
def paintEvent(self, event):
|
||
|
""" Reimplemented to paint the background panel.
|
||
|
"""
|
||
|
painter = QtWidgets.QStylePainter(self)
|
||
|
option = QtWidgets.QStyleOptionFrame()
|
||
|
option.initFrom(self)
|
||
|
painter.drawPrimitive(QtWidgets.QStyle.PE_PanelTipLabel, option)
|
||
|
painter.end()
|
||
|
|
||
|
super(CallTipWidget, self).paintEvent(event)
|
||
|
|
||
|
def setFont(self, font):
|
||
|
""" Reimplemented to allow use of this method as a slot.
|
||
|
"""
|
||
|
super(CallTipWidget, self).setFont(font)
|
||
|
|
||
|
def showEvent(self, event):
|
||
|
""" Reimplemented to connect signal handlers and event filter.
|
||
|
"""
|
||
|
super(CallTipWidget, self).showEvent(event)
|
||
|
self._text_edit.cursorPositionChanged.connect(
|
||
|
self._cursor_position_changed)
|
||
|
self._text_edit.installEventFilter(self)
|
||
|
|
||
|
#--------------------------------------------------------------------------
|
||
|
# 'CallTipWidget' interface
|
||
|
#--------------------------------------------------------------------------
|
||
|
|
||
|
def show_inspect_data(self, content, maxlines=20):
|
||
|
"""Show inspection data as a tooltip"""
|
||
|
data = content.get('data', {})
|
||
|
text = data.get('text/plain', '')
|
||
|
match = re.match("(?:[^\n]*\n){%i}" % maxlines, text)
|
||
|
if match:
|
||
|
text = text[:match.end()] + '\n[Documentation continues...]'
|
||
|
|
||
|
return self.show_tip(self._format_tooltip(text))
|
||
|
|
||
|
def show_tip(self, tip):
|
||
|
""" Attempts to show the specified tip at the current cursor location.
|
||
|
"""
|
||
|
# Attempt to find the cursor position at which to show the call tip.
|
||
|
text_edit = self._text_edit
|
||
|
document = text_edit.document()
|
||
|
cursor = text_edit.textCursor()
|
||
|
search_pos = cursor.position() - 1
|
||
|
self._start_position, _ = self._find_parenthesis(search_pos,
|
||
|
forward=False)
|
||
|
if self._start_position == -1:
|
||
|
return False
|
||
|
|
||
|
# Set the text and resize the widget accordingly.
|
||
|
self.setText(tip)
|
||
|
self.resize(self.sizeHint())
|
||
|
|
||
|
# Locate and show the widget. Place the tip below the current line
|
||
|
# unless it would be off the screen. In that case, decide the best
|
||
|
# location based trying to minimize the area that goes off-screen.
|
||
|
padding = 3 # Distance in pixels between cursor bounds and tip box.
|
||
|
cursor_rect = text_edit.cursorRect(cursor)
|
||
|
screen_rect = QtWidgets.QApplication.instance().desktop().screenGeometry(text_edit)
|
||
|
point = text_edit.mapToGlobal(cursor_rect.bottomRight())
|
||
|
point.setY(point.y() + padding)
|
||
|
tip_height = self.size().height()
|
||
|
tip_width = self.size().width()
|
||
|
|
||
|
vertical = 'bottom'
|
||
|
horizontal = 'Right'
|
||
|
if point.y() + tip_height > screen_rect.height() + screen_rect.y():
|
||
|
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() + screen_rect.x():
|
||
|
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())
|
||
|
point.setY(point.y() + padding)
|
||
|
if vertical == 'top':
|
||
|
point.setY(point.y() - tip_height)
|
||
|
if horizontal == 'Left':
|
||
|
point.setX(point.x() - tip_width - padding)
|
||
|
|
||
|
self.move(point)
|
||
|
self.show()
|
||
|
return True
|
||
|
|
||
|
#--------------------------------------------------------------------------
|
||
|
# 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
|
||
|
document = self._text_edit.document()
|
||
|
char = document.characterAt(position)
|
||
|
# Search until a match is found or a non-printable character is
|
||
|
# encountered.
|
||
|
while category(char) != 'Cc' and position > 0:
|
||
|
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
|
||
|
char = document.characterAt(position)
|
||
|
else:
|
||
|
position = -1
|
||
|
return position, commas
|
||
|
|
||
|
def _leave_event_hide(self):
|
||
|
""" Hides the tooltip after some time has passed (assuming the cursor is
|
||
|
not over the tooltip).
|
||
|
"""
|
||
|
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.
|
||
|
QtWidgets.QApplication.instance().topLevelAt(QtGui.QCursor.pos()) != self):
|
||
|
self._hide_timer.start(300, self)
|
||
|
|
||
|
def _format_tooltip(self, doc):
|
||
|
doc = re.sub(r'\033\[(\d|;)+?m', '', doc)
|
||
|
return doc
|
||
|
|
||
|
#------ Signal handlers ----------------------------------------------------
|
||
|
|
||
|
def _cursor_position_changed(self):
|
||
|
""" Updates the tip based on user cursor movement.
|
||
|
"""
|
||
|
cursor = self._text_edit.textCursor()
|
||
|
if cursor.position() <= self._start_position:
|
||
|
self.hide()
|
||
|
else:
|
||
|
position, commas = self._find_parenthesis(self._start_position + 1)
|
||
|
if position != -1:
|
||
|
self.hide()
|