From 5444752e76ac7e0a5f7ef67b4c9b34e114357719 Mon Sep 17 00:00:00 2001 From: Andre Ramnitz Date: Tue, 20 Oct 2026 18:48:09 +0200 Subject: [PATCH] Add keybinds for moving clients within the tree does not apply to floating or ignored clients fixup: normalize the split direction if a window within the tree is removed --- contents/code/main.js | 172 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 163 insertions(+), 9 deletions(-) diff --git a/contents/code/main.js b/contents/code/main.js index 23922b4..d07e96c 100644 --- a/contents/code/main.js +++ b/contents/code/main.js @@ -2,7 +2,7 @@ // SimpleDwindle // KWin 6.8 - Minimal chronological Dwindle -const VERSION = "0.3.5"; +const VERSION = "0.4.0"; // ------------------------------------------------------------ // Configuration @@ -196,11 +196,11 @@ function depth(node) { function insertWindow(layout, w) { if (!layout.root) { layout.root = leaf(w); - layout.last = w; + layout.last = layout.root; return; } - let target = findLeaf(layout.root, layout.last); + let target = layout.last; if (!target) target = rightmost(layout.root); @@ -214,7 +214,7 @@ function insertWindow(layout, w) { target.first = leaf(oldWindow, target); target.second = leaf(w, target); - layout.last = w; + layout.last = target.second; } function removeNode(layout, w) { @@ -247,16 +247,35 @@ function removeNode(layout, w) { grandparent.second = sibling; } - if (layout.last === w) { - const lastNode = rightmost(layout.root); - layout.last = lastNode - ? lastNode.window - : null; + // A removed leaf may be the growth tip, or its sibling may + // replace an ancestor containing the growth tip. + let tip = layout.last; + let tipRemoved = false; + + while (tip) { + if (tip === parent) { + tipRemoved = true; + break; + } + tip = tip.parent; } + if (tipRemoved) + layout.last = rightmost(layout.root); + return true; } +function normalizeSplits(node, depth = 0) { + if (!node || node.window) + return; + + node.vertical = (depth % 2 === 0); + + normalizeSplits(node.first, depth + 1); + normalizeSplits(node.second, depth + 1); +} + // ------------------------------------------------------------ // Geometry // ------------------------------------------------------------ @@ -345,6 +364,135 @@ function arrange(layout) { applyNode(layout.root, area); } +// ------------------------------------------------------------ +// Spatial swap (tiled windows only) +// ------------------------------------------------------------ + +// Use tree-assigned rectangles, not asynchronous frameGeometry updates. +function collectLeaves(node, rect, result) { + if (!node) + return; + + if (node.window) { + result.push({ node: node, rect: rect }); + return; + } + + if (node.vertical) { + const half = rect.width / 2; + collectLeaves(node.first, { + x: rect.x, y: rect.y, + width: half, height: rect.height + }, result); + collectLeaves(node.second, { + x: rect.x + half, y: rect.y, + width: rect.width - half, height: rect.height + }, result); + } else { + const half = rect.height / 2; + collectLeaves(node.first, { + x: rect.x, y: rect.y, + width: rect.width, height: half + }, result); + collectLeaves(node.second, { + x: rect.x, y: rect.y + half, + width: rect.width, height: rect.height - half + }, result); + } +} + +function findSwapNeighbor(w, direction) { + const layout = findWindowLayout(w); + if (!layout) + return null; + + const area = workspace.clientArea( + KWin.MaximizeArea, layout.output, layout.desktop + ); + const leaves = []; + collectLeaves(layout.root, area, leaves); + + const source = leaves.find(item => item.node.window === w); + if (!source) + return null; + + const a = source.rect; + const ax = a.x + a.width / 2; + const ay = a.y + a.height / 2; + let best = null; + let bestScore = null; + + for (const item of leaves) { + const candidate = item.node.window; + if (candidate === w || candidate.fullScreen || candidate.minimized) + continue; + + const b = item.rect; + const bx = b.x + b.width / 2; + const by = b.y + b.height / 2; + const dx = bx - ax; + const dy = by - ay; + + if ((direction === "left" && dx >= 0) || + (direction === "right" && dx <= 0) || + (direction === "up" && dy >= 0) || + (direction === "down" && dy <= 0)) + continue; + + const horizontal = direction === "left" || direction === "right"; + const overlap = horizontal + ? Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y)) + : Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x)); + + // Prefer windows sharing a boundary in the requested direction. + if (overlap <= 0) + continue; + + const edgeDistance = horizontal + ? (direction === "left" ? a.x - (b.x + b.width) : b.x - (a.x + a.width)) + : (direction === "up" ? a.y - (b.y + b.height) : b.y - (a.y + a.height)); + + if (edgeDistance < -0.001) + continue; + + const centerDistance = dx * dx + dy * dy; + const score = [edgeDistance, -overlap, centerDistance]; + if (!bestScore || + score[0] < bestScore[0] || + (score[0] === bestScore[0] && score[1] < bestScore[1]) || + (score[0] === bestScore[0] && score[1] === bestScore[1] && score[2] < bestScore[2])) { + best = item; + bestScore = score; + } + } + + return best ? best.node : null; +} + +function swapDirection(direction) { + const w = workspace.activeWindow; + if (!w || !contains(managed, w) || w.fullScreen || w.minimized) + return; + + const layout = findWindowLayout(w); + const neighbor = findSwapNeighbor(w, direction); + if (!layout || !neighbor) + return; + + const current = findLeaf(layout.root, w); + if (!current) + return; + + const other = neighbor.window; + current.window = other; + neighbor.window = w; + + // layout.last points to a leaf, so swapping cannot move the growth tip. + arrange(layout); + workspace.activeWindow = w; + log("SWAPPED " + direction + ": " + w.caption + " <-> " + other.caption); +} + // ------------------------------------------------------------ // Window states // ------------------------------------------------------------ @@ -385,6 +533,7 @@ function detachWindow(w) { if (layout) { removeNode(layout, w); + normalizeSplits(layout.root); arrange(layout); } @@ -554,6 +703,11 @@ registerShortcut( toggleFloating ); +registerShortcut("SimpleDwindleSwapLeft", "SimpleDwindle: Swap Left", "Meta+Shift+H", function() { swapDirection("left"); }); +registerShortcut("SimpleDwindleSwapDown", "SimpleDwindle: Swap Down", "Meta+Shift+J", function() { swapDirection("down"); }); +registerShortcut("SimpleDwindleSwapUp", "SimpleDwindle: Swap Up", "Meta+Shift+K", function() { swapDirection("up"); }); +registerShortcut("SimpleDwindleSwapRight", "SimpleDwindle: Swap Right", "Meta+Shift+L", function() { swapDirection("right"); }); + registerShortcut( "SimpleDwindleListWindows", "SimpleDwindle: List Windows", -- 2.55.0