diff --git a/envmanager/config.h b/envmanager/config.h index 20592338..d26c39f9 100644 --- a/envmanager/config.h +++ b/envmanager/config.h @@ -59,7 +59,9 @@ QMap> getKwinrcSettings(KSharedConfig::Ptr m_mo {"convergentwindowsEnabled", true}, // enable our convergent window plugin {"mobiletaskswitcherEnabled", !convergenceModeEnabled}, // mobile task switcher on phone only; convergence uses standard Alt-Tab tabbox {"overviewEnabled", convergenceModeEnabled}, // enable KWin Overview effect in convergence mode for desktop-style task switching - {"screenedgeEnabled", convergenceModeEnabled} // enable screen edge visual feedback in convergence mode (mouse hot corners) + {"screenedgeEnabled", convergenceModeEnabled}, // enable screen edge visual feedback in convergence mode (mouse hot corners) + {"shift-tilingEnabled", convergenceModeEnabled}, + {"shift-tile-previewEnabled", convergenceModeEnabled} }}, {"Wayland", { @@ -78,8 +80,8 @@ QMap> getKwinrcSettings(KSharedConfig::Ptr m_mo // Have a separate list here because we need to trigger DBus calls to load/unload each effect/script. // Make sure that the effect/script is added to the kwinrc "Plugins" section above! -const QList KWIN_EFFECTS = {"blur", "mobiletaskswitcher", "overview", "screenedge"}; -const QList KWIN_SCRIPTS = {"convergentwindows"}; +const QList KWIN_EFFECTS = {"blur", "mobiletaskswitcher", "overview", "screenedge", "shift-tile-preview"}; +const QList KWIN_SCRIPTS = {"convergentwindows", "shift-tiling"}; // .config/plasma-mobile/ksmserver - immutable settings: const QMap> KSMSERVER_SETTINGS = {{"General", {{"loginMode", "emptySession"}}}}; diff --git a/kwin/effects/CMakeLists.txt b/kwin/effects/CMakeLists.txt index 67fb2257..91c3c7fd 100644 --- a/kwin/effects/CMakeLists.txt +++ b/kwin/effects/CMakeLists.txt @@ -8,3 +8,4 @@ function(add_kwin_effect name source) endfunction() add_kwin_effect(shift-snap-assist shift-snap-assist) +add_kwin_effect(shift-tile-preview shift-tile-preview) diff --git a/kwin/effects/shift-tile-preview/contents/ui/main.qml b/kwin/effects/shift-tile-preview/contents/ui/main.qml new file mode 100644 index 00000000..85832d21 --- /dev/null +++ b/kwin/effects/shift-tile-preview/contents/ui/main.qml @@ -0,0 +1,383 @@ +// 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.shellsettingsplugin as ShellSettings + +KWinComponents.SceneEffect { + id: effect + + visible: ShellSettings.Settings.convergenceModeEnabled + && !ShellSettings.Settings.gamingModeEnabled + && ShellSettings.Settings.dynamicTilingEnabled + + readonly property int outerGap: 8 + readonly property int floatEscapeMargin: 32 + readonly property int previewAnimationDuration: 180 + readonly property int previewFadeDuration: 110 + + 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 splitRect(geometry, orientation) { + if (orientation === "vertical") { + const firstWidth = Math.round(geometry.width * 0.5); + return [ + Qt.rect(geometry.x, geometry.y, firstWidth, geometry.height), + Qt.rect(geometry.x + firstWidth, geometry.y, geometry.width - firstWidth, geometry.height) + ]; + } + + const firstHeight = Math.round(geometry.height * 0.5); + return [ + Qt.rect(geometry.x, geometry.y, geometry.width, firstHeight), + Qt.rect(geometry.x, geometry.y + firstHeight, geometry.width, geometry.height - firstHeight) + ]; + } + + function insertDirection(cursor, geometry) { + const leftDistance = Math.abs(cursor.x - geometry.x); + const rightDistance = Math.abs((geometry.x + geometry.width) - cursor.x); + const topDistance = Math.abs(cursor.y - geometry.y); + const bottomDistance = Math.abs((geometry.y + geometry.height) - cursor.y); + const nearestDistance = Math.min(leftDistance, rightDistance, topDistance, bottomDistance); + + if (nearestDistance === leftDistance) { + return "left"; + } + if (nearestDistance === rightDistance) { + return "right"; + } + if (nearestDistance === topDistance) { + return "up"; + } + return "down"; + } + + function previewRectFor(cursor, targetGeometry) { + const direction = insertDirection(cursor, targetGeometry); + const orientation = direction === "left" || direction === "right" ? "vertical" : "horizontal"; + const splitGeometries = splitRect(targetGeometry, orientation); + if (direction === "left" || direction === "up") { + return splitGeometries[0]; + } + return splitGeometries[1]; + } + + 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) { + if (!validRect(geometry) || screenName === "") { + hidePreview(); + return; + } + + if (!previewVisible || previewScreenName !== screenName) { + animatePreview = false; + previewGeometry = insetPreviewGeometry(geometry); + previewMode = mode; + previewScreenName = screenName; + previewVisible = true; + enableAnimationTimer.restart(); + return; + } + + previewMode = mode; + previewScreenName = screenName; + previewGeometry = insetPreviewGeometry(geometry); + previewVisible = true; + } + + function hidePreview() { + previewVisible = false; + draggingWindow = null; + dragSourceGeometry = Qt.rect(0, 0, 0, 0); + } + + 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("insert", previewRectFor(cursor, 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 + } + + 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 + NumberAnimation { duration: effect.previewAnimationDuration; easing.type: Easing.OutCubic } + } + Behavior on y { + enabled: effect.animatePreview + NumberAnimation { duration: effect.previewAnimationDuration; easing.type: Easing.OutCubic } + } + Behavior on width { + enabled: effect.animatePreview + NumberAnimation { duration: effect.previewAnimationDuration; easing.type: Easing.OutCubic } + } + Behavior on height { + enabled: effect.animatePreview + NumberAnimation { duration: effect.previewAnimationDuration; easing.type: Easing.OutCubic } + } + Behavior on opacity { + NumberAnimation { duration: effect.previewFadeDuration; easing.type: Easing.InOutQuad } + } + Behavior on color { + ColorAnimation { duration: effect.previewFadeDuration; easing.type: Easing.OutCubic } + } + Behavior on border.color { + ColorAnimation { duration: effect.previewFadeDuration; easing.type: Easing.OutCubic } + } + + 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() +} \ No newline at end of file diff --git a/kwin/effects/shift-tile-preview/metadata.json b/kwin/effects/shift-tile-preview/metadata.json new file mode 100644 index 00000000..f70a6a08 --- /dev/null +++ b/kwin/effects/shift-tile-preview/metadata.json @@ -0,0 +1,20 @@ +{ + "KPackageStructure": "KWin/Effect", + "KPlugin": { + "Authors": [ + { + "Email": "marcoa@example.com", + "Name": "Marco Allegretti" + } + ], + "Category": "Appearance", + "Description": "Animated drag preview for SHIFT dynamic tiling in convergence mode.", + "EnabledByDefault": false, + "Id": "shift-tile-preview", + "License": "EUPL-1.2", + "Name": "SHIFT Tile Preview", + "Version": "1.0" + }, + "X-KDE-Ordering": 61, + "X-Plasma-API": "declarativescript" +} \ No newline at end of file diff --git a/kwin/scripts/shift-tiling/contents/ui/main.qml b/kwin/scripts/shift-tiling/contents/ui/main.qml index 9a8445b4..ab0e5b4c 100644 --- a/kwin/scripts/shift-tiling/contents/ui/main.qml +++ b/kwin/scripts/shift-tiling/contents/ui/main.qml @@ -795,14 +795,10 @@ Item { } dragDropMode = mode; dragOutlineRect = Qt.rect(rect.x, rect.y, rect.width, rect.height); - KWinComponents.Workspace.showOutline(dragOutlineRect); dragOutlineActive = true; } function clearDragOutline() { - if (dragOutlineActive) { - KWinComponents.Workspace.hideOutline(); - } dragOutlineActive = false; dragDropMode = ""; dragOutlineRect = Qt.rect(0, 0, 0, 0); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1d06a619..84571180 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -30,6 +30,11 @@ if(BASH_EXECUTABLE) COMMAND ${BASH_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/check-preview-qml-stubs.sh ) + add_test( + NAME shift-dynamic-tiles-motion + COMMAND ${BASH_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/check-dynamic-tiles-motion.sh + ) + add_test( NAME shift-initialstart-metadata COMMAND ${BASH_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/check-shift-initialstart-metadata.sh diff --git a/tests/check-dynamic-tiles-motion.sh b/tests/check-dynamic-tiles-motion.sh new file mode 100644 index 00000000..254a7699 --- /dev/null +++ b/tests/check-dynamic-tiles-motion.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 Marco Allegretti +# SPDX-License-Identifier: GPL-2.0-or-later + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +effect_qml="$repo_root/kwin/effects/shift-tile-preview/contents/ui/main.qml" +effect_metadata="$repo_root/kwin/effects/shift-tile-preview/metadata.json" +effects_cmake="$repo_root/kwin/effects/CMakeLists.txt" +tiling_script="$repo_root/kwin/scripts/shift-tiling/contents/ui/main.qml" +env_config="$repo_root/envmanager/config.h" + +require_line() { + local file="$1" + local needle="$2" + + if ! grep -Fq -- "$needle" "$file"; then + echo "Missing dynamic tile motion invariant in ${file#$repo_root/}: $needle" >&2 + exit 1 + fi +} + +reject_line() { + local file="$1" + local needle="$2" + + if grep -Fq -- "$needle" "$file"; then + echo "Rejected dynamic tile motion pattern in ${file#$repo_root/}: $needle" >&2 + exit 1 + fi +} + +require_line "$effects_cmake" "add_kwin_effect(shift-tile-preview shift-tile-preview)" +require_line "$effect_metadata" '"Id": "shift-tile-preview"' +require_line "$effect_metadata" '"X-Plasma-API": "declarativescript"' + +require_line "$env_config" '{"shift-tilingEnabled", convergenceModeEnabled}' +require_line "$env_config" '{"shift-tile-previewEnabled", convergenceModeEnabled}' +require_line "$env_config" '"shift-tile-preview"' +require_line "$env_config" '"shift-tiling"' + +require_line "$effect_qml" "KWinComponents.SceneEffect" +require_line "$effect_qml" "ShellSettings.Settings.dynamicTilingEnabled" +require_line "$effect_qml" "interactiveMoveResizeStarted.connect" +require_line "$effect_qml" "interactiveMoveResizeStepped.connect" +require_line "$effect_qml" "interactiveMoveResizeFinished.connect" +require_line "$effect_qml" "Behavior on x" +require_line "$effect_qml" "Behavior on y" +require_line "$effect_qml" "Behavior on width" +require_line "$effect_qml" "Behavior on height" +require_line "$effect_qml" "showPreview(\"insert\"" +require_line "$effect_qml" "showPreview(\"float\"" +require_line "$effect_qml" "showPreview(\"restore\"" + +reject_line "$tiling_script" "KWinComponents.Workspace.showOutline(dragOutlineRect)" +reject_line "$tiling_script" "KWinComponents.Workspace.hideOutline()" + +printf '%s\n' 'dynamic-tiles-motion-ok' \ No newline at end of file