ZioByte
ZioByte

Reputation: 3004

PySide6 how to use `QMetaObject.connectSlotsByName(MainWindow)` when using also `QUiLoader().load(...)`

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?

Upvotes: 2

Views: 965

Answers (2)

ekhumoro
ekhumoro

Reputation: 120618

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.

Upvotes: 1

musicamante
musicamante

Reputation: 48335

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.

Upvotes: 1

Related Questions