FernAndr
FernAndr

Reputation: 1608

PyQt / Qt Designer - Extra parameters for promoted widget without a plugin

Let's say I have a custom PyQt widget that I want to use in Qt Designer, such as for a matplotlib canvas:

plot_widget.py:

from PyQt5 import QtWidgets
import matplotlib
matplotlib.use('Qt5Agg')
from matplotlib.backends.backend_qt5agg import (
    FigureCanvasQTAgg, NavigationToolbar2QT
)

class PlotWidget(QtWidgets.QWidget):

    def __init__(self, parent=None, toolbar=True, figsize=None, dpi=100):
        super(PlotWidget, self).__init__(parent)
        self.setupUi(toolbar, figsize, dpi)

    def setupUi(self, toolbar=True, figsize=None, dpi=100):
        layout = QtWidgets.QVBoxLayout(self)
        self.figure = Figure(figsize=figsize, dpi=dpi)
        self.canvas = FigureCanvasQTAgg(self.figure)
        if toolbar:
            self.toolbar = NavigationToolbar2QT(self.canvas, self)
            layout.addWidget(self.toolbar, 0)
        layout.addWidget(self.canvas, 1)

In the Qt Designer, I can easily promote a QWidget for this, with the header file set to "my_module/plot_widget.h" and its name set to PlotWidget. Then, I can construct a widget from my ui file by loading it dynamically such as this:

example.py:

from PyQt5 import QtWidgets, uic

class MyWidget(QtWidgets.QWidget):

    def __init__(self, parent=None):
        super(MyWidget, self).__init__(parent)
        uic.loadUi('example.ui', self)
        self.show()

This works fine, except for the fact that I don't see a straightforward way of modifying the default init parameters that I have (toolbar, figsize and dpi in this case).

I know that there is the possibility of creating custom plugins for this, but I would like to avoid it, as I think it would be adding an unnecessary complexity to my simple needs. In addition, I want my code to be as compatible with PySide / PyQt4 / PyQt5 as possible (to that end, I am actually using QtPy instead of PyQt5 directly, although this is a bit off-topic), and I am not fully convinced I could achieve that if I start using plugins.

Hence, I thought of the following two approaches:

Approach 1: Calling setupUi() programmatically

Basically, I can simplify my constructor so that setupUi() is not called there, and do that call after loading the ui file:

plot_widget.py:

class PlotWidget(QtWidgets.QWidget):

    def __init__(self, parent=None):
        super(PlotWidget, self).__init__(parent)

    ...

example.py:

from PyQt5 import QtWidgets, uic

class MyWidget(QtWidgets.QWidget):

    def __init__(self, parent=None):
        super(MyWidget, self).__init__(parent)
        uic.loadUi('example.ui', self)
        self.myPlotWidget.setupUi(figsize=(4, 4), dpi=200)
        self.show()

Approach 2: Using dynamic properties

Instead of passing extra arguments to setupUi(), I can add any dynamic property that I need in the Qt Designer, and then use them such as this:

figsize = self.property('figsize')

If they are not defined, they will just be None. The problem is that the init constructor is called before those dynamic properties are added to the object (which seems logic), and hence figsize would always be None in this code:

plot_widget.py:

class PlotWidget(QtWidgets.QWidget):

    def __init__(self, parent=None):
        super(PlotWidget, self).__init__(parent)

    def setupUi(self):
        layout = QtWidgets.QVBoxLayout(self)
        toolbar = self.property('toolbar')  # Always None
        figsize = self.property('figsize')  # Always None
        dpi = self.property('dpi')          # Always None
        self.figure = Figure(figsize=figsize, dpi=dpi)
        self.canvas = FigureCanvasQTAgg(self.figure)
        if toolbar:
            self.toolbar = NavigationToolbar2QT(self.canvas, self)
            layout.addWidget(self.toolbar, 0)
        layout.addWidget(self.canvas, 1)

Which means that I would still need an explicit call to setupUi() in my example.py file, unless I can figure out something else.

Conclusion

I like much more the idea of Approach 2, as it allows me to modify all the ui parameters directly from the Qt Designer. However, I would like to avoid that explicit call to setupUi(). Hence, is there any QWidget method that always gets called after the dynamic properties have been assigned to the object (and hence I could override this to make the call to setupUi() there)?

