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" ]
}