1

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()
ekhumoro
  • 115,249
  • 20
  • 229
  • 336
Suzanne Soy
  • 3,027
  • 6
  • 38
  • 56
  • Is this how you start the embedded application in your real program? And are you actually trying to embed *mousepad*, or some other application? A more realistic test case might be more helpful. You should also state which platforms you *need* this to work on. – ekhumoro Jan 21 '21 at 17:11
  • @ekhumoro I'm embedding multiple applications: Inkscape, GIMP, Blender… and at least one text editor (though probably not Mousepad), so the solution should not be application-specific. I start the application using `Popen`, and filter the outputs of `xwininfo` and `xprop` to get the window ID from the PID. The full code is at [github.com/jsmaniac](https://github.com/jsmaniac/XternalApps/blob/650158ac5f172eb102603b546493f05530dcf52a/Embed.py) (still messy) + [MyX11Utils.py](https://github.com/jsmaniac/XternalApps/blob/650158ac5f172eb102603b546493f05530dcf52a/MyX11Utils.py) in the same directory – Suzanne Soy Jan 22 '21 at 01:06
  • @ekhumoro Good point about platforms. Since I'm embedding these external apps into FreeCAD, which works on Linux, Windows, BSD and macos, those four would be ideal, but a Linux-only Xorg-only (no Wayland) would be a good start. Right now my only option is polling, and that's expensive. – Suzanne Soy Jan 22 '21 at 01:08
  • @SuzanneDupéron while foreign window embedding is achievable, it has *lots* of issues, depending on the platform and the "version" (OS version for MacOS/Windows, distribution/wm/dm for Linux). One of the most important is keyboard focus dealing, which often has some problems as it's not correctly acquired or released; geometry is often another one, as depending on the frameworks used for programs there could be features (or even overrides) that assume that the window of the program is "its own", which could also become a bigger problem for dock widgets based programs such as GIMP. – musicamante Jan 22 '21 at 13:40

1 Answers1

3

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__))
ekhumoro
  • 115,249
  • 20
  • 229
  • 336
  • It should be noted that (as expected) this won't work with programs that create other windows on their own: for instance, I use GIMP with separate windows and it obviously can't catch up with tool windows, nor windows created for new images. I'm wondering if using the wmctrl python module (or even Xlib or wnck) there might be more reliable results allowing other features. – musicamante Jan 22 '21 at 19:37
  • 2
    @musicamante The wmctrl tool is only used for getting the winID from the procID. It makes no difference how you do that. The main topic of the question is how to detect when an ermbedded program closes. My answer is to use QProcess - the main point being that controlling how the program is launched allows you to detect when it closes. However, I felt I should also provide some extra context that explains why such an approach is necessary (and add some caveats). My answer certainly isn't intended to be a general solution for how to embed arbitrary foreign windows! It's just a simple demo. – ekhumoro Jan 22 '21 at 20:26
  • The main topic of the question was how to detect when an embedded window closes, so if the program has two windows from the same process (e.g. for two separate documents), this solution will only detect when the process terminates (usually when the last window closes). However, your quote from the wiki makes it clear one shouldn't expect a Qt-only solution for detecting when a window closes, so I'm accepting this as an answer. Plus it contains several improvements over my solution (like the use of `-s` to suppress the splash screen and the `.setGeometry` which I hadn't managed to get right). – Suzanne Soy Jan 23 '21 at 17:54
  • @SuzanneDupéron Thanks for accepting my answer. However, your question and comments didn't mention any of those extra details about separate documents - you even said the solution should not be application specific. The example in your question just embeds mousepad and tries to detect Ctrl+Q, so that's what I based my answer on. Sorry you didn't quite get what you wanted, but I did ask you for a more realistic example... – ekhumoro Jan 23 '21 at 18:23
  • On Linux/X11 given a pid you can find the native window id using a combination of XGetWindowProperty and XQueryTree. – Damian Dixon May 21 '21 at 06:39