On Wayland, directly replicating X11’s Qt.WindowType.WindowStaysOnBottomHint and Qt.WindowType.Tool flags for draggable, resizable desktop widgets is not straightforward due to Wayland’s compositor-centric window management. The most effective programmatic solution involves using a frameless window with the Qt.WindowType.Tool hint and Qt.WA_TranslucentBackground attribute, leveraging startSystemMove() and startSystemResize() for user interaction, while acknowledging limitations regarding absolute stacking order.

The Problem

A common challenge when migrating PyQt6 applications from X11 to Wayland is the differing window management paradigms. In X11, flags like Qt.WindowType.WindowStaysOnBottomHint and Qt.WindowType.Tool allow applications to create “desktop widgets” that are frameless, do not appear in the taskbar, and remain beneath other windows, freely draggable and resizable. On Wayland, these flags are often ignored by compositors (such as KWin in KDE Plasma), causing the application to behave like a standard window, disrupting the intended desktop overlay functionality. The core issue lies in Wayland compositors having strict control over window stacking and decoration, limiting direct application-level manipulation of these properties.

The Solution

To achieve a desktop-like widget with no taskbar entry, a frameless appearance, and user-initiated dragging/resizing on Wayland, combine Qt.WindowType.FramelessWindowHint and Qt.WindowType.Tool with Qt.WA_TranslucentBackground. Implement custom event handling for mousePressEvent to trigger self.windowHandle().startSystemMove() and self.windowHandle().startSystemResize(), allowing the compositor to manage the interaction.

import sys
from PyQt6.QtWidgets import QApplication, QWidget, QVBoxLayout, QLabel, QPushButton
from PyQt6.QtCore import Qt, QPoint
from PyQt6.QtGui import QMouseEvent, QCursor

