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

Posted

技术标签:

【中文标题】如何制作有角度的表格标题?【英文标题】:How can I make angled table headers? 【发布时间】:2019-08-03 17:57:54 【问题描述】:

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

我在 QTable 小部件的 QtCreator (Designer) 中没有看到类似的东西。我可以使用以下方式旋转标签:

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

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

【问题讨论】:

这不是 Qt 提供的。你必须自己做,大概是通过重新实现 QHeaderView::paintSection 来绘制平行四边形。桌子的右边缘会发生什么?您设想的最右边的标题项是否比可见小部件的其余部分向右延伸?你需要支持调整大小吗? (大概,但是哎哟......) 可能不需要调整大小。您对右侧发生的事情的评论是我的担忧之一。最后几个标签无疑需要超出表格的主画布。该表预计足够宽,以至于需要滚动条,因此右侧的“悬垂”可能不是什么大问题。但我不清楚它将如何在布局中工作。我不了解 C++ 语法,因此涉足 Qt 源代码通常会让我头疼……感谢您指出 QHeaderView。 您可以查看示例doc.qt.io/qt-5/qtwidgets-widgets-shapedclock-example.html# 以获取有关如何处理不寻常小部件形状的灵感。您的组合标题和表格仍然只是一个简单的多边形,您将使用 QRegion 指定的小部件掩码来确保点击可以传播到右下方表格小部件下方的其他小部件,如果需要,还可以传播到顶部离开。 【参考方案1】:

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

优点

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

缺点

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

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

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

示例截图:

滚动(靠近右边缘)屏幕截图:

表格的大小在最后一个水平列的右边缘之后稍稍调整:

示例代码

import sys
from math import sqrt, sin, acos, hypot, degrees, radians
from PyQt5 import QtCore, QtGui, QtWidgets

class AngledHeader(QtWidgets.QHeaderView):
    borderPen = QtGui.QColor(0, 190, 255)
    labelBrush = QtGui.QColor(255, 212, 0)
    def __init__(self, parent=None):
        QtWidgets.QHeaderView.__init__(self, QtCore.Qt.Horizontal, parent)
        self.setSectionResizeMode(self.Fixed)
        self.setDefaultSectionSize(sqrt((self.fontMetrics().height() + 4)** 2 *2))
        self.setSectionsClickable(True)
        self.setDefaultSectionSize(int(sqrt((self.fontMetrics().height() + 4)** 2 *2)))
        self.setMaximumHeight(100)
        # compute the ellipsis size according to the angle; remember that:
        # 1. if the angle is not 45 degrees, you'll need to compute this value 
        #   using trigonometric functions according to the angle;
        # 2. we assume ellipsis is done with three period characters, so we can 
        #   "half" its size as (usually) they're painted on the bottom line and 
        #   they are large enough, allowing us to show as much as text is possible
        self.fontEllipsisSize = int(hypot(*[self.fontMetrics().height()] * 2) * .5)
        self.setSectionsClickable(True)

    def sizeHint(self):
        # compute the minimum height using the maximum header label "hypotenuse"'s
        hint = QtWidgets.QHeaderView.sizeHint(self)
        count = self.count()
        if not count:
            return hint
        fm = self.fontMetrics()
        width = minSize = self.defaultSectionSize()
        # set the minimum width to ("hypotenuse" * sectionCount) + minimumHeight
        # at least, ensuring minimal horizontal scroll bar interaction
        hint.setWidth(width * count + self.minimumHeight())
        maxDiag = maxWidth = maxHeight = 1
        for s in range(count):
            if self.isSectionHidden(s):
                continue
            # compute the diagonal of the text's bounding rect, 
            # shift its angle by 45° to get the minimum required 
            # height
            rect = fm.boundingRect(
                str(self.model().headerData(s, QtCore.Qt.Horizontal)) + '    ')
            # avoid math domain errors for empty header labels
            diag = max(1, hypot(rect.width(), rect.height()))
            if diag > maxDiag:
                maxDiag = diag
                maxWidth = max(1, rect.width())
                maxHeight = max(1, rect.height())
        # get the angle of the largest boundingRect using the "Law of cosines":
        # https://en.wikipedia.org/wiki/Law_of_cosines
        angle = degrees(acos(
                (maxDiag ** 2 + maxWidth ** 2 - maxHeight ** 2) / 
                (2. * maxDiag * maxWidth)
            ))
        # compute the minimum required height using the angle found above
        minSize = max(minSize, sin(radians(angle + 45)) * maxDiag)
        hint.setHeight(min(self.maximumHeight(), minSize))
        return hint

    def mousePressEvent(self, event):
        width = self.defaultSectionSize()
        start = self.sectionViewportPosition(0)
        rect = QtCore.QRect(0, 0, width, -self.height())
        transform = QtGui.QTransform().translate(0, self.height()).shear(-1, 0)
        for s in range(self.count()):
            if self.isSectionHidden(s):
                continue
            if transform.mapToPolygon(
                rect.translated(s * width + start, 0)).containsPoint(
                    event.pos(), QtCore.Qt.WindingFill):
                        self.sectionPressed.emit(s)
                        return

    def paintEvent(self, event):
        qp = QtGui.QPainter(self.viewport())
        qp.setRenderHints(qp.Antialiasing)
        width = self.defaultSectionSize()
        delta = self.height()
        # add offset if the view is horizontally scrolled
        qp.translate(self.sectionViewportPosition(0) - .5, -.5)
        fmDelta = (self.fontMetrics().height() - self.fontMetrics().descent()) * .5
        # create a reference rectangle (note that the negative height)
        rect = QtCore.QRectF(0, 0, width, -delta)
        diagonal = hypot(delta, delta)
        for s in range(self.count()):
            if self.isSectionHidden(s):
                continue
            qp.save()
            qp.save()
            qp.setPen(self.borderPen)
            # apply a "shear" transform making the rectangle a parallelogram;
            # since the transformation is applied top to bottom
            # we translate vertically to the bottom of the view
            # and draw the "negative height" rectangle
            qp.setTransform(qp.transform().translate(s * width, delta).shear(-1, 0))
            qp.drawRect(rect)
            qp.setPen(QtCore.Qt.NoPen)
            qp.setBrush(self.labelBrush)
            qp.drawRect(rect.adjusted(2, -2, -2, 2))
            qp.restore()

            qp.translate(s * width + width, delta)
            qp.rotate(-45)
            label = str(self.model().headerData(s, QtCore.Qt.Horizontal))
            elidedLabel = self.fontMetrics().elidedText(
                label, QtCore.Qt.ElideRight, diagonal - self.fontEllipsisSize)
            qp.drawText(0, -fmDelta, elidedLabel)
            qp.restore()


