1
votes

Using PySide2 or PyQt5, I want to make a table widget with header labels that are on a 45 degree angle, like in the image here.

Angled Horizontal Headers Example

I don't see anything like this in QtCreator (Designer) for the QTable widget. I can rotate a label using something like this:

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

But, there are several niggles. Ideally this would be a QLineEdit widget, I would need the widgets to 'play nice' so as not to overlap anything else, and I would like them to fill in above the table from the header. I'm looking for suggestions.

2
This isn't offered by Qt. You'll have to do it yourself, presumably by reimplementing QHeaderView::paintSection to paint parallelograms. What happens at the right edge of the table? Do your envisaged rightmost header items extend further right than the rest of the visible widget? Do you need to support resizing? (Presumably, but ouch...) - user9088793
Resizing is probably not necessary. Your comment about what happens on the right side is one of the concerns I have. The last few labels will no doubt need to extend beyond the main canvas of the table. The table is expected to be wide enough that it will require a scrollbar so "overhang" on the right probably isn't that big of an issue. But it's not clear to me how it will work in a Layout. I'm not up on C++ syntax and so wading into the Qt source usually gives me a headache... Thank you for pointing out QHeaderView. - chmedly
You could look at the example doc.qt.io/qt-5/qtwidgets-widgets-shapedclock-example.html# for inspiration on how to handle the unusual widget shape. Your combined header and table are still just a simple polygon, and you'll be using a widget mask specified by QRegion to ensure that clicks can propagate to other widgets below the table widget on the lower right side, and, if desired, the top left. - user9088793

2 Answers

4
votes

This is a very interesting topic, as Qt doesn't provide such a feature, but it can be implemented.
The following example is far from perfect, I'll list its main pros/cons.

Pros

  • it works ;-)
  • changing horizontal header labels automatically updates the header height
  • supports horizontal scrolling "over" the last item position (if the table view is smaller than its contents, the horizontal scrollbar allows to see the full header text)
  • it works :-D

Cons

  • sections are fixed
  • sections are not movable
  • QAbstractItemView.ScrollPerPixel is mandatory for the horizontal scroll mode in this implementation. Qt's ScrollPerItem mode is a bit complex, and has some issues if it's not overrided with huge care. This doesn't mean that it's not possible to use that mode, but it requires a lot of efforts, possibly by carefully reading and understanding the source code of both QTableView and QAbstractItemView. Long story short: ScrollPerItem works until you reach the maximum value of the horizontal scrollbar; at that point, the view will try to resize and adapt its viewport and scrollbar value/range, and the last header labels will be "cut out".
  • if all horizontal columns are visible (meaning that the items wouldn't require horizontal scrolling), the last horizontal headers are not completely shown, since the horizontal scroll bar is not required.

I think that it should be possible to support all header features (custom/stretchable section size, movable sections, item scroll, etc.), but it would require a very deep reimplementation process of both QTableView and QHeaderView methods.

Anyhow, that's the result I've got so far, which supports scrolling, painting, and basic mouse interaction (section highlight on click).

Example screenshot:
screenshot of the example code

Scrolled (near the right edge) screenshot:
screenshot of the example code with partial scrolling

Table sized slightly after the right edge of the last horizontal column:
screenshot of the scrolling issue

Example code

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

Please note that I edited the painting and click detection code using QTransforms instead QPolygons: while it's a bit more complex to understand its mechanics, it's faster than creating a polygon and computing its points each time a column header has to be drawn.
Also, I've added support for maximum header height (in case any header label get too long), and a "spacer" widget that shifts the vertical scrollbar to the actual "beginning" of the table contents.

1
votes

musicamante posted such an excellent answer that I've used it as the basis to add a few more (stolen) bits. In this code, when a user double clicks an angled header they are greeted with a popup where they can rename the header. Because of the wonderful code that music provided, it redraws everything automatically.

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