From da7b8a0241abd8cc84e6eeae6f5a16d297f9f93f Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 2 Aug 2026 01:18:38 +0530 Subject: [PATCH 01/42] fix: correct distance formula when picking the best vacant tile The loop that finds the vacant tile nearest the centre of the screen was seeded with a differently-parenthesised expression than the one used in the loop body: seed: Math.abs(0.5 - tile.x + tile.width / 2) body: Math.abs(0.5 - (tile.x + tile.width / 2)) The seed adds half the tile's width instead of subtracting it, so the first tile's score is inflated and it loses comparisons it should win. On a 67/33 two-column layout the first window is placed in the narrow right tile rather than the wide left one. Replace the seed with Number.MAX_VALUE and start the loop at 0, so the formula exists in exactly one place. Ties still resolve to the leftmost tile, as the original seed-at-index-0 intended. The same block is duplicated in the window menu's "Move to best tile" entry; both are fixed. --- src/components/tilingsystem/tilingManager.ts | 8 ++------ src/components/window_menu/overriddenWindowMenu.ts | 8 ++------ 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/components/tilingsystem/tilingManager.ts b/src/components/tilingsystem/tilingManager.ts index 4bc1a683..12ac84f8 100644 --- a/src/components/tilingsystem/tilingManager.ts +++ b/src/components/tilingsystem/tilingManager.ts @@ -1333,12 +1333,8 @@ export class TilingManager { vacantTiles.sort((a, b) => a.x - b.x); let bestTileIndex = 0; - let bestDistance = Math.abs( - 0.5 - - vacantTiles[bestTileIndex].x + - vacantTiles[bestTileIndex].width / 2, - ); - for (let index = 1; index < vacantTiles.length; index++) { + let bestDistance = Number.MAX_VALUE; + for (let index = 0; index < vacantTiles.length; index++) { const distance = Math.abs( 0.5 - (vacantTiles[index].x + vacantTiles[index].width / 2), ); diff --git a/src/components/window_menu/overriddenWindowMenu.ts b/src/components/window_menu/overriddenWindowMenu.ts index e233bbbe..fb537927 100644 --- a/src/components/window_menu/overriddenWindowMenu.ts +++ b/src/components/window_menu/overriddenWindowMenu.ts @@ -138,12 +138,8 @@ export default class OverriddenWindowMenu extends GObject.Object { vacantTiles.sort((a, b) => a.x - b.x); let bestTileIndex = 0; - let bestDistance = Math.abs( - 0.5 - - vacantTiles[bestTileIndex].x + - vacantTiles[bestTileIndex].width / 2, - ); - for (let index = 1; index < vacantTiles.length; index++) { + let bestDistance = Number.MAX_VALUE; + for (let index = 0; index < vacantTiles.length; index++) { const distance = Math.abs( 0.5 - (vacantTiles[index].x + vacantTiles[index].width / 2), ); From 3ae5f5738295f4514586a24440dc051558b6ef86 Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 2 Aug 2026 02:08:19 +0530 Subject: [PATCH 02/42] feat(dynamic): decompose a layout into a binary split tree First of the two pure layers behind dynamic tiling. buildLayoutTree takes the tiles of a layout and recursively looks for a full-span guillotine cut that no tile straddles, returning a binary tree of splits and leaves, or null when no such decomposition exists (a pinwheel, for instance) so the caller can fall back to static behaviour. No GNOME imports, so it is unit-testable without a shell. The repository had no test harness at all; tests run on node's built-in runner via `npm test`, which needs no new dependencies. esbuild now excludes *.test.ts from the bundle, since node:test does not exist in GJS. Ambiguous layouts such as a plain 2x2 grid admit a valid cut on either axis; x is chosen deterministically for now. The layout's `groups` field records which divider the user actually drew and can refine this later. --- esbuild.mjs | 9 +- package.json | 3 +- .../layout/dynamic/layoutTree.test.ts | 103 ++++++++++++++++++ src/components/layout/dynamic/layoutTree.ts | 74 +++++++++++++ 4 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 src/components/layout/dynamic/layoutTree.test.ts create mode 100644 src/components/layout/dynamic/layoutTree.ts diff --git a/esbuild.mjs b/esbuild.mjs index 609ce8c9..b91d60f1 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -300,10 +300,17 @@ async function processLegacyFiles(files) { })); } +// Unit tests run under `node --test` and import node:test, which does not exist +// in GJS — they must never reach the built extension. +const sourceEntryPoints = await glob('src/**/*.ts', { + ignore: 'src/**/*.test.ts', + posix: true, +}); + // build extension build({ logLevel: "info", - entryPoints: ['src/**/*.ts', 'src/styles/stylesheet.scss', 'src/styles/prefs.scss', 'src/prefs.ts'], + entryPoints: [...sourceEntryPoints, 'src/styles/stylesheet.scss', 'src/styles/prefs.scss', 'src/prefs.ts'], outdir: distDir, bundle: false, treeShaking: false, diff --git a/package.json b/package.json index 0b093dc4..b5a7ebbc 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "vm:halt:gnome47": "vagrant halt gnome47", "dev:vm:gnome49": "npm run vm:sync gnome49; vagrant up gnome49", "vm:destroy:gnome49": "vagrant destroy gnome49", - "vm:halt:gnome49": "vagrant halt gnome49" + "vm:halt:gnome49": "vagrant halt gnome49", + "test": "node --test \"src/**/*.test.ts\"" }, "devDependencies": { "@babel/generator": "^7.28.3", diff --git a/src/components/layout/dynamic/layoutTree.test.ts b/src/components/layout/dynamic/layoutTree.test.ts new file mode 100644 index 00000000..7e869ac5 --- /dev/null +++ b/src/components/layout/dynamic/layoutTree.test.ts @@ -0,0 +1,103 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildLayoutTree } from './layoutTree.ts'; + +const leaf = (x: number, y: number, width: number, height: number) => ({ + kind: 'leaf', + tile: { x, y, width, height }, +}); + +test('a single tile covering the whole area becomes a leaf', () => { + const tree = buildLayoutTree([{ x: 0, y: 0, width: 1, height: 1 }]); + + assert.deepEqual(tree, leaf(0, 0, 1, 1)); +}); + +test('two columns split on x at their shared edge', () => { + const tree = buildLayoutTree([ + { x: 0, y: 0, width: 0.67, height: 1 }, + { x: 0.67, y: 0, width: 0.33, height: 1 }, + ]); + + assert.deepEqual(tree, { + kind: 'split', + axis: 'x', + at: 0.67, + first: leaf(0, 0, 0.67, 1), + second: leaf(0.67, 0, 0.33, 1), + }); +}); + +test('two rows split on y', () => { + const tree = buildLayoutTree([ + { x: 0, y: 0, width: 1, height: 0.4 }, + { x: 0, y: 0.4, width: 1, height: 0.6 }, + ]); + + assert.deepEqual(tree, { + kind: 'split', + axis: 'y', + at: 0.4, + first: leaf(0, 0, 1, 0.4), + second: leaf(0, 0.4, 1, 0.6), + }); +}); + +test('a stacked side column recurses into a nested split', () => { + // left column split in two, then two full-height columns + const tree = buildLayoutTree([ + { x: 0, y: 0, width: 0.25, height: 0.5 }, + { x: 0.25, y: 0, width: 0.375, height: 1 }, + { x: 0.625, y: 0, width: 0.375, height: 1 }, + { x: 0, y: 0.5, width: 0.25, height: 0.5 }, + ]); + + assert.deepEqual(tree, { + kind: 'split', + axis: 'x', + at: 0.25, + first: { + kind: 'split', + axis: 'y', + at: 0.5, + first: leaf(0, 0, 0.25, 0.5), + second: leaf(0, 0.5, 0.25, 0.5), + }, + second: { + kind: 'split', + axis: 'x', + at: 0.625, + first: leaf(0.25, 0, 0.375, 1), + second: leaf(0.625, 0, 0.375, 1), + }, + }); +}); + +test('an ambiguous 2x2 grid is cut on x first', () => { + // Both x=0.5 and y=0.5 are valid root cuts here and the tile geometry + // alone cannot say which divider the user drew. We pick x deterministically. + // The layout's `groups` field records the real answer; using it as a + // tie-breaker is a later refinement. + const tree = buildLayoutTree([ + { x: 0, y: 0, width: 0.5, height: 0.5 }, + { x: 0.5, y: 0, width: 0.5, height: 0.5 }, + { x: 0, y: 0.5, width: 0.5, height: 0.5 }, + { x: 0.5, y: 0.5, width: 0.5, height: 0.5 }, + ]); + + assert.equal(tree?.kind, 'split'); + assert.equal((tree as { axis: string }).axis, 'x'); + assert.equal((tree as { at: number }).at, 0.5); +}); + +test('a pinwheel layout cannot be decomposed and returns null', () => { + // every candidate cut line is straddled by some tile + const tree = buildLayoutTree([ + { x: 0, y: 0, width: 0.6, height: 0.4 }, + { x: 0.6, y: 0, width: 0.4, height: 0.6 }, + { x: 0.4, y: 0.6, width: 0.6, height: 0.4 }, + { x: 0, y: 0.4, width: 0.4, height: 0.6 }, + ]); + + assert.equal(tree, null); +}); diff --git a/src/components/layout/dynamic/layoutTree.ts b/src/components/layout/dynamic/layoutTree.ts new file mode 100644 index 00000000..a5744b94 --- /dev/null +++ b/src/components/layout/dynamic/layoutTree.ts @@ -0,0 +1,74 @@ +/** + * Decomposition of a user-drawn layout into a binary split tree. + * + * Pure: no GNOME imports, no side effects. Operates on plain normalised + * rectangles (0..1 on both axes) so it can be unit-tested without a shell. + */ + +export interface TileRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface Leaf { + kind: 'leaf'; + tile: TileRect; +} + +/** + * A guillotine cut. `axis: 'x'` is a vertical cut line at a constant x, + * producing a left child (`first`) and a right child (`second`); `axis: 'y'` + * cuts horizontally into a top and a bottom child. + */ +export interface Split { + kind: 'split'; + axis: 'x' | 'y'; + at: number; + first: SplitTree; + second: SplitTree; +} + +export type SplitTree = Leaf | Split; + +// Layout coordinates come from a UI that drags dividers around, so exact +// equality is never safe. +const EPSILON = 1e-6; + +/** + * Builds a split tree from the tiles of a layout, or returns null when the + * layout cannot be decomposed by guillotine cuts. + */ +export function buildLayoutTree(tiles: TileRect[]): SplitTree | null { + if (tiles.length === 0) return null; + if (tiles.length === 1) return { kind: 'leaf', tile: tiles[0] }; + + for (const axis of ['x', 'y'] as const) { + const extent = axis === 'x' ? 'width' : 'height'; + + // Every trailing edge is a candidate cut line. + const candidates = [ + ...new Set(tiles.map((t) => t[axis] + t[extent])), + ].sort((a, b) => a - b); + + for (const at of candidates) { + const before = tiles.filter((t) => t[axis] + t[extent] <= at + EPSILON); + const after = tiles.filter((t) => t[axis] >= at - EPSILON); + + // A tile landing in neither group straddles the line, so this is + // not a full-span cut. + if (before.length + after.length !== tiles.length) continue; + if (before.length === 0 || after.length === 0) continue; + + const first = buildLayoutTree(before); + if (!first) continue; + const second = buildLayoutTree(after); + if (!second) continue; + + return { kind: 'split', axis, at, first, second }; + } + } + + return null; +} From 2caa82f4efa766ed97f51f6a99be276ddc278483 Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 2 Aug 2026 02:12:26 +0530 Subject: [PATCH 03/42] feat(dynamic): distribute N windows across a layout's split tree Second pure layer. reflow turns a split tree and a window count into one rectangle per window: 1 window the whole area, so a lone window is fullscreen fewer than tiles windows are shared between subtrees in proportion to the tiles each holds; a subtree that receives fewer windows than it has tiles collapses into its bounds and the sibling absorbs the space one per tile the layout exactly as the user drew it more than tiles the roomiest rectangle is halved across its longer side, repeatedly, so every window still gets one Closing a window is the same call with one fewer, which is where "the space goes back to the neighbour" comes from without any extra code. Tested against four layout shapes at every window count from 1 to 7: the rectangles always tile the area exactly, with no gaps and no overlaps. --- src/components/layout/dynamic/reflow.test.ts | 110 +++++++++++++++++++ src/components/layout/dynamic/reflow.ts | 93 ++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 src/components/layout/dynamic/reflow.test.ts create mode 100644 src/components/layout/dynamic/reflow.ts diff --git a/src/components/layout/dynamic/reflow.test.ts b/src/components/layout/dynamic/reflow.test.ts new file mode 100644 index 00000000..dd6b91a0 --- /dev/null +++ b/src/components/layout/dynamic/reflow.test.ts @@ -0,0 +1,110 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildLayoutTree } from './layoutTree.ts'; +import type { TileRect } from './layoutTree.ts'; +import { reflow } from './reflow.ts'; + +const twoColumns = () => + buildLayoutTree([ + { x: 0, y: 0, width: 0.67, height: 1 }, + { x: 0.67, y: 0, width: 0.33, height: 1 }, + ])!; + +test('a single window fills the whole area', () => { + assert.deepEqual(reflow(twoColumns(), 1), [ + { x: 0, y: 0, width: 1, height: 1 }, + ]); +}); + +test('a subtree holding fewer windows than tiles collapses into its bounds', () => { + // 25 / 50 / 25 columns; the tree is A | (B | C) + const threeColumns = buildLayoutTree([ + { x: 0, y: 0, width: 0.25, height: 1 }, + { x: 0.25, y: 0, width: 0.5, height: 1 }, + { x: 0.75, y: 0, width: 0.25, height: 1 }, + ])!; + + // two windows: A keeps its tile, B and C collapse into one region + assert.deepEqual(reflow(threeColumns, 2), [ + { x: 0, y: 0, width: 0.25, height: 1 }, + { x: 0.25, y: 0, width: 0.75, height: 1 }, + ]); +}); + +test('one window per tile reproduces the layout exactly as drawn', () => { + assert.deepEqual(reflow(twoColumns(), 2), [ + { x: 0, y: 0, width: 0.67, height: 1 }, + { x: 0.67, y: 0, width: 0.33, height: 1 }, + ]); +}); + +test('more windows than tiles subdivides the largest tile', () => { + // the wide left tile is largest; it is taller than it is wide, so it is + // cut horizontally and its halves stay in its place in the order + assert.deepEqual(reflow(twoColumns(), 3), [ + { x: 0, y: 0, width: 0.67, height: 0.5 }, + { x: 0, y: 0.5, width: 0.67, height: 0.5 }, + { x: 0.67, y: 0, width: 0.33, height: 1 }, + ]); +}); + +test('every window always gets exactly one rectangle', () => { + for (let n = 1; n <= 8; n++) + assert.equal(reflow(twoColumns(), n).length, n, `for ${n} windows`); +}); + +const overlap = (a: TileRect, b: TileRect) => + Math.max( + 0, + Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x), + ) * + Math.max( + 0, + Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y), + ); + +test('the rectangles always tile the whole area with no gaps or overlaps', () => { + const layouts: Record = { + 'two columns': [ + { x: 0, y: 0, width: 0.67, height: 1 }, + { x: 0.67, y: 0, width: 0.33, height: 1 }, + ], + 'three columns': [ + { x: 0, y: 0, width: 0.25, height: 1 }, + { x: 0.25, y: 0, width: 0.5, height: 1 }, + { x: 0.75, y: 0, width: 0.25, height: 1 }, + ], + 'stacked side column': [ + { x: 0, y: 0, width: 0.25, height: 0.5 }, + { x: 0.25, y: 0, width: 0.375, height: 1 }, + { x: 0.625, y: 0, width: 0.375, height: 1 }, + { x: 0, y: 0.5, width: 0.25, height: 0.5 }, + ], + '2x2 grid': [ + { x: 0, y: 0, width: 0.5, height: 0.5 }, + { x: 0.5, y: 0, width: 0.5, height: 0.5 }, + { x: 0, y: 0.5, width: 0.5, height: 0.5 }, + { x: 0.5, y: 0.5, width: 0.5, height: 0.5 }, + ], + }; + + for (const [name, tiles] of Object.entries(layouts)) { + const tree = buildLayoutTree(tiles)!; + for (let n = 1; n <= 7; n++) { + const rects = reflow(tree, n); + const covered = rects.reduce((sum, r) => sum + r.width * r.height, 0); + assert.ok( + Math.abs(covered - 1) < 1e-9, + `${name} with ${n} windows covers ${covered}, expected 1`, + ); + for (let i = 0; i < rects.length; i++) { + for (let j = i + 1; j < rects.length; j++) { + assert.ok( + overlap(rects[i], rects[j]) < 1e-9, + `${name} with ${n} windows: rects ${i} and ${j} overlap`, + ); + } + } + } + } +}); diff --git a/src/components/layout/dynamic/reflow.ts b/src/components/layout/dynamic/reflow.ts new file mode 100644 index 00000000..4e37b3be --- /dev/null +++ b/src/components/layout/dynamic/reflow.ts @@ -0,0 +1,93 @@ +/** + * Distributes N windows across a layout's split tree. + * + * Pure: no GNOME imports, no side effects. Rectangles are normalised (0..1) + * exactly as the tree's tiles are. + */ + +import type { SplitTree, TileRect } from './layoutTree'; + +/** The area a subtree covers: the union of its leaves. */ +export function boundsOf(tree: SplitTree): TileRect { + if (tree.kind === 'leaf') return tree.tile; + + const first = boundsOf(tree.first); + const second = boundsOf(tree.second); + const x = Math.min(first.x, second.x); + const y = Math.min(first.y, second.y); + + return { + x, + y, + width: Math.max(first.x + first.width, second.x + second.width) - x, + height: Math.max(first.y + first.height, second.y + second.height) - y, + }; +} + +/** + * Returns one rectangle per window. With a single window the whole area is + * used; the layout is only followed once there are enough windows to fill it. + */ +export function reflow(tree: SplitTree, windowCount: number): TileRect[] { + if (windowCount <= 1) return [boundsOf(tree)]; + + const leaves = leavesOf(tree); + if (windowCount === leaves.length) return leaves; + if (windowCount > leaves.length) return subdivide(leaves, windowCount); + + // Fewer windows than tiles: share them between the two subtrees in + // proportion to how many tiles each holds, giving each at least one. A + // subtree that ends up with fewer windows than tiles recurses and + // eventually collapses into its own bounds. + const split = tree as Exclude; + const firstTiles = leavesOf(split.first).length; + + let toFirst = Math.round((windowCount * firstTiles) / leaves.length); + toFirst = Math.max(1, Math.min(windowCount - 1, toFirst)); + + return [ + ...reflow(split.first, toFirst), + ...reflow(split.second, windowCount - toFirst), + ]; +} + +/** + * Past the last tile there is nowhere left to put a window, so the roomiest + * rectangle is halved, repeatedly, until every window has one. A rectangle is + * always cut across its longer side, which keeps the pieces from growing into + * slivers. + */ +function subdivide(leaves: TileRect[], windowCount: number): TileRect[] { + const rects = [...leaves]; + + while (rects.length < windowCount) { + let widest = 0; + for (let i = 1; i < rects.length; i++) { + if (areaOf(rects[i]) > areaOf(rects[widest])) widest = i; + } + + const r = rects[widest]; + const halves: TileRect[] = + r.height >= r.width + ? [ + { ...r, height: r.height / 2 }, + { ...r, y: r.y + r.height / 2, height: r.height / 2 }, + ] + : [ + { ...r, width: r.width / 2 }, + { ...r, x: r.x + r.width / 2, width: r.width / 2 }, + ]; + + rects.splice(widest, 1, ...halves); + } + + return rects; +} + +const areaOf = (r: TileRect) => r.width * r.height; + +/** The tiles of a subtree, left-to-right then top-to-bottom. */ +export function leavesOf(tree: SplitTree): TileRect[] { + if (tree.kind === 'leaf') return [tree.tile]; + return [...leavesOf(tree.first), ...leavesOf(tree.second)]; +} From b4d5a02a69610506884bf6eca4a262e4e26562ba Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 2 Aug 2026 02:18:09 +0530 Subject: [PATCH 04/42] feat(dynamic): place windows through the split tree as they open and close Wires the two pure layers into TilingManager. When dynamic tiling is on, a new window claims a slot and every managed window is reflowed for the new count; when one closes it gives the slot back and the survivors reflow into the space. Placement reuses the existing easing, so gaps, scaling and animation behave exactly as they already do. A layout with no guillotine decomposition yields a null tree and is left to the existing static behaviour rather than being distorted. Enabled by a new enable-dynamic-tiling key, off by default. It takes precedence over auto-tiling, which places one window into one tile and means something different. --- ...e.shell.extensions.tilingshell.gschema.xml | 5 + src/components/tilingsystem/tilingManager.ts | 104 +++++++++++++++++- src/settings/settings.ts | 9 ++ 3 files changed, 117 insertions(+), 1 deletion(-) diff --git a/resources/schemas/org.gnome.shell.extensions.tilingshell.gschema.xml b/resources/schemas/org.gnome.shell.extensions.tilingshell.gschema.xml index 67fe8399..cf079247 100644 --- a/resources/schemas/org.gnome.shell.extensions.tilingshell.gschema.xml +++ b/resources/schemas/org.gnome.shell.extensions.tilingshell.gschema.xml @@ -107,6 +107,11 @@ Enable auto tiling Automatically tile a new window to the best tile according to the current layout. + + false + Enable dynamic tiling + Windows always fill the screen, following the proportions of the selected layout. A single window is fullscreen; opening another splits the space; closing one gives it back. + false Raise tiled windows together diff --git a/src/components/tilingsystem/tilingManager.ts b/src/components/tilingsystem/tilingManager.ts index 4bc1a683..f5acb5f2 100644 --- a/src/components/tilingsystem/tilingManager.ts +++ b/src/components/tilingsystem/tilingManager.ts @@ -21,6 +21,8 @@ import SignalHandling from '../../utils/signalHandling'; import Layout from '../layout/Layout'; import Tile from '../layout/Tile'; import TileUtils from '../layout/TileUtils'; +import { buildLayoutTree } from '../layout/dynamic/layoutTree'; +import { reflow } from '../layout/dynamic/reflow'; import GlobalState from '../../utils/globalState'; import { Monitor } from 'resource:///org/gnome/shell/ui/layout.js'; import ExtendedWindow from './extendedWindow'; @@ -75,6 +77,8 @@ export class TilingManager { private _snapAssistingInfo: SnapAssistingInfo; private _movingWindowTimerId: number | null = null; + // Windows placed by dynamic tiling, in the order they claim slots. + private _dynamicWindows: Meta.Window[] = []; private readonly _signals: SignalHandling; private readonly _debug: (..._content: unknown[]) => void; @@ -296,7 +300,9 @@ export class TilingManager { global.display, 'window-created', (_display: Meta.Display, window: Meta.Window) => { - if (Settings.ENABLE_AUTO_TILING) this._autoTile(window, true); + if (Settings.ENABLE_DYNAMIC_TILING) this._dynamicAdd(window); + else if (Settings.ENABLE_AUTO_TILING) + this._autoTile(window, true); }, ); this._signals.connect( @@ -1258,6 +1264,102 @@ export class TilingManager { ); } + /** Windows that dynamic tiling is willing to place. */ + private _isDynamicCandidate(window: Meta.Window): boolean { + return ( + window !== null && + window.windowType === Meta.WindowType.NORMAL && + window.get_transient_for() === null && + !window.is_attached_dialog() && + !window.minimized && + !window.maximizedHorizontally && + !window.maximizedVertically + ); + } + + /** + * Gives a newly created window a slot and reflows everything else to make + * room for it. Closing is the mirror image: the window gives its slot back + * and the survivors reflow into the space. + */ + private _dynamicAdd(window: Meta.Window) { + if (window.get_monitor() !== this._monitor.index) return; + if (!this._isDynamicCandidate(window)) return; + if (this._dynamicWindows.includes(window)) return; + + this._dynamicWindows.push(window); + + window.connect('unmanaged', () => { + const slot = this._dynamicWindows.indexOf(window); + if (slot < 0) return; + this._dynamicWindows.splice(slot, 1); + this._applyDynamicTiling(); + }); + + const windowActor = + window.get_compositor_private() as Meta.WindowActor | null; + if (!windowActor) { + this._applyDynamicTiling(); + return; + } + + // wait for the window to be drawn, exactly as auto-tiling does, so it + // does not visibly jump from its default position + const id = windowActor.connect('first-frame', () => { + this._applyDynamicTiling(); + windowActor.disconnect(id); + }); + } + + /** + * Recomputes every managed window's rectangle for the current window count + * and eases them all into place. + */ + private _applyDynamicTiling() { + if (!Settings.ENABLE_DYNAMIC_TILING) return; + + const ws = global.workspaceManager.get_active_workspace(); + if (!ws) return; + + const layout = GlobalState.get().getSelectedLayoutOfMonitor( + this._monitor.index, + ws.index(), + ); + const tree = buildLayoutTree( + layout.tiles.map((t) => ({ + x: t.x, + y: t.y, + width: t.width, + height: t.height, + })), + ); + // a layout with no guillotine decomposition keeps the static behaviour + if (!tree) return; + + const windows = this._dynamicWindows.filter( + (w) => + this._isDynamicCandidate(w) && + w.get_workspace() === ws && + w.get_monitor() === this._monitor.index, + ); + if (windows.length === 0) return; + + const rects = reflow(tree, windows.length); + windows.forEach((window, index) => { + const rect = rects[index]; + this._easeWindowRectFromTile( + new Tile({ + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + groups: [], + }), + window, + ); + }); + } + private _autoTile(window: Meta.Window, windowCreated: boolean) { // do not handle windows in monitors not managed by this manager if (window.get_monitor() !== this._monitor.index) return; diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 59ff551c..ce2f7c74 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -102,6 +102,7 @@ export default class Settings { static KEY_ENABLE_BLUR_SELECTED_TILEPREVIEW = 'enable-blur-selected-tilepreview'; static KEY_ENABLE_MOVE_KEYBINDINGS = 'enable-move-keybindings'; static KEY_ENABLE_AUTO_TILING = 'enable-autotiling'; + static KEY_ENABLE_DYNAMIC_TILING = 'enable-dynamic-tiling'; static KEY_RAISE_TOGETHER = 'raise-together'; static KEY_ACTIVE_SCREEN_EDGES = 'active-screen-edges'; static KEY_TOP_EDGE_MAXIMIZE = 'top-edge-maximize'; @@ -341,6 +342,14 @@ export default class Settings { set_boolean(Settings.KEY_ENABLE_AUTO_TILING, val); } + static get ENABLE_DYNAMIC_TILING(): boolean { + return get_boolean(Settings.KEY_ENABLE_DYNAMIC_TILING); + } + + static set ENABLE_DYNAMIC_TILING(val: boolean) { + set_boolean(Settings.KEY_ENABLE_DYNAMIC_TILING, val); + } + static get RAISE_TOGETHER(): boolean { return get_boolean(Settings.KEY_RAISE_TOGETHER); } From 35426cbfd5df9a28787443aa4f2c87c86ae03602 Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 2 Aug 2026 02:33:22 +0530 Subject: [PATCH 05/42] feat(dynamic): swap windows on drop and split the focused tile on overflow Dragging a managed window now drops it into whichever slot the pointer is over and exchanges places with the window living there, which is the answer to the open question on issue #342. Dropping outside every slot, or back where it started, simply snaps the window home. Overflow past the last tile previously halved whichever rectangle was roomiest. It now halves the tile of the window that had focus when the new window appeared, matching what tiling window managers do, and falls back to the roomiest rectangle when there is no focus to speak of. --- src/components/layout/dynamic/reflow.test.ts | 18 +++ src/components/layout/dynamic/reflow.ts | 27 +++- src/components/tilingsystem/tilingManager.ts | 129 +++++++++++++++---- 3 files changed, 143 insertions(+), 31 deletions(-) diff --git a/src/components/layout/dynamic/reflow.test.ts b/src/components/layout/dynamic/reflow.test.ts index dd6b91a0..322594f0 100644 --- a/src/components/layout/dynamic/reflow.test.ts +++ b/src/components/layout/dynamic/reflow.test.ts @@ -48,6 +48,24 @@ test('more windows than tiles subdivides the largest tile', () => { ]); }); +test('overflow splits the focused tile rather than the largest', () => { + // slot 1 is the narrow right tile; focusing it should split it even though + // the left tile is roomier + assert.deepEqual(reflow(twoColumns(), 3, 1), [ + { x: 0, y: 0, width: 0.67, height: 1 }, + { x: 0.67, y: 0, width: 0.33, height: 0.5 }, + { x: 0.67, y: 0.5, width: 0.33, height: 0.5 }, + ]); +}); + +test('overflow falls back to the largest tile when nothing is focused', () => { + assert.deepEqual(reflow(twoColumns(), 3), [ + { x: 0, y: 0, width: 0.67, height: 0.5 }, + { x: 0, y: 0.5, width: 0.67, height: 0.5 }, + { x: 0.67, y: 0, width: 0.33, height: 1 }, + ]); +}); + test('every window always gets exactly one rectangle', () => { for (let n = 1; n <= 8; n++) assert.equal(reflow(twoColumns(), n).length, n, `for ${n} windows`); diff --git a/src/components/layout/dynamic/reflow.ts b/src/components/layout/dynamic/reflow.ts index 4e37b3be..1f180a0d 100644 --- a/src/components/layout/dynamic/reflow.ts +++ b/src/components/layout/dynamic/reflow.ts @@ -28,12 +28,17 @@ export function boundsOf(tree: SplitTree): TileRect { * Returns one rectangle per window. With a single window the whole area is * used; the layout is only followed once there are enough windows to fill it. */ -export function reflow(tree: SplitTree, windowCount: number): TileRect[] { +export function reflow( + tree: SplitTree, + windowCount: number, + focusedIndex?: number, +): TileRect[] { if (windowCount <= 1) return [boundsOf(tree)]; const leaves = leavesOf(tree); if (windowCount === leaves.length) return leaves; - if (windowCount > leaves.length) return subdivide(leaves, windowCount); + if (windowCount > leaves.length) + return subdivide(leaves, windowCount, focusedIndex); // Fewer windows than tiles: share them between the two subtrees in // proportion to how many tiles each holds, giving each at least one. A @@ -57,14 +62,30 @@ export function reflow(tree: SplitTree, windowCount: number): TileRect[] { * always cut across its longer side, which keeps the pieces from growing into * slivers. */ -function subdivide(leaves: TileRect[], windowCount: number): TileRect[] { +function subdivide( + leaves: TileRect[], + windowCount: number, + focusedIndex?: number, +): TileRect[] { const rects = [...leaves]; + // the first new window takes half of the focused window's space, the way + // a tiling WM splits whatever you are looking at + let target = + focusedIndex !== undefined && + focusedIndex >= 0 && + focusedIndex < rects.length + ? focusedIndex + : undefined; while (rects.length < windowCount) { let widest = 0; for (let i = 1; i < rects.length; i++) { if (areaOf(rects[i]) > areaOf(rects[widest])) widest = i; } + if (target !== undefined) { + widest = target; + target = undefined; + } const r = rects[widest]; const halves: TileRect[] = diff --git a/src/components/tilingsystem/tilingManager.ts b/src/components/tilingsystem/tilingManager.ts index f5acb5f2..b1b4adfa 100644 --- a/src/components/tilingsystem/tilingManager.ts +++ b/src/components/tilingsystem/tilingManager.ts @@ -824,6 +824,11 @@ export class TilingManager { this._snapAssist.close(true); this._lastCursorPos = null; + // Dynamic tiling owns every drop of a window it manages: the window + // trades places with whatever occupies the slot under the pointer. + if (Settings.ENABLE_DYNAMIC_TILING && this._dynamicSwapOnDrop(window)) + return; + const isTilingSystemActivated = this._activationKeyStatus( global.get_pointer()[2], Settings.TILING_SYSTEM_ACTIVATION_KEY, @@ -1287,6 +1292,11 @@ export class TilingManager { if (!this._isDynamicCandidate(window)) return; if (this._dynamicWindows.includes(window)) return; + // Whatever the user was looking at when this window appeared is the + // window whose space the newcomer should take half of, once the layout + // has run out of tiles. + const splitTarget = global.display.focus_window ?? undefined; + this._dynamicWindows.push(window); window.connect('unmanaged', () => { @@ -1299,33 +1309,25 @@ export class TilingManager { const windowActor = window.get_compositor_private() as Meta.WindowActor | null; if (!windowActor) { - this._applyDynamicTiling(); + this._applyDynamicTiling(splitTarget); return; } // wait for the window to be drawn, exactly as auto-tiling does, so it // does not visibly jump from its default position const id = windowActor.connect('first-frame', () => { - this._applyDynamicTiling(); + this._applyDynamicTiling(splitTarget); windowActor.disconnect(id); }); } - /** - * Recomputes every managed window's rectangle for the current window count - * and eases them all into place. - */ - private _applyDynamicTiling() { - if (!Settings.ENABLE_DYNAMIC_TILING) return; - - const ws = global.workspaceManager.get_active_workspace(); - if (!ws) return; - + /** The split tree of the layout selected for a workspace, if it has one. */ + private _dynamicTree(ws: Meta.Workspace) { const layout = GlobalState.get().getSelectedLayoutOfMonitor( this._monitor.index, ws.index(), ); - const tree = buildLayoutTree( + return buildLayoutTree( layout.tiles.map((t) => ({ x: t.x, y: t.y, @@ -1333,33 +1335,104 @@ export class TilingManager { height: t.height, })), ); - // a layout with no guillotine decomposition keeps the static behaviour - if (!tree) return; + } - const windows = this._dynamicWindows.filter( + /** Managed windows currently on this monitor and workspace, in slot order. */ + private _dynamicManagedWindows(ws: Meta.Workspace): Meta.Window[] { + return this._dynamicWindows.filter( (w) => this._isDynamicCandidate(w) && w.get_workspace() === ws && w.get_monitor() === this._monitor.index, ); + } + + private _tileOf(rect: { + x: number; + y: number; + width: number; + height: number; + }): Tile { + return new Tile({ ...rect, groups: [] }); + } + + /** + * Recomputes every managed window's rectangle for the current window count + * and eases them all into place. + */ + private _applyDynamicTiling(splitTarget?: Meta.Window) { + if (!Settings.ENABLE_DYNAMIC_TILING) return; + + const ws = global.workspaceManager.get_active_workspace(); + if (!ws) return; + + // a layout with no guillotine decomposition keeps the static behaviour + const tree = this._dynamicTree(ws); + if (!tree) return; + + const windows = this._dynamicManagedWindows(ws); if (windows.length === 0) return; - const rects = reflow(tree, windows.length); + const focusedIndex = splitTarget + ? windows.indexOf(splitTarget) + : undefined; + const rects = reflow( + tree, + windows.length, + focusedIndex !== undefined && focusedIndex >= 0 + ? focusedIndex + : undefined, + ); windows.forEach((window, index) => { - const rect = rects[index]; - this._easeWindowRectFromTile( - new Tile({ - x: rect.x, - y: rect.y, - width: rect.width, - height: rect.height, - groups: [], - }), - window, - ); + this._easeWindowRectFromTile(this._tileOf(rects[index]), window); }); } + /** + * Drops a dragged window into whichever slot the pointer is over, + * exchanging places with the window already living there. Returns true + * when dynamic tiling has taken responsibility for the drop. + */ + private _dynamicSwapOnDrop(window: Meta.Window): boolean { + const ws = global.workspaceManager.get_active_workspace(); + if (!ws) return false; + + const tree = this._dynamicTree(ws); + if (!tree) return false; + + const windows = this._dynamicManagedWindows(ws); + const from = windows.indexOf(window); + if (from < 0) return false; + + // nothing to exchange with, but the window still belongs in its slot + if (windows.length < 2) { + this._applyDynamicTiling(); + return true; + } + + const rects = reflow(tree, windows.length); + const [pointerX, pointerY] = global.get_pointer(); + const to = rects.findIndex((rect) => + isPointInsideRect( + { x: pointerX, y: pointerY }, + TileUtils.apply_props(this._tileOf(rect), this._workArea), + ), + ); + + if (to >= 0 && to !== from) { + const a = this._dynamicWindows.indexOf(windows[from]); + const b = this._dynamicWindows.indexOf(windows[to]); + [this._dynamicWindows[a], this._dynamicWindows[b]] = [ + this._dynamicWindows[b], + this._dynamicWindows[a], + ]; + } + + // dropped outside every slot, or back where it started: snap it home + this._applyDynamicTiling(); + return true; + } + private _autoTile(window: Meta.Window, windowCreated: boolean) { // do not handle windows in monitors not managed by this manager if (window.get_monitor() !== this._monitor.index) return; From 52a3246128af34715692678a06072550eceaaf2e Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 2 Aug 2026 02:39:27 +0530 Subject: [PATCH 06/42] feat(dynamic): choose the layout by window count, and add an indicator toggle Layout order is now preference. For a given number of windows dynamic tiling picks the leftmost layout with exactly that many tiles and uses it as drawn; failing that the leftmost roomier layout, collapsed to fit; failing that the roomiest layout, subdivided. Layouts with no guillotine decomposition are never candidates. This closes the other half of issue #340, which asked for a different layout per window count, without a separate picker: the order the user already arranges their layouts in is the answer. The indicator menu gains a Dynamic tiling switch, so the mode can be turned on and off without opening preferences. --- .../layout/dynamic/pickLayout.test.ts | 38 +++++++++++++ src/components/layout/dynamic/pickLayout.ts | 30 +++++++++++ src/components/tilingsystem/tilingManager.ts | 54 ++++++++++++------- src/indicator/defaultMenu.ts | 21 ++++++++ 4 files changed, 123 insertions(+), 20 deletions(-) create mode 100644 src/components/layout/dynamic/pickLayout.test.ts create mode 100644 src/components/layout/dynamic/pickLayout.ts diff --git a/src/components/layout/dynamic/pickLayout.test.ts b/src/components/layout/dynamic/pickLayout.test.ts new file mode 100644 index 00000000..3806a478 --- /dev/null +++ b/src/components/layout/dynamic/pickLayout.test.ts @@ -0,0 +1,38 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { pickLayoutIndex } from './pickLayout.ts'; + +// Jacob's layouts in their current order: a 4-tile layout, then three 2-tile ones +const layouts = [4, 2, 2, 2]; + +test('a layout with exactly as many tiles as windows wins', () => { + assert.equal(pickLayoutIndex(layouts, 2), 1); + assert.equal(pickLayoutIndex(layouts, 4), 0); +}); + +test('the leftmost exact match wins, so order is preference', () => { + assert.equal(pickLayoutIndex([2, 4, 2], 2), 0); + assert.equal(pickLayoutIndex([4, 2, 2], 2), 1); +}); + +test('with no exact match the leftmost roomier layout is collapsed', () => { + // three windows, nothing has three tiles: the 4-tile layout collapses + assert.equal(pickLayoutIndex(layouts, 3), 0); +}); + +test('a single window uses the leftmost layout, which reflow makes fullscreen', () => { + assert.equal(pickLayoutIndex(layouts, 1), 0); +}); + +test('when nothing is big enough the roomiest layout is subdivided', () => { + assert.equal(pickLayoutIndex(layouts, 5), 0); + assert.equal(pickLayoutIndex([2, 3, 2], 9), 1); +}); + +test('ties on tile count keep the leftmost', () => { + assert.equal(pickLayoutIndex([3, 3], 7), 0); +}); + +test('an empty list has nothing to pick', () => { + assert.equal(pickLayoutIndex([], 3), -1); +}); diff --git a/src/components/layout/dynamic/pickLayout.ts b/src/components/layout/dynamic/pickLayout.ts new file mode 100644 index 00000000..23ec24fd --- /dev/null +++ b/src/components/layout/dynamic/pickLayout.ts @@ -0,0 +1,30 @@ +/** + * Chooses which layout dynamic tiling should follow for a given number of + * windows. + * + * Pure: takes only the tile count of each candidate layout, in the user's + * preferred order, and returns an index into that list. + * + * Order is preference. A layout that has exactly as many tiles as there are + * windows is used as drawn, and the leftmost such layout wins. Failing that + * the leftmost roomier layout is collapsed down to fit. Failing that the + * roomiest layout is subdivided. + */ +export function pickLayoutIndex( + tileCounts: number[], + windowCount: number, +): number { + if (tileCounts.length === 0) return -1; + + const exact = tileCounts.indexOf(windowCount); + if (exact >= 0) return exact; + + const roomier = tileCounts.findIndex((count) => count > windowCount); + if (roomier >= 0) return roomier; + + let roomiest = 0; + for (let i = 1; i < tileCounts.length; i++) { + if (tileCounts[i] > tileCounts[roomiest]) roomiest = i; + } + return roomiest; +} diff --git a/src/components/tilingsystem/tilingManager.ts b/src/components/tilingsystem/tilingManager.ts index b1b4adfa..b6c48bff 100644 --- a/src/components/tilingsystem/tilingManager.ts +++ b/src/components/tilingsystem/tilingManager.ts @@ -23,6 +23,7 @@ import Tile from '../layout/Tile'; import TileUtils from '../layout/TileUtils'; import { buildLayoutTree } from '../layout/dynamic/layoutTree'; import { reflow } from '../layout/dynamic/reflow'; +import { pickLayoutIndex } from '../layout/dynamic/pickLayout'; import GlobalState from '../../utils/globalState'; import { Monitor } from 'resource:///org/gnome/shell/ui/layout.js'; import ExtendedWindow from './extendedWindow'; @@ -1321,20 +1322,33 @@ export class TilingManager { }); } - /** The split tree of the layout selected for a workspace, if it has one. */ - private _dynamicTree(ws: Meta.Workspace) { - const layout = GlobalState.get().getSelectedLayoutOfMonitor( - this._monitor.index, - ws.index(), - ); - return buildLayoutTree( - layout.tiles.map((t) => ({ - x: t.x, - y: t.y, - width: t.width, - height: t.height, - })), + /** + * The split tree dynamic tiling should follow for a given number of + * windows. Layout order is preference: a layout with exactly as many tiles + * as there are windows is used as drawn, otherwise the leftmost roomier + * one is collapsed to fit, otherwise the roomiest is subdivided. Layouts + * with no guillotine decomposition are not candidates at all. + */ + private _dynamicTree(windowCount: number) { + const candidates = GlobalState.get() + .layouts.map((layout) => ({ + tileCount: layout.tiles.length, + tree: buildLayoutTree( + layout.tiles.map((t) => ({ + x: t.x, + y: t.y, + width: t.width, + height: t.height, + })), + ), + })) + .filter((candidate) => candidate.tree !== null); + + const index = pickLayoutIndex( + candidates.map((candidate) => candidate.tileCount), + windowCount, ); + return index < 0 ? null : candidates[index].tree; } /** Managed windows currently on this monitor and workspace, in slot order. */ @@ -1366,13 +1380,13 @@ export class TilingManager { const ws = global.workspaceManager.get_active_workspace(); if (!ws) return; - // a layout with no guillotine decomposition keeps the static behaviour - const tree = this._dynamicTree(ws); - if (!tree) return; - const windows = this._dynamicManagedWindows(ws); if (windows.length === 0) return; + // no decomposable layout at all keeps the static behaviour + const tree = this._dynamicTree(windows.length); + if (!tree) return; + const focusedIndex = splitTarget ? windows.indexOf(splitTarget) : undefined; @@ -1397,13 +1411,13 @@ export class TilingManager { const ws = global.workspaceManager.get_active_workspace(); if (!ws) return false; - const tree = this._dynamicTree(ws); - if (!tree) return false; - const windows = this._dynamicManagedWindows(ws); const from = windows.indexOf(window); if (from < 0) return false; + const tree = this._dynamicTree(windows.length); + if (!tree) return false; + // nothing to exchange with, but the window still belongs in its slot if (windows.length < 2) { this._applyDynamicTiling(); diff --git a/src/indicator/defaultMenu.ts b/src/indicator/defaultMenu.ts index 2ab03a0f..280073dd 100644 --- a/src/indicator/defaultMenu.ts +++ b/src/indicator/defaultMenu.ts @@ -160,6 +160,27 @@ export default class DefaultMenu implements CurrentMenu { this._signals = new SignalHandling(); this._openPrefsFn = openPrefsFn; this._children = []; + const dynamicToggle = new PopupMenu.PopupSwitchMenuItem( + _('Dynamic tiling'), + Settings.ENABLE_DYNAMIC_TILING, + {}, + ); + this._children.push(dynamicToggle); + dynamicToggle.connect('toggled', (_item: unknown, state: boolean) => { + Settings.ENABLE_DYNAMIC_TILING = state; + }); + // keep the switch honest if the setting is changed from anywhere else + this._signals.connect( + Settings, + Settings.KEY_ENABLE_DYNAMIC_TILING, + () => { + dynamicToggle.setToggleState(Settings.ENABLE_DYNAMIC_TILING); + }, + ); + (this._indicator.menu as PopupMenu.PopupMenu).addMenuItem( + dynamicToggle, + ); + const layoutsPopupMenu = new PopupMenu.PopupBaseMenuItem({ style_class: 'indicator-menu-item', }); From 10549793c02d714ecdd55a5f693000f907e98b95 Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 2 Aug 2026 02:47:03 +0530 Subject: [PATCH 07/42] feat(dynamic): group layouts by window count in the editor The editor now sorts layouts by how many tiles they hold and draws a rule between groups. The reorder arrows move a layout only within its own group, because that is the only movement that changes anything: dynamic tiling uses the leftmost layout of the group matching the window count, so position across groups is meaningless. The picker now prefers the smallest layout still large enough rather than the leftmost one in storage order, so a layout is collapsed as little as possible. With three windows and 4-tile and 8-tile layouts available, the 4-tile one is used. --- src/components/editor/editorDialog.ts | 43 ++++++++++++++++--- .../layout/dynamic/pickLayout.test.ts | 17 +++++++- src/components/layout/dynamic/pickLayout.ts | 8 +++- src/styles/editor.scss | 7 +++ 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/src/components/editor/editorDialog.ts b/src/components/editor/editorDialog.ts index 7bbd0ef9..260393aa 100644 --- a/src/components/editor/editorDialog.ts +++ b/src/components/editor/editorDialog.ts @@ -240,7 +240,36 @@ export default class EditorDialog extends ModalDialog.ModalDialog { const gaps = Settings.get_inner_gaps(1).top > 0 ? this._gapsSize : 0; this._layoutsBoxLayout.destroy_all_children(); - params.layouts.forEach((lay, btnInd) => { + // Layouts are grouped by how many windows they hold. Dynamic tiling + // uses the leftmost layout of the group matching the window count, so + // ordering only means anything inside a group. + const ordered = params.layouts + .map((lay, index) => ({ lay, index })) + .sort( + (a, b) => + a.lay.tiles.length - b.lay.tiles.length || + a.index - b.index, + ); + + ordered.forEach(({ lay, index: btnInd }, position) => { + const previous = ordered[position - 1]; + const next = ordered[position + 1]; + const sameAsPrevious = + previous !== undefined && + previous.lay.tiles.length === lay.tiles.length; + const sameAsNext = + next !== undefined && + next.lay.tiles.length === lay.tiles.length; + + if (previous !== undefined && !sameAsPrevious) { + this._layoutsBoxLayout.add_child( + new St.Widget({ + styleClass: 'layout-group-separator', + yExpand: true, + }), + ); + } + const layoutBox = new St.BoxLayout({ xAlign: Clutter.ActorAlign.CENTER, styleClass: 'layout-button-container', @@ -261,8 +290,8 @@ export default class EditorDialog extends ModalDialog.ModalDialog { }); layoutBox.add_child(moveAndDeleteButtonsBox); if (params.layouts.length > 1) { - // move left button if not first layout - if (btnInd >= 1) { + // move left only within the group + if (sameAsPrevious) { const moveLeftBtn = new St.Button({ xExpand: false, xAlign: Clutter.ActorAlign.CENTER, @@ -276,7 +305,7 @@ export default class EditorDialog extends ModalDialog.ModalDialog { iconSize: 16, }); moveLeftBtn.connect('clicked', () => { - params.onReorderLayout(btnInd, btnInd-1); + params.onReorderLayout(btnInd, previous.index); this._drawLayouts({ ...params, layouts: GlobalState.get().layouts, @@ -305,8 +334,8 @@ export default class EditorDialog extends ModalDialog.ModalDialog { }); }); moveAndDeleteButtonsBox.add_child(deleteBtn); - // move right button if not last layout - if (btnInd + 1 < params.layouts.length) { + // move right only within the group + if (sameAsNext) { const moveRightBtn = new St.Button({ xExpand: false, xAlign: Clutter.ActorAlign.CENTER, @@ -320,7 +349,7 @@ export default class EditorDialog extends ModalDialog.ModalDialog { iconSize: 16, }); moveRightBtn.connect('clicked', () => { - params.onReorderLayout(btnInd, btnInd+1); + params.onReorderLayout(btnInd, next.index); this._drawLayouts({ ...params, layouts: GlobalState.get().layouts, diff --git a/src/components/layout/dynamic/pickLayout.test.ts b/src/components/layout/dynamic/pickLayout.test.ts index 3806a478..fb433466 100644 --- a/src/components/layout/dynamic/pickLayout.test.ts +++ b/src/components/layout/dynamic/pickLayout.test.ts @@ -20,8 +20,21 @@ test('with no exact match the leftmost roomier layout is collapsed', () => { assert.equal(pickLayoutIndex(layouts, 3), 0); }); -test('a single window uses the leftmost layout, which reflow makes fullscreen', () => { - assert.equal(pickLayoutIndex(layouts, 1), 0); +test('the least roomier layout is preferred, to collapse as little as possible', () => { + // two windows, no 2-tile layout: 3 collapses less than 8, whatever the + // order the layouts happen to sit in + assert.equal(pickLayoutIndex([8, 3, 5], 2), 1); + assert.equal(pickLayoutIndex([5, 8, 3], 2), 2); +}); + +test('within the chosen tile count the leftmost still wins', () => { + assert.equal(pickLayoutIndex([8, 3, 3], 2), 1); +}); + +test('a single window picks the least roomy layout, and reflow makes it fullscreen', () => { + // any layout collapses to fullscreen for one window, so this only decides + // which one does the collapsing: the 2-tile group, not the 4-tile one + assert.equal(pickLayoutIndex(layouts, 1), 1); }); test('when nothing is big enough the roomiest layout is subdivided', () => { diff --git a/src/components/layout/dynamic/pickLayout.ts b/src/components/layout/dynamic/pickLayout.ts index 23ec24fd..ecc379a3 100644 --- a/src/components/layout/dynamic/pickLayout.ts +++ b/src/components/layout/dynamic/pickLayout.ts @@ -19,7 +19,13 @@ export function pickLayoutIndex( const exact = tileCounts.indexOf(windowCount); if (exact >= 0) return exact; - const roomier = tileCounts.findIndex((count) => count > windowCount); + // Collapse as little as possible: the smallest tile count still large + // enough, and the leftmost layout having it. + let roomier = -1; + for (let i = 0; i < tileCounts.length; i++) { + if (tileCounts[i] <= windowCount) continue; + if (roomier < 0 || tileCounts[i] < tileCounts[roomier]) roomier = i; + } if (roomier >= 0) return roomier; let roomiest = 0; diff --git a/src/styles/editor.scss b/src/styles/editor.scss index f76add13..767964e6 100644 --- a/src/styles/editor.scss +++ b/src/styles/editor.scss @@ -31,6 +31,13 @@ padding: constants.$base_padding; } + // divides layouts into groups by the number of windows they hold + .layout-group-separator { + width: 2px; + background-color: rgba(255, 255, 255, 0.18); + margin: 4px 2px; + } + .editor-dialog-title { text-align: center; font-weight: 800; From 19e01209d131e295ce9bf5cd68f0813c76825667 Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 2 Aug 2026 12:09:08 +0530 Subject: [PATCH 08/42] docs: state plainly that this is a fork of domferr/tilingshell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Credits the original author, keeps his donation links, and explains that the fork exists because upstream review is slow rather than because the feature was rejected — he was receptive to it on issue #342. --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index 3c300351..5a5dd298 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,29 @@ +> ### This is a modified fork of [Tiling Shell](https://github.com/domferr/tilingshell) +> +> **All of the work below, and the extension itself, is by [Domenico Ferraro (@domferr)](https://github.com/domferr).** +> I did not write Tiling Shell. This fork only adds a dynamic tiling mode on top of +> it, and everything else you see here is his. +> +> If you find this useful, the person to support is him: +> [Ko-fi](https://ko-fi.com/domferr) · [Patreon](https://patreon.com/domferr). +> +> **Why a fork rather than a pull request?** Not because the feature was turned down. +> On [issue #342](https://github.com/domferr/tilingshell/issues/342) domferr said he'd +> been asked for dynamic tiling many times and that it seemed like something people +> would enjoy. The repository is simply slow to review right now, and I wanted to run +> the feature on my own machine. The design has been offered upstream and I'd be glad +> to see it merged there instead, at which point this fork stops being necessary. +> +> **What is added here:** a dynamic tiling mode, off by default, in which windows always +> fill the screen following the proportions of a layout drawn in Tiling Shell's own +> editor — one window fullscreen, a second splits the space, closing one gives it back. +> The layout used is chosen by how many windows are open. Bug fixes found along the way +> are sent upstream separately ([#595](https://github.com/domferr/tilingshell/pull/595)). +> +> **Installing this replaces upstream Tiling Shell**, since it deliberately keeps the +> same extension UUID rather than masquerading as a different extension. Licensed +> GPLv3, exactly as the original. + [![release](https://img.shields.io/badge/Release_v16-blue?style=for-the-badge)]([https://ko-fi.com/domferr](https://github.com/domferr/tilingshell/releases)) From 2fcd2f3ff2558287c48d38ddd57b26c6b9c6b43d Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 2 Aug 2026 12:13:22 +0530 Subject: [PATCH 09/42] fix(dynamic): tile windows that open maximized, and give the first window the roomiest region Nothing was being tiled at all. Windows passed the candidate check at window-created and failed it milliseconds later at reflow time, because the check disqualified maximized windows and applications such as Brave, Files and Nautilus maximize themselves as soon as they are mapped. Every window was refused and simply stayed maximized. Being maximized is no longer disqualifying. In a mode whose premise is that windows fill the screen according to a layout, refusing them would mean refusing almost everything, so they are unmaximized on placement instead, the way a tiling window manager would. Slots are now ordered by area rather than by position in the tree, so the window opened first keeps the roomiest region whichever side of the layout it is drawn on. Reversing the tree order would have worked only for layouts whose largest tile happens to come last. Two call sites need the mapping: overflow translates the focused slot into the tile it occupies before splitting it, and the drag hit test translates a rectangle back into a slot before swapping. The [dyn] debug lines are deliberately left in for one more round of testing and will be removed once the behaviour is confirmed on a real session. --- src/components/layout/dynamic/reflow.test.ts | 49 ++++++++++- src/components/layout/dynamic/reflow.ts | 20 +++++ src/components/tilingsystem/tilingManager.ts | 89 +++++++++++++++----- 3 files changed, 136 insertions(+), 22 deletions(-) diff --git a/src/components/layout/dynamic/reflow.test.ts b/src/components/layout/dynamic/reflow.test.ts index 322594f0..a967750d 100644 --- a/src/components/layout/dynamic/reflow.test.ts +++ b/src/components/layout/dynamic/reflow.test.ts @@ -2,7 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { buildLayoutTree } from './layoutTree.ts'; import type { TileRect } from './layoutTree.ts'; -import { reflow } from './reflow.ts'; +import { reflow, slotOrder } from './reflow.ts'; const twoColumns = () => buildLayoutTree([ @@ -126,3 +126,50 @@ test('the rectangles always tile the whole area with no gaps or overlaps', () => } } }); + +test('slots are ordered by area so the first window gets the biggest region', () => { + // 67/33 drawn big-first: order is unchanged + assert.deepEqual( + slotOrder([ + { x: 0, y: 0, width: 0.67, height: 1 }, + { x: 0.67, y: 0, width: 0.33, height: 1 }, + ]), + [0, 1], + ); + + // 33/67 drawn small-first: the big one is promoted + assert.deepEqual( + slotOrder([ + { x: 0, y: 0, width: 0.33, height: 1 }, + { x: 0.33, y: 0, width: 0.67, height: 1 }, + ]), + [1, 0], + ); +}); + +test('equal areas keep a stable top-then-left order', () => { + assert.deepEqual( + slotOrder([ + { x: 0.5, y: 0.5, width: 0.5, height: 0.5 }, + { x: 0, y: 0, width: 0.5, height: 0.5 }, + { x: 0.5, y: 0, width: 0.5, height: 0.5 }, + { x: 0, y: 0.5, width: 0.5, height: 0.5 }, + ]), + [1, 2, 3, 0], + ); +}); + +test("Layout 2 at four windows gives the oldest window a widest column", () => { + const rects = reflow( + buildLayoutTree([ + { x: 0, y: 0, width: 0.26, height: 0.5 }, + { x: 0.26, y: 0, width: 0.37, height: 1 }, + { x: 0.63, y: 0, width: 0.37, height: 1 }, + { x: 0, y: 0.5, width: 0.26, height: 0.5 }, + ])!, + 4, + ); + const first = rects[slotOrder(rects)[0]]; + assert.equal(first.width, 0.37); + assert.equal(first.height, 1); +}); diff --git a/src/components/layout/dynamic/reflow.ts b/src/components/layout/dynamic/reflow.ts index 1f180a0d..d2d7e3d2 100644 --- a/src/components/layout/dynamic/reflow.ts +++ b/src/components/layout/dynamic/reflow.ts @@ -107,6 +107,26 @@ function subdivide( const areaOf = (r: TileRect) => r.width * r.height; +/** + * The order in which windows should claim rectangles: roomiest first, so the + * window opened first — the one the user came for — gets the most space, on + * whichever side of the layout it happens to be drawn. Equal areas keep a + * stable top-then-left order. + * + * Returns indices into `rects`, not rectangles. + */ +export function slotOrder(rects: TileRect[]): number[] { + return rects + .map((_, index) => index) + .sort((a, b) => { + const byArea = areaOf(rects[b]) - areaOf(rects[a]); + if (Math.abs(byArea) > 1e-9) return byArea; + if (Math.abs(rects[a].y - rects[b].y) > 1e-9) + return rects[a].y - rects[b].y; + return rects[a].x - rects[b].x; + }); +} + /** The tiles of a subtree, left-to-right then top-to-bottom. */ export function leavesOf(tree: SplitTree): TileRect[] { if (tree.kind === 'leaf') return [tree.tile]; diff --git a/src/components/tilingsystem/tilingManager.ts b/src/components/tilingsystem/tilingManager.ts index b6c48bff..9c3d457e 100644 --- a/src/components/tilingsystem/tilingManager.ts +++ b/src/components/tilingsystem/tilingManager.ts @@ -22,7 +22,7 @@ import Layout from '../layout/Layout'; import Tile from '../layout/Tile'; import TileUtils from '../layout/TileUtils'; import { buildLayoutTree } from '../layout/dynamic/layoutTree'; -import { reflow } from '../layout/dynamic/reflow'; +import { reflow, slotOrder, leavesOf } from '../layout/dynamic/reflow'; import { pickLayoutIndex } from '../layout/dynamic/pickLayout'; import GlobalState from '../../utils/globalState'; import { Monitor } from 'resource:///org/gnome/shell/ui/layout.js'; @@ -1270,16 +1270,22 @@ export class TilingManager { ); } - /** Windows that dynamic tiling is willing to place. */ + /** + * Windows that dynamic tiling is willing to place. + * + * Being maximized is deliberately not disqualifying. Plenty of + * applications maximize themselves the moment they are mapped, and in a + * mode whose whole premise is that windows fill the screen according to a + * layout, refusing them would mean refusing almost everything. They are + * unmaximized on placement instead. + */ private _isDynamicCandidate(window: Meta.Window): boolean { return ( window !== null && window.windowType === Meta.WindowType.NORMAL && window.get_transient_for() === null && !window.is_attached_dialog() && - !window.minimized && - !window.maximizedHorizontally && - !window.maximizedVertically + !window.minimized ); } @@ -1289,8 +1295,21 @@ export class TilingManager { * and the survivors reflow into the space. */ private _dynamicAdd(window: Meta.Window) { - if (window.get_monitor() !== this._monitor.index) return; - if (!this._isDynamicCandidate(window)) return; + this._debug( + `[dyn] created "${window.get_title()}" monitor=${window.get_monitor()} ` + + `(mine=${this._monitor.index}) type=${window.windowType} ` + + `transient=${window.get_transient_for() !== null} ` + + `dialog=${window.is_attached_dialog()} min=${window.minimized} ` + + `maxH=${window.maximizedHorizontally} maxV=${window.maximizedVertically}`, + ); + if (window.get_monitor() !== this._monitor.index) { + this._debug('[dyn] skipped: other monitor'); + return; + } + if (!this._isDynamicCandidate(window)) { + this._debug('[dyn] skipped: not a candidate'); + return; + } if (this._dynamicWindows.includes(window)) return; // Whatever the user was looking at when this window appeared is the @@ -1381,24 +1400,49 @@ export class TilingManager { if (!ws) return; const windows = this._dynamicManagedWindows(ws); + this._debug( + `[dyn] apply: tracked=${this._dynamicWindows.length} eligible=${windows.length} ` + + `[${this._dynamicWindows + .map( + (w) => + `"${w.get_title()}" cand=${this._isDynamicCandidate(w)} ` + + `ws=${w.get_workspace() === ws} mon=${w.get_monitor()}`, + ) + .join(' | ')}]`, + ); if (windows.length === 0) return; // no decomposable layout at all keeps the static behaviour const tree = this._dynamicTree(windows.length); - if (!tree) return; + if (!tree) { + this._debug('[dyn] no decomposable layout, leaving static'); + return; + } - const focusedIndex = splitTarget - ? windows.indexOf(splitTarget) - : undefined; - const rects = reflow( - tree, - windows.length, - focusedIndex !== undefined && focusedIndex >= 0 - ? focusedIndex - : undefined, - ); - windows.forEach((window, index) => { - this._easeWindowRectFromTile(this._tileOf(rects[index]), window); + // Slots run oldest window first and claim the roomiest region first, + // so the window opened first keeps the most space whichever side of + // the layout it is drawn on. A focused slot has to be translated into + // the tile it currently occupies before overflow can split it. + const focusedSlot = splitTarget ? windows.indexOf(splitTarget) : -1; + const leafOrder = slotOrder(leavesOf(tree)); + const focusedTile = + focusedSlot >= 0 && focusedSlot < leafOrder.length + ? leafOrder[focusedSlot] + : undefined; + + const rects = reflow(tree, windows.length, focusedTile); + const order = slotOrder(rects); + + windows.forEach((window, slot) => { + // a maximized window cannot be moved into a tile, and many + // applications maximize themselves as they open + if (window.maximizedHorizontally || window.maximizedVertically) + unmaximizeWindow(window); + + this._easeWindowRectFromTile( + this._tileOf(rects[order[slot]]), + window, + ); }); } @@ -1425,13 +1469,16 @@ export class TilingManager { } const rects = reflow(tree, windows.length); + const order = slotOrder(rects); const [pointerX, pointerY] = global.get_pointer(); - const to = rects.findIndex((rect) => + const droppedOn = rects.findIndex((rect) => isPointInsideRect( { x: pointerX, y: pointerY }, TileUtils.apply_props(this._tileOf(rect), this._workArea), ), ); + // the hit test yields a rectangle; slots are ordered by area + const to = droppedOn < 0 ? -1 : order.indexOf(droppedOn); if (to >= 0 && to !== from) { const a = this._dynamicWindows.indexOf(windows[from]); From cd289492e1294d430baf6369db02e99a5841539d Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 2 Aug 2026 12:29:30 +0530 Subject: [PATCH 10/42] feat(dynamic): reflow when a window is minimized or restored A minimized window is not a placement candidate, so it gave up its slot to the windows behind it, but nothing recomputed the layout: its region was simply left empty until the next window opened, closed or was dragged. Minimize and unminimize now trigger a reflow, deferred to idle so the window's own minimized state has settled before it is read. Restoring a window reclaims its original slot, because minimizing only removes it from the eligible list and never from the slot list. The first window keeps the roomiest region across a minimize and restore. --- src/components/tilingsystem/tilingManager.ts | 22 ++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/components/tilingsystem/tilingManager.ts b/src/components/tilingsystem/tilingManager.ts index 9c3d457e..7bce9754 100644 --- a/src/components/tilingsystem/tilingManager.ts +++ b/src/components/tilingsystem/tilingManager.ts @@ -239,6 +239,28 @@ export class TilingManager { this._onSnapAssist.bind(this), ); + // A minimized window gives up its slot to the windows behind it and + // reclaims it when restored, since it keeps its place in the slot + // list. Both need a reflow, deferred so the window's own minimized + // state has settled before it is read. + const reflowAfterMinimizeChange = () => { + if (!Settings.ENABLE_DYNAMIC_TILING) return; + GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { + this._applyDynamicTiling(); + return GLib.SOURCE_REMOVE; + }); + }; + this._signals.connect( + global.windowManager, + 'minimize', + reflowAfterMinimizeChange, + ); + this._signals.connect( + global.windowManager, + 'unminimize', + reflowAfterMinimizeChange, + ); + this._signals.connect( global.workspaceManager, 'active-workspace-changed', From b7659807f73c63f6641e7ec15f8398c16d555a1e Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 2 Aug 2026 12:51:31 +0530 Subject: [PATCH 11/42] chore: point the extension url at this fork GNOME's Extensions app links to this url, so bug reports about the dynamic tiling added here would otherwise land on domferr's tracker for code he did not write. --- resources/metadata.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/metadata.json b/resources/metadata.json index e58a93c7..21fca59d 100644 --- a/resources/metadata.json +++ b/resources/metadata.json @@ -15,7 +15,7 @@ ], "version": 99, "version-name": "17.3", - "url": "https://github.com/domferr/tilingshell", + "url": "https://github.com/J4KE-B/tilingshell", "settings-schema": "org.gnome.shell.extensions.tilingshell", "gettext-domain": "tilingshell", "donations": { From 8fb11cad576f6e00ebdfc78dc18b08492aacce67 Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 2 Aug 2026 12:51:59 +0530 Subject: [PATCH 12/42] chore: remove the dynamic tiling debug probes They were left in deliberately to diagnose why no window was being placed; the behaviour is confirmed working now. --- src/components/tilingsystem/tilingManager.ts | 32 ++------------------ 1 file changed, 3 insertions(+), 29 deletions(-) diff --git a/src/components/tilingsystem/tilingManager.ts b/src/components/tilingsystem/tilingManager.ts index e8b4375b..c243b431 100644 --- a/src/components/tilingsystem/tilingManager.ts +++ b/src/components/tilingsystem/tilingManager.ts @@ -1317,21 +1317,8 @@ export class TilingManager { * and the survivors reflow into the space. */ private _dynamicAdd(window: Meta.Window) { - this._debug( - `[dyn] created "${window.get_title()}" monitor=${window.get_monitor()} ` + - `(mine=${this._monitor.index}) type=${window.windowType} ` + - `transient=${window.get_transient_for() !== null} ` + - `dialog=${window.is_attached_dialog()} min=${window.minimized} ` + - `maxH=${window.maximizedHorizontally} maxV=${window.maximizedVertically}`, - ); - if (window.get_monitor() !== this._monitor.index) { - this._debug('[dyn] skipped: other monitor'); - return; - } - if (!this._isDynamicCandidate(window)) { - this._debug('[dyn] skipped: not a candidate'); - return; - } + if (window.get_monitor() !== this._monitor.index) return; + if (!this._isDynamicCandidate(window)) return; if (this._dynamicWindows.includes(window)) return; // Whatever the user was looking at when this window appeared is the @@ -1422,24 +1409,11 @@ export class TilingManager { if (!ws) return; const windows = this._dynamicManagedWindows(ws); - this._debug( - `[dyn] apply: tracked=${this._dynamicWindows.length} eligible=${windows.length} ` + - `[${this._dynamicWindows - .map( - (w) => - `"${w.get_title()}" cand=${this._isDynamicCandidate(w)} ` + - `ws=${w.get_workspace() === ws} mon=${w.get_monitor()}`, - ) - .join(' | ')}]`, - ); if (windows.length === 0) return; // no decomposable layout at all keeps the static behaviour const tree = this._dynamicTree(windows.length); - if (!tree) { - this._debug('[dyn] no decomposable layout, leaving static'); - return; - } + if (!tree) return; // Slots run oldest window first and claim the roomiest region first, // so the window opened first keeps the most space whichever side of From d15602625774922e6f1a4abaf3432cd5d0583c3a Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 2 Aug 2026 12:56:52 +0530 Subject: [PATCH 13/42] docs: warn that installing this stops Tiling Shell updates, and how to go back Same UUID as upstream and a higher version number than the one published on extensions.gnome.org, so GNOME will never offer an update back. Anyone testing this deserves to know that before installing, along with the two commands that undo it. --- README.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/README.md b/README.md index 5a5dd298..d1ea9e5c 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,63 @@ > same extension UUID rather than masquerading as a different extension. Licensed > GPLv3, exactly as the original. +## ⚠️ Read this before installing + +This fork keeps Tiling Shell's extension UUID, `tilingshell@ferrarodomenico.com`. That +has one consequence you need to know about: + +**You will stop receiving Tiling Shell updates.** The extension reports version `99`, +while the version published on extensions.gnome.org is `76`. Because 99 is the higher +number, GNOME will never offer you an update — you will silently stay on this fork, +believing you are up to date, until you go back manually. New releases and bug fixes +from domferr will not reach you. + +That version number is inherited from upstream's development branch, not something +invented here, but the effect on you is the same either way. + +You also cannot run this and upstream Tiling Shell side by side. Same UUID, one wins. + +### Installing + +Download the zip for your GNOME version from the +[Releases](https://github.com/J4KE-B/tilingshell/releases) page, then: + +```sh +gnome-extensions install --force tilingshell@ferrarodomenico.com.zip +# log out and log back in — GNOME caches extension code and will not +# pick up the new version any other way +gnome-extensions enable tilingshell@ferrarodomenico.com +``` + +Dynamic tiling is **off** by default. Turn it on from the switch at the top of the +Tiling Shell menu in the top bar. With it off, this behaves like ordinary Tiling Shell. + +### Going back to the real Tiling Shell + +```sh +gnome-extensions uninstall tilingshell@ferrarodomenico.com +``` + +Then reinstall from +[extensions.gnome.org](https://extensions.gnome.org/extension/7065/tiling-shell/), or +through the Extension Manager app, and log out and back in. + +Your layouts and settings live in dconf, not in the extension, so they survive the +round trip and upstream will pick them straight up. The one setting it will not know +about is `enable-dynamic-tiling`, which it simply ignores. To wipe everything and start +from stock defaults instead: + +```sh +dconf reset -f /org/gnome/shell/extensions/tilingshell/ +``` + +### If something breaks + +File it [here](https://github.com/J4KE-B/tilingshell/issues), not on domferr's tracker. +The dynamic tiling code is mine and he should not be answering for it. If you can still +reproduce the problem with dynamic tiling switched **off**, then it probably is an +upstream issue and belongs [there](https://github.com/domferr/tilingshell/issues). + [![release](https://img.shields.io/badge/Release_v16-blue?style=for-the-badge)]([https://ko-fi.com/domferr](https://github.com/domferr/tilingshell/releases)) From 3c662cdcd9ec970008815ebbe40169d12a7f6400 Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 2 Aug 2026 14:38:48 +0530 Subject: [PATCH 14/42] fix(dynamic): make Super+Arrow swap regions instead of using the static layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keyboard move keybindings went straight to the static tiling layout, placing the window in a tile that dynamic tiling knows nothing about. The next reflow — any window opening, closing or being minimised — then moved it somewhere else, so the keypress appeared to work and then silently undid itself. For a window dynamic tiling manages, the arrow keys now swap it with the region in that direction, which is the keyboard equivalent of dragging it there. Reaching the edge of the screen does nothing rather than falling through to the static path. Windows dynamic tiling does not manage, and the span keybindings, are left to the existing behaviour untouched. neighbourIndex is pure and unit tested: a candidate must lie beyond the starting region and share the edge being crossed, so a region merely off to one side is not a neighbour. Nearest wins, ties to the topmost. --- src/components/layout/dynamic/reflow.test.ts | 46 +++++++++++++- src/components/layout/dynamic/reflow.ts | 64 ++++++++++++++++++++ src/components/tilingsystem/tilingManager.ts | 63 ++++++++++++++++++- 3 files changed, 171 insertions(+), 2 deletions(-) diff --git a/src/components/layout/dynamic/reflow.test.ts b/src/components/layout/dynamic/reflow.test.ts index a967750d..8a358fb7 100644 --- a/src/components/layout/dynamic/reflow.test.ts +++ b/src/components/layout/dynamic/reflow.test.ts @@ -2,7 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { buildLayoutTree } from './layoutTree.ts'; import type { TileRect } from './layoutTree.ts'; -import { reflow, slotOrder } from './reflow.ts'; +import { reflow, slotOrder, neighbourIndex } from './reflow.ts'; const twoColumns = () => buildLayoutTree([ @@ -173,3 +173,47 @@ test("Layout 2 at four windows gives the oldest window a widest column", () => { assert.equal(first.width, 0.37); assert.equal(first.height, 1); }); + +test('the neighbour in a direction is the nearest region that overlaps across it', () => { + // 67/33 side by side + const cols = reflow(twoColumns(), 2); + assert.equal(neighbourIndex(cols, 0, 'right'), 1); + assert.equal(neighbourIndex(cols, 1, 'left'), 0); + assert.equal(neighbourIndex(cols, 0, 'up'), -1, 'nothing above'); + assert.equal(neighbourIndex(cols, 0, 'down'), -1, 'nothing below'); +}); + +test('a neighbour must actually share the crossing edge', () => { + // left column split in two, one tall column on the right: + // 0: top-left 1: bottom-left 2: right + const rects = reflow( + buildLayoutTree([ + { x: 0, y: 0, width: 0.5, height: 0.5 }, + { x: 0, y: 0.5, width: 0.5, height: 0.5 }, + { x: 0.5, y: 0, width: 0.5, height: 1 }, + ])!, + 3, + ); + const top = rects.findIndex((r) => r.y === 0 && r.x === 0); + const bottom = rects.findIndex((r) => r.y === 0.5); + const right = rects.findIndex((r) => r.x === 0.5); + + assert.equal(neighbourIndex(rects, top, 'down'), bottom); + assert.equal(neighbourIndex(rects, bottom, 'up'), top); + assert.equal(neighbourIndex(rects, top, 'right'), right); + assert.equal(neighbourIndex(rects, right, 'left'), top, 'ties go to the topmost'); +}); + +test('the nearest neighbour wins when several lie in the same direction', () => { + const three = reflow( + buildLayoutTree([ + { x: 0, y: 0, width: 0.25, height: 1 }, + { x: 0.25, y: 0, width: 0.5, height: 1 }, + { x: 0.75, y: 0, width: 0.25, height: 1 }, + ])!, + 3, + ); + const left = three.findIndex((r) => r.x === 0); + const mid = three.findIndex((r) => r.x === 0.25); + assert.equal(neighbourIndex(three, left, 'right'), mid, 'not the far right one'); +}); diff --git a/src/components/layout/dynamic/reflow.ts b/src/components/layout/dynamic/reflow.ts index d2d7e3d2..018ac3d1 100644 --- a/src/components/layout/dynamic/reflow.ts +++ b/src/components/layout/dynamic/reflow.ts @@ -107,6 +107,70 @@ function subdivide( const areaOf = (r: TileRect) => r.width * r.height; +export type Direction = 'left' | 'right' | 'up' | 'down'; + +/** + * The region adjacent to `from` in a direction, or -1 if the edge of the screen + * lies that way. + * + * A candidate must lie wholly on the far side of the starting region and must + * overlap it across the perpendicular axis, so that the two genuinely share the + * edge being crossed rather than merely sitting somewhere off to that side. The + * nearest such region wins; ties go to the one nearest the top-left, matching + * how slots are ordered elsewhere. + */ +export function neighbourIndex( + rects: TileRect[], + from: number, + direction: Direction, +): number { + const start = rects[from]; + if (!start) return -1; + + const horizontal = direction === 'left' || direction === 'right'; + + // near/far edges along the axis being crossed + const startNear = horizontal ? start.x : start.y; + const startFar = startNear + (horizontal ? start.width : start.height); + + let best = -1; + let bestGap = Number.MAX_VALUE; + + rects.forEach((rect, index) => { + if (index === from) return; + + const near = horizontal ? rect.x : rect.y; + const far = near + (horizontal ? rect.width : rect.height); + + const gap = + direction === 'right' || direction === 'down' + ? near - startFar + : startNear - far; + if (gap < -1e-9) return; // overlaps or lies behind us + + // must share the edge we are crossing + const aNear = horizontal ? start.y : start.x; + const aFar = aNear + (horizontal ? start.height : start.width); + const bNear = horizontal ? rect.y : rect.x; + const bFar = bNear + (horizontal ? rect.height : rect.width); + if (Math.min(aFar, bFar) - Math.max(aNear, bNear) <= 1e-9) return; + + if (gap < bestGap - 1e-9) { + best = index; + bestGap = gap; + } else if (Math.abs(gap - bestGap) <= 1e-9 && best >= 0) { + const current = rects[best]; + if ( + rect.y < current.y - 1e-9 || + (Math.abs(rect.y - current.y) <= 1e-9 && rect.x < current.x) + ) + best = index; + } + }); + + return best; +} + /** * The order in which windows should claim rectangles: roomiest first, so the * window opened first — the one the user came for — gets the most space, on diff --git a/src/components/tilingsystem/tilingManager.ts b/src/components/tilingsystem/tilingManager.ts index c243b431..5239773b 100644 --- a/src/components/tilingsystem/tilingManager.ts +++ b/src/components/tilingsystem/tilingManager.ts @@ -22,7 +22,13 @@ import Layout from '../layout/Layout'; import Tile from '../layout/Tile'; import TileUtils from '../layout/TileUtils'; import { buildLayoutTree } from '../layout/dynamic/layoutTree'; -import { reflow, slotOrder, leavesOf } from '../layout/dynamic/reflow'; +import { + reflow, + slotOrder, + leavesOf, + neighbourIndex, +} from '../layout/dynamic/reflow'; +import type { Direction } from '../layout/dynamic/reflow'; import { pickLayoutIndex } from '../layout/dynamic/pickLayout'; import GlobalState from '../../utils/globalState'; import { Monitor } from 'resource:///org/gnome/shell/ui/layout.js'; @@ -36,6 +42,13 @@ import { maximizeWindow, unmaximizeWindow } from '../../utils/gnomesupport'; const MINIMUM_DISTANCE_TO_RESTORE_ORIGINAL_SIZE = 90; +const DYNAMIC_DIRECTION: Partial> = { + [KeyBindingsDirection.LEFT]: 'left', + [KeyBindingsDirection.RIGHT]: 'right', + [KeyBindingsDirection.UP]: 'up', + [KeyBindingsDirection.DOWN]: 'down', +}; + class SnapAssistingInfo { private _snapAssistantLayoutId: string | undefined; @@ -362,6 +375,13 @@ export class TilingManager { spanFlag: boolean, clamp: boolean, ): boolean { + // Dynamic tiling owns the arrow keys for windows it manages. Falling + // through would move the window into a tile of the static layout, + // which the next reflow would immediately undo. + if (Settings.ENABLE_DYNAMIC_TILING && !spanFlag) { + if (this._dynamicMoveByKeyboard(window, direction)) return true; + } + let destination: { rect: Mtk.Rectangle; tile: Tile } | undefined; const isMaximized = window.maximizedHorizontally || window.maximizedVertically; @@ -1301,6 +1321,47 @@ export class TilingManager { * layout, refusing them would mean refusing almost everything. They are * unmaximized on placement instead. */ + /** + * Swaps the focused window with the region beside it. Returns true when + * dynamic tiling has taken responsibility for the keypress, including when + * there is nothing in that direction — reaching the edge of the screen + * should do nothing, rather than fall through to the static layout. + */ + private _dynamicMoveByKeyboard( + window: Meta.Window, + direction: KeyBindingsDirection, + ): boolean { + const towards = DYNAMIC_DIRECTION[direction]; + if (!towards) return false; + + const ws = global.workspaceManager.get_active_workspace(); + if (!ws) return false; + + const windows = this._dynamicManagedWindows(ws); + const from = windows.indexOf(window); + if (from < 0) return false; // not ours: let the static path have it + if (windows.length < 2) return true; + + const tree = this._dynamicTree(windows.length); + if (!tree) return false; + + const rects = reflow(tree, windows.length); + const order = slotOrder(rects); + const neighbour = neighbourIndex(rects, order[from], towards); + if (neighbour < 0) return true; // at the edge of the screen + + const to = order.indexOf(neighbour); + const a = this._dynamicWindows.indexOf(windows[from]); + const b = this._dynamicWindows.indexOf(windows[to]); + [this._dynamicWindows[a], this._dynamicWindows[b]] = [ + this._dynamicWindows[b], + this._dynamicWindows[a], + ]; + + this._applyDynamicTiling(); + return true; + } + private _isDynamicCandidate(window: Meta.Window): boolean { return ( window !== null && From 3db5a358cc06a80317128c72cb83357726fd194f Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 2 Aug 2026 15:33:12 +0530 Subject: [PATCH 15/42] feat(dynamic): hide the static tile menu entries in dynamic mode Right-clicking a titlebar offered "Move to best tile", "Move to leftmost tile", "Move to rightmost tile", a layout tile picker and four quarter placements. All of them move the window into a fixed rectangle of the static layout, which dynamic tiling undoes at the next reflow, so they appear to work and then quietly revert. While dynamic tiling is on the menu now keeps GNOME's own entries and nothing else. With it off the full menu returns unchanged. --- src/components/window_menu/overriddenWindowMenu.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/components/window_menu/overriddenWindowMenu.ts b/src/components/window_menu/overriddenWindowMenu.ts index fb537927..85c4ac8a 100644 --- a/src/components/window_menu/overriddenWindowMenu.ts +++ b/src/components/window_menu/overriddenWindowMenu.ts @@ -4,6 +4,7 @@ import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import { GObject, St, Clutter, Meta } from '../../gi/ext'; import GlobalState from '../../utils/globalState'; +import Settings from '../../settings/settings'; import { registerGObjectClass } from '../../utils/gjs'; import Tile from '../../components/layout/Tile'; import { @@ -103,6 +104,12 @@ export default class OverriddenWindowMenu extends GObject.Object { const oldFunction = OverriddenWindowMenu._old_buildMenu?.bind(this); if (oldFunction) oldFunction(window); + // Every entry below moves the window into a fixed rectangle of the + // static layout. Dynamic tiling decides placement itself and would undo + // any of them at the next reflow, so the menu keeps GNOME's own items + // and nothing else. + if (Settings.ENABLE_DYNAMIC_TILING) return; + const layouts = GlobalState.get().layouts; if (layouts.length === 0) return; From 1b3406929f2aa94090335f633c29960b026eef6f Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 2 Aug 2026 16:02:06 +0530 Subject: [PATCH 16/42] =?UTF-8?q?fix(dynamic):=20nine=20bugs=20found=20by?= =?UTF-8?q?=20review=20=E2=80=94=20lifecycle,=20stability=20and=20adoption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adoption. Windows were only ever picked up from window-created, so after the extension was disabled and re-enabled — which happens on every screen lock and every monitor change — nothing already on screen was managed at all. Turning the switch on mid-session had the same problem. Both now adopt every open window. Stability. reflow's subdivision target was passed only by the code path that added a window, so every later reflow re-picked the roomiest region instead and reshuffled every window on screen. Worse, the drop hit test and the directional lookup recomputed geometry without it and reasoned about rectangles the windows were not in, so a drag or Super+Arrow could swap with the wrong window. A new pure `assign` returns rectangles already indexed by slot and takes the split target, which is now persisted, so every caller sees the geometry the windows are actually in. Overflow. Splitting a region made its halves smaller, and the assignment then re-sorted by area, so the halves fell to the end of the order and the window whose region had just been split was evicted from it. `assign` keeps the owner in the first half and appends the newcomer. Workspaces. Reflows always targeted the active workspace, so a window closing elsewhere left a hole behind; and nothing listened for a window changing workspace, which left a hole at one end and an overlap at the other. Every workspace holding a managed window is now reflowed, and workspace-changed is handled. Lifecycle. The per-window unmanaged handler was never disconnected — it could not be, since SignalHandling is keyed by signal name and one entry would overwrite another — so every window kept a destroyed manager alive and threw when closed after disable. Handlers are now tracked per window and torn down, the minimize idle source id is kept and removed, repeated minimizes coalesce into one reflow, and destroy clears the slot list. Interactions. Auto-tiling and dynamic tiling could both be on: the unmaximized handler was ungated, so every window dynamic tiling deliberately unmaximized was then grabbed by auto-tiling a frame later. The explicit unmaximize before easing was redundant and made the untile path restore to the wrong size, so it is gone. Returning early from a dynamic drop no longer abandons snap-assist state, which had been leaving edge tiling blocked for every subsequent drag. Also corrects pickLayout's header comment, which described a rule the code had stopped following. --- src/components/layout/dynamic/pickLayout.ts | 5 +- src/components/layout/dynamic/reflow.test.ts | 43 +++- src/components/layout/dynamic/reflow.ts | 57 +++++ src/components/tilingsystem/tilingManager.ts | 211 ++++++++++++------- 4 files changed, 241 insertions(+), 75 deletions(-) diff --git a/src/components/layout/dynamic/pickLayout.ts b/src/components/layout/dynamic/pickLayout.ts index ecc379a3..915101a7 100644 --- a/src/components/layout/dynamic/pickLayout.ts +++ b/src/components/layout/dynamic/pickLayout.ts @@ -6,8 +6,9 @@ * preferred order, and returns an index into that list. * * Order is preference. A layout that has exactly as many tiles as there are - * windows is used as drawn, and the leftmost such layout wins. Failing that - * the leftmost roomier layout is collapsed down to fit. Failing that the + * windows is used as drawn, and the leftmost such layout wins. Failing that, + * the smallest layout still large enough is collapsed to fit — collapsing as + * little as possible — again leftmost among equals. Failing that, the * roomiest layout is subdivided. */ export function pickLayoutIndex( diff --git a/src/components/layout/dynamic/reflow.test.ts b/src/components/layout/dynamic/reflow.test.ts index 8a358fb7..21c2998c 100644 --- a/src/components/layout/dynamic/reflow.test.ts +++ b/src/components/layout/dynamic/reflow.test.ts @@ -2,7 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { buildLayoutTree } from './layoutTree.ts'; import type { TileRect } from './layoutTree.ts'; -import { reflow, slotOrder, neighbourIndex } from './reflow.ts'; +import { reflow, slotOrder, neighbourIndex, assign } from './reflow.ts'; const twoColumns = () => buildLayoutTree([ @@ -217,3 +217,44 @@ test('the nearest neighbour wins when several lie in the same direction', () => const mid = three.findIndex((r) => r.x === 0.25); assert.equal(neighbourIndex(three, left, 'right'), mid, 'not the far right one'); }); + +test('assign hands the roomiest region to the first slot', () => { + assert.deepEqual(assign(twoColumns(), 2), [ + { x: 0, y: 0, width: 0.67, height: 1 }, + { x: 0.67, y: 0, width: 0.33, height: 1 }, + ]); +}); + +test('overflow leaves the owner in place and gives the newcomer the other half', () => { + // slot 0 owns the wide region; splitting it must keep slot 0 in the first + // half rather than evicting it to the end of the order + assert.deepEqual(assign(twoColumns(), 3), [ + { x: 0, y: 0, width: 0.67, height: 0.5 }, + { x: 0.67, y: 0, width: 0.33, height: 1 }, + { x: 0, y: 0.5, width: 0.67, height: 0.5 }, + ]); +}); + +test('overflow splits the nominated slot', () => { + assert.deepEqual(assign(twoColumns(), 3, 1), [ + { x: 0, y: 0, width: 0.67, height: 1 }, + { x: 0.67, y: 0, width: 0.33, height: 0.5 }, + { x: 0.67, y: 0.5, width: 0.33, height: 0.5 }, + ]); +}); + +test('assign is deterministic and total for every window count', () => { + const tree = buildLayoutTree([ + { x: 0, y: 0, width: 0.26, height: 0.5 }, + { x: 0.26, y: 0, width: 0.37, height: 1 }, + { x: 0.63, y: 0, width: 0.37, height: 1 }, + { x: 0, y: 0.5, width: 0.26, height: 0.5 }, + ])!; + for (let n = 1; n <= 9; n++) { + const once = assign(tree, n); + assert.equal(once.length, n, `count for ${n}`); + assert.deepEqual(assign(tree, n), once, `stable for ${n}`); + const covered = once.reduce((s, r) => s + r.width * r.height, 0); + assert.ok(Math.abs(covered - 1) < 1e-9, `coverage for ${n}`); + } +}); diff --git a/src/components/layout/dynamic/reflow.ts b/src/components/layout/dynamic/reflow.ts index 018ac3d1..bb01b4ab 100644 --- a/src/components/layout/dynamic/reflow.ts +++ b/src/components/layout/dynamic/reflow.ts @@ -107,6 +107,63 @@ function subdivide( const areaOf = (r: TileRect) => r.width * r.height; +/** + * One rectangle per slot, indexed by slot rather than by position on screen. + * + * Slot 0 gets the roomiest region, so the window opened first keeps the most + * space whichever side of the layout it is drawn on. + * + * Past the last tile a region is halved. The slot that owned it **keeps the + * first half** and the newcomer is appended, so overflow never evicts a window + * from the region it was already in. `splitSlot` nominates which slot is + * halved first; any further overflow takes the roomiest. + * + * The result depends only on its arguments, so every caller that passes the + * same arguments sees the same geometry the windows are actually in. + */ +export function assign( + tree: SplitTree, + windowCount: number, + splitSlot?: number, +): TileRect[] { + if (windowCount <= 0) return []; + + const leaves = leavesOf(tree); + if (windowCount <= leaves.length) { + const rects = reflow(tree, windowCount); + return slotOrder(rects).map((index) => rects[index]); + } + + const slots = slotOrder(leaves).map((index) => leaves[index]); + let nominated = + splitSlot !== undefined && splitSlot >= 0 && splitSlot < slots.length + ? splitSlot + : undefined; + + while (slots.length < windowCount) { + let target = nominated; + nominated = undefined; + + if (target === undefined) { + target = 0; + for (let i = 1; i < slots.length; i++) { + if (areaOf(slots[i]) > areaOf(slots[target])) target = i; + } + } + + const r = slots[target]; + if (r.height >= r.width) { + slots[target] = { ...r, height: r.height / 2 }; + slots.push({ ...r, y: r.y + r.height / 2, height: r.height / 2 }); + } else { + slots[target] = { ...r, width: r.width / 2 }; + slots.push({ ...r, x: r.x + r.width / 2, width: r.width / 2 }); + } + } + + return slots; +} + export type Direction = 'left' | 'right' | 'up' | 'down'; /** diff --git a/src/components/tilingsystem/tilingManager.ts b/src/components/tilingsystem/tilingManager.ts index 5239773b..6ef39d2e 100644 --- a/src/components/tilingsystem/tilingManager.ts +++ b/src/components/tilingsystem/tilingManager.ts @@ -22,12 +22,7 @@ import Layout from '../layout/Layout'; import Tile from '../layout/Tile'; import TileUtils from '../layout/TileUtils'; import { buildLayoutTree } from '../layout/dynamic/layoutTree'; -import { - reflow, - slotOrder, - leavesOf, - neighbourIndex, -} from '../layout/dynamic/reflow'; +import { assign, neighbourIndex } from '../layout/dynamic/reflow'; import type { Direction } from '../layout/dynamic/reflow'; import { pickLayoutIndex } from '../layout/dynamic/pickLayout'; import GlobalState from '../../utils/globalState'; @@ -93,6 +88,13 @@ export class TilingManager { private _movingWindowTimerId: number | null = null; // Windows placed by dynamic tiling, in the order they claim slots. private _dynamicWindows: Meta.Window[] = []; + // Per-window handlers, kept here because SignalHandling is keyed by signal + // name and so cannot hold one entry per window. + private _dynamicWindowSignals: Map = new Map(); + // Which slot overflow should halve. Persisted so that every reflow agrees + // on the geometry, not just the one that added a window. + private _splitSlot: number | undefined = undefined; + private _dynamicReflowSourceId: number | null = null; private readonly _signals: SignalHandling; private readonly _debug: (..._content: unknown[]) => void; @@ -258,10 +260,16 @@ export class TilingManager { // state has settled before it is read. const reflowAfterMinimizeChange = () => { if (!Settings.ENABLE_DYNAMIC_TILING) return; - GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { - this._applyDynamicTiling(); - return GLib.SOURCE_REMOVE; - }); + // one reflow however many windows were minimized at once + if (this._dynamicReflowSourceId !== null) return; + this._dynamicReflowSourceId = GLib.idle_add( + GLib.PRIORITY_DEFAULT_IDLE, + () => { + this._dynamicReflowSourceId = null; + this._applyDynamicTiling(); + return GLib.SOURCE_REMOVE; + }, + ); }; this._signals.connect( global.windowManager, @@ -274,6 +282,15 @@ export class TilingManager { reflowAfterMinimizeChange, ); + // Turning the mode on mid-session must adopt what is already open, + // otherwise nothing happens until the next window is created. + this._signals.connect( + Settings, + Settings.KEY_ENABLE_DYNAMIC_TILING, + () => this._adoptOpenWindows(), + ); + this._adoptOpenWindows(); + this._signals.connect( global.workspaceManager, 'active-workspace-changed', @@ -345,6 +362,9 @@ export class TilingManager { TilingShellWindowManager.get(), 'unmaximized', (_, window: Meta.Window) => { + // dynamic tiling unmaximizes windows on purpose; without this + // guard auto-tiling would grab each one a frame later + if (Settings.ENABLE_DYNAMIC_TILING) return; if (Settings.ENABLE_AUTO_TILING) this._autoTile(window, false); }, ); @@ -517,6 +537,16 @@ export class TilingManager { GLib.Source.remove(this._movingWindowTimerId); this._movingWindowTimerId = null; } + if (this._dynamicReflowSourceId !== null) { + GLib.Source.remove(this._dynamicReflowSourceId); + this._dynamicReflowSourceId = null; + } + this._dynamicWindowSignals.forEach((ids, window) => + ids.forEach((id) => window.disconnect(id)), + ); + this._dynamicWindowSignals.clear(); + this._dynamicWindows.length = 0; + this._splitSlot = undefined; this._signals.disconnect(); this._isGrabbingWindow = false; this._snapAssistingInfo.update(undefined); @@ -869,8 +899,13 @@ export class TilingManager { // Dynamic tiling owns every drop of a window it manages: the window // trades places with whatever occupies the slot under the pointer. - if (Settings.ENABLE_DYNAMIC_TILING && this._dynamicSwapOnDrop(window)) + if (Settings.ENABLE_DYNAMIC_TILING && this._dynamicSwapOnDrop(window)) { + // returning early must still leave the drag state clean, or edge + // tiling stays blocked for every later drag + this._snapAssistingInfo.update(undefined); + this._edgeTilingManager.abortEdgeTiling(); return; + } const isTilingSystemActivated = this._activationKeyStatus( global.get_pointer()[2], @@ -1345,12 +1380,10 @@ export class TilingManager { const tree = this._dynamicTree(windows.length); if (!tree) return false; - const rects = reflow(tree, windows.length); - const order = slotOrder(rects); - const neighbour = neighbourIndex(rects, order[from], towards); - if (neighbour < 0) return true; // at the edge of the screen + const rects = assign(tree, windows.length, this._splitSlot); + const to = neighbourIndex(rects, from, towards); + if (to < 0) return true; // at the edge of the screen - const to = order.indexOf(neighbour); const a = this._dynamicWindows.indexOf(windows[from]); const b = this._dynamicWindows.indexOf(windows[to]); [this._dynamicWindows[a], this._dynamicWindows[b]] = [ @@ -1377,36 +1410,88 @@ export class TilingManager { * room for it. Closing is the mirror image: the window gives its slot back * and the survivors reflow into the space. */ - private _dynamicAdd(window: Meta.Window) { - if (window.get_monitor() !== this._monitor.index) return; - if (!this._isDynamicCandidate(window)) return; - if (this._dynamicWindows.includes(window)) return; - - // Whatever the user was looking at when this window appeared is the - // window whose space the newcomer should take half of, once the layout - // has run out of tiles. - const splitTarget = global.display.focus_window ?? undefined; + /** + * Starts managing a window: gives it a slot and listens for the events + * that must reflow. Returns false when the window is not ours to place. + */ + private _trackDynamicWindow(window: Meta.Window): boolean { + if (window.get_monitor() !== this._monitor.index) return false; + if (!this._isDynamicCandidate(window)) return false; + if (this._dynamicWindowSignals.has(window)) return false; this._dynamicWindows.push(window); + this._dynamicWindowSignals.set(window, [ + window.connect('unmanaged', () => + this._untrackDynamicWindow(window), + ), + // moving to another workspace leaves a hole behind and crowds the + // destination, so both ends need recomputing + window.connect('workspace-changed', () => + this._applyDynamicTiling(), + ), + ]); + return true; + } - window.connect('unmanaged', () => { - const slot = this._dynamicWindows.indexOf(window); - if (slot < 0) return; - this._dynamicWindows.splice(slot, 1); - this._applyDynamicTiling(); + /** + * Stops managing a window. The handlers are dropped rather than + * disconnected because this runs while the window is being destroyed. + */ + private _untrackDynamicWindow(window: Meta.Window) { + this._dynamicWindowSignals.delete(window); + const slot = this._dynamicWindows.indexOf(window); + if (slot < 0) return; + this._dynamicWindows.splice(slot, 1); + this._applyDynamicTiling(); + } + + /** + * Takes on every window already open. Needed because windows are otherwise + * only picked up as they are created, and the extension is disabled and + * re-enabled on lock, unlock and monitor changes — after which nothing on + * screen would be managed at all. + */ + private _adoptOpenWindows() { + if (!Settings.ENABLE_DYNAMIC_TILING) return; + + let adopted = false; + getWindows().forEach((window) => { + if (this._trackDynamicWindow(window as Meta.Window)) adopted = true; }); + if (adopted) this._applyDynamicTiling(); + } + + /** + * Gives a newly created window a slot and reflows everything else to make + * room for it. Closing is the mirror image: the window gives its slot back + * and the survivors reflow into the space. + */ + private _dynamicAdd(window: Meta.Window) { + // Whatever the user was looking at is the region the newcomer should + // take half of, once the layout has run out of tiles. Recorded before + // the newcomer is tracked, and kept, so that every later reflow splits + // the same region rather than re-picking the roomiest one. + const ws = window.get_workspace(); + const focused = global.display.focus_window; + const focusedSlot = + focused && ws + ? this._dynamicManagedWindows(ws).indexOf(focused) + : -1; + + if (!this._trackDynamicWindow(window)) return; + this._splitSlot = focusedSlot >= 0 ? focusedSlot : undefined; const windowActor = window.get_compositor_private() as Meta.WindowActor | null; if (!windowActor) { - this._applyDynamicTiling(splitTarget); + this._applyDynamicTiling(); return; } // wait for the window to be drawn, exactly as auto-tiling does, so it // does not visibly jump from its default position const id = windowActor.connect('first-frame', () => { - this._applyDynamicTiling(splitTarget); + this._applyDynamicTiling(); windowActor.disconnect(id); }); } @@ -1460,46 +1545,31 @@ export class TilingManager { } /** - * Recomputes every managed window's rectangle for the current window count - * and eases them all into place. + * Recomputes rectangles and eases every managed window into place, on + * every workspace that holds one — a window closing on another workspace + * must not leave a hole there. */ - private _applyDynamicTiling(splitTarget?: Meta.Window) { + private _applyDynamicTiling() { if (!Settings.ENABLE_DYNAMIC_TILING) return; - const ws = global.workspaceManager.get_active_workspace(); - if (!ws) return; + const workspaces = new Set(); + this._dynamicWindows.forEach((window) => { + const ws = window.get_workspace(); + if (ws) workspaces.add(ws); + }); - const windows = this._dynamicManagedWindows(ws); - if (windows.length === 0) return; + workspaces.forEach((ws) => { + const windows = this._dynamicManagedWindows(ws); + if (windows.length === 0) return; - // no decomposable layout at all keeps the static behaviour - const tree = this._dynamicTree(windows.length); - if (!tree) return; - - // Slots run oldest window first and claim the roomiest region first, - // so the window opened first keeps the most space whichever side of - // the layout it is drawn on. A focused slot has to be translated into - // the tile it currently occupies before overflow can split it. - const focusedSlot = splitTarget ? windows.indexOf(splitTarget) : -1; - const leafOrder = slotOrder(leavesOf(tree)); - const focusedTile = - focusedSlot >= 0 && focusedSlot < leafOrder.length - ? leafOrder[focusedSlot] - : undefined; - - const rects = reflow(tree, windows.length, focusedTile); - const order = slotOrder(rects); - - windows.forEach((window, slot) => { - // a maximized window cannot be moved into a tile, and many - // applications maximize themselves as they open - if (window.maximizedHorizontally || window.maximizedVertically) - unmaximizeWindow(window); - - this._easeWindowRectFromTile( - this._tileOf(rects[order[slot]]), - window, - ); + // no decomposable layout at all keeps the static behaviour + const tree = this._dynamicTree(windows.length); + if (!tree) return; + + const rects = assign(tree, windows.length, this._splitSlot); + windows.forEach((window, slot) => { + this._easeWindowRectFromTile(this._tileOf(rects[slot]), window); + }); }); } @@ -1525,17 +1595,14 @@ export class TilingManager { return true; } - const rects = reflow(tree, windows.length); - const order = slotOrder(rects); + const rects = assign(tree, windows.length, this._splitSlot); const [pointerX, pointerY] = global.get_pointer(); - const droppedOn = rects.findIndex((rect) => + const to = rects.findIndex((rect) => isPointInsideRect( { x: pointerX, y: pointerY }, TileUtils.apply_props(this._tileOf(rect), this._workArea), ), ); - // the hit test yields a rectangle; slots are ordered by area - const to = droppedOn < 0 ? -1 : order.indexOf(droppedOn); if (to >= 0 && to !== from) { const a = this._dynamicWindows.indexOf(windows[from]); From 85072d746ee8a25c3868204bf2a6914e1e0d6c9d Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 2 Aug 2026 20:37:29 +0530 Subject: [PATCH 17/42] fix(dynamic): three problems the previous review found in its own fixes Adoption only saw the active workspace, so unlocking or a monitor change left every window on any other workspace unmanaged, and a window opened there afterwards would be the sole tracked one and take the whole screen on top of windows that were never adopted. Adoption now loops every workspace. Adoption order was most-recently-used, so every unlock or toggle promoted whichever window had last been focused into the master slot instead of whichever was opened first. Adopted windows are now sorted by get_stable_sequence(), which reproduces creation order and makes the cycle a no-op. Minimized windows were excluded from candidacy entirely, so a window minimized before dynamic tiling started, or before a lock, was never tracked and stayed unmanaged even after being restored. Candidacy is now split: trackable (may be tracked, including while minimized) and eligible (has a rectangle to be placed in). Tracking adds a window regardless of minimized state; placement still only considers eligible ones. Alongside this: the split target that decides which region overflow halves was a bare slot index applied to every workspace, so opening a window on one workspace could reshuffle another, and closing a window ahead of it in the list made it name the wrong region after the list shifted. It is now held as a window reference, resolved to an index against the correct workspace's window list at every reflow, and cleared when that window is untracked. The minimize/unminimize/workspace-changed reflows are also now routed through one coalescing method rather than duplicating the idle-queue logic inline. --- src/components/tilingsystem/tilingManager.ts | 173 ++++++++++++------- 1 file changed, 114 insertions(+), 59 deletions(-) diff --git a/src/components/tilingsystem/tilingManager.ts b/src/components/tilingsystem/tilingManager.ts index 6ef39d2e..86075795 100644 --- a/src/components/tilingsystem/tilingManager.ts +++ b/src/components/tilingsystem/tilingManager.ts @@ -91,9 +91,11 @@ export class TilingManager { // Per-window handlers, kept here because SignalHandling is keyed by signal // name and so cannot hold one entry per window. private _dynamicWindowSignals: Map = new Map(); - // Which slot overflow should halve. Persisted so that every reflow agrees - // on the geometry, not just the one that added a window. - private _splitSlot: number | undefined = undefined; + // The window whose region overflow should halve, on the workspace it is + // actually on. Held as a window rather than a slot index so it survives + // windows ahead of it closing, and resolved to an index per workspace at + // reflow time so it cannot be misapplied to an unrelated workspace. + private _splitTarget: Meta.Window | null = null; private _dynamicReflowSourceId: number | null = null; private readonly _signals: SignalHandling; @@ -256,30 +258,19 @@ export class TilingManager { // A minimized window gives up its slot to the windows behind it and // reclaims it when restored, since it keeps its place in the slot - // list. Both need a reflow, deferred so the window's own minimized - // state has settled before it is read. - const reflowAfterMinimizeChange = () => { - if (!Settings.ENABLE_DYNAMIC_TILING) return; - // one reflow however many windows were minimized at once - if (this._dynamicReflowSourceId !== null) return; - this._dynamicReflowSourceId = GLib.idle_add( - GLib.PRIORITY_DEFAULT_IDLE, - () => { - this._dynamicReflowSourceId = null; - this._applyDynamicTiling(); - return GLib.SOURCE_REMOVE; - }, - ); - }; + // list, and moving a window to another workspace must recompute both + // ends. All three are deferred through the same coalescer so several + // firing together — e.g. a workspace being torn down and reassigning + // every window on it — produce one reflow, not one each. this._signals.connect( global.windowManager, 'minimize', - reflowAfterMinimizeChange, + () => this._queueDynamicReflow(), ); this._signals.connect( global.windowManager, 'unminimize', - reflowAfterMinimizeChange, + () => this._queueDynamicReflow(), ); // Turning the mode on mid-session must adopt what is already open, @@ -546,7 +537,7 @@ export class TilingManager { ); this._dynamicWindowSignals.clear(); this._dynamicWindows.length = 0; - this._splitSlot = undefined; + this._splitTarget = null; this._signals.disconnect(); this._isGrabbingWindow = false; this._snapAssistingInfo.update(undefined); @@ -1347,15 +1338,6 @@ export class TilingManager { ); } - /** - * Windows that dynamic tiling is willing to place. - * - * Being maximized is deliberately not disqualifying. Plenty of - * applications maximize themselves the moment they are mapped, and in a - * mode whose whole premise is that windows fill the screen according to a - * layout, refusing them would mean refusing almost everything. They are - * unmaximized on placement instead. - */ /** * Swaps the focused window with the region beside it. Returns true when * dynamic tiling has taken responsibility for the keypress, including when @@ -1380,7 +1362,14 @@ export class TilingManager { const tree = this._dynamicTree(windows.length); if (!tree) return false; - const rects = assign(tree, windows.length, this._splitSlot); + const splitSlot = this._splitTarget + ? windows.indexOf(this._splitTarget) + : -1; + const rects = assign( + tree, + windows.length, + splitSlot >= 0 ? splitSlot : undefined, + ); const to = neighbourIndex(rects, from, towards); if (to < 0) return true; // at the edge of the screen @@ -1395,28 +1384,39 @@ export class TilingManager { return true; } - private _isDynamicCandidate(window: Meta.Window): boolean { + /** + * Windows dynamic tiling is willing to keep track of at all — including + * ones it cannot place right now, such as a minimized window, so that + * tracking survives a minimize/restore or a lock/unlock cycle rather than + * losing the window entirely. + * + * Being maximized is deliberately not disqualifying either. Plenty of + * applications maximize themselves the moment they are mapped, and in a + * mode whose whole premise is that windows fill the screen according to a + * layout, refusing them would mean refusing almost everything. They are + * unmaximized on placement instead, by `_easeWindowRectFromTile`. + */ + private _isDynamicTrackable(window: Meta.Window): boolean { return ( window !== null && window.windowType === Meta.WindowType.NORMAL && window.get_transient_for() === null && - !window.is_attached_dialog() && - !window.minimized + !window.is_attached_dialog() ); } - /** - * Gives a newly created window a slot and reflows everything else to make - * room for it. Closing is the mirror image: the window gives its slot back - * and the survivors reflow into the space. - */ + /** Trackable windows that additionally have a rectangle to be placed in. */ + private _isDynamicEligible(window: Meta.Window): boolean { + return this._isDynamicTrackable(window) && !window.minimized; + } + /** * Starts managing a window: gives it a slot and listens for the events * that must reflow. Returns false when the window is not ours to place. */ private _trackDynamicWindow(window: Meta.Window): boolean { if (window.get_monitor() !== this._monitor.index) return false; - if (!this._isDynamicCandidate(window)) return false; + if (!this._isDynamicTrackable(window)) return false; if (this._dynamicWindowSignals.has(window)) return false; this._dynamicWindows.push(window); @@ -1427,7 +1427,7 @@ export class TilingManager { // moving to another workspace leaves a hole behind and crowds the // destination, so both ends need recomputing window.connect('workspace-changed', () => - this._applyDynamicTiling(), + this._queueDynamicReflow(), ), ]); return true; @@ -1439,6 +1439,8 @@ export class TilingManager { */ private _untrackDynamicWindow(window: Meta.Window) { this._dynamicWindowSignals.delete(window); + if (this._splitTarget === window) this._splitTarget = null; + const slot = this._dynamicWindows.indexOf(window); if (slot < 0) return; this._dynamicWindows.splice(slot, 1); @@ -1446,17 +1448,29 @@ export class TilingManager { } /** - * Takes on every window already open. Needed because windows are otherwise - * only picked up as they are created, and the extension is disabled and - * re-enabled on lock, unlock and monitor changes — after which nothing on - * screen would be managed at all. + * Takes on every window already open, on every workspace. Needed because + * windows are otherwise only picked up as they are created, and the + * extension is disabled and re-enabled on lock, unlock and monitor + * changes — after which nothing already on screen would be managed at + * all. Ordered by creation rather than by recency, so an unlock or a + * toggle does not promote whichever window was last focused into the + * master slot. */ private _adoptOpenWindows() { if (!Settings.ENABLE_DYNAMIC_TILING) return; + const byWorkspace: Meta.Window[] = []; + for (let i = 0; i < global.workspaceManager.get_n_workspaces(); i++) { + const ws = global.workspaceManager.get_workspace_by_index(i); + if (ws) byWorkspace.push(...getWindows(ws)); + } + const inCreationOrder = [...new Set(byWorkspace)].sort( + (a, b) => a.get_stable_sequence() - b.get_stable_sequence(), + ); + let adopted = false; - getWindows().forEach((window) => { - if (this._trackDynamicWindow(window as Meta.Window)) adopted = true; + inCreationOrder.forEach((window) => { + if (this._trackDynamicWindow(window)) adopted = true; }); if (adopted) this._applyDynamicTiling(); } @@ -1467,19 +1481,21 @@ export class TilingManager { * and the survivors reflow into the space. */ private _dynamicAdd(window: Meta.Window) { - // Whatever the user was looking at is the region the newcomer should - // take half of, once the layout has run out of tiles. Recorded before - // the newcomer is tracked, and kept, so that every later reflow splits - // the same region rather than re-picking the roomiest one. + // Whatever the user was looking at is the window whose region the + // newcomer should take half of, once the layout runs out of tiles. + // Recorded before the newcomer is tracked so the lookup only sees + // windows already on screen, and kept as a window reference — not a + // slot index — so it resolves freshly, and only against the correct + // workspace, at every later reflow. const ws = window.get_workspace(); const focused = global.display.focus_window; - const focusedSlot = + const focusedIsManaged = focused && ws - ? this._dynamicManagedWindows(ws).indexOf(focused) - : -1; + ? this._dynamicManagedWindows(ws).includes(focused) + : false; if (!this._trackDynamicWindow(window)) return; - this._splitSlot = focusedSlot >= 0 ? focusedSlot : undefined; + this._splitTarget = focusedIsManaged ? focused : null; const windowActor = window.get_compositor_private() as Meta.WindowActor | null; @@ -1525,11 +1541,15 @@ export class TilingManager { return index < 0 ? null : candidates[index].tree; } - /** Managed windows currently on this monitor and workspace, in slot order. */ + /** + * Managed windows currently placeable on this monitor and workspace, in + * slot order. Minimized windows stay tracked (see `_isDynamicTrackable`) + * but are excluded here, since they have nothing on screen to place. + */ private _dynamicManagedWindows(ws: Meta.Workspace): Meta.Window[] { return this._dynamicWindows.filter( (w) => - this._isDynamicCandidate(w) && + this._isDynamicEligible(w) && w.get_workspace() === ws && w.get_monitor() === this._monitor.index, ); @@ -1544,6 +1564,24 @@ export class TilingManager { return new Tile({ ...rect, groups: [] }); } + /** + * Queues one reflow on the next idle, coalescing any further calls until + * it runs — several windows minimizing, unminimizing or changing + * workspace together must produce one reflow, not one each. + */ + private _queueDynamicReflow() { + if (!Settings.ENABLE_DYNAMIC_TILING) return; + if (this._dynamicReflowSourceId !== null) return; + this._dynamicReflowSourceId = GLib.idle_add( + GLib.PRIORITY_DEFAULT_IDLE, + () => { + this._dynamicReflowSourceId = null; + this._applyDynamicTiling(); + return GLib.SOURCE_REMOVE; + }, + ); + } + /** * Recomputes rectangles and eases every managed window into place, on * every workspace that holds one — a window closing on another workspace @@ -1566,7 +1604,17 @@ export class TilingManager { const tree = this._dynamicTree(windows.length); if (!tree) return; - const rects = assign(tree, windows.length, this._splitSlot); + // the split target only applies to the workspace it is actually + // on; elsewhere it resolves to -1 and overflow picks the roomiest + // region instead, exactly as when nothing is focused at all + const splitSlot = this._splitTarget + ? windows.indexOf(this._splitTarget) + : -1; + const rects = assign( + tree, + windows.length, + splitSlot >= 0 ? splitSlot : undefined, + ); windows.forEach((window, slot) => { this._easeWindowRectFromTile(this._tileOf(rects[slot]), window); }); @@ -1595,7 +1643,14 @@ export class TilingManager { return true; } - const rects = assign(tree, windows.length, this._splitSlot); + const splitSlot = this._splitTarget + ? windows.indexOf(this._splitTarget) + : -1; + const rects = assign( + tree, + windows.length, + splitSlot >= 0 ? splitSlot : undefined, + ); const [pointerX, pointerY] = global.get_pointer(); const to = rects.findIndex((rect) => isPointInsideRect( From 8b406b25f98e23f8348167aeba627d3842f24d5e Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 2 Aug 2026 20:46:26 +0530 Subject: [PATCH 18/42] feat(dynamic): Super+; cycles between layouts of the same size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Super+N was requested first, but it is already GNOME's own toggle-message-tray — checked every keybinding schema (desktop, shell, mutter, settings-daemon, and Tiling Shell's own) and every punctuation key next to Super was free. Semicolon was picked; Shift+Super+; goes backward. Both confirmed free before wiring anything up. pickLayoutIndexAt is pickLayoutIndex with a group and an offset: it finds every candidate layout sharing the tile count of the default pick and steps to another member of that group, wrapping in either direction. Offset 0 always agrees with pickLayoutIndex, which is now implemented as a call to it — one algorithm, not two to keep in sync. The offset is stored per workspace on the manager and cleared when a workspace is removed, alongside the existing cleanup for its tiling layout. _dynamicTree takes the workspace now, for the same reason splitTarget needed one: a preference set on one workspace must not leak onto another. Wired through the same KeyBindings/extension.ts pattern the other keyboard actions already use, gated on enable-move-keybindings — Super+ Arrow already implicitly depends on that toggle for the same reason, so this stays consistent rather than inventing a second gate. --- ...e.shell.extensions.tilingshell.gschema.xml | 8 +++ .../layout/dynamic/pickLayout.test.ts | 33 ++++++++++- src/components/layout/dynamic/pickLayout.ts | 26 +++++++++ src/components/tilingsystem/tilingManager.ts | 58 +++++++++++++++---- src/extension.ts | 11 ++++ src/keybindings.ts | 25 ++++++++ src/settings/settings.ts | 3 + 7 files changed, 153 insertions(+), 11 deletions(-) diff --git a/resources/schemas/org.gnome.shell.extensions.tilingshell.gschema.xml b/resources/schemas/org.gnome.shell.extensions.tilingshell.gschema.xml index cf079247..641be372 100644 --- a/resources/schemas/org.gnome.shell.extensions.tilingshell.gschema.xml +++ b/resources/schemas/org.gnome.shell.extensions.tilingshell.gschema.xml @@ -294,6 +294,14 @@ Cycle backwards through available workspace layouts + + semicolon']]]> + In dynamic tiling, use the next layout of the same size + + + semicolon']]]> + In dynamic tiling, use the previous layout of the same size + diff --git a/src/components/layout/dynamic/pickLayout.test.ts b/src/components/layout/dynamic/pickLayout.test.ts index fb433466..1482906f 100644 --- a/src/components/layout/dynamic/pickLayout.test.ts +++ b/src/components/layout/dynamic/pickLayout.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { pickLayoutIndex } from './pickLayout.ts'; +import { pickLayoutIndex, pickLayoutIndexAt } from './pickLayout.ts'; // Jacob's layouts in their current order: a 4-tile layout, then three 2-tile ones const layouts = [4, 2, 2, 2]; @@ -49,3 +49,34 @@ test('ties on tile count keep the leftmost', () => { test('an empty list has nothing to pick', () => { assert.equal(pickLayoutIndex([], 3), -1); }); + +test('the group at an offset is every layout sharing the picked tile count, offset wraps', () => { + // 4, 2, 2, 2 — 2 windows picks index 1, whose group is every 2-tile layout + assert.equal(pickLayoutIndexAt(layouts, 2, 0), 1); + assert.equal(pickLayoutIndexAt(layouts, 2, 1), 2); + assert.equal(pickLayoutIndexAt(layouts, 2, 2), 3); + assert.equal(pickLayoutIndexAt(layouts, 2, 3), 1, 'wraps back to the first'); + assert.equal(pickLayoutIndexAt(layouts, 2, -1), 3, 'negative wraps backward'); +}); + +test('offset 0 always agrees with pickLayoutIndex', () => { + for (const [layouts_, n] of [ + [[4, 2, 2, 2], 2], + [[4, 2, 2, 2], 3], + [[4, 2, 2, 2], 4], + [[4, 2, 2, 2], 1], + [[8, 3, 5], 2], + [[], 3], + ] as [number[], number][]) { + assert.equal(pickLayoutIndexAt(layouts_, n, 0), pickLayoutIndex(layouts_, n)); + } +}); + +test('a group of one does not move regardless of offset', () => { + assert.equal(pickLayoutIndexAt([4, 2, 2, 2], 4, 1), 0); + assert.equal(pickLayoutIndexAt([4, 2, 2, 2], 4, 5), 0); +}); + +test('an empty list has nothing to cycle either', () => { + assert.equal(pickLayoutIndexAt([], 3, 1), -1); +}); diff --git a/src/components/layout/dynamic/pickLayout.ts b/src/components/layout/dynamic/pickLayout.ts index 915101a7..37120afa 100644 --- a/src/components/layout/dynamic/pickLayout.ts +++ b/src/components/layout/dynamic/pickLayout.ts @@ -35,3 +35,29 @@ export function pickLayoutIndex( } return roomiest; } + +/** + * Like `pickLayoutIndex`, but lets the caller step through every layout that + * shares the picked one's tile count — a manual "use a different layout of + * this size" override. `offset` is taken modulo the group size and may be + * negative, so cycling forward and backward from any starting point always + * lands on a member of the group. `offset` 0 always agrees with + * `pickLayoutIndex`. + */ +export function pickLayoutIndexAt( + tileCounts: number[], + windowCount: number, + offset: number, +): number { + const picked = pickLayoutIndex(tileCounts, windowCount); + if (picked < 0) return -1; + + const group = tileCounts + .map((count, index) => ({ count, index })) + .filter((entry) => entry.count === tileCounts[picked]) + .map((entry) => entry.index); + + const position = group.indexOf(picked); + const wrapped = ((offset % group.length) + group.length) % group.length; + return group[(position + wrapped) % group.length]; +} diff --git a/src/components/tilingsystem/tilingManager.ts b/src/components/tilingsystem/tilingManager.ts index 86075795..c6ab438e 100644 --- a/src/components/tilingsystem/tilingManager.ts +++ b/src/components/tilingsystem/tilingManager.ts @@ -24,7 +24,7 @@ import TileUtils from '../layout/TileUtils'; import { buildLayoutTree } from '../layout/dynamic/layoutTree'; import { assign, neighbourIndex } from '../layout/dynamic/reflow'; import type { Direction } from '../layout/dynamic/reflow'; -import { pickLayoutIndex } from '../layout/dynamic/pickLayout'; +import { pickLayoutIndexAt } from '../layout/dynamic/pickLayout'; import GlobalState from '../../utils/globalState'; import { Monitor } from 'resource:///org/gnome/shell/ui/layout.js'; import ExtendedWindow from './extendedWindow'; @@ -97,6 +97,9 @@ export class TilingManager { // reflow time so it cannot be misapplied to an unrelated workspace. private _splitTarget: Meta.Window | null = null; private _dynamicReflowSourceId: number | null = null; + // How many layouts to step past the default pick, per workspace, within + // whichever tile-count group currently applies. Set by cycleDynamicLayout. + private _dynamicLayoutOffset: Map = new Map(); private readonly _signals: SignalHandling; private readonly _debug: (..._content: unknown[]) => void; @@ -336,6 +339,14 @@ export class TilingManager { ); this._workspaceTilingLayout.clear(); this._workspaceTilingLayout = newMap; + + // drop the cycle offset of whichever workspace was removed, + // rather than hold a reference to a dead one forever + const liveWorkspaces = new Set(newMap.keys()); + [...this._dynamicLayoutOffset.keys()] + .filter((ws) => !liveWorkspaces.has(ws)) + .forEach((ws) => this._dynamicLayoutOffset.delete(ws)); + this._debug('deleted workspace'); }, ); @@ -537,6 +548,7 @@ export class TilingManager { ); this._dynamicWindowSignals.clear(); this._dynamicWindows.length = 0; + this._dynamicLayoutOffset.clear(); this._splitTarget = null; this._signals.disconnect(); this._isGrabbingWindow = false; @@ -1359,7 +1371,7 @@ export class TilingManager { if (from < 0) return false; // not ours: let the static path have it if (windows.length < 2) return true; - const tree = this._dynamicTree(windows.length); + const tree = this._dynamicTree(windows.length, ws); if (!tree) return false; const splitSlot = this._splitTarget @@ -1514,12 +1526,17 @@ export class TilingManager { /** * The split tree dynamic tiling should follow for a given number of - * windows. Layout order is preference: a layout with exactly as many tiles - * as there are windows is used as drawn, otherwise the leftmost roomier - * one is collapsed to fit, otherwise the roomiest is subdivided. Layouts - * with no guillotine decomposition are not candidates at all. + * windows on a given workspace. Layout order is preference: a layout with + * exactly as many tiles as there are windows is used as drawn, otherwise + * the leftmost roomier one is collapsed to fit, otherwise the roomiest is + * subdivided. Layouts with no guillotine decomposition are not candidates + * at all. + * + * That default pick can be stepped away from with cycleDynamicLayout, + * which moves within the group of layouts sharing the same tile count — + * the offset is per workspace and read here. */ - private _dynamicTree(windowCount: number) { + private _dynamicTree(windowCount: number, ws: Meta.Workspace) { const candidates = GlobalState.get() .layouts.map((layout) => ({ tileCount: layout.tiles.length, @@ -1534,13 +1551,34 @@ export class TilingManager { })) .filter((candidate) => candidate.tree !== null); - const index = pickLayoutIndex( + const index = pickLayoutIndexAt( candidates.map((candidate) => candidate.tileCount), windowCount, + this._dynamicLayoutOffset.get(ws) ?? 0, ); return index < 0 ? null : candidates[index].tree; } + /** + * Steps to the next (or, with a negative direction, previous) layout that + * shares the tile count currently in use on the active workspace, and + * reflows immediately. A group of one layout — nothing else the same + * size — is a harmless no-op. + */ + public cycleDynamicLayout(direction: 1 | -1) { + if (!Settings.ENABLE_DYNAMIC_TILING) return; + + const ws = global.workspaceManager.get_active_workspace(); + if (!ws) return; + if (this._dynamicManagedWindows(ws).length === 0) return; + + this._dynamicLayoutOffset.set( + ws, + (this._dynamicLayoutOffset.get(ws) ?? 0) + direction, + ); + this._applyDynamicTiling(); + } + /** * Managed windows currently placeable on this monitor and workspace, in * slot order. Minimized windows stay tracked (see `_isDynamicTrackable`) @@ -1601,7 +1639,7 @@ export class TilingManager { if (windows.length === 0) return; // no decomposable layout at all keeps the static behaviour - const tree = this._dynamicTree(windows.length); + const tree = this._dynamicTree(windows.length, ws); if (!tree) return; // the split target only applies to the workspace it is actually @@ -1634,7 +1672,7 @@ export class TilingManager { const from = windows.indexOf(window); if (from < 0) return false; - const tree = this._dynamicTree(windows.length); + const tree = this._dynamicTree(windows.length, ws); if (!tree) return false; // nothing to exchange with, but the window still belongs in its slot diff --git a/src/extension.ts b/src/extension.ts index eee13a99..7d5b8c32 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -279,6 +279,17 @@ export default class TilingShellExtension extends Extension { if (manager) manager.onSpanAllTiles(window); }, ); + this._signals.connect( + this._keybindings, + 'cycle-dynamic-layout', + (kb: KeyBindings, dp: Meta.Display, direction: number) => { + const window = dp.focus_window; + if (!window) return; + const manager = this._tilingManagers[window.get_monitor()]; + if (manager) + manager.cycleDynamicLayout(direction > 0 ? 1 : -1); + }, + ); this._signals.connect( this._keybindings, 'untile-window', diff --git a/src/keybindings.ts b/src/keybindings.ts index d617c754..f5fa3eae 100644 --- a/src/keybindings.ts +++ b/src/keybindings.ts @@ -56,6 +56,9 @@ export default class KeyBindings extends GObject.Object { GObject.TYPE_INT ], // Meta.Display, action number, mask number }, + 'cycle-dynamic-layout': { + param_types: [Meta.Display.$gtype, GObject.TYPE_INT], // Meta.Display, direction (1 or -1) + }, }, })}; @@ -280,6 +283,26 @@ export default class KeyBindings extends GObject.Object { this._onCycleLayouts(display, event, binding, this._cycleLayoutsBackwardAction!); }, ); + + Main.wm.addKeybinding( + Settings.SETTING_CYCLE_DYNAMIC_LAYOUT, + extensionSettings, + Meta.KeyBindingFlags.NONE, + Shell.ActionMode.NORMAL, + (display: Meta.Display) => { + this.emit('cycle-dynamic-layout', display, 1); + }, + ); + + Main.wm.addKeybinding( + Settings.SETTING_CYCLE_DYNAMIC_LAYOUT_BACKWARD, + extensionSettings, + Meta.KeyBindingFlags.NONE, + Shell.ActionMode.NORMAL, + (display: Meta.Display) => { + this.emit('cycle-dynamic-layout', display, -1); + }, + ); } private _onCycleLayouts(display: Meta.Display, event: Clutter.Event, binding: Meta.KeyBinding, action: number) { @@ -383,6 +406,8 @@ export default class KeyBindings extends GObject.Object { Main.wm.removeKeybinding(Settings.SETTING_HIGHLIGHT_CURRENT_WINDOW); Main.wm.removeKeybinding(Settings.SETTING_CYCLE_LAYOUTS); Main.wm.removeKeybinding(Settings.SETTING_CYCLE_LAYOUTS_BACKWARD); + Main.wm.removeKeybinding(Settings.SETTING_CYCLE_DYNAMIC_LAYOUT); + Main.wm.removeKeybinding(Settings.SETTING_CYCLE_DYNAMIC_LAYOUT_BACKWARD); } private _restoreNatives() { diff --git a/src/settings/settings.ts b/src/settings/settings.ts index ce2f7c74..88fc7f76 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -145,6 +145,9 @@ export default class Settings { static SETTING_HIGHLIGHT_CURRENT_WINDOW = 'highlight-current-window'; static SETTING_CYCLE_LAYOUTS = 'cycle-layouts'; static SETTING_CYCLE_LAYOUTS_BACKWARD = 'cycle-layouts-backward'; + static SETTING_CYCLE_DYNAMIC_LAYOUT = 'cycle-dynamic-layout'; + static SETTING_CYCLE_DYNAMIC_LAYOUT_BACKWARD = + 'cycle-dynamic-layout-backward'; static initialize(settings: Gio.Settings) { if (this._is_initialized) return; From adfda4b160644525f962b6ba8ecf6e0d2b4543af Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 2 Aug 2026 21:27:08 +0530 Subject: [PATCH 19/42] fix(dynamic): reflow on layout edits and work-area changes, scope the cycle offset per group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps found by an independent review of the whole feature as it stands, none of them touched by the two prior rounds. Editing a layout, adding or deleting one, or reordering with the editor's arrows fires GlobalState's layouts-changed signal, which only relayed to the static TilingManager. Nothing told dynamic tiling anything had changed, so a window count that should now pick a different layout, or a layout whose geometry itself changed, sat stale until an unrelated event happened to trigger a reflow. The editor's own reorder arrows are how a user expresses layout preference under dynamic tiling, so this was the most visible of the three. The work area changing — a panel or dock appearing, a monitor resizing — updated the static layout, snap-assist and edge-tiling but left dynamic windows converting their normalized rectangles through a stale work area until, again, an unrelated event forced a reflow. The cycle offset from Super+; was a single number per workspace, applied to whichever tile-count group happened to be current at reflow time. Cycling with N windows open, then changing the window count, silently applied that offset to a different group's layouts — landing on one the user never chose. It cannot corrupt geometry, since pickLayoutIndexAt never leaves the group it was given, but it does silently pick the wrong member of a different group. The offset is now keyed by workspace and by the tile count of the default pick, so a preference set for one group never leaks into another; both _dynamicTree and cycleDynamicLayout now share one _dynamicLayoutCandidates helper so they agree on where those group boundaries are. --- src/components/tilingsystem/tilingManager.ts | 77 +++++++++++++++----- 1 file changed, 59 insertions(+), 18 deletions(-) diff --git a/src/components/tilingsystem/tilingManager.ts b/src/components/tilingsystem/tilingManager.ts index c6ab438e..91c97ea8 100644 --- a/src/components/tilingsystem/tilingManager.ts +++ b/src/components/tilingsystem/tilingManager.ts @@ -24,7 +24,7 @@ import TileUtils from '../layout/TileUtils'; import { buildLayoutTree } from '../layout/dynamic/layoutTree'; import { assign, neighbourIndex } from '../layout/dynamic/reflow'; import type { Direction } from '../layout/dynamic/reflow'; -import { pickLayoutIndexAt } from '../layout/dynamic/pickLayout'; +import { pickLayoutIndex, pickLayoutIndexAt } from '../layout/dynamic/pickLayout'; import GlobalState from '../../utils/globalState'; import { Monitor } from 'resource:///org/gnome/shell/ui/layout.js'; import ExtendedWindow from './extendedWindow'; @@ -97,9 +97,15 @@ export class TilingManager { // reflow time so it cannot be misapplied to an unrelated workspace. private _splitTarget: Meta.Window | null = null; private _dynamicReflowSourceId: number | null = null; - // How many layouts to step past the default pick, per workspace, within - // whichever tile-count group currently applies. Set by cycleDynamicLayout. - private _dynamicLayoutOffset: Map = new Map(); + // How many layouts to step past the default pick, per workspace and per + // tile-count group (outer key: workspace, inner key: the tile count of + // the group's default pick), so an offset set while N windows are open + // cannot leak into the group that applies once the window count changes. + // Set by cycleDynamicLayout. + private _dynamicLayoutOffset: Map< + Meta.Workspace, + Map + > = new Map(); private readonly _signals: SignalHandling; private readonly _debug: (..._content: unknown[]) => void; @@ -212,6 +218,7 @@ export class TilingManager { ws.index(), ); this._workspaceTilingLayout.get(ws)?.relayout({ layout }); + this._queueDynamicReflow(); }, ); @@ -576,6 +583,7 @@ export class TilingManager { ); this._snapAssist.workArea = this._workArea; this._edgeTilingManager.workarea = this._workArea; + this._queueDynamicReflow(); } private _onWindowGrabBegin(window: Meta.Window, grabOp: number) { @@ -1534,10 +1542,36 @@ export class TilingManager { * * That default pick can be stepped away from with cycleDynamicLayout, * which moves within the group of layouts sharing the same tile count — - * the offset is per workspace and read here. + * the offset is per workspace and per tile-count group, and read here. */ private _dynamicTree(windowCount: number, ws: Meta.Workspace) { - const candidates = GlobalState.get() + const candidates = this._dynamicLayoutCandidates(); + const tileCounts = candidates.map((candidate) => candidate.tileCount); + + // The offset only ever applies within the tile-count group of the + // *default* (offset-0) pick for this window count, so find that + // group before looking the offset up — otherwise an offset set while + // a different window count was current would be misapplied here. + const defaultIndex = pickLayoutIndex(tileCounts, windowCount); + const offset = + defaultIndex < 0 + ? 0 + : (this._dynamicLayoutOffset + .get(ws) + ?.get(tileCounts[defaultIndex]) ?? 0); + + const index = pickLayoutIndexAt(tileCounts, windowCount, offset); + return index < 0 ? null : candidates[index].tree; + } + + /** + * Every layout with a valid guillotine decomposition, paired with its + * tile count, in the user's preferred order. Shared by `_dynamicTree` + * and `cycleDynamicLayout` so both agree on what the tile-count groups + * are. + */ + private _dynamicLayoutCandidates() { + return GlobalState.get() .layouts.map((layout) => ({ tileCount: layout.tiles.length, tree: buildLayoutTree( @@ -1550,13 +1584,6 @@ export class TilingManager { ), })) .filter((candidate) => candidate.tree !== null); - - const index = pickLayoutIndexAt( - candidates.map((candidate) => candidate.tileCount), - windowCount, - this._dynamicLayoutOffset.get(ws) ?? 0, - ); - return index < 0 ? null : candidates[index].tree; } /** @@ -1570,12 +1597,26 @@ export class TilingManager { const ws = global.workspaceManager.get_active_workspace(); if (!ws) return; - if (this._dynamicManagedWindows(ws).length === 0) return; - - this._dynamicLayoutOffset.set( - ws, - (this._dynamicLayoutOffset.get(ws) ?? 0) + direction, + const windowCount = this._dynamicManagedWindows(ws).length; + if (windowCount === 0) return; + + // The offset being stepped belongs to whichever tile-count group the + // default (offset-0) pick falls into for the window count currently + // on this workspace, not to the workspace as a whole. + const tileCounts = this._dynamicLayoutCandidates().map( + (candidate) => candidate.tileCount, ); + const defaultIndex = pickLayoutIndex(tileCounts, windowCount); + if (defaultIndex < 0) return; + const tileCount = tileCounts[defaultIndex]; + + const groupOffsets = this._dynamicLayoutOffset.get(ws) ?? new Map(); + groupOffsets.set( + tileCount, + (groupOffsets.get(tileCount) ?? 0) + direction, + ); + this._dynamicLayoutOffset.set(ws, groupOffsets); + this._applyDynamicTiling(); } From b84e72b90b0b4b6d80a4ca1181db6e5edff6f6e5 Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 2 Aug 2026 22:36:11 +0530 Subject: [PATCH 20/42] fix(windowBorder): square corners when smart border radius is off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable-smart-window-border-radius chooses between detecting each window's actual corner radius asynchronously and matching it, versus a fixed fallback — but that fallback was DEFAULT_BORDER_RADIUS (11px) on every corner regardless, so turning smart mode off never actually produced a square border; it just meant "always 11px round" instead of "round dynamically." There was no way to get a square border at all. The non-smart fallback is now 0 on every corner. Smart mode's own transient pre-scan placeholder values (11px on top, 0 on bottom, both overwritten once the async pixel scan completes) are untouched. --- src/components/windowBorder/windowBorder.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/components/windowBorder/windowBorder.ts b/src/components/windowBorder/windowBorder.ts index 209d56fe..c8e7468b 100644 --- a/src/components/windowBorder/windowBorder.ts +++ b/src/components/windowBorder/windowBorder.ts @@ -44,12 +44,17 @@ export default class WindowBorder extends St.DrawingArea { this._windowMonitor = win.get_monitor(); this._enableScaling = enableScaling; this._delayedSmartBorderRadius = false; + // Smart mode detects each window's actual corner radius and matches + // it, computed asynchronously after first paint (see below) — these + // are only the values used before that completes, or permanently + // when smart mode is off, in which case corners are square rather + // than an arbitrary fixed round. const smartRadius = Settings.ENABLE_SMART_WINDOW_BORDER_RADIUS; this._borderRadiusValue = [ - DEFAULT_BORDER_RADIUS, - DEFAULT_BORDER_RADIUS, - smartRadius ? 0 : DEFAULT_BORDER_RADIUS, - smartRadius ? 0 : DEFAULT_BORDER_RADIUS, + smartRadius ? DEFAULT_BORDER_RADIUS : 0, + smartRadius ? DEFAULT_BORDER_RADIUS : 0, + 0, + 0, ]; // default value this.close(); From 7326c20b0fe0a9efe5e7f8cc5a24b200ef15bd6f Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 9 Aug 2026 15:11:18 +0530 Subject: [PATCH 21/42] docs: document dynamic tiling's keyboard shortcuts Super+; layout cycling and the Super+Arrow swap reinterpretation existed in gschema.xml/keybindings.ts but weren't documented anywhere in the README. --- README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/README.md b/README.md index d1ea9e5c..bdf9e118 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,31 @@ > same extension UUID rather than masquerading as a different extension. Licensed > GPLv3, exactly as the original. +## Dynamic Tiling + +The reason this fork exists. Turn it on from the switch at the top of the Tiling +Shell panel menu — it's **off by default**, and with it off this behaves exactly +like upstream Tiling Shell. + +With it on, windows always fill the screen automatically as you open and close +them, following the proportions of whichever layout you've selected: one window +is fullscreen, opening a second splits the space in half, opening a third takes +half of whatever region you're currently focused on, and so on. Closing a window +gives its space back to whatever was sharing it. No manual tiling needed. + +| Shortcut | Action | +|---|---| +| SUPER+←/↑/↓/→ | Swap the focused window with the region next to it | +| SUPER+; | Cycle to the next layout with the same number of tiles | +| SHIFT+SUPER+; | Cycle to the previous layout with the same number of tiles | +| Drag a window onto another | Swap their positions | + +In dynamic mode, SUPER+arrows swaps windows instead of moving to a +static tile, since there's no fixed grid to move to. + +> Multi-monitor isn't fully exercised yet — dragging a tiled window from one +> monitor to another isn't handled specially. + ## ⚠️ Read this before installing This fork keeps Tiling Shell's extension UUID, `tilingshell@ferrarodomenico.com`. That From c4edfc02e38a33a620232bbced67100024bf46dd Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Mon, 10 Aug 2026 16:03:30 +0530 Subject: [PATCH 22/42] fix(dynamic-tiling): untrack windows dragged to another monitor, cache layout candidates Addresses two Copilot review comments on PR #600: - _trackDynamicWindow filtered windows by monitor only at track time, so a window dragged to another monitor after being tracked stayed in _dynamicWindows/_dynamicWindowSignals indefinitely, with its handlers still connected. Now polled on position-changed (matching the existing get_monitor() pattern in windowBorder.ts) and released via _releaseDynamicWindow, which disconnects the window's handlers before dropping it. - _dynamicLayoutCandidates() rebuilt a split tree for every saved layout on every call, and _dynamicTree() called it on every reflow. Now cached and only invalidated on GlobalState.SIGNAL_LAYOUTS_CHANGED. --- src/components/tilingsystem/tilingManager.ts | 47 +++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/src/components/tilingsystem/tilingManager.ts b/src/components/tilingsystem/tilingManager.ts index 91c97ea8..87cdd3e8 100644 --- a/src/components/tilingsystem/tilingManager.ts +++ b/src/components/tilingsystem/tilingManager.ts @@ -21,7 +21,7 @@ import SignalHandling from '../../utils/signalHandling'; import Layout from '../layout/Layout'; import Tile from '../layout/Tile'; import TileUtils from '../layout/TileUtils'; -import { buildLayoutTree } from '../layout/dynamic/layoutTree'; +import { buildLayoutTree, SplitTree } from '../layout/dynamic/layoutTree'; import { assign, neighbourIndex } from '../layout/dynamic/reflow'; import type { Direction } from '../layout/dynamic/reflow'; import { pickLayoutIndex, pickLayoutIndexAt } from '../layout/dynamic/pickLayout'; @@ -97,6 +97,13 @@ export class TilingManager { // reflow time so it cannot be misapplied to an unrelated workspace. private _splitTarget: Meta.Window | null = null; private _dynamicReflowSourceId: number | null = null; + // Cache of _dynamicLayoutCandidates(), rebuilt only when the saved + // layouts change rather than on every reflow. + private _dynamicLayoutCandidatesCache: { + tileCount: number; + tree: SplitTree | null; + }[] | null = null; + // How many layouts to step past the default pick, per workspace and per // tile-count group (outer key: workspace, inner key: the tile count of // the group's default pick), so an offset set while N windows are open @@ -210,6 +217,8 @@ export class TilingManager { GlobalState.get(), GlobalState.SIGNAL_LAYOUTS_CHANGED, () => { + this._dynamicLayoutCandidatesCache = null; + const ws = global.workspaceManager.get_active_workspace(); if (!ws) return; @@ -1449,6 +1458,15 @@ export class TilingManager { window.connect('workspace-changed', () => this._queueDynamicReflow(), ), + // dragging a window onto another monitor is the one way a + // tracked window can leave this manager's domain without being + // destroyed, so it is caught by polling get_monitor() on move + // rather than a notify::monitor signal (which windowBorder.ts + // does not rely on existing either) + window.connect('position-changed', () => { + if (window.get_monitor() !== this._monitor.index) + this._releaseDynamicWindow(window); + }), ]); return true; } @@ -1467,6 +1485,25 @@ export class TilingManager { this._applyDynamicTiling(); } + /** + * Stops managing a window that is still alive, e.g. one dragged onto + * another monitor. Unlike `_untrackDynamicWindow`, this disconnects the + * window's handlers first, since the window survives and would + * otherwise keep firing reflows into a manager that no longer owns it + * — and, if dragged back later, `_trackDynamicWindow` would refuse to + * re-track it while a stale entry lingers. + * + * The window is only dropped here, not adopted by the destination + * monitor's manager; it is picked back up the next time dynamic tiling + * is toggled or that manager re-adopts open windows. + */ + private _releaseDynamicWindow(window: Meta.Window) { + this._dynamicWindowSignals + .get(window) + ?.forEach((id) => window.disconnect(id)); + this._untrackDynamicWindow(window); + } + /** * Takes on every window already open, on every workspace. Needed because * windows are otherwise only picked up as they are created, and the @@ -1571,7 +1608,10 @@ export class TilingManager { * are. */ private _dynamicLayoutCandidates() { - return GlobalState.get() + if (this._dynamicLayoutCandidatesCache !== null) + return this._dynamicLayoutCandidatesCache; + + const candidates = GlobalState.get() .layouts.map((layout) => ({ tileCount: layout.tiles.length, tree: buildLayoutTree( @@ -1584,6 +1624,9 @@ export class TilingManager { ), })) .filter((candidate) => candidate.tree !== null); + + this._dynamicLayoutCandidatesCache = candidates; + return candidates; } /** From a6d5e9c13a6819a2bc0b28a63cbbced112905cd7 Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Mon, 10 Aug 2026 16:03:38 +0530 Subject: [PATCH 23/42] test: run TS tests via tsx instead of Node's native type stripping Addresses a Copilot review comment on PR #600: `node --test` against .test.ts files relied on Node's native TypeScript type stripping with no engines field or loader declared, so npm test would fail on Node versions where that isn't available/unflagged by default (e.g. Node 20 LTS). tsx works consistently regardless of Node version. --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index b5a7ebbc..a0b90d34 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "dev:vm:gnome49": "npm run vm:sync gnome49; vagrant up gnome49", "vm:destroy:gnome49": "vagrant destroy gnome49", "vm:halt:gnome49": "vagrant halt gnome49", - "test": "node --test \"src/**/*.test.ts\"" + "test": "tsx --test \"src/**/*.test.ts\"" }, "devDependencies": { "@babel/generator": "^7.28.3", @@ -52,6 +52,7 @@ "glob": "^11.0.3", "globals": "^16.4.0", "prettier": "^3.7.3", + "tsx": "^4.23.12", "typescript": "^5.9.2" }, "dependencies": { From 4f668c7e05698cd2f5ff39dabc8b301a834e00ce Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 13 Sep 2026 14:30:10 +0530 Subject: [PATCH 24/42] test: mutter simulation harness that reproduces the frozen-actor placement bug Models mutter 50.4's Wayland move/resize (configure dedupe, client ack, MOVED/RESIZED results), MetaWindowActor freeze/geometry sync and size-change accounting, and ports GNOME Shell 50.4's windowManager.js bookkeeping. Characterises _easeWindowRect at HEAD and in the Aug-14 working tree: an identical re-request after the client clamped leaves the actor frozen. --- package.json | 2 +- test/mutter-sim/clock.ts | 50 ++ test/mutter-sim/legacy.test.ts | 99 ++++ test/mutter-sim/legacyPlacers.ts | 70 +++ test/mutter-sim/mutter.ts | 715 ++++++++++++++++++++++++++ test/mutter-sim/shellWindowManager.ts | 185 +++++++ tsconfig.json | 3 +- 7 files changed, 1122 insertions(+), 2 deletions(-) create mode 100644 test/mutter-sim/clock.ts create mode 100644 test/mutter-sim/legacy.test.ts create mode 100644 test/mutter-sim/legacyPlacers.ts create mode 100644 test/mutter-sim/mutter.ts create mode 100644 test/mutter-sim/shellWindowManager.ts diff --git a/package.json b/package.json index a0b90d34..0c643c7e 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "dev:vm:gnome49": "npm run vm:sync gnome49; vagrant up gnome49", "vm:destroy:gnome49": "vagrant destroy gnome49", "vm:halt:gnome49": "vagrant halt gnome49", - "test": "tsx --test \"src/**/*.test.ts\"" + "test": "tsx --test \"src/**/*.test.ts\" \"test/**/*.test.ts\"" }, "devDependencies": { "@babel/generator": "^7.28.3", diff --git a/test/mutter-sim/clock.ts b/test/mutter-sim/clock.ts new file mode 100644 index 00000000..c1a92562 --- /dev/null +++ b/test/mutter-sim/clock.ts @@ -0,0 +1,50 @@ +/** + * Deterministic clock for the mutter simulation: GLib timeouts and Clutter + * transitions both complete from `tick()`, in the order they are due. + */ +export type TimeoutId = number; + +interface Scheduled { + id: TimeoutId; + at: number; + seq: number; + cb: () => void; +} + +export class SimClock { + now = 0; + private _next = 1; + private _seq = 0; + private _scheduled: Scheduled[] = []; + + timeout(ms: number, cb: () => void): TimeoutId { + const id = this._next++; + this._scheduled.push({ id, at: this.now + ms, seq: this._seq++, cb }); + return id; + } + + cancel(id: TimeoutId): boolean { + const before = this._scheduled.length; + this._scheduled = this._scheduled.filter((s) => s.id !== id); + return this._scheduled.length !== before; + } + + get pendingTimeouts(): number { + return this._scheduled.length; + } + + /** Advance time, running everything that becomes due, in due order. */ + tick(ms: number): void { + const until = this.now + ms; + for (;;) { + const due = this._scheduled + .filter((s) => s.at <= until) + .sort((a, b) => a.at - b.at || a.seq - b.seq)[0]; + if (!due) break; + this._scheduled = this._scheduled.filter((s) => s.id !== due.id); + this.now = Math.max(this.now, due.at); + due.cb(); + } + this.now = until; + } +} diff --git a/test/mutter-sim/legacy.test.ts b/test/mutter-sim/legacy.test.ts new file mode 100644 index 00000000..a2ac0f51 --- /dev/null +++ b/test/mutter-sim/legacy.test.ts @@ -0,0 +1,99 @@ +/** + * Characterisation of the bug: the placement code at HEAD (and the Aug-14 + * working tree) freezes the window actor through GNOME Shell's private + * _prepareAnimationInfo and relies on a `size-changed` that Wayland never + * sends when the request is a no-op for mutter. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { SimClock } from './clock.ts'; +import { SimCompositor, SimWindow } from './mutter.ts'; +import { ShellWindowManager } from './shellWindowManager.ts'; +import { easeWindowRectHead, easeWindowRectWorkingTree } from './legacyPlacers.ts'; + +const WORK_AREA = { x: 0, y: 32, width: 1920, height: 1048 }; +const TILE_S = { x: 8, y: 40, width: 600, height: 500 }; + +function fixture() { + const clock = new SimClock(); + const compositor = new SimCompositor(clock, WORK_AREA); + const wm = new ShellWindowManager(compositor, clock); + // Brave/WhatsApp-like client: refuses widths below 700 and heights below 600 + const brave = compositor.createWindow('brave', { x: 8, y: 40, width: 800, height: 600 }, { + kind: 'clampMin', + minWidth: 700, + minHeight: 600, + }); + return { clock, compositor, wm, brave }; +} + +test('HEAD placer: an identical re-request after the client clamped leaves the actor frozen forever', () => { + const { clock, compositor, wm, brave } = fixture(); + const actor = brave.get_compositor_private()!; + + // 1st request: configure is sent, actor frozen while waiting for the ack + easeWindowRectHead(wm, brave, TILE_S); + assert.equal(brave.sentConfigurations.length, 1); + assert.equal(actor.freezeCount, 1); + assert.equal(actor.__animationInfo?.frozen, true); + + // client acks with its clamped size -> mutter emits size-changed -> shell thaws + animates + clock.tick(20); + assert.deepEqual(brave.get_frame_rect(), { x: 8, y: 40, width: 700, height: 600 }); + assert.equal(actor.freezeCount, 0); + clock.tick(300); + assert.equal(actor.__animationInfo, undefined); + assert.equal(compositor.warnings.length, 1, 'one unpaired completed_size_change per placement'); + + // 2nd identical request: frame (700x600) != dest (600x500) so the placer does not early-return + easeWindowRectHead(wm, brave, TILE_S); + assert.equal(brave.sentConfigurations.length, 1, 'mutter dedupes the equivalent configuration'); + assert.equal(actor.freezeCount, 1); + clock.tick(10_000); + assert.equal(actor.__animationInfo?.frozen, true, 'no size-changed ever arrives'); + assert.equal(actor.freezeCount, 1, 'still frozen after 10s'); + + // the app keeps painting but nothing reaches the screen + const shownBefore = actor.visibleFrame; + brave.clientCommitFrame(); + brave.clientCommitFrame(); + assert.equal(actor.visibleFrame, shownBefore, 'frozen actor shows stale content'); + + // a request for a different rect heals it (double prepare thaws + re-freezes, then the ack thaws) + easeWindowRectHead(wm, brave, { ...TILE_S, x: 300 }); + assert.ok(compositor.events.includes('Old animationInfo removed')); + clock.tick(20); + assert.equal(actor.freezeCount, 0); + assert.equal(compositor.warnings.length, 2, 'double prepare paid one completion'); + clock.tick(300); + assert.equal(compositor.warnings.length, 3, 'and the animation end paid another'); +}); + +test('working-tree placer: the alreadyAnimating guard never re-prepares, so the freeze is permanent', () => { + const { clock, compositor, wm, brave } = fixture(); + const actor = brave.get_compositor_private()!; + + easeWindowRectWorkingTree(wm, brave, TILE_S); + clock.tick(320); + easeWindowRectWorkingTree(wm, brave, TILE_S); // deduped -> frozen + clock.tick(10_000); + assert.equal(actor.freezeCount, 1); + + // 3rd identical request: guard skips the prepare, so not even the accidental thaw happens + easeWindowRectWorkingTree(wm, brave, TILE_S); + assert.ok(!compositor.events.includes('Old animationInfo removed')); + clock.tick(10_000); + assert.equal(actor.freezeCount, 1); + assert.equal(actor.__animationInfo?.frozen, true); + // mutter never moved the window either: it sits at the clamped rect, not in the tile, + // and no repaint reaches the screen + assert.deepEqual(brave.get_frame_rect(), { x: 8, y: 40, width: 700, height: 600 }); + const shown = actor.visibleFrame; + brave.clientCommitFrame(); + assert.equal(actor.visibleFrame, shown); + + // a different-position request still heals via MOVED -> size-changed (model honesty check) + easeWindowRectWorkingTree(wm, brave, { ...TILE_S, x: 300 }); + clock.tick(20); + assert.equal(actor.freezeCount, 0); +}); diff --git a/test/mutter-sim/legacyPlacers.ts b/test/mutter-sim/legacyPlacers.ts new file mode 100644 index 00000000..964550bc --- /dev/null +++ b/test/mutter-sim/legacyPlacers.ts @@ -0,0 +1,70 @@ +/** + * Verbatim transcriptions (modulo the sim types) of `_easeWindowRect` as it + * is at HEAD (src/components/tilingsystem/tilingManager.ts, commit a6d5e9c + * L1074-1112) and in the Aug-14 working tree (branch wip/worktree-snapshot, + * L1115-1168). They exist so the harness can characterise the bug; they are + * not used by the extension. + */ +import { Rect, SimWindow, SizeChange } from './mutter.ts'; +import { ShellWindowManager } from './shellWindowManager.ts'; + +export function easeWindowRectHead( + wm: ShellWindowManager, + window: SimWindow, + destRect: Rect, + user_op = false, + force = false, + monitorIndex = 0, +): void { + const windowActor = window.get_compositor_private()!; + + const beforeRect = window.get_frame_rect(); + // do not animate the window if it will not move or scale + if ( + destRect.x === beforeRect.x && + destRect.y === beforeRect.y && + destRect.width === beforeRect.width && + destRect.height === beforeRect.height + ) + return; + + // apply animations when tiling the window + windowActor.remove_all_transitions(); + wm._prepareAnimationInfo(undefined as never, windowActor, { ...beforeRect }, SizeChange.UNMAXIMIZE); + + // move and resize the window to the current selection + window.move_to_monitor(monitorIndex); + if (force) window.move_frame(user_op, destRect.x, destRect.y); + window.move_resize_frame(user_op, destRect.x, destRect.y, destRect.width, destRect.height); +} + +export function easeWindowRectWorkingTree( + wm: ShellWindowManager, + window: SimWindow, + destRect: Rect, + user_op = false, + force = false, + monitorIndex = 0, +): void { + const windowActor = window.get_compositor_private()!; + + const beforeRect = window.get_frame_rect(); + if ( + destRect.x === beforeRect.x && + destRect.y === beforeRect.y && + destRect.width === beforeRect.width && + destRect.height === beforeRect.height + ) + return; + + windowActor.remove_all_transitions(); + + const alreadyAnimating = !!windowActor.__animationInfo; + if (!alreadyAnimating) { + wm._prepareAnimationInfo(undefined as never, windowActor, { ...beforeRect }, SizeChange.UNMAXIMIZE); + } + + window.move_to_monitor(monitorIndex); + if (force) window.move_frame(user_op, destRect.x, destRect.y); + window.move_resize_frame(user_op, destRect.x, destRect.y, destRect.width, destRect.height); +} diff --git a/test/mutter-sim/mutter.ts b/test/mutter-sim/mutter.ts new file mode 100644 index 00000000..96230f48 --- /dev/null +++ b/test/mutter-sim/mutter.ts @@ -0,0 +1,715 @@ +/** + * A deterministic model of the parts of mutter 50.4 that matter for window + * placement on Wayland: + * + * - MetaWindowActor freeze/thaw and geometry sync + * (src/compositor/meta-window-actor.c: meta_window_actor_sync_actor_geometry + * returns POSITION|SIZE *without applying* while frozen; thaw re-syncs) + * - the size-change effect accounting that logs + * "Error in size change accounting." when completed_size_change() is called + * more often than size_change() (meta_window_actor_effect_completed) + * - MetaWindow move/resize on Wayland + * (src/core/window.c meta_window_move_resize_internal + + * src/wayland/meta-window-wayland.c move_resize_internal / should_configure / + * finish_move_resize): a resize is a *request* answered by the client; a + * configuration equivalent to the last one sent is not re-sent; MOVED / + * RESIZED result flags decide whether the compositor syncs geometry, and + * only a geometry sync that reports a SIZE change emits shellwm + * 'size-changed'. + * - a Wayland client with a pluggable ack policy. + */ +import { SimClock, TimeoutId } from './clock.ts'; + +export interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +export const rectEquals = (a: Rect, b: Rect): boolean => + a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; + +const copy = (r: Rect): Rect => ({ ...r }); + +// ---------------------------------------------------------------- signals + +type Handler = (...args: unknown[]) => void; + +export class SignalBus { + private _handlers = new Map(); + private _next = 1; + + connect(name: Name, cb: Handler): number { + const id = this._next++; + this._handlers.set(id, { name, cb }); + return id; + } + + disconnect(id: number): void { + this._handlers.delete(id); + } + + emit(name: Name, ...args: unknown[]): void { + // snapshot: handlers may connect/disconnect while we iterate + for (const { name: n, cb } of [...this._handlers.values()]) + if (n === name) cb(...args); + } +} + +// --------------------------------------------------------------- actors + +export interface EaseParams { + duration: number; + onStopped?: (finished: boolean) => void; + [prop: string]: unknown; +} + +const ANIMATABLE = [ + 'x', + 'y', + 'width', + 'height', + 'scale_x', + 'scale_y', + 'translation_x', + 'translation_y', + 'opacity', +] as const; +type Animatable = (typeof ANIMATABLE)[number]; + +interface Transition { + id: number; + props: Partial>; + timeout: TimeoutId | null; + onStopped?: (finished: boolean) => void; + stopped: boolean; +} + +/** + * Minimal Clutter.Actor: animatable properties, per-ease transitions whose + * `onStopped` fires with `false` when the transition is removed early and + * `true` when it completes (gnome-shell environment.js semantics). + */ +export class SimActor { + x = 0; + y = 0; + width = 0; + height = 0; + scale_x = 1; + scale_y = 1; + translation_x = 0; + translation_y = 0; + opacity = 255; + parent: SimActor | null = null; + children: SimActor[] = []; + destroyed = false; + + private _transitions: Transition[] = []; + private _nextTransition = 1; + private _destroyHandlers: Array<() => void> = []; + + constructor(protected readonly clock: SimClock) {} + + add_child(child: SimActor): void { + if (child.parent !== null) + throw new Error( + "clutter_actor_add_child: assertion 'child->priv->parent == NULL' failed", + ); + child.parent = this; + this.children.push(child); + } + + remove_child(child: SimActor): void { + this.children = this.children.filter((c) => c !== child); + child.parent = null; + } + + set_position(x: number, y: number): void { + this.x = x; + this.y = y; + } + + set_size(width: number, height: number): void { + this.width = width; + this.height = height; + } + + ease(params: EaseParams): void { + const { duration, onStopped, ...rest } = params; + const props: Partial> = {}; + for (const [k, v] of Object.entries(rest)) + if ((ANIMATABLE as readonly string[]).includes(k)) + props[k as Animatable] = v as number; + + // Clutter replaces an in-flight transition on the same property; the + // replaced transition is stopped early (onStopped(false)). + for (const t of [...this._transitions]) + if (Object.keys(props).some((p) => p in t.props)) this._stop(t, false); + + if (duration <= 0) { + Object.assign(this, props); + onStopped?.(true); + return; + } + + const transition: Transition = { + id: this._nextTransition++, + props, + timeout: null, + onStopped, + stopped: false, + }; + transition.timeout = this.clock.timeout(duration, () => { + transition.timeout = null; + Object.assign(this, props); + this._stop(transition, true); + }); + this._transitions.push(transition); + } + + remove_all_transitions(): void { + for (const t of [...this._transitions]) this._stop(t, false); + } + + get_transition(prop: string): Transition | null { + return this._transitions.find((t) => prop in t.props) ?? null; + } + + get hasTransitions(): boolean { + return this._transitions.length > 0; + } + + connect(signal: 'destroy', cb: () => void): number { + if (signal !== 'destroy') throw new Error(`unsupported signal ${signal}`); + this._destroyHandlers.push(cb); + return this._destroyHandlers.length; + } + + /** gnome-shell's connectObject(signal, cb, owner): disconnected when `owner` is destroyed */ + connectObject(signal: 'destroy', cb: () => void, owner: SimActor): void { + this.connect(signal, cb); + owner.connect('destroy', () => { + this._destroyHandlers = this._destroyHandlers.filter((h) => h !== cb); + }); + } + + destroy(): void { + if (this.destroyed) return; + this.destroyed = true; + this.remove_all_transitions(); + this.parent?.remove_child(this); + for (const h of [...this._destroyHandlers]) h(); + this._destroyHandlers = []; + } + + private _stop(t: Transition, finished: boolean): void { + if (t.stopped) return; + t.stopped = true; + if (t.timeout !== null) this.clock.cancel(t.timeout); + this._transitions = this._transitions.filter((x) => x !== t); + t.onStopped?.(finished); + } +} + +export const enum ActorChanges { + NONE = 0, + POSITION = 1, + SIZE = 2, +} + +export class SimContent { + constructor(public readonly rect: Rect) {} +} + +/** MetaWindowActor: freeze/thaw, geometry sync and size-change accounting. */ +export class SimWindowActor extends SimActor { + freezeCount = 0; + sizeChangeInProgress = 0; + /** client frames that reached the screen / were withheld while frozen */ + visibleFrame = 0; + pendingDamage = 0; + mapped = true; + __animationInfo?: { clone: SimActor; oldRect: Rect; frozen: boolean }; + + constructor( + clock: SimClock, + public readonly meta_window: SimWindow, + private readonly compositor: SimCompositor, + ) { + super(clock); + const b = meta_window.get_buffer_rect(); + this.set_position(b.x, b.y); + this.set_size(b.width, b.height); + } + + is_frozen(): boolean { + return this.freezeCount > 0; + } + + freeze(): void { + this.freezeCount++; + } + + thaw(): void { + this.freezeCount--; + if (this.freezeCount < 0) { + this.compositor.warn('Error in freeze/thaw accounting'); + this.freezeCount = 0; + } + if (this.freezeCount > 0) return; + // meta_window_actor_sync_thawed_state: geometry + withheld damage + this.syncActorGeometry(false); + this.visibleFrame += this.pendingDamage; + this.pendingDamage = 0; + } + + /** meta_window_actor_sync_actor_geometry */ + syncActorGeometry(didPlacement: boolean): ActorChanges { + if (this.is_frozen() && !didPlacement) + return ActorChanges.POSITION | ActorChanges.SIZE; + if (this.destroyed) return ActorChanges.NONE; + const b = this.meta_window.get_buffer_rect(); + let changes = ActorChanges.NONE; + if (this.x !== b.x || this.y !== b.y) { + this.set_position(b.x, b.y); + changes |= ActorChanges.POSITION; + } + if (this.width !== b.width || this.height !== b.height) { + this.set_size(b.width, b.height); + changes |= ActorChanges.SIZE; + } + return changes; + } + + processDamage(): void { + if (this.is_frozen()) this.pendingDamage++; + else this.visibleFrame++; + } + + paint_to_content(rect: Rect): SimContent { + return new SimContent(copy(rect)); + } + + get_texture(): boolean { + return this.mapped; + } + + /** meta_window_actor_size_change: counts the in-flight effect, then asks the plugin */ + sizeChange(which: SizeChange, oldFrame: Rect, oldBuffer: Rect): void { + this.sizeChangeInProgress++; + this.compositor.shellwm.emit('size-change', this, which, copy(oldFrame), copy(oldBuffer)); + } + + /** meta_window_actor_effect_completed (META_PLUGIN_SIZE_CHANGE) */ + sizeChangeCompleted(): void { + this.sizeChangeInProgress--; + if (this.sizeChangeInProgress < 0) { + this.compositor.warn('Error in size change accounting.'); + this.sizeChangeInProgress = 0; + } + } +} + +// --------------------------------------------------------------- windows + +export const enum SizeChange { + MAXIMIZE = 0, + UNMAXIMIZE = 1, + FULLSCREEN = 2, + UNFULLSCREEN = 3, + MONITOR_MOVE = 4, +} + +export type AckPolicy = + | { kind: 'comply' } + | { kind: 'clampMin'; minWidth: number; minHeight: number } + | { kind: 'ignore' } + | { kind: 'delayed'; ms: number; then: AckPolicy }; + +export interface Configuration { + serial: number; + x: number; + y: number; + width: number; + height: number; + isResizing: boolean; + maximized: boolean; +} + +const configurationEquivalent = (a: Configuration, b: Configuration | null): boolean => + b !== null && + a.x === b.x && + a.y === b.y && + a.width === b.width && + a.height === b.height && + a.isResizing === b.isResizing && + a.maximized === b.maximized; + +const enum Flags { + NONE = 0, + MOVE_ACTION = 1 << 0, + RESIZE_ACTION = 1 << 1, + STATE_CHANGED = 1 << 2, + FORCE_MOVE = 1 << 3, + FINISH_MOVE_RESIZE = 1 << 4, + UNMAXIMIZE = 1 << 5, +} + +const enum Result { + NONE = 0, + MOVED = 1, + RESIZED = 2, + STATE_CHANGED = 4, +} + +export type WindowSignal = 'size-changed' | 'position-changed' | 'unmanaging' | 'unmanaged'; + +export interface WindowOptions { + /** decorations/shadows around the frame (custom_frame_extents) */ + extents?: { left: number; right: number; top: number; bottom: number }; + /** min size the client advertises through xdg_toplevel.set_min_size */ + minSizeHint?: { width: number; height: number }; + /** how long the client takes to answer a configure (ms) */ + ackDelayMs?: number; + maximized?: boolean; +} + +/** MetaWindow (Wayland) plus the client on the other end of the socket. */ +export class SimWindow { + private _frame: Rect; + private _bufferPos: { x: number; y: number }; + private readonly _extents: NonNullable; + private readonly _minHint: { width: number; height: number }; + private readonly _ackDelay: number; + private _actor: SimWindowActor | null = null; + private _serial = 1; + private _signals = new SignalBus(); + private _savedRect: Rect | null = null; + + maximized: boolean; + unmanaging = false; + monitor = 0; + lastSentConfiguration: Configuration | null = null; + lastAckedConfiguration: Configuration | null = null; + pendingConfigurations: Configuration[] = []; + /** every configure that actually went to the client */ + sentConfigurations: Configuration[] = []; + + constructor( + public readonly id: string, + frame: Rect, + public policy: AckPolicy, + private readonly compositor: SimCompositor, + private readonly clock: SimClock, + opts: WindowOptions = {}, + ) { + this._frame = copy(frame); + this._extents = opts.extents ?? { left: 0, right: 0, top: 0, bottom: 0 }; + this._bufferPos = { x: frame.x - this._extents.left, y: frame.y - this._extents.top }; + this._minHint = opts.minSizeHint ?? { width: 1, height: 1 }; + this._ackDelay = opts.ackDelayMs ?? 16; + this.maximized = opts.maximized ?? false; + if (this.maximized) this._savedRect = copy(frame); + } + + // -- MetaWindow API used by the extension -------------------------------- + + get_frame_rect(): Rect { + return copy(this._frame); + } + + get_buffer_rect(): Rect { + return { + x: this._bufferPos.x, + y: this._bufferPos.y, + width: this._frame.width + this._extents.left + this._extents.right, + height: this._frame.height + this._extents.top + this._extents.bottom, + }; + } + + get_compositor_private(): SimWindowActor | null { + return this._actor; + } + + get_monitor(): number { + return this.monitor; + } + + get maximizedHorizontally(): boolean { + return this.maximized; + } + + get maximizedVertically(): boolean { + return this.maximized; + } + + move_to_monitor(index: number): void { + this.monitor = index; + } + + move_frame(userOp: boolean, x: number, y: number): void { + void userOp; + this._moveResizeInternal(Flags.MOVE_ACTION, { x, y, width: this._frame.width, height: this._frame.height }); + } + + move_resize_frame(userOp: boolean, x: number, y: number, width: number, height: number): void { + void userOp; + this._moveResizeInternal(Flags.MOVE_ACTION | Flags.RESIZE_ACTION, { x, y, width, height }); + } + + /** meta_window_unmaximize: mutter's own size-change effect, then a configure for the saved rect */ + unmaximize(): void { + if (!this.maximized) return; + const oldFrame = this.get_frame_rect(); + const oldBuffer = this.get_buffer_rect(); + this.maximized = false; + const target = this._savedRect ?? oldFrame; + this._actor?.sizeChange(SizeChange.UNMAXIMIZE, oldFrame, oldBuffer); + this._moveResizeInternal( + Flags.MOVE_ACTION | Flags.RESIZE_ACTION | Flags.STATE_CHANGED | Flags.UNMAXIMIZE, + target, + ); + } + + connect(signal: WindowSignal, cb: Handler): number { + return this._signals.connect(signal, cb); + } + + disconnect(id: number): void { + this._signals.disconnect(id); + } + + // -- lifecycle ------------------------------------------------------------- + + /** @internal called by the compositor when the actor is created */ + _attachActor(actor: SimWindowActor): void { + this._actor = actor; + } + + /** meta_window_unmanage: actor goes away first, 'unmanaged' is emitted last */ + unmanage(): void { + this.unmanaging = true; + this._signals.emit('unmanaging', this); + const actor = this._actor; + this._actor = null; // meta_window_actor_queue_destroy clears compositor_private first + actor?.destroy(); + this.compositor._removeWindow(this); + this._signals.emit('unmanaged', this); + } + + // -- the client ------------------------------------------------------------ + + /** the client repaints (wl_surface.commit with damage, same size) */ + clientCommitFrame(): void { + this._actor?.processDamage(); + } + + /** the client resizes itself, unprompted (e.g. Brave growing to 634px) */ + clientResize(width: number, height: number): void { + this._finishMoveResize(null, { width, height }); + } + + /** the client acks the given (or latest pending) configure with a size */ + clientAck(size?: { width: number; height: number }, configuration?: Configuration): void { + const cfg = configuration ?? this.pendingConfigurations[this.pendingConfigurations.length - 1]; + if (!cfg) return; + this.pendingConfigurations = this.pendingConfigurations.filter((c) => c.serial > cfg.serial); + this.lastAckedConfiguration = cfg; + this._finishMoveResize(cfg, size ?? { width: cfg.width, height: cfg.height }); + } + + private _scheduleClientResponse(cfg: Configuration, policy: AckPolicy = this.policy, extraDelay = 0): void { + switch (policy.kind) { + case 'ignore': + return; + case 'delayed': + this._scheduleClientResponse(cfg, policy.then, extraDelay + policy.ms); + return; + case 'comply': + this.clock.timeout(this._ackDelay + extraDelay, () => this.clientAck(undefined, cfg)); + return; + case 'clampMin': { + const size = { + width: Math.max(cfg.width, policy.minWidth), + height: Math.max(cfg.height, policy.minHeight), + }; + this.clock.timeout(this._ackDelay + extraDelay, () => this.clientAck(size, cfg)); + } + } + } + + // -- mutter internals ------------------------------------------------------ + + private _constrain(rect: Rect): Rect { + const wa = this.compositor.workArea; + const r = copy(rect); + // constrain_size_limits (client hints) + r.width = Math.max(r.width, this._minHint.width); + r.height = Math.max(r.height, this._minHint.height); + // constrain_fully_onscreen: shift, never shrink + if (r.x + r.width > wa.x + wa.width) r.x = wa.x + wa.width - r.width; + if (r.y + r.height > wa.y + wa.height) r.y = wa.y + wa.height - r.height; + if (r.x < wa.x) r.x = wa.x; + if (r.y < wa.y) r.y = wa.y; + return r; + } + + /** meta-window-wayland.c should_configure() */ + private _shouldConfigure(constrained: Rect, flags: Flags): boolean { + const last = this.lastSentConfiguration; + if (!last) return true; + if ( + flags & Flags.RESIZE_ACTION && + (constrained.width !== last.width || constrained.height !== last.height) + ) + return true; + if (constrained.width !== this._frame.width || constrained.height !== this._frame.height) + return true; + if (flags & Flags.STATE_CHANGED) return true; + return false; + } + + /** the client committed a buffer: meta_window_wayland_finish_move_resize */ + private _finishMoveResize(cfg: Configuration | null, size: { width: number; height: number }): void { + if (this.unmanaging) return; + const rect: Rect = { + x: cfg ? cfg.x : this._frame.x, + y: cfg ? cfg.y : this._frame.y, + width: size.width, + height: size.height, + }; + let flags = Flags.FINISH_MOVE_RESIZE | Flags.RESIZE_ACTION; + if (cfg) flags |= Flags.MOVE_ACTION; + this._moveResizeInternal(flags, rect); + } + + /** meta_window_move_resize_internal + the Wayland move_resize_internal vfunc */ + private _moveResizeInternal(flags: Flags, unconstrained: Rect): void { + if (this.unmanaging) return; + const constrained = this._constrain(unconstrained); + let result = Result.NONE; + let canMoveNow = false; + const frame = this._frame; + + if (flags & Flags.FORCE_MOVE) { + canMoveNow = true; + } else if (flags & Flags.FINISH_MOVE_RESIZE) { + // the size is whatever the client committed + if (frame.width !== unconstrained.width || frame.height !== unconstrained.height) { + result |= Result.RESIZED; + this._frame.width = unconstrained.width; + this._frame.height = unconstrained.height; + } + canMoveNow = true; + } else if (this._shouldConfigure(constrained, flags)) { + const cfg: Configuration = { + serial: this._serial++, + x: constrained.x, + y: constrained.y, + width: constrained.width, + height: constrained.height, + isResizing: false, + maximized: this.maximized, + }; + if (!configurationEquivalent(cfg, this.lastSentConfiguration)) { + this.lastSentConfiguration = cfg; + this.pendingConfigurations.push(cfg); + this.sentConfigurations.push(cfg); + this._scheduleClientResponse(cfg); + canMoveNow = false; + } + // equivalent configuration: dropped, and can_move_now stays FALSE + } else { + canMoveNow = true; + } + + const newX = canMoveNow ? constrained.x : frame.x; + const newY = canMoveNow ? constrained.y : frame.y; + if (newX !== frame.x || newY !== frame.y) { + result |= Result.MOVED; + this._frame.x = newX; + this._frame.y = newY; + } + const newBufferX = newX - this._extents.left; + const newBufferY = newY - this._extents.top; + if (newBufferX !== this._bufferPos.x || newBufferY !== this._bufferPos.y) { + result |= Result.MOVED; + this._bufferPos = { x: newBufferX, y: newBufferY }; + } + if (canMoveNow && flags & Flags.STATE_CHANGED) result |= Result.STATE_CHANGED; + + // back in meta_window_move_resize_internal + let movedOrResized = false; + if (result & Result.MOVED) { + movedOrResized = true; + this._signals.emit('position-changed', this); + } + if (result & Result.RESIZED) { + movedOrResized = true; + this._signals.emit('size-changed', this); + } + if (movedOrResized || result & Result.STATE_CHANGED) + this.compositor.syncWindowGeometry(this, false); + } +} + +// ------------------------------------------------------------ compositor + +export type ShellWmSignal = 'size-change' | 'size-changed' | 'kill-window-effects'; + +export class SimCompositor { + readonly shellwm = new SignalBus(); + readonly warnings: string[] = []; + readonly events: string[] = []; + readonly windows: SimWindow[] = []; + /** Main.uiGroup */ + readonly uiGroup: SimActor; + + constructor( + public readonly clock: SimClock, + public readonly workArea: Rect, + ) { + this.uiGroup = new SimActor(clock); + } + + createWindow(id: string, frame: Rect, policy: AckPolicy, opts: WindowOptions = {}): SimWindow { + const window = new SimWindow(id, frame, policy, this, this.clock, opts); + const actor = new SimWindowActor(this.clock, window, this); + window._attachActor(actor); + this.windows.push(window); + return window; + } + + warn(message: string): void { + this.warnings.push(message); + this.events.push(message); + } + + log(message: string): void { + this.events.push(message); + } + + /** meta_compositor_sync_window_geometry */ + syncWindowGeometry(window: SimWindow, didPlacement: boolean): void { + const actor = window.get_compositor_private(); + if (!actor) return; + const changes = actor.syncActorGeometry(didPlacement); + if (changes & ActorChanges.SIZE) this.shellwm.emit('size-changed', actor); + } + + /** shellwm.completed_size_change */ + completed_size_change(actor: SimWindowActor): void { + actor.sizeChangeCompleted(); + } + + /** @internal */ + _removeWindow(window: SimWindow): void { + const i = this.windows.indexOf(window); + if (i >= 0) this.windows.splice(i, 1); + } +} + +export function actorRect(a: SimActor): Rect { + return { x: a.x, y: a.y, width: a.width, height: a.height }; +} diff --git a/test/mutter-sim/shellWindowManager.ts b/test/mutter-sim/shellWindowManager.ts new file mode 100644 index 00000000..e7f884b3 --- /dev/null +++ b/test/mutter-sim/shellWindowManager.ts @@ -0,0 +1,185 @@ +/** + * Port of the size-change animation bookkeeping in GNOME Shell 50.4 + * js/ui/windowManager.js (extract with + * `gresource extract /usr/lib64/gnome-shell/libshell-18.so /org/gnome/shell/ui/windowManager.js`): + * ctor state L505-509 _resizing / _resizePending / _skippedActors + * kill-window-effects L517-522 + * signal connections L530-531 + * skipNextEffect L1097-1099 + * _shouldAnimateActor L1142-1155 + * _sizeChangeWindow L1293-1304 + * _prepareAnimationInfo L1306-1331 + * _sizeChangedWindow L1333-1389 + * _clearAnimationInfo L1391-1401 + * _sizeChangeWindowDone L1403-1417 + * Kept as literal as the model allows so the harness fails when the shell + * contract changes rather than when our reading of it does. + */ +import { SimClock } from './clock.ts'; +import { Rect, SimActor, SimCompositor, SimWindowActor, SizeChange } from './mutter.ts'; + +export const WINDOW_ANIMATION_TIME = 250; + +export class ShellWindowManager { + readonly _resizing = new Set(); + readonly _resizePending = new Set(); + readonly _skippedActors = new Set(); + private readonly _shellwm: SimCompositor; + + constructor( + compositor: SimCompositor, + private readonly clock: SimClock, + ) { + this._shellwm = compositor; + compositor.shellwm.connect('kill-window-effects', (actor) => { + this._sizeChangeWindowDone(this._shellwm, actor as SimWindowActor); + }); + compositor.shellwm.connect('size-change', (actor, which, oldFrame, oldBuffer) => + this._sizeChangeWindow( + this._shellwm, + actor as SimWindowActor, + which as SizeChange, + oldFrame as Rect, + oldBuffer as Rect, + ), + ); + compositor.shellwm.connect('size-changed', (actor) => + this._sizeChangedWindow(this._shellwm, actor as SimWindowActor), + ); + } + + skipNextEffect(actor: SimWindowActor): void { + this._skippedActors.add(actor); + } + + _shouldAnimateActor(actor: SimWindowActor, _types: unknown[]): boolean { + if (this._skippedActors.delete(actor)) return false; + if (!actor.get_texture()) return false; + return true; // window type NORMAL, no overview/gesture in the model + } + + _sizeChangeWindow( + shellwm: SimCompositor, + actor: SimWindowActor, + whichChange: SizeChange, + oldFrameRect: Rect, + _oldBufferRect: Rect, + ): void { + const shouldAnimate = + this._shouldAnimateActor(actor, []) && oldFrameRect.width > 0 && oldFrameRect.height > 0; + if (shouldAnimate) this._prepareAnimationInfo(shellwm, actor, oldFrameRect, whichChange); + else shellwm.completed_size_change(actor); + } + + _prepareAnimationInfo( + _shellwm: SimCompositor, + actor: SimWindowActor, + oldFrameRect: Rect, + _change: SizeChange, + ): void { + // Position a clone of the window on top of the old position, + // while actor updates are frozen. + actor.paint_to_content(oldFrameRect); + const actorClone = new SimActor(this.clock); + actorClone.set_position(oldFrameRect.x, oldFrameRect.y); + actorClone.set_size(oldFrameRect.width, oldFrameRect.height); + + actor.freeze(); + + if (this._clearAnimationInfo(actor)) { + this._shellwm.log('Old animationInfo removed'); + this._shellwm.completed_size_change(actor); + } + + actor.connectObject('destroy', () => this._clearAnimationInfo(actor), actorClone); + + this._resizePending.add(actor); + actor.__animationInfo = { + clone: actorClone, + oldRect: oldFrameRect, + frozen: true, + }; + } + + _sizeChangedWindow(shellwm: SimCompositor, actor: SimWindowActor): void { + if (!actor.__animationInfo) return; + if (this._resizing.has(actor)) return; + + const actorClone = actor.__animationInfo.clone; + const targetRect = actor.meta_window.get_frame_rect(); + const sourceRect = actor.__animationInfo.oldRect; + + const scaleX = targetRect.width / sourceRect.width; + const scaleY = targetRect.height / sourceRect.height; + + this._resizePending.delete(actor); + this._resizing.add(actor); + + try { + this._shellwm.uiGroup.add_child(actorClone); + } catch (e) { + this._shellwm.warn((e as Error).message); + } + + // Now scale and fade out the clone + actorClone.ease({ + x: targetRect.x, + y: targetRect.y, + scale_x: scaleX, + scale_y: scaleY, + opacity: 0, + duration: WINDOW_ANIMATION_TIME, + }); + + actor.translation_x = -targetRect.x + sourceRect.x; + actor.translation_y = -targetRect.y + sourceRect.y; + + // Now set scale the actor to size it as the clone. + actor.scale_x = 1 / scaleX; + actor.scale_y = 1 / scaleY; + + // Scale it to its actual new size + actor.ease({ + scale_x: 1, + scale_y: 1, + translation_x: 0, + translation_y: 0, + duration: WINDOW_ANIMATION_TIME, + onStopped: () => this._sizeChangeWindowDone(shellwm, actor), + }); + + // ease didn't animate and cleared the info, we are done + if (!actor.__animationInfo) return; + + // Now unfreeze actor updates, to get it to the new size. + actor.thaw(); + actor.__animationInfo.frozen = false; + } + + _clearAnimationInfo(actor: SimWindowActor): boolean { + if (actor.__animationInfo) { + actor.__animationInfo.clone.destroy(); + if (actor.__animationInfo.frozen) actor.thaw(); + delete actor.__animationInfo; + return true; + } + return false; + } + + _sizeChangeWindowDone(_shellwm: SimCompositor, actor: SimWindowActor): void { + if (this._resizing.delete(actor)) { + actor.remove_all_transitions(); + actor.scale_x = 1.0; + actor.scale_y = 1.0; + actor.translation_x = 0; + actor.translation_y = 0; + this._clearAnimationInfo(actor); + this._shellwm.completed_size_change(actor); + } + + if (this._resizePending.delete(actor)) { + this._clearAnimationInfo(actor); + this._shellwm.completed_size_change(actor); + } + } +} diff --git a/tsconfig.json b/tsconfig.json index 579566f8..3b835bbc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,8 @@ }, "include": [ "ambient.d.ts", - "src/**/*.ts" + "src/**/*.ts", + "test/**/*.ts" ], "exclude": ["node_modules", "*.d.ts" ] } From 607db619e48bc0f040fd630d7e1c5a46a3407100 Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 13 Sep 2026 14:32:47 +0530 Subject: [PATCH 25/42] feat(tiling): non-freezing window placer and reflow scheduler WindowPlacer replaces the Main.wm._prepareAnimationInfo dance: it never freezes the actor, remembers the last request and the frame the client settled on so a refused rect is not re-requested (mutter drops equivalent configures and never emits size-changed for them), and animates by easing the real actor's transform once mutter reports the new geometry. ReflowScheduler defers a reflow requested from inside a running reflow to one idle follow-up. Both are pure and covered by the mutter simulation. --- .../tilingsystem/reflowScheduler.test.ts | 51 +++ .../tilingsystem/reflowScheduler.ts | 36 ++ .../tilingsystem/windowPlacer.test.ts | 98 ++++++ src/components/tilingsystem/windowPlacer.ts | 314 ++++++++++++++++++ test/mutter-sim/legacy.test.ts | 69 +++- test/mutter-sim/legacyPlacers.ts | 30 +- test/mutter-sim/mutter.ts | 148 +++++++-- test/mutter-sim/placement.test.ts | 272 +++++++++++++++ test/mutter-sim/shellWindowManager.ts | 49 ++- test/mutter-sim/simTarget.ts | 76 +++++ 10 files changed, 1081 insertions(+), 62 deletions(-) create mode 100644 src/components/tilingsystem/reflowScheduler.test.ts create mode 100644 src/components/tilingsystem/reflowScheduler.ts create mode 100644 src/components/tilingsystem/windowPlacer.test.ts create mode 100644 src/components/tilingsystem/windowPlacer.ts create mode 100644 test/mutter-sim/placement.test.ts create mode 100644 test/mutter-sim/simTarget.ts diff --git a/src/components/tilingsystem/reflowScheduler.test.ts b/src/components/tilingsystem/reflowScheduler.test.ts new file mode 100644 index 00000000..cd3d620f --- /dev/null +++ b/src/components/tilingsystem/reflowScheduler.test.ts @@ -0,0 +1,51 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ReflowScheduler } from './reflowScheduler.ts'; + +test('runs the reflow synchronously when idle', () => { + let idleQueued = 0; + const s = new ReflowScheduler(() => idleQueued++); + let ran = 0; + assert.equal( + s.run(() => ran++), + 'ran', + ); + assert.equal(ran, 1); + assert.equal(idleQueued, 0); + assert.equal(s.isRunning, false); +}); + +test('(vii) a nested request during a reflow is deferred to one idle follow-up', () => { + let idleQueued = 0; + const s = new ReflowScheduler(() => idleQueued++); + const placed: string[] = []; + const nested: string[] = []; + s.run(() => { + for (const w of ['A', 'B', 'C']) { + placed.push(w); + if (w === 'B') { + // B's position-changed handler releases it and asks for a reflow + nested.push(s.run(() => placed.push('nested'))); + nested.push(s.run(() => placed.push('nested'))); + } + } + }); + assert.deepEqual(placed, ['A', 'B', 'C'], 'outer pass completes untouched'); + assert.deepEqual(nested, ['deferred', 'deferred']); + assert.equal(idleQueued, 1, 'exactly one follow-up'); + assert.equal(s.isRunning, false); +}); + +test('a throwing reflow leaves the scheduler usable', () => { + const s = new ReflowScheduler(() => {}); + assert.throws(() => + s.run(() => { + throw new Error('boom'); + }), + ); + assert.equal(s.isRunning, false); + assert.equal( + s.run(() => {}), + 'ran', + ); +}); diff --git a/src/components/tilingsystem/reflowScheduler.ts b/src/components/tilingsystem/reflowScheduler.ts new file mode 100644 index 00000000..8029f155 --- /dev/null +++ b/src/components/tilingsystem/reflowScheduler.ts @@ -0,0 +1,36 @@ +/** + * Guards a reflow against re-entrancy. Placing windows emits signals + * synchronously (`position-changed`, `size-changed`, `unmanaged`) whose + * handlers may ask for another reflow while the current one is still + * iterating over its snapshot of windows and rects. Running that nested + * reflow immediately would place windows twice in one tick with two + * different answers; instead it is deferred to one idle follow-up. + */ +export class ReflowScheduler { + private _running = false; + private _pending = false; + + constructor(private readonly _queueIdle: () => void) {} + + get isRunning(): boolean { + return this._running; + } + + run(reflow: () => void): 'ran' | 'deferred' { + if (this._running) { + this._pending = true; + return 'deferred'; + } + this._running = true; + try { + reflow(); + } finally { + this._running = false; + } + if (this._pending) { + this._pending = false; + this._queueIdle(); + } + return 'ran'; + } +} diff --git a/src/components/tilingsystem/windowPlacer.test.ts b/src/components/tilingsystem/windowPlacer.test.ts new file mode 100644 index 00000000..a3378ce4 --- /dev/null +++ b/src/components/tilingsystem/windowPlacer.test.ts @@ -0,0 +1,98 @@ +/** Pure policy table for WindowPlacer.place() decisions, no simulation. */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + PlacementTarget, + PlacerClock, + Rect, + WindowPlacer, +} from './windowPlacer.ts'; + +const clock: PlacerClock = { timeout: () => 0, cancel: () => {} }; + +function fakeTarget(frame: Rect, alive = true) { + const calls: string[] = []; + let current = { ...frame }; + const target: PlacementTarget = { + key: {}, + isAlive: () => alive, + getFrameRect: () => ({ ...current }), + moveToMonitor: (i) => calls.push(`monitor:${i}`), + moveFrame: (_u, x, y) => calls.push(`move:${x},${y}`), + moveResizeFrame: (_u, r) => + calls.push(`moveResize:${r.x},${r.y},${r.width},${r.height}`), + getActor: () => null, + onGeometryChanged: () => () => {}, + onGone: () => () => {}, + }; + return { + target, + calls, + setFrame: (r: Rect) => { + current = { ...r }; + }, + }; +} + +const DEST: Rect = { x: 10, y: 10, width: 500, height: 400 }; + +test('dead target -> skipped-dead, nothing called', () => { + const { target, calls } = fakeTarget(DEST, false); + assert.equal(new WindowPlacer(clock).place(target, DEST), 'skipped-dead'); + assert.deepEqual(calls, []); +}); + +test('frame already equals dest -> skipped-identical', () => { + const { target, calls } = fakeTarget(DEST); + assert.equal( + new WindowPlacer(clock).place(target, DEST), + 'skipped-identical', + ); + assert.deepEqual(calls, []); +}); + +test('first request -> move_to_monitor + move_resize_frame; forceMove adds move_frame', () => { + const { target, calls } = fakeTarget({ + x: 0, + y: 0, + width: 100, + height: 100, + }); + assert.equal( + new WindowPlacer(clock, { monitorIndex: 2 }).place(target, DEST, { + forceMove: true, + }), + 'requested', + ); + assert.deepEqual(calls, [ + 'monitor:2', + 'move:10,10', + 'moveResize:10,10,500,400', + ]); +}); + +test('after a settle, the same dest is skipped while the frame is unchanged and re-requested once it moved', () => { + const { target, calls, setFrame } = fakeTarget({ + x: 0, + y: 0, + width: 100, + height: 100, + }); + // a synchronous clock: every request settles immediately (as if the client never answered) + const placer = new WindowPlacer({ + timeout: (_ms, cb) => { + cb(); + return 0; + }, + cancel: () => {}, + }); + assert.equal(placer.place(target, DEST), 'requested'); + assert.equal(placer.place(target, DEST), 'skipped-settled'); + setFrame({ x: 10, y: 10, width: 700, height: 400 }); // the client answered late, clamping width + assert.equal( + placer.place(target, DEST), + 'requested', + 'the frame changed since the settle', + ); + assert.equal(calls.filter((c) => c.startsWith('moveResize')).length, 2); +}); diff --git a/src/components/tilingsystem/windowPlacer.ts b/src/components/tilingsystem/windowPlacer.ts new file mode 100644 index 00000000..ab8b32ee --- /dev/null +++ b/src/components/tilingsystem/windowPlacer.ts @@ -0,0 +1,314 @@ +/** + * Placement policy for tiled windows, kept free of GNOME imports so it can be + * driven both by the extension (through `metaPlacementTarget.ts`) and by the + * mutter simulation under `test/mutter-sim`. + * + * Why this exists: on Wayland, `Meta.Window.move_resize_frame()` is a request. + * mutter sends the client a configure and applies whatever size the client + * answers with; a client is free to clamp (Brave, WhatsApp) or to ignore it, + * and mutter does not even re-send a configure equivalent to the last one. + * The previous implementation froze the window actor through GNOME Shell's + * private `_prepareAnimationInfo` and waited for a `size-changed` that in + * those cases never comes, leaving the actor frozen at its old geometry. + * + * This placer never freezes anything. It remembers, per window, the last rect + * it asked for and the frame the client settled on in answer, so a request the + * client already refused is not repeated, and it animates by transforming the + * real actor from its previous geometry once mutter reports the new one. + */ + +export interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +export const rectEquals = (a: Rect, b: Rect): boolean => + a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; + +const sizeEquals = (a: Rect, b: Rect): boolean => + a.width === b.width && a.height === b.height; + +const copyRect = (r: Rect): Rect => ({ + x: r.x, + y: r.y, + width: r.width, + height: r.height, +}); + +/** The bits of the window actor the placer animates. */ +export interface PlacementActor { + setTransform(scaleX: number, scaleY: number, tx: number, ty: number): void; + easeToIdentity( + durationMs: number, + onStopped: (finished: boolean) => void, + ): void; + cancelTransitions(): void; +} + +/** The bits of Meta.Window the placer needs. */ +export interface PlacementTarget { + /** identity of the underlying window, used as a WeakMap key */ + readonly key: object; + /** false once the compositor actor is gone (before/after unmanage) */ + isAlive(): boolean; + getFrameRect(): Rect; + moveToMonitor(index: number): void; + moveFrame(userOp: boolean, x: number, y: number): void; + moveResizeFrame(userOp: boolean, rect: Rect): void; + getActor(): PlacementActor | null; + /** size-changed + position-changed; returns the disconnect function */ + onGeometryChanged(cb: () => void): () => void; + /** unmanaging; returns the disconnect function */ + onGone(cb: () => void): () => void; +} + +export interface PlacerClock { + timeout(ms: number, cb: () => void): unknown; + cancel(handle: unknown): void; +} + +export interface PlaceOptions { + userOp?: boolean; + /** also call move_frame first (GNOME 42 restart-grab path) */ + forceMove?: boolean; + animate?: boolean; +} + +export type PlaceResult = + | 'requested' + | 'requested-move-only' + | 'skipped-identical' + | 'skipped-settled' + | 'skipped-dead' + | 'coalesced'; + +export interface PlacerOptions { + monitorIndex: number; + /** give up waiting for the client after this long */ + settleTimeoutMs: number; + /** after a geometry change that is not yet the target, wait this long for more */ + quietMs: number; + animationMs: number; +} + +interface Pending { + dest: Rect; + before: Rect; + animate: boolean; + disconnectGeometry: () => void; + disconnectGone: () => void; + timeout: unknown; + quiet: unknown; +} + +interface History { + lastRequested?: Rect; + settledFrame?: Rect; + pending?: Pending; +} + +export class WindowPlacer { + private readonly _opts: PlacerOptions; + private readonly _history = new WeakMap(); + private readonly _inFlight = new Set(); + + /** Called once a request settled, with what was asked and what the client gave. */ + public onSettled?: ( + target: PlacementTarget, + requested: Rect, + actual: Rect, + ) => void; + + constructor( + private readonly _clock: PlacerClock, + opts: Partial = {}, + ) { + this._opts = { + monitorIndex: 0, + settleTimeoutMs: 300, + quietMs: 40, + animationMs: 250, + ...opts, + }; + } + + public place( + target: PlacementTarget, + dest: Rect, + options: PlaceOptions = {}, + ): PlaceResult { + if (!target.isAlive()) return 'skipped-dead'; + + const hist = this._historyOf(target); + const frame = target.getFrameRect(); + + if (rectEquals(frame, dest)) { + hist.lastRequested = copyRect(dest); + hist.settledFrame = copyRect(frame); + return 'skipped-identical'; + } + + if (hist.pending && rectEquals(hist.pending.dest, dest)) + return 'coalesced'; + + const sameAsLast = + hist.lastRequested !== undefined && + rectEquals(hist.lastRequested, dest) && + hist.settledFrame !== undefined; + if (sameAsLast && rectEquals(frame, hist.settledFrame!)) { + // the client already answered exactly this request with this + // frame and nothing moved it since; mutter would drop the + // configure anyway (meta-window-wayland.c: equivalent + // configurations are not re-sent), so do not wait on it + return 'skipped-settled'; + } + + const userOp = options.userOp ?? false; + if ( + sameAsLast && + sizeEquals(frame, hist.settledFrame!) && + (frame.x !== dest.x || frame.y !== dest.y) + ) { + // the client refused this size before but the window has been + // moved off its tile: ask for the position back at the size the + // client accepted. That is a new configuration, so it is sent. + const request = { + x: dest.x, + y: dest.y, + width: frame.width, + height: frame.height, + }; + this._request(target, hist, dest, request, frame, options, userOp); + return 'requested-move-only'; + } + + this._request(target, hist, dest, dest, frame, options, userOp); + return 'requested'; + } + + /** Drop everything known about a window (it is no longer managed). */ + public forget(target: PlacementTarget): void { + const hist = this._history.get(target.key); + if (hist?.pending) this._cancelPending(hist); + this._history.delete(target.key); + this._inFlight.delete(target); + } + + public destroy(): void { + for (const target of [...this._inFlight]) this.forget(target); + this.onSettled = undefined; + } + + private _request( + target: PlacementTarget, + hist: History, + dest: Rect, + request: Rect, + before: Rect, + options: PlaceOptions, + userOp: boolean, + ): void { + // a newer request supersedes the previous one, but the animation + // still starts from where the window was before the first of them + const earlierBefore = hist.pending?.before; + if (hist.pending) this._cancelPending(hist); + target.getActor()?.cancelTransitions(); + + const pending: Pending = { + dest: copyRect(dest), + before: copyRect(earlierBefore ?? before), + animate: options.animate ?? false, + disconnectGeometry: () => {}, + disconnectGone: () => {}, + timeout: null, + quiet: null, + }; + hist.pending = pending; + this._inFlight.add(target); + + pending.disconnectGeometry = target.onGeometryChanged(() => { + if (hist.pending !== pending) return; + if (rectEquals(target.getFrameRect(), pending.dest)) { + this._settle(target, hist, pending); + return; + } + // the client answered with something else (or only moved so + // far): give it a moment to finish before judging + if (pending.quiet !== null) this._clock.cancel(pending.quiet); + pending.quiet = this._clock.timeout(this._opts.quietMs, () => { + pending.quiet = null; + this._settle(target, hist, pending); + }); + }); + pending.disconnectGone = target.onGone(() => { + if (hist.pending === pending) this.forget(target); + }); + pending.timeout = this._clock.timeout( + this._opts.settleTimeoutMs, + () => { + pending.timeout = null; + if (hist.pending === pending) + this._settle(target, hist, pending); + }, + ); + + target.moveToMonitor(this._opts.monitorIndex); + if (options.forceMove) target.moveFrame(userOp, request.x, request.y); + target.moveResizeFrame(userOp, request); + } + + private _settle( + target: PlacementTarget, + hist: History, + pending: Pending, + ): void { + this._cancelPending(hist); + this._inFlight.delete(target); + if (!target.isAlive()) return; + + const frame = target.getFrameRect(); + hist.lastRequested = copyRect(pending.dest); + hist.settledFrame = copyRect(frame); + + if (pending.animate && !rectEquals(pending.before, frame)) { + const actor = target.getActor(); + if (actor) { + const before = pending.before; + actor.setTransform( + before.width / frame.width, + before.height / frame.height, + before.x - frame.x, + before.y - frame.y, + ); + actor.easeToIdentity(this._opts.animationMs, () => { + // whether it finished or was cancelled, never leave a + // transform behind + actor.setTransform(1, 1, 0, 0); + }); + } + } + + this.onSettled?.(target, pending.dest, frame); + } + + private _cancelPending(hist: History): void { + const p = hist.pending; + if (!p) return; + hist.pending = undefined; + p.disconnectGeometry(); + p.disconnectGone(); + if (p.timeout !== null) this._clock.cancel(p.timeout); + if (p.quiet !== null) this._clock.cancel(p.quiet); + } + + private _historyOf(target: PlacementTarget): History { + let hist = this._history.get(target.key); + if (!hist) { + hist = {}; + this._history.set(target.key, hist); + } + return hist; + } +} diff --git a/test/mutter-sim/legacy.test.ts b/test/mutter-sim/legacy.test.ts index a2ac0f51..2c68222f 100644 --- a/test/mutter-sim/legacy.test.ts +++ b/test/mutter-sim/legacy.test.ts @@ -9,7 +9,10 @@ import assert from 'node:assert/strict'; import { SimClock } from './clock.ts'; import { SimCompositor, SimWindow } from './mutter.ts'; import { ShellWindowManager } from './shellWindowManager.ts'; -import { easeWindowRectHead, easeWindowRectWorkingTree } from './legacyPlacers.ts'; +import { + easeWindowRectHead, + easeWindowRectWorkingTree, +} from './legacyPlacers.ts'; const WORK_AREA = { x: 0, y: 32, width: 1920, height: 1048 }; const TILE_S = { x: 8, y: 40, width: 600, height: 500 }; @@ -19,11 +22,15 @@ function fixture() { const compositor = new SimCompositor(clock, WORK_AREA); const wm = new ShellWindowManager(compositor, clock); // Brave/WhatsApp-like client: refuses widths below 700 and heights below 600 - const brave = compositor.createWindow('brave', { x: 8, y: 40, width: 800, height: 600 }, { - kind: 'clampMin', - minWidth: 700, - minHeight: 600, - }); + const brave = compositor.createWindow( + 'brave', + { x: 8, y: 40, width: 800, height: 600 }, + { + kind: 'clampMin', + minWidth: 700, + minHeight: 600, + }, + ); return { clock, compositor, wm, brave }; } @@ -39,34 +46,63 @@ test('HEAD placer: an identical re-request after the client clamped leaves the a // client acks with its clamped size -> mutter emits size-changed -> shell thaws + animates clock.tick(20); - assert.deepEqual(brave.get_frame_rect(), { x: 8, y: 40, width: 700, height: 600 }); + assert.deepEqual(brave.get_frame_rect(), { + x: 8, + y: 40, + width: 700, + height: 600, + }); assert.equal(actor.freezeCount, 0); clock.tick(300); assert.equal(actor.__animationInfo, undefined); - assert.equal(compositor.warnings.length, 1, 'one unpaired completed_size_change per placement'); + assert.equal( + compositor.warnings.length, + 1, + 'one unpaired completed_size_change per placement', + ); // 2nd identical request: frame (700x600) != dest (600x500) so the placer does not early-return easeWindowRectHead(wm, brave, TILE_S); - assert.equal(brave.sentConfigurations.length, 1, 'mutter dedupes the equivalent configuration'); + assert.equal( + brave.sentConfigurations.length, + 1, + 'mutter dedupes the equivalent configuration', + ); assert.equal(actor.freezeCount, 1); clock.tick(10_000); - assert.equal(actor.__animationInfo?.frozen, true, 'no size-changed ever arrives'); + assert.equal( + actor.__animationInfo?.frozen, + true, + 'no size-changed ever arrives', + ); assert.equal(actor.freezeCount, 1, 'still frozen after 10s'); // the app keeps painting but nothing reaches the screen const shownBefore = actor.visibleFrame; brave.clientCommitFrame(); brave.clientCommitFrame(); - assert.equal(actor.visibleFrame, shownBefore, 'frozen actor shows stale content'); + assert.equal( + actor.visibleFrame, + shownBefore, + 'frozen actor shows stale content', + ); // a request for a different rect heals it (double prepare thaws + re-freezes, then the ack thaws) easeWindowRectHead(wm, brave, { ...TILE_S, x: 300 }); assert.ok(compositor.events.includes('Old animationInfo removed')); clock.tick(20); assert.equal(actor.freezeCount, 0); - assert.equal(compositor.warnings.length, 2, 'double prepare paid one completion'); + assert.equal( + compositor.warnings.length, + 2, + 'double prepare paid one completion', + ); clock.tick(300); - assert.equal(compositor.warnings.length, 3, 'and the animation end paid another'); + assert.equal( + compositor.warnings.length, + 3, + 'and the animation end paid another', + ); }); test('working-tree placer: the alreadyAnimating guard never re-prepares, so the freeze is permanent', () => { @@ -87,7 +123,12 @@ test('working-tree placer: the alreadyAnimating guard never re-prepares, so the assert.equal(actor.__animationInfo?.frozen, true); // mutter never moved the window either: it sits at the clamped rect, not in the tile, // and no repaint reaches the screen - assert.deepEqual(brave.get_frame_rect(), { x: 8, y: 40, width: 700, height: 600 }); + assert.deepEqual(brave.get_frame_rect(), { + x: 8, + y: 40, + width: 700, + height: 600, + }); const shown = actor.visibleFrame; brave.clientCommitFrame(); assert.equal(actor.visibleFrame, shown); diff --git a/test/mutter-sim/legacyPlacers.ts b/test/mutter-sim/legacyPlacers.ts index 964550bc..d84db6bf 100644 --- a/test/mutter-sim/legacyPlacers.ts +++ b/test/mutter-sim/legacyPlacers.ts @@ -30,12 +30,23 @@ export function easeWindowRectHead( // apply animations when tiling the window windowActor.remove_all_transitions(); - wm._prepareAnimationInfo(undefined as never, windowActor, { ...beforeRect }, SizeChange.UNMAXIMIZE); + wm._prepareAnimationInfo( + undefined as never, + windowActor, + { ...beforeRect }, + SizeChange.UNMAXIMIZE, + ); // move and resize the window to the current selection window.move_to_monitor(monitorIndex); if (force) window.move_frame(user_op, destRect.x, destRect.y); - window.move_resize_frame(user_op, destRect.x, destRect.y, destRect.width, destRect.height); + window.move_resize_frame( + user_op, + destRect.x, + destRect.y, + destRect.width, + destRect.height, + ); } export function easeWindowRectWorkingTree( @@ -61,10 +72,21 @@ export function easeWindowRectWorkingTree( const alreadyAnimating = !!windowActor.__animationInfo; if (!alreadyAnimating) { - wm._prepareAnimationInfo(undefined as never, windowActor, { ...beforeRect }, SizeChange.UNMAXIMIZE); + wm._prepareAnimationInfo( + undefined as never, + windowActor, + { ...beforeRect }, + SizeChange.UNMAXIMIZE, + ); } window.move_to_monitor(monitorIndex); if (force) window.move_frame(user_op, destRect.x, destRect.y); - window.move_resize_frame(user_op, destRect.x, destRect.y, destRect.width, destRect.height); + window.move_resize_frame( + user_op, + destRect.x, + destRect.y, + destRect.width, + destRect.height, + ); } diff --git a/test/mutter-sim/mutter.ts b/test/mutter-sim/mutter.ts index 96230f48..4fa11d80 100644 --- a/test/mutter-sim/mutter.ts +++ b/test/mutter-sim/mutter.ts @@ -145,7 +145,8 @@ export class SimActor { // Clutter replaces an in-flight transition on the same property; the // replaced transition is stopped early (onStopped(false)). for (const t of [...this._transitions]) - if (Object.keys(props).some((p) => p in t.props)) this._stop(t, false); + if (Object.keys(props).some((p) => p in t.props)) + this._stop(t, false); if (duration <= 0) { Object.assign(this, props); @@ -181,7 +182,8 @@ export class SimActor { } connect(signal: 'destroy', cb: () => void): number { - if (signal !== 'destroy') throw new Error(`unsupported signal ${signal}`); + if (signal !== 'destroy') + throw new Error(`unsupported signal ${signal}`); this._destroyHandlers.push(cb); return this._destroyHandlers.length; } @@ -190,7 +192,9 @@ export class SimActor { connectObject(signal: 'destroy', cb: () => void, owner: SimActor): void { this.connect(signal, cb); owner.connect('destroy', () => { - this._destroyHandlers = this._destroyHandlers.filter((h) => h !== cb); + this._destroyHandlers = this._destroyHandlers.filter( + (h) => h !== cb, + ); }); } @@ -298,7 +302,13 @@ export class SimWindowActor extends SimActor { /** meta_window_actor_size_change: counts the in-flight effect, then asks the plugin */ sizeChange(which: SizeChange, oldFrame: Rect, oldBuffer: Rect): void { this.sizeChangeInProgress++; - this.compositor.shellwm.emit('size-change', this, which, copy(oldFrame), copy(oldBuffer)); + this.compositor.shellwm.emit( + 'size-change', + this, + which, + copy(oldFrame), + copy(oldBuffer), + ); } /** meta_window_actor_effect_completed (META_PLUGIN_SIZE_CHANGE) */ @@ -337,7 +347,10 @@ export interface Configuration { maximized: boolean; } -const configurationEquivalent = (a: Configuration, b: Configuration | null): boolean => +const configurationEquivalent = ( + a: Configuration, + b: Configuration | null, +): boolean => b !== null && a.x === b.x && a.y === b.y && @@ -363,7 +376,8 @@ const enum Result { STATE_CHANGED = 4, } -export type WindowSignal = 'size-changed' | 'position-changed' | 'unmanaging' | 'unmanaged'; +export type WindowSignal = + 'size-changed' | 'position-changed' | 'unmanaging' | 'unmanaged'; export interface WindowOptions { /** decorations/shadows around the frame (custom_frame_extents) */ @@ -405,8 +419,16 @@ export class SimWindow { opts: WindowOptions = {}, ) { this._frame = copy(frame); - this._extents = opts.extents ?? { left: 0, right: 0, top: 0, bottom: 0 }; - this._bufferPos = { x: frame.x - this._extents.left, y: frame.y - this._extents.top }; + this._extents = opts.extents ?? { + left: 0, + right: 0, + top: 0, + bottom: 0, + }; + this._bufferPos = { + x: frame.x - this._extents.left, + y: frame.y - this._extents.top, + }; this._minHint = opts.minSizeHint ?? { width: 1, height: 1 }; this._ackDelay = opts.ackDelayMs ?? 16; this.maximized = opts.maximized ?? false; @@ -424,7 +446,8 @@ export class SimWindow { x: this._bufferPos.x, y: this._bufferPos.y, width: this._frame.width + this._extents.left + this._extents.right, - height: this._frame.height + this._extents.top + this._extents.bottom, + height: + this._frame.height + this._extents.top + this._extents.bottom, }; } @@ -450,12 +473,28 @@ export class SimWindow { move_frame(userOp: boolean, x: number, y: number): void { void userOp; - this._moveResizeInternal(Flags.MOVE_ACTION, { x, y, width: this._frame.width, height: this._frame.height }); + this._moveResizeInternal(Flags.MOVE_ACTION, { + x, + y, + width: this._frame.width, + height: this._frame.height, + }); } - move_resize_frame(userOp: boolean, x: number, y: number, width: number, height: number): void { + move_resize_frame( + userOp: boolean, + x: number, + y: number, + width: number, + height: number, + ): void { void userOp; - this._moveResizeInternal(Flags.MOVE_ACTION | Flags.RESIZE_ACTION, { x, y, width, height }); + this._moveResizeInternal(Flags.MOVE_ACTION | Flags.RESIZE_ACTION, { + x, + y, + width, + height, + }); } /** meta_window_unmaximize: mutter's own size-change effect, then a configure for the saved rect */ @@ -467,7 +506,10 @@ export class SimWindow { const target = this._savedRect ?? oldFrame; this._actor?.sizeChange(SizeChange.UNMAXIMIZE, oldFrame, oldBuffer); this._moveResizeInternal( - Flags.MOVE_ACTION | Flags.RESIZE_ACTION | Flags.STATE_CHANGED | Flags.UNMAXIMIZE, + Flags.MOVE_ACTION | + Flags.RESIZE_ACTION | + Flags.STATE_CHANGED | + Flags.UNMAXIMIZE, target, ); } @@ -511,30 +553,52 @@ export class SimWindow { } /** the client acks the given (or latest pending) configure with a size */ - clientAck(size?: { width: number; height: number }, configuration?: Configuration): void { - const cfg = configuration ?? this.pendingConfigurations[this.pendingConfigurations.length - 1]; + clientAck( + size?: { width: number; height: number }, + configuration?: Configuration, + ): void { + const cfg = + configuration ?? + this.pendingConfigurations[this.pendingConfigurations.length - 1]; if (!cfg) return; - this.pendingConfigurations = this.pendingConfigurations.filter((c) => c.serial > cfg.serial); + this.pendingConfigurations = this.pendingConfigurations.filter( + (c) => c.serial > cfg.serial, + ); this.lastAckedConfiguration = cfg; - this._finishMoveResize(cfg, size ?? { width: cfg.width, height: cfg.height }); + this._finishMoveResize( + cfg, + size ?? { width: cfg.width, height: cfg.height }, + ); } - private _scheduleClientResponse(cfg: Configuration, policy: AckPolicy = this.policy, extraDelay = 0): void { + private _scheduleClientResponse( + cfg: Configuration, + policy: AckPolicy = this.policy, + extraDelay = 0, + ): void { switch (policy.kind) { case 'ignore': return; case 'delayed': - this._scheduleClientResponse(cfg, policy.then, extraDelay + policy.ms); + this._scheduleClientResponse( + cfg, + policy.then, + extraDelay + policy.ms, + ); return; case 'comply': - this.clock.timeout(this._ackDelay + extraDelay, () => this.clientAck(undefined, cfg)); + this.clock.timeout(this._ackDelay + extraDelay, () => + this.clientAck(undefined, cfg), + ); return; case 'clampMin': { const size = { width: Math.max(cfg.width, policy.minWidth), height: Math.max(cfg.height, policy.minHeight), }; - this.clock.timeout(this._ackDelay + extraDelay, () => this.clientAck(size, cfg)); + this.clock.timeout(this._ackDelay + extraDelay, () => + this.clientAck(size, cfg), + ); } } } @@ -549,7 +613,8 @@ export class SimWindow { r.height = Math.max(r.height, this._minHint.height); // constrain_fully_onscreen: shift, never shrink if (r.x + r.width > wa.x + wa.width) r.x = wa.x + wa.width - r.width; - if (r.y + r.height > wa.y + wa.height) r.y = wa.y + wa.height - r.height; + if (r.y + r.height > wa.y + wa.height) + r.y = wa.y + wa.height - r.height; if (r.x < wa.x) r.x = wa.x; if (r.y < wa.y) r.y = wa.y; return r; @@ -561,17 +626,24 @@ export class SimWindow { if (!last) return true; if ( flags & Flags.RESIZE_ACTION && - (constrained.width !== last.width || constrained.height !== last.height) + (constrained.width !== last.width || + constrained.height !== last.height) ) return true; - if (constrained.width !== this._frame.width || constrained.height !== this._frame.height) + if ( + constrained.width !== this._frame.width || + constrained.height !== this._frame.height + ) return true; if (flags & Flags.STATE_CHANGED) return true; return false; } /** the client committed a buffer: meta_window_wayland_finish_move_resize */ - private _finishMoveResize(cfg: Configuration | null, size: { width: number; height: number }): void { + private _finishMoveResize( + cfg: Configuration | null, + size: { width: number; height: number }, + ): void { if (this.unmanaging) return; const rect: Rect = { x: cfg ? cfg.x : this._frame.x, @@ -596,7 +668,10 @@ export class SimWindow { canMoveNow = true; } else if (flags & Flags.FINISH_MOVE_RESIZE) { // the size is whatever the client committed - if (frame.width !== unconstrained.width || frame.height !== unconstrained.height) { + if ( + frame.width !== unconstrained.width || + frame.height !== unconstrained.height + ) { result |= Result.RESIZED; this._frame.width = unconstrained.width; this._frame.height = unconstrained.height; @@ -633,11 +708,15 @@ export class SimWindow { } const newBufferX = newX - this._extents.left; const newBufferY = newY - this._extents.top; - if (newBufferX !== this._bufferPos.x || newBufferY !== this._bufferPos.y) { + if ( + newBufferX !== this._bufferPos.x || + newBufferY !== this._bufferPos.y + ) { result |= Result.MOVED; this._bufferPos = { x: newBufferX, y: newBufferY }; } - if (canMoveNow && flags & Flags.STATE_CHANGED) result |= Result.STATE_CHANGED; + if (canMoveNow && flags & Flags.STATE_CHANGED) + result |= Result.STATE_CHANGED; // back in meta_window_move_resize_internal let movedOrResized = false; @@ -656,7 +735,8 @@ export class SimWindow { // ------------------------------------------------------------ compositor -export type ShellWmSignal = 'size-change' | 'size-changed' | 'kill-window-effects'; +export type ShellWmSignal = + 'size-change' | 'size-changed' | 'kill-window-effects'; export class SimCompositor { readonly shellwm = new SignalBus(); @@ -673,7 +753,12 @@ export class SimCompositor { this.uiGroup = new SimActor(clock); } - createWindow(id: string, frame: Rect, policy: AckPolicy, opts: WindowOptions = {}): SimWindow { + createWindow( + id: string, + frame: Rect, + policy: AckPolicy, + opts: WindowOptions = {}, + ): SimWindow { const window = new SimWindow(id, frame, policy, this, this.clock, opts); const actor = new SimWindowActor(this.clock, window, this); window._attachActor(actor); @@ -695,7 +780,8 @@ export class SimCompositor { const actor = window.get_compositor_private(); if (!actor) return; const changes = actor.syncActorGeometry(didPlacement); - if (changes & ActorChanges.SIZE) this.shellwm.emit('size-changed', actor); + if (changes & ActorChanges.SIZE) + this.shellwm.emit('size-changed', actor); } /** shellwm.completed_size_change */ diff --git a/test/mutter-sim/placement.test.ts b/test/mutter-sim/placement.test.ts new file mode 100644 index 00000000..81a12fc6 --- /dev/null +++ b/test/mutter-sim/placement.test.ts @@ -0,0 +1,272 @@ +/** + * The new placement policy against the mutter simulation. Every scenario + * asserts the invariants the legacy placer violates: the actor is never + * frozen, its geometry always matches the window, and mutter's size-change + * accounting is never touched. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { SimClock } from './clock.ts'; +import { + AckPolicy, + Rect, + SimCompositor, + SimWindow, + SimWindowActor, + actorRect, +} from './mutter.ts'; +import { ShellWindowManager } from './shellWindowManager.ts'; +import { simClock, simTargetFor } from './simTarget.ts'; +import { WindowPlacer } from '../../src/components/tilingsystem/windowPlacer.ts'; + +const WORK_AREA: Rect = { x: 0, y: 32, width: 1920, height: 1048 }; +const TILE_S: Rect = { x: 8, y: 40, width: 600, height: 500 }; + +function fixture( + policy: AckPolicy = { kind: 'comply' }, + frame: Rect = { x: 8, y: 40, width: 800, height: 600 }, +) { + const clock = new SimClock(); + const compositor = new SimCompositor(clock, WORK_AREA); + const wm = new ShellWindowManager(compositor, clock); + const window = compositor.createWindow('w', frame, policy); + const placer = new WindowPlacer(simClock(clock), { monitorIndex: 0 }); + return { clock, compositor, wm, window, placer }; +} + +function assertHealthy( + actor: SimWindowActor, + window: SimWindow, + compositor: SimCompositor, + wm: ShellWindowManager, +) { + assert.equal(actor.freezeCount, 0, 'never frozen'); + assert.equal(actor.__animationInfo, undefined, 'no shell animation info'); + assert.equal(wm._resizePending.size, 0); + assert.equal( + compositor.warnings.length, + 0, + `no warnings: ${compositor.warnings.join(', ')}`, + ); + assert.deepEqual( + actorRect(actor), + window.get_buffer_rect(), + 'actor tracks the window', + ); +} + +test('(iii) clamping client: repeated identical requests never freeze and never re-request', () => { + const { clock, compositor, wm, window, placer } = fixture({ + kind: 'clampMin', + minWidth: 700, + minHeight: 600, + }); + const actor = window.get_compositor_private()!; + const target = simTargetFor(window); + + assert.equal(placer.place(target, TILE_S, { animate: true }), 'requested'); + assert.equal(window.sentConfigurations.length, 1); + clock.tick(20); // client acks with 700x600 + assert.deepEqual(window.get_frame_rect(), { + x: 8, + y: 40, + width: 700, + height: 600, + }); + clock.tick(300); // our animation settles + assertHealthy(actor, window, compositor, wm); + assert.equal(actor.scale_x, 1); + + // identical request twice: the client already gave its answer to exactly this rect + assert.equal( + placer.place(target, TILE_S, { animate: true }), + 'skipped-settled', + ); + assert.equal( + placer.place(target, TILE_S, { animate: true }), + 'skipped-settled', + ); + assert.equal(window.sentConfigurations.length, 1, 'nothing re-sent'); + clock.tick(10_000); + assertHealthy(actor, window, compositor, wm); + + // the user drags it away, then the tile is asked for again: only the position is off, + // so request a move at the size the client accepted (a new configuration, not deduped) + window.move_frame(true, 300, 300); + assert.deepEqual(window.get_frame_rect(), { + x: 300, + y: 300, + width: 700, + height: 600, + }); + assert.equal( + placer.place(target, TILE_S, { animate: true }), + 'requested-move-only', + ); + clock.tick(20); + assert.deepEqual(window.get_frame_rect(), { + x: 8, + y: 40, + width: 700, + height: 600, + }); + clock.tick(300); + assertHealthy(actor, window, compositor, wm); + assert.equal(clock.pendingTimeouts, 0, 'no leaked timeouts'); +}); + +test('(iv) rapid successive requests before any ack: one animation, from the first rect to the last', () => { + const { clock, compositor, wm, window, placer } = fixture({ + kind: 'delayed', + ms: 50, + then: { kind: 'comply' }, + }); + const actor = window.get_compositor_private()!; + const target = simTargetFor(window); + const r1 = { x: 8, y: 40, width: 600, height: 500 }; + const r2 = { x: 8, y: 40, width: 900, height: 500 }; + const r3 = { x: 700, y: 40, width: 900, height: 1000 }; + + assert.equal(placer.place(target, r1, { animate: true }), 'requested'); + assert.equal(placer.place(target, r2, { animate: true }), 'requested'); + assert.equal(placer.place(target, r3, { animate: true }), 'requested'); + assert.equal(actor.freezeCount, 0); + + clock.tick(200); // all acks in + assert.deepEqual(window.get_frame_rect(), r3); + // the animation starts from the pre-r1 rect (800x600) toward r3 + assert.ok( + actor.scale_x !== 1 || actor.translation_x !== 0, + 'animation in flight', + ); + assert.deepEqual( + actorRect(actor), + window.get_buffer_rect(), + 'geometry already synced, only a transform', + ); + clock.tick(300); + assertHealthy(actor, window, compositor, wm); + assert.equal(actor.scale_x, 1); + assert.equal(actor.translation_x, 0); + assert.equal(clock.pendingTimeouts, 0); +}); + +test('(v) a window whose actor is gone is skipped, not thrown on', () => { + const { clock, compositor, placer } = fixture(); + const a = compositor.createWindow( + 'a', + { x: 0, y: 32, width: 300, height: 300 }, + { kind: 'comply' }, + ); + const b = compositor.createWindow( + 'b', + { x: 0, y: 32, width: 300, height: 300 }, + { kind: 'comply' }, + ); + const c = compositor.createWindow( + 'c', + { x: 0, y: 32, width: 300, height: 300 }, + { kind: 'comply' }, + ); + b.unmanage(); + const results = [a, b, c].map((w, i) => + placer.place(simTargetFor(w), { + x: i * 640, + y: 40, + width: 600, + height: 1000, + }), + ); + assert.deepEqual(results, ['requested', 'skipped-dead', 'requested']); + clock.tick(400); + assert.deepEqual(a.get_frame_rect(), { + x: 0, + y: 40, + width: 600, + height: 1000, + }); + assert.deepEqual(c.get_frame_rect(), { + x: 1280, + y: 40, + width: 600, + height: 1000, + }); +}); + +test('(vi) maximized window: mutter animates the unmaximize, we only place afterwards', () => { + const { clock, compositor, wm, window, placer } = fixture( + { kind: 'comply' }, + { x: 0, y: 32, width: 1920, height: 1048 }, + ); + window.maximized = true; + const actor = window.get_compositor_private()!; + const target = simTargetFor(window); + + // what _easeWindowRectFromTile will do for a maximized window + wm.skipNextEffect(actor); + window.unmaximize(); + assert.equal( + actor.sizeChangeInProgress, + 0, + 'skipNextEffect completed the effect immediately', + ); + assert.equal(placer.place(target, TILE_S, { animate: true }), 'requested'); + clock.tick(400); + assert.deepEqual(window.get_frame_rect(), TILE_S); + assertHealthy(actor, window, compositor, wm); +}); + +test('(viii) a client that never acks: the request settles by timeout and is not repeated', () => { + const { clock, compositor, wm, window, placer } = fixture({ + kind: 'ignore', + }); + const actor = window.get_compositor_private()!; + const target = simTargetFor(window); + + assert.equal(placer.place(target, TILE_S, { animate: true }), 'requested'); + assert.equal(placer.place(target, TILE_S, { animate: true }), 'coalesced'); + clock.tick(400); + assert.equal( + actor.scale_x, + 1, + 'no animation for a rect that never changed', + ); + assertHealthy(actor, window, compositor, wm); + assert.equal(clock.pendingTimeouts, 0); + assert.equal( + placer.place(target, TILE_S, { animate: true }), + 'skipped-settled', + ); + assert.equal(window.sentConfigurations.length, 1); +}); + +test('(ix) a window that is unmanaged mid-request cleans up without animating', () => { + const { clock, window, placer } = fixture({ + kind: 'delayed', + ms: 100, + then: { kind: 'comply' }, + }); + const target = simTargetFor(window); + assert.equal(placer.place(target, TILE_S, { animate: true }), 'requested'); + window.unmanage(); + clock.tick(1000); + assert.equal(clock.pendingTimeouts, 0); + assert.equal(placer.place(target, TILE_S), 'skipped-dead'); +}); + +test('(x) placer.destroy() cancels everything in flight', () => { + const { clock, window, placer } = fixture({ + kind: 'delayed', + ms: 100, + then: { kind: 'comply' }, + }); + placer.place(simTargetFor(window), TILE_S, { animate: true }); + placer.destroy(); + assert.equal( + clock.pendingTimeouts, + 1, + 'only the client ack remains scheduled', + ); + clock.tick(1000); + assert.equal(window.get_compositor_private()!.scale_x, 1); +}); diff --git a/test/mutter-sim/shellWindowManager.ts b/test/mutter-sim/shellWindowManager.ts index e7f884b3..400c5266 100644 --- a/test/mutter-sim/shellWindowManager.ts +++ b/test/mutter-sim/shellWindowManager.ts @@ -16,7 +16,13 @@ * contract changes rather than when our reading of it does. */ import { SimClock } from './clock.ts'; -import { Rect, SimActor, SimCompositor, SimWindowActor, SizeChange } from './mutter.ts'; +import { + Rect, + SimActor, + SimCompositor, + SimWindowActor, + SizeChange, +} from './mutter.ts'; export const WINDOW_ANIMATION_TIME = 250; @@ -34,14 +40,16 @@ export class ShellWindowManager { compositor.shellwm.connect('kill-window-effects', (actor) => { this._sizeChangeWindowDone(this._shellwm, actor as SimWindowActor); }); - compositor.shellwm.connect('size-change', (actor, which, oldFrame, oldBuffer) => - this._sizeChangeWindow( - this._shellwm, - actor as SimWindowActor, - which as SizeChange, - oldFrame as Rect, - oldBuffer as Rect, - ), + compositor.shellwm.connect( + 'size-change', + (actor, which, oldFrame, oldBuffer) => + this._sizeChangeWindow( + this._shellwm, + actor as SimWindowActor, + which as SizeChange, + oldFrame as Rect, + oldBuffer as Rect, + ), ); compositor.shellwm.connect('size-changed', (actor) => this._sizeChangedWindow(this._shellwm, actor as SimWindowActor), @@ -66,8 +74,16 @@ export class ShellWindowManager { _oldBufferRect: Rect, ): void { const shouldAnimate = - this._shouldAnimateActor(actor, []) && oldFrameRect.width > 0 && oldFrameRect.height > 0; - if (shouldAnimate) this._prepareAnimationInfo(shellwm, actor, oldFrameRect, whichChange); + this._shouldAnimateActor(actor, []) && + oldFrameRect.width > 0 && + oldFrameRect.height > 0; + if (shouldAnimate) + this._prepareAnimationInfo( + shellwm, + actor, + oldFrameRect, + whichChange, + ); else shellwm.completed_size_change(actor); } @@ -91,7 +107,11 @@ export class ShellWindowManager { this._shellwm.completed_size_change(actor); } - actor.connectObject('destroy', () => this._clearAnimationInfo(actor), actorClone); + actor.connectObject( + 'destroy', + () => this._clearAnimationInfo(actor), + actorClone, + ); this._resizePending.add(actor); actor.__animationInfo = { @@ -166,7 +186,10 @@ export class ShellWindowManager { return false; } - _sizeChangeWindowDone(_shellwm: SimCompositor, actor: SimWindowActor): void { + _sizeChangeWindowDone( + _shellwm: SimCompositor, + actor: SimWindowActor, + ): void { if (this._resizing.delete(actor)) { actor.remove_all_transitions(); actor.scale_x = 1.0; diff --git a/test/mutter-sim/simTarget.ts b/test/mutter-sim/simTarget.ts new file mode 100644 index 00000000..69866a8b --- /dev/null +++ b/test/mutter-sim/simTarget.ts @@ -0,0 +1,76 @@ +/** PlacementTarget adapter over the simulated MetaWindow, mirroring metaPlacementTarget.ts. */ +import type { + PlacementActor, + PlacementTarget, + PlacerClock, + Rect, +} from '../../src/components/tilingsystem/windowPlacer.ts'; +import { SimClock } from './clock.ts'; +import { SimWindow, SimWindowActor } from './mutter.ts'; + +const targets = new WeakMap(); + +export function simClock(clock: SimClock): PlacerClock { + return { + timeout: (ms, cb) => clock.timeout(ms, cb), + cancel: (h) => { + clock.cancel(h as number); + }, + }; +} + +function actorFor(actor: SimWindowActor): PlacementActor { + return { + setTransform(scaleX, scaleY, tx, ty) { + actor.scale_x = scaleX; + actor.scale_y = scaleY; + actor.translation_x = tx; + actor.translation_y = ty; + }, + easeToIdentity(durationMs, onStopped) { + actor.ease({ + scale_x: 1, + scale_y: 1, + translation_x: 0, + translation_y: 0, + duration: durationMs, + onStopped, + }); + }, + cancelTransitions() { + actor.remove_all_transitions(); + }, + }; +} + +export function simTargetFor(window: SimWindow): PlacementTarget { + let t = targets.get(window); + if (t) return t; + t = { + key: window, + isAlive: () => window.get_compositor_private() !== null, + getFrameRect: () => window.get_frame_rect(), + moveToMonitor: (i) => window.move_to_monitor(i), + moveFrame: (userOp, x, y) => window.move_frame(userOp, x, y), + moveResizeFrame: (userOp, r) => + window.move_resize_frame(userOp, r.x, r.y, r.width, r.height), + getActor: () => { + const a = window.get_compositor_private(); + return a ? actorFor(a) : null; + }, + onGeometryChanged: (cb) => { + const a = window.connect('size-changed', cb); + const b = window.connect('position-changed', cb); + return () => { + window.disconnect(a); + window.disconnect(b); + }; + }, + onGone: (cb) => { + const id = window.connect('unmanaging', cb); + return () => window.disconnect(id); + }, + }; + targets.set(window, t); + return t; +} From 4167f148b785243f28bb0c21fb28b24be42f462a Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 13 Sep 2026 14:35:58 +0530 Subject: [PATCH 26/42] fix(tiling): place windows through WindowPlacer, never freeze the actor _easeWindowRect froze the window actor via GNOME Shell's private _prepareAnimationInfo and waited for a size-changed that Wayland does not send when the client already refused the size and mutter drops the equivalent configure: the actor stayed frozen at its old geometry, visibly outside its tile. Every such placement also paid an unpaired completed_size_change ('Error in size change accounting.'). Route all placements through WindowPlacer; skip mutter's own unmaximize effect before tiling a maximized window; treat a window without a compositor actor as not placeable (a grab ending during unmanage crashed the reflow loop with 'windowActor is null'); run the reflow under ReflowScheduler and queue the event-driven callers so a reflow can no longer re-enter itself from a synchronous position-changed. --- .../tilingsystem/metaPlacementTarget.ts | 87 +++++++++++++ src/components/tilingsystem/tilingManager.ts | 119 ++++++++++-------- src/utils/ui.ts | 9 ++ 3 files changed, 166 insertions(+), 49 deletions(-) create mode 100644 src/components/tilingsystem/metaPlacementTarget.ts diff --git a/src/components/tilingsystem/metaPlacementTarget.ts b/src/components/tilingsystem/metaPlacementTarget.ts new file mode 100644 index 00000000..156f8523 --- /dev/null +++ b/src/components/tilingsystem/metaPlacementTarget.ts @@ -0,0 +1,87 @@ +import { Clutter, GLib, Meta, Mtk } from '../../gi/ext'; +import type { + PlacementActor, + PlacementTarget, + PlacerClock, + Rect, +} from './windowPlacer'; + +/** + * Adapts a Meta.Window to the WindowPlacer's PlacementTarget. One adapter per + * window, cached, so the placer's per-window history keys stay stable. + */ +const targets = new WeakMap(); + +export const gjsPlacerClock: PlacerClock = { + timeout: (ms, cb) => + GLib.timeout_add(GLib.PRIORITY_DEFAULT, ms, () => { + cb(); + return GLib.SOURCE_REMOVE; + }), + cancel: (handle) => { + GLib.Source.remove(handle as number); + }, +}; + +export function toRect(r: Mtk.Rectangle): Rect { + return { x: r.x, y: r.y, width: r.width, height: r.height }; +} + +function actorFor(actor: Meta.WindowActor): PlacementActor { + return { + setTransform(scaleX, scaleY, tx, ty) { + actor.scale_x = scaleX; + actor.scale_y = scaleY; + actor.translation_x = tx; + actor.translation_y = ty; + }, + easeToIdentity(durationMs, onStopped) { + actor.ease({ + scaleX: 1, + scaleY: 1, + translationX: 0, + translationY: 0, + duration: durationMs, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onStopped, + }); + }, + cancelTransitions() { + actor.remove_all_transitions(); + }, + }; +} + +export function placementTargetFor(window: Meta.Window): PlacementTarget { + let target = targets.get(window); + if (target) return target; + + target = { + key: window, + isAlive: () => window.get_compositor_private() !== null, + getFrameRect: () => toRect(window.get_frame_rect()), + moveToMonitor: (index) => window.move_to_monitor(index), + moveFrame: (userOp, x, y) => window.move_frame(userOp, x, y), + moveResizeFrame: (userOp, r) => + window.move_resize_frame(userOp, r.x, r.y, r.width, r.height), + getActor: () => { + const actor = + window.get_compositor_private() as Meta.WindowActor | null; + return actor ? actorFor(actor) : null; + }, + onGeometryChanged: (cb) => { + const a = window.connect('size-changed', cb); + const b = window.connect('position-changed', cb); + return () => { + window.disconnect(a); + window.disconnect(b); + }; + }, + onGone: (cb) => { + const id = window.connect('unmanaging', cb); + return () => window.disconnect(id); + }, + }; + targets.set(window, target); + return target; +} diff --git a/src/components/tilingsystem/tilingManager.ts b/src/components/tilingsystem/tilingManager.ts index 87cdd3e8..bb5ef14d 100644 --- a/src/components/tilingsystem/tilingManager.ts +++ b/src/components/tilingsystem/tilingManager.ts @@ -10,6 +10,7 @@ import { getWindows, isPointInsideRect, isTileOnContainerBorder, + isWindowAlive, squaredEuclideanDistance, } from '../../utils/ui'; import TilingLayout from '../../components/tilingsystem/tilingLayout'; @@ -34,6 +35,13 @@ import { KeyBindingsDirection } from '../../keybindings'; import TilingShellWindowManager from '../../components/windowManager/tilingShellWindowManager'; import TilingLayoutWithSuggestions from '../windowsSuggestions/tilingLayoutWithSuggestions'; import { maximizeWindow, unmaximizeWindow } from '../../utils/gnomesupport'; +import { WindowPlacer } from './windowPlacer'; +import { ReflowScheduler } from './reflowScheduler'; +import { + gjsPlacerClock, + placementTargetFor, + toRect, +} from './metaPlacementTarget'; const MINIMUM_DISTANCE_TO_RESTORE_ORIGINAL_SIZE = 90; @@ -114,6 +122,12 @@ export class TilingManager { Map > = new Map(); + // Places windows without freezing their actors, and remembers what each + // client answered so a refused rect is not asked for again. + private readonly _placer: WindowPlacer; + // Defers a reflow requested from inside a running reflow. + private readonly _reflow: ReflowScheduler; + private readonly _signals: SignalHandling; private readonly _debug: (..._content: unknown[]) => void; @@ -129,6 +143,10 @@ export class TilingManager { this._enableScaling = enableScaling; this._monitor = monitor; this._signals = new SignalHandling(); + this._placer = new WindowPlacer(gjsPlacerClock, { + monitorIndex: monitor.index, + }); + this._reflow = new ReflowScheduler(() => this._queueDynamicReflow()); this._debug = logger(`TilingManager ${monitor.index}`); @@ -551,6 +569,7 @@ export class TilingManager { * Destroys the tiling manager and cleans up resources. */ public destroy() { + this._placer.destroy(); if (this._movingWindowTimerId) { GLib.Source.remove(this._movingWindowTimerId); this._movingWindowTimerId = null; @@ -917,6 +936,14 @@ export class TilingManager { this._snapAssist.close(true); this._lastCursorPos = null; + // mutter ends the grab of a window that is being closed; its actor is + // already gone by then, and there is nothing left to place + if (!isWindowAlive(window)) { + this._snapAssistingInfo.update(undefined); + this._edgeTilingManager.abortEdgeTiling(); + return; + } + // Dynamic tiling owns every drop of a window it manages: the window // trades places with whatever occupies the slot under the pointer. if (Settings.ENABLE_DYNAMIC_TILING && this._dynamicSwapOnDrop(window)) { @@ -1077,38 +1104,14 @@ export class TilingManager { user_op: boolean = false, force: boolean = false, ) { - const windowActor = window.get_compositor_private() as Clutter.Actor; - - const beforeRect = window.get_frame_rect(); - // do not animate the window if it will not move or scale - if ( - destRect.x === beforeRect.x && - destRect.y === beforeRect.y && - destRect.width === beforeRect.width && - destRect.height === beforeRect.height - ) - return; - - // apply animations when tiling the window - windowActor.remove_all_transitions(); - // @ts-expect-error "Main.wm has the "private" function _prepareAnimationInfo" - Main.wm._prepareAnimationInfo( - global.windowManager, - windowActor, - beforeRect.copy(), - Meta.SizeChange.UNMAXIMIZE, - ); - - // move and resize the window to the current selection - window.move_to_monitor(this._monitor.index); - if (force) window.move_frame(user_op, destRect.x, destRect.y); - window.move_resize_frame( - user_op, - destRect.x, - destRect.y, - destRect.width, - destRect.height, - ); + // Never freeze the actor or touch Main.wm's size-change bookkeeping: + // on Wayland the resize is a request the client answers (or does + // not), and the placer animates only once mutter reports the result. + this._placer.place(placementTargetFor(window), toRect(destRect), { + userOp: user_op, + forceMove: force, + animate: true, + }); } private _onSnapAssist(_: SnapAssist, tile: Tile, layoutId: string) { @@ -1321,7 +1324,14 @@ export class TilingManager { const isMaximized = window.maximizedHorizontally || window.maximizedVertically; const rememberOriginalSize = !isMaximized; - if (isMaximized) unmaximizeWindow(window); + if (isMaximized) { + // mutter would animate the unmaximize on its own and then our + // placement would animate again on top of it; skip mutter's + const actor = + window.get_compositor_private() as Meta.WindowActor | null; + if (actor) Main.wm.skipNextEffect(actor); + unmaximizeWindow(window); + } if (rememberOriginalSize && !(window as ExtendedWindow).assignedTile) { (window as ExtendedWindow).originalSize = window @@ -1337,17 +1347,9 @@ export class TilingManager { }), this._workArea, ); - if (skipAnimation) { - window.move_resize_frame( - false, - destinationRect.x, - destinationRect.y, - destinationRect.width, - destinationRect.height, - ); - } else { - this._easeWindowRect(window, destinationRect); - } + this._placer.place(placementTargetFor(window), toRect(destinationRect), { + animate: !skipAnimation, + }); } public onTileFromWindowMenu(tile: Tile, window: Meta.Window) { @@ -1436,7 +1438,11 @@ export class TilingManager { /** Trackable windows that additionally have a rectangle to be placed in. */ private _isDynamicEligible(window: Meta.Window): boolean { - return this._isDynamicTrackable(window) && !window.minimized; + return ( + this._isDynamicTrackable(window) && + isWindowAlive(window) && + !window.minimized + ); } /** @@ -1478,11 +1484,16 @@ export class TilingManager { private _untrackDynamicWindow(window: Meta.Window) { this._dynamicWindowSignals.delete(window); if (this._splitTarget === window) this._splitTarget = null; + this._placer.forget(placementTargetFor(window)); const slot = this._dynamicWindows.indexOf(window); if (slot < 0) return; this._dynamicWindows.splice(slot, 1); - this._applyDynamicTiling(); + // this runs from inside mutter's unmanage (and from a position-changed + // handler when a window leaves the monitor), so the reflow is queued + // rather than run on the spot; several windows closing at once then + // produce one reflow instead of one each + this._queueDynamicReflow(); } /** @@ -1529,7 +1540,7 @@ export class TilingManager { inCreationOrder.forEach((window) => { if (this._trackDynamicWindow(window)) adopted = true; }); - if (adopted) this._applyDynamicTiling(); + if (adopted) this._queueDynamicReflow(); } /** @@ -1557,7 +1568,10 @@ export class TilingManager { const windowActor = window.get_compositor_private() as Meta.WindowActor | null; if (!windowActor) { - this._applyDynamicTiling(); + // no actor yet: placing it now would fail, so let the idle reflow + // pick it up once it has one (a dying window is filtered out by + // _isDynamicEligible) + this._queueDynamicReflow(); return; } @@ -1711,7 +1725,10 @@ export class TilingManager { */ private _applyDynamicTiling() { if (!Settings.ENABLE_DYNAMIC_TILING) return; + this._reflow.run(() => this._reflowDynamicWindows()); + } + private _reflowDynamicWindows() { const workspaces = new Set(); this._dynamicWindows.forEach((window) => { const ws = window.get_workspace(); @@ -1738,6 +1755,9 @@ export class TilingManager { splitSlot >= 0 ? splitSlot : undefined, ); windows.forEach((window, slot) => { + // a window can be unmanaged by a placement earlier in this + // very loop; skip it rather than abort the others + if (!isWindowAlive(window)) return; this._easeWindowRectFromTile(this._tileOf(rects[slot]), window); }); }); @@ -1816,7 +1836,8 @@ export class TilingManager { if (windowCreated) { const windowActor = - window.get_compositor_private() as Meta.WindowActor; + window.get_compositor_private() as Meta.WindowActor | null; + if (!windowActor) return; const id = windowActor.connect('first-frame', () => { // while we restore the opacity, making the window visible // again, we perform easing of movement too diff --git a/src/utils/ui.ts b/src/utils/ui.ts index 76cea4ed..350513ee 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -189,6 +189,15 @@ export function filterUnfocusableWindows( }); } +/** + * A window has a compositor actor only between being mapped and the start of + * its unmanage; placement code must not touch it outside that span (mutter + * clears compositor_private before it emits 'unmanaged'). + */ +export function isWindowAlive(window: Meta.Window | null): window is Meta.Window { + return window !== null && window.get_compositor_private() !== null; +} + /** From Gnome Shell: https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/main/js/ui/altTab.js#L53 */ export function getWindows(workspace?: Meta.Workspace): Meta.Window[] { if (!workspace) workspace = global.workspaceManager.get_active_workspace(); From 899e1949caace5c473a6cd78fa247359fa5d2e1c Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 13 Sep 2026 15:01:02 +0530 Subject: [PATCH 27/42] fix(tiling): review fixes for the window placer - ease() keys must be snake_case: gnome-shell maps them with replaceAll('_','-') to find the Clutter transitions, so camelCase keys were set but never animated. One adapter (placementAdapter.ts) now serves both the extension and the simulation, whose ease() follows the same mapping, so this cannot regress silently. - a request for the current frame supersedes a conflicting pending one - animate:false no longer cancels the actor's transitions (kept cutting gnome-shell's map animation short for auto-tiled windows) - re-asking for the rect mutter last sent, after the client changed on its own, is preceded by a one-pixel nudge so the configure is not dropped as equivalent - the animation start accounts for CSD extents around the frame - onSettled is per request, reporting requested vs actual rects - keyboard moves unmaximize with skipNextEffect too (shared helper) - ReflowScheduler flushes its deferred reflow even if the reflow threw - sim: synchronous (X11-style) ack policy and scenario --- .../tilingsystem/metaPlacementTarget.ts | 71 ++--------- .../tilingsystem/placementAdapter.ts | 101 +++++++++++++++ .../tilingsystem/reflowScheduler.test.ts | 12 ++ .../tilingsystem/reflowScheduler.ts | 8 +- src/components/tilingsystem/tilingManager.ts | 32 +++-- .../tilingsystem/windowPlacer.test.ts | 13 +- src/components/tilingsystem/windowPlacer.ts | 104 +++++++++++----- test/mutter-sim/mutter.ts | 42 ++++++- test/mutter-sim/placement.test.ts | 117 ++++++++++++++++++ test/mutter-sim/simTarget.ts | 62 ++-------- 10 files changed, 397 insertions(+), 165 deletions(-) create mode 100644 src/components/tilingsystem/placementAdapter.ts diff --git a/src/components/tilingsystem/metaPlacementTarget.ts b/src/components/tilingsystem/metaPlacementTarget.ts index 156f8523..2780d686 100644 --- a/src/components/tilingsystem/metaPlacementTarget.ts +++ b/src/components/tilingsystem/metaPlacementTarget.ts @@ -1,14 +1,11 @@ import { Clutter, GLib, Meta, Mtk } from '../../gi/ext'; -import type { - PlacementActor, - PlacementTarget, - PlacerClock, - Rect, -} from './windowPlacer'; +import type { PlacementTarget, PlacerClock, Rect } from './windowPlacer'; +import { adaptWindow } from './placementAdapter'; /** - * Adapts a Meta.Window to the WindowPlacer's PlacementTarget. One adapter per - * window, cached, so the placer's per-window history keys stay stable. + * PlacementTarget over a Meta.Window: one cached adapter per window, so the + * placer's per-window history has a stable key. The adapter itself is shared + * with the mutter simulation (placementAdapter.ts). */ const targets = new WeakMap(); @@ -27,61 +24,11 @@ export function toRect(r: Mtk.Rectangle): Rect { return { x: r.x, y: r.y, width: r.width, height: r.height }; } -function actorFor(actor: Meta.WindowActor): PlacementActor { - return { - setTransform(scaleX, scaleY, tx, ty) { - actor.scale_x = scaleX; - actor.scale_y = scaleY; - actor.translation_x = tx; - actor.translation_y = ty; - }, - easeToIdentity(durationMs, onStopped) { - actor.ease({ - scaleX: 1, - scaleY: 1, - translationX: 0, - translationY: 0, - duration: durationMs, - mode: Clutter.AnimationMode.EASE_OUT_QUAD, - onStopped, - }); - }, - cancelTransitions() { - actor.remove_all_transitions(); - }, - }; -} - export function placementTargetFor(window: Meta.Window): PlacementTarget { let target = targets.get(window); - if (target) return target; - - target = { - key: window, - isAlive: () => window.get_compositor_private() !== null, - getFrameRect: () => toRect(window.get_frame_rect()), - moveToMonitor: (index) => window.move_to_monitor(index), - moveFrame: (userOp, x, y) => window.move_frame(userOp, x, y), - moveResizeFrame: (userOp, r) => - window.move_resize_frame(userOp, r.x, r.y, r.width, r.height), - getActor: () => { - const actor = - window.get_compositor_private() as Meta.WindowActor | null; - return actor ? actorFor(actor) : null; - }, - onGeometryChanged: (cb) => { - const a = window.connect('size-changed', cb); - const b = window.connect('position-changed', cb); - return () => { - window.disconnect(a); - window.disconnect(b); - }; - }, - onGone: (cb) => { - const id = window.connect('unmanaging', cb); - return () => window.disconnect(id); - }, - }; - targets.set(window, target); + if (!target) { + target = adaptWindow(window, Clutter.AnimationMode.EASE_OUT_QUAD); + targets.set(window, target); + } return target; } diff --git a/src/components/tilingsystem/placementAdapter.ts b/src/components/tilingsystem/placementAdapter.ts new file mode 100644 index 00000000..2a26327e --- /dev/null +++ b/src/components/tilingsystem/placementAdapter.ts @@ -0,0 +1,101 @@ +import type { PlacementActor, PlacementTarget, Rect } from './windowPlacer'; + +/** + * Structural view of Meta.Window / Meta.WindowActor, so one adapter serves + * both the extension and the mutter simulation and neither can drift from + * the other. + */ +export interface ActorLike { + scale_x: number; + scale_y: number; + translation_x: number; + translation_y: number; + ease(params: Record): void; + remove_all_transitions(): void; +} + +export interface WindowLike { + get_compositor_private(): A | null; + get_frame_rect(): Rect; + get_buffer_rect(): Rect; + move_to_monitor(index: number): void; + move_frame(userOp: boolean, x: number, y: number): void; + move_resize_frame( + userOp: boolean, + x: number, + y: number, + width: number, + height: number, + ): void; + connect(signal: string, cb: () => void): number; + disconnect(id: number): void; +} + +const plainRect = (r: Rect): Rect => ({ + x: r.x, + y: r.y, + width: r.width, + height: r.height, +}); + +export function adaptActor( + actor: ActorLike, + easeMode?: unknown, +): PlacementActor { + return { + setTransform(scaleX, scaleY, tx, ty) { + actor.scale_x = scaleX; + actor.scale_y = scaleY; + actor.translation_x = tx; + actor.translation_y = ty; + }, + easeToIdentity(durationMs, onStopped) { + // snake_case on purpose: gnome-shell's ease() finds the Clutter + // transitions by `key.replaceAll('_', '-')`; camelCase keys are + // set but never animated + actor.ease({ + scale_x: 1, + scale_y: 1, + translation_x: 0, + translation_y: 0, + duration: durationMs, + mode: easeMode, + onStopped, + }); + }, + cancelTransitions() { + actor.remove_all_transitions(); + }, + }; +} + +export function adaptWindow( + window: WindowLike, + easeMode?: unknown, +): PlacementTarget { + return { + isAlive: () => window.get_compositor_private() !== null, + getFrameRect: () => plainRect(window.get_frame_rect()), + getBufferRect: () => plainRect(window.get_buffer_rect()), + moveToMonitor: (index) => window.move_to_monitor(index), + moveFrame: (userOp, x, y) => window.move_frame(userOp, x, y), + moveResizeFrame: (userOp, r) => + window.move_resize_frame(userOp, r.x, r.y, r.width, r.height), + getActor: () => { + const actor = window.get_compositor_private(); + return actor ? adaptActor(actor, easeMode) : null; + }, + onGeometryChanged: (cb) => { + const a = window.connect('size-changed', cb); + const b = window.connect('position-changed', cb); + return () => { + window.disconnect(a); + window.disconnect(b); + }; + }, + onGone: (cb) => { + const id = window.connect('unmanaging', cb); + return () => window.disconnect(id); + }, + }; +} diff --git a/src/components/tilingsystem/reflowScheduler.test.ts b/src/components/tilingsystem/reflowScheduler.test.ts index cd3d620f..26e0d5ed 100644 --- a/src/components/tilingsystem/reflowScheduler.test.ts +++ b/src/components/tilingsystem/reflowScheduler.test.ts @@ -49,3 +49,15 @@ test('a throwing reflow leaves the scheduler usable', () => { 'ran', ); }); + +test('a reflow that throws after a nested request still queues the follow-up', () => { + let idleQueued = 0; + const s = new ReflowScheduler(() => idleQueued++); + assert.throws(() => + s.run(() => { + s.run(() => {}); + throw new Error('boom'); + }), + ); + assert.equal(idleQueued, 1); +}); diff --git a/src/components/tilingsystem/reflowScheduler.ts b/src/components/tilingsystem/reflowScheduler.ts index 8029f155..6a91ec7e 100644 --- a/src/components/tilingsystem/reflowScheduler.ts +++ b/src/components/tilingsystem/reflowScheduler.ts @@ -26,10 +26,10 @@ export class ReflowScheduler { reflow(); } finally { this._running = false; - } - if (this._pending) { - this._pending = false; - this._queueIdle(); + if (this._pending) { + this._pending = false; + this._queueIdle(); + } } return 'ran'; } diff --git a/src/components/tilingsystem/tilingManager.ts b/src/components/tilingsystem/tilingManager.ts index bb5ef14d..57508ec9 100644 --- a/src/components/tilingsystem/tilingManager.ts +++ b/src/components/tilingsystem/tilingManager.ts @@ -552,7 +552,7 @@ export class TilingManager { ); } - if (isMaximized) unmaximizeWindow(window); + if (isMaximized) this._unmaximizeForPlacement(window); this._easeWindowRect(window, destination.rect, false, force); @@ -1098,6 +1098,19 @@ export class TilingManager { ); } + /** + * Unmaximizes a window that is about to be placed. mutter would animate + * the unmaximize on its own and the placement would then animate again on + * top of it, so mutter's effect is skipped; the placer's animation covers + * the whole way from the maximized rect to the tile. + */ + private _unmaximizeForPlacement(window: Meta.Window) { + const actor = + window.get_compositor_private() as Meta.WindowActor | null; + if (actor) Main.wm.skipNextEffect(actor); + unmaximizeWindow(window); + } + private _easeWindowRect( window: Meta.Window, destRect: Mtk.Rectangle, @@ -1324,14 +1337,7 @@ export class TilingManager { const isMaximized = window.maximizedHorizontally || window.maximizedVertically; const rememberOriginalSize = !isMaximized; - if (isMaximized) { - // mutter would animate the unmaximize on its own and then our - // placement would animate again on top of it; skip mutter's - const actor = - window.get_compositor_private() as Meta.WindowActor | null; - if (actor) Main.wm.skipNextEffect(actor); - unmaximizeWindow(window); - } + if (isMaximized) this._unmaximizeForPlacement(window); if (rememberOriginalSize && !(window as ExtendedWindow).assignedTile) { (window as ExtendedWindow).originalSize = window @@ -1722,6 +1728,14 @@ export class TilingManager { * Recomputes rectangles and eases every managed window into place, on * every workspace that holds one — a window closing on another workspace * must not leave a hole there. + * + * Two ways in: call this directly only when the caller is the last thing + * before a frame the user is watching for (a drop, a keybinding, a + * window's first frame); everything that is merely a consequence of + * window state changing (open/close/minimize/workspace/layout/work area) + * goes through `_queueDynamicReflow`, which coalesces bursts into one + * idle reflow. A reflow requested from inside a running one is deferred + * to that same idle by the scheduler. */ private _applyDynamicTiling() { if (!Settings.ENABLE_DYNAMIC_TILING) return; diff --git a/src/components/tilingsystem/windowPlacer.test.ts b/src/components/tilingsystem/windowPlacer.test.ts index a3378ce4..ddca0066 100644 --- a/src/components/tilingsystem/windowPlacer.test.ts +++ b/src/components/tilingsystem/windowPlacer.test.ts @@ -14,9 +14,9 @@ function fakeTarget(frame: Rect, alive = true) { const calls: string[] = []; let current = { ...frame }; const target: PlacementTarget = { - key: {}, isAlive: () => alive, getFrameRect: () => ({ ...current }), + getBufferRect: () => ({ ...current }), moveToMonitor: (i) => calls.push(`monitor:${i}`), moveFrame: (_u, x, y) => calls.push(`move:${x},${y}`), moveResizeFrame: (_u, r) => @@ -94,5 +94,14 @@ test('after a settle, the same dest is skipped while the frame is unchanged and 'requested', 'the frame changed since the settle', ); - assert.equal(calls.filter((c) => c.startsWith('moveResize')).length, 2); + // re-asking for the very rect mutter last sent would be dropped as an + // equivalent configure, so it is preceded by a one-pixel nudge + assert.deepEqual( + calls.filter((c) => c.startsWith('moveResize')), + [ + 'moveResize:10,10,500,400', + 'moveResize:10,10,501,400', + 'moveResize:10,10,500,400', + ], + ); }); diff --git a/src/components/tilingsystem/windowPlacer.ts b/src/components/tilingsystem/windowPlacer.ts index ab8b32ee..3c50f4ce 100644 --- a/src/components/tilingsystem/windowPlacer.ts +++ b/src/components/tilingsystem/windowPlacer.ts @@ -47,13 +47,16 @@ export interface PlacementActor { cancelTransitions(): void; } -/** The bits of Meta.Window the placer needs. */ +/** + * The bits of Meta.Window the placer needs. One target per window: the + * placer keeps its per-window history keyed by the target object. + */ export interface PlacementTarget { - /** identity of the underlying window, used as a WeakMap key */ - readonly key: object; /** false once the compositor actor is gone (before/after unmanage) */ isAlive(): boolean; getFrameRect(): Rect; + /** frame plus client-side decorations/shadows; the actor's own rect */ + getBufferRect(): Rect; moveToMonitor(index: number): void; moveFrame(userOp: boolean, x: number, y: number): void; moveResizeFrame(userOp: boolean, rect: Rect): void; @@ -74,6 +77,11 @@ export interface PlaceOptions { /** also call move_frame first (GNOME 42 restart-grab path) */ forceMove?: boolean; animate?: boolean; + /** + * Called once this request settled — not if it was superseded — with the + * rect that was asked for and the frame the client actually ended on. + */ + onSettled?: (requested: Rect, actual: Rect) => void; } export type PlaceResult = @@ -85,6 +93,11 @@ export type PlaceResult = | 'coalesced'; export interface PlacerOptions { + /** + * Every request first moves the window to this monitor, preserving the + * move_to_monitor → move_frame → move_resize_frame order the extension + * has always used. + */ monitorIndex: number; /** give up waiting for the client after this long */ settleTimeoutMs: number; @@ -97,6 +110,7 @@ interface Pending { dest: Rect; before: Rect; animate: boolean; + onSettled?: (requested: Rect, actual: Rect) => void; disconnectGeometry: () => void; disconnectGone: () => void; timeout: unknown; @@ -111,16 +125,9 @@ interface History { export class WindowPlacer { private readonly _opts: PlacerOptions; - private readonly _history = new WeakMap(); + private readonly _history = new WeakMap(); private readonly _inFlight = new Set(); - /** Called once a request settled, with what was asked and what the client gave. */ - public onSettled?: ( - target: PlacementTarget, - requested: Rect, - actual: Rect, - ) => void; - constructor( private readonly _clock: PlacerClock, opts: Partial = {}, @@ -144,15 +151,17 @@ export class WindowPlacer { const hist = this._historyOf(target); const frame = target.getFrameRect(); - if (rectEquals(frame, dest)) { + if (hist.pending && rectEquals(hist.pending.dest, dest)) + return 'coalesced'; + + // a pending request for another rect is about to move the window + // away from `dest`, so "already there" only holds without one + if (rectEquals(frame, dest) && !hist.pending) { hist.lastRequested = copyRect(dest); hist.settledFrame = copyRect(frame); return 'skipped-identical'; } - if (hist.pending && rectEquals(hist.pending.dest, dest)) - return 'coalesced'; - const sameAsLast = hist.lastRequested !== undefined && rectEquals(hist.lastRequested, dest) && @@ -161,7 +170,10 @@ export class WindowPlacer { // the client already answered exactly this request with this // frame and nothing moved it since; mutter would drop the // configure anyway (meta-window-wayland.c: equivalent - // configurations are not re-sent), so do not wait on it + // configurations are not re-sent), so do not wait on it. + // Known limitation: a client whose constraints relax later + // (a collapsed sidebar, a changed min size) is only asked + // again once its frame or its tile changes. return 'skipped-settled'; } @@ -184,21 +196,35 @@ export class WindowPlacer { return 'requested-move-only'; } - this._request(target, hist, dest, dest, frame, options, userOp); + // the same rect as last time but the window changed on its own since + // (e.g. a browser restoring its saved size after start-up): mutter + // still remembers `dest` as the last configuration it sent and would + // drop an identical one, so nudge it with a rect one pixel wider + // first; the real configure is then not equivalent to the previous + // one and goes out + this._request( + target, + hist, + dest, + dest, + frame, + options, + userOp, + sameAsLast, + ); return 'requested'; } /** Drop everything known about a window (it is no longer managed). */ public forget(target: PlacementTarget): void { - const hist = this._history.get(target.key); + const hist = this._history.get(target); if (hist?.pending) this._cancelPending(hist); - this._history.delete(target.key); + this._history.delete(target); this._inFlight.delete(target); } public destroy(): void { for (const target of [...this._inFlight]) this.forget(target); - this.onSettled = undefined; } private _request( @@ -209,17 +235,22 @@ export class WindowPlacer { before: Rect, options: PlaceOptions, userOp: boolean, + nudge = false, ): void { // a newer request supersedes the previous one, but the animation // still starts from where the window was before the first of them const earlierBefore = hist.pending?.before; if (hist.pending) this._cancelPending(hist); - target.getActor()?.cancelTransitions(); + const animate = options.animate ?? false; + // only an animated placement owns the actor's transitions; a plain + // one must not cut short e.g. gnome-shell's map animation + if (animate) target.getActor()?.cancelTransitions(); const pending: Pending = { dest: copyRect(dest), before: copyRect(earlierBefore ?? before), - animate: options.animate ?? false, + animate, + onSettled: options.onSettled, disconnectGeometry: () => {}, disconnectGone: () => {}, timeout: null, @@ -228,6 +259,8 @@ export class WindowPlacer { hist.pending = pending; this._inFlight.add(target); + // handlers go in before the request: an X11 client (or a pure move) + // can settle synchronously inside moveResizeFrame pending.disconnectGeometry = target.onGeometryChanged(() => { if (hist.pending !== pending) return; if (rectEquals(target.getFrameRect(), pending.dest)) { @@ -256,6 +289,11 @@ export class WindowPlacer { target.moveToMonitor(this._opts.monitorIndex); if (options.forceMove) target.moveFrame(userOp, request.x, request.y); + if (nudge) + target.moveResizeFrame(userOp, { + ...request, + width: request.width + 1, + }); target.moveResizeFrame(userOp, request); } @@ -276,11 +314,19 @@ export class WindowPlacer { const actor = target.getActor(); if (actor) { const before = pending.before; + const scaleX = before.width / frame.width; + const scaleY = before.height / frame.height; + // the actor is scaled about the buffer's origin, which sits + // extents.left/top outside the frame; keep the visual frame + // edge where the old frame was + const buffer = target.getBufferRect(); + const left = frame.x - buffer.x; + const top = frame.y - buffer.y; actor.setTransform( - before.width / frame.width, - before.height / frame.height, - before.x - frame.x, - before.y - frame.y, + scaleX, + scaleY, + before.x - frame.x + left * (1 - scaleX), + before.y - frame.y + top * (1 - scaleY), ); actor.easeToIdentity(this._opts.animationMs, () => { // whether it finished or was cancelled, never leave a @@ -290,7 +336,7 @@ export class WindowPlacer { } } - this.onSettled?.(target, pending.dest, frame); + pending.onSettled?.(copyRect(pending.dest), copyRect(frame)); } private _cancelPending(hist: History): void { @@ -304,10 +350,10 @@ export class WindowPlacer { } private _historyOf(target: PlacementTarget): History { - let hist = this._history.get(target.key); + let hist = this._history.get(target); if (!hist) { hist = {}; - this._history.set(target.key, hist); + this._history.set(target, hist); } return hist; } diff --git a/test/mutter-sim/mutter.ts b/test/mutter-sim/mutter.ts index 4fa11d80..52779e38 100644 --- a/test/mutter-sim/mutter.ts +++ b/test/mutter-sim/mutter.ts @@ -61,6 +61,7 @@ export class SignalBus { export interface EaseParams { duration: number; + mode?: unknown; onStopped?: (finished: boolean) => void; [prop: string]: unknown; } @@ -135,12 +136,40 @@ export class SimActor { this.height = height; } + /** + * gnome-shell environment.js `_easeActor`: the animated properties are + * `Object.keys(params).map(p => p.replaceAll('_', '-'))`, looked up by + * Clutter's kebab-case pspec names. A key that does not map to one (e.g. + * camelCase `scaleX`) is still *set* by `actor.set(params)` but yields no + * transition, and with no transition at all the callback runs at once. + */ ease(params: EaseParams): void { - const { duration, onStopped, ...rest } = params; + const { duration, onStopped, mode: _mode, ...rest } = params; const props: Partial> = {}; - for (const [k, v] of Object.entries(rest)) - if ((ANIMATABLE as readonly string[]).includes(k)) - props[k as Animatable] = v as number; + for (const [k, v] of Object.entries(rest)) { + const kebab = k.replaceAll('_', '-'); + const snake = kebab.replaceAll('-', '_'); + if ( + kebab === k.replaceAll('_', '-') && + k === snake && + (ANIMATABLE as readonly string[]).includes(snake) + ) { + props[snake as Animatable] = v as number; + } else { + // GObject `set` accepts camelCase too: applied, not animated + const camelToSnake = k.replace( + /[A-Z]/g, + (c) => `_${c.toLowerCase()}`, + ); + if ((ANIMATABLE as readonly string[]).includes(camelToSnake)) + (this as unknown as Record)[camelToSnake] = + v as number; + } + } + if (Object.keys(props).length === 0) { + onStopped?.(true); + return; + } // Clutter replaces an in-flight transition on the same property; the // replaced transition is stopped early (onStopped(false)). @@ -333,6 +362,8 @@ export const enum SizeChange { export type AckPolicy = | { kind: 'comply' } + /** acks inside move_resize_frame, like an X11 client under mutter */ + | { kind: 'sync' } | { kind: 'clampMin'; minWidth: number; minHeight: number } | { kind: 'ignore' } | { kind: 'delayed'; ms: number; then: AckPolicy }; @@ -591,6 +622,9 @@ export class SimWindow { this.clientAck(undefined, cfg), ); return; + case 'sync': + this.clientAck(undefined, cfg); + return; case 'clampMin': { const size = { width: Math.max(cfg.width, policy.minWidth), diff --git a/test/mutter-sim/placement.test.ts b/test/mutter-sim/placement.test.ts index 81a12fc6..a67c1d03 100644 --- a/test/mutter-sim/placement.test.ts +++ b/test/mutter-sim/placement.test.ts @@ -270,3 +270,120 @@ test('(x) placer.destroy() cancels everything in flight', () => { clock.tick(1000); assert.equal(window.get_compositor_private()!.scale_x, 1); }); + +test('(xi) a request for the current frame cancels a conflicting in-flight request', () => { + // window B opened (A asked to shrink to R1) and closed again before the + // client answered: A must end where it already is, not at R1 + const { clock, window, placer } = fixture({ + kind: 'delayed', + ms: 100, + then: { kind: 'comply' }, + }); + const target = simTargetFor(window); + const r0 = window.get_frame_rect(); + const r1 = { x: 8, y: 40, width: 600, height: 500 }; + assert.equal(placer.place(target, r1), 'requested'); + assert.equal( + placer.place(target, r0), + 'requested', + 'must supersede the pending request', + ); + clock.tick(500); + assert.deepEqual(window.get_frame_rect(), r0); +}); + +test('(xii) animate:false leaves an in-flight actor transition (the map animation) alone', () => { + const { clock, window, placer } = fixture(); + const actor = window.get_compositor_private()!; + actor.opacity = 0; + actor.ease({ opacity: 255, duration: 250 }); // gnome-shell _mapWindow + placer.place(simTargetFor(window), TILE_S, { animate: false }); + assert.equal(actor.hasTransitions, true, 'map animation still running'); + clock.tick(300); + assert.equal(actor.opacity, 255); +}); + +test('(xiii) after the client resized itself, the same tile is asked for again in a way mutter sends', () => { + // Brave grew from 512 to 634 tall six seconds after being placed + const { clock, compositor, wm, window, placer } = fixture(); + const actor = window.get_compositor_private()!; + const target = simTargetFor(window); + assert.equal(placer.place(target, TILE_S), 'requested'); + clock.tick(400); + assert.deepEqual(window.get_frame_rect(), TILE_S); + + window.clientResize(600, 634); + assert.deepEqual(window.get_frame_rect(), { ...TILE_S, height: 634 }); + + assert.equal(placer.place(target, TILE_S), 'requested'); + clock.tick(400); + assert.deepEqual( + window.get_frame_rect(), + TILE_S, + 'mutter must not have dropped the configure', + ); + assertHealthy(actor, window, compositor, wm); +}); + +test('(xiv) the animation start accounts for CSD extents around the frame', () => { + const clock = new SimClock(); + const compositor = new SimCompositor(clock, WORK_AREA); + new ShellWindowManager(compositor, clock); + const window = compositor.createWindow( + 'csd', + { x: 8, y: 40, width: 800, height: 600 }, + { kind: 'comply' }, + { + extents: { left: 40, right: 40, top: 30, bottom: 50 }, + }, + ); + const actor = window.get_compositor_private()!; + const placer = new WindowPlacer(simClock(clock), { monitorIndex: 0 }); + placer.place(simTargetFor(window), TILE_S, { animate: true }); + clock.tick(20); // acked, animation starts now + const s = 800 / 600; + assert.equal(actor.scale_x, s); + // visual frame edge: buffer.x + tx + s*left must equal the old frame x (8) + const buffer = window.get_buffer_rect(); + assert.equal(Math.round(buffer.x + actor.translation_x + s * 40), 8); +}); + +test('(xv) X11-style client that acks synchronously inside move_resize_frame settles in-call', () => { + const { clock, compositor, wm, window, placer } = fixture({ kind: 'sync' }); + const actor = window.get_compositor_private()!; + let settled = 0; + assert.equal( + placer.place(simTargetFor(window), TILE_S, { + animate: true, + onSettled: () => settled++, + }), + 'requested', + ); + assert.equal(settled, 1, 'settled before place() returned'); + assert.deepEqual(window.get_frame_rect(), TILE_S); + assert.equal(clock.pendingTimeouts, 1, 'only the animation is left'); + clock.tick(300); + assertHealthy(actor, window, compositor, wm); +}); + +test('(xvi) onSettled is per request and reports what was asked and what the client gave', () => { + const { clock, window, placer } = fixture({ + kind: 'clampMin', + minWidth: 700, + minHeight: 600, + }); + const seen: Array<[Rect, Rect]> = []; + placer.place(simTargetFor(window), TILE_S, { + onSettled: (req, actual) => seen.push([req, actual]), + }); + placer.place(simTargetFor(window), { ...TILE_S, x: 100 }); // supersedes: the first never settles + clock.tick(400); + assert.deepEqual(seen, [], 'superseded request did not report'); + placer.place(simTargetFor(window), TILE_S, { + onSettled: (req, actual) => seen.push([req, actual]), + }); + clock.tick(400); + assert.deepEqual(seen, [ + [TILE_S, { x: 8, y: 40, width: 700, height: 600 }], + ]); +}); diff --git a/test/mutter-sim/simTarget.ts b/test/mutter-sim/simTarget.ts index 69866a8b..3a7858ce 100644 --- a/test/mutter-sim/simTarget.ts +++ b/test/mutter-sim/simTarget.ts @@ -1,12 +1,11 @@ -/** PlacementTarget adapter over the simulated MetaWindow, mirroring metaPlacementTarget.ts. */ +/** PlacementTarget over the simulated MetaWindow, through the same adapter the extension uses. */ import type { - PlacementActor, PlacementTarget, PlacerClock, - Rect, } from '../../src/components/tilingsystem/windowPlacer.ts'; +import { adaptWindow } from '../../src/components/tilingsystem/placementAdapter.ts'; import { SimClock } from './clock.ts'; -import { SimWindow, SimWindowActor } from './mutter.ts'; +import { SimWindow } from './mutter.ts'; const targets = new WeakMap(); @@ -19,58 +18,11 @@ export function simClock(clock: SimClock): PlacerClock { }; } -function actorFor(actor: SimWindowActor): PlacementActor { - return { - setTransform(scaleX, scaleY, tx, ty) { - actor.scale_x = scaleX; - actor.scale_y = scaleY; - actor.translation_x = tx; - actor.translation_y = ty; - }, - easeToIdentity(durationMs, onStopped) { - actor.ease({ - scale_x: 1, - scale_y: 1, - translation_x: 0, - translation_y: 0, - duration: durationMs, - onStopped, - }); - }, - cancelTransitions() { - actor.remove_all_transitions(); - }, - }; -} - export function simTargetFor(window: SimWindow): PlacementTarget { let t = targets.get(window); - if (t) return t; - t = { - key: window, - isAlive: () => window.get_compositor_private() !== null, - getFrameRect: () => window.get_frame_rect(), - moveToMonitor: (i) => window.move_to_monitor(i), - moveFrame: (userOp, x, y) => window.move_frame(userOp, x, y), - moveResizeFrame: (userOp, r) => - window.move_resize_frame(userOp, r.x, r.y, r.width, r.height), - getActor: () => { - const a = window.get_compositor_private(); - return a ? actorFor(a) : null; - }, - onGeometryChanged: (cb) => { - const a = window.connect('size-changed', cb); - const b = window.connect('position-changed', cb); - return () => { - window.disconnect(a); - window.disconnect(b); - }; - }, - onGone: (cb) => { - const id = window.connect('unmanaging', cb); - return () => window.disconnect(id); - }, - }; - targets.set(window, t); + if (!t) { + t = adaptWindow(window); + targets.set(window, t); + } return t; } From 1a0b5e1c046a64eaa133abfcf1ef447aa787f601 Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 13 Sep 2026 15:41:17 +0530 Subject: [PATCH 28/42] fix(tiling): verify a placement after it settles and re-ask once when the client refused Brave answers the first configure after start-up with a larger size than asked (820x512 or 628x634 for a ~500-wide tile) and accepts the very same request a moment later; some windows also resize themselves seconds after being placed. Both left windows overlapping their neighbours until the next reflow. The placer now retries a refused size after 1 s and 3 s (nudged so mutter sends the configure), then gives up, and for 10 s after a request settled puts a window back once if it changes its own size. A reflow re-asking for the same rect keeps the pending retry instead of dropping it. --- .../tilingsystem/windowPlacer.test.ts | 15 +- src/components/tilingsystem/windowPlacer.ts | 131 +++++++++++++++++- test/mutter-sim/mutter.ts | 22 +++ test/mutter-sim/placement.test.ts | 128 ++++++++++++++++- 4 files changed, 285 insertions(+), 11 deletions(-) diff --git a/src/components/tilingsystem/windowPlacer.test.ts b/src/components/tilingsystem/windowPlacer.test.ts index ddca0066..6909d8f2 100644 --- a/src/components/tilingsystem/windowPlacer.test.ts +++ b/src/components/tilingsystem/windowPlacer.test.ts @@ -79,13 +79,16 @@ test('after a settle, the same dest is skipped while the frame is unchanged and height: 100, }); // a synchronous clock: every request settles immediately (as if the client never answered) - const placer = new WindowPlacer({ - timeout: (_ms, cb) => { - cb(); - return 0; + const placer = new WindowPlacer( + { + timeout: (_ms, cb) => { + cb(); + return 0; + }, + cancel: () => {}, }, - cancel: () => {}, - }); + { retryDelaysMs: [], driftWatchMs: 0 }, + ); assert.equal(placer.place(target, DEST), 'requested'); assert.equal(placer.place(target, DEST), 'skipped-settled'); setFrame({ x: 10, y: 10, width: 700, height: 400 }); // the client answered late, clamping width diff --git a/src/components/tilingsystem/windowPlacer.ts b/src/components/tilingsystem/windowPlacer.ts index 3c50f4ce..1e56abb2 100644 --- a/src/components/tilingsystem/windowPlacer.ts +++ b/src/components/tilingsystem/windowPlacer.ts @@ -104,6 +104,17 @@ export interface PlacerOptions { /** after a geometry change that is not yet the target, wait this long for more */ quietMs: number; animationMs: number; + /** + * When the client settled on a different size than asked, ask again after + * each of these delays (some clients, e.g. Brave right after start-up, + * refuse a size once and accept it a moment later). Empty disables. + */ + retryDelaysMs: number[]; + /** + * After a request settled, watch the window for this long: a client that + * resizes itself away from what it accepted is put back once. + */ + driftWatchMs: number; } interface Pending { @@ -121,6 +132,12 @@ interface History { lastRequested?: Rect; settledFrame?: Rect; pending?: Pending; + /** retries already spent on `lastRequested` */ + retries: number; + retryTimeout: unknown; + watch?: { disconnect: () => void; timeout: unknown; quiet: unknown }; + /** the options of the request being verified, reused by retries */ + lastOptions?: PlaceOptions; } export class WindowPlacer { @@ -137,6 +154,8 @@ export class WindowPlacer { settleTimeoutMs: 300, quietMs: 40, animationMs: 250, + retryDelaysMs: [1000, 3000], + driftWatchMs: 10_000, ...opts, }; } @@ -154,6 +173,17 @@ export class WindowPlacer { if (hist.pending && rectEquals(hist.pending.dest, dest)) return 'coalesced'; + // a different rect makes whatever was being verified moot; the same + // rect keeps its pending retry (a reflow re-asking for it must not + // silently drop the one chance the client gets) + if ( + hist.lastRequested === undefined || + !rectEquals(hist.lastRequested, dest) + ) { + this._cancelVerification(hist); + hist.retries = 0; + } + // a pending request for another rect is about to move the window // away from `dest`, so "already there" only holds without one if (rectEquals(frame, dest) && !hist.pending) { @@ -218,7 +248,10 @@ export class WindowPlacer { /** Drop everything known about a window (it is no longer managed). */ public forget(target: PlacementTarget): void { const hist = this._history.get(target); - if (hist?.pending) this._cancelPending(hist); + if (hist) { + this._cancelPending(hist); + this._cancelVerification(hist); + } this._history.delete(target); this._inFlight.delete(target); } @@ -241,11 +274,13 @@ export class WindowPlacer { // still starts from where the window was before the first of them const earlierBefore = hist.pending?.before; if (hist.pending) this._cancelPending(hist); + this._cancelVerification(hist); const animate = options.animate ?? false; // only an animated placement owns the actor's transitions; a plain // one must not cut short e.g. gnome-shell's map animation if (animate) target.getActor()?.cancelTransitions(); + hist.lastOptions = options; const pending: Pending = { dest: copyRect(dest), before: copyRect(earlierBefore ?? before), @@ -337,6 +372,98 @@ export class WindowPlacer { } pending.onSettled?.(copyRect(pending.dest), copyRect(frame)); + + this._verify(target, hist, pending.dest, frame); + } + + /** + * The client has answered. If it did not give the size that was asked + * for, ask again a little later (bounded); once it has, keep an eye on it + * for a while in case it changes its mind on its own. + */ + private _verify( + target: PlacementTarget, + hist: History, + dest: Rect, + frame: Rect, + ): void { + const delays = this._opts.retryDelaysMs; + if (!sizeEquals(frame, dest)) { + if (hist.retries >= delays.length) return; // give up + const delay = delays[hist.retries++]; + this._inFlight.add(target); + hist.retryTimeout = this._clock.timeout(delay, () => { + hist.retryTimeout = null; + this._inFlight.delete(target); + if (hist.pending || !target.isAlive()) return; + this._request( + target, + hist, + dest, + dest, + target.getFrameRect(), + hist.lastOptions ?? {}, + hist.lastOptions?.userOp ?? false, + true, + ); + }); + return; + } + + if (this._opts.driftWatchMs <= 0) return; + const watch: NonNullable = { + disconnect: () => {}, + timeout: null, + quiet: null, + }; + hist.watch = watch; + this._inFlight.add(target); + const stop = () => { + if (hist.watch !== watch) return; + this._cancelVerification(hist); + this._inFlight.delete(target); + }; + watch.disconnect = target.onGeometryChanged(() => { + if (hist.watch !== watch || hist.pending) return; + if (sizeEquals(target.getFrameRect(), frame)) return; + // the client changed its size on its own: let it finish, then + // put it back once + if (watch.quiet !== null) this._clock.cancel(watch.quiet); + watch.quiet = this._clock.timeout(this._opts.quietMs, () => { + watch.quiet = null; + stop(); + if (!target.isAlive()) return; + hist.retries = delays.length; // one correction, no retries + this._request( + target, + hist, + dest, + dest, + target.getFrameRect(), + hist.lastOptions ?? {}, + hist.lastOptions?.userOp ?? false, + true, + ); + }); + }); + watch.timeout = this._clock.timeout(this._opts.driftWatchMs, () => { + watch.timeout = null; + stop(); + }); + } + + private _cancelVerification(hist: History): void { + if (hist.retryTimeout !== null && hist.retryTimeout !== undefined) { + this._clock.cancel(hist.retryTimeout); + hist.retryTimeout = null; + } + const w = hist.watch; + if (w) { + hist.watch = undefined; + w.disconnect(); + if (w.timeout !== null) this._clock.cancel(w.timeout); + if (w.quiet !== null) this._clock.cancel(w.quiet); + } } private _cancelPending(hist: History): void { @@ -352,7 +479,7 @@ export class WindowPlacer { private _historyOf(target: PlacementTarget): History { let hist = this._history.get(target); if (!hist) { - hist = {}; + hist = { retries: 0, retryTimeout: null }; this._history.set(target, hist); } return hist; diff --git a/test/mutter-sim/mutter.ts b/test/mutter-sim/mutter.ts index 52779e38..0b9f72e1 100644 --- a/test/mutter-sim/mutter.ts +++ b/test/mutter-sim/mutter.ts @@ -366,6 +366,13 @@ export type AckPolicy = | { kind: 'sync' } | { kind: 'clampMin'; minWidth: number; minHeight: number } | { kind: 'ignore' } + /** clamps the first `times` configures, then complies (Brave right after start-up) */ + | { + kind: 'clampThenComply'; + minWidth: number; + minHeight: number; + times: number; + } | { kind: 'delayed'; ms: number; then: AckPolicy }; export interface Configuration { @@ -431,6 +438,7 @@ export class SimWindow { private _serial = 1; private _signals = new SignalBus(); private _savedRect: Rect | null = null; + private _clampsLeft?: number; maximized: boolean; unmanaging = false; @@ -625,6 +633,20 @@ export class SimWindow { case 'sync': this.clientAck(undefined, cfg); return; + case 'clampThenComply': { + this._clampsLeft ??= policy.times; + const size = + this._clampsLeft-- > 0 + ? { + width: Math.max(cfg.width, policy.minWidth), + height: Math.max(cfg.height, policy.minHeight), + } + : { width: cfg.width, height: cfg.height }; + this.clock.timeout(this._ackDelay + extraDelay, () => + this.clientAck(size, cfg), + ); + return; + } case 'clampMin': { const size = { width: Math.max(cfg.width, policy.minWidth), diff --git a/test/mutter-sim/placement.test.ts b/test/mutter-sim/placement.test.ts index a67c1d03..bc2546c6 100644 --- a/test/mutter-sim/placement.test.ts +++ b/test/mutter-sim/placement.test.ts @@ -86,8 +86,24 @@ test('(iii) clamping client: repeated identical requests never freeze and never placer.place(target, TILE_S, { animate: true }), 'skipped-settled', ); - assert.equal(window.sentConfigurations.length, 1, 'nothing re-sent'); + assert.equal( + window.sentConfigurations.length, + 1, + 'nothing re-sent by the reflow itself', + ); clock.tick(10_000); + // the placer's own bounded retries (2 × nudge + real) went out and were refused + assert.equal(window.sentConfigurations.length, 5); + assert.deepEqual(window.get_frame_rect(), { + x: 8, + y: 40, + width: 700, + height: 600, + }); + assert.equal( + placer.place(target, TILE_S, { animate: true }), + 'skipped-settled', + ); assertHealthy(actor, window, compositor, wm); // the user drags it away, then the tile is asked for again: only the position is off, @@ -112,6 +128,7 @@ test('(iii) clamping client: repeated identical requests never freeze and never }); clock.tick(300); assertHealthy(actor, window, compositor, wm); + clock.tick(30_000); // retries exhausted long ago, nothing else armed assert.equal(clock.pendingTimeouts, 0, 'no leaked timeouts'); }); @@ -148,6 +165,7 @@ test('(iv) rapid successive requests before any ack: one animation, from the fir assertHealthy(actor, window, compositor, wm); assert.equal(actor.scale_x, 1); assert.equal(actor.translation_x, 0); + clock.tick(10_000); // drift watch expires assert.equal(clock.pendingTimeouts, 0); }); @@ -232,12 +250,17 @@ test('(viii) a client that never acks: the request settles by timeout and is not 'no animation for a rect that never changed', ); assertHealthy(actor, window, compositor, wm); + assert.equal( + placer.place(target, TILE_S, { animate: true }), + 'skipped-settled', + ); + clock.tick(30_000); // bounded retries, all ignored as well + assert.equal(window.sentConfigurations.length, 5); assert.equal(clock.pendingTimeouts, 0); assert.equal( placer.place(target, TILE_S, { animate: true }), 'skipped-settled', ); - assert.equal(window.sentConfigurations.length, 1); }); test('(ix) a window that is unmanaged mid-request cleans up without animating', () => { @@ -361,7 +384,11 @@ test('(xv) X11-style client that acks synchronously inside move_resize_frame set ); assert.equal(settled, 1, 'settled before place() returned'); assert.deepEqual(window.get_frame_rect(), TILE_S); - assert.equal(clock.pendingTimeouts, 1, 'only the animation is left'); + assert.equal( + clock.pendingTimeouts, + 2, + 'only the animation and the drift watch are left', + ); clock.tick(300); assertHealthy(actor, window, compositor, wm); }); @@ -387,3 +414,98 @@ test('(xvi) onSettled is per request and reports what was asked and what the cli [TILE_S, { x: 8, y: 40, width: 700, height: 600 }], ]); }); + +test('(xvii) a client that refuses the size only at first is asked again and ends on the tile', () => { + const { clock, compositor, wm, window, placer } = fixture({ + kind: 'clampThenComply', + minWidth: 820, + minHeight: 634, + times: 1, + }); + const actor = window.get_compositor_private()!; + placer.place(simTargetFor(window), TILE_S, { animate: true }); + clock.tick(400); + assert.deepEqual( + window.get_frame_rect(), + { x: 8, y: 40, width: 820, height: 634 }, + 'first answer refused', + ); + clock.tick(1500); // first retry (nudged) went out and was answered + assert.deepEqual(window.get_frame_rect(), TILE_S); + assert.equal(window.sentConfigurations.length, 3, 'initial + nudge + real'); + clock.tick(20_000); + assert.equal( + window.sentConfigurations.length, + 3, + 'nothing more once it complied', + ); + assertHealthy(actor, window, compositor, wm); + assert.equal(clock.pendingTimeouts, 0); +}); + +test('(xviii) a client that always refuses is retried a bounded number of times, then left alone', () => { + const { clock, window, placer } = fixture({ + kind: 'clampMin', + minWidth: 700, + minHeight: 600, + }); + const target = simTargetFor(window); + placer.place(target, TILE_S); + clock.tick(30_000); + // initial + 2 retries × (nudge + real) + assert.equal(window.sentConfigurations.length, 5); + assert.equal(clock.pendingTimeouts, 0, 'no watch or retry left armed'); + assert.equal(placer.place(target, TILE_S), 'skipped-settled'); +}); + +test('(xix) a window that grows on its own shortly after placement is put back once', () => { + const { clock, window, placer } = fixture(); + const target = simTargetFor(window); + placer.place(target, TILE_S); + clock.tick(400); + assert.deepEqual(window.get_frame_rect(), TILE_S); + clock.tick(3000); + window.clientResize(600, 634); // Brave six seconds later + clock.tick(400); + assert.deepEqual(window.get_frame_rect(), TILE_S, 'corrected'); + assert.equal(window.sentConfigurations.length, 3, 'initial + nudge + real'); + // outside the watch window it is the user's (or the next reflow's) business + clock.tick(20_000); + window.clientResize(600, 700); + clock.tick(2000); + assert.deepEqual(window.get_frame_rect(), { ...TILE_S, height: 700 }); + assert.equal(clock.pendingTimeouts, 0); +}); + +test('(xx) a new request cancels pending retries and the drift watch', () => { + const { clock, window, placer } = fixture({ + kind: 'clampMin', + minWidth: 700, + minHeight: 600, + }); + const target = simTargetFor(window); + placer.place(target, TILE_S); + clock.tick(400); // settled clamped, retry armed + const other = { x: 900, y: 40, width: 900, height: 900 }; + placer.place(target, other); + clock.tick(30_000); + assert.deepEqual(window.get_frame_rect(), other); + assert.equal( + window.sentConfigurations.length, + 2, + 'the retry for TILE_S never went out', + ); + assert.equal(clock.pendingTimeouts, 0); +}); + +test('(xxi) placer.destroy() also cancels retries and drift watches', () => { + const { clock, window, placer } = fixture({ + kind: 'clampMin', + minWidth: 700, + minHeight: 600, + }); + placer.place(simTargetFor(window), TILE_S); + clock.tick(400); + placer.destroy(); + assert.equal(clock.pendingTimeouts, 0); +}); From f9ee7ebeaa0cf4e823e1bbc1cbe569cc27aa091a Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 13 Sep 2026 16:01:53 +0530 Subject: [PATCH 29/42] fix(tiling): retry a refused placement after 400 ms, then 2 s Brave accepts the re-asked size well within a second; the 1 s first retry made the correction visibly slow. --- src/components/tilingsystem/windowPlacer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/tilingsystem/windowPlacer.ts b/src/components/tilingsystem/windowPlacer.ts index 1e56abb2..e99b2dea 100644 --- a/src/components/tilingsystem/windowPlacer.ts +++ b/src/components/tilingsystem/windowPlacer.ts @@ -154,7 +154,7 @@ export class WindowPlacer { settleTimeoutMs: 300, quietMs: 40, animationMs: 250, - retryDelaysMs: [1000, 3000], + retryDelaysMs: [400, 2000], driftWatchMs: 10_000, ...opts, }; From a1905cf3a308e38528835742744379725696b28c Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 13 Sep 2026 16:04:24 +0530 Subject: [PATCH 30/42] fix(tiling): first placement retry after 200 ms --- src/components/tilingsystem/windowPlacer.ts | 2 +- test/mutter-sim/placement.test.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/tilingsystem/windowPlacer.ts b/src/components/tilingsystem/windowPlacer.ts index e99b2dea..bebdaba7 100644 --- a/src/components/tilingsystem/windowPlacer.ts +++ b/src/components/tilingsystem/windowPlacer.ts @@ -154,7 +154,7 @@ export class WindowPlacer { settleTimeoutMs: 300, quietMs: 40, animationMs: 250, - retryDelaysMs: [400, 2000], + retryDelaysMs: [200, 2000], driftWatchMs: 10_000, ...opts, }; diff --git a/test/mutter-sim/placement.test.ts b/test/mutter-sim/placement.test.ts index bc2546c6..8b0a5f04 100644 --- a/test/mutter-sim/placement.test.ts +++ b/test/mutter-sim/placement.test.ts @@ -73,9 +73,9 @@ test('(iii) clamping client: repeated identical requests never freeze and never width: 700, height: 600, }); - clock.tick(300); // our animation settles - assertHealthy(actor, window, compositor, wm); - assert.equal(actor.scale_x, 1); + clock.tick(100); // settled, animation still easing, first retry not yet due + assert.equal(wm._resizePending.size, 0); + assert.equal(actor.freezeCount, 0); // identical request twice: the client already gave its answer to exactly this rect assert.equal( @@ -424,13 +424,13 @@ test('(xvii) a client that refuses the size only at first is asked again and end }); const actor = window.get_compositor_private()!; placer.place(simTargetFor(window), TILE_S, { animate: true }); - clock.tick(400); + clock.tick(100); assert.deepEqual( window.get_frame_rect(), { x: 8, y: 40, width: 820, height: 634 }, 'first answer refused', ); - clock.tick(1500); // first retry (nudged) went out and was answered + clock.tick(1800); // first retry (nudged) went out and was answered assert.deepEqual(window.get_frame_rect(), TILE_S); assert.equal(window.sentConfigurations.length, 3, 'initial + nudge + real'); clock.tick(20_000); @@ -485,7 +485,7 @@ test('(xx) a new request cancels pending retries and the drift watch', () => { }); const target = simTargetFor(window); placer.place(target, TILE_S); - clock.tick(400); // settled clamped, retry armed + clock.tick(100); // settled clamped, retry armed but not due const other = { x: 900, y: 40, width: 900, height: 900 }; placer.place(target, other); clock.tick(30_000); From 21d2b5c6c01616b308b585bfc52b671767bac272 Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 13 Sep 2026 16:16:02 +0530 Subject: [PATCH 31/42] fix(tiling): retry a refused placement three times, at 200/400/600 ms --- src/components/tilingsystem/windowPlacer.ts | 2 +- test/mutter-sim/placement.test.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/tilingsystem/windowPlacer.ts b/src/components/tilingsystem/windowPlacer.ts index bebdaba7..a2e54869 100644 --- a/src/components/tilingsystem/windowPlacer.ts +++ b/src/components/tilingsystem/windowPlacer.ts @@ -154,7 +154,7 @@ export class WindowPlacer { settleTimeoutMs: 300, quietMs: 40, animationMs: 250, - retryDelaysMs: [200, 2000], + retryDelaysMs: [200, 400, 600], driftWatchMs: 10_000, ...opts, }; diff --git a/test/mutter-sim/placement.test.ts b/test/mutter-sim/placement.test.ts index 8b0a5f04..bc682297 100644 --- a/test/mutter-sim/placement.test.ts +++ b/test/mutter-sim/placement.test.ts @@ -92,8 +92,8 @@ test('(iii) clamping client: repeated identical requests never freeze and never 'nothing re-sent by the reflow itself', ); clock.tick(10_000); - // the placer's own bounded retries (2 × nudge + real) went out and were refused - assert.equal(window.sentConfigurations.length, 5); + // the placer's own bounded retries (3 × nudge + real) went out and were refused + assert.equal(window.sentConfigurations.length, 7); assert.deepEqual(window.get_frame_rect(), { x: 8, y: 40, @@ -255,7 +255,7 @@ test('(viii) a client that never acks: the request settles by timeout and is not 'skipped-settled', ); clock.tick(30_000); // bounded retries, all ignored as well - assert.equal(window.sentConfigurations.length, 5); + assert.equal(window.sentConfigurations.length, 7); assert.equal(clock.pendingTimeouts, 0); assert.equal( placer.place(target, TILE_S, { animate: true }), @@ -452,8 +452,8 @@ test('(xviii) a client that always refuses is retried a bounded number of times, const target = simTargetFor(window); placer.place(target, TILE_S); clock.tick(30_000); - // initial + 2 retries × (nudge + real) - assert.equal(window.sentConfigurations.length, 5); + // initial + 3 retries × (nudge + real) + assert.equal(window.sentConfigurations.length, 7); assert.equal(clock.pendingTimeouts, 0, 'no watch or retry left armed'); assert.equal(placer.place(target, TILE_S), 'skipped-settled'); }); From 8d6f2e989cf4ccaf63213c411111c9641dfa350f Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 13 Sep 2026 16:18:34 +0530 Subject: [PATCH 32/42] feat(dynamic): indicator and snap assist reflect the layout dynamic tiling is using With dynamic tiling on, the panel menu and the snap-assist popup used to highlight the static per-monitor selection, which has nothing to do with what is on screen. They now show the saved layout dynamic tiling actually resolved for the active workspace's window count (including a Super+; offset), clicking a layout in that tile-count group switches to it, and layouts outside the group are greyed out instead of silently doing nothing. Snap assist only highlights; the drop is still owned by dynamic tiling's swap. --- src/components/snapassist/snapAssist.ts | 21 +++ src/components/snapassist/snapAssistLayout.ts | 6 + src/components/tilingsystem/tilingManager.ts | 107 +++++++++++++++ src/extension.ts | 6 +- src/indicator/defaultMenu.ts | 126 +++++++++++++----- src/indicator/indicator.ts | 35 ++++- src/indicator/layoutButton.ts | 7 + src/styles/layout_button.scss | 7 + src/styles/snap_assist.scss | 17 +++ 9 files changed, 299 insertions(+), 33 deletions(-) diff --git a/src/components/snapassist/snapAssist.ts b/src/components/snapassist/snapAssist.ts index dd9fa52d..11b7aa4b 100644 --- a/src/components/snapassist/snapAssist.ts +++ b/src/components/snapassist/snapAssist.ts @@ -61,6 +61,7 @@ class SnapAssistContent extends St.BoxLayout { private _snapAssistLayouts: SnapAssistLayout[]; private _isEnlarged = false; private _hoveredInfo: [SnapAssistTile, SnapAssistLayout] | undefined; + private _selectedLayoutId: string | undefined; private _padding: number; private _blur: boolean; private _snapAssistantThreshold: number; @@ -249,6 +250,7 @@ class SnapAssistContent extends St.BoxLayout { width, height, ); + saLay.setSelected(lay.id === this._selectedLayoutId); // build and place a spacer if (ind < layouts.length - 1) { this.add_child( @@ -261,6 +263,21 @@ class SnapAssistContent extends St.BoxLayout { this.set_x(this._container.width / 2 - this.width / 2); } + /** + * Which saved layout to show as selected, e.g. the one dynamic tiling + * is currently using as its template — independent of hover, which + * tracks the tile the pointer is over rather than what is "active". + * `undefined` clears the highlight (dynamic tiling off, or nothing to + * place yet). + */ + public setDynamicLayoutId(layoutId: string | undefined) { + if (this._selectedLayoutId === layoutId) return; + this._selectedLayoutId = layoutId; + this._snapAssistLayouts.forEach((lay) => + lay.setSelected(lay.layout.id === layoutId), + ); + } + public onMovingWindow( window: Meta.Window, currPointerPos: { x: number; y: number }, @@ -431,4 +448,8 @@ export default class SnapAssist extends St.Widget { public close(ease: boolean = false) { this._content.close(ease); } + + public setDynamicLayoutId(layoutId: string | undefined) { + this._content.setDynamicLayoutId(layoutId); + } } diff --git a/src/components/snapassist/snapAssistLayout.ts b/src/components/snapassist/snapAssistLayout.ts index 4d1b8f06..751feb09 100644 --- a/src/components/snapassist/snapAssistLayout.ts +++ b/src/components/snapassist/snapAssistLayout.ts @@ -23,6 +23,7 @@ export default class SnapAssistLayout extends LayoutWidget { layout, innerGaps, outerGaps, + styleClass: 'snap-assist-layout', }); this.set_size(width, height); super.relayout(); @@ -37,6 +38,11 @@ export default class SnapAssistLayout extends LayoutWidget { return new SnapAssistTile({ parent, rect, gaps, tile }); } + public setSelected(selected: boolean) { + if (selected) this.add_style_class_name('selected'); + else this.remove_style_class_name('selected'); + } + public getTileBelow(cursorPos: { x: number; y: number; diff --git a/src/components/tilingsystem/tilingManager.ts b/src/components/tilingsystem/tilingManager.ts index 57508ec9..697bf0cb 100644 --- a/src/components/tilingsystem/tilingManager.ts +++ b/src/components/tilingsystem/tilingManager.ts @@ -108,6 +108,7 @@ export class TilingManager { // Cache of _dynamicLayoutCandidates(), rebuilt only when the saved // layouts change rather than on every reflow. private _dynamicLayoutCandidatesCache: { + id: string; tileCount: number; tree: SplitTree | null; }[] | null = null; @@ -861,6 +862,18 @@ export class TilingManager { } if (Settings.SNAP_ASSIST) { + // reflect whichever saved layout dynamic tiling is + // actually using right now, rather than the static + // per-monitor selection, so the popup doesn't offer a + // layout unrelated to the arrangement already on screen + const dynamicWs = Settings.ENABLE_DYNAMIC_TILING + ? window.get_workspace() + : null; + this._snapAssist.setDynamicLayoutId( + dynamicWs + ? this.getCurrentDynamicLayoutId(dynamicWs) + : undefined, + ); this._snapAssist.onMovingWindow( window, currPointerPos, @@ -1633,6 +1646,7 @@ export class TilingManager { const candidates = GlobalState.get() .layouts.map((layout) => ({ + id: layout.id, tileCount: layout.tiles.length, tree: buildLayoutTree( layout.tiles.map((t) => ({ @@ -1683,6 +1697,99 @@ export class TilingManager { this._applyDynamicTiling(); } + /** + * The saved layout currently acting as dynamic tiling's template on this + * workspace — the same one `_dynamicTree` resolves to, including any + * `cycleDynamicLayout` offset, whether or not the window count matches + * its tile count exactly. `undefined` when there is nothing to place + * (no windows, or no decomposable layout at all). + */ + public getCurrentDynamicLayoutId(ws: Meta.Workspace): string | undefined { + const windowCount = this._dynamicManagedWindows(ws).length; + if (windowCount === 0) return undefined; + + const candidates = this._dynamicLayoutCandidates(); + const tileCounts = candidates.map((candidate) => candidate.tileCount); + const defaultIndex = pickLayoutIndex(tileCounts, windowCount); + if (defaultIndex < 0) return undefined; + + const offset = + this._dynamicLayoutOffset.get(ws)?.get(tileCounts[defaultIndex]) ?? + 0; + const index = pickLayoutIndexAt(tileCounts, windowCount, offset); + return index < 0 ? undefined : candidates[index].id; + } + + /** + * Every saved layout `selectDynamicLayout` would actually accept right + * now — the ones sharing the current tile-count group — so UI can grey + * out the rest instead of offering a choice that silently does nothing. + * `undefined` when there is nothing to place. + */ + public getCurrentDynamicLayoutGroupIds( + ws: Meta.Workspace, + ): Set | undefined { + const windowCount = this._dynamicManagedWindows(ws).length; + if (windowCount === 0) return undefined; + + const candidates = this._dynamicLayoutCandidates(); + const tileCounts = candidates.map((candidate) => candidate.tileCount); + const defaultIndex = pickLayoutIndex(tileCounts, windowCount); + if (defaultIndex < 0) return undefined; + + const tileCount = tileCounts[defaultIndex]; + return new Set( + candidates + .filter((candidate) => candidate.tileCount === tileCount) + .map((candidate) => candidate.id), + ); + } + + /** + * Jumps directly to a specific saved layout, the same way + * `cycleDynamicLayout` steps by one — this just computes the offset + * needed to land on `layoutId` in a single move instead of stepping. + * A no-op, returning false, when `layoutId` is not part of the + * tile-count group currently in use (there is nothing sensible to + * switch to outside that group: dynamic tiling picks the group from the + * window count, not from what is clicked). + */ + public selectDynamicLayout(ws: Meta.Workspace, layoutId: string): boolean { + if (!Settings.ENABLE_DYNAMIC_TILING) return false; + + const windowCount = this._dynamicManagedWindows(ws).length; + if (windowCount === 0) return false; + + const candidates = this._dynamicLayoutCandidates(); + const tileCounts = candidates.map((candidate) => candidate.tileCount); + const defaultIndex = pickLayoutIndex(tileCounts, windowCount); + if (defaultIndex < 0) return false; + const tileCount = tileCounts[defaultIndex]; + + const targetIndex = candidates.findIndex( + (candidate) => candidate.id === layoutId, + ); + if (targetIndex < 0 || candidates[targetIndex].tileCount !== tileCount) + return false; + + // Same group-membership search pickLayoutIndexAt does internally, + // reproduced here since it only accepts a numeric offset, not a + // target index or id. + const group = tileCounts + .map((count, index) => ({ count, index })) + .filter((entry) => entry.count === tileCount) + .map((entry) => entry.index); + const defaultPosition = group.indexOf(defaultIndex); + const targetPosition = group.indexOf(targetIndex); + + const groupOffsets = this._dynamicLayoutOffset.get(ws) ?? new Map(); + groupOffsets.set(tileCount, targetPosition - defaultPosition); + this._dynamicLayoutOffset.set(ws, groupOffsets); + + this._applyDynamicTiling(); + return true; + } + /** * Managed windows currently placeable on this monitor and workspace, in * slot order. Minimized windows stay tracked (see `_isDynamicTrackable`) diff --git a/src/extension.ts b/src/extension.ts index 7d5b8c32..ff02b2c6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -79,7 +79,11 @@ export default class TilingShellExtension extends Extension { } createIndicator() { - this._indicator = new Indicator(this.path, this.uuid); + this._indicator = new Indicator( + this.path, + this.uuid, + (monitorIndex) => this._tilingManagers[monitorIndex], + ); this._indicator.enableScaling = !this._fractionalScalingEnabled; this._indicator.enable(); this._signals?.connect(this._indicator, 'open-preferences', () => this.openPreferences()); diff --git a/src/indicator/defaultMenu.ts b/src/indicator/defaultMenu.ts index 280073dd..4c65541b 100644 --- a/src/indicator/defaultMenu.ts +++ b/src/indicator/defaultMenu.ts @@ -35,6 +35,7 @@ class LayoutsRow extends St.BoxLayout { private _layoutsBox: St.BoxLayout; private _layoutsButtons: LayoutButton[]; + private _layouts: Layout[]; private _label: St.Label; private _monitor: Monitor; @@ -44,6 +45,7 @@ class LayoutsRow extends St.BoxLayout { selectedId: string, showMonitorName: boolean, monitor: Monitor, + selectableIds?: Set, ) { super({ xAlign: Clutter.ActorAlign.CENTER, @@ -71,6 +73,7 @@ class LayoutsRow extends St.BoxLayout { parent.add_child(this); + this._layouts = layouts; const selectedIndex = layouts.findIndex((lay) => lay.id === selectedId); const hasGaps = Settings.get_inner_gaps(1).top > 0; @@ -90,6 +93,8 @@ class LayoutsRow extends St.BoxLayout { () => !btn.checked && this.emit('selected-layout', lay.id), ); if (ind === selectedIndex) btn.set_checked(true); + if (selectableIds && !selectableIds.has(lay.id)) + btn.setDisabled(true); return btn; }); } @@ -103,6 +108,20 @@ class LayoutsRow extends St.BoxLayout { ); } + /** + * While dynamic tiling is on, only layouts sharing the current + * tile-count group can actually be switched to (see + * `TilingManager.selectDynamicLayout`) — everything else is disabled so + * it doesn't look clickable when it isn't. `undefined` (dynamic tiling + * off, or nothing placed yet) re-enables every button. + */ + public setSelectable(selectableIds: Set | undefined) { + this._layoutsButtons.forEach((btn, ind) => { + const lay = this._layouts[ind]; + btn.setDisabled(!!selectableIds && !selectableIds.has(lay.id)); + }); + } + public updateMonitorName( showMonitorName: boolean, monitorsDetails: { @@ -175,6 +194,9 @@ export default class DefaultMenu implements CurrentMenu { Settings.KEY_ENABLE_DYNAMIC_TILING, () => { dynamicToggle.setToggleState(Settings.ENABLE_DYNAMIC_TILING); + // switching sources (static vs. dynamic template) for the + // highlight, not just the checked state of this one switch + this._refreshSelectedLayouts(); }, ); (this._indicator.menu as PopupMenu.PopupMenu).addMenuItem( @@ -232,33 +254,24 @@ export default class DefaultMenu implements CurrentMenu { if (this._layoutsRows.length !== getMonitors().length) this._drawLayouts(); - const selected_layouts = Settings.get_selected_layouts(); - const wsIndex = - global.workspaceManager.get_active_workspace_index(); - getMonitors().forEach((m, index) => { - const selectedId = - wsIndex < selected_layouts.length - ? selected_layouts[wsIndex][index] - : GlobalState.get().layouts[0].id; - this._layoutsRows[index].selectLayout(selectedId); - }); + this._refreshSelectedLayouts(); }, ); this._signals.connect( global.workspaceManager, 'active-workspace-changed', - () => { - const selected_layouts = Settings.get_selected_layouts(); - const wsIndex = - global.workspaceManager.get_active_workspace_index(); - getMonitors().forEach((m, index) => { - const selectedId = - wsIndex < selected_layouts.length - ? selected_layouts[wsIndex][index] - : GlobalState.get().layouts[0].id; - this._layoutsRows[index].selectLayout(selectedId); - }); + () => this._refreshSelectedLayouts(), + ); + + // the menu is normally closed, so the highlight can go stale (e.g. + // dynamic tiling picking a different layout as windows open/close) + // without anything above firing; refresh right as it is opened + this._signals.connect( + this._indicator.menu, + 'open-state-changed', + (_menu: unknown, isOpen: boolean) => { + if (isOpen) this._refreshSelectedLayouts(); }, ); @@ -441,24 +454,21 @@ export default class DefaultMenu implements CurrentMenu { this._container.destroy_all_children(); this._layoutsRows = []; - const selected_layouts = Settings.get_selected_layouts(); const ws_index = global.workspaceManager.get_active_workspace_index(); const monitors = getMonitors(); this._layoutsRows = monitors.map((monitor) => { - const ws_selected_layouts = - ws_index < selected_layouts.length - ? selected_layouts[ws_index] - : []; - const selectedId = - monitor.index < ws_selected_layouts.length - ? ws_selected_layouts[monitor.index] - : GlobalState.get().layouts[0].id; + const selectedId = this._selectedIdFor(monitor.index, ws_index); + const selectableIds = this._selectableIdsFor( + monitor.index, + ws_index, + ); const row = new LayoutsRow( this._container, layouts, selectedId, monitors.length > 1, monitor, + selectableIds, ); row.connect( 'selected-layout', @@ -473,6 +483,62 @@ export default class DefaultMenu implements CurrentMenu { }); } + /** + * The layout that should show as selected for a monitor: while dynamic + * tiling is on and actually placing windows on that monitor's + * workspace, this is whichever saved layout it is using as its + * template right now (see `TilingManager.getCurrentDynamicLayoutId`) — + * otherwise it falls back to the static per-monitor selection. + */ + private _selectedIdFor(monitorIndex: number, wsIndex: number): string { + if (Settings.ENABLE_DYNAMIC_TILING) { + const ws = global.workspaceManager.get_workspace_by_index(wsIndex); + const dynamicId = ws + ? this._indicator + .getTilingManager(monitorIndex) + ?.getCurrentDynamicLayoutId(ws) + : undefined; + if (dynamicId) return dynamicId; + } + + const selected_layouts = Settings.get_selected_layouts(); + const ws_selected_layouts = + wsIndex < selected_layouts.length ? selected_layouts[wsIndex] : []; + return monitorIndex < ws_selected_layouts.length + ? ws_selected_layouts[monitorIndex] + : GlobalState.get().layouts[0].id; + } + + /** + * Which layouts are actually switchable to for a monitor right now — + * `undefined` (every button enabled) unless dynamic tiling is on and + * has a tile-count group to restrict to. + */ + private _selectableIdsFor( + monitorIndex: number, + wsIndex: number, + ): Set | undefined { + if (!Settings.ENABLE_DYNAMIC_TILING) return undefined; + const ws = global.workspaceManager.get_workspace_by_index(wsIndex); + return ws + ? this._indicator + .getTilingManager(monitorIndex) + ?.getCurrentDynamicLayoutGroupIds(ws) + : undefined; + } + + private _refreshSelectedLayouts() { + const wsIndex = global.workspaceManager.get_active_workspace_index(); + getMonitors().forEach((m, index) => { + this._layoutsRows[index]?.selectLayout( + this._selectedIdFor(m.index, wsIndex), + ); + this._layoutsRows[index]?.setSelectable( + this._selectableIdsFor(m.index, wsIndex), + ); + }); + } + public destroy() { this._signals.disconnect(); this._layoutsRows.forEach((lr) => lr.destroy()); diff --git a/src/indicator/indicator.ts b/src/indicator/indicator.ts index e1a30bf4..7a590f43 100644 --- a/src/indicator/indicator.ts +++ b/src/indicator/indicator.ts @@ -12,6 +12,7 @@ import EditorDialog from '../components/editor/editorDialog'; import CurrentMenu from './currentMenu'; import { registerGObjectClass } from '../utils/gjs'; import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; +import type { TilingManager } from '../components/tilingsystem/tilingManager'; enum IndicatorState { DEFAULT = 1, @@ -36,10 +37,18 @@ export default class Indicator extends PanelMenu.Button { private _enableScaling: boolean; private _path: string; private _keyPressEvent: number | null; - - constructor(path: string, uuid: string) { + private _getTilingManager: ( + monitorIndex: number, + ) => TilingManager | undefined; + + constructor( + path: string, + uuid: string, + getTilingManager: (monitorIndex: number) => TilingManager | undefined, + ) { super(0.5, 'Tiling Shell Indicator', false); Main.panel.addToStatusArea(uuid, this, 1, 'right'); + this._getTilingManager = getTilingManager; // Bind the "show-indicator" setting to the "visible" property Settings.bind( @@ -72,6 +81,10 @@ export default class Indicator extends PanelMenu.Button { return this._path; } + public getTilingManager(monitorIndex: number): TilingManager | undefined { + return this._getTilingManager(monitorIndex); + } + public set enableScaling(value: boolean) { if (this._enableScaling === value) return; this._enableScaling = value; @@ -88,6 +101,24 @@ export default class Indicator extends PanelMenu.Button { } public selectLayoutOnClick(monitorIndex: number, layoutToSelectId: string) { + if (Settings.ENABLE_DYNAMIC_TILING) { + const ws = global.workspaceManager.get_active_workspace(); + const switched = + ws && + this.getTilingManager(monitorIndex)?.selectDynamicLayout( + ws, + layoutToSelectId, + ); + if (switched) { + this.menu.toggle(); + return; + } + // Not part of the tile-count group dynamic tiling is currently + // using for this workspace: nothing sensible to switch to, so + // fall through and leave the static selection as a record of + // preference for when dynamic tiling is turned off. + } + GlobalState.get().setSelectedLayoutOfMonitor( layoutToSelectId, monitorIndex, diff --git a/src/indicator/layoutButton.ts b/src/indicator/layoutButton.ts index dd388df7..fadd7392 100644 --- a/src/indicator/layoutButton.ts +++ b/src/indicator/layoutButton.ts @@ -65,4 +65,11 @@ export default class LayoutButton extends St.Button { width * scalingFactor, ); } + + public setDisabled(disabled: boolean) { + this.reactive = !disabled; + this.can_focus = !disabled; + if (disabled) this.add_style_class_name('layout-button-disabled'); + else this.remove_style_class_name('layout-button-disabled'); + } } diff --git a/src/styles/layout_button.scss b/src/styles/layout_button.scss index 1f8e5cd0..ab0f69e1 100644 --- a/src/styles/layout_button.scss +++ b/src/styles/layout_button.scss @@ -37,3 +37,10 @@ background-color: #c4c4c4; } } + +// dynamic tiling limits switching to layouts sharing the current tile-count +// group (see TilingManager.selectDynamicLayout); everything else is dimmed +// and non-reactive rather than looking clickable and silently doing nothing +.layout-button.layout-button-disabled { + opacity: 0.35; +} diff --git a/src/styles/snap_assist.scss b/src/styles/snap_assist.scss index 29bc2cef..93857e72 100644 --- a/src/styles/snap_assist.scss +++ b/src/styles/snap_assist.scss @@ -34,3 +34,20 @@ $snap-assist-tile-border-radius: 6px; border-color: rgba(94, 94, 94, 0.7); background-color: rgba(154, 154, 154, 0.6); } + +// the layout dynamic tiling is currently using as its template. A border +// around the whole thumbnail rather than the individual tiles, following +// the same pattern .layout-button uses for its own :checked state, so it +// doesn't collide with (and blank out) each tile's own hover feedback. The +// border space is reserved even when unselected so selecting one doesn't +// shift its size. +.snap-assist-layout { + border-style: solid; + border-width: 2px; + border-radius: constants.$base_border_radius; + border-color: transparent; +} + +.snap-assist-layout.selected { + border-color: rgba(255, 255, 255, 0.7); +} From 529714a9360d59fc7fa4d66b228d72ed3f3f6ac4 Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 13 Sep 2026 16:33:24 +0530 Subject: [PATCH 33/42] fix(indicator): dim non-group layouts via actor opacity, refresh after the dynamic toggle settles St has no CSS opacity property, so the .layout-button-disabled rule never rendered; the dimming is now the actor's opacity, and can_focus is only touched while a button is disabled. The highlight refresh on the dynamic tiling toggle is deferred to an idle so the TilingManagers (which listen to the same setting) have adopted the open windows first. The snap-assist layouts leave room for their 2px selection ring instead of drawing the tiles over it. --- src/components/snapassist/snapAssist.ts | 6 +++++- src/indicator/defaultMenu.ts | 24 +++++++++++++++++++++--- src/indicator/layoutButton.ts | 16 +++++++++++++--- src/styles/layout_button.scss | 7 ------- 4 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/components/snapassist/snapAssist.ts b/src/components/snapassist/snapAssist.ts index 11b7aa4b..28eb4116 100644 --- a/src/components/snapassist/snapAssist.ts +++ b/src/components/snapassist/snapAssist.ts @@ -21,6 +21,8 @@ const GAPS = 4; // 16:9 ratio and then rounded to int const SNAP_ASSIST_LAYOUT_WIDTH = 120; const SNAP_ASSIST_LAYOUT_HEIGHT = 68; +// keep in sync with .snap-assist-layout border-width in snap_assist.scss +const SNAP_ASSIST_LAYOUT_BORDER = 2; class SnapAssistContent extends St.BoxLayout { static { registerGObjectClass(this, { @@ -246,7 +248,9 @@ class SnapAssistContent extends St.BoxLayout { this, lay, layoutGaps, - new Clutter.Margin(), + // the tiles are placed at fixed positions inside the + // widget, so leave room for its 2px selection border + buildMarginOf(SNAP_ASSIST_LAYOUT_BORDER), width, height, ); diff --git a/src/indicator/defaultMenu.ts b/src/indicator/defaultMenu.ts index 4c65541b..0a28c7be 100644 --- a/src/indicator/defaultMenu.ts +++ b/src/indicator/defaultMenu.ts @@ -1,4 +1,4 @@ -import { GObject, St, Clutter, Gio } from '../gi/ext'; +import { GObject, St, Clutter, Gio, GLib } from '../gi/ext'; import SignalHandling from '../utils/signalHandling'; import Indicator from './indicator'; import * as Main from 'resource:///org/gnome/shell/ui/main.js'; @@ -195,8 +195,11 @@ export default class DefaultMenu implements CurrentMenu { () => { dynamicToggle.setToggleState(Settings.ENABLE_DYNAMIC_TILING); // switching sources (static vs. dynamic template) for the - // highlight, not just the checked state of this one switch - this._refreshSelectedLayouts(); + // highlight, not just the checked state of this one switch. + // Deferred: the TilingManagers listen to the same setting + // and, depending on who connected first, may only adopt the + // open windows after this handler ran + this._queueRefreshSelectedLayouts(); }, ); (this._indicator.menu as PopupMenu.PopupMenu).addMenuItem( @@ -539,7 +542,22 @@ export default class DefaultMenu implements CurrentMenu { }); } + private _refreshSourceId: number | null = null; + + private _queueRefreshSelectedLayouts() { + if (this._refreshSourceId !== null) return; + this._refreshSourceId = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + this._refreshSourceId = null; + this._refreshSelectedLayouts(); + return GLib.SOURCE_REMOVE; + }); + } + public destroy() { + if (this._refreshSourceId !== null) { + GLib.Source.remove(this._refreshSourceId); + this._refreshSourceId = null; + } this._signals.disconnect(); this._layoutsRows.forEach((lr) => lr.destroy()); this._layoutsRows = []; diff --git a/src/indicator/layoutButton.ts b/src/indicator/layoutButton.ts index fadd7392..e83bf2a6 100644 --- a/src/indicator/layoutButton.ts +++ b/src/indicator/layoutButton.ts @@ -66,10 +66,20 @@ export default class LayoutButton extends St.Button { ); } + private _canFocusWhenEnabled: boolean | null = null; + public setDisabled(disabled: boolean) { this.reactive = !disabled; - this.can_focus = !disabled; - if (disabled) this.add_style_class_name('layout-button-disabled'); - else this.remove_style_class_name('layout-button-disabled'); + if (disabled) { + if (this._canFocusWhenEnabled === null) + this._canFocusWhenEnabled = this.can_focus; + this.can_focus = false; + } else if (this._canFocusWhenEnabled !== null) { + this.can_focus = this._canFocusWhenEnabled; + this._canFocusWhenEnabled = null; + } + // St's CSS has no opacity property, so the dimming is an actor + // property rather than a style class + this.opacity = disabled ? 89 : 255; } } diff --git a/src/styles/layout_button.scss b/src/styles/layout_button.scss index ab0f69e1..1f8e5cd0 100644 --- a/src/styles/layout_button.scss +++ b/src/styles/layout_button.scss @@ -37,10 +37,3 @@ background-color: #c4c4c4; } } - -// dynamic tiling limits switching to layouts sharing the current tile-count -// group (see TilingManager.selectDynamicLayout); everything else is dimmed -// and non-reactive rather than looking clickable and silently doing nothing -.layout-button.layout-button-disabled { - opacity: 0.35; -} From 12b35c615ed0c9dcc1676a7fd1dbd5db57145899 Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 13 Sep 2026 16:43:16 +0530 Subject: [PATCH 34/42] chore(keybindings): cycle dynamic layouts with Shift+Super+; only Super+; is GNOME's emoji picker; the forward cycle now defaults to Shift+Super+; and the backward binding is unset by default (still configurable in the preferences). --- README.md | 3 +-- .../org.gnome.shell.extensions.tilingshell.gschema.xml | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index bdf9e118..d9980e40 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,7 @@ gives its space back to whatever was sharing it. No manual tiling needed. | Shortcut | Action | |---|---| | SUPER+←/↑/↓/→ | Swap the focused window with the region next to it | -| SUPER+; | Cycle to the next layout with the same number of tiles | -| SHIFT+SUPER+; | Cycle to the previous layout with the same number of tiles | +| SHIFT+SUPER+; | Cycle to the next layout with the same number of tiles (SUPER+; is left to GNOME's emoji picker; a backward binding can be set in the preferences) | | Drag a window onto another | Swap their positions | In dynamic mode, SUPER+arrows swaps windows instead of moving to a diff --git a/resources/schemas/org.gnome.shell.extensions.tilingshell.gschema.xml b/resources/schemas/org.gnome.shell.extensions.tilingshell.gschema.xml index 641be372..4e88debd 100644 --- a/resources/schemas/org.gnome.shell.extensions.tilingshell.gschema.xml +++ b/resources/schemas/org.gnome.shell.extensions.tilingshell.gschema.xml @@ -295,11 +295,11 @@ Cycle backwards through available workspace layouts - semicolon']]]> + semicolon']]]> In dynamic tiling, use the next layout of the same size - semicolon']]]> + In dynamic tiling, use the previous layout of the same size From a511d0c300857b3e7418bf7920f94e1e39c9e3bb Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 13 Sep 2026 16:55:25 +0530 Subject: [PATCH 35/42] feat(dynamic): preview the slot a dragged window will actually be dropped into With dynamic tiling the drop swaps the window into the slot under the pointer, but the blue preview still showed the hovered tile of the saved layout, which matches neither a collapsed nor a subdivided tree. The preview now follows the dynamic slot (own slot when the pointer is outside all of them), the drop and the preview share one slotUnderPoint lookup, and the snap-assist popup's tiles no longer drive the preview during such a drag (the popup keeps highlighting the layout in use). --- src/components/layout/dynamic/reflow.test.ts | 18 ++- src/components/layout/dynamic/reflow.ts | 25 +++ src/components/tilingsystem/tilingManager.ts | 161 +++++++++++++++---- 3 files changed, 172 insertions(+), 32 deletions(-) diff --git a/src/components/layout/dynamic/reflow.test.ts b/src/components/layout/dynamic/reflow.test.ts index 21c2998c..e1217f58 100644 --- a/src/components/layout/dynamic/reflow.test.ts +++ b/src/components/layout/dynamic/reflow.test.ts @@ -2,7 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { buildLayoutTree } from './layoutTree.ts'; import type { TileRect } from './layoutTree.ts'; -import { reflow, slotOrder, neighbourIndex, assign } from './reflow.ts'; +import { reflow, slotOrder, neighbourIndex, assign, slotUnderPoint } from './reflow.ts'; const twoColumns = () => buildLayoutTree([ @@ -258,3 +258,19 @@ test('assign is deterministic and total for every window count', () => { assert.ok(Math.abs(covered - 1) < 1e-9, `coverage for ${n}`); } }); + +test('slotUnderPoint: maps a screen point to the slot whose rect contains it', () => { + const workArea = { x: 0, y: 32, width: 1920, height: 1048 }; + const rects = [ + { x: 0, y: 0, width: 0.5, height: 1 }, + { x: 0.5, y: 0, width: 0.5, height: 0.5 }, + { x: 0.5, y: 0.5, width: 0.5, height: 0.5 }, + ]; + assert.equal(slotUnderPoint(rects, workArea, { x: 100, y: 100 }), 0); + assert.equal(slotUnderPoint(rects, workArea, { x: 1500, y: 100 }), 1); + assert.equal(slotUnderPoint(rects, workArea, { x: 1500, y: 900 }), 2); + // outside the work area (the top panel) + assert.equal(slotUnderPoint(rects, workArea, { x: 100, y: 10 }), -1); + // the last pixel of the work area still belongs to the last slot + assert.equal(slotUnderPoint(rects, workArea, { x: 1919, y: 1079 }), 2); +}); diff --git a/src/components/layout/dynamic/reflow.ts b/src/components/layout/dynamic/reflow.ts index bb01b4ab..8dd48a97 100644 --- a/src/components/layout/dynamic/reflow.ts +++ b/src/components/layout/dynamic/reflow.ts @@ -253,3 +253,28 @@ export function leavesOf(tree: SplitTree): TileRect[] { if (tree.kind === 'leaf') return [tree.tile]; return [...leavesOf(tree.first), ...leavesOf(tree.second)]; } + +/** + * The index of the slot whose screen rectangle contains `point`, or -1. + * `rects` are the normalised (0..1) slot rectangles of `assign()`, scaled + * to `workArea` the same way TileUtils.apply_props does (rounded), so a + * drop and its preview agree on which slot the pointer is over. + */ +export function slotUnderPoint( + rects: TileRect[], + workArea: { x: number; y: number; width: number; height: number }, + point: { x: number; y: number }, +): number { + return rects.findIndex((r) => { + const x = Math.round(workArea.x + r.x * workArea.width); + const y = Math.round(workArea.y + r.y * workArea.height); + const width = Math.round(r.width * workArea.width); + const height = Math.round(r.height * workArea.height); + return ( + point.x >= x && + point.x < x + width && + point.y >= y && + point.y < y + height + ); + }); +} diff --git a/src/components/tilingsystem/tilingManager.ts b/src/components/tilingsystem/tilingManager.ts index 697bf0cb..05426adb 100644 --- a/src/components/tilingsystem/tilingManager.ts +++ b/src/components/tilingsystem/tilingManager.ts @@ -23,7 +23,7 @@ import Layout from '../layout/Layout'; import Tile from '../layout/Tile'; import TileUtils from '../layout/TileUtils'; import { buildLayoutTree, SplitTree } from '../layout/dynamic/layoutTree'; -import { assign, neighbourIndex } from '../layout/dynamic/reflow'; +import { assign, neighbourIndex, slotUnderPoint } from '../layout/dynamic/reflow'; import type { Direction } from '../layout/dynamic/reflow'; import { pickLayoutIndex, pickLayoutIndexAt } from '../layout/dynamic/pickLayout'; import GlobalState from '../../utils/globalState'; @@ -128,6 +128,10 @@ export class TilingManager { private readonly _placer: WindowPlacer; // Defers a reflow requested from inside a running reflow. private readonly _reflow: ReflowScheduler; + // While a dynamically managed window is dragged: the slot whose preview + // is on screen (null when none), so the preview is only redrawn when the + // pointer crosses into another slot. + private _dynamicPreviewSlot: number | null = null; private readonly _signals: SignalHandling; private readonly _debug: (..._content: unknown[]) => void; @@ -837,6 +841,20 @@ export class TilingManager { this._wasTilingSystemActivated = isTilingSystemActivated; this._wasSpanMultipleTilesActivated = isSpanMultiTilesActivated; + // a dynamically managed window is dropped into the slot under the + // pointer, whatever the static layout or the snap assistant would + // suggest, so that slot is what gets previewed + if ( + Settings.ENABLE_DYNAMIC_TILING && + this._previewDynamicSlot( + window, + currentWs, + currPointerPos, + tilingLayout, + ) + ) + return GLib.SOURCE_CONTINUE; + // layout must not be shown if it was disabled or if it is enabled but tiling system activation key is not pressed // then close it and open snap assist (if enabled) if (!showTilingSystem) { @@ -948,6 +966,7 @@ export class TilingManager { this._selectedTilesPreview.close(true); this._snapAssist.close(true); this._lastCursorPos = null; + this._dynamicPreviewSlot = null; // mutter ends the grab of a window that is being closed; its actor is // already gone by then, and there is nothing left to place @@ -1141,6 +1160,11 @@ export class TilingManager { } private _onSnapAssist(_: SnapAssist, tile: Tile, layoutId: string) { + // during a dynamic drag the popup only shows which layout is in use; + // its tiles say nothing about where the drop lands (see + // _previewDynamicSlot), so they must not drive the preview + if (this._dynamicPreviewSlot !== null) return; + // if there isn't a tile hovered, then close selection if (tile.width === 0 || tile.height === 0) { this._selectedTilesPreview.close(true); @@ -1293,15 +1317,15 @@ export class TilingManager { this.openSelectionTilePreview(edgeTile, false, true, window); } - private _easeWindowRectFromTile( + /** + * The rectangle a window gets for a tile: the tile scaled to the work + * area, clamped to it, minus the configured gaps. Null when nothing is + * left after the gaps. + */ + private _windowRectForTile( tile: Tile, - window: Meta.Window, - skipAnimation: boolean = false, - ) { - const currentWs = window.get_workspace(); - const tilingLayout = this._workspaceTilingLayout.get(currentWs); - if (!tilingLayout) return; - + tilingLayout: TilingLayout, + ): Mtk.Rectangle | null { // We apply the proportions to get tile size and position relative to the work area const scaledRect = TileUtils.apply_props(tile, this._workArea); // ensure the rect doesn't go horizontally beyond the workarea @@ -1337,15 +1361,102 @@ export class TilingManager { : undefined, ).gaps; - const destinationRect = buildRectangle({ + const rect = buildRectangle({ x: scaledRect.x + gaps.left, y: scaledRect.y + gaps.top, width: scaledRect.width - gaps.left - gaps.right, height: scaledRect.height - gaps.top - gaps.bottom, }); + return rect.width <= 0 || rect.height <= 0 ? null : rect; + } + /** + * The managed windows of a workspace and the slot rectangles dynamic + * tiling gives them, or null when there is nothing to place. + */ + private _dynamicSlots( + ws: Meta.Workspace, + ): { windows: Meta.Window[]; rects: ReturnType } | null { + const windows = this._dynamicManagedWindows(ws); + if (windows.length === 0) return null; + const tree = this._dynamicTree(windows.length, ws); + if (!tree) return null; + const splitSlot = this._splitTarget + ? windows.indexOf(this._splitTarget) + : -1; + return { + windows, + rects: assign( + tree, + windows.length, + splitSlot >= 0 ? splitSlot : undefined, + ), + }; + } + + /** + * Previews, for a dragged window dynamic tiling manages, the slot it + * would be dropped into: the one under the pointer, or its own when the + * pointer is outside every slot. Returns false when the window is not + * managed, leaving the static previews to their usual logic. + */ + private _previewDynamicSlot( + window: Meta.Window, + ws: Meta.Workspace, + pointer: { x: number; y: number }, + tilingLayout: TilingLayout, + ): boolean { + const slots = this._dynamicSlots(ws); + const from = slots ? slots.windows.indexOf(window) : -1; + if (!slots || from < 0) return false; + + // none of the static machinery applies to this drop + if (tilingLayout.showing) tilingLayout.close(); + if (this._edgeTilingManager.isPerformingEdgeTiling()) + this._edgeTilingManager.abortEdgeTiling(); + this._snapAssistingInfo.update(undefined); + + // the popup stays: it shows (and highlights) the layout in use + if (Settings.SNAP_ASSIST) { + this._snapAssist.setDynamicLayoutId( + this.getCurrentDynamicLayoutId(ws), + ); + this._snapAssist.onMovingWindow(window, pointer, true); + } else { + this._snapAssist.close(true); + } + + const under = slotUnderPoint(slots.rects, this._workArea, pointer); + const to = under >= 0 ? under : from; + if (to === this._dynamicPreviewSlot) return true; + + const rect = this._windowRectForTile( + this._tileOf(slots.rects[to]), + tilingLayout, + ); + if (rect) { + this._selectedTilesPreview.open( + rect, + this._dynamicPreviewSlot !== null, + ); + this._dynamicPreviewSlot = to; + } + return true; + } + + private _easeWindowRectFromTile( + tile: Tile, + window: Meta.Window, + skipAnimation: boolean = false, + ) { + const currentWs = window.get_workspace(); + const tilingLayout = this._workspaceTilingLayout.get(currentWs); + if (!tilingLayout) return; + + const scaledRect = TileUtils.apply_props(tile, this._workArea); + const destinationRect = this._windowRectForTile(tile, tilingLayout); // abort if there is an invalid selection - if (destinationRect.width <= 0 || destinationRect.height <= 0) return; + if (!destinationRect) return; const isMaximized = window.maximizedHorizontally || window.maximizedVertically; @@ -1893,12 +2004,10 @@ export class TilingManager { const ws = global.workspaceManager.get_active_workspace(); if (!ws) return false; - const windows = this._dynamicManagedWindows(ws); - const from = windows.indexOf(window); - if (from < 0) return false; - - const tree = this._dynamicTree(windows.length, ws); - if (!tree) return false; + const slots = this._dynamicSlots(ws); + const from = slots ? slots.windows.indexOf(window) : -1; + if (!slots || from < 0) return false; + const { windows, rects } = slots; // nothing to exchange with, but the window still belongs in its slot if (windows.length < 2) { @@ -1906,21 +2015,11 @@ export class TilingManager { return true; } - const splitSlot = this._splitTarget - ? windows.indexOf(this._splitTarget) - : -1; - const rects = assign( - tree, - windows.length, - splitSlot >= 0 ? splitSlot : undefined, - ); const [pointerX, pointerY] = global.get_pointer(); - const to = rects.findIndex((rect) => - isPointInsideRect( - { x: pointerX, y: pointerY }, - TileUtils.apply_props(this._tileOf(rect), this._workArea), - ), - ); + const to = slotUnderPoint(rects, this._workArea, { + x: pointerX, + y: pointerY, + }); if (to >= 0 && to !== from) { const a = this._dynamicWindows.indexOf(windows[from]); From 2772755ed28cd5530a67147ce219e64e4cce83ab Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 13 Sep 2026 17:59:32 +0530 Subject: [PATCH 36/42] feat(dynamic): grow the slot of a window whose client refuses to shrink Brave will not go below 500 px wide; in a 492 px slot it stayed 8 px too wide and ate the gap. Once the placer's retries are spent (onSettled now says whether an answer is final), the window's refused size is recorded as a pin; at every reflow the slot rects from assign() are rebuilt into a split tree and the pinned leaves are grown with pinLeaf (ported from feature/forced-window-resize, with its tests), so the neighbours give way and every gap stays exact. A pin is dropped again when the client accepts a smaller size, or when the window is untracked. Reflow, drop and drag preview all read the pinned slots from one place (_dynamicSlots). --- .../layout/dynamic/layoutTree.test.ts | 134 ++++++++++- src/components/layout/dynamic/layoutTree.ts | 209 +++++++++++++++++- src/components/layout/dynamic/pins.test.ts | 81 +++++++ src/components/layout/dynamic/pins.ts | 63 ++++++ src/components/tilingsystem/tilingManager.ts | 118 +++++++--- src/components/tilingsystem/windowPlacer.ts | 15 +- test/mutter-sim/placement.test.ts | 17 ++ 7 files changed, 605 insertions(+), 32 deletions(-) create mode 100644 src/components/layout/dynamic/pins.test.ts create mode 100644 src/components/layout/dynamic/pins.ts diff --git a/src/components/layout/dynamic/layoutTree.test.ts b/src/components/layout/dynamic/layoutTree.test.ts index 7e869ac5..91896dde 100644 --- a/src/components/layout/dynamic/layoutTree.test.ts +++ b/src/components/layout/dynamic/layoutTree.test.ts @@ -1,6 +1,14 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { buildLayoutTree } from './layoutTree.ts'; +import { + buildLayoutTree, + pinLeaf, + pathOf, + rectAtPath, + type Split, + type SplitTree, +} from './layoutTree.ts'; +import { boundsOf, assign } from './reflow.ts'; const leaf = (x: number, y: number, width: number, height: number) => ({ kind: 'leaf', @@ -101,3 +109,127 @@ test('a pinwheel layout cannot be decomposed and returns null', () => { assert.equal(tree, null); }); + +test('pinLeaf adjusts the cut line for a simple split', () => { + const tree = buildLayoutTree([ + { x: 0, y: 0, width: 0.5, height: 1 }, + { x: 0.5, y: 0, width: 0.5, height: 1 }, + ]); + + const pinnedTree = pinLeaf(tree!, ['first'], 0.7, 'x'); + + assert.equal(pinnedTree.kind, 'split'); + if (pinnedTree.kind === 'split') { + assert.equal(pinnedTree.at, 0.7); + assert.equal(boundsOf(pinnedTree.first).width, 0.7); + assert.ok(Math.abs(boundsOf(pinnedTree.second).width - 0.3) < 1e-9); + } +}); + +test('pinLeaf cascades changes to parents when necessary', () => { + const tree = buildLayoutTree([ + { x: 0, y: 0, width: 0.25, height: 1 }, + { x: 0.25, y: 0, width: 0.25, height: 1 }, + { x: 0.5, y: 0, width: 0.5, height: 1 }, + ]); + + // Tree structure built by buildLayoutTree: + // x at 0.25 + // first: leaf 0.25 + // second: split x at 0.5 + // first: leaf 0.25 + // second: leaf 0.5 + + // We pin the first leaf (which has width 0.25) to 0.8 + // This is wider than its current bounds (0.25). + const pinnedTree = pinLeaf(tree!, ['first'], 0.8, 'x'); + + // The first leaf should get exactly 0.8 + // The second split gets the remaining 0.2 space, keeping proportions + + assert.equal(pinnedTree.kind, 'split'); + if (pinnedTree.kind === 'split') { + assert.ok(Math.abs(pinnedTree.at - 0.8) < 1e-9); + assert.equal(boundsOf(pinnedTree.first).width, 0.8); + assert.ok(Math.abs(boundsOf(pinnedTree.second).width - 0.2) < 1e-9); + } +}); + +test('two pins in the same subtree without overlap', () => { + const tree = buildLayoutTree([ + { x: 0, y: 0, width: 0.25, height: 1 }, + { x: 0.25, y: 0, width: 0.25, height: 1 }, + { x: 0.5, y: 0, width: 0.5, height: 1 }, + ]); + + let pinnedTree = pinLeaf(tree!, ['first'], 0.4, 'x'); + pinnedTree = pinLeaf(pinnedTree, ['second', 'second'], 0.5, 'x'); + + assert.equal(pinnedTree.kind, 'split'); + if (pinnedTree.kind === 'split') { + assert.ok(Math.abs(pinnedTree.at - 0.4) < 1e-9); + assert.equal(boundsOf(pinnedTree.first).width, 0.4); + + const secondSplit = pinnedTree.second as Split; + assert.equal(secondSplit.kind, 'split'); + assert.ok(Math.abs(secondSplit.at - 0.5) < 1e-9); + assert.ok(Math.abs(boundsOf(secondSplit.first).width - 0.1) < 1e-9); + assert.ok(Math.abs(boundsOf(secondSplit.second).width - 0.5) < 1e-9); + } +}); + +test('pin removal returning to original proportions', () => { + const tree = buildLayoutTree([ + { x: 0, y: 0, width: 0.5, height: 1 }, + { x: 0.5, y: 0, width: 0.5, height: 1 }, + ]); + + const pinnedTree = pinLeaf(tree!, ['first'], 0.8, 'x'); + assert.notEqual(tree, pinnedTree); + + // original tree should be unmutated + assert.equal((tree as Split).at, 0.5); + assert.equal(boundsOf((tree as Split).first).width, 0.5); +}); + +test('item C: reading rects by path preserves unpinned slot identities', () => { + // 3 columns: 0.25, 0.25, 0.5 + const tree = buildLayoutTree([ + { x: 0, y: 0, width: 0.25, height: 1 }, + { x: 0.25, y: 0, width: 0.25, height: 1 }, + { x: 0.5, y: 0, width: 0.5, height: 1 }, + ]) as SplitTree; + + // assign without pin + const unpinnedRects = assign(tree, 3); + const paths = unpinnedRects.map((r) => pathOf(tree, r)); + + // pin slot 1 (the middle 0.25 column, which assign() might order differently) + // Actually, assign() orders by area, so 0.5 is slot 0, and the two 0.25s are slots 1 and 2. + // Let's just pin whatever is at slot 1 to width 0.6 + const path1 = paths[1]!; + const pinnedTree = pinLeaf(tree, path1, 0.6, 'x'); + + // Read final rects by path + const finalRects = paths.map((p) => rectAtPath(pinnedTree, p!)); + + // slot 0 should still be the 0.5 leaf (now shrunken), slot 2 should still be the other 0.25 leaf + // We mainly assert that finalRects has 3 items and they don't overlap, but crucially + // that slot 1's rect actually has width 0.6 + assert.equal(finalRects.length, 3); + assert.ok(Math.abs(finalRects[1]!.width - 0.6) < 1e-9); +}); + +test('item A: pathOf skips when target is a union of leaves', () => { + // 2 leaves + const tree = buildLayoutTree([ + { x: 0, y: 0, width: 0.5, height: 1 }, + { x: 0.5, y: 0, width: 0.5, height: 1 }, + ]) as SplitTree; + + // if we ask for 1 window, assign() gives the whole area + const unpinnedRects = assign(tree, 1); + const path = pathOf(tree, unpinnedRects[0]); + // pathOf should return null because the rect spans multiple leaves + assert.equal(path, null); +}); diff --git a/src/components/layout/dynamic/layoutTree.ts b/src/components/layout/dynamic/layoutTree.ts index a5744b94..88c39bdf 100644 --- a/src/components/layout/dynamic/layoutTree.ts +++ b/src/components/layout/dynamic/layoutTree.ts @@ -32,6 +32,8 @@ export interface Split { export type SplitTree = Leaf | Split; +export type SplitPath = ('first' | 'second')[]; + // Layout coordinates come from a UI that drags dividers around, so exact // equality is never safe. const EPSILON = 1e-6; @@ -53,7 +55,9 @@ export function buildLayoutTree(tiles: TileRect[]): SplitTree | null { ].sort((a, b) => a - b); for (const at of candidates) { - const before = tiles.filter((t) => t[axis] + t[extent] <= at + EPSILON); + const before = tiles.filter( + (t) => t[axis] + t[extent] <= at + EPSILON, + ); const after = tiles.filter((t) => t[axis] >= at - EPSILON); // A tile landing in neither group straddles the line, so this is @@ -72,3 +76,206 @@ export function buildLayoutTree(tiles: TileRect[]): SplitTree | null { return null; } + +/** Returns the path to a target rect, or null if it cannot be found. */ +export function pathOf(tree: SplitTree, target: TileRect): SplitPath | null { + function walk(node: SplitTree, path: SplitPath): SplitPath | null { + if (node.kind === 'leaf') { + if ( + target.x >= node.tile.x - EPSILON && + target.y >= node.tile.y - EPSILON && + target.x + target.width <= + node.tile.x + node.tile.width + EPSILON && + target.y + target.height <= + node.tile.y + node.tile.height + EPSILON + ) { + return path; + } + return null; + } + return ( + walk(node.first, [...path, 'first']) || + walk(node.second, [...path, 'second']) + ); + } + return walk(tree, []); +} + +/** The rect of the leaf reached by walking `path` from `tree`'s root. */ +export function rectAtPath(tree: SplitTree, path: SplitPath): TileRect | null { + let node = tree; + for (const dir of path) { + if (node.kind === 'leaf') return null; + node = node[dir]; + } + return node.kind === 'leaf' ? node.tile : treeBounds(node); +} + +/** Returns the number of leaves in the tree. */ +export function leafCount(tree: SplitTree): number { + if (tree.kind === 'leaf') return 1; + return leafCount(tree.first) + leafCount(tree.second); +} + +/** + * The area a subtree covers: the union of its leaves. Duplicated from + * reflow.ts's `boundsOf` rather than imported — this file is executed two + * ways (esbuild for the shipped extension, Node's native TS runner for + * tests) that disagree on whether a relative import needs a `.ts` + * extension, so a cross-file value import between these two pure-layer + * files can't satisfy both at once. + */ +function treeBounds(tree: SplitTree): TileRect { + if (tree.kind === 'leaf') return tree.tile; + const first = treeBounds(tree.first); + const second = treeBounds(tree.second); + const x = Math.min(first.x, second.x); + const y = Math.min(first.y, second.y); + return { + x, + y, + width: Math.max(first.x + first.width, second.x + second.width) - x, + height: Math.max(first.y + first.height, second.y + second.height) - y, + }; +} + +function applyBounds(tree: SplitTree, bounds: TileRect): SplitTree { + if (tree.kind === 'leaf') return { kind: 'leaf', tile: bounds }; + const { axis, at, first, second } = tree; + const oldBounds = treeBounds(tree); + const extentProp = axis === 'x' ? 'width' : 'height'; + const startProp = axis === 'x' ? 'x' : 'y'; + + let ratio = 0.5; + if (oldBounds[extentProp] > EPSILON) { + ratio = (at - oldBounds[startProp]) / oldBounds[extentProp]; + } + + const newAt = bounds[startProp] + bounds[extentProp] * ratio; + + const firstBounds = { ...bounds }; + firstBounds[extentProp] = newAt - bounds[startProp]; + + const secondBounds = { ...bounds }; + secondBounds[startProp] = newAt; + secondBounds[extentProp] = bounds[startProp] + bounds[extentProp] - newAt; + + return { + kind: 'split', + axis, + at: newAt, + first: applyBounds(first, firstBounds), + second: applyBounds(second, secondBounds), + }; +} + +/** Mutates a split tree to allocate `actualExtent` to the leaf at `path`. */ +export function pinLeaf( + tree: SplitTree, + path: SplitPath, + actualExtent: number, + axis: 'x' | 'y', +): SplitTree { + const MIN_SIBLING_FRACTION = 0.05; + const extentProp = axis === 'x' ? 'width' : 'height'; + const startProp = axis === 'x' ? 'x' : 'y'; + + function walk( + node: SplitTree, + pathIndex: number, + ): { tree: SplitTree; requestedExtent: number } { + if (node.kind === 'leaf' || pathIndex >= path.length) { + return { tree: node, requestedExtent: actualExtent }; + } + + const dir = path[pathIndex]; + const child = node[dir]; + const { tree: newChild, requestedExtent } = walk(child, pathIndex + 1); + + if (node.axis === axis) { + const oldBounds = treeBounds(node); + const oldExtent = oldBounds[extentProp]; + const minSize = MIN_SIBLING_FRACTION * oldExtent; + + let newAt = node.at; + let myRequestedExtent = oldExtent; + + if (dir === 'first') { + newAt = oldBounds[startProp] + requestedExtent; + let clampedAt = newAt; + if (clampedAt > oldBounds[startProp] + oldExtent - minSize) { + clampedAt = oldBounds[startProp] + oldExtent - minSize; + } + if (clampedAt < oldBounds[startProp] + minSize) { + clampedAt = oldBounds[startProp] + minSize; + } + + const achievedExtent = clampedAt - oldBounds[startProp]; + if (Math.abs(achievedExtent - requestedExtent) > EPSILON) { + myRequestedExtent = + requestedExtent + treeBounds(node.second)[extentProp]; + } + + const firstBounds = { ...oldBounds }; + firstBounds[extentProp] = clampedAt - oldBounds[startProp]; + + const secondBounds = { ...oldBounds }; + secondBounds[startProp] = clampedAt; + secondBounds[extentProp] = oldExtent - firstBounds[extentProp]; + + const newNode: Split = { + kind: 'split', + axis, + at: clampedAt, + first: applyBounds(newChild, firstBounds), + second: applyBounds(node.second, secondBounds), + }; + return { tree: newNode, requestedExtent: myRequestedExtent }; + } else { + newAt = oldBounds[startProp] + oldExtent - requestedExtent; + let clampedAt = newAt; + if (clampedAt < oldBounds[startProp] + minSize) { + clampedAt = oldBounds[startProp] + minSize; + } + if (clampedAt > oldBounds[startProp] + oldExtent - minSize) { + clampedAt = oldBounds[startProp] + oldExtent - minSize; + } + + const achievedExtent = + oldBounds[startProp] + oldExtent - clampedAt; + if (Math.abs(achievedExtent - requestedExtent) > EPSILON) { + myRequestedExtent = + requestedExtent + treeBounds(node.first)[extentProp]; + } + + const firstBounds = { ...oldBounds }; + firstBounds[extentProp] = clampedAt - oldBounds[startProp]; + + const secondBounds = { ...oldBounds }; + secondBounds[startProp] = clampedAt; + secondBounds[extentProp] = oldExtent - firstBounds[extentProp]; + + const newNode: Split = { + kind: 'split', + axis, + at: clampedAt, + first: applyBounds(node.first, firstBounds), + second: applyBounds(newChild, secondBounds), + }; + return { tree: newNode, requestedExtent: myRequestedExtent }; + } + } else { + const newNode: Split = { + kind: 'split', + axis: node.axis, + at: node.at, + first: dir === 'first' ? newChild : node.first, + second: dir === 'second' ? newChild : node.second, + }; + return { tree: newNode, requestedExtent }; + } + } + + const { tree: newTree } = walk(tree, 0); + return applyBounds(newTree, treeBounds(tree)); +} diff --git a/src/components/layout/dynamic/pins.test.ts b/src/components/layout/dynamic/pins.test.ts new file mode 100644 index 00000000..0d99c9b6 --- /dev/null +++ b/src/components/layout/dynamic/pins.test.ts @@ -0,0 +1,81 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { applyPins } from './pins.ts'; +import type { TileRect } from './layoutTree.ts'; + +const close = (a: number, b: number) => Math.abs(a - b) < 1e-9; +const overlap = (a: TileRect, b: TileRect) => + Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x)) * + Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y)); + +function assertPartition(rects: TileRect[]) { + const covered = rects.reduce((s, r) => s + r.width * r.height, 0); + assert.ok(close(covered, 1), `covers ${covered}`); + for (let i = 0; i < rects.length; i++) + for (let j = i + 1; j < rects.length; j++) + assert.ok( + overlap(rects[i], rects[j]) < 1e-9, + `${i} and ${j} overlap`, + ); +} + +test('applyPins widens a slot to its minimum width and shrinks the neighbour', () => { + const rects = [ + { x: 0, y: 0, width: 0.5, height: 1 }, + { x: 0.5, y: 0, width: 0.5, height: 1 }, + ]; + const out = applyPins(rects, [{ slot: 0, minWidth: 0.6 }]); + assert.ok(close(out[0].width, 0.6)); + assert.ok(close(out[1].x, 0.6) && close(out[1].width, 0.4)); + assertPartition(out); +}); + +test('applyPins keeps slot order and leaves satisfied pins alone', () => { + const rects = [ + { x: 0, y: 0, width: 0.5, height: 1 }, + { x: 0.5, y: 0, width: 0.5, height: 1 }, + ]; + assert.deepEqual(applyPins(rects, [{ slot: 1, minWidth: 0.4 }]), rects); + assert.deepEqual(applyPins(rects, []), rects); +}); + +test('applyPins on a 2x2 grid moves the whole column divider', () => { + // assign()'s slot order for a 2x2 grid: equal areas, top row first + const rects = [ + { x: 0, y: 0, width: 0.5, height: 0.5 }, + { x: 0.5, y: 0, width: 0.5, height: 0.5 }, + { x: 0, y: 0.5, width: 0.5, height: 0.5 }, + { x: 0.5, y: 0.5, width: 0.5, height: 0.5 }, + ]; + const out = applyPins(rects, [{ slot: 0, minWidth: 0.6 }]); + assert.ok(close(out[0].width, 0.6)); + assert.ok(close(out[2].width, 0.6), 'the cell below shares the divider'); + assert.ok(close(out[1].x, 0.6) && close(out[3].x, 0.6)); + assert.ok(close(out[0].height, 0.5), 'rows untouched'); + assertPartition(out); +}); + +test('applyPins handles height and two pins at once', () => { + const rects = [ + { x: 0, y: 0, width: 0.5, height: 0.5 }, + { x: 0.5, y: 0, width: 0.5, height: 1 }, + { x: 0, y: 0.5, width: 0.5, height: 0.5 }, + ]; + const out = applyPins(rects, [ + { slot: 2, minHeight: 0.7 }, + { slot: 1, minWidth: 0.55 }, + ]); + assert.ok(out[2].height >= 0.7 - 1e-9); + assert.ok(out[1].width >= 0.55 - 1e-9); + assertPartition(out); +}); + +test('applyPins never produces a rect outside the unit square, even for absurd pins', () => { + const rects = [ + { x: 0, y: 0, width: 0.5, height: 1 }, + { x: 0.5, y: 0, width: 0.5, height: 1 }, + ]; + const out = applyPins(rects, [{ slot: 0, minWidth: 5 }]); + assertPartition(out); + assert.ok(out[1].width > 0); +}); diff --git a/src/components/layout/dynamic/pins.ts b/src/components/layout/dynamic/pins.ts new file mode 100644 index 00000000..f59ef60c --- /dev/null +++ b/src/components/layout/dynamic/pins.ts @@ -0,0 +1,63 @@ +/** + * Grows slots that a window cannot fit into. Pure: no GNOME imports. + * + * `assign()` hands out slot rectangles that always form a guillotine + * partition of the unit square, so they can be turned back into a split + * tree; growing a slot then means moving one of that tree's dividers, which + * shrinks the neighbours on the other side of it and keeps everything + * covering the screen without overlaps. + */ + +import { buildLayoutTree, pathOf, pinLeaf, rectAtPath } from './layoutTree'; +import type { TileRect } from './layoutTree'; + +export interface SlotPin { + /** index into the rects `assign()` returned */ + slot: number; + /** normalised (0..1) extents the slot must have at least */ + minWidth?: number; + minHeight?: number; +} + +const EPSILON = 1e-9; + +/** + * Returns the rects with every pinned slot at least as large as its pin, + * in the same order. Rects are returned untouched when no pin needs work. + */ +export function applyPins(rects: TileRect[], pins: SlotPin[]): TileRect[] { + const needed = pins.filter((pin) => { + const r = rects[pin.slot]; + if (!r) return false; + return ( + (pin.minWidth !== undefined && r.width < pin.minWidth - EPSILON) || + (pin.minHeight !== undefined && r.height < pin.minHeight - EPSILON) + ); + }); + if (needed.length === 0) return rects; + + let tree = buildLayoutTree(rects); + if (!tree) return rects; + const paths = rects.map((r) => pathOf(tree!, r)); + if (paths.some((p) => p === null)) return rects; + + for (const pin of needed) { + const path = paths[pin.slot]!; + const current = rectAtPath(tree, path); + if (!current) continue; + if ( + pin.minWidth !== undefined && + current.width < pin.minWidth - EPSILON + ) + tree = pinLeaf(tree, path, Math.min(pin.minWidth, 1), 'x'); + const grown = rectAtPath(tree, path); + if ( + grown && + pin.minHeight !== undefined && + grown.height < pin.minHeight - EPSILON + ) + tree = pinLeaf(tree, path, Math.min(pin.minHeight, 1), 'y'); + } + + return rects.map((r, i) => rectAtPath(tree!, paths[i]!) ?? r); +} diff --git a/src/components/tilingsystem/tilingManager.ts b/src/components/tilingsystem/tilingManager.ts index 05426adb..3b5759fd 100644 --- a/src/components/tilingsystem/tilingManager.ts +++ b/src/components/tilingsystem/tilingManager.ts @@ -24,6 +24,8 @@ import Tile from '../layout/Tile'; import TileUtils from '../layout/TileUtils'; import { buildLayoutTree, SplitTree } from '../layout/dynamic/layoutTree'; import { assign, neighbourIndex, slotUnderPoint } from '../layout/dynamic/reflow'; +import { applyPins } from '../layout/dynamic/pins'; +import type { SlotPin } from '../layout/dynamic/pins'; import type { Direction } from '../layout/dynamic/reflow'; import { pickLayoutIndex, pickLayoutIndexAt } from '../layout/dynamic/pickLayout'; import GlobalState from '../../utils/globalState'; @@ -132,6 +134,11 @@ export class TilingManager { // is on screen (null when none), so the preview is only redrawn when the // pointer crosses into another slot. private _dynamicPreviewSlot: number | null = null; + // Frame sizes (px) below which a window's client has refused to go, as + // learnt from its final answers to placements. A pinned window's slot is + // grown to fit and its neighbours shrink, so the gaps stay intact. + private _pinnedWindows: Map = + new Map(); private readonly _signals: SignalHandling; private readonly _debug: (..._content: unknown[]) => void; @@ -589,6 +596,7 @@ export class TilingManager { this._dynamicWindowSignals.clear(); this._dynamicWindows.length = 0; this._dynamicLayoutOffset.clear(); + this._pinnedWindows.clear(); this._splitTarget = null; this._signals.disconnect(); this._isGrabbingWindow = false; @@ -1384,14 +1392,49 @@ export class TilingManager { const splitSlot = this._splitTarget ? windows.indexOf(this._splitTarget) : -1; - return { - windows, - rects: assign( - tree, - windows.length, - splitSlot >= 0 ? splitSlot : undefined, - ), - }; + const rects = assign( + tree, + windows.length, + splitSlot >= 0 ? splitSlot : undefined, + ); + return { windows, rects: this._applyWindowPins(ws, windows, rects) }; + } + + /** + * Grows the slots of pinned windows so the frame the client insists on + * fits inside them together with the gaps; the neighbours give way. + */ + private _applyWindowPins( + ws: Meta.Workspace, + windows: Meta.Window[], + rects: ReturnType, + ): ReturnType { + if (this._pinnedWindows.size === 0) return rects; + const tilingLayout = this._workspaceTilingLayout.get(ws); + if (!tilingLayout) return rects; + + const pins: SlotPin[] = []; + windows.forEach((window, slot) => { + const pin = this._pinnedWindows.get(window); + if (!pin) return; + const tile = this._tileOf(rects[slot]); + const tilePx = TileUtils.apply_props(tile, this._workArea); + const framePx = this._windowRectForTile(tile, tilingLayout); + if (!framePx) return; + // the gaps this slot loses to the frame stay the same once it + // grows, so the slot has to hold the frame plus those gaps + const entry: SlotPin = { slot }; + if (pin.width !== undefined) + entry.minWidth = + (pin.width + tilePx.width - framePx.width) / + this._workArea.width; + if (pin.height !== undefined) + entry.minHeight = + (pin.height + tilePx.height - framePx.height) / + this._workArea.height; + pins.push(entry); + }); + return pins.length === 0 ? rects : applyPins(rects, pins); } /** @@ -1479,9 +1522,47 @@ export class TilingManager { ); this._placer.place(placementTargetFor(window), toRect(destinationRect), { animate: !skipAnimation, + onSettled: (requested, actual, final) => + this._onPlacementSettled(window, requested, actual, final), }); } + /** + * Learns from the client's final answer to a placement. A window that + * ends up larger than asked cannot be made that small, so its slot must + * grow (pin); one that accepted a size below its pin has proven the pin + * wrong. Either change means a reflow. + */ + private _onPlacementSettled( + window: Meta.Window, + requested: { width: number; height: number }, + actual: { width: number; height: number }, + final: boolean, + ) { + if (!final || !Settings.ENABLE_DYNAMIC_TILING) return; + if (!this._dynamicWindowSignals.has(window)) return; + + const pin = { ...(this._pinnedWindows.get(window) ?? {}) }; + const update = (axis: 'width' | 'height') => { + if (actual[axis] > requested[axis]) pin[axis] = actual[axis]; + else if (pin[axis] !== undefined && requested[axis] < pin[axis]) + delete pin[axis]; + }; + update('width'); + update('height'); + + const before = this._pinnedWindows.get(window); + const changed = + (before?.width ?? null) !== (pin.width ?? null) || + (before?.height ?? null) !== (pin.height ?? null); + if (!changed) return; + + if (pin.width === undefined && pin.height === undefined) + this._pinnedWindows.delete(window); + else this._pinnedWindows.set(window, pin); + this._queueDynamicReflow(); + } + public onTileFromWindowMenu(tile: Tile, window: Meta.Window) { this._easeWindowRectFromTile(tile, window); } @@ -1615,6 +1696,7 @@ export class TilingManager { this._dynamicWindowSignals.delete(window); if (this._splitTarget === window) this._splitTarget = null; this._placer.forget(placementTargetFor(window)); + this._pinnedWindows.delete(window); const slot = this._dynamicWindows.indexOf(window); if (slot < 0) return; @@ -1968,24 +2050,10 @@ export class TilingManager { }); workspaces.forEach((ws) => { - const windows = this._dynamicManagedWindows(ws); - if (windows.length === 0) return; - // no decomposable layout at all keeps the static behaviour - const tree = this._dynamicTree(windows.length, ws); - if (!tree) return; - - // the split target only applies to the workspace it is actually - // on; elsewhere it resolves to -1 and overflow picks the roomiest - // region instead, exactly as when nothing is focused at all - const splitSlot = this._splitTarget - ? windows.indexOf(this._splitTarget) - : -1; - const rects = assign( - tree, - windows.length, - splitSlot >= 0 ? splitSlot : undefined, - ); + const slots = this._dynamicSlots(ws); + if (!slots) return; + const { windows, rects } = slots; windows.forEach((window, slot) => { // a window can be unmanaged by a placement earlier in this // very loop; skip it rather than abort the others diff --git a/src/components/tilingsystem/windowPlacer.ts b/src/components/tilingsystem/windowPlacer.ts index a2e54869..a1576511 100644 --- a/src/components/tilingsystem/windowPlacer.ts +++ b/src/components/tilingsystem/windowPlacer.ts @@ -78,10 +78,12 @@ export interface PlaceOptions { forceMove?: boolean; animate?: boolean; /** - * Called once this request settled — not if it was superseded — with the - * rect that was asked for and the frame the client actually ended on. + * Called each time this request settled — not if it was superseded — + * with the rect that was asked for and the frame the client actually + * ended on. `final` is true when the placer will not ask again: the + * size matched, or every retry has been spent. */ - onSettled?: (requested: Rect, actual: Rect) => void; + onSettled?: (requested: Rect, actual: Rect, final: boolean) => void; } export type PlaceResult = @@ -121,7 +123,7 @@ interface Pending { dest: Rect; before: Rect; animate: boolean; - onSettled?: (requested: Rect, actual: Rect) => void; + onSettled?: (requested: Rect, actual: Rect, final: boolean) => void; disconnectGeometry: () => void; disconnectGone: () => void; timeout: unknown; @@ -371,7 +373,10 @@ export class WindowPlacer { } } - pending.onSettled?.(copyRect(pending.dest), copyRect(frame)); + const final = + sizeEquals(frame, pending.dest) || + hist.retries >= this._opts.retryDelaysMs.length; + pending.onSettled?.(copyRect(pending.dest), copyRect(frame), final); this._verify(target, hist, pending.dest, frame); } diff --git a/test/mutter-sim/placement.test.ts b/test/mutter-sim/placement.test.ts index bc682297..60e878f0 100644 --- a/test/mutter-sim/placement.test.ts +++ b/test/mutter-sim/placement.test.ts @@ -509,3 +509,20 @@ test('(xxi) placer.destroy() also cancels retries and drift watches', () => { placer.destroy(); assert.equal(clock.pendingTimeouts, 0); }); + +test('(xxii) onSettled reports whether the answer is final: matched size, or retries exhausted', () => { + const { clock, window, placer } = fixture({ kind: 'clampMin', minWidth: 700, minHeight: 600 }); + const finals: boolean[] = []; + placer.place(simTargetFor(window), TILE_S, { + onSettled: (_req, _actual, final) => finals.push(final), + }); + clock.tick(30_000); + // initial answer + 3 retries, only the last one is final + assert.deepEqual(finals, [false, false, false, true]); + + const { clock: c2, window: w2, placer: p2 } = fixture({ kind: 'comply' }); + const finals2: boolean[] = []; + p2.place(simTargetFor(w2), TILE_S, { onSettled: (_r, _a, final) => finals2.push(final) }); + c2.tick(1000); + assert.deepEqual(finals2, [true], 'a matching answer is final at once'); +}); From fb91f6e6572816eeeebe9e256783a5e87a38e22a Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 13 Sep 2026 18:11:18 +0530 Subject: [PATCH 37/42] fix(dynamic): pin review fixes - a drift-watch correction is a single attempt: its refusal is no longer reported final, so it can never create a pin on its own - pinLeaf's cascade asks the ancestor for what is missing (request + sibling floor) instead of the sibling's whole extent, and applyPins re-applies pins for a few passes so one pin cannot silently unmet another; both covered by exact-extent tests - keyboard moves read the pinned slots too --- src/components/layout/dynamic/layoutTree.ts | 16 ++--- src/components/layout/dynamic/pins.test.ts | 36 ++++++++++ src/components/layout/dynamic/pins.ts | 70 ++++++++++++-------- src/components/tilingsystem/tilingManager.ts | 18 ++--- src/components/tilingsystem/windowPlacer.ts | 10 ++- test/mutter-sim/placement.test.ts | 30 ++++++++- 6 files changed, 127 insertions(+), 53 deletions(-) diff --git a/src/components/layout/dynamic/layoutTree.ts b/src/components/layout/dynamic/layoutTree.ts index 88c39bdf..ae254d97 100644 --- a/src/components/layout/dynamic/layoutTree.ts +++ b/src/components/layout/dynamic/layoutTree.ts @@ -118,12 +118,8 @@ export function leafCount(tree: SplitTree): number { } /** - * The area a subtree covers: the union of its leaves. Duplicated from - * reflow.ts's `boundsOf` rather than imported — this file is executed two - * ways (esbuild for the shipped extension, Node's native TS runner for - * tests) that disagree on whether a relative import needs a `.ts` - * extension, so a cross-file value import between these two pure-layer - * files can't satisfy both at once. + * The area a subtree covers: the union of its leaves (same as reflow.ts's + * `boundsOf`, kept local so this file has no dependency on reflow.ts). */ function treeBounds(tree: SplitTree): TileRect { if (tree.kind === 'leaf') return tree.tile; @@ -212,8 +208,9 @@ export function pinLeaf( const achievedExtent = clampedAt - oldBounds[startProp]; if (Math.abs(achievedExtent - requestedExtent) > EPSILON) { - myRequestedExtent = - requestedExtent + treeBounds(node.second)[extentProp]; + // the sibling is already at its floor: the parent has to + // grow this node by exactly what is missing + myRequestedExtent = requestedExtent + minSize; } const firstBounds = { ...oldBounds }; @@ -244,8 +241,7 @@ export function pinLeaf( const achievedExtent = oldBounds[startProp] + oldExtent - clampedAt; if (Math.abs(achievedExtent - requestedExtent) > EPSILON) { - myRequestedExtent = - requestedExtent + treeBounds(node.first)[extentProp]; + myRequestedExtent = requestedExtent + minSize; } const firstBounds = { ...oldBounds }; diff --git a/src/components/layout/dynamic/pins.test.ts b/src/components/layout/dynamic/pins.test.ts index 0d99c9b6..62fdb0d4 100644 --- a/src/components/layout/dynamic/pins.test.ts +++ b/src/components/layout/dynamic/pins.test.ts @@ -79,3 +79,39 @@ test('applyPins never produces a rect outside the unit square, even for absurd p assertPartition(out); assert.ok(out[1].width > 0); }); + +test('applyPins meets a pin that needs the divider above it to move, without over-allocating', () => { + // three columns 0.5 | 0.25 | 0.25; the middle one must reach 0.49 — + // more than its own split can give, so the root divider has to move + const rects = [ + { x: 0, y: 0, width: 0.5, height: 1 }, + { x: 0.5, y: 0, width: 0.25, height: 1 }, + { x: 0.75, y: 0, width: 0.25, height: 1 }, + ]; + const out = applyPins(rects, [{ slot: 1, minWidth: 0.49 }]); + assert.ok(out[1].width >= 0.49 - 1e-6, `middle got ${out[1].width}`); + assert.ok( + out[1].width <= 0.49 + 0.03, + `middle over-allocated: ${out[1].width}`, + ); + assert.ok( + out[0].width >= 0.4, + `left column needlessly crushed: ${out[0].width}`, + ); + assertPartition(out); +}); + +test('applyPins keeps an already satisfied pin satisfied when a later pin moves its divider', () => { + const rects = [ + { x: 0, y: 0, width: 0.5, height: 1 }, + { x: 0.5, y: 0, width: 0.25, height: 1 }, + { x: 0.75, y: 0, width: 0.25, height: 1 }, + ]; + const out = applyPins(rects, [ + { slot: 0, minWidth: 0.45 }, + { slot: 1, minWidth: 0.49 }, + ]); + assert.ok(out[0].width >= 0.45 - 1e-6, `slot 0: ${out[0].width}`); + assert.ok(out[1].width >= 0.49 - 1e-6, `slot 1: ${out[1].width}`); + assertPartition(out); +}); diff --git a/src/components/layout/dynamic/pins.ts b/src/components/layout/dynamic/pins.ts index f59ef60c..9bd88999 100644 --- a/src/components/layout/dynamic/pins.ts +++ b/src/components/layout/dynamic/pins.ts @@ -9,7 +9,7 @@ */ import { buildLayoutTree, pathOf, pinLeaf, rectAtPath } from './layoutTree'; -import type { TileRect } from './layoutTree'; +import type { SplitPath, SplitTree, TileRect } from './layoutTree'; export interface SlotPin { /** index into the rects `assign()` returned */ @@ -21,12 +21,14 @@ export interface SlotPin { const EPSILON = 1e-9; -/** - * Returns the rects with every pinned slot at least as large as its pin, - * in the same order. Rects are returned untouched when no pin needs work. - */ -export function applyPins(rects: TileRect[], pins: SlotPin[]): TileRect[] { - const needed = pins.filter((pin) => { +const readBack = ( + tree: SplitTree, + rects: TileRect[], + paths: (SplitPath | null)[], +): TileRect[] => rects.map((r, i) => rectAtPath(tree, paths[i]!) ?? r); + +const unsatisfied = (rects: TileRect[], pins: SlotPin[]): SlotPin[] => + pins.filter((pin) => { const r = rects[pin.slot]; if (!r) return false; return ( @@ -34,30 +36,46 @@ export function applyPins(rects: TileRect[], pins: SlotPin[]): TileRect[] { (pin.minHeight !== undefined && r.height < pin.minHeight - EPSILON) ); }); - if (needed.length === 0) return rects; + +/** + * Returns the rects with every pinned slot at least as large as its pin, + * in the same order. Rects are returned untouched when no pin needs work. + * + * Growing one slot can shrink another pinned one (they may share a + * divider, or a clamped divider makes an ancestor rescale a whole + * subtree), so the pins are re-applied until they all hold or the passes + * run out; pins that cannot all be met leave the last one short. + */ +export function applyPins(rects: TileRect[], pins: SlotPin[]): TileRect[] { + if (unsatisfied(rects, pins).length === 0) return rects; let tree = buildLayoutTree(rects); if (!tree) return rects; const paths = rects.map((r) => pathOf(tree!, r)); if (paths.some((p) => p === null)) return rects; - for (const pin of needed) { - const path = paths[pin.slot]!; - const current = rectAtPath(tree, path); - if (!current) continue; - if ( - pin.minWidth !== undefined && - current.width < pin.minWidth - EPSILON - ) - tree = pinLeaf(tree, path, Math.min(pin.minWidth, 1), 'x'); - const grown = rectAtPath(tree, path); - if ( - grown && - pin.minHeight !== undefined && - grown.height < pin.minHeight - EPSILON - ) - tree = pinLeaf(tree, path, Math.min(pin.minHeight, 1), 'y'); + let current = rects; + for (let pass = 0; pass < 4; pass++) { + const needed = unsatisfied(current, pins); + if (needed.length === 0) break; + for (const pin of needed) { + const path = paths[pin.slot]!; + const leaf = rectAtPath(tree, path); + if (!leaf) continue; + if ( + pin.minWidth !== undefined && + leaf.width < pin.minWidth - EPSILON + ) + tree = pinLeaf(tree, path, Math.min(pin.minWidth, 1), 'x'); + const grown = rectAtPath(tree, path); + if ( + grown && + pin.minHeight !== undefined && + grown.height < pin.minHeight - EPSILON + ) + tree = pinLeaf(tree, path, Math.min(pin.minHeight, 1), 'y'); + } + current = readBack(tree, rects, paths); } - - return rects.map((r, i) => rectAtPath(tree!, paths[i]!) ?? r); + return current; } diff --git a/src/components/tilingsystem/tilingManager.ts b/src/components/tilingsystem/tilingManager.ts index 3b5759fd..f33c9b2d 100644 --- a/src/components/tilingsystem/tilingManager.ts +++ b/src/components/tilingsystem/tilingManager.ts @@ -1596,22 +1596,12 @@ export class TilingManager { const ws = global.workspaceManager.get_active_workspace(); if (!ws) return false; - const windows = this._dynamicManagedWindows(ws); - const from = windows.indexOf(window); - if (from < 0) return false; // not ours: let the static path have it + const slots = this._dynamicSlots(ws); + const from = slots ? slots.windows.indexOf(window) : -1; + if (!slots || from < 0) return false; // not ours: let the static path have it + const { windows, rects } = slots; if (windows.length < 2) return true; - const tree = this._dynamicTree(windows.length, ws); - if (!tree) return false; - - const splitSlot = this._splitTarget - ? windows.indexOf(this._splitTarget) - : -1; - const rects = assign( - tree, - windows.length, - splitSlot >= 0 ? splitSlot : undefined, - ); const to = neighbourIndex(rects, from, towards); if (to < 0) return true; // at the edge of the screen diff --git a/src/components/tilingsystem/windowPlacer.ts b/src/components/tilingsystem/windowPlacer.ts index a1576511..96f26582 100644 --- a/src/components/tilingsystem/windowPlacer.ts +++ b/src/components/tilingsystem/windowPlacer.ts @@ -123,6 +123,8 @@ interface Pending { dest: Rect; before: Rect; animate: boolean; + /** a one-shot drift correction: not retried, and never reported final */ + fromDrift: boolean; onSettled?: (requested: Rect, actual: Rect, final: boolean) => void; disconnectGeometry: () => void; disconnectGone: () => void; @@ -271,6 +273,7 @@ export class WindowPlacer { options: PlaceOptions, userOp: boolean, nudge = false, + fromDrift = false, ): void { // a newer request supersedes the previous one, but the animation // still starts from where the window was before the first of them @@ -287,6 +290,7 @@ export class WindowPlacer { dest: copyRect(dest), before: copyRect(earlierBefore ?? before), animate, + fromDrift, onSettled: options.onSettled, disconnectGeometry: () => {}, disconnectGone: () => {}, @@ -373,9 +377,12 @@ export class WindowPlacer { } } + // a drift correction is a single attempt with no retries behind it, + // so a refusal there says nothing definite about the client const final = sizeEquals(frame, pending.dest) || - hist.retries >= this._opts.retryDelaysMs.length; + (!pending.fromDrift && + hist.retries >= this._opts.retryDelaysMs.length); pending.onSettled?.(copyRect(pending.dest), copyRect(frame), final); this._verify(target, hist, pending.dest, frame); @@ -448,6 +455,7 @@ export class WindowPlacer { hist.lastOptions ?? {}, hist.lastOptions?.userOp ?? false, true, + true, ); }); }); diff --git a/test/mutter-sim/placement.test.ts b/test/mutter-sim/placement.test.ts index 60e878f0..3ad38c09 100644 --- a/test/mutter-sim/placement.test.ts +++ b/test/mutter-sim/placement.test.ts @@ -511,7 +511,11 @@ test('(xxi) placer.destroy() also cancels retries and drift watches', () => { }); test('(xxii) onSettled reports whether the answer is final: matched size, or retries exhausted', () => { - const { clock, window, placer } = fixture({ kind: 'clampMin', minWidth: 700, minHeight: 600 }); + const { clock, window, placer } = fixture({ + kind: 'clampMin', + minWidth: 700, + minHeight: 600, + }); const finals: boolean[] = []; placer.place(simTargetFor(window), TILE_S, { onSettled: (_req, _actual, final) => finals.push(final), @@ -522,7 +526,29 @@ test('(xxii) onSettled reports whether the answer is final: matched size, or ret const { clock: c2, window: w2, placer: p2 } = fixture({ kind: 'comply' }); const finals2: boolean[] = []; - p2.place(simTargetFor(w2), TILE_S, { onSettled: (_r, _a, final) => finals2.push(final) }); + p2.place(simTargetFor(w2), TILE_S, { + onSettled: (_r, _a, final) => finals2.push(final), + }); c2.tick(1000); assert.deepEqual(finals2, [true], 'a matching answer is final at once'); }); + +test('(xxiii) a refused drift correction is not reported as final', () => { + const { clock, window, placer } = fixture(); + const finals: boolean[] = []; + placer.place(simTargetFor(window), TILE_S, { + onSettled: (_r, _a, f) => finals.push(f), + }); + clock.tick(400); + assert.deepEqual(finals, [true]); + // the client grows on its own and then refuses the single correction + window.policy = { kind: 'clampMin', minWidth: 1400, minHeight: 900 }; + window.clientResize(1400, 900); + clock.tick(2000); + assert.deepEqual( + finals, + [true, false], + 'one correction, refused, not final', + ); + assert.equal(clock.pendingTimeouts, 0); +}); From e30928c1a488bef8dd84a1684d8abc0139aad31c Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 13 Sep 2026 18:22:01 +0530 Subject: [PATCH 38/42] fix(dynamic): pinned siblings ask the parent split for room instead of fighting over one divider Two windows in the same row both refusing to shrink (two Brave windows at their 500 px minimum) each moved the divider between them, undoing the other's pin; pinLeaf only escalated when a sibling hit the 5 % floor, so the row never grew and the last pin lost its gap. pinLeaf now takes a leafFloor and treats the sibling subtree's pinned minimums (summed along the axis, max across it) as the divider's floor; when the request cannot be met without breaking a sibling's pin it asks the parent for requested + siblingFloor. applyPins feeds it the other pins' extents by path. --- src/components/layout/dynamic/layoutTree.ts | 57 ++++++++++++++++++--- src/components/layout/dynamic/pins.test.ts | 38 ++++++++++++++ src/components/layout/dynamic/pins.ts | 26 +++++++++- 3 files changed, 112 insertions(+), 9 deletions(-) diff --git a/src/components/layout/dynamic/layoutTree.ts b/src/components/layout/dynamic/layoutTree.ts index ae254d97..5f7c1fe7 100644 --- a/src/components/layout/dynamic/layoutTree.ts +++ b/src/components/layout/dynamic/layoutTree.ts @@ -165,12 +165,42 @@ function applyBounds(tree: SplitTree, bounds: TileRect): SplitTree { }; } -/** Mutates a split tree to allocate `actualExtent` to the leaf at `path`. */ +/** + * The smallest extent a subtree can shrink to along `axis` without any of + * its leaves dropping under the floor `leafFloor` gives for it: floors add + * up across a cut on the same axis and compete across a cut on the other. + */ +function subtreeFloor( + node: SplitTree, + path: SplitPath, + axis: 'x' | 'y', + leafFloor: (path: SplitPath) => number, +): number { + if (node.kind === 'leaf') return leafFloor(path); + const first = subtreeFloor(node.first, [...path, 'first'], axis, leafFloor); + const second = subtreeFloor( + node.second, + [...path, 'second'], + axis, + leafFloor, + ); + return node.axis === axis ? first + second : Math.max(first, second); +} + +/** + * Mutates a split tree to allocate `actualExtent` to the leaf at `path`. + * + * A sibling on the other side of a moved divider is never shrunk under a + * small fraction of the split, nor under `leafFloor` of the leaves it holds + * (other pins); when that stops the divider short, the parent split is asked + * for the missing room instead. + */ export function pinLeaf( tree: SplitTree, path: SplitPath, actualExtent: number, axis: 'x' | 'y', + leafFloor: (path: SplitPath) => number = () => 0, ): SplitTree { const MIN_SIBLING_FRACTION = 0.05; const extentProp = axis === 'x' ? 'width' : 'height'; @@ -192,6 +222,16 @@ export function pinLeaf( const oldBounds = treeBounds(node); const oldExtent = oldBounds[extentProp]; const minSize = MIN_SIBLING_FRACTION * oldExtent; + const siblingDir = dir === 'first' ? 'second' : 'first'; + const siblingFloor = Math.max( + minSize, + subtreeFloor( + node[siblingDir], + [...path.slice(0, pathIndex), siblingDir], + axis, + leafFloor, + ), + ); let newAt = node.at; let myRequestedExtent = oldExtent; @@ -199,8 +239,11 @@ export function pinLeaf( if (dir === 'first') { newAt = oldBounds[startProp] + requestedExtent; let clampedAt = newAt; - if (clampedAt > oldBounds[startProp] + oldExtent - minSize) { - clampedAt = oldBounds[startProp] + oldExtent - minSize; + if ( + clampedAt > + oldBounds[startProp] + oldExtent - siblingFloor + ) { + clampedAt = oldBounds[startProp] + oldExtent - siblingFloor; } if (clampedAt < oldBounds[startProp] + minSize) { clampedAt = oldBounds[startProp] + minSize; @@ -210,7 +253,7 @@ export function pinLeaf( if (Math.abs(achievedExtent - requestedExtent) > EPSILON) { // the sibling is already at its floor: the parent has to // grow this node by exactly what is missing - myRequestedExtent = requestedExtent + minSize; + myRequestedExtent = requestedExtent + siblingFloor; } const firstBounds = { ...oldBounds }; @@ -231,8 +274,8 @@ export function pinLeaf( } else { newAt = oldBounds[startProp] + oldExtent - requestedExtent; let clampedAt = newAt; - if (clampedAt < oldBounds[startProp] + minSize) { - clampedAt = oldBounds[startProp] + minSize; + if (clampedAt < oldBounds[startProp] + siblingFloor) { + clampedAt = oldBounds[startProp] + siblingFloor; } if (clampedAt > oldBounds[startProp] + oldExtent - minSize) { clampedAt = oldBounds[startProp] + oldExtent - minSize; @@ -241,7 +284,7 @@ export function pinLeaf( const achievedExtent = oldBounds[startProp] + oldExtent - clampedAt; if (Math.abs(achievedExtent - requestedExtent) > EPSILON) { - myRequestedExtent = requestedExtent + minSize; + myRequestedExtent = requestedExtent + siblingFloor; } const firstBounds = { ...oldBounds }; diff --git a/src/components/layout/dynamic/pins.test.ts b/src/components/layout/dynamic/pins.test.ts index 62fdb0d4..e0afa4be 100644 --- a/src/components/layout/dynamic/pins.test.ts +++ b/src/components/layout/dynamic/pins.test.ts @@ -115,3 +115,41 @@ test('applyPins keeps an already satisfied pin satisfied when a later pin moves assert.ok(out[1].width >= 0.49 - 1e-6, `slot 1: ${out[1].width}`); assertPartition(out); }); + +test('applyPins grows the parent when two pinned siblings cannot both fit in their split', () => { + // 0.5 | 0.25 | 0.25 with both right-hand columns pinned to 0.3: the + // divider between them cannot satisfy both, so the root divider has to + // move instead of the pins fighting over the same cut line + const rects = [ + { x: 0, y: 0, width: 0.5, height: 1 }, + { x: 0.5, y: 0, width: 0.25, height: 1 }, + { x: 0.75, y: 0, width: 0.25, height: 1 }, + ]; + const out = applyPins(rects, [ + { slot: 1, minWidth: 0.3 }, + { slot: 2, minWidth: 0.3 }, + ]); + assert.ok(out[1].width >= 0.3 - 1e-6, `slot 1: ${out[1].width}`); + assert.ok(out[2].width >= 0.3 - 1e-6, `slot 2: ${out[2].width}`); + assert.ok(close(out[0].width, 0.4), `slot 0: ${out[0].width}`); + assertPartition(out); +}); + +test('applyPins grows the parent for pinned siblings nested under a cross-axis split', () => { + // the live layout: left column = row of two cells over a wide cell, + // right column = one tall cell; both cells of the row pinned to 0.26 + const rects = [ + { x: 0, y: 0, width: 0.26, height: 0.5 }, + { x: 0.26, y: 0, width: 0.26, height: 0.5 }, + { x: 0, y: 0.5, width: 0.52, height: 0.5 }, + { x: 0.52, y: 0, width: 0.48, height: 1 }, + ]; + const out = applyPins(rects, [ + { slot: 0, minWidth: 0.27 }, + { slot: 1, minWidth: 0.27 }, + ]); + assert.ok(out[0].width >= 0.27 - 1e-6, `slot 0: ${out[0].width}`); + assert.ok(out[1].width >= 0.27 - 1e-6, `slot 1: ${out[1].width}`); + assert.ok(close(out[3].x, 0.54), `right column at ${out[3].x}`); + assertPartition(out); +}); diff --git a/src/components/layout/dynamic/pins.ts b/src/components/layout/dynamic/pins.ts index 9bd88999..7243028b 100644 --- a/src/components/layout/dynamic/pins.ts +++ b/src/components/layout/dynamic/pins.ts @@ -54,6 +54,16 @@ export function applyPins(rects: TileRect[], pins: SlotPin[]): TileRect[] { const paths = rects.map((r) => pathOf(tree!, r)); if (paths.some((p) => p === null)) return rects; + // what every other pinned leaf must keep when a divider next to it + // moves, so two pins on the same cut line ask the parent for room + // instead of taking it from each other + const floors = new Map(); + for (const pin of pins) floors.set(paths[pin.slot]!.join('/'), pin); + const widthFloor = (p: SplitPath) => + Math.min(floors.get(p.join('/'))?.minWidth ?? 0, 1); + const heightFloor = (p: SplitPath) => + Math.min(floors.get(p.join('/'))?.minHeight ?? 0, 1); + let current = rects; for (let pass = 0; pass < 4; pass++) { const needed = unsatisfied(current, pins); @@ -66,14 +76,26 @@ export function applyPins(rects: TileRect[], pins: SlotPin[]): TileRect[] { pin.minWidth !== undefined && leaf.width < pin.minWidth - EPSILON ) - tree = pinLeaf(tree, path, Math.min(pin.minWidth, 1), 'x'); + tree = pinLeaf( + tree, + path, + Math.min(pin.minWidth, 1), + 'x', + widthFloor, + ); const grown = rectAtPath(tree, path); if ( grown && pin.minHeight !== undefined && grown.height < pin.minHeight - EPSILON ) - tree = pinLeaf(tree, path, Math.min(pin.minHeight, 1), 'y'); + tree = pinLeaf( + tree, + path, + Math.min(pin.minHeight, 1), + 'y', + heightFloor, + ); } current = readBack(tree, rects, paths); } From a6020f69648c198547c180ed3275d3d7def40feb Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 13 Sep 2026 20:09:06 +0530 Subject: [PATCH 39/42] fix(dynamic): escalate a pin only when the leaf got less than it asked for A request under the 5 % floor was granted more than it asked and still escalated, with a request smaller than the node, so the parent shrank the node and inflated its sibling. applyPins also skips a pin whose slot does not exist instead of throwing, matching unsatisfied(). --- src/components/layout/dynamic/layoutTree.ts | 9 ++++--- src/components/layout/dynamic/pins.test.ts | 28 +++++++++++++++++++++ src/components/layout/dynamic/pins.ts | 5 +++- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/components/layout/dynamic/layoutTree.ts b/src/components/layout/dynamic/layoutTree.ts index 5f7c1fe7..177a36f0 100644 --- a/src/components/layout/dynamic/layoutTree.ts +++ b/src/components/layout/dynamic/layoutTree.ts @@ -250,9 +250,10 @@ export function pinLeaf( } const achievedExtent = clampedAt - oldBounds[startProp]; - if (Math.abs(achievedExtent - requestedExtent) > EPSILON) { - // the sibling is already at its floor: the parent has to - // grow this node by exactly what is missing + if (achievedExtent < requestedExtent - EPSILON) { + // the sibling is already at its floor: ask the parent + // for enough room to hold both (a request under the + // floor is simply granted more and must not escalate) myRequestedExtent = requestedExtent + siblingFloor; } @@ -283,7 +284,7 @@ export function pinLeaf( const achievedExtent = oldBounds[startProp] + oldExtent - clampedAt; - if (Math.abs(achievedExtent - requestedExtent) > EPSILON) { + if (achievedExtent < requestedExtent - EPSILON) { myRequestedExtent = requestedExtent + siblingFloor; } diff --git a/src/components/layout/dynamic/pins.test.ts b/src/components/layout/dynamic/pins.test.ts index e0afa4be..0fd56b9f 100644 --- a/src/components/layout/dynamic/pins.test.ts +++ b/src/components/layout/dynamic/pins.test.ts @@ -153,3 +153,31 @@ test('applyPins grows the parent for pinned siblings nested under a cross-axis s assert.ok(close(out[3].x, 0.54), `right column at ${out[3].x}`); assertPartition(out); }); + +test('applyPins does not escalate a pin smaller than the sibling floor', () => { + // the 0.02 column asks for 0.024, less than the 5 % floor its split + // grants anyway; the root divider must not move + const rects = [ + { x: 0, y: 0, width: 0.5, height: 1 }, + { x: 0.5, y: 0, width: 0.02, height: 1 }, + { x: 0.52, y: 0, width: 0.48, height: 1 }, + ]; + const out = applyPins(rects, [{ slot: 1, minWidth: 0.024 }]); + assert.ok(out[1].width >= 0.024 - 1e-6, `slot 1: ${out[1].width}`); + assert.ok(close(out[0].width, 0.5), `slot 0 moved: ${out[0].width}`); + assert.ok(out[2].width > 0.4, `slot 2 crushed: ${out[2].width}`); + assertPartition(out); +}); + +test('applyPins ignores a pin whose slot does not exist', () => { + const rects = [ + { x: 0, y: 0, width: 0.5, height: 1 }, + { x: 0.5, y: 0, width: 0.5, height: 1 }, + ]; + const out = applyPins(rects, [ + { slot: 0, minWidth: 0.6 }, + { slot: 7, minWidth: 0.6 }, + ]); + assert.ok(close(out[0].width, 0.6)); + assertPartition(out); +}); diff --git a/src/components/layout/dynamic/pins.ts b/src/components/layout/dynamic/pins.ts index 7243028b..368c057b 100644 --- a/src/components/layout/dynamic/pins.ts +++ b/src/components/layout/dynamic/pins.ts @@ -58,7 +58,10 @@ export function applyPins(rects: TileRect[], pins: SlotPin[]): TileRect[] { // moves, so two pins on the same cut line ask the parent for room // instead of taking it from each other const floors = new Map(); - for (const pin of pins) floors.set(paths[pin.slot]!.join('/'), pin); + for (const pin of pins) { + const path = paths[pin.slot]; + if (path) floors.set(path.join('/'), pin); + } const widthFloor = (p: SplitPath) => Math.min(floors.get(p.join('/'))?.minWidth ?? 0, 1); const heightFloor = (p: SplitPath) => From f1172f9b9f7ba529220ba367feb9815c8adb32e4 Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Mon, 14 Sep 2026 14:44:38 +0530 Subject: [PATCH 40/42] feat(dynamic): a new window halves the focused window's tile; closing rebuilds by seniority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Past the tiles of the template layout the arrangement was recomputed from scratch on every reflow: only the first overflow split looked at the focused window (and not at all when it sat in an overflow half), every later split took the roomiest slot, and the newcomer received whichever half was pushed last. Opening an 8th window with focus in an overflow half rebuilt the whole screen and stacked four windows in one column. The overflow arrangement is now remembered per workspace as a split tree whose leaves are the windows (arrangement.ts, pure). A window _dynamicAdd just tracked cuts the focused window's leaf along its longer side and takes the second half; nothing else moves. Any other change — a close, minimise, workspace move, layout cycle — rebuilds from the layout in creation order, so the oldest windows get the roomiest tiles again. assign() now halves the nominated slot last (so a rebuild also gives the newest window the focused tile's half) and accepts an overflow half as the nominee. Swaps trade leaves as well as slots. --- README.md | 6 +- .../layout/dynamic/arrangement.test.ts | 161 ++++++++++++++++++ src/components/layout/dynamic/arrangement.ts | 156 +++++++++++++++++ src/components/layout/dynamic/reflow.test.ts | 22 +++ src/components/layout/dynamic/reflow.ts | 12 +- src/components/tilingsystem/tilingManager.ts | 145 +++++++++++++--- 6 files changed, 470 insertions(+), 32 deletions(-) create mode 100644 src/components/layout/dynamic/arrangement.test.ts create mode 100644 src/components/layout/dynamic/arrangement.ts diff --git a/README.md b/README.md index d9980e40..24b049ac 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,10 @@ like upstream Tiling Shell. With it on, windows always fill the screen automatically as you open and close them, following the proportions of whichever layout you've selected: one window is fullscreen, opening a second splits the space in half, opening a third takes -half of whatever region you're currently focused on, and so on. Closing a window -gives its space back to whatever was sharing it. No manual tiling needed. +half of whatever region you're currently focused on, and so on — and only that +region changes; every other window stays where it is. Closing a window rebuilds +the layout by seniority: the windows you opened first get the roomiest tiles. +No manual tiling needed. | Shortcut | Action | |---|---| diff --git a/src/components/layout/dynamic/arrangement.test.ts b/src/components/layout/dynamic/arrangement.test.ts new file mode 100644 index 00000000..b698d417 --- /dev/null +++ b/src/components/layout/dynamic/arrangement.test.ts @@ -0,0 +1,161 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildLayoutTree } from './layoutTree.ts'; +import type { TileRect } from './layoutTree.ts'; +import { assign } from './reflow.ts'; +import { + arrangementKeys as keysOf, + arrangementRectOf as rectOf, + buildArrangement as build, + insertIntoArrangement as insert, + swapInArrangement as swap, +} from './arrangement.ts'; +import type { Arrangement } from './arrangement.ts'; + +const twoColumns = () => + buildLayoutTree([ + { x: 0, y: 0, width: 0.67, height: 1 }, + { x: 0.67, y: 0, width: 0.33, height: 1 }, + ])!; + +const layout2 = () => + buildLayoutTree([ + { x: 0, y: 0, width: 0.26, height: 0.5 }, + { x: 0.26, y: 0, width: 0.37, height: 1 }, + { x: 0.63, y: 0, width: 0.37, height: 1 }, + { x: 0, y: 0.5, width: 0.26, height: 0.5 }, + ])!; + +const close = (a: number, b: number) => Math.abs(a - b) < 1e-9; +const rectClose = (a: TileRect | null, b: TileRect) => + a !== null && + close(a.x, b.x) && + close(a.y, b.y) && + close(a.width, b.width) && + close(a.height, b.height); +const assertRect = (tree: Arrangement, key: string, r: TileRect) => + assert.ok( + rectClose(rectOf(tree, key), r), + `${key}: ${JSON.stringify(rectOf(tree, key))} != ${JSON.stringify(r)}`, + ); +const overlap = (a: TileRect, b: TileRect) => + Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x)) * + Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y)); + +function assertPartition(tree: Arrangement, keys: string[]) { + assert.deepEqual([...keysOf(tree)].sort(), [...keys].sort()); + const rects = keys.map((k) => rectOf(tree, k)!); + const covered = rects.reduce((s, r) => s + r.width * r.height, 0); + assert.ok(close(covered, 1), `covers ${covered}`); + for (let i = 0; i < rects.length; i++) + for (let j = i + 1; j < rects.length; j++) + assert.ok( + overlap(rects[i], rects[j]) < 1e-9, + `${keys[i]} and ${keys[j]} overlap`, + ); +} + +test('build maps keys onto the layout in slot order', () => { + const t = build(twoColumns(), ['a', 'b'])!; + assertRect(t, 'a', { x: 0, y: 0, width: 0.67, height: 1 }); + assertRect(t, 'b', { x: 0.67, y: 0, width: 0.33, height: 1 }); +}); + +test('insert cuts the nominated leaf and gives the newcomer the second half', () => { + const t = insert(build(twoColumns(), ['a', 'b'])!, 'a', 'c'); + assertRect(t, 'a', { x: 0, y: 0, width: 0.67, height: 0.5 }); + assertRect(t, 'c', { x: 0, y: 0.5, width: 0.67, height: 0.5 }); + assertRect(t, 'b', { x: 0.67, y: 0, width: 0.33, height: 1 }); +}); + +test('insert leaves every other leaf alone, even a roomier one', () => { + const t = insert(build(twoColumns(), ['a', 'b'])!, 'b', 'c'); + assertRect(t, 'a', { x: 0, y: 0, width: 0.67, height: 1 }); + assertRect(t, 'b', { x: 0.67, y: 0, width: 0.33, height: 0.5 }); + assertRect(t, 'c', { x: 0.67, y: 0.5, width: 0.33, height: 0.5 }); +}); + +test('insert without a nominee cuts the roomiest leaf', () => { + const t = insert(build(twoColumns(), ['a', 'b'])!, undefined, 'c'); + assertRect(t, 'a', { x: 0, y: 0, width: 0.67, height: 0.5 }); + assertRect(t, 'c', { x: 0, y: 0.5, width: 0.67, height: 0.5 }); +}); + +test('insert with an unknown nominee behaves like no nominee', () => { + const t = insert(build(twoColumns(), ['a', 'b'])!, 'zzz', 'c'); + assertRect(t, 'c', { x: 0, y: 0.5, width: 0.67, height: 0.5 }); +}); + +test('insert cuts a wide leaf vertically', () => { + let t = build(twoColumns(), ['a', 'b'])!; + t = insert(t, 'a', 'c'); + t = insert(t, 'c', 'd'); + assertRect(t, 'c', { x: 0, y: 0.5, width: 0.335, height: 0.5 }); + assertRect(t, 'd', { x: 0.335, y: 0.5, width: 0.335, height: 0.5 }); + assertRect(t, 'a', { x: 0, y: 0, width: 0.67, height: 0.5 }); +}); + +test('insert keeps history: an earlier focused cut survives a later one', () => { + // the plan's step 2: w5 split R (not the roomiest), then w6 splits L-T; + // w5 must stay in R's bottom half + let t = build(layout2(), ['w1', 'w2', 'w3', 'w4'])!; + t = insert(t, 'w2', 'w5'); + t = insert(t, 'w3', 'w6'); + assertRect(t, 'w1', { x: 0.26, y: 0, width: 0.37, height: 1 }); + assertRect(t, 'w2', { x: 0.63, y: 0, width: 0.37, height: 0.5 }); + assertRect(t, 'w5', { x: 0.63, y: 0.5, width: 0.37, height: 0.5 }); + assertRect(t, 'w3', { x: 0, y: 0, width: 0.26, height: 0.25 }); + assertRect(t, 'w6', { x: 0, y: 0.25, width: 0.26, height: 0.25 }); + assertRect(t, 'w4', { x: 0, y: 0.5, width: 0.26, height: 0.5 }); +}); + +test('swap exchanges the two keys and nothing else', () => { + const before = build(twoColumns(), ['a', 'b'])!; + const t = swap(before, 'a', 'b'); + assertRect(t, 'b', rectOf(before, 'a')!); + assertRect(t, 'a', rectOf(before, 'b')!); + assert.deepEqual(keysOf(t).sort(), ['a', 'b']); +}); + +test('rectOf is null for an unknown key', () => { + assert.equal(rectOf(build(twoColumns(), ['a', 'b'])!, 'x'), null); +}); + +test('build with a focused key reproduces assign with that slot nominated', () => { + const keys = ['a', 'b', 'c', 'd']; + const t = build(twoColumns(), keys, 'b')!; + const rects = assign(twoColumns(), 4, 1); + keys.forEach((k, i) => assertRect(t, k, rects[i])); +}); + +test('build with a focused key in an overflow half matches assign', () => { + const keys = ['w1', 'w2', 'w3', 'w4', 'w5', 'w6']; + const t = build(layout2(), keys, 'w5')!; + const rects = assign(layout2(), 6, 4); + keys.forEach((k, i) => assertRect(t, k, rects[i])); +}); + +test('random inserts and swaps always keep a partition of the screen', () => { + let seed = 12345; + const rnd = (n: number) => { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + return seed % n; + }; + for (let run = 0; run < 200; run++) { + const keys = ['w1', 'w2', 'w3', 'w4']; + let t = build(layout2(), keys)!; + for (let step = 0; step < 8; step++) { + if (rnd(3) === 0) { + const a = keys[rnd(keys.length)]; + const b = keys[rnd(keys.length)]; + t = swap(t, a, b); + } else { + const at = rnd(4) === 0 ? undefined : keys[rnd(keys.length)]; + const k = `n${run}-${step}`; + keys.push(k); + t = insert(t, at, k); + } + assertPartition(t, keys); + } + } +}); diff --git a/src/components/layout/dynamic/arrangement.ts b/src/components/layout/dynamic/arrangement.ts new file mode 100644 index 00000000..2dd1f2d5 --- /dev/null +++ b/src/components/layout/dynamic/arrangement.ts @@ -0,0 +1,156 @@ +/** + * The arrangement dynamic tiling keeps between reflows once there are more + * windows than the selected layout has tiles: a split tree whose leaves are + * the windows themselves. Pure: no GNOME imports, keys are opaque. + * + * Opening a window cuts the focused window's leaf and nothing else moves; + * everything that is not a fresh open (a close, a layout change, …) is a + * `build` from the layout, in creation order, so the oldest windows get the + * roomiest tiles again. + */ + +import { buildLayoutTree } from './layoutTree'; +import type { SplitTree, TileRect } from './layoutTree'; +import { assign, slotOrder } from './reflow'; + +export type Arrangement = + | { kind: 'leaf'; key: K; tile: TileRect } + | { + kind: 'split'; + axis: 'x' | 'y'; + at: number; + first: Arrangement; + second: Arrangement; + }; + +const EPSILON = 1e-6; + +const sameRect = (a: TileRect, b: TileRect) => + Math.abs(a.x - b.x) < EPSILON && + Math.abs(a.y - b.y) < EPSILON && + Math.abs(a.width - b.width) < EPSILON && + Math.abs(a.height - b.height) < EPSILON; + +function label( + tree: SplitTree, + rects: TileRect[], + keys: K[], +): Arrangement | null { + if (tree.kind === 'leaf') { + const index = rects.findIndex((r) => sameRect(r, tree.tile)); + if (index < 0) return null; + return { kind: 'leaf', key: keys[index], tile: tree.tile }; + } + const first = label(tree.first, rects, keys); + const second = label(tree.second, rects, keys); + if (!first || !second) return null; + return { kind: 'split', axis: tree.axis, at: tree.at, first, second }; +} + +/** + * The arrangement `assign()` would give `keys` (in creation order) on + * `base`, with the leaf of `focused` — if any — the one halved last. + * Null only if the geometry cannot be re-read as a split tree. + */ +export function buildArrangement( + base: SplitTree, + keys: K[], + focused?: K, +): Arrangement | null { + if (keys.length === 0) return null; + const splitSlot = focused === undefined ? -1 : keys.indexOf(focused); + const rects = assign( + base, + keys.length, + splitSlot >= 0 ? splitSlot : undefined, + ); + const tree = buildLayoutTree(rects); + return tree ? label(tree, rects, keys) : null; +} + +function leaves(tree: Arrangement): { key: K; tile: TileRect }[] { + if (tree.kind === 'leaf') return [tree]; + return [...leaves(tree.first), ...leaves(tree.second)]; +} + +/** Every key in the tree, left-to-right then top-to-bottom. */ +export function arrangementKeys(tree: Arrangement): K[] { + return leaves(tree).map((leaf) => leaf.key); +} + +/** The normalised rect of `key`'s leaf, or null when it is not in the tree. */ +export function arrangementRectOf( + tree: Arrangement, + key: K, +): TileRect | null { + return leaves(tree).find((leaf) => leaf.key === key)?.tile ?? null; +} + +/** + * Cuts the leaf of `at` — or the roomiest leaf when `at` is missing — along + * its longer side, exactly as `assign()` halves a slot: `at` keeps the first + * half and `key` takes the second. No other leaf changes. + */ +export function insertIntoArrangement( + tree: Arrangement, + at: K | undefined, + key: K, +): Arrangement { + let target = at; + if (target === undefined || arrangementRectOf(tree, target) === null) { + const all = leaves(tree); + target = all[slotOrder(all.map((leaf) => leaf.tile))[0]].key; + } + + const cut = (node: Arrangement): Arrangement => { + if (node.kind === 'leaf') { + if (node.key !== target) return node; + const r = node.tile; + if (r.height >= r.width) { + const mid = r.y + r.height / 2; + return { + kind: 'split', + axis: 'y', + at: mid, + first: { ...node, tile: { ...r, height: r.height / 2 } }, + second: { + kind: 'leaf', + key, + tile: { ...r, y: mid, height: r.height / 2 }, + }, + }; + } + const mid = r.x + r.width / 2; + return { + kind: 'split', + axis: 'x', + at: mid, + first: { ...node, tile: { ...r, width: r.width / 2 } }, + second: { + kind: 'leaf', + key, + tile: { ...r, x: mid, width: r.width / 2 }, + }, + }; + } + return { ...node, first: cut(node.first), second: cut(node.second) }; + }; + return cut(tree); +} + +/** The same tree with the leaves of `a` and `b` trading keys. */ +export function swapInArrangement( + tree: Arrangement, + a: K, + b: K, +): Arrangement { + const walk = (node: Arrangement): Arrangement => { + if (node.kind === 'leaf') { + if (node.key === a) return { ...node, key: b }; + if (node.key === b) return { ...node, key: a }; + return node; + } + return { ...node, first: walk(node.first), second: walk(node.second) }; + }; + return walk(tree); +} diff --git a/src/components/layout/dynamic/reflow.test.ts b/src/components/layout/dynamic/reflow.test.ts index e1217f58..92f60e67 100644 --- a/src/components/layout/dynamic/reflow.test.ts +++ b/src/components/layout/dynamic/reflow.test.ts @@ -243,6 +243,28 @@ test('overflow splits the nominated slot', () => { ]); }); +test('overflow splits the nominated slot last, so the newest window gets its half', () => { + // two extra windows: the roomiest (left) is halved first for the older + // one, the nominated right tile is halved last for the newcomer + assert.deepEqual(assign(twoColumns(), 4, 1), [ + { x: 0, y: 0, width: 0.67, height: 0.5 }, + { x: 0.67, y: 0, width: 0.33, height: 0.5 }, + { x: 0, y: 0.5, width: 0.67, height: 0.5 }, + { x: 0.67, y: 0.5, width: 0.33, height: 0.5 }, + ]); +}); + +test('overflow can nominate a slot that is itself an overflow half', () => { + // slot 2 is the bottom half of the left tile; it is wider than tall so + // it is cut vertically for the newcomer + assert.deepEqual(assign(twoColumns(), 4, 2), [ + { x: 0, y: 0, width: 0.67, height: 0.5 }, + { x: 0.67, y: 0, width: 0.33, height: 1 }, + { x: 0, y: 0.5, width: 0.335, height: 0.5 }, + { x: 0.335, y: 0.5, width: 0.335, height: 0.5 }, + ]); +}); + test('assign is deterministic and total for every window count', () => { const tree = buildLayoutTree([ { x: 0, y: 0, width: 0.26, height: 0.5 }, diff --git a/src/components/layout/dynamic/reflow.ts b/src/components/layout/dynamic/reflow.ts index 8dd48a97..130e1de7 100644 --- a/src/components/layout/dynamic/reflow.ts +++ b/src/components/layout/dynamic/reflow.ts @@ -115,8 +115,9 @@ const areaOf = (r: TileRect) => r.width * r.height; * * Past the last tile a region is halved. The slot that owned it **keeps the * first half** and the newcomer is appended, so overflow never evicts a window - * from the region it was already in. `splitSlot` nominates which slot is - * halved first; any further overflow takes the roomiest. + * from the region it was already in. Overflow takes the roomiest slot, except + * that `splitSlot` nominates the slot halved **last** — the one the newest + * window takes half of — and may name a slot that is itself an earlier half. * * The result depends only on its arguments, so every caller that passes the * same arguments sees the same geometry the windows are actually in. @@ -135,14 +136,13 @@ export function assign( } const slots = slotOrder(leaves).map((index) => leaves[index]); - let nominated = - splitSlot !== undefined && splitSlot >= 0 && splitSlot < slots.length + const nominated = + splitSlot !== undefined && splitSlot >= 0 && splitSlot < windowCount - 1 ? splitSlot : undefined; while (slots.length < windowCount) { - let target = nominated; - nominated = undefined; + let target = slots.length === windowCount - 1 ? nominated : undefined; if (target === undefined) { target = 0; diff --git a/src/components/tilingsystem/tilingManager.ts b/src/components/tilingsystem/tilingManager.ts index f33c9b2d..deda86be 100644 --- a/src/components/tilingsystem/tilingManager.ts +++ b/src/components/tilingsystem/tilingManager.ts @@ -22,8 +22,20 @@ import SignalHandling from '../../utils/signalHandling'; import Layout from '../layout/Layout'; import Tile from '../layout/Tile'; import TileUtils from '../layout/TileUtils'; -import { buildLayoutTree, SplitTree } from '../layout/dynamic/layoutTree'; +import { + buildLayoutTree, + leafCount, + SplitTree, +} from '../layout/dynamic/layoutTree'; import { assign, neighbourIndex, slotUnderPoint } from '../layout/dynamic/reflow'; +import { + arrangementKeys, + arrangementRectOf, + buildArrangement, + insertIntoArrangement, + swapInArrangement, +} from '../layout/dynamic/arrangement'; +import type { Arrangement } from '../layout/dynamic/arrangement'; import { applyPins } from '../layout/dynamic/pins'; import type { SlotPin } from '../layout/dynamic/pins'; import type { Direction } from '../layout/dynamic/reflow'; @@ -106,6 +118,17 @@ export class TilingManager { // windows ahead of it closing, and resolved to an index per workspace at // reflow time so it cannot be misapplied to an unrelated workspace. private _splitTarget: Meta.Window | null = null; + // Once there are more windows than the template layout has tiles, the + // arrangement is kept per workspace and only evolved on a fresh open + // (the focused window's leaf is halved); anything else rebuilds it. + private _dynamicArrangements: Map< + Meta.Workspace, + { layoutId: string; tree: Arrangement } + > = new Map(); + + // Windows _dynamicAdd tracked that no reflow has placed yet: the only + // ones an existing arrangement grows by a cut instead of a rebuild. + private _dynamicNewcomers: Set = new Set(); private _dynamicReflowSourceId: number | null = null; // Cache of _dynamicLayoutCandidates(), rebuilt only when the saved // layouts change rather than on every reflow. @@ -248,6 +271,9 @@ export class TilingManager { GlobalState.SIGNAL_LAYOUTS_CHANGED, () => { this._dynamicLayoutCandidatesCache = null; + // an edited layout keeps its id, so the remembered + // arrangements cannot tell they are stale + this._dynamicArrangements.clear(); const ws = global.workspaceManager.get_active_workspace(); if (!ws) return; @@ -392,6 +418,9 @@ export class TilingManager { [...this._dynamicLayoutOffset.keys()] .filter((ws) => !liveWorkspaces.has(ws)) .forEach((ws) => this._dynamicLayoutOffset.delete(ws)); + [...this._dynamicArrangements.keys()] + .filter((ws) => !liveWorkspaces.has(ws)) + .forEach((ws) => this._dynamicArrangements.delete(ws)); this._debug('deleted workspace'); }, @@ -598,6 +627,8 @@ export class TilingManager { this._dynamicLayoutOffset.clear(); this._pinnedWindows.clear(); this._splitTarget = null; + this._dynamicArrangements.clear(); + this._dynamicNewcomers.clear(); this._signals.disconnect(); this._isGrabbingWindow = false; this._snapAssistingInfo.update(undefined); @@ -1381,25 +1412,100 @@ export class TilingManager { /** * The managed windows of a workspace and the slot rectangles dynamic * tiling gives them, or null when there is nothing to place. + * + * While the template layout has a tile per window the rects are a pure + * function of the window count. Past that, this is where the remembered + * arrangement advances: a window `_dynamicAdd` just tracked halves the + * focused window's leaf and nothing else moves; any other difference + * from what was remembered — a window closed, minimised or moved, the + * layout cycled — rebuilds from the layout in creation order, so the + * oldest windows get the roomiest tiles again. Calling this twice + * without a change in between returns the same rects. */ private _dynamicSlots( ws: Meta.Workspace, ): { windows: Meta.Window[]; rects: ReturnType } | null { const windows = this._dynamicManagedWindows(ws); - if (windows.length === 0) return null; + if (windows.length === 0) { + this._dynamicArrangements.delete(ws); + return null; + } const tree = this._dynamicTree(windows.length, ws); if (!tree) return null; - const splitSlot = this._splitTarget - ? windows.indexOf(this._splitTarget) - : -1; - const rects = assign( - tree, - windows.length, - splitSlot >= 0 ? splitSlot : undefined, - ); + const rects = this._dynamicRects(ws, tree, windows); return { windows, rects: this._applyWindowPins(ws, windows, rects) }; } + private _dynamicRects( + ws: Meta.Workspace, + tree: SplitTree, + windows: Meta.Window[], + ): ReturnType { + if (windows.length <= leafCount(tree)) { + this._dynamicArrangements.delete(ws); + return assign(tree, windows.length); + } + + const focused = + this._splitTarget && windows.includes(this._splitTarget) + ? this._splitTarget + : undefined; + const layoutId = this.getCurrentDynamicLayoutId(ws) ?? ''; + const previous = this._dynamicArrangements.get(ws); + + let arrangement: Arrangement | null = null; + if (previous && previous.layoutId === layoutId) { + const known = new Set(arrangementKeys(previous.tree)); + const newcomers = windows.filter((w) => !known.has(w)); + const onlyGrew = + known.size + newcomers.length === windows.length && + [...known].every((w) => windows.includes(w)) && + newcomers.every((w) => this._dynamicNewcomers.has(w)); + if (onlyGrew) { + arrangement = newcomers.reduce( + (t, w) => insertIntoArrangement(t, focused, w), + previous.tree, + ); + } + } + if (!arrangement) + arrangement = buildArrangement(tree, windows, focused); + + windows.forEach((w) => this._dynamicNewcomers.delete(w)); + if (!arrangement) { + // the geometry could not be read back as a tree: place without + // memory rather than not at all + this._dynamicArrangements.delete(ws); + const splitSlot = focused ? windows.indexOf(focused) : -1; + return assign( + tree, + windows.length, + splitSlot >= 0 ? splitSlot : undefined, + ); + } + this._dynamicArrangements.set(ws, { layoutId, tree: arrangement }); + const final = arrangement; + return windows.map((w) => arrangementRectOf(final, w)!); + } + + /** + * Trades the places of two managed windows: their creation-order slots + * (what a rebuild hands out) and, when an arrangement is remembered for + * the workspace, their leaves in it. + */ + private _dynamicSwap(ws: Meta.Workspace, a: Meta.Window, b: Meta.Window) { + const i = this._dynamicWindows.indexOf(a); + const j = this._dynamicWindows.indexOf(b); + if (i < 0 || j < 0) return; + [this._dynamicWindows[i], this._dynamicWindows[j]] = [ + this._dynamicWindows[j], + this._dynamicWindows[i], + ]; + const remembered = this._dynamicArrangements.get(ws); + if (remembered) + remembered.tree = swapInArrangement(remembered.tree, a, b); + } + /** * Grows the slots of pinned windows so the frame the client insists on * fits inside them together with the gaps; the neighbours give way. @@ -1605,12 +1711,7 @@ export class TilingManager { const to = neighbourIndex(rects, from, towards); if (to < 0) return true; // at the edge of the screen - const a = this._dynamicWindows.indexOf(windows[from]); - const b = this._dynamicWindows.indexOf(windows[to]); - [this._dynamicWindows[a], this._dynamicWindows[b]] = [ - this._dynamicWindows[b], - this._dynamicWindows[a], - ]; + this._dynamicSwap(ws, windows[from], windows[to]); this._applyDynamicTiling(); return true; @@ -1684,6 +1785,7 @@ export class TilingManager { */ private _untrackDynamicWindow(window: Meta.Window) { this._dynamicWindowSignals.delete(window); + this._dynamicNewcomers.delete(window); if (this._splitTarget === window) this._splitTarget = null; this._placer.forget(placementTargetFor(window)); this._pinnedWindows.delete(window); @@ -1765,6 +1867,7 @@ export class TilingManager { : false; if (!this._trackDynamicWindow(window)) return; + this._dynamicNewcomers.add(window); this._splitTarget = focusedIsManaged ? focused : null; const windowActor = @@ -2079,14 +2182,8 @@ export class TilingManager { y: pointerY, }); - if (to >= 0 && to !== from) { - const a = this._dynamicWindows.indexOf(windows[from]); - const b = this._dynamicWindows.indexOf(windows[to]); - [this._dynamicWindows[a], this._dynamicWindows[b]] = [ - this._dynamicWindows[b], - this._dynamicWindows[a], - ]; - } + if (to >= 0 && to !== from) + this._dynamicSwap(ws, windows[from], windows[to]); // dropped outside every slot, or back where it started: snap it home this._applyDynamicTiling(); From e22e2ba2b81e34484c00cc50206d19d1cd324a65 Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Mon, 14 Sep 2026 14:49:39 +0530 Subject: [PATCH 41/42] fix(dynamic): forget newcomers once placed anywhere; drop arrangements holding a closed window --- src/components/tilingsystem/tilingManager.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/tilingsystem/tilingManager.ts b/src/components/tilingsystem/tilingManager.ts index deda86be..04fbae90 100644 --- a/src/components/tilingsystem/tilingManager.ts +++ b/src/components/tilingsystem/tilingManager.ts @@ -1433,6 +1433,9 @@ export class TilingManager { const tree = this._dynamicTree(windows.length, ws); if (!tree) return null; const rects = this._dynamicRects(ws, tree, windows); + // placed once, a window is no longer a newcomer anywhere: if it + // later turns up on another workspace that is a move, not an open + windows.forEach((w) => this._dynamicNewcomers.delete(w)); return { windows, rects: this._applyWindowPins(ws, windows, rects) }; } @@ -1471,7 +1474,6 @@ export class TilingManager { if (!arrangement) arrangement = buildArrangement(tree, windows, focused); - windows.forEach((w) => this._dynamicNewcomers.delete(w)); if (!arrangement) { // the geometry could not be read back as a tree: place without // memory rather than not at all @@ -1786,6 +1788,11 @@ export class TilingManager { private _untrackDynamicWindow(window: Meta.Window) { this._dynamicWindowSignals.delete(window); this._dynamicNewcomers.delete(window); + // an arrangement holding this window is stale (the next reflow of + // that workspace rebuilds anyway) and must not keep a dead window + [...this._dynamicArrangements.entries()] + .filter(([, { tree }]) => arrangementKeys(tree).includes(window)) + .forEach(([ws]) => this._dynamicArrangements.delete(ws)); if (this._splitTarget === window) this._splitTarget = null; this._placer.forget(placementTargetFor(window)); this._pinnedWindows.delete(window); From d3c2bbbd8801865b086c4f2c87203ef5753b1427 Mon Sep 17 00:00:00 2001 From: J4KE-B Date: Sun, 9 Aug 2026 22:05:59 +0530 Subject: [PATCH 42/42] chore: point metadata url back at upstream for this PR --- README.md | 109 ---------------------------------------- resources/metadata.json | 2 +- 2 files changed, 1 insertion(+), 110 deletions(-) diff --git a/README.md b/README.md index 24b049ac..3c300351 100644 --- a/README.md +++ b/README.md @@ -1,112 +1,3 @@ -> ### This is a modified fork of [Tiling Shell](https://github.com/domferr/tilingshell) -> -> **All of the work below, and the extension itself, is by [Domenico Ferraro (@domferr)](https://github.com/domferr).** -> I did not write Tiling Shell. This fork only adds a dynamic tiling mode on top of -> it, and everything else you see here is his. -> -> If you find this useful, the person to support is him: -> [Ko-fi](https://ko-fi.com/domferr) · [Patreon](https://patreon.com/domferr). -> -> **Why a fork rather than a pull request?** Not because the feature was turned down. -> On [issue #342](https://github.com/domferr/tilingshell/issues/342) domferr said he'd -> been asked for dynamic tiling many times and that it seemed like something people -> would enjoy. The repository is simply slow to review right now, and I wanted to run -> the feature on my own machine. The design has been offered upstream and I'd be glad -> to see it merged there instead, at which point this fork stops being necessary. -> -> **What is added here:** a dynamic tiling mode, off by default, in which windows always -> fill the screen following the proportions of a layout drawn in Tiling Shell's own -> editor — one window fullscreen, a second splits the space, closing one gives it back. -> The layout used is chosen by how many windows are open. Bug fixes found along the way -> are sent upstream separately ([#595](https://github.com/domferr/tilingshell/pull/595)). -> -> **Installing this replaces upstream Tiling Shell**, since it deliberately keeps the -> same extension UUID rather than masquerading as a different extension. Licensed -> GPLv3, exactly as the original. - -## Dynamic Tiling - -The reason this fork exists. Turn it on from the switch at the top of the Tiling -Shell panel menu — it's **off by default**, and with it off this behaves exactly -like upstream Tiling Shell. - -With it on, windows always fill the screen automatically as you open and close -them, following the proportions of whichever layout you've selected: one window -is fullscreen, opening a second splits the space in half, opening a third takes -half of whatever region you're currently focused on, and so on — and only that -region changes; every other window stays where it is. Closing a window rebuilds -the layout by seniority: the windows you opened first get the roomiest tiles. -No manual tiling needed. - -| Shortcut | Action | -|---|---| -| SUPER+←/↑/↓/→ | Swap the focused window with the region next to it | -| SHIFT+SUPER+; | Cycle to the next layout with the same number of tiles (SUPER+; is left to GNOME's emoji picker; a backward binding can be set in the preferences) | -| Drag a window onto another | Swap their positions | - -In dynamic mode, SUPER+arrows swaps windows instead of moving to a -static tile, since there's no fixed grid to move to. - -> Multi-monitor isn't fully exercised yet — dragging a tiled window from one -> monitor to another isn't handled specially. - -## ⚠️ Read this before installing - -This fork keeps Tiling Shell's extension UUID, `tilingshell@ferrarodomenico.com`. That -has one consequence you need to know about: - -**You will stop receiving Tiling Shell updates.** The extension reports version `99`, -while the version published on extensions.gnome.org is `76`. Because 99 is the higher -number, GNOME will never offer you an update — you will silently stay on this fork, -believing you are up to date, until you go back manually. New releases and bug fixes -from domferr will not reach you. - -That version number is inherited from upstream's development branch, not something -invented here, but the effect on you is the same either way. - -You also cannot run this and upstream Tiling Shell side by side. Same UUID, one wins. - -### Installing - -Download the zip for your GNOME version from the -[Releases](https://github.com/J4KE-B/tilingshell/releases) page, then: - -```sh -gnome-extensions install --force tilingshell@ferrarodomenico.com.zip -# log out and log back in — GNOME caches extension code and will not -# pick up the new version any other way -gnome-extensions enable tilingshell@ferrarodomenico.com -``` - -Dynamic tiling is **off** by default. Turn it on from the switch at the top of the -Tiling Shell menu in the top bar. With it off, this behaves like ordinary Tiling Shell. - -### Going back to the real Tiling Shell - -```sh -gnome-extensions uninstall tilingshell@ferrarodomenico.com -``` - -Then reinstall from -[extensions.gnome.org](https://extensions.gnome.org/extension/7065/tiling-shell/), or -through the Extension Manager app, and log out and back in. - -Your layouts and settings live in dconf, not in the extension, so they survive the -round trip and upstream will pick them straight up. The one setting it will not know -about is `enable-dynamic-tiling`, which it simply ignores. To wipe everything and start -from stock defaults instead: - -```sh -dconf reset -f /org/gnome/shell/extensions/tilingshell/ -``` - -### If something breaks - -File it [here](https://github.com/J4KE-B/tilingshell/issues), not on domferr's tracker. -The dynamic tiling code is mine and he should not be answering for it. If you can still -reproduce the problem with dynamic tiling switched **off**, then it probably is an -upstream issue and belongs [there](https://github.com/domferr/tilingshell/issues). - [![release](https://img.shields.io/badge/Release_v16-blue?style=for-the-badge)]([https://ko-fi.com/domferr](https://github.com/domferr/tilingshell/releases)) diff --git a/resources/metadata.json b/resources/metadata.json index 21fca59d..e58a93c7 100644 --- a/resources/metadata.json +++ b/resources/metadata.json @@ -15,7 +15,7 @@ ], "version": 99, "version-name": "17.3", - "url": "https://github.com/J4KE-B/tilingshell", + "url": "https://github.com/domferr/tilingshell", "settings-schema": "org.gnome.shell.extensions.tilingshell", "gettext-domain": "tilingshell", "donations": {