class AngledTable(QtWidgets.QTableView):
    def __init__(self, *args, **kwargs):
        QtWidgets.QTableView.__init__(self, *args, **kwargs)
        self.setHorizontalHeader(AngledHeader(self))
        self.verticalScrollBarSpacer = QtWidgets.QWidget()
        self.addScrollBarWidget(self.verticalScrollBarSpacer, QtCore.Qt.AlignTop)
        self.fixLock = False

    def setModel(self, model):
        if self.model():
            self.model().headerDataChanged.disconnect(self.fixViewport)
        QtWidgets.QTableView.setModel(self, model)
        model.headerDataChanged.connect(self.fixViewport)

    def fixViewport(self):
        if self.fixLock:
            return
        self.fixLock = True
        # delay the viewport/scrollbar states since the view has to process its 
        # new header data first
        QtCore.QTimer.singleShot(0, self.delayedFixViewport)

    def delayedFixViewport(self):
        # add a right margin through the horizontal scrollbar range
        QtWidgets.QApplication.processEvents()
        header = self.horizontalHeader()
        if not header.isVisible():
            self.verticalScrollBarSpacer.setFixedHeight(0)
            self.updateGeometries()
            return
        self.verticalScrollBarSpacer.setFixedHeight(header.sizeHint().height())
        bar = self.horizontalScrollBar()
        bar.blockSignals(True)
        step = bar.singleStep() * (header.height() / header.defaultSectionSize())
        bar.setMaximum(bar.maximum() + step)
        bar.blockSignals(False)
        self.fixLock = False

    def resizeEvent(self, event):
        # ensure that the viewport and scrollbars are updated whenever 
        # the table size change
        QtWidgets.QTableView.resizeEvent(self, event)
        self.fixViewport()


class TestWidget(QtWidgets.QWidget):
    def __init__(self):
        QtWidgets.QWidget.__init__(self)
        l = QtWidgets.QGridLayout()
        self.setLayout(l)
        self.table = AngledTable()
        l.addWidget(self.table)
        model = QtGui.QStandardItemModel(4, 5)
        self.table.setModel(model)
        self.table.setHorizontalScrollMode(self.table.ScrollPerPixel)
        model.setVerticalHeaderLabels(['Location '.format(l + 1) for l in range(8)])
        columns = ['Column '.format(c + 1) for c in range(8)]
        columns[3] += ' very, very, very, very, very, very, long'
        model.setHorizontalHeaderLabels(columns)


