Refine Dynamic Tiling drag zones

Use edge insert zones for drag-and-drop reordering, keep drag signal
connections stable across window creation, and consume task-menu tiling
requests through the script settings bridge.
This commit is contained in:
Marco Allegretti 2026-05-10 09:32:33 +02:00
parent 3f6916cafc
commit 3fbf68d56b

View file

@ -23,6 +23,7 @@ Item {
// Configuration // Configuration
readonly property int outerGap: 8 readonly property int outerGap: 8
readonly property int innerGap: 8 // half applied to each edge 4px per tile readonly property int innerGap: 8 // half applied to each edge 4px per tile
readonly property int insertZoneThickness: 48
// State // State
@ -33,24 +34,31 @@ Item {
// Windows the user has manually floated (by UUID string). // Windows the user has manually floated (by UUID string).
property var floatedWindows: ({}) property var floatedWindows: ({})
// Whether tiling is globally enabled. // Windows whose interactive move/resize signals are already connected.
property bool tilingEnabled: true property var dragConnectedWindows: ({})
property int lastWindowRequestSerial: ShellSettings.Settings.dynamicTilingWindowRequestSerial
// Drag state. // Drag state.
// //
// Behaviour: dragging a tile and dropping it onto ANOTHER tile swaps // Behaviour: dragging a tile and dropping it onto ANOTHER tile swaps or
// their positions in the BSP layout. Dropping anywhere else does // inserts based on the cursor zone. Dropping inside the work area but
// nothing (the window will be re-tiled into its original slot on the // outside another tile restores the original slot. Dropping outside the
// next layout pass, unless KWin's native quick-tile / electric border // work area floats the window, giving users a visible escape hatch from
// takes over which is fine; we don't fight it). // the tiled layout.
property var draggingWindow: null property var draggingWindow: null
property var swapOutlineActive: false 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 // Reorder state kept stable while dragging so the rest of the layout
// doesn't shuffle under the cursor. // doesn't shuffle under the cursor.
property string dragSourceScreen: "" property string dragSourceScreen: ""
property int dragSourceIndex: -1 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 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. // Deferred retile queue.
// The dockSpaceReserver LayerShell exclusive zone needs one Wayland // The dockSpaceReserver LayerShell exclusive zone needs one Wayland
@ -118,12 +126,11 @@ Item {
if (minW > 0 && maxW > 0 && minW >= maxW) return true; if (minW > 0 && maxW > 0 && minW >= maxW) return true;
if (minH > 0 && maxH > 0 && minH >= maxH) return true; if (minH > 0 && maxH > 0 && minH >= maxH) return true;
// Manually floated // Manually floated
if (floatedWindows[win.internalId]) return true; if (floatedWindows[windowKey(win)]) return true;
return false; return false;
} }
function isTileable(win) { function isTileable(win) {
if (!tilingEnabled) return false;
if (!isConvergence()) return false; if (!isConvergence()) return false;
if (shouldIgnore(win)) return false; if (shouldIgnore(win)) return false;
if (shouldFloat(win)) return false; if (shouldFloat(win)) return false;
@ -240,6 +247,8 @@ Item {
function addWindow(win) { function addWindow(win) {
if (!isTileable(win)) return; if (!isTileable(win)) return;
connectDragHandlers(win);
const output = win.output; const output = win.output;
if (!output) return; if (!output) return;
const name = output.name; const name = output.name;
@ -251,7 +260,7 @@ Item {
// Avoid duplicates // Avoid duplicates
const tiles = screenTiles[name]; const tiles = screenTiles[name];
for (let i = 0; i < tiles.length; i++) { for (let i = 0; i < tiles.length; i++) {
if (tiles[i].win.internalId === win.internalId) return; if (windowKey(tiles[i].win) === windowKey(win)) return;
} }
tiles.push({ win: win, rect: Qt.rect(0, 0, 0, 0) }); tiles.push({ win: win, rect: Qt.rect(0, 0, 0, 0) });
@ -275,7 +284,7 @@ Item {
for (const sName in screenTiles) { for (const sName in screenTiles) {
const tiles = screenTiles[sName]; const tiles = screenTiles[sName];
for (let i = 0; i < tiles.length; i++) { for (let i = 0; i < tiles.length; i++) {
if (tiles[i].win.internalId === win.internalId) { if (windowKey(tiles[i].win) === windowKey(win)) {
tiles.splice(i, 1); tiles.splice(i, 1);
screenTiles[sName] = tiles; screenTiles[sName] = tiles;
retileScreen(sName); retileScreen(sName);
@ -285,6 +294,64 @@ Item {
} }
} }
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 // Keyboard navigation helpers
function centreOf(rect) { function centreOf(rect) {
@ -306,7 +373,7 @@ Item {
for (let i = 0; i < tiles.length; i++) { for (let i = 0; i < tiles.length; i++) {
const t = tiles[i]; const t = tiles[i];
if (t.win.internalId === fromWin.internalId) continue; if (windowKey(t.win) === windowKey(fromWin)) continue;
const tc = centreOf(t.rect); const tc = centreOf(t.rect);
const dx = tc.x - fc.x; const dx = tc.x - fc.x;
const dy = tc.y - fc.y; const dy = tc.y - fc.y;
@ -336,13 +403,12 @@ Item {
function onWindowAdded(win) { function onWindowAdded(win) {
if (isTileable(win)) { if (isTileable(win)) {
addWindow(win); addWindow(win);
win.interactiveMoveResizeStarted.connect(function() { root.onDragStart(win); });
win.interactiveMoveResizeStepped.connect(function(geo) { root.onDragStep(win, geo); });
win.interactiveMoveResizeFinished.connect(function() { root.onDragEnd(win); });
} }
} }
function onWindowRemoved(win) { function onWindowRemoved(win) {
const key = root.windowKey(win);
if (key) delete root.dragConnectedWindows[key];
root.removeWindow(win); root.removeWindow(win);
} }
} }
@ -385,6 +451,10 @@ Item {
screenTiles = {}; screenTiles = {};
} }
} }
function onDynamicTilingWindowRequestChanged() {
root.handleWindowTilingRequest();
}
} }
// Drag handlers // Drag handlers
@ -394,7 +464,7 @@ Item {
for (const sName in screenTiles) { for (const sName in screenTiles) {
const tiles = screenTiles[sName]; const tiles = screenTiles[sName];
for (let i = 0; i < tiles.length; i++) { for (let i = 0; i < tiles.length; i++) {
if (tiles[i].win && tiles[i].win.internalId === win.internalId) { if (tiles[i].win && windowKey(tiles[i].win) === windowKey(win)) {
return { screen: sName, index: i }; return { screen: sName, index: i };
} }
} }
@ -408,7 +478,7 @@ Item {
const tiles = screenTiles[sName]; const tiles = screenTiles[sName];
for (let i = 0; i < tiles.length; i++) { for (let i = 0; i < tiles.length; i++) {
const t = tiles[i]; const t = tiles[i];
if (ignoreWin && t.win && t.win.internalId === ignoreWin.internalId) continue; if (ignoreWin && t.win && windowKey(t.win) === windowKey(ignoreWin)) continue;
const r = t.rect; const r = t.rect;
if (!r || r.width <= 0 || r.height <= 0) continue; if (!r || r.width <= 0 || r.height <= 0) continue;
if (cursor.x >= r.x && cursor.x <= r.x + r.width && if (cursor.x >= r.x && cursor.x <= r.x + r.width &&
@ -420,60 +490,230 @@ Item {
return null; 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) { function onDragStart(win) {
if (!isConvergence()) return; if (!isConvergence()) return;
draggingWindow = win; draggingWindow = win;
swapOutlineActive = false; clearDragOutline();
dragSwapTarget = null; dragSwapTarget = null;
dragInsertTarget = null;
dragSourceRect = Qt.rect(0, 0, 0, 0);
// Remember the source slot so we can swap on drop. // 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 tile stays in screenTiles[] during the drag so the rest of
// the layout doesn't shuffle. // the layout doesn't shuffle.
const slot = findTileSlot(win); const slot = findTileSlot(win);
if (slot) { if (slot) {
dragSourceScreen = slot.screen; dragSourceScreen = slot.screen;
dragSourceIndex = slot.index; 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 { } else {
dragSourceScreen = ""; dragSourceScreen = "";
dragSourceIndex = -1; dragSourceIndex = -1;
} }
showDragOutline("restore", dragSourceRect);
} }
function onDragStep(win, geo) { function onDragStep(win, geo) {
if (!isConvergence()) return; if (!isConvergence()) {
if (draggingWindow !== win) return; resetDragState();
return;
// Only show an outline when the cursor is over another tile
// a clear visual hint that "drop here = swap".
const cursor = KWinComponents.Workspace.cursorPos;
const target = findTileAtCursor(cursor, win);
if (target) {
if (!dragSwapTarget ||
dragSwapTarget.screen !== target.screen ||
dragSwapTarget.index !== target.index) {
dragSwapTarget = target;
KWinComponents.Workspace.showOutline(target.rect);
swapOutlineActive = true;
}
} else {
dragSwapTarget = null;
if (swapOutlineActive) {
KWinComponents.Workspace.hideOutline();
swapOutlineActive = false;
}
} }
if (draggingWindow !== win) return;
updateDragIntent(win, geo);
} }
function onDragEnd(win) { function onDragEnd(win) {
if (!isConvergence()) return; if (!isConvergence()) {
if (swapOutlineActive) { resetDragState();
KWinComponents.Workspace.hideOutline(); return;
swapOutlineActive = false; }
const finalDropMode = dragDropMode;
clearDragOutline();
if (finalDropMode === "float") {
floatWindow(win);
}
else if (finalDropMode === "insert" && dragInsertTarget) {
insertDraggedWindow(dragInsertTarget);
} }
// Dropped on another tile swap source/target slots. // Dropped on another tile swap source/target slots.
if (dragSwapTarget && dragSourceScreen && dragSourceIndex >= 0) { else if (dragSwapTarget && dragSourceScreen && dragSourceIndex >= 0) {
const sScreen = dragSourceScreen; const sScreen = dragSourceScreen;
const sIdx = dragSourceIndex; const sIdx = dragSourceIndex;
const tScreen = dragSwapTarget.screen; const tScreen = dragSwapTarget.screen;
@ -506,10 +746,7 @@ Item {
retileScreen(dragSourceScreen); retileScreen(dragSourceScreen);
} }
dragSwapTarget = null; resetDragState();
dragSourceScreen = "";
dragSourceIndex = -1;
draggingWindow = null;
} }
// Keyboard shortcuts // Keyboard shortcuts
@ -568,13 +805,11 @@ Item {
onActivated: { onActivated: {
const win = KWinComponents.Workspace.activeWindow; const win = KWinComponents.Workspace.activeWindow;
if (!win) return; if (!win) return;
const id = win.internalId; const id = root.windowKey(win);
if (root.floatedWindows[id]) { if (root.floatedWindows[id]) {
delete root.floatedWindows[id]; root.tileWindow(win);
root.addWindow(win);
} else { } else {
root.floatedWindows[id] = true; root.floatWindow(win);
root.removeWindow(win);
} }
} }
} }
@ -585,21 +820,12 @@ Item {
text: "SHIFT Tiling: Toggle tiling on/off" text: "SHIFT Tiling: Toggle tiling on/off"
sequence: "Meta+T" sequence: "Meta+T"
onActivated: { onActivated: {
root.tilingEnabled = !root.tilingEnabled; ShellSettings.Settings.dynamicTilingEnabled = !ShellSettings.Settings.dynamicTilingEnabled;
if (root.tilingEnabled) {
const wins = KWinComponents.Workspace.windows;
for (let i = 0; i < wins.length; i++) root.addWindow(wins[i]);
} else {
root.screenTiles = {};
}
} }
} }
// Right-click menu // Component setup
// Note: registerUserActionsMenu is a global function in KWin JS scripts.
// In declarative QML scripts it is exposed via the KWin global object.
// We wire it up after the component is complete.
Component.onCompleted: { Component.onCompleted: {
// Connect to existing windows // Connect to existing windows
const wins = KWinComponents.Workspace.windows; const wins = KWinComponents.Workspace.windows;
@ -607,9 +833,6 @@ Item {
const win = wins[i]; const win = wins[i];
if (isTileable(win)) { if (isTileable(win)) {
addWindow(win); addWindow(win);
win.interactiveMoveResizeStarted.connect(function() { root.onDragStart(win); });
win.interactiveMoveResizeStepped.connect(function(geo) { root.onDragStep(win, geo); });
win.interactiveMoveResizeFinished.connect(function() { root.onDragEnd(win); });
} }
} }
} }