0

I'm trying to create a grid of square buttons that is scrollable if the window is too small to show all of them. I'd like there to be labels on the left-most column and top-most row showing the button indices.

Is there a way to create a QScrollArea with the widgets (labels) in the top-most row and left-most column "frozen". Similar to how you can freeze rows and columns in an Excel Sheet where they follow the view around as you scroll.

See a mockup here:

enter image description here

Either Qt and PyQt are welcome.

Nevermore
  • 318
  • 2
  • 11
  • You say: *Please don't edit my tags again*. SO is a collaborative site where the edits can be done by anyone (if they have the privileges). We can make a mistake (from your point of view or someone else's) so for those cases you can re-edit the post by *correcting the error*. Please do not indicate what someone else can do since in SO there are rules that already limit our actions, and if the code of conduct is breached then please report it to the [meta] or to a moderator using custom flag, as you see fit. – eyllanesc Nov 18 '21 at 01:44
  • I did re-edit and correct the error. – Nevermore Nov 18 '21 at 01:51
  • 1
    @Nevermore if you want to specify that you can deal with both PyQt *and* C++ Qt, you should do it in the post body: we cannot just guess it from the tags, as we cannot know if you just added them due to inexperience with SO or not. The title should summarize the problem (not the language), the body should clarify that you're fine with different languages for the solution, which is then reflected by the tags. Besides that, see [`setViewportMargins()`](//doc.qt.io/qt-5/qabstractscrollarea.html#setViewportMargins): you can add custom widgets and update their geometries when scrollbars are used. – musicamante Nov 18 '21 at 02:34
  • 2
    See [QTableWidget](https://doc.qt.io/qt-5.15/qtablewidget.html) – alec Nov 18 '21 at 02:43
  • @musicamante Understood and done. From the description, viewportMargins may be just what I need, I'll look into it, thanks. – Nevermore Nov 18 '21 at 03:43

2 Answers2

1

I solved my problem with multiple QScrollAreas using the method outlined in this answer. The idea is to have the frozen areas be QScrollArea with disabled scrolling, while the unfrozen QScrollArea scrollbar signals are connected to the frozen QScrollArea scrollbar slots.

Here is the code of my mockup with the top-most row and left-most column frozen. The especially relevant parts are the FrozenScrollArea class and the connections inside the Window class.

import sys
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
    QApplication,
    QPushButton,
    QWidget,
    QScrollArea,
    QGridLayout,
    QLabel,
    QFrame,
    QSpacerItem,
    QSizePolicy,
    )


ROWS = 10
COLS = 20
SIZE = 35


style = """
Button {
    padding: 0;
    margin: 0;
    border: 1px solid black;
}
Button::checked {
    background-color: lightgreen;
}
"""


class Button(QPushButton):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.setFixedSize(SIZE, SIZE)
        self.setCheckable(True)
        self.setStyleSheet(style)


class Label(QLabel):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.setAlignment(Qt.AlignCenter)
        self.setFixedSize(SIZE, SIZE)


class Labels(QWidget):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        layout = QGridLayout()
        layout.setHorizontalSpacing(0)
        layout.setVerticalSpacing(0)
        layout.setContentsMargins(0, 0, 0, 0)
        self.setLayout(layout)


class FrozenScrollArea(QScrollArea):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.setWidgetResizable(True)
        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.verticalScrollBar().setEnabled(False)
        self.horizontalScrollBar().setEnabled(False)


class FrozenRow(FrozenScrollArea):
    def __init__(self, parent):
        super().__init__()

        labels = Labels(parent)
        for c in range(COLS):
            label = Label(self, text = str(c))
            labels.layout().addWidget(label, 0, c, 1, 1, Qt.AlignCenter)

        labels.layout().addItem(QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Minimum), 0, COLS, 1, 1)

        self.setFrameShape(QFrame.NoFrame)
        self.setFixedHeight(SIZE)
        self.setWidget(labels)


class FrozenColumn(FrozenScrollArea):
    def __init__(self, parent):
        super().__init__()

        labels = Labels(parent)
        for r in range(ROWS):
            label = Label(self, text = str(r))
            labels.layout().addWidget(label, r, 0, 1, 1, Qt.AlignCenter)

        labels.layout().addItem(QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding), ROWS, 0, 1, 1)

        self.setFrameShape(QFrame.NoFrame)
        self.setFixedWidth(SIZE)
        self.setWidget(labels)


class ButtonGroup(QWidget):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        layout = QGridLayout()
        for r in range(ROWS):
            for c in range(COLS):
                button = Button(self)
                layout.addWidget(button, r, c, 1, 1)

        layout.setHorizontalSpacing(0)
        layout.setVerticalSpacing(0)
        layout.setContentsMargins(0, 0, 0, 0)

        self.setLayout(layout)


class Buttons(QScrollArea):
    def __init__(self, parent):
        super().__init__()
        self.setFrameShape(QFrame.NoFrame)
        self.setWidget(ButtonGroup(parent))


