pythonpython-3.xembedpyside2qwindow

How to detect when a foreign window embedded with QWidget.createWindowContainer closes itself?


I'm embedding another window into a Qt widget using PySide2.QtGui.QWindow.fromWinId(windowId). It works well, but it does not fire an event when the the original X11 window destroys it.

If I run the file below with mousepad & python3 embed.py and press Ctrl+Q, no event fires and I'm left with an empty widget.

How can I detect when the X11 window imported by QWindow.fromWinId is destroyed by its creator?

Screenshots of the existing Mousepad window, of the mousepad window embedded in the embed.py frame, and of the empty embed.py frame

#!/usr/bin/env python

# sudo apt install python3-pip
# pip3 install PySide2

import sys, subprocess, PySide2
from PySide2 import QtGui, QtWidgets, QtCore

class MyApp(QtCore.QObject):
  def __init__(self):
    super(MyApp, self).__init__()

    # Get some external window's windowID
    print("Click on a window to embed it")
    windowIdStr = subprocess.check_output(['sh', '-c', """xwininfo -int | sed -ne 's/^.*Window id: \\([0-9]\\+\\).*$/\\1/p'"""]).decode('utf-8')
    windowId = int(windowIdStr)
    print("Embedding window with windowId=" + repr(windowId))

    # Create a simple window frame
    self.app = QtWidgets.QApplication(sys.argv)
    self.mainWindow = QtWidgets.QMainWindow()
    self.mainWindow.show()

    # Grab the external window and put it inside our window frame
    self.externalWindow = QtGui.QWindow.fromWinId(windowId)
    self.externalWindow.setFlags(QtGui.Qt.FramelessWindowHint)
    self.container = QtWidgets.QWidget.createWindowContainer(self.externalWindow)
    self.mainWindow.setCentralWidget(self.container)

    # Install event filters on all Qt objects
    self.externalWindow.installEventFilter(self)
    self.container.installEventFilter(self)
    self.mainWindow.installEventFilter(self)
    self.app.installEventFilter(self)

    self.app.exec_()

  def eventFilter(self, obj, event):
    # Lots of events fire, but no the Close one
    print(str(event.type())) 
    if event.type() == QtCore.QEvent.Close:
      mainWindow.close()
    return False

prevent_garbage_collection = MyApp()

Solution

  • Below is a simple demo script that shows how to detect when an embedded external window closes. The script is only intended to work on Linux/X11. To run it, you must have wmctrl installed. The solution itself doesn't rely on wmctrl at all: it's merely used to get the window ID from the process ID; I only used it in my demo script because its output is very easy to parse.

    The actual solution relies on QProcess. This is used to start the external program, and its finished signal then notifies the main window that the program has closed. The intention is that this mechanism should replace your current approach of using subprocess and polling. The main limitation of both these approaches is they will not work with programs that run themselves as background tasks. However, I tested my script with a number applications on my Arch Linux system - including Inkscape, GIMP, GPicView, SciTE, Konsole and SMPlayer - and they all behaved as expected (i.e. they closed the container window when exiting).

    NB: for the demo script to work properly, it may be necessary to disable splash-screens and such like in some programs so they can embed themselves correctly. For example, GIMP must be run like this:

    $ python demo_script.py gimp -s
    

    If the script complains that it can't find the program ID, that probably means the program launched itself as a background task, so you will have to try to find some way to force it into the foreground.


    Disclaimer: The above solution may work on other platforms, but I have not tested it there, and so cannot offer any guarantees. I also cannot guarantee that it will work with all programs on Linux/X11.

    I should also point out that embedding external, third-party windows is not officially supported by Qt. The createWindowContainer function is only intended to work with Qt window IDs, so the behaviour with foreign window IDs is strictly undefined (see: QTBUG-44404). The various issues are documentented in this wiki article: Qt and foreign windows. In particular, it states:

    A larger issue with our current APIs, that hasn't been discussed yet, is the fact that QWindow::fromWinId() returns a QWindow pointer, which from an API contract point of view should support any operation that any other QWindow supports, including using setters to manipulate the window, and connecting to signals to observe changes to the window.

    This contract is not adhered to in practice by any of our platforms, and the documentation for QWindow::fromWinId() doesn't mention anything about the situation.

    The reasons for this undefined/platform specific behaviour largely boils down to our platforms relying on having full control of the native window handle, and the native window handle often being a subclass of the native window handle type, where we implement callbacks and other logic. When replacing the native window handle with an instance we don't control, and which doesn't implement our callback logic, the behaviour becomes undefined and full of holes compared to a regular QWindow.

    So, please bear all that in mind when designing an application that relies on this functionality, and adjust your expectations accordingly...


    The Demo script:

    import sys, os, shutil
    from PySide2.QtCore import (
        Qt, QProcess, QTimer,
        )
    from PySide2.QtGui import (
        QWindow,
        )
    from PySide2.QtWidgets import (
        QApplication, QWidget, QVBoxLayout, QMessageBox,
        )
    
    class Window(QWidget):
        def __init__(self, program, arguments):
            super().__init__()
            layout = QVBoxLayout()
            layout.setContentsMargins(0, 0, 0, 0)
            self.setLayout(layout)
            self.external = QProcess(self)
            self.external.start(program, arguments)
            self.wmctrl = QProcess()
            self.wmctrl.setProgram('wmctrl')
            self.wmctrl.setArguments(['-lpx'])
            self.wmctrl.readyReadStandardOutput.connect(self.handleReadStdOut)
            self.timer = QTimer(self)
            self.timer.setSingleShot(True)
            self.timer.setInterval(25)
            self.timer.timeout.connect(self.wmctrl.start)
            self.timer.start()
            self._tries = 0
    
        def closeEvent(self, event):
            for process in self.external, self.wmctrl:
                process.terminate()
                process.waitForFinished(1000)
    
        def embedWindow(self, wid):
            window = QWindow.fromWinId(wid)
            widget = QWidget.createWindowContainer(
                window, self, Qt.FramelessWindowHint)
            self.layout().addWidget(widget)
    
        def handleReadStdOut(self):
            pid = self.external.processId()
            if pid > 0:
                windows = {}
                for line in bytes(self.wmctrl.readAll()).decode().splitlines():
                    columns = line.split(maxsplit=5)
                    # print(columns)
                    # wid, desktop, pid, wmclass, client, title
                    windows[int(columns[2])] = int(columns[0], 16)
                if pid in windows:
                    self.embedWindow(windows[pid])
                    # this is where the magic happens...
                    self.external.finished.connect(self.close)
                elif self._tries < 100:
                    self._tries += 1
                    self.timer.start()
                else:
                    QMessageBox.warning(self, 'Error',
                        'Could not find WID for PID: %s' % pid)
            else:
                QMessageBox.warning(self, 'Error',
                    'Could not find PID for: %r' % self.external.program())
    
    if __name__ == '__main__':
    
        if len(sys.argv) > 1:
            if shutil.which(sys.argv[1]):
                app = QApplication(sys.argv)
                window = Window(sys.argv[1], sys.argv[2:])
                window.setGeometry(100, 100, 800, 600)
                window.show()
                sys.exit(app.exec_())
            else:
                print('could not find program: %r' % sys.argv[1])
        else:
            print('usage: python %s <external-program-name> [args]' %
                  os.path.basename(__file__))