class WaylandDesktopWidget(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Wayland Desktop Widget")
        self.setGeometry(100, 100, 300, 200)

        # Apply Wayland-compatible window flags
        # FramelessWindowHint: Removes window borders and title bar.
        # Tool: Prevents the window from appearing in the taskbar/dock.
        self.setWindowFlags(
            Qt.WindowType.FramelessWindowHint |
            Qt.WindowType.Tool
        )

        # Set attribute for transparent background, allowing the underlying desktop/wallpaper to show through.
        self.setAttribute(Qt.WA_TranslucentBackground)

        # Apply styling for visual feedback during development; can be removed for pure transparency.
        self.setStyleSheet("""
            QWidget {
                background-color: rgba(60, 60, 60, 180); /* Semi-transparent dark grey */
                border: 2px solid #0078D4; /* Accent color border */
                border-radius: 8px; /* Rounded corners */
            }
            QLabel {
                color: white;
                font-size: 16px;
            }
            QPushButton {
                background-color: #0078D4;
                color: white;
                border: none;
                padding: 5px 10px;
                border-radius: 4px;
            }
            QPushButton:hover {
                background-color: #005bb5;
            }
        """)

        # Layout and content for demonstration
        layout = QVBoxLayout(self)
        label = QLabel("PyQt6 Desktop Widget on Wayland")
        label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        layout.addWidget(label)

        close_button = QPushButton("Close Widget")
        close_button.clicked.connect(self.close)
        layout.addWidget(close_button)

        self.initial_pos = QPoint()
        self.is_resizing = False
        self.resize_direction = Qt.Edges(Qt.Edge.NoEdge)

    def mousePressEvent(self, event: QMouseEvent):
        if event.button() == Qt.MouseButton.LeftButton:
            # Determine if the click is near a border for resizing
            self.resize_direction = self.get_resize_direction(event.pos())
            if self.resize_direction != Qt.Edges(Qt.Edge.NoEdge):
                # Initiate system resize
                self.is_resizing = True
                self.windowHandle().startSystemResize(self.resize_direction)
            else:
                # Initiate system move for dragging
                self.is_resizing = False
                self.windowHandle().startSystemMove()
        super().mousePressEvent(event)

    def mouseMoveEvent(self, event: QMouseEvent):
        # Update cursor shape based on proximity to borders
        self.set_resize_cursor(event.pos())
        super().mouseMoveEvent(event)

    def mouseReleaseEvent(self, event: QMouseEvent):
        self.is_resizing = False
        self.resize_direction = Qt.Edges(Qt.Edge.NoEdge)
        super().mouseReleaseEvent(event)
        self.setCursor(Qt.CursorShape.ArrowCursor) # Reset cursor after interaction

    def get_resize_direction(self, pos: QPoint) -> Qt.Edges:
        """Detects if the mouse is near an edge for resizing."""
        border_size = 8 # Define the width of the resize grip area in pixels
        direction = Qt.Edges(Qt.Edge.NoEdge)
        if pos.x() < border_size:
            direction |= Qt.Edge.LeftEdge
        if pos.x() > self.width() - border_size:
            direction |= Qt.Edge.RightEdge
        if pos.y() < border_size:
            direction |= Qt.Edge.TopEdge
        if pos.y() > self.height() - border_size:
            direction |= Qt.Edge.BottomEdge
        return direction

    def set_resize_cursor(self, pos: QPoint):
        """Sets the appropriate cursor for resizing based on position."""
        direction = self.get_resize_direction(pos)
        if direction == (Qt.Edge.LeftEdge | Qt.Edge.TopEdge):
            self.setCursor(Qt.CursorShape.SizeFDiagCursor)
        elif direction == (Qt.Edge.RightEdge | Qt.Edge.BottomEdge):
            self.setCursor(Qt.CursorShape.SizeFDiagCursor)
        elif direction == (Qt.Edge.LeftEdge | Qt.Edge.BottomEdge):
            self.setCursor(Qt.CursorShape.SizeBDiagCursor)
        elif direction == (Qt.Edge.RightEdge | Qt.Edge.TopEdge):
            self.setCursor(Qt.CursorShape.SizeBDiagCursor)
        elif direction & Qt.Edge.LeftEdge or direction & Qt.Edge.RightEdge:
            self.setCursor(Qt.CursorShape.SizeHorCursor)
        elif direction & Qt.Edge.TopEdge or direction & Qt.Edge.BottomEdge:
            self.setCursor(Qt.CursorShape.SizeVerCursor)
        else:
            self.setCursor(Qt.CursorShape.ArrowCursor)

if __name__ == '__main__':
    app = QApplication(sys.argv)
    widget = WaylandDesktopWidget()
    widget.show()
    sys.exit(app.exec())

Why It Works

  • Wayland Compositor Control: Wayland delegates window management, including decorations, stacking, and movement, entirely to the compositor. Applications cannot directly draw window borders or dictate their absolute stacking order.
  • Qt.WindowType.FramelessWindowHint: This flag instructs Qt to tell the Wayland compositor that the client (your application) will handle its own decorations. The compositor then typically removes native window borders and title bars, allowing the application to present a custom look.
  • Qt.WindowType.Tool: This hint informs the compositor that the window is an auxiliary tool window. Wayland compositors, including KWin in KDE Plasma, often interpret this hint to prevent the window from appearing in the taskbar or dock, fulfilling the “no open window in the taskbar” requirement.
  • Qt.WA_TranslucentBackground: This attribute enables per-pixel alpha blending for the window’s background. Combined with a background-color: rgba(...) style, it allows the underlying desktop or wallpaper to be visible through semi-transparent areas of the widget.
  • startSystemMove() and startSystemResize(): Unlike X11 where applications could directly manipulate window geometry, Wayland requires that such operations are initiated by the application but executed by the compositor. QWindow.startSystemMove() and QWindow.startSystemResize() are the correct methods to delegate these actions, allowing the compositor to handle the interaction securely and consistently with the desktop environment.
  • Stacking Order Limitation: Qt.WindowStaysOnBottomHint (X11’s _NET_WM_STATE_BELOW) is fundamentally incompatible with Wayland’s security model for standard application windows. Wayland compositors strictly control window stacking order for security and user experience. An application cannot programmatically force itself to stay below all other windows or be directly on the wallpaper layer like a wlr-layer-shell surface. For true “stays on bottom” behavior, such as for a desktop background or panel, Wayland relies on specific protocols like wlr-layer-shell, which typically results in fixed, compositor-managed surfaces rather than arbitrarily draggable application widgets. For KWin, manual “Window Rules” configured by the user might achieve persistent “keep below” behavior for specific applications, but this is an end-user configuration, not programmatic control.

Reference