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

时间:2019-08-03 17:57:54

标签: python pyqt pyqt5

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

Angled Horizontal Headers Example

在QtCreator(设计器)中,对于QTable小部件,我看不到任何类似信息。我可以使用以下方式旋转标签:

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小部件,我需要这些小部件“玩得很开心”,以免与其他任何东西重叠,并且我希望它们从标题的表格上方填充。我正在寻找建议。

2 个答案:

答案 0 :(得分:2)

这是一个非常有趣的主题,因为Qt没有提供这种功能,但是它可以实现。

以下示例远非完美,我将列出其主要优点/缺点。

优点

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

缺点

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

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

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

示例屏幕截图:
screenshot of the example code

滚动(靠近右边缘)屏幕截图:
screenshot of the example code with partial scrolling

表的大小在最后一个水平列的右边缘之后稍微变小:
screenshot of the scrolling issue

示例代码

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编辑了绘画并单击了检测代码:虽然理解其机制有点复杂,但它比每次绘制列标题时创建多边形和计算其点要快。
另外,我还增加了对最大标题高度的支持(以防任何标题标签太长),以及一个“空格”小部件,可将垂直滚动条移动到表格内容的实际“开始”位置。

答案 1 :(得分:0)

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