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..0c643c7e 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": "tsx --test \"src/**/*.test.ts\" \"test/**/*.test.ts\"" }, "devDependencies": { "@babel/generator": "^7.28.3", @@ -51,6 +52,7 @@ "glob": "^11.0.3", "globals": "^16.4.0", "prettier": "^3.7.3", + "tsx": "^4.23.12", "typescript": "^5.9.2" }, "dependencies": { diff --git a/resources/schemas/org.gnome.shell.extensions.tilingshell.gschema.xml b/resources/schemas/org.gnome.shell.extensions.tilingshell.gschema.xml index 67fe8399..4e88debd 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 @@ -289,6 +294,14 @@ Cycle backwards through available workspace layouts + + semicolon']]]> + In dynamic tiling, use the next layout of the same size + + + + In dynamic tiling, use the previous layout of the same size + 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/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/layoutTree.test.ts b/src/components/layout/dynamic/layoutTree.test.ts new file mode 100644 index 00000000..91896dde --- /dev/null +++ b/src/components/layout/dynamic/layoutTree.test.ts @@ -0,0 +1,235 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +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', + 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); +}); + +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 new file mode 100644 index 00000000..177a36f0 --- /dev/null +++ b/src/components/layout/dynamic/layoutTree.ts @@ -0,0 +1,321 @@ +/** + * 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; + +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; + +/** + * 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; +} + +/** 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 (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; + 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), + }; +} + +/** + * 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'; + 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; + 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; + + if (dir === 'first') { + newAt = oldBounds[startProp] + requestedExtent; + let clampedAt = newAt; + if ( + clampedAt > + oldBounds[startProp] + oldExtent - siblingFloor + ) { + clampedAt = oldBounds[startProp] + oldExtent - siblingFloor; + } + if (clampedAt < oldBounds[startProp] + minSize) { + clampedAt = oldBounds[startProp] + minSize; + } + + const achievedExtent = clampedAt - oldBounds[startProp]; + 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; + } + + 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] + siblingFloor) { + clampedAt = oldBounds[startProp] + siblingFloor; + } + if (clampedAt > oldBounds[startProp] + oldExtent - minSize) { + clampedAt = oldBounds[startProp] + oldExtent - minSize; + } + + const achievedExtent = + oldBounds[startProp] + oldExtent - clampedAt; + if (achievedExtent < requestedExtent - EPSILON) { + myRequestedExtent = requestedExtent + siblingFloor; + } + + 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/pickLayout.test.ts b/src/components/layout/dynamic/pickLayout.test.ts new file mode 100644 index 00000000..1482906f --- /dev/null +++ b/src/components/layout/dynamic/pickLayout.test.ts @@ -0,0 +1,82 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +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]; + +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('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', () => { + 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); +}); + +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 new file mode 100644 index 00000000..37120afa --- /dev/null +++ b/src/components/layout/dynamic/pickLayout.ts @@ -0,0 +1,63 @@ +/** + * 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 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( + tileCounts: number[], + windowCount: number, +): number { + if (tileCounts.length === 0) return -1; + + const exact = tileCounts.indexOf(windowCount); + if (exact >= 0) return exact; + + // 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; + for (let i = 1; i < tileCounts.length; i++) { + if (tileCounts[i] > tileCounts[roomiest]) roomiest = i; + } + 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/layout/dynamic/pins.test.ts b/src/components/layout/dynamic/pins.test.ts new file mode 100644 index 00000000..0fd56b9f --- /dev/null +++ b/src/components/layout/dynamic/pins.test.ts @@ -0,0 +1,183 @@ +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); +}); + +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); +}); + +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); +}); + +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 new file mode 100644 index 00000000..368c057b --- /dev/null +++ b/src/components/layout/dynamic/pins.ts @@ -0,0 +1,106 @@ +/** + * 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 { SplitPath, SplitTree, 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; + +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 ( + (pin.minWidth !== undefined && r.width < pin.minWidth - EPSILON) || + (pin.minHeight !== undefined && r.height < pin.minHeight - EPSILON) + ); + }); + +/** + * 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; + + // 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) { + 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) => + Math.min(floors.get(p.join('/'))?.minHeight ?? 0, 1); + + 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', + 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', + heightFloor, + ); + } + current = readBack(tree, rects, paths); + } + return current; +} diff --git a/src/components/layout/dynamic/reflow.test.ts b/src/components/layout/dynamic/reflow.test.ts new file mode 100644 index 00000000..92f60e67 --- /dev/null +++ b/src/components/layout/dynamic/reflow.test.ts @@ -0,0 +1,298 @@ +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, slotUnderPoint } 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('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`); +}); + +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`, + ); + } + } + } + } +}); + +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); +}); + +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'); +}); + +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('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 }, + { 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}`); + } +}); + +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 new file mode 100644 index 00000000..130e1de7 --- /dev/null +++ b/src/components/layout/dynamic/reflow.ts @@ -0,0 +1,280 @@ +/** + * 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, + 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, 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 + // 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, + 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[] = + 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; + +/** + * 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. 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. + */ +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]); + const nominated = + splitSlot !== undefined && splitSlot >= 0 && splitSlot < windowCount - 1 + ? splitSlot + : undefined; + + while (slots.length < windowCount) { + let target = slots.length === windowCount - 1 ? 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'; + +/** + * 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 + * 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]; + 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/snapassist/snapAssist.ts b/src/components/snapassist/snapAssist.ts index dd9fa52d..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, { @@ -61,6 +63,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; @@ -245,10 +248,13 @@ 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, ); + saLay.setSelected(lay.id === this._selectedLayoutId); // build and place a spacer if (ind < layouts.length - 1) { this.add_child( @@ -261,6 +267,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 +452,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/metaPlacementTarget.ts b/src/components/tilingsystem/metaPlacementTarget.ts new file mode 100644 index 00000000..2780d686 --- /dev/null +++ b/src/components/tilingsystem/metaPlacementTarget.ts @@ -0,0 +1,34 @@ +import { Clutter, GLib, Meta, Mtk } from '../../gi/ext'; +import type { PlacementTarget, PlacerClock, Rect } from './windowPlacer'; +import { adaptWindow } from './placementAdapter'; + +/** + * 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(); + +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 }; +} + +export function placementTargetFor(window: Meta.Window): PlacementTarget { + let target = targets.get(window); + 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 new file mode 100644 index 00000000..26e0d5ed --- /dev/null +++ b/src/components/tilingsystem/reflowScheduler.test.ts @@ -0,0 +1,63 @@ +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', + ); +}); + +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 new file mode 100644 index 00000000..6a91ec7e --- /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/tilingManager.ts b/src/components/tilingsystem/tilingManager.ts index 4bc1a683..04fbae90 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'; @@ -21,6 +22,24 @@ import SignalHandling from '../../utils/signalHandling'; import Layout from '../layout/Layout'; import Tile from '../layout/Tile'; import TileUtils from '../layout/TileUtils'; +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'; +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'; @@ -30,9 +49,23 @@ 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; +const DYNAMIC_DIRECTION: Partial> = { + [KeyBindingsDirection.LEFT]: 'left', + [KeyBindingsDirection.RIGHT]: 'right', + [KeyBindingsDirection.UP]: 'up', + [KeyBindingsDirection.DOWN]: 'down', +}; + class SnapAssistingInfo { private _snapAssistantLayoutId: string | undefined; @@ -75,6 +108,60 @@ 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[] = []; + // 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(); + // 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; + // 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. + private _dynamicLayoutCandidatesCache: { + id: string; + 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 + // cannot leak into the group that applies once the window count changes. + // Set by cycleDynamicLayout. + private _dynamicLayoutOffset: Map< + Meta.Workspace, + 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; + // 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; + // 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; @@ -91,6 +178,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}`); @@ -179,6 +270,11 @@ export class TilingManager { GlobalState.get(), 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; @@ -187,6 +283,7 @@ export class TilingManager { ws.index(), ); this._workspaceTilingLayout.get(ws)?.relayout({ layout }); + this._queueDynamicReflow(); }, ); @@ -234,6 +331,32 @@ 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, 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', + () => this._queueDynamicReflow(), + ); + this._signals.connect( + global.windowManager, + 'unminimize', + () => this._queueDynamicReflow(), + ); + + // 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', @@ -288,6 +411,17 @@ 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._dynamicArrangements.keys()] + .filter((ws) => !liveWorkspaces.has(ws)) + .forEach((ws) => this._dynamicArrangements.delete(ws)); + this._debug('deleted workspace'); }, ); @@ -296,13 +430,18 @@ 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( 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); }, ); @@ -333,6 +472,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; @@ -447,7 +593,7 @@ export class TilingManager { ); } - if (isMaximized) unmaximizeWindow(window); + if (isMaximized) this._unmaximizeForPlacement(window); this._easeWindowRect(window, destination.rect, false, force); @@ -464,10 +610,25 @@ 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; } + 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._dynamicLayoutOffset.clear(); + this._pinnedWindows.clear(); + this._splitTarget = null; + this._dynamicArrangements.clear(); + this._dynamicNewcomers.clear(); this._signals.disconnect(); this._isGrabbingWindow = false; this._snapAssistingInfo.update(undefined); @@ -494,6 +655,7 @@ export class TilingManager { ); this._snapAssist.workArea = this._workArea; this._edgeTilingManager.workarea = this._workArea; + this._queueDynamicReflow(); } private _onWindowGrabBegin(window: Meta.Window, grabOp: number) { @@ -718,6 +880,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) { @@ -743,6 +919,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, @@ -817,6 +1005,25 @@ 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 + 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)) { + // 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], @@ -962,47 +1169,41 @@ 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, 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) { + // 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); @@ -1155,15 +1356,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 @@ -1199,20 +1400,219 @@ 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. + * + * 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) { + this._dynamicArrangements.delete(ws); + return null; + } + 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) }; + } + + 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); + + 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. + */ + 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); + } + + /** + * 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; const rememberOriginalSize = !isMaximized; - if (isMaximized) unmaximizeWindow(window); + if (isMaximized) this._unmaximizeForPlacement(window); if (rememberOriginalSize && !(window as ExtendedWindow).assignedTile) { (window as ExtendedWindow).originalSize = window @@ -1228,17 +1628,47 @@ 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, + 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) { @@ -1258,6 +1688,515 @@ export class TilingManager { ); } + /** + * 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 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 to = neighbourIndex(rects, from, towards); + if (to < 0) return true; // at the edge of the screen + + this._dynamicSwap(ws, windows[from], windows[to]); + + this._applyDynamicTiling(); + return true; + } + + /** + * 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() + ); + } + + /** Trackable windows that additionally have a rectangle to be placed in. */ + private _isDynamicEligible(window: Meta.Window): boolean { + return ( + this._isDynamicTrackable(window) && + isWindowAlive(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._isDynamicTrackable(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._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; + } + + /** + * 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); + 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); + + const slot = this._dynamicWindows.indexOf(window); + if (slot < 0) return; + this._dynamicWindows.splice(slot, 1); + // 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(); + } + + /** + * 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 + * 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; + inCreationOrder.forEach((window) => { + if (this._trackDynamicWindow(window)) adopted = true; + }); + if (adopted) this._queueDynamicReflow(); + } + + /** + * 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 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 focusedIsManaged = + focused && ws + ? this._dynamicManagedWindows(ws).includes(focused) + : false; + + if (!this._trackDynamicWindow(window)) return; + this._dynamicNewcomers.add(window); + this._splitTarget = focusedIsManaged ? focused : null; + + const windowActor = + window.get_compositor_private() as Meta.WindowActor | null; + if (!windowActor) { + // 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; + } + + // 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); + }); + } + + /** + * The split tree dynamic tiling should follow for a given number of + * 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 per tile-count group, and read here. + */ + private _dynamicTree(windowCount: number, ws: Meta.Workspace) { + 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() { + if (this._dynamicLayoutCandidatesCache !== null) + return this._dynamicLayoutCandidatesCache; + + const candidates = GlobalState.get() + .layouts.map((layout) => ({ + id: layout.id, + 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); + + this._dynamicLayoutCandidatesCache = candidates; + return candidates; + } + + /** + * 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; + 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(); + } + + /** + * 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`) + * 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._isDynamicEligible(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: [] }); + } + + /** + * 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 + * 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; + this._reflow.run(() => this._reflowDynamicWindows()); + } + + private _reflowDynamicWindows() { + const workspaces = new Set(); + this._dynamicWindows.forEach((window) => { + const ws = window.get_workspace(); + if (ws) workspaces.add(ws); + }); + + workspaces.forEach((ws) => { + // no decomposable layout at all keeps the static behaviour + 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 + if (!isWindowAlive(window)) return; + this._easeWindowRectFromTile(this._tileOf(rects[slot]), 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 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) { + this._applyDynamicTiling(); + return true; + } + + const [pointerX, pointerY] = global.get_pointer(); + const to = slotUnderPoint(rects, this._workArea, { + x: pointerX, + y: pointerY, + }); + + 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(); + 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; @@ -1279,7 +2218,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 @@ -1333,12 +2273,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/tilingsystem/windowPlacer.test.ts b/src/components/tilingsystem/windowPlacer.test.ts new file mode 100644 index 00000000..6909d8f2 --- /dev/null +++ b/src/components/tilingsystem/windowPlacer.test.ts @@ -0,0 +1,110 @@ +/** 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 = { + isAlive: () => alive, + getFrameRect: () => ({ ...current }), + getBufferRect: () => ({ ...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: () => {}, + }, + { 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 + assert.equal( + placer.place(target, DEST), + 'requested', + 'the frame changed since the settle', + ); + // 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 new file mode 100644 index 00000000..96f26582 --- /dev/null +++ b/src/components/tilingsystem/windowPlacer.ts @@ -0,0 +1,500 @@ +/** + * 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. One target per window: the + * placer keeps its per-window history keyed by the target object. + */ +export interface PlacementTarget { + /** 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; + 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; + /** + * 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, final: boolean) => void; +} + +export type PlaceResult = + | 'requested' + | 'requested-move-only' + | 'skipped-identical' + | 'skipped-settled' + | 'skipped-dead' + | '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; + /** 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 { + 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; + timeout: unknown; + quiet: unknown; +} + +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 { + private readonly _opts: PlacerOptions; + private readonly _history = new WeakMap(); + private readonly _inFlight = new Set(); + + constructor( + private readonly _clock: PlacerClock, + opts: Partial = {}, + ) { + this._opts = { + monitorIndex: 0, + settleTimeoutMs: 300, + quietMs: 40, + animationMs: 250, + retryDelaysMs: [200, 400, 600], + driftWatchMs: 10_000, + ...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 (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) { + hist.lastRequested = copyRect(dest); + hist.settledFrame = copyRect(frame); + return 'skipped-identical'; + } + + 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. + // 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'; + } + + 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'; + } + + // 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); + if (hist) { + this._cancelPending(hist); + this._cancelVerification(hist); + } + this._history.delete(target); + this._inFlight.delete(target); + } + + public destroy(): void { + for (const target of [...this._inFlight]) this.forget(target); + } + + private _request( + target: PlacementTarget, + hist: History, + dest: Rect, + request: Rect, + before: Rect, + 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 + 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), + animate, + fromDrift, + onSettled: options.onSettled, + disconnectGeometry: () => {}, + disconnectGone: () => {}, + timeout: null, + quiet: null, + }; + 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)) { + 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); + if (nudge) + target.moveResizeFrame(userOp, { + ...request, + width: request.width + 1, + }); + 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; + 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( + 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 + // transform behind + actor.setTransform(1, 1, 0, 0); + }); + } + } + + // 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) || + (!pending.fromDrift && + hist.retries >= this._opts.retryDelaysMs.length); + pending.onSettled?.(copyRect(pending.dest), copyRect(frame), final); + + 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, + 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 { + 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); + if (!hist) { + hist = { retries: 0, retryTimeout: null }; + this._history.set(target, hist); + } + return hist; + } +} 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(); diff --git a/src/components/window_menu/overriddenWindowMenu.ts b/src/components/window_menu/overriddenWindowMenu.ts index e233bbbe..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; @@ -138,12 +145,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), ); diff --git a/src/extension.ts b/src/extension.ts index eee13a99..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()); @@ -279,6 +283,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/indicator/defaultMenu.ts b/src/indicator/defaultMenu.ts index 2ab03a0f..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'; @@ -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: { @@ -160,6 +179,33 @@ 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); + // switching sources (static vs. dynamic template) for the + // 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( + dynamicToggle, + ); + const layoutsPopupMenu = new PopupMenu.PopupBaseMenuItem({ style_class: 'indicator-menu-item', }); @@ -211,33 +257,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(); }, ); @@ -420,24 +457,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', @@ -452,7 +486,78 @@ 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), + ); + }); + } + + 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/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..e83bf2a6 100644 --- a/src/indicator/layoutButton.ts +++ b/src/indicator/layoutButton.ts @@ -65,4 +65,21 @@ export default class LayoutButton extends St.Button { width * scalingFactor, ); } + + private _canFocusWhenEnabled: boolean | null = null; + + public setDisabled(disabled: boolean) { + this.reactive = !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/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 59ff551c..88fc7f76 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'; @@ -144,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; @@ -341,6 +345,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); } 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; 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); +} 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(); 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..2c68222f --- /dev/null +++ b/test/mutter-sim/legacy.test.ts @@ -0,0 +1,140 @@ +/** + * 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..d84db6bf --- /dev/null +++ b/test/mutter-sim/legacyPlacers.ts @@ -0,0 +1,92 @@ +/** + * 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..0b9f72e1 --- /dev/null +++ b/test/mutter-sim/mutter.ts @@ -0,0 +1,857 @@ +/** + * 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; + mode?: unknown; + 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; + } + + /** + * 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, mode: _mode, ...rest } = params; + const props: Partial> = {}; + 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)). + 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' } + /** acks inside move_resize_frame, like an X11 client under mutter */ + | { 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 { + 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; + private _clampsLeft?: number; + + 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 '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), + 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/placement.test.ts b/test/mutter-sim/placement.test.ts new file mode 100644 index 00000000..3ad38c09 --- /dev/null +++ b/test/mutter-sim/placement.test.ts @@ -0,0 +1,554 @@ +/** + * 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(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( + 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 by the reflow itself', + ); + clock.tick(10_000); + // 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, + 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, + // 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); + clock.tick(30_000); // retries exhausted long ago, nothing else armed + 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); + clock.tick(10_000); // drift watch expires + 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( + placer.place(target, TILE_S, { animate: true }), + 'skipped-settled', + ); + clock.tick(30_000); // bounded retries, all ignored as well + assert.equal(window.sentConfigurations.length, 7); + assert.equal(clock.pendingTimeouts, 0); + assert.equal( + placer.place(target, TILE_S, { animate: true }), + 'skipped-settled', + ); +}); + +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); +}); + +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, + 2, + 'only the animation and the drift watch are 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 }], + ]); +}); + +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(100); + assert.deepEqual( + window.get_frame_rect(), + { x: 8, y: 40, width: 820, height: 634 }, + 'first answer refused', + ); + 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); + 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 + 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'); +}); + +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(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); + 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); +}); + +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'); +}); + +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); +}); diff --git a/test/mutter-sim/shellWindowManager.ts b/test/mutter-sim/shellWindowManager.ts new file mode 100644 index 00000000..400c5266 --- /dev/null +++ b/test/mutter-sim/shellWindowManager.ts @@ -0,0 +1,208 @@ +/** + * 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/test/mutter-sim/simTarget.ts b/test/mutter-sim/simTarget.ts new file mode 100644 index 00000000..3a7858ce --- /dev/null +++ b/test/mutter-sim/simTarget.ts @@ -0,0 +1,28 @@ +/** PlacementTarget over the simulated MetaWindow, through the same adapter the extension uses. */ +import type { + PlacementTarget, + PlacerClock, +} from '../../src/components/tilingsystem/windowPlacer.ts'; +import { adaptWindow } from '../../src/components/tilingsystem/placementAdapter.ts'; +import { SimClock } from './clock.ts'; +import { SimWindow } 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); + }, + }; +} + +export function simTargetFor(window: SimWindow): PlacementTarget { + let t = targets.get(window); + if (!t) { + t = adaptWindow(window); + targets.set(window, t); + } + return t; +} 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" ] }