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.
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 (ie 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.
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:
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).
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_())
The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.