if __name__ == '__main__':
    app = QtWidgets.QApplication(sys.argv)
    w = TestWidget()
    w.show()
    sys.exit(app.exec_())

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

【讨论】:

【参考方案2】:

musicamante 发布了如此出色的答案,我以此为基础添加了更多(被盗)位。在这段代码中,当用户双击一个有角度的标题时,他们会看到一个弹出窗口,他们可以在其中重命名标题。由于音乐提供的精彩代码,它会自动重绘所有内容。

import sys
from math import sqrt, sin, acos, hypot, degrees, radians

from PySide2 import QtCore, QtGui, QtWidgets

class AngledHeader(QtWidgets.QHeaderView):
    borderPen = QtGui.QColor(0, 190, 255)
    labelBrush = QtGui.QColor(255, 212, 0)
    def __init__(self, parent=None):
        QtWidgets.QHeaderView.__init__(self, QtCore.Qt.Horizontal, parent)
        self.setSectionResizeMode(self.Fixed)
        self.setDefaultSectionSize(sqrt((self.fontMetrics().height() + 4)** 2 *2))
        self.setSectionsClickable(True)

    def sizeHint(self):
        # compute the minimum height using the maximum header
        # label "hypotenuse"'s
        fm = self.fontMetrics()
        width = minSize = self.defaultSectionSize()
        count = self.count()
        for s in range(count):
            if self.isSectionHidden(s):
                continue
            # compute the diagonal of the text's bounding rect,
            # shift its angle by 45° to get the minimum required
            # height
            rect = fm.boundingRect(str(self.model().headerData(s, QtCore.Qt.Horizontal)) + '    ')
            diag = hypot(rect.width(), rect.height())
            # get the angle of the boundingRect using the
            # "Law of cosines":
            # https://en.wikipedia.org/wiki/Law_of_cosines
            angle = degrees(acos((diag ** 2 + rect.width() ** 2 - rect.height() ** 2) / (2. * diag * rect.width())))
            # compute the minimum required height using the
            # angle found above
            minSize = max(minSize, sin(radians(angle + 45)) * diag)
        hint = QtCore.QSize(width * count + 2000, minSize)
        return hint

    def mousePressEvent(self, event):
        width = self.defaultSectionSize()
        first = self.sectionViewportPosition(0)
        rect = QtCore.QRect(0, 0, width, -self.height())
        transform = QtGui.QTransform().translate(0, self.height()).shear(-1, 0)

        for s in range(self.count()):
            if self.isSectionHidden(s):
                continue
            if transform.mapToPolygon(rect.translated(s * width + first,
                    0)).containsPoint(event.pos(), QtCore.Qt.WindingFill):
                self.sectionPressed.emit(s)
                self.last = ("Click", s) #log initial click and define the column index
                return

    def mouseReleaseEvent(self, event):
        if self.last[0] == "Double Click":#if this was a double click then we have work to do
            index = self.last[1]
            oldHeader = str(self.model().headerData(index, QtCore.Qt.Horizontal))
            newHeader, ok = QtWidgets.QInputDialog.getText(self,
                                                          'Change header label for column %d' % index,
                                                          'Header:',
                                                           QtWidgets.QLineEdit.Normal,
                                                           oldHeader)
            if ok:
                self.model().horizontalHeaderItem(index).setText(newHeader)
            self.update()

    def mouseDoubleClickEvent(self, event):
        self.last = ("Double Click", self.last[1])
        #log that it's a double click and pass on the index

    def paintEvent(self, event):
        qp = QtGui.QPainter(self.viewport())
        qp.setRenderHints(qp.Antialiasing)
        width = self.defaultSectionSize()
        delta = self.height()
        # add offset if the view is horizontally scrolled
        qp.translate(self.sectionViewportPosition(0) - .5, -.5)
        fmDelta = (self.fontMetrics().height() - self.fontMetrics().descent()) * .5
        # create a reference rectangle (note that the negative height)
        rect = QtCore.QRectF(0, 0, width, -delta)
        for s in range(self.count()):
            if self.isSectionHidden(s):
                continue
            qp.save()
            qp.save()
            qp.setPen(self.borderPen)
            # apply a "shear" transform making the rectangle a parallelogram;
            # since the transformation is applied top to bottom
            # we translate vertically to the bottom of the view
            # and draw the "negative height" rectangle
            qp.setTransform(qp.transform().translate(s * width, delta).shear(-1, 0))
            qp.drawRect(rect)
            qp.setPen(QtCore.Qt.NoPen)
            qp.setBrush(self.labelBrush)
            qp.drawRect(rect.adjusted(2, -2, -2, 2))
            qp.restore()

            qp.translate(s * width + width, delta)
            qp.rotate(-45)
            qp.drawText(0, -fmDelta, str(self.model().headerData(s, QtCore.Qt.Horizontal)))
            qp.restore()


