aboutsummaryrefslogtreecommitdiffstats
path: root/window-switcher/Expose.js
blob: 344fdde21f7323384b907b63a0d2e8f71d10d372 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
.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 pulled toward the centre and pushed apart again for a number of
// rounds, so the scatter is a cluster rather than a loose spiral, 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 = 200;         // real pixels kept between two windows, room for the name
const PASSES = 300;
const FILL = 0.95;       // 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 ROUNDS = 30;
const PULL = 0.85;       // centre distance kept per gravity round
const GOLDEN = 137.5 * Math.PI / 180;

function separate(r, W, H) {
    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;
    }

}

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 };
    });

    // Gravity: everything is pulled toward the centre, then pushed apart
    // again, until the cluster stops shrinking. Separation alone leaves
    // the spiral's gaps in place and the scatter reads as loose.
    // 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 round = 0; round < ROUNDS; round++) {
        for (const o of r) {
            o.x = (o.x + o.w / 2) * PULL - o.w / 2;
            o.y = (o.y + o.h / 2) * PULL - o.h / 2;
        }
        separate(r, W, H);
    }

    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 }));
}

// Keyboard selection over the scatter, which has no rows or columns to step
// through. Returns the index of the card the arrow points at from card i:
// the nearest centre on that side, with sideways drift counted double so
// Right prefers the card level with this one over a closer one far above.
// With no card on that side the selection stays put. With nothing selected
// yet (i < 0) any arrow starts from the top-left card.
function step(rects, i, dx, dy) {
    const c = o => ({ x: o.x + o.w / 2, y: o.y + o.h / 2 });
    if (i < 0) {
        let best = -1;
        for (let k = 0; k < rects.length; k++)
            if (best < 0 || rects[k].x + rects[k].y < rects[best].x + rects[best].y) best = k;
        return best;
    }
    const from = c(rects[i]);
    let best = i, score = Infinity;
    for (let k = 0; k < rects.length; k++) {
        if (k === i) continue;
        const to = c(rects[k]);
        const ahead = (to.x - from.x) * dx + (to.y - from.y) * dy;
        if (ahead <= 0) continue;
        const side = Math.abs((to.x - from.x) * dy) + Math.abs((to.y - from.y) * dx);
        if (ahead + 2 * side < score) { score = ahead + 2 * side; best = k; }
    }
    return best;
}