123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265 |
- # 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.qApp.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.qApp.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()
|