Phoenix is a very barebones macOS window manager that is scriptable via JavaScript, and which I have taken to instead of Moom to a degree.
This is a snapshot of my configuration:
// ********************************************************************************
// Setup and TODO
// ********************************************************************************
Phoenix.set({
daemon: false,
openAtLogin: true
});
/*
* API docs: https://kasper.github.io/phoenix/
*
* TODO:
* - [ ] Drag and Drop Snap (can't be implemented in current Phoenix, apparently)
* - [X] Move mode
* - [x] Move to adjacent window
* - [x] More logical handling of sixths
* - [x] Hint Manager class
* - [x] Cleanup Constants
* - [x] Basic Space handling
* - [x] Move mouse pointer on rotation focus changes
* - [x] Centered popup
* - [x] Frame abstraction
*/
// ********************************************************************************
// Constants & Feature Flags
// ********************************************************************************
const DEBUG = false; // Toggle verbose logging & optional debug helpers.
/**
* Direction identifiers used throughout layout, navigation and tiling logic.
* Semantic (capitalized) strings are used instead of single-letter aliases for clarity.
*/
const DIR = Object.freeze({
NONE: "none",
COLS: "Cols",
FULL: "Full",
NORTH: "North",
SOUTH: "South",
EAST: "East",
WEST: "West",
NW: "North-West",
NE: "North-East",
SW: "South-West",
SE: "South-East"
});
/**
* Layout related configuration.
* MODES enumerates the available tiling strategies applied per-screen in rotation.
*/
const LAYOUT = Object.freeze({
MODES: [DIR.NONE, DIR.EAST, DIR.WEST, DIR.COLS]
});
/**
* Hint system tuning.
* CHARS defines the selection alphabet; ICON batching avoids UI stalls on large window counts.
*/
const HINT = Object.freeze({
APPEARANCE: "dark",
CANCEL_KEY: "escape",
CHARS: "FJDKSLAGHRUEIWOVNCM",
LAZY_ICONS: true,
ICON_BATCH_DELAY: 0.015,
ICON_BATCH_SIZE: 12,
MAX_OVERLAP_ADJUST: 500,
TEXT_ONLY_THRESHOLD: 25,
DEBUG_OVERLAY: false,
});
// Direction aliases removed (use DIR.* directly for clarity and grep-ability)
/** Geometry and heuristic values (pixel units). */
const GEOM = Object.freeze({
PADDING: 8,
OVERLAP_TOLERANCE: 6
});
/** Performance tuning thresholds for window registry & event debounce. */
const PERF = Object.freeze({
REGISTRY_TTL_BASE_MS: 450,
REGISTRY_TTL_MAX_MS: 1200,
REGISTRY_TTL_MIN_MS: 200,
REGISTRY_ADAPTIVE: true,
EVENT_DEBOUNCE_MS: 80,
NEIGHBOR_SCAN_MAX_DIST: 600 // px: early break distance when building directional neighbor lists
});
/** Focus tracking limits & feature toggles. */
const FOCUS = Object.freeze({
ENABLE_GLOBAL_MRU: true,
GLOBAL_MRU_LIMIT: 25,
ENABLE_DIRECTIONAL_MRU: true,
DIRECTIONAL_MRU_LIMIT: 15,
MOVE_POINTER_ON_FOCUS: true // Toggle mouse centering after focus changes
});
/** Move mode nudge distances (pixels). */
const MOVE = Object.freeze({
NUDGE_SMALL: 10,
NUDGE_LARGE: 50
});
// Runtime mutable tracking
var LAST_POSITION = {
window: null,
grid: "",
positions: []
};
var LAST_POSITION_INDEX = -1;
var ICON_CACHE = {};
function iconKey(win) {
try {
const app = win.app();
if (app.bundleIdentifier) {
const bid = app.bundleIdentifier();
if (bid) return bid;
}
return 'pid:' + app.pid();
} catch (e) {
return 'hash:' + win.hash();
}
}
// --------------------------------------------------------------------------------
// Utilities Namespace
// --------------------------------------------------------------------------------
const Util = (() => {
function log(...args) { if (DEBUG) Phoenix.log(args.join(' ')); }
function rotateArray(arr, n) {
if (!arr.length) return arr;
n = ((n % arr.length) + arr.length) % arr.length;
return arr.slice(n).concat(arr.slice(0, n));
}
function clamp(v, min, max) { return v < min ? min : (v > max ? max : v); }
return { log, rotateArray, clamp };
})();
// ********************************************************************************
// Keyboard Bindings
// Cheat Sheet:
// ctrl+opt+return / arrows : stepped sizing / grid cycles
// shift+ctrl+opt+arrows : vertical reposition sequences
// ctrl+opt+h/j/k/l : focus adjacent (vim)
// ctrl+opt+cmd+←/→ : move window to screen
// shift+ctrl+opt+cmd+←/→ : move window to space
// shift+ctrl+z : cycle tiling mode
// shift+cmd+space : hints
// ctrl+opt+cmd+m : move mode (later binding)
// ********************************************************************************
// --- 1. Grid / Size Cycling (Moom-like) ---
// ********************************************************************************
// Keyboard Bindings
// ********************************************************************************
// Moom-like bindings
Key.on("return", ["control", "option"], () => { Window.focused().positionInGrid(4, 0, 3).centerMouse() });
// Mixed grid bases (4/6/16) intentionally provide progressive sizing steps.
Key.on("left", ["control", "option"], () => steppedSizing(Window.focused(), [[4, 0, 2], [6, 0, 3], [16, 0, 9]]));
Key.on("right", ["control", "option"], () => steppedSizing(Window.focused(), [[4, 1, 3], [6, 2, 5], [16, 6, 15]]));
Key.on("up", ["control", "option"], () => steppedSizing(Window.focused(), [[4, 0, 1], [4, 0, 3]]));
Key.on("down", ["control", "option"], () => steppedSizing(Window.focused(), [[4, 2, 3], [8, 2, 13], [6, 1, 4]]));
Key.on("left", ["shift", "control", "option"], () => { Window.focused().reposition(DIR.WEST).centerMouse() });
Key.on("right", ["shift", "control", "option"], () => { Window.focused().reposition(DIR.EAST).centerMouse() });
Key.on("up", ["shift", "control", "option"], () => { Window.focused().reposition(DIR.NORTH).centerMouse() });
Key.on("down", ["shift", "control", "option"], () => { Window.focused().reposition(DIR.SOUTH).centerMouse() });
// Sixths
// --- 2. Sixth Segments ---
Key.on(",", ["shift", "control", "option"], () => { Window.focused().positionInGrid(6, 0, 0).centerMouse() });
Key.on(".", ["shift", "control", "option"], () => { Window.focused().positionInGrid(6, 1, 1).centerMouse() });
Key.on("-", ["shift", "control", "option"], () => { Window.focused().positionInGrid(6, 2, 2).centerMouse() });
Key.on(";", ["shift", "control", "option"], () => { Window.focused().positionInGrid(6, 2, 2).centerMouse() }); // US
Key.on(",", ["control", "option"], () => { Window.focused().positionInGrid(6, 3, 3).centerMouse() });
Key.on(".", ["control", "option"], () => { Window.focused().positionInGrid(6, 4, 4).centerMouse() });
Key.on("-", ["control", "option"], () => { Window.focused().positionInGrid(6, 5, 5).centerMouse() });
Key.on(";", ["control", "option"], () => { Window.focused().positionInGrid(6, 5, 5).centerMouse() }); // US
// Move horizontally between screens
// --- 3. Screen Transfer ---
Key.on("right", ["control", "option", "command"], () => Window.focused().toScreen(DIR.EAST).centerMouse());
Key.on("left", ["control", "option", "command"], () => Window.focused().toScreen(DIR.WEST).centerMouse());
// Move horizontally between spaces
// --- 4. Space Transfer ---
Key.on("right", ["shift", "control", "option", "command"], () => Window.focused().toSpace(DIR.EAST));
Key.on("left", ["shift", "control", "option", "command"], () => Window.focused().toSpace(DIR.WEST));
// Change focus to adjacent window (Vim-like: h=West, l=East)
// --- 5. Focus Navigation (Vim-like) ---
Key.on("h", ["control", "option"], () => { Window.focused().focusAdjacent(DIR.WEST).centerMouse() }); // FIX: Previously inverted
Key.on("j", ["control", "option"], () => { Window.focused().focusAdjacent(DIR.SOUTH).centerMouse() });
Key.on("k", ["control", "option"], () => { Window.focused().focusAdjacent(DIR.NORTH).centerMouse() });
Key.on("l", ["control", "option"], () => { Window.focused().focusAdjacent(DIR.EAST).centerMouse() }); // FIX: Previously inverted
// --- 6. Tiling Mode / Rotation ---
Key.on("z", ["shift", "control"], () => wm.change_tiling_mode())
Key.on("n", ["shift", "control"], () => wm.rotate(-1, false));
Key.on("n", ["shift", "control", "option"], () => wm.rotate(-1, true));
Key.on("m", ["shift", "control"], () => wm.rotate(1, false));
Key.on("m", ["shift", "control", "option"], () => wm.rotate(1, true));
// --- 7. Modes & Hints ---
Key.on('m', ['control', 'option', 'command'], () => MoveMode.activate(Window.focused()));
Key.on("space", ["shift", "command"], () => HintManager.activate());
// ********************************************************************************
// Size steps
// ********************************************************************************
function steppedSizing(win, gridPositions) {
if (!gridPositions || !gridPositions.length) return win; // Guard against empty cycles
if (!win.isEqual(LAST_POSITION.window)) {
LAST_POSITION_INDEX = 0;
LAST_POSITION.window = win;
} else if (JSON.stringify(LAST_POSITION.positions) != JSON.stringify(gridPositions)) {
LAST_POSITION_INDEX = 0; // Accept simple stringify comparison (small arrays)
}
const res = win.positionInGrid.apply(win, gridPositions[LAST_POSITION_INDEX]).centerMouse();
LAST_POSITION.grid = gridPositions[LAST_POSITION_INDEX].join(",");
LAST_POSITION_INDEX = (LAST_POSITION_INDEX + 1) % gridPositions.length;
LAST_POSITION.positions = gridPositions;
return res
}
// ********************************************************************************
// Hints
// ********************************************************************************
class Hints {
constructor() {
this.active = false;
this.keys = [];
this.hints = {};
this.escbind = null;
this.bsbind = null;
this.id = Math.random();
} // constructor
cancel() {
for (var activator in this.hints) {
if (this.hints[activator])
this.hints[activator].modal.close();
};
// remove all key bindings
Key.off(this.escbind);
Key.off(this.bsbind);
this.keys.map(Key.off);
// clear hints
this.hints = {};
this.keys = [];
this.active = false;
return this;
} // cancel
show(windows, prefix) {
const self = this;
prefix = prefix || "";
const t0 = Date.now();
// Allow callers to omit windows: pull from WindowRegistry (all screens) then filter to current space
if (!windows) {
try {
const focused = Window.focused();
let spaceId = null;
if (focused && focused.spaces) {
const sp = focused.spaces();
if (sp && sp.length) {
try { spaceId = sp[0].hash ? sp[0].hash() : sp[0]; } catch (e) { }
}
}
if (spaceId) {
// Directly query space-partitioned entries (avoids allocating all entries and filtering)
windows = WindowRegistry.getSpaceEntries(spaceId).map(e => e.win);
} else {
// Fallback to all entries if space cannot be determined
windows = WindowRegistry.getAllEntries().map(e => e.win);
}
} catch (e) {
windows = WindowRegistry.getAllEntries().map(e => e.win);
}
}
if (!windows || !windows.length) return this;
const totalWindows = windows.length;
const textOnly = totalWindows > HINT.TEXT_ONLY_THRESHOLD;
// If more windows than hint characters, build a two-level selection rather than recursive early-return.
// Partition windows into buckets keyed by first-level char.
if (windows.length > HINT.CHARS.length) {
const buckets = {};
windows.forEach((win, idx) => {
const key = HINT.CHARS[idx % HINT.CHARS.length];
(buckets[key] = buckets[key] || []).push(win);
});
// Show only first-level hints now; activating one expands second level.
Object.keys(buckets).forEach((ch, i) => {
const pseudoWin = buckets[ch][0]; // anchor modal to first window in bucket
const hash = iconKey(pseudoWin);
if (!ICON_CACHE[hash]) ICON_CACHE[hash] = pseudoWin.app().icon();
const hint = Modal.build({
text: prefix + ch,
appearance: HINT.APPEARANCE,
icon: ICON_CACHE[hash],
weight: 16,
duration: 0,
}).attach(pseudoWin);
self.hints[prefix + ch] = { win: pseudoWin, modal: hint, position: 0, active: true, bucket: buckets[ch] };
});
self._prepareKeyHandlersForBuckets(prefix);
this.active = true;
return this;
}
// Use WindowRegistry metadata directly (avoids duplicate frame/title/icon lookups)
const entriesByHash = {};
WindowRegistry.getAllEntries().forEach(e => { entriesByHash[e.hash] = e; });
const tMetaStart = Date.now();
const meta = windows.map((win, i) => {
const h = win.hash();
const snap = entriesByHash[h];
if (snap) return { win, i, hash: h, multi: snap.multi, frame: snap.frame, titleShort: snap.titleShort };
// fallback minimal (rare path)
return { win, i, hash: h, multi: false, frame: win.frame(), titleShort: "" };
});
const tMetaEnd = Date.now();
// Build hints in one pass
// Create all modals without (or with cached) icons first for fast initial paint
const lazyQueue = [];
const tModalStart = Date.now();
meta.forEach(m => {
const ikey = iconKey(m.win);
const hasIcon = !!ICON_CACHE[ikey];
if (!textOnly) {
if (hasIcon === false && HINT.LAZY_ICONS) lazyQueue.push({ key: ikey, win: m.win });
else if (!hasIcon && !HINT.LAZY_ICONS) { // eager path if lazy disabled
try { ICON_CACHE[ikey] = m.win.app().icon(); } catch (e) { }
}
}
let label = "";
if (!textOnly && m.multi) label += " | " + (m.titleShort || "");
const hint = Modal.build({
text: prefix + HINT.CHARS[m.i] + label,
appearance: HINT.APPEARANCE,
icon: (textOnly ? undefined : (ICON_CACHE[ikey] || undefined)),
weight: 16,
duration: 0,
}).attach(m.win);
self.hints[prefix + HINT.CHARS[m.i]] = { win: m.win, modal: hint, position: 0, active: true, _iconKey: ikey };
});
const tModalEnd = Date.now();
// Lazy icon loading in small timed batches to keep UI responsive
if (!textOnly && HINT.LAZY_ICONS && lazyQueue.length) {
let idx = 0;
function loadBatch() {
const slice = lazyQueue.slice(idx, idx + HINT.ICON_BATCH_SIZE);
slice.forEach(entry => {
if (!ICON_CACHE[entry.key]) {
try { ICON_CACHE[entry.key] = entry.win.app().icon(); } catch (e) { }
}
// Update modal icon if still active
for (const activator in self.hints) {
const h = self.hints[activator];
if (h && h._iconKey === entry.key && ICON_CACHE[entry.key]) {
h.modal.icon = ICON_CACHE[entry.key];
}
}
});
idx += HINT.ICON_BATCH_SIZE;
if (idx < lazyQueue.length) Timer.after(HINT.ICON_BATCH_DELAY, loadBatch);
}
Timer.after(HINT.ICON_BATCH_DELAY, loadBatch);
}
// Overlap adjustment (single pass, sorted by origin.y)
const activators = Object.keys(self.hints).sort((a, b) => self.hints[a].modal.origin.y - self.hints[b].modal.origin.y);
if (!textOnly && activators.length > 1) { // skip costly adjustments in text-only mode
let adjustments = 0;
for (let i = 0; i < activators.length; i++) {
const a = self.hints[activators[i]].modal;
const aBottom = a.origin.y + a.frame().height + GEOM.PADDING;
for (let j = i + 1; j < activators.length; j++) {
if (adjustments > HINT.MAX_OVERLAP_ADJUST) break;
const b = self.hints[activators[j]].modal;
// Early vertical non-overlap break (list sorted by y increasing)
if (b.origin.y > aBottom) break;
// Overlap test
if (
a.origin.x < b.origin.x + b.frame().width + GEOM.PADDING &&
a.origin.x + a.frame().width > b.origin.x - GEOM.PADDING &&
a.origin.y < b.origin.y + b.frame().height + GEOM.PADDING &&
a.origin.y + a.frame().height > b.origin.y - GEOM.PADDING
) {
b.origin = { x: b.origin.x, y: a.origin.y + a.frame().height + GEOM.PADDING };
adjustments++;
}
}
if (adjustments > HINT.MAX_OVERLAP_ADJUST) break;
}
}
self.escbind = Key.on(HINT.CANCEL_KEY, [], () => self.cancel());
this.active = true;
// Debug overlay (appears briefly)
if (HINT.DEBUG_OVERLAY) {
const tEnd = Date.now();
try {
const stats = [
`win:${totalWindows}`,
`text:${textOnly}`,
`meta:${tMetaEnd - tMetaStart}ms`,
`modal:${tModalEnd - tModalStart}ms`,
`lazyQ:${lazyQueue.length}`,
`total:${tEnd - t0}ms`
].join(' ');
Modal.build({
text: stats,
appearance: HINT.APPEARANCE,
weight: 12,
duration: 0.9,
}).flash(Screen.main ? Screen.main() : Screen.all()[0]);
} catch (e) { }
}
return this;
} // show
_prepareKeyHandlersForBuckets(prefix) {
// Replace existing handlers with bucket-expansion logic.
const self = this;
const sequences = Object.keys(self.hints);
self.keys = [];
HINT.CHARS.split("").forEach(ch => {
self.keys.push(Key.on(ch, [], function () {
const activator = prefix + ch;
const entry = self.hints[activator];
if (!entry) return; // not a valid first-level key
// Close first-level modals except chosen bucket
sequences.forEach(seq => { if (seq !== activator) self.hints[seq].modal.close(); });
const bucket = entry.bucket;
// Clear hints and rebuild with second-level inside bucket
const bucketWins = bucket;
self.cancel();
self.show(bucketWins, activator); // recursing with prefix expands second level
}));
});
}
activate() {
var self = this;
if (this.active) {
self.cancel();
} else {
Event.once("mouseDidLeftClick", function () { self.cancel(); });
this.show(Window.all({ visible: true }));
var sequence = "";
self.keys = [];
HINT.CHARS.split("").forEach(function (hintchar) {
// set up each individual hint handler
self.keys.push(Key.on(hintchar, [], function () {
// ISSUE: Potential race if windows close mid-sequence; consider validating hint.win.exists().
sequence += hintchar;
for (var activator in self.hints) {
var hint = self.hints[activator];
if (!hint.active) continue;
if (activator[hint.position] === hintchar) {
hint.position++;
if (hint.position === activator.length) {
hint.win.focus();
Mouse.move({
x: hint.modal.origin.x + hint.modal.frame().width / 2,
y: Screen.all()[0].frame().height - hint.modal.origin.y - hint.modal.frame().height / 2
});
return self.cancel();
}
hint.modal.text = hint.modal.text.substr(1);
} else {
hint.modal.close();
hint.active = false;
}
}
}));
});
self.bsbind = Key.on("delete", [], function () {
if (!sequence.length)
self.cancel();
var letter = sequence[sequence.length - 1];
sequence = sequence.substr(0, sequence.length - 1);
for (var activator in self.hints) {
var hint = self.hints[activator];
if (hint.active) {
hint.position--;
hint.modal.text = letter + hint.modal.text;
} else if (activator.substr(0, sequence.length) === sequence) {
hint.modal.show();
hint.active = true;
}
}
});
}
} // activate
}
// ********************************************************************************
// Window Manager Abstraction
// ********************************************************************************
class WindowManager {
constructor() {
this.tiling_modes = Array(Screen.all().length)
this.tiling_modes.fill(DIR.NONE)
this.timers = Array(Screen.all().length)
this.layouts = Array(Screen.all().length)
}
change_tiling_mode() {
const window = Window.focused(),
screen = window.screen(),
index = Screen.all().indexOf(screen),
visible = screen.windows({ visible: true })
// rotate tiling mode
this.tiling_modes[index] = LAYOUT.MODES[(LAYOUT.MODES.indexOf(this.tiling_modes[index]) + 1) % LAYOUT.MODES.length]
if (DEBUG) Phoenix.log(index + " " + this.tiling_modes[index]);
// Try layout cache reuse
const key = layoutCacheKey(screen, this.tiling_modes[index], visible)
let cached = LAYOUT_CACHE.get(key)
if (cached) {
const layout = new Layout(screen, this.tiling_modes[index])
layout.frames = cached.map(f => new Frame(f.x, f.y, f.width, f.height))
this.layouts[index] = layout
} else {
this.layouts[index] = new Layout(screen, this.tiling_modes[index])
// store frames snapshot
LAYOUT_CACHE.set(key, this.layouts[index].frames.map(f => ({ x: f.x, y: f.y, width: f.width, height: f.height })))
}
var modal = Modal.build({
text: "Tiling mode: " + this.tiling_modes[index],
appearance: HINT.APPEARANCE,
weight: 24,
icon: App.get('Phoenix').icon(),
duration: 0.5,
}).flash(screen)
if (DEBUG) Phoenix.log("layouts pre-apply count=" + this.layouts[index].frames.length);
this.layouts[index].windows = visible
this.layouts[index].apply()
if (DEBUG) Phoenix.log("layouts applied");
return this
}
rotate(dir, focus_only) {
const window = Window.focused(),
screen = window.screen(),
index = Screen.all().indexOf(screen)
Phoenix.log(index + " " + dir + " " + this.layouts[index].windows.map(x => x.hash()))
this.layouts[index].rotate(dir).apply(screen)
return this
}
}
const wm = new WindowManager()
// ********************************************************************************
// Layout Abstraction
// ********************************************************************************
class Layout {
constructor(screen, mode) {
this.frames = []
this.windows = []
switch (mode) {
case DIR.EAST:
this.east(screen)
break;
case DIR.WEST:
this.west(screen)
break;
case DIR.COLS:
this.cols(screen)
break;
}
}
east(screen) {
const f = screen.flippedVisibleFrame(),
v = screen.windows({ visible: true }),
c = v.length - 1,
w = f.width / 2,
h = ~~(f.height / c),
self = this
this.screen = screen
if (v.length === 1) {
this.frames.push(new Frame(f.x, f.y, f.width, f.height).pad())
} else {
// main window
self.frames.push(new Frame(f.x, f.y, w, f.height)
.displace(f.width / 2, 0).pad())
// secondary windows
for (var i = 0; i < c; i++) {
self.frames.push(new Frame(f.x, f.y, w, h)
.displace(0, h * i).pad())
}
}
return this
}
west(screen) {
const f = screen.flippedVisibleFrame(),
v = screen.windows({ visible: true }),
c = v.length - 1,
w = f.width / 2,
h = ~~(f.height / c),
self = this
this.screen = screen
if (v.length === 1) {
self.frames.push(new Frame(f.x, f.y, f.width, f.height).pad())
} else {
// main window
self.frames.push(new Frame(f.x, f.y, w, f.height)
.pad())
// secondary windows
for (var i = 0; i < c; i++) {
self.frames.push(new Frame(f.x, f.y, w, h)
.displace(w, h * i).pad())
}
}
return this
}
cols(screen) {
const f = screen.flippedVisibleFrame(),
v = screen.windows({ visible: true }),
c = v.length,
w = ~~(f.width / c),
h = f.height,
self = this
this.screen = screen
// all windows
for (var i = 0; i < c; i++) {
self.frames.push(new Frame(f.x, f.y, w, h)
.displace(w * i, 0).pad())
}
return this
}
none(screen) {
this.frames = []
return this
}
rotate(dir) {
// Use utility rotation (non-mutating) instead of Array.prototype extension.
this.windows = Util.rotateArray(this.windows, dir);
return this
}
apply() {
const self = this
if (this.frames.length)
for (var i = 0; i < self.windows.length; i++) {
self.windows[i].setFrame(self.frames[i])
}
return this
}
}
// ********************************************************************************
// Frame Abstraction
// ********************************************************************************
class Frame {
constructor(x, y, width, height) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
pad() {
return new Frame(
this.x + GEOM.PADDING / 2,
this.y + GEOM.PADDING / 2,
this.width - GEOM.PADDING,
this.height - GEOM.PADDING
)
}
snap(screen, dir) { // Expects a DIR.* token; unsupported values leave frame unchanged.
const s = screen.flippedVisibleFrame();
const f = new Frame(this.x, this.y, this.width, this.height);
// Align to right side for east-oriented quadrants
if ([DIR.EAST, DIR.NE, DIR.SE].includes(dir)) f.x += s.width - f.width;
// Align to bottom side for south-oriented quadrants
if ([DIR.SE, DIR.SW].includes(dir)) f.y += s.height - f.height;
// Full width if explicitly requested
if (dir === DIR.FULL) f.width = s.width;
// Full height for full or pure east/west halves
if ([DIR.FULL, DIR.EAST, DIR.WEST].includes(dir)) f.height = s.height;
return f;
}
displace(x, y) { // Does not clamp to screen intentionally (used for relative moves)
return new Frame(
this.x + x,
this.y + y,
this.width,
this.height
)
}
log() {
Phoenix.log(this.x + ", " + this.y + ", " + this.width + ", " + this.height);
return this;
}
rect() {
return {
x: this.x,
y: this.y,
width: this.width,
height: this.height
}
}
ratio(a, b) { // Returns a transformer that scales a frame from space a -> space b
var wr = b.width / a.width,
hr = b.height / a.height;
return ({ x, y, width, height }) => {
x = Math.round(b.x + (x - a.x) * wr);
y = Math.round(b.y + (y - a.y) * hr);
width = Math.round(width * wr);
height = Math.round(height * hr);
return { x, y, width, height }
}
}
}
// ********************************************************************************
// Window Extensions
// ********************************************************************************
// Snap a window in a given direction
Window.prototype.to = function (direction) { // Half-screen quadrant snap utility
var s = this.screen(),
f = s.flippedVisibleFrame(),
frame = new Frame(
f.x,
f.y,
f.width / 2,
f.height / 2,
).snap(s, direction).pad();
this.setFrame(frame);
};
// Move a window to a given screen
Window.prototype.toScreen = function (dir) { // East/West nearest screen by X origin (vertical stack not handled yet)
var screen = null;
if (dir === DIR.EAST || dir === DIR.WEST) {
const cur = this.screen();
const curX = cur.origin().x;
// Find nearest screen strictly to the east or west
screen = Screen.all()
.filter(s => dir === DIR.EAST ? s.origin().x > curX : s.origin().x < curX)
.sort((a, b) => Math.abs(a.origin().x - curX) - Math.abs(b.origin().x - curX))[0];
if (screen) {
const ratio = Frame.prototype.ratio(cur.flippedVisibleFrame(), screen.flippedVisibleFrame());
this.setFrame(ratio(this.frame()));
}
}
return this;
};
// Algorithmic vertical repositioning: cycles a column between spanning both rows and a single-top/single-bottom cell.
// Grid assumptions: 2 rows layout (cells even; indices laid out row-major). Examples (4 cells: 2x2, 6 cells: 3x2)
// Behavior:
// - If window spans both rows in a column and moved NORTH -> collapse to top cell of that column.
// - If window spans both rows and moved SOUTH -> collapse to bottom cell of that column.
// - If window is a single bottom cell and moved NORTH -> move to top cell (same column).
// - If window is a single top cell and moved SOUTH -> move to bottom cell (same column).
// - Otherwise no change.
Window.prototype.reposition = function(dir) {
if (!this.isEqual(LAST_POSITION.window)) return this;
const spec = LAST_POSITION.grid; // e.g. "6,2,5"
if (!spec) return this;
const parts = spec.split(',').map(s => parseInt(s,10));
if (parts.length !== 3 || parts.some(isNaN)) return this;
const [cells,start,end] = parts;
if (cells % 2 !== 0) return this; // only handle 2-row grids (row-major assumption)
const cols = cells / 2;
const startCol = start % cols;
const endCol = end % cols;
const startRow = Math.floor(start / cols);
const endRow = Math.floor(end / cols);
let targetStart = start, targetEnd = end;
if (dir === DIR.NORTH || dir === DIR.SOUTH) {
// Vertical cycling (existing logic, slightly reorganized)
// Operate only when selection is a single column span (may cover both rows or a single cell)
if (startCol !== endCol) return this;
const spansBoth = startRow !== endRow; // two rows
if (dir === DIR.NORTH) {
if (spansBoth || startRow === 1) { // spanning or bottom single -> collapse to top
targetStart = startCol; // row 0
targetEnd = targetStart;
} else return this; // already top
} else { // SOUTH
if (spansBoth || startRow === 0) { // spanning or top single -> collapse to bottom
targetStart = startCol + cols; // row 1
targetEnd = targetStart;
} else return this; // already bottom
}
} else if (dir === DIR.EAST || dir === DIR.WEST) {
// Horizontal cycling: mirror vertical behavior across columns within a single row.
// Only operate when selection spans a single row (may cover multiple columns) OR is a single cell.
if (startRow !== endRow) return this; // ignore multi-row spans (vertical logic handles those)
const spansMultiple = startCol !== endCol;
if (spansMultiple) {
// Collapse a multi-column span to an edge cell depending on direction.
if (dir === DIR.WEST) {
targetStart = startRow * cols + startCol; // leftmost
targetEnd = targetStart;
} else { // EAST
targetStart = startRow * cols + endCol; // rightmost
targetEnd = targetStart;
}
} else { // single cell -> move laterally within row if possible
if (dir === DIR.WEST && startCol > 0) {
targetStart = targetEnd = start - 1;
} else if (dir === DIR.EAST && startCol < cols - 1) {
targetStart = targetEnd = start + 1;
} else return this; // edge cell, no change
}
} else {
return this; // unsupported direction
}
if (targetStart !== start || targetEnd !== end) {
this.positionInGrid(cells, targetStart, targetEnd);
}
return this;
};
Window.prototype.positionInGrid = function (cells, start, end) {
if (cells <= 0) return this;
if (start < 0 || end < 0 || start >= cells || end >= cells) return this; // Guard invalid indices
LAST_POSITION = {
window: this,
grid: cells + "," + start + "," + end
}
var cols = ~~(cells / 2);
var screen = this.screen();
var cellwidth = (screen.width() - ((cols - 1) * GEOM.PADDING)) / cols;
var cellheight = (screen.height() - GEOM.PADDING) / 2;
var startc = start % cols,
startw = ~~(start / cols),
startl = screen.origin().x + (cellwidth + GEOM.PADDING) * startc,
startt = screen.origin().y + (cellheight + GEOM.PADDING) * startw,
startr = startl + cellwidth,
startb = startt + cellheight;
var endc = end % cols,
endw = ~~(end / cols),
endl = screen.origin().x + (cellwidth + GEOM.PADDING) * endc,
endt = screen.origin().y + (cellheight + GEOM.PADDING) * endw,
endr = endl + cellwidth,
endb = endt + cellheight;
var frame = this.frame();
frame.x = Math.min(startl, endl);
frame.y = Math.min(startt, endt);
frame.width = Math.max(startr, endr) - frame.x;
frame.height = Math.max(startb, endb) - frame.y;
this.setFrame(frame);
return this;
}
// Resize a window by coeff units in the given direction
// coeff: -n shrinks by pixels units, +n grows by n pixels
Window.prototype.resize = function (dir, coeff) {
var frame = this.frame()
if (dir === DIR.WEST) frame.x += coeff * -1
if (dir === DIR.NORTH) frame.y += coeff * -1
if ([DIR.EAST, DIR.WEST].indexOf(dir) > -1) frame.width += coeff
if ([DIR.NORTH, DIR.SOUTH].indexOf(dir) > -1) frame.height += coeff
// Minimal size clamp
if (frame.width < 40) frame.width = 40;
if (frame.height < 40) frame.height = 40;
this.setFrame(frame)
return this
}
Window.prototype.toSpace = function (dir) {
var curSpace = this.spaces()[0],
newSpace = curSpace.next()
if (dir === DIR.WEST)
newSpace = curSpace.previous()
if (!newSpace) return this; // Guard end-of-chain
curSpace.removeWindows([this])
newSpace.addWindows([this])
this.focus()
return this
}
Window.prototype.centerMouse = function () {
if (!FOCUS.MOVE_POINTER_ON_FOCUS) return this;
Mouse.move({
x: this.frame().x + this.frame().width / 2,
y: this.frame().y + this.frame().height / 2
})
return this
}
// Nudge the window by an (x,y) offset determined by direction
// dir: one of EAST/WEST/NORTH/SOUTH or single-char h/j/k/l synonyms
// amount: pixels (default MOVE.NUDGE_SMALL)
Window.prototype.nudge = function (dir, amount) {
amount = amount || MOVE.NUDGE_SMALL;
const frame = this.frame();
switch (dir) {
case DIR.EAST: case 'E': case 'l': frame.x += amount; break;
case DIR.WEST: case 'W': case 'h': frame.x -= amount; break;
case DIR.SOUTH: case 'S': case 'j': frame.y += amount; break;
case DIR.NORTH: case 'N': case 'k': frame.y -= amount; break;
default: return this;
}
// Clamp to screen visible frame
try {
const s = this.screen().flippedVisibleFrame();
if (frame.x < s.x) frame.x = s.x;
if (frame.y < s.y) frame.y = s.y;
if (frame.x + frame.width > s.x + s.width) frame.x = s.x + s.width - frame.width;
if (frame.y + frame.height > s.y + s.height) frame.y = s.y + s.height - frame.height;
} catch (e) { }
this.setFrame(frame);
return this;
}
Window.prototype.log = function () {
Phoenix.log(this.frame().x + "," + this.frame().y + "," + this.frame().width + "," + this.frame().height)
return this
}
// ********************************************************************************
// Focus Adjacent Window
// ********************************************************************************
// Refined adjacency: uses frame edges & overlap rather than just centers.
// Ranking (updated):
// 1. Overlapping along perpendicular axis (preferred)
// 2. Larger overlap span (how much they overlap along the perpendicular axis)
// 3. Larger window area (prefer substantial targets)
// 4. Primary directional distance (edge-to-edge, nearer is better)
// 5. Perpendicular center distance (tie-breaker)
// If no overlapping candidates exist, non-overlapping ones are considered (overlap span = 0).
// Does not traverse to other screens (future extension point).
Window.prototype.focusAdjacent = function (dir) {
const cur = this.frame();
const screen = this.screen();
// Fast path: pull stats first (does not rebuild). If snapshot still valid soon, we avoid revalidation churn across rapid key repeats.
const stats = (WindowRegistry.stats && WindowRegistry.stats()) || null;
const entries = WindowRegistry.get(screen); // may rebuild if expired
if (!entries.length) return this;
const screenEntries = entries.filter(e => !e.win.isEqual(this));
if (!screenEntries.length) return this;
const { byLeft, byRight, byTop, byBottom } = WindowRegistry.getIndices(screen);
// Attempt neighbor graph fast-path (precomputed directional adjacency)
let neighborHashes = [];
try {
const nh = WindowRegistry.neighbors(this.hash());
if (dir === DIR.NORTH && nh.North) neighborHashes = nh.North;
else if (dir === DIR.SOUTH && nh.South) neighborHashes = nh.South;
else if (dir === DIR.EAST && nh.East) neighborHashes = nh.East;
else if (dir === DIR.WEST && nh.West) neighborHashes = nh.West;
} catch (e) { }
let neighborEntries = null;
if (neighborHashes.length) {
// Build a hash->entry map once (entries already small per screen)
const map = new Map(entries.map(e => [e.hash, e]));
neighborEntries = neighborHashes.map(h => map.get(h)).filter(Boolean);
}
// Allow a small tolerance so touching or slightly overlapping edges still count
const TOL = FOCUS.ENABLE_GLOBAL_MRU ? GEOM.OVERLAP_TOLERANCE : 0; // pixels (centralized)
const curCenterX = cur.x + cur.width / 2;
const curCenterY = cur.y + cur.height / 2;
const horizOverlap = (a, b) => !(a.x + a.width <= b.x + TOL || b.x + b.width <= a.x + TOL);
const vertOverlap = (a, b) => !(a.y + a.height <= b.y + TOL || b.y + b.height <= a.y + TOL);
// MRU bias helper (lower rank = more recent). Missing => large number.
function mruRank(win) {
if (!FOCUS.ENABLE_GLOBAL_MRU || !FOCUS_MRU.length) return 9999;
const h = win.hash();
const idx = FOCUS_MRU.indexOf(h);
return idx === -1 ? 9999 : idx; // index 0 = most recent
}
function directionalMruRank(win) {
if (!FOCUS.ENABLE_DIRECTIONAL_MRU) return 9999;
const list = DIRECTIONAL_FOCUS_MRU[dir];
if (!list || !list.length) return 9999;
const h = win.hash();
const idx = list.indexOf(h);
return idx === -1 ? 9999 : idx;
}
function classify(entry) {
const w = entry.win;
const f = entry.frame;
const centerX = entry.cx;
const centerY = entry.cy;
let primaryDist = Infinity;
let overlapsPerp = false;
let overlapSpan = 0;
switch (dir) {
case DIR.NORTH:
if (f.y + f.height <= cur.y + TOL || centerY < curCenterY) {
overlapsPerp = horizOverlap(f, cur);
if (overlapsPerp) {
const left = Math.max(f.x, cur.x);
const right = Math.min(f.x + f.width, cur.x + cur.width);
overlapSpan = Math.max(0, right - left);
}
primaryDist = Math.max(0, cur.y - (f.y + f.height));
}
break;
case DIR.SOUTH:
if (f.y >= cur.y + cur.height - TOL || centerY > curCenterY) {
overlapsPerp = horizOverlap(f, cur);
if (overlapsPerp) {
const left = Math.max(f.x, cur.x);
const right = Math.min(f.x + f.width, cur.x + cur.width);
overlapSpan = Math.max(0, right - left);
}
primaryDist = Math.max(0, f.y - (cur.y + cur.height));
}
break;
case DIR.EAST:
if (f.x >= cur.x + cur.width - TOL || centerX > curCenterX) {
overlapsPerp = vertOverlap(f, cur);
if (overlapsPerp) {
const top = Math.max(f.y, cur.y);
const bottom = Math.min(f.y + f.height, cur.y + cur.height);
overlapSpan = Math.max(0, bottom - top);
}
primaryDist = Math.max(0, f.x - (cur.x + cur.width));
}
break;
case DIR.WEST:
if (f.x + f.width <= cur.x + TOL || centerX < curCenterX) {
overlapsPerp = vertOverlap(f, cur);
if (overlapsPerp) {
const top = Math.max(f.y, cur.y);
const bottom = Math.min(f.y + f.height, cur.y + cur.height);
overlapSpan = Math.max(0, bottom - top);
}
primaryDist = Math.max(0, cur.x - (f.x + f.width));
}
break;
default:
return null;
}
if (primaryDist === Infinity) return null;
// Lazy compute of perpendicular center delta only if needed later; store centers for tie-break.
return { w, f, overlapsPerp, overlapSpan, area: f.width * f.height, primaryDist, cx: centerX, cy: centerY, z: entry.z, mru: mruRank(w), dmru: directionalMruRank(w) };
}
// Binary search helpers for lower/upper bounds
function upperBound(arr, getter, value) { // last index with getter(el) <= value
let lo = 0, hi = arr.length - 1, ans = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (getter(arr[mid]) <= value) { ans = mid; lo = mid + 1; } else hi = mid - 1;
}
return ans;
}
function lowerBound(arr, getter, value) { // first index with getter(el) >= value
let lo = 0, hi = arr.length - 1, ans = arr.length;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (getter(arr[mid]) >= value) { ans = mid; hi = mid - 1; } else lo = mid + 1;
}
return ans;
}
let candidatePool = [];
if (neighborEntries && neighborEntries.length) {
candidatePool = neighborEntries;
} else {
// Original directional index coarse filtering
switch (dir) {
case DIR.NORTH: {
const limit = cur.y + TOL;
const idx = upperBound(byBottom, e => e.y + e.h, limit);
if (idx >= 0) candidatePool = byBottom.slice(0, idx + 1);
break;
}
case DIR.SOUTH: {
const start = cur.y + cur.height - TOL;
const idx = lowerBound(byTop, e => e.y, start);
if (idx < byTop.length) candidatePool = byTop.slice(idx);
break;
}
case DIR.EAST: {
const start = cur.x + cur.width - TOL;
const idx = lowerBound(byLeft, e => e.x, start);
if (idx < byLeft.length) candidatePool = byLeft.slice(idx);
break;
}
case DIR.WEST: {
const limit = cur.x + TOL;
const idx = upperBound(byRight, e => e.x + e.w, limit);
if (idx >= 0) candidatePool = byRight.slice(0, idx + 1);
break;
}
default:
candidatePool = screenEntries;
}
if (!candidatePool.length) candidatePool = screenEntries;
}
const candidates = candidatePool.map(classify).filter(Boolean);
if (!candidates.length) return this;
// Prefer: overlap > larger overlap span > larger area > nearer primary distance > nearer perpendicular center > (fallback z original order).
candidates.sort((a, b) => {
// Primary structural ordering
const ov = (a.overlapsPerp === b.overlapsPerp ? 0 : a.overlapsPerp ? -1 : 1);
if (ov) return ov;
const span = b.overlapSpan - a.overlapSpan; if (span) return span;
const areaDiff = b.area - a.area; if (areaDiff) return areaDiff;
const dmruDiff = a.dmru - b.dmru; if (dmruDiff) return dmruDiff; // direction-specific recency before global MRU
const mruDiff = a.mru - b.mru; if (mruDiff) return mruDiff; // global recency fallback
const prim = a.primaryDist - b.primaryDist; if (prim) return prim;
// Deferred perpendicular center distance calculation
const aPerp = (dir === DIR.NORTH || dir === DIR.SOUTH) ? Math.abs(curCenterX - a.cx) : Math.abs(curCenterY - a.cy);
const bPerp = (dir === DIR.NORTH || dir === DIR.SOUTH) ? Math.abs(curCenterX - b.cx) : Math.abs(curCenterY - b.cy);
if (aPerp !== bPerp) return aPerp - bPerp;
return ((a.z ?? 9999) - (b.z ?? 9999));
});
const target = candidates[0];
if (target) {
target.w.focus();
// Record directional MRU hit
recordDirectionalFocus(dir, target.w);
// Return the newly focused window so method chaining (e.g. .centerMouse()) applies to it
return target.w;
}
return this; // no change
}
// ********************************************************************************
// Modal Extensions
// ********************************************************************************
// Flash a modal in the center of a given screen
Modal.prototype.flash = function (screen) {
var tf = this.frame(),
sf = screen.frame();
this.origin = {
x: sf.x + sf.width / 2 - tf.width / 2,
y: sf.y + sf.height / 2 - tf.height / 2
}
this.show();
return this;
}
Modal.prototype.attach = function (window) {
var tf = this.frame(),
wf = window.frame(),
sf = window.screen().frame();
this.origin = {
x: Math.min(
Math.max(wf.x + wf.width / 2 - tf.width / 2, sf.x),
sf.x + sf.width - tf.width
),
y: Math.min(
Math.max(Screen.all()[0].frame().height - (wf.y + wf.height / 2 + tf.height / 2), sf.y),
sf.y + sf.height - tf.height
)
};
this.show();
return this;
}
// ********************************************************************************
// Screen Extensions
// ********************************************************************************
Screen.prototype.width = function () { // DEPRECATED
return this.flippedVisibleFrame().width - GEOM.PADDING * 2
}
Screen.prototype.height = function () { // DEPRECATED
return this.flippedVisibleFrame().height - GEOM.PADDING * 2
}
Screen.prototype.origin = function () { // DEPRECATED
return {
x: this.flippedVisibleFrame().x + GEOM.PADDING,
y: this.flippedVisibleFrame().y + GEOM.PADDING
}
}
// ********************************************************************************
// Utilities
// ********************************************************************************
// Unified WindowRegistry: shared snapshot for visible windows with derived metrics.
// Replaces WindowSnapshotCache and can be extended further (e.g., directional indices).
const WindowRegistry = (() => {
let ttl = PERF.REGISTRY_TTL_BASE_MS; // adaptive TTL (starts at base)
let expiry = 0;
let cache = new Map(); // screenIndex -> { entries: [...], indices: { byLeft, byRight, byTop, byBottom } }
let lastRebuildTs = 0;
let lastDuration = 0;
let consecutiveFast = 0;
let consecutiveSlow = 0;
let version = 0; // incremented on any structural change
let flatAll = null; // cached flattened entries array for getAllEntries / space partition
let flatAllVersion = -1;
let bySpace = new Map(); // spaceId -> array of entries
let neighborsVersion = -1; // version of neighbor graph
let neighborMap = new Map(); // hash -> {North:[],South:[],East:[],West:[]}
// Dirty flags for incremental index resorting
let dirtyLeft = false, dirtyRight = false, dirtyTop = false, dirtyBottom = false, dirtySpace = false;
function buildEntry(w, order) {
const f = w.frame();
const app = w.app();
let appId = null;
try { appId = (app.bundleIdentifier && app.bundleIdentifier()) || app.pid(); } catch (e) { appId = app.pid(); }
const title = w.title();
// Precompute whether app has multiple windows (expensive to call repeatedly later)
let multi = false;
try { multi = app.windows().length > 1; } catch (e) { multi = false; }
return {
win: w,
frame: f,
x: f.x, y: f.y, w: f.width, h: f.height,
cx: f.x + f.width / 2,
cy: f.y + f.height / 2,
area: f.width * f.height,
z: order,
hash: w.hash(),
appId: appId,
titleShort: title.length > 15 ? title.substr(0, 15) + "…" : title,
multi
};
}
function rebuild() {
const start = Date.now();
cache.clear();
bySpace.clear();
Screen.all().forEach((scr, idx) => {
const entries = scr.windows({ visible: true }).map(buildEntry);
// Precompute directional indices (store indices to avoid duplicating objects)
const byLeft = entries.slice().sort((a, b) => a.x - b.x);
const byRight = entries.slice().sort((a, b) => (a.x + a.w) - (b.x + b.w));
const byTop = entries.slice().sort((a, b) => a.y - b.y);
const byBottom = entries.slice().sort((a, b) => (a.y + a.h) - (b.y + b.h));
cache.set(idx, { entries, indices: { byLeft, byRight, byTop, byBottom } });
// space partitioning (first space only heuristic)
entries.forEach(e => {
let sid = null;
try { const sp = e.win.spaces && e.win.spaces(); if (sp && sp.length) sid = sp[0].hash ? sp[0].hash() : sp[0]; } catch (err) { }
if (!sid) return; // skip if unknown
if (!bySpace.has(sid)) bySpace.set(sid, []);
bySpace.get(sid).push(e);
});
});
lastDuration = Date.now() - start;
lastRebuildTs = Date.now();
// Adaptive backoff: if rebuilds are expensive, extend TTL; if very cheap repeatedly, allow slight shrink
if (PERF.REGISTRY_ADAPTIVE) {
if (lastDuration > 25) { // considered slow
consecutiveSlow++; consecutiveFast = 0;
if (consecutiveSlow >= 2) { // two slow rebuilds back to back
ttl = Math.min(PERF.REGISTRY_TTL_MAX_MS, Math.round(ttl * 1.4));
consecutiveSlow = 0; // reset streak
}
} else if (lastDuration < 6) { // very fast
consecutiveFast++; consecutiveSlow = 0;
if (consecutiveFast >= 4 && ttl > PERF.REGISTRY_TTL_MIN_MS) {
ttl = Math.max(PERF.REGISTRY_TTL_MIN_MS, Math.round(ttl * 0.85));
consecutiveFast = 0;
}
} else {
consecutiveFast = 0; consecutiveSlow = 0; // neutral rebuild
}
}
expiry = Date.now() + ttl;
version++;
flatAll = null; // invalidate flattened cache
flatAllVersion = -1;
neighborsVersion = -1; neighborMap.clear();
}
function ensureIndices(scrIdx) {
const rec = cache.get(scrIdx);
if (!rec) return;
if (dirtyLeft) rec.indices.byLeft = rec.entries.slice().sort((a, b) => a.x - b.x);
if (dirtyRight) rec.indices.byRight = rec.entries.slice().sort((a, b) => (a.x + a.w) - (b.x + b.w));
if (dirtyTop) rec.indices.byTop = rec.entries.slice().sort((a, b) => a.y - b.y);
if (dirtyBottom) rec.indices.byBottom = rec.entries.slice().sort((a, b) => (a.y + a.h) - (b.y + b.h));
}
function flushDirty() {
if (!(dirtyLeft || dirtyRight || dirtyTop || dirtyBottom || dirtySpace)) return;
cache.forEach((_, idx) => ensureIndices(idx));
if (dirtySpace) rebuildSpacePartitions();
dirtyLeft = dirtyRight = dirtyTop = dirtyBottom = dirtySpace = false;
version++; flatAll = null; flatAllVersion = -1;
neighborsVersion = -1; neighborMap.clear();
}
function rebuildSpacePartitions() {
bySpace.clear();
cache.forEach(rec => {
rec.entries.forEach(e => {
let sid = null;
try { const sp = e.win.spaces && e.win.spaces(); if (sp && sp.length) sid = sp[0].hash ? sp[0].hash() : sp[0]; } catch (err) { }
if (!sid) return;
if (!bySpace.has(sid)) bySpace.set(sid, []);
bySpace.get(sid).push(e);
});
});
}
function updateWindow(win, typ) {
if (Date.now() > expiry) rebuild(); // baseline freshness
const scr = win.screen && win.screen();
if (!scr) { return; }
const idx = Screen.all().indexOf(scr);
if (idx === -1) return;
let rec = cache.get(idx);
if (!rec) { rebuild(); return; }
const h = win.hash();
if (typ === 'close') {
const beforeLen = rec.entries.length;
rec.entries = rec.entries.filter(e => e.hash !== h);
if (rec.entries.length !== beforeLen) {
dirtyLeft = dirtyRight = dirtyTop = dirtyBottom = true; dirtySpace = true; version++; flatAll = null; flatAllVersion = -1;
neighborsVersion = -1; neighborMap.clear();
}
return;
}
// find existing
let entry = rec.entries.find(e => e.hash === h);
if (!entry) {
if (typ === 'open') {
entry = buildEntry(win);
rec.entries.push(entry);
dirtyLeft = dirtyRight = dirtyTop = dirtyBottom = true; dirtySpace = true;
version++; flatAll = null; flatAllVersion = -1;
}
return;
}
// move/resize: update geometry in place
if (typ === 'move' || typ === 'resize') {
const f = win.frame();
if (f.x !== entry.x || f.y !== entry.y || f.width !== entry.w || f.height !== entry.h) {
entry.frame = f; entry.x = f.x; entry.y = f.y; entry.w = f.width; entry.h = f.height; entry.cx = f.x + f.width / 2; entry.cy = f.y + f.height / 2; entry.area = f.width * f.height;
dirtyLeft = dirtyRight = dirtyTop = dirtyBottom = true; dirtySpace = true; version++; flatAll = null; flatAllVersion = -1;
neighborsVersion = -1; neighborMap.clear();
}
}
}
function buildNeighbors() {
// Build directional neighbor lists using sweep-line heuristics.
flushDirty();
neighborMap.clear();
const all = flatAll && flatAllVersion === version ? flatAll : (() => { let arr = []; cache.forEach(v => arr = arr.concat(v.entries)); return arr; })();
// Initialize
all.forEach(e => neighborMap.set(e.hash, { North: [], South: [], East: [], West: [] }));
const TOL = GEOM.OVERLAP_TOLERANCE;
// Horizontal neighbors (E/W): sort by x
const byX = all.slice().sort((a, b) => a.x - b.x);
for (let i = 0; i < byX.length; i++) {
const a = byX[i];
for (let j = i + 1; j < byX.length; j++) {
const b = byX[j];
if (b.x - (a.x + a.w) > PERF.NEIGHBOR_SCAN_MAX_DIST) break; // hard cap distance to limit scan
// vertical overlap check
const overlapY = !(a.y + a.h <= b.y + TOL || b.y + b.h <= a.y + TOL);
if (!overlapY) continue;
const dist = b.x - (a.x + a.w);
if (dist >= -TOL) { // b to the East of a
neighborMap.get(a.hash).East.push(b.hash);
neighborMap.get(b.hash).West.push(a.hash);
}
}
}
// Vertical neighbors (N/S): sort by y
const byY = all.slice().sort((a, b) => a.y - b.y);
for (let i = 0; i < byY.length; i++) {
const a = byY[i];
for (let j = i + 1; j < byY.length; j++) {
const b = byY[j];
if (b.y - (a.y + a.h) > 600) break;
const overlapX = !(a.x + a.w <= b.x + TOL || b.x + b.w <= a.x + TOL);
if (!overlapX) continue;
const dist = b.y - (a.y + a.h);
if (dist >= -TOL) { // b South of a
neighborMap.get(a.hash).South.push(b.hash);
neighborMap.get(b.hash).North.push(a.hash);
}
}
}
neighborsVersion = version;
}
return {
get(screen) {
if (Date.now() > expiry) rebuild();
flushDirty();
const idx = Screen.all().indexOf(screen);
const rec = cache.get(idx);
return rec ? rec.entries : [];
},
getIndices(screen) {
if (Date.now() > expiry) rebuild();
flushDirty();
const idx = Screen.all().indexOf(screen);
const rec = cache.get(idx);
return rec ? rec.indices : { byLeft: [], byRight: [], byTop: [], byBottom: [] };
},
getAllEntries() { // flattened list across screens (no rebuild loop duplication)
if (Date.now() > expiry) rebuild();
flushDirty();
let all = [];
cache.forEach(v => { all = all.concat(v.entries); });
return all;
},
getAllEntriesFlattened() { // cached flattened array until version changes
if (Date.now() > expiry) rebuild();
flushDirty();
if (flatAll && flatAllVersion === version) return flatAll;
flatAll = [];
cache.forEach(v => { flatAll = flatAll.concat(v.entries); });
flatAllVersion = version;
return flatAll;
},
getSpaceEntries(spaceId) { if (Date.now() > expiry) rebuild(); flushDirty(); return bySpace.get(spaceId) || []; },
updateWindow,
neighbors(hash) { if (neighborsVersion !== version) buildNeighbors(); return neighborMap.get(hash) || { North: [], South: [], East: [], West: [] }; },
clear() { cache.clear(); expiry = 0; },
/**
* WindowRegistry statistics snapshot.
* @returns {{ttl:number, expiresIn:number, lastDuration:number, version:number, cachedFlat:boolean}}
*/
stats() { return { ttl, expiresIn: Math.max(0, expiry - Date.now()), lastDuration, version, cachedFlat: !!flatAll }; }
};
})();
// Layout cache: key = screenIndex|mode|windowHashes (sorted). Stores array of frames.
const LAYOUT_CACHE = new Map();
function layoutCacheKey(screen, mode, windows) {
const idx = Screen.all().indexOf(screen);
const hashes = windows.map(w => w.hash()).sort().join(':');
return idx + '|' + mode + '|' + hashes;
}
// (Removed) Array.prototype.rotate -- replaced with Util.rotateArray to avoid prototype pollution.
// Opposite direction lookup (includes diagonals for symmetry operations)
const OPPOSITE = Object.freeze({
[DIR.NORTH]: DIR.SOUTH,
[DIR.SOUTH]: DIR.NORTH,
[DIR.EAST]: DIR.WEST,
[DIR.WEST]: DIR.EAST,
[DIR.NW]: DIR.SE,
[DIR.NE]: DIR.SW,
[DIR.SW]: DIR.NE,
[DIR.SE]: DIR.NW,
});
function opposite(dir) { return OPPOSITE[dir] || dir; }
// ********************************************************************************
// Focus MRU Tracking
// ********************************************************************************
// Tracks most recently focused windows to bias adjacency (recency preference after area/overlap).
const FOCUS_MRU = [];
// Directional MRU: direction -> array of window hashes (most recent first)
const DIRECTIONAL_FOCUS_MRU = { North: [], South: [], East: [], West: [] };
function recordFocus(win) {
if (!FOCUS.ENABLE_GLOBAL_MRU || !win) return;
try {
const h = win.hash();
const idx = FOCUS_MRU.indexOf(h);
if (idx !== -1) FOCUS_MRU.splice(idx, 1);
FOCUS_MRU.unshift(h);
if (FOCUS_MRU.length > FOCUS.GLOBAL_MRU_LIMIT) FOCUS_MRU.pop();
} catch (e) { }
}
function recordDirectionalFocus(dir, win) {
if (!FOCUS.ENABLE_DIRECTIONAL_MRU || !win) return;
const bucket = DIRECTIONAL_FOCUS_MRU[dir];
if (!bucket) return;
try {
const h = win.hash();
const idx = bucket.indexOf(h);
if (idx !== -1) bucket.splice(idx, 1);
bucket.unshift(h);
if (bucket.length > FOCUS.DIRECTIONAL_MRU_LIMIT) bucket.pop();
} catch (e) { }
}
['windowDidBecomeMain', 'windowDidFocus'].forEach(ev => Event.on(ev, () => {
try { const w = Window.focused(); if (w) recordFocus(w); } catch (e) { }
}));
// ********************************************************************************
// Startup
// ********************************************************************************
const HintManager = new Hints() // ISSUE: Singleton pattern; if reloaded dynamically may leak old key bindings.
// ********************************************************************************
// Move Mode Controller
// ********************************************************************************
class MoveModeController {
constructor() {
this.active = false;
this.window = null;
this.overlay = null;
this.bindings = [];
}
_buildOverlay() {
if (!this.window) return;
try { if (this.overlay) this.overlay.close(); } catch (e) { }
try {
// Fetch or cache the app icon for the focused window
let ic = undefined;
try {
const key = iconKey(this.window);
if (!ICON_CACHE[key]) {
ICON_CACHE[key] = this.window.app().icon();
}
ic = ICON_CACHE[key];
} catch (e) { }
this.overlay = Modal.build({
text: '[ MOVE ]\nEnter/Esc to exit',
appearance: HINT.APPEARANCE,
weight: 18,
duration: 0, // persistent until closed
icon: ic
}).attach(this.window);
} catch (e) { }
}
_repositionOverlay() { // refresh modal position after window move
if (!this.overlay || !this.window) return;
try { this.overlay.attach(this.window); } catch (e) { }
}
activate(win) {
if (this.active) { // toggle off
return this.deactivate();
}
this.window = win || Window.focused();
if (!this.window) return;
this.active = true;
this._buildOverlay();
this._bindKeys();
}
_bindKeys() {
const self = this;
const push = (k, mods, fn) => { self.bindings.push(Key.on(k, mods, fn)); };
function nudgeDir(dir, amt) {
if (!self.window || !self.active) return;
self.window.nudge(dir, amt);
self._repositionOverlay();
}
// Unmodified keys = LARGE nudges
push('h', [], () => nudgeDir(DIR.WEST, MOVE.NUDGE_LARGE));
push('j', [], () => nudgeDir(DIR.SOUTH, MOVE.NUDGE_LARGE));
push('k', [], () => nudgeDir(DIR.NORTH, MOVE.NUDGE_LARGE));
push('l', [], () => nudgeDir(DIR.EAST, MOVE.NUDGE_LARGE));
push('left', [], () => nudgeDir(DIR.WEST, MOVE.NUDGE_LARGE));
push('down', [], () => nudgeDir(DIR.SOUTH, MOVE.NUDGE_LARGE));
push('up', [], () => nudgeDir(DIR.NORTH, MOVE.NUDGE_LARGE));
push('right', [], () => nudgeDir(DIR.EAST, MOVE.NUDGE_LARGE));
// Shift-modified keys = SMALL nudges
push('h', ['shift'], () => nudgeDir(DIR.WEST, MOVE.NUDGE_SMALL));
push('j', ['shift'], () => nudgeDir(DIR.SOUTH, MOVE.NUDGE_SMALL));
push('k', ['shift'], () => nudgeDir(DIR.NORTH, MOVE.NUDGE_SMALL));
push('l', ['shift'], () => nudgeDir(DIR.EAST, MOVE.NUDGE_SMALL));
push('left', ['shift'], () => nudgeDir(DIR.WEST, MOVE.NUDGE_SMALL));
push('down', ['shift'], () => nudgeDir(DIR.SOUTH, MOVE.NUDGE_SMALL));
push('up', ['shift'], () => nudgeDir(NORTH, MOVE.NUDGE_SMALL));
push('right', ['shift'], () => nudgeDir(EAST, MOVE.NUDGE_SMALL));
// Exit keys
push('escape', [], () => self.deactivate());
push('return', [], () => self.deactivate());
// Auto-cancel on mouse click (like hints)
Event.once("mouseDidLeftClick", () => self.deactivate());
}
deactivate() {
if (!this.active) return;
this.active = false;
this.window = null;
this.bindings.forEach(Key.off); this.bindings = [];
try { if (this.overlay) this.overlay.close(); } catch (e) { }
this.overlay = null;
}
}
const MoveMode = new MoveModeController();
Modal.build({
text: "Ready",
appearance: HINT.APPEARANCE,
weight: 24,
icon: App.get('Phoenix').icon(),
duration: 0.5,
}).flash(Screen.all()[0]);
// ********************************************************************************
// Cache Invalidation Hooks
// ********************************************************************************
// Debounced invalidation so rapid bursts of events (e.g. live resize) only rebuild once.
; (function () {
if (typeof WindowRegistry === 'undefined') return;
let invalidateTimer = null;
const DEBOUNCE_MS = PERF.EVENT_DEBOUNCE_MS; // migrated from WINDOW_EVENT_DEBOUNCE_MS
function scheduleInvalidate(kind, winFetcher) {
// For open/close we still fallback to full clear; for move/resize attempt incremental
if (kind === 'move' || kind === 'resize') {
try { const w = winFetcher && winFetcher(); if (w) WindowRegistry.updateWindow(w, kind); } catch (e) { }
return; // no debounce needed for simple geometry update
}
if (invalidateTimer) return; // coalesce full rebuild triggers
invalidateTimer = Timer.after(DEBOUNCE_MS / 1000, () => { WindowRegistry.clear(); invalidateTimer = null; });
}
Event.on('windowDidMove', () => scheduleInvalidate('move', () => Window.focused()));
Event.on('windowDidResize', () => scheduleInvalidate('resize', () => Window.focused()));
Event.on('windowDidOpen', () => scheduleInvalidate('open'));
Event.on('windowDidClose', () => scheduleInvalidate('close'));
Event.on('windowDidMinimize', () => scheduleInvalidate('close'));
Event.on('windowDidUnminimize', () => scheduleInvalidate('open'));
Event.on('windowDidChangeScreen', () => scheduleInvalidate('open'));
Event.on('windowDidChangeSpace', () => scheduleInvalidate('open'));
Event.on('windowDidBecomeMain', () => scheduleInvalidate('move', () => Window.focused()));
})();