mirror of
https://invent.kde.org/marcoa/shift-shell.git
synced 2026-06-11 00:47:22 +00:00
Add SHIFT snap-assist KWin effect
Declarative SceneEffect that shows a floating panel of layout presets (halves, thirds, quadrants). Activated via Meta+Shift+S or by the shift-tiling script when the cursor hovers the title-bar controls of the active window for 500 ms. Clicking a preset assigns the active window to the chosen zone with the same outer gap used by the BSP engine; remaining zones are shown as snap-assist drop targets for recently-used windows.
This commit is contained in:
parent
730385d8c8
commit
9364cb29db
4 changed files with 301 additions and 0 deletions
|
|
@ -2,5 +2,6 @@
|
||||||
# SPDX-License-Identifier: LGPL-2.1-or-later
|
# SPDX-License-Identifier: LGPL-2.1-or-later
|
||||||
|
|
||||||
add_subdirectory(scripts)
|
add_subdirectory(scripts)
|
||||||
|
add_subdirectory(effects)
|
||||||
add_subdirectory(decorations)
|
add_subdirectory(decorations)
|
||||||
add_subdirectory(mobiletaskswitcher)
|
add_subdirectory(mobiletaskswitcher)
|
||||||
10
kwin/effects/CMakeLists.txt
Normal file
10
kwin/effects/CMakeLists.txt
Normal file
|
|
@ -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)
|
||||||
270
kwin/effects/shift-snap-assist/contents/ui/main.qml
Normal file
270
kwin/effects/shift-snap-assist/contents/ui/main.qml
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
kwin/effects/shift-snap-assist/metadata.json
Normal file
20
kwin/effects/shift-snap-assist/metadata.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue