diff --git a/lib/extension/tree.js b/lib/extension/tree.js index ea957d29..fdecbf7b 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: * @@ -1389,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"); @@ -1405,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()) { @@ -1602,20 +1604,140 @@ 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); + 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; + } + + /** + * 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") { + 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).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)) { + 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; + } + + /** + * 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; }); - // TODO - make sure the totalSize = the sizes total - return sizes; + return true; } findFirstNodeWindowFrom(node) { diff --git a/lib/extension/window.js b/lib/extension/window.js index 61820e1e..c3729103 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 */ @@ -804,10 +796,19 @@ export class WindowManager extends GObject.Object { resize(grabOp, amount) { let metaWindow = this.focusMetaWindow; - let display = global.display; - - this._handleGrabOpBegin(display, metaWindow, grabOp); + if (!metaWindow) return; // no focused window, e.g. an empty workspace + + 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); @@ -820,27 +821,110 @@ 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); - 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, 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) + 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); + if (direction === undefined || direction === null) return null; // not an edge resize + 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) + .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]]; + } + if (parent.isMonitor()) break; + } + return null; + } disable() { Utils._disableDecorations(); this._removeSignals(); @@ -2482,13 +2566,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; }); @@ -2501,10 +2585,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: focus may have moved since + let focusMetaWindow = metaWindow ?? this.focusMetaWindow; if (!focusMetaWindow) return; let focusNodeWindow = this.findNodeWindow(focusMetaWindow); @@ -2565,15 +2650,21 @@ 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); @@ -2585,9 +2676,13 @@ 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); + // 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 @@ -2598,7 +2693,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; @@ -2614,49 +2709,73 @@ export class WindowManager extends GObject.Object { } if (!firstRect || !secondRect) { - return; + return resizedParents; } parentRect = parentNodeForFocus.rect; - let changePx = currentRect.width - firstRect.width; - let firstPercent = (firstRect.width + changePx) / parentRect.width; - let secondPercent = (secondRect.width - changePx) / parentRect.width; - focusNodeWindow.percent = firstPercent; - resizePairForWindow.percent = secondPercent; + let [firstSize, secondSize] = this._resizePairSizes( + focusNodeWindow, + (focusNodeWindow.rect ?? firstRect).width, + resizePairForWindow, + secondRect.width, + currentRect.width, + orientation + ); + 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) { 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; - let changePx = currentRect.width - firstWindowRect.width; - let firstPercent = (firstRect.width + changePx) / parentRect.width; - let secondPercent = (secondRect.width - changePx) / parentRect.width; - parentNodeForFocus.percent = firstPercent; - resizePairForWindow.percent = secondPercent; + let [firstSize, secondSize] = this._resizePairSizes( + parentNodeForFocus, + firstRect.width, + resizePairForWindow, + secondRect.width, + firstRect.width + currentRect.width - (focusNodeWindow.rect ?? firstWindowRect).width, + orientation, + this._pathMinSize(parentNodeForFocus, focusNodeWindow, orientation) + ); + const pairParent = resizePairForWindow.parentNode; + const sizes = [firstSize, secondSize, parentRect.width]; + if ( + this._setPairShares(pairParent, parentNodeForFocus, resizePairForWindow, ...sizes) + ) { + const firstShown = firstRect.width; + this._resizeSharesAlongPath( + parentNodeForFocus, + focusNodeWindow, + firstSize - firstShown, + orientation + ); + resizedParents.add(pairParent); + } } } } 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) { @@ -2670,60 +2789,187 @@ export class WindowManager extends GObject.Object { } } if (!firstRect || !secondRect) { - return; + 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; - focusNodeWindow.percent = firstPercent; - resizePairForWindow.percent = secondPercent; + let [firstSize, secondSize] = this._resizePairSizes( + focusNodeWindow, + (focusNodeWindow.rect ?? firstRect).height, + resizePairForWindow, + secondRect.height, + currentRect.height, + orientation + ); + 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) { 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; - let changePx = currentRect.height - firstWindowRect.height; - let firstPercent = (firstRect.height + changePx) / parentRect.height; - let secondPercent = (secondRect.height - changePx) / parentRect.height; - parentNodeForFocus.percent = firstPercent; - resizePairForWindow.percent = secondPercent; + let [firstSize, secondSize] = this._resizePairSizes( + parentNodeForFocus, + firstRect.height, + resizePairForWindow, + secondRect.height, + firstRect.height + + currentRect.height - + (focusNodeWindow.rect ?? firstWindowRect).height, + orientation, + this._pathMinSize(parentNodeForFocus, focusNodeWindow, orientation) + ); + const pairParent = resizePairForWindow.parentNode; + const sizes = [firstSize, secondSize, parentRect.height]; + if ( + this._setPairShares(pairParent, parentNodeForFocus, resizePairForWindow, ...sizes) + ) { + const firstShown = firstRect.height; + this._resizeSharesAlongPath( + parentNodeForFocus, + focusNodeWindow, + firstSize - firstShown, + orientation + ); + resizedParents.add(pairParent); + } } } } } + 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 (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, minFirst = 0) { + const total = firstSize + secondSize; + 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 + // 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]; } + /** + * 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; + } + + /** + * 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`) 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, 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 childNew = child.percent * total + delta; + children.forEach((c) => { + c.percent = (c === child ? childNew : c.percent * total) / (total + delta); + }); + } + 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). * 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);