A while ago I had posted how I use Hammerspoon to switch apps.
Essentially I have a list of shortcuts defined like this:
1 2 3 4 5 6 7 8 |
ctrlCmdShortcuts = { {"A", "The Archive"}, {"C", "Visual Studio Code; Calendar"}, {"F", "Firefox; Finder"}, {"T", "Things3"}, {"O", "Microsoft Outlook"}, {"W", "WorkFlowy; Microsoft Word"}, } |
And I can press Ctrl+Cmd+C
to switch to Visual Studio Code, Ctrl+Cmd+F
to switch to Firefox, and so on. That’s how I began things, but as I detailed in that post I then extended this to switch between apps. Thus, in the above example, the first time I press Ctrl+Cmd+W
I switch to Workflowy, but if I am already on Workflowy and I press these keys it will take me to Microsoft Word. Which is so neat coz I have an app switcher of sorts that just switches between these apps.
Moreover, if there are multiple windows it will switch between these. So Ctrl+Cmd+O
will take me to Outlook, press it again and it will either do nothing, or if there’s another window it will switch to that. Press again, and if there’s yet another window it will take me that, else take me back to the first window. Very neat!
There is a catch in that if there are more than one window of an application, and I have defined a second one too for that key, it won’t switch to that second application. So Ctrl+Cmd+C
will take me to Visual Studio Code, pressing again will take me to the second window if it exists, pressing again will take me back to the first window (assuming only two windows). I won’t ever go to Calendar until I have just one window of Visual Studio Code.
The keys can also launch an application if it’s not open. For instance, press Ctrl+Cmd+O
and if Outlook is not open it will launch and switch to it. :) This behaviour is what I now wanted to fine tune. I came across this blog post by Christian Sellig where here uses Hammerspoon to switch between XCode windows and if XCode isn’t already launched it won’t open it. That’s a good idea, but I wanted to take it one step further and have it as an optional thing.
That’s to say, with things like Outlook which are work related, I don’t want to press Ctrl+Cmd+O
on a weekend and suddenly be faced with work emails; but I am ok with Ctrl+Cmd+F
launching Firefox if it isn’t running.
So I came up with this variant:
1 2 3 4 5 6 7 8 |
ctrlCmdShortcuts = { {"A", "The Archive"}, {"C", "Visual Studio Code; Calendar*"}, {"F", "Firefox; Finder"}, {"T", "Things3"}, {"O", "Microsoft Outlook*"}, {"W", "WorkFlowy; Microsoft Word*"}, } |
If a program has an asterisk next to it, don’t launch it if it isn’t already running. Else feel free to lauch it.
To achieve this I had to modify my previous script a bit.
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 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 |
-- 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 = {} -- the ..xxx.. notation is how you do string interpolation (i.e. put a variable in a string) -- so we are have a regex [^xxx]+... which means any character that is not one or more instances of xxx --[[ appList = "Microsoft Outlook; Microsoft Word" separator = ";" for str in string.gmatch(appList, "([^"..separator.."]+)") do print(str) sanitizedAppName = (string.gsub(str, '^%s+', '')) print(sanitizedAppName) end output: Microsoft Outlook Microsoft Outlook Microsoft Word Microsoft Word ]]-- -- notice it includes the space; so we remove that too later 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 -- 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 -- Launch, Focus or Rotate application -- From https://apple.stackexchange.com/a/455010 -- Modified by me local function launchOrFocusOrRotate(appList) -- Get the first app from the list local appFromList = getFirstAppFromList(appList) -- thanks http://lua-users.org/wiki/PatternsTutorial local app if string.match(appFromList,'*$') then -- check if an app is already running. the app name is the name we got from the list, with the * removed app = string.gsub(appFromList,"*","") local appFind = hs.application.find(app) if appFind == nil then -- thanks http://lua-users.org/wiki/StringInterpolation for how to include variable in string local message = string.format("👻 %s is not open", app) hs.notify.new({ title = message, informativeText = "Manually launch " ..app.. " and then try if you want to switch to that"}):send() return end else app = appFromList end 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 appFromList = getAppToLaunchFromList(appList, app) -- thanks http://lua-users.org/wiki/PatternsTutorial local app if string.match(appFromList,'*$') then -- check if an app is already running. the app name is the name we got from the list, with the * removed app = string.gsub(appFromList,"*","") local appFind = hs.application.find(app) if appFind == nil then -- thanks http://lua-users.org/wiki/StringInterpolation for how to include variable in string local message = string.format("👻 %s is not open", app) hs.notify.new({ title = message, informativeText = "Manually launch " ..app.. " and then try if you want to switch to that"}):send() return end else app = appFromList end hs.application.launchOrFocus(app) -- Finder needs special treatment -- From https://zhiye.li/hammerspoon-use-the-keyboard-shortcuts-to-launch-apps-a7c59ab3d92 if app == 'Finder' then hs.appfinder.appFromName(app):activate() end end else -- if not focused hs.application.launchOrFocus(app) -- Finder needs special treatment -- From https://zhiye.li/hammerspoon-use-the-keyboard-shortcuts-to-launch-apps-a7c59ab3d92 if app == 'Finder' then hs.appfinder.appFromName(app):activate() end end end |
I’ve highlighted the parts I added. But I changed the other code too slightly so there will be some difference to what I put in the previous post. Basically I added code to check for the existence of the asterisk and accordingly not do anything.
I love programming in Lua. I haven’t done much except with Hammerspoon, but it’s very neat in a way and I like it.