编辑 QTableView 单元格时 QTextCursor 从视图中消失

QTextCursor disappears from view while editing QTableView cell

这是一个 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)    

要做的事情是双击右下角的单元格(或按 F2)。然后在文本的底部添加一些行。您会看到文本光标从视图中消失,但请多输入几行。

然后按Ctrl-Return:这已经设置结束编辑会话,保存内容。

然后单击上面的单元格...然后再次单击右下角的单元格。几分之一秒后,此单元格会正确触发滚动,以便可以看到其所有内容。

我的目标:确保在编辑过程中,文本光标不会从视图中消失。 IE。当焦点离开并再次返回时导致正确显示的任何机制都会被适当调用,可能是在检测到 QTextCursor“看不见”时。

我的观察是,通过将焦点转移和转移回来,我所包含的唯一被调用的 print 语句是委托的 paint 方法中的语句。我推测问题是在编辑过程中有些东西没有在正确的时间获得正确的“尺寸提示”。但是我不知道该怎么办。

在 Java 上下文中,我想知道如何使单元格“无效”:在 Swing 中绘画似乎是屏幕空间的一部分变得“无效”的结果。我不知道Qt中是什么触发了sizeHintand/orpaint

编辑器通常不应尝试更改视图项的大小。这是许多工具包的标准行为,也是 Qt 中默认编辑器的相同行为。

一个更好更简单的解决方案是根据内容的变化调整编辑器的几何形状,并确保它始终具有正确的位置并且永远不会超出视口可见区域.

class HistoryTableDelegate(QtWidgets.QStyledItemDelegate):
    def __init__(self, parent, *args):
        super().__init__(parent, *args)
        self.editor = None
    
    def createEditor(self, parent, option, index):
        self.editor = EntryEdit(parent, index)
        self.editor.setMinimumHeight(option.rect.height() + 
            self.editor.frameWidth() * 2)
        self.editor.commit.connect(self.commitData)
        self.editor.commit.connect(self.closeEditor)
        self.editor.updateSizeRequest.connect(self.updateEditorSize)
        self.editor_index = index
        self.row = index.row()
        return self.editor

    def updateEditorSize(self, editor, index):
        geo = self.parent().visualRect(index)
        docHeight = int(editor.document().size().height())
        height = max(editor.minimumHeight(), 
            docHeight + editor.frameWidth() * 2)
        geo.setHeight(height)
        parentGeo = editor.parent().rect()
        if geo.bottom() > parentGeo.bottom():
            geo.moveBottom(parentGeo.bottom())
        if geo.y() < 0:
            geo.setTop(0)
        editor.setGeometry(geo)

    def updateEditorGeometry(self, editor, opt, index):
        b = editor.frameWidth()
        editor.setGeometry(opt.rect.adjusted(-b, -b, b, b))

    # ...


class EntryEdit(QtWidgets.QTextEdit):
    updateSizeRequest = QtCore.pyqtSignal(object, QtCore.QModelIndex)
    commit = QtCore.pyqtSignal(QtWidgets.QWidget, 
        QtWidgets.QStyledItemDelegate.EndEditHint)
    def __init__(self, parent, index):
        super().__init__(parent)
        self.index = index
        self.setFrameShape(0)
        # a small border to ensure that the rectangle of the editor 
        # is always visible when it exceeds the size of its index
        self.setStyleSheet('''
            EntryEdit {
                border: 1px solid lightGray;
            }
        ''')
        self.document().setDocumentMargin(0)
        self.updateSizeTimer = QtCore.QTimer(self, singleShot=True, 
            interval=0, timeout=self.emitUpdateSize)
        self.document().contentsChanged.connect(self.updateSizeTimer.start)

    def emitUpdateSize(self):
        self.updateSizeRequest.emit(self, self.index)

    def keyPressEvent(self, event):
        if (event.key() in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return) 
            and event.modifiers() == QtCore.Qt.ControlModifier):
                self.commit.emit(self, QtWidgets.QStyledItemDelegate.NoHint)
                return
        super().keyPressEvent(event)

Musicamante 在一次讨论中向我解释说,与我的目的最相关的方法是 QTableView.scrollTo()。使用该提示,我将委托的方法 editor_text_changed 更改为以下内容:

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)

或者,更简洁地说:

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))

...在这里放置一个 non-zero 毫秒值似乎以 non-intermittent 的方式达到目的。

Musicamante 清楚地认为我的方法(本质上我试图让用户产生一种错觉,即在编辑会话开始时呈现的单元格神奇地变成了编辑器)从根本上是不可取的。只要我能克服他预见到的技术障碍,我就很乐意坚持我的 illusion-based 方法。

我认为让编辑开始吃掉(即屏蔽)table(他的解决方案中上面的行)的其他部分并不是特别可取。

纯粹从practical/ergonomics的角度来看,假设用户在编辑最后一行时需要参考前面几行的内容?在我的示例中,确实,正如 class 名称可能暗示的那样(忘记“Lorem ipsum”......!),这是一个按时间顺序排列的“历史”,由带日期的“条目”组成,因此用户很可能需要能够看到以前条目的内容。