aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-15 10:24:27 +0200
committerDanilo M. <danix@danix.xyz>2026-10-15 10:24:27 +0200
commitfcd5d1c48e5993c9b452bfe824900d9a80df78c3 (patch)
treebe89d9de38b796e9a78c77699aa2510452dd32f2
parent875bda45d04004815bb89e3e9f735af439e7fefd (diff)
downloadquickshell-fcd5d1c48e5993c9b452bfe824900d9a80df78c3.tar.gz
quickshell-fcd5d1c48e5993c9b452bfe824900d9a80df78c3.zip
feat(window-switcher): the expose scatter layout
layout() seeds windows on a golden-angle spiral, most recent at the centre, pushes overlapping pairs apart along the axis that costs less screen, and fits the result with one scale so sizes stay relative. The spec's first seed, the windows' real positions, was prototyped and dropped: every window on this desk fills its monitor, so they started in one pile, and pushing a pile apart made a line that still overlapped after 300 passes and filled 8 to 15% of the screen. The spiral converged in 1 to 55 passes. Comparing overlaps in raw pixels stacked wide windows into a column, which is why the axis choice divides by the screen's width and height. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--docs/superpowers/specs/2026-10-15-expose-design.md35
-rw-r--r--window-switcher/Expose.js79
-rw-r--r--window-switcher/test_expose.js59
3 files changed, 157 insertions, 16 deletions
diff --git a/docs/superpowers/specs/2026-10-15-expose-design.md b/docs/superpowers/specs/2026-10-15-expose-design.md
index c86bd7c..cc4eac9 100644
--- a/docs/superpowers/specs/2026-10-15-expose-design.md
+++ b/docs/superpowers/specs/2026-10-15-expose-design.md
@@ -61,19 +61,23 @@ pointer stops there.
`Expose.js`, pure JavaScript with no QML, like `Perf.js`:
- layout(rects, W, H) -> [{x, y, w, h}] // rects: [{x, y, w, h}] in real pixels
-
-1. **Seed.** Each rect is the window's real `at` and `size` from
- `lastIpcObject`, in global coordinates, so DP-3 windows start left of DP-1
- ones. Where windows sit on their desktop otherwise carries no meaning.
+ layout(rects, W, H) -> [{x, y, w, h}] // rects: [{w, h}] in real pixels
+
+1. **Seed.** Window k starts on a golden-angle spiral (radius 0.45 times
+ the tallest window times sqrt(k), angle k times 137.5°, x stretched by
+ the screen's aspect), so the most recent window sits at the centre.
+ Seeding from real positions was the first design and was dropped after
+ a prototype: every window here fills its monitor, so all start in one
+ pile, and pushing a pile apart made a line that still overlapped after
+ 300 passes and filled 8 to 15% of the screen. The spiral converged in 1
+ to 55 passes with no overlap. Where a window lives carries no meaning
+ in the scatter, by choice.
2. **Separate.** Repeated passes over all pairs: two rects overlapping, with
a 60px real gap counted as overlap, are each pushed by half the overlap
- along the axis of least overlap. Coincident centres, the normal case here
- because every window fills its monitor, have no direction to push along,
- so each rect of such a pair moves along its own direction, its index times
- the golden angle (137.5°). That is deterministic and spreads a pile in
- every direction rather than along a line. Stops when a pass moves
- nothing, or after 300 passes.
+ along the axis that costs less screen, overlap divided by the screen's
+ width or height. Compared in raw pixels, wide windows always separate
+ vertically and stack into a column. Stops when a pass moves nothing, or
+ after 300 passes.
3. **Fit.** One uniform scale fits the bounding box into 92% of W by H,
capped at 0.5 so a lone window is not a full-screen mirror, and the result
is centred. A uniform scale keeps rects apart and keeps their sizes
@@ -82,9 +86,8 @@ pointer stops there.
The input order is most recently used first, as `Windows.windows` already
returns it, so the most recent window keeps the lowest index.
-A window whose `lastIpcObject` has no `at` or `size` yet gets 1280x800 at the
-origin; the layout is a binding and repaints when the data arrives. A window
-appearing or closing while the overlay is open recomputes the layout, and the
+A window whose `lastIpcObject` has no `size` yet gets 1280x800; the layout
+is a binding and repaints when the data arrives. A window appearing or closing while the overlay is open recomputes the layout, and the
previews move.
Ceilings, documented with `ponytail:` comments: the pairwise loop is O(n²)
@@ -118,8 +121,8 @@ rule.
`node window-switcher/test_expose.js`, asserting:
-- seven identical 2560x1080 rects at the same position end with no two
- overlapping, all inside W by H;
+- seven identical 2556x1076 rects, and a mixed desk of fourteen, end with
+ no two overlapping, all inside W by H;
- two runs on the same input give the same output;
- a single window is scaled at most 0.5;
- a 1076x1916 rect and a 2556x1076 rect keep their width and height ratios
diff --git a/window-switcher/Expose.js b/window-switcher/Expose.js
new file mode 100644
index 0000000..2b14e4e
--- /dev/null
+++ b/window-switcher/Expose.js
@@ -0,0 +1,79 @@
+.pragma library
+
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+// The expose scatter, kept pure so it runs under node (test_expose.js).
+// Input is [{w, h}] in real pixels, most recently used first; output is
+// [{x, y, w, h}] inside a W by H screen.
+//
+// Windows are seeded on a golden-angle spiral, the most recent at the centre,
+// then pushed apart until nothing overlaps, then fitted with one scale, so
+// their sizes stay relative to each other. Seeding from the windows' real
+// positions was tried first and degenerates here: every window fills its
+// monitor, so they all start in one pile, and pushing a pile apart makes a
+// line that still overlapped after 300 passes and filled under 15% of the
+// screen.
+
+const GAP = 60; // real pixels kept between two windows
+const PASSES = 300;
+const FILL = 0.92; // of the screen, each way
+const MAX_SCALE = 0.5; // a lone window is not a full-screen mirror
+const SPREAD = 0.45; // spiral step, as a fraction of the tallest window
+const GOLDEN = 137.5 * Math.PI / 180;
+
+function layout(rects, W, H) {
+ if (rects.length === 0) return [];
+ const aspect = W / H;
+ const step = SPREAD * Math.max(...rects.map(o => o.h));
+
+ // The spiral is stretched by the screen's aspect, so the cluster comes
+ // out the shape of the screen rather than round.
+ const r = rects.map((o, k) => {
+ const rad = step * Math.sqrt(k), t = k * GOLDEN;
+ return { x: rad * Math.cos(t) * aspect - o.w / 2,
+ y: rad * Math.sin(t) - o.h / 2, w: o.w, h: o.h };
+ });
+
+ // ponytail: all pairs every pass, O(n^2); fine for this desk's ~20
+ // windows, a spatial grid if it ever is not.
+ for (let pass = 0; pass < PASSES; pass++) {
+ let moved = false;
+ for (let i = 0; i < r.length; i++) for (let j = i + 1; j < r.length; j++) {
+ const a = r[i], b = r[j];
+ const ox = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) + GAP;
+ const oy = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) + GAP;
+ if (ox <= 0 || oy <= 0) continue;
+ moved = true;
+ // Push along whichever axis costs less screen, measured against
+ // the screen's own width and height: compared in raw pixels, wide
+ // windows always separate vertically and stack into a column.
+ if (ox / W < oy / H) {
+ const s = (b.x + b.w / 2) >= (a.x + a.w / 2) ? 1 : -1;
+ a.x -= s * ox / 2; b.x += s * ox / 2;
+ } else {
+ const s = (b.y + b.h / 2) >= (a.y + a.h / 2) ? 1 : -1;
+ a.y -= s * oy / 2; b.y += s * oy / 2;
+ }
+ }
+ if (!moved) break;
+ }
+
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
+ for (const o of r) {
+ x0 = Math.min(x0, o.x); y0 = Math.min(y0, o.y);
+ x1 = Math.max(x1, o.x + o.w); y1 = Math.max(y1, o.y + o.h);
+ }
+ const k = Math.min(FILL * W / (x1 - x0), FILL * H / (y1 - y0), MAX_SCALE);
+ const dx = (W - (x1 - x0) * k) / 2, dy = (H - (y1 - y0) * k) / 2;
+ return r.map(o => ({ x: dx + (o.x - x0) * k, y: dy + (o.y - y0) * k,
+ w: o.w * k, h: o.h * k }));
+}
diff --git a/window-switcher/test_expose.js b/window-switcher/test_expose.js
new file mode 100644
index 0000000..d394253
--- /dev/null
+++ b/window-switcher/test_expose.js
@@ -0,0 +1,59 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+// Run: node window-switcher/test_expose.js
+// Expose.js is a QML library, so its .pragma line is stripped before node
+// evaluates it, as test_perf.js does for Perf.js.
+
+const fs = require("fs");
+const assert = require("assert");
+
+const src = fs.readFileSync(__dirname + "/Expose.js", "utf8")
+ .replace(/^\.pragma library$/m, "");
+const { layout } = new Function(src + "\nreturn { layout };")();
+
+const W = 2560, H = 1080;
+const wide = { w: 2556, h: 1076 }, tall = { w: 1076, h: 1916 };
+
+function apart(a, b) {
+ return a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y;
+}
+function check(out) {
+ for (const o of out) {
+ assert.ok(o.x >= 0 && o.y >= 0 && o.x + o.w <= W + 1e-6 && o.y + o.h <= H + 1e-6, "inside the screen");
+ }
+ for (let i = 0; i < out.length; i++)
+ for (let j = i + 1; j < out.length; j++)
+ assert.ok(apart(out[i], out[j]), `windows ${i} and ${j} overlap`);
+}
+
+assert.deepStrictEqual(layout([], W, H), []);
+
+// Seven identical full-screen windows, the normal case here: no overlap.
+const seven = Array(7).fill(wide);
+check(layout(seven, W, H));
+
+// A busy mixed desk still separates within the pass limit.
+check(layout(Array.from({ length: 14 }, (_, i) => i % 3 ? wide : tall), W, H));
+
+// Deterministic: the same input gives the same scatter.
+assert.deepStrictEqual(layout(seven, W, H), layout(seven, W, H));
+
+// A lone window is capped at half size.
+const one = layout([wide], W, H)[0];
+assert.ok(Math.abs(one.w - wide.w * 0.5) < 1e-6);
+
+// One scale for all: sizes stay relative.
+const [a, b] = layout([wide, tall], W, H);
+assert.ok(Math.abs(a.w / b.w - wide.w / tall.w) < 1e-9);
+assert.ok(Math.abs(a.h / b.h - wide.h / tall.h) < 1e-9);
+
+console.log("test_expose: ok");