call_tip_widget.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. # Standard library imports
  2. import re
  3. from unicodedata import category
  4. # System library imports
  5. from qtpy import QtCore, QtGui, QtWidgets
  6. class CallTipWidget(QtWidgets.QLabel):
  7. """ Shows call tips by parsing the current text of Q[Plain]TextEdit.
  8. """
  9. #--------------------------------------------------------------------------
  10. # 'QObject' interface
  11. #--------------------------------------------------------------------------
  12. def __init__(self, text_edit):
  13. """ Create a call tip manager that is attached to the specified Qt
  14. text edit widget.
  15. """
  16. assert isinstance(text_edit, (QtWidgets.QTextEdit, QtWidgets.QPlainTextEdit))
  17. super(CallTipWidget, self).__init__(None, QtCore.Qt.ToolTip)
  18. self._hide_timer = QtCore.QBasicTimer()
  19. self._text_edit = text_edit
  20. self.setFont(text_edit.document().defaultFont())
  21. self.setForegroundRole(QtGui.QPalette.ToolTipText)
  22. self.setBackgroundRole(QtGui.QPalette.ToolTipBase)
  23. self.setPalette(QtWidgets.QToolTip.palette())
  24. self.setAlignment(QtCore.Qt.AlignLeft)
  25. self.setIndent(1)
  26. self.setFrameStyle(QtWidgets.QFrame.NoFrame)
  27. self.setMargin(1 + self.style().pixelMetric(
  28. QtWidgets.QStyle.PM_ToolTipLabelFrameWidth, None, self))
  29. self.setWindowOpacity(self.style().styleHint(
  30. QtWidgets.QStyle.SH_ToolTipLabel_Opacity, None, self, None) / 255.0)
  31. self.setWordWrap(True)
  32. def eventFilter(self, obj, event):
  33. """ Reimplemented to hide on certain key presses and on text edit focus
  34. changes.
  35. """
  36. if obj == self._text_edit:
  37. etype = event.type()
  38. if etype == QtCore.QEvent.KeyPress:
  39. key = event.key()
  40. if key in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return):
  41. self.hide()
  42. elif key == QtCore.Qt.Key_Escape:
  43. self.hide()
  44. return True
  45. elif etype == QtCore.QEvent.FocusOut:
  46. self.hide()
  47. elif etype == QtCore.QEvent.Enter:
  48. self._hide_timer.stop()
  49. elif etype == QtCore.QEvent.Leave:
  50. self._leave_event_hide()
  51. return super(CallTipWidget, self).eventFilter(obj, event)
  52. def timerEvent(self, event):
  53. """ Reimplemented to hide the widget when the hide timer fires.
  54. """
  55. if event.timerId() == self._hide_timer.timerId():
  56. self._hide_timer.stop()
  57. self.hide()
  58. #--------------------------------------------------------------------------
  59. # 'QWidget' interface
  60. #--------------------------------------------------------------------------
  61. def enterEvent(self, event):
  62. """ Reimplemented to cancel the hide timer.
  63. """
  64. super(CallTipWidget, self).enterEvent(event)
  65. self._hide_timer.stop()
  66. def hideEvent(self, event):
  67. """ Reimplemented to disconnect signal handlers and event filter.
  68. """
  69. super(CallTipWidget, self).hideEvent(event)
  70. # This fixes issue jupyter/qtconsole#383
  71. try:
  72. self._text_edit.cursorPositionChanged.disconnect(
  73. self._cursor_position_changed)
  74. except TypeError:
  75. pass
  76. self._text_edit.removeEventFilter(self)
  77. def leaveEvent(self, event):
  78. """ Reimplemented to start the hide timer.
  79. """
  80. super(CallTipWidget, self).leaveEvent(event)
  81. self._leave_event_hide()
  82. def paintEvent(self, event):
  83. """ Reimplemented to paint the background panel.
  84. """
  85. painter = QtWidgets.QStylePainter(self)
  86. option = QtWidgets.QStyleOptionFrame()
  87. option.initFrom(self)
  88. painter.drawPrimitive(QtWidgets.QStyle.PE_PanelTipLabel, option)
  89. painter.end()
  90. super(CallTipWidget, self).paintEvent(event)
  91. def setFont(self, font):
  92. """ Reimplemented to allow use of this method as a slot.
  93. """
  94. super(CallTipWidget, self).setFont(font)
  95. def showEvent(self, event):
  96. """ Reimplemented to connect signal handlers and event filter.
  97. """
  98. super(CallTipWidget, self).showEvent(event)
  99. self._text_edit.cursorPositionChanged.connect(
  100. self._cursor_position_changed)
  101. self._text_edit.installEventFilter(self)
  102. #--------------------------------------------------------------------------
  103. # 'CallTipWidget' interface
  104. #--------------------------------------------------------------------------
  105. def show_inspect_data(self, content, maxlines=20):
  106. """Show inspection data as a tooltip"""
  107. data = content.get('data', {})
  108. text = data.get('text/plain', '')
  109. match = re.match("(?:[^\n]*\n){%i}" % maxlines, text)
  110. if match:
  111. text = text[:match.end()] + '\n[Documentation continues...]'
  112. return self.show_tip(self._format_tooltip(text))
  113. def show_tip(self, tip):
  114. """ Attempts to show the specified tip at the current cursor location.
  115. """
  116. # Attempt to find the cursor position at which to show the call tip.
  117. text_edit = self._text_edit
  118. document = text_edit.document()
  119. cursor = text_edit.textCursor()
  120. search_pos = cursor.position() - 1
  121. self._start_position, _ = self._find_parenthesis(search_pos,
  122. forward=False)
  123. if self._start_position == -1:
  124. return False
  125. # Set the text and resize the widget accordingly.
  126. self.setText(tip)
  127. self.resize(self.sizeHint())
  128. # Locate and show the widget. Place the tip below the current line
  129. # unless it would be off the screen. In that case, decide the best
  130. # location based trying to minimize the area that goes off-screen.
  131. padding = 3 # Distance in pixels between cursor bounds and tip box.
  132. cursor_rect = text_edit.cursorRect(cursor)
  133. screen_rect = QtWidgets.qApp.desktop().screenGeometry(text_edit)
  134. point = text_edit.mapToGlobal(cursor_rect.bottomRight())
  135. point.setY(point.y() + padding)
  136. tip_height = self.size().height()
  137. tip_width = self.size().width()
  138. vertical = 'bottom'
  139. horizontal = 'Right'
  140. if point.y() + tip_height > screen_rect.height() + screen_rect.y():
  141. point_ = text_edit.mapToGlobal(cursor_rect.topRight())
  142. # If tip is still off screen, check if point is in top or bottom
  143. # half of screen.
  144. if point_.y() - tip_height < padding:
  145. # If point is in upper half of screen, show tip below it.
  146. # otherwise above it.
  147. if 2*point.y() < screen_rect.height():
  148. vertical = 'bottom'
  149. else:
  150. vertical = 'top'
  151. else:
  152. vertical = 'top'
  153. if point.x() + tip_width > screen_rect.width() + screen_rect.x():
  154. point_ = text_edit.mapToGlobal(cursor_rect.topRight())
  155. # If tip is still off-screen, check if point is in the right or
  156. # left half of the screen.
  157. if point_.x() - tip_width < padding:
  158. if 2*point.x() < screen_rect.width():
  159. horizontal = 'Right'
  160. else:
  161. horizontal = 'Left'
  162. else:
  163. horizontal = 'Left'
  164. pos = getattr(cursor_rect, '%s%s' %(vertical, horizontal))
  165. point = text_edit.mapToGlobal(pos())
  166. point.setY(point.y() + padding)
  167. if vertical == 'top':
  168. point.setY(point.y() - tip_height)
  169. if horizontal == 'Left':
  170. point.setX(point.x() - tip_width - padding)
  171. self.move(point)
  172. self.show()
  173. return True
  174. #--------------------------------------------------------------------------
  175. # Protected interface
  176. #--------------------------------------------------------------------------
  177. def _find_parenthesis(self, position, forward=True):
  178. """ If 'forward' is True (resp. False), proceed forwards
  179. (resp. backwards) through the line that contains 'position' until an
  180. unmatched closing (resp. opening) parenthesis is found. Returns a
  181. tuple containing the position of this parenthesis (or -1 if it is
  182. not found) and the number commas (at depth 0) found along the way.
  183. """
  184. commas = depth = 0
  185. document = self._text_edit.document()
  186. char = document.characterAt(position)
  187. # Search until a match is found or a non-printable character is
  188. # encountered.
  189. while category(char) != 'Cc' and position > 0:
  190. if char == ',' and depth == 0:
  191. commas += 1
  192. elif char == ')':
  193. if forward and depth == 0:
  194. break
  195. depth += 1
  196. elif char == '(':
  197. if not forward and depth == 0:
  198. break
  199. depth -= 1
  200. position += 1 if forward else -1
  201. char = document.characterAt(position)
  202. else:
  203. position = -1
  204. return position, commas
  205. def _leave_event_hide(self):
  206. """ Hides the tooltip after some time has passed (assuming the cursor is
  207. not over the tooltip).
  208. """
  209. if (not self._hide_timer.isActive() and
  210. # If Enter events always came after Leave events, we wouldn't need
  211. # this check. But on Mac OS, it sometimes happens the other way
  212. # around when the tooltip is created.
  213. QtWidgets.qApp.topLevelAt(QtGui.QCursor.pos()) != self):
  214. self._hide_timer.start(300, self)
  215. def _format_tooltip(self, doc):
  216. doc = re.sub(r'\033\[(\d|;)+?m', '', doc)
  217. return doc
  218. #------ Signal handlers ----------------------------------------------------
  219. def _cursor_position_changed(self):
  220. """ Updates the tip based on user cursor movement.
  221. """
  222. cursor = self._text_edit.textCursor()
  223. if cursor.position() <= self._start_position:
  224. self.hide()
  225. else:
  226. position, commas = self._find_parenthesis(self._start_position + 1)
  227. if position != -1:
  228. self.hide()