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.)