├── README.md ├── spec ├── screenlayout_spec.lua ├── support │ ├── FakeOS.lua │ ├── FakeScreen.lua │ ├── FakeWindow.lua │ ├── FakeWindowTracker.lua │ ├── FakeWorkspace.lua │ └── spec_helper.lua ├── windowregistry_spec.lua └── workspacefinder_spec.lua └── wm ├── controller.lua ├── floatinglayout.lua ├── fnutils.lua ├── init.lua ├── layout.lua ├── os.lua ├── screenlayout.lua ├── utils.lua ├── windowregistry.lua ├── windowtracker.lua ├── workspace.lua └── workspacefinder.lua /README.md: -------------------------------------------------------------------------------- 1 | # Whammy 2 | 3 | Whammy is a clone of the [i3 window manager](https://i3wm.org/) for OS X, built on [Hammerspoon](http://www.hammerspoon.org/). It's currently pre-alpha. 4 | 5 | ## Installation 6 | 7 | ### Install Hammerspoon 8 | Whammy requires [Hammerspoon](http://www.hammerspoon.org/), and currently you must install from source. To do so: 9 | 10 | [Install Cocoapods](https://guides.cocoapods.org/using/getting-started.html). 11 | ```sh 12 | $ git clone https://github.com/tmandry/hammerspoon.git 13 | $ git checkout patch-finder 14 | $ open hammerspoon/Hammerspoon.xcodeproj 15 | ``` 16 | 17 | and run it inside Xcode. 18 | 19 | ### Install Whammy 20 | 21 | ```sh 22 | $ cd ~/.hammerspoon 23 | $ git clone https://github.com/tmandry/whammy.git 24 | $ ln -s whammy/wm 25 | ``` 26 | 27 | ### Set up your configuration 28 | Copy the [sample config](https://github.com/tmandry/whammy/wiki/Sample-Config) to `~/.hammerspoon/init.lua`. 29 | 30 | Finally, click the Hammerspoon icon and "Reload Config", and Whammy is running. 31 | 32 | ## Usage 33 | 34 | **Learn how to use i3 ([video](https://www.youtube.com/watch?v=Wx0eNaGzAZU), [user guide](https://i3wm.org/docs/userguide.html)).** The commands in the [sample config](https://github.com/tmandry/whammy/wiki/Sample-Config) are similar to its default commands, with the alt key being used and _actual vim keys (hjkl)_ being used to control direction. 35 | 36 | ## Development 37 | 38 | ### Running tests 39 | 40 | ```sh 41 | $ brew install luarocks 42 | $ luarocks install busted 43 | $ cd path/to/whammy 44 | $ busted 45 | ``` 46 | -------------------------------------------------------------------------------- /spec/screenlayout_spec.lua: -------------------------------------------------------------------------------- 1 | require 'spec.support.spec_helper' 2 | 3 | local screenlayout = require 'wm.screenlayout' 4 | local workspace = require 'wm.workspace' 5 | 6 | local FakeScreen = require 'spec.support.FakeScreen' 7 | local FakeWorkspace = require 'spec.support.FakeWorkspace' 8 | local FakeWindow = require 'spec.support.FakeWindow' 9 | 10 | describe("screenlayout", function() 11 | local old, workspaces 12 | 13 | before_each(function() 14 | old = {} 15 | workspaces = {} 16 | 17 | old.workspace_new = workspace.new 18 | workspace.new = spy.new(function(self, ...) 19 | local ws = FakeWorkspace:new(...) 20 | table.insert(workspaces, ws) 21 | return ws 22 | end) 23 | end) 24 | 25 | after_each(function() 26 | workspace.new = old.workspace_new 27 | end) 28 | 29 | it("creates a workspace for one screen on startup", function() 30 | local screens = {FakeScreen:new(1)} 31 | local sl = screenlayout:new(screens) 32 | 33 | assert.are.equal(1, #sl:workspaces()) 34 | assert.spy(workspace.new).was.called(1) 35 | assert.spy(workspace.new).was.called_with(workspace, screens[1]) 36 | end) 37 | 38 | it("creates a workspace for two screens on startup", function() 39 | local screens = {FakeScreen:new(1), FakeScreen:new(2)} 40 | local sl = screenlayout:new(screens) 41 | 42 | assert.are.equal(2, #sl:workspaces()) 43 | assert.spy(workspace.new).was.called(2) 44 | assert.spy(workspace.new).was.called_with(workspace, screens[1]) 45 | assert.spy(workspace.new).was.called_with(workspace, screens[2]) 46 | end) 47 | 48 | it("selects a workspace and screen on startup", function() 49 | local screens = {FakeScreen:new(1)} 50 | local sl = screenlayout:new(screens) 51 | 52 | assert.are.equal(sl:workspaces()[1], sl:selectedWorkspace()) 53 | assert.are.equal(screens[1], sl:selectedScreen()) 54 | end) 55 | 56 | describe("setWorkspaceForScreen", function() 57 | it("accepts nil as a workspace and creates a new one on that screen", function() 58 | local screen = FakeScreen:new(1) 59 | local sl = screenlayout:new({screen}) 60 | 61 | local ws = sl:workspaces()[1] 62 | sl:setWorkspaceForScreen(screen, nil) 63 | 64 | assert.spy(workspace.new).was.called(2) -- once at creation, once at setWorkspaceForScreen 65 | local newWs = sl:workspaces()[#sl:workspaces()] 66 | assert.are_not.equal(ws, newWs) 67 | assert.are.equal(screen, newWs:screen()) 68 | end) 69 | 70 | it("selects the new workspace if that screen was already selected", function() 71 | local screen = FakeScreen:new(1) 72 | local sl = screenlayout:new({screen}) 73 | 74 | local ws = sl:workspaces()[1] 75 | assert.are.equal(screen, sl:selectedScreen()) 76 | sl:setWorkspaceForScreen(screen, nil) 77 | assert.are.equal(sl:workspaces()[1], sl:selectedWorkspace()) 78 | end) 79 | 80 | it("removes the currently selected workspace, if it's empty", function() 81 | local screen = FakeScreen:new(1) 82 | local sl = screenlayout:new({screen}) 83 | 84 | local workspace = sl:workspaces()[1] 85 | sl:setWorkspaceForScreen(screen, nil) 86 | assert.is_not.in_array(sl:workspaces(), workspace) 87 | end) 88 | 89 | it("doesn't remove the currently selected workspace if it isn't empty", function() 90 | local screen = FakeScreen:new(1) 91 | local sl = screenlayout:new({screen}) 92 | 93 | sl:addWindow(FakeWindow:new(1), screen) 94 | local workspace = sl:workspaces()[1] 95 | sl:setWorkspaceForScreen(screen, nil) 96 | assert.is.in_array(sl:workspaces(), workspace) 97 | end) 98 | 99 | it("changes the screen of the workspace", function() 100 | local screens = {FakeScreen:new(1), FakeScreen:new(2)} 101 | local sl = screenlayout:new(screens) 102 | 103 | -- TODO: ws needs to be added to sl already 104 | local ws = FakeWorkspace:new(screens[1]) 105 | sl:setWorkspaceForScreen(screens[2], ws) 106 | assert.are.equal(screens[2], ws:screen()) 107 | end) 108 | 109 | it("throws an error if the screen is not recognized", function() 110 | local screen = FakeScreen:new(1) 111 | local otherScreen = FakeScreen:new(2) 112 | local sl = screenlayout:new({screen}) 113 | 114 | assert.has_error(function() 115 | sl:setWorkspaceForScreen(otherScreen, nil) 116 | end) 117 | end) 118 | end) 119 | 120 | describe("addWindow", function() 121 | it("adds the window to the selected workspace", function() 122 | local sl = screenlayout:new({FakeScreen:new(1)}) 123 | 124 | local window = FakeWindow:new(1, screen) 125 | sl:addWindow(window) 126 | assert.is.in_array(sl:workspaces()[1]:allWindows(), window) 127 | end) 128 | end) 129 | 130 | describe("removeWindow", function() 131 | it("removes the window from the correct workspace when active", function() 132 | local screen = FakeScreen:new(1) 133 | local sl = screenlayout:new({screen}) 134 | local ws = sl:workspaces()[1] 135 | 136 | local window = FakeWindow:new(1, screen) 137 | sl:addWindow(window) 138 | sl:removeWindow(window) 139 | 140 | assert.is_not.in_array(ws:allWindows(), window) 141 | end) 142 | 143 | it("removes the window from the correct workspace when not active", function() 144 | local screen = FakeScreen:new(1) 145 | local sl = screenlayout:new({screen}) 146 | local ws = sl:workspaces()[1] 147 | 148 | local window = FakeWindow:new(1, screen) 149 | sl:addWindow(window) 150 | sl:setWorkspaceForScreen(screen, nil) 151 | sl:removeWindow(window) 152 | 153 | assert.is_not.in_array(ws:allWindows(), window) 154 | end) 155 | 156 | it("does nothing if the window is not recognized", function() 157 | local screen = FakeScreen:new(1) 158 | local sl = screenlayout:new({screen}) 159 | 160 | local window = FakeWindow:new(1, screen) 161 | assert.has_no.errors(function() 162 | sl:removeWindow(window) 163 | end) 164 | end) 165 | end) 166 | 167 | describe("selectScreen", function() 168 | it("selects the right screen", function() 169 | local screens = {FakeScreen:new(1), FakeScreen:new(2)} 170 | local sl = screenlayout:new(screens) 171 | 172 | sl:selectScreen(screens[1]) 173 | assert.are.equal(screens[1], sl:selectedScreen()) 174 | sl:selectScreen(screens[2]) 175 | assert.are.equal(screens[2], sl:selectedScreen()) 176 | end) 177 | 178 | it("selects the right workspace", function() 179 | local screens = {FakeScreen:new(1), FakeScreen:new(2)} 180 | local sl = screenlayout:new(screens) 181 | 182 | sl:selectScreen(screens[1]) 183 | assert.are.equal(screens[1], sl:selectedWorkspace():screen()) 184 | sl:selectScreen(screens[2]) 185 | assert.are.equal(screens[2], sl:selectedWorkspace():screen()) 186 | end) 187 | 188 | it("throws an error if the screen is not recognized", function() 189 | local screen = FakeScreen:new(1) 190 | local otherScreen = FakeScreen:new(2) 191 | local sl = screenlayout:new({screen}) 192 | 193 | assert.has_error(function() 194 | sl:selectScreen(otherScreen) 195 | end) 196 | end) 197 | end) 198 | 199 | describe("selectWindow", function() 200 | it("selects the screen that corresponds to the window's workspace", function() 201 | local screens = {FakeScreen:new(1), FakeScreen:new(2)} 202 | local sl = screenlayout:new(screens) 203 | local window = FakeWindow:new(1, screens[2]) 204 | 205 | sl:selectScreen(screens[2]) 206 | sl:addWindow(window) -- will be added to screen 2 207 | sl:selectScreen(screens[1]) 208 | sl:selectWindow(window) 209 | 210 | assert.are.equal(screens[2], sl:selectedWorkspace():screen()) 211 | end) 212 | 213 | it("selects the window in the workspace", function() 214 | local screen = FakeScreen:new(1) 215 | local sl = screenlayout:new({screen}) 216 | local window1 = FakeWindow:new(1, screen) 217 | local window2 = FakeWindow:new(2, screen) 218 | 219 | sl:addWindow(window1) 220 | sl:addWindow(window2) 221 | 222 | sl:selectWindow(window1) 223 | assert.equals(window1, sl:selectedWorkspace().selection) 224 | sl:selectWindow(window2) 225 | assert.equals(window2, sl:selectedWorkspace().selection) 226 | end) 227 | 228 | it("does nothing if the window is not in a workspace", function() 229 | local screens = {FakeScreen:new(1), FakeScreen:new(2)} 230 | local sl = screenlayout:new(screens) 231 | local window = FakeWindow:new(1, screens[2]) 232 | 233 | assert.has_no_errors(function() 234 | sl:selectWindow(window) 235 | end) 236 | assert.equals(screens[1], sl:selectedScreen()) 237 | end) 238 | end) 239 | end) 240 | -------------------------------------------------------------------------------- /spec/support/FakeOS.lua: -------------------------------------------------------------------------------- 1 | local FakeOS = {} 2 | 3 | local os = require 'wm.os' 4 | 5 | function FakeOS.setup() 6 | os.uiEvents.applicationActivated = "AXApplicationActivated" 7 | os.uiEvents.applicationDeactivated = "AXApplicationDeactivated" 8 | os.uiEvents.applicationHidden = "AXApplicationHidden" 9 | os.uiEvents.applicationShown = "AXApplicationShown" 10 | 11 | os.uiEvents.mainWindowChanged = "AXMainWindowChanged" 12 | os.uiEvents.focusedWindowChanged = "AXFocusedWindowChanged" 13 | os.uiEvents.focusedElementChanged = "AXFocusedUIElementChanged" 14 | 15 | os.uiEvents.windowCreated = "AXWindowCreated" 16 | os.uiEvents.windowMoved = "AXWindowMoved" 17 | os.uiEvents.windowResized = "AXWindowResized" 18 | os.uiEvents.windowMinimized = "AXWindowMiniaturized" 19 | os.uiEvents.windowUnminimized = "AXWindowDeminiaturized" 20 | 21 | os.uiEvents.elementDestroyed = "AXUIElementDestroyed" 22 | os.uiEvents.titleChanged = "AXTitleChanged" 23 | end 24 | 25 | return FakeOS 26 | -------------------------------------------------------------------------------- /spec/support/FakeScreen.lua: -------------------------------------------------------------------------------- 1 | local FakeScreen = {} 2 | 3 | function FakeScreen:new(id) 4 | id = id or 1 5 | local obj = {id=id} 6 | setmetatable(obj, self) 7 | return obj 8 | end 9 | 10 | function FakeScreen:id() 11 | return self.id 12 | end 13 | 14 | FakeScreen.__index = FakeScreen 15 | FakeScreen.__eq = function(a, b) return a.id == b.id end 16 | 17 | return FakeScreen 18 | -------------------------------------------------------------------------------- /spec/support/FakeWindow.lua: -------------------------------------------------------------------------------- 1 | local FakeWindow = {} 2 | 3 | function FakeWindow:new(id, screen) 4 | local obj = { 5 | attrs = { 6 | id = id, 7 | screen = screen, 8 | title = "my window", 9 | visible = true, 10 | standard = true 11 | } 12 | } 13 | setmetatable(obj, self) 14 | return obj 15 | end 16 | 17 | function FakeWindow.makeWindows(n, screen) 18 | local ret = {} 19 | for i = 1,n do 20 | table.insert(ret, FakeWindow:new(i, screen)) 21 | end 22 | return ret 23 | end 24 | 25 | function FakeWindow:id() 26 | return self.attrs.id 27 | end 28 | 29 | function FakeWindow:screen() 30 | return self.attrs.screen 31 | end 32 | 33 | function FakeWindow:title() 34 | return self.attrs.title 35 | end 36 | 37 | function FakeWindow:isVisible() 38 | return self.attrs.visible 39 | end 40 | 41 | function FakeWindow:isStandard() 42 | return self.attrs.standard 43 | end 44 | 45 | FakeWindow.__index = FakeWindow 46 | FakeWindow.__eq = function(a, b) return a.attrs.id == b.attrs.id end 47 | 48 | return FakeWindow 49 | -------------------------------------------------------------------------------- /spec/support/FakeWindowTracker.lua: -------------------------------------------------------------------------------- 1 | local FakeWindowTracker = {} 2 | 3 | function FakeWindowTracker:new(watchEvents, handler) 4 | obj = { 5 | watchEvents = watchEvents, 6 | handler = handler, 7 | started = false 8 | } 9 | 10 | setmetatable(obj, self) 11 | return obj 12 | end 13 | 14 | function FakeWindowTracker:start() 15 | self.started = true 16 | end 17 | 18 | function FakeWindowTracker:stop() 19 | self.started = false 20 | end 21 | 22 | local function contains(t, el) 23 | for k, v in pairs(t) do 24 | if v == el then 25 | return true 26 | end 27 | end 28 | return false 29 | end 30 | 31 | -- To be called in test 32 | function FakeWindowTracker:postEvent(window, event) 33 | if self.started and contains(self.watchEvents, event) then 34 | self.handler(window, event) 35 | end 36 | end 37 | 38 | FakeWindowTracker.__index = FakeWindowTracker 39 | 40 | return FakeWindowTracker 41 | -------------------------------------------------------------------------------- /spec/support/FakeWorkspace.lua: -------------------------------------------------------------------------------- 1 | local FakeWorkspace = {} 2 | 3 | -- windows is an optional parameter for testing purposes 4 | function FakeWorkspace:new(screen, windows) 5 | windows = windows or {} 6 | local obj = {_screen=screen, _windows=windows} 7 | setmetatable(obj, {__index = self}) 8 | return obj 9 | end 10 | 11 | function FakeWorkspace:allWindows() 12 | return self._windows 13 | end 14 | 15 | function FakeWorkspace:isEmpty() 16 | return #self._windows == 0 17 | end 18 | 19 | function FakeWorkspace:addWindow(window) 20 | table.insert(self._windows, window) 21 | end 22 | 23 | function FakeWorkspace:removeWindowById(id) 24 | for i, win in pairs(self._windows) do 25 | if win:id() == id then 26 | table.remove(self._windows, i) 27 | return true 28 | end 29 | end 30 | return false 31 | end 32 | 33 | function FakeWorkspace:setScreen(screen) 34 | self._screen = screen 35 | end 36 | 37 | function FakeWorkspace:screen() 38 | return self._screen 39 | end 40 | 41 | function FakeWorkspace:selectWindow(window) 42 | self.selection = window 43 | end 44 | 45 | function FakeWorkspace:toggleFloating() 46 | end 47 | 48 | function FakeWorkspace:toggleFocusMode() 49 | end 50 | 51 | return FakeWorkspace 52 | -------------------------------------------------------------------------------- /spec/support/spec_helper.lua: -------------------------------------------------------------------------------- 1 | local assert = require 'luassert.assert' 2 | local say = require 'say' 3 | 4 | local function in_array(state, arguments) 5 | if not type(arguments[1]) == "table" or #arguments ~= 2 then 6 | return false 7 | end 8 | 9 | for k, v in pairs(arguments[1]) do 10 | if v == arguments[2] then 11 | return true 12 | end 13 | end 14 | return false 15 | end 16 | 17 | say:set("assertion.in_array.positive", "Expected %s \nto have value: %s") 18 | say:set("assertion.in_array.negative", "Expected %s \nto not have value: %s") 19 | assert:register("assertion", "in_array", in_array, "assertion.in_array.positive", "assertion.in_array.negative") 20 | -------------------------------------------------------------------------------- /spec/windowregistry_spec.lua: -------------------------------------------------------------------------------- 1 | require 'spec.support.spec_helper' 2 | 3 | local windowregistry = require 'wm.windowregistry' 4 | 5 | local FakeScreen = require 'spec.support.FakeScreen' 6 | local FakeWorkspace = require 'spec.support.FakeWorkspace' 7 | local FakeWindow = require 'spec.support.FakeWindow' 8 | 9 | describe("windowregistry", function() 10 | local windowRegistry 11 | before_each(function() 12 | windowRegistry = windowregistry:new() 13 | end) 14 | 15 | describe("putWindowInWorkspace", function() 16 | it("adds a window to the given workspace", function() 17 | local ws = FakeWorkspace:new(FakeScreen:new()) 18 | local win = FakeWindow:new(1, ws:screen()) 19 | 20 | windowRegistry:putWindowInWorkspace(win, ws) 21 | 22 | assert.is.in_array(ws:allWindows(), win) 23 | end) 24 | 25 | it("removes the window from workspace it's currently in", function() 26 | local screen = FakeScreen:new() 27 | local ws1 = FakeWorkspace:new(screen) 28 | local ws2 = FakeWorkspace:new(screen) 29 | local win = FakeWindow:new(1, screen) 30 | 31 | windowRegistry:putWindowInWorkspace(win, ws1) 32 | windowRegistry:putWindowInWorkspace(win, ws2) 33 | 34 | assert.is.in_array(ws2:allWindows(), win) 35 | assert.is_not.in_array(ws1:allWindows(), win) 36 | end) 37 | end) 38 | 39 | describe("removeWindow", function() 40 | it("removes a window from the correct workspace", function() 41 | local ws = FakeWorkspace:new(FakeScreen:new()) 42 | local win = FakeWindow:new(1, ws:screen()) 43 | 44 | windowRegistry:putWindowInWorkspace(win, ws) 45 | windowRegistry:removeWindow(win) 46 | 47 | assert.is_not.in_array(ws:allWindows(), win) 48 | end) 49 | 50 | it("does nothing if the window is not in a workspace", function() 51 | local win = FakeWindow:new(1, FakeScreen:new()) 52 | 53 | assert.has_no.errors(function() 54 | windowRegistry:removeWindow(win) 55 | end) 56 | end) 57 | end) 58 | 59 | describe("getWorkspaceForWindow", function() 60 | it("returns the workspace for a window that has been added", function() 61 | local ws = FakeWorkspace:new(FakeScreen:new()) 62 | local win = FakeWindow:new(1, ws:screen()) 63 | 64 | windowRegistry:putWindowInWorkspace(win, ws) 65 | 66 | assert.equals(ws, windowRegistry:getWorkspaceForWindow(win)) 67 | end) 68 | 69 | it("returns nil for a window that has never been added", function() 70 | local win = FakeWindow:new(1, FakeScreen:new()) 71 | 72 | assert.equals(nil, windowRegistry:getWorkspaceForWindow(win)) 73 | end) 74 | 75 | it("returns nil for a window that has been added and then removed", function() 76 | local ws = FakeWorkspace:new(FakeScreen:new()) 77 | local win = FakeWindow:new(1, ws:screen()) 78 | 79 | windowRegistry:putWindowInWorkspace(win, ws) 80 | windowRegistry:removeWindow(win) 81 | 82 | assert.equals(nil, windowRegistry:getWorkspaceForWindow(win)) 83 | end) 84 | 85 | it("accepts nil as an argument and returns nil", function() 86 | local ret 87 | assert.has_no.errors(function() 88 | ret = windowRegistry:getWorkspaceForWindow(nil) 89 | end) 90 | assert.equals(nil, ret) 91 | end) 92 | end) 93 | end) 94 | -------------------------------------------------------------------------------- /spec/workspacefinder_spec.lua: -------------------------------------------------------------------------------- 1 | local workspacefinder = require 'wm.workspacefinder' 2 | 3 | local FakeScreen = require 'spec.support.FakeScreen' 4 | local FakeWorkspace = require 'spec.support.FakeWorkspace' 5 | local FakeWindow = require 'spec.support.FakeWindow' 6 | 7 | describe("workspacefinder", function() 8 | describe("find", function() 9 | it("returns a screenInfos object for each screen", function() 10 | local screens = {FakeScreen:new(1), FakeScreen:new(2)} 11 | local screenInfos = workspacefinder.find({}, screens, {}) 12 | 13 | assert.are.equal(#screenInfos, 2) 14 | assert.are.equal(screenInfos[1].screen, screens[1]) 15 | assert.are.equal(screenInfos[2].screen, screens[2]) 16 | end) 17 | 18 | it("passes a single screenInfos object for one screen", function() 19 | local screens = {FakeScreen:new(1)} 20 | local screenInfos = workspacefinder.find({}, screens, {}) 21 | 22 | assert.are.equal(#screenInfos, 1) 23 | assert.are.equal(screenInfos[1].screen, screens[1]) 24 | end) 25 | 26 | it("matches existing workspaces with matching windows", function() 27 | local screens = {FakeScreen:new(1)} 28 | local windows = FakeWindow.makeWindows(2, screens[1]) 29 | local workspaces = {FakeWorkspace:new(screens[1], windows)} 30 | local screenInfos = workspacefinder.find(workspaces, screens, windows) 31 | 32 | assert.are.equal(screenInfos[1].workspace, workspaces[1]) 33 | end) 34 | 35 | it("doesn't match existing workspaces with no matching windows", function() 36 | local screens = {FakeScreen:new(1)} 37 | local windows = FakeWindow.makeWindows(2, screens[1]) 38 | local workspaces = {FakeWorkspace:new(screens[1], {})} 39 | local screenInfos = workspacefinder.find(workspaces, screens, windows) 40 | 41 | assert.are.equal(screenInfos[1].workspace, nil) 42 | end) 43 | 44 | it("doesn't match existing workspaces with fewer than half of known windows", function() 45 | local screens = {FakeScreen:new(1)} 46 | local windows = FakeWindow.makeWindows(3, screens[1]) 47 | local workspaces = {FakeWorkspace:new(screens[1], windows)} 48 | local screenInfos = workspacefinder.find(workspaces, screens, {windows[1]}) 49 | 50 | assert.are.equal(screenInfos[1].workspace, nil) 51 | end) 52 | 53 | it("picks the best match when multiple workspaces partially match", function() 54 | local screens = {FakeScreen:new(1)} 55 | local windows = FakeWindow.makeWindows(3, screens[1]) 56 | local workspaces = { 57 | FakeWorkspace:new(screens[1], {windows[1]}), 58 | FakeWorkspace:new(screens[1], {windows[2], windows[3]}) 59 | } 60 | local screenInfos = workspacefinder.find(workspaces, screens, windows) 61 | 62 | assert.are.equal(screenInfos[1].workspace, workspaces[2]) 63 | end) 64 | 65 | it("handles windows which return nil for :screen()", function() 66 | -- This is a problem during screen layout changes. The actual behavior doesn't matter, because 67 | -- everything will be redone when the layout change is complete, but workspacefinder must not 68 | -- raise an error. 69 | 70 | local screens = {FakeScreen:new(1)} 71 | local windows = {FakeWindow:new(1, nil)} 72 | local workspaces = { FakeWorkspace:new(screens[1], {windows[1]}) } 73 | 74 | assert.has_no.errors(function() 75 | local screenInfos = workspacefinder.find(workspaces, screens, windows) 76 | end) 77 | end) 78 | end) 79 | end) 80 | -------------------------------------------------------------------------------- /wm/controller.lua: -------------------------------------------------------------------------------- 1 | local controller = {} 2 | 3 | local fnutils = require 'wm.fnutils' 4 | local os = require 'wm.os' 5 | local screenlayout = require 'wm.screenlayout' 6 | local windowtracker = require 'wm.windowtracker' 7 | local workspacefinder = require 'wm.workspacefinder' 8 | 9 | function controller:new() 10 | local obj = {} 11 | setmetatable(obj, {__index = self}) 12 | 13 | -- The root of our WM tree; all commands are routed through here. 14 | obj.screenLayout = screenlayout:new(os.allScreens()) 15 | 16 | -- Tracks events on windows. 17 | obj.windowTracker = windowtracker:new( 18 | {windowtracker.windowCreated, windowtracker.windowDestroyed, windowtracker.mainWindowChanged}, 19 | function(...) obj:_handleWindowEvent(...) end) 20 | obj.windowTracker:start() 21 | 22 | -- Tracks space changes. 23 | obj.spaceWatcher = hs.spaces.watcher.new(function() obj:_handleSpaceChange() end) 24 | obj.spaceWatcher:start() 25 | 26 | -- Tracks screen layout changes. 27 | obj.screenWatcher = hs.screen.watcher.new(function(...) obj:_handleScreenLayoutChange(...) end) 28 | obj.screenWatcher:start() 29 | 30 | return obj 31 | end 32 | 33 | function controller:_handleWindowEvent(win, event) 34 | print(event.." on win "..(win and win:title() or "NIL WINDOW")) 35 | 36 | local e = os.uiEvents 37 | if e.windowCreated == event then 38 | self.screenLayout:addWindow(win) 39 | elseif e.elementDestroyed == event then 40 | self.screenLayout:removeWindow(win) 41 | elseif e.mainWindowChanged == event then 42 | self.screenLayout:selectWindow(win) 43 | end 44 | end 45 | 46 | function controller:_handleSpaceChange() 47 | -- Get the workspace on each screen and update the screenLayout. 48 | local screenInfos = 49 | workspacefinder.find(self.screenLayout:workspaces(), os.allScreens(), os.allVisibleWindows()) 50 | fnutils.each(screenInfos, function(info) 51 | self.screenLayout:setWorkspaceForScreen(info.screen, info.workspace) 52 | end) 53 | 54 | -- Use the OS behavior to determine which screen should be focused. Default to the last focused screen. 55 | -- The workspace selection will be updated by a later window event. 56 | local focusedWindow = os.focusedWindow() 57 | if focusedWindow then 58 | self.screenLayout:selectScreen(focusedWindow:screen()) 59 | end 60 | end 61 | 62 | function controller:_handleScreenLayoutChange() 63 | -- Prep screenLayout with the new set of screens. 64 | self.screenLayout:updateScreenLayout(os.allScreens()) 65 | 66 | -- After that, handle as if it were a space change. 67 | self:_handleSpaceChange() 68 | end 69 | 70 | return controller 71 | -------------------------------------------------------------------------------- /wm/floatinglayout.lua: -------------------------------------------------------------------------------- 1 | -- floatinglayout is a layer of the screen layout that has no tiling or tree-based structure; it is 2 | -- simply a collection of windows that can overlap and be moved and resized. 3 | 4 | local utils = require 'wm.utils' 5 | 6 | local floatinglayout = {} 7 | 8 | local direction = utils.direction 9 | local orientation = utils.orientation 10 | local incrementForDirection = utils.incrementForDirection 11 | local orientationForDirection = utils.orientationForDirection 12 | 13 | function floatinglayout:new() 14 | local obj = { 15 | windows = {}, 16 | selection = nil 17 | } 18 | 19 | setmetatable(obj, {__index = self}) 20 | return obj 21 | end 22 | 23 | function floatinglayout:addWindow(win) 24 | table.insert(self.windows, win) 25 | if not self.selection then self.selection = win end 26 | end 27 | 28 | function floatinglayout:allWindows() 29 | return self.windows 30 | end 31 | 32 | function floatinglayout:selectWindow(win) 33 | if hs.fnutils.contains(self.windows, win) then 34 | self.selection = win 35 | return true 36 | end 37 | return false 38 | end 39 | 40 | function floatinglayout:focusSelection() 41 | if self.selection then 42 | self.selection:focus() 43 | return true 44 | end 45 | return false 46 | end 47 | 48 | function floatinglayout:bringToFrontAndFocusSelection() 49 | -- Focus selection first (for user visual identification) 50 | if self.selection then self.selection:focus() end 51 | -- Focus other windows 52 | hs.fnutils.each(self.windows, function(win) 53 | if win ~= self.selection then 54 | win:focus() 55 | end 56 | end) 57 | -- Focus selection last (for final focus) 58 | if self.selection then self.selection:focus() end 59 | end 60 | 61 | function floatinglayout:removeSelectedWindows() 62 | local win = self.selection 63 | local idx = hs.fnutils.indexOf(self.windows, win) 64 | table.remove(self.windows, idx) 65 | return {win} 66 | end 67 | 68 | function floatinglayout:removeWindowById(id) 69 | local idx = utils.findIdx(self.windows, function(w) return w:id() == id end) 70 | if idx then 71 | table.remove(self.windows, idx) 72 | return true 73 | else 74 | return false 75 | end 76 | end 77 | 78 | function floatinglayout:isEmpty() 79 | return #self.windows == 0 80 | end 81 | 82 | function floatinglayout:isTiling() 83 | return false 84 | end 85 | 86 | function floatinglayout:move(dir) 87 | if not self.selection then return end 88 | 89 | local topLeft = self.selection:topLeft() 90 | local increment = incrementForDirection(dir) 91 | local o = orientationForDirection(dir) 92 | 93 | if o == orientation.horizontal then 94 | topLeft.x = topLeft.x + increment * 10 95 | elseif o == orientation.vertical then 96 | topLeft.y = topLeft.y + increment * 10 97 | end 98 | 99 | self.selection:setTopLeft(topLeft) 100 | end 101 | 102 | function floatinglayout:focus(dir) 103 | local o = orientationForDirection(dir) 104 | local function midpoint(rect) 105 | if o == orientation.horizontal then 106 | return (rect.x + rect.w)/2 107 | else 108 | return (rect.y + rect.h)/2 109 | end 110 | end 111 | 112 | -- Find closest window in specified direction. 113 | local increment = incrementForDirection(dir) 114 | local start = midpoint(self.selection:frame()) 115 | local best, bestDistance 116 | for i, win in pairs(self.windows) do 117 | local point = midpoint(win:frame()) 118 | local diff = point - start 119 | if (increment > 0 and diff > 0) or (increment < 0 and diff < 0) then 120 | local distance = math.abs(diff) 121 | if not bestMidpoint or distance < bestDistance then 122 | best = win 123 | bestDistance = distance 124 | end 125 | end 126 | end 127 | 128 | if best then 129 | best:focus() 130 | end 131 | end 132 | 133 | function floatinglayout:resize(dir, pct) 134 | if not self.selection then return end 135 | 136 | local screenFrame = self.selection:screen():frame() 137 | local frame = self.selection:frame() 138 | 139 | if dir == direction.up then 140 | frame.y = frame.y - pct*screenFrame.h 141 | frame.h = frame.h + pct*screenFrame.h 142 | elseif dir == direction.down then 143 | frame.h = frame.h + pct*screenFrame.h 144 | elseif dir == direction.left then 145 | frame.x = frame.x - pct*screenFrame.w 146 | frame.w = frame.w + pct*screenFrame.w 147 | elseif dir == direction.right then 148 | frame.w = frame.w + pct*screenFrame.w 149 | end 150 | 151 | self.selection:setFrame(frame, 0) 152 | end 153 | 154 | return floatinglayout 155 | -------------------------------------------------------------------------------- /wm/fnutils.lua: -------------------------------------------------------------------------------- 1 | --- === hs.fnutils === 2 | --- 3 | --- Super-helpful functional programming utilities. 4 | 5 | local fnutils = {} 6 | 7 | 8 | --- hs.fnutils.map(t, fn) -> t 9 | --- Function 10 | --- Returns a table of the results of fn(el) on every el in t. 11 | function fnutils.map(t, fn) 12 | local nt = {} 13 | for k, v in pairs(t) do 14 | table.insert(nt, fn(v) or nil) 15 | end 16 | return nt 17 | end 18 | 19 | --- hs.fnutils.each(t, fn) -> t 20 | --- Function 21 | --- Runs fn(el) for every el in t. 22 | function fnutils.each(t, fn) 23 | for k, v in pairs(t) do 24 | fn(v) 25 | end 26 | end 27 | 28 | --- hs.fnutils.filter(t, fn) -> t 29 | --- Function 30 | --- Returns a table of the elements in t in which fn(el) is truthy. 31 | function fnutils.filter(t, fn) 32 | local nt = {} 33 | for k, v in pairs(t) do 34 | if fn(v) then table.insert(nt, v) end 35 | end 36 | return nt 37 | end 38 | 39 | --- hs.fnutils.copy(t) -> t2 40 | --- Function 41 | --- Returns a new copy of t using pairs(t). 42 | function fnutils.copy(t) 43 | local nt = {} 44 | for k, v in pairs(t) do 45 | nt[k] = v 46 | end 47 | return nt 48 | end 49 | 50 | --- hs.fnutils.contains(t, el) -> bool 51 | --- Function 52 | --- Returns whether the table contains the given element. 53 | function fnutils.contains(t, el) 54 | for k, v in pairs(t) do 55 | if v == el then 56 | return true 57 | end 58 | end 59 | return false 60 | end 61 | 62 | --- hs.fnutils.indexOf(t, el) -> int or nil 63 | --- Function 64 | --- Returns the index of a given element in a table, or nil if not found. 65 | function fnutils.indexOf(t, el) 66 | for k, v in pairs(t) do 67 | if v == el then 68 | return k 69 | end 70 | end 71 | return nil 72 | end 73 | 74 | --- hs.fnutils.concat(t1, t2) 75 | --- Function 76 | --- Adds all elements of t2 to the end of t1. 77 | function fnutils.concat(t1, t2) 78 | for i = 1, #t2 do 79 | t1[#t1 + 1] = t2[i] 80 | end 81 | return t1 82 | end 83 | 84 | --- hs.fnutils.mapCat(t, fn) -> t2 85 | --- Function 86 | --- Runs fn(el) for every el in t, and assuming the results are tables, combines them into a new table. 87 | function fnutils.mapCat(t, fn) 88 | local nt = {} 89 | for k, v in pairs(t) do 90 | fnutils.concat(nt, fn(v)) 91 | end 92 | return nt 93 | end 94 | 95 | --- hs.fnutils.reduce(t, fn) -> t2 96 | --- Function 97 | --- Runs fn(el1, el2) for every el in t, then fn(result, el3), etc, until there's only one left. 98 | function fnutils.reduce(t, fn) 99 | local len = #t 100 | if len == 0 then return nil end 101 | if len == 1 then return t[1] end 102 | 103 | local result = t[1] 104 | for i = 2, #t do 105 | result = fn(result, t[i]) 106 | end 107 | return result 108 | end 109 | 110 | --- hs.fnutils.find(t, fn) -> el 111 | --- Function 112 | --- Returns the first element where fn(el) is truthy. 113 | function fnutils.find(t, fn) 114 | for _, v in pairs(t) do 115 | if fn(v) then return v end 116 | end 117 | return nil 118 | end 119 | 120 | --- hs.fnutils.sequence(...) -> fn 121 | --- Function 122 | --- Returns a list of the results of the passed functions. 123 | function fnutils.sequence(...) 124 | local arg = table.pack(...) 125 | return function() 126 | local results = {} 127 | for _, fn in ipairs(arg) do 128 | table.insert(results, fn()) 129 | end 130 | return results 131 | end 132 | end 133 | 134 | --- hs.fnutils.partial(fn, ...) -> fn' 135 | --- Function 136 | --- Returns fn partially applied to arg (...). 137 | function fnutils.partial(fn, ...) 138 | local args = table.pack(...) 139 | return function(...) 140 | for idx, val in ipairs(table.pack(...)) do 141 | args[args.n + idx] = val 142 | end 143 | return fn(table.unpack(args)) 144 | end 145 | end 146 | 147 | --- hs.fnutils.cycle(t) -> fn() -> t[n] 148 | --- Function 149 | --- Returns a function that returns t[1], t[2], ... t[#t], t[1], ... on successive calls. 150 | --- Example: 151 | --- f = cycle({4, 5, 6}) 152 | --- {f(), f(), f(), f(), f(), f(), f()} == {4, 5, 6, 4, 5, 6, 4} 153 | function fnutils.cycle(t) 154 | local i = 1 155 | return function() 156 | local x = t[i] 157 | i = i % #t + 1 158 | return x 159 | end 160 | end 161 | 162 | return fnutils 163 | -------------------------------------------------------------------------------- /wm/init.lua: -------------------------------------------------------------------------------- 1 | local wm = {} 2 | 3 | require('wm.os').setup() 4 | 5 | local controller = require('wm.controller') 6 | 7 | wm.controller = controller:new() 8 | 9 | return wm 10 | -------------------------------------------------------------------------------- /wm/layout.lua: -------------------------------------------------------------------------------- 1 | -- layout is the tree-based layout of a screen. This is where all the focus and movement commands 2 | -- are implemented, except moving windows across screens. 3 | -- 4 | -- layout publishes two events: 5 | -- onFocusPastEnd(layout, direction) and onMovePastEnd(layout, node, direction) 6 | -- To use them, set the corresponding attribute on the layout root to your handler function. 7 | -- 8 | -- layout is a recursive data structure. The root node (created with :new()) has one child which is 9 | -- the actual top level of the layout tree. This top level node can change. 10 | 11 | local fnutils = require 'wm.fnutils' 12 | local utils = require 'wm.utils' 13 | 14 | local layout = {} 15 | 16 | local direction = utils.direction 17 | local orientation = utils.orientation 18 | local incrementForDirection = utils.incrementForDirection 19 | local orientationForDirection = utils.orientationForDirection 20 | 21 | local mode = { 22 | default = 0, stacked = 1, tabbed = 2 23 | } 24 | layout.mode = mode 25 | 26 | local function orientationForDirection(d) 27 | if d == direction.left or d == direction.right then 28 | return orientation.horizontal 29 | else 30 | return orientation.vertical 31 | end 32 | end 33 | 34 | local function incrementForDirection(d) 35 | if d == direction.left or d == direction.up then 36 | return -1 37 | else 38 | return 1 39 | end 40 | end 41 | 42 | function layout:_new() 43 | local obj = { 44 | root = nil, 45 | parent = nil, 46 | children = {}, 47 | frame = nil, 48 | size = 1.0, 49 | mode = mode.default, 50 | 51 | window = nil, -- bottom level only 52 | fullscreen = false, 53 | 54 | orientation = orientation.horizontal, 55 | selection = nil, 56 | previousSelection = nil 57 | } 58 | 59 | setmetatable(obj, {__index = self, __tostring = layout.__tostring}) 60 | return obj 61 | end 62 | 63 | -- Creates a new root layout node. 64 | function layout:new(screen) 65 | -- The root is tied to a screen and is never replaced. 66 | local root = layout:_new() 67 | root.screen = screen 68 | root.selectedParent = nil -- used to select parent nodes instead of bottom-level nodes 69 | root.fullscreenNode = nil 70 | root.orientation = nil 71 | root.root = root 72 | root.frame = screen:frame() 73 | 74 | -- The top level node is where the actual layout tree begins. 75 | -- It may be replaced, but one will always exist. 76 | local topLevel = layout:_new() 77 | topLevel.root = root 78 | topLevel.parent = root 79 | topLevel.frame = screen:frame() 80 | 81 | root.children = {topLevel} 82 | root.selection = topLevel 83 | return root 84 | end 85 | 86 | function layout:_newChild(parent) 87 | local obj = layout:_new() 88 | obj.parent = parent 89 | obj.root = parent.root 90 | return obj 91 | end 92 | 93 | function layout:_newParent(child) 94 | local parent = layout:_new() 95 | parent.root = child.root 96 | parent.parent = child.parent 97 | parent.children = {child} 98 | parent.selection = child 99 | parent.frame = child.frame 100 | parent.size = child.size 101 | 102 | local grandparent = parent.parent or parent.root 103 | local idx = fnutils.indexOf(grandparent.children, child) 104 | table.remove(grandparent.children, idx) 105 | table.insert(grandparent.children, idx, parent) 106 | if grandparent.selection == child then grandparent.selection = parent end 107 | 108 | child.parent = parent 109 | child.size = 1.0 110 | 111 | return parent 112 | end 113 | 114 | function layout:setDirection(dir) 115 | self.orientation = dir 116 | self:_update(self.frame) 117 | end 118 | 119 | function layout:addWindow(win) 120 | -- Only called on root 121 | self.children[1]:_addWindow(win, nil) 122 | self:focusSelection() 123 | end 124 | 125 | -- Adds a node to this layout that is moving in the given direction. 126 | -- For example, if the node is moving to the right (from somewhere on the left), pass right as the 127 | -- direction and the node will be added to the left side of the layout. 128 | function layout:addNodeGoingInDirection(node, direction) 129 | local topLevel = self.children[1] 130 | local idx 131 | if incrementForDirection(direction) > 0 then 132 | idx = 1 133 | else 134 | idx = #topLevel.children + 1 135 | end 136 | node:_foreachNode(function(node) node.root = self.root end) 137 | topLevel:_addNode(node, idx) 138 | end 139 | 140 | -- Selects a window, coming into this layout from the given direction. 141 | function layout:selectWindowGoingInDirection(direction) 142 | if self.root.fullscreenNode then 143 | -- Keep fullscreen node selected 144 | return 145 | end 146 | self.root.selectParent = nil 147 | 148 | if orientationForDirection(direction) == self.orientation then 149 | if incrementForDirection(direction) > 0 then 150 | self:_setSelection(self.children[1]) 151 | else 152 | self:_setSelection(self.children[#self.children]) 153 | end 154 | end -- else, keep current selection 155 | 156 | if self.selection then 157 | self.selection:selectWindowGoingInDirection(direction) 158 | end 159 | end 160 | 161 | -- Toggles the fullscreen state of the selected node. If another node is fullscreen, the selected 162 | -- node will replace that node. 163 | function layout:toggleFullscreen() 164 | local oldNode = self.root.fullscreenNode 165 | if oldNode then 166 | self.root.fullscreenNode = nil 167 | oldNode:update() 168 | end 169 | local selection = self:_getSelectedNode() 170 | if selection ~= oldNode then 171 | self.root.fullscreenNode = selection 172 | selection:update() 173 | end 174 | end 175 | 176 | function layout:splitCurrent(orientation) 177 | self:_getSelectedNode():_split(orientation) 178 | end 179 | 180 | function layout:selectParent() 181 | self.selectedParent = self:_getSelectedNode().parent 182 | if self.selectParent == self.root then 183 | -- Don't allow selecting root node 184 | self.selectParent = self.root.children[1] 185 | end 186 | hs.alert.show(tostring(self.selectedParent)) 187 | end 188 | 189 | function layout:selectChild() 190 | local newSelection = self:_getSelectedNode().selection 191 | if newSelection and #newSelection.children > 0 then 192 | self.selectedParent = newSelection 193 | elseif newSelection then 194 | -- Erase the override and select it normally 195 | newSelection:_select() 196 | end 197 | self:showFocus() 198 | end 199 | 200 | function layout:showFocus() 201 | hs.alert.show(tostring(self:_getSelectedNode())) 202 | end 203 | 204 | function layout:closeSelected() 205 | local windows = self:_getSelectedNode():allWindows() 206 | fnutils.each(windows, function(win) win:close() end) 207 | end 208 | 209 | function layout:setMode(mode) 210 | self:_getSelectedNode().parent:_setMode(mode) 211 | end 212 | 213 | function layout:_setMode(newMode) 214 | if self.mode == newMode then return end 215 | 216 | if newMode == mode.default then 217 | if self.oldOrientation then 218 | self.orientation = self.oldOrientation 219 | end 220 | else 221 | if self.mode == mode.default then 222 | self.oldOrientation = self.orientation 223 | end 224 | 225 | if newMode == mode.stacked then 226 | self.orientation = orientation.vertical 227 | elseif newMode == mode.tabbed then 228 | self.orientation = orientation.horizontal 229 | end 230 | end 231 | 232 | self.mode = newMode 233 | self:update() 234 | end 235 | 236 | function layout:isEmpty() 237 | -- called on root 238 | return #self.children[1].children == 0 239 | end 240 | 241 | function layout:allWindows() 242 | if self.window then 243 | return {self.window} 244 | end 245 | 246 | local windows = {} 247 | for i, c in pairs(self.children) do 248 | fnutils.concat(windows, c:allWindows()) 249 | end 250 | return windows 251 | end 252 | 253 | function layout:allVisibleWindows() 254 | if self.window then 255 | return {self.window} 256 | end 257 | 258 | local windows = {} 259 | if self.mode == mode.default then 260 | for i, c in pairs(self.children) do 261 | fnutils.concat(windows, c:allVisibleWindows()) 262 | end 263 | else 264 | windows = self.selection:allVisibleWindows() 265 | end 266 | return windows 267 | end 268 | 269 | function layout:removeSelectedWindows() 270 | local selection = self:_getSelectedNode() 271 | selection:removeFromParent() 272 | return selection:allWindows() 273 | end 274 | 275 | -- Called on root node to bring all visible windows of the layout to the front. 276 | function layout:bringToFrontAndFocusSelection() 277 | local selection = self:_getSelectedNode(true) 278 | local windows = self:allVisibleWindows() 279 | 280 | -- Focus selection first (for user visual identification) 281 | if selection.window then selection.window:focus() end 282 | -- Focus other windows 283 | fnutils.each(windows, function(win) 284 | if win ~= selection.window then 285 | win:focus() 286 | end 287 | end) 288 | -- Focus selection last (for final focus) 289 | if selection.window then selection.window:focus() end 290 | end 291 | 292 | function layout:_foreachNode(f) 293 | f(self) 294 | for i, child in pairs(self.children) do 295 | child:_foreachNode(f) 296 | end 297 | end 298 | 299 | local function findIdx(t, f) 300 | for k, v in pairs(t) do 301 | if f(v) then return k end 302 | end 303 | return nil 304 | end 305 | 306 | -- Creates a single-child parent of this node with the given orientation. If a single-child parent 307 | -- already exists, sets its orientation. 308 | function layout:_split(orientation) 309 | if #self.parent.children == 1 then 310 | self.parent.orientation = orientation 311 | else 312 | local newParent = layout:_newParent(self) 313 | newParent.orientation = orientation 314 | end 315 | end 316 | 317 | function layout:_addWindow(win) 318 | if self:_selection() then 319 | -- Descend down selection path 320 | self:_selection():_addWindow(win) 321 | else 322 | print("adding window: "..win:title()) 323 | if self.parent ~= self.root then 324 | self.parent:_addWindowToNode(win) 325 | else 326 | -- top-level 327 | self:_addWindowToNode(win) 328 | end 329 | end 330 | end 331 | 332 | function layout:_addWindowToNode(win) 333 | local child = layout:_newChild(self) 334 | child.window = win 335 | local selectedIdx = fnutils.indexOf(self.children, self.selection) or #self.children 336 | self:_addNode(child, selectedIdx + 1) 337 | end 338 | 339 | -- Adds the given node as a child at the given index. 340 | function layout:_addNode(node, idx) 341 | if self.root.fullscreenNode then 342 | self.root.fullscreenNode = nil 343 | end 344 | 345 | table.insert(self.children, idx, node) 346 | node.parent = self 347 | self:_onChildAdded(node, idx) 348 | self:_setSelection(node) 349 | end 350 | 351 | function layout:removeWindowById(id) 352 | local result = self:_removeWindowById(id) 353 | self:focusSelection() 354 | return result 355 | end 356 | 357 | function layout:_removeWindowById(id) 358 | if self.window and self.window:id() == id then 359 | self:_remove() 360 | return true 361 | end 362 | 363 | for idx, child in pairs(self.children) do 364 | if child:_removeWindowById(id) then 365 | return true 366 | end 367 | end 368 | return false 369 | end 370 | 371 | function layout:removeFromParent() 372 | if self.parent == self.root then 373 | -- Top-level node must be replaced 374 | layout:_newParent(self) 375 | end 376 | self:_remove() 377 | end 378 | 379 | function layout:focusSelection() 380 | local sel = self:_getSelectedNode() 381 | if sel.window then 382 | sel.window:focus() 383 | return true 384 | end 385 | return false 386 | end 387 | 388 | function layout:selectWindow(win) 389 | if self.window then 390 | if win == self.window then 391 | self:_select() 392 | return true 393 | end 394 | else 395 | for i, child in pairs(self.children) do 396 | if child:selectWindow(win) then 397 | return true 398 | end 399 | end 400 | end 401 | return false 402 | end 403 | 404 | -- Moves a node from its current location to a new index in a new parent. 405 | -- The new parent can be the same as the old parent, or nil, in which case the node is removed. 406 | -- The new index should refer to an accurate location in the newParent BEFORE the call. So 407 | -- if you are moving a node to a higher index in the same parent, the effective index after the 408 | -- call will be one less than newIdx. 409 | local function _moveNode(node, newParent, newIdx) 410 | local oldParent = node.parent 411 | 412 | -- Move the node 413 | local oldIdx = fnutils.indexOf(oldParent.children, node) 414 | table.remove(oldParent.children, oldIdx) 415 | if newParent == oldParent and oldIdx < newIdx then newIdx = newIdx - 1 end 416 | if newParent then 417 | table.insert(newParent.children, newIdx, node) 418 | end 419 | if newParent == oldParent and newIdx < oldIdx then oldIdx = oldIdx + 1 end -- used for selection 420 | node.parent = newParent 421 | 422 | -- Call event callbacks 423 | if oldParent ~= newParent then 424 | oldParent:_onChildRemoved(node, oldIdx) 425 | if newParent then 426 | newParent:_onChildAdded(node, newIdx) 427 | else 428 | node.root:_onNodeRemovedFromLayout(node) 429 | end 430 | else 431 | oldParent:_onChildrenRearranged() 432 | end 433 | end 434 | 435 | function layout:_remove() 436 | if self.parent == self.root then return end -- don't delete the top-level node 437 | if self.root.fullscreenNode == self then 438 | self.root.fullscreenNode = nil 439 | end 440 | _moveNode(self, nil, nil) 441 | end 442 | 443 | function layout:_removeLink() 444 | if self.parent == self.root then return end -- don't delete the top-level node 445 | _moveNode(self.children[1], self.parent, fnutils.indexOf(self.parent.children, self)) 446 | -- _moveNode calls _remove automatically 447 | end 448 | 449 | function layout:_onChildRemoved(oldChild, oldIdx) 450 | if self.parent ~= self.root then 451 | -- Cull self if no children 452 | if #self.children == 0 then 453 | self:_remove() 454 | return 455 | end 456 | 457 | -- Cull self if only has one child container. This has no effect on window position. 458 | if #self.children == 1 and #self.children[1].children > 0 then 459 | self:_removeLink() 460 | return 461 | end 462 | end 463 | 464 | -- Fix selection 465 | if self.selection == oldChild then 466 | local defaultSelection = self.children[math.min(oldIdx, #self.children)] 467 | self:_restoreSelection(defaultSelection) 468 | end 469 | 470 | -- Fix sizes 471 | self:_rebalanceChildren(1, #self.children, 1.0, false) 472 | 473 | self:update() 474 | end 475 | 476 | function layout:_onChildAdded(newChild, newIdx) 477 | -- Give the new child the size it would have been if it was previously a child and all children 478 | -- were equally sized. After calling _rebalanceChildren it will have the size equal to 479 | -- 1/numChildren, and all windows will have shrunk proportionally to accommodate it. 480 | if #self.children > 1 then 481 | newChild.size = 1.0 / (#self.children-1) 482 | end 483 | self:_rebalanceChildren(1, #self.children, 1.0, false) 484 | 485 | self:update() 486 | end 487 | 488 | function layout:_onChildrenRearranged() 489 | self:update() 490 | end 491 | 492 | function layout:_onNodeRemovedFromLayout(oldNode) 493 | -- Called on root 494 | if self.selectParent == oldNode then 495 | self.selectParent = nil 496 | end 497 | if self.fullscreenNode == oldNode then 498 | self.fullscreenNode = nil 499 | end 500 | end 501 | 502 | -- Sets the screen of the layout and updates the frame / window sizing, if necessary. 503 | function layout:setScreen(screen) 504 | if self.root.screen ~= screen or not utils.rectEquals(self.root.frame, screen:frame()) then 505 | self.root.screen = screen 506 | -- Update internal node sizes. Could be faster if we use something that doesn't resize windows. 507 | self.root:update() 508 | end 509 | end 510 | 511 | function layout:update() 512 | if self.root == self then 513 | self:_update(self.screen:frame()) 514 | else 515 | self:_update(self.frame) 516 | end 517 | end 518 | 519 | -- Recalculates the frames of this node and its descendants, moves windows into place. 520 | function layout:_update(frame) 521 | if self.root.fullscreenNode == self then 522 | -- Ignore frame, use screen frame 523 | frame = self.root.screen:frame() 524 | else 525 | self.frame = frame 526 | end 527 | 528 | if #self.children == 0 then 529 | -- Bottom-level node 530 | if self.window then 531 | self.window:setFrame(frame, 0) 532 | end 533 | else 534 | if self.mode == mode.stacked or self.mode == mode.tabbed then 535 | -- Children of stacked nodes share the same frame. 536 | for idx, child in pairs(self.children) do 537 | child:_update(frame) 538 | end 539 | else 540 | local cursor = (self.orientation == orientation.horizontal) and frame.x or frame.y 541 | for idx, child in pairs(self.children) do 542 | local childFrame 543 | if self.orientation == orientation.horizontal then 544 | childFrame = {x=cursor, y=frame.y, w=frame.w*child.size, h=frame.h} 545 | cursor = cursor + childFrame.w 546 | else 547 | childFrame = {x=frame.x, y=cursor, w=frame.w, h=frame.h*child.size} 548 | cursor = cursor + childFrame.h 549 | end 550 | child:_update(childFrame) 551 | end 552 | end 553 | end 554 | end 555 | 556 | -- Returns the parent and index of the node that is in a certain direction of this one. This could 557 | -- either be a sibling (if direction is in the same orientation as the parent) or a sibling of one 558 | -- of our ancestors (if not). If the top-level node is reached and there is no container in that 559 | -- direction, returns the top-level node with an index out-of-bounds on the side we're trying to go 560 | -- to. Treats the fullscreen node like the top-level node. 561 | function layout:_moveInDirection(direction) 562 | if not self:_selection() then 563 | -- Bottom of tree, go up. 564 | if self.parent and self.parent ~= self.root and self ~= self.root.fullscreenNode then 565 | return self.parent:_moveInDirection(direction) 566 | end 567 | return nil 568 | end 569 | 570 | local orientation = orientationForDirection(direction) 571 | local idx = fnutils.indexOf(self.children, self:_selection()) + incrementForDirection(direction) 572 | 573 | if self.orientation == orientation and self.children[idx] then 574 | -- Set new focus. 575 | return self, idx 576 | else 577 | -- Can't go this way; move up one level and try again. 578 | if self.parent and self.parent ~= self.root and self ~= self.root.fullscreenNode then 579 | return self.parent:_moveInDirection(direction) 580 | else 581 | -- We're already at the top 582 | if self.orientation == orientation then 583 | -- Return an out-of-bounds index 584 | return self, idx 585 | else 586 | -- Signal that we tried to go a different direction 587 | return self, -1 588 | end 589 | end 590 | end 591 | end 592 | 593 | -- Selects the window in the specified direction of the current selection. 594 | function layout:focus(direction) 595 | local node, idx = self:_getSelectedNode():_moveInDirection(direction) 596 | if node and node.children[idx] then 597 | node:_setSelection(node.children[idx]) 598 | print("focusing: "..tostring(node)) 599 | self:focusSelection() 600 | else 601 | -- Trying to focus past the end of the top-level container; there is an event for this. 602 | if self.root.onFocusPastEnd then 603 | self.root.onFocusPastEnd(self.root, direction) 604 | end 605 | end 606 | end 607 | 608 | -- Moves the selection in the specified direction. 609 | function layout:move(direction) 610 | local node = self:_getSelectedNode() 611 | local newAncestor, idx = node:_moveInDirection(direction) 612 | if newAncestor and newAncestor.children[idx] then 613 | -- Descend down selection path to find final destination. 614 | local newSibling = newAncestor.children[idx]:_getSelectedNode() 615 | local newParent = newSibling.parent 616 | local newIdx = fnutils.indexOf(newParent.children, newSibling) 617 | if incrementForDirection(direction) > 0 or newParent ~= node.parent then 618 | -- put it to the right of newSibling 619 | newIdx = newIdx + 1 620 | end -- otherwise, put it to the left 621 | 622 | _moveNode(node, newParent, newIdx) 623 | elseif newAncestor then 624 | -- idx is out of bounds; newAncestor is the top-level or fullscreen container. 625 | if newAncestor.parent ~= self.root then 626 | -- Fullscreen container; do nothing. 627 | elseif orientationForDirection(direction) ~= newAncestor.orientation then 628 | -- The user wants to move perpendicular to the direction of the top-level container. 629 | -- Create a new top-level container. 630 | local parent = layout:_newParent(newAncestor) 631 | parent.orientation = orientationForDirection(direction) 632 | _moveNode(node, parent, (incrementForDirection(direction) < 0) and 1 or 2) 633 | elseif node.parent == newAncestor then 634 | -- Trying to move something past the end of the top-level container; there is an event for this. 635 | if node ~= self.root.fullscreenNode and self.root.onMovePastEnd then 636 | self.root.onMovePastEnd(self.root, node, direction) 637 | end 638 | else 639 | -- Move something out of a lower level to the end of the top-level container. 640 | _moveNode(node, newAncestor, math.max(idx, 1)) 641 | end 642 | end 643 | node:_select() 644 | end 645 | 646 | -- Gets the lowest-level ancestor that has the orientation. Returns that and the index of the 647 | -- ancestor right below it. 648 | function layout:_findAncestorWithOrientation(orientation) 649 | if self.parent == self.root then 650 | return nil 651 | elseif self.parent.orientation == orientation then 652 | local idx = fnutils.indexOf(self.parent.children, self) 653 | return self.parent, idx 654 | else 655 | return self.parent:_findAncestorWithOrientation(orientation) 656 | end 657 | end 658 | 659 | function layout:resize(direction, pct) 660 | local function isIndexOnEnd(increment, idx, parent) 661 | return (increment < 0 and idx == 1) or (increment > 0 and idx == #parent.children) 662 | end 663 | 664 | local orientation = orientationForDirection(direction) 665 | local increment = incrementForDirection(direction) 666 | local screenFrame = self.root.screen:frame() 667 | 668 | -- Find the ancestor of the current selection that has the given orientation. 669 | -- We will resize its child (also an ancestor) by the given amount. 670 | -- Keep going up if we are at the end of the container. 671 | local parent = self:_getSelectedNode(), idx 672 | repeat 673 | parent, idx = parent:_findAncestorWithOrientation(orientation) 674 | if parent == nil then return end -- no such ancestor exists 675 | until not isIndexOnEnd(increment, idx, parent) 676 | local child = parent.children[idx] 677 | 678 | -- Child will get all of pct; all siblings in the direction of the resize will share the cut. 679 | child.size = child.size + pct 680 | if increment > 0 then 681 | parent:_rebalanceChildren(idx+1, #parent.children, -pct, true) 682 | else 683 | parent:_rebalanceChildren(1, idx-1, -pct, true) 684 | end 685 | 686 | parent:update() 687 | end 688 | 689 | -- Proportionally rebalance the sizes of all children between startIdx and endIdx inclusive to fit 690 | -- in the given size. If relative is true, grow/shrink the current size by size. 691 | function layout:_rebalanceChildren(startIdx, endIdx, size, relative) 692 | local curSize = 0 693 | for i = startIdx, endIdx do 694 | curSize = curSize + self.children[i].size 695 | end 696 | 697 | local newSize = relative and (curSize + size) or size 698 | for i = startIdx, endIdx do 699 | local pct = self.children[i].size / curSize 700 | self.children[i].size = newSize * pct 701 | end 702 | end 703 | 704 | -- Use this method to get the selection of a node, unless you are deciding where to place a 705 | -- new window inside this node. 706 | function layout:_selection() 707 | if self.root.selectedParent == self then 708 | return nil -- terminate selection path early 709 | else 710 | return self.selection 711 | end 712 | end 713 | 714 | -- Gets the bottom-level node that is selected from this node. Takes selectedParent into 715 | -- consideration, if it is a child node. 716 | function layout:_getSelectedNode(ignoreSelectedParent) 717 | local node = self 718 | while node.selection and (ignoreSelectedParent or node.root.selectedParent ~= node) do 719 | node = node.selection 720 | end 721 | return node 722 | end 723 | 724 | -- Ensure that this node is in the selection path. 725 | function layout:_select() 726 | if self.root.selectedParent ~= self then 727 | self.root.selectedParent = nil 728 | end 729 | 730 | local node = self 731 | while node and node.parent do 732 | node.parent:_setSelection(node) 733 | node = node.parent 734 | end 735 | end 736 | 737 | -- Set the current selection path, remembering the previous one. 738 | function layout:_setSelection(selection) 739 | if selection ~= self.selection then 740 | if fnutils.contains(self.children, self.selection) then -- don't overwrite previousSelection with not-a-child 741 | self.previousSelection = self.selection 742 | end 743 | self.selection = selection 744 | 745 | selection:_onSelected() 746 | end 747 | end 748 | 749 | -- Called when a node is newly selected by its parent (not necessarily the global selection). 750 | function layout:_onSelected() 751 | print("onSelected: "..tostring(self)) 752 | if self.parent.mode == mode.stacked or self.parent.mode == mode.tabbed then 753 | -- Bring all windows to front. 754 | for i, node in pairs(self.children) do 755 | if node ~= self.selection then 756 | for j, win in pairs(node:allVisibleWindows()) do 757 | print("onSelected: focusing "..win:title()) 758 | win:focus() 759 | end 760 | end 761 | end 762 | if self.selection then 763 | for j, win in pairs(self.selection:allVisibleWindows()) do 764 | print("onSelected: focusing "..win:title()) 765 | win:focus() 766 | end 767 | end 768 | end 769 | end 770 | 771 | -- Pick a different selection now that the old one is gone. 772 | function layout:_restoreSelection(default) 773 | if self.previousSelection and 774 | fnutils.contains(self.children, self.previousSelection) then 775 | self.selection = self.previousSelection 776 | elseif default then 777 | self.selection = default 778 | else 779 | self.selection = self.children[#self.children] 780 | end 781 | end 782 | 783 | function layout:__tostring() 784 | if self.window then 785 | return '<'..self.window:title()..'>' 786 | else 787 | str = '[' 788 | if self.root == self then 789 | str = str..'R' 790 | elseif self.mode == mode.default then 791 | str = str..((self.orientation == orientation.horizontal) and 'H' or 'V') 792 | elseif self.mode == mode.stacked then 793 | str = str..'S' 794 | elseif self.mode == mode.tabbed then 795 | str = str..'T' 796 | end 797 | 798 | for i, c in pairs(self.children) do 799 | str = str..' ' 800 | if c == self.selection then str = str..'*' end 801 | str = str..tostring(c) 802 | end 803 | str = str..']' 804 | return str 805 | end 806 | end 807 | 808 | return layout 809 | -------------------------------------------------------------------------------- /wm/os.lua: -------------------------------------------------------------------------------- 1 | -- Abstracts interaction with OS (via Hammerspoon). This is useful for stubbing out during tests. 2 | 3 | local os = {} 4 | 5 | function os.allScreens() 6 | return hs.screen.allScreens() 7 | end 8 | 9 | function os.allVisibleWindows() 10 | return hs.window.allWindows() 11 | end 12 | 13 | function os.focusedWindow() 14 | return hs.window.focusedWindow() 15 | end 16 | 17 | os.uiEvents = {} 18 | 19 | -- Called at launch in production 20 | function os.setup() 21 | os.uiEvents = hs.uielement.watcher 22 | end 23 | 24 | return os 25 | -------------------------------------------------------------------------------- /wm/screenlayout.lua: -------------------------------------------------------------------------------- 1 | -- screenlayout keeps track of the OS screen layout as well as the workspace visible on each screen 2 | -- (the current space). It directs events to the active workspace and handles moves of focus and 3 | -- windows bewteen screens. 4 | -- 5 | -- screenlayout differs from the window layout in that it is not tree-based and it is not in our 6 | -- control. Moves between screens are handled in a geometric fashion rather than via tree traversal. 7 | 8 | local screenlayout = {} 9 | 10 | local fnutils = require 'wm.fnutils' 11 | local utils = require 'wm.utils' 12 | local workspace = require 'wm.workspace' 13 | local windowregistry = require 'wm.windowregistry' 14 | 15 | function screenlayout:new(allScreens) 16 | local obj = { 17 | _screenInfos = {}, -- Keeps the screen object and visible workspace for each screen. 18 | _workspaces = {}, -- A list of all workspace objects. 19 | _selectedScreenInfo = nil, 20 | _windowRegistry = windowregistry:new() 21 | } 22 | setmetatable(obj, {__index = self}) 23 | 24 | -- Create initial workspaces for the current spaces. 25 | obj:updateScreenLayout(allScreens) 26 | 27 | return obj 28 | end 29 | 30 | function screenlayout:workspaces() 31 | return self._workspaces 32 | end 33 | 34 | -- Selects the given screen. 35 | function screenlayout:selectScreen(screen) 36 | local info = self._screenInfos[self:_getScreenInfoIndex(screen)] 37 | print("screen index "..self:_getScreenInfoIndex(screen).." selected") 38 | assert(info, "screen not recognized. Was updateScreenLayout not called?") 39 | self._selectedScreenInfo = info 40 | end 41 | 42 | -- If the window is in a workspace, selects that workspace and selects the window in the workspace. 43 | function screenlayout:selectWindow(win) 44 | local ws = self._windowRegistry:getWorkspaceForWindow(win) 45 | if ws then 46 | assert(ws:screen() == win:screen(), "window is not in expected screen") 47 | self:selectScreen(ws:screen()) 48 | ws:selectWindow(win) 49 | end 50 | end 51 | 52 | function screenlayout:selectedWorkspace() 53 | return self._selectedScreenInfo.workspace 54 | end 55 | 56 | function screenlayout:selectedScreen() 57 | return self._selectedScreenInfo.screen 58 | end 59 | 60 | -- Forces the selected window to be focused. 61 | function screenlayout:focusSelection() 62 | self._selectedScreenInfo.workspace:focusSelection() 63 | end 64 | 65 | function screenlayout:setWorkspaceForScreen(screen, ws) 66 | local idx = self:_getScreenInfoIndex(screen) 67 | assert(idx, "screen not recognized. Was updateScreenLayout not called?") 68 | 69 | local oldWorkspace = self._screenInfos[idx].workspace 70 | if ws ~= oldWorkspace then 71 | -- Cull old workspace if it's empty. 72 | if oldWorkspace:isEmpty() then 73 | utils.remove(self._workspaces, oldWorkspace) 74 | end 75 | 76 | if ws == nil then 77 | ws = self:_createWorkspace(screen) 78 | end 79 | self._screenInfos[idx].workspace = ws 80 | end 81 | 82 | -- Make sure screen size gets updated by always calling setScreen. 83 | ws:setScreen(screen) 84 | end 85 | 86 | function screenlayout:updateScreenLayout(allScreens) 87 | -- Create initial workspaces for the current spaces. 88 | self._screenInfos = {} 89 | for i, screen in pairs(allScreens) do 90 | table.insert(self._screenInfos, {screen=screen}) 91 | end 92 | self:_populateWorkspaces() 93 | self._selectedScreenInfo = self._screenInfos[1] 94 | end 95 | 96 | function screenlayout:numScreens() 97 | return #self._screenInfos 98 | end 99 | 100 | -- Remove workspaces that are empty and not visible. 101 | function screenlayout:_cullWorkspaces() 102 | utils.removeIf(self._workspaces, function(workspace) 103 | if not workspace:isEmpty() then 104 | -- Non-empty 105 | return false 106 | else 107 | for j, info in pairs(self._screenInfos) do 108 | if info.workspace == workspace then 109 | -- Visible 110 | return false 111 | end 112 | end 113 | return true 114 | end 115 | end) 116 | end 117 | 118 | -- Create empty workspaces on screens that don't have one. 119 | function screenlayout:_populateWorkspaces() 120 | for i, info in pairs(self._screenInfos) do 121 | if not info.workspace then 122 | info.workspace = self:_createWorkspace(info.screen) 123 | end 124 | end 125 | end 126 | 127 | -- Adds the window to the currently selected workspace. 128 | function screenlayout:addWindow(win) 129 | self._windowRegistry:putWindowInWorkspace(win, self._selectedScreenInfo.workspace) 130 | end 131 | 132 | -- Removes the window from whatever workspace it is in. 133 | function screenlayout:removeWindow(win) 134 | self._windowRegistry:removeWindow(win) 135 | end 136 | 137 | -- Called when the user requests to move focus past the end of the current workspace. 138 | function screenlayout:_onFocusPastEnd(workspace, direction) 139 | local curIdx = self:_getWorkspaceIndex(workspace) 140 | local newIdx = self:_getScreenInDirection(curIdx, direction) 141 | local newWorkspace = self._screenInfos[newIdx].workspace 142 | if newWorkspace then 143 | self._selectedScreenInfo = self._screenInfos[newIdx] 144 | newWorkspace:selectWindowGoingInDirection(direction) 145 | newWorkspace:focusSelection() 146 | end 147 | assert(self._selectedScreenInfo, "selectedScreenInfo is nil") 148 | end 149 | 150 | -- Called when the user requests to move a node past the end of the current workspace. 151 | function screenlayout:_onMovePastEnd(workspace, node, direction) 152 | local curIdx = self:_getWorkspaceIndex(workspace) 153 | local newIdx = self:_getScreenInDirection(curIdx, direction) 154 | 155 | node:removeFromParent() 156 | 157 | -- Keep current screen selected, unless it's empty 158 | if not self._screenInfos[curIdx].workspace:focusSelection() then 159 | self._selectedScreenInfo = self._screenInfos[newIdx] 160 | assert(self._selectedScreenInfo, "selectedScreenInfo is nil") 161 | end 162 | 163 | self._windowRegistry:moveNodeGoingInDirection(node, direction, self._screenInfos[newIdx].workspace) 164 | end 165 | 166 | function screenlayout:_getScreenInDirection(curIdx, direction) 167 | -- TODO actually implement 168 | local newIdx = curIdx + 1 169 | if newIdx > #self._screenInfos then newIdx = 1 end 170 | return newIdx 171 | end 172 | 173 | function screenlayout:_createWorkspace(screen) 174 | local workspace = workspace:new(screen) 175 | workspace.onFocusPastEnd = function(...) self:_onFocusPastEnd(...) end 176 | workspace.onMovePastEnd = function(...) self:_onMovePastEnd(...) end 177 | 178 | table.insert(self._workspaces, workspace) 179 | return workspace 180 | end 181 | 182 | function screenlayout:_removeWorkspace(screenIdx) 183 | table.remove(self._workspaces, fnutils.indexOf(self._workspaces, self._screenInfos[screenIdx].workspace)) 184 | self._screenInfos[screenIdx].workspace = nil 185 | end 186 | 187 | function screenlayout:_getScreenInfoIndex(screen) 188 | for i, info in pairs(self._screenInfos) do 189 | if info.screen == screen then 190 | return i 191 | end 192 | end 193 | end 194 | 195 | function screenlayout:_getWorkspaceIndex(workspace) 196 | for i, info in pairs(self._screenInfos) do 197 | if info.workspace == workspace then 198 | return i 199 | end 200 | end 201 | end 202 | 203 | return screenlayout 204 | -------------------------------------------------------------------------------- /wm/utils.lua: -------------------------------------------------------------------------------- 1 | local utils = {} 2 | 3 | local orientation = { 4 | horizontal = 0, vertical = 1 5 | } 6 | utils.orientation = orientation 7 | 8 | local direction = { 9 | left = 0, right = 1, up = 2, down = 3 10 | } 11 | utils.direction = direction 12 | 13 | function utils.orientationForDirection(d) 14 | if d == direction.left or d == direction.right then 15 | return orientation.horizontal 16 | else 17 | return orientation.vertical 18 | end 19 | end 20 | 21 | function utils.incrementForDirection(d) 22 | if d == direction.left or d == direction.up then 23 | return -1 24 | else 25 | return 1 26 | end 27 | end 28 | 29 | function utils.rectEquals(a, b) 30 | return a.x == b.x and a.y == b.y and a.w == b.w and a.h == b.h 31 | end 32 | 33 | -- Removes elements from an array if the provided function returns true on them. 34 | function utils.removeIf(t, fn) 35 | local i = 1 36 | local count = #t 37 | while i <= count do 38 | if fn(t[i]) then 39 | table.remove(t, i) 40 | count = count - 1 41 | else 42 | i = i + 1 43 | end 44 | end 45 | return t 46 | end 47 | 48 | function utils.remove(t, x) 49 | return utils.removeIf(t, function(y) return (x==y) end) 50 | end 51 | 52 | --- Returns the first element index where fn(el) is truthy. 53 | function utils.findIdx(t, fn) 54 | for i, v in pairs(t) do 55 | if fn(v) then return i end 56 | end 57 | return nil 58 | end 59 | 60 | return utils 61 | -------------------------------------------------------------------------------- /wm/windowregistry.lua: -------------------------------------------------------------------------------- 1 | -- windowregistry keeps track of windows and which workspace they are in for screenlayout. 2 | 3 | local windowregistry = {} 4 | 5 | local fnutils = require 'wm.fnutils' 6 | 7 | function windowregistry:new() 8 | local obj = { 9 | windowWorkspaces = {} -- table of workspaces, keyed by window id 10 | } 11 | setmetatable(obj, {__index = self}) 12 | 13 | return obj 14 | end 15 | 16 | -- Adds a window to a workspace, removing it from any other workspace it is in. 17 | function windowregistry:putWindowInWorkspace(win, workspace) 18 | self:removeWindow(win) 19 | 20 | workspace:addWindow(win) 21 | self.windowWorkspaces[win:id()] = workspace 22 | end 23 | 24 | -- Removes a window from its workspace, if any. 25 | function windowregistry:removeWindow(win) 26 | local id = win:id() 27 | local oldWorkspace = self.windowWorkspaces[id] 28 | 29 | if oldWorkspace then 30 | oldWorkspace:removeWindowById(id) 31 | self.windowWorkspaces[id] = nil 32 | end 33 | end 34 | 35 | -- Returns the workspace a window is in, or nil. 36 | function windowregistry:getWorkspaceForWindow(win) 37 | return (win and self.windowWorkspaces[win:id()] or nil) 38 | end 39 | 40 | -- Calls addNodeGoingInDirection on the new workspace, recording the windows that are moving. 41 | function windowregistry:moveNodeGoingInDirection(node, direction, workspace) 42 | workspace:addNodeGoingInDirection(node, direction) 43 | 44 | fnutils.each(node:allWindows(), function(win) 45 | self.windowWorkspaces[win:id()] = workspace 46 | end) 47 | end 48 | 49 | return windowregistry 50 | -------------------------------------------------------------------------------- /wm/windowtracker.lua: -------------------------------------------------------------------------------- 1 | --- === windowtracker === 2 | --- 3 | --- Track all windows on the screen. windowtracker abstracts away applications and treats all 4 | --- windows the same, subscribing to all events on all windows. 5 | --- 6 | --- You can watch for the following events: 7 | --- * windowtracker.windowCreated: A window was created. 8 | --- * windowtracker.windowDestroyed: A window was destroyed. 9 | --- * windowtracker.mainWindowChanged: The main window was changed. This is usually the same as the 10 | --- focused window (except for helper dialog boxes like file pickers, which are not reported by 11 | --- this event). Note that switching applications triggers this event, unlike the OS X 12 | --- accessibility API. 13 | --- * windowtracker.windowMoved: A window was moved. 14 | --- * windowtracker.windowResized: A window was resized. 15 | --- * windowtracker.windowMinimized: A window was minimized. 16 | --- * windowtracker.windowUnminimized: A window was unminimized. 17 | --- 18 | --- Note that Hammerspoon windows (the console) are ignored by windowtracker. This is because the 19 | --- console pops after an error, which can cause infinite exception loops, rendering the computer 20 | --- unusable. 21 | 22 | local windowtracker = {} 23 | 24 | local fnutils = require 'wm.fnutils' 25 | local os = require 'wm.os' 26 | 27 | windowtracker.windowCreated = os.uiEvents.windowCreated 28 | windowtracker.windowDestroyed = os.uiEvents.elementDestroyed 29 | windowtracker.mainWindowChanged = os.uiEvents.mainWindowChanged 30 | windowtracker.windowCreated = os.uiEvents.windowCreated 31 | windowtracker.windowMoved = os.uiEvents.windowMoved 32 | windowtracker.windowResized = os.uiEvents.windowResized 33 | windowtracker.windowMinimized = os.uiEvents.windowMinimized 34 | windowtracker.windowUnminimized = os.uiEvents.windowUnminimized 35 | 36 | --- windowtracker.new(watchEvents, handler) -> windowtracker 37 | --- Constructor 38 | --- Creates a new tracker for the given events. 39 | --- 40 | --- handler receives two arguments: the window object and the event name. 41 | function windowtracker:new(watchEvents, handler) 42 | obj = { 43 | appsWatcher = nil, 44 | watchers = {}, 45 | handler = handler, 46 | watchEvents = watchEvents, 47 | winWatchEvents = {}, 48 | started = false 49 | } 50 | 51 | -- Decide which events will be watched on new windows. Exclude events that are watched on the app. 52 | local nonWindowEvents = {windowtracker.windowCreated, windowtracker.mainWindowChanged} 53 | for i, event in pairs(watchEvents) do 54 | if not hs.fnutils.contains(nonWindowEvents, event) then table.insert(obj.winWatchEvents, event) end 55 | end 56 | if not hs.fnutils.contains(obj.winWatchEvents, windowtracker.windowDestroyed) then 57 | table.insert(obj.winWatchEvents, windowtracker.windowDestroyed) -- always watch this event 58 | end 59 | 60 | setmetatable(obj, self) 61 | return obj 62 | end 63 | 64 | --- windowtracker:start() 65 | --- Method 66 | --- Starts tracking all windows. 67 | function windowtracker:start() 68 | if self.started then return end 69 | 70 | self.appsWatcher = hs.application.watcher.new(function(...) self:_handleGlobalAppEvent(...) end) 71 | self.appsWatcher:start() 72 | 73 | -- Watch any apps that already exist 74 | local apps = hs.application.runningApplications() 75 | for i = 1, #apps do 76 | if apps[i]:title() ~= "Hammerspoon" then 77 | self:_watchApp(apps[i], true) 78 | end 79 | end 80 | 81 | self.started = true 82 | end 83 | 84 | --- windowtracker:stop() 85 | --- Method 86 | --- Stops tracking all windows. 87 | --- 88 | --- The handler will not be called after this method, unless start() is called again. 89 | function windowtracker:stop() 90 | if not self.started then return end 91 | 92 | self.appsWatcher:stop() 93 | for pid, appWatchers in pairs(self.watchers) do 94 | for watcherId, watcher in pairs(appWatchers) do 95 | watcher:stop() 96 | end 97 | end 98 | self.watchers = {} 99 | 100 | self.started = false 101 | end 102 | 103 | function windowtracker:_handleGlobalAppEvent(name, event, app) 104 | if event == hs.application.watcher.launched then 105 | self:_watchApp(app) 106 | elseif event == hs.application.watcher.terminated then 107 | self.watchers[app:pid()] = nil 108 | end 109 | end 110 | 111 | function windowtracker:_watchApp(app, starting) 112 | if not app:isApplication() then return end 113 | if self.watchers[app:pid()] then return end 114 | 115 | local watcher = app:newWatcher(function(...) self:_handleAppEvent(...) end) 116 | self.watchers[app:pid()] = {app=watcher} 117 | 118 | if fnutils.contains(self.watchEvents, windowtracker.mainWindowChanged) then 119 | watcher:start({ 120 | windowtracker.windowCreated, 121 | windowtracker.mainWindowChanged, 122 | os.uiEvents.applicationActivated}) 123 | else 124 | watcher:start({windowtracker.windowCreated}) 125 | end 126 | 127 | -- Watch any windows that already exist 128 | for i, window in pairs(app:allWindows()) do 129 | self:_watchWindow(window, starting) 130 | end 131 | local wins = app:allWindows() 132 | end 133 | 134 | function windowtracker:_handleAppEvent(element, event) 135 | if event == windowtracker.windowCreated then 136 | local isNew = self:_watchWindow(element) 137 | 138 | -- Track event if wanted. 139 | if isNew and hs.fnutils.contains(self.watchEvents, windowtracker.windowCreated) then 140 | self.handler(element, windowtracker.windowCreated) 141 | end 142 | elseif event == windowtracker.mainWindowChanged and element:isWindow() 143 | and element:application() == hs.application.frontmostApplication() then 144 | self.handler(element, windowtracker.mainWindowChanged) 145 | elseif event == os.uiEvents.applicationActivated then 146 | -- Generate a mainWindowChanged event since the application changed. 147 | self.handler(element:mainWindow(), windowtracker.mainWindowChanged) 148 | end 149 | end 150 | 151 | function windowtracker:_watchWindow(win, starting) 152 | if not win:isWindow() or not win:isStandard() then return end 153 | 154 | -- Ensure we don't track a window twice. 155 | local appWindows = self.watchers[win:application():pid()] 156 | if not appWindows[win:id()] then 157 | local watcher = win:newWatcher(function(...) self:_handleWindowEvent(...) end) 158 | appWindows[win:id()] = watcher 159 | 160 | watcher:start(self.winWatchEvents) 161 | return true 162 | end 163 | 164 | return false 165 | end 166 | 167 | function windowtracker:_handleWindowEvent(win, event, watcher) 168 | if win ~= watcher:element() then return end 169 | if event == windowtracker.windowDestroyed then 170 | self.watchers[win:pid()][win:id()] = nil 171 | end 172 | if fnutils.contains(self.watchEvents, event) then 173 | self.handler(watcher:element(), event) 174 | end 175 | end 176 | 177 | windowtracker.__index = windowtracker 178 | 179 | return windowtracker 180 | -------------------------------------------------------------------------------- /wm/workspace.lua: -------------------------------------------------------------------------------- 1 | -- There is one active workspace per screen at a time, and a workspace has both a tiling and a 2 | -- floating layout. The job of this class is to coordinate and abstract over the two kinds of 3 | -- layouts. 4 | 5 | local workspace = {} 6 | 7 | local layout = require 'wm.layout' 8 | local floatinglayout = require 'wm.floatinglayout' 9 | 10 | function workspace:new(screen) 11 | local obj = { 12 | tilingLayout = layout:new(screen), 13 | floatingLayout = floatinglayout:new(), 14 | selection = nil, 15 | 16 | -- Handler functions 17 | onFocusPastEnd = nil, 18 | onMovePastEnd = nil 19 | } 20 | obj.selection = obj.tilingLayout 21 | 22 | -- Set up handler functions 23 | obj.tilingLayout.onFocusPastEnd = function(...) obj:_onFocusPastEnd(false, ...) end 24 | obj.tilingLayout.onMovePastEnd = function(...) obj:_onMovePastEnd(false, ...) end 25 | obj.floatingLayout.onFocusPastEnd = function(...) obj:_onFocusPastEnd(true, ...) end 26 | obj.floatingLayout.onMovePastEnd = function(...) obj:_onMovePastEnd(true, ...) end 27 | 28 | setmetatable(obj, {__index = self}) 29 | return obj 30 | end 31 | 32 | function workspace:allWindows() 33 | return hs.fnutils.concat(self.tilingLayout:allWindows(), self.floatingLayout:allWindows()) 34 | end 35 | 36 | function workspace:isEmpty() 37 | return self.tilingLayout:isEmpty() and self.floatingLayout:isEmpty() 38 | end 39 | 40 | function workspace:setScreen(screen) 41 | self.tilingLayout:setScreen(screen) 42 | end 43 | 44 | function workspace:screen() 45 | return self.tilingLayout.screen 46 | end 47 | 48 | function workspace:selectWindow(win) 49 | if self.tilingLayout:selectWindow(win) then 50 | self.selection = self.tilingLayout 51 | return true 52 | elseif self.floatingLayout:selectWindow(win) then 53 | self.selection = self.floatingLayout 54 | return true 55 | end 56 | return false 57 | end 58 | 59 | -- Toggles whether the selection is tiling or floating. 60 | function workspace:toggleFloating() 61 | local windows = self.selection:removeSelectedWindows() 62 | local dstLayout = (self.selection == self.tilingLayout) and self.floatingLayout or self.tilingLayout 63 | 64 | for i, win in pairs(windows) do 65 | dstLayout:addWindow(win) 66 | end 67 | 68 | self.selection = dstLayout 69 | self.selection:bringToFrontAndFocusSelection() 70 | end 71 | 72 | -- Toggle whether the tiling or floating layout is selected. 73 | function workspace:toggleFocusMode() 74 | local oldSelection = self.selection 75 | self.selection = (self.selection == self.tilingLayout) and self.floatingLayout or self.tilingLayout 76 | if self.selection:isEmpty() and not oldSelection:isEmpty() then 77 | -- Revert change 78 | print("No windows on this layer, not toggling") 79 | self.selection = oldSelection 80 | else 81 | self.selection:bringToFrontAndFocusSelection() 82 | end 83 | end 84 | 85 | function workspace:removeWindowById(id) 86 | if not self.tilingLayout:removeWindowById(id) then 87 | return self.floatingLayout:removeWindowById(id) 88 | else 89 | return true 90 | end 91 | end 92 | 93 | function workspace:_onFocusPastEnd(floating, layout, direction) 94 | if self.onFocusPastEnd then 95 | self.onFocusPastEnd(self, direction, floating) 96 | end 97 | end 98 | 99 | function workspace:_onMovePastEnd(floating, layout, node, direction) 100 | print(tostring(self)..' '..tostring(layout)..' '..tostring(node)..' '..tostring(direction)..' '..tostring(floating)) 101 | if self.onMovePastEnd then 102 | self.onMovePastEnd(self, node, direction, floating) 103 | end 104 | end 105 | 106 | -- Forward calls to the selected layout. 107 | function workspace:_lookupFunction(funcName) 108 | return function(ws, ...) 109 | if not ws.selection then 110 | print('No active workspace; cannot send '..funcName) 111 | return nil 112 | end 113 | 114 | local func = ws.selection[funcName] 115 | if type(func) == 'function' then 116 | return func(ws.selection, ...) 117 | end 118 | end 119 | end 120 | 121 | setmetatable(workspace, {__index = workspace._lookupFunction}) 122 | 123 | return workspace 124 | -------------------------------------------------------------------------------- /wm/workspacefinder.lua: -------------------------------------------------------------------------------- 1 | -- workspacefinder takes a list of screens and a list of workspaces and attempts to match each 2 | -- screen to a workspace, based on the windows on that screen. 3 | 4 | local layout = require 'wm.layout' 5 | local fnutils = require 'wm.fnutils' 6 | 7 | local workspacefinder = {} 8 | 9 | local function times(x, num) 10 | local list = {} 11 | for i = 1, num do 12 | table.insert(list, x) 13 | end 14 | return list 15 | end 16 | 17 | -- Find the workspace on each screen. 18 | -- 19 | -- Returns an array of {[screen], [workspace]} for each screen, where workspace is nil if none could 20 | -- be matched. 21 | function workspacefinder.find(workspaces, screens, windows) 22 | -- Get list of windows for each workspace. 23 | local workspaceInfo = {} 24 | for i, workspace in pairs(workspaces) do 25 | workspaceInfo[i] = {windows = workspace:allWindows(), matches = times(0, #screens)} 26 | end 27 | 28 | -- Match each window to a workspace if possible. 29 | for i, win in pairs(windows) do 30 | for j, info in pairs(workspaceInfo) do 31 | if win:screen() and fnutils.contains(info.windows, win) then 32 | local screenIdx = fnutils.indexOf(screens, win:screen()) 33 | info.matches[screenIdx] = info.matches[screenIdx] + 1 34 | break 35 | end 36 | end 37 | end 38 | 39 | -- Pick the best match for each screen. Must see more than half of windows in the workspace to be a match. 40 | local bestMatches = {} 41 | for i, info in pairs(workspaceInfo) do 42 | for j, matches in pairs(info.matches) do 43 | if matches > #info.windows/2 then 44 | if not bestMatches[j] or matches > workspaceInfo[bestMatches[j]].matches[j] then 45 | bestMatches[j] = i 46 | end 47 | end 48 | end 49 | end 50 | 51 | -- Assemble data into an array of screenInfo tables. 52 | local screenInfos = {} 53 | for i, screen in pairs(screens) do 54 | local idx = bestMatches[i] 55 | local workspace = (idx and workspaces[idx] or nil) 56 | screenInfos[i] = {screen=screen, workspace=workspace} 57 | end 58 | 59 | return screenInfos 60 | end 61 | 62 | return workspacefinder 63 | --------------------------------------------------------------------------------