mirror of
https://invent.kde.org/marcoa/shift-shell.git
synced 2026-06-11 00:47:22 +00:00
Expose layout requests and state through shell settings so Folio can offer alternatives for the current tiled workspace. Keep KWin as the owner of layout changes, snapshot request serials so requests are not dropped, and hide the edge menu when fewer than two windows are tiled.
2272 lines
81 KiB
QML
2272 lines
81 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: -1
|
|
property string lastPublishedMaximizedWindowIds: "__unpublished__"
|
|
property int lastLayoutRequestSerial: -1
|
|
property string lastPublishedLayoutState: "__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;
|
|
publishDynamicTilingLayoutState();
|
|
}
|
|
|
|
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]);
|
|
}
|
|
|
|
publishDynamicTilingLayoutState();
|
|
}
|
|
|
|
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;
|
|
const desktop = KWinComponents.Workspace.currentDesktop;
|
|
const screens = KWinComponents.Workspace.screens;
|
|
if (desktop && screens && screens.length > 0) {
|
|
for (let i = 0; i < screens.length; i++) {
|
|
const currentDesktopName = layoutKeyFor(screens[i].name, desktop);
|
|
if (screenLayouts[currentDesktopName]) return currentDesktopName;
|
|
}
|
|
return layoutKeyFor(screens[0].name, desktop);
|
|
}
|
|
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);
|
|
}
|
|
publishDynamicTilingLayoutState();
|
|
}
|
|
|
|
function setLayoutMode(outputName, mode) {
|
|
if (!outputName || layoutModes.indexOf(mode) < 0) return;
|
|
setLayoutModeForScreen(outputName, mode);
|
|
|
|
const windows = orderedWindowsForScreen(outputName);
|
|
if (windows.length > 0) {
|
|
setStableLayout(outputName, windows);
|
|
const transaction = createLayoutTransaction();
|
|
markLayoutChanged(transaction, outputName);
|
|
applyLayoutTransaction(transaction);
|
|
}
|
|
publishDynamicTilingLayoutState();
|
|
}
|
|
|
|
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 publishDynamicTilingLayoutState() {
|
|
if (!isConvergence()) {
|
|
const disabledState = "|0";
|
|
if (disabledState === lastPublishedLayoutState) return;
|
|
|
|
lastPublishedLayoutState = disabledState;
|
|
if (ShellSettings.Settings.reportDynamicTilingLayoutState !== undefined) {
|
|
ShellSettings.Settings.reportDynamicTilingLayoutState("", 0);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const outputName = outputNameForActiveWindow();
|
|
const mode = outputName !== "" ? layoutModeForScreen(outputName) : "";
|
|
const windowCount = outputName !== "" ? windowCountForLayout(outputName) : 0;
|
|
const serialized = mode + "|" + windowCount;
|
|
if (serialized === lastPublishedLayoutState) return;
|
|
|
|
lastPublishedLayoutState = serialized;
|
|
if (ShellSettings.Settings.reportDynamicTilingLayoutState !== undefined) {
|
|
ShellSettings.Settings.reportDynamicTilingLayoutState(mode, windowCount);
|
|
}
|
|
}
|
|
|
|
function clearDynamicTilingLayoutState() {
|
|
lastPublishedLayoutState = "__unpublished__";
|
|
if (ShellSettings.Settings.reportDynamicTilingLayoutState !== undefined) {
|
|
ShellSettings.Settings.reportDynamicTilingLayoutState("", 0);
|
|
}
|
|
}
|
|
|
|
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 handleLayoutModeRequest() {
|
|
const serial = ShellSettings.Settings.dynamicTilingLayoutRequestSerial;
|
|
if (serial === lastLayoutRequestSerial) return;
|
|
lastLayoutRequestSerial = serial;
|
|
|
|
if (!isConvergence()) return;
|
|
|
|
const mode = ShellSettings.Settings.dynamicTilingLayoutRequestMode;
|
|
if (layoutModes.indexOf(mode) < 0) return;
|
|
|
|
setLayoutMode(outputNameForActiveWindow(), mode);
|
|
}
|
|
|
|
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);
|
|
root.publishDynamicTilingLayoutState();
|
|
}
|
|
|
|
function onCurrentDesktopChanged() {
|
|
root.retileCurrentDesktopLayouts();
|
|
root.publishDynamicTilingLayoutState();
|
|
}
|
|
|
|
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();
|
|
root.publishDynamicTilingLayoutState();
|
|
}
|
|
}
|
|
|
|
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]);
|
|
}
|
|
publishDynamicTilingLayoutState();
|
|
} else {
|
|
// Clear all tiles — the convergentwindows script will re-maximize
|
|
restoreAllMaximizedLayouts();
|
|
screenLayouts = {};
|
|
screenTiles = {};
|
|
screenLayoutModes = {};
|
|
lastFocusedWindowKeys = {};
|
|
maximizedLayouts = {};
|
|
clearDynamicTilingLayoutState();
|
|
}
|
|
}
|
|
|
|
function onGamingModeEnabledChanged() {
|
|
if (ShellSettings.Settings.gamingModeEnabled) {
|
|
restoreAllMaximizedLayouts();
|
|
screenLayouts = {};
|
|
screenTiles = {};
|
|
screenLayoutModes = {};
|
|
lastFocusedWindowKeys = {};
|
|
maximizedLayouts = {};
|
|
clearDynamicTilingLayoutState();
|
|
} else if (isConvergence()) {
|
|
const wins = KWinComponents.Workspace.windows;
|
|
for (let i = 0; i < wins.length; i++) {
|
|
adoptWindow(wins[i]);
|
|
}
|
|
publishDynamicTilingLayoutState();
|
|
}
|
|
}
|
|
|
|
function onDynamicTilingEnabledChanged() {
|
|
if (isConvergence()) {
|
|
const wins = KWinComponents.Workspace.windows;
|
|
for (let i = 0; i < wins.length; i++) {
|
|
adoptWindow(wins[i]);
|
|
}
|
|
publishDynamicTilingLayoutState();
|
|
} else {
|
|
// Tiling turned off — leave windows where they are.
|
|
restoreAllMaximizedLayouts();
|
|
screenLayouts = {};
|
|
screenTiles = {};
|
|
screenLayoutModes = {};
|
|
lastFocusedWindowKeys = {};
|
|
maximizedLayouts = {};
|
|
clearDynamicTilingLayoutState();
|
|
}
|
|
}
|
|
|
|
function onDynamicTilingWindowRequestChanged() {
|
|
root.handleWindowTilingRequest();
|
|
}
|
|
|
|
function onDynamicTilingLayoutRequestChanged() {
|
|
root.handleLayoutModeRequest();
|
|
}
|
|
}
|
|
|
|
// ── 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: {
|
|
lastWindowRequestSerial = ShellSettings.Settings.dynamicTilingWindowRequestSerial;
|
|
lastLayoutRequestSerial = ShellSettings.Settings.dynamicTilingLayoutRequestSerial;
|
|
|
|
// 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();
|
|
publishDynamicTilingLayoutState();
|
|
}
|
|
|
|
Component.onDestruction: {
|
|
disconnectDragHandlers();
|
|
clearDynamicTilingWindowState();
|
|
clearDynamicTilingLayoutState();
|
|
}
|
|
}
|