// SPDX-FileCopyrightText: 2026 Marco Allegretti // SPDX-License-Identifier: EUPL-1.2 // // SHIFT Dynamic Tiling — KWin declarative script // // Architecture: // - One persistent layout tree per output and virtual desktop // - Tree nodes are split groups or window leaves; leaf rects are absolute // in-screen coordinates // - Automatic add/remove rebuilds a stable master-stack tree from window // order, so normal app churn does not leave arbitrary nested splits behind // - If the current page is full, new windows move to another existing // virtual desktop with free capacity // - The current screen can cycle between master-stack, equal columns, and // equal rows without changing window order // - Dragging over an existing tile swaps the two window leaves without // changing the split tree shape // - Drag detection uses interactiveMoveResizeStarted/Stepped/Finished // - Dragging over a tile previews the slot that will be swapped on drop // - 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 maxWindowsPerPage: 4 readonly property real stablePrimaryRatio: 0.58 readonly property var layoutModes: ["master", "columns", "rows"] // ── State ─────────────────────────────────────────────────────────────── // Per-screen/per-desktop layout tree. Key: output.name + desktop key. // Value: split/leaf node. property var screenLayouts: ({}) // Per-screen/per-desktop tile list derived from screenLayouts. // Value: [{win, rect}], where rect is absolute in-screen coordinates. property var screenTiles: ({}) // Per-screen/per-desktop layout mode. Key: output.name + desktop key. // Value: one of layoutModes. property var screenLayoutModes: ({}) // Last focused tiled window per screen/desktop page. Used as the // replacement target when a user manually moves another window onto a // full page. property var lastFocusedWindowKeys: ({}) // 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 over another tile previews the target slot // that will be swapped 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", "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, key, 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(); } function desktopKey(desktop) { if (!desktop) return ""; if (desktop.x11DesktopNumber !== undefined) return String(desktop.x11DesktopNumber); const desktops = KWinComponents.Workspace.desktops; const index = desktops ? desktops.indexOf(desktop) : -1; return index >= 0 ? String(index + 1) : String(desktop); } function desktopForWindow(win) { if (win && win.desktops && win.desktops.length > 0) { return win.desktops[0]; } return KWinComponents.Workspace.currentDesktop; } function layoutKeyFor(outputName, desktop) { const key = desktopKey(desktop); return outputName && key !== "" ? outputName + "::" + key : ""; } function desktopForLayoutKey(outputName) { const separator = outputName.lastIndexOf("::"); if (separator < 0) return null; const wantedKey = outputName.slice(separator + 2); const desktops = KWinComponents.Workspace.desktops; if (!desktops) return null; for (let i = 0; i < desktops.length; i++) { if (desktopKey(desktops[i]) === wantedKey) { return desktops[i]; } } return null; } function layoutKeyForWindow(win) { if (!win || !win.output) return ""; return layoutKeyFor(win.output.name, desktopForWindow(win)); } function assignWindowToDesktop(win, desktop) { if (!win || !desktop) return; if (win.desktops && win.desktops.length === 1 && win.desktops[0] === desktop) return; win.desktops = [desktop]; } function windowCountForLayout(outputName) { const leaves = []; collectLeaves(screenLayouts[outputName], leaves); let count = 0; for (let i = 0; i < leaves.length; i++) { const win = leaves[i].win; if (win && !win.deleted && isTileable(win) && layoutKeyForWindow(win) === outputName) { count++; } } return count; } function targetLayoutForNewWindow(win) { const currentDesktop = desktopForWindow(win); const currentKey = layoutKeyForWindow(win); if (!win || !win.output || currentKey === "") { return { key: "", desktop: null }; } if (windowCountForLayout(currentKey) < maxWindowsPerPage) { return { key: currentKey, desktop: currentDesktop }; } const desktops = KWinComponents.Workspace.desktops; if (!desktops || desktops.length < 2) { return { key: currentKey, desktop: currentDesktop }; } let startIndex = desktops.indexOf(currentDesktop); if (startIndex < 0) { startIndex = desktops.indexOf(KWinComponents.Workspace.currentDesktop); } if (startIndex < 0) { startIndex = 0; } for (let offset = 1; offset < desktops.length; offset++) { const desktop = desktops[(startIndex + offset) % desktops.length]; const key = layoutKeyFor(win.output.name, desktop); if (key !== "" && windowCountForLayout(key) < maxWindowsPerPage) { return { key: key, desktop: desktop }; } } return { key: currentKey, desktop: currentDesktop }; } // ── 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 = desktopForWindow(win); 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; } function setScreenLayout(outputName, layout) { const layouts = Object.assign({}, screenLayouts); if (layout) { layouts[outputName] = layout; } else { 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 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 leafIndexForWindow(outputName, win) { const key = windowKey(win); if (!key) return -1; const leaves = []; collectLeaves(screenLayouts[outputName], leaves); for (let i = 0; i < leaves.length; i++) { if (windowKey(leaves[i].win) === key) { return i; } } return -1; } function leafForSlot(outputName, preferredIndex) { const leaves = []; collectLeaves(screenLayouts[outputName], leaves); if (leaves.length === 0) return null; if (preferredIndex >= 0 && preferredIndex < leaves.length) { return leaves[preferredIndex]; } return leaves[leaves.length - 1]; } function rememberFocusedWindow(win) { if (!isTileable(win)) return; const outputName = layoutKeyForWindow(win); const key = windowKey(win); if (!outputName || !key || !containsLeaf(screenLayouts[outputName], key)) return; const focused = Object.assign({}, lastFocusedWindowKeys); focused[outputName] = key; lastFocusedWindowKeys = focused; } function replacementLeafForLayout(outputName, fallbackIndex) { const focusedKey = lastFocusedWindowKeys[outputName] || ""; const focusedLeaf = focusedKey ? findLeaf(screenLayouts[outputName], focusedKey) : null; if (focusedLeaf && focusedLeaf.win && layoutKeyForWindow(focusedLeaf.win) === outputName) { return focusedLeaf; } return leafForSlot(outputName, fallbackIndex); } function orderedWindowsForScreen(outputName) { const windows = []; const seen = {}; const leaves = []; collectLeaves(screenLayouts[outputName], leaves); for (let i = 0; i < leaves.length; i++) { const win = leaves[i].win; const key = windowKey(win); if (!key || seen[key] || !isTileable(win)) continue; if (layoutKeyForWindow(win) !== outputName) continue; seen[key] = true; windows.push(win); } return windows; } function appendWindowIfMissing(windows, win) { const key = windowKey(win); if (!key) return false; for (let i = 0; i < windows.length; i++) { if (windowKey(windows[i]) === key) { return false; } } windows.push(win); return true; } function buildStableStack(windows, startIndex) { const remaining = windows.length - startIndex; if (remaining <= 0) return null; if (remaining === 1) return makeLeaf(windows[startIndex]); return makeSplit( "horizontal", makeLeaf(windows[startIndex]), buildStableStack(windows, startIndex + 1), 1 / remaining ); } function buildLinearLayout(windows, startIndex, orientation) { const remaining = windows.length - startIndex; if (remaining <= 0) return null; if (remaining === 1) return makeLeaf(windows[startIndex]); return makeSplit( orientation, makeLeaf(windows[startIndex]), buildLinearLayout(windows, startIndex + 1, orientation), 1 / remaining ); } function layoutModeForScreen(outputName) { const mode = screenLayoutModes[outputName]; return layoutModes.indexOf(mode) >= 0 ? mode : "master"; } function setLayoutModeForScreen(outputName, mode) { if (!outputName || layoutModes.indexOf(mode) < 0) return; const modes = Object.assign({}, screenLayoutModes); modes[outputName] = mode; screenLayoutModes = modes; } function buildStableLayout(windows, mode) { if (!windows || windows.length === 0) return null; if (windows.length === 1) return makeLeaf(windows[0]); if (mode === "columns") { return buildLinearLayout(windows, 0, "vertical"); } if (mode === "rows") { return buildLinearLayout(windows, 0, "horizontal"); } if (windows.length === 2) { return makeSplit("vertical", makeLeaf(windows[0]), makeLeaf(windows[1]), 0.5); } return makeSplit( "vertical", makeLeaf(windows[0]), buildStableStack(windows, 1), stablePrimaryRatio ); } function setStableLayout(outputName, windows) { setScreenLayout(outputName, buildStableLayout(windows, layoutModeForScreen(outputName))); } function outputNameForActiveWindow() { const win = KWinComponents.Workspace.activeWindow; const activeName = layoutKeyForWindow(win); if (activeName !== "") return activeName; for (const name in screenLayouts) { return name; } return ""; } function cycleLayoutMode(outputName) { if (!outputName) return; const currentMode = layoutModeForScreen(outputName); const currentIndex = layoutModes.indexOf(currentMode); const nextMode = layoutModes[(currentIndex + 1) % layoutModes.length]; setLayoutModeForScreen(outputName, nextMode); const windows = orderedWindowsForScreen(outputName); if (windows.length > 0) { setStableLayout(outputName, windows); retileScreen(outputName); scheduleRetile(outputName); } } 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 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 windows = orderedWindowsForScreen(outputName); if (!appendWindowIfMissing(windows, win)) return; setStableLayout(outputName, windows); } function removeWindowFromLayout(outputName, win) { const key = windowKey(win); const rootNode = screenLayouts[outputName]; if (!key || !rootNode) return false; const leaves = []; const seen = {}; const remaining = []; let removed = false; collectLeaves(rootNode, leaves); for (let i = 0; i < leaves.length; i++) { const leafWindow = leaves[i].win; const leafKey = windowKey(leafWindow); if (leafKey === key) { removed = true; continue; } if (!leafKey || seen[leafKey] || !isTileable(leafWindow)) continue; if (layoutKeyForWindow(leafWindow) !== outputName) continue; seen[leafKey] = true; remaining.push(leafWindow); } if (!removed) return false; setStableLayout(outputName, remaining); return true; } function swapWindowBetweenLayouts(win, sourceName, targetName) { if (!win || !sourceName || !targetName || sourceName === targetName) return false; const sourceDesktop = desktopForLayoutKey(sourceName); const sourceLeaf = findLeaf(screenLayouts[sourceName], windowKey(win)); if (!sourceDesktop || !sourceLeaf) return false; const sourceIndex = leafIndexForWindow(sourceName, win); const targetLeaf = replacementLeafForLayout(targetName, sourceIndex); if (!targetLeaf || !targetLeaf.win || windowKey(targetLeaf.win) === windowKey(win)) return false; const displacedWindow = targetLeaf.win; targetLeaf.win = win; sourceLeaf.win = displacedWindow; assignWindowToDesktop(displacedWindow, sourceDesktop); retileScreen(sourceName); retileScreen(targetName); scheduleRetile(sourceName); scheduleRetile(targetName); KWinComponents.Workspace.activeWindow = win; return true; } // Recompute and apply layout for a single screen. function retileScreen(outputName) { 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 < leaves.length; i++) { const r = workRect(leaves[i].win); if (r) { area = r; break; } } if (!area) return; 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 < rawLeaves.length; i++) { const win = rawLeaves[i].win; if (!win || win.deleted) continue; const r = gappedRects[i]; rawLeaves[i].rect = r; win.frameGeometry = r; } setScreenLayout(outputName, rootNode); setScreenTiles(outputName, rawLeaves.slice()); } // Retile all layout trees. function retileAll() { 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 = layoutKeyForWindow(win); if (targetName === "") return; if (currentName !== "" && currentName !== targetName) { if (windowCountForLayout(targetName) >= maxWindowsPerPage && swapWindowBetweenLayouts(win, currentName, targetName)) { return; } removeWindowFromLayout(currentName, win); scheduleRetile(currentName); } if (!containsLeaf(screenLayouts[targetName], windowKey(win))) { insertWindowInLayout(targetName, win); } if (win.maximizable) win.setMaximize(false, false); win.noBorder = false; retileScreen(targetName); scheduleRetile(targetName); } // 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 initialName = layoutKeyForWindow(win); const target = targetLayoutForNewWindow(win); if (target.key === "") return; const name = target.key; if (target.desktop && initialName !== name) { assignWindowToDesktop(win, target.desktop); KWinComponents.Workspace.currentDesktop = target.desktop; } // Avoid duplicates 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) === key) return; } insertWindowInLayout(name, win); // 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; retileScreen(name); scheduleRetile(name); } // Remove a window from its screen's tile list and retile. function removeWindow(win) { if (!win) 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; } } } 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); }); if (win.desktopsChanged !== undefined) { win.desktopsChanged.connect(function() { root.reconcileWindowOutput(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); } } function promoteWindow(win) { if (!isTileable(win)) return; const outputName = screenNameForWindow(win) || layoutKeyForWindow(win); if (!outputName) return; const key = windowKey(win); const windows = orderedWindowsForScreen(outputName); if (!key || windows.length < 2) return; const reordered = []; let promoted = null; for (let i = 0; i < windows.length; i++) { if (windowKey(windows[i]) === key) { promoted = windows[i]; } else { reordered.push(windows[i]); } } if (!promoted) return; reordered.unshift(promoted); setStableLayout(outputName, reordered); retileScreen(outputName); scheduleRetile(outputName); KWinComponents.Workspace.activeWindow = 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 = layoutKeyForWindow(fromWin); 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); } function onActiveWindowChanged() { root.rememberFocusedWindow(KWinComponents.Workspace.activeWindow); } 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 { 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 screenLayouts = {}; screenTiles = {}; screenLayoutModes = {}; lastFocusedWindowKeys = {}; } } function onGamingModeEnabledChanged() { if (ShellSettings.Settings.gamingModeEnabled) { screenLayouts = {}; screenTiles = {}; screenLayoutModes = {}; lastFocusedWindowKeys = {}; } 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. screenLayouts = {}; screenTiles = {}; screenLayoutModes = {}; lastFocusedWindowKeys = {}; } } 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) { const activeName = dragSourceScreen || layoutKeyForWindow(ignoreWin); for (const sName in screenTiles) { if (activeName && sName !== activeName) continue; 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, key: windowKey(t.win), win: t.win }; } } } return null; } function findLeaf(node, key) { if (!node || !key) return null; if (node.kind === "leaf") { return windowKey(node.win) === key ? node : null; } return findLeaf(node.first, key) || findLeaf(node.second, key); } function swapDraggedWindow(target) { if (!target || !draggingWindow || !dragSourceScreen) return; const sourceKey = windowKey(draggingWindow); if (!sourceKey || sourceKey === target.key || target.screen !== dragSourceScreen) return; const rootNode = screenLayouts[dragSourceScreen]; const sourceLeaf = findLeaf(rootNode, sourceKey); const targetLeaf = findLeaf(rootNode, target.key); if (!sourceLeaf || !targetLeaf) return; const sourceWindow = sourceLeaf.win; sourceLeaf.win = targetLeaf.win; targetLeaf.win = sourceWindow; retileScreen(dragSourceScreen); scheduleRetile(dragSourceScreen); KWinComponents.Workspace.activeWindow = sourceWindow; } 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); dragOutlineActive = true; } function clearDragOutline() { dragOutlineActive = false; dragDropMode = ""; dragOutlineRect = Qt.rect(0, 0, 0, 0); } function resetDragState() { clearDragOutline(); dragSwapTarget = 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 && target.screen === dragSourceScreen && validRect(target.rect)) { dragSwapTarget = { screen: target.screen, key: target.key, rect: target.rect }; showDragOutline("swap", target.rect); return; } dragSwapTarget = 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; dragSourceRect = Qt.rect(0, 0, 0, 0); // Remember the source slot so we can swap 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 === "swap" && dragSwapTarget) { swapDraggedWindow(dragSwapTarget); } // 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); } } } KWinComponents.ShortcutHandler { name: "SHIFT Tiling Promote Primary" text: "SHIFT Tiling: Promote active window to primary tile" sequence: "Meta+Shift+Return" onActivated: { const win = KWinComponents.Workspace.activeWindow; if (win) root.promoteWindow(win); } } KWinComponents.ShortcutHandler { name: "SHIFT Tiling Cycle Layout" text: "SHIFT Tiling: Cycle layout mode" sequence: "Meta+Shift+T" onActivated: root.cycleLayoutMode(root.outputNameForActiveWindow()) } // 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); } } rememberFocusedWindow(KWinComponents.Workspace.activeWindow); } }