Also, do you see any other flaw in this approach or do you know of any other alternative method of achieving what I am trying to achieve? Note that I have used a matplotlib canvas as an example (for which there might be some specific approaches already), but I would like to use this for any custom widget that I might need.

Upvotes: 5

Views: 2932

Answers (2)

musicamante
musicamante

Reputation: 48399

I know some months have passed since your own answer, maybe you've already found a solution that satisfies your needs and maybe I've not completely understood your situation.

From what I can understand, I'd set dynamic properties for the widget in Designer, and then overload QObject.setProperty() or, better, use pyqtProperty in the custom widget, then use the @property.setter to setup the layout. The only issue would be that the properties have to be declared in the right order. You could circumvent with different approaches.

  1. use a single QStringList dynamic property to set all the parameters as a fake dictionary of (key, value) tuples.

  2. use resizeEvent (or showEvent) as you mentioned, setting a class flag, to avoid multiple calls to setupUi:

    class Widget(QtWidgets.QWidget):
        shown = False
        [...]
        def showEvent(self, event):
            if not self.shown:
                self.shown = True
                self.setupUi()
    
  3. add each widget to the layout according to their position using insertWidget whenever their property is set (this only works for QBoxLayout and if you know the widgets will have a fixed layout structure)

Alternatively, you could just use another ui for the custom widget, add all possible promoted plotlib widgets and then use setVisible() in the @property.setter decorator; then you decide wether to hide them on the init and show them only when their property is set, or remember to always set the dynamic properties.

Upvotes: 2

FernAndr
FernAndr

Reputation: 1608

Original answer:

I found out that I may be able to use resizeEvent for this, but I don't know if it will work in any situation (i.e. whether the ui loader will always trigger the resizeEvent under all circumstances):

class PlotWidget(QtWidgets.QWidget):

    def __init__(self, parent=None):
        super(PlotWidget, self).__init__(parent)
        self._alreadySetup = False

    def resizeEvent(self, event):
        # This call can actually be after the super() call and still work
        self.setupUi()
        super(PlotWidget, self).resizeEvent(event)

    def setupUi(self):
        if self._alreadySetup:
            return
        self._alreadySetup = True
        layout = QtWidgets.QVBoxLayout(self)
        ...

Hence, unless I detect issues with this approach or I can figure out something more clever (or somebody else can), I will consider this the answer to my question.

Edit: Just be aware that, if the widget is never visible, the resizeEvent will never be called. This is normally not a problem, but on certain occasions (such as during unit tests or doing heavy initialization before showing a widget) it may happen.

Alternative approach:

Lazy initialization can also be used for this as follows:

class PlotWidget(QtWidgets.QWidget):

    def __init__(self, parent=None):
        super(PlotWidget, self).__init__(parent)
        self._figure = None
        self._canvas = None
        self._toolbar = None
        self._initialized = False

    @property
    def figure(self):
        self.setupUi()
        return self._figure

    @property
    def canvas(self):
        self.setupUi()
        return self._canvas

    @property
    def toolbar(self):
        self.setupUi()
        return self._toolbar

    def setupUi(self):
        if self._initialized:
            return
        self._initialized = True
        layout = QtWidgets.QVBoxLayout(self)
        toolbar = self.property('toolbar')
        figsize = self.property('figsize')
        dpi = self.property('dpi')
        self._figure = Figure(figsize=figsize, dpi=dpi)
        self._canvas = FigureCanvasQTAgg(self._figure)
        if toolbar:
            self._toolbar = NavigationToolbar2QT(self._canvas, self)
            layout.addWidget(self._toolbar, 0)
        layout.addWidget(self._canvas, 1)

The uic module does not have any reason to access these properties while loading the .ui file, and hence only the programmer can do so at his convenience. Nevertheless, this can also have a few inconveniences, such as:

  • You will need to access at least one of the properties at some point to make the content available. For example, the plot might not be drawn until the user presses a button, and until then it will just look like an empty widget
  • Unless using some clever descriptor, you will probably need to generate one Python property per lazy attribute being used, which can end up being quite verbose

Upvotes: 0

Related Questions