如何制作有角度的表格标题?

chm*_*dly 1 python pyqt pyqt5

使用 PySide2 或 PyQt5,我想制作一个带有 45 度角标题标签的表格小部件,如下图所示。

倾斜水平标题示例

我在 QTable 小部件的 QtCreator(设计器)中没有看到类似的内容。我可以使用如下方式旋转标签:

class MyLabel(QtGui.QWidget):
    def paintEvent(self, event):
        painter = QtGui.QPainter(self)
        painter.setPen(QtCore.Qt.black)
        painter.translate(20, 100)
        painter.rotate(-45)
        painter.drawText(0, 0, "hellos")
        painter.end()
Run Code Online (Sandbox Code Playgroud)

但是,有几个小问题。理想情况下,这将是一个 QLineEdit 小部件,我需要这些小部件“发挥得很好”,以免与其他任何内容重叠,并且我希望它们从标题填充到表格上方。我正在寻找建议。

mus*_*nte 5

这是一个非常有趣的话题,因为Qt没有提供这样的功能,但是它是可以实现的。
\n下面的例子远非完美,我将列出它的主要优点/缺点。

\n\n

优点

\n\n
    \n
  • 有用 ;-)
  • \n
  • 更改水平标题标签会自动更新标题高度
  • \n
  • 支持水平滚动“越过”最后一个项目位置(如果表视图小于其内容,则水平滚动条允许查看完整的标题文本)
  • \n
  • 它有效:-D
  • \n
\n\n

缺点

\n\n
    \n
  • 部分是固定的
  • \n
  • 部分不可移动
  • \n
  • 在此实现中,QAbstractItemView.ScrollPerPixel 对于水平滚动模式是必需的。Qt 的 ScrollPerItem 模式有点复杂,如果不非常小心地覆盖它,就会出现一些问题。这并不意味着不可能使用该模式,但它需要付出很多努力,可能需要仔细阅读和理解 QTableView 和 QAbstractItemView 的源代码。长话短说:ScrollPerItem 一直有效,直到达到水平滚动条的最大值;此时,视图将尝试调整其大小并调整其视口和滚动条值/范围,并且最后一个标题标签将被“剪切”。
  • \n
  • 如果所有水平列都是可见的(意味着这些项目不需要水平滚动),则最后一个水平标题不会完全显示,因为不需要水平滚动条。
  • \n
\n\n

我认为应该可以支持所有标题功能(自定义/可拉伸部分大小、可移动部分、项目滚动等),但需要对 QTableView 和 QHeaderView 方法进行非常深入的重新实现过程。

\n\n

无论如何,这就是我到目前为止得到的结果,它支持滚动、绘画和基本的鼠标交互(单击时突出显示部分)。

\n\n

屏幕截图示例:
\n示例代码的屏幕截图

\n\n

滚动(靠近右边缘)屏幕截图:
\n部分滚动的示例代码的屏幕截图

\n\n

表格大小略晚于最后一个水平列的右边缘:
\n滚动问题的屏幕截图

\n\n

示例代码

