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.
Related
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_())
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_())
I have made a custom widget similar to QPushbutton or label. I would like to let the user resize the widget when the mouse is over the edge of the widget. How can I do this?
(Note: I am not looking for Splitter window)
An image editing software, you have a dedicated "space" for the image, and the user is free to do anything she/he wants within the boundaries of that space. When a widget is placed within a layout-managed container (as it normally should) that can represent multiple issues. Not only you've to implement the whole mouse interaction to resize the widget, but you also need to notify the possible parent widget(s) about the resizing.
That said, what you're trying to achieve can be done, with some caveats.
The following is a very basic implementation of a standard QWidget that is able to resize itself, while notifying its parent widget(s) about the size hint modifications. Note that this is not complete, and its behavior doesn't correctly respond to mouse movements whenever they happen on the top or left edges of the widget. Moreover, while it (could) correctly resize the parent widget(s) while increasing its size, the resize doesn't happen when shrinking. This can theoretically be achieved by setting a minimumSize() and manually calling adjustSize() but, in order to correctly provide all the possible features required by a similar concept, you'll need to do the whole implementation by yourself.
from PyQt5 import QtCore, QtGui, QtWidgets
Left, Right = 1, 2
Top, Bottom = 4, 8
TopLeft = Top|Left
TopRight = Top|Right
BottomRight = Bottom|Right
BottomLeft = Bottom|Left
class ResizableLabel(QtWidgets.QWidget):
resizeMargin = 4
# note that the Left, Top, Right, Bottom constants cannot be used as class
# attributes if you want to use list comprehension for better performance,
# and that's due to the variable scope behavior on Python 3
sections = [x|y for x in (Left, Right) for y in (Top, Bottom)]
cursors = {
Left: QtCore.Qt.SizeHorCursor,
Top|Left: QtCore.Qt.SizeFDiagCursor,
Top: QtCore.Qt.SizeVerCursor,
Top|Right: QtCore.Qt.SizeBDiagCursor,
Right: QtCore.Qt.SizeHorCursor,
Bottom|Right: QtCore.Qt.SizeFDiagCursor,
Bottom: QtCore.Qt.SizeVerCursor,
Bottom|Left: QtCore.Qt.SizeBDiagCursor,
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.startPos = self.section = None
self.rects = {section:QtCore.QRect() for section in self.sections}
# mandatory for cursor updates
self.setMouseTracking(True)
# just for demonstration purposes
background = QtGui.QPixmap(3, 3)
background.fill(QtCore.Qt.transparent)
qp = QtGui.QPainter(background)
pen = QtGui.QPen(QtCore.Qt.darkGray, .5)
qp.setPen(pen)
qp.drawLine(0, 2, 2, 0)
qp.end()
self.background = QtGui.QBrush(background)
def updateCursor(self, pos):
for section, rect in self.rects.items():
if pos in rect:
self.setCursor(self.cursors[section])
self.section = section
return section
self.unsetCursor()
def adjustSize(self):
del self._sizeHint
super().adjustSize()
def minimumSizeHint(self):
try:
return self._sizeHint
except:
return super().minimumSizeHint()
def mousePressEvent(self, event):
if event.button() == QtCore.Qt.LeftButton:
if self.updateCursor(event.pos()):
self.startPos = event.pos()
return
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
if self.startPos is not None:
delta = event.pos() - self.startPos
if self.section & Left:
delta.setX(-delta.x())
elif not self.section & (Left|Right):
delta.setX(0)
if self.section & Top:
delta.setY(-delta.y())
elif not self.section & (Top|Bottom):
delta.setY(0)
newSize = QtCore.QSize(self.width() + delta.x(), self.height() + delta.y())
self._sizeHint = newSize
self.startPos = event.pos()
self.updateGeometry()
elif not event.buttons():
self.updateCursor(event.pos())
super().mouseMoveEvent(event)
self.update()
def mouseReleaseEvent(self, event):
super().mouseReleaseEvent(event)
self.updateCursor(event.pos())
self.startPos = self.section = None
self.setMinimumSize(0, 0)
def resizeEvent(self, event):
super().resizeEvent(event)
outRect = self.rect()
inRect = self.rect().adjusted(self.resizeMargin, self.resizeMargin, -self.resizeMargin, -self.resizeMargin)
self.rects[Left] = QtCore.QRect(outRect.left(), inRect.top(), self.resizeMargin, inRect.height())
self.rects[TopLeft] = QtCore.QRect(outRect.topLeft(), inRect.topLeft())
self.rects[Top] = QtCore.QRect(inRect.left(), outRect.top(), inRect.width(), self.resizeMargin)
self.rects[TopRight] = QtCore.QRect(inRect.right(), outRect.top(), self.resizeMargin, self.resizeMargin)
self.rects[Right] = QtCore.QRect(inRect.right(), self.resizeMargin, self.resizeMargin, inRect.height())
self.rects[BottomRight] = QtCore.QRect(inRect.bottomRight(), outRect.bottomRight())
self.rects[Bottom] = QtCore.QRect(inRect.left(), inRect.bottom(), inRect.width(), self.resizeMargin)
self.rects[BottomLeft] = QtCore.QRect(outRect.bottomLeft(), inRect.bottomLeft()).normalized()
# ---- optional, mostly for demonstration purposes ----
def paintEvent(self, event):
super().paintEvent(event)
qp = QtGui.QPainter(self)
if self.underMouse() and self.section:
qp.save()
qp.setPen(QtCore.Qt.lightGray)
qp.setBrush(self.background)
qp.drawRect(self.rect().adjusted(0, 0, -1, -1))
qp.restore()
qp.drawText(self.rect(), QtCore.Qt.AlignCenter, '{}x{}'.format(self.width(), self.height()))
def enterEvent(self, event):
self.update()
def leaveEvent(self, event):
self.update()
class Test(QtWidgets.QWidget):
def __init__(self):
super().__init__()
layout = QtWidgets.QGridLayout(self)
for row in range(3):
for column in range(3):
if (row, column) == (1, 1):
continue
layout.addWidget(QtWidgets.QPushButton(), row, column)
label = ResizableLabel()
layout.addWidget(label, 1, 1)
if __name__ == '__main__':
import sys
app = QtWidgets.QApplication(sys.argv)
w = Test()
w.show()
sys.exit(app.exec_())
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)
I want to add a QPushButton to the tree view that ends with .pdf and when I click it I want to return the path for that Index it's assigned at.
This might not even be possible with the Native QTreeView but if anyone could guide me in the right direction that would be awesome!
To conclude more of what I would want is to have a QPushButton appear where that red square is below.
Current code for the "Tree View":
from PyQt5.QtMultimediaWidgets import *
from PyQt5.QtMultimedia import *
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5 import *
import os, sys
class MainMenu(QWidget):
def __init__(self, parent = None):
super(MainMenu, self).__init__(parent)
self.model = QFileSystemModel()
self.model.setRootPath(QDir.rootPath())
self.model.setFilter(QDir.NoDotAndDotDot | QDir.AllEntries | QDir.Dirs | QDir.Files)
self.proxy_model = QSortFilterProxyModel(recursiveFilteringEnabled = True, filterRole = QFileSystemModel.FileNameRole)
self.proxy_model.setSourceModel(self.model)
self.model.setReadOnly(False)
self.model.setNameFilterDisables(False)
self.indexRoot = self.model.index(self.model.rootPath())
self.treeView = QTreeView(self)
self.treeView.setModel(self.proxy_model)
self.treeView.setRootIndex(self.indexRoot)
self.treeView.setAnimated(True)
self.treeView.setIndentation(20)
self.treeView.setSortingEnabled(True)
self.treeView.setDragEnabled(False)
self.treeView.setAcceptDrops(False)
self.treeView.setDropIndicatorShown(True)
self.treeView.setEditTriggers(QTreeView.NoEditTriggers)
for i in range(1, self.treeView.model().columnCount()):
self.treeView.header().hideSection(i)
if __name__ == '__main__':
app = QApplication(sys.argv)
main = MainMenu()
main.show()
sys.exit(app.exec_())
For this you'll probably need an item delegate.
The idea is that you are going to leave basic item painting to the base class paint() function, and then paint a virtual button over it.
To achieve that, QStyleOptionButton is used against the view style (obtained from the option argument): you create a style option, init it from the view (option.widget, which will apply the basic rectangle of the widget, the font, palette, etc.), adjust the rectangle to suit your needs and finally paint it.
To better implement drawing (mouse hover effects, but also to ensure correct painting update), you'll also need to set mouse tracking to True for the tree view. This, amongst other checks explained in the code, allows you to draw the virtual button, including its hover or pressed states.
Finally, when the button is released and the mouse is within its boundaries, a buttonClicked signal is emitted, with the current index as argument.
class TreeButtonDelegate(QtWidgets.QStyledItemDelegate):
buttonClicked = QtCore.pyqtSignal(QtCore.QModelIndex, int)
def __init__(self, fsModel, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fsModel = fsModel
self.clickedPaths = {}
self._mousePos = None
self._pressed = False
self.minimumButtonWidth = 32
def getOption(self, option, index):
btnOption = QtWidgets.QStyleOptionButton()
# initialize the basic options with the view
btnOption.initFrom(option.widget)
clickedCount = self.clickedPaths.get(self.fsModel.filePath(index), 0)
if clickedCount:
btnOption.text = '{}'.format(clickedCount)
else:
btnOption.text = 'NO'
# the original option properties should never be touched, so we can't
# directly use it's "rect"; let's create a new one from it
btnOption.rect = QtCore.QRect(option.rect)
# adjust it to the minimum size
btnOption.rect.setLeft(option.rect.right() - self.minimumButtonWidth)
style = option.widget.style()
# get the available space for the contents of the button
textRect = style.subElementRect(
QtWidgets.QStyle.SE_PushButtonContents, btnOption)
# get the margins between the contents and the border, multiplied by 2
# since they're used for both the left and right side
margin = style.pixelMetric(
QtWidgets.QStyle.PM_ButtonMargin, btnOption) * 2
# the width of the current button text
textWidth = btnOption.fontMetrics.width(btnOption.text)
if textRect.width() < textWidth + margin:
# if the width is too small, adjust the *whole* button rect size
# to fit the contents
btnOption.rect.setLeft(btnOption.rect.left() - (
textWidth - textRect.width() + margin))
return btnOption
def editorEvent(self, event, model, option, index):
# map the proxy index to the fsModel
srcIndex = index.model().mapToSource(index)
# I'm just checking if it's a file, if you want to check the extension
# you might need to use fsModel.fileName(srcIndex)
if not self.fsModel.isDir(srcIndex):
if event.type() in (QtCore.QEvent.Enter, QtCore.QEvent.MouseMove):
self._mousePos = event.pos()
# request an update of the current index
option.widget.update(index)
elif event.type() == QtCore.QEvent.Leave:
self._mousePos = None
elif (event.type() in (QtCore.QEvent.MouseButtonPress, QtCore.QEvent.MouseButtonDblClick)
and event.button() == QtCore.Qt.LeftButton):
# check that the click is within the virtual button rectangle
if event.pos() in self.getOption(option, srcIndex).rect:
self._pressed = True
option.widget.update(index)
if event.type() == QtCore.QEvent.MouseButtonDblClick:
# do not send double click events
return True
elif event.type() == QtCore.QEvent.MouseButtonRelease:
if self._pressed and event.button() == QtCore.Qt.LeftButton:
# emit the click only if the release is within the button rect
if event.pos() in self.getOption(option, srcIndex).rect:
filePath = self.fsModel.filePath(srcIndex)
count = self.clickedPaths.setdefault(filePath, 0)
self.buttonClicked.emit(index, count + 1)
self.clickedPaths[filePath] += 1
self._pressed = False
option.widget.update(index)
return super().editorEvent(event, model, option, index)
def paint(self, painter, option, index):
super().paint(painter, option, index)
srcIndex = index.model().mapToSource(index)
if not self.fsModel.isDir(srcIndex):
btnOption = self.getOption(option, srcIndex)
# remove the focus rectangle, as it will be inherited from the view
btnOption.state &= ~QtWidgets.QStyle.State_HasFocus
if self._mousePos is not None and self._mousePos in btnOption.rect:
# if the style supports it, some kind of "glowing" border
# will be shown on the button
btnOption.state |= QtWidgets.QStyle.State_MouseOver
if self._pressed == QtCore.Qt.LeftButton:
# set the button pressed state
btnOption.state |= QtWidgets.QStyle.State_On
else:
# ensure that there's no mouse over state (see above)
btnOption.state &= ~QtWidgets.QStyle.State_MouseOver
# finally, draw the virtual button
option.widget.style().drawControl(
QtWidgets.QStyle.CE_PushButton, btnOption, painter)
class MainMenu(QWidget):
def __init__(self, parent = None):
super(MainMenu, self).__init__(parent)
# ...
self.treeView = QTreeView(self)
self.treeView.setMouseTracking(True)
# ...
self.treeDelegate = TreeDelegate(self.model)
self.treeView.setItemDelegateForColumn(0, self.treeDelegate)
self.treeDelegate.buttonClicked.connect(self.treeButtonClicked)
# ...
def treeButtonClicked(self, index, count):
print('{} clicked {} times'.format(index.data(), count))
Note: I implemented the click counter as you asked in the comments (and used an helper function to accomodate the longer function that computes the button size accordingly), just remember that this doesn't take into account the possibility of files renamed, removed and/or recreated (or files renamed overwriting an existing one). To obtain that you'll need to use a more complex approach than a simple path-based dictionary, possibly by implementing QFileSystemWatcher and checking for files removed/renamed.
Also note that to speed up things a bit I'm adding the source filesystem model to the init of the delegate so that it doesn't need to be found each time it's required for painting or mouse tracking.