aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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");