From 5311dc8fe87cc7a05069cf0d8ebc556c2e24dbc5 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Fri, 22 May 2026 09:39:11 +0200 Subject: [PATCH] Own dynamic tiling window workflows --- .../mobileshellsettings.cpp | 45 + .../shellsettingsplugin/mobileshellsettings.h | 8 + .../convergentwindows/contents/ui/main.qml | 22 + .../scripts/shift-tiling/contents/ui/main.qml | 966 +++++++++++++++++- 4 files changed, 1008 insertions(+), 33 deletions(-) diff --git a/components/shellsettingsplugin/mobileshellsettings.cpp b/components/shellsettingsplugin/mobileshellsettings.cpp index b04dae15..d1343daf 100644 --- a/components/shellsettingsplugin/mobileshellsettings.cpp +++ b/components/shellsettingsplugin/mobileshellsettings.cpp @@ -48,6 +48,7 @@ MobileShellSettings::MobileShellSettings(QObject *parent) Q_EMIT gamingDismissHintEnabledChanged(); Q_EMIT dynamicTilingEnabledChanged(); Q_EMIT dynamicTilingWindowRequestChanged(); + Q_EMIT dynamicTilingWindowStateChanged(); Q_EMIT snapLayoutsEnabledChanged(); Q_EMIT allowLogoutChanged(); } @@ -326,6 +327,50 @@ void MobileShellSettings::requestDynamicTilingWindowAction(const QString &window Q_EMIT dynamicTilingWindowRequestChanged(); } +QStringList MobileShellSettings::dynamicTilingMaximizedWindowIds() const +{ + auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP}; + return group.readEntry("dynamicTilingMaximizedWindowIds", QStringList{}); +} + +int MobileShellSettings::dynamicTilingWindowStateSerial() const +{ + auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP}; + return group.readEntry("dynamicTilingWindowStateSerial", 0); +} + +bool MobileShellSettings::isDynamicTilingWindowMaximized(const QString &windowId) const +{ + if (windowId.isEmpty()) { + return false; + } + + return dynamicTilingMaximizedWindowIds().contains(windowId); +} + +void MobileShellSettings::reportDynamicTilingWindowState(const QStringList &maximizedWindowIds) +{ + QStringList normalizedIds; + for (const QString &windowId : maximizedWindowIds) { + if (!windowId.isEmpty() && !normalizedIds.contains(windowId)) { + normalizedIds.push_back(windowId); + } + } + normalizedIds.sort(Qt::CaseSensitive); + + auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP}; + if (group.readEntry("dynamicTilingMaximizedWindowIds", QStringList{}) == normalizedIds) { + return; + } + + const int serial = group.readEntry("dynamicTilingWindowStateSerial", 0) + 1; + group.writeEntry("dynamicTilingMaximizedWindowIds", normalizedIds, KConfigGroup::Notify); + group.writeEntry("dynamicTilingWindowStateSerial", serial, KConfigGroup::Notify); + m_config->sync(); + + Q_EMIT dynamicTilingWindowStateChanged(); +} + bool MobileShellSettings::snapLayoutsEnabled() const { auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP}; diff --git a/components/shellsettingsplugin/mobileshellsettings.h b/components/shellsettingsplugin/mobileshellsettings.h index a6c75676..5bcbf66b 100644 --- a/components/shellsettingsplugin/mobileshellsettings.h +++ b/components/shellsettingsplugin/mobileshellsettings.h @@ -11,6 +11,7 @@ #include #include #include +#include #include /** @@ -62,6 +63,8 @@ class MobileShellSettings : public QObject Q_PROPERTY(QString dynamicTilingWindowRequestAction READ dynamicTilingWindowRequestAction NOTIFY dynamicTilingWindowRequestChanged) Q_PROPERTY(QString dynamicTilingWindowRequestId READ dynamicTilingWindowRequestId NOTIFY dynamicTilingWindowRequestChanged) Q_PROPERTY(int dynamicTilingWindowRequestSerial READ dynamicTilingWindowRequestSerial NOTIFY dynamicTilingWindowRequestChanged) + Q_PROPERTY(QStringList dynamicTilingMaximizedWindowIds READ dynamicTilingMaximizedWindowIds NOTIFY dynamicTilingWindowStateChanged) + Q_PROPERTY(int dynamicTilingWindowStateSerial READ dynamicTilingWindowStateSerial NOTIFY dynamicTilingWindowStateChanged) // Snap layout picker — only meaningful in convergence mode when dynamic tiling is off. Q_PROPERTY(bool snapLayoutsEnabled READ snapLayoutsEnabled WRITE setSnapLayoutsEnabled NOTIFY snapLayoutsEnabledChanged) @@ -295,6 +298,10 @@ public: QString dynamicTilingWindowRequestId() const; int dynamicTilingWindowRequestSerial() const; Q_INVOKABLE void requestDynamicTilingWindowAction(const QString &windowId, const QString &action); + QStringList dynamicTilingMaximizedWindowIds() const; + int dynamicTilingWindowStateSerial() const; + Q_INVOKABLE bool isDynamicTilingWindowMaximized(const QString &windowId) const; + Q_INVOKABLE void reportDynamicTilingWindowState(const QStringList &maximizedWindowIds); /** * Whether the SHIFT snap layout picker is enabled. @@ -354,6 +361,7 @@ Q_SIGNALS: void gamingDismissHintEnabledChanged(); void dynamicTilingEnabledChanged(); void dynamicTilingWindowRequestChanged(); + void dynamicTilingWindowStateChanged(); void snapLayoutsEnabledChanged(); void allowLogoutChanged(); void lockscreenLeftButtonActionChanged(); diff --git a/kwin/scripts/convergentwindows/contents/ui/main.qml b/kwin/scripts/convergentwindows/contents/ui/main.qml index 41a7fa38..5f350be4 100644 --- a/kwin/scripts/convergentwindows/contents/ui/main.qml +++ b/kwin/scripts/convergentwindows/contents/ui/main.qml @@ -28,6 +28,7 @@ Loader { root.pendingConstrainWindows = [] for (const window of windows) { if (!window || window.deleted || !window.normalWindow) continue + if (root.dynamicTilingOwnsWindowPlacement()) continue if (!ShellSettings.Settings.convergenceModeEnabled) continue if (ShellSettings.Settings.gamingModeEnabled) continue @@ -52,11 +53,22 @@ Loader { } } + function dynamicTilingOwnsWindowPlacement() { + return ShellSettings.Settings.convergenceModeEnabled + && ShellSettings.Settings.dynamicTilingEnabled + && !ShellSettings.Settings.gamingModeEnabled; + } + function run(window) { if (!window || window.deleted || !window.normalWindow) { return; } + if (root.dynamicTilingOwnsWindowPlacement()) { + window.noBorder = false; + return; + } + // HACK: don't maximize xwaylandvideobridge // see: https://invent.kde.org/plasma/plasma-mobile/-/issues/324 if (window.resourceClass === 'xwaylandvideobridge') { @@ -153,6 +165,16 @@ Loader { } } } + + function onDynamicTilingEnabledChanged() { + const windows = KWinComponents.Workspace.windows; + + for (let i = 0; i < windows.length; i++) { + if (windows[i].normalWindow) { + root.run(windows[i]); + } + } + } } Connections { diff --git a/kwin/scripts/shift-tiling/contents/ui/main.qml b/kwin/scripts/shift-tiling/contents/ui/main.qml index 4312dc3e..469a0819 100644 --- a/kwin/scripts/shift-tiling/contents/ui/main.qml +++ b/kwin/scripts/shift-tiling/contents/ui/main.qml @@ -47,6 +47,18 @@ Item { // 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. @@ -56,9 +68,11 @@ Item { 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. // @@ -77,6 +91,7 @@ Item { 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 @@ -127,6 +142,15 @@ Item { 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 : ""; @@ -148,6 +172,31 @@ Item { 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)); @@ -198,6 +247,7 @@ Item { 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 }; } @@ -206,6 +256,105 @@ Item { 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 @@ -252,6 +401,39 @@ Item { 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) { @@ -312,6 +494,168 @@ Item { 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) }; } @@ -515,8 +859,9 @@ Item { const windows = orderedWindowsForScreen(outputName); if (windows.length > 0) { setStableLayout(outputName, windows); - retileScreen(outputName); - scheduleRetile(outputName); + const transaction = createLayoutTransaction(); + markLayoutChanged(transaction, outputName); + applyLayoutTransaction(transaction); } } @@ -576,12 +921,59 @@ Item { 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 || !sourceLeaf) return false; + if (!sourceDesktop || !targetDesktop || !sourceLeaf) return false; const sourceIndex = leafIndexForWindow(sourceName, win); const targetLeaf = replacementLeafForLayout(targetName, sourceIndex); @@ -590,12 +982,17 @@ Item { const displacedWindow = targetLeaf.win; targetLeaf.win = win; sourceLeaf.win = displacedWindow; + releaseWindowForLayoutMove(win, sourceName, targetName); + releaseWindowForLayoutMove(displacedWindow, targetName, sourceName); + assignWindowToDesktop(win, targetDesktop); assignWindowToDesktop(displacedWindow, sourceDesktop); - retileScreen(sourceName); - retileScreen(targetName); - scheduleRetile(sourceName); - scheduleRetile(targetName); + normalizeLayoutConflicts(sourceName); + normalizeLayoutConflicts(targetName); + const transaction = createLayoutTransaction(); + markLayoutChanged(transaction, sourceName); + markLayoutChanged(transaction, targetName); + applyLayoutTransaction(transaction); KWinComponents.Workspace.activeWindow = win; return true; } @@ -655,6 +1052,26 @@ Item { } } + 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 ""; @@ -670,6 +1087,9 @@ Item { function reconcileWindowOutput(win) { if (!isTileable(win)) return; + normalizeWindowDesktopScope(win); + normalizeNativeWindowOperationState(win); + connectDragHandlers(win); const output = win.output; @@ -679,28 +1099,39 @@ Item { 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); - scheduleRetile(currentName); + 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; - retileScreen(targetName); - scheduleRetile(targetName); + 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; @@ -725,25 +1156,45 @@ Item { } 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; - retileScreen(name); - scheduleRetile(name); + 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)) { - retileScreen(sName); + const transaction = createLayoutTransaction(); + markLayoutRetiled(transaction, sName); + markLayoutCleanup(transaction, sName); + applyLayoutTransaction(transaction); return; } } @@ -767,6 +1218,11 @@ Item { 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); } @@ -775,22 +1231,417 @@ Item { 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; - dragConnectedWindows[key] = true; - win.interactiveMoveResizeStarted.connect(function() { root.onDragStart(win); }); - win.interactiveMoveResizeStepped.connect(function(geo) { root.onDragStep(win, geo); }); - win.interactiveMoveResizeFinished.connect(function() { root.onDragEnd(win); }); + 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) { - win.desktopsChanged.connect(function() { root.reconcileWindowOutput(win); }); + 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() { @@ -807,6 +1658,27 @@ Item { 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); + } } } @@ -833,8 +1705,9 @@ Item { if (!promoted) return; reordered.unshift(promoted); setStableLayout(outputName, reordered); - retileScreen(outputName); - scheduleRetile(outputName); + const transaction = createLayoutTransaction(); + markLayoutChanged(transaction, outputName); + applyLayoutTransaction(transaction); KWinComponents.Workspace.activeWindow = win; } @@ -887,9 +1760,7 @@ Item { target: KWinComponents.Workspace function onWindowAdded(win) { - if (isTileable(win)) { - addWindow(win); - } + root.adoptWindow(win); } function onWindowRemoved(win) { @@ -902,6 +1773,10 @@ Item { root.rememberFocusedWindow(KWinComponents.Workspace.activeWindow); } + function onCurrentDesktopChanged() { + root.retileCurrentDesktopLayouts(); + } + function onScreensChanged() { if (!root.isConvergence()) { return; @@ -924,27 +1799,31 @@ Item { // Tile all existing normal windows const wins = KWinComponents.Workspace.windows; for (let i = 0; i < wins.length; i++) { - addWindow(wins[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++) { - addWindow(wins[i]); + adoptWindow(wins[i]); } } } @@ -953,14 +1832,16 @@ Item { if (isConvergence()) { const wins = KWinComponents.Workspace.windows; for (let i = 0; i < wins.length; i++) { - addWindow(wins[i]); + adoptWindow(wins[i]); } } else { // Tiling turned off — leave windows where they are. + restoreAllMaximizedLayouts(); screenLayouts = {}; screenTiles = {}; screenLayoutModes = {}; lastFocusedWindowKeys = {}; + maximizedLayouts = {}; } } @@ -1026,8 +1907,9 @@ Item { const sourceWindow = sourceLeaf.win; sourceLeaf.win = targetLeaf.win; targetLeaf.win = sourceWindow; - retileScreen(dragSourceScreen); - scheduleRetile(dragSourceScreen); + const transaction = createLayoutTransaction(); + markLayoutChanged(transaction, dragSourceScreen); + applyLayoutTransaction(transaction); KWinComponents.Workspace.activeWindow = sourceWindow; } @@ -1085,10 +1967,20 @@ Item { 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); @@ -1115,6 +2007,7 @@ Item { draggingWindow = win; clearDragOutline(); dragSwapTarget = null; + dragNativeResize = false; dragSourceRect = Qt.rect(0, 0, 0, 0); // Remember the source slot so we can swap on drop. @@ -1155,7 +2048,11 @@ Item { const finalDropMode = dragDropMode; clearDragOutline(); - if (finalDropMode === "float") { + if (dragNativeResize && dragSourceScreen && dragSourceIndex >= 0) { + retileScreen(dragSourceScreen); + } + + else if (finalDropMode === "float") { floatWindow(win); } @@ -1271,11 +2168,14 @@ Item { // Connect to existing windows const wins = KWinComponents.Workspace.windows; for (let i = 0; i < wins.length; i++) { - const win = wins[i]; - if (isTileable(win)) { - addWindow(win); - } + adoptWindow(wins[i]); } rememberFocusedWindow(KWinComponents.Workspace.activeWindow); + publishDynamicTilingWindowState(); + } + + Component.onDestruction: { + disconnectDragHandlers(); + clearDynamicTilingWindowState(); } }