pythonpyqtqtextedit

PyQt: Editing QTextEdit block format without adding it to Undo stack


I have a problem which is very similar to this topic. Sadly it did not get fully solved back then.

I have a custom QTextEdit widget in my PyQt application. I've added few features to it to make it fancier. Especially, I lately added a feature which, when a line is selected, uses setBlockFormat to color the line.

But swapping the format pushes the command to the QTextEdit undo stack, and I don't want to trigger this behaviour.

The previous topic did not get a proper answer. The "easier" way on paper would be to block it. However it does not seem to be possible to do that, is it?

The other (and harder) way would be to set the QTextEdit with a custom QUndoStack, which would only pushes key event, but I'm a bit lost with this solution, especially with the deletion of characters (basically I would want to have only typed/deleted text in the Undo stack).

Am I mistaken in my understanding of the topic? Is there a simple solution that I don't see?

EDIT: to answer the question, the purpose of highlighting the line is just to emphasize current line selection, nothing more. It's what is done in software such as Notepad++. Attached is a screenshot of the feature.line highlighting


Solution

  • Visually highlighting a portion of text should never directly affect the underlying QTextDocument.

    While it's probably possible to achieve this by using a smart implementation of QSyntaxHighlighter (through overriding of highlightBlock() and by setting block states), if you already have a syntax highlighter with custom states and user data, supporting this might become quite a handful. Also, by trying to change the background color of text, the result is that any other highlighting that actually changes the background becomes invisible in the selection.

    I propose another solution: overriding the text edit paintEvent() and use the text cursor selection to draw any highlighting before the default painting.

    The procedure is the following:

    1. create a list for the highlighted blocks;
    2. connect the selectionChanged signal of the QTextEdit to a function that will:
      1. create a temporary list and check if any selection exists;
      2. cycle through each block of the document, and if the selection is contained in a block, add it to the list;
      3. if the default list is different from the temporary one, replace it and schedule an update() on the viewport() (important! Calling textEdit.update() is a no-op, since you need to update the contents of its scroll area)
    3. in the paintEvent() override, check if a "highlighted" list of blocks exists, and then:
      1. query the documentLayout();
      2. create an empty QRect;
      3. iterate all "highlighted" blocks and get their blockBoundingRect() that will be merged with the QRect above;
      4. create a QPainter on the viewport and draw the resulting QRect;
    4. call the basic implementation;

    Now, this might be not very effective, especially starting from point 3. and considering that QTextEdit calls paintEvent() every time the cursor "caret" blinks. The solution is to "cache" the highlighted rectangle using QPicture, so that you don't need to compute everything at each paint event. The only requirement is to clear that cache whenever the text edit is resized, since the layout might change the bounding rect of each block.

    Here is the result (using NoWrap to show the result with scrolling):

    screenshot of the example

    ...and the code:

    class HighlightTextEdit(QTextEdit):
        highlightCache = None
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            self.highlightBlocks = []
            self.selectionChanged.connect(self.highlightSelection)
    
        def highlightSelection(self):
            cursor = self.textCursor()
            highlightBlocks = []
            if cursor.hasSelection():
                selectionStart = cursor.selectionStart()
                selectionEnd = cursor.selectionEnd()
                block = self.document().begin()
                while block.isValid():
                    blockStart = block.position()
                    blockEnd = blockStart + block.length()
                    if blockEnd > selectionStart:
                        # the block probably contains the selection
                        if blockStart > selectionEnd:
                            # the block begins before the end of the selection:
                            # we can ignore it and any other block after it
                            break
                        highlightBlocks.append(block)
                    block = block.next()
            if self.highlightBlocks != highlightBlocks:
                self.highlightBlocks = highlightBlocks
                self.highlightCache = None
                self.viewport().update()
    
        def updateHighlightCache(self):
            if not self.highlightBlocks:
                self.highlightCache = None
                return
    
            docLayout = self.document().documentLayout()
            rect = QRectF()
            for block in self.highlightBlocks:
                rect |= docLayout.blockBoundingRect(block)
    
            self.highlightCache = QPicture()
            qp = QPainter(self.highlightCache)
            qp.setPen(Qt.NoPen)
            color = self.palette().color(QPalette.Highlight)
            color.setAlphaF(max(.5, color.alphaF() * .5))
            qp.setBrush(color)
            qp.drawRect(rect)
    
        def paintEvent(self, event):
            if self.highlightBlocks:
                if self.highlightCache is None:
                    self.updateHighlightCache()
                qp = QPainter(self.viewport())
                # translate according to the scroll bars
                qp.translate(
                    -self.horizontalScrollBar().value(), 
                    -self.verticalScrollBar().value()
                )
                self.highlightCache.play(qp)
    
            super().paintEvent(event)
            
        def resizeEvent(self, event):
            super().resizeEvent(event)
            self.updateHighlightCache()
    

    Note that the above is quite effective, since you can do whatever you want with the given bounding rects. For instance, instead of just drawing a merged rectangle, you could create "expanded" rects of each block and add them to a QPainterPath as rounded rects:

        def updateHighlightCache(self):
            if not self.highlightBlocks:
                self.highlightCache = None
                return
    
            docLayout = self.document().documentLayout()
            path = QPainterPath()
            path.setFillRule(Qt.WindingFill)
            for block in self.highlightBlocks:
                rect = docLayout.blockBoundingRect(block)
                path.addRoundedRect(rect.adjusted(-1, -1, 1, 1), 2, 2)
    
            self.highlightCache = QPicture()
            qp = QPainter(self.highlightCache)
            qp.setPen(Qt.NoPen)
            color = self.palette().color(QPalette.Highlight)
            color.setAlphaF(max(.5, color.alphaF() * .5))
            qp.setBrush(color)
            qp.drawPath(path)
    

    Another important aspect is that if the QTextDocument contains rich text with laid out items (such as table cells or inner frames), the selection might only include the rectangle of that block. In that case, if you still want to highlight the horizontal bounding rect, you need to merge the rectangle of the bounding rects with the rectangle of the whole document (the "top frame"), while considering the document margins:

        def updateHighlightCache(self):
            if not self.highlightBlocks:
                self.highlightCache = None
                return
    
            docLayout = self.document().documentLayout()
            rect = QRectF()
            for block in self.highlightBlocks:
                rect |= docLayout.blockBoundingRect(block)
            topRect = docLayout.frameBoundingRect(self.document().rootFrame())
            margin = self.document().documentMargin()
            rect.setLeft(topRect.x() + margin)
            rect.setRight(min(rect.right(), topRect.right() - margin * 2))
    
            self.highlightCache = QPicture()
            qp = QPainter(self.highlightCache)
            qp.setPen(Qt.NoPen)
            color = self.palette().color(QPalette.Highlight)
            color.setAlphaF(max(.5, color.alphaF() * .5))
            qp.setBrush(color)
            qp.drawRect(rect)