How to make QTextDocument highlight text without repositioning it?
-
I have a bunch of items (forum posts) in a view, and a delegate that uses a QTextDocument to render & hit-test the rich text in each item. When the user selects text, I use QTextCursor.setCharFormat() to apply selected-text colors to the appropriate characters.
The problem:
When the start or end of a selection lands between certain character pairs, QTextDocument repositions the rest of the characters on the line. (It looks like kerning gets disabled for characters whose colors don't match.) The shift is slight, but noticeable, and sometimes even wraps the last word on a line down to the next line. The result is text that jitters back and forth while the user is selecting it.
These sample images show the problem. They are very similar, but if you use an image viewer to switch back and forth between the first and third image, or the second and third one, the text repositioning is noticeable:
-
No highlighting:
-
Highlighting with QTextEdit + setTextCursor() works nicely:
-
Highlighting with QTextDocument + setCharFormat() shifts text slightly to the right: (Compare this with image 1 or 2.)
-
Edit to add: example of QTextDocument text shift causing words to re-wrap: (Compare this with image 1.)
I know Qt has some way to avoid this problem, since widgets like QTextEdit and QLabel can highlight selected text without moving it at all. (See image 2.) How can I do the same with QTextDocument?
An obvious workaround would be to use QTextEdit instead of QTextDocument, but my view will contain thousands of items, and I have read that performance would suffer with that many widgets.
Here's a program that can render & highlight text using different strategies, to demonstrate the problem:
#!/usr/bin/env python3 import signal import sys from PySide2.QtCore import QPoint, QRectF, Qt from PySide2.QtGui import QKeySequence, QPainter, QTextCursor, QSyntaxHighlighter, QTextDocument from PySide2.QtWidgets import QApplication, QFrame, QShortcut, QTextEdit, QWidget TEXT = """# ToovevoV.t'rrorqre To V. To V. To V. adipiscing word adipiscing --- This program demonstrates how QTextDocument repositions text when highlighting is applied to part of a word, starting or ending between certain adjacent letters. Use the mouse wheel or arrow keys to apply text highlighting. Watch for text shifting horizontally when the highlight ends within a word. These shifts are usually very slight, but are more pronounced between glyph pairs like "V." or "To". Due to word wrap, these shifts can even push a line's last word down to the next line, depending on word length and window width. Meanwhile, QTextEdit and QLabel apply highlighting without shifting the text. Run this program with the "edit" command line argument to try it. """ class TextEditRenderer(QTextEdit): STRATEGY = "QTextEdit + setTextCursor()" def __init__(self, width): super().__init__() self.setMarkdown(TEXT) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setFrameStyle(QFrame.NoFrame) self.setAttribute(Qt.WA_DontShowOnScreen) self.show() self.setFixedWidth(width) self.setFixedHeight(self.document().size().height()) def get_size(self): return self.size() def calculate_cursor_pos(self, oldpos, cursorop): cursor = self.textCursor() cursor.setPosition(oldpos) cursor.movePosition(cursorop) return cursor.position() def paint(self, painter, palette, selectlen): cursor = self.textCursor() cursor.setPosition(selectlen, QTextCursor.KeepAnchor) self.setTextCursor(cursor) self.render(painter, QPoint()) class TextDocumentRenderer(QTextDocument): STRATEGY = "QTextDocument + setCharFormat()" def __init__(self, width): super().__init__() self.setMarkdown(TEXT) self.setTextWidth(width) def get_size(self): return self.size().toSize() def calculate_cursor_pos(self, oldpos, cursorop): cursor = QTextCursor(self) cursor.setPosition(oldpos) cursor.movePosition(cursorop) return cursor.position() def paint(self, painter, palette, selectlen): cursor = QTextCursor(self) cursor.setPosition(selectlen, QTextCursor.KeepAnchor) cformat = cursor.charFormat() cformat.setBackground(palette.highlight()) cformat.setForeground(palette.highlightedText()) cursor.setCharFormat(cformat) rect = QRectF(QPoint(), self.size()) painter.fillRect(rect, palette.base()) self.drawContents(painter) class Highlighter(QSyntaxHighlighter): def __init__(self, parent, palette, selectlen): super().__init__(parent) self.selectlen = selectlen self.palette = palette def highlightBlock(self, text): offset = max(self.previousBlockState(), 0) remain = self.selectlen - offset count = min(remain, len(text)) if remain > 0: cformat = self.format(0) cformat.setBackground(self.palette.highlight()) cformat.setForeground(self.palette.highlightedText()) self.setFormat(0, count, cformat) self.setCurrentBlockState(offset + len(text)) class SyntaxHighlighterRenderer(TextDocumentRenderer): STRATEGY = "QTextDocument + QSyntaxHighlighter" def paint(self, painter, palette, selectlen): highlighter = Highlighter(self, palette, selectlen) highlighter.rehighlight() rect = QRectF(QPoint(), self.size()) painter.fillRect(rect, palette.base()) self.drawContents(painter) class MainWidget(QWidget): def __init__(self, rendertype, width): super().__init__() self.rendertype = rendertype renderer = self.rendertype(width) self.resize(renderer.get_size()) self.selectlen = 0 # number of characters to highlight quit_shortcut = QShortcut(QKeySequence(Qt.CTRL + Qt.Key_Q), self) quit_shortcut.activated.connect(QApplication.quit) def resizeEvent(self, event): self.window().setWindowTitle( f"{event.size().width()}x{event.size().height()}") def sizeHint(self): renderer = self.rendertype(self.width()) return renderer.get_size() def paintEvent(self, event): renderer = self.rendertype(self.width()) renderer.paint(QPainter(self), self.palette(), self.selectlen) def wheelEvent(self, event): self._change_selection(event, 1 if event.angleDelta().y() > 0 else 0) def keyPressEvent(self, event): if event.key() in (Qt.Key_Right, Qt.Key_Up): self._change_selection(event, 1) elif event.key() in (Qt.Key_Left, Qt.Key_Down): self._change_selection(event, 0) CHAROPS = (QTextCursor.PreviousCharacter, QTextCursor.NextCharacter) WORDOPS = (QTextCursor.PreviousWord, QTextCursor.NextWord) def _change_selection(self, event, direction): operations = self.WORDOPS if event.modifiers() else self.CHAROPS cursorop = operations[direction] renderer = self.rendertype(self.width()) self.selectlen = renderer.calculate_cursor_pos(self.selectlen, cursorop) self.update() def main(): if len(sys.argv) < 2: rendertype = TextDocumentRenderer elif sys.argv[1].startswith('h'): rendertype = SyntaxHighlighterRenderer else: rendertype = TextEditRenderer print("highlighting strategy:", rendertype.STRATEGY) app = QApplication(sys.argv) signal.signal(signal.SIGINT, signal.SIG_DFL) # I chose an initial window width that makes the problem obvious on my # system, due to its effect on word wrap. People with different desktop # and font settings might need a different width to see that effect. widget = MainWidget(rendertype, width=343) widget.show() sys.exit(app.exec_()) if __name__ == "__main__": main()
-
-
@SGaist my question is about Qt's internal behavior, not about Python or the Python bindings. (I used python in some sample code for the sake of simplicity; a C++ version would surely have the same behavior.) I believe it is therefore unlikely to get any answers in the Python forum. Would you please move it back?