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.
This commit is contained in:
Marco Allegretti 2026-05-27 21:33:34 +02:00
parent 495f76900f
commit f09a1db84a
6 changed files with 221 additions and 118 deletions

View file

@ -54,15 +54,14 @@ QMap<QString, QMap<QString, QVariant>> 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
{{"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}
}},
{"shift-tile-previewEnabled", convergenceModeEnabled}}},
{"Wayland",
{
{"VirtualKeyboardEnabled", true} // enable vkbd
@ -80,7 +79,7 @@ QMap<QString, QMap<QString, QVariant>> 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<QString> KWIN_EFFECTS = {"blur", "mobiletaskswitcher", "overview", "screenedge", "shift-tile-preview"};
const QList<QString> KWIN_EFFECTS = {"blur", "mobiletaskswitcher", "overview", "screenedge", "shift-tile-animations", "shift-tile-preview"};
const QList<QString> KWIN_SCRIPTS = {"convergentwindows", "shift-tiling"};
// .config/plasma-mobile/ksmserver - immutable settings:

View file

@ -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)

View file

@ -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();

View file

@ -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"
}

View file

@ -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) {
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()
}

View file

@ -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"