在 PyQt 用 Delegate 來客製化顯示項目

PyQt 預設的 View 顯示雖然能用,但如果想要實現進階的顯示方式,就必須使用 Delegate 來做客制化。PyQt 中的 Delegate 類別允許我們控制 Painter 在 Context(畫布)上手動繪製 GUI,就像用程式操控小畫家的畫筆一樣,移動到某個位置,然後繪製。

當然你也可以一筆一畫從頭畫到尾,但更聰明的方式是搭配預設控制項來繪製。

用文字寫可能有看沒有懂,直接從實例來解説吧。

VerticalGridDelegate

讓表格只繪製直綫的 Delegate。

class VerticalGridDelegate(QtWidgets.QStyledItemDelegate):
    # https://www.appsloveworld.com/python/1244/how-to-have-vertical-grid-lines-in-qtablewidget

    def paint(self, painter, option, index):
        super().paint(painter, option, index)

        line = QtCore.QLine(option.rect.topRight(), option.rect.bottomRight())
        color = option.palette.color(QtGui.QPalette.Midlight)
        painter.save()
        painter.setPen(QtGui.QPen(color))
        painter.drawLine(line)
        painter.restore()

 

HtmlRichTextDelegate

來練習新增一個 Delegate 類別可以顯示 HTML 的 RichText 内容。

class RichTextDelegate(QtWidgets.QStyledItemDelegate):
    def __init__(self):
        super().__init__()
        opt = QtGui.QTextOption()
        opt.setWrapMode(opt.WrapMode.NoWrap)
        self.doc = QtGui.QTextDocument()
        self.doc.setDefaultTextOption(opt)

    def paint(self, painter, option, index):
        painter.save()

        opt = QtWidgets.QStyleOptionViewItem(option)
        self.initStyleOption(opt, index)

        self.doc.setTextWidth(opt.rect.width())
        self.doc.setDefaultFont(opt.font)
        # 假設你的 ItemModel 會在 UserRole 回傳 HTML 格式的文字
        display_text = index.data(Qt.ItemDataRole.UserRole)
        # 將 HTML 内容帶入臨時的 doc 實體以便使用其内建的繪製方法
        self.doc.setHtml(display_text)

        opt.text = ""
        style = QtGui.QApplication.style() if opt.widget is None else opt.widget.style()
        # 繪製一個不含文字的格子(文字的部分在前2行清掉了)
        style.drawControl(QtWidgets.QStyle.ControlElement.CE_ItemViewItem, opt, painter)
        if opt.features & QtWidgets.QStyleOptionViewItem.ViewItemFeature.HasCheckIndicator:
            # 如果項目被 flag 標記為 Checkable,需要繪製一個 Checkbox
            opt.rect = style.subElementRect(
                QtWidgets.QStyle.SubElement.SE_CheckBoxContents, opt
            )

        # 調整繪製裁切範圍
        ctx = QtGui.QAbstractTextDocumentLayout.PaintContext()
        if opt.displayAlignment == Qt.AlignmentFlag.AlignRight:
            ctx.clip = QtCore.QRectF(
                opt.rect.right() - opt.fontMetrics.boundingRect(display_text).width(),
                0,
                opt.rect.width(),
                opt.rect.height(),
            )
        else:
            ctx.clip = QtCore.QRectF(0, 0, opt.rect.width(), opt.rect.height())

        # 調整 y offset 到項目格子的正中間
        content_height = self.doc.size().height()
        offset_y = (option.rect.height() - content_height) / 2
        painter.translate(opt.rect.left(), opt.rect.top() + offset_y)

        # 繪製 doc 的内容
        self.doc.documentLayout().draw(painter, ctx)

        painter.restore()

    def sizeHint(self, option, index):
        opt = QtWidgets.QStyleOptionViewItem(option)
        self.initStyleOption(opt, index)

        self.doc.setTextWidth(opt.rect.width())
        self.doc.setDefaultFont(opt.font)
        # 假設你的 ItemModel 會在 UserRole 回傳 HTML 格式的文字
        display_text = index.data(Qt.ItemDataRole.UserRole)
        # 將 HTML 内容帶入臨時的 doc 實體以便使用其内建的繪製方法
        self.doc.setHtml(display_text)
        return QSize(int(self.doc.idealWidth()), int(self.doc.size().height()))

SyntaxHighlightDelegate

需搭配 syntax highlighter。

class SyntaxHighlightDelegate(QtWidgets.QStyledItemDelegate):
    def __init__(self, highlighter: syntax.AbsHighlighter, color_theme):
        super().__init__()
        self._app = QApplication.instance()
        self.doc = QtGui.QTextDocument()
        self.docEllipse = QtGui.QTextDocument()
        self.docEllipse.setPlainText("…")
        self._initTextOption(self.doc)
        self.marker = highlighter(self.doc, syntax.THEMES[color_theme])

    def _initTextOption(self, document):
        opt = QtGui.QTextOption()
        opt.setWrapMode(opt.WrapMode.NoWrap)
        document.setDefaultTextOption(opt)

    def isDarkMode(self):
        bg = self._app.palette().color(QPalette.ColorRole.Window)
        return bg.rgb() < 0xFF808080

    def paint(self, painter, option, index):
        painter.save()

        opt = QtWidgets.QStyleOptionViewItem(option)
        self.initStyleOption(opt, index)

        display_text = opt.fontMetrics.elidedText(
            opt.text, opt.textElideMode, opt.rect.width()
        )
        self.doc.setTextWidth(opt.rect.width())
        self.docEllipse.setTextWidth(opt.rect.width())
        self.doc.setDefaultFont(opt.font)
        self.docEllipse.setDefaultFont(opt.font)
        self.doc.setPlainText(display_text)

        opt.text = ""
        style = QtGui.QApplication.style() if opt.widget is None else opt.widget.style()
        # print(index.row(), hex(int(opt.state)), hex(int(opt.backgroundBrush.color().rgb())))
        # style.drawPrimitive(QtWidgets.QStyle.PrimitiveElement.PE_PanelItemViewItem, opt, painter)
        if opt.state & QtWidgets.QStyle.State_HasFocus:
            # remove dotted border of selected item
            # https://stackoverflow.com/questions/9795791/removing-dotted-border-without-setting-nofocus-in-windows-pyqt
            opt.state ^= QtWidgets.QStyle.State_HasFocus
        elif opt.state ^ QtWidgets.QStyle.State_Active:
            if opt.state & QtWidgets.QStyle.State_MouseOver and not self.isDarkMode():
                color = (
                    "#DCEAF6" if opt.features & QStyleOptionViewItem.Alternate else "#E5F3FF"
                )
                opt.backgroundBrush = QBrush(QColor(color))
        style.drawControl(QtWidgets.QStyle.ControlElement.CE_ItemViewItem, opt, painter)

        ctx = QtGui.QAbstractTextDocumentLayout.PaintContext()
        ctx.clip = QtCore.QRectF(0, 0, opt.rect.width(), opt.rect.height())
        content_height = self.doc.size().height()
        offset_y = (option.rect.height() - content_height) / 2

        # ellipsis_width = int(self.docEllipse.idealWidth())
        painter.translate(opt.rect.left(), opt.rect.top() + offset_y)

        self.marker.rehighlight()
        self.doc.documentLayout().draw(painter, ctx)

        painter.restore()

        if (
            not self.isDarkMode()
            and opt.state & QtWidgets.QStyle.State_Selected
            and opt.state & QtWidgets.QStyle.State_MouseOver
        ):
            # draw top/bottom border of selection
            painter.save()
            painter.setPen(QtGui.QPen(QColor("#99D1FF")))
            line = QtCore.QLine(option.rect.topLeft(), option.rect.topRight())
            painter.drawLine(line)
            line = QtCore.QLine(option.rect.bottomLeft(), option.rect.bottomRight())
            painter.drawLine(line)
            painter.restore()

 

References