如何在视图中实现富文本编辑器(PyQt/PySide/Qt)?

How to implement a rich text editor in a view (PyQt/PySide/Qt)?

短版

我有一个 QTreeView 并希望用户能够精细控制文本的外观,为他们提供富文本格式选项。我已经有了它,所以可以 select 编辑整个项目以进行格式化(例如,粗体),但我需要更大的灵活性。例如,用户必须能够突出显示项目文本的 部分 并加粗它。

请注意,我正在使用 QStandardItemModel(请参阅下面的 SSCCE)。

详细版

给整个项目加气很简单:

itemFont = item.font()
itemFont.setBold(True)
item.setFont(itemFont) 

不幸的是,我的用户需要更细粒度的控制,所以

Hi how are you?

他们应该能够 select 使用鼠标只输入第一个词,并使该项目的文本显示为:

Hi how are you?

我正在考虑的两个选项是:

  1. setIndexWidget

    在我需要此功能的每个单元格中,使用 setIndexWidget 将其显示为 QTextEdit 小部件,类似的操作已在此处完成: To set widgets on children items on QTreeView。然后我可以使用标准工具在每个单元格中进行富文本编辑。

  2. 自定义委托

    使用自定义委托在我需要此功能的地方绘制每个项目,就像这里应用的一样: How to make item view render rich (html) text in Qt

请注意,与那个问题不同的是,我不只是问如何呈现富文本,而是如何让用户 select text 并将其呈现为富文本细粒度的文本。

SSCCE

from PySide import QtGui, QtCore
import sys

class MainTree(QtGui.QMainWindow):
    def __init__(self, tree, parent = None):
        QtGui.QMainWindow.__init__(self)
        self.setAttribute(QtCore.Qt.WA_DeleteOnClose) 
        self.setCentralWidget(tree)
        self.createStatusBar()
        self.createBoldAction()
        self.createToolbar()

    def createStatusBar(self):                          
        self.status = self.statusBar()
        self.status.setSizeGripEnabled(False)
        self.status.showMessage("Ready")

    def createToolbar(self):
        self.textToolbar = self.addToolBar("Text actions")
        self.textToolbar.addAction(self.boldTextAction)

    def createBoldAction(self):
        self.boldTextAction = QtGui.QAction("Bold", self)
        self.boldTextAction.setIcon(QtGui.QIcon("boldText.png"))
        self.boldTextAction.triggered.connect(self.emboldenText)
        self.boldTextAction.setStatusTip("Make selected text bold")

    def emboldenText(self):
        print "Make selected text bold...How do I do this?"


class SimpleTree(QtGui.QTreeView):
    def __init__(self, parent = None):    
        QtGui.QTreeView.__init__(self)
        model = QtGui.QStandardItemModel()
        model.setHorizontalHeaderLabels(['Title', 'Summary'])
        rootItem = model.invisibleRootItem()
        item0 = [QtGui.QStandardItem('Title0'), QtGui.QStandardItem('Summary0')]
        item00 = [QtGui.QStandardItem('Title00'), QtGui.QStandardItem('Summary00')]
        rootItem.appendRow(item0)
        item0[0].appendRow(item00)          
        self.setModel(model)
        self.expandAll()


def main():
    app = QtGui.QApplication(sys.argv)
    myTree = SimpleTree()
    #myTree.show()
    myMainTree = MainTree(myTree)
    myMainTree.show()
    sys.exit(app.exec_())

if __name__ == "__main__":
    main()

唯一合理的方法是使用选项 2:创建自定义委托。您的情况几乎就是委托的确切类型:使用 createEditor 创建自定义编辑器(例如旋转框或富文本编辑器等),并实现 paint使您可以精确控制数据输入后的外观的方法。虽然可能有其他方法可以做到这一点,但几乎可以肯定它们比使用委托更糟糕。

因此,要使其正常工作,您需要为 QStyledItemDelegate.

重新实现 paintcreateEditor

不幸的是,为了实现 createEditor,Qt 没有提供原生的富文本行编辑器(也就是说,没有类似 QLineEdit 的富文本编辑器)。幸运的是,Mark Summerfield 实际上在他关于 PyQt 的书的第 13 章中写了这样一个函数,所以我将其应用到下面的一个完整的示例中,其中包括主 window 中的树视图,具有切换的能力在编辑器打开时使用工具栏或上下文(右键单击)菜单或键盘快捷键的文本属性。


