// 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. Hovering the maximize button in convergence mode while dynamic tiling is off // // When a preset is clicked, the active window's frameGeometry is set to the // chosen zone (with outer gaps applied). import QtQuick import QtQuick.Layouts 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 { id: effect visible: false readonly property bool snapLayoutsEligible: ShellSettings.Settings.convergenceModeEnabled && !ShellSettings.Settings.gamingModeEnabled && !ShellSettings.Settings.dynamicTilingEnabled && ShellSettings.Settings.snapLayoutsEnabled readonly property int hoverBarHeight: 30 readonly property int decorationButtonSize: 16 readonly property int decorationButtonSpacing: 8 readonly property int decorationButtonSideMargin: 12 readonly property int maximizeButtonPadding: 4 readonly property int hoverTimerInterval: 75 readonly property int hoverDwellTicks: 14 readonly property int hoverMoveTolerance: 5 readonly property int hoverCooldownMs: 1200 readonly property int panelDismissMargin: 24 readonly property int panelScreenMargin: 8 readonly property int panelCursorGap: 12 readonly property int panelCursorRightBias: 34 readonly property int hoverAnimationDuration: MobileShell.Motion.duration(MobileShell.Motion.Press) property var hoverWindowId: null property int hoverTicks: 0 property string hoverWindowStateKey: "" property bool hoverSuppressedUntilLeave: false property point hoverAnchorPos: Qt.point(0, 0) property double hoverCooldownUntil: 0 property var panelAnchorPos: Qt.point(0, 0) property string panelAnchorScreenName: "" property bool previewVisible: false property rect previewGeometry: Qt.rect(0, 0, 0, 0) property rect previewArea: Qt.rect(0, 0, 0, 0) property var previewZones: [] property int previewActiveIndex: -1 property string previewScreenName: "" function resetHoverState() { hoverWindowId = null; hoverTicks = 0; hoverWindowStateKey = ""; hoverSuppressedUntilLeave = false; hoverAnchorPos = Qt.point(0, 0); } function resetHoverCandidate(win) { hoverWindowId = win ? win.internalId : null; hoverTicks = 1; hoverWindowStateKey = win ? windowStateKey(win) : ""; hoverSuppressedUntilLeave = false; hoverAnchorPos = KWinComponents.Workspace.cursorPos; } function setHoverCooldown() { hoverCooldownUntil = Date.now() + hoverCooldownMs; } function hoverOnCooldown() { return Date.now() < hoverCooldownUntil; } function hoverMovedTooFar() { const cursor = KWinComponents.Workspace.cursorPos; const dx = cursor.x - hoverAnchorPos.x; const dy = cursor.y - hoverAnchorPos.y; return dx * dx + dy * dy > hoverMoveTolerance * hoverMoveTolerance; } function windowStateKey(win) { const geometry = win.frameGeometry; const maximized = win.maximized === undefined ? "" : win.maximized; return maximized + ":" + geometry.x + "," + geometry.y + "," + geometry.width + "x" + geometry.height; } function hideSnapLayouts() { if (visible) { setHoverCooldown(); } visible = false; resetHoverState(); panelAnchorScreenName = ""; hideSnapPreview(); } function showSnapLayouts() { const win = KWinComponents.Workspace.activeWindow; panelAnchorPos = KWinComponents.Workspace.cursorPos; panelAnchorScreenName = win && win.output ? win.output.name : ""; visible = true; } function toggleActiveWindowMaximized() { const win = KWinComponents.Workspace.activeWindow; if (!win || !win.normalWindow || win.fullScreen || !win.maximizable) { hideSnapLayouts(); return; } const maximize = !win.maximized; win.setMaximize(maximize, maximize); hideSnapLayouts(); setHoverCooldown(); } function hideSnapPreview() { previewVisible = false; previewScreenName = ""; previewZones = []; previewActiveIndex = -1; KWinComponents.Workspace.hideOutline(); } function showSnapPreview(preset, activeIndex) { const win = KWinComponents.Workspace.activeWindow; if (!win || !win.output) { hideSnapPreview(); return; } const desktop = win.desktops.length > 0 ? win.desktops[0] : null; if (!desktop) { hideSnapPreview(); return; } const area = KWinComponents.Workspace.clientArea(KWinComponents.Workspace.MaximizeArea, win.output, desktop); const zone = preset.zones[activeIndex]; const gap = effect.outerGap; previewGeometry = Qt.rect( area.x + Math.round(zone.x * area.width) + gap, area.y + Math.round(zone.y * area.height) + gap, Math.round(zone.w * area.width) - 2 * gap, Math.round(zone.h * area.height) - 2 * gap ); previewArea = area; previewZones = preset.zones; previewActiveIndex = activeIndex; previewScreenName = win.output.name; previewVisible = true; KWinComponents.Workspace.hideOutline(); } function cursorInActiveWindowMaximizeStrip() { const win = KWinComponents.Workspace.activeWindow; if (!win || !win.normalWindow || win.fullScreen || !win.maximizable) { return false; } const cursor = KWinComponents.Workspace.cursorPos; const button = maximizeButtonRect(win); if (button.width <= 0 || button.height <= 0) { return false; } return cursor.x >= button.x - effect.maximizeButtonPadding && cursor.x <= button.x + button.width + effect.maximizeButtonPadding && cursor.y >= button.y - effect.maximizeButtonPadding && cursor.y <= button.y + button.height + effect.maximizeButtonPadding; } function decorationButtonVisible(code) { return code === "M" || code === "N" || code === "I" || code === "A" || code === "X"; } function visibleDecorationButtons(sequence) { const buttons = []; for (let i = 0; i < sequence.length; i++) { const code = sequence[i]; if (decorationButtonVisible(code)) { buttons.push(code); } } return buttons; } function maximizeButtonRect(win) { const fg = win.frameGeometry; const buttonY = fg.y + Math.round((effect.hoverBarHeight - effect.decorationButtonSize) / 2); const leftButtons = visibleDecorationButtons(ShellSettings.KWinSettings.titleButtonsOnLeft); const leftIndex = leftButtons.indexOf("A"); if (leftIndex >= 0) { return Qt.rect( fg.x + effect.decorationButtonSideMargin + leftIndex * (effect.decorationButtonSize + effect.decorationButtonSpacing), buttonY, effect.decorationButtonSize, effect.decorationButtonSize ); } const rightButtons = visibleDecorationButtons(ShellSettings.KWinSettings.titleButtonsOnRight); const rightIndex = rightButtons.indexOf("A"); if (rightIndex >= 0) { const rowWidth = rightButtons.length * effect.decorationButtonSize + Math.max(0, rightButtons.length - 1) * effect.decorationButtonSpacing; return Qt.rect( fg.x + fg.width - effect.decorationButtonSideMargin - rowWidth + rightIndex * (effect.decorationButtonSize + effect.decorationButtonSpacing), buttonY, effect.decorationButtonSize, effect.decorationButtonSize ); } return Qt.rect(0, 0, 0, 0); } function cursorInPanel(screen) { if (!screen) { return false; } if (panelAnchorScreenName !== "" && panelAnchorScreenName !== screen.name) { return false; } const cursor = KWinComponents.Workspace.cursorPos; const panel = panelRect(screen); return cursor.x >= panel.x - panelDismissMargin && cursor.x <= panel.x + panel.width + panelDismissMargin && cursor.y >= panel.y - panelDismissMargin && cursor.y <= panel.y + panel.height + panelDismissMargin; } function panelRect(screen) { const minX = screen.geometry.x + panelScreenMargin; const maxX = screen.geometry.x + screen.geometry.width - snapPanelWidth - panelScreenMargin; const minY = screen.geometry.y + panelScreenMargin; const maxY = screen.geometry.y + screen.geometry.height - snapPanelHeight - panelScreenMargin; const wantedX = panelAnchorPos.x - snapPanelWidth + panelCursorRightBias; const wantedY = panelAnchorPos.y + panelCursorGap; return Qt.rect( Math.max(minX, Math.min(maxX, wantedX)), Math.max(minY, Math.min(maxY, wantedY)), snapPanelWidth, snapPanelHeight ); } // ── 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: { if (!effect.snapLayoutsEligible) { effect.hideSnapLayouts(); return; } if (effect.visible) { effect.hideSnapLayouts(); } else { effect.showSnapLayouts(); } } } KWinComponents.ShortcutHandler { name: "SHIFT Snap Assist Escape" text: "SHIFT Snap Assist: Hide snap layout picker" sequence: "Esc" onActivated: effect.hideSnapLayouts() } Timer { id: hoverTimer interval: effect.hoverTimerInterval repeat: true running: effect.snapLayoutsEligible onTriggered: { if (effect.visible) { const screen = KWinComponents.Workspace.activeWindow ? KWinComponents.Workspace.activeWindow.output : null; if (!effect.cursorInActiveWindowMaximizeStrip() && !effect.cursorInPanel(screen)) { effect.hideSnapLayouts(); } else { effect.resetHoverState(); } return; } const win = KWinComponents.Workspace.activeWindow; if (!win || !win.normalWindow || win.fullScreen || !win.maximizable) { effect.resetHoverState(); return; } if (!effect.cursorInActiveWindowMaximizeStrip()) { effect.resetHoverState(); return; } if (effect.hoverOnCooldown()) { effect.resetHoverState(); return; } if (effect.hoverWindowId !== win.internalId) { effect.resetHoverCandidate(win); return; } if (effect.hoverMovedTooFar()) { effect.resetHoverCandidate(win); return; } const stateKey = effect.windowStateKey(win); if (stateKey !== effect.hoverWindowStateKey) { effect.hoverWindowStateKey = stateKey; effect.hoverTicks = 0; effect.hoverSuppressedUntilLeave = true; effect.setHoverCooldown(); return; } if (effect.hoverSuppressedUntilLeave) { return; } effect.hoverTicks++; if (effect.hoverTicks >= effect.hoverDwellTicks) { effect.showSnapLayouts(); effect.resetHoverState(); } } } Connections { target: KWinComponents.Workspace function onActiveWindowChanged() { effect.resetHoverState(); } } Connections { target: ShellSettings.Settings function onConvergenceModeEnabledChanged() { if (!effect.snapLayoutsEligible) { effect.hideSnapLayouts(); } } function onGamingModeEnabledChanged() { if (!effect.snapLayoutsEligible) { effect.hideSnapLayouts(); } } function onDynamicTilingEnabledChanged() { if (!effect.snapLayoutsEligible) { effect.hideSnapLayouts(); } } function onSnapLayoutsEnabledChanged() { if (!effect.snapLayoutsEligible) { effect.hideSnapLayouts(); } } } // ── 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 currently visual-only. 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 } ] } ] readonly property int snapButtonWidth: 58 readonly property int snapButtonHeight: 38 readonly property int snapButtonSpacing: 8 readonly property int snapPanelHorizontalPadding: 28 readonly property int snapPanelVerticalPadding: 34 readonly property int snapPanelWidth: presets.length * snapButtonWidth + Math.max(0, presets.length - 1) * snapButtonSpacing + snapPanelHorizontalPadding readonly property int snapPanelHeight: snapButtonHeight + snapPanelVerticalPadding + 23 function accentColor(alpha) { return Qt.rgba(46 / 255, 184 / 255, 168 / 255, alpha); } function accentPaleColor(alpha) { return Qt.rgba(163 / 255, 218 / 255, 212 / 255, alpha); } // 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 readonly property var targetScreen: KWinComponents.SceneView.screen readonly property rect popupRect: effect.panelRect(targetScreen) color: "transparent" KWinComponents.DesktopBackground { anchors.fill: parent z: -100 activity: KWinComponents.Workspace.currentActivity desktop: KWinComponents.Workspace.currentDesktop outputName: screenDelegate.targetScreen.name } Instantiator { model: KWinComponents.WindowFilterModel { desktop: KWinComponents.Workspace.currentDesktop screenName: screenDelegate.targetScreen.name windowModel: stackModel minimizedWindows: false windowType: ~KWinComponents.WindowFilterModel.Desktop & ~KWinComponents.WindowFilterModel.Notification & ~KWinComponents.WindowFilterModel.CriticalNotification } KWinComponents.WindowThumbnail { wId: model.window.internalId x: model.window.x - screenDelegate.targetScreen.geometry.x y: model.window.y - screenDelegate.targetScreen.geometry.y z: model.window.stackingOrder visible: !model.window.hidden } onObjectAdded: (index, object) => { object.parent = screenDelegate } } KWinComponents.WindowModel { id: stackModel } Repeater { model: effect.previewVisible && effect.previewScreenName === screenDelegate.targetScreen.name ? effect.previewZones : [] delegate: Rectangle { required property var modelData required property int index readonly property bool activeZone: index === effect.previewActiveIndex readonly property int previewGap: effect.outerGap x: effect.previewArea.x + Math.round(modelData.x * effect.previewArea.width) + previewGap - screenDelegate.targetScreen.geometry.x y: effect.previewArea.y + Math.round(modelData.y * effect.previewArea.height) + previewGap - screenDelegate.targetScreen.geometry.y width: Math.max(1, Math.round(modelData.w * effect.previewArea.width) - 2 * previewGap) height: Math.max(1, Math.round(modelData.h * effect.previewArea.height) - 2 * previewGap) z: activeZone ? 90001 : 90000 radius: 6 color: activeZone ? effect.accentColor(0.24) : effect.accentPaleColor(0.08) border.width: activeZone ? 2 : 1 border.color: activeZone ? effect.accentPaleColor(0.78) : effect.accentPaleColor(0.26) } } Keys.onEscapePressed: effect.hideSnapLayouts() Item { id: maximizeForwarder readonly property var activeWindow: KWinComponents.Workspace.activeWindow readonly property rect buttonRect: activeWindow ? effect.maximizeButtonRect(activeWindow) : Qt.rect(0, 0, 0, 0) visible: activeWindow && activeWindow.output && activeWindow.output.name === screenDelegate.targetScreen.name && buttonRect.width > 0 && buttonRect.height > 0 z: 100001 x: buttonRect.x - effect.maximizeButtonPadding - screenDelegate.targetScreen.geometry.x y: buttonRect.y - effect.maximizeButtonPadding - screenDelegate.targetScreen.geometry.y width: buttonRect.width + 2 * effect.maximizeButtonPadding height: buttonRect.height + 2 * effect.maximizeButtonPadding TapHandler { acceptedButtons: Qt.LeftButton onTapped: effect.toggleActiveWindowMaximized() } } SnapPanel { id: snapPanel visible: effect.panelAnchorScreenName === "" || effect.panelAnchorScreenName === screenDelegate.targetScreen.name z: 100000 x: screenDelegate.popupRect.x - screenDelegate.targetScreen.geometry.x y: screenDelegate.popupRect.y - screenDelegate.targetScreen.geometry.y screen: KWinComponents.SceneView.screen } } // ── Snap panel component ────────────────────────────────────────────── component SnapPanel: Rectangle { id: panel required property var screen width: effect.snapPanelWidth height: effect.snapPanelHeight color: Qt.rgba(0.08, 0.10, 0.15, 0.82) radius: 8 border.width: 1 border.color: Qt.rgba(1, 1, 1, 0.16) Rectangle { anchors.fill: parent anchors.topMargin: 3 color: Qt.rgba(0, 0, 0, 0.22) radius: parent.radius z: -1 } Column { anchors { fill: parent; margins: 14 } spacing: 10 Text { text: "Snap layouts" color: effect.accentPaleColor(0.9) font.pixelSize: 10 font.capitalization: Font.AllUppercase } Row { id: presetsRow spacing: effect.snapButtonSpacing Repeater { model: effect.presets delegate: PresetButton { required property var modelData preset: modelData screen: panel.screen onClicked: { effect.hideSnapLayouts(); } } } } } } // ── Preset button ───────────────────────────────────────────────────── component PresetButton: Rectangle { id: btn required property var preset required property var screen signal clicked width: effect.snapButtonWidth height: effect.snapButtonHeight readonly property int previewMargin: 6 color: hovered ? Qt.rgba(1, 1, 1, 0.18) : Qt.rgba(1, 1, 1, 0.08) radius: 6 border.width: 1 border.color: hovered ? effect.accentPaleColor(0.62) : Qt.rgba(1, 1, 1, 0.14) property bool hovered: false Behavior on color { MobileShell.MotionColorAnimation { type: MobileShell.Motion.Press; duration: effect.hoverAnimationDuration } } Rectangle { id: previewFrame anchors.fill: parent anchors.margins: btn.previewMargin color: Qt.rgba(0.06, 0.08, 0.12, 0.72) radius: 4 border.width: 1 border.color: Qt.rgba(1, 1, 1, 0.08) clip: true Repeater { model: btn.preset.zones delegate: Rectangle { required property var modelData required property int index x: Math.round(modelData.x * previewFrame.width) y: Math.round(modelData.y * previewFrame.height) width: Math.max(4, Math.round(modelData.w * previewFrame.width) - 1) height: Math.max(4, Math.round(modelData.h * previewFrame.height) - 1) color: zoneHover.hovered ? effect.accentPaleColor(0.96) : (index === 0 ? effect.accentColor(0.96) : effect.accentPaleColor(0.48)) border.width: 0 radius: 2 HoverHandler { id: zoneHover onHoveredChanged: { btn.hovered = hovered; if (hovered) { effect.showSnapPreview(btn.preset, index); } } } TapHandler { onTapped: btn.applyZone(modelData) } } } } HoverHandler { onHoveredChanged: { if (!hovered) { btn.hovered = false; effect.hideSnapPreview(); } } } function applyZone(zone) { 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); effect.hideSnapPreview(); effect.applyZone(win, zone, area); btn.clicked(); } } }