1

I have the following test code:

from os import path

from PySide6.QtCore import QObject, QMetaObject
from PySide6.QtUiTools import QUiLoader
from PySide6.QtWidgets import QApplication


class MyWin(QObject):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.ui = QUiLoader().load(path.join(path.dirname(__file__), "MainWindow.ui"))
        self.ui.pushButton.clicked.connect(self.on_pushButton_clicked)

    def show(self):
        self.ui.show()

    def on_pushButton_clicked(self):
        print("button pushed!")


app = QApplication([])
win = MyWin()
win.show()
app.exec()

with its associated MainWindow.ui:

<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
 <class>MainWindow</class>
 <widget class="QMainWindow" name="MainWindow">
  <property name="geometry">
   <rect>
    <x>0</x>
    <y>0</y>
    <width>800</width>
    <height>600</height>
   </rect>
  </property>
  <property name="windowTitle">
   <string>MainWindow</string>
  </property>
  <widget class="QWidget" name="centralwidget">
   <layout class="QVBoxLayout" name="verticalLayout">
    <item>
     <widget class="QPushButton" name="pushButton">
      <property name="text">
       <string>PushButton</string>
      </property>
     </widget>
    </item>
    <item>
     <widget class="QTableView" name="tableView"/>
    </item>
   </layout>
  </widget>
  <widget class="QMenuBar" name="menubar">
   <property name="geometry">
    <rect>
     <x>0</x>
     <y>0</y>
     <width>800</width>
     <height>19</height>
    </rect>
   </property>
  </widget>
  <widget class="QStatusBar" name="statusbar"/>
 </widget>
 <resources/>
 <connections/>
</ui>

... which works as expected.

Question is: how do I replace the line:

        self.ui.pushButton.clicked.connect(self.on_pushButton_clicked)

with an equivalent using QMetaObject.connectSlotsByName(???) ?

Problem here is PySide6 QUiLoader is incapable to add widgets as children of self (as PyQt6 uic.loadUi(filename, self) can do) and thus I'm forced to put UI in a separate variable (self.ui) while slots are defined in "parent" MyWin.

How can I circumvent limitation?

Reason why I ask is my real program has zillions of signals/slots and connect()'ing them manually is a real PITA (and error-prone)

UPDATE: Following advice I modified MyWin to inherit from QWidget, but enabling self.ui.setParent(self) is enough to prevent display of UI.

from os import path

from PySide6.QtCore import QMetaObject
from PySide6.QtUiTools import QUiLoader
from PySide6.QtWidgets import QApplication, QWidget


