Overwrite paintEvent in QTextEdit to draw rectangle around word - python

I use QTextEdit from PyQt5 and I want to put a frame around selected words. As suggested by musicamante I tried to overwrite the paintEvent. The coordinates for the rectangle I want to extract from the cursor position. So, I put the cursor of my TextEditor at the beginning and at the end of the text and then tried to get the global coordinates from each the start and the end. With these coordinates a rectangle should be drawn. But when I run the code, the output coordinates are wrong and only a dash or a very small rectangle is drawn.
import sys
from PyQt5.QtGui import QPainter, QPen
from PyQt5.QtWidgets import QTextEdit, QWidget, QApplication, QVBoxLayout
from PyQt5.QtCore import Qt
class TextEditor(QTextEdit):
def __init__(self, parent=None):
super().__init__(parent)
self.coordinates = []
def paintEvent(self, event):
painter = QPainter(self.viewport())
painter.setPen(QPen(Qt.black, 4, Qt.SolidLine))
if self.coordinates:
for coordinate in self.coordinates:
painter.drawRect(coordinate[0].x(), coordinate[0].y(), coordinate[1].x() - coordinate[0].x(), 10)
super(TextEditor, self).paintEvent(event)
class Window(QWidget):
def __init__(self):
super(Window, self).__init__()
edit = TextEditor(self)
layout = QVBoxLayout(self)
layout.addWidget(edit)
self.boxes = []
text = "Hello World"
edit.setText(text)
word = "World"
start = text.find(word)
end = start + len(word)
edit.coordinates.append(self.emit_coorindate(start, end, edit))
edit.viewport().update()
def emit_coorindate(self, start, end, edit):
cursor = edit.textCursor()
cursor.setPosition(start)
x = edit.cursorRect().topLeft()
cursor.setPosition(end)
y = edit.cursorRect().bottomRight()
return (x, y)
if __name__ == '__main__':
app = QApplication(sys.argv)
window = Window()
window.setGeometry(800, 100, 1000, 1000)
window.show()
sys.exit(app.exec_())

Note: I'm basing this answer on an earlier version of the question which used QTextCharFormat to set the background of a text fragment. I added further support as I found myself looking for a valid solution for similar issue, but didn't have the opportunity to do it properly until now.
Premise
The laying out of text is quite complex, especially when dealing with rich text, including simple aspects like multiple lines.
While the Qt rich text engine allows setting the background of text, there is no support to draw a border around text.
For very basic cases, the answer provided for Getting the bounding box of QTextEdit selection will suffice, but it has some flaws.
First of all, if the text wraps on a new line (i.e. a very long selection), the complete bounding rect will be shown, which will include text that is not part of the selection. As shown in the above answer, you can see the result:
Then, the proposed solution is only valid for static text: whenever the text is updated, the selection is not updated along with it. While it's possible to update the internal selection when the text is changed programmatically, user editing would make it much more complex and prone to errors or unexpected behavior.
Solution: using QTextCharFormat
While the following approach is clearly much more complex, it's more effective, and allows further customization (like setting the border color and width). It works by using existing features of the Qt rich text engine, setting a custom format property that will always be preserved, no matter if the text is changed. Once the format is set for the selected text fragment, what's left is implementing the part that will dynamically compute the rectangles of the borders and, obviously, their painting.
In order to achieve this, it is necessary to cycle through the whole document layout and get the exact coordinates of each text fragment that needs "highlighting". This is done by:
iterating through all text blocks of the document;
iterating through all text fragments of each block;
get the possible lines that are part of that fragment (since word wrapping might force even single words to appear on more than one line);
find the extents of the characters belonging to the fragments in those lines, which will be used as coordinates for the borders;
To provide such feature, I used a custom QTextFormat property with a simple QPen instance that will be used to draw the borders, and that property is set for a specific QTextCharFormat set for the wanted text fragment.
Then, a QTimer connected to the relevant signals will compute the geometry of the borders (if any) and eventually request a repaint: this is necessary because any change in the document layout (text contents, but also editor/document size) can potentially change the geometry of the borders.
The paintEvent() will then paint those borders whenever they are included in the event rectangle (for optimization reasons, QTextEdit only redraws portion of the text that actually needs repainting).
Here is the result of the following code:
And here is what happens when breaking the line in the "selection":
from PyQt5 import QtCore, QtGui, QtWidgets
BorderProperty = QtGui.QTextFormat.UserProperty + 100
class BorderTextEdit(QtWidgets.QTextEdit):
_joinBorders = True
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._borderData = []
self._updateBorders()
self._updateBordersTimer = QtCore.QTimer(self, singleShot=True,
interval=0, timeout=self._updateBorders)
self.document().documentLayout().updateBlock.connect(
self.scheduleUpdateBorders)
self.document().contentsChange.connect(
self.scheduleUpdateBorders)
def scheduleUpdateBorders(self):
self._updateBordersTimer.start()
#QtCore.pyqtProperty(bool)
def joinBorders(self):
'''
When the *same* border format spans more than one line (due to line
wrap/break) some rectangles can be contiguous.
If this property is False, those borders will always be shown as
separate rectangles.
If this property is True, try to merge contiguous rectangles to
create unique regions.
'''
return self._joinBorders
#joinBorders.setter
def joinBorders(self, join):
if self._joinBorders != join:
self._joinBorders = join
self._updateBorders()
#QtCore.pyqtSlot(bool)
def setBordersJoined(self, join):
self.joinBorders = join
def _updateBorders(self):
if not self.toPlainText():
if self._borderData:
self._borderData.clear()
self.viewport().update()
return
doc = self.document()
block = doc.begin()
end = doc.end()
docLayout = doc.documentLayout()
borderRects = []
lastBorderRects = []
lastBorder = None
while block != end:
if not block.text():
block = block.next()
continue
blockRect = docLayout.blockBoundingRect(block)
blockX = blockRect.x()
blockY = blockRect.y()
it = block.begin()
while not it.atEnd():
fragment = it.fragment()
fmt = fragment.charFormat()
border = fmt.property(BorderProperty)
if lastBorder != border and lastBorderRects:
borderRects.append((lastBorderRects, lastBorder))
lastBorderRects = []
if isinstance(border, QtGui.QPen):
lastBorder = border
blockLayout = block.layout()
fragPos = fragment.position() - block.position()
fragEnd = fragPos + fragment.length()
while True:
line = blockLayout.lineForTextPosition(
fragPos)
if line.isValid():
x, _ = line.cursorToX(fragPos)
right, lineEnd = line.cursorToX(fragEnd)
rect = QtCore.QRectF(
blockX + x, blockY + line.y(),
right - x, line.height()
)
lastBorderRects.append(rect)
if lineEnd != fragEnd:
fragPos = lineEnd
else:
break
else:
break
it += 1
block = block.next()
borderData = []
if lastBorderRects and lastBorder:
borderRects.append((lastBorderRects, lastBorder))
if not self._joinBorders:
for rects, border in borderRects:
path = QtGui.QPainterPath()
for rect in rects:
path.addRect(rect.adjusted(0, 0, -1, -1))
path.translate(.5, .5)
borderData.append((border, path))
else:
for rects, border in borderRects:
path = QtGui.QPainterPath()
for rect in rects:
path.addRect(rect)
path.translate(.5, .5)
path = path.simplified()
fixPath = QtGui.QPainterPath()
last = None
# see the [*] note below for this block
for e in range(path.elementCount()):
element = path.elementAt(e)
if element.type != path.MoveToElement:
if element.x < last.x:
last.y -= 1
element.y -= 1
elif element.y > last.y:
last.x -= 1
element.x -= 1
if last:
if last.isMoveTo():
fixPath.moveTo(last.x, last.y)
else:
fixPath.lineTo(last.x, last.y)
last = element
if last.isLineTo():
fixPath.lineTo(last.x, last.y)
borderData.append((border, fixPath))
if self._borderData != borderData:
self._borderData[:] = borderData
# we need to schedule a repainting on the whole viewport
self.viewport().update()
def paintEvent(self, event):
if self._borderData:
offset = QtCore.QPointF(
-self.horizontalScrollBar().value(),
-self.verticalScrollBar().value())
rect = QtCore.QRectF(event.rect()).translated(-offset)
if self._borderData[-1][1].boundingRect().bottom() >= rect.y():
toDraw = []
for border, path in self._borderData:
if not path.intersects(rect):
if path.boundingRect().y() > rect.y():
break
continue
toDraw.append((border, path))
if toDraw:
qp = QtGui.QPainter(self.viewport())
qp.setRenderHint(qp.Antialiasing)
qp.translate(offset)
for border, path in toDraw:
qp.setPen(border)
qp.drawPath(path)
super().paintEvent(event)
if __name__ == '__main__':
import sys
app = QtWidgets.QApplication(sys.argv)
editor = BorderTextEdit()
text = 'Hello World'
editor.setText(text)
cursor = editor.textCursor()
word = "World"
start_index = text.find(word)
cursor.setPosition(start_index)
cursor.setPosition(start_index + len(word), cursor.KeepAnchor)
format = QtGui.QTextCharFormat()
format.setForeground(QtGui.QBrush(QtCore.Qt.green))
format.setProperty(BorderProperty, QtGui.QPen(QtCore.Qt.red))
cursor.mergeCharFormat(format)
editor.show()
sys.exit(app.exec_())
[*] - the border should always be within the bounding rect of the text, otherwise there would be overlapping, so the rectangles are always adjusted by 1 pixel left/above for the right/bottom borders; to allow rectangle joining, we must preserve the original rectangles first, so we fix the resulting paths by adjusting the "remaining lines" of those rectangles: since rectangles are always drawn clockwise, we adjust the "right lines" that go from top to bottom (by moving their x points left by one pixel) and the "bottom lines" from right to left (y points moved above by one pixel).
The clipboard issue
Now, there is a problem: since Qt uses the system clipboard also for internal cut/copy/paste operations, all that format data will be lost when trying to use that basic feature.
In order to solve this, a work around is to add the custom data to the clipboard, which stores the formatted contents as HTML. Note that we cannot change the contents of the HTML, becase there is no reliable way to find the specific position of the "border text" in the generated code. The custom data must be stored in other ways.
QTextEdit calls createMimeDataFromSelection() whenever it has to cut/copy a selection, so we can override that function by adding custom data to the returned mimedata object, and eventually read it back when the related insertFromMimeData() function is called for the paste operation.
The border data is read using a similar concept above (cycling through the blocks that are part of the selection) and serialized through the json module. Then, it gets restored by unserializing the data (if it exists) while keeping track of the previous cursor position before pasting.
Note: in the following solution, I just append the serialized data to the HTML (using the <!-- ... ---> comments), but another option is to add further data with a custom format to the mimeData object.
import json
BorderProperty = QtGui.QTextFormat.UserProperty + 100
BorderDataStart = "<!-- BorderData='"
BorderDataEnd = "' -->"
class BorderTextEdit(QtWidgets.QTextEdit):
# ...
def createMimeDataFromSelection(self):
mime = super().createMimeDataFromSelection()
cursor = self.textCursor()
if cursor.hasSelection():
selStart = cursor.selectionStart()
selEnd = cursor.selectionEnd()
block = self.document().findBlock(selStart)
borderData = []
while block.isValid() and block.position() < selEnd:
it = block.begin()
while not it.atEnd():
fragment = it.fragment()
fragStart = fragment.position()
fragEnd = fragStart + fragment.length()
if fragEnd >= selStart and fragStart < selEnd:
fmt = fragment.charFormat()
border = fmt.property(BorderProperty)
if isinstance(border, QtGui.QPen):
start = max(0, fragStart - selStart)
end = min(selEnd, fragEnd)
borderDict = {
'start': start,
'length': end - (selStart + start),
'color': border.color().name(),
'width': border.width()
}
if border.width() != 1:
borderDict['width'] = border.width()
borderData.append(borderDict)
it += 1
block = block.next()
if borderData:
mime.setHtml(mime.html()
+ BorderDataStart
+ json.dumps(borderData)
+ BorderDataEnd)
return mime
def insertFromMimeData(self, source):
cursor = self.textCursor()
# merge the paste operation to avoid multiple levels of editing
cursor.beginEditBlock()
self._customPaste(source, cursor.selectionStart())
cursor.endEditBlock()
def _customPaste(self, data, cursorPos):
super().insertFromMimeData(data)
if not data.hasHtml():
return
html = data.html()
htmlEnd = html.rfind('</html>')
if htmlEnd < 0:
return
hasBorderData = html.find(BorderDataStart)
if hasBorderData < 0:
return
end = html.find(BorderDataEnd)
if end < 0:
return
try:
borderData = json.loads(
html[hasBorderData + len(BorderDataStart):end])
except ValueError:
return
cursor = self.textCursor()
keys = set(('start', 'length', 'color'))
for data in borderData:
if not isinstance(data, dict) or keys & set(data) != keys:
continue
start = cursorPos + data['start']
cursor.setPosition(start)
oldFormat = cursor.charFormat()
cursor.setPosition(start + data['length'], cursor.KeepAnchor)
newBorder = QtGui.QPen(QtGui.QColor(data['color']))
width = data.get('width')
if width:
newBorder.setWidth(width)
if oldFormat.property(BorderProperty) != newBorder:
fmt = QtGui.QTextCharFormat()
else:
fmt = oldFormat
fmt.setProperty(BorderProperty, newBorder)
cursor.mergeCharFormat(fmt)
For obvious reasons, this will provide clipboard support for the borders only for instances of BorderTextEdit or its subclasses, and will not be available when pasting into other programs, even if they accept HTML data.

