Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
da7b8a0
fix: correct distance formula when picking the best vacant tile
J4KE-B Aug 1, 2026
3ae5f57
feat(dynamic): decompose a layout into a binary split tree
J4KE-B Aug 1, 2026
2caa82f
feat(dynamic): distribute N windows across a layout's split tree
J4KE-B Aug 1, 2026
b4d5a02
feat(dynamic): place windows through the split tree as they open and …
J4KE-B Aug 1, 2026
35426cb
feat(dynamic): swap windows on drop and split the focused tile on ove…
J4KE-B Aug 1, 2026
52a3246
feat(dynamic): choose the layout by window count, and add an indicato…
J4KE-B Aug 1, 2026
1054979
feat(dynamic): group layouts by window count in the editor
J4KE-B Aug 1, 2026
19e0120
docs: state plainly that this is a fork of domferr/tilingshell
J4KE-B Aug 2, 2026
2fcd2f3
fix(dynamic): tile windows that open maximized, and give the first wi…
J4KE-B Aug 2, 2026
cd28949
feat(dynamic): reflow when a window is minimized or restored
J4KE-B Aug 2, 2026
3976f4e
Merge branch 'fix/best-tile-distance' into feat/dynamic-tiling
J4KE-B Aug 2, 2026
b765980
chore: point the extension url at this fork
J4KE-B Aug 2, 2026
8fb11ca
chore: remove the dynamic tiling debug probes
J4KE-B Aug 2, 2026
d156026
docs: warn that installing this stops Tiling Shell updates, and how t…
J4KE-B Aug 2, 2026
3c662cd
fix(dynamic): make Super+Arrow swap regions instead of using the stat…
J4KE-B Aug 2, 2026
3db5a35
feat(dynamic): hide the static tile menu entries in dynamic mode
J4KE-B Aug 2, 2026
1b34069
fix(dynamic): nine bugs found by review — lifecycle, stability and ad…
J4KE-B Aug 2, 2026
85072d7
fix(dynamic): three problems the previous review found in its own fixes
J4KE-B Aug 2, 2026
8b406b2
feat(dynamic): Super+; cycles between layouts of the same size
J4KE-B Aug 2, 2026
adfda4b
fix(dynamic): reflow on layout edits and work-area changes, scope the…
J4KE-B Aug 2, 2026
b84e72b
fix(windowBorder): square corners when smart border radius is off
J4KE-B Aug 2, 2026
7326c20
docs: document dynamic tiling's keyboard shortcuts
J4KE-B Aug 9, 2026
c4edfc0
fix(dynamic-tiling): untrack windows dragged to another monitor, cach…
J4KE-B Aug 10, 2026
a6d5e9c
test: run TS tests via tsx instead of Node's native type stripping
J4KE-B Aug 10, 2026
4f668c7
test: mutter simulation harness that reproduces the frozen-actor plac…
J4KE-B Sep 13, 2026
607db61
feat(tiling): non-freezing window placer and reflow scheduler
J4KE-B Sep 13, 2026
4167f14
fix(tiling): place windows through WindowPlacer, never freeze the actor
J4KE-B Sep 13, 2026
899e194
fix(tiling): review fixes for the window placer
J4KE-B Sep 13, 2026
1a0b5e1
fix(tiling): verify a placement after it settles and re-ask once when…
J4KE-B Sep 13, 2026
f9ee7eb
fix(tiling): retry a refused placement after 400 ms, then 2 s
J4KE-B Sep 13, 2026
a1905cf
fix(tiling): first placement retry after 200 ms
J4KE-B Sep 13, 2026
21d2b5c
fix(tiling): retry a refused placement three times, at 200/400/600 ms
J4KE-B Sep 13, 2026
8d6f2e9
feat(dynamic): indicator and snap assist reflect the layout dynamic t…
J4KE-B Sep 13, 2026
529714a
fix(indicator): dim non-group layouts via actor opacity, refresh afte…
J4KE-B Sep 13, 2026
12b35c6
chore(keybindings): cycle dynamic layouts with Shift+Super+; only
J4KE-B Sep 13, 2026
a511d0c
feat(dynamic): preview the slot a dragged window will actually be dro…
J4KE-B Sep 13, 2026
2772755
feat(dynamic): grow the slot of a window whose client refuses to shrink
J4KE-B Sep 13, 2026
fb91f6e
fix(dynamic): pin review fixes
J4KE-B Sep 13, 2026
e30928c
fix(dynamic): pinned siblings ask the parent split for room instead o…
J4KE-B Sep 13, 2026
a6020f6
fix(dynamic): escalate a pin only when the leaf got less than it aske…
J4KE-B Sep 13, 2026
f1172f9
feat(dynamic): a new window halves the focused window's tile; closing…
J4KE-B Sep 14, 2026
e22e2ba
fix(dynamic): forget newcomers once placed anywhere; drop arrangement…
J4KE-B Sep 14, 2026
d3c2bbb
chore: point metadata url back at upstream for this PR
J4KE-B Aug 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion esbuild.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@
<summary>Enable auto tiling</summary>
<description>Automatically tile a new window to the best tile according to the current layout.</description>
</key>
<key name="enable-dynamic-tiling" type="b">
<default>false</default>
<summary>Enable dynamic tiling</summary>
<description>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.</description>
</key>
<key name="raise-together" type="b">
<default>false</default>
<summary>Raise tiled windows together</summary>
Expand Down Expand Up @@ -289,6 +294,14 @@
<default><![CDATA[['']]]></default>
<summary>Cycle backwards through available workspace layouts</summary>
</key>
<key type="as" name="cycle-dynamic-layout">
<default><![CDATA[['<Shift><Super>semicolon']]]></default>
<summary>In dynamic tiling, use the next layout of the same size</summary>
</key>
<key type="as" name="cycle-dynamic-layout-backward">
<default><![CDATA[[]]]></default>
<summary>In dynamic tiling, use the previous layout of the same size</summary>
</key>
</schema>

</schemalist>
43 changes: 36 additions & 7 deletions src/components/editor/editorDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
161 changes: 161 additions & 0 deletions src/components/layout/dynamic/arrangement.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>, 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<string>, 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);
}
}
});
Loading