├── .gitignore ├── .gitmodules ├── Spoons ├── Teamz.spoon │ ├── README.md │ └── init.lua └── HyperModal.spoon │ ├── .github │ └── workflows │ │ └── main.yml │ ├── init.lua │ └── README.md ├── browserSnip.lua ├── brave.lua ├── README.md ├── configApplications.lua └── init.lua /.gitignore: -------------------------------------------------------------------------------- 1 | .secrets.json 2 | localConfig.lua 3 | 4 | # Devenv 5 | .devenv* 6 | devenv.local.nix 7 | 8 | # direnv 9 | .direnv 10 | 11 | # pre-commit 12 | .pre-commit-config.yaml 13 | 14 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "Spoons/Hyper.spoon"] 2 | path = Spoons/Hyper.spoon 3 | url = https://github.com/evantravers/Hyper.spoon.git 4 | [submodule "Spoons/Headspace.spoon"] 5 | path = Spoons/Headspace.spoon 6 | url = https://github.com/evantravers/Headspace.spoon.git 7 | -------------------------------------------------------------------------------- /Spoons/Teamz.spoon/README.md: -------------------------------------------------------------------------------- 1 | # Teamz 2 | 3 | Microsoft Teams is difficult to work with... they don't name their windows 4 | consistently. This is a wrapper that keeps track of a Microsoft Teams 5 | application, and isolating the hs.window that contains the call. 6 | 7 | ```lua 8 | Teamz = spoon.Teamz:start() 9 | 10 | -- to focus the working call... 11 | if Teamz:isRunning() then 12 | Teamz:callWindow():focus() 13 | end 14 | ``` 15 | -------------------------------------------------------------------------------- /browserSnip.lua: -------------------------------------------------------------------------------- 1 | -- https://stackoverflow.com/questions/19326368/iterate-over-lines-including-blank-lines 2 | local function magiclines(s) 3 | if s:sub(-1)~="\n" then s=s.."\n" end 4 | return s:gmatch("(.-)\n") 5 | end 6 | 7 | -- Snip current highlight 8 | Hyper:bind({}, 's', nil, function() 9 | local win = hs.window.focusedWindow() 10 | 11 | -- get the window title 12 | local title = win:title() 13 | :gsub("- Brave", "") 14 | :gsub("- Google Chrome", "") 15 | -- get the highlighted item 16 | hs.eventtap.keyStroke('command', 'c') 17 | local highlight = hs.pasteboard.readString() 18 | local quote = "" 19 | for line in magiclines(highlight) do 20 | quote = quote .. "> " .. line .. "\n" 21 | end 22 | -- get the URL 23 | hs.eventtap.keyStroke('command', 'l') 24 | hs.eventtap.keyStroke('command', 'c') 25 | local url = hs.pasteboard.readString():gsub("?utm_source=.*", "") 26 | -- 27 | local template = string.format([[%s 28 | 29 | %s 30 | [%s](%s)]], title, quote, title, url) 31 | -- format and send to drafts 32 | hs.urlevent.openURL("drafts://x-callback-url/create?tag=links&text=" .. hs.http.encodeForQuery(template)) 33 | hs.notify.show("Snipped!", "The snippet has been sent to Drafts", "") 34 | end) 35 | -------------------------------------------------------------------------------- /brave.lua: -------------------------------------------------------------------------------- 1 | -- BRAVE 2 | -- 3 | -- Some utility functions for controlling Brave Browser. 4 | -- Probably would work super similarly on Chrome and Safari, or any webkit 5 | -- browser. 6 | -- 7 | -- NOTE: May require you enable View -> Developer -> Allow Javascript from 8 | -- Apple Events in Brave's menu. 9 | 10 | local module = {} 11 | 12 | module.start = function(config_table) 13 | module.config = config_table 14 | end 15 | 16 | module.jump = function(url) 17 | hs.osascript.javascript([[ 18 | (function() { 19 | var brave = Application('Brave'); 20 | brave.activate(); 21 | 22 | for (win of brave.windows()) { 23 | var tabIndex = 24 | win.tabs().findIndex(tab => tab.url().match(/]] .. url .. [[/)); 25 | 26 | if (tabIndex != -1) { 27 | win.activeTabIndex = (tabIndex + 1); 28 | win.index = 1; 29 | } 30 | } 31 | })(); 32 | ]]) 33 | end 34 | 35 | module.killTabsByDomain = function(domain) 36 | hs.osascript.javascript([[ 37 | (function() { 38 | var brave = Application('Brave'); 39 | 40 | for (win of brave.windows()) { 41 | for (tab of win.tabs()) { 42 | if (tab.url().match(/]] .. string.gsub(domain, '/', '\\/') .. [[/)) { 43 | tab.close() 44 | } 45 | } 46 | } 47 | })(); 48 | ]]) 49 | end 50 | 51 | return module 52 | -------------------------------------------------------------------------------- /Spoons/HyperModal.spoon/.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the action will run. 6 | on: 7 | push: 8 | tags: 9 | - '*' 10 | 11 | # Allows you to run this workflow manually from the Actions tab 12 | workflow_dispatch: 13 | 14 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 15 | jobs: 16 | # This workflow contains a single job called "build" 17 | build: 18 | # The type of runner that the job will run on 19 | runs-on: ubuntu-latest 20 | 21 | # Steps represent a sequence of tasks that will be executed as part of the job 22 | steps: 23 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 24 | - uses: actions/checkout@v2 25 | 26 | - name: Zip Release 27 | # You may pin to the exact commit or the version. 28 | # uses: TheDoctor0/zip-release@a1afcab9c664c9976ac398fa831eac67bed1eb0e 29 | uses: TheDoctor0/zip-release@0.4.1 30 | with: 31 | # Filename for archive 32 | exclusions: '*.git*' 33 | filename: MoveWindows.zip 34 | - name: Upload Release 35 | uses: ncipollo/release-action@v1 36 | with: 37 | artifacts: "MoveWindows.zip" 38 | token: ${{ secrets.GITHUB_TOKEN }} 39 | allowUpdates: true 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Personal Hammerspoon Setup 2 | 3 | > [!CAUTION] 4 | > Currently maintained as part of my darwin-nix setup at: https://github.com/evantravers/dotfiles/tree/master/.config/hammerspoon 5 | 6 | --- 7 | 8 | Intended to live in `~/.hammerspoon` 9 | To install: `git clone git@github.com:evantravers/hammerspoon-config.git ~/.hammerspoon` 10 | 11 | ## Requirements 12 | 13 | - Hyper requires Karabiner-elements, or some way of binding an F19 key (I bind 14 | left control -> F19) 15 | 16 | ## Modules 17 | 18 | ### Autolayout 19 | 20 | - Listens to display changes and moves and maximizes windows based on screen 21 | preferences. 22 | 23 | ### Brave 24 | 25 | - Provides bindings for Brave Browser 26 | - Focus tab by domain in any window 27 | - Kill tabs by domain 28 | 29 | ### Hyper 30 | 31 | Moved to [https://github.com/evantravers/Hyper.spoon](https://github.com/evantravers/Hyper.spoon) 32 | 33 | ### Headspace 34 | 35 | Moved to [https://github.com/evantravers/headspace.spoon](https://github.com/evantravers/headspace.spoon) 36 | 37 | ### Movewindows 38 | 39 | Moved to [https://github.com/evantravers/MoveWindows.spoon/](https://github.com/evantravers/MoveWindows.spoon/) 40 | 41 | One interesting binding for auto splitting an reference application, moved out to [https://github.com/evantravers/split.spoon](https://github.com/evantravers/split.spoon) 42 | 43 | ### Secrets 44 | 45 | Simple loading of API keys or secret variables into `hs.settings` via 46 | `hs.json`. 47 | 48 | ### Browsersnip 49 | 50 | Quickly snipping ZK-style notes from browsers into Drafts.app 51 | -------------------------------------------------------------------------------- /Spoons/HyperModal.spoon/init.lua: -------------------------------------------------------------------------------- 1 | --- === HyperModal === 2 | 3 | local m = hs.hotkey.modal.new({}, nil) 4 | 5 | m.name = "HyperModal" 6 | m.version = "0.0.1" 7 | m.author = "Evan Travers " 8 | m.license = "MIT " 9 | m.homepage = "https://github.com/evantravers/hammerspoon-config/Spoons/HyperModal/" 10 | 11 | -- initialize it as "closed" 12 | m.isOpen = false 13 | 14 | function m:entered() 15 | m.isOpen = true 16 | m.alertUuids = hs.fnutils.map(hs.screen.allScreens(), function(screen) 17 | local prompt = string.format("🖥 : %s", 18 | hs.window.focusedWindow():application():title()) 19 | return hs.alert.show(prompt, hs.alert.defaultStyle, screen, true) 20 | end) 21 | 22 | return self 23 | end 24 | 25 | function m:exited() 26 | m.isOpen = false 27 | hs.fnutils.ieach(m.alertUuids, function(uuid) 28 | hs.alert.closeSpecific(uuid) 29 | end) 30 | 31 | return self 32 | end 33 | 34 | function m:toggle() 35 | if m.isOpen then 36 | m:exit() 37 | else 38 | m:enter() 39 | end 40 | 41 | return self 42 | end 43 | 44 | function m:start() 45 | -- disable animations 46 | hs.window.animationDuration = 0 47 | 48 | -- provide alternate escapes 49 | m 50 | :bind('ctrl', '[', function() m:exit() end) 51 | :bind('', 'escape', function() m:exit() end) 52 | 53 | return self 54 | end 55 | 56 | function m:bindHotKeys(mapping) 57 | local spec = { 58 | toggle = hs.fnutils.partial(self.toggle, self) 59 | } 60 | 61 | hs.spoons.bindHotkeysToSpec(spec, mapping) 62 | 63 | return self 64 | end 65 | 66 | return m 67 | -------------------------------------------------------------------------------- /Spoons/HyperModal.spoon/README.md: -------------------------------------------------------------------------------- 1 | # MoveWindows 2 | 3 | MoveWindows is a "moom" style window mover, originally written by 4 | [@tmiller](https://github.com/tmiller/) that I've adapted over the years. 5 | 6 | At it's core, MoveWindows provides a sane set of default vim-style keybindings 7 | to move windows around, but you can override anything at any point. 8 | 9 | Quickstart, setting the mode to `⌘+⇧+M`: 10 | 11 | ```lua 12 | hs.loadSpoon('MoveWindows') 13 | :start() 14 | :bindHotKeys({toggle = {{"command", "shift"}, "m"}}) 15 | ``` 16 | 17 | If you wish to adjust the bindings and "grid", simply set or extend 18 | MoveWindows.grid before calling `MoveWindows:start()`. 19 | 20 | ```lua 21 | MoveWindows = hs.loadSpoon('MoveWindows') 22 | MoveWindows.grid = { 23 | { key='j', unit=hs.geometry.rect(0, 0.5, 1, 0.5) }, 24 | { key='space', unit=hs.layout.maximized } 25 | } 26 | 27 | MoveWindows 28 | :start() 29 | :bindHotKeys({toggle = {{"command", "shift"}, "m"}}) 30 | ``` 31 | 32 | Because MoveWindows is simply a wrapper around 33 | [hs.hotkey.modal](https://www.hammerspoon.org/docs/hs.hotkey.modal), you can 34 | expand it's functionality with other Spoons like 35 | [Split.spoon](https://github.com/evantravers/Split.spoon), or include your own 36 | functionality: 37 | 38 | ```lua 39 | hs.loadSpoon('MoveWindows') 40 | 41 | MoveWindows = spoon.MoveWindows 42 | MoveWindows 43 | :start() 44 | :bind('', ',', function() 45 | hs.window.focusedWindow() 46 | :application() 47 | :selectMenuItem("Tile Window to Left of Screen") 48 | MoveWindows:exit() 49 | end) 50 | :bind('', '.', function() 51 | hs.window.focusedWindow() 52 | :application() 53 | :selectMenuItem("Tile Window to Right of Screen") 54 | MoveWindows:exit() 55 | end) 56 | :bind('', 'v', function() 57 | spoon.Split.split() 58 | MoveWindows:exit() 59 | end) 60 | :bindHotKeys({toggle = {{"command", "shift"}, "m"}}) 61 | ``` 62 | 63 | If you want to use [Hyper.spoon](https://github.com/evantravers/Hyper.spoon), 64 | you can just access `MoveWindows:toggle()` directly: 65 | 66 | ```lua 67 | Hyper:bind({}, 'm', function() MoveWindows:toggle() end) 68 | ``` 69 | 70 | ## Highlight 71 | 72 | MoveWindows also uses `hs.window.highlight` if you have it setup. To see the 73 | window you are about to move highlighted, just set 74 | `hs.window.highlight.ui.overlay=true`. 75 | -------------------------------------------------------------------------------- /Spoons/Teamz.spoon/init.lua: -------------------------------------------------------------------------------- 1 | --- === Teamz === 2 | --- Microsoft Teams is difficult to work with... they don't name their windows 3 | --- consistently. This is a wrapper that keeps track of a Microsoft Teams 4 | --- application, and isolating the hs.window that contains the call. 5 | 6 | local m = { 7 | name = "Teamz", 8 | version = "0.9", 9 | author = "Evan Travers ", 10 | license = "MIT ", 11 | homepage = "https://github.com/evantravers/Teamz.spoon", 12 | } 13 | 14 | --- Teamz:start() -> table 15 | --- Method 16 | --- Starts an hs.application.watcher that listens for Microsoft Teams and 17 | --- stores the first real window created. 18 | --- 19 | --- Returns: 20 | --- * self 21 | function m:start() 22 | m.watcher = hs.application.watcher.new(function(appName, event, hsApp) 23 | if hsApp:bundleID() == 'com.microsoft.teams2' then 24 | if event == hs.application.watcher.launching then 25 | hs.timer.waitUntil(function() 26 | return hsApp:mainWindow() ~= nil and not string.match(hsApp:mainWindow():title(), "Loading") 27 | end, 28 | function() 29 | m.app = hsApp 30 | m.firstWindow = hsApp:mainWindow() 31 | end) 32 | end 33 | end 34 | end) 35 | :start() 36 | 37 | return self 38 | end 39 | 40 | --- Teamz:stop() -> table 41 | --- Method 42 | --- Kills the watcher and destroy it 43 | --- 44 | --- Returns: 45 | --- * self 46 | function m:stop() 47 | m.watcher:stop() 48 | m.watcher = nil 49 | 50 | return self 51 | end 52 | 53 | local function checkAttachment() 54 | if m.app == nil then 55 | hs.application.launchOrFocusByBundleID('com.microsoft.teams2') 56 | m.app = hs.application.find('com.microsoft.teams2') 57 | end 58 | 59 | if m.firstWindow == nil then 60 | local count = 0 61 | hs.fnutils.each(m.app:allWindows(), function() 62 | count = count + 1 63 | end) 64 | 65 | -- if there's only one window, that's it 66 | if count == 1 then 67 | m.firstWindow = m.app:mainWindow() 68 | else 69 | hs.chooser.new(function(choice) 70 | m.firstWindow = hs.window.find(choice.id) 71 | end) 72 | :placeholderText("Which of these is the chat window?") 73 | :choices(hs.fnutils.map(m.app:allWindows(), function(win) 74 | return { 75 | text = win:title(), 76 | id = win:id() 77 | } 78 | end)) 79 | :show() 80 | end 81 | end 82 | end 83 | 84 | --- Teamz.callWindow() -> table 85 | --- Method 86 | --- Returns the hs.window that is most likely going to be the window with the 87 | --- video call in it. I usually wind up using Teamz.callWindow():focus() 88 | --- 89 | --- Returns: 90 | --- * hs.window 91 | function m.callWindow() 92 | checkAttachment() 93 | 94 | return hs.fnutils.find(m.app:allWindows(), function(window) 95 | if window == m.chatWindow() then return false end 96 | if string.match(window:title(), 'Notification') then return false end 97 | if string.match(window:title(), 'in progress') then return false end 98 | return true 99 | end) 100 | end 101 | 102 | --- Teamz.chatWindow() -> table 103 | --- Method 104 | --- Returns the first window launched in Teams. I use it combined with a 105 | --- hs.hotkey binding and hs.window:focus to jump to chat. 106 | --- 107 | --- Returns: 108 | --- * hs.window 109 | function m.chatWindow() 110 | checkAttachment() 111 | 112 | return m.firstWindow 113 | end 114 | 115 | function m.setCallWindow() 116 | m.callWindow = hs.window.focusedWindow() 117 | end 118 | 119 | return m 120 | -------------------------------------------------------------------------------- /configApplications.lua: -------------------------------------------------------------------------------- 1 | return { 2 | ['com.raycast.macos'] = { 3 | bundleID = 'com.raycast.macos', 4 | localBindings = {'c', 'space', 'o'} 5 | }, 6 | ['com.github.wez.wezterm'] = { 7 | bundleID = 'com.github.wez.wezterm', 8 | hyperKey = 'j', 9 | tags = {'coding'} 10 | }, 11 | ['com.brave.Browser'] = { 12 | bundleID = 'com.brave.Browser', 13 | tags = {'browsers'}, 14 | }, 15 | ['org.mozilla.firefox'] = { 16 | bundleID = 'org.mozilla.firefox', 17 | tags = {'browsers'} 18 | }, 19 | ['com.apple.Safari'] = { 20 | bundleID = 'com.apple.Safari', 21 | tags = {'browsers'} 22 | }, 23 | ['com.google.Chrome'] = { 24 | bundleID = 'com.google.Chrome', 25 | tags = {'browsers'} 26 | }, 27 | ['com.kapeli.dashdoc'] = { 28 | bundleID = 'com.kapeli.dashdoc', 29 | hyperKey = 'h', 30 | tags = {'coding'} 31 | }, 32 | ['com.microsoft.teams2'] = { 33 | bundleID = 'com.microsoft.teams2', 34 | tags = {'communication', 'chat'} 35 | }, 36 | ['com.apple.mail'] = { 37 | bundleID = 'com.apple.mail', 38 | hyperKey = 'e', 39 | tags = {'communication', 'distraction'} 40 | }, 41 | ['com.flexibits.fantastical2.mac'] = { 42 | bundleID = 'com.flexibits.fantastical2.mac', 43 | hyperKey = 'y', 44 | localBindings = {'/'}, 45 | tags = {'planning', 'review', 'calendar'}, 46 | whitelisted = true, 47 | }, 48 | ['com.apple.finder'] = { 49 | bundleID = 'com.apple.finder', 50 | hyperKey = 'f' 51 | }, 52 | ['com.hnc.Discord'] = { 53 | bundleID = 'com.hnc.Discord', 54 | tags = {'distraction', 'chat'}, 55 | }, 56 | ['com.tinyspeck.slackmacgap'] = { 57 | bundleID = 'com.tinyspeck.slackmacgap', 58 | tags = {'distraction', 'communication', 'chat'}, 59 | }, 60 | ['com.tapbots.Tweetbot3Mac'] = { 61 | bundleID = 'com.tapbots.Tweetbot3Mac', 62 | tags = {'distraction', 'socialmedia'}, 63 | }, 64 | ['com.culturedcode.ThingsMac'] = { 65 | bundleID = 'com.culturedcode.ThingsMac', 66 | hyperKey = 't', 67 | tags = {'planning', 'review', 'tasks'}, 68 | whitelisted = true, 69 | localBindings = {',', '.'}, 70 | }, 71 | ['com.agiletortoise.Drafts-OSX'] = { 72 | bundleID = 'com.agiletortoise.Drafts-OSX', 73 | hyperKey ='d', 74 | tags = {'review', 'writing', 'research', 'notes'}, 75 | whitelisted = true, 76 | localBindings = {'x', ';'} 77 | }, 78 | ['com.joehribar.toggl'] = { 79 | bundleID = 'com.joehribar.toggl', 80 | hyperKey = 'n' 81 | }, 82 | ['com.ideasoncanvas.mindnode.macos'] = { 83 | bundleID = 'com.ideasoncanvas.mindnode.macos', 84 | tags = {'research'}, 85 | }, 86 | ['com.apple.MobileSMS'] = { 87 | bundleID = 'com.apple.MobileSMS', 88 | tags = {'communication', 'distraction', 'personal'}, 89 | }, 90 | ['com.valvesoftware.steam'] = { 91 | bundleID = 'com.valvesoftware.steam', 92 | tags = {'distraction'} 93 | }, 94 | ['net.battle.app'] = { 95 | bundleID = 'net.battle.app', 96 | tags = {'distraction'} 97 | }, 98 | ['com.spotify.client'] = { 99 | bundleID = 'com.spotify.client' 100 | }, 101 | ['com.figma.Desktop'] = { 102 | bundleID = 'com.figma.Desktop', 103 | tags = {'design'}, 104 | }, 105 | ['com.reederapp.5.macOS'] = { 106 | bundleID = 'com.reederapp.5.macOS', 107 | tags = {'distraction'}, 108 | }, 109 | ['md.obsidian'] = { 110 | bundleID = 'md.obsidian', 111 | hyperKey = 'g', 112 | tags = {'research', 'notes'}, 113 | }, 114 | ['us.zoom.xos'] = { 115 | bundleID = 'us.zoom.xos', 116 | }, 117 | ['org.whispersystems.signal-desktop'] = { 118 | bundleID = 'org.whispersystems.signal-desktop', 119 | tags = {'distraction', 'communication', 'personal'} 120 | }, 121 | ['ru.keepcoder.Telegram'] = { 122 | bundleID = 'ru.keepcoder.Telegram', 123 | tags = {'distraction', 'communication', 'personal'} 124 | }, 125 | ['com.surteesstudios.Bartender'] = { 126 | bundleID = 'com.surteesstudios.Bartender', 127 | localBindings = {'b'} 128 | }, 129 | ['com.loom.desktop'] = { 130 | bundleID = 'com.loom.desktop', 131 | }, 132 | ['company.thebrowser.Browser'] = { 133 | bundleID = 'company.thebrowser.Browser', 134 | tags = {'browsers'} 135 | }, 136 | ['com.dexterleng.Homerow'] = { 137 | bundleID = 'com.dexterleng.Homerow', 138 | localBindings = {'l'} 139 | }, 140 | ['com.flexibits.cardhop.mac'] = { 141 | bundleID = 'com.flexibits.cardhop.mac', 142 | localBindings = {'u'} 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /init.lua: -------------------------------------------------------------------------------- 1 | hs.loadSpoon('Hyper') 2 | hs.loadSpoon('Headspace'):start() 3 | hs.loadSpoon('Teamz'):start() 4 | hs.loadSpoon('HyperModal') 5 | 6 | IsDocked = function() 7 | return hs.fnutils.some(hs.usb.attachedDevices(), function(device) 8 | return device.productName == "CalDigit Thunderbolt 3 Audio" 9 | end) 10 | end 11 | 12 | Config = {} 13 | Config.applications = require('configApplications') 14 | 15 | Hyper = spoon.Hyper 16 | 17 | Hyper:bindHotKeys({hyperKey = {{}, 'F19'}}) 18 | 19 | hs.fnutils.each(Config.applications, function(appConfig) 20 | if appConfig.hyperKey then 21 | Hyper:bind({}, appConfig.hyperKey, function() hs.application.launchOrFocusByBundleID(appConfig.bundleID) end) 22 | end 23 | if appConfig.localBindings then 24 | hs.fnutils.each(appConfig.localBindings, function(key) 25 | Hyper:bindPassThrough(key, appConfig.bundleID) 26 | end) 27 | end 28 | end) 29 | 30 | -- provide the ability to override config per computer 31 | if (hs.fs.displayName('./localConfig.lua')) then 32 | require('localConfig') 33 | end 34 | 35 | -- https://github.com/dmitriiminaev/Hammerspoon-HyperModal/blob/master/.hammerspoon/yabai.lua 36 | local yabai = function(args, completion) 37 | local yabai_output = "" 38 | local yabai_error = "" 39 | -- Runs in background very fast 40 | local yabai_task = hs.task.new("/run/current-system/sw/bin/yabai", function(err, stdout, stderr) 41 | print() 42 | end, function(task, stdout, stderr) 43 | -- print("stdout:"..stdout, "stderr:"..stderr) 44 | if stdout ~= nil then 45 | yabai_output = yabai_output .. stdout 46 | end 47 | if stderr ~= nil then 48 | yabai_error = yabai_error .. stderr 49 | end 50 | return true 51 | end, args) 52 | if type(completion) == "function" then 53 | yabai_task:setCallback(function() 54 | completion(yabai_output, yabai_error/run/current-system/sw/bin/yabai) 55 | end) 56 | end 57 | yabai_task:start() 58 | end 59 | 60 | HyperModal = spoon.HyperModal 61 | HyperModal 62 | :start() 63 | :bind('', "1", function() 64 | yabai({"-m", "window", "--swap", "first"}) 65 | HyperModal:exit() 66 | end) 67 | :bind('', "z", function() 68 | yabai({"-m", "window", "--toggle", "zoom-parent"}) 69 | HyperModal:exit() 70 | end) 71 | :bind('', "v", function() 72 | yabai({"-m", "space", "--mirror", "y-axis"}) 73 | HyperModal:exit() 74 | end) 75 | :bind('', "x", function() 76 | yabai({"-m", "window", "--toggle", "split"}) 77 | HyperModal:exit() 78 | end) 79 | :bind('', "space", function() 80 | yabai({"-m", "window", "--toggle", "zoom-fullscreen"}) 81 | HyperModal:exit() 82 | end) 83 | :bind('', "h", function() 84 | yabai({"-m", "window", "--swap", "west"}) 85 | HyperModal:exit() 86 | end) 87 | :bind('', "j", function() 88 | yabai({"-m", "window", "--swap", "south"}) 89 | HyperModal:exit() 90 | end) 91 | :bind('', "k", function() 92 | yabai({"-m", "window", "--swap", "north"}) 93 | HyperModal:exit() 94 | end) 95 | :bind('', "l", function() 96 | yabai({"-m", "window", "--swap", "east"}) 97 | HyperModal:exit() 98 | end) 99 | :bind({"alt"}, "h", function() 100 | yabai({"-m", "window", "--warp", "west"}) 101 | HyperModal:exit() 102 | end) 103 | :bind({"alt"}, "j", function() 104 | yabai({"-m", "window", "--warp", "south"}) 105 | HyperModal:exit() 106 | end) 107 | :bind({"alt"}, "k", function() 108 | yabai({"-m", "window", "--warp", "north"}) 109 | HyperModal:exit() 110 | end) 111 | :bind({"alt"}, "l", function() 112 | yabai({"-m", "window", "--warp", "east"}) 113 | HyperModal:exit() 114 | end) 115 | :bind({'shift'}, "l", function() 116 | yabai({"-m", "window", "--display", "east"}) 117 | HyperModal:exit() 118 | end) 119 | :bind({'shift'}, "h", function() 120 | yabai({"-m", "window", "--display", "west"}) 121 | HyperModal:exit() 122 | end) 123 | :bind("", "s", function() 124 | yabai({"-m", "window", "--stack", "mouse"}) 125 | HyperModal:exit() 126 | end) 127 | :bind('', "r", function() 128 | yabai({"-m", "space", "--balance"}) 129 | HyperModal:exit() 130 | end) 131 | :bind({"shift"}, "b", function() 132 | yabai({"-m", "space", "--layout", "stack"}) 133 | HyperModal:exit() 134 | end) 135 | :bind("", "b", function() 136 | yabai({"-m", "space", "--layout", "bsp"}) 137 | HyperModal:exit() 138 | end) 139 | :bind('', ';', function() 140 | hs.urlevent.openURL("raycast://extensions/raycast/system/toggle-system-appearance") 141 | HyperModal:exit() 142 | end) 143 | 144 | Hyper:bind({}, 'm', function() HyperModal:toggle() end) 145 | 146 | local brave = require('brave') 147 | brave.start(Config) 148 | 149 | -- Random bindings 150 | Hyper:bind({}, 'r', nil, function() 151 | hs.application.launchOrFocusByBundleID('org.hammerspoon.Hammerspoon') 152 | end) 153 | Hyper:bind({'shift'}, 'r', nil, function() hs.reload() end) 154 | 155 | local chooseFromGroup = function(choice) 156 | local name = hs.application.nameForBundleID(choice.bundleID) 157 | 158 | hs.notify.new(nil) 159 | :title("Switching ✦-" .. choice.key .. " to " .. name) 160 | :contentImage(hs.image.imageFromAppBundle(choice.bundleID)) 161 | :send() 162 | 163 | hs.settings.set("group." .. choice.tag, choice.bundleID) 164 | hs.application.launchOrFocusByBundleID(choice.bundleID) 165 | end 166 | 167 | local hyperGroup = function(key, tag) 168 | Hyper:bind({}, key, nil, function() 169 | hs.application.launchOrFocusByBundleID(hs.settings.get("group." .. tag)) 170 | end) 171 | Hyper:bind({'option'}, key, nil, function() 172 | local group = 173 | hs.fnutils.filter(Config.applications, function(app) 174 | return app.tags and 175 | hs.fnutils.contains(app.tags, tag) and 176 | app.bundleID ~= hs.settings.get("group." .. tag) 177 | end) 178 | 179 | local choices = {} 180 | hs.fnutils.each(group, function(app) 181 | table.insert(choices, { 182 | text = hs.application.nameForBundleID(app.bundleID), 183 | image = hs.image.imageFromAppBundle(app.bundleID), 184 | bundleID = app.bundleID, 185 | key = key, 186 | tag = tag 187 | }) 188 | end) 189 | 190 | if #choices == 1 then 191 | chooseFromGroup(choices[1]) 192 | else 193 | hs.chooser.new(chooseFromGroup) 194 | :placeholderText("Choose an application for hyper+" .. key .. ":") 195 | :choices(choices) 196 | :show() 197 | end 198 | end) 199 | end 200 | 201 | hyperGroup('q', 'personal') 202 | hyperGroup('k', 'browsers') 203 | hyperGroup('i', 'chat') 204 | 205 | -- Jump to google hangout or zoom 206 | Z_count = 0 207 | Hyper:bind({}, 'z', nil, function() 208 | Z_count = Z_count + 1 209 | 210 | hs.timer.doAfter(0.2, function() 211 | Z_count = 0 212 | end) 213 | 214 | if Z_count == 2 then 215 | spoon.ElgatoKey:toggle() 216 | else 217 | -- start a timer 218 | -- if not pressed again then 219 | if hs.application.find('us.zoom.xos') then 220 | hs.application.launchOrFocusByBundleID('us.zoom.xos') 221 | elseif hs.application.find('com.microsoft.teams2') then 222 | hs.application.launchOrFocusByBundleID('com.microsoft.teams2') 223 | local call = hs.settings.get("call") 224 | call:focus() 225 | else 226 | brave.jump("meet.google.com|hangouts.google.com.call") 227 | end 228 | end 229 | end) 230 | 231 | -- Jump to figma 232 | Hyper:bind({}, 'v', nil, function() 233 | if hs.application.find('com.figma.Desktop') then 234 | hs.application.launchOrFocusByBundleID('com.figma.Desktop') 235 | elseif hs.application.find('com.electron.realtimeboard') then 236 | hs.application.launchOrFocusByBundleID('com.electron.realtimeboard') 237 | elseif hs.application.find('com.adobe.LightroomClassicCC7') then 238 | hs.application.launchOrFocusByBundleID('com.adobe.LightroomClassicCC7') 239 | else 240 | brave.jump("lucidchart.com|figma.com") 241 | end 242 | end) 243 | 244 | Hyper:bind({}, 'p', nil, function() 245 | local _success, projects, _output = hs.osascript.javascript([[ 246 | (function() { 247 | var Things = Application("Things"); 248 | var divider = /## Resources/; 249 | 250 | Things.launch(); 251 | 252 | let getUrls = function(proj) { 253 | if (proj.notes() && proj.notes().match(divider)) { 254 | return proj.notes() 255 | .split(divider)[1] 256 | .replace(divider, "") 257 | .split("\n") 258 | .map(str => str.replace(/^- /, "")) 259 | .filter(s => s != "") 260 | } 261 | else { 262 | return false; 263 | } 264 | } 265 | 266 | let projects = 267 | Things 268 | .projects() 269 | .filter(t => t.status() == "open") 270 | .map(function(proj) { 271 | return { 272 | text: proj.name(), 273 | subText: proj.area().name(), 274 | urls: getUrls(proj), 275 | id: proj.id() 276 | } 277 | }) 278 | .filter(function(proj) { 279 | return proj.urls 280 | }); 281 | 282 | return projects; 283 | })(); 284 | ]]) 285 | 286 | hs.chooser.new(function(choice) 287 | hs.fnutils.each(choice.urls, hs.urlevent.openURL) 288 | hs.urlevent.openURL("things:///show?id=" .. choice.id) 289 | end) 290 | :placeholderText("Choose a project…") 291 | :choices(projects) 292 | :show() 293 | end) 294 | 295 | require('browserSnip') 296 | 297 | -- sync tags to OSX 298 | -- Currently unused, because of permissions 299 | Config.appsync = function() 300 | hs.fnutils.map(Config.applications, function(a) 301 | if a["tags"] and a["bundleID"] then 302 | local app_path = hs.application.pathForBundleID(a["bundleID"]) -- may not be installed! 303 | if app_path then 304 | if not a["bundleID"]:find("apple") then 305 | print("attempting " .. app_path) 306 | print(hs.inspect(a["tags"])) 307 | hs.fs.tagsSet(app_path, a["tags"]) 308 | end 309 | end 310 | end 311 | end) 312 | end 313 | 314 | -- change audio settings based on output 315 | hs.audiodevice.watcher.setCallback(function(event) 316 | if event == "dOut" then 317 | local name = hs.audiodevice.defaultOutputDevice():name() 318 | if name == "WH-1000XM4" then 319 | hs.shortcuts.run("XM4") 320 | end 321 | if name == "MacBook Pro Speakers" then 322 | hs.shortcuts.run("Macbook Pro Speakers") 323 | end 324 | end 325 | end) 326 | hs.audiodevice.watcher.start() 327 | 328 | Hyper:bind({}, 'w', nil, function() 329 | hs.window.focusedWindow():application():selectMenuItem({"Edit", "Start Dictation"}) 330 | end) 331 | --------------------------------------------------------------------------------