mirror of
https://invent.kde.org/marcoa/shift-shell.git
synced 2026-06-11 00:47:22 +00:00
Add dynamic tile drag preview
This commit is contained in:
parent
6df3bd6ff5
commit
e0e51d7ffd
7 changed files with 474 additions and 7 deletions
|
|
@ -59,7 +59,9 @@ QMap<QString, QMap<QString, QVariant>> 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<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"};
|
||||
const QList<QString> KWIN_SCRIPTS = {"convergentwindows"};
|
||||
const QList<QString> KWIN_EFFECTS = {"blur", "mobiletaskswitcher", "overview", "screenedge", "shift-tile-preview"};
|
||||
const QList<QString> KWIN_SCRIPTS = {"convergentwindows", "shift-tiling"};
|
||||
|
||||
// .config/plasma-mobile/ksmserver - immutable settings:
|
||||
const QMap<QString, QMap<QString, QVariant>> KSMSERVER_SETTINGS = {{"General", {{"loginMode", "emptySession"}}}};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
383
kwin/effects/shift-tile-preview/contents/ui/main.qml
Normal file
383
kwin/effects/shift-tile-preview/contents/ui/main.qml
Normal file
|
|
@ -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()
|
||||
}
|
||||
20
kwin/effects/shift-tile-preview/metadata.json
Normal file
20
kwin/effects/shift-tile-preview/metadata.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
60
tests/check-dynamic-tiles-motion.sh
Normal file
60
tests/check-dynamic-tiles-motion.sh
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
#!/usr/bin/env bash
|
||||
# SPDX-FileCopyrightText: 2026 Marco Allegretti <mightymarco4@gmail.com>
|
||||
# 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'
|
||||
Loading…
Reference in a new issue