mirror of
https://invent.kde.org/marcoa/shift-shell.git
synced 2026-06-11 08:57:21 +00:00
2181 lines
77 KiB
QML
2181 lines
77 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: ({})
|
|
|
|
// Per-screen/per-desktop maximize sessions. A session keeps one window on
|
|
// the source page and parks the displaced windows on other desktops.
|
|
property var maximizedLayouts: ({})
|
|
|
|
// Windows temporarily parked by a maximize session. Key: window UUID.
|
|
// Value: the layout key of the page that may restore it.
|
|
property var displacedWindowOwners: ({})
|
|
|
|
// Virtual desktops created by dynamic tiling for overflow parking.
|
|
// User-created desktops are never removed by the script.
|
|
property var autoParkingDesktopKeys: ({})
|
|
|
|
// 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.
|
|
// Values keep callback references so KWin script reloads can disconnect them.
|
|
property var dragConnectedWindows: ({})
|
|
|
|
property int lastWindowRequestSerial: ShellSettings.Settings.dynamicTilingWindowRequestSerial
|
|
property string lastPublishedMaximizedWindowIds: "__unpublished__"
|
|
|
|
// 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 bool dragNativeResize: false
|
|
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 normalizeWindowDesktopScope(win) {
|
|
if (!win || (win.desktops && win.desktops.length > 0)) return;
|
|
|
|
const desktop = KWinComponents.Workspace.currentDesktop;
|
|
if (desktop) {
|
|
win.desktops = [desktop];
|
|
}
|
|
}
|
|
|
|
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 desktopForMoveRequest(desktopId, desktopNumber) {
|
|
const wantedId = String(desktopId || "");
|
|
const wantedNumber = String(desktopNumber || "");
|
|
const desktops = KWinComponents.Workspace.desktops;
|
|
if (!desktops) return null;
|
|
|
|
for (let i = 0; i < desktops.length; i++) {
|
|
const desktop = desktops[i];
|
|
const indexKey = String(i + 1);
|
|
if (wantedId !== "") {
|
|
if (desktop.id !== undefined && String(desktop.id) === wantedId) return desktop;
|
|
if (desktop.uuid !== undefined && String(desktop.uuid) === wantedId) return desktop;
|
|
if (desktop.name !== undefined && String(desktop.name) === wantedId) return desktop;
|
|
if (wantedId === indexKey) return desktop;
|
|
if (desktop.x11DesktopNumber !== undefined && String(desktop.x11DesktopNumber) === wantedId) return desktop;
|
|
}
|
|
if (wantedNumber !== "") {
|
|
if (wantedNumber === indexKey) return desktop;
|
|
if (desktop.x11DesktopNumber !== undefined && String(desktop.x11DesktopNumber) === wantedNumber) return desktop;
|
|
}
|
|
}
|
|
|
|
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 (maximizedLayouts[key]) continue;
|
|
if (key !== "" && windowCountForLayout(key) < maxWindowsPerPage) {
|
|
return { key: key, desktop: desktop };
|
|
}
|
|
}
|
|
|
|
return { key: currentKey, desktop: currentDesktop };
|
|
}
|
|
|
|
function targetLayoutForDisplacedWindow(win, sourceDesktop, reservedCounts) {
|
|
if (!win || !win.output || !sourceDesktop) {
|
|
return { key: "", desktop: null };
|
|
}
|
|
|
|
const desktops = KWinComponents.Workspace.desktops;
|
|
if (!desktops) {
|
|
return { key: "", desktop: null };
|
|
}
|
|
|
|
let startIndex = desktops.indexOf(sourceDesktop);
|
|
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 === "") continue;
|
|
if (maximizedLayouts[key]) continue;
|
|
|
|
const reserved = reservedCounts && reservedCounts[key] ? reservedCounts[key] : 0;
|
|
if (windowCountForLayout(key) + reserved < maxWindowsPerPage) {
|
|
return { key: key, desktop: desktop };
|
|
}
|
|
}
|
|
|
|
const createdDesktop = createParkingDesktop();
|
|
if (!createdDesktop) {
|
|
return { key: "", desktop: null };
|
|
}
|
|
|
|
return { key: layoutKeyFor(win.output.name, createdDesktop), desktop: createdDesktop };
|
|
}
|
|
|
|
function createParkingDesktop() {
|
|
const desktops = KWinComponents.Workspace.desktops;
|
|
if (!desktops || typeof KWinComponents.Workspace.createDesktop !== "function") {
|
|
return null;
|
|
}
|
|
|
|
const previousCount = desktops.length;
|
|
const created = KWinComponents.Workspace.createDesktop(previousCount, "");
|
|
if (created) {
|
|
rememberAutoParkingDesktop(created);
|
|
return created;
|
|
}
|
|
|
|
const updatedDesktops = KWinComponents.Workspace.desktops;
|
|
if (updatedDesktops && updatedDesktops.length > previousCount) {
|
|
const desktop = updatedDesktops[updatedDesktops.length - 1];
|
|
rememberAutoParkingDesktop(desktop);
|
|
return desktop;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function createUserDesktop() {
|
|
const desktops = KWinComponents.Workspace.desktops;
|
|
if (!desktops || typeof KWinComponents.Workspace.createDesktop !== "function") {
|
|
return null;
|
|
}
|
|
|
|
const previousCount = desktops.length;
|
|
const created = KWinComponents.Workspace.createDesktop(previousCount, "");
|
|
if (created) {
|
|
return created;
|
|
}
|
|
|
|
const updatedDesktops = KWinComponents.Workspace.desktops;
|
|
if (updatedDesktops && updatedDesktops.length > previousCount) {
|
|
return updatedDesktops[updatedDesktops.length - 1];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function rememberAutoParkingDesktop(desktop) {
|
|
const key = desktopKey(desktop);
|
|
if (key === "") return;
|
|
|
|
const keys = Object.assign({}, autoParkingDesktopKeys);
|
|
keys[key] = true;
|
|
autoParkingDesktopKeys = keys;
|
|
}
|
|
|
|
function forgetAutoParkingDesktop(desktop) {
|
|
const key = desktopKey(desktop);
|
|
if (key === "" || !autoParkingDesktopKeys[key]) return;
|
|
|
|
const keys = Object.assign({}, autoParkingDesktopKeys);
|
|
delete keys[key];
|
|
autoParkingDesktopKeys = keys;
|
|
}
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
function dynamicTilingOwnsWindow(win) {
|
|
if (!isConvergence()) return false;
|
|
if (!win || win.deleted || !win.normalWindow || !win.maximizable) return false;
|
|
if (floatedWindows[windowKey(win)]) return false;
|
|
return screenNameForWindow(win) !== "" || !shouldFloat(win);
|
|
}
|
|
|
|
function normalizeNativeWindowOperationState(win) {
|
|
if (!dynamicTilingOwnsWindow(win)) return false;
|
|
|
|
let changed = false;
|
|
if (win.fullScreen === true) {
|
|
try {
|
|
win.fullScreen = false;
|
|
changed = true;
|
|
} catch (error) {}
|
|
}
|
|
if (win.noBorder === true) {
|
|
win.noBorder = false;
|
|
changed = true;
|
|
}
|
|
if (win.keepAbove === true) {
|
|
win.keepAbove = false;
|
|
changed = true;
|
|
}
|
|
if (win.keepBelow === true) {
|
|
win.keepBelow = false;
|
|
changed = true;
|
|
}
|
|
|
|
return changed;
|
|
}
|
|
|
|
// ── 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 clearLayoutMetadata(outputName) {
|
|
if (!outputName) return;
|
|
|
|
const modes = Object.assign({}, screenLayoutModes);
|
|
delete modes[outputName];
|
|
screenLayoutModes = modes;
|
|
|
|
const focused = Object.assign({}, lastFocusedWindowKeys);
|
|
delete focused[outputName];
|
|
lastFocusedWindowKeys = focused;
|
|
}
|
|
|
|
function clearDisplacedWindowOwnersForLayout(outputName) {
|
|
if (!outputName) return;
|
|
|
|
const owners = Object.assign({}, displacedWindowOwners);
|
|
let changed = false;
|
|
for (const key in owners) {
|
|
if (owners[key] === outputName) {
|
|
delete owners[key];
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (changed) {
|
|
displacedWindowOwners = owners;
|
|
}
|
|
}
|
|
|
|
function desktopHasNormalWindows(desktop) {
|
|
if (!desktop) return true;
|
|
|
|
const wins = KWinComponents.Workspace.windows;
|
|
for (let i = 0; i < wins.length; i++) {
|
|
const win = wins[i];
|
|
if (!win || win.deleted || !win.normalWindow) continue;
|
|
if (!win.desktops || win.desktops.length === 0) continue;
|
|
if (win.desktops.indexOf(desktop) >= 0) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function fallbackDesktopForRemoval(desktop) {
|
|
const desktops = KWinComponents.Workspace.desktops;
|
|
if (!desktops || desktops.length < 2) return null;
|
|
|
|
const current = KWinComponents.Workspace.currentDesktop;
|
|
if (current && current !== desktop) {
|
|
return current;
|
|
}
|
|
|
|
const index = desktops.indexOf(desktop);
|
|
if (index > 0) {
|
|
return desktops[index - 1];
|
|
}
|
|
if (desktops.length > 1) {
|
|
return desktops[1];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function cleanupAutoParkingDesktopForLayout(outputName) {
|
|
const desktop = desktopForLayoutKey(outputName);
|
|
if (!desktop || !autoParkingDesktopKeys[desktopKey(desktop)]) return;
|
|
if (desktopHasNormalWindows(desktop)) return;
|
|
if (typeof KWinComponents.Workspace.removeDesktop !== "function") return;
|
|
|
|
const fallback = fallbackDesktopForRemoval(desktop);
|
|
if (!fallback) return;
|
|
|
|
if (KWinComponents.Workspace.currentDesktop === desktop) {
|
|
KWinComponents.Workspace.currentDesktop = fallback;
|
|
}
|
|
|
|
KWinComponents.Workspace.removeDesktop(desktop);
|
|
forgetAutoParkingDesktop(desktop);
|
|
}
|
|
|
|
function cleanupAllAutoParkingDesktops() {
|
|
const desktops = KWinComponents.Workspace.desktops;
|
|
if (!desktops || typeof KWinComponents.Workspace.removeDesktop !== "function") return;
|
|
|
|
const candidates = [];
|
|
for (let i = 0; i < desktops.length; i++) {
|
|
if (autoParkingDesktopKeys[desktopKey(desktops[i])] && !desktopHasNormalWindows(desktops[i])) {
|
|
candidates.push(desktops[i]);
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < candidates.length; i++) {
|
|
const fallback = fallbackDesktopForRemoval(candidates[i]);
|
|
if (!fallback) continue;
|
|
|
|
if (KWinComponents.Workspace.currentDesktop === candidates[i]) {
|
|
KWinComponents.Workspace.currentDesktop = fallback;
|
|
}
|
|
|
|
KWinComponents.Workspace.removeDesktop(candidates[i]);
|
|
forgetAutoParkingDesktop(candidates[i]);
|
|
}
|
|
}
|
|
|
|
function cleanupEmptyLayout(outputName) {
|
|
if (!outputName || windowCountForLayout(outputName) > 0) return;
|
|
|
|
setScreenLayout(outputName, null);
|
|
setScreenTiles(outputName, []);
|
|
clearLayoutMetadata(outputName);
|
|
setMaximizedLayout(outputName, null);
|
|
clearDisplacedWindowOwnersForLayout(outputName);
|
|
cleanupAutoParkingDesktopForLayout(outputName);
|
|
}
|
|
|
|
function createLayoutTransaction() {
|
|
return {
|
|
retileLayouts: [],
|
|
scheduledLayouts: [],
|
|
cleanupLayouts: []
|
|
};
|
|
}
|
|
|
|
function markLayoutRetiled(transaction, outputName) {
|
|
if (!transaction || !outputName || transaction.retileLayouts.indexOf(outputName) >= 0) return;
|
|
transaction.retileLayouts.push(outputName);
|
|
}
|
|
|
|
function markLayoutScheduled(transaction, outputName) {
|
|
if (!transaction || !outputName || transaction.scheduledLayouts.indexOf(outputName) >= 0) return;
|
|
transaction.scheduledLayouts.push(outputName);
|
|
}
|
|
|
|
function markLayoutCleanup(transaction, outputName) {
|
|
if (!transaction || !outputName || transaction.cleanupLayouts.indexOf(outputName) >= 0) return;
|
|
transaction.cleanupLayouts.push(outputName);
|
|
}
|
|
|
|
function markLayoutChanged(transaction, outputName) {
|
|
markLayoutRetiled(transaction, outputName);
|
|
markLayoutScheduled(transaction, outputName);
|
|
}
|
|
|
|
function applyLayoutTransaction(transaction) {
|
|
if (!transaction) return;
|
|
|
|
const retileLayouts = transaction.retileLayouts || [];
|
|
for (let i = 0; i < retileLayouts.length; i++) {
|
|
retileScreen(retileLayouts[i]);
|
|
}
|
|
|
|
const scheduledLayouts = transaction.scheduledLayouts || [];
|
|
for (let i = 0; i < scheduledLayouts.length; i++) {
|
|
scheduleRetile(scheduledLayouts[i]);
|
|
}
|
|
|
|
const cleanupLayouts = transaction.cleanupLayouts || [];
|
|
for (let i = 0; i < cleanupLayouts.length; i++) {
|
|
cleanupEmptyLayout(cleanupLayouts[i]);
|
|
}
|
|
}
|
|
|
|
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);
|
|
const transaction = createLayoutTransaction();
|
|
markLayoutChanged(transaction, outputName);
|
|
applyLayoutTransaction(transaction);
|
|
}
|
|
}
|
|
|
|
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 collapseMaximizedLayout(outputName) {
|
|
const state = maximizedLayouts[outputName];
|
|
if (!state) return false;
|
|
|
|
clearDisplacedWindowOwnersForWindows(state.originalWindows || []);
|
|
setMaximizedLayout(outputName, null);
|
|
|
|
const windows = orderedWindowsForScreen(outputName);
|
|
setStableLayout(outputName, windows);
|
|
const transaction = createLayoutTransaction();
|
|
markLayoutChanged(transaction, outputName);
|
|
markLayoutCleanup(transaction, outputName);
|
|
applyLayoutTransaction(transaction);
|
|
return true;
|
|
}
|
|
|
|
function normalizeLayoutConflicts(outputName) {
|
|
if (!outputName) return false;
|
|
|
|
const state = maximizedLayouts[outputName];
|
|
if (!state) {
|
|
cleanupEmptyLayout(outputName);
|
|
return false;
|
|
}
|
|
|
|
const windows = orderedWindowsForScreen(outputName);
|
|
if (windows.length === 1 && windowKey(windows[0]) === state.windowKey) {
|
|
return false;
|
|
}
|
|
|
|
collapseMaximizedLayout(outputName);
|
|
return true;
|
|
}
|
|
|
|
function releaseWindowForLayoutMove(win, sourceName, targetName) {
|
|
const key = windowKey(win);
|
|
if (!key || !sourceName || sourceName === targetName) return;
|
|
|
|
const state = maximizedLayouts[sourceName];
|
|
if (state && state.windowKey === key) {
|
|
clearDisplacedWindowOwnersForWindows(state.originalWindows || []);
|
|
setMaximizedLayout(sourceName, null);
|
|
}
|
|
clearDisplacedWindowOwnerByKey(key);
|
|
}
|
|
|
|
function swapWindowBetweenLayouts(win, sourceName, targetName) {
|
|
if (!win || !sourceName || !targetName || sourceName === targetName) return false;
|
|
|
|
const sourceDesktop = desktopForLayoutKey(sourceName);
|
|
const targetDesktop = desktopForLayoutKey(targetName);
|
|
const sourceLeaf = findLeaf(screenLayouts[sourceName], windowKey(win));
|
|
if (!sourceDesktop || !targetDesktop || !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;
|
|
releaseWindowForLayoutMove(win, sourceName, targetName);
|
|
releaseWindowForLayoutMove(displacedWindow, targetName, sourceName);
|
|
assignWindowToDesktop(win, targetDesktop);
|
|
assignWindowToDesktop(displacedWindow, sourceDesktop);
|
|
|
|
normalizeLayoutConflicts(sourceName);
|
|
normalizeLayoutConflicts(targetName);
|
|
const transaction = createLayoutTransaction();
|
|
markLayoutChanged(transaction, sourceName);
|
|
markLayoutChanged(transaction, targetName);
|
|
applyLayoutTransaction(transaction);
|
|
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 retileCurrentDesktopLayouts() {
|
|
if (!isConvergence()) return;
|
|
|
|
const desktop = KWinComponents.Workspace.currentDesktop;
|
|
const screens = KWinComponents.Workspace.screens;
|
|
if (!desktop || !screens) {
|
|
scheduleRetileAll();
|
|
return;
|
|
}
|
|
|
|
for (let i = 0; i < screens.length; i++) {
|
|
const outputName = layoutKeyFor(screens[i].name, desktop);
|
|
if (outputName !== "" && screenLayouts[outputName]) {
|
|
const transaction = createLayoutTransaction();
|
|
markLayoutChanged(transaction, outputName);
|
|
applyLayoutTransaction(transaction);
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
normalizeWindowDesktopScope(win);
|
|
normalizeNativeWindowOperationState(win);
|
|
|
|
connectDragHandlers(win);
|
|
|
|
const output = win.output;
|
|
if (!output) return;
|
|
|
|
const currentName = screenNameForWindow(win);
|
|
const targetName = layoutKeyForWindow(win);
|
|
if (targetName === "") return;
|
|
|
|
const transaction = createLayoutTransaction();
|
|
|
|
if (currentName !== "" && currentName !== targetName) {
|
|
releaseWindowForLayoutMove(win, currentName, targetName);
|
|
if (maximizedLayouts[targetName]) {
|
|
collapseMaximizedLayout(targetName);
|
|
}
|
|
if (windowCountForLayout(targetName) >= maxWindowsPerPage && swapWindowBetweenLayouts(win, currentName, targetName)) {
|
|
return;
|
|
}
|
|
removeWindowFromLayout(currentName, win);
|
|
markLayoutScheduled(transaction, currentName);
|
|
markLayoutCleanup(transaction, currentName);
|
|
}
|
|
|
|
if (!containsLeaf(screenLayouts[targetName], windowKey(win))) {
|
|
insertWindowInLayout(targetName, win);
|
|
}
|
|
|
|
normalizeLayoutConflicts(targetName);
|
|
if (win.maximizable) win.setMaximize(false, false);
|
|
win.noBorder = false;
|
|
markLayoutChanged(transaction, targetName);
|
|
applyLayoutTransaction(transaction);
|
|
}
|
|
|
|
// Add a window to its screen's tile list and retile.
|
|
function addWindow(win) {
|
|
if (!isTileable(win)) return;
|
|
|
|
normalizeWindowDesktopScope(win);
|
|
normalizeNativeWindowOperationState(win);
|
|
|
|
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);
|
|
normalizeLayoutConflicts(name);
|
|
|
|
// 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;
|
|
const transaction = createLayoutTransaction();
|
|
markLayoutChanged(transaction, name);
|
|
applyLayoutTransaction(transaction);
|
|
}
|
|
|
|
function adoptWindow(win) {
|
|
if (!isConvergence() || !win || win.deleted || !win.normalWindow || !win.maximizable) return;
|
|
|
|
normalizeNativeWindowOperationState(win);
|
|
if (isTileable(win)) {
|
|
addWindow(win);
|
|
}
|
|
}
|
|
|
|
// Remove a window from its screen's tile list and retile.
|
|
function removeWindow(win) {
|
|
if (!win) return;
|
|
|
|
const maximizedName = maximizedLayoutNameForWindow(win);
|
|
if (maximizedName !== "") {
|
|
restoreMaximizedLayout(maximizedName);
|
|
}
|
|
clearDisplacedWindowOwner(win);
|
|
|
|
// 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)) {
|
|
const transaction = createLayoutTransaction();
|
|
markLayoutRetiled(transaction, sName);
|
|
markLayoutCleanup(transaction, sName);
|
|
applyLayoutTransaction(transaction);
|
|
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;
|
|
const maximizedName = maximizedLayoutNameForWindow(win);
|
|
if (maximizedName !== "") {
|
|
restoreMaximizedLayout(maximizedName);
|
|
}
|
|
clearDisplacedWindowOwner(win);
|
|
floatedWindows[key] = true;
|
|
removeWindow(win);
|
|
}
|
|
|
|
function tileWindow(win) {
|
|
const key = windowKey(win);
|
|
if (!key) return;
|
|
delete floatedWindows[key];
|
|
clearDisplacedWindowOwner(win);
|
|
if (isTileable(win)) {
|
|
addWindow(win);
|
|
}
|
|
}
|
|
|
|
function setMaximizedLayout(outputName, state) {
|
|
const states = Object.assign({}, maximizedLayouts);
|
|
if (state) {
|
|
states[outputName] = state;
|
|
} else {
|
|
delete states[outputName];
|
|
}
|
|
maximizedLayouts = states;
|
|
publishDynamicTilingWindowState();
|
|
}
|
|
|
|
function maximizedWindowIds() {
|
|
const ids = [];
|
|
const seen = {};
|
|
for (const outputName in maximizedLayouts) {
|
|
const state = maximizedLayouts[outputName];
|
|
const key = state ? state.windowKey : "";
|
|
if (key && !seen[key]) {
|
|
ids.push(key);
|
|
seen[key] = true;
|
|
}
|
|
}
|
|
ids.sort();
|
|
return ids;
|
|
}
|
|
|
|
function publishDynamicTilingWindowState() {
|
|
const ids = maximizedWindowIds();
|
|
const serialized = ids.join(",");
|
|
if (serialized === lastPublishedMaximizedWindowIds) return;
|
|
|
|
lastPublishedMaximizedWindowIds = serialized;
|
|
if (ShellSettings.Settings.reportDynamicTilingWindowState !== undefined) {
|
|
ShellSettings.Settings.reportDynamicTilingWindowState(ids);
|
|
}
|
|
}
|
|
|
|
function clearDynamicTilingWindowState() {
|
|
lastPublishedMaximizedWindowIds = "__unpublished__";
|
|
if (ShellSettings.Settings.reportDynamicTilingWindowState !== undefined) {
|
|
ShellSettings.Settings.reportDynamicTilingWindowState([]);
|
|
}
|
|
}
|
|
|
|
function maximizedLayoutNameForWindow(win) {
|
|
const key = windowKey(win);
|
|
if (!key) return "";
|
|
|
|
for (const outputName in maximizedLayouts) {
|
|
const state = maximizedLayouts[outputName];
|
|
if (state && state.windowKey === key) {
|
|
return outputName;
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function setDisplacedWindowOwner(win, outputName) {
|
|
const key = windowKey(win);
|
|
if (!key || !outputName) return;
|
|
|
|
const owners = Object.assign({}, displacedWindowOwners);
|
|
owners[key] = outputName;
|
|
displacedWindowOwners = owners;
|
|
}
|
|
|
|
function clearDisplacedWindowOwnerByKey(key) {
|
|
if (!key || !displacedWindowOwners[key]) return;
|
|
|
|
const owners = Object.assign({}, displacedWindowOwners);
|
|
delete owners[key];
|
|
displacedWindowOwners = owners;
|
|
}
|
|
|
|
function clearDisplacedWindowOwner(win) {
|
|
clearDisplacedWindowOwnerByKey(windowKey(win));
|
|
}
|
|
|
|
function clearDisplacedWindowOwnersForWindows(windows) {
|
|
if (!windows) return;
|
|
|
|
const owners = Object.assign({}, displacedWindowOwners);
|
|
let changed = false;
|
|
for (let i = 0; i < windows.length; i++) {
|
|
const key = windowKey(windows[i]);
|
|
if (key && owners[key]) {
|
|
delete owners[key];
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (changed) {
|
|
displacedWindowOwners = owners;
|
|
}
|
|
}
|
|
|
|
function restoreMaximizedLayout(outputName) {
|
|
const state = maximizedLayouts[outputName];
|
|
if (!state) return false;
|
|
|
|
const sourceDesktop = state.sourceDesktop || desktopForLayoutKey(outputName);
|
|
if (!sourceDesktop) {
|
|
setMaximizedLayout(outputName, null);
|
|
return false;
|
|
}
|
|
|
|
const transaction = createLayoutTransaction();
|
|
markLayoutChanged(transaction, outputName);
|
|
markLayoutCleanup(transaction, outputName);
|
|
const restoredWindows = [];
|
|
const seen = {};
|
|
const originalWindows = state.originalWindows || [];
|
|
|
|
for (let i = 0; i < originalWindows.length; i++) {
|
|
const win = originalWindows[i];
|
|
const key = windowKey(win);
|
|
if (!key || seen[key] || !win || win.deleted || shouldIgnore(win) || floatedWindows[key]) continue;
|
|
if (key !== state.windowKey && displacedWindowOwners[key] !== outputName) continue;
|
|
|
|
const currentName = screenNameForWindow(win);
|
|
if (currentName && currentName !== outputName) {
|
|
removeWindowFromLayout(currentName, win);
|
|
markLayoutChanged(transaction, currentName);
|
|
markLayoutCleanup(transaction, currentName);
|
|
}
|
|
|
|
assignWindowToDesktop(win, sourceDesktop);
|
|
restoredWindows.push(win);
|
|
clearDisplacedWindowOwnerByKey(key);
|
|
seen[key] = true;
|
|
}
|
|
|
|
setStableLayout(outputName, restoredWindows);
|
|
setMaximizedLayout(outputName, null);
|
|
applyLayoutTransaction(transaction);
|
|
|
|
if (state.window && !state.window.deleted) {
|
|
KWinComponents.Workspace.activeWindow = state.window;
|
|
}
|
|
if (restoredWindows.length > 0) {
|
|
KWinComponents.Workspace.currentDesktop = sourceDesktop;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function restoreAllMaximizedLayouts() {
|
|
const names = [];
|
|
for (const outputName in maximizedLayouts) {
|
|
names.push(outputName);
|
|
}
|
|
|
|
for (let i = 0; i < names.length; i++) {
|
|
restoreMaximizedLayout(names[i]);
|
|
}
|
|
displacedWindowOwners = {};
|
|
cleanupAllAutoParkingDesktops();
|
|
publishDynamicTilingWindowState();
|
|
}
|
|
|
|
function toggleMaximizedLayout(win) {
|
|
if (!isTileable(win)) return;
|
|
|
|
const existingName = maximizedLayoutNameForWindow(win);
|
|
if (existingName !== "") {
|
|
restoreMaximizedLayout(existingName);
|
|
return;
|
|
}
|
|
|
|
const outputName = screenNameForWindow(win) || layoutKeyForWindow(win);
|
|
if (!outputName) return;
|
|
|
|
if (maximizedLayouts[outputName]) {
|
|
collapseMaximizedLayout(outputName);
|
|
}
|
|
|
|
const sourceDesktop = desktopForLayoutKey(outputName) || desktopForWindow(win);
|
|
const windows = orderedWindowsForScreen(outputName);
|
|
const key = windowKey(win);
|
|
if (!sourceDesktop || !key || windows.length < 2) {
|
|
clearDisplacedWindowOwnersForWindows(windows);
|
|
const transaction = createLayoutTransaction();
|
|
markLayoutChanged(transaction, outputName);
|
|
applyLayoutTransaction(transaction);
|
|
return;
|
|
}
|
|
|
|
const transaction = createLayoutTransaction();
|
|
markLayoutChanged(transaction, outputName);
|
|
const plannedMoves = [];
|
|
const reservedCounts = {};
|
|
|
|
for (let i = 0; i < windows.length; i++) {
|
|
const other = windows[i];
|
|
if (windowKey(other) === key) continue;
|
|
|
|
const target = targetLayoutForDisplacedWindow(other, sourceDesktop, reservedCounts);
|
|
if (target.key === "" || !target.desktop) {
|
|
applyLayoutTransaction(transaction);
|
|
return;
|
|
}
|
|
|
|
plannedMoves.push({ window: other, target: target });
|
|
reservedCounts[target.key] = (reservedCounts[target.key] || 0) + 1;
|
|
}
|
|
|
|
const movedWindows = [];
|
|
clearDisplacedWindowOwnersForWindows(windows);
|
|
|
|
for (let i = 0; i < plannedMoves.length; i++) {
|
|
const other = plannedMoves[i].window;
|
|
const target = plannedMoves[i].target;
|
|
|
|
removeWindowFromLayout(outputName, other);
|
|
assignWindowToDesktop(other, target.desktop);
|
|
insertWindowInLayout(target.key, other);
|
|
setDisplacedWindowOwner(other, outputName);
|
|
markLayoutChanged(transaction, target.key);
|
|
movedWindows.push(other);
|
|
}
|
|
|
|
if (movedWindows.length === 0) {
|
|
setStableLayout(outputName, windows);
|
|
applyLayoutTransaction(transaction);
|
|
return;
|
|
}
|
|
|
|
setStableLayout(outputName, [win]);
|
|
setMaximizedLayout(outputName, {
|
|
window: win,
|
|
windowKey: key,
|
|
sourceDesktop: sourceDesktop,
|
|
originalWindows: windows,
|
|
movedWindows: movedWindows
|
|
});
|
|
|
|
applyLayoutTransaction(transaction);
|
|
|
|
KWinComponents.Workspace.currentDesktop = sourceDesktop;
|
|
KWinComponents.Workspace.activeWindow = win;
|
|
}
|
|
|
|
function handleNativeMaximizeChange(win) {
|
|
if (!win || !isWindowMaximized(win) || !dynamicTilingOwnsWindow(win)) return;
|
|
|
|
win.setMaximize(false, false);
|
|
normalizeNativeWindowOperationState(win);
|
|
toggleMaximizedLayout(win);
|
|
}
|
|
|
|
function handleNativeWindowOperationStateChange(win) {
|
|
if (!normalizeNativeWindowOperationState(win)) return;
|
|
|
|
reconcileWindowOutput(win);
|
|
}
|
|
|
|
function isWindowMaximized(win) {
|
|
if (!win) return false;
|
|
if (win.maximizeMode !== undefined) return win.maximizeMode !== 0;
|
|
if (win.maximized !== undefined) return win.maximized;
|
|
return false;
|
|
}
|
|
|
|
function connectDragHandlers(win) {
|
|
const key = windowKey(win);
|
|
if (!key || dragConnectedWindows[key]) return;
|
|
|
|
const handlers = {
|
|
window: win,
|
|
moveStarted: function() { if (root) root.onDragStart(win); },
|
|
moveStepped: function(geo) { if (root) root.onDragStep(win, geo); },
|
|
moveFinished: function() { if (root) root.onDragEnd(win); },
|
|
desktopsChanged: null,
|
|
maximizedChanged: null,
|
|
maximizeModeChanged: null,
|
|
fullScreenChanged: null,
|
|
noBorderChanged: null,
|
|
keepAboveChanged: null,
|
|
keepBelowChanged: null
|
|
};
|
|
|
|
dragConnectedWindows[key] = handlers;
|
|
win.interactiveMoveResizeStarted.connect(handlers.moveStarted);
|
|
win.interactiveMoveResizeStepped.connect(handlers.moveStepped);
|
|
win.interactiveMoveResizeFinished.connect(handlers.moveFinished);
|
|
if (win.desktopsChanged !== undefined) {
|
|
handlers.desktopsChanged = function() { if (root) root.reconcileWindowOutput(win); };
|
|
win.desktopsChanged.connect(handlers.desktopsChanged);
|
|
}
|
|
if (win.maximizedChanged !== undefined) {
|
|
handlers.maximizedChanged = function() { if (root) root.handleNativeMaximizeChange(win); };
|
|
win.maximizedChanged.connect(handlers.maximizedChanged);
|
|
}
|
|
if (win.maximizeModeChanged !== undefined) {
|
|
handlers.maximizeModeChanged = function() { if (root) root.handleNativeMaximizeChange(win); };
|
|
win.maximizeModeChanged.connect(handlers.maximizeModeChanged);
|
|
}
|
|
if (win.fullScreenChanged !== undefined) {
|
|
handlers.fullScreenChanged = function() { if (root) root.handleNativeWindowOperationStateChange(win); };
|
|
win.fullScreenChanged.connect(handlers.fullScreenChanged);
|
|
}
|
|
if (win.noBorderChanged !== undefined) {
|
|
handlers.noBorderChanged = function() { if (root) root.handleNativeWindowOperationStateChange(win); };
|
|
win.noBorderChanged.connect(handlers.noBorderChanged);
|
|
}
|
|
if (win.keepAboveChanged !== undefined) {
|
|
handlers.keepAboveChanged = function() { if (root) root.handleNativeWindowOperationStateChange(win); };
|
|
win.keepAboveChanged.connect(handlers.keepAboveChanged);
|
|
}
|
|
if (win.keepBelowChanged !== undefined) {
|
|
handlers.keepBelowChanged = function() { if (root) root.handleNativeWindowOperationStateChange(win); };
|
|
win.keepBelowChanged.connect(handlers.keepBelowChanged);
|
|
}
|
|
}
|
|
|
|
function disconnectDragHandlers() {
|
|
for (const key in dragConnectedWindows) {
|
|
const handlers = dragConnectedWindows[key];
|
|
if (!handlers || !handlers.window) continue;
|
|
|
|
const win = handlers.window;
|
|
try { win.interactiveMoveResizeStarted.disconnect(handlers.moveStarted); } catch (error) {}
|
|
try { win.interactiveMoveResizeStepped.disconnect(handlers.moveStepped); } catch (error) {}
|
|
try { win.interactiveMoveResizeFinished.disconnect(handlers.moveFinished); } catch (error) {}
|
|
if (handlers.desktopsChanged) {
|
|
try { win.desktopsChanged.disconnect(handlers.desktopsChanged); } catch (error) {}
|
|
}
|
|
if (handlers.maximizedChanged) {
|
|
try { win.maximizedChanged.disconnect(handlers.maximizedChanged); } catch (error) {}
|
|
}
|
|
if (handlers.maximizeModeChanged) {
|
|
try { win.maximizeModeChanged.disconnect(handlers.maximizeModeChanged); } catch (error) {}
|
|
}
|
|
if (handlers.fullScreenChanged) {
|
|
try { win.fullScreenChanged.disconnect(handlers.fullScreenChanged); } catch (error) {}
|
|
}
|
|
if (handlers.noBorderChanged) {
|
|
try { win.noBorderChanged.disconnect(handlers.noBorderChanged); } catch (error) {}
|
|
}
|
|
if (handlers.keepAboveChanged) {
|
|
try { win.keepAboveChanged.disconnect(handlers.keepAboveChanged); } catch (error) {}
|
|
}
|
|
if (handlers.keepBelowChanged) {
|
|
try { win.keepBelowChanged.disconnect(handlers.keepBelowChanged); } catch (error) {}
|
|
}
|
|
}
|
|
dragConnectedWindows = ({});
|
|
}
|
|
|
|
function moveWindowToDesktop(win, desktop) {
|
|
if (!win || !desktop || !win.output || shouldIgnore(win)) return false;
|
|
|
|
const targetName = layoutKeyFor(win.output.name, desktop);
|
|
if (targetName === "") return false;
|
|
|
|
const sourceName = screenNameForWindow(win);
|
|
if (sourceName === targetName) {
|
|
assignWindowToDesktop(win, desktop);
|
|
KWinComponents.Workspace.currentDesktop = desktop;
|
|
KWinComponents.Workspace.activeWindow = win;
|
|
return true;
|
|
}
|
|
|
|
if (shouldFloat(win)) {
|
|
clearDisplacedWindowOwner(win);
|
|
assignWindowToDesktop(win, desktop);
|
|
KWinComponents.Workspace.currentDesktop = desktop;
|
|
KWinComponents.Workspace.activeWindow = win;
|
|
return true;
|
|
}
|
|
|
|
connectDragHandlers(win);
|
|
if (maximizedLayouts[targetName]) {
|
|
collapseMaximizedLayout(targetName);
|
|
}
|
|
if (sourceName !== "" && windowCountForLayout(targetName) >= maxWindowsPerPage) {
|
|
if (swapWindowBetweenLayouts(win, sourceName, targetName)) {
|
|
KWinComponents.Workspace.currentDesktop = desktop;
|
|
KWinComponents.Workspace.activeWindow = win;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const transaction = createLayoutTransaction();
|
|
if (sourceName !== "") {
|
|
releaseWindowForLayoutMove(win, sourceName, targetName);
|
|
if (removeWindowFromLayout(sourceName, win)) {
|
|
markLayoutScheduled(transaction, sourceName);
|
|
markLayoutCleanup(transaction, sourceName);
|
|
}
|
|
}
|
|
|
|
assignWindowToDesktop(win, desktop);
|
|
if (!containsLeaf(screenLayouts[targetName], windowKey(win))) {
|
|
insertWindowInLayout(targetName, win);
|
|
}
|
|
|
|
normalizeLayoutConflicts(targetName);
|
|
if (win.maximizable) win.setMaximize(false, false);
|
|
win.noBorder = false;
|
|
markLayoutChanged(transaction, targetName);
|
|
applyLayoutTransaction(transaction);
|
|
KWinComponents.Workspace.currentDesktop = desktop;
|
|
KWinComponents.Workspace.activeWindow = win;
|
|
return true;
|
|
}
|
|
|
|
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);
|
|
} else if (action === "maximize" || action === "maximize-toggle") {
|
|
toggleMaximizedLayout(win);
|
|
} else if (action === "restore") {
|
|
const outputName = maximizedLayoutNameForWindow(win);
|
|
if (outputName !== "") {
|
|
restoreMaximizedLayout(outputName);
|
|
}
|
|
} else if (action.indexOf("move-to-desktop:") === 0) {
|
|
const payload = action.slice("move-to-desktop:".length);
|
|
const separator = payload.lastIndexOf("|");
|
|
const desktopId = separator >= 0 ? payload.slice(0, separator) : payload;
|
|
const desktopNumber = separator >= 0 ? payload.slice(separator + 1) : "";
|
|
const desktop = desktopForMoveRequest(desktopId, desktopNumber);
|
|
if (desktop) {
|
|
moveWindowToDesktop(win, desktop);
|
|
}
|
|
} else if (action === "move-to-new-desktop") {
|
|
const desktop = createUserDesktop();
|
|
if (desktop) {
|
|
moveWindowToDesktop(win, desktop);
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
const transaction = createLayoutTransaction();
|
|
markLayoutChanged(transaction, outputName);
|
|
applyLayoutTransaction(transaction);
|
|
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) {
|
|
root.adoptWindow(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 onCurrentDesktopChanged() {
|
|
root.retileCurrentDesktopLayouts();
|
|
}
|
|
|
|
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++) {
|
|
adoptWindow(wins[i]);
|
|
}
|
|
} else {
|
|
// Clear all tiles — the convergentwindows script will re-maximize
|
|
restoreAllMaximizedLayouts();
|
|
screenLayouts = {};
|
|
screenTiles = {};
|
|
screenLayoutModes = {};
|
|
lastFocusedWindowKeys = {};
|
|
maximizedLayouts = {};
|
|
}
|
|
}
|
|
|
|
function onGamingModeEnabledChanged() {
|
|
if (ShellSettings.Settings.gamingModeEnabled) {
|
|
restoreAllMaximizedLayouts();
|
|
screenLayouts = {};
|
|
screenTiles = {};
|
|
screenLayoutModes = {};
|
|
lastFocusedWindowKeys = {};
|
|
maximizedLayouts = {};
|
|
} else if (isConvergence()) {
|
|
const wins = KWinComponents.Workspace.windows;
|
|
for (let i = 0; i < wins.length; i++) {
|
|
adoptWindow(wins[i]);
|
|
}
|
|
}
|
|
}
|
|
|
|
function onDynamicTilingEnabledChanged() {
|
|
if (isConvergence()) {
|
|
const wins = KWinComponents.Workspace.windows;
|
|
for (let i = 0; i < wins.length; i++) {
|
|
adoptWindow(wins[i]);
|
|
}
|
|
} else {
|
|
// Tiling turned off — leave windows where they are.
|
|
restoreAllMaximizedLayouts();
|
|
screenLayouts = {};
|
|
screenTiles = {};
|
|
screenLayoutModes = {};
|
|
lastFocusedWindowKeys = {};
|
|
maximizedLayouts = {};
|
|
}
|
|
}
|
|
|
|
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;
|
|
const transaction = createLayoutTransaction();
|
|
markLayoutChanged(transaction, dragSourceScreen);
|
|
applyLayoutTransaction(transaction);
|
|
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);
|
|
dragNativeResize = false;
|
|
draggingWindow = null;
|
|
}
|
|
|
|
function updateDragIntent(win, geo) {
|
|
if (validRect(dragSourceRect) && validRect(geo)
|
|
&& (Math.abs(geo.width - dragSourceRect.width) > 2
|
|
|| Math.abs(geo.height - dragSourceRect.height) > 2)) {
|
|
dragNativeResize = true;
|
|
dragSwapTarget = null;
|
|
showDragOutline("restore", dragSourceRect);
|
|
return;
|
|
}
|
|
|
|
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;
|
|
dragNativeResize = false;
|
|
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 (dragNativeResize && dragSourceScreen && dragSourceIndex >= 0) {
|
|
retileScreen(dragSourceScreen);
|
|
}
|
|
|
|
else 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++) {
|
|
adoptWindow(wins[i]);
|
|
}
|
|
rememberFocusedWindow(KWinComponents.Workspace.activeWindow);
|
|
publishDynamicTilingWindowState();
|
|
}
|
|
|
|
Component.onDestruction: {
|
|
disconnectDragHandlers();
|
|
clearDynamicTilingWindowState();
|
|
}
|
|
}
|