Search code examples
pythonpyqt5qt5editorqtableview

Make row of QTableView expand as editor grows in height


This follows on directly from this question. Here is an MRE:

class MainWindow(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle('Get a grip of table view row height MRE')
        self.setGeometry(QtCore.QRect(100, 100, 1000, 800))
        layout = QtWidgets.QVBoxLayout()
        central_widget = QtWidgets.QWidget( self )
        central_widget.setLayout(layout)
        self.table_view = SegmentsTableView(self)
        self.setCentralWidget(central_widget)
        layout.addWidget(self.table_view)
        rows = [
         ['one potatoe two potatoe', 'one potatoe two potatoe'],
         ['Sed ut perspiciatis, unde omnis iste natus error sit voluptatem accusantium doloremque',
          'Sed ut <b>perspiciatis, unde omnis <i>iste natus</b> error sit voluptatem</i> accusantium doloremque'],
         ['Nemo enim ipsam voluptatem, quia voluptas sit, aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos, qui ratione voluptatem sequi nesciunt, neque porro quisquam est, qui do lorem ipsum, quia dolor sit amet consectetur adipiscing velit, sed quia non numquam do eius modi tempora incididunt, ut labore et dolore magnam aliquam quaerat voluptatem.',
          'Nemo enim ipsam <i>voluptatem, quia voluptas sit, <b>aspernatur aut odit aut fugit, <u>sed quia</i> consequuntur</u> magni dolores eos</b>, qui ratione voluptatem sequi nesciunt, neque porro quisquam est, qui do lorem ipsum, quia dolor sit amet consectetur adipiscing velit, sed quia non numquam do eius modi tempora incididunt, ut labore et dolore magnam aliquam quaerat voluptatem.'
          ],
         ['Ut enim ad minima veniam',
          'Ut enim ad minima veniam'],
         ['Quis autem vel eum iure reprehenderit',
          'Quis autem vel eum iure reprehenderit'],
         ['At vero eos et accusamus et iusto odio dignissimos ducimus, qui blanditiis praesentium voluptatum deleniti atque corrupti, quos dolores et quas molestias excepturi sint, obcaecati cupiditate non provident, similique sunt in culpa, qui officia deserunt mollitia animi, id est laborum et dolorum fuga.',
          'At vero eos et accusamus et iusto odio dignissimos ducimus, qui blanditiis praesentium voluptatum deleniti atque corrupti, quos dolores et quas molestias excepturi sint, obcaecati cupiditate non provident, similique sunt in culpa, qui officia deserunt mollitia animi, id est laborum et dolorum fuga.'
         ]]
        for n_row, row in enumerate(rows):
            self.table_view.model().insertRow(n_row)
            self.table_view.model().setItem(n_row, 0, QtGui.QStandardItem(row[0]))
            self.table_view.model().setItem(n_row, 1, QtGui.QStandardItem(row[1]))
        self.table_view.setColumnWidth(0, 400)
        self.table_view.setColumnWidth(1, 400)
        self.qle = QtWidgets.QLineEdit()
        layout.addWidget(self.qle)
        self._second_timer = QtCore.QTimer(self)
        self._second_timer.timeout.connect(self.show_doc_size)
        # every 1s 
        self._second_timer.start(1000)
        
    def show_doc_size(self, *args):
        if self.table_view.itemDelegate().editor == None:
            self.qle.setText('no editor yet')
        else:
            self.qle.setText(f'self.table_view.itemDelegate().editor.document().size() {self.table_view.itemDelegate().editor.document().size()}')
        
class SegmentsTableView(QtWidgets.QTableView):
    def __init__(self, parent):
        super().__init__(parent)
        self.setItemDelegate(SegmentsTableViewDelegate(self))
        self.setModel(QtGui.QStandardItemModel())
        v_header =  self.verticalHeader()
        # 
        v_header.setMinimumSectionSize(5)
        v_header.sectionHandleDoubleClicked.disconnect()
        v_header.sectionHandleDoubleClicked.connect(self.resizeRowToContents)
        self.horizontalHeader().sectionResized.connect(self.resizeRowsToContents)        
        
    def resizeRowToContents(self, row):
        print(f'row {row}')
        super().resizeRowToContents(row)
        self.verticalHeader().resizeSection(row, self.sizeHintForRow(row))

    def resizeRowsToContents(self):
        header = self.verticalHeader()
        for row in range(self.model().rowCount()):
            hint = self.sizeHintForRow(row)
            header.resizeSection(row, hint)    
            
    def sizeHintForRow(self, row):
        super_result = super().sizeHintForRow(row)
        print(f'row {row} super_result {super_result}')
        return super_result
        
class SegmentsTableViewDelegate(QtWidgets.QStyledItemDelegate):
    def __init__(self, *args):
        super().__init__(*args)
        self.editor = None
    
    def createEditor(self, parent, option, index):
        class Editor(QtWidgets.QTextEdit):
            def resizeEvent(self, event):
                print(f'event {event}')
                super().resizeEvent(event)
        self.editor = Editor(parent)
        # does not seem to solve things:
        self.editor.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)

        class Document(QtGui.QTextDocument):
            def __init__(self, *args):
                super().__init__(*args)
                self.contentsChange.connect(self.contents_change)
            
            def drawContents(self, p, rect):
                print(f'p {p} rect {rect}')
                super().drawContents(p, rect)
                
            def contents_change(self, position, chars_removed, chars_added):
                # strangely, after a line break, this shows a higher rect NOT when the first character 
                # causes a line break... but after that!
                print(f'contents change, size {self.size()}')
                # parent.parent() is the table view
                parent.parent().resizeRowToContents(index.row())
                
        self.editor.setDocument(Document())
        return self.editor
    
    def paint(self, painter, option, index):
        doc = QtGui.QTextDocument()
        doc.setDocumentMargin(0)
        doc.setDefaultFont(option.font)
        self.initStyleOption(option, index)
        painter.save()
        doc.setTextWidth(option.rect.width())
        doc.setHtml(option.text)
        option.text = ""
        option.widget.style().drawControl(QtWidgets.QStyle.CE_ItemViewItem, option, painter)
        painter.translate(option.rect.left(), option.rect.top())
        clip = QtCore.QRectF(0, 0, option.rect.width(), option.rect.height())
        painter.setClipRect(clip)
        ctx = QtGui.QAbstractTextDocumentLayout.PaintContext()
        ctx.clip = clip
        doc.documentLayout().draw(painter, ctx)
        painter.restore()
            
    def sizeHint(self, option, index):
        self.initStyleOption(option, index)
        doc = QtGui.QTextDocument()
        if self.editor != None and index.row() == 0:
            print(f'self.editor.size() {self.editor.size()}')
            print(f'self.editor.document().size() {self.editor.document().size()}')
        doc.setTextWidth(option.rect.width())
        doc.setDefaultFont(option.font)
        doc.setDocumentMargin(0)
        doc.setHtml(option.text)
        doc_height_int = int(doc.size().height())
        if self.editor != None and index.row() == 0:
            print(f'...row 0 doc_height_int {doc_height_int}')
        return QtCore.QSize(int(doc.idealWidth()), doc_height_int)
                
