pythonpyqtpyqt5qmainwindow

Is it possible to create QMainWindow with only outer border?


I am trying to rebuild a screen record PyQt App, and the ScreenToGIF is a very good demo for me, it creates an interface which only has the border and record contents in the "Central Widgets", like this:

ScreenShot of ScreenToGif Software

with key functions of:

  1. The border exists and can be drag and resize by mouse
  2. the inner content is transparent
  3. the mouse click can penetrate through the app, and interact with other app beneath it.

However, it is implemented in C# (link:https://github.com/NickeManarin/ScreenToGif), I am wondering whether it possible to make a similar PyQt App without learning to be expertise about C#?

Changing the background image of QMainWidgets to the desktop area been overlayed doesn't make sense, because mouse operation on desktop (such as double click to open files) should be recorded. Mouse event can penetrate the app (like Qt.WindowTransparentForInput applied for inner contents?)


Solution

  • What you want to achieve requires setting a mask, allowing you to have a widget that has a specific "shape" that doesn't have to be a rectangle.

    The main difficulty is to understand how window geometries work, which can be tricky.
    You have to ensure that the window "frame" (which includes its margins and titlebar - if any) has been computed, then find out the inner rectangle and create a mask accordingly. Note that on Linux this happens "some time" after show() has been called; I think you're on Windows, but I've implemented it in a way that should work fine for both Linux, MacOS and Windows. There's a comment about that, if you're sure that your program will run on Windows only.

    Finally, I've only been able to run this on Linux, Wine and a virtualized WinXP environment. It should work fine on any system, but, from my experience, there's a specific "cosmetic" bug: the title bar is not painted according to the current Windows theme. I think that this is due to the fact that whenever a mask is applied, the underlying windows system doesn't draw its "styled" window frame as it usually would. If this happens in newer systems also, there could be a workaround, but it's not easy, and I cannot guarantee that it would solve this issue.

    NB: remember that this approach will never allow you to draw anything inside the "grab rectangle" (no shade, nor semi-transparent color mask); the reason for this is that you obviously need to achieve mouse interaction with what is "beneath" the widget, and painting over it would require altering the overlaying mask.

    from PyQt5 import QtCore, QtGui, QtWidgets
    
    class VLine(QtWidgets.QFrame):
        # a simple VLine, like the one you get from designer
        def __init__(self):
            super(VLine, self).__init__()
            self.setFrameShape(self.VLine|self.Sunken)
    
    
    class Grabber(QtWidgets.QWidget):
        dirty = True
        def __init__(self):
            super(Grabber, self).__init__()
            self.setWindowTitle('Screen grabber')
            # ensure that the widget always stays on top, no matter what
            self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint)
    
            layout = QtWidgets.QVBoxLayout()
            self.setLayout(layout)
            # limit widget AND layout margins
            layout.setContentsMargins(0, 0, 0, 0)
            self.setContentsMargins(0, 0, 0, 0)
            layout.setSpacing(0)
    
            # create a "placeholder" widget for the screen grab geometry
            self.grabWidget = QtWidgets.QWidget()
            self.grabWidget.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding)
            layout.addWidget(self.grabWidget)
    
            # let's add a configuration panel
            self.panel = QtWidgets.QWidget()
            layout.addWidget(self.panel)
    
            panelLayout = QtWidgets.QHBoxLayout()
            self.panel.setLayout(panelLayout)
            panelLayout.setContentsMargins(0, 0, 0, 0)
            self.setContentsMargins(1, 1, 1, 1)
    
            self.configButton = QtWidgets.QPushButton(self.style().standardIcon(QtWidgets.QStyle.SP_ComputerIcon), '')
            self.configButton.setFlat(True)
            panelLayout.addWidget(self.configButton)
    
            panelLayout.addWidget(VLine())
    
            self.fpsSpinBox = QtWidgets.QSpinBox()
            panelLayout.addWidget(self.fpsSpinBox)
            self.fpsSpinBox.setRange(1, 50)
            self.fpsSpinBox.setValue(15)
            panelLayout.addWidget(QtWidgets.QLabel('fps'))
    
            panelLayout.addWidget(VLine())
    
            self.widthLabel = QtWidgets.QLabel()
            panelLayout.addWidget(self.widthLabel)
            self.widthLabel.setFrameShape(QtWidgets.QLabel.StyledPanel|QtWidgets.QLabel.Sunken)
    
            panelLayout.addWidget(QtWidgets.QLabel('x'))
    
            self.heightLabel = QtWidgets.QLabel()
            panelLayout.addWidget(self.heightLabel)
            self.heightLabel.setFrameShape(QtWidgets.QLabel.StyledPanel|QtWidgets.QLabel.Sunken)
    
            panelLayout.addWidget(QtWidgets.QLabel('px'))
    
            panelLayout.addWidget(VLine())
    
            self.recButton = QtWidgets.QPushButton('rec')
            panelLayout.addWidget(self.recButton)
    
            self.playButton = QtWidgets.QPushButton('play')
            panelLayout.addWidget(self.playButton)
    
            panelLayout.addStretch(1000)
    
        def updateMask(self):
            # get the *whole* window geometry, including its titlebar and borders
            frameRect = self.frameGeometry()
    
            # get the grabWidget geometry and remap it to global coordinates
            grabGeometry = self.grabWidget.geometry()
            grabGeometry.moveTopLeft(self.grabWidget.mapToGlobal(QtCore.QPoint(0, 0)))
    
            # get the actual margins between the grabWidget and the window margins
            left = frameRect.left() - grabGeometry.left()
            top = frameRect.top() - grabGeometry.top()
            right = frameRect.right() - grabGeometry.right()
            bottom = frameRect.bottom() - grabGeometry.bottom()
    
            # reset the geometries to get "0-point" rectangles for the mask
            frameRect.moveTopLeft(QtCore.QPoint(0, 0))
            grabGeometry.moveTopLeft(QtCore.QPoint(0, 0))
    
            # create the base mask region, adjusted to the margins between the
            # grabWidget and the window as computed above
            region = QtGui.QRegion(frameRect.adjusted(left, top, right, bottom))
            # "subtract" the grabWidget rectangle to get a mask that only contains
            # the window titlebar, margins and panel
            region -= QtGui.QRegion(grabGeometry)
            self.setMask(region)
    
            # update the grab size according to grabWidget geometry
            self.widthLabel.setText(str(self.grabWidget.width()))
            self.heightLabel.setText(str(self.grabWidget.height()))
    
        def resizeEvent(self, event):
            super(Grabber, self).resizeEvent(event)
            # the first resizeEvent is called *before* any first-time showEvent and
            # paintEvent, there's no need to update the mask until then; see below
            if not self.dirty:
                self.updateMask()
    
        def paintEvent(self, event):
            super(Grabber, self).paintEvent(event)
            # on Linux the frameGeometry is actually updated "sometime" after show()
            # is called; on Windows and MacOS it *should* happen as soon as the first
            # non-spontaneous showEvent is called (programmatically called: showEvent
            # is also called whenever a window is restored after it has been
            # minimized); we can assume that all that has already happened as soon as
            # the first paintEvent is called; before then the window is flagged as
            # "dirty", meaning that there's no need to update its mask yet.
            # Once paintEvent has been called the first time, the geometries should
            # have been already updated, we can mark the geometries "clean" and then
            # actually apply the mask.
            if self.dirty:
                self.updateMask()
                self.dirty = False
    
    
    if __name__ == '__main__':
        import sys
        app = QtWidgets.QApplication(sys.argv)
        grabber = Grabber()
        grabber.show()
        sys.exit(app.exec_())