shift-shell/kwin/scripts/shift-tiling/contents/ui/main.qml

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

1108 lines
38 KiB
QML
Raw Normal View History

// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
//
// SHIFT Dynamic Tiling — KWin declarative script
//
// Architecture:
// - One persistent layout tree per output, keyed by output.name
// - Tree nodes are split groups or window leaves; leaf rects are absolute
// in-screen coordinates
2026-05-19 07:55:25 +00:00
// - Automatic add/remove rebuilds a stable master-stack tree from window
// order, so normal app churn does not leave arbitrary nested splits behind
// - Explicit drag insertion still edits the tree directly because that is a
// deliberate user layout change
// - Drag detection uses interactiveMoveResizeStarted/Stepped/Finished
// - Dragging over a tile shows the exact directional split that will be
// created 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
2026-05-19 07:55:25 +00:00
readonly property real stablePrimaryRatio: 0.58
2026-05-19 08:00:43 +00:00
readonly property real insertIntentDeadZone: 0.18
// ── State ───────────────────────────────────────────────────────────────
// Per-screen layout tree. Key: output.name. Value: split/leaf node.
property var screenLayouts: ({})
// Per-screen tile list derived from screenLayouts. Key: output.name.
// Value: [{win, rect}], where rect is absolute in-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 over another tile previews the exact
// left/right/up/down split that will be created 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: "" // "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 dragInsertTarget: null // {screen, key, direction, 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;
}
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 splitOrientation(rect) {
return rect && rect.width >= rect.height ? "vertical" : "horizontal";
}
function directionOrientation(direction, rect) {
if (direction === "left" || direction === "right") return "vertical";
if (direction === "up" || direction === "down") return "horizontal";
return splitOrientation(rect);
}
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 lastLeafKey(node) {
if (!node) return "";
if (node.kind === "leaf") return windowKey(node.win);
return lastLeafKey(node.second) || lastLeafKey(node.first);
}
2026-05-19 07:55:25 +00:00
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 (!win.output || win.output.name !== 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 buildStableLayout(windows) {
if (!windows || windows.length === 0) return null;
if (windows.length === 1) return makeLeaf(windows[0]);
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));
}
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 splitLeaf(node, targetKey, newLeaf, direction, fallbackRect) {
if (!node || !targetKey || !newLeaf) return node;
if (node.kind === "leaf") {
if (windowKey(node.win) !== targetKey) return node;
const baseRect = validRect(node.rect) ? node.rect : fallbackRect;
const orientation = directionOrientation(direction, baseRect);
if (direction === "left" || direction === "up") {
return makeSplit(orientation, newLeaf, node, 0.5);
}
return makeSplit(orientation, node, newLeaf, 0.5);
}
node.first = splitLeaf(node.first, targetKey, newLeaf, direction, fallbackRect);
node.second = splitLeaf(node.second, targetKey, newLeaf, direction, fallbackRect);
return node;
}
function removeLeaf(node, key) {
if (!node || !key) return { node: node, leaf: null };
if (node.kind === "leaf") {
if (windowKey(node.win) === key) {
return { node: null, leaf: node };
}
return { node: node, leaf: null };
}
let first = removeLeaf(node.first, key);
if (first.leaf) {
node.first = first.node;
if (!node.first) return { node: node.second, leaf: first.leaf };
return { node: node, leaf: first.leaf };
}
let second = removeLeaf(node.second, key);
if (second.leaf) {
node.second = second.node;
if (!node.second) return { node: node.first, leaf: second.leaf };
return { node: node, leaf: second.leaf };
}
return { node: node, leaf: null };
}
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) {
2026-05-19 07:55:25 +00:00
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;
2026-05-19 07:55:25 +00:00
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 (!leafWindow.output || leafWindow.output.name !== outputName) continue;
seen[leafKey] = true;
remaining.push(leafWindow);
}
if (!removed) return false;
setStableLayout(outputName, remaining);
return true;
}
function moveWindowToSplit(target) {
if (!target || !draggingWindow || !dragSourceScreen) return;
const sourceKey = windowKey(draggingWindow);
if (!sourceKey || sourceKey === target.key) return;
const sourceRoot = screenLayouts[dragSourceScreen];
const detached = removeLeaf(sourceRoot, sourceKey);
if (!detached.leaf) return;
setScreenLayout(dragSourceScreen, detached.node);
const targetRoot = screenLayouts[target.screen];
if (!targetRoot || !containsLeaf(targetRoot, target.key)) {
setScreenLayout(dragSourceScreen, sourceRoot);
return;
}
setScreenLayout(target.screen, splitLeaf(targetRoot, target.key, detached.leaf, target.direction, target.rect));
retileScreen(dragSourceScreen);
if (target.screen !== dragSourceScreen) {
retileScreen(target.screen);
}
}
// 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 = output.name;
if (currentName !== "" && currentName !== targetName) {
removeWindowFromLayout(currentName, win);
scheduleRetile(currentName);
}
if (!containsLeaf(screenLayouts[targetName], windowKey(win))) {
insertWindowInLayout(targetName, win);
}
if (win.maximizable) win.setMaximize(false, false);
win.noBorder = false;
2026-05-19 08:00:43 +00:00
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 name = output.name;
// 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;
2026-05-19 08:00:43 +00:00
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); });
}
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);
}
}
2026-05-19 08:01:45 +00:00
function promoteWindow(win) {
if (!isTileable(win)) return;
const outputName = screenNameForWindow(win) || (win.output ? win.output.name : "");
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 = 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);
}
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 = {};
}
}
function onGamingModeEnabledChanged() {
if (ShellSettings.Settings.gamingModeEnabled) {
screenLayouts = {};
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.
screenLayouts = {};
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, key: windowKey(t.win), win: t.win };
}
}
}
return null;
}
function tileInsertDirection(cursor, rect) {
if (!validRect(rect)) return "";
2026-05-19 08:00:43 +00:00
const relativeX = (cursor.x - rect.x) / rect.width;
const relativeY = (cursor.y - rect.y) / rect.height;
const fromCenterX = relativeX - 0.5;
const fromCenterY = relativeY - 0.5;
if (Math.abs(fromCenterX) < insertIntentDeadZone && Math.abs(fromCenterY) < insertIntentDeadZone) {
return "";
}
2026-05-19 08:00:43 +00:00
if (Math.abs(fromCenterX) >= Math.abs(fromCenterY)) {
return fromCenterX < 0 ? "left" : "right";
}
return fromCenterY < 0 ? "up" : "down";
}
function previewInsertRect(win, target, position) {
if (!target || !validRect(target.rect) || dragSourceIndex < 0) return null;
const orientation = directionOrientation(position, target.rect);
const rects = splitRect(target.rect, orientation, 0.5);
if (position === "left" || position === "up") return rects[0];
return rects[1];
}
function insertDraggedWindow(target) {
moveWindowToSplit(target);
}
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();
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 = tileInsertDirection(cursor, target.rect);
const insertRect = previewInsertRect(win, target, position);
if (validRect(insertRect)) {
dragInsertTarget = {
screen: target.screen,
key: target.key,
direction: position,
rect: insertRect,
position: position
};
showDragOutline("insert", insertRect);
return;
}
}
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();
dragInsertTarget = null;
dragSourceRect = Qt.rect(0, 0, 0, 0);
// Remember the source slot so we can 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 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);
}
}
}
2026-05-19 08:01:45 +00:00
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);
}
}
// 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);
}
}
}
}