I found a solution with QRubberband, which is pretty close to what I wanted:
import sys
from PyQt5.QtGui import QTextCursor
from PyQt5.QtWidgets import QTextEdit, QWidget, QApplication, QVBoxLayout
from PyQt5.Qt import QRubberBand
class TextEditor(QTextEdit):
def __init__(self, parent=None):
super().__init__(parent)
text = "Hello World"
self.setText(text)
word = "World"
start_index = text.find(word)
end_index = start_index + len(word)
self.set = set()
self.set.add((start_index, end_index))
def getBoundingRect(self, start, end):
cursor = self.textCursor()
cursor.setPosition(end)
last_rect = end_rect = self.cursorRect(cursor)
cursor.setPosition(start)
first_rect = start_rect = self.cursorRect(cursor)
if start_rect.y() != end_rect.y():
cursor.movePosition(QTextCursor.StartOfLine)
first_rect = last_rect = self.cursorRect(cursor)
while True:
cursor.movePosition(QTextCursor.EndOfLine)
rect = self.cursorRect(cursor)
if rect.y() < end_rect.y() and rect.x() > last_rect.x():
last_rect = rect
moved = cursor.movePosition(QTextCursor.NextCharacter)
if not moved or rect.y() > end_rect.y():
break
last_rect = last_rect.united(end_rect)
return first_rect.united(last_rect)
class Window(QWidget):
def __init__(self):
super(Window, self).__init__()
self.edit = TextEditor(self)
layout = QVBoxLayout(self)
layout.addWidget(self.edit)
self.boxes = []
def showBoxes(self):
while self.boxes:
self.boxes.pop().deleteLater()
viewport = self.edit.viewport()
for start, end in self.edit.set:
print(start, end)
rect = self.edit.getBoundingRect(start, end)
box = QRubberBand(QRubberBand.Rectangle, viewport)
box.setGeometry(rect)
box.show()
self.boxes.append(box)
def resizeEvent(self, event):
self.showBoxes()
super().resizeEvent(event)
if __name__ == '__main__':
app = QApplication(sys.argv)
window = Window()
window.setGeometry(800, 100, 1000, 1000)
window.show()
window.showBoxes()
sys.exit(app.exec_())

Related

Overwrite paintEvent of QTextEdit to draw rectangle around word [duplicate]