相关帖子

我在以下线程中直接获得了实现其中许多功能的帮助:


图标

工具栏中使用的图像如下:


代码

这是代码。对于大小,我深表歉意,但它包含了很多可能对那些学习代表有用的东西(正如 OP 显然是的那样),所以我决定不对其进行编辑:

import sys
from xml.sax.saxutils import escape as escape
from PySide import QtGui, QtCore


class MainTree(QtGui.QMainWindow):
    def __init__(self, tree, parent = None):
        QtGui.QMainWindow.__init__(self)
        self.setAttribute(QtCore.Qt.WA_DeleteOnClose) 
        self.setCentralWidget(tree)
        self.createStatusBar()
        self.createActions()
        self.createToolbar()
        self.tree = tree
        self.setGeometry(500,150,400,300)

    def createStatusBar(self):                          
        self.status = self.statusBar()
        self.status.setSizeGripEnabled(False)
        self.status.showMessage("Ready")

    def createActions(self):
        '''Create all actions to be used in toolbars/menus: calls createAction()'''
        self.boldTextAction = self.createAction("&Bold",
                shortcut = QtGui.QKeySequence.Bold, iconName = "boldText", tip = "Embolden",
                status = "Toggle bold", disabled = True)              
        self.italicTextAction = self.createAction("&Italic",
                shortcut = QtGui.QKeySequence.Italic, iconName = "italicText", tip = "Italicize",
                status = "Toggle italics", disabled = True)
        self.underlineTextAction = self.createAction("&Underline",
                shortcut = QtGui.QKeySequence.Underline, iconName = "underlineText", tip = "Underline",
                status = "Toggle underline", disabled = True)   
        self.strikeoutTextAction = self.createAction("Stri&keout",
                shortcut = QtGui.QKeySequence("Ctrl+K"), iconName = "strikeoutText", tip = "Strikeout",
                status = "Toggle strikeout", disabled = True)                

    def createAction(self, text, slot = None, shortcut = None, iconName = None,
                     tip = None, status = None, disabled = False):
        '''Creates each individual action'''
        action = QtGui.QAction(text, self)
        if iconName is not None:
            action.setIcon(QtGui.QIcon("{0}.png".format(iconName)))
        if shortcut is not None:
            action.setShortcut(shortcut)
        if tip is not None:
            action.setToolTip(tip)
        if status is not None:
            action.setStatusTip(status)
        if slot is not None:
            action.triggered.connect(slot)
        if disabled:
            action.setDisabled(True)
        return action 

    def createToolbar(self):
        self.textToolbar = self.addToolBar("Text actions")
        self.textToolbar.addAction(self.boldTextAction)
        self.textToolbar.addAction(self.underlineTextAction)
        self.textToolbar.addAction(self.italicTextAction)
        self.textToolbar.addAction(self.strikeoutTextAction)

class HtmlTree(QtGui.QTreeView):
    def __init__(self, parent = None):    
        QtGui.QTreeView.__init__(self)
        model = QtGui.QStandardItemModel()
        model.setHorizontalHeaderLabels(['Task', 'Description'])
        self.rootItem = model.invisibleRootItem()
        item0 = [QtGui.QStandardItem('Sneeze'), QtGui.QStandardItem('You have been blocked up')]
        item00 = [QtGui.QStandardItem('Tickle nose'), QtGui.QStandardItem('Key first step')]
        item1 = [QtGui.QStandardItem('Get a job'), QtGui.QStandardItem('Do not blow it')]
        item01 = [QtGui.QStandardItem('Call temp agency'), QtGui.QStandardItem('Maybe they will be kind')]
        self.rootItem.appendRow(item0)
        item0[0].appendRow(item00) 
        self.rootItem.appendRow(item1)
        item1[0].appendRow(item01)
        self.setModel(model)
        self.expandAll()
        self.setItemDelegate(HtmlPainter(self))
        self.resizeColumnToContents(0)
        self.resizeColumnToContents(1)
        #print "unoiform row heights? ", self.uniformRowHeights()

