QWidgetAction.requestWidget doesn't keep the Python object

Maurizio Berti maurizio.berti at gmail.com
Sat Nov 18 06:12:45 GMT 2023


Summary:

Calls to QWidgetAction.requestWidget always result in losing the Python
reference of a custom widget, including its class.

Explanation:

I was experimenting with a custom widget acting as a QAction container, and
intended as a permanent widget in QStatusBar, so that I was able to add
actions to it.

The intended behavior is similar to that of QToolBar, so I decided to
override actionEvent().

If the event is ActionAdded with a standard QAction, I create a
QToolButton, call setDefaultAction() and add that button to the layout.

I'll skip the details about the lifespan consistency between the created
widgets and their related actions, as it's not really important for this
matter (I used a dictionary, to create/get action/widgets pairs, but I had
to use sip.[un]wrapinstance() as actions that are being deleted may nullify
a previous python reference, leading to the known "underlying object has
been deleted").

Now, the problem comes when using a QWidgetAction: since the action can be
displayed in different places other than my custom widget (for example, a
toolbar), I had to override createWidget(), but since those widgets may
alter their appearance/behavior and should obviously be consistent within
the same action, I also implemented a custom method that, when called,
updates all created widgets consistently.

In my previous (simple) usages of QWidgetAction, I always directly called
createWidget(), keeping a private list of created widgets in the instance
updated: the overall context was contained, and my approach was fine for
that purpose. That approach made things a bit clumsy, though: I had to
ensure that the widget list and their connections were consistent, possibly
using custom functions to iterate the private list of widgets and ensuring
about the real existence of a possibly destroyed widget that could have
been created temporarily (for instance, in a non persistent context menu).

Still, doing this is not consistent with the behavior of QToolBar, which
always uses requestWidget() for QWidgetAction, and keeps its internal list
updated (available through createdWidgets()), eventually deleting all
*owned* widgets created for the actions added to it.

I therefore decided to take a more consistent approach, calling
requestWidget() from my custom container. That's when I realized that
things got weird.

It looks like requestWidget() properly keeps the custom widget reference,
including Python implemented methods, but only when directly called from
Qt, which is the case of QToolBar. If I do it from my custom class, I just
get an "uncast" QWidget.

Not only do I get a basic QWidget from requestWidget() if I try to create
and add it in my container, but the original Python wrapper (meaning its
full instance) is completely destroyed in the process. I know that I can do
a sip.cast(widget, myclass), but that will just reinterpret the widget as
the given type, not as an instance, thus ignoring its aspects and anything
eventually set in: most importantly, whatever I may have done within its
__init__().

I can ignore all of that and just go on with direct calls to createWidget(),
but I don't like that: first of all, it's not consistent; then, it can
still create some issues when dealing with standard QAction containers
created in/by Qt: not only QToolBar, but also QMenu or even QMenuBar. And,
if virtual functions are overridden in the custom widget, the result is
that they may not be properly called as (or when) necessary.

I created a basic MRE to allow you to test this issue.
I know it seems a bit convoluted, but I tried to keep it as simple as
possible while showing the problem in its extent.

class CustomWidget(QFrame):
    value = 0
    valueChanged = pyqtSignal(int)
    def __init__(self, parent):
        super().__init__(parent)
        self.setFrameStyle(self.Shape.StyledPanel|self.Shadow.Raised)
        self.label = QLabel(str(self.value))

        layout = QHBoxLayout(self)
        layout.setContentsMargins(10, 4, 10, 4)
        layout.addWidget(self.label)

    def mousePressEvent(self, event):
        print('click!', self)
        self.value += 1
        self.valueChanged.emit(self.value)
        self.label.setNum(self.value)

    def setValue(self, value):
        if self.value != value:
            self.value = value
            self.label.setNum(value)
            self.valueChanged.emit(value)

    def __del__(self):
        print('Python CustomWidget destroyed!')


class CustomAction(QWidgetAction):
    valueChanged = pyqtSignal(int)
    def createWidget(self, parent):
        custom = CustomWidget(parent)
        custom.valueChanged.connect(self.valueChanged)
        return custom

    def setValue(self, value):
        for widget in self.createdWidgets():
            try:
                widget.setValue(value)
            except:
                pass


class ActionContainer(QFrame):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setFrameStyle(self.Shape.StyledPanel|self.Shadow.Sunken)
        layout = QHBoxLayout(self)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.addWidget(QLabel('Custom actions:'))

    def eventFilter(self, obj, event):
        if event.type() == event.Type.MouseButtonPress:
            print('Expecting a "click! <instance>"')
            QTimer.singleShot(1, lambda: print('Did it happen?'))
        return super().eventFilter(obj, event)

    def actionEvent(self, event):
        if event.type() == event.Type.ActionAdded:
            action = event.action()
            if isinstance(action, QWidgetAction):
                widget = event.action().requestWidget(self)
                print('adding custom widget action', type(widget))

                # the following is pointless, as it will just return the
type
                # known to Qt (QFrame), not the custom class
                # widget = sip.cast(widget, CustomWidget)
                # print('cast attempt of custom widget:', type(widget))

                # the following will throw an AttributeError if uncommented
                # widget.valueChanged.connect(lambda: print('whatever'))
            else:
                widget = QToolButton()
                widget.setDefaultAction(action)
                widget.pressed.connect(lambda: print('click!', widget))
                print('adding standard action', type(widget))
            widget.installEventFilter(self)
            self.layout().addWidget(widget)


app = QApplication([])
window = QMainWindow()
window.setCentralWidget(QScrollArea())

tb = QToolBar('toolbar')
window.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb)

statusWidget = ActionContainer()
window.statusBar().addPermanentWidget(statusWidget)

tb.addAction(CustomAction(window))

tb.addSeparator()

action1 = QAction('Test', window)
action2 = CustomAction(window)
action3 = CustomAction(window)
action3.valueChanged.connect(action2.setValue)
tb.addAction(action1)
tb.addAction(action2)
tb.addAction(action3)
statusWidget.addAction(action1)
statusWidget.addAction(action2)
statusWidget.addAction(action3)

window.show()

As said, I know I can just settle with a private list of created widgets,
but I don't really like that approach. I know that it's possible to cast
the returned widget based on the original Python object type (it works fine
on QToolBar) but I'm not completely sure it's possible with requestWidget().
If it is, I'd ask to fix it, otherwise it will still good to know that.

Note that I tested the above with recent versions of both PyQt5 and 6.
They're not the latest available, though: 5.15.2 and 6.3.1.

Thank you!
MaurizioB/musicamante
-- 
È difficile avere una convinzione precisa quando si parla delle ragioni del
cuore. - "Sostiene Pereira", Antonio Tabucchi
http://www.jidesk.net
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://www.riverbankcomputing.com/pipermail/pyqt/attachments/20231118/c9034c4e/attachment-0001.htm>


More information about the PyQt mailing list