0

I have a column of auto-generated buttons which, if there are too many of, can squash UI elements in the window. Therefore, I want to automatically convert the single column of buttons - nominally inside of a QVBoxLayout referred to as self.main_layout - into a multi-column affair by:

  • Removing the buttons from self.main_layout
  • Adding them to alternating new columns represented by QVBoxLayouts
  • Changing self.main_layout to a QHBoxLayout
  • Adding the new columns to this layout

My attempt simply results in the buttons staying in a single column but now don't even resize to fill the QSplitter frame they occupy:

app = QApplication(sys.argv)
window = TestCase()
app.exec_()

class TestCase(QMainWindow):
    def __init__(self):
        super().__init__()
        test = QWidget()
        self.layout = QVBoxLayout()
        test.setLayout(self.layout)
        for i in range(10):
            temp_btn = QPushButton(str(i))
            temp_btn.pressed.connect(self.multi_col)
            self.layout.addWidget(temp_btn)
        self.setCentralWidget(test)

    @pyqtSlot()
    def multi_col(self):
        cols = [QVBoxLayout(), QVBoxLayout()]
        while self.layout.count():
            child = self.layout.takeAt(0)
            if child.widget():
                self.layout.removeItem(child)
                cols[0].addItem(child)
                cols[1], cols[0] = cols[0], cols[1]
        self.layout = QHBoxLayout()
        self.layout.addLayout(cols[0])
        self.layout.addLayout(cols[1])

Any glaringly obvious thing I'm doing wrong here?

eyllanesc
  • 235,170
  • 19
  • 170
  • 241
Connor Spangler
  • 805
  • 2
  • 12
  • 29

2 Answers2

3

Replacing a layout of a QWidget is not so simple with assigning another object to the variable that stored the reference of the other layout. In a few lines of code you are doing:

self.layout = Foo()
widget.setLayout(self.layout)
self.layout = Bar()

An object is not the same as a variable, the object itself is the entity that performs the actions but the variable is only a place where the reference of the object is stored. For example, objects could be people and variables our names, so if they change our name it does not imply that they change us as a person.

The solution is to remove the QLayout using sip.delete and then set the new layout:

import sys

from PyQt5.QtCore import pyqtSlot
from PyQt5.QtWidgets import (
    QApplication,
    QHBoxLayout,
    QMainWindow,
    QPushButton,
    QVBoxLayout,
    QWidget,
)
import sip


class TestCase(QMainWindow):
    def __init__(self):
        super().__init__()
        test = QWidget()
        self.setCentralWidget(test)

        layout = QVBoxLayout(test)
        for i in range(10):
            temp_btn = QPushButton(str(i))
            temp_btn.pressed.connect(self.multi_col)
            layout.addWidget(temp_btn)

    @pyqtSlot()
    def multi_col(self):
        cols = [QVBoxLayout(), QVBoxLayout()]
        old_layout = self.centralWidget().layout()

        while old_layout.count():
            child = old_layout.takeAt(0)
            widget = child.widget()
            if widget is not None:
                old_layout.removeItem(child)
                cols[0].addWidget(widget)
                cols[1], cols[0] = cols[0], cols[1]
        sip.delete(old_layout)
        lay = QHBoxLayout(self.centralWidget())
        lay.addLayout(cols[0])
        lay.addLayout(cols[1])


def main():
    app = QApplication(sys.argv)
    window = TestCase()
    window.show()
    app.exec_()


if __name__ == "__main__":
    main()
eyllanesc
  • 235,170
  • 19
  • 170
  • 241
  • The `sip` component was what I was missing; I attempted setting the parent of the "new" layout to the widget before but was met each time with the error telling me a layout was already applied to the widget. – Connor Spangler Jul 21 '21 at 00:28
  • 1
    It's also possible to delete the layout by doing `QObjectCleanupHandler().add(self.centralWidget().layout())` – musicamante Jul 21 '21 at 00:35
  • Definitely a fan of the Qt-based method in lieu of additional dependency with sip, thanks for that @musicamante – Connor Spangler Jul 21 '21 at 00:41
  • @ConnorSpangler Note: sip is not an additional dependency on PyQt5 as it is part of PyQt5. sip is a library that allows you to do several that C++ allows but python does not, such as deleting objects. In the case of PySide, its similar is shiboken – eyllanesc Jul 21 '21 at 00:43
  • @eyllanesc interesting, it notified me that `sip` was an unknown import when I attempted it initially, and I had to pip install it manually to get it going. – Connor Spangler Jul 21 '21 at 00:51
1

I'd like to propose an alternative solution, which is to use a QGridLayout and just change the column of the widgets instead of setting a new layout everytime. The "trick" is that addWidget() always adds the widget at the specified position, even if it was already part of the layout, so you don't need to remove layout items.
Obviously, the drawback of this approach is that if the widgets have different heights, every row depends on the minimum required height of all widgets in that row, but since the OP was about using buttons, that shouldn't be the case.

This has the major benefit that the switch can be done automatically with one function, possibly by setting a maximum column number to provide further implementation.
In the following example the multi_col function actually increases the column count until the maximum number is reached, then it resets to one column again.

class TestCase(QMainWindow):
    def __init__(self):
        super().__init__()
        test = QWidget()
        self.layout = QGridLayout()
        test.setLayout(self.layout)
        for i in range(10):
            temp_btn = QPushButton(str(i))
            temp_btn.clicked.connect(self.multi_col)
            self.layout.addWidget(temp_btn)
        self.setCentralWidget(test)
        self.multiColumns = 3
        self.columns = 1

    def multi_col(self):
        maxCol = 0
        widgets = []
        for i in range(self.layout.count()):
            item = self.layout.itemAt(i)
            if item.widget():
                widgets.append(item.widget())
                row, col, rSpan, cSpan = self.layout.getItemPosition(i)
                maxCol = max(col, maxCol)
        if maxCol < self.multiColumns - 1:
            self.columns += 1
        else:
            self.columns = 1
        row = col = 0
        for widget in widgets:
            self.layout.addWidget(widget, row, col)
            col += 1
            if col > self.columns - 1:
                col = 0
                row += 1

Note: I changed to the clicked signal, as it's usually preferred against pressed due to the standard convention of buttons (which "accept" a click only if the mouse button is released inside it), and in your case it also creates two issues:

  1. visually, the UI creates confusion, with the pressed button becoming unpressed in a different position (since the mouse button is released outside its actual and, at that point, different geometry);
  2. conceptually, because if the user moves the mouse again inside the previously pressed button before releasing the mouse, it will trigger pressed once again;
musicamante
  • 41,230
  • 6
  • 33
  • 58
  • Thank you for the info on pressed vs. click, as well as taking the time to provide a great alternative for my current solution. I may very well use this proposed method as the number of columns may need to change dynamically as well. – Connor Spangler Jul 21 '21 at 00:56