class AngledTable(QtWidgets.QTableView):
    def __init__(self, *args, **kwargs):
        QtWidgets.QTableView.__init__(self, *args, **kwargs)
        self.setHorizontalHeader(AngledHeader(self))
        self.fixLock = False

    def setModel(self, model):
        if self.model():
            self.model().headerDataChanged.disconnect(self.fixViewport)
        QtWidgets.QTableView.setModel(self, model)
        model.headerDataChanged.connect(self.fixViewport)

    def fixViewport(self):
        if self.fixLock:
            return
        self.fixLock = True
        # delay the viewport/scrollbar states since the view has to process its
        # new header data first
        QtCore.QTimer.singleShot(0, self.delayedFixViewport)

    def delayedFixViewport(self):
        # add a right margin through the horizontal scrollbar range
        QtWidgets.QApplication.processEvents()
        header = self.horizontalHeader()
        bar = self.horizontalScrollBar()
        bar.blockSignals(True)
        step = bar.singleStep() * (header.height() / header.defaultSectionSize())
        bar.setMaximum(bar.maximum() + step)
        bar.blockSignals(False)
        self.fixLock = False

    def resizeEvent(self, event):
        # ensure that the viewport and scrollbars are updated whenever
        # the table size change
        QtWidgets.QTableView.resizeEvent(self, event)
        self.fixViewport()


class TestWidget(QtWidgets.QWidget):
    def __init__(self):
        QtWidgets.QWidget.__init__(self)
        l = QtWidgets.QGridLayout()
        self.setLayout(l)
        self.table = AngledTable()
        l.addWidget(self.table)
        model = QtGui.QStandardItemModel(4, 5)
        self.table.setModel(model)
        self.table.setHorizontalScrollMode(self.table.ScrollPerPixel)
        self.table.headerlist = ['Column'.format(c + 1) for c in range(8)]
        model.setVerticalHeaderLabels(['Location 1', 'Location 2', 'Location 3', 'Location 4'])
        model.setHorizontalHeaderLabels(self.table.headerlist)


if __name__ == '__main__':
    app = QtWidgets.QApplication(sys.argv)
    w = TestWidget()
    w.show()
    sys.exit(app.exec_())

【讨论】:

你可以只创建一个单独的函数来获得[双击]部分(使用event.pos()作为参数并返回索引)并将其用于mousePressEvent和mouseDoubleClickEvent;此外,这样做的好处是您可以从那里手动启动sectionDoubleClicked() 信号,并从其他地方(可能是父/应用程序)连接它,这也将确保更好的模块化您的程序:您将能够“捕捉”修改标题的尝试并因此选择如何操作。 我刚刚意识到我的实现存在一个重要问题。如果标题的字符串很长,标题可能会太高,与垂直滚动条不一致。您可以为标题字符串设置最大长度,但图形省略号效果更好。此外,您可以创建一个间隔小部件并将其添加到 init 上的垂直滚动条(使用 addScrollBarWidget(spacer, Qt.AlignTop)),然后在 delayedFixViewport 函数中设置其固定大小,假设标题可见。我已经相应地更新了我的答案,其中实现了省略号+间距。 PS:我尝试添加对突出显示部分绘制的支持(例如:活动部分标题的粗体字体)。不幸的是,Qt 的内部绘制算法似乎不容易允许在当前标题部分(这是一个纯 QRect)之外进行绘制,这意味着您将在其他标题部分上获得不一致的绘制,直到每个标题部分都获得需要它的事件(通常、鼠标悬停事件或完整的父绘制请求)。我认为可以通过向 QApplication 发送延迟的 QPaintEvent 来覆盖它,但是,除非你真的需要,否则这将是一个实际的超调。

以上是关于如何制作有角度的表格标题?的主要内容,如果未能解决你的问题,请参考以下文章

以角度形式将输入创建为数组

如何制作单独的角度材料步进标题和内容?

如何制作多表头

制作EXCEL表格步骤

如何在 UIView 上制作斜角?

如何在 ReportLab 中制作一个简单的表格