class Window(QWidget):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        # layout
        layout = QGridLayout()
        self.setLayout(layout)
        layout.setHorizontalSpacing(0)
        layout.setVerticalSpacing(0)
        layout.setContentsMargins(0, 0, 0, 0)

        # frozen row (top)
        self.frozenRow = FrozenRow(self)
        layout.addWidget(self.frozenRow, 0, 1, 1, 1)

        # frozen column (left)
        self.frozenColumn = FrozenColumn(self)
        layout.addWidget(self.frozenColumn, 1, 0, 1, 1)

        # button grid
        self.buttons = Buttons(self)
        layout.addWidget(self.buttons, 1, 1, 1, 1)

        # scrollbar connections
        self.buttons.horizontalScrollBar().valueChanged.connect(self.frozenRow.horizontalScrollBar().setValue)  # horizontal scroll affects frozen row only
        self.buttons.verticalScrollBar().valueChanged.connect(self.frozenColumn.verticalScrollBar().setValue)  # vertical scroll affects frozemn column only

        self.show()


if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = Window()
    sys.exit(app.exec())

Nevermore
  • 318
  • 2
  • 11
0

While the frozen scroll area method is effective, it has some drawbacks; most importantly, it:

  • is not dynamic;
  • does not consider basic box layouts;
  • does not support different directions (for boxed layouts) or origin points (for grid layouts);

While this is more a "fringe case", I'd like to suggest an alternative, based on QHeaderView and a "private" model that uses the layout manager for the header sizes.

It doesn't directly support resizing as one would expect from a standard QHeaderView, but that's almost impossible: for boxed layouts it's not possible to set a layout item size (if not by completely overriding the way the layout sets geometries), and for grid layouts there's no way to know if rows or columns are "actually" removed, since rowCount() and columnCount() are never updated dynamically when the grid size changes.

The concept is based on overriding the event filter of the scroll area and check whether geometry changes are happening and if the layout has to lay out items again. Then, the implementation uses the layout information to update the underlying model and provide appropriate values for the SizeHintRole for headerData().

The subclassed QScrollArea creates two QHeaderViews and updates them whenever required using the ResizeToContents section resize mode (which queries headerData()) and uses setViewportMargins based on the size hints of the headers.

class LayoutModel(QtCore.QAbstractTableModel):
    reverse = {
        QtCore.Qt.Horizontal: False, 
        QtCore.Qt.Vertical: False
    }
    def __init__(self, rows=None, columns=None):
        super().__init__()
        self.rows = rows or []
        self.columns = columns or []

    def setLayoutData(self, hSizes, vSizes, reverseH=False, reverseV=False):
        self.beginResetModel()
        self.reverse = {
            QtCore.Qt.Horizontal: reverseH, 
            QtCore.Qt.Vertical: reverseV
        }
        self.rows = vSizes
        self.columns = hSizes
        opt = QtWidgets.QStyleOptionHeader()
        opt.text = str(len(vSizes))
        style = QtWidgets.QApplication.style()
        self.headerSizeHint = style.sizeFromContents(style.CT_HeaderSection, opt, QtCore.QSize())
        self.endResetModel()

    def rowCount(self, parent=None):
        return len(self.rows)

    def columnCount(self, parent=None):
        return len(self.columns)

    def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole):
        if role == QtCore.Qt.DisplayRole:
            if self.reverse[orientation]:
                if orientation == QtCore.Qt.Horizontal:
                    section = len(self.columns) - 1 - section
                else:
                    section = len(self.rows) - 1 - section
            # here you can add support for custom header labels
            return str(section + 1)
        elif role == QtCore.Qt.SizeHintRole:
            if orientation == QtCore.Qt.Horizontal:
                return QtCore.QSize(self.columns[section], self.headerSizeHint.height())
            return QtCore.QSize(self.headerSizeHint.width(), self.rows[section])

    def data(self, *args, **kwargs):
        pass # not really required, but provided for consistency


