From f09a1db84ae4c0ba419eb789f65cc55d4a94721e Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Wed, 27 May 2026 21:33:34 +0200 Subject: [PATCH] Animate dynamic tiling window moves Add a KWin JavaScript effect for compositor-side size and translation transitions, and enable it in convergence mode. Keep the tile preview effect as an outline driver so nested preview does not activate a SceneEffect surface. --- envmanager/config.h | 19 +-- kwin/effects/CMakeLists.txt | 1 + .../contents/code/main.js | 155 ++++++++++++++++++ .../shift-tile-animations/metadata.json | 20 +++ .../shift-tile-preview/contents/ui/main.qml | 118 ++----------- tests/check-dynamic-tiles-motion.sh | 26 ++- 6 files changed, 221 insertions(+), 118 deletions(-) create mode 100644 kwin/effects/shift-tile-animations/contents/code/main.js create mode 100644 kwin/effects/shift-tile-animations/metadata.json diff --git a/envmanager/config.h b/envmanager/config.h index d26c39f9..8901a703 100644 --- a/envmanager/config.h +++ b/envmanager/config.h @@ -54,15 +54,14 @@ QMap> getKwinrcSettings(KSharedConfig::Ptr m_mo {"ElectricBorderTiling", convergenceModeEnabled} // drag to left/right edges to tile half-screen in convergence mode }}, {"Plugins", - { - {"blurEnabled", false}, // disable blur for performance reasons, we could reconsider in the future for more powerful devices - {"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) - {"shift-tilingEnabled", convergenceModeEnabled}, - {"shift-tile-previewEnabled", convergenceModeEnabled} - }}, + {{"blurEnabled", false}, // disable blur for performance reasons, we could reconsider in the future for more powerful devices + {"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) + {"shift-tile-animationsEnabled", convergenceModeEnabled}, + {"shift-tilingEnabled", convergenceModeEnabled}, + {"shift-tile-previewEnabled", convergenceModeEnabled}}}, {"Wayland", { {"VirtualKeyboardEnabled", true} // enable vkbd @@ -80,7 +79,7 @@ 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", "shift-tile-preview"}; +const QList KWIN_EFFECTS = {"blur", "mobiletaskswitcher", "overview", "screenedge", "shift-tile-animations", "shift-tile-preview"}; const QList KWIN_SCRIPTS = {"convergentwindows", "shift-tiling"}; // .config/plasma-mobile/ksmserver - immutable settings: diff --git a/kwin/effects/CMakeLists.txt b/kwin/effects/CMakeLists.txt index 91c3c7fd..4138e084 100644 --- a/kwin/effects/CMakeLists.txt +++ b/kwin/effects/CMakeLists.txt @@ -8,4 +8,5 @@ function(add_kwin_effect name source) endfunction() add_kwin_effect(shift-snap-assist shift-snap-assist) +add_kwin_effect(shift-tile-animations shift-tile-animations) add_kwin_effect(shift-tile-preview shift-tile-preview) diff --git a/kwin/effects/shift-tile-animations/contents/code/main.js b/kwin/effects/shift-tile-animations/contents/code/main.js new file mode 100644 index 00000000..793a19cd --- /dev/null +++ b/kwin/effects/shift-tile-animations/contents/code/main.js @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 +/*global effect, effects, animate, animationTime, cancel, Effect, QEasingCurve */ + +"use strict"; + +class ShiftTileAnimationsEffect { + constructor() { + effect.configChanged.connect(this.loadConfig.bind(this)); + effect.animationEnded.connect(this.cleanupWindow.bind(this)); + effects.windowAdded.connect(this.manage.bind(this)); + effects.windowClosed.connect(this.cleanupWindow.bind(this)); + + this.loadConfig(); + + for (const window of effects.stackingOrder) { + this.manage(window); + } + } + + loadConfig() { + this.baseDuration = effect.readConfig("Duration", 185) || 185; + this.minimumDelta = effect.readConfig("MinimumDelta", 3) || 3; + } + + manage(window) { + if (!window || window.shiftTileAnimationsManaged) { + return; + } + + window.shiftTileAnimationsManaged = true; + window.shiftTileUserMoveResize = false; + window.windowFrameGeometryChanged.connect(this.onWindowFrameGeometryChanged.bind(this)); + window.windowStartUserMovedResized.connect(this.onWindowStartUserMovedResized.bind(this)); + window.windowFinishUserMovedResized.connect(this.onWindowFinishUserMovedResized.bind(this)); + } + + eligibleWindow(window) { + return window + && !effects.hasActiveFullScreenEffect + && window.visible + && !window.deleted + && window.managed + && window.normalWindow + && !window.fullScreen + && !window.desktopWindow + && !window.dock + && !window.popup + && !window.popupWindow + && !window.outline; + } + + validGeometry(geometry) { + return geometry && geometry.width > 0 && geometry.height > 0; + } + + movedEnough(oldGeometry, newGeometry) { + return Math.abs(oldGeometry.x - newGeometry.x) >= this.minimumDelta + || Math.abs(oldGeometry.y - newGeometry.y) >= this.minimumDelta + || Math.abs(oldGeometry.width - newGeometry.width) >= this.minimumDelta + || Math.abs(oldGeometry.height - newGeometry.height) >= this.minimumDelta; + } + + durationFor(oldGeometry, newGeometry) { + const oldCenterX = oldGeometry.x + oldGeometry.width / 2; + const oldCenterY = oldGeometry.y + oldGeometry.height / 2; + const newCenterX = newGeometry.x + newGeometry.width / 2; + const newCenterY = newGeometry.y + newGeometry.height / 2; + const distanceX = newCenterX - oldCenterX; + const distanceY = newCenterY - oldCenterY; + const distance = Math.sqrt(distanceX * distanceX + distanceY * distanceY); + const resize = Math.abs(newGeometry.width - oldGeometry.width) + Math.abs(newGeometry.height - oldGeometry.height); + const travelAllowance = Math.min(80, Math.round(Math.max(distance / 12, resize / 18))); + + return animationTime(Math.max(135, Math.min(260, this.baseDuration + travelAllowance))); + } + + cancelWindowAnimation(window) { + if (!window || window.shiftTileMoveAnimation === undefined) { + return; + } + + cancel(window.shiftTileMoveAnimation); + window.shiftTileMoveAnimation = undefined; + window.setData(Effect.WindowForceBlurRole, null); + } + + cleanupWindow(window) { + if (!window) { + return; + } + + window.shiftTileMoveAnimation = undefined; + window.setData(Effect.WindowForceBlurRole, null); + } + + onWindowStartUserMovedResized(window) { + if (!window) { + return; + } + + window.shiftTileUserMoveResize = true; + this.cancelWindowAnimation(window); + } + + onWindowFinishUserMovedResized(window) { + if (window) { + window.shiftTileUserMoveResize = false; + } + } + + onWindowFrameGeometryChanged(window, oldGeometry) { + if (!this.eligibleWindow(window) || window.shiftTileUserMoveResize) { + return; + } + + const newGeometry = window.geometry; + if (!this.validGeometry(oldGeometry) || !this.validGeometry(newGeometry) || !this.movedEnough(oldGeometry, newGeometry)) { + return; + } + + this.cancelWindowAnimation(window); + window.setData(Effect.WindowForceBlurRole, true); + window.shiftTileMoveAnimation = animate({ + window: window, + duration: this.durationFor(oldGeometry, newGeometry), + keepAlive: false, + animations: [{ + type: Effect.Size, + from: { + value1: oldGeometry.width, + value2: oldGeometry.height + }, + to: { + value1: newGeometry.width, + value2: newGeometry.height + }, + curve: QEasingCurve.OutCubic + }, { + type: Effect.Translation, + from: { + value1: oldGeometry.x - newGeometry.x - (newGeometry.width / 2 - oldGeometry.width / 2), + value2: oldGeometry.y - newGeometry.y - (newGeometry.height / 2 - oldGeometry.height / 2) + }, + to: { + value1: 0, + value2: 0 + }, + curve: QEasingCurve.OutCubic + }] + }); + } +} + +new ShiftTileAnimationsEffect(); \ No newline at end of file diff --git a/kwin/effects/shift-tile-animations/metadata.json b/kwin/effects/shift-tile-animations/metadata.json new file mode 100644 index 00000000..52ab5eec --- /dev/null +++ b/kwin/effects/shift-tile-animations/metadata.json @@ -0,0 +1,20 @@ +{ + "KPackageStructure": "KWin/Effect", + "KPlugin": { + "Authors": [ + { + "Email": "marcoa@example.com", + "Name": "Marco Allegretti" + } + ], + "Category": "Appearance", + "Description": "Animates SHIFT convergence window layout moves.", + "EnabledByDefault": false, + "Id": "shift-tile-animations", + "License": "EUPL-1.2", + "Name": "SHIFT Tile Animations", + "Version": "1.0" + }, + "X-KDE-Ordering": 62, + "X-Plasma-API": "javascript" +} \ No newline at end of file diff --git a/kwin/effects/shift-tile-preview/contents/ui/main.qml b/kwin/effects/shift-tile-preview/contents/ui/main.qml index 32490368..60aef776 100644 --- a/kwin/effects/shift-tile-preview/contents/ui/main.qml +++ b/kwin/effects/shift-tile-preview/contents/ui/main.qml @@ -3,7 +3,6 @@ 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 { @@ -13,14 +12,11 @@ KWinComponents.SceneEffect { 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: "" @@ -126,14 +122,26 @@ KWinComponents.SceneEffect { } function showPreview(mode, geometry, screenName) { - hidePreview(); + if (!isActive() || !validRect(geometry)) { + hidePreview(); + return; + } + + previewMode = mode; + previewScreenName = screenName; + previewGeometry = insetPreviewGeometry(geometry); + previewVisible = true; + KWinComponents.Workspace.showOutline(previewGeometry); } function hidePreview() { previewVisible = false; draggingWindow = null; dragSourceGeometry = Qt.rect(0, 0, 0, 0); - disableEffectTimer.restart(); + previewMode = ""; + previewScreenName = ""; + previewGeometry = Qt.rect(0, 0, 0, 0); + KWinComponents.Workspace.hideOutline(); } function updatePreview(window, dragGeometry) { @@ -189,44 +197,6 @@ KWinComponents.SceneEffect { } } - 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 @@ -267,65 +237,5 @@ KWinComponents.SceneEffect { } } - 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() } \ No newline at end of file diff --git a/tests/check-dynamic-tiles-motion.sh b/tests/check-dynamic-tiles-motion.sh index 89f755a3..f6b8f222 100644 --- a/tests/check-dynamic-tiles-motion.sh +++ b/tests/check-dynamic-tiles-motion.sh @@ -8,6 +8,8 @@ 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" +animation_effect_js="$repo_root/kwin/effects/shift-tile-animations/contents/code/main.js" +animation_effect_metadata="$repo_root/kwin/effects/shift-tile-animations/metadata.json" effects_cmake="$repo_root/kwin/effects/CMakeLists.txt" tiling_script="$repo_root/kwin/scripts/shift-tiling/contents/ui/main.qml" decoration_qml="$repo_root/kwin/decorations/org.shift.decoration/contents/ui/main.qml" @@ -36,12 +38,17 @@ reject_line() { } require_line "$effects_cmake" "add_kwin_effect(shift-tile-preview shift-tile-preview)" +require_line "$effects_cmake" "add_kwin_effect(shift-tile-animations shift-tile-animations)" require_line "$effect_metadata" '"Id": "shift-tile-preview"' require_line "$effect_metadata" '"X-Plasma-API": "declarativescript"' +require_line "$animation_effect_metadata" '"Id": "shift-tile-animations"' +require_line "$animation_effect_metadata" '"X-Plasma-API": "javascript"' require_line "$env_config" '{"shift-tilingEnabled", convergenceModeEnabled}' require_line "$env_config" '{"shift-tile-previewEnabled", convergenceModeEnabled}' +require_line "$env_config" '{"shift-tile-animationsEnabled", convergenceModeEnabled}' require_line "$env_config" '"shift-tile-preview"' +require_line "$env_config" '"shift-tile-animations"' require_line "$env_config" '"shift-tiling"' require_line "$effect_qml" "KWinComponents.SceneEffect" @@ -50,13 +57,24 @@ 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(\"swap\"" require_line "$effect_qml" "showPreview(\"float\"" require_line "$effect_qml" "showPreview(\"restore\"" +require_line "$effect_qml" "KWinComponents.Workspace.showOutline(previewGeometry)" +require_line "$effect_qml" "KWinComponents.Workspace.hideOutline()" +reject_line "$effect_qml" "MobileShell.MotionNumberAnimation" +reject_line "$effect_qml" "MobileShell.MotionColorAnimation" +reject_line "$effect_qml" "disableEffectTimer" + +require_line "$animation_effect_js" "class ShiftTileAnimationsEffect" +require_line "$animation_effect_js" "window.windowFrameGeometryChanged.connect" +require_line "$animation_effect_js" "window.windowStartUserMovedResized.connect" +require_line "$animation_effect_js" "window.windowFinishUserMovedResized.connect" +require_line "$animation_effect_js" "animationTime(" +require_line "$animation_effect_js" "type: Effect.Size" +require_line "$animation_effect_js" "type: Effect.Translation" +require_line "$animation_effect_js" "curve: QEasingCurve.OutCubic" +require_line "$animation_effect_js" "window.shiftTileUserMoveResize" require_line "$tiling_script" "readonly property int maxWindowsPerPage: 4" require_line "$tiling_script" "readonly property real stablePrimaryRatio: 0.58"