shift-shell/kwin/effects/shift-tile-preview/contents/ui/main.qml
Marco Allegretti f09a1db84a 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.
2026-05-27 21:33:34 +02:00

241 lines
No EOL
7.3 KiB
QML

// 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: false
readonly property int outerGap: 8
readonly property int floatEscapeMargin: 32
property var dragConnectedWindows: ({})
property var draggingWindow: null
property rect dragSourceGeometry: Qt.rect(0, 0, 0, 0)
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) {
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);
previewMode = "";
previewScreenName = "";
previewGeometry = Qt.rect(0, 0, 0, 0);
KWinComponents.Workspace.hideOutline();
}
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]);
}
}
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();
}
}
}
Component.onCompleted: connectExistingWindows()
}