]> Freerunner's - simpledwindle.git/commitdiff
Add keybinds for moving clients within the tree
authorAndre Ramnitz <andre.ramnitz@mail.de>
Tue, 20 Oct 2026 16:48:09 +0000 (18:48 +0200)
committerAndre Ramnitz <andre.ramnitz@mail.de>
Tue, 20 Oct 2026 16:48:09 +0000 (18:48 +0200)
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

index 23922b443e4a5c1e54ebd885141fdca55f400d37..d07e96cf042df549b234b87bbdc3d56a4e46992c 100644 (file)
@@ -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",