class MyWin(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.ui = QUiLoader().load(path.join(path.dirname(__file__), "MainWindow.ui"))
        self.ui.pushButton.clicked.connect(self.on_pushButton_clicked)
        self.ui.setParent(self)
        # QMetaObject.connectSlotsByName(self)

    def myshow(self):
        self.ui.show()

    def on_pushButton_clicked(self):
        print("button pushed!")


app = QApplication([])
win = MyWin()
win.myshow()
app.exec()

I also see some strange errors:

mcon@ikea:~/projects/pyside6-test$ venv/bin/python t.py
qt.pysideplugin: Environment variable PYSIDE_DESIGNER_PLUGINS is not set, bailing out.
qt.pysideplugin: No instance of QPyDesignerCustomWidgetCollection was found.
Qt WebEngine seems to be initialized from a plugin. Please set Qt::AA_ShareOpenGLContexts using QCoreApplication::setAttribute and QSGRendererInterface::OpenGLRhi using QQuickWindow::setGraphicsApi before constructing QGuiApplication.
^C^C^C^C
Terminated

I need to kill process from another terminal, normal Ctrl-C is ignored.

UPDATE2: I further updated code following @ekhumoro advice:

from os import path

from PySide6.QtCore import QMetaObject
from PySide6.QtUiTools import QUiLoader
from PySide6.QtWidgets import QApplication, QWidget, QMainWindow


class UiLoader(QUiLoader):
    _baseinstance = None

    def createWidget(self, classname, parent=None, name=''):
        if parent is None and self._baseinstance is not None:
            widget = self._baseinstance
        else:
            widget = super(UiLoader, self).createWidget(classname, parent, name)
            if self._baseinstance is not None:
                setattr(self._baseinstance, name, widget)
        return widget

    def loadUi(self, uifile, baseinstance=None):
        self._baseinstance = baseinstance
        widget = self.load(uifile)
        QMetaObject.connectSlotsByName(widget)
        return widget


class MyWin(QMainWindow):
    def __init__(self, parent=None):
        super().__init__(parent)
        UiLoader().loadUi(path.join(path.dirname(__file__), "MainWindow.ui"), self)
        # self.pushButton.clicked.connect(self.on_pushButton_clicked)
        QMetaObject.connectSlotsByName(self)

    def on_pushButton_clicked(self):
        print("button pushed!")


app = QApplication([])
win = MyWin()
win.show()
app.exec()

This doesn't work either: it shows GUI, but button click is not connected (unless I explicitly do it uncommenting the line).

What am I doing wrong?

ZioByte
  • 2,690
  • 1
  • 32
  • 68

2 Answers2

1

connectSlotsByName() is a static function, and it can only accept an argument (the "target" object), meaning that it can only operate with children of the object and its own functions.

The solution, then, is to make the top level widget a child of the "controller".

This cannot be directly done with setParent() in your case, though, since the QWidget override of setParent() expects a QWidget as argument, and your MyWin class is a simple QObject instead.

While the theoretical solution could be to call QObject.setParent(self.ui, self) to circumvent the override, this won't work and will sooner or later crash. As explained in this related post, the only available solution is to make the "controller" a QWidget subclass, even if you're not showing it.

Note that there is an alternative solution that should provide the same results as uic.loadUi using a QUiLoader subclass, as explained in this answer. It obviously misses the other PyQt parameters, but for general usage it shouldn't be a problem.

Finally, remember that you could always use the loadUiType function that works exactly like the PyQt one (again, without extra parameters); it's a slightly different pattern, since it generates the class names dynamically, but has the benefit that it parses the ui just once, instead of doing it every time a new instance is created.

With a custom function you can even create a class constructor with the path and return a type that also calls setupUi() on its own:

def UiClass(path):
    formClass, widgetClass = loadUiType(path)
    name = os.path.basename(path).replace('.', '_')
    def __init__(self, parent=None):
        widgetClass.__init__(self, parent)
        formClass.__init__(self)
        self.setupUi(self)
    return type(name, (widgetClass, formClass), {'__init__': __init__})

class Win(UiClass('mainWindow.ui')):
    def __init__(self):
        super().__init__()
        # no need to call setupUi()


if __name__ == "__main__":
    import sys
    app = QApplication(sys.argv)
    test = Win()
    test.show()
    sys.exit(app.exec())

With the above, print(Win.__mro__) will show the following (this is from PyQt, PySide will obviously have the appropriate module name):

(<class '__main__.Win'>, <class '__main__.mainWindow_ui'>, 
<class 'PyQt5.QtWidgets.QMainWindow'>, <class 'PyQt5.QtWidgets.QWidget'>, 
<class 'PyQt5.QtCore.QObject'>, <class 'sip.wrapper'>, 
<class 'PyQt5.QtGui.QPaintDevice'>, <class 'sip.simplewrapper'>, 
<class 'Ui_MainWindow'>, <class 'object'>)

As noted in the comment by @ekhumoro, since PySide6 the behavior has changed, since uic is now able to directly output python code, so you need to ensure that the uic command of Qt is in the user PATH.

PS: note that, with PyQt, using connectSlotsByName() always calls the target function as many overrides as the signal has, which is the case of clicked signal of buttons; this is one of the few cases for which the @pyqtSlot decorator is required, so in your case you should decorate the function with @pyqtSlot(), since you are not interested in the checked argument. For PySide, instead, the @Slot is mandatory in order to make connectSlotsByName() work.
See this related answer.

musicamante
  • 41,230
  • 6
  • 33
  • 58
  • It's a long time since I wrote that `loadUi` port, and I never really tested it that much. In what way does the result differ? Or are you referring to the missing `package` and `resource_suffix` parameters? – ekhumoro Oct 30 '22 at 16:43
  • @ekhumoro if you're referring to "allows having *almost* the same result", yes, I was referring to the fact that your solution obviously doesn't accept the same parameters as `loadUi`; I should probably rephrase that. In reality, I've never tested it on my own, I just trusted your expertise :-) – musicamante Oct 30 '22 at 16:50
  • That's a dangerous game to play ;-) – ekhumoro Oct 30 '22 at 16:51
  • Well, that's up to those who want to play with it :-D In any case, I've updated the explanation and added a further alternative in case people wouldn't be as trusting as I was ;-) – musicamante Oct 30 '22 at 17:36
  • 1
    One limitation is that the QUiLoader class doesn't automatically handle custom widgets, which I think is why I made my port fairly minimal. It could be done by parsing the xml and adding the custom classes using `registerCustomWidget`, but that would complicate things quite a lot. At the time I wrote my port, there was no `loadUiType` either. That works much more directly because it simply `exec`s the output of the `uic` tool. However, [as pointed out in the docs](https://doc.qt.io/qtforpython-5/PySide2/QtUiTools/ls.loadUiType.html), the big downside is that `uic` must be in the user's PATH. – ekhumoro Oct 30 '22 at 18:22
  • I saw that old post by @ekhumoro, but id didn't seem to work for me with PySide6. I will retest and I'll try also the `loadUiType()` solution; I will post results. – ZioByte Oct 30 '22 at 18:24
  • @ZioByte I just tried your ui file with my `loadUi` implementation and it works fine for me with both PySide6 and PySide2. But note that the original example was written for PySide (Qt4), so you will need to adjust the imports. – ekhumoro Oct 30 '22 at 18:32
  • @ekhumoro: I retested in my example and it indeed works and creates the widgets as needed, but `QMetaObject.connectSlotsByName(self)` is still not honored, neither the one in your code neither if I place another one after return. I'll do an UPDATE2 to OP to reflect. – ZioByte Oct 30 '22 at 19:05
  • @ZioByte You need to decorate your slots with `@QtCore.Slot()`. – ekhumoro Oct 30 '22 at 19:22
  • 1
    @musicamante Your `mro` output shows PyQt5 classes whereas the OP is using PySide6. Also, the PS regarding `connectSlotsByName` is rather PyQt-biased, and hence somewhat misleading, since PySide cannot automatically connect undecorated slots. – ekhumoro Oct 30 '22 at 19:50
  • @ekhumoro Thanks for your notes. I'll correct the `connectSlotsByName` note, unfortunately I don't have PySide6 on this machine and cannot add appropriate info about the path issue. I hope I'll remember that when I finally get to update my system. – musicamante Oct 31 '22 at 00:21
1

To answer the question as stated in the title:

It's possible to fix the original example by setting the container-widget as the parent of the ui-widget. However, there are a few extra steps required. Firstly, the flags of the ui-widget must include Qt.Window, otherwise it will just become the child of an invisble window. Secondly, the close-event of the ui-widget must be reimplemented so that the application shuts down properly. And finally, the auto-connected slots must be decorated with QtCore.Slot.

Here's a fully working example:

from os import path
from PySide6.QtCore import Qt, QEvent, Slot, QMetaObject
from PySide6.QtUiTools import QUiLoader
from PySide6.QtWidgets import QApplication, QWidget


class MyWin(QWidget):
    def __init__(self):
        super().__init__()
        self.ui = QUiLoader().load(
            path.join(path.dirname(__file__), "MainWindow.ui"))
        self.ui.setParent(self, self.ui.windowFlags() | Qt.WindowType.Window)
        self.ui.installEventFilter(self)
        QMetaObject.connectSlotsByName(self)

    def eventFilter(self, source, event):
        if event.type() == QEvent.Type.Close and source is self.ui:
            QApplication.instance().quit()
        return super().eventFilter(source, event)

    def myshow(self):
        self.ui.show()

    @Slot()
    def on_pushButton_clicked(self):
        print("button pushed!")


app = QApplication(['Test'])
win = MyWin()
win.myshow()
app.exec()

PS: see also my completely revised alternative solution using a loadUi-style approach that now works properly with both PySide2 and PySide6.

ekhumoro
  • 115,249
  • 20
  • 229
  • 336