From a33836945751682424c6f7b7a3e1a7d2637affe3 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Sun, 17 May 2026 18:26:28 +0200 Subject: [PATCH] Improve dynamic tiling layout handling Replace the flat per-screen tiling order with a persistent layout tree so split orientation and window relationships survive retiles. Derive the per-screen tile cache from the tree when applying geometries. Make drag insertion deterministic by mapping the cursor position over a target tile to the nearest split direction and previewing the exact half that will be assigned. Retile on Workspace.onScreensChanged so screen rotation and output changes recompute tiled window geometry from the updated work area, and reconcile tiled windows that KWin moved to another output. --- .../scripts/shift-tiling/contents/ui/main.qml | 505 ++++++++++++------ 1 file changed, 332 insertions(+), 173 deletions(-) diff --git a/kwin/scripts/shift-tiling/contents/ui/main.qml b/kwin/scripts/shift-tiling/contents/ui/main.qml index 3c8b5c35..9a8445b4 100644 --- a/kwin/scripts/shift-tiling/contents/ui/main.qml +++ b/kwin/scripts/shift-tiling/contents/ui/main.qml @@ -4,13 +4,15 @@ // SHIFT Dynamic Tiling — KWin declarative script // // Architecture: -// - One ScreenState per output, keyed by output.name -// - Each ScreenState holds an ordered list of TileNodes -// - A TileNode is { win, rect } where rect is absolute in-screen coordinates -// - On any change (add/remove/resize) the layout is recomputed from scratch -// for the affected screen using a BSP algorithm +// - One persistent layout tree per output, keyed by output.name +// - Tree nodes are split groups or window leaves; leaf rects are absolute +// in-screen coordinates +// - On add/remove/reorder the tree is updated and then laid out for the +// affected screen, preserving the split structure instead of rebuilding a +// fresh linear BSP from window count // - Drag detection uses interactiveMoveResizeStarted/Stepped/Finished -// - Snap zones are the 6 screen-edge regions (left/right/top/corners) +// - Dragging over a tile shows the exact directional split that will be +// created on drop // - Gap: outer 8px on screen edges, inner 8px between tiles (4px each side) import QtQuick @@ -23,12 +25,14 @@ 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 ─────────────────────────────────────────────────────────────── - // Per-screen tile list. Key: output.name Value: [{win, rect}] - // rect is a Qt.rect in absolute screen coordinates. + // Per-screen layout tree. Key: output.name. Value: split/leaf node. + property var screenLayouts: ({}) + + // Per-screen tile list derived from screenLayouts. Key: output.name. + // Value: [{win, rect}], where rect is absolute in-screen coordinates. property var screenTiles: ({}) // Windows the user has manually floated (by UUID string). @@ -41,14 +45,14 @@ Item { // Drag state. // - // 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. + // Behaviour: dragging a tile over another tile previews the exact + // left/right/up/down split that will be created on drop. 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 bool dragOutlineActive: false - property string dragDropMode: "" // "swap", "insert", "restore", or "float" + property string dragDropMode: "" // "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 @@ -56,8 +60,7 @@ Item { 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 + property var dragInsertTarget: null // {screen, key, direction, rect} preview slot readonly property int floatEscapeMargin: 32 // Deferred retile queue. @@ -177,72 +180,299 @@ Item { return result; } - // Binary-space-partition layout. - // Splits `area` recursively for `n` windows. - // Returns an ordered array of Qt.rect (without gaps applied). - function bspRects(area, n) { - if (n <= 0) return []; - if (n === 1) return [area]; - - // Pick split axis: split the longer dimension - const splitHorizontally = area.width >= area.height; - const rects = []; - - if (splitHorizontally) { - // Left half gets one window; right half gets (n-1) - const leftW = Math.round(area.width / 2); - const left = Qt.rect(area.x, area.y, leftW, area.height); - const right = Qt.rect(area.x + leftW, area.y, area.width - leftW, area.height); - rects.push(left); - const sub = bspRects(right, n - 1); - for (let i = 0; i < sub.length; i++) rects.push(sub[i]); + function setScreenLayout(outputName, layout) { + const layouts = Object.assign({}, screenLayouts); + if (layout) { + layouts[outputName] = layout; } else { - // Top half gets one window; bottom half gets (n-1) - const topH = Math.round(area.height / 2); - const top = Qt.rect(area.x, area.y, area.width, topH); - const bottom = Qt.rect(area.x, area.y + topH, area.width, area.height - topH); - rects.push(top); - const sub = bspRects(bottom, n - 1); - for (let i = 0; i < sub.length; i++) rects.push(sub[i]); + delete layouts[outputName]; + } + screenLayouts = layouts; + } + + function setScreenTiles(outputName, tiles) { + const allTiles = Object.assign({}, screenTiles); + if (tiles && tiles.length > 0) { + allTiles[outputName] = tiles; + } else { + delete allTiles[outputName]; + } + screenTiles = allTiles; + } + + function makeLeaf(win) { + return { kind: "leaf", win: win, rect: Qt.rect(0, 0, 0, 0) }; + } + + function makeSplit(orientation, first, second, ratio) { + return { + kind: "split", + orientation: orientation, + first: first, + second: second, + ratio: ratio || 0.5, + rect: Qt.rect(0, 0, 0, 0) + }; + } + + function splitOrientation(rect) { + return rect && rect.width >= rect.height ? "vertical" : "horizontal"; + } + + function directionOrientation(direction, rect) { + if (direction === "left" || direction === "right") return "vertical"; + if (direction === "up" || direction === "down") return "horizontal"; + return splitOrientation(rect); + } + + function splitRect(rect, orientation, ratio) { + const splitRatio = ratio || 0.5; + if (orientation === "vertical") { + const firstWidth = Math.round(rect.width * splitRatio); + return [ + Qt.rect(rect.x, rect.y, firstWidth, rect.height), + Qt.rect(rect.x + firstWidth, rect.y, rect.width - firstWidth, rect.height) + ]; + } + + const firstHeight = Math.round(rect.height * splitRatio); + return [ + Qt.rect(rect.x, rect.y, rect.width, firstHeight), + Qt.rect(rect.x, rect.y + firstHeight, rect.width, rect.height - firstHeight) + ]; + } + + function collectLeaves(node, leaves) { + if (!node) return; + if (node.kind === "leaf") { + leaves.push(node); + return; + } + collectLeaves(node.first, leaves); + collectLeaves(node.second, leaves); + } + + function lastLeafKey(node) { + if (!node) return ""; + if (node.kind === "leaf") return windowKey(node.win); + return lastLeafKey(node.second) || lastLeafKey(node.first); + } + + function containsLeaf(node, key) { + if (!node || !key) return false; + if (node.kind === "leaf") return windowKey(node.win) === key; + return containsLeaf(node.first, key) || containsLeaf(node.second, key); + } + + function splitLeaf(node, targetKey, newLeaf, direction, fallbackRect) { + if (!node || !targetKey || !newLeaf) return node; + + if (node.kind === "leaf") { + if (windowKey(node.win) !== targetKey) return node; + + const baseRect = validRect(node.rect) ? node.rect : fallbackRect; + const orientation = directionOrientation(direction, baseRect); + if (direction === "left" || direction === "up") { + return makeSplit(orientation, newLeaf, node, 0.5); + } + return makeSplit(orientation, node, newLeaf, 0.5); + } + + node.first = splitLeaf(node.first, targetKey, newLeaf, direction, fallbackRect); + node.second = splitLeaf(node.second, targetKey, newLeaf, direction, fallbackRect); + return node; + } + + function removeLeaf(node, key) { + if (!node || !key) return { node: node, leaf: null }; + + if (node.kind === "leaf") { + if (windowKey(node.win) === key) { + return { node: null, leaf: node }; + } + return { node: node, leaf: null }; + } + + let first = removeLeaf(node.first, key); + if (first.leaf) { + node.first = first.node; + if (!node.first) return { node: node.second, leaf: first.leaf }; + return { node: node, leaf: first.leaf }; + } + + let second = removeLeaf(node.second, key); + if (second.leaf) { + node.second = second.node; + if (!node.second) return { node: node.first, leaf: second.leaf }; + return { node: node, leaf: second.leaf }; + } + + return { node: node, leaf: null }; + } + + function layoutTree(node, area, leaves) { + if (!node || !area) return; + node.rect = Qt.rect(area.x, area.y, area.width, area.height); + + if (node.kind === "leaf") { + leaves.push(node); + return; + } + + const rects = splitRect(area, node.orientation, node.ratio); + layoutTree(node.first, rects[0], leaves); + layoutTree(node.second, rects[1], leaves); + } + + function insertWindowInLayout(outputName, win) { + const rootNode = screenLayouts[outputName]; + const newLeaf = makeLeaf(win); + + if (!rootNode) { + setScreenLayout(outputName, newLeaf); + return; + } + + let targetKey = ""; + const active = KWinComponents.Workspace.activeWindow; + if (active && active.output && active.output.name === outputName && windowKey(active) !== windowKey(win)) { + targetKey = windowKey(active); + } + if (!containsLeaf(rootNode, targetKey)) { + targetKey = lastLeafKey(rootNode); + } + + setScreenLayout(outputName, splitLeaf(rootNode, targetKey, newLeaf, "", workRect(win))); + } + + function removeWindowFromLayout(outputName, win) { + const key = windowKey(win); + const rootNode = screenLayouts[outputName]; + if (!key || !rootNode) return false; + + const result = removeLeaf(rootNode, key); + if (!result.leaf) return false; + setScreenLayout(outputName, result.node); + return true; + } + + function moveWindowToSplit(target) { + if (!target || !draggingWindow || !dragSourceScreen) return; + + const sourceKey = windowKey(draggingWindow); + if (!sourceKey || sourceKey === target.key) return; + + const sourceRoot = screenLayouts[dragSourceScreen]; + const detached = removeLeaf(sourceRoot, sourceKey); + if (!detached.leaf) return; + + setScreenLayout(dragSourceScreen, detached.node); + + const targetRoot = screenLayouts[target.screen]; + if (!targetRoot || !containsLeaf(targetRoot, target.key)) { + setScreenLayout(dragSourceScreen, sourceRoot); + return; + } + + setScreenLayout(target.screen, splitLeaf(targetRoot, target.key, detached.leaf, target.direction, target.rect)); + retileScreen(dragSourceScreen); + if (target.screen !== dragSourceScreen) { + retileScreen(target.screen); } - return rects; } // Recompute and apply layout for a single screen. function retileScreen(outputName) { - const tiles = screenTiles[outputName]; - if (!tiles || tiles.length === 0) return; + const rootNode = screenLayouts[outputName]; + if (!rootNode) { + setScreenTiles(outputName, []); + return; + } + + const leaves = []; + collectLeaves(rootNode, leaves); + if (leaves.length === 0) { + setScreenTiles(outputName, []); + return; + } // Get work area from the first window's output let area = null; - for (let i = 0; i < tiles.length; i++) { - const r = workRect(tiles[i].win); + for (let i = 0; i < leaves.length; i++) { + const r = workRect(leaves[i].win); if (r) { area = r; break; } } if (!area) return; - const n = tiles.length; - const rawRects = bspRects(area, n); + const rawLeaves = []; + layoutTree(rootNode, area, rawLeaves); + const rawRects = []; + for (let i = 0; i < rawLeaves.length; i++) { + rawRects.push(rawLeaves[i].rect); + } const gappedRects = applyGaps(rawRects, area); - for (let i = 0; i < tiles.length; i++) { - const win = tiles[i].win; + for (let i = 0; i < rawLeaves.length; i++) { + const win = rawLeaves[i].win; if (!win || win.deleted) continue; const r = gappedRects[i]; - tiles[i].rect = r; + rawLeaves[i].rect = r; win.frameGeometry = r; } - // Trigger a binding update - screenTiles[outputName] = tiles.slice(); + setScreenLayout(outputName, rootNode); + setScreenTiles(outputName, rawLeaves.slice()); } - // Retile all screens. + // Retile all layout trees. function retileAll() { - for (const name in screenTiles) { + for (const name in screenLayouts) { retileScreen(name); } } + function scheduleRetileAll() { + for (const name in screenLayouts) { + scheduleRetile(name); + } + } + + function screenNameForWindow(win) { + const key = windowKey(win); + if (!key) return ""; + + for (const name in screenLayouts) { + if (containsLeaf(screenLayouts[name], key)) { + return name; + } + } + return ""; + } + + function reconcileWindowOutput(win) { + if (!isTileable(win)) return; + + connectDragHandlers(win); + + const output = win.output; + if (!output) return; + + const currentName = screenNameForWindow(win); + const targetName = output.name; + + if (currentName !== "" && currentName !== targetName) { + removeWindowFromLayout(currentName, win); + scheduleRetile(currentName); + } + + if (!containsLeaf(screenLayouts[targetName], windowKey(win))) { + insertWindowInLayout(targetName, win); + } + + if (win.maximizable) win.setMaximize(false, false); + win.noBorder = false; + scheduleRetile(targetName); + } + // Add a window to its screen's tile list and retile. function addWindow(win) { if (!isTileable(win)) return; @@ -253,18 +483,16 @@ Item { if (!output) return; const name = output.name; - if (!screenTiles[name]) { - screenTiles[name] = []; - } - // Avoid duplicates - const tiles = screenTiles[name]; + const key = windowKey(win); + if (containsLeaf(screenLayouts[name], key)) return; + + const tiles = screenTiles[name] || []; for (let i = 0; i < tiles.length; i++) { - if (windowKey(tiles[i].win) === windowKey(win)) return; + if (windowKey(tiles[i].win) === key) return; } - tiles.push({ win: win, rect: Qt.rect(0, 0, 0, 0) }); - screenTiles[name] = tiles; + insertWindowInLayout(name, win); // Un-maximize now so the exclusive-zone Wayland roundtrip begins; // retileScreen runs 200 ms later when MaximizeArea has settled. @@ -277,19 +505,13 @@ Item { // Remove a window from its screen's tile list and retile. function removeWindow(win) { if (!win) return; - const output = win.output; - const name = output ? output.name : null; - // Search all screens (window may have been moved) - for (const sName in screenTiles) { - const tiles = screenTiles[sName]; - for (let i = 0; i < tiles.length; i++) { - if (windowKey(tiles[i].win) === windowKey(win)) { - tiles.splice(i, 1); - screenTiles[sName] = tiles; - retileScreen(sName); - return; - } + // Search all layout trees (window may have been moved or queued before + // the deferred tile list has refreshed). + for (const sName in screenLayouts) { + if (removeWindowFromLayout(sName, win)) { + retileScreen(sName); + return; } } } @@ -411,6 +633,19 @@ Item { if (key) delete root.dragConnectedWindows[key]; root.removeWindow(win); } + + function onScreensChanged() { + if (!root.isConvergence()) { + return; + } + + const wins = KWinComponents.Workspace.windows; + for (let i = 0; i < wins.length; i++) { + root.reconcileWindowOutput(wins[i]); + } + + root.scheduleRetileAll(); + } } Connections { @@ -425,12 +660,14 @@ Item { } } else { // Clear all tiles — the convergentwindows script will re-maximize + screenLayouts = {}; screenTiles = {}; } } function onGamingModeEnabledChanged() { if (ShellSettings.Settings.gamingModeEnabled) { + screenLayouts = {}; screenTiles = {}; } else if (isConvergence()) { const wins = KWinComponents.Workspace.windows; @@ -448,6 +685,7 @@ Item { } } else { // Tiling turned off — leave windows where they are. + screenLayouts = {}; screenTiles = {}; } } @@ -483,82 +721,39 @@ Item { if (!r || r.width <= 0 || r.height <= 0) continue; if (cursor.x >= r.x && cursor.x <= r.x + r.width && cursor.y >= r.y && cursor.y <= r.y + r.height) { - return { screen: sName, index: i, rect: r }; + return { screen: sName, index: i, rect: r, key: windowKey(t.win), win: t.win }; } } } return null; } - function tileInsertPosition(cursor, rect) { + function tileInsertDirection(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; + if (nearest === left) return "left"; + if (nearest === right) return "right"; + if (nearest === top) return "up"; + return "down"; } function previewInsertRect(win, target, position) { - if (!target || !target.rect || dragSourceIndex < 0) return null; + if (!target || !validRect(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; + const orientation = directionOrientation(position, target.rect); + const rects = splitRect(target.rect, orientation, 0.5); + if (position === "left" || position === "up") return rects[0]; + return rects[1]; } 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); + moveWindowToSplit(target); } function validRect(rect) { @@ -615,7 +810,6 @@ Item { function resetDragState() { clearDragOutline(); - dragSwapTarget = null; dragInsertTarget = null; dragSourceScreen = ""; dragSourceIndex = -1; @@ -628,28 +822,21 @@ Item { const target = findTileAtCursor(cursor, win); if (target) { - const position = tileInsertPosition(cursor, target.rect); - const insertRect = position !== "" ? previewInsertRect(win, target, position) : null; + const position = tileInsertDirection(cursor, target.rect); + const insertRect = previewInsertRect(win, target, position); if (validRect(insertRect)) { - dragSwapTarget = null; dragInsertTarget = { screen: target.screen, - index: target.index, - insertIndex: insertIndexForTarget(target, position), + key: target.key, + direction: 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); @@ -662,11 +849,10 @@ Item { if (!isConvergence()) return; draggingWindow = win; clearDragOutline(); - dragSwapTarget = null; dragInsertTarget = null; dragSourceRect = Qt.rect(0, 0, 0, 0); - // Remember the source slot so we can swap or insert on drop. + // Remember the source slot so we can insert on drop. // The tile stays in screenTiles[] during the drag so the rest of // the layout doesn't shuffle. const slot = findTileSlot(win); @@ -712,33 +898,6 @@ Item { insertDraggedWindow(dragInsertTarget); } - // Dropped on another tile → swap source/target slots. - else if (dragSwapTarget && dragSourceScreen && dragSourceIndex >= 0) { - const sScreen = dragSourceScreen; - const sIdx = dragSourceIndex; - const tScreen = dragSwapTarget.screen; - const tIdx = dragSwapTarget.index; - - const sTiles = screenTiles[sScreen]; - const tTiles = screenTiles[tScreen]; - if (sTiles && tTiles && sTiles[sIdx] && tTiles[tIdx]) { - const a = sTiles[sIdx]; - const b = tTiles[tIdx]; - if (sScreen === tScreen) { - sTiles[sIdx] = b; - sTiles[tIdx] = a; - screenTiles[sScreen] = sTiles.slice(); - retileScreen(sScreen); - } else { - sTiles[sIdx] = b; - tTiles[tIdx] = a; - screenTiles[sScreen] = sTiles.slice(); - screenTiles[tScreen] = tTiles.slice(); - retileScreen(sScreen); - retileScreen(tScreen); - } - } - } // Dropped elsewhere → restore the source tile to its original slot. // (KWin's native quick-tile may have moved the window; retileScreen // sets frameGeometry back to the BSP rect so the layout stays intact.)