\n\n
import sys\nfrom math import sqrt, sin, acos, hypot, degrees, radians\nfrom PyQt5 import QtCore, QtGui, QtWidgets\n\nclass AngledHeader(QtWidgets.QHeaderView):\n    borderPen = QtGui.QColor(0, 190, 255)\n    labelBrush = QtGui.QColor(255, 212, 0)\n    def __init__(self, parent=None):\n        QtWidgets.QHeaderView.__init__(self, QtCore.Qt.Horizontal, parent)\n        self.setSectionResizeMode(self.Fixed)\n        self.setDefaultSectionSize(sqrt((self.fontMetrics().height() + 4)** 2 *2))\n        self.setSectionsClickable(True)\n        self.setDefaultSectionSize(int(sqrt((self.fontMetrics().height() + 4)** 2 *2)))\n        self.setMaximumHeight(100)\n        # compute the ellipsis size according to the angle; remember that:\n        # 1. if the angle is not 45 degrees, you\'ll need to compute this value \n        #   using trigonometric functions according to the angle;\n        # 2. we assume ellipsis is done with three period characters, so we can \n        #   "half" its size as (usually) they\'re painted on the bottom line and \n        #   they are large enough, allowing us to show as much as text is possible\n        self.fontEllipsisSize = int(hypot(*[self.fontMetrics().height()] * 2) * .5)\n        self.setSectionsClickable(True)\n\n    def sizeHint(self):\n        # compute the minimum height using the maximum header label "hypotenuse"\'s\n        hint = QtWidgets.QHeaderView.sizeHint(self)\n        count = self.count()\n        if not count:\n            return hint\n        fm = self.fontMetrics()\n        width = minSize = self.defaultSectionSize()\n        # set the minimum width to ("hypotenuse" * sectionCount) + minimumHeight\n        # at least, ensuring minimal horizontal scroll bar interaction\n        hint.setWidth(width * count + self.minimumHeight())\n        maxDiag = maxWidth = maxHeight = 1\n        for s in range(count):\n            if self.isSectionHidden(s):\n                continue\n            # compute the diagonal of the text\'s bounding rect, \n            # shift its angle by 45\xc2\xb0 to get the minimum required \n            # height\n            rect = fm.boundingRect(\n                str(self.model().headerData(s, QtCore.Qt.Horizontal)) + \'    \')\n            # avoid math domain errors for empty header labels\n            diag = max(1, hypot(rect.width(), rect.height()))\n            if diag > maxDiag:\n                maxDiag = diag\n                maxWidth = max(1, rect.width())\n                maxHeight = max(1, rect.height())\n        # get the angle of the largest boundingRect using the "Law of cosines":\n        # https://en.wikipedia.org/wiki/Law_of_cosines\n        angle = degrees(acos(\n                (maxDiag ** 2 + maxWidth ** 2 - maxHeight ** 2) / \n                (2. * maxDiag * maxWidth)\n            ))\n        # compute the minimum required height using the angle found above\n        minSize = max(minSize, sin(radians(angle + 45)) * maxDiag)\n        hint.setHeight(min(self.maximumHeight(), minSize))\n        return hint\n\n    def mousePressEvent(self, event):\n        width = self.defaultSectionSize()\n        start = self.sectionViewportPosition(0)\n        rect = QtCore.QRect(0, 0, width, -self.height())\n        transform = QtGui.QTransform().translate(0, self.height()).shear(-1, 0)\n        for s in range(self.count()):\n            if self.isSectionHidden(s):\n                continue\n            if transform.mapToPolygon(\n                rect.translated(s * width + start, 0)).containsPoint(\n                    event.pos(), QtCore.Qt.WindingFill):\n                        self.sectionPressed.emit(s)\n                        return\n\n    def paintEvent(self, event):\n        qp = QtGui.QPainter(self.viewport())\n        qp.setRenderHints(qp.Antialiasing)\n        width = self.defaultSectionSize()\n        delta = self.height()\n        # add offset if the view is horizontally scrolled\n        qp.translate(self.sectionViewportPosition(0) - .5, -.5)\n        fmDelta = (self.fontMetrics().height() - self.fontMetrics().descent()) * .5\n        # create a reference rectangle (note that the negative height)\n        rect = QtCore.QRectF(0, 0, width, -delta)\n        diagonal = hypot(delta, delta)\n        for s in range(self.count()):\n            if self.isSectionHidden(s):\n                continue\n            qp.save()\n            qp.save()\n            qp.setPen(self.borderPen)\n            # apply a "shear" transform making the rectangle a parallelogram;\n            # since the transformation is applied top to bottom\n            # we translate vertically to the bottom of the view\n            # and draw the "negative height" rectangle\n            qp.setTransform(qp.transform().translate(s * width, delta).shear(-1, 0))\n            qp.drawRect(rect)\n            qp.setPen(QtCore.Qt.NoPen)\n            qp.setBrush(self.labelBrush)\n            qp.drawRect(rect.adjusted(2, -2, -2, 2))\n            qp.restore()\n\n            qp.translate(s * width + width, delta)\n            qp.rotate(-45)\n            label = str(self.model().headerData(s, QtCore.Qt.Horizontal))\n            elidedLabel = self.fontMetrics().elidedText(\n                label, QtCore.Qt.ElideRight, diagonal - self.fontEllipsisSize)\n            qp.drawText(0, -fmDelta, elidedLabel)\n            qp.restore()\n\n\nclass AngledTable(QtWidgets.QTableView):\n    def __init__(self, *args, **kwargs):\n        QtWidgets.QTableView.__init__(self, *args, **kwargs)\n        self.setHorizontalHeader(AngledHeader(self))\n        self.verticalScrollBarSpacer = QtWidgets.QWidget()\n        self.addScrollBarWidget(self.verticalScrollBarSpacer, QtCore.Qt.AlignTop)\n        self.fixLock = False\n\n    def setModel(self, model):\n        if self.model():\n            self.model().headerDataChanged.disconnect(self.fixViewport)\n        QtWidgets.QTableView.setModel(self, model)\n        model.headerDataChanged.connect(self.fixViewport)\n\n    def fixViewport(self):\n        if self.fixLock:\n            return\n        self.fixLock = True\n        # delay the viewport/scrollbar states since the view has to process its \n        # new header data first\n        QtCore.QTimer.singleShot(0, self.delayedFixViewport)\n\n    def delayedFixViewport(self):\n        # add a right margin through the horizontal scrollbar range\n        QtWidgets.QApplication.processEvents()\n        header = self.horizontalHeader()\n        if not header.isVisible():\n            self.verticalScrollBarSpacer.setFixedHeight(0)\n            self.updateGeometries()\n            return\n        self.verticalScrollBarSpacer.setFixedHeight(header.sizeHint().height())\n        bar = self.horizontalScrollBar()\n        bar.blockSignals(True)\n        step = bar.singleStep() * (header.height() / header.defaultSectionSize())\n        bar.setMaximum(bar.maximum() + step)\n        bar.blockSignals(False)\n        self.fixLock = False\n\n    def resizeEvent(self, event):\n        # ensure that the viewport and scrollbars are updated whenever \n        # the table size change\n        QtWidgets.QTableView.resizeEvent(self, event)\n        self.fixViewport()\n\n\nclass TestWidget(QtWidgets.QWidget):\n    def __init__(self):\n        QtWidgets.QWidget.__init__(self)\n        l = QtWidgets.QGridLayout()\n        self.setLayout(l)\n        self.table = AngledTable()\n        l.addWidget(self.table)\n        model = QtGui.QStandardItemModel(4, 5)\n        self.table.setModel(model)\n        self.table.setHorizontalScrollMode(self.table.ScrollPerPixel)\n        model.setVerticalHeaderLabels([\'Location {}\'.format(l + 1) for l in range(8)])\n        columns = [\'Column {}\'.format(c + 1) for c in range(8)]\n        columns[3] += \' very, very, very, very, very, very, long\'\n        model.setHorizontalHeaderLabels(columns)\n\n\nif __name__ == \'__main__\':\n    app = QtWidgets.QApplication(sys.argv)\n    w = TestWidget()\n    w.show()\n    sys.exit(app.exec_())\n
Run Code Online (Sandbox Code Playgroud)\n\n

请注意,我使用 QTransforms 而不是 QPolygons 编辑了绘画和单击检测代码:虽然理解其机制有点复杂,但它比创建多边形并在每次必须添加列标题时计算其点要快。画。
\n此外,我还添加了对最大标题高度的支持(以防任何标题标签变得太长),以及一个“间隔”小部件,可将垂直滚动条移动到表格内容的实际“开始”位置。

\n