QWidgetAction.requestWidget doesn't keep the Python object
Phil Thompson
phil at riverbankcomputing.com
Sat Nov 18 08:19:57 GMT 2023
On 18/11/2023 06:12, Maurizio Berti wrote:
> 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
Are you able to build PyQt yourself? If so please try the attached.
Phil
-------------- next part --------------
A non-text attachment was scrubbed...
Name: qwidgetaction.sip
Type: text/x-c++
Size: 966 bytes
Desc: not available
URL: <https://www.riverbankcomputing.com/pipermail/pyqt/attachments/20231118/d046498d/attachment.bin>
More information about the PyQt
mailing list