// SPDX-FileCopyrightText: 2026 Marco Allegretti // SPDX-License-Identifier: EUPL-1.2 import QtQuick import org.kde.kwin as KWinComponents import org.kde.plasma.private.mobileshell as MobileShell import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings KWinComponents.SceneEffect { id: effect visible: false readonly property int outerGap: 8 readonly property int floatEscapeMargin: 32 readonly property int previewAnimationDuration: MobileShell.Motion.duration(MobileShell.Motion.SpatialDefault) readonly property int previewFadeDuration: MobileShell.Motion.duration(MobileShell.Motion.EffectsFast) property var dragConnectedWindows: ({}) property var draggingWindow: null property rect dragSourceGeometry: Qt.rect(0, 0, 0, 0) property bool animatePreview: false property bool previewVisible: false property string previewMode: "" property string previewScreenName: "" property rect previewGeometry: Qt.rect(0, 0, 0, 0) function isActive() { return ShellSettings.Settings.convergenceModeEnabled && !ShellSettings.Settings.gamingModeEnabled && ShellSettings.Settings.dynamicTilingEnabled; } function windowKey(window) { return window && window.internalId !== undefined ? String(window.internalId) : ""; } function shouldIgnore(window) { if (!window || window.deleted) { return true; } if (!window.normalWindow || !window.maximizable || window.fullScreen) { return true; } return window.resourceClass === "xwaylandvideobridge"; } function validRect(geometry) { return geometry && geometry.width > 0 && geometry.height > 0; } function rectContainsPoint(geometry, point) { return validRect(geometry) && point.x >= geometry.x && point.x <= geometry.x + geometry.width && point.y >= geometry.y && point.y <= geometry.y + geometry.height; } function workRect(window) { if (!window || !window.output || window.desktops.length === 0) { return null; } return KWinComponents.Workspace.clientArea( KWinComponents.Workspace.MaximizeArea, window.output, window.desktops[0] ); } function outsideWorkArea(window, cursor) { const area = workRect(window); if (!area) { return false; } const expandedArea = Qt.rect( area.x - floatEscapeMargin, area.y - floatEscapeMargin, area.width + floatEscapeMargin * 2, area.height + floatEscapeMargin * 2 ); return !rectContainsPoint(expandedArea, cursor); } function findTileAtCursor(cursor, ignoredWindow) { const ignoredKey = windowKey(ignoredWindow); const windows = KWinComponents.Workspace.windows; let bestWindow = null; let bestGeometry = Qt.rect(0, 0, 0, 0); let bestArea = 0; for (let index = 0; index < windows.length; index++) { const candidate = windows[index]; if (shouldIgnore(candidate) || windowKey(candidate) === ignoredKey) { continue; } if (!ignoredWindow || !candidate.output || !ignoredWindow.output || candidate.output.name !== ignoredWindow.output.name) { continue; } const geometry = candidate.frameGeometry; if (!rectContainsPoint(geometry, cursor)) { continue; } const area = geometry.width * geometry.height; if (!bestWindow || area < bestArea) { bestWindow = candidate; bestGeometry = geometry; bestArea = area; } } return bestWindow ? bestGeometry : null; } function insetPreviewGeometry(geometry) { const inset = Math.max(2, Math.round(outerGap / 2)); return Qt.rect( geometry.x + inset, geometry.y + inset, Math.max(1, geometry.width - inset * 2), Math.max(1, geometry.height - inset * 2) ); } function showPreview(mode, geometry, screenName) { hidePreview(); } function hidePreview() { previewVisible = false; draggingWindow = null; dragSourceGeometry = Qt.rect(0, 0, 0, 0); disableEffectTimer.restart(); } function updatePreview(window, dragGeometry) { if (!isActive() || draggingWindow !== window || !window.output) { hidePreview(); return; } const cursor = KWinComponents.Workspace.cursorPos; const targetGeometry = findTileAtCursor(cursor, window); if (targetGeometry) { showPreview("swap", targetGeometry, window.output.name); return; } if (outsideWorkArea(window, cursor)) { showPreview("float", validRect(dragGeometry) ? dragGeometry : window.frameGeometry, window.output.name); return; } showPreview("restore", dragSourceGeometry, window.output.name); } function connectDragHandlers(window) { const key = windowKey(window); if (!key || dragConnectedWindows[key]) { return; } dragConnectedWindows[key] = true; window.interactiveMoveResizeStarted.connect(function() { if (!isActive() || shouldIgnore(window)) { return; } draggingWindow = window; dragSourceGeometry = window.frameGeometry; showPreview("restore", dragSourceGeometry, window.output ? window.output.name : ""); }); window.interactiveMoveResizeStepped.connect(function(geometry) { updatePreview(window, geometry); }); window.interactiveMoveResizeFinished.connect(function() { if (draggingWindow === window) { hidePreview(); } }); } function connectExistingWindows() { const windows = KWinComponents.Workspace.windows; for (let index = 0; index < windows.length; index++) { connectDragHandlers(windows[index]); } } function previewFillColor(mode) { if (mode === "float") { return Qt.rgba(1.0, 0.62, 0.24, 0.18); } if (mode === "restore") { return Qt.rgba(1.0, 1.0, 1.0, 0.10); } return Qt.rgba(0.18, 0.72, 0.66, 0.22); } function previewBorderColor(mode) { if (mode === "float") { return Qt.rgba(1.0, 0.72, 0.36, 0.72); } if (mode === "restore") { return Qt.rgba(1.0, 1.0, 1.0, 0.36); } return Qt.rgba(0.64, 0.90, 0.86, 0.82); } Timer { id: enableAnimationTimer interval: 1 repeat: false onTriggered: effect.animatePreview = true } Timer { id: disableEffectTimer interval: effect.previewFadeDuration repeat: false onTriggered: { if (!effect.previewVisible) { effect.visible = false; } } } Connections { target: KWinComponents.Workspace function onWindowAdded(window) { effect.connectDragHandlers(window); } function onWindowRemoved(window) { const key = effect.windowKey(window); if (key) { delete effect.dragConnectedWindows[key]; } if (effect.draggingWindow === window) { effect.hidePreview(); } } } Connections { target: ShellSettings.Settings function onConvergenceModeEnabledChanged() { if (!effect.isActive()) { effect.hidePreview(); } } function onGamingModeEnabledChanged() { if (!effect.isActive()) { effect.hidePreview(); } } function onDynamicTilingEnabledChanged() { if (!effect.isActive()) { effect.hidePreview(); } } } delegate: Rectangle { id: screenDelegate readonly property var targetScreen: KWinComponents.SceneView.screen readonly property bool previewOnScreen: effect.previewScreenName === targetScreen.name color: "transparent" Rectangle { id: previewSurface visible: opacity > 0 x: effect.previewGeometry.x - screenDelegate.targetScreen.geometry.x y: effect.previewGeometry.y - screenDelegate.targetScreen.geometry.y width: effect.previewGeometry.width height: effect.previewGeometry.height radius: 14 opacity: effect.previewVisible && screenDelegate.previewOnScreen ? 1 : 0 color: effect.previewFillColor(effect.previewMode) border.width: 2 border.color: effect.previewBorderColor(effect.previewMode) Behavior on x { enabled: effect.animatePreview MobileShell.MotionNumberAnimation { type: MobileShell.Motion.SpatialDefault; duration: effect.previewAnimationDuration } } Behavior on y { enabled: effect.animatePreview MobileShell.MotionNumberAnimation { type: MobileShell.Motion.SpatialDefault; duration: effect.previewAnimationDuration } } Behavior on width { enabled: effect.animatePreview MobileShell.MotionNumberAnimation { type: MobileShell.Motion.SpatialDefault; duration: effect.previewAnimationDuration } } Behavior on height { enabled: effect.animatePreview MobileShell.MotionNumberAnimation { type: MobileShell.Motion.SpatialDefault; duration: effect.previewAnimationDuration } } Behavior on opacity { MobileShell.MotionNumberAnimation { type: MobileShell.Motion.EffectsFast; duration: effect.previewFadeDuration } } Behavior on color { MobileShell.MotionColorAnimation { type: MobileShell.Motion.EffectsFast; duration: effect.previewFadeDuration } } Behavior on border.color { MobileShell.MotionColorAnimation { type: MobileShell.Motion.EffectsFast; duration: effect.previewFadeDuration } } Rectangle { anchors.fill: parent anchors.margins: 5 radius: Math.max(0, parent.radius - anchors.margins) color: "transparent" border.width: 1 border.color: Qt.rgba(1, 1, 1, 0.14) opacity: effect.previewMode === "insert" ? 1 : 0.45 } } } Component.onCompleted: connectExistingWindows() }