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 abackground-color: rgba(...)style, it allows the underlying desktop or wallpaper to be visible through semi-transparent areas of the widget.startSystemMove()andstartSystemResize(): 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()andQWindow.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 awlr-layer-shellsurface. For true “stays on bottom” behavior, such as for a desktop background or panel, Wayland relies on specific protocols likewlr-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.