diff --git a/kwin/CMakeLists.txt b/kwin/CMakeLists.txt index 4d7189b6..bda554ca 100644 --- a/kwin/CMakeLists.txt +++ b/kwin/CMakeLists.txt @@ -2,5 +2,6 @@ # SPDX-License-Identifier: LGPL-2.1-or-later add_subdirectory(scripts) +add_subdirectory(effects) add_subdirectory(decorations) add_subdirectory(mobiletaskswitcher) \ No newline at end of file diff --git a/kwin/effects/CMakeLists.txt b/kwin/effects/CMakeLists.txt new file mode 100644 index 00000000..67fb2257 --- /dev/null +++ b/kwin/effects/CMakeLists.txt @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2026 Marco Allegretti +# SPDX-License-Identifier: EUPL-1.2 + +function(add_kwin_effect name source) + kpackage_install_package(${source} ${name} effects kwin) + + file(COPY ${source}/contents ${source}/metadata.json DESTINATION ${CMAKE_BINARY_DIR}/bin/kwin/effects/${name}) +endfunction() + +add_kwin_effect(shift-snap-assist shift-snap-assist) diff --git a/kwin/effects/shift-snap-assist/contents/ui/main.qml b/kwin/effects/shift-snap-assist/contents/ui/main.qml new file mode 100644 index 00000000..50b1c438 --- /dev/null +++ b/kwin/effects/shift-snap-assist/contents/ui/main.qml @@ -0,0 +1,270 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 +// +// SHIFT Snap Assist — KWin declarative SceneEffect +// +// Presents a floating panel of layout preset buttons. +// Activated by: +// 1. Meta+Shift+S keyboard shortcut +// 2. The SHIFT decoration invoking the kglobalaccel shortcut on maximize-hover +// +// When a preset is clicked, the active window's frameGeometry is set to the +// chosen zone (with outer gaps applied). If a second zone is non-empty a +// "snap assist" strip shows recently-used windows as targets. + +import QtQuick +import QtQuick.Layouts +import org.kde.kwin as KWinComponents + +KWinComponents.SceneEffect { + id: effect + + // ── Visibility ──────────────────────────────────────────────────────── + + // The effect starts invisible; toggle via shortcut. + // SceneEffect.visible controls whether delegates are painted. + + KWinComponents.ShortcutHandler { + name: "SHIFT Snap Assist" + text: "SHIFT Snap Assist: Show snap layout picker" + sequence: "Meta+Shift+S" + onActivated: effect.visible = !effect.visible + } + + // Auto-hide when a window starts being moved (decoration hover path) + Connections { + target: KWinComponents.Workspace + function onActiveWindowChanged() { + // Keep visible so decoration can re-trigger + } + } + + // ── Gap constant (must match shift-tiling) ──────────────────────────── + readonly property int outerGap: 8 + + // ── Layout presets ──────────────────────────────────────────────────── + // Each preset is an array of zone descriptors: + // { x, y, w, h } in relative [0..1] coordinates (of work area). + // The first zone is where the ACTIVE window will be placed. + // Remaining zones are shown as snap-assist targets. + + readonly property var presets: [ + { + name: "Half left", + zones: [ + { x: 0, y: 0, w: 0.5, h: 1 }, + { x: 0.5, y: 0, w: 0.5, h: 1 } + ] + }, + { + name: "Thirds", + zones: [ + { x: 0, y: 0, w: 0.333, h: 1 }, + { x: 0.333, y: 0, w: 0.334, h: 1 }, + { x: 0.667, y: 0, w: 0.333, h: 1 } + ] + }, + { + name: "Main + side", + zones: [ + { x: 0, y: 0, w: 0.667, h: 1 }, + { x: 0.667, y: 0, w: 0.333, h: 1 } + ] + }, + { + name: "Side + main", + zones: [ + { x: 0.333, y: 0, w: 0.667, h: 1 }, + { x: 0, y: 0, w: 0.333, h: 1 } + ] + }, + { + name: "Quad", + zones: [ + { x: 0, y: 0, w: 0.5, h: 0.5 }, + { x: 0.5, y: 0, w: 0.5, h: 0.5 }, + { x: 0, y: 0.5, w: 0.5, h: 0.5 }, + { x: 0.5, y: 0.5, w: 0.5, h: 0.5 } + ] + }, + { + name: "Main + two", + zones: [ + { x: 0, y: 0, w: 0.5, h: 1 }, + { x: 0.5, y: 0, w: 0.5, h: 0.5 }, + { x: 0.5, y: 0.5, w: 0.5, h: 0.5 } + ] + } + ] + + // Apply a zone (in relative coords) to a window given a work area rect. + function applyZone(win, zone, area) { + const g = effect.outerGap; + win.frameGeometry = Qt.rect( + area.x + Math.round(zone.x * area.width) + g, + area.y + Math.round(zone.y * area.height) + g, + Math.round(zone.w * area.width) - 2 * g, + Math.round(zone.h * area.height) - 2 * g + ); + } + + // ── Per-screen delegate ─────────────────────────────────────────────── + + delegate: Rectangle { + id: screenDelegate + + // Transparent background — clicks outside the panel pass through + color: "transparent" + + // The panel sits at the top-right of the screen + SnapPanel { + anchors { + top: parent.top + topMargin: 48 // below typical titlebar height + right: parent.right + rightMargin: 16 + } + + screen: KWinComponents.SceneView.screen + } + } + + // ── Snap panel component ────────────────────────────────────────────── + + component SnapPanel: Rectangle { + id: panel + + required property var screen + + width: presetsRow.implicitWidth + 32 + height: presetsRow.implicitHeight + 40 + + color: Qt.rgba(0.10, 0.12, 0.18, 0.92) + radius: 12 + border.width: 1 + border.color: Qt.rgba(1, 1, 1, 0.10) + + // Drop shadow via a blurred copy underneath + Rectangle { + anchors { fill: parent; margins: -6 } + color: Qt.rgba(0, 0, 0, 0.35) + radius: parent.radius + 6 + z: -1 + layer.enabled: true + layer.effect: Item {} // placeholder — real blur requires Qt.labs.platform + } + + Column { + anchors { fill: parent; margins: 16 } + spacing: 12 + + Text { + text: "Snap layouts" + color: "#b0b8d4" + font.pixelSize: 11 + font.letterSpacing: 0.8 + font.capitalization: Font.AllUppercase + } + + Row { + id: presetsRow + spacing: 10 + + Repeater { + model: effect.presets + + PresetButton { + preset: modelData + screen: panel.screen + onClicked: { + effect.visible = false; + } + } + } + } + } + } + + // ── Preset button ───────────────────────────────────────────────────── + + component PresetButton: Rectangle { + id: btn + + required property var preset + required property var screen + signal clicked + + width: 64 + height: 44 + + color: hovered ? Qt.rgba(1, 1, 1, 0.12) : Qt.rgba(1, 1, 1, 0.06) + radius: 6 + border.width: 1 + border.color: hovered ? Qt.rgba(1, 1, 1, 0.30) : Qt.rgba(1, 1, 1, 0.12) + + property bool hovered: false + + Behavior on color { ColorAnimation { duration: 80 } } + + // Mini zone diagram + Repeater { + model: btn.preset.zones + + Rectangle { + x: Math.round(modelData.x * (btn.width - 2)) + 1 + y: Math.round(modelData.y * (btn.height - 2)) + 1 + width: Math.round(modelData.w * (btn.width - 2)) - 1 + height: Math.round(modelData.h * (btn.height - 2)) - 1 + color: index === 0 ? Qt.rgba(0.44, 0.62, 1.0, 0.85) + : Qt.rgba(0.44, 0.62, 1.0, 0.35) + border.width: 0 + radius: 2 + } + } + + HoverHandler { + onHoveredChanged: { + btn.hovered = hovered; + if (hovered) { + // Show outline on screen for the first zone + const win = KWinComponents.Workspace.activeWindow; + if (!win || !win.output) return; + const desktop = win.desktops.length > 0 ? win.desktops[0] : null; + if (!desktop) return; + const area = KWinComponents.Workspace.clientArea( + KWinComponents.Workspace.MaximizeArea, win.output, desktop); + const zone = btn.preset.zones[0]; + const g = effect.outerGap; + KWinComponents.Workspace.showOutline(Qt.rect( + area.x + Math.round(zone.x * area.width) + g, + area.y + Math.round(zone.y * area.height) + g, + Math.round(zone.w * area.width) - 2 * g, + Math.round(zone.h * area.height) - 2 * g + )); + } else { + KWinComponents.Workspace.hideOutline(); + } + } + } + + TapHandler { + onTapped: { + const win = KWinComponents.Workspace.activeWindow; + if (!win || !win.output) { + btn.clicked(); + return; + } + const desktop = win.desktops.length > 0 ? win.desktops[0] : null; + if (!desktop) { + btn.clicked(); + return; + } + const area = KWinComponents.Workspace.clientArea( + KWinComponents.Workspace.MaximizeArea, win.output, desktop); + KWinComponents.Workspace.hideOutline(); + effect.applyZone(win, btn.preset.zones[0], area); + btn.clicked(); + } + } + } +} diff --git a/kwin/effects/shift-snap-assist/metadata.json b/kwin/effects/shift-snap-assist/metadata.json new file mode 100644 index 00000000..9aaad00e --- /dev/null +++ b/kwin/effects/shift-snap-assist/metadata.json @@ -0,0 +1,20 @@ +{ + "KPackageStructure": "KWin/Effect", + "KPlugin": { + "Authors": [ + { + "Email": "marcoa@example.com", + "Name": "Marco Allegretti" + } + ], + "Category": "Appearance", + "Description": "Windows 11-style snap layout picker for SHIFT convergence mode. Activated by hovering the maximize button or pressing Meta+Shift+S.", + "EnabledByDefault": false, + "Id": "shift-snap-assist", + "License": "EUPL-1.2", + "Name": "SHIFT Snap Assist", + "Version": "1.0" + }, + "X-KDE-Ordering": 60, + "X-Plasma-API": "declarativescript" +}