app = QtWidgets.QApplication([])
app.setFont(default_font)
main_window = MainWindow()
main_window.show()
exec_return = app.exec()
sys.exit(exec_return)   

If I start editing the right-hand cell in row 0 (F2 or double-click), and start typing, slowly at the end of the existing text, the words "four five six seven", I find that a line break (word-wrap) occurs when I type the "n" of seven. But the line which at that moment prints f'contents change, size {self.size()}' shows that the document height is still only 32.0. It is only when I type another character that this increases to 56.0.

I want the row to expand in height as the editor (or its document?) grows in height.

There are a couple of other puzzles: when I type in this editor, the characters are currently jumping up and down a bit. Secondly, the line self.editor.document().size() (in sizeHint) is printed 4 times when I type each character. To me both phenomena suggest that I might be short-circuiting signals in some way, or in some way doing things in the wrong way.

As described, I have not been able to find any way of measuring the true height of the QTextDocument (or its QTextEdit editor) immediately after a line break, or indeed anything like a signal which is emitted when a line break occurs (in this connection I also looked at QTextCursor, for example).

Edit

I've now changed the main window constructor a bit so a QLE can show the dimensions of the QTextDocument in deferred fashion (NB can't use a button because clicking takes focus away and destroys the editor). So please try new version as above if intrigued.

What this shows is rather revealing: you will see that the next 1-second "tick" after the word-wrap occurs, the correct height for the document is given in the QLE. This suggests to me that there is some sort of deferred triggering going on here. But because I haven't been able to find a suitable method or signal which activates when the QTextDocument changes size, I'm not sure how it is possible to respond to that.

