From 3fbf68d56b0930b76f6ed6983738e52b56607700 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Sun, 10 May 2026 09:32:33 +0200 Subject: [PATCH] Refine Dynamic Tiling drag zones Use edge insert zones for drag-and-drop reordering, keep drag signal connections stable across window creation, and consume task-menu tiling requests through the script settings bridge. --- .../scripts/shift-tiling/contents/ui/main.qml | 363 ++++++++++++++---- 1 file changed, 293 insertions(+), 70 deletions(-) diff --git a/kwin/scripts/shift-tiling/contents/ui/main.qml b/kwin/scripts/shift-tiling/contents/ui/main.qml index 3e136cac..3c8b5c35 100644 --- a/kwin/scripts/shift-tiling/contents/ui/main.qml +++ b/kwin/scripts/shift-tiling/contents/ui/main.qml @@ -23,6 +23,7 @@ Item { // ── Configuration ─────────────────────────────────────────────────────── readonly property int outerGap: 8 readonly property int innerGap: 8 // half applied to each edge → 4px per tile + readonly property int insertZoneThickness: 48 // ── State ─────────────────────────────────────────────────────────────── @@ -33,24 +34,31 @@ Item { // Windows the user has manually floated (by UUID string). property var floatedWindows: ({}) - // Whether tiling is globally enabled. - property bool tilingEnabled: true + // Windows whose interactive move/resize signals are already connected. + property var dragConnectedWindows: ({}) + + property int lastWindowRequestSerial: ShellSettings.Settings.dynamicTilingWindowRequestSerial // Drag state. // - // Behaviour: dragging a tile and dropping it onto ANOTHER tile swaps - // their positions in the BSP layout. Dropping anywhere else does - // nothing (the window will be re-tiled into its original slot on the - // next layout pass, unless KWin's native quick-tile / electric border - // takes over — which is fine; we don't fight it). + // Behaviour: dragging a tile and dropping it onto ANOTHER tile swaps or + // inserts based on the cursor zone. Dropping inside the work area but + // outside another tile restores the original slot. Dropping outside the + // work area floats the window, giving users a visible escape hatch from + // the tiled layout. property var draggingWindow: null - property var swapOutlineActive: false + property bool dragOutlineActive: false + property string dragDropMode: "" // "swap", "insert", "restore", or "float" + property rect dragOutlineRect: Qt.rect(0, 0, 0, 0) // Reorder state — kept stable while dragging so the rest of the layout // doesn't shuffle under the cursor. property string dragSourceScreen: "" property int dragSourceIndex: -1 + property rect dragSourceRect: Qt.rect(0, 0, 0, 0) property var dragSwapTarget: null // {screen, index, rect} of tile under cursor + property var dragInsertTarget: null // {screen, index, insertIndex, rect} preview slot + readonly property int floatEscapeMargin: 32 // Deferred retile queue. // The dockSpaceReserver LayerShell exclusive zone needs one Wayland @@ -118,12 +126,11 @@ Item { if (minW > 0 && maxW > 0 && minW >= maxW) return true; if (minH > 0 && maxH > 0 && minH >= maxH) return true; // Manually floated - if (floatedWindows[win.internalId]) return true; + if (floatedWindows[windowKey(win)]) return true; return false; } function isTileable(win) { - if (!tilingEnabled) return false; if (!isConvergence()) return false; if (shouldIgnore(win)) return false; if (shouldFloat(win)) return false; @@ -240,6 +247,8 @@ Item { function addWindow(win) { if (!isTileable(win)) return; + connectDragHandlers(win); + const output = win.output; if (!output) return; const name = output.name; @@ -251,7 +260,7 @@ Item { // Avoid duplicates const tiles = screenTiles[name]; for (let i = 0; i < tiles.length; i++) { - if (tiles[i].win.internalId === win.internalId) return; + if (windowKey(tiles[i].win) === windowKey(win)) return; } tiles.push({ win: win, rect: Qt.rect(0, 0, 0, 0) }); @@ -275,7 +284,7 @@ Item { for (const sName in screenTiles) { const tiles = screenTiles[sName]; for (let i = 0; i < tiles.length; i++) { - if (tiles[i].win.internalId === win.internalId) { + if (windowKey(tiles[i].win) === windowKey(win)) { tiles.splice(i, 1); screenTiles[sName] = tiles; retileScreen(sName); @@ -285,6 +294,64 @@ Item { } } + function windowKey(win) { + return win && win.internalId !== undefined ? String(win.internalId) : ""; + } + + function findWindowById(windowId) { + const wanted = String(windowId); + const wins = KWinComponents.Workspace.windows; + for (let i = 0; i < wins.length; i++) { + if (windowKey(wins[i]) === wanted) { + return wins[i]; + } + } + return null; + } + + function floatWindow(win) { + const key = windowKey(win); + if (!key) return; + floatedWindows[key] = true; + removeWindow(win); + } + + function tileWindow(win) { + const key = windowKey(win); + if (!key) return; + delete floatedWindows[key]; + if (isTileable(win)) { + addWindow(win); + } + } + + function connectDragHandlers(win) { + const key = windowKey(win); + if (!key || dragConnectedWindows[key]) return; + + dragConnectedWindows[key] = true; + win.interactiveMoveResizeStarted.connect(function() { root.onDragStart(win); }); + win.interactiveMoveResizeStepped.connect(function(geo) { root.onDragStep(win, geo); }); + win.interactiveMoveResizeFinished.connect(function() { root.onDragEnd(win); }); + } + + function handleWindowTilingRequest() { + const serial = ShellSettings.Settings.dynamicTilingWindowRequestSerial; + if (serial === lastWindowRequestSerial) return; + lastWindowRequestSerial = serial; + + if (!isConvergence()) return; + const win = findWindowById(ShellSettings.Settings.dynamicTilingWindowRequestId); + if (!win || shouldIgnore(win)) return; + + const action = ShellSettings.Settings.dynamicTilingWindowRequestAction; + if (action === "float") { + floatWindow(win); + } else if (action === "tile") { + tileWindow(win); + } + } + // ── Keyboard navigation helpers ────────────────────────────────────────── function centreOf(rect) { @@ -306,7 +373,7 @@ Item { for (let i = 0; i < tiles.length; i++) { const t = tiles[i]; - if (t.win.internalId === fromWin.internalId) continue; + if (windowKey(t.win) === windowKey(fromWin)) continue; const tc = centreOf(t.rect); const dx = tc.x - fc.x; const dy = tc.y - fc.y; @@ -336,13 +403,12 @@ Item { function onWindowAdded(win) { if (isTileable(win)) { addWindow(win); - win.interactiveMoveResizeStarted.connect(function() { root.onDragStart(win); }); - win.interactiveMoveResizeStepped.connect(function(geo) { root.onDragStep(win, geo); }); - win.interactiveMoveResizeFinished.connect(function() { root.onDragEnd(win); }); } } function onWindowRemoved(win) { + const key = root.windowKey(win); + if (key) delete root.dragConnectedWindows[key]; root.removeWindow(win); } } @@ -385,6 +451,10 @@ Item { screenTiles = {}; } } + + function onDynamicTilingWindowRequestChanged() { + root.handleWindowTilingRequest(); + } } // ── Drag handlers ───────────────────────────────────────────────────── @@ -394,7 +464,7 @@ Item { for (const sName in screenTiles) { const tiles = screenTiles[sName]; for (let i = 0; i < tiles.length; i++) { - if (tiles[i].win && tiles[i].win.internalId === win.internalId) { + if (tiles[i].win && windowKey(tiles[i].win) === windowKey(win)) { return { screen: sName, index: i }; } } @@ -408,7 +478,7 @@ Item { const tiles = screenTiles[sName]; for (let i = 0; i < tiles.length; i++) { const t = tiles[i]; - if (ignoreWin && t.win && t.win.internalId === ignoreWin.internalId) continue; + if (ignoreWin && t.win && windowKey(t.win) === windowKey(ignoreWin)) continue; const r = t.rect; if (!r || r.width <= 0 || r.height <= 0) continue; if (cursor.x >= r.x && cursor.x <= r.x + r.width && @@ -420,60 +490,230 @@ Item { return null; } + function tileInsertPosition(cursor, rect) { + if (!validRect(rect)) return ""; + + const zone = Math.min(insertZoneThickness, Math.max(16, Math.floor(Math.min(rect.width, rect.height) / 4))); + const left = Math.abs(cursor.x - rect.x); + const right = Math.abs((rect.x + rect.width) - cursor.x); + const top = Math.abs(cursor.y - rect.y); + const bottom = Math.abs((rect.y + rect.height) - cursor.y); + const nearest = Math.min(left, right, top, bottom); + + if (nearest > zone) return ""; + if (nearest === left || nearest === top) return "before"; + return "after"; + } + + function insertIndexForTarget(target, position) { + if (!target) return -1; + + let targetIndex = target.index; + if (dragSourceScreen === target.screen && dragSourceIndex >= 0 && dragSourceIndex < targetIndex) { + targetIndex--; + } + return position === "before" ? targetIndex : targetIndex + 1; + } + + function previewInsertRect(win, target, position) { + if (!target || !target.rect || dragSourceIndex < 0) return null; + + const tiles = screenTiles[target.screen]; + if (!tiles || !tiles[target.index]) return null; + + const area = workRect(tiles[target.index].win || win); + if (!area) return null; + + const count = tiles.length + (dragSourceScreen === target.screen ? 0 : 1); + const insertIndex = insertIndexForTarget(target, position); + if (insertIndex < 0 || insertIndex >= count) return null; + + const rawRects = bspRects(area, count); + const gappedRects = applyGaps(rawRects, area); + return gappedRects[insertIndex] || null; + } + + function insertDraggedWindow(target) { + if (!target || dragSourceIndex < 0 || !dragSourceScreen) return; + + if (dragSourceScreen === target.screen) { + const tiles = screenTiles[dragSourceScreen] ? screenTiles[dragSourceScreen].slice() : null; + if (!tiles || !tiles[dragSourceIndex]) return; + + const node = tiles.splice(dragSourceIndex, 1)[0]; + const insertIndex = Math.max(0, Math.min(target.insertIndex, tiles.length)); + tiles.splice(insertIndex, 0, node); + screenTiles[dragSourceScreen] = tiles; + retileScreen(dragSourceScreen); + return; + } + + const sourceTiles = screenTiles[dragSourceScreen] ? screenTiles[dragSourceScreen].slice() : null; + const targetTiles = screenTiles[target.screen] ? screenTiles[target.screen].slice() : null; + if (!sourceTiles || !targetTiles || !sourceTiles[dragSourceIndex]) return; + + const node = sourceTiles.splice(dragSourceIndex, 1)[0]; + const insertIndex = Math.max(0, Math.min(target.insertIndex, targetTiles.length)); + targetTiles.splice(insertIndex, 0, node); + screenTiles[dragSourceScreen] = sourceTiles; + screenTiles[target.screen] = targetTiles; + retileScreen(dragSourceScreen); + retileScreen(target.screen); + } + + function validRect(rect) { + return rect && rect.width > 0 && rect.height > 0; + } + + function rectContainsPoint(rect, point) { + return validRect(rect) && + point.x >= rect.x && point.x <= rect.x + rect.width && + point.y >= rect.y && point.y <= rect.y + rect.height; + } + + function rectsClose(a, b) { + return Math.abs(a.x - b.x) < 2 && + Math.abs(a.y - b.y) < 2 && + Math.abs(a.width - b.width) < 2 && + Math.abs(a.height - b.height) < 2; + } + + function outsideWorkArea(win, cursor) { + const area = workRect(win); + if (!area) return false; + const expanded = Qt.rect( + area.x - floatEscapeMargin, + area.y - floatEscapeMargin, + area.width + floatEscapeMargin * 2, + area.height + floatEscapeMargin * 2 + ); + return !rectContainsPoint(expanded, cursor); + } + + function showDragOutline(mode, rect) { + if (!validRect(rect)) { + clearDragOutline(); + return; + } + if (dragOutlineActive && dragDropMode === mode && rectsClose(dragOutlineRect, rect)) { + return; + } + 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); + } + + function resetDragState() { + clearDragOutline(); + dragSwapTarget = null; + dragInsertTarget = null; + dragSourceScreen = ""; + dragSourceIndex = -1; + dragSourceRect = Qt.rect(0, 0, 0, 0); + draggingWindow = null; + } + + function updateDragIntent(win, geo) { + const cursor = KWinComponents.Workspace.cursorPos; + const target = findTileAtCursor(cursor, win); + + if (target) { + const position = tileInsertPosition(cursor, target.rect); + const insertRect = position !== "" ? previewInsertRect(win, target, position) : null; + if (validRect(insertRect)) { + dragSwapTarget = null; + dragInsertTarget = { + screen: target.screen, + index: target.index, + insertIndex: insertIndexForTarget(target, position), + rect: insertRect, + position: position + }; + showDragOutline("insert", insertRect); + return; + } + + dragSwapTarget = target; + dragInsertTarget = null; + showDragOutline("swap", target.rect); + return; + } + + dragSwapTarget = null; + dragInsertTarget = null; + if (outsideWorkArea(win, cursor)) { + showDragOutline("float", validRect(geo) ? geo : win.frameGeometry); + } else { + showDragOutline("restore", dragSourceRect); + } + } + function onDragStart(win) { if (!isConvergence()) return; draggingWindow = win; - swapOutlineActive = false; + clearDragOutline(); dragSwapTarget = null; + dragInsertTarget = null; + dragSourceRect = Qt.rect(0, 0, 0, 0); - // Remember the source slot so we can swap on drop. + // Remember the source slot so we can swap or insert on drop. // The tile stays in screenTiles[] during the drag so the rest of // the layout doesn't shuffle. const slot = findTileSlot(win); if (slot) { dragSourceScreen = slot.screen; dragSourceIndex = slot.index; + const tiles = screenTiles[slot.screen]; + if (tiles && tiles[slot.index]) { + const rect = tiles[slot.index].rect; + dragSourceRect = Qt.rect(rect.x, rect.y, rect.width, rect.height); + } } else { dragSourceScreen = ""; dragSourceIndex = -1; } + + showDragOutline("restore", dragSourceRect); } function onDragStep(win, geo) { - if (!isConvergence()) return; - if (draggingWindow !== win) return; - - // Only show an outline when the cursor is over another tile — - // a clear visual hint that "drop here = swap". - const cursor = KWinComponents.Workspace.cursorPos; - const target = findTileAtCursor(cursor, win); - - if (target) { - if (!dragSwapTarget || - dragSwapTarget.screen !== target.screen || - dragSwapTarget.index !== target.index) { - dragSwapTarget = target; - KWinComponents.Workspace.showOutline(target.rect); - swapOutlineActive = true; - } - } else { - dragSwapTarget = null; - if (swapOutlineActive) { - KWinComponents.Workspace.hideOutline(); - swapOutlineActive = false; - } + if (!isConvergence()) { + resetDragState(); + return; } + if (draggingWindow !== win) return; + updateDragIntent(win, geo); } function onDragEnd(win) { - if (!isConvergence()) return; - if (swapOutlineActive) { - KWinComponents.Workspace.hideOutline(); - swapOutlineActive = false; + if (!isConvergence()) { + resetDragState(); + return; + } + + const finalDropMode = dragDropMode; + clearDragOutline(); + + if (finalDropMode === "float") { + floatWindow(win); + } + + else if (finalDropMode === "insert" && dragInsertTarget) { + insertDraggedWindow(dragInsertTarget); } // Dropped on another tile → swap source/target slots. - if (dragSwapTarget && dragSourceScreen && dragSourceIndex >= 0) { + else if (dragSwapTarget && dragSourceScreen && dragSourceIndex >= 0) { const sScreen = dragSourceScreen; const sIdx = dragSourceIndex; const tScreen = dragSwapTarget.screen; @@ -506,10 +746,7 @@ Item { retileScreen(dragSourceScreen); } - dragSwapTarget = null; - dragSourceScreen = ""; - dragSourceIndex = -1; - draggingWindow = null; + resetDragState(); } // ── Keyboard shortcuts ───────────────────────────────────────────────── @@ -568,13 +805,11 @@ Item { onActivated: { const win = KWinComponents.Workspace.activeWindow; if (!win) return; - const id = win.internalId; + const id = root.windowKey(win); if (root.floatedWindows[id]) { - delete root.floatedWindows[id]; - root.addWindow(win); + root.tileWindow(win); } else { - root.floatedWindows[id] = true; - root.removeWindow(win); + root.floatWindow(win); } } } @@ -585,21 +820,12 @@ Item { text: "SHIFT Tiling: Toggle tiling on/off" sequence: "Meta+T" onActivated: { - root.tilingEnabled = !root.tilingEnabled; - if (root.tilingEnabled) { - const wins = KWinComponents.Workspace.windows; - for (let i = 0; i < wins.length; i++) root.addWindow(wins[i]); - } else { - root.screenTiles = {}; - } + ShellSettings.Settings.dynamicTilingEnabled = !ShellSettings.Settings.dynamicTilingEnabled; } } - // ── Right-click menu ────────────────────────────────────────────────── + // ── Component setup ─────────────────────────────────────────────────── - // Note: registerUserActionsMenu is a global function in KWin JS scripts. - // In declarative QML scripts it is exposed via the KWin global object. - // We wire it up after the component is complete. Component.onCompleted: { // Connect to existing windows const wins = KWinComponents.Workspace.windows; @@ -607,9 +833,6 @@ Item { const win = wins[i]; if (isTileable(win)) { addWindow(win); - win.interactiveMoveResizeStarted.connect(function() { root.onDragStart(win); }); - win.interactiveMoveResizeStepped.connect(function(geo) { root.onDragStep(win, geo); }); - win.interactiveMoveResizeFinished.connect(function() { root.onDragEnd(win); }); } } }