// SPDX-FileCopyrightText: 2026 Marco Allegretti // SPDX-License-Identifier: EUPL-1.2 // // 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 // - Drag detection uses interactiveMoveResizeStarted/Stepped/Finished // - Snap zones are the 6 screen-edge regions (left/right/top/corners) // - Gap: outer 8px on screen edges, inner 8px between tiles (4px each side) import QtQuick import org.kde.kwin as KWinComponents import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings Item { id: root // ── 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. property var screenTiles: ({}) // Windows the user has manually floated (by UUID string). property var floatedWindows: ({}) // 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 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 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 // roundtrip after setMaximize() before KWin updates MaximizeArea. // We queue output names and flush after 200 ms — same pattern as // convergentwindows constrainAfterRestoreTimer. property var pendingRetile: [] Timer { id: retileTimer interval: 200 repeat: false onTriggered: { const queue = root.pendingRetile.slice(); root.pendingRetile = []; const done = {}; for (let i = 0; i < queue.length; i++) { if (!done[queue[i]]) { done[queue[i]] = true; root.retileScreen(queue[i]); } } } } function scheduleRetile(outputName) { const q = root.pendingRetile.slice(); if (q.indexOf(outputName) < 0) q.push(outputName); root.pendingRetile = q; retileTimer.restart(); } // ── Tiling guard ──────────────────────────────────────────────────────── // Active when convergence is on, gaming is off, AND the user has // dynamic tiling enabled in quick settings. When this returns false // the script is fully inert and KWin's native quick-tile behaviour // owns window placement. function isConvergence() { return ShellSettings.Settings.convergenceModeEnabled && !ShellSettings.Settings.gamingModeEnabled && ShellSettings.Settings.dynamicTilingEnabled; } // Mirror the same guard used by convergentwindows: only normalWindow is // reliable in the KWin 6 declarative script API. Add maximizable to // avoid calling setMaximize on layer-shell / panel surfaces. function shouldIgnore(win) { if (!win || win.deleted) return true; if (!win.normalWindow) return true; // panels, dock, desktop, layer-shell if (!win.maximizable) return true; // fixed/special surfaces if (win.fullScreen) return true; // Skip xwaylandvideobridge (same as convergentwindows) if (win.resourceClass === "xwaylandvideobridge") return true; return false; } function shouldFloat(win) { if (!win) return true; // Fixed-size windows (won't tile sensibly) const minW = win.minSize ? win.minSize.width : 0; const maxW = win.maxSize ? win.maxSize.width : 0; const minH = win.minSize ? win.minSize.height : 0; const maxH = win.maxSize ? win.maxSize.height : 0; if (minW > 0 && maxW > 0 && minW >= maxW) return true; if (minH > 0 && maxH > 0 && minH >= maxH) return true; // Manually floated if (floatedWindows[windowKey(win)]) return true; return false; } function isTileable(win) { if (!isConvergence()) return false; if (shouldIgnore(win)) return false; if (shouldFloat(win)) return false; return true; } // ── Layout engine ─────────────────────────────────────────────────────── function workRect(win) { const output = win.output; const desktop = win.desktops[0]; if (!output || !desktop) return null; return KWinComponents.Workspace.clientArea( KWinComponents.Workspace.MaximizeArea, output, desktop); } // Apply outer + inner gaps to a list of rects that together tile a screen. // outer: gap between screen edge and tile // inner: total gap between two adjacent tiles (split equally, so 4px each side) function applyGaps(rects, workArea) { if (!rects || rects.length === 0) return rects; const half = innerGap / 2; const result = []; for (let i = 0; i < rects.length; i++) { let r = rects[i]; // Determine which edges touch the work area boundary const atLeft = Math.abs(r.x - workArea.x) < 2; const atTop = Math.abs(r.y - workArea.y) < 2; const atRight = Math.abs((r.x + r.width) - (workArea.x + workArea.width)) < 2; const atBottom = Math.abs((r.y + r.height) - (workArea.y + workArea.height)) < 2; const left = atLeft ? outerGap : half; const top = atTop ? outerGap : half; const right = atRight ? outerGap : half; const bottom = atBottom ? outerGap : half; result.push(Qt.rect( r.x + left, r.y + top, r.width - left - right, r.height - top - bottom )); } 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]); } 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]); } return rects; } // Recompute and apply layout for a single screen. function retileScreen(outputName) { const tiles = screenTiles[outputName]; if (!tiles || tiles.length === 0) 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); if (r) { area = r; break; } } if (!area) return; const n = tiles.length; const rawRects = bspRects(area, n); const gappedRects = applyGaps(rawRects, area); for (let i = 0; i < tiles.length; i++) { const win = tiles[i].win; if (!win || win.deleted) continue; const r = gappedRects[i]; tiles[i].rect = r; win.frameGeometry = r; } // Trigger a binding update screenTiles[outputName] = tiles.slice(); } // Retile all screens. function retileAll() { for (const name in screenTiles) { retileScreen(name); } } // Add a window to its screen's tile list and retile. function addWindow(win) { if (!isTileable(win)) return; connectDragHandlers(win); const output = win.output; if (!output) return; const name = output.name; if (!screenTiles[name]) { screenTiles[name] = []; } // Avoid duplicates const tiles = screenTiles[name]; for (let i = 0; i < tiles.length; i++) { if (windowKey(tiles[i].win) === windowKey(win)) return; } tiles.push({ win: win, rect: Qt.rect(0, 0, 0, 0) }); screenTiles[name] = tiles; // Un-maximize now so the exclusive-zone Wayland roundtrip begins; // retileScreen runs 200 ms later when MaximizeArea has settled. // (Same pattern as convergentwindows constrainAfterRestoreTimer.) if (win.maximizable) win.setMaximize(false, false); win.noBorder = false; scheduleRetile(name); } // 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; } } } } 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) { return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; } // Find the tile on-screen whose centre is most in `direction` from `fromRect`. // direction: "left"|"right"|"up"|"down" function findNeighbour(fromWin, direction) { const outputName = fromWin.output ? fromWin.output.name : null; if (!outputName) return null; const tiles = screenTiles[outputName]; if (!tiles) return null; const from = fromWin.frameGeometry; const fc = centreOf(from); let best = null; let bestScore = Infinity; for (let i = 0; i < tiles.length; i++) { const t = tiles[i]; if (windowKey(t.win) === windowKey(fromWin)) continue; const tc = centreOf(t.rect); const dx = tc.x - fc.x; const dy = tc.y - fc.y; let inDirection = false; let primary = 0; let secondary = 0; switch (direction) { case "left": inDirection = dx < -5; primary = -dx; secondary = Math.abs(dy); break; case "right": inDirection = dx > 5; primary = dx; secondary = Math.abs(dy); break; case "up": inDirection = dy < -5; primary = -dy; secondary = Math.abs(dx); break; case "down": inDirection = dy > 5; primary = dy; secondary = Math.abs(dx); break; } if (!inDirection) continue; // Score: penalise perpendicular distance lightly const score = primary + secondary * 0.3; if (score < bestScore) { bestScore = score; best = t.win; } } return best; } // ── Workspace connections ───────────────────────────────────────────── Connections { target: KWinComponents.Workspace function onWindowAdded(win) { if (isTileable(win)) { addWindow(win); } } function onWindowRemoved(win) { const key = root.windowKey(win); if (key) delete root.dragConnectedWindows[key]; root.removeWindow(win); } } Connections { target: ShellSettings.Settings function onConvergenceModeEnabledChanged() { if (isConvergence()) { // Tile all existing normal windows const wins = KWinComponents.Workspace.windows; for (let i = 0; i < wins.length; i++) { addWindow(wins[i]); } } else { // Clear all tiles — the convergentwindows script will re-maximize screenTiles = {}; } } function onGamingModeEnabledChanged() { if (ShellSettings.Settings.gamingModeEnabled) { screenTiles = {}; } else if (isConvergence()) { const wins = KWinComponents.Workspace.windows; for (let i = 0; i < wins.length; i++) { addWindow(wins[i]); } } } function onDynamicTilingEnabledChanged() { if (isConvergence()) { const wins = KWinComponents.Workspace.windows; for (let i = 0; i < wins.length; i++) { addWindow(wins[i]); } } else { // Tiling turned off — leave windows where they are. screenTiles = {}; } } function onDynamicTilingWindowRequestChanged() { root.handleWindowTilingRequest(); } } // ── Drag handlers ───────────────────────────────────────────────────── // Find the (screen, index) of an existing tile holding this window. function findTileSlot(win) { for (const sName in screenTiles) { const tiles = screenTiles[sName]; for (let i = 0; i < tiles.length; i++) { if (tiles[i].win && windowKey(tiles[i].win) === windowKey(win)) { return { screen: sName, index: i }; } } } return null; } // Find the tile under a cursor position, ignoring the dragged window. function findTileAtCursor(cursor, ignoreWin) { for (const sName in screenTiles) { const tiles = screenTiles[sName]; for (let i = 0; i < tiles.length; i++) { const t = tiles[i]; 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 && cursor.y >= r.y && cursor.y <= r.y + r.height) { return { screen: sName, index: i, rect: r }; } } } 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; clearDragOutline(); dragSwapTarget = null; dragInsertTarget = null; dragSourceRect = Qt.rect(0, 0, 0, 0); // 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()) { resetDragState(); return; } if (draggingWindow !== win) return; updateDragIntent(win, geo); } function onDragEnd(win) { 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. 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.) else if (dragSourceScreen && dragSourceIndex >= 0) { retileScreen(dragSourceScreen); } resetDragState(); } // ── Keyboard shortcuts ───────────────────────────────────────────────── // Focus navigation KWinComponents.ShortcutHandler { name: "SHIFT Tiling Focus Left" text: "SHIFT Tiling: Focus window to the left" sequence: "Meta+H" onActivated: { const win = KWinComponents.Workspace.activeWindow; if (!win) return; const target = root.findNeighbour(win, "left"); if (target) KWinComponents.Workspace.activeWindow = target; } } KWinComponents.ShortcutHandler { name: "SHIFT Tiling Focus Right" text: "SHIFT Tiling: Focus window to the right" sequence: "Meta+L" onActivated: { const win = KWinComponents.Workspace.activeWindow; if (!win) return; const target = root.findNeighbour(win, "right"); if (target) KWinComponents.Workspace.activeWindow = target; } } KWinComponents.ShortcutHandler { name: "SHIFT Tiling Focus Up" text: "SHIFT Tiling: Focus window above" sequence: "Meta+K" onActivated: { const win = KWinComponents.Workspace.activeWindow; if (!win) return; const target = root.findNeighbour(win, "up"); if (target) KWinComponents.Workspace.activeWindow = target; } } KWinComponents.ShortcutHandler { name: "SHIFT Tiling Focus Down" text: "SHIFT Tiling: Focus window below" sequence: "Meta+J" onActivated: { const win = KWinComponents.Workspace.activeWindow; if (!win) return; const target = root.findNeighbour(win, "down"); if (target) KWinComponents.Workspace.activeWindow = target; } } // Float toggle KWinComponents.ShortcutHandler { name: "SHIFT Tiling Float Toggle" text: "SHIFT Tiling: Toggle float for active window" sequence: "Meta+F" onActivated: { const win = KWinComponents.Workspace.activeWindow; if (!win) return; const id = root.windowKey(win); if (root.floatedWindows[id]) { root.tileWindow(win); } else { root.floatWindow(win); } } } // Tiling on/off KWinComponents.ShortcutHandler { name: "SHIFT Tiling Toggle" text: "SHIFT Tiling: Toggle tiling on/off" sequence: "Meta+T" onActivated: { ShellSettings.Settings.dynamicTilingEnabled = !ShellSettings.Settings.dynamicTilingEnabled; } } // ── Component setup ─────────────────────────────────────────────────── Component.onCompleted: { // Connect to existing windows const wins = KWinComponents.Workspace.windows; for (let i = 0; i < wins.length; i++) { const win = wins[i]; if (isTileable(win)) { addWindow(win); } } } }