diff --git a/lib/extension/tree.js b/lib/extension/tree.js index ea957d29..7ad2ba9b 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: * @@ -1288,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}`); } @@ -1303,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() { @@ -1389,7 +1391,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 +1409,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 +1603,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..87493572 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 */ @@ -52,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); @@ -804,10 +799,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 +824,105 @@ 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, 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); + 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; + moves.push([w.nodeValue, r]); + }); + }); + this.moveAll(moves); + 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(); @@ -973,12 +1055,162 @@ 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) { + this._queuedMoves?.delete(metaWindow); // a direct request replaces a queued one + 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(), + }; + } + + /** + * 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; + if (recent && (pending.width !== frame.width || pending.height !== frame.height)) + return pending; + return frame; } moveCenter(metaWindow) { @@ -1140,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; @@ -2277,7 +2515,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 +2544,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 @@ -2482,13 +2720,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 +2739,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 +2804,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); @@ -2585,9 +2831,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 @@ -2598,7 +2846,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 +2862,74 @@ 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, + firstShown, + firstSize, + 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 +2943,188 @@ 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, + firstShown, + firstSize, + 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`) 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). * 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); @@ -2735,10 +3136,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); } }); }