mirror of
https://invent.kde.org/marcoa/shift-shell.git
synced 2026-06-11 16:57:43 +00:00
Replace the flat per-screen tiling order with a persistent layout tree so split orientation and window relationships survive retiles. Derive the per-screen tile cache from the tree when applying geometries. Make drag insertion deterministic by mapping the cursor position over a target tile to the nearest split direction and previewing the exact half that will be assigned. Retile on Workspace.onScreensChanged so screen rotation and output changes recompute tiled window geometry from the updated work area, and reconcile tiled windows that KWin moved to another output.
998 lines
35 KiB
QML
998 lines
35 KiB
QML
// 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
|
|
// - On add/remove/reorder the tree is updated and then laid out for the
|
|
// affected screen, preserving the split structure instead of rebuilding a
|
|
// fresh linear BSP from window count
|
|
// - 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
|
|
|
|
// ── 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);
|
|
}
|
|
|
|
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) {
|
|
const rootNode = screenLayouts[outputName];
|
|
const newLeaf = makeLeaf(win);
|
|
|
|
if (!rootNode) {
|
|
setScreenLayout(outputName, newLeaf);
|
|
return;
|
|
}
|
|
|
|
let targetKey = "";
|
|
const active = KWinComponents.Workspace.activeWindow;
|
|
if (active && active.output && active.output.name === outputName && windowKey(active) !== windowKey(win)) {
|
|
targetKey = windowKey(active);
|
|
}
|
|
if (!containsLeaf(rootNode, targetKey)) {
|
|
targetKey = lastLeafKey(rootNode);
|
|
}
|
|
|
|
setScreenLayout(outputName, splitLeaf(rootNode, targetKey, newLeaf, "", workRect(win)));
|
|
}
|
|
|
|
function removeWindowFromLayout(outputName, win) {
|
|
const key = windowKey(win);
|
|
const rootNode = screenLayouts[outputName];
|
|
if (!key || !rootNode) return false;
|
|
|
|
const result = removeLeaf(rootNode, key);
|
|
if (!result.leaf) return false;
|
|
setScreenLayout(outputName, result.node);
|
|
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;
|
|
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;
|
|
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);
|
|
}
|
|
}
|
|
|
|
// ── 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 "";
|
|
|
|
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 === left) return "left";
|
|
if (nearest === right) return "right";
|
|
if (nearest === top) return "up";
|
|
return "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);
|
|
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();
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
}
|