mirror of
https://invent.kde.org/marcoa/shift-shell.git
synced 2026-06-11 00:47:22 +00:00
Keep one layout per output and virtual desktop. Move new windows to another existing desktop when the current page is full. When a user moves a window onto a full page, replace the last focused window on that page and send it back to the source desktop. Use stable slot swaps instead of insert splits so the layout shape does not change during moves.
1281 lines
44 KiB
QML
1281 lines
44 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 and virtual desktop
|
|
// - Tree nodes are split groups or window leaves; leaf rects are absolute
|
|
// in-screen coordinates
|
|
// - Automatic add/remove rebuilds a stable master-stack tree from window
|
|
// order, so normal app churn does not leave arbitrary nested splits behind
|
|
// - If the current page is full, new windows move to another existing
|
|
// virtual desktop with free capacity
|
|
// - The current screen can cycle between master-stack, equal columns, and
|
|
// equal rows without changing window order
|
|
// - Dragging over an existing tile swaps the two window leaves without
|
|
// changing the split tree shape
|
|
// - Drag detection uses interactiveMoveResizeStarted/Stepped/Finished
|
|
// - Dragging over a tile previews the slot that will be swapped 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
|
|
readonly property int maxWindowsPerPage: 4
|
|
readonly property real stablePrimaryRatio: 0.58
|
|
readonly property var layoutModes: ["master", "columns", "rows"]
|
|
|
|
// ── State ───────────────────────────────────────────────────────────────
|
|
|
|
// Per-screen/per-desktop layout tree. Key: output.name + desktop key.
|
|
// Value: split/leaf node.
|
|
property var screenLayouts: ({})
|
|
|
|
// Per-screen/per-desktop tile list derived from screenLayouts.
|
|
// Value: [{win, rect}], where rect is absolute in-screen coordinates.
|
|
property var screenTiles: ({})
|
|
|
|
// Per-screen/per-desktop layout mode. Key: output.name + desktop key.
|
|
// Value: one of layoutModes.
|
|
property var screenLayoutModes: ({})
|
|
|
|
// Last focused tiled window per screen/desktop page. Used as the
|
|
// replacement target when a user manually moves another window onto a
|
|
// full page.
|
|
property var lastFocusedWindowKeys: ({})
|
|
|
|
// 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 target slot
|
|
// that will be swapped 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: "" // "swap", "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 dragSwapTarget: null // {screen, key, 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();
|
|
}
|
|
|
|
function desktopKey(desktop) {
|
|
if (!desktop) return "";
|
|
if (desktop.x11DesktopNumber !== undefined) return String(desktop.x11DesktopNumber);
|
|
|
|
const desktops = KWinComponents.Workspace.desktops;
|
|
const index = desktops ? desktops.indexOf(desktop) : -1;
|
|
return index >= 0 ? String(index + 1) : String(desktop);
|
|
}
|
|
|
|
function desktopForWindow(win) {
|
|
if (win && win.desktops && win.desktops.length > 0) {
|
|
return win.desktops[0];
|
|
}
|
|
return KWinComponents.Workspace.currentDesktop;
|
|
}
|
|
|
|
function layoutKeyFor(outputName, desktop) {
|
|
const key = desktopKey(desktop);
|
|
return outputName && key !== "" ? outputName + "::" + key : "";
|
|
}
|
|
|
|
function desktopForLayoutKey(outputName) {
|
|
const separator = outputName.lastIndexOf("::");
|
|
if (separator < 0) return null;
|
|
|
|
const wantedKey = outputName.slice(separator + 2);
|
|
const desktops = KWinComponents.Workspace.desktops;
|
|
if (!desktops) return null;
|
|
|
|
for (let i = 0; i < desktops.length; i++) {
|
|
if (desktopKey(desktops[i]) === wantedKey) {
|
|
return desktops[i];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function layoutKeyForWindow(win) {
|
|
if (!win || !win.output) return "";
|
|
return layoutKeyFor(win.output.name, desktopForWindow(win));
|
|
}
|
|
|
|
function assignWindowToDesktop(win, desktop) {
|
|
if (!win || !desktop) return;
|
|
if (win.desktops && win.desktops.length === 1 && win.desktops[0] === desktop) return;
|
|
win.desktops = [desktop];
|
|
}
|
|
|
|
function windowCountForLayout(outputName) {
|
|
const leaves = [];
|
|
collectLeaves(screenLayouts[outputName], leaves);
|
|
let count = 0;
|
|
for (let i = 0; i < leaves.length; i++) {
|
|
const win = leaves[i].win;
|
|
if (win && !win.deleted && isTileable(win) && layoutKeyForWindow(win) === outputName) {
|
|
count++;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function targetLayoutForNewWindow(win) {
|
|
const currentDesktop = desktopForWindow(win);
|
|
const currentKey = layoutKeyForWindow(win);
|
|
if (!win || !win.output || currentKey === "") {
|
|
return { key: "", desktop: null };
|
|
}
|
|
if (windowCountForLayout(currentKey) < maxWindowsPerPage) {
|
|
return { key: currentKey, desktop: currentDesktop };
|
|
}
|
|
|
|
const desktops = KWinComponents.Workspace.desktops;
|
|
if (!desktops || desktops.length < 2) {
|
|
return { key: currentKey, desktop: currentDesktop };
|
|
}
|
|
|
|
let startIndex = desktops.indexOf(currentDesktop);
|
|
if (startIndex < 0) {
|
|
startIndex = desktops.indexOf(KWinComponents.Workspace.currentDesktop);
|
|
}
|
|
if (startIndex < 0) {
|
|
startIndex = 0;
|
|
}
|
|
|
|
for (let offset = 1; offset < desktops.length; offset++) {
|
|
const desktop = desktops[(startIndex + offset) % desktops.length];
|
|
const key = layoutKeyFor(win.output.name, desktop);
|
|
if (key !== "" && windowCountForLayout(key) < maxWindowsPerPage) {
|
|
return { key: key, desktop: desktop };
|
|
}
|
|
}
|
|
|
|
return { key: currentKey, desktop: currentDesktop };
|
|
}
|
|
|
|
// ── 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 = desktopForWindow(win);
|
|
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 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 leafIndexForWindow(outputName, win) {
|
|
const key = windowKey(win);
|
|
if (!key) return -1;
|
|
|
|
const leaves = [];
|
|
collectLeaves(screenLayouts[outputName], leaves);
|
|
for (let i = 0; i < leaves.length; i++) {
|
|
if (windowKey(leaves[i].win) === key) {
|
|
return i;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function leafForSlot(outputName, preferredIndex) {
|
|
const leaves = [];
|
|
collectLeaves(screenLayouts[outputName], leaves);
|
|
if (leaves.length === 0) return null;
|
|
if (preferredIndex >= 0 && preferredIndex < leaves.length) {
|
|
return leaves[preferredIndex];
|
|
}
|
|
return leaves[leaves.length - 1];
|
|
}
|
|
|
|
function rememberFocusedWindow(win) {
|
|
if (!isTileable(win)) return;
|
|
|
|
const outputName = layoutKeyForWindow(win);
|
|
const key = windowKey(win);
|
|
if (!outputName || !key || !containsLeaf(screenLayouts[outputName], key)) return;
|
|
|
|
const focused = Object.assign({}, lastFocusedWindowKeys);
|
|
focused[outputName] = key;
|
|
lastFocusedWindowKeys = focused;
|
|
}
|
|
|
|
function replacementLeafForLayout(outputName, fallbackIndex) {
|
|
const focusedKey = lastFocusedWindowKeys[outputName] || "";
|
|
const focusedLeaf = focusedKey ? findLeaf(screenLayouts[outputName], focusedKey) : null;
|
|
if (focusedLeaf && focusedLeaf.win && layoutKeyForWindow(focusedLeaf.win) === outputName) {
|
|
return focusedLeaf;
|
|
}
|
|
return leafForSlot(outputName, fallbackIndex);
|
|
}
|
|
|
|
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 (layoutKeyForWindow(win) !== 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 buildLinearLayout(windows, startIndex, orientation) {
|
|
const remaining = windows.length - startIndex;
|
|
if (remaining <= 0) return null;
|
|
if (remaining === 1) return makeLeaf(windows[startIndex]);
|
|
|
|
return makeSplit(
|
|
orientation,
|
|
makeLeaf(windows[startIndex]),
|
|
buildLinearLayout(windows, startIndex + 1, orientation),
|
|
1 / remaining
|
|
);
|
|
}
|
|
|
|
function layoutModeForScreen(outputName) {
|
|
const mode = screenLayoutModes[outputName];
|
|
return layoutModes.indexOf(mode) >= 0 ? mode : "master";
|
|
}
|
|
|
|
function setLayoutModeForScreen(outputName, mode) {
|
|
if (!outputName || layoutModes.indexOf(mode) < 0) return;
|
|
const modes = Object.assign({}, screenLayoutModes);
|
|
modes[outputName] = mode;
|
|
screenLayoutModes = modes;
|
|
}
|
|
|
|
function buildStableLayout(windows, mode) {
|
|
if (!windows || windows.length === 0) return null;
|
|
if (windows.length === 1) return makeLeaf(windows[0]);
|
|
if (mode === "columns") {
|
|
return buildLinearLayout(windows, 0, "vertical");
|
|
}
|
|
if (mode === "rows") {
|
|
return buildLinearLayout(windows, 0, "horizontal");
|
|
}
|
|
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, layoutModeForScreen(outputName)));
|
|
}
|
|
|
|
function outputNameForActiveWindow() {
|
|
const win = KWinComponents.Workspace.activeWindow;
|
|
const activeName = layoutKeyForWindow(win);
|
|
if (activeName !== "") return activeName;
|
|
for (const name in screenLayouts) {
|
|
return name;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function cycleLayoutMode(outputName) {
|
|
if (!outputName) return;
|
|
|
|
const currentMode = layoutModeForScreen(outputName);
|
|
const currentIndex = layoutModes.indexOf(currentMode);
|
|
const nextMode = layoutModes[(currentIndex + 1) % layoutModes.length];
|
|
setLayoutModeForScreen(outputName, nextMode);
|
|
|
|
const windows = orderedWindowsForScreen(outputName);
|
|
if (windows.length > 0) {
|
|
setStableLayout(outputName, windows);
|
|
retileScreen(outputName);
|
|
scheduleRetile(outputName);
|
|
}
|
|
}
|
|
|
|
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 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 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;
|
|
|
|
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 (layoutKeyForWindow(leafWindow) !== outputName) continue;
|
|
seen[leafKey] = true;
|
|
remaining.push(leafWindow);
|
|
}
|
|
|
|
if (!removed) return false;
|
|
setStableLayout(outputName, remaining);
|
|
return true;
|
|
}
|
|
|
|
function swapWindowBetweenLayouts(win, sourceName, targetName) {
|
|
if (!win || !sourceName || !targetName || sourceName === targetName) return false;
|
|
|
|
const sourceDesktop = desktopForLayoutKey(sourceName);
|
|
const sourceLeaf = findLeaf(screenLayouts[sourceName], windowKey(win));
|
|
if (!sourceDesktop || !sourceLeaf) return false;
|
|
|
|
const sourceIndex = leafIndexForWindow(sourceName, win);
|
|
const targetLeaf = replacementLeafForLayout(targetName, sourceIndex);
|
|
if (!targetLeaf || !targetLeaf.win || windowKey(targetLeaf.win) === windowKey(win)) return false;
|
|
|
|
const displacedWindow = targetLeaf.win;
|
|
targetLeaf.win = win;
|
|
sourceLeaf.win = displacedWindow;
|
|
assignWindowToDesktop(displacedWindow, sourceDesktop);
|
|
|
|
retileScreen(sourceName);
|
|
retileScreen(targetName);
|
|
scheduleRetile(sourceName);
|
|
scheduleRetile(targetName);
|
|
KWinComponents.Workspace.activeWindow = win;
|
|
return true;
|
|
}
|
|
|
|
// 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 = layoutKeyForWindow(win);
|
|
if (targetName === "") return;
|
|
|
|
if (currentName !== "" && currentName !== targetName) {
|
|
if (windowCountForLayout(targetName) >= maxWindowsPerPage && swapWindowBetweenLayouts(win, currentName, targetName)) {
|
|
return;
|
|
}
|
|
removeWindowFromLayout(currentName, win);
|
|
scheduleRetile(currentName);
|
|
}
|
|
|
|
if (!containsLeaf(screenLayouts[targetName], windowKey(win))) {
|
|
insertWindowInLayout(targetName, win);
|
|
}
|
|
|
|
if (win.maximizable) win.setMaximize(false, false);
|
|
win.noBorder = false;
|
|
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 initialName = layoutKeyForWindow(win);
|
|
const target = targetLayoutForNewWindow(win);
|
|
if (target.key === "") return;
|
|
const name = target.key;
|
|
|
|
if (target.desktop && initialName !== name) {
|
|
assignWindowToDesktop(win, target.desktop);
|
|
KWinComponents.Workspace.currentDesktop = target.desktop;
|
|
}
|
|
|
|
// 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;
|
|
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); });
|
|
if (win.desktopsChanged !== undefined) {
|
|
win.desktopsChanged.connect(function() { root.reconcileWindowOutput(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);
|
|
}
|
|
}
|
|
|
|
function promoteWindow(win) {
|
|
if (!isTileable(win)) return;
|
|
|
|
const outputName = screenNameForWindow(win) || layoutKeyForWindow(win);
|
|
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 = layoutKeyForWindow(fromWin);
|
|
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 onActiveWindowChanged() {
|
|
root.rememberFocusedWindow(KWinComponents.Workspace.activeWindow);
|
|
}
|
|
|
|
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 = {};
|
|
screenLayoutModes = {};
|
|
lastFocusedWindowKeys = {};
|
|
}
|
|
}
|
|
|
|
function onGamingModeEnabledChanged() {
|
|
if (ShellSettings.Settings.gamingModeEnabled) {
|
|
screenLayouts = {};
|
|
screenTiles = {};
|
|
screenLayoutModes = {};
|
|
lastFocusedWindowKeys = {};
|
|
} 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 = {};
|
|
screenLayoutModes = {};
|
|
lastFocusedWindowKeys = {};
|
|
}
|
|
}
|
|
|
|
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) {
|
|
const activeName = dragSourceScreen || layoutKeyForWindow(ignoreWin);
|
|
for (const sName in screenTiles) {
|
|
if (activeName && sName !== activeName) continue;
|
|
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 findLeaf(node, key) {
|
|
if (!node || !key) return null;
|
|
if (node.kind === "leaf") {
|
|
return windowKey(node.win) === key ? node : null;
|
|
}
|
|
return findLeaf(node.first, key) || findLeaf(node.second, key);
|
|
}
|
|
|
|
function swapDraggedWindow(target) {
|
|
if (!target || !draggingWindow || !dragSourceScreen) return;
|
|
|
|
const sourceKey = windowKey(draggingWindow);
|
|
if (!sourceKey || sourceKey === target.key || target.screen !== dragSourceScreen) return;
|
|
|
|
const rootNode = screenLayouts[dragSourceScreen];
|
|
const sourceLeaf = findLeaf(rootNode, sourceKey);
|
|
const targetLeaf = findLeaf(rootNode, target.key);
|
|
if (!sourceLeaf || !targetLeaf) return;
|
|
|
|
const sourceWindow = sourceLeaf.win;
|
|
sourceLeaf.win = targetLeaf.win;
|
|
targetLeaf.win = sourceWindow;
|
|
retileScreen(dragSourceScreen);
|
|
scheduleRetile(dragSourceScreen);
|
|
KWinComponents.Workspace.activeWindow = sourceWindow;
|
|
}
|
|
|
|
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();
|
|
dragSwapTarget = 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 && target.screen === dragSourceScreen && validRect(target.rect)) {
|
|
dragSwapTarget = {
|
|
screen: target.screen,
|
|
key: target.key,
|
|
rect: target.rect
|
|
};
|
|
showDragOutline("swap", target.rect);
|
|
return;
|
|
}
|
|
|
|
dragSwapTarget = 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();
|
|
dragSwapTarget = null;
|
|
dragSourceRect = Qt.rect(0, 0, 0, 0);
|
|
|
|
// Remember the source slot so we can swap 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 === "swap" && dragSwapTarget) {
|
|
swapDraggedWindow(dragSwapTarget);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
KWinComponents.ShortcutHandler {
|
|
name: "SHIFT Tiling Cycle Layout"
|
|
text: "SHIFT Tiling: Cycle layout mode"
|
|
sequence: "Meta+Shift+T"
|
|
onActivated: root.cycleLayoutMode(root.outputNameForActiveWindow())
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
rememberFocusedWindow(KWinComponents.Workspace.activeWindow);
|
|
}
|
|
}
|