class HtmlPainter(QtGui.QStyledItemDelegate):
    def __init__(self, parent=None):
        print "delegate parent: ", parent, parent.metaObject().className()
        QtGui.QStyledItemDelegate.__init__(self, parent)

    def paint(self, painter, option, index):
        if index.column() == 1 or index.column() == 0: 
            text = index.model().data(index) 
            palette = QtGui.QApplication.palette()
            document = QtGui.QTextDocument()
            document.setDefaultFont(option.font)
            #Set text (color depends on whether selected)
            if option.state & QtGui.QStyle.State_Selected:  
                displayString = "<font color={0}>{1}</font>".format(palette.highlightedText().color().name(), text) 
                document.setHtml(displayString)
            else:
                document.setHtml(text)
            #Set background color
            bgColor = palette.highlight().color() if (option.state & QtGui.QStyle.State_Selected)\
                     else palette.base().color()
            painter.save()
            painter.fillRect(option.rect, bgColor)
            document.setTextWidth(option.rect.width())
            offset_y = (option.rect.height() - document.size().height())/2
            painter.translate(option.rect.x(), option.rect.y() + offset_y) 
            document.drawContents(painter)
            painter.restore()
        else:
            QtGui.QStyledItemDelegate.paint(self, painter, option, index)          

    def sizeHint(self, option, index):
        rowHeight = 18
        text = index.model().data(index)
        document = QtGui.QTextDocument()
        document.setDefaultFont(option.font)
        document.setHtml(text)
        return QtCore.QSize(document.idealWidth() + 5,  rowHeight) #fm.height())

    def createEditor(self, parent, option, index):
        if index.column() == 1:
            editor = RichTextLineEdit(option, parent)
            editor.returnPressed.connect(self.commitAndCloseEditor) 
            editor.mainWindow = parent.window()
            self.setConnections(editor.mainWindow, editor)
            self.enableActions(editor.mainWindow)
            return editor
        else:
            return QtGui.QStyledItemDelegate.createEditor(self, parent, option,
                                                    index)

    def setConnections(self, mainWindow, editor):
            '''Create connections for font toggle actions when editor is created'''
            mainWindow.boldTextAction.triggered.connect(editor.toggleBold)
            mainWindow.underlineTextAction.triggered.connect(editor.toggleUnderline)
            mainWindow.italicTextAction.triggered.connect(editor.toggleItalic)
            mainWindow.strikeoutTextAction.triggered.connect(editor.toggleStrikeout)

    def enableActions(self, mainWindow):
            mainWindow.boldTextAction.setEnabled(True)
            mainWindow.underlineTextAction.setEnabled(True)
            mainWindow.italicTextAction.setEnabled(True)
            mainWindow.strikeoutTextAction.setEnabled(True)

    def disableActions(self, mainWindow):
            mainWindow.boldTextAction.setDisabled(True)
            mainWindow.underlineTextAction.setDisabled(True)
            mainWindow.italicTextAction.setDisabled(True)
            mainWindow.strikeoutTextAction.setDisabled(True)  

    def commitAndCloseEditor(self):
        editor = self.sender()
        if isinstance(editor, (QtGui.QTextEdit, QtGui.QLineEdit)):
            self.commitData.emit(editor)
            self.closeEditor.emit(editor, QtGui.QAbstractItemDelegate.NoHint)

    def setModelData(self, editor, model, index):        
        if index.column() == 1:
            self.disableActions(editor.mainWindow)
            model.setData(index, editor.toSimpleHtml())
        else:
            QtGui.QStyledItemDelegate.setModelData(self, editor, model, index)