I use QTextEdit from PyQt5 and I want to put a frame around selected words. As suggested by musicamante I tried to overwrite the paintEvent. The coordinates for the rectangle I want to extract from the cursor position. So, I put the cursor of my TextEditor at the beginning and at the end of the text and then tried to get the global coordinates from each the start and the end. With these coordinates a rectangle should be drawn. But when I run the code, the output coordinates are wrong and only a dash or a very small rectangle is drawn.
import sys
from PyQt5.QtGui import QPainter, QPen
from PyQt5.QtWidgets import QTextEdit, QWidget, QApplication, QVBoxLayout
from PyQt5.QtCore import Qt
class TextEditor(QTextEdit):
def __init__(self, parent=None):
super().__init__(parent)
self.coordinates = []
def paintEvent(self, event):
painter = QPainter(self.viewport())
painter.setPen(QPen(Qt.black, 4, Qt.SolidLine))
if self.coordinates:
for coordinate in self.coordinates:
painter.drawRect(coordinate[0].x(), coordinate[0].y(), coordinate[1].x() - coordinate[0].x(), 10)
super(TextEditor, self).paintEvent(event)
class Window(QWidget):
def __init__(self):
super(Window, self).__init__()
edit = TextEditor(self)
layout = QVBoxLayout(self)
layout.addWidget(edit)
self.boxes = []
text = "Hello World"
edit.setText(text)
word = "World"
start = text.find(word)
end = start + len(word)
edit.coordinates.append(self.emit_coorindate(start, end, edit))
edit.viewport().update()
def emit_coorindate(self, start, end, edit):
cursor = edit.textCursor()
cursor.setPosition(start)
x = edit.cursorRect().topLeft()
cursor.setPosition(end)
y = edit.cursorRect().bottomRight()
return (x, y)
if __name__ == '__main__':
app = QApplication(sys.argv)
window = Window()
window.setGeometry(800, 100, 1000, 1000)
window.show()
sys.exit(app.exec_())
Note: I'm basing this answer on an earlier version of the question which used QTextCharFormat to set the background of a text fragment. I added further support as I found myself looking for a valid solution for similar issue, but didn't have the opportunity to do it properly until now.
Premise
The laying out of text is quite complex, especially when dealing with rich text, including simple aspects like multiple lines.
While the Qt rich text engine allows setting the background of text, there is no support to draw a border around text.
For very basic cases, the answer provided for Getting the bounding box of QTextEdit selection will suffice, but it has some flaws.
First of all, if the text wraps on a new line (i.e. a very long selection), the complete bounding rect will be shown, which will include text that is not part of the selection. As shown in the above answer, you can see the result:
Then, the proposed solution is only valid for static text: whenever the text is updated, the selection is not updated along with it. While it's possible to update the internal selection when the text is changed programmatically, user editing would make it much more complex and prone to errors or unexpected behavior.
Solution: using QTextCharFormat
While the following approach is clearly much more complex, it's more effective, and allows further customization (like setting the border color and width). It works by using existing features of the Qt rich text engine, setting a custom format property that will always be preserved, no matter if the text is changed. Once the format is set for the selected text fragment, what's left is implementing the part that will dynamically compute the rectangles of the borders and, obviously, their painting.
In order to achieve this, it is necessary to cycle through the whole document layout and get the exact coordinates of each text fragment that needs "highlighting". This is done by:
iterating through all text blocks of the document;
iterating through all text fragments of each block;
get the possible lines that are part of that fragment (since word wrapping might force even single words to appear on more than one line);
find the extents of the characters belonging to the fragments in those lines, which will be used as coordinates for the borders;
To provide such feature, I used a custom QTextFormat property with a simple QPen instance that will be used to draw the borders, and that property is set for a specific QTextCharFormat set for the wanted text fragment.
Then, a QTimer connected to the relevant signals will compute the geometry of the borders (if any) and eventually request a repaint: this is necessary because any change in the document layout (text contents, but also editor/document size) can potentially change the geometry of the borders.
The paintEvent() will then paint those borders whenever they are included in the event rectangle (for optimization reasons, QTextEdit only redraws portion of the text that actually needs repainting).
Here is the result of the following code:
And here is what happens when breaking the line in the "selection":
from PyQt5 import QtCore, QtGui, QtWidgets
BorderProperty = QtGui.QTextFormat.UserProperty + 100
class BorderTextEdit(QtWidgets.QTextEdit):
_joinBorders = True
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._borderData = []
self._updateBorders()
self._updateBordersTimer = QtCore.QTimer(self, singleShot=True,
interval=0, timeout=self._updateBorders)
self.document().documentLayout().updateBlock.connect(
self.scheduleUpdateBorders)
self.document().contentsChange.connect(
self.scheduleUpdateBorders)
def scheduleUpdateBorders(self):
self._updateBordersTimer.start()
#QtCore.pyqtProperty(bool)
def joinBorders(self):
'''
When the *same* border format spans more than one line (due to line
wrap/break) some rectangles can be contiguous.
If this property is False, those borders will always be shown as
separate rectangles.
If this property is True, try to merge contiguous rectangles to
create unique regions.
'''
return self._joinBorders
#joinBorders.setter
def joinBorders(self, join):
if self._joinBorders != join:
self._joinBorders = join
self._updateBorders()
#QtCore.pyqtSlot(bool)
def setBordersJoined(self, join):
self.joinBorders = join
def _updateBorders(self):
if not self.toPlainText():
if self._borderData:
self._borderData.clear()
self.viewport().update()
return
doc = self.document()
block = doc.begin()
end = doc.end()
docLayout = doc.documentLayout()
borderRects = []
lastBorderRects = []
lastBorder = None
while block != end:
if not block.text():
block = block.next()
continue
blockRect = docLayout.blockBoundingRect(block)
blockX = blockRect.x()
blockY = blockRect.y()
it = block.begin()
while not it.atEnd():
fragment = it.fragment()
fmt = fragment.charFormat()
border = fmt.property(BorderProperty)
if lastBorder != border and lastBorderRects:
borderRects.append((lastBorderRects, lastBorder))
lastBorderRects = []
if isinstance(border, QtGui.QPen):
lastBorder = border
blockLayout = block.layout()
fragPos = fragment.position() - block.position()
fragEnd = fragPos + fragment.length()
while True:
line = blockLayout.lineForTextPosition(
fragPos)
if line.isValid():
x, _ = line.cursorToX(fragPos)
right, lineEnd = line.cursorToX(fragEnd)
rect = QtCore.QRectF(
blockX + x, blockY + line.y(),
right - x, line.height()
)
lastBorderRects.append(rect)
if lineEnd != fragEnd:
fragPos = lineEnd
else:
break
else:
break
it += 1
block = block.next()
borderData = []
if lastBorderRects and lastBorder:
borderRects.append((lastBorderRects, lastBorder))
if not self._joinBorders:
for rects, border in borderRects:
path = QtGui.QPainterPath()
for rect in rects:
path.addRect(rect.adjusted(0, 0, -1, -1))
path.translate(.5, .5)
borderData.append((border, path))
else:
for rects, border in borderRects:
path = QtGui.QPainterPath()
for rect in rects:
path.addRect(rect)
path.translate(.5, .5)
path = path.simplified()
fixPath = QtGui.QPainterPath()
last = None
# see the [*] note below for this block
for e in range(path.elementCount()):
element = path.elementAt(e)
if element.type != path.MoveToElement:
if element.x < last.x:
last.y -= 1
element.y -= 1
elif element.y > last.y:
last.x -= 1
element.x -= 1
if last:
if last.isMoveTo():
fixPath.moveTo(last.x, last.y)
else:
fixPath.lineTo(last.x, last.y)
last = element
if last.isLineTo():
fixPath.lineTo(last.x, last.y)
borderData.append((border, fixPath))
if self._borderData != borderData:
self._borderData[:] = borderData
# we need to schedule a repainting on the whole viewport
self.viewport().update()
def paintEvent(self, event):
if self._borderData:
offset = QtCore.QPointF(
-self.horizontalScrollBar().value(),
-self.verticalScrollBar().value())
rect = QtCore.QRectF(event.rect()).translated(-offset)
if self._borderData[-1][1].boundingRect().bottom() >= rect.y():
toDraw = []
for border, path in self._borderData:
if not path.intersects(rect):
if path.boundingRect().y() > rect.y():
break
continue
toDraw.append((border, path))
if toDraw:
qp = QtGui.QPainter(self.viewport())
qp.setRenderHint(qp.Antialiasing)
qp.translate(offset)
for border, path in toDraw:
qp.setPen(border)
qp.drawPath(path)
super().paintEvent(event)
if __name__ == '__main__':
import sys
app = QtWidgets.QApplication(sys.argv)
editor = BorderTextEdit()
text = 'Hello World'
editor.setText(text)
cursor = editor.textCursor()
word = "World"
start_index = text.find(word)
cursor.setPosition(start_index)
cursor.setPosition(start_index + len(word), cursor.KeepAnchor)
format = QtGui.QTextCharFormat()
format.setForeground(QtGui.QBrush(QtCore.Qt.green))
format.setProperty(BorderProperty, QtGui.QPen(QtCore.Qt.red))
cursor.mergeCharFormat(format)
editor.show()
sys.exit(app.exec_())
[*] - the border should always be within the bounding rect of the text, otherwise there would be overlapping, so the rectangles are always adjusted by 1 pixel left/above for the right/bottom borders; to allow rectangle joining, we must preserve the original rectangles first, so we fix the resulting paths by adjusting the "remaining lines" of those rectangles: since rectangles are always drawn clockwise, we adjust the "right lines" that go from top to bottom (by moving their x points left by one pixel) and the "bottom lines" from right to left (y points moved above by one pixel).
The clipboard issue
Now, there is a problem: since Qt uses the system clipboard also for internal cut/copy/paste operations, all that format data will be lost when trying to use that basic feature.
In order to solve this, a work around is to add the custom data to the clipboard, which stores the formatted contents as HTML. Note that we cannot change the contents of the HTML, becase there is no reliable way to find the specific position of the "border text" in the generated code. The custom data must be stored in other ways.
QTextEdit calls createMimeDataFromSelection() whenever it has to cut/copy a selection, so we can override that function by adding custom data to the returned mimedata object, and eventually read it back when the related insertFromMimeData() function is called for the paste operation.
The border data is read using a similar concept above (cycling through the blocks that are part of the selection) and serialized through the json module. Then, it gets restored by unserializing the data (if it exists) while keeping track of the previous cursor position before pasting.
Note: in the following solution, I just append the serialized data to the HTML (using the <!-- ... ---> comments), but another option is to add further data with a custom format to the mimeData object.
import json
BorderProperty = QtGui.QTextFormat.UserProperty + 100
BorderDataStart = "<!-- BorderData='"
BorderDataEnd = "' -->"
class BorderTextEdit(QtWidgets.QTextEdit):
# ...
def createMimeDataFromSelection(self):
mime = super().createMimeDataFromSelection()
cursor = self.textCursor()
if cursor.hasSelection():
selStart = cursor.selectionStart()
selEnd = cursor.selectionEnd()
block = self.document().findBlock(selStart)
borderData = []
while block.isValid() and block.position() < selEnd:
it = block.begin()
while not it.atEnd():
fragment = it.fragment()
fragStart = fragment.position()
fragEnd = fragStart + fragment.length()
if fragEnd >= selStart and fragStart < selEnd:
fmt = fragment.charFormat()
border = fmt.property(BorderProperty)
if isinstance(border, QtGui.QPen):
start = max(0, fragStart - selStart)
end = min(selEnd, fragEnd)
borderDict = {
'start': start,
'length': end - (selStart + start),
'color': border.color().name(),
'width': border.width()
}
if border.width() != 1:
borderDict['width'] = border.width()
borderData.append(borderDict)
it += 1
block = block.next()
if borderData:
mime.setHtml(mime.html()
+ BorderDataStart
+ json.dumps(borderData)
+ BorderDataEnd)
return mime
def insertFromMimeData(self, source):
cursor = self.textCursor()
# merge the paste operation to avoid multiple levels of editing
cursor.beginEditBlock()
self._customPaste(source, cursor.selectionStart())
cursor.endEditBlock()
def _customPaste(self, data, cursorPos):
super().insertFromMimeData(data)
if not data.hasHtml():
return
html = data.html()
htmlEnd = html.rfind('</html>')
if htmlEnd < 0:
return
hasBorderData = html.find(BorderDataStart)
if hasBorderData < 0:
return
end = html.find(BorderDataEnd)
if end < 0:
return
try:
borderData = json.loads(
html[hasBorderData + len(BorderDataStart):end])
except ValueError:
return
cursor = self.textCursor()
keys = set(('start', 'length', 'color'))
for data in borderData:
if not isinstance(data, dict) or keys & set(data) != keys:
continue
start = cursorPos + data['start']
cursor.setPosition(start)
oldFormat = cursor.charFormat()
cursor.setPosition(start + data['length'], cursor.KeepAnchor)
newBorder = QtGui.QPen(QtGui.QColor(data['color']))
width = data.get('width')
if width:
newBorder.setWidth(width)
if oldFormat.property(BorderProperty) != newBorder:
fmt = QtGui.QTextCharFormat()
else:
fmt = oldFormat
fmt.setProperty(BorderProperty, newBorder)
cursor.mergeCharFormat(fmt)
For obvious reasons, this will provide clipboard support for the borders only for instances of BorderTextEdit or its subclasses, and will not be available when pasting into other programs, even if they accept HTML data.
I found a solution with QRubberband, which is pretty close to what I wanted:
import sys
from PyQt5.QtGui import QTextCursor
from PyQt5.QtWidgets import QTextEdit, QWidget, QApplication, QVBoxLayout
from PyQt5.Qt import QRubberBand
class TextEditor(QTextEdit):
def __init__(self, parent=None):
super().__init__(parent)
text = "Hello World"
self.setText(text)
word = "World"
start_index = text.find(word)
end_index = start_index + len(word)
self.set = set()
self.set.add((start_index, end_index))
def getBoundingRect(self, start, end):
cursor = self.textCursor()
cursor.setPosition(end)
last_rect = end_rect = self.cursorRect(cursor)
cursor.setPosition(start)
first_rect = start_rect = self.cursorRect(cursor)
if start_rect.y() != end_rect.y():
cursor.movePosition(QTextCursor.StartOfLine)
first_rect = last_rect = self.cursorRect(cursor)
while True:
cursor.movePosition(QTextCursor.EndOfLine)
rect = self.cursorRect(cursor)
if rect.y() < end_rect.y() and rect.x() > last_rect.x():
last_rect = rect
moved = cursor.movePosition(QTextCursor.NextCharacter)
if not moved or rect.y() > end_rect.y():
break
last_rect = last_rect.united(end_rect)
return first_rect.united(last_rect)
class Window(QWidget):
def __init__(self):
super(Window, self).__init__()
self.edit = TextEditor(self)
layout = QVBoxLayout(self)
layout.addWidget(self.edit)
self.boxes = []
def showBoxes(self):
while self.boxes:
self.boxes.pop().deleteLater()
viewport = self.edit.viewport()
for start, end in self.edit.set:
print(start, end)
rect = self.edit.getBoundingRect(start, end)
box = QRubberBand(QRubberBand.Rectangle, viewport)
box.setGeometry(rect)
box.show()
self.boxes.append(box)
def resizeEvent(self, event):
self.showBoxes()
super().resizeEvent(event)
if __name__ == '__main__':
app = QApplication(sys.argv)
window = Window()
window.setGeometry(800, 100, 1000, 1000)
window.show()
window.showBoxes()
sys.exit(app.exec_())

