Here's an MRE:
import sys, time
from PyQt5 import QtWidgets, QtCore, QtGui
start_time = time.time_ns()
def get_time():
return '{:.3f}'.format((time.time_ns() - start_time) / 1000000000.)
class MainWindow(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle('MRE - cursor disappears out of view')
self.setGeometry(QtCore.QRect(100, 100, 1000, 400))
self.table_view = HistoryTableView(self)
self.setCentralWidget(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<BR>ducimus, qui blanditiis praesentium<BR>voluptatum deleniti atque<BR>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)
class HistoryTableView(QtWidgets.QTableView):
def __init__(self, *args):
super().__init__(*args)
self.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
self.horizontalHeader().setStretchLastSection(True)
self.horizontalHeader().hide()
self.verticalHeader().hide()
self.setModel(HistoryTableModel(self))
self.setItemDelegate(HistoryTableDelegate(self))
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 resizeEvent(self, event):
print(f'{get_time()}: QTV resizeEvent')
super().resizeEvent(event)
QtCore.QTimer.singleShot(0, self.resizeRowsToContents)
def resizeRowsToContent(self):
print(f'{get_time()}: QTV resizeRowsToContent')
header = self.verticalHeader()
for row in range(self.model().rowCount()):
hint = self.sizeHintForRow(row)
header.resizeSection(row, hint)
class HistoryTableDelegate(QtWidgets.QStyledItemDelegate):
def __init__(self, parent, *args):
super().__init__(parent, *args)
self.editor = None
def createEditor(self, parent, option, index):
print(f'{get_time()}: Delegate createEditor')
self.editor = EntryEdit(parent, self)
self.editor_index = index
self.row = index.row()
return self.editor
def editor_text_changed(self, *args):
print(f'{get_time()}: Delegate editor_text_changed')
def setModelData(self, editor, model, index):
print(f'{get_time()}: Delegate setModelData')
plain_text = self.editor.document().toPlainText()
plain_text = plain_text.replace('\n', '<BR>')
model.setData(index, plain_text, QtCore.Qt.DisplayRole)
def destroyEditor(self, editor, index):
print(f'{get_time()}: Delegate destroyEditor')
super().destroyEditor(editor, index)
self.parent().resizeRowToContents(index.row())
self.editor = None
def sizeHint(self, option, index):
print(f'{get_time()}: Delegate sizeHint')
self.initStyleOption(option, index)
if self.editor != None and self.editor_index == index:
doc = self.editor.document()
else:
doc = QtGui.QTextDocument()
doc.setTextWidth(option.rect.width())
doc.setDefaultFont(option.font)
doc.setDocumentMargin(0)
doc.setHtml(option.text)
return QtCore.QSize(int(doc.idealWidth()), int(doc.size().height()))
def paint(self, painter, option, index):
print(f'{get_time()}: Delegate paint')
self.initStyleOption(option, index)
painter.save()
if self.editor != None and self.editor_index == index:
doc = self.editor.document()
else:
doc = QtGui.QTextDocument()
doc.setDocumentMargin(0)
doc.setDefaultFont(option.font)
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()
class HistoryTableModel(QtGui.QStandardItemModel):
def appendRow(self, *args):
super().appendRow(*args)
QtCore.QTimer.singleShot(0, self.parent().resizeRowsToContents)
QtCore.QTimer.singleShot(10, self.parent().scrollToBottom)
def data(self, index, role):
if role == QtCore.Qt.TextAlignmentRole:
return QtCore.Qt.AlignTop
return super().data(index, role)
class EntryEdit(QtWidgets.QTextEdit):
def __init__(self, parent, delegate, *args):
assert isinstance(delegate, QtWidgets.QStyledItemDelegate)
super().__init__(parent, *args)
self.delegate = delegate
self.setSizeAdjustPolicy(QtWidgets.QPlainTextEdit.SizeAdjustPolicy.AdjustToContents)
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setFrameShape(0)
editor_document = EditorDocument(self)
self.setDocument(editor_document)
# put the cursor to the end of the EntryEdit at start of edit...
self.textChanged.connect(delegate.editor_text_changed)
QtCore.QTimer.singleShot(0, self.cursor_to_end)
def cursor_to_end(self):
print(f'{get_time()}: Editor cursor_to_end')
new_cursor = self.textCursor()
new_cursor.movePosition(QtGui.QTextCursor.End)
self.setTextCursor(new_cursor)
def keyPressEvent(self, event):
# Ctrl+Return to end an edit session (keeping modified contents)
if event.key() == QtCore.Qt.Key.Key_Enter or event.key() == QtCore.Qt.Key.Key_Return:
modifs = QtWidgets.QApplication.keyboardModifiers()
if modifs == QtCore.Qt.ControlModifier:
self.delegate.commitData.emit(self)
self.delegate.closeEditor.emit(self)
super().keyPressEvent(event)
self.delegate.parent().resizeRowsToContents()
class EditorDocument(QtGui.QTextDocument):
def __init__(self, parent):
super().__init__(parent)
self.setDocumentMargin(0)
self.contentsChanged.connect(self.contents_changed)
parent.setDocument(self)
def contents_changed(self, *args):
print(f'{get_time()}: Document contents_changed')
QtCore.QTimer.singleShot(0, self.resize_editor)
def resize_editor(self, *args):
print(f'{get_time()}: Document resize_editor')
doc_size = self.size()
self.parent().resize(int(doc_size.width()), int(doc_size.height()))
app = QtWidgets.QApplication([])
main_window = MainWindow()
main_window.show()
exec_return = app.exec()
sys.exit(exec_return)
The thing to do is double-click in the bottom right cell (or press F2). Then add some lines to the bottom of the text. You will see that the text cursor disappears from view, but type a couple more lines.
Then press Ctrl-Return: this has been set up to end the editing session, saving the contents.
Then single-click in the cell above... and then in the bottom right cell again. After a fraction of a second this cell triggers the scrolling properly so that all its contents can be seen.
My goal: to ensure that, during editing, the text cursor does not disappear from view. I.e. that whatever mechanism caused that correct display when the focus goes away and comes back again, is invoked appropriately, probably on detecting that the QTextCursor
is "out of sight".
My observations are that by doing this focus away and back again, the only print
statement of the ones I've included that gets called is the one in the delegate's paint
method. I surmise that the problem is that something is not getting the right "size hint" at the right time during editing. But I can't work out what to do.
In a Java context I'd be wondering about "invalidating" the cell: painting in Swing seemed to be a consequence of a part of screen real estate becoming "invalid". I don't know what triggers sizeHint
and/or paint
in Qt.
Musicamante explained to me in a discussion that the most relevant method for my purposes is QTableView.scrollTo()
. Using that tip I changed the delegate's method editor_text_changed
to the following:
def editor_text_changed(self, *args):
print(f'{get_time()}: Delegate editor_text_changed')
def scroll():
self.parent().scrollTo(self.editor_index)
QtCore.QTimer.singleShot(10, scroll)
or, more concisely:
def editor_text_changed(self, *args):
print(f'{get_time()}: Delegate editor_text_changed')
QtCore.QTimer.singleShot(10, lambda: self.parent().scrollTo(self.editor_index))
... putting a non-zero ms value here seems to do the trick in a non-intermittent way.
Musicamante clearly thinks that my approach (where essentially I'm trying to make the user have the illusion that the rendered cell has magically become an editor when an edit session starts) is fundamentally undesirable. As long as I can overcome the technical hitches which he foresees I'm quite happy to persist with my illusion-based approach.
I think making things so that the editor starts to eat up (i.e. mask) other parts of the table (rows above in his solution) is not particularly desirable.
From a purely practical/ergonomics standpoint, supposing the user needs to make reference to the content of the preceding rows whilst editing the final row? In my example, indeed, as the class names might suggest (forget the "Lorem ipsum"...!), this is a chronological "history" consisting of dated "entries", so the user is very likely to need to be able to see the previous entries' contents.