class ScrollAreaLayoutHeaders(QtWidgets.QScrollArea):
    _initialized = False
    def __init__(self):
        super().__init__()
        self.hHeader = QtWidgets.QHeaderView(QtCore.Qt.Horizontal, self)
        self.vHeader = QtWidgets.QHeaderView(QtCore.Qt.Vertical, self)
        self.layoutModel = LayoutModel()
        for header in self.hHeader, self.vHeader:
            header.setModel(self.layoutModel)
            header.setSectionResizeMode(header.Fixed)
        self.updateTimer = QtCore.QTimer(
            interval=0, timeout=self.updateHeaderSizes, singleShot=True)

    def layout(self):
        try:
            return self.widget().layout()
        except AttributeError:
            pass

    def eventFilter(self, obj, event):
        if obj == self.widget() and obj.layout() is not None:
            if event.type() in (event.Resize, event.Move):
                if self.sender() in (self.verticalScrollBar(), self.horizontalScrollBar()):
                    self.updateGeometries()
                else:
                    self.updateHeaderSizes()
            elif event.type() == event.LayoutRequest:
                self.widget().adjustSize()
                self.updateTimer.start()
        return super().eventFilter(obj, event)

    def updateHeaderSizes(self):
        layout = self.layout()
        if layout is None:
            self.layoutModel.setLayoutData([], [])
            self.updateGeometries()
            return
        self._initialized = True
        hSizes = []
        vSizes = []
        layGeo = self.widget().rect()
        reverseH = reverseV = False
        if isinstance(layout, QtWidgets.QBoxLayout):
            count = layout.count()
            direction = layout.direction()
            geometries = [layout.itemAt(i).geometry() for i in range(count)]
            # LeftToRight and BottomToTop layouts always have a first bit set
            reverse = direction & 1
            if reverse:
                geometries.reverse()
            lastPos = 0
            lastGeo = geometries[0]
            if layout.direction() in (layout.LeftToRight, layout.RightToLeft):
                if reverse:
                    reverseH = True
                vSizes.append(layGeo.bottom())
                lastExt = lastGeo.x() + lastGeo.width()
                for geo in geometries[1:]:
                    newPos = lastExt + (geo.x() - lastExt) / 2
                    hSizes.append(newPos - lastPos)
                    lastPos = newPos
                    lastExt = geo.x() + geo.width()
                hSizes.append(layGeo.right() - lastPos - 1)
            else:
                if reverse:
                    reverseV = True
                hSizes.append(layGeo.right())
                lastExt = lastGeo.y() + lastGeo.height()
                for geo in geometries[1:]:
                    newPos = lastExt + (geo.y() - lastExt) / 2
                    vSizes.append(newPos - lastPos)
                    lastPos = newPos
                    lastExt = geo.y() + geo.height()
                vSizes.append(layGeo.bottom() - lastPos + 1)
        else:
            # assume a grid layout
            origin = layout.originCorner()
            if origin & 1:
                reverseH = True
            if origin & 2:
                reverseV = True
            first = layout.cellRect(0, 0)
            lastX = lastY = 0
            lastRight = first.x() + first.width()
            lastBottom = first.y() + first.height()
            for c in range(1, layout.columnCount()):
                cr = layout.cellRect(0, c)
                newX = lastRight + (cr.x() - lastRight) / 2
                hSizes.append(newX - lastX)
                lastX = newX
                lastRight = cr.x() + cr.width()
            hSizes.append(layGeo.right() - lastX)
            for r in range(1, layout.rowCount()):
                cr = layout.cellRect(r, 0)
                newY = lastBottom + (cr.y() - lastBottom) / 2
                vSizes.append(newY - lastY)
                lastY = newY
                lastBottom = cr.y() + cr.height()
            vSizes.append(layGeo.bottom() - lastY)
        hSizes[0] += 2
        vSizes[0] += 2
        self.layoutModel.setLayoutData(hSizes, vSizes, reverseH, reverseV)
        self.updateGeometries()

    def updateGeometries(self):
        self.hHeader.resizeSections(self.hHeader.ResizeToContents)
        self.vHeader.resizeSections(self.vHeader.ResizeToContents)
        left = self.vHeader.sizeHint().width()
        top = self.hHeader.sizeHint().height()
        self.setViewportMargins(left, top, 0, 0)
        vg = self.viewport().geometry()
        self.hHeader.setGeometry(vg.x(), 0, 
            self.viewport().width(), top)
        self.vHeader.setGeometry(0, vg.y(), 
            left, self.viewport().height())
        self.hHeader.setOffset(self.horizontalScrollBar().value())
        self.vHeader.setOffset(self.verticalScrollBar().value())

    def sizeHint(self):
        if not self._initialized and self.layout():
            self.updateHeaderSizes()
        hint = super().sizeHint()
        if self.widget():
            viewHint = self.viewportSizeHint()
            if self.horizontalScrollBarPolicy() == QtCore.Qt.ScrollBarAsNeeded:
                if viewHint.width() > hint.width():
                    hint.setHeight(hint.height() + self.horizontalScrollBar().sizeHint().height())
            if self.verticalScrollBarPolicy() == QtCore.Qt.ScrollBarAsNeeded:
                if viewHint.height() > hint.height():
                    hint.setWidth(hint.width() + self.verticalScrollBar().sizeHint().width())
        hint += QtCore.QSize(
            self.viewportMargins().left(), self.viewportMargins().top())
        return hint

    def resizeEvent(self, event):
        super().resizeEvent(event)
        QtCore.QTimer.singleShot(0, self.updateGeometries)

Notes:

  • the code above will cause some level of recursion; that is expected, as resizing the viewport will obviously trigger a resizeEvent, but Qt is smart enough to ignore them whenever sizes are unchanged;
  • this will only work for basic QBoxLayouts and QGridLayout; it's untested for QFormLayout and the behavior of other custom QLayout subclasses is completely unexpected;
musicamante
  • 41,230
  • 6
  • 33
  • 58