PyQt5 QTabBar paintEvent with tabs that can move

I would like to have a QTabBar with customised painting in the paintEvent(self,event) method, whilst maintaining the moving tabs animations / mechanics. I posted a question the other day about something similar, but it wasn't worded too well so I have heavily simplified the question with the following code:
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
from PyQt5.QtTest import QTest
import sys
class MainWindow(QMainWindow):
def __init__(self,parent=None,*args,**kwargs):
QMainWindow.__init__(self,parent,*args,**kwargs)
self.tabs = QTabWidget(self)
self.tabs.setTabBar(TabBar(self.tabs))
self.tabs.setMovable(True)
for color in ["red","orange","yellow","lime","green","cyan","blue","purple","violet","magenta"]:
title = color
widget = QWidget(styleSheet="background-color:%s" % color)
pixmap = QPixmap(8,8)
pixmap.fill(QColor(color))
icon = QIcon(pixmap)
self.tabs.addTab(widget,icon,title)
self.setCentralWidget(self.tabs)
self.showMaximized()
class TabBar(QTabBar):
def __init__(self,parent,*args,**kwargs):
QTabBar.__init__(self,parent,*args,**kwargs)
def paintEvent(self,event):
painter = QStylePainter(self)
option = QStyleOptionTab()
for i in range(self.count()):
self.initStyleOption(option,i)
#Customise 'option' here
painter.drawControl(QStyle.CE_TabBarTab,option)
def tabSizeHint(self,index):
return QSize(112,48)
def exceptHook(e,v,t):
sys.__excepthook__(e,v,t)
if __name__ == "__main__":
sys.excepthook = exceptHook
application = QApplication(sys.argv)
mainwindow = MainWindow()
application.exec_()
there are some clear problems:
Dragging the tab to 'slide' it in the QTabBar is not smooth (it doens't glide) - it jumps to the next index.
The background tabs (non-selected tabs) don't glide into place once displaced - they jump into position.
When the tab is slid to the end of the tab bar (past the most right tab) and then let go of it doesn't glide back to the last index - it jumps there.
When sliding a tab, it stays in its original place and at the mouse cursor (in its dragging position) at the same time, and only when the mouse is released does the tab only show at the correct place (up until then it is also showing at the index it is originally from).
How can I modify the painting of a QTabBar with a QStyleOptionTab whilst maintaining all of the moving mechanics / animations of the tabs?
While it might seem a slightly simple widget, QTabBar is not, at least if you want to provide all of its features.
If you closely look at its source code, you'll find out that within the mouseMoveEvent() a private QMovableTabWidget is created whenever the drag distance is wide enough. That QWidget is a child of QTabBar that shows a QPixmap grab of the "moving" tab using the tab style option and following the mouse movements, while at the same moment that tab becomes invisible.
While your implementation might seem reasonable (note that I'm also referring to your original, now deleted, question), there are some important issues:
it doesn't account for the above "moving" child widget (in fact, with your code I can still see the original tab, even if that is that moving widget that's not actually moving since no call to the base implementation of mouseMoveEvent() is called);
it doesn't actually tabs;
it doesn't correctly process mouse events;
This is a complete implementation partially based on the C++ sources (I've tested it even with vertical tabs, and it seems to behave as it should):
class TabBar(QTabBar):
class MovingTab(QWidget):
'''
A private QWidget that paints the current moving tab
'''
def setPixmap(self, pixmap):
self.pixmap = pixmap
self.update()
def paintEvent(self, event):
qp = QPainter(self)
qp.drawPixmap(0, 0, self.pixmap)
def __init__(self,parent, *args, **kwargs):
QTabBar.__init__(self,parent, *args, **kwargs)
self.movingTab = None
self.isMoving = False
self.animations = {}
self.pressedIndex = -1
def isVertical(self):
return self.shape() in (
self.RoundedWest,
self.RoundedEast,
self.TriangularWest,
self.TriangularEast)
def createAnimation(self, start, stop):
animation = QVariantAnimation()
animation.setStartValue(start)
animation.setEndValue(stop)
animation.setEasingCurve(QEasingCurve.InOutQuad)
def removeAni():
for k, v in self.animations.items():
if v == animation:
self.animations.pop(k)
animation.deleteLater()
break
animation.finished.connect(removeAni)
animation.valueChanged.connect(self.update)
animation.start()
return animation
def layoutTab(self, overIndex):
oldIndex = self.pressedIndex
self.pressedIndex = overIndex
if overIndex in self.animations:
# if the animation exists, move its key to the swapped index value
self.animations[oldIndex] = self.animations.pop(overIndex)
else:
start = self.tabRect(overIndex).topLeft()
stop = self.tabRect(oldIndex).topLeft()
self.animations[oldIndex] = self.createAnimation(start, stop)
self.moveTab(oldIndex, overIndex)
def finishedMovingTab(self):
self.movingTab.deleteLater()
self.movingTab = None
self.pressedIndex = -1
self.update()
# reimplemented functions
def tabSizeHint(self, i):
return QSize(112, 48)
def mousePressEvent(self, event):
super().mousePressEvent(event)
if event.button() == Qt.LeftButton:
self.pressedIndex = self.tabAt(event.pos())
if self.pressedIndex < 0:
return
self.startPos = event.pos()
def mouseMoveEvent(self,event):
if not event.buttons() & Qt.LeftButton or self.pressedIndex < 0:
super().mouseMoveEvent(event)
else:
delta = event.pos() - self.startPos
if not self.isMoving and delta.manhattanLength() < QApplication.startDragDistance():
# ignore the movement as it's too small to be considered a drag
return
if not self.movingTab:
# create a private widget that appears as the current (moving) tab
tabRect = self.tabRect(self.pressedIndex)
overlap = self.style().pixelMetric(
QStyle.PM_TabBarTabOverlap, None, self)
tabRect.adjust(-overlap, 0, overlap, 0)
pm = QPixmap(tabRect.size())
pm.fill(Qt.transparent)
qp = QStylePainter(pm, self)
opt = QStyleOptionTab()
self.initStyleOption(opt, self.pressedIndex)
if self.isVertical():
opt.rect.moveTopLeft(QPoint(0, overlap))
else:
opt.rect.moveTopLeft(QPoint(overlap, 0))
opt.position = opt.OnlyOneTab
qp.drawControl(QStyle.CE_TabBarTab, opt)
qp.end()
self.movingTab = self.MovingTab(self)
self.movingTab.setPixmap(pm)
self.movingTab.setGeometry(tabRect)
self.movingTab.show()
self.isMoving = True
self.startPos = event.pos()
isVertical = self.isVertical()
startRect = self.tabRect(self.pressedIndex)
if isVertical:
delta = delta.y()
translate = QPoint(0, delta)
startRect.moveTop(startRect.y() + delta)
else:
delta = delta.x()
translate = QPoint(delta, 0)
startRect.moveLeft(startRect.x() + delta)
movingRect = self.movingTab.geometry()
movingRect.translate(translate)
self.movingTab.setGeometry(movingRect)
if delta < 0:
overIndex = self.tabAt(startRect.topLeft())
else:
if isVertical:
overIndex = self.tabAt(startRect.bottomLeft())
else:
overIndex = self.tabAt(startRect.topRight())
if overIndex < 0:
return
# if the target tab is valid, move the current whenever its position
# is over the half of its size
overRect = self.tabRect(overIndex)
if isVertical:
if ((overIndex < self.pressedIndex and movingRect.top() < overRect.center().y()) or
(overIndex > self.pressedIndex and movingRect.bottom() > overRect.center().y())):
self.layoutTab(overIndex)
elif ((overIndex < self.pressedIndex and movingRect.left() < overRect.center().x()) or
(overIndex > self.pressedIndex and movingRect.right() > overRect.center().x())):
self.layoutTab(overIndex)
def mouseReleaseEvent(self,event):
super().mouseReleaseEvent(event)
if self.movingTab:
if self.pressedIndex > 0:
animation = self.createAnimation(
self.movingTab.geometry().topLeft(),
self.tabRect(self.pressedIndex).topLeft()
)
# restore the position faster than the default 250ms
animation.setDuration(80)
animation.finished.connect(self.finishedMovingTab)
animation.valueChanged.connect(self.movingTab.move)
else:
self.finishedMovingTab()
else:
self.pressedIndex = -1
self.isMoving = False
self.update()
def paintEvent(self, event):
if self.pressedIndex < 0:
super().paintEvent(event)
return
painter = QStylePainter(self)
tabOption = QStyleOptionTab()
for i in range(self.count()):
if i == self.pressedIndex and self.isMoving:
continue
self.initStyleOption(tabOption, i)
if i in self.animations:
tabOption.rect.moveTopLeft(self.animations[i].currentValue())
painter.drawControl(QStyle.CE_TabBarTab, tabOption)
I strongly suggest you to carefully read and try to understand the above code (along with the source code), as I didn't comment everything I've done, and it's very important to understand what's happening if you really need to do further subclassing in the future.
Update
If you need to alter the appearance of the dragged tab while moving it, you need to update its pixmap. You can just store the QStyleOptionTab when you create it, and then update when necessary. In the following example the WindowText (note that QPalette.Foreground is obsolete) color is changed whenever the index of the tab is changed:
def mouseMoveEvent(self,event):
# ...
if not self.movingTab:
# ...
self.movingOption = opt
def layoutTab(self, overIndex):
# ...
self.moveTab(oldIndex, overIndex)
pm = QPixmap(self.movingTab.pixmap.size())
pm.fill(Qt.transparent)
qp = QStylePainter(pm, self)
self.movingOption.palette.setColor(QPalette.WindowText, <someColor>)
qp.drawControl(QStyle.CE_TabBarTab, self.movingOption)
qp.end()
self.movingTab.setPixmap(pm)
Another small suggestion: while you can obviously use the indentation style you like, when sharing your code on public spaces like StackOverflow it's always better to stick to common conventions, so I suggest you to always provide your code with 4-spaces indentations; also, remember that there should always be a space after each comma separated variable, as it dramatically improves readability.

PyQt5 tooltip with clickable hyperlink?

Can you suggest me a way to make hyperlink in PyQt5 tooltip clickable? Tried like this:
from PyQt5 import QtWidgets
app = QtWidgets.QApplication([])
w = QtWidgets.QMainWindow()
QtWidgets.QLabel(parent = w, text = 'Hover mouse here', toolTip = 'Unclickable link')
w.show()
app.exec_()
Link is visible, but not clickable, unfortunatelly.
This is not an easy task to achieve.
One of the most important aspects is that users are accustomed to the conventional behavior of tool tips: if the mouse cursor hovers them (or they are clicked), they would probably disappear; this is to avoid any possibility that some important part of the widget they refer to becomes hidden (imagine a table that shows a big tooltip for a cell and hides the values of other cells that are near the first one).
Qt follows the same concepts; so, not only you can't interactively click on a tooltip, but it's usually almost impossible to hover a tooltip at all.
The only solution is to create your own tooltip.
In the following (rather complex) example, I'm going to show how to achieve that.
Note that this implementation is not perfect: I could test it under Linux only, but, most importantly, it's not application-wide (it could theoretically be possible, though).
The basic concept is to install an event filter on all widgets that could potentially have a clickable url, intercept every QEvent that is of QEvent.ToolTip type, and create a widget that behaves like it afterwards.
I tried to implement it as much as similar to the standard QToolTip object (which is only accessible almost by static methods). The only difference here is that the static method returns the instance, which allows to connect to the linkActivated signal.
class ClickableTooltip(QtWidgets.QLabel):
__instance = None
refWidget = None
refPos = None
menuShowing = False
def __init__(self):
super().__init__(flags=QtCore.Qt.ToolTip)
margin = self.style().pixelMetric(
QtWidgets.QStyle.PM_ToolTipLabelFrameWidth, None, self)
self.setMargin(margin + 1)
self.setForegroundRole(QtGui.QPalette.ToolTipText)
self.setWordWrap(True)
self.mouseTimer = QtCore.QTimer(interval=250, timeout=self.checkCursor)
self.hideTimer = QtCore.QTimer(singleShot=True, timeout=self.hide)
def checkCursor(self):
# ignore if the link context menu is visible
for menu in self.findChildren(
QtWidgets.QMenu, options=QtCore.Qt.FindDirectChildrenOnly):
if menu.isVisible():
return
# an arbitrary check for mouse position; since we have to be able to move
# inside the tooltip margins (standard QToolTip hides itself on hover),
# let's add some margins just for safety
region = QtGui.QRegion(self.geometry().adjusted(-10, -10, 10, 10))
if self.refWidget:
rect = self.refWidget.rect()
rect.moveTopLeft(self.refWidget.mapToGlobal(QtCore.QPoint()))
region |= QtGui.QRegion(rect)
else:
# add a circular region for the mouse cursor possible range
rect = QtCore.QRect(0, 0, 16, 16)
rect.moveCenter(self.refPos)
region |= QtGui.QRegion(rect, QtGui.QRegion.Ellipse)
if QtGui.QCursor.pos() not in region:
self.hide()
def show(self):
super().show()
QtWidgets.QApplication.instance().installEventFilter(self)
def event(self, event):
# just for safety...
if event.type() == QtCore.QEvent.WindowDeactivate:
self.hide()
return super().event(event)
def eventFilter(self, source, event):
# if we detect a mouse button or key press that's not originated from the
# label, assume that the tooltip should be closed; note that widgets that
# have been just mapped ("shown") might return events for their QWindow
# instead of the actual QWidget
if source not in (self, self.windowHandle()) and event.type() in (
QtCore.QEvent.MouseButtonPress, QtCore.QEvent.KeyPress):
self.hide()
return super().eventFilter(source, event)
def move(self, pos):
# ensure that the style has "polished" the widget (font, palette, etc.)
self.ensurePolished()
# ensure that the tooltip is shown within the available screen area
geo = QtCore.QRect(pos, self.sizeHint())
try:
screen = QtWidgets.QApplication.screenAt(pos)
except:
# support for Qt < 5.10
for screen in QtWidgets.QApplication.screens():
if pos in screen.geometry():
break
else:
screen = None
if not screen:
screen = QtWidgets.QApplication.primaryScreen()
screenGeo = screen.availableGeometry()
# screen geometry correction should always consider the top-left corners
# *last* so that at least their beginning text is always visible (that's
# why I used pairs of "if" instead of "if/else"); also note that this
# doesn't take into account right-to-left languages, but that can be
# accounted for by checking QGuiApplication.layoutDirection()
if geo.bottom() > screenGeo.bottom():
geo.moveBottom(screenGeo.bottom())
if geo.top() < screenGeo.top():
geo.moveTop(screenGeo.top())
if geo.right() > screenGeo.right():
geo.moveRight(screenGeo.right())
if geo.left() < screenGeo.left():
geo.moveLeft(screenGeo.left())
super().move(geo.topLeft())
def contextMenuEvent(self, event):
# check the children QMenu objects before showing the menu (which could
# potentially hide the label)
knownChildMenus = set(self.findChildren(
QtWidgets.QMenu, options=QtCore.Qt.FindDirectChildrenOnly))
self.menuShowing = True
super().contextMenuEvent(event)
newMenus = set(self.findChildren(
QtWidgets.QMenu, options=QtCore.Qt.FindDirectChildrenOnly))
if knownChildMenus == newMenus:
# no new context menu? hide!
self.hide()
else:
# hide ourselves as soon as the (new) menus close
for m in knownChildMenus ^ newMenus:
m.aboutToHide.connect(self.hide)
m.aboutToHide.connect(lambda m=m: m.aboutToHide.disconnect())
self.menuShowing = False
def mouseReleaseEvent(self, event):
# click events on link are delivered on button release!
super().mouseReleaseEvent(event)
self.hide()
def hide(self):
if not self.menuShowing:
super().hide()
def hideEvent(self, event):
super().hideEvent(event)
QtWidgets.QApplication.instance().removeEventFilter(self)
self.refWidget.window().removeEventFilter(self)
self.refWidget = self.refPos = None
self.mouseTimer.stop()
self.hideTimer.stop()
def resizeEvent(self, event):
super().resizeEvent(event)
# on some systems the tooltip is not a rectangle, let's "mask" the label
# according to the system defaults
opt = QtWidgets.QStyleOption()
opt.initFrom(self)
mask = QtWidgets.QStyleHintReturnMask()
if self.style().styleHint(
QtWidgets.QStyle.SH_ToolTip_Mask, opt, self, mask):
self.setMask(mask.region)
def paintEvent(self, event):
# we cannot directly draw the label, since a tooltip could have an inner
# border, so let's draw the "background" before that
qp = QtGui.QPainter(self)
opt = QtWidgets.QStyleOption()
opt.initFrom(self)
style = self.style()
style.drawPrimitive(style.PE_PanelTipLabel, opt, qp, self)
# now we paint the label contents
super().paintEvent(event)
#staticmethod
def showText(pos, text:str, parent=None, rect=None, delay=0):
# this is a method similar to QToolTip.showText;
# it reuses an existent instance, but also returns the tooltip so that
# its linkActivated signal can be connected
if ClickableTooltip.__instance is None:
if not text:
return
ClickableTooltip.__instance = ClickableTooltip()
toolTip = ClickableTooltip.__instance
toolTip.mouseTimer.stop()
toolTip.hideTimer.stop()
# disconnect all previously connected signals, if any
try:
toolTip.linkActivated.disconnect()
except:
pass
if not text:
toolTip.hide()
return
toolTip.setText(text)
if parent:
toolTip.refRect = rect
else:
delay = 0
pos += QtCore.QPoint(16, 16)
# adjust the tooltip position if necessary (based on arbitrary margins)
if not toolTip.isVisible() or parent != toolTip.refWidget or (
not parent and toolTip.refPos and
(toolTip.refPos - pos).manhattanLength() > 10):
toolTip.move(pos)
# we assume that, if no parent argument is given, the current activeWindow
# is what we should use as a reference for mouse detection
toolTip.refWidget = parent or QtWidgets.QApplication.activeWindow()
toolTip.refPos = pos
toolTip.show()
toolTip.mouseTimer.start()
if delay:
toolTip.hideTimer.start(delay)
return toolTip
class ToolTipTest(QtWidgets.QWidget):
def __init__(self):
super().__init__()
layout = QtWidgets.QGridLayout(self)
count = 1
tip = 'This is link {c}'
for row in range(4):
for col in range(4):
button = QtWidgets.QPushButton('Hello {}'.format(count))
layout.addWidget(button, row, col)
button.setToolTip(tip.format(c=count))
button.installEventFilter(self)
count += 1
def toolTipLinkClicked(self, url):
print(url)
def eventFilter(self, source, event):
if event.type() == QtCore.QEvent.ToolTip and source.toolTip():
toolTip = ClickableTooltip.showText(
QtGui.QCursor.pos(), source.toolTip(), source)
toolTip.linkActivated.connect(self.toolTipLinkClicked)
return True
return super().eventFilter(source, event)

Capturing hover events with sceneEventFilter in pyqt

I have user-adjustable annotations in a graphics scene. The size/rotation of annotations is handled by dragging corners of a rectangle about the annotation. I'm using a custom rect (instead of the boundingRect) so it follows the rotation of the parent annotation. The control corners are marked by two ellipses whose parent is the rect so transformations of rect/ellipse/annotation are seamless.
I want to detect when the cursor is over one of the corners, which corner it is, and the exact coordinates. For this task it seems that I should filter the hoverevents with the parent rect using a sceneEventFilter.
I've tried umpty zilch ways of implementing the sceneEventFilter to no avail. All events go directly to the hoverEnterEvent function. I've only found a few bits of example code that do something like this but I'm just plain stuck. btw, I'm totally self taught on Python and QT over the past 3 months, so please bear with me. I'm sure I'm missing something very basic. The code is a simplified gui with two ellipses. We're looking to capture events in the sceneEventFilter but always goes to hoverEnterEvent.
from pyqtgraph.Qt import QtGui, QtCore
import pyqtgraph as pg
from PyQt5.QtWidgets import QGraphicsScene, QGraphicsView, QGraphicsItem
import sys
class myHandle(QtGui.QGraphicsEllipseItem):
def __init__(self, parent = None):
super(myHandle, self).__init__(parent)
def addTheHandle(self, h_parent = 'null', kind = 'null'):
handle_w = 40
if kind == 'scaling handle':
handle_x = h_parent.boundingRect().topRight().x() - handle_w/2
handle_y = h_parent.boundingRect().topRight().y() - handle_w/2
if kind == 'rotation handle':
handle_x = h_parent.boundingRect().topLeft().x() - handle_w/2
handle_y = h_parent.boundingRect().topLeft().y() - handle_w/2
the_handle = QtGui.QGraphicsEllipseItem(QtCore.QRectF(handle_x, handle_y, handle_w, handle_w))
the_handle.setPen(QtGui.QPen(QtGui.QColor(255, 100, 0), 3))
the_handle.setParentItem(h_parent)
the_handle.setAcceptHoverEvents(True)
the_handle.kind = kind
return the_handle
class myRect(QtGui.QGraphicsRectItem):
def __init__(self, parent = None):
super(myRect, self).__init__(parent)
def rectThing(self, boundingrectangle):
self.setAcceptHoverEvents(True)
self.setRect(boundingrectangle)
mh = myHandle()
rotation_handle = mh.addTheHandle(h_parent = self, kind = 'rotation handle')
scaling_handle = mh.addTheHandle(h_parent = self, kind = 'scaling handle')
self.installSceneEventFilter(rotation_handle)
self.installSceneEventFilter(scaling_handle)
return self, rotation_handle, scaling_handle
def sceneEventFilter(self, event):
print('scene ev filter')
return False
def hoverEnterEvent(self, event):
print('hover enter event')
class Basic(QtGui.QMainWindow):
def __init__(self):
super(Basic, self).__init__()
self.initUI()
def eventFilter(self, source, event):
return QtGui.QMainWindow.eventFilter(self, source, event)
def exit_the_program(self):
pg.exit()
def initUI(self):
self.resize(300, 300)
self.centralwidget = QtGui.QWidget()
self.setCentralWidget(self.centralwidget)
self.h_layout = QtGui.QHBoxLayout(self.centralwidget)
self.exit_program = QtGui.QPushButton('Exit')
self.exit_program.clicked.connect(self.exit_the_program)
self.h_layout.addWidget(self.exit_program)
self.this_scene = QGraphicsScene()
self.this_view = QGraphicsView(self.this_scene)
self.this_view.setMouseTracking(True)
self.this_view.viewport().installEventFilter(self)
self.h_layout.addWidget(self.this_view)
self.circle = self.this_scene.addEllipse(QtCore.QRectF(40, 40, 65, 65), QtGui.QPen(QtCore.Qt.black))
mr = myRect()
the_rect, rotation_handle, scaling_handle = mr.rectThing(self.circle.boundingRect())
the_rect.setPen(QtGui.QPen(QtCore.Qt.black))
the_rect.setParentItem(self.circle)
self.this_scene.addItem(the_rect)
self.this_scene.addItem(rotation_handle)
self.this_scene.addItem(scaling_handle)
def main():
app = QtGui.QApplication([])
main = Basic()
main.show()
sys.exit(app.exec_())
if __name__ == '__main__':
main()
The main problem is that you are installing the event filter of the target items on the rectangle: the event filter of the rectangle will never receive anything. Moreover, sceneEventFilter accepts two arguments (the watched item and the event), but you only used one.
What you should do is to install the event filter of the rectangle on the target items:
rotation_handle.installSceneEventFilter(self)
scaling_handle.installSceneEventFilter(self)
That said, if you want to use those ellipse items for scaling or rotation of the source circle, your approach is a bit wrong to begin with.
from math import sqrt
# ...
class myRect(QtGui.QGraphicsRectItem):
def __init__(self, parent):
super(myRect, self).__init__(parent)
self.setRect(parent.boundingRect())
# rotation is usually based on the center of an object
self.parentItem().setTransformOriginPoint(self.parentItem().rect().center())
# a rectangle that has a center at (0, 0)
handleRect = QtCore.QRectF(-20, -20, 40, 40)
self.rotation_handle = QtGui.QGraphicsEllipseItem(handleRect, self)
self.scaling_handle = QtGui.QGraphicsEllipseItem(handleRect, self)
# position the handles by centering them at the right corners
self.rotation_handle.setPos(self.rect().topLeft())
self.scaling_handle.setPos(self.rect().topRight())
for source in (self.rotation_handle, self.scaling_handle):
# install the *self* event filter on the handles
source.installSceneEventFilter(self)
source.setPen(QtGui.QPen(QtGui.QColor(255, 100, 0), 3))
def sceneEventFilter(self, source, event):
if event.type() == QtCore.QEvent.GraphicsSceneMouseMove:
# map the handle event position to the ellipse parent item; we could
# also map to "self", but using the parent is more consistent
localPos = self.parentItem().mapFromItem(source, event.pos())
if source == self.rotation_handle:
# create a temporary line to get the rotation angle
line = QtCore.QLineF(self.boundingRect().center(), localPos)
# add the current rotation to the angle between the center and the
# top left corner, then subtract the new line angle
self.parentItem().setRotation(135 + self.parentItem().rotation() - line.angle())
# note that I'm assuming that the ellipse is a circle, so the top
# left angle will always be at 135°; if it's not a circle, the
# rect width and height won't match and the angle will be
# different, so you'll need to compute that
# parentRect = self.parentItem().rect()
# oldLine = QtCore.QLineF(parentRect.center(), parentRect.topLeft())
# self.parentItem().setRotation(
# oldLine.angle() + self.parentItem().rotation() - line.angle())
elif source == self.scaling_handle:
# still assuming a perfect circle, so the rectangle is a square;
# the line from the center to the top right corner is used to
# compute the square side size, which is the double of a
# right-triangle cathetus where the hypotenuse is the line
# between the center and any of its corners;
# if the ellipse is not a perfect circle, you'll have to
# compute both of the catheti
hyp = QtCore.QLineF(self.boundingRect().center(), localPos)
size = sqrt(2) * hyp.length()
rect = QtCore.QRectF(0, 0, size, size)
rect.moveCenter(self.rect().center())
self.parentItem().setRect(rect)
self.setRect(rect)
# update the positions of both handles
self.rotation_handle.setPos(self.rect().topLeft())
self.scaling_handle.setPos(self.rect().topRight())
return True
elif event.type() == QtCore.QEvent.GraphicsSceneMousePress:
# return True to the press event (which is almost as setting it as
# accepted, so that it won't be processed further more by the scene,
# allowing the sceneEventFilter to capture the following mouseMove
# events that the watched graphics items will receive
return True
return super(myRect, self).sceneEventFilter(source, event)
class Basic(QtGui.QMainWindow):
# ...
def initUI(self):
# ...
self.circle = self.this_scene.addEllipse(QtCore.QRectF(40, 40, 65, 65), QtGui.QPen(QtCore.Qt.black))
mr = myRect(self.circle)
self.this_scene.addItem(mr)

Selecting several items and then taking distance between them

I am currently having an issue with finding the distance between two items in a QGraphicsScene. I have the distance formula already down but the issue is that when I select two points or items in the graphicsScene it says they are at the same point and since they are at the same point the distance always comes out zero. To select multiple items I use ctrl + click to get two points selected at once and I know the two points are at different areas in the scene. Here is the code:
import sys
from PyQt4 import QtGui, QtCore
import math
#this sets the scene for drawing and the microscope image
class MyView(QtGui.QGraphicsView):
def __init__(self,window):
QtGui.QGraphicsView.__init__(self)
self.window = window
self.scene = QtGui.QGraphicsScene(self)
self.item = QtGui.QGraphicsRectItem(400, 400, 400, 400)
self.scene.addItem(self.item)
self.setScene(self.scene)
def paintMarkers(self,event):
##self.cursor = QtGui.QCursor()
#self.cursor.setShape(2)
p = self.mapToScene(event.x(),event.y())
if (p.x() > 400 and p.x() < 800) and (p.y() > 400 and p.y() < 800):
self.circleItem = QtGui.QGraphicsEllipseItem(p.x(),p.y(),5,5)
self.circleItem.setFlag(QtGui.QGraphicsItem.ItemIsSelectable,True)
self.scene.addItem(self.circleItem)
self.circleItem.setPen(QtGui.QPen(QtCore.Qt.red, 1.5))
#self.setScene(self.scene)
def deleteMarkers(self,event):
p = self.mapToScene(event.x(),event.y())
if self.scene.itemAt(p.x(),p.y()) != self.item:
self.scene.removeItem(self.scene.itemAt(p.x(),p.y()))
#print "Hello world"
#def mousePressEvent(self,QMouseEvent):
#self.paintMarkers()
def mousePressEvent(self,event):
if self.window.btnPaintDot.isChecked():
self.paintMarkers(event)
if self.window.btnDeleteMarks.isChecked():
self.deleteMarkers(event)
if self.window.btnPaintLength.isChecked():
self.distanceFormula(self.scene.selectedItems())
return QtGui.QGraphicsView.mousePressEvent(self,event)
def distanceFormula(self,list):
if len(list) == 2:
"""for i in list:
p = self.mapToScene(i.x(),i.y())
print p.x()
print p.y()"""
p = self.mapToScene(list[0].x(),list[0].y())
q = self.mapToScene(list[1].x(),list[1].y())
print p.x()
print p.y()
print q.x()
print q.y()
print list[0]
print list[1]
print len(list)
length = math.sqrt((p.x() - q.x())**2 + (p.y() - q.y())**2)
print length
class Window(QtGui.QMainWindow):
def __init__(self):
#This initializes the main window or form
super(Window,self).__init__()
self.setGeometry(50,50,1000,1000)
self.setWindowTitle("Pre-Alignment system")
self.view = MyView(self)
self.setCentralWidget(self.view)
self.btnPaintLength = QtGui.QPushButton("Len",self)
self.btnPaintLength.clicked.connect(self.paintLengthOnImage)
self.btnPaintLength.setCheckable(True)
self.btnPaintLength.resize(30,30)
self.btnPaintLength.move(330,50)
#this creates the paint button allowing user to draw lines or anything on the image from the microscope
self.btnPaintDot = QtGui.QPushButton("*",self)
self.btnPaintDot.setCheckable(True)
self.btnPaintDot.clicked.connect(self.paintDotOnImage)
self.btnPaintDot.resize(30,30)
self.btnPaintDot.move(300,50)
#creates the delete marker button
self.btnDeleteMarks = QtGui.QPushButton("del",self)
self.btnDeleteMarks.clicked.connect(self.paintDeleteMarks)
self.btnDeleteMarks.setCheckable(True)
self.btnDeleteMarks.resize(30,30)
self.btnDeleteMarks.move(390,50)
def paintLengthOnImage(self):
"""create length on image"""
if self.btnPaintLength.isChecked():
self.btnPaintDot.setChecked(False)
self.btnPaintPolygon.setChecked(False)
self.btnDeleteMarks.setChecked(False)
self.view.distanceFormula(self.view.scene.selectedItems())
else:
self.btnPaintLength.setChecked(False)
def paintDotOnImage(self):
"""create a dot paint button"""
if self.btnPaintDot.isChecked():
self.btnPaintDot.setChecked(True)
self.btnPaintPolygon.setChecked(False)
self.btnPaintLength.setChecked(False)
self.btnDeleteMarks.setChecked(False)
else:
self.btnPaintDot.setChecked(False)
def paintDeleteMarks(self):
if self.btnDeleteMarks.isChecked():
self.btnPaintDot.setChecked(False)
self.btnPaintPolygon.setChecked(False)
self.btnPaintPolygon.setChecked(False)
self.btnDeleteMarks.setChecked(True)
else:
self.btnDeleteMarks.setChecked(False)
def run():
app = QtGui.QApplication(sys.argv)
GUI = Window()
GUI.show()
sys.exit(app.exec_())
run()
The print statements were just there for my purpose of finding where the issue was. Much thanks!
It looks like this happens because of the way the items were created. Items are always created at (0,0). When you specify the (x,y,width,height) when you construct the item, you actually create an items which extends from (0,0) to (x+width, y+height). You can verify this by looking at the size of the items boundingRect().
Instead, you should construct the object at (0,0) and move it to the correct location. With you current code, this means doing the following when you create the item:
self.circleItem = QtGui.QGraphicsEllipseItem(0,0,5,5)
self.circleItem.setPos(p.x()-self.circleItem.boundingRect().width()/2.0,p.y()-self.circleItem.boundingRect().height()/2.0)
Note, here I offset the position by half of the width/height so that the items is centred on the mouse click. You may or may not want this.

Categories

Resources