A while ago I posted about Hammerspoon. I never got much time to play with it until this past weekend though.
One of the things I wanted to do with it is use is as an app launcher. I keep switching between apps (nothing new there!) but rather than Cmd+Tab my way through a list of apps I wanted an easy way of going to an app I want. Sort of having global hotkeys for them, if you know what I mean. Bear, for instance, let’s me assign “Ctrl+Cmd+B” to it so I can press these keys from anywhere and Bear opens up. If I could get this functionality in place for all apps, I could say assign “Ctrl+Cmd+C” for Visual Studio Code, “Ctrl+Cmd+O” for Outlook, and easily switch to that app without “Cmd+Tab”-ing through everything else.
This would also be useful for apps like Bitwarden which very irritatingly don’t have the option for a global hotkey. Currently, any time I want to lookup a password or use the generator I have to click in the menu bar to launch it. But if I could just assign a hotkey to launch it, that would be great.
One app I know that can do something like this is rcmd. It’s a good app, and the developer is very responsive and helpful too. In my initial enthusiasm for it, I bought it from the App Store after giving it a quick try – a decision I regret in retrospect, as it’s not very flexible. Yes, I can assign the right cmd key (rcmd) and a key to launch apps – so, for instance, “RCmd+C” to launch Visual Studio Code – but it’s not very flexible. I can’t, for example, use “C” as in my example above as rcmd defaults to “V”. There is a way to make it use “C” but it’s a bit of a round-about way, and I felt like the app fights against how I wanted to do things.
Anyway, this post is not about rcmd but Hammerspoon. How can I use Hammerspoon to quickly launch apps? ☺️
Quite easy actually, as it turns out. Thanks to this SO post I found that the following bit of code does it neatly:
1 2 3 4 5 6 7 8 9 10 |
ctrlCmdShortcuts = { {"C", "Calendar"}, {"M", "Mail"}, } for i,shortcut in ipairs(ctrlCmdShortcuts) do hs.hotkey.bind({"ctrl","cmd"}, shortcut[1], function() hs.application.launchOrFocus(shortcut[2]) end) end |
Hah! Nice. The above maps “Ctrl+Cmd+C” to Calendar, and “Ctrl+Cmd+M” to Mail. I extended it a bit to also use “Cmd+Shift” for when a letter is already taken – e.g. “Cmd+Shift+C” to launch Visual Studio Code. It’s easy to do that, just duplicate the code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
ctrlCmdShortcuts = { {"C", "Calendar"}, {"M", "Mail"}, } for i,shortcut in ipairs(ctrlCmdShortcuts) do hs.hotkey.bind({"ctrl","cmd"}, shortcut[1], function() hs.application.launchOrFocus(shortcut[2]) end) end cmdShiftShortcuts = { {"C", "Visual Studio Code"}, } for i,shortcut in ipairs(cmdShiftShortcuts) do hs.hotkey.bind({"cmd","shift"}, shortcut[1], function() hs.application.launchOrFocus(shortcut[2]) end) end |
Lovely!
I had done this much soon after I discovered Hammerspoon and that’s what I was doing until the weekend.
Mod 1
But obviously I wanted more. I don’t want to just launch an app, I also want to be able to switch between its Windows. For instance, I usually have 2 windows of Visual Studio Code and 2 windows of Firefox. If I do “Ctrl+Cmd+C” and reach Code, it will show one of the windows. Pressing it again won’t go to the next window, so I have to resort to the mouse of “Cmd+Tab” (or “Cmd+§” which I have mapped in Contexts to switch between windows of apps) – not ideal.
How can I make Hammerspoon switch between open windows of an app if I keep pressing the same keys? Again, SO to the rescue. Found this code there:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
-- launch, focus or rotate application local function launchOrFocusOrRotate(app) local focusedWindow = hs.window.focusedWindow() -- If already focused, try to find the next window if focusedWindow and focusedWindow:application():name() == app then local appWindows = hs.application.get(app):allWindows() if #appWindows > 0 then -- It seems that this list order changes after one window get focused, -- let's directly bring the last one to focus every time appWindows[#appWindows]:focus() else -- this should not happen, but just in case hs.application.launchOrFocus(app) end else -- if not focused hs.application.launchOrFocus(app) end end |
On the weekend I spent some time figuring out how this code works and slightly modified it. The result is:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 |
local function launchOrFocusOrRotate(app) local focusedWindow = hs.window.focusedWindow() -- Output of the above is an hs.window object -- I can get the application it belongs to via the :application() method -- See https://www.hammerspoon.org/docs/hs.window.html#application local focusedWindowApp = focusedWindow:application() -- This returns an hs.application object -- Get the name of this application; this isn't really useful fof us as launchOrFocus needs the app name on disk -- I do use it below, further on... local focusedWindowAppName = focusedWindowApp:name() -- This gives the path - /Applications/<application>.app local focusedWindowPath = focusedWindowApp:path() -- I need to extract <application> from that local appNameOnDisk = string.gsub(focusedWindowPath,"/Applications/", "") local appNameOnDisk = string.gsub(appNameOnDisk,".app", "") -- Finder has this as its path local appNameOnDisk = string.gsub(appNameOnDisk,"/System/Library/CoreServices/","") -- If already focused, try to find the next window if focusedWindow and appNameOnDisk == app then -- hs.application.get needs the name as per hs.application:name() and not the name on disk -- It can also take pid or bundle, but that doesn't help here -- Since I have the name already from above, I can use that though local appWindows = hs.application.get(focusedWindowAppName):allWindows() -- https://www.hammerspoon.org/docs/hs.application.html#allWindows -- A table of zero or more hs.window objects owned by the application. From the current space. if #appWindows > 0 then -- It seems that this list order changes after one window get focused, -- Let's directly bring the last one to focus every time -- https://www.hammerspoon.org/docs/hs.window.html#focus if app == "Finder" then -- If the app is Finder the window count returned is one more than the actual count, so I subtract appWindows[#appWindows-1]:focus() else appWindows[#appWindows]:focus() end else -- this should not happen, but just in case hs.application.launchOrFocus(app) end else -- if not focused hs.application.launchOrFocus(app) end end ctrlCmdShortcuts = { {"C", "Calendar"}, {"M", "Mail"}, } for i,shortcut in ipairs(ctrlCmdShortcuts) do hs.hotkey.bind({"ctrl","cmd"}, shortcut[1], function() -- hs.application.launchOrFocus(shortcut[2]) launchOrFocusOrRotate(shortcut[2]) end) end |
I had to modify it from what I found on SO because the loop I had got going (what I found in the first SO post basically) had the app names as on disk (e.g. “Visual Studio Code”) – because that’s what Hammerspoon’s hs.application.launchOrFocus()
uses but that’s not the name returned by hs.window.focusedWindow()
(e.g. “Code”) when you try to get the app name of the focussed window. So I find the path of the application returned, then extract the name on disk from that, and use that to check if the focussed window is of an app I am interested in.
I also made some changes to cater to Finder as it has a different path. There could be other system apps too like this I suppose, got to cater to them later.
Honestly, I am quite pleased with this. I have no idea of Lua or Hammerspoon, but I spent a few hours figuring out what’s happening and Googled my way through making the modifications.
Mod 2
Fast forward to today. I am on holiday and there’s this enhancement to the above that I have been thinking of the past few days so I figured I should try and tackle it.
It’s good I can switch between multiple windows, but can I also modify this to switch between multiple apps? Like say, I assign both Visual Studio Code and Calendar to the same key and when I press it I want Hammerspoon to switch between just these two.
I am not sure how to tackle this though. Especially with the windows and apps. Say in the above example I have two windows of Visual Studio Code open. When I press “Ctrl+Cmd+C” it will take me to the first window. Press again, and it will take me to the second window. Press again… and what should it do? Take me to Calendar or back to the first window? If there was some way to capture the state that I was previously on the first window, now the second window, and I am still pressing the keys so I probably want to go to Calendar now then I could implement something to do that. But we are not keeping state anywhere, and it’s going to be too complicated doing that too I think. Instead I figured I should keep things simple and take the policy that if I switch to an app and it has more than one window then I keep to that app irrespective of whether any other app is assigned the key. But if that app has only one window then I can switch between apps. Makes sense?
So, to begin with this is how I’ll define the mappings:
1 2 3 4 5 6 7 8 9 10 11 |
ctrlCmdShortcuts = { {"C", "Visual Studio Code; Calendar"}, {"M", "Mail"}, } for i,shortcut in ipairs(ctrlCmdShortcuts) do hs.hotkey.bind({"ctrl","cmd"}, shortcut[1], function() -- hs.application.launchOrFocus(shortcut[2]) launchOrFocusOrRotate(shortcut[2]) end) end |
Multiple apps are separated by semi-colons.
What next? Here’s what I was talking about above, in a better format:
- If I press a key combo, switch to that app – a single app, or the first one in the list if there’s more than one.
- So I need a way of splitting and getting the first item in a list of apps.
- If I am already in that app, check if it has more than one window open
- If yes, switch to the other window.
- If no, do I have more than one app in my list?
- If yes, then switch to the next app.
- So I need a way of finding this app position in my list and then go to the next one. But if it’s the last one, then launch the first app.
- If no, then do nothing. Or just launch the app anyway.
- If yes, then switch to the next app.
Inspired by this SO post which shows how to split a string I made this function to begin with:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
-- Takes a list of apps (appList) and appName and separator (defaults to ;) -- Tells me what app to launch. Answer could be appName itself. local function getAppToLaunchFromList(appList, appName, separator) -- If no separator is specified assume it is a semi-colon if separator == nil then separator = ';' end local position = 0 local counter = 1 local tokens = {} for str in string.gmatch(appList, "([^"..separator.."]+)") do -- Sanitize the name by removing any spaces before the name... coz you would enter "abc; def" but the app name is actually "def" -- I must put the whole thing in brackets coz else the output is the replace string followed by the number of times a replacement was made -- https://www.lua.org/manual/5.4/manual.html#3.4.12 sanitizedAppName = (string.gsub(str, '^%s+', '')) table.insert(tokens, sanitizedAppName) if sanitizedAppName == appName then -- If we match the app name set the position to that position = counter else -- Else keep incrementing the counter until the end counter = counter + 1 end end -- If position is 0 it means we didn't find anything if position == 0 then return nil else if position == #tokens then return tokens[1] else return tokens[position+1] end end end |
It takes a list of apps – “Visual Studio Code; Code”, and the currently focussed app (“Visual Studio Code”, for example), and a separator character (defaults to the semi-colon) and it returns the next app I should launch. In this case it will be “Calendar” but if the currently focussed app was “Calendar” it will return “Visual Studio Code”.
It’s pretty simple, but as a Lua newbie I had to Google and read some docs (example & example) to make sense of things. It helped that I am already aware of things like regular expressions and have a programming/ scripting background.
Next I made the following function:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
-- Returns the first app in the list of apps local function getFirstAppFromList(appList, separator) -- If no separator is specified assume it is a semi-colon if separator == nil then separator = ';' end -- Check if the appList has the separator; if not we know it's a single entry if string.find(appList, "([^"..separator.."]+)") then -- Replace ; followed by whatever with nothing -- Got to enclose the whole thing in () for reasons I mention in the other function return (string.gsub(appList, ";.*", '')) else return appList end end |
This returns the first app in a list of apps; or the single entry if it’s just one app.
With this I was able to modify the previous function:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
local function launchOrFocusOrRotate(appList) -- Get the first app from the list local app = getFirstAppFromList(appList) local focusedWindow = hs.window.focusedWindow() -- Output of the above is an hs.window object -- I can get the application it belongs to via the :application() method -- See https://www.hammerspoon.org/docs/hs.window.html#application local focusedWindowApp = focusedWindow:application() -- This returns an hs.application object -- Get the name of this application; this isn't really useful for us as launchOrFocus needs the app name on disk -- I do use it below, further on... local focusedWindowAppName = focusedWindowApp:name() -- This gives the path - /Applications/<application>.app local focusedWindowPath = focusedWindowApp:path() -- I need to extract <application> from that local appNameOnDisk = string.gsub(focusedWindowPath,"/Applications/", "") local appNameOnDisk = string.gsub(appNameOnDisk,".app", "") -- Finder has this as its path local appNameOnDisk = string.gsub(appNameOnDisk,"/System/Library/CoreServices/","") -- If already focused, try to find the next window if focusedWindow and appNameOnDisk == app then -- hs.application.get needs the name as per hs.application:name() and not the name on disk -- It can also take pid or bundle, but that doesn't help here -- Since I have the name already from above, I can use that though local appWindows = hs.application.get(focusedWindowAppName):allWindows() -- https://www.hammerspoon.org/docs/hs.application.html#allWindows -- A table of zero or more hs.window objects owned by the application. From the current space. -- Does the app have more than 1 window, if so switch between them if #appWindows > 1 then -- It seems that this list order changes after one window get focused, -- Let's directly bring the last one to focus every time -- https://www.hammerspoon.org/docs/hs.window.html#focus if app == "Finder" then -- If the app is Finder the window count returned is one more than the actual count, so I subtract appWindows[#appWindows-1]:focus() else appWindows[#appWindows]:focus() end else -- The app doesn't have more than one window, but we are focussed on it and still pressing the key -- So let's switch to any other app in that list if present app = getAppToLaunchFromList(appList, app) hs.application.launchOrFocus(app) end else -- if not focused hs.application.launchOrFocus(app) end end |
I just had to make some minor changes to my existing code to use the new functions I created – which I highlighted above. Here’s what the new functions do in the context of what I wrote above:
- If I press a key combo, switch to that app – a single app, or the first one in the list if there’s more than one.
- So I need a way of splitting and getting the first item in a list of apps. This is what
getFirstAppFromList
does.
- So I need a way of splitting and getting the first item in a list of apps. This is what
- If I am already in that app, check if it has more than one window open
- If yes, switch to the other window.
- If no, do I have more than one app in my list?
- If yes, then switch to the next app.
- So I need a way of finding this app position in my list and then go to the next one. But if it’s the last one, then launch the first app.
-
I created
getAppToLaunchFromList
which will do this. It will return the app if it’s the only one in the list, or the next one.
- If no, then do nothing. Or just launch the app anyway.
- If yes, then switch to the next app.
That’s all for now!