class RichTextLineEdit(QtGui.QTextEdit):
    '''Single line editor invoked by delegate'''
    (Bold, Italic, Underline, StrikeOut) = range(4)
    returnPressed = QtCore.Signal()

    def __init__(self,  option, parent=None):
        QtGui.QTextEdit.__init__(self,  parent)
        self.setLineWrapMode(QtGui.QTextEdit.NoWrap)
        self.setTabChangesFocus(True)
        self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
        self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
        #Following lines set it so text is centered in editor        
        fontMetrics = QtGui.QFontMetrics(self.font())       
        margin = 2
        self.document().setDocumentMargin(margin)
        height = fontMetrics.height() + (margin + self.frameWidth()) * 2
        self.setFixedHeight(height)
        self.setToolTip("Right click for text effect menu.")

    def toggleBold(self):
        self.setFontWeight(QtGui.QFont.Normal
                if self.fontWeight() > QtGui.QFont.Normal else QtGui.QFont.Bold)

    def toggleItalic(self):
        self.setFontItalic(not self.fontItalic())     

    def toggleUnderline(self):
        self.setFontUnderline(not self.fontUnderline())


    def toggleStrikeout(self): 
        #Adapted from: https://www.binpress.com/tutorial/developing-a-pyqt-text-editor-part-2/145
        #https://srinikom.github.io/pyside-docs/PySide/QtGui/QTextCharFormat.html
        # Grab the text's format
        textFormat = self.currentCharFormat()
        # Change the fontStrikeOut property to its opposite
        textFormat.setFontStrikeOut(not textFormat.fontStrikeOut())  
        # Apply the new format
        self.setCurrentCharFormat(textFormat)

    def contextMenuEvent(self, event):
        '''
        Context menu for controlling text
        '''
        textFormat = self.currentCharFormat()
        menu = QtGui.QMenu("Text Effects")
        for text, shortcut, data, checked in (
                ("&Bold", "Ctrl+B", RichTextLineEdit.Bold,
                 self.fontWeight() > QtGui.QFont.Normal),
                ("&Italic", "Ctrl+I", RichTextLineEdit.Italic,
                 self.fontItalic()),
                ("Stri&keout", "Ctrl+K", RichTextLineEdit.StrikeOut,
                 textFormat.fontStrikeOut()),
                ("&Underline", "Ctrl+U", RichTextLineEdit.Underline,
                 self.fontUnderline())):
            action = menu.addAction(text, self.setTextEffect)
            if shortcut is not None:
                action.setShortcut(QtGui.QKeySequence(shortcut))
            action.setData(data)
            action.setCheckable(True)
            action.setChecked(checked)
        self.ensureCursorVisible()
        menu.exec_(self.viewport().mapToGlobal(
                   self.cursorRect().center()))

    def setTextEffect(self):
        '''Called by context menu'''
        action = self.sender()
        if action is not None and isinstance(action, QtGui.QAction):
            what = int(action.data())
            if what == RichTextLineEdit.Bold:
                self.toggleBold()
                return
            if what == RichTextLineEdit.Italic:
                self.toggleItalic()
                return
            if what == RichTextLineEdit.Underline:
                self.toggleUnderline()
                return
            format = self.currentCharFormat()
            if what == RichTextLineEdit.StrikeOut:
                format.setFontStrikeOut(not format.fontStrikeOut())
            self.mergeCurrentCharFormat(format)     

    def keyPressEvent(self, event):
        '''
        Handles all keyboard shortcuts, and stops retun from returning newline
        '''
        if event.modifiers() & QtCore.Qt.ControlModifier:
            handled = False
            if event.key() == QtCore.Qt.Key_B:
                self.toggleBold()
                handled = True
            elif event.key() == QtCore.Qt.Key_I:
                self.toggleItalic()
                handled = True
            elif event.key() == QtCore.Qt.Key_U:
                self.toggleUnderline()
                handled = True
            if handled:
                event.accept()
                return
        if event.key() in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return):
            self.returnPressed.emit()
            event.accept()
        else:
            QtGui.QTextEdit.keyPressEvent(self, event)

    def toSimpleHtml(self):
        html = ""
        block = self.document().begin()
        while block.isValid():
            iterator = block.begin()
            while iterator != block.end():
                fragment = iterator.fragment()
                if fragment.isValid():
                    format = fragment.charFormat()
                    text = escape(fragment.text())
                    if format.fontUnderline():
                        text = "<u>{}</u>".format(text)
                    if format.fontItalic():
                        text = "<i>{}</i>".format(text)
                    if format.fontWeight() > QtGui.QFont.Normal:
                        text = "<b>{}</b>".format(text)
                    if format.fontStrikeOut():
                        text = "<s>{}</s>".format(text)
                    html += text
                iterator += 1
            block = block.next()
        return html


def main():
    app = QtGui.QApplication(sys.argv)
    myTree = HtmlTree()    #myTree.show()
    myMainTree = MainTree(myTree)
    myMainTree.show()
    sys.exit(app.exec_())


if __name__ == "__main__":
    main()