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.
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:
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);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;eventFilter
function to capture a keyboard shortcut, like Ctrl+Return;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:
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)
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;contentsChange
of the editor whenever a isAutoRepeat()
key event is captured;