PS it works the other way too: if you slowly delete characters after having provoked a word-wrap, until the text becomes one line again, the QLE shows the right height of 32.0 while contents_change continues to show an incorrect height of 56.0.


Solution

  • Resizing the row based on the contents might present some problems, and it might cause recursion you're not very careful, or at least calling a lot of functions unnecessarily, for many, many times.

    The default behavior of Qt delegates is to adapt the string editor (a QLineEdit subclass) based on the contents, making it eventually larger (and never smaller) than its original cell, in order to show as much content as possible, but not larger than the right margin of the view.
    While this behavior works fine, implementing it for a multi-line editor becomes much more complex: a vertical scroll bar should be probably shown (but that creates some problems due to recursion of the document size based on their visibility), and borders around the editor should be visible in order to understand when the actual content ends (otherwise you might mistake the content of the next row for the content of the editor); considering that, resizing the row might make sense, but, as said, careful precautions might be taken. A well written implementation should consider that and possibly inherit the same behavior by properly resizing the editor.

    That said, here are some suggestions:

    • there is usually no real benefit in creating classes inside a function; if you want to make a class "private", just use the double underscore prefix for its name; if you do it in order to access local variables, then it probably means that the whole logic is conceptually wrong: a class should (theoretically) not care about the local scope in which it was created, but only the global environment;
    • changes in the content of a QTextEdit's document require event loop processing in order to be effective: the scroll area needs to update its contents based on the layout system and then effectively resize the document layout; you must use a 0-timeout timer in order to get the actual new size of the document;
    • while in your case only one editor will theoretically exist at any given time, the delegate should not keep a unique static reference for the editor: you might want to use openPersistentEditor() at some point, and that will break a lot of things; unfortunately Qt doesn't provide a public API for the current open editor at a given index, but you can create a dictionary for that; the catch is that you should use a QPersistentModelIndex to be perfectly safe (this is very important if the model supports sorting/filtering or it could be updated externally by another function or thread);
    • the toHtml() function automatically sets the p, li { white-space: pre-wrap; } stylesheet (it's hardcoded, so it cannot be overridden, search for QTextHtmlExporter::toHtml in the sources); since the first paragraph will always begin with a new line for the <p> tag, this means that the resulting QTextDocument based on that HTML will have a pre-wrap new line using the paragraph line spacing. Since item delegates use the editor's user property (which is the html property for QTextEdit) to set the editor data and then submit it to the model, the solution is to create a custom Qt property (with the user flag set to True that would override the existing one) and return the result of toHtml() without the first line break after the <body> tag;
    • clicking outside the index to commit the data is unintuitive; you can override the delegate eventFilter function to capture a keyboard shortcut, like Ctrl+Return;
    • using the document layout with a paint context is normally unnecessary in these situations, and you can just translate the painter and use drawContents;

    Considering the above, here's a possible solution:

    class DelegateRichTextEditor(QtWidgets.QTextEdit):
        commit = QtCore.pyqtSignal(QtWidgets.QWidget)
        sizeHintChanged = QtCore.pyqtSignal()
        storedSize = None
    
        def __init__(self, parent):
            super().__init__(parent)
            self.setFrameShape(0)
            self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
            self.contentTimer = QtCore.QTimer(self, 
                timeout=self.contentsChange, interval=0)
            self.document().setDocumentMargin(0)
            self.document().contentsChange.connect(self.contentTimer.start)
    
        @QtCore.pyqtProperty(str, user=True)
        def content(self):
            text = self.toHtml()
            # find the end of the <body> tag and remove the new line character
            bodyTag = text.find('>', text.find('<body')) + 1
            if text[bodyTag] == '\n':
                text = text[:bodyTag] + text[bodyTag + 1:]
            return text
    
        @content.setter
        def content(self, text):
            self.setHtml(text)
    
        def contentsChange(self):
            newSize = self.document().size()
            if self.storedSize != newSize:
                self.storedSize = newSize
                self.sizeHintChanged.emit()
    
        def keyPressEvent(self, event):
            if event.modifiers() == QtCore.Qt.ControlModifier:
                if event.key() in (QtCore.Qt.Key_Return, ):
                    self.commit.emit(self)
                    return
                elif event.key() == QtCore.Qt.Key_B:
                    if self.fontWeight() == QtGui.QFont.Bold:
                        self.setFontWeight(QtGui.QFont.Normal)
                    else:
                        self.setFontWeight(QtGui.QFont.Bold)
                elif event.key() == QtCore.Qt.Key_I:
                    self.setFontItalic(not self.fontItalic())
                elif event.key() == QtCore.Qt.Key_U:
                    self.setFontUnderline(not self.fontUnderline())
            super().keyPressEvent(event)
    
        def showEvent(self, event):
            super().showEvent(event)
            cursor = self.textCursor()
            cursor.movePosition(cursor.End)
            self.setTextCursor(cursor)
    
    
    class SegmentsTableViewDelegate(QtWidgets.QStyledItemDelegate):
        rowSizeHintChanged = QtCore.pyqtSignal(int)
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            self.editors = {}
    
        def createEditor(self, parent, option, index):
            pIndex = QtCore.QPersistentModelIndex(index)
            editor = self.editors.get(pIndex)
            if not editor:
                editor = DelegateRichTextEditor(parent)
                editor.sizeHintChanged.connect(
                    lambda: self.rowSizeHintChanged.emit(pIndex.row()))
                self.editors[pIndex] = editor
            return editor
    
        def eventFilter(self, editor, event):
            if (event.type() == event.KeyPress and 
                event.modifiers() == QtCore.Qt.ControlModifier and 
                event.key() in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return)):
                    self.commitData.emit(editor)
                    self.closeEditor.emit(editor)
                    return True
            return super().eventFilter(editor, event)
    
        def destroyEditor(self, editor, index):
            # remove the editor from the dict so that it gets properly destroyed;
            # this avoids any "wrapped C++ object destroyed" exception
            self.editors.pop(QtCore.QPersistentModelIndex(index))
            super().destroyEditor(editor, index)
            # emit the signal again: if the data has been rejected, we need to
            # restore the correct hint
            self.rowSizeHintChanged.emit(index.row())
    
    
        def paint(self, painter, option, index):
            self.initStyleOption(option, index)
            painter.save()
            doc = QtGui.QTextDocument()
            doc.setDocumentMargin(0)
            doc.setTextWidth(option.rect.width())
            doc.setHtml(option.text)
            option.text = ""
            option.widget.style().drawControl(
                QtWidgets.QStyle.CE_ItemViewItem, option, painter)
            painter.translate(option.rect.left(), option.rect.top())
            doc.drawContents(painter)
            painter.restore()
    
        def sizeHint(self, option, index):
            self.initStyleOption(option, index)
            editor = self.editors.get(QtCore.QPersistentModelIndex(index))
            if editor:
                doc = QtGui.QTextDocument.clone(editor.document())
            else:
                doc = QtGui.QTextDocument()
                doc.setDocumentMargin(0)
                doc.setHtml(option.text)
                doc.setTextWidth(option.rect.width())
            doc_height_int = int(doc.size().height())
            return QtCore.QSize(int(doc.idealWidth()), doc_height_int)
    
    
    class SegmentsTableView(QtWidgets.QTableView):
        def __init__(self, parent):
            super().__init__(parent)
            delegate = SegmentsTableViewDelegate(self)
            self.setItemDelegate(delegate)
            delegate.rowSizeHintChanged.connect(self.resizeRowToContents)
            # ...
    

    Further notes:

    • while the paint function is usually called last in the event loop, some care should be taken when overriding the option values; when the "modified" option is only used temporarily (for instance, querying the current style with changed values), it's good habit to create a new option based on the given one; you can use a new option by doing the following:
      newOption = option.__class__(option)
    • "Secondly, the line self.editor.document().size() (in sizeHint) is printed 4 times when I type each character": this is because resizeRowToContents is being triggered by contents_change; that resize function automatically calls sizeHint() of the delegate for all indexes in the given row to get all available hints, then it resizes all the sections according to the width computation, but since resizeRowsToContents is connected to resizeRowsToContents it will be called again if the row sizes don't match; this is a typical example for which one must be very careful in changing geometries after some "resize" event, since they could cause some (possibly, infinite) level of recursion;
    • the only drawback of this is that keyboard repetition is not taken into account, so the editor (and the view) won't be updated until a repeated key is actually released; this could be solved by using an alternate timer that triggers the contentsChange of the editor whenever a isAutoRepeat() key event is captured;