From d1db4ee9141532b9c2c0f97ec76924436a6c60e9 Mon Sep 17 00:00:00 2001 From: mattchristenson Date: Sat, 26 Sep 2026 13:43:42 -0500 Subject: [PATCH 1/9] fix: keyboard resize moves the wrong edge for top/bottom resize() applied the y adjustment to the wrong vertical direction. directionFromGrab() maps KEYBOARD_RESIZING_N to UP and KEYBOARD_RESIZING_S to DOWN, but the UP case only changed the height (so resizing the top edge moved the bottom edge) and the DOWN case also moved y (so resizing the bottom edge moved the top edge). While a resize shortcut repeats, the window slid by one step per repeat (e.g. -210 px after a 700 ms hold of window-resize-bottom-increase, partly off-screen) until the render at the end of the grab put it back into the layout. Move the y adjustment to the UP case, mirroring the LEFT/RIGHT cases. Co-Authored-By: Claude Opus 5.5 --- lib/extension/window.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/extension/window.js b/lib/extension/window.js index 61820e1e..2e7e8521 100644 --- a/lib/extension/window.js +++ b/lib/extension/window.js @@ -820,11 +820,13 @@ export class WindowManager extends GObject.Object { rect.x = rect.x - amount; break; case Meta.MotionDirection.UP: + // top edge: keep the bottom edge in place rect.height = rect.height + amount; + rect.y = rect.y - amount; break; case Meta.MotionDirection.DOWN: + // bottom edge: keep the top edge in place rect.height = rect.height + amount; - rect.y = rect.y - amount; break; } this.move(metaWindow, rect); From 2d1dbd9145acd85857e6a1a662041d2de3b2ff85 Mon Sep 17 00:00:00 2001 From: mattchristenson Date: Sat, 26 Sep 2026 16:22:34 -0500 Subject: [PATCH 2/9] fix: ignore resize shortcuts when no window is focused resize() read the focused window's frame without checking that there is one, so a resize shortcut on an empty workspace threw a TypeError from the keybinding handler after _handleGrabOpBegin had already set this.grabOp. Co-Authored-By: Claude Opus 5.5 --- lib/extension/window.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/extension/window.js b/lib/extension/window.js index 2e7e8521..db111a76 100644 --- a/lib/extension/window.js +++ b/lib/extension/window.js @@ -805,6 +805,7 @@ export class WindowManager extends GObject.Object { resize(grabOp, amount) { let metaWindow = this.focusMetaWindow; let display = global.display; + if (!metaWindow) return; // no focused window, e.g. an empty workspace this._handleGrabOpBegin(display, metaWindow, grabOp); From 617962582cf48cab63ee7f6eb3575e4c3161d94e Mon Sep 17 00:00:00 2001 From: mattchristenson Date: Sat, 26 Sep 2026 17:09:24 -0500 Subject: [PATCH 3/9] fix: move and resize a window in one request move() asked for the new position with move_frame() and then for the new position and size with move_resize_frame(). On Wayland, GNOME applies a requested position together with a requested size once the app has drawn the new size, but the separate move_frame() applied the position at once. So while an app redrew, its window was shown at the new position with its old size: - during a held keyboard resize of a left or top edge, the opposite edge moved (one step per key repeat); - while an app was slow or busy, its window slid instead of resizing, and stayed shifted until the resize ended (255 px after 0.6 s); - a shrinking neighbour whose left edge moved right stuck out past its right edge until it redrew. Ask for position and size in one move_resize_frame() call (a shared _requestFrame(), also used by the live resize). Because the position of a resized window now arrives with its new size, the pointer warp (after moving or swapping a window) aims at where the window is going: the frame Forge last asked for while it is still pending. A window whose app is frozen now stays where it is until the app draws again, instead of moving to its new place at its old size. Tested in a nested GNOME Shell 50.1 with moves to another monitor and back, maximize/unmaximize and an X11 app: unchanged. Co-Authored-By: Claude Opus 5.5 --- lib/extension/window.js | 47 +++++++++++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/lib/extension/window.js b/lib/extension/window.js index 61820e1e..f686b5d2 100644 --- a/lib/extension/window.js +++ b/lib/extension/window.js @@ -973,12 +973,44 @@ export class WindowManager extends GObject.Object { metaWindow.unmaximize(Meta.MaximizeFlags.BOTH); } - let windowActor = metaWindow.get_compositor_private(); + this._requestFrame(metaWindow, rect); + } + + /** + * Ask for `metaWindow`'s frame to be `rect`, position and size in one request: on Wayland, + * GNOME then applies both together once the app has drawn the new size. Do not move the + * window separately first, or it is shown at the new position with its old size meanwhile. + */ + _requestFrame(metaWindow, rect) { + const windowActor = metaWindow.get_compositor_private(); if (!windowActor) return; windowActor.remove_all_transitions(); - - metaWindow.move_frame(true, rect.x, rect.y); metaWindow.move_resize_frame(true, rect.x, rect.y, rect.width, rect.height); + this._rememberRequest(metaWindow, rect); + } + + _rememberRequest(metaWindow, rect) { + metaWindow.forgePendingFrame = { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + time: GLib.get_monotonic_time(), + }; + } + + /** + * Where `metaWindow` is going: the frame Forge asked for in the last half second if the app + * has not drawn its size yet (its position is only applied together with its size), + * otherwise its current frame. + */ + _targetRect(metaWindow) { + const frame = metaWindow.get_frame_rect(); + const pending = metaWindow.forgePendingFrame; + const recent = pending && GLib.get_monotonic_time() - pending.time < 500000; + if (recent && (pending.width !== frame.width || pending.height !== frame.height)) + return pending; + return frame; } moveCenter(metaWindow) { @@ -2277,7 +2309,7 @@ export class WindowManager extends GObject.Object { canMovePointerInsideNodeWindow(nodeWindow) { if (nodeWindow && nodeWindow._data) { const metaWindow = nodeWindow.nodeValue; - const metaRect = metaWindow.get_frame_rect(); + const metaRect = this._targetRect(metaWindow); const pointerCoord = global.get_pointer(); return ( metaRect && @@ -2306,7 +2338,7 @@ export class WindowManager extends GObject.Object { getPointerPositionInside(nodeWindow) { if (nodeWindow && nodeWindow._data) { const metaWindow = nodeWindow.nodeValue; - const metaRect = metaWindow.get_frame_rect(); + const metaRect = this._targetRect(metaWindow); // on: last position of cursor inside window // on: titlebar: near to app toolbars, menubar, tabs, etc... let [wx, wy] = nodeWindow.pointer @@ -2735,10 +2767,7 @@ export class WindowManager extends GObject.Object { if (r.width > 0 && r.height > 0) { // Call move_resize_frame directly — this.move() bails out because // metaWindow.grabbed is true for all windows during a Wayland grab - const actor = nodeWin.nodeValue.get_compositor_private(); - if (!actor) return; - actor.remove_all_transitions(); - nodeWin.nodeValue.move_resize_frame(true, r.x, r.y, r.width, r.height); + this._requestFrame(nodeWin.nodeValue, r); } }); } From a155b965e2bcad2fcda963f494459f9c65a0c535 Mon Sep 17 00:00:00 2001 From: mattchristenson Date: Sat, 26 Sep 2026 16:52:20 -0500 Subject: [PATCH 4/9] fix: keep a mouse resize when the neighbour is in another container Dragging the border between a window and a neighbour in a different container (e.g. [A] + CON[B, C], dragging B's left edge) did not move the neighbour during the drag, and on release both snapped back to almost the pre-drag layout. A regression from the live resize in #512. In _handleResizing()'s "parent pairs" branch the percents are computed from the rects of the resize pair's container, but _liveResizeNeighbors() only reprocessed the dragged window's own parent, so those rects stayed at their pre-grab values and every 16 ms tick overwrote the previous one with "stale rect + this tick's delta". - _handleResizing() returns the containers whose children's percents it changed, and the live-resize loop reprocesses exactly those (each once, nothing when no percents changed). - The other side of the resized edge is the child of the neighbour's container that holds the resized window, not simply the next index, which can be a minimized or floating sibling. - The end of a grab cleans up the window whose grab ended, not whichever window has focus by then. Co-Authored-By: Claude Opus 5.5 --- lib/extension/window.js | 104 ++++++++++++++++++++++------------------ 1 file changed, 58 insertions(+), 46 deletions(-) diff --git a/lib/extension/window.js b/lib/extension/window.js index db111a76..ba811236 100644 --- a/lib/extension/window.js +++ b/lib/extension/window.js @@ -34,15 +34,7 @@ import { Logger } from "../shared/logger.js"; // App imports import * as Utils from "./utils.js"; import { Keybindings } from "./keybindings.js"; -import { - Tree, - Queue, - Node, - POSITION, - LAYOUT_TYPES, - ORIENTATION_TYPES, - NODE_TYPES, -} from "./tree.js"; +import { Tree, Queue, Node, LAYOUT_TYPES, ORIENTATION_TYPES, NODE_TYPES } from "./tree.js"; import { production } from "../shared/settings.js"; /** @typedef {import('../../extension.js').default} ForgeExtension */ @@ -2485,13 +2477,13 @@ export class WindowManager extends GObject.Object { lastWidth = currentRect.width; lastHeight = currentRect.height; - this._handleResizing(focusNodeWindow); + const resizedParents = this._handleResizing(focusNodeWindow); // Update initRect so next tick delta is relative to current frame, // not the grab start (prevents percent accumulation) focusNodeWindow.initRect = currentRect; - this._liveResizeNeighbors(focusNodeWindow); + this._liveResizeNeighbors(focusNodeWindow, resizedParents); return GLib.SOURCE_CONTINUE; }); @@ -2504,10 +2496,11 @@ export class WindowManager extends GObject.Object { } } - _handleGrabOpEnd(_display, _metaWindow, grabOp) { + _handleGrabOpEnd(_display, metaWindow, grabOp) { this._stopLiveResizeLoop(); this.unfreezeRender(); - let focusMetaWindow = this.focusMetaWindow; + // The window whose grab ended (a keyboard resize passes it too): focus may have moved since + let focusMetaWindow = metaWindow ?? this.focusMetaWindow; if (!focusMetaWindow) return; let focusNodeWindow = this.findNodeWindow(focusMetaWindow); @@ -2568,15 +2561,22 @@ export class WindowManager extends GObject.Object { return this.kbd.allowDragDropTile(); } + /** + * Update the percents of the resized node and its neighbour from the + * focused window's current size. + * + * @returns {Set} the parent nodes whose children's percents were changed, + * so a live resize can re-layout exactly those containers. + */ _handleResizing(focusNodeWindow) { - if (!focusNodeWindow || focusNodeWindow.isFloat()) return; + const resizedParents = new Set(); + if (!focusNodeWindow || focusNodeWindow.isFloat()) return resizedParents; let grabOps = Utils.decomposeGrabOp(this.grabOp); for (let grabOp of grabOps) { let initGrabOp = focusNodeWindow.initGrabOp; let direction = Utils.directionFromGrab(grabOp); let orientation = Utils.orientationFromGrab(grabOp); let parentNodeForFocus = focusNodeWindow.parentNode; - let position = Utils.positionFromGrabOp(grabOp); // normalize the rect without gaps let frameRect = this.focusMetaWindow.get_frame_rect(); let gaps = this.calculateGaps(focusNodeWindow); @@ -2588,9 +2588,11 @@ export class WindowManager extends GObject.Object { if (initGrabOp === Meta.GrabOp.RESIZING_UNKNOWN) { // the direction is null so do not process yet below. - return; + return resizedParents; } else { resizePairForWindow = this.tree.nextVisible(focusNodeWindow, direction); + // Nothing that way (nextVisible() can return -1), or another monitor: no split to resize + if (!(resizePairForWindow instanceof Node) || resizePairForWindow.isMonitor()) continue; } let sameParent = resizePairForWindow @@ -2601,7 +2603,7 @@ export class WindowManager extends GObject.Object { if (sameParent) { // use the window or con pairs if (this.tree.getTiledChildren(parentNodeForFocus.childNodes).length <= 1) { - return; + return resizedParents; } firstRect = focusNodeWindow.initRect; @@ -2617,7 +2619,7 @@ export class WindowManager extends GObject.Object { } if (!firstRect || !secondRect) { - return; + return resizedParents; } parentRect = parentNodeForFocus.rect; @@ -2626,25 +2628,25 @@ export class WindowManager extends GObject.Object { let secondPercent = (secondRect.width - changePx) / parentRect.width; focusNodeWindow.percent = firstPercent; resizePairForWindow.percent = secondPercent; + resizedParents.add(parentNodeForFocus); } else { // use the parent pairs (con to another con or window) if (resizePairForWindow && resizePairForWindow.parentNode) { if (this.tree.getTiledChildren(resizePairForWindow.parentNode.childNodes).length <= 1) { - return; + return resizedParents; } let firstWindowRect = focusNodeWindow.initRect; - let index = resizePairForWindow.index; - if (position === POSITION.BEFORE) { - // Find the opposite node - index = index + 1; - } else { - index = index - 1; - } - parentNodeForFocus = resizePairForWindow.parentNode.childNodes[index]; + // The other side of the resized edge: the child of the neighbour's container that + // holds the resized window (not simply the next index, which can be a minimized or + // floating sibling) + parentNodeForFocus = resizePairForWindow.parentNode.childNodes.find( + (c) => c === focusNodeWindow || c.contains(focusNodeWindow) + ); + if (!parentNodeForFocus) return resizedParents; firstRect = parentNodeForFocus.rect; secondRect = resizePairForWindow.rect; if (!firstRect || !secondRect) { - return; + return resizedParents; } parentRect = parentNodeForFocus.parentNode.rect; @@ -2653,13 +2655,14 @@ export class WindowManager extends GObject.Object { let secondPercent = (secondRect.width - changePx) / parentRect.width; parentNodeForFocus.percent = firstPercent; resizePairForWindow.percent = secondPercent; + resizedParents.add(resizePairForWindow.parentNode); } } } else if (orientation === ORIENTATION_TYPES.VERTICAL) { if (sameParent) { // use the window or con pairs if (this.tree.getTiledChildren(parentNodeForFocus.childNodes).length <= 1) { - return; + return resizedParents; } firstRect = focusNodeWindow.initRect; if (resizePairForWindow) { @@ -2673,7 +2676,7 @@ export class WindowManager extends GObject.Object { } } if (!firstRect || !secondRect) { - return; + return resizedParents; } parentRect = parentNodeForFocus.rect; let changePx = currentRect.height - firstRect.height; @@ -2681,25 +2684,25 @@ export class WindowManager extends GObject.Object { let secondPercent = (secondRect.height - changePx) / parentRect.height; focusNodeWindow.percent = firstPercent; resizePairForWindow.percent = secondPercent; + resizedParents.add(parentNodeForFocus); } else { // use the parent pairs (con to another con or window) if (resizePairForWindow && resizePairForWindow.parentNode) { if (this.tree.getTiledChildren(resizePairForWindow.parentNode.childNodes).length <= 1) { - return; + return resizedParents; } let firstWindowRect = focusNodeWindow.initRect; - let index = resizePairForWindow.index; - if (position === POSITION.BEFORE) { - // Find the opposite node - index = index + 1; - } else { - index = index - 1; - } - parentNodeForFocus = resizePairForWindow.parentNode.childNodes[index]; + // The other side of the resized edge: the child of the neighbour's container that + // holds the resized window (not simply the next index, which can be a minimized or + // floating sibling) + parentNodeForFocus = resizePairForWindow.parentNode.childNodes.find( + (c) => c === focusNodeWindow || c.contains(focusNodeWindow) + ); + if (!parentNodeForFocus) return resizedParents; firstRect = parentNodeForFocus.rect; secondRect = resizePairForWindow.rect; if (!firstRect || !secondRect) { - return; + return resizedParents; } parentRect = parentNodeForFocus.parentNode.rect; @@ -2708,10 +2711,12 @@ export class WindowManager extends GObject.Object { let secondPercent = (secondRect.height - changePx) / parentRect.height; parentNodeForFocus.percent = firstPercent; resizePairForWindow.percent = secondPercent; + resizedParents.add(resizePairForWindow.parentNode); } } } } + return resizedParents; } /** @@ -2719,14 +2724,21 @@ export class WindowManager extends GObject.Object { * EXCEPT the one currently being dragged (GNOME owns its position). * Bypasses this.move() which is blocked by metaWindow.grabbed on Wayland. */ - _liveResizeNeighbors(draggingNodeWindow) { + _liveResizeNeighbors(draggingNodeWindow, resizedParents) { const draggingMetaWin = draggingNodeWindow.nodeValue; - // Only reprocess the affected container subtree, not the entire tree - const parentNode = draggingNodeWindow.parentNode; - if (parentNode) { - this.tree.processNode(parentNode); - } + // Only reprocess the affected container subtrees, not the entire tree. + // When the resize pair lives above the dragged window's own container + // (e.g. resizing a window inside a con against a sibling of that con), + // the percents were changed on that ancestor, so it must be reprocessed: + // otherwise its children's rects stay stale for the whole grab, every tick + // computes percents from the stale rects plus only that tick's delta, and + // the layout snaps back when the grab ends. + if (!resizedParents.size) return; + const nested = (node) => [...resizedParents].some((p) => p !== node && p.contains(node)); + resizedParents.forEach((node) => { + if (!nested(node)) this.tree.processNode(node); + }); // Move all tiled windows except the one being dragged const tiledWindows = this.tree.getNodeByType(NODE_TYPES.WINDOW); From a46630a09a4a4dfd8707d3112a76ca56c1ee3ce9 Mon Sep 17 00:00:00 2001 From: mattchristenson Date: Sat, 26 Sep 2026 14:20:26 -0500 Subject: [PATCH 5/9] fix: stop a resize when the neighbour reaches its minimum size A resize kept moving the pair's percents after the neighbour's app had refused to shrink any further. The neighbour was pushed off-screen or over other windows, and percents went over 100% (e.g. 1.008 + 0.046). Compute the new sizes of the resized pair from their combined layout size, and keep each node at or above its minimum size: the window's own minimum (Meta.Window.get_min_size(), GNOME 50+) converted to a frame size, plus gaps, with a small floor for windows without one and for older GNOME versions. Containers use the sum or the largest of their children's minimums, depending on the split direction. Co-Authored-By: Claude Opus 5.5 --- lib/extension/tree.js | 53 ++++++++++++++++++++++++++++ lib/extension/window.js | 77 ++++++++++++++++++++++++++++++++++------- 2 files changed, 118 insertions(+), 12 deletions(-) diff --git a/lib/extension/tree.js b/lib/extension/tree.js index ea957d29..37ade0b9 100644 --- a/lib/extension/tree.js +++ b/lib/extension/tree.js @@ -51,6 +51,9 @@ export const ORIENTATION_TYPES = Utils.createEnum(["NONE", "HORIZONTAL", "VERTIC export const POSITION = Utils.createEnum(["BEFORE", "AFTER", "UNKNOWN"]); +// Smallest size (logical px, gaps excluded) a tiled window can be resized to +const MIN_WINDOW_SIZE = 50; + /** * The Node data representation of the following elements in the user's display: * @@ -1618,6 +1621,56 @@ export class Tree extends Node { return sizes; } + /** + * The smallest size (px, gaps included) that `node` can take along `orientation` + * without any of its windows going below the minimum size its app allows. + * Windows without a minimum size, and GNOME versions that cannot report it (< 50), + * use a small floor so that a node can never shrink to nothing. + */ + minSizeOf(node, orientation, gap = null) { + if (!node) return 0; + const horizontal = orientation === ORIENTATION_TYPES.HORIZONTAL; + // The gap only depends on the monitor, so it is looked up once per call + if (gap === null) { + const firstWindow = node.isWindow() ? node : node.getNodeByType(NODE_TYPES.WINDOW)[0]; + gap = firstWindow ? this.extWm.calculateGaps(firstWindow) : 0; + } + + if (node.isWindow()) { + const metaWindow = node.nodeValue; + let min = 0; + if (metaWindow && typeof metaWindow.get_min_size === "function") { + const [hasMin, minWidth, minHeight] = metaWindow.get_min_size(); + if (hasMin) { + // The minimum is a client size; convert it to a frame size (CSD shadows, SSD title bar) + const rect = metaWindow.get_frame_rect(); + rect.width = minWidth; + rect.height = minHeight; + const frame = metaWindow.client_rect_to_frame_rect(rect); + min = horizontal ? frame.width : frame.height; + } + } + return Math.max(min, MIN_WINDOW_SIZE) + gap * 2; + } + + const children = this.getTiledChildren(node.childNodes); + if (children.length === 0) return 0; + const mins = children.map((child) => this.minSizeOf(child, orientation, gap)); + if ((node.isHSplit() && horizontal) || (node.isVSplit() && !horizontal)) { + return mins.reduce((sum, min) => sum + min, 0); + } + + // Children share the node's full size; stacked and tabbed headers take some of the height + let min = Math.max(...mins); + if (!horizontal && node.isStacked()) { + min += this.defaultStackHeight * (children.length - 1); + } else if (!horizontal && node.isTabbed()) { + const showTabs = this.settings.get_boolean("showtab-decoration-enabled"); + min += showTabs ? this.defaultStackHeight * Utils.dpi() : 0; + } + return min; + } + findFirstNodeWindowFrom(node) { let results = node.getNodeByType(NODE_TYPES.WINDOW); if (results.length > 0) { diff --git a/lib/extension/window.js b/lib/extension/window.js index ba811236..78fc3a78 100644 --- a/lib/extension/window.js +++ b/lib/extension/window.js @@ -2623,9 +2623,16 @@ export class WindowManager extends GObject.Object { } parentRect = parentNodeForFocus.rect; - let changePx = currentRect.width - firstRect.width; - let firstPercent = (firstRect.width + changePx) / parentRect.width; - let secondPercent = (secondRect.width - changePx) / parentRect.width; + let [firstSize, secondSize] = this._resizePairSizes( + focusNodeWindow, + (focusNodeWindow.rect ?? firstRect).width, + resizePairForWindow, + secondRect.width, + currentRect.width, + orientation + ); + let firstPercent = firstSize / parentRect.width; + let secondPercent = secondSize / parentRect.width; focusNodeWindow.percent = firstPercent; resizePairForWindow.percent = secondPercent; resizedParents.add(parentNodeForFocus); @@ -2650,9 +2657,16 @@ export class WindowManager extends GObject.Object { } parentRect = parentNodeForFocus.parentNode.rect; - let changePx = currentRect.width - firstWindowRect.width; - let firstPercent = (firstRect.width + changePx) / parentRect.width; - let secondPercent = (secondRect.width - changePx) / parentRect.width; + let [firstSize, secondSize] = this._resizePairSizes( + parentNodeForFocus, + firstRect.width, + resizePairForWindow, + secondRect.width, + firstRect.width + currentRect.width - (focusNodeWindow.rect ?? firstWindowRect).width, + orientation + ); + let firstPercent = firstSize / parentRect.width; + let secondPercent = secondSize / parentRect.width; parentNodeForFocus.percent = firstPercent; resizePairForWindow.percent = secondPercent; resizedParents.add(resizePairForWindow.parentNode); @@ -2679,9 +2693,16 @@ export class WindowManager extends GObject.Object { return resizedParents; } parentRect = parentNodeForFocus.rect; - let changePx = currentRect.height - firstRect.height; - let firstPercent = (firstRect.height + changePx) / parentRect.height; - let secondPercent = (secondRect.height - changePx) / parentRect.height; + let [firstSize, secondSize] = this._resizePairSizes( + focusNodeWindow, + (focusNodeWindow.rect ?? firstRect).height, + resizePairForWindow, + secondRect.height, + currentRect.height, + orientation + ); + let firstPercent = firstSize / parentRect.height; + let secondPercent = secondSize / parentRect.height; focusNodeWindow.percent = firstPercent; resizePairForWindow.percent = secondPercent; resizedParents.add(parentNodeForFocus); @@ -2706,9 +2727,18 @@ export class WindowManager extends GObject.Object { } parentRect = parentNodeForFocus.parentNode.rect; - let changePx = currentRect.height - firstWindowRect.height; - let firstPercent = (firstRect.height + changePx) / parentRect.height; - let secondPercent = (secondRect.height - changePx) / parentRect.height; + let [firstSize, secondSize] = this._resizePairSizes( + parentNodeForFocus, + firstRect.height, + resizePairForWindow, + secondRect.height, + firstRect.height + + currentRect.height - + (focusNodeWindow.rect ?? firstWindowRect).height, + orientation + ); + let firstPercent = firstSize / parentRect.height; + let secondPercent = secondSize / parentRect.height; parentNodeForFocus.percent = firstPercent; resizePairForWindow.percent = secondPercent; resizedParents.add(resizePairForWindow.parentNode); @@ -2719,6 +2749,29 @@ export class WindowManager extends GObject.Object { return resizedParents; } + /** + * New sizes for two adjacent nodes after a resize that asks for `first` to become + * `newFirstSize`. The pair keeps its combined size, and neither node goes below its + * minimum size. Without this, a resize keeps moving percents after a neighbour has stopped + * shrinking, pushing it off-screen or over other windows, with percents over 100%. + */ + _resizePairSizes(first, firstSize, second, secondSize, newFirstSize, orientation) { + const total = firstSize + secondSize; + const minFirst = this.tree.minSizeOf(first, orientation); + const minSecond = this.tree.minSizeOf(second, orientation); + if (minFirst + minSecond > total) { + // Both minimums can't fit: allow only a change that leaves neither node further below its + // minimum than it already is + const size = Math.min( + Math.max(newFirstSize, Math.min(firstSize, minFirst)), + total - Math.min(secondSize, minSecond) + ); + return [size, total - size]; + } + const size = Math.min(Math.max(newFirstSize, minFirst), total - minSecond); + return [size, total - size]; + } + /** * During a mouse-drag resize, immediately re-layout all tiled windows * EXCEPT the one currently being dragged (GNOME owns its position). From 9e5a85dfa030986f64d787e8a16f280bb785c848 Mon Sep 17 00:00:00 2001 From: mattchristenson Date: Sat, 26 Sep 2026 17:45:10 -0500 Subject: [PATCH 6/9] fix: give tiled windows at least their minimum size computeSizes() split a container purely by percent (Math.floor of each share). When a child's share was smaller than the minimum size its app allows, GNOME kept the window at its minimum, so it overlapped its neighbour or extended off-screen, e.g. after the gaps grew or the display got smaller. When the percents did not add up to 1, the children also left a gap or ran off the end, and the rounding lost up to a pixel per child. - fitSizes(): a child that would get less than its minimum gets its minimum and the others give up the difference in proportion. If the minimums can't all fit, they are scaled down together (every child is short by the same fraction) instead of all being dropped at once. Sizes are rounded so that they add up to exactly the container's size (the old TODO). Only splits use minimums; tabbed and stacked children share the whole container. - A mouse resize changes the pair's shares starting from what is shown (Tree.syncShares()), and only when the size changes, so a window held at its minimum no longer makes the next resize drift, and a grab that moves nothing keeps an equal split equal. - Sizes are computed for the same children the layout loop places (those whose windows are still valid), and minSizeOf() ignores windows going away mid-render. Stored percents are not changed by rendering, so "0 = equal split" keeps its meaning and the user's proportions return when there is room again. Co-Authored-By: Claude Opus 5.5 --- lib/extension/tree.js | 137 ++++++++++++++++++++++++++++++---------- lib/extension/window.js | 50 +++++++++------ 2 files changed, 133 insertions(+), 54 deletions(-) diff --git a/lib/extension/tree.js b/lib/extension/tree.js index 37ade0b9..fdecbf7b 100644 --- a/lib/extension/tree.js +++ b/lib/extension/tree.js @@ -1392,7 +1392,9 @@ export class Tree extends Node { } let tiledChildren = this.getTiledChildren(node.childNodes); - let sizes = this.computeSizes(node, tiledChildren); + // Skip windows whose actors were destroyed mid-render + let validChildren = tiledChildren.filter((c) => c.isNodeValid()); + let sizes = this.computeSizes(node, validChildren); params.sizes = sizes; let showTabs = this.settings.get_boolean("showtab-decoration-enabled"); @@ -1408,20 +1410,17 @@ export class Tree extends Node { }); } - // Skip windows whose actors were destroyed mid-render - tiledChildren - .filter((c) => c.isNodeValid()) - .forEach((child, index) => { - // A monitor can contain a window or container child - if (node.layout === LAYOUT_TYPES.HSPLIT || node.layout === LAYOUT_TYPES.VSPLIT) { - this.processSplit(node, child, params, index); - } else if (node.layout === LAYOUT_TYPES.STACKED) { - this.processStacked(node, child, params, index); - } else if (node.layout === LAYOUT_TYPES.TABBED) { - this.processTabbed(node, child, params, index); - } - this.processNode(child); - }); + validChildren.forEach((child, index) => { + // A monitor can contain a window or container child + if (node.layout === LAYOUT_TYPES.HSPLIT || node.layout === LAYOUT_TYPES.VSPLIT) { + this.processSplit(node, child, params, index); + } else if (node.layout === LAYOUT_TYPES.STACKED) { + this.processStacked(node, child, params, index); + } else if (node.layout === LAYOUT_TYPES.TABBED) { + this.processTabbed(node, child, params, index); + } + this.processNode(child); + }); } if (node.isWindow()) { @@ -1605,20 +1604,67 @@ export class Tree extends Node { } computeSizes(node, childItems) { - let sizes = []; let orientation = Utils.orientationFromLayout(node.layout); let totalSize = orientation === ORIENTATION_TYPES.HORIZONTAL ? node.rect.width : node.rect.height; let grabTiled = node.getNodeByMode(Window.WINDOW_MODES.GRAB_TILE).length > 0; - childItems.forEach((childNode, index) => { - let percent = - childNode.percent && childNode.percent > 0.0 && !grabTiled - ? childNode.percent - : 1.0 / childItems.length; - sizes[index] = Math.floor(percent * totalSize); - }); - // TODO - make sure the totalSize = the sizes total - return sizes; + let percents = childItems.map((childNode) => + childNode.percent && childNode.percent > 0.0 && !grabTiled + ? childNode.percent + : 1.0 / childItems.length + ); + // Only splits use the sizes (tabbed and stacked children share the whole container) + let split = node.isHSplit() || node.isVSplit(); + let mins = childItems.map((childNode) => (split ? this.minSizeOf(childNode, orientation) : 0)); + return this.fitSizes(percents, mins, totalSize); + } + + /** + * Split `totalSize` px between children in proportion to `percents`, but give every child at + * least its minimum size: a child that would get less gets its minimum and the difference comes + * from the others. If the minimums don't all fit, they are scaled down together, so every + * child is short of its minimum by the same fraction. The sizes always add up to exactly + * `totalSize`, so the children fill their container without a gap or overlap, even if the + * percents do not sum to 1. + */ + fitSizes(percents, mins, totalSize) { + const count = percents.length; + const minTotal = mins.reduce((sum, min) => sum + min, 0); + if (minTotal > totalSize) mins = mins.map((min) => (min * totalSize) / minTotal); + const sizes = new Array(count).fill(0); + const atMin = new Array(count).fill(false); + + let changed = true; + while (changed) { + changed = false; + let free = totalSize; + let share = 0; + for (let i = 0; i < count; i++) { + if (atMin[i]) free -= mins[i]; + else share += percents[i]; + } + for (let i = 0; i < count; i++) { + if (atMin[i]) { + sizes[i] = mins[i]; + continue; + } + sizes[i] = share > 0 ? (free * percents[i]) / share : 0; + if (sizes[i] < mins[i]) { + atMin[i] = true; + changed = true; + } + } + } + + const rounded = sizes.map((size) => Math.round(size)); + const rest = totalSize - rounded.reduce((sum, size) => sum + size, 0); + if (count > 0 && rest !== 0) { + // The rounding remainder goes to the child with the most room above its minimum + let k = 0; + for (let i = 1; i < count; i++) if (rounded[i] - mins[i] > rounded[k] - mins[k]) k = i; + rounded[k] += rest; + } + return rounded; } /** @@ -1640,20 +1686,24 @@ export class Tree extends Node { const metaWindow = node.nodeValue; let min = 0; if (metaWindow && typeof metaWindow.get_min_size === "function") { - const [hasMin, minWidth, minHeight] = metaWindow.get_min_size(); - if (hasMin) { - // The minimum is a client size; convert it to a frame size (CSD shadows, SSD title bar) - const rect = metaWindow.get_frame_rect(); - rect.width = minWidth; - rect.height = minHeight; - const frame = metaWindow.client_rect_to_frame_rect(rect); - min = horizontal ? frame.width : frame.height; + try { + const [hasMin, minWidth, minHeight] = metaWindow.get_min_size(); + if (hasMin) { + // The minimum is a client size; convert it to a frame size (CSD shadows, SSD title bar) + const rect = metaWindow.get_frame_rect(); + rect.width = minWidth; + rect.height = minHeight; + const frame = metaWindow.client_rect_to_frame_rect(rect); + min = horizontal ? frame.width : frame.height; + } + } catch (e) { + // the window may be going away mid-render } } return Math.max(min, MIN_WINDOW_SIZE) + gap * 2; } - const children = this.getTiledChildren(node.childNodes); + const children = this.getTiledChildren(node.childNodes).filter((c) => c.isNodeValid()); if (children.length === 0) return 0; const mins = children.map((child) => this.minSizeOf(child, orientation, gap)); if ((node.isHSplit() && horizontal) || (node.isVSplit() && !horizontal)) { @@ -1671,6 +1721,25 @@ export class Tree extends Node { return min; } + /** + * Set the shares of the split `node`'s tiled children from their current sizes (what is + * shown), so that a resize which changes some of them leaves the others exactly where they are. + * Stored shares can differ from what is shown: 0 means an equal split, and minimum sizes can + * override a share. Children that are not shown (minimized, floating) get no share. Returns + * false (and changes nothing) if a child has not been laid out yet. + */ + syncShares(node) { + if (!node.rect || !(node.isHSplit() || node.isVSplit())) return false; + const horizontal = node.isHSplit(); + const total = horizontal ? node.rect.width : node.rect.height; + const shown = this.getTiledChildren(node.childNodes).filter((c) => c.isNodeValid()); + if (!total || shown.some((c) => !c.rect)) return false; + node.childNodes.forEach((c) => { + c.percent = shown.includes(c) ? (horizontal ? c.rect.width : c.rect.height) / total : 0; + }); + return true; + } + findFirstNodeWindowFrom(node) { let results = node.getNodeByType(NODE_TYPES.WINDOW); if (results.length > 0) { diff --git a/lib/extension/window.js b/lib/extension/window.js index 78fc3a78..e1eb632a 100644 --- a/lib/extension/window.js +++ b/lib/extension/window.js @@ -2631,11 +2631,10 @@ export class WindowManager extends GObject.Object { currentRect.width, orientation ); - let firstPercent = firstSize / parentRect.width; - let secondPercent = secondSize / parentRect.width; - focusNodeWindow.percent = firstPercent; - resizePairForWindow.percent = secondPercent; - resizedParents.add(parentNodeForFocus); + const pairParent = parentNodeForFocus; + const sizes = [firstSize, secondSize, parentRect.width]; + if (this._setPairShares(pairParent, focusNodeWindow, resizePairForWindow, ...sizes)) + resizedParents.add(pairParent); } else { // use the parent pairs (con to another con or window) if (resizePairForWindow && resizePairForWindow.parentNode) { @@ -2665,11 +2664,10 @@ export class WindowManager extends GObject.Object { firstRect.width + currentRect.width - (focusNodeWindow.rect ?? firstWindowRect).width, orientation ); - let firstPercent = firstSize / parentRect.width; - let secondPercent = secondSize / parentRect.width; - parentNodeForFocus.percent = firstPercent; - resizePairForWindow.percent = secondPercent; - resizedParents.add(resizePairForWindow.parentNode); + const pairParent = resizePairForWindow.parentNode; + const sizes = [firstSize, secondSize, parentRect.width]; + if (this._setPairShares(pairParent, parentNodeForFocus, resizePairForWindow, ...sizes)) + resizedParents.add(pairParent); } } } else if (orientation === ORIENTATION_TYPES.VERTICAL) { @@ -2701,11 +2699,10 @@ export class WindowManager extends GObject.Object { currentRect.height, orientation ); - let firstPercent = firstSize / parentRect.height; - let secondPercent = secondSize / parentRect.height; - focusNodeWindow.percent = firstPercent; - resizePairForWindow.percent = secondPercent; - resizedParents.add(parentNodeForFocus); + const pairParent = parentNodeForFocus; + const sizes = [firstSize, secondSize, parentRect.height]; + if (this._setPairShares(pairParent, focusNodeWindow, resizePairForWindow, ...sizes)) + resizedParents.add(pairParent); } else { // use the parent pairs (con to another con or window) if (resizePairForWindow && resizePairForWindow.parentNode) { @@ -2737,11 +2734,10 @@ export class WindowManager extends GObject.Object { (focusNodeWindow.rect ?? firstWindowRect).height, orientation ); - let firstPercent = firstSize / parentRect.height; - let secondPercent = secondSize / parentRect.height; - parentNodeForFocus.percent = firstPercent; - resizePairForWindow.percent = secondPercent; - resizedParents.add(resizePairForWindow.parentNode); + const pairParent = resizePairForWindow.parentNode; + const sizes = [firstSize, secondSize, parentRect.height]; + if (this._setPairShares(pairParent, parentNodeForFocus, resizePairForWindow, ...sizes)) + resizedParents.add(pairParent); } } } @@ -2772,6 +2768,20 @@ export class WindowManager extends GObject.Object { return [size, total - size]; } + /** + * Give a resized pair its new sizes (px) as shares of `parent`. Nothing happens if the size + * doesn't change; otherwise the shares are first synced to what is shown, so that the other + * children stay exactly where they are. Returns whether anything changed. + */ + _setPairShares(parent, first, second, firstSize, secondSize, parentSize) { + const shown = parent.isHSplit() ? first.rect?.width : first.rect?.height; + if (shown !== undefined && Math.abs(firstSize - shown) < 0.5) return false; + if (!this.tree.syncShares(parent)) return false; + first.percent = firstSize / parentSize; + second.percent = secondSize / parentSize; + return true; + } + /** * During a mouse-drag resize, immediately re-layout all tiled windows * EXCEPT the one currently being dragged (GNOME owns its position). From 7198a25116bfbeb48564a44145511360cf4188ae Mon Sep 17 00:00:00 2001 From: mattchristenson Date: Sat, 26 Sep 2026 17:45:52 -0500 Subject: [PATCH 7/9] fix: keep a mouse resize to the window when its container splits the same way When a window's container is split in the same direction as its parent, e.g. HSPLIT[A, HSPLIT[B, C]] (which auto-split creates on wide screens), dragging B's outer edge changed the container [B, C] and the change was then shared between B and C by their percents: C grew or shrank too and the B|C border moved, although only the A|B border was dragged. After a parent-pair resize, keep the change on the way down to the window: in each container split in the resize direction, only the child holding the window changes size (its siblings keep their sizes as shown). The resize also stops at that child's minimum size. Co-Authored-By: Claude Opus 5.5 --- lib/extension/window.js | 94 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 87 insertions(+), 7 deletions(-) diff --git a/lib/extension/window.js b/lib/extension/window.js index e1eb632a..4aed955b 100644 --- a/lib/extension/window.js +++ b/lib/extension/window.js @@ -2662,12 +2662,24 @@ export class WindowManager extends GObject.Object { resizePairForWindow, secondRect.width, firstRect.width + currentRect.width - (focusNodeWindow.rect ?? firstWindowRect).width, - orientation + orientation, + this._pathMinSize(parentNodeForFocus, focusNodeWindow, orientation) ); const pairParent = resizePairForWindow.parentNode; const sizes = [firstSize, secondSize, parentRect.width]; - if (this._setPairShares(pairParent, parentNodeForFocus, resizePairForWindow, ...sizes)) + if ( + this._setPairShares(pairParent, parentNodeForFocus, resizePairForWindow, ...sizes) + ) { + const firstShown = firstRect.width; + this._resizeSharesAlongPath( + parentNodeForFocus, + focusNodeWindow, + firstShown, + firstSize, + orientation + ); resizedParents.add(pairParent); + } } } } else if (orientation === ORIENTATION_TYPES.VERTICAL) { @@ -2732,12 +2744,24 @@ export class WindowManager extends GObject.Object { firstRect.height + currentRect.height - (focusNodeWindow.rect ?? firstWindowRect).height, - orientation + orientation, + this._pathMinSize(parentNodeForFocus, focusNodeWindow, orientation) ); const pairParent = resizePairForWindow.parentNode; const sizes = [firstSize, secondSize, parentRect.height]; - if (this._setPairShares(pairParent, parentNodeForFocus, resizePairForWindow, ...sizes)) + if ( + this._setPairShares(pairParent, parentNodeForFocus, resizePairForWindow, ...sizes) + ) { + const firstShown = firstRect.height; + this._resizeSharesAlongPath( + parentNodeForFocus, + focusNodeWindow, + firstShown, + firstSize, + orientation + ); resizedParents.add(pairParent); + } } } } @@ -2748,12 +2772,12 @@ export class WindowManager extends GObject.Object { /** * New sizes for two adjacent nodes after a resize that asks for `first` to become * `newFirstSize`. The pair keeps its combined size, and neither node goes below its - * minimum size. Without this, a resize keeps moving percents after a neighbour has stopped + * minimum size (for `first`, at least `minFirst`). Without this, a resize keeps moving percents after a neighbour has stopped * shrinking, pushing it off-screen or over other windows, with percents over 100%. */ - _resizePairSizes(first, firstSize, second, secondSize, newFirstSize, orientation) { + _resizePairSizes(first, firstSize, second, secondSize, newFirstSize, orientation, minFirst = 0) { const total = firstSize + secondSize; - const minFirst = this.tree.minSizeOf(first, orientation); + minFirst = Math.max(minFirst, this.tree.minSizeOf(first, orientation)); const minSecond = this.tree.minSizeOf(second, orientation); if (minFirst + minSecond > total) { // Both minimums can't fit: allow only a change that leaves neither node further below its @@ -2782,6 +2806,62 @@ export class WindowManager extends GObject.Object { return true; } + /** + * The children on the way from `node` down to `nodeWindow`, starting with `node`'s. + */ + _pathTo(node, nodeWindow) { + const path = []; + while (node && node !== nodeWindow) { + node = node.childNodes.find((c) => c === nodeWindow || c.contains(nodeWindow)); + if (node) path.push(node); + } + return path; + } + /** + * After a resize changed `first` (a container holding `nodeWindow`) from `oldSize` to + * `newSize` px, keep that change on the way down to `nodeWindow`: in every container on the way + * that is split in the resize direction, only the child holding the window changes size and its + * siblings keep theirs. Otherwise the change is spread over all the children by their shares, + * and borders the user did not touch move too. Works from the shares (synced to what is shown), + * so it also holds when key repeats come faster than renders. + */ + _resizeSharesAlongPath(first, nodeWindow, oldSize, newSize, orientation) { + const horizontal = orientation === ORIENTATION_TYPES.HORIZONTAL; + let node = first; + for (const child of this._pathTo(first, nodeWindow)) { + if (horizontal ? node.isHSplit() : node.isVSplit()) { + if (!this.tree.syncShares(node)) return; // not laid out yet: leave the shares alone + const children = this.tree.getTiledChildren(node.childNodes); + const childOld = child.percent * oldSize; + const childNew = childOld + newSize - oldSize; + children.forEach((c) => { + c.percent = (c === child ? childNew : c.percent * oldSize) / newSize; + }); + oldSize = childOld; + newSize = childNew; + } + node = child; + } + } + /** + * The smallest size `first` can shrink to when, as in _resizeSharesAlongPath(), only the child on + * the way to `nodeWindow` changes size in containers split in the resize direction. + */ + _pathMinSize(first, nodeWindow, orientation) { + const horizontal = orientation === ORIENTATION_TYPES.HORIZONTAL; + const size = (rect) => (horizontal ? rect.width : rect.height); + if (!first.rect) return 0; + let slack = Infinity; + let node = first; + for (const child of this._pathTo(first, nodeWindow)) { + const along = horizontal ? node.isHSplit() : node.isVSplit(); + if (along && child.rect) { + slack = Math.min(slack, size(child.rect) - this.tree.minSizeOf(child, orientation)); + } + node = child; + } + return slack === Infinity ? 0 : size(first.rect) - Math.max(slack, 0); + } /** * During a mouse-drag resize, immediately re-layout all tiled windows * EXCEPT the one currently being dragged (GNOME owns its position). From bd7e5b86046db37fe462d2ab4992cc55fa84eaaf Mon Sep 17 00:00:00 2001 From: mattchristenson Date: Sat, 26 Sep 2026 17:46:14 -0500 Subject: [PATCH 8/9] fix: resize tiled windows with the keyboard by changing the layout Keyboard resizing emulated a mouse grab: each key press started a fake grab, grew the window's frame, and a 16 ms loop worked the layout back out of the frame once the app had redrawn; a 50 ms timer ended the grab. That round trip caused several bugs: - a held shortcut against a neighbour in another container snapped back on release (#532): each key repeat restarted the grab and dropped the steps the loop had not processed yet; - a held shortcut showed the window past the neighbour's minimum size, over it or off-screen, until release; - the last step of a held resize could be lost when the grab ended. A resize shortcut on a tiled window now changes the layout directly: it finds the two nodes on either side of the resized edge (the window, or its nearest container in a split in that direction, and its tiled sibling that way; windows in a tabbed or stacked container share one place, so those are passed through, and the search stops at the monitor), moves the step between their sizes as shown, within both minimum sizes and keeping it on the way down to the window, lays out that container and moves the windows under the two nodes. The layout stays the one source of truth: no grab, no polling, no timer, and each key repeat builds on the last one. A maximized or fullscreen tiled window is left alone (the change would be invisible), and so is the layout while a window is being dragged. Floating windows, and all windows while tiling is off, still resize their frame, now without the grab emulation. Co-Authored-By: Claude Opus 5.5 --- lib/extension/window.js | 114 +++++++++++++++++++++++++++++++++++----- 1 file changed, 100 insertions(+), 14 deletions(-) diff --git a/lib/extension/window.js b/lib/extension/window.js index 4aed955b..e9cff868 100644 --- a/lib/extension/window.js +++ b/lib/extension/window.js @@ -796,11 +796,19 @@ export class WindowManager extends GObject.Object { resize(grabOp, amount) { let metaWindow = this.focusMetaWindow; - let display = global.display; if (!metaWindow) return; // no focused window, e.g. an empty workspace - this._handleGrabOpBegin(display, metaWindow, grabOp); + const nodeWindow = this.findNodeWindow(metaWindow); + if (nodeWindow?.isTile() && this.ext.settings.get_boolean("tiling-mode-enabled")) { + // Leave the layout alone while a window is being dragged, and behind a maximized or + // fullscreen window (the change would be invisible) + if (!this._freezeRender && !this._isMaximizedOrFullscreen(metaWindow)) { + this._resizeTiled(nodeWindow, grabOp, amount); + } + return; + } + // Not tiled (floating, or not managed by Forge): resize its frame let rect = metaWindow.get_frame_rect(); let direction = Utils.directionFromGrab(grabOp); @@ -823,19 +831,97 @@ export class WindowManager extends GObject.Object { break; } this.move(metaWindow, rect); - this.queueEvent( - { - name: "manual-resize", - callback: () => { - if (this.eventQueue.length === 0) { - this._handleGrabOpEnd(display, metaWindow, grabOp); - } - }, - }, - 50 - ); + this.updateBorderLayout(); } + /** + * Resize a tiled window with the keyboard by changing the layout: the shares of the two nodes + * on either side of the resized edge (see _resizePairFor()), within their minimum sizes; then + * lay out that container. The layout stays the one source of truth, so a held shortcut can't + * overshoot or snap back, and each key repeat builds on the last one. + */ + _resizeTiled(nodeWindow, grabOp, amount) { + const pair = this._resizePairFor(nodeWindow, grabOp); + if (!pair) return; // no neighbour that way: the edge is the monitor's edge + const [first, second] = pair; + const parent = first.parentNode; + if (!parent.rect || !first.rect || !second.rect) return; + const orientation = Utils.orientationFromGrab(grabOp); + const size = (rect) => + orientation === ORIENTATION_TYPES.HORIZONTAL ? rect.width : rect.height; + + // Start from what is shown, so that the other children stay exactly where they are + if (!this.tree.syncShares(parent)) return; // not laid out yet + const total = size(parent.rect); + const firstSize = size(first.rect); + const secondSize = size(second.rect); + const [newFirst, newSecond] = this._resizePairSizes( + first, + firstSize, + second, + secondSize, + firstSize + amount, + orientation, + first === nodeWindow ? 0 : this._pathMinSize(first, nodeWindow, orientation) + ); + if (Math.abs(newFirst - firstSize) < 0.5) return; // at a limit: nothing to do + first.percent = newFirst / total; + second.percent = newSecond / total; + this._resizeSharesAlongPath(first, nodeWindow, firstSize, newFirst, orientation); + + // Lay out that container now, so that the next key repeat sees the new sizes, and move the + // windows whose place changed (those under the two nodes) + this.tree.processNode(parent); + [first, second].forEach((node) => { + const windows = node.isWindow() ? [node] : node.getNodeByType(NODE_TYPES.WINDOW); + windows.forEach((w) => { + const r = w.renderRect; + if (!w.isTile() || w.nodeValue.minimized || !r || r.width <= 0 || r.height <= 0) return; + try { + this.move(w.nodeValue, r); + } catch (e) { + // the window may have gone away since it was laid out + } + }); + }); + this.updateDecorationLayout(); + this.updateBorderLayout(); + } + _isMaximizedOrFullscreen(metaWindow) { + if (metaWindow.is_fullscreen()) return true; + try { + // GNOME 49+ + return metaWindow.is_maximized(); + } catch (e) { + // pre-49 fallback + return metaWindow.get_maximized() === Meta.MaximizeFlags.BOTH; + } + } + /** + * The two nodes on either side of `nodeWindow`'s edge in the direction of `grabOp`: the + * window, or its nearest container that sits in a split in that direction with a tiled + * sibling that way, and that sibling. Windows in a tabbed or stacked container share one + * place, so the search goes past those; it stops at the monitor. Null if there is no + * neighbour that way. + */ + _resizePairFor(nodeWindow, grabOp) { + const direction = Utils.directionFromGrab(grabOp); + const horizontal = + direction === Meta.MotionDirection.LEFT || direction === Meta.MotionDirection.RIGHT; + const forward = + direction === Meta.MotionDirection.RIGHT || direction === Meta.MotionDirection.DOWN; + for (let node = nodeWindow; node?.parentNode; node = node.parentNode) { + const parent = node.parentNode; + if (horizontal ? parent.isHSplit() : parent.isVSplit()) { + const siblings = this.tree.getTiledChildren(parent.childNodes); + const i = siblings.indexOf(node); + const j = forward ? i + 1 : i - 1; + if (i >= 0 && j >= 0 && j < siblings.length) return [node, siblings[j]]; + } + if (parent.isMonitor()) break; + } + return null; + } disable() { Utils._disableDecorations(); this._removeSignals(); @@ -2499,7 +2585,7 @@ export class WindowManager extends GObject.Object { _handleGrabOpEnd(_display, metaWindow, grabOp) { this._stopLiveResizeLoop(); this.unfreezeRender(); - // The window whose grab ended (a keyboard resize passes it too): focus may have moved since + // The window whose grab ended: focus may have moved since let focusMetaWindow = metaWindow ?? this.focusMetaWindow; if (!focusMetaWindow) return; let focusNodeWindow = this.findNodeWindow(focusMetaWindow); From c65ebc303dfff25d798a8a811895dd8a74efaf8f Mon Sep 17 00:00:00 2001 From: mattchristenson Date: Tue, 29 Sep 2026 17:38:54 -0500 Subject: [PATCH 9/9] fix: don't draw windows over each other while apps redraw When a layout changes, GNOME shows a Wayland window's new size only once its app has drawn it, while X11 windows move at once. A quicker neighbour then took the space before the slower app had left it, and was drawn over it until the app caught up; while a resize key repeated, for seconds. moveAll() applies a layout's places: a window whose new place is still covered by a window on its way out waits until that window has left (re-checked every frame), so there is a gap instead of an overlap. The wait restarts while what it waits for keeps moving, and ends after 500 ms without progress. Windows not placed yet, windows already covering the leaving one, and windows that only wait on each other (swaps) don't wait. Co-Authored-By: Claude Opus 5.5 --- lib/extension/tree.js | 9 ++- lib/extension/window.js | 141 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 137 insertions(+), 13 deletions(-) diff --git a/lib/extension/tree.js b/lib/extension/tree.js index fdecbf7b..7ad2ba9b 100644 --- a/lib/extension/tree.js +++ b/lib/extension/tree.js @@ -1291,14 +1291,11 @@ export class Tree extends Node { let tiledChildren = node .getNodeByMode(Window.WINDOW_MODES.TILE) .filter((t) => t.nodeType === NODE_TYPES.WINDOW); + const moves = []; tiledChildren.forEach((w) => { if (w.renderRect) { if (w.renderRect.width > 0 && w.renderRect.height > 0) { - // Window may have been destroyed since processNode computed renderRect - let metaWin = w.nodeValue; - try { - this.extWm.move(metaWin, w.renderRect); - } catch (e) {} + moves.push([w.nodeValue, w.renderRect]); } else { Logger.debug(`ignoring apply for ${w.renderRect.width}x${w.renderRect.height}`); } @@ -1306,6 +1303,8 @@ export class Tree extends Node { if (w.nodeValue.firstRender) w.nodeValue.firstRender = false; }); + // all together, so that windows giving up space are moved before those taking it + this.extWm.moveAll(moves); } cleanTree() { diff --git a/lib/extension/window.js b/lib/extension/window.js index 5ba97bab..87493572 100644 --- a/lib/extension/window.js +++ b/lib/extension/window.js @@ -44,6 +44,9 @@ export const WINDOW_MODES = Utils.createEnum(["FLOAT", "TILE", "GRAB_TILE", "DEF // Simplify the grab modes export const GRAB_TYPES = Utils.createEnum(["RESIZING", "MOVING", "UNKNOWN"]); +// The longest a window waits for its new place without progress (see moveAll) +const MOVE_MAX_WAIT_MS = 500; + export class WindowManager extends GObject.Object { static { GObject.registerClass(this); @@ -872,18 +875,16 @@ export class WindowManager extends GObject.Object { // Lay out that container now, so that the next key repeat sees the new sizes, and move the // windows whose place changed (those under the two nodes) this.tree.processNode(parent); + const moves = []; [first, second].forEach((node) => { const windows = node.isWindow() ? [node] : node.getNodeByType(NODE_TYPES.WINDOW); windows.forEach((w) => { const r = w.renderRect; if (!w.isTile() || w.nodeValue.minimized || !r || r.width <= 0 || r.height <= 0) return; - try { - this.move(w.nodeValue, r); - } catch (e) { - // the window may have gone away since it was laid out - } + moves.push([w.nodeValue, r]); }); }); + this.moveAll(moves); this.updateDecorationLayout(); this.updateBorderLayout(); } @@ -1063,6 +1064,7 @@ export class WindowManager extends GObject.Object { * window separately first, or it is shown at the new position with its old size meanwhile. */ _requestFrame(metaWindow, rect) { + this._queuedMoves?.delete(metaWindow); // a direct request replaces a queued one const windowActor = metaWindow.get_compositor_private(); if (!windowActor) return; windowActor.remove_all_transitions(); @@ -1081,11 +1083,128 @@ export class WindowManager extends GObject.Object { } /** - * Where `metaWindow` is going: the frame Forge asked for in the last half second if the app - * has not drawn its size yet (its position is only applied together with its size), - * otherwise its current frame. + * Move several windows to new places without drawing one over another while the apps catch up. + * An app takes a frame or more to draw a new size, and GNOME shows its window at the old place + * until then (X11 apps move at once). So a window's new place is only requested once the + * windows that still cover it have left: e.g. when one window grows into the space its neighbour + * gives up, the neighbour shrinks first. Meanwhile there is a gap, never an overlap. + * A window waits as long as what it waits for keeps moving (a slow app always jumps to the + * latest place asked of it, e.g. while a resize key repeats), but at most MOVE_MAX_WAIT_MS + * without progress, in case an app never redraws. + * @param {Array<[Meta.Window, {x: number, y: number, width: number, height: number}]>} moves + */ + moveAll(moves) { + const queue = (this._queuedMoves ??= new Map()); + moves.forEach(([metaWindow, rect]) => { + if (!metaWindow) return; + const { x, y, width, height } = rect; + const entry = queue.get(metaWindow) ?? { waitFor: [], waitKey: null, since: 0 }; + queue.set(metaWindow, { ...entry, rect: { x, y, width, height } }); + }); + this._flushMoves(); + } + + /** Request the queued places that are free now; keep checking every frame for the others. */ + _flushMoves() { + const queue = this._queuedMoves; + if (!queue) return; + const now = GLib.get_monotonic_time(); + const covers = (a, b) => + Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > 2 && + Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > 2; + const showing = (w) => w.get_compositor_private() && !w.minimized; + const destination = (w) => queue.get(w)?.rect ?? this._targetRect(w); + const others = this.tree.nodeWindows.map((n) => n.nodeValue).filter((w) => w && showing(w)); + // The windows that cover `rect` now but are on their way out of it. A window Forge hasn't + // placed yet (just opened, wherever the app put it) doesn't wait, and neither does one for a + // window that it already covers. + const leaving = (metaWindow, rect) => { + if (!metaWindow.forgePendingFrame) return []; + const from = metaWindow.get_frame_rect(); + return others.filter((w) => { + if (w === metaWindow || w.get_workspace() !== metaWindow.get_workspace()) return false; + const frame = w.get_frame_rect(); + return covers(frame, rect) && !covers(destination(w), rect) && !covers(frame, from); + }); + }; + const request = (metaWindow) => { + const { rect } = queue.get(metaWindow); + queue.delete(metaWindow); + try { + this.move(metaWindow, rect); + } catch (e) { + // the window may have gone away since it was queued + } + }; + for (let pass = 0; queue.size && pass < 2 * queue.size + 2; pass++) { + let moved = false; + const onlyQueued = []; + for (const [metaWindow, entry] of [...queue]) { + if (!showing(metaWindow) || !this.tree.findNode(metaWindow)) { + queue.delete(metaWindow); // closed, minimized or no longer Forge's + continue; + } + const waitFor = leaving(metaWindow, entry.rect); + entry.waitFor = waitFor; + // The wait starts again whenever what it waits for moves + const where = waitFor + .map((w) => { + const f = w.get_frame_rect(); + return `${w.get_id()}:${f.x},${f.y},${f.width},${f.height}`; + }) + .join(" "); + if (where !== entry.waitKey) { + entry.waitKey = where; + entry.since = now; + } + if (!waitFor.length || now - entry.since > MOVE_MAX_WAIT_MS * 1000) { + request(metaWindow); // (an X11 app's frame moves at once, which may free other places) + moved = true; + } else { + onlyQueued.push(metaWindow); + } + } + if (moved) continue; + // Windows that wait only on each other (e.g. two that swap places) can't take turns: release + // a group of them only if it is closed, everything it waits on waiting on it in turn. A chain + // leading into or out of such a group, and anything waiting on a window on its way, waits. + const waiting = new Set(onlyQueued); + const reachable = (w) => { + const seen = new Set(); + const todo = [...queue.get(w).waitFor]; + while (todo.length) { + const x = todo.pop(); + if (seen.has(x)) continue; + seen.add(x); + if (waiting.has(x)) todo.push(...queue.get(x).waitFor); + } + return seen; + }; + const reach = new Map(onlyQueued.map((w) => [w, reachable(w)])); + const cycle = onlyQueued.filter((w) => + [...reach.get(w)].every((x) => waiting.has(x) && reach.get(x).has(w)) + ); + if (!cycle.length) break; + cycle.forEach(request); + } + if (queue.size && !this._flushMovesSrcId) { + this._flushMovesSrcId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 16, () => { + this._flushMoves(); + if (this._queuedMoves.size) return GLib.SOURCE_CONTINUE; + this._flushMovesSrcId = 0; + return GLib.SOURCE_REMOVE; + }); + } + } + + /** + * Where `metaWindow` is going: the place it waits for (see moveAll), or the frame Forge asked + * for in the last half second if the app has not drawn its size yet (its position is only + * applied together with its size), otherwise its current frame. */ _targetRect(metaWindow) { + const queued = this._queuedMoves?.get(metaWindow); + if (queued) return queued.rect; // waiting for its place to be free (see moveAll) const frame = metaWindow.get_frame_rect(); const pending = metaWindow.forgePendingFrame; const recent = pending && GLib.get_monotonic_time() - pending.time < 500000; @@ -1253,6 +1372,12 @@ export class WindowManager extends GObject.Object { this._queueSourceId = 0; } + if (this._flushMovesSrcId) { + GLib.Source.remove(this._flushMovesSrcId); + this._flushMovesSrcId = 0; + } + this._queuedMoves?.clear(); + if (this._pointerFocusTimeoutId) { GLib.Source.remove(this._pointerFocusTimeoutId); this._pointerFocusTimeoutId = 0;