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://github.com/kasper/phoenix/blob/master/docs/API.md
*
* TODO:
* - [ ] Drag and Drop Snap (can't be implemented in curren Phoenix, apparently)
* - [ ] New XMonad with precomputed layouts
* - [ ] Nearest Rectangle Match
* - [ ] Window overlay test
* - [ ] Cleanup positionInGrid
* - [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
// ********************************************************************************
const INCREMENT = 50;
const PADDING = 8;
// DIRECTIONS
const NONE = "none",
COLS = "Cols",
F = FULL = "Full",
N = NORTH = "North",
S = SOUTH = "South",
E = EAST = "East",
W = WEST = "West",
NW = "North-West",
NE = "North-East",
SW = "South-West",
SE = "South-East";
const TILING_MODES = [NONE, EAST, WEST, COLS];
var MOD = ["shift", "command"];
var ALTMOD = ["shift", "option"];
var HINT_APPEARANCE = "dark";
var HINT_BUTTON = "space";
var HINT_CANCEL = "escape";
var HINT_CHARS = "FJDKSLAGHRUEIWOVNCM";
var LAST_POSITION = {
window: null,
grid: "",
positions: []
}
var LAST_POSITION_INDEX = -1;
var ICON_CACHE = {}
// ********************************************************************************
// Keyboard Bindings
// ********************************************************************************
// Moom-like bindings
Key.on("return", ["control", "option"], () => {Window.focused().positionInGrid(4,0,3).centerMouse()});
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"], () => {Window.focused().positionInGrid(6,0,5).centerMouse()});
Key.on("down", ["control", "option"], () => steppedSizing(Window.focused(), [[8,2,13], [6,1,4]]));
Key.on("left", ["shift", "control", "option"], () => {Window.focused().positionInGrid(6,0,3).centerMouse()});
Key.on("right", ["shift", "control", "option"], () => {Window.focused().positionInGrid(6,2,5).centerMouse()});
Key.on("up", ["shift", "control", "option"], () => {Window.focused().reposition(NORTH).centerMouse()});
Key.on("down", ["shift", "control", "option"], () => {Window.focused().reposition(SOUTH).centerMouse()});
// Sixths
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(",", ["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()});
// Move horizontally between screens
Key.on("right", ["control", "option", "command"], () => Window.focused().toScreen(EAST).centerMouse());
Key.on("left", ["control", "option", "command"], () => Window.focused().toScreen(WEST).centerMouse());
// Move horizontally between spaces
Key.on("right", ["shift", "control", "option", "command"], () => Window.focused().toSpace(EAST));
Key.on("left", ["shift", "control", "option", "command"], () => Window.focused().toSpace(WEST));
// ********************************************************************************
// Size steps
// ********************************************************************************
function steppedSizing(win, gridPositions) {
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;
}
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) {
self = this;
prefix = prefix || "";
// check if there are too many windows and recurse
if (windows.length > HINT_CHARS.length) {
var partitionSize = Math.floor(windows.length / HINT_CHARS.length);
var lists = _.toArray(_.groupBy(windows, function (win, k) {
return k % HINT_CHARS.length;
}));
for (var j = 0; j < HINT_CHARS.length; j++) {
return this.show(lists[j], prefix + HINT_CHARS[j]);
}
return this;
}
var self = this;
windows.forEach(function (win, i) {
var label = "",
hash = win.hash();
if(!ICON_CACHE[hash]) {
ICON_CACHE[hash] = win.app().icon()
}
if (win.app().windows().length > 1) {
label += " | " + win.title().substr(0, 15) + (win.title().length > 15 ? "…" : "");
}
var hint = Modal.build({
text: prefix + HINT_CHARS[i] + label,
appearance: HINT_APPEARANCE,
icon: ICON_CACHE[hash],
weight: 16,
duration: 0,
}).attach(win);
var activators = Object.keys(self.hints);
// Check for overlaps - TODO: use Frame
for (var l = 0; l < activators.length; l++) {
var hint2 = self.hints[activators[l]].modal;
if (
hint.origin.x < hint2.origin.x + hint2.frame().width + PADDING
&& hint.origin.x + hint.frame().width > hint2.origin.x - PADDING
&& hint.origin.y < hint2.origin.y + hint2.frame().height + PADDING
&& hint.origin.y + hint.frame().width > hint2.origin.y - PADDING
) {
hint.origin = {
x: hint.origin.x,
y: hint2.origin.y + hint2.frame().height + PADDING
};
l = -1;
}
}
self.hints[prefix + HINT_CHARS[i]] = {
win: win,
modal: hint,
position: 0,
active: true
};
});
self.escbind = Key.on(HINT_CANCEL, [], function() {
self.cancel();
})
this.active = true;
return this;
} // show
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 () {
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(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] = TILING_MODES[(TILING_MODES.indexOf(this.tiling_modes[index])+1) % TILING_MODES.length]
Phoenix.log(index + " " + this.tiling_modes[index])
this.layouts[index] = new Layout(screen, this.tiling_modes[index])
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)
Phoenix.log(this.layouts)
this.layouts[index].windows = visible
this.layouts[index].apply()
Phoenix.log(this.layouts)
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()
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));
// ********************************************************************************
// Layout Abstraction
// ********************************************************************************
class Layout {
constructor(screen, mode) {
this.frames = []
this.windows = []
switch(mode) {
case EAST:
this.east(screen)
break;
case WEST:
this.west(screen)
break;
case 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) {
this.windows.rotate(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 + PADDING/2,
this.y + PADDING/2,
this.width - PADDING,
this.height - PADDING
)
}
snap(screen, dir) {
var s = screen.flippedVisibleFrame(),
f = new Frame(this.x, this.y, this.width, this.height);
if ([E, NE, SE].indexOf(dir) > -1) f.x += s.width - f.width;
if ([SE, SW].indexOf(dir) > -1) f.y += s.height - f.height;
if (dir === F) f.width = s.width;
if ([F, E, W].indexOf(dir) > -1) f.height = s.height;
return f;
}
displace(x, y) {
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) {
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) {
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) {
var screen = null;
if(dir===EAST) {
screen = Screen.all().filter((a) => {return a.origin().x > this.screen().origin().x}).sort((a,b) => {return a.origin().x > b.origin().x})[0]
}
else if(dir===WEST) {
screen = Screen.all().filter((a) => {return a.origin().x < this.screen().origin().x}).sort((a,b) => {return a.origin().x < b.origin().x})[0];
}
if(screen) {
ratio = Frame.prototype.ratio(this.screen().flippedVisibleFrame(), screen.flippedVisibleFrame());
this.setFrame(ratio(this.frame()));
}
return this;
};
Window.prototype.reposition = function (dir) {
var l = LAST_POSITION.window,
g = LAST_POSITION.grid;
if(this.isEqual(l)) {
if(dir === NORTH) {
switch(g) {
case "4,0,2": this.positionInGrid(4,0,0); break;
case "4,2,2": this.positionInGrid(4,0,0); break;
case "4,1,3": this.positionInGrid(4,1,1); break;
case "4,3,3": this.positionInGrid(4,1,1); break;
case "6,0,3": this.positionInGrid(6,0,0); break;
case "6,3,3": this.positionInGrid(6,0,0); break;
case "6,2,5": this.positionInGrid(6,2,2); break;
case "6,5,5": this.positionInGrid(6,2,2); break;
case "6,1,4": this.positionInGrid(6,1,1); break;
case "6,4,4": this.positionInGrid(6,1,1); break;
}
} else if (dir === SOUTH) {
switch(g) {
case "4,0,2": this.positionInGrid(4,2,2); break;
case "4,0,0": this.positionInGrid(4,2,2); break;
case "4,1,3": this.positionInGrid(4,3,3); break;
case "4,1,1": this.positionInGrid(4,3,3); break;
case "6,0,3": this.positionInGrid(6,3,3); break;
case "6,0,0": this.positionInGrid(6,3,3); break;
case "6,2,5": this.positionInGrid(6,5,5); break;
case "6,2,2": this.positionInGrid(6,5,5); break;
case "6,1,4": this.positionInGrid(6,4,4); break;
case "6,1,1": this.positionInGrid(6,4,4); break;
}
}
}
return this
}
Window.prototype.positionInGrid = function (cells, start, end) {
LAST_POSITION = {
window: this,
grid: cells + "," + start + "," + end
}
var cols = ~~(cells / 2);
var screen = this.screen();
var cellwidth = (screen.width() - ((cols - 1) * PADDING)) / cols;
var cellheight = (screen.height() - PADDING) / 2;
var startc = start % cols,
startw = ~~(start / cols),
startl = screen.origin().x + (cellwidth + PADDING) * startc,
startt = screen.origin().y + (cellheight + PADDING) * startw,
startr = startl + cellwidth,
startb = startt + cellheight;
var endc = end % cols,
endw = ~~(end / cols),
endl = screen.origin().x + (cellwidth + PADDING) * endc,
endt = screen.origin().y + (cellheight + 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 === W) frame.x += coeff * -1
if (dir === N) frame.y += coeff * -1
if ([E, W].indexOf(dir) > -1) frame.width += coeff
if ([N, S].indexOf(dir) > -1) frame.height += coeff
this.setFrame(frame)
return this
}
Window.prototype.toSpace = function (dir) {
var curSpace = this.spaces()[0],
newSpace = curSpace.next()
if (dir === WEST)
newSpace = curSpace.previous()
curSpace.removeWindows([this])
newSpace.addWindows([this])
this.focus()
return this
}
Window.prototype.centerMouse = function() {
Mouse.move({
x: this.frame().x + this.frame().width / 2,
y: this.frame().y + this.frame().height / 2
})
return this
}
Window.prototype.log = function() {
Phoenix.log(this.frame().x + "," + this.frame().y + "," + this.frame().width + "," + this.frame().height)
return this
}
// ********************************************************************************
// 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 - PADDING * 2
}
Screen.prototype.height = function () { // DEPRECATED
return this.flippedVisibleFrame().height - PADDING * 2
}
Screen.prototype.origin = function () { // DEPRECATED
return {
x: this.flippedVisibleFrame().x + PADDING,
y: this.flippedVisibleFrame().y + PADDING
}
}
// ********************************************************************************
// Utilities
// ********************************************************************************
Array.prototype.rotate = (function() {
const unshift = Array.prototype.unshift,
splice = Array.prototype.splice
return function(count) {
var len = this.length >>> 0,
count = count >> 0;
unshift.apply(this, splice.call(this, count % len, len));
return this;
}
})()
function opposite(dir) {
switch (dir) {
case N: return S;
case S: return N;
case E: return W;
case W: return E;
case NW: return SE;
case NE: return SW;
case SW: return NE;
case SE: return NW;
}
}
// ********************************************************************************
// Startup
// ********************************************************************************
const HintManager = new Hints()
Key.on("space", ["shift", "command"], () => HintManager.activate());
Modal.build({
text: "Ready",
appearance: HINT_APPEARANCE,
weight: 24,
icon: App.get('Phoenix').icon(),
duration: 0.5,
}).flash(Screen.all()[0]);