From d1db4ee9141532b9c2c0f97ec76924436a6c60e9 Mon Sep 17 00:00:00 2001 From: mattchristenson Date: Sat, 26 Sep 2026 13:43:42 -0500 Subject: [PATCH 1/8] 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/8] 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 a155b965e2bcad2fcda963f494459f9c65a0c535 Mon Sep 17 00:00:00 2001 From: mattchristenson Date: Sat, 26 Sep 2026 16:52:20 -0500 Subject: [PATCH 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 bdc4022576c52eb24eccc4e5ebaf067dde7da2c7 Mon Sep 17 00:00:00 2001 From: mattchristenson Date: Mon, 28 Sep 2026 09:32:16 -0500 Subject: [PATCH 8/8] fix: resize a tabbed or stacked group from any of its windows Dragging the outer edge of a window in a tabbed group (or the top/bottom edge of one in a stacked group) snapped back on release unless the window was the first in its group. _handleResizing() found the window on the other side of the edge with Tree.nextVisible(), and Tree.next() walks a tabbed group as if its windows were side by side (a stacked one as if they were stacked) - right for focus navigation, wrong for resizing: it found the previous tab, the resize changed shares inside the group, and nothing moved. The mouse resize now finds its pair the way the keyboard resize does (_resizePairFor(): the nearest tiled sibling in a split in that direction, through tabbed/stacked groups, stopping at the monitor). So it also never pairs with a floating window or with a container whose windows are all minimized. _resizeSharesAlongPath() uses each split's own shown size, which inside a group is smaller than the group's by the headers. Co-Authored-By: Claude Opus 5.5 --- lib/extension/window.js | 38 ++++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/lib/extension/window.js b/lib/extension/window.js index e9cff868..c3729103 100644 --- a/lib/extension/window.js +++ b/lib/extension/window.js @@ -867,7 +867,7 @@ export class WindowManager extends GObject.Object { 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); + this._resizeSharesAlongPath(first, nodeWindow, newFirst - firstSize, 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) @@ -906,6 +906,7 @@ export class WindowManager extends GObject.Object { */ _resizePairFor(nodeWindow, grabOp) { const direction = Utils.directionFromGrab(grabOp); + if (direction === undefined || direction === null) return null; // not an edge resize const horizontal = direction === Meta.MotionDirection.LEFT || direction === Meta.MotionDirection.RIGHT; const forward = @@ -913,7 +914,9 @@ export class WindowManager extends GObject.Object { 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 siblings = this.tree + .getTiledChildren(parent.childNodes) + .filter((c) => c.isNodeValid()); const i = siblings.indexOf(node); const j = forward ? i + 1 : i - 1; if (i >= 0 && j >= 0 && j < siblings.length) return [node, siblings[j]]; @@ -2660,7 +2663,6 @@ export class WindowManager extends GObject.Object { 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; // normalize the rect without gaps @@ -2676,9 +2678,11 @@ export class WindowManager extends GObject.Object { // the direction is null so do not process yet below. 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; + // The same pair as a keyboard resize: the nearest tiled sibling in a split in that + // direction. Windows in a tabbed or stacked group share its place, so the group's edge is + // resized (Tree.next() would walk the group's windows as if side by side, and pick a tab). + resizePairForWindow = this._resizePairFor(focusNodeWindow, grabOp)?.[1]; + if (!resizePairForWindow) continue; // nothing that way on this monitor: no split to resize } let sameParent = resizePairForWindow @@ -2760,8 +2764,7 @@ export class WindowManager extends GObject.Object { this._resizeSharesAlongPath( parentNodeForFocus, focusNodeWindow, - firstShown, - firstSize, + firstSize - firstShown, orientation ); resizedParents.add(pairParent); @@ -2842,8 +2845,7 @@ export class WindowManager extends GObject.Object { this._resizeSharesAlongPath( parentNodeForFocus, focusNodeWindow, - firstShown, - firstSize, + firstSize - firstShown, orientation ); resizedParents.add(pairParent); @@ -2904,27 +2906,27 @@ export class WindowManager extends GObject.Object { 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 + * After a resize changed `first` (a container holding `nodeWindow`) by `delta` 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) { + _resizeSharesAlongPath(first, nodeWindow, delta, 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 + // The split's own shown size: inside a tabbed or stacked group it is smaller than the + // group's by the headers + const total = horizontal ? node.rect.width : node.rect.height; const children = this.tree.getTiledChildren(node.childNodes); - const childOld = child.percent * oldSize; - const childNew = childOld + newSize - oldSize; + const childNew = child.percent * total + delta; children.forEach((c) => { - c.percent = (c === child ? childNew : c.percent * oldSize) / newSize; + c.percent = (c === child ? childNew : c.percent * total) / (total + delta); }); - oldSize = childOld; - newSize = childNew; } node = child; }