{"id":7364,"date":"2023-08-06T19:12:22","date_gmt":"2023-08-06T18:12:22","guid":{"rendered":"https:\/\/rakhesh.com\/?p=7364"},"modified":"2023-08-06T19:12:22","modified_gmt":"2023-08-06T18:12:22","slug":"using-hammerspoon-to-switch-apps-part-2","status":"publish","type":"post","link":"https:\/\/rakhesh.com\/coding\/using-hammerspoon-to-switch-apps-part-2\/","title":{"rendered":"Using Hammerspoon to switch apps (part 2)"},"content":{"rendered":"

A while ago I had posted how I use Hammerspoon to switch apps<\/a>.<\/p>\n

Essentially I have a list of shortcuts defined like this:<\/p>\n

ctrlCmdShortcuts = {\r\n  {\"A\", \"The Archive\"},\r\n  {\"C\", \"Visual Studio Code; Calendar\"},\r\n  {\"F\", \"Firefox; Finder\"},\r\n  {\"T\", \"Things3\"},\r\n  {\"O\", \"Microsoft Outlook\"},\r\n  {\"W\", \"WorkFlowy; Microsoft Word\"},\r\n}<\/pre>\n

And I can press Ctrl+Cmd+C<\/code> to switch to Visual Studio Code, Ctrl+Cmd+F<\/code> 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<\/code> 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.<\/p>\n

Moreover, if there are multiple windows it will switch between these. So Ctrl+Cmd+O<\/code> 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!<\/p>\n

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<\/code> 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.<\/em><\/p>\n

The keys can also launch an application if it’s not open. For instance, press Ctrl+Cmd+O<\/code> 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<\/a> 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.<\/p>\n

That’s to say, with things like Outlook which are work related, I don’t want to press Ctrl+Cmd+O<\/code> on a weekend and suddenly be faced with work emails; but I am ok with Ctrl+Cmd+F<\/code> launching Firefox if it isn’t running.<\/p>\n

So I came up with this variant:<\/p>\n

ctrlCmdShortcuts = {\r\n  {\"A\", \"The Archive\"},\r\n  {\"C\", \"Visual Studio Code; Calendar*\"},\r\n  {\"F\", \"Firefox; Finder\"},\r\n  {\"T\", \"Things3\"},\r\n  {\"O\", \"Microsoft Outlook*\"},\r\n  {\"W\", \"WorkFlowy; Microsoft Word*\"},\r\n}<\/pre>\n

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.<\/p>\n

To achieve this I had to modify my previous script a bit.<\/p>\n

-- Takes a list of apps (appList) and appName and separator (defaults to ;)\r\n-- Tells me what app to launch. Answer could be appName itself.\r\nlocal function getAppToLaunchFromList(appList, appName, separator)\r\n  -- If no separator is specified assume it is a semi-colon\r\n  if separator == nil then\r\n    separator = ';'\r\n  end\r\n\r\n  local position = 0\r\n  local counter = 1\r\n  local tokens = {}\r\n\r\n  -- the ..xxx.. notation is how you do string interpolation (i.e. put a variable in a string)\r\n  -- so we are have a regex [^xxx]+... which means any character that is not one or more instances of xxx\r\n  --[[\r\n      appList = \"Microsoft Outlook; Microsoft Word\"\r\n      separator = \";\"\r\n      for str in string.gmatch(appList, \"([^\"..separator..\"]+)\") do\r\n        print(str)\r\n        sanitizedAppName = (string.gsub(str, '^%s+', ''))\r\n        print(sanitizedAppName)\r\n      end\r\n\r\n      output:\r\n      Microsoft Outlook\r\n      Microsoft Outlook\r\n        Microsoft Word\r\n      Microsoft Word\r\n\r\n  ]]--\r\n  -- notice it includes the space; so we remove that too later\r\n  for str in string.gmatch(appList, \"([^\"..separator..\"]+)\") do\r\n    -- Sanitize the name by removing any spaces before the name... coz you would enter \"abc; def\" but the app name is actually \"def\"\r\n    -- 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\r\n    -- https:\/\/www.lua.org\/manual\/5.4\/manual.html#3.4.12\r\n    sanitizedAppName = (string.gsub(str, '^%s+', ''))\r\n\r\n    table.insert(tokens, sanitizedAppName)\r\n    if sanitizedAppName == appName then\r\n      -- If we match the app name set the position to that\r\n      position = counter\r\n    else\r\n      -- Else keep incrementing the counter until the end\r\n      counter = counter + 1\r\n    end\r\n  end\r\n\r\n  -- If position is 0 it means we didn't find anything\r\n  if position == 0 then\r\n    return nil\r\n  else\r\n    if position == #tokens then\r\n      return tokens[1]\r\n    else\r\n      return tokens[position+1]\r\n    end\r\n  end\r\nend\r\n\r\n-- Returns the first app in the list of apps\r\nlocal function getFirstAppFromList(appList, separator)\r\n  -- If no separator is specified assume it is a semi-colon\r\n  if separator == nil then\r\n    separator = ';'\r\n  end\r\n\r\n  -- Check if the appList has the separator; if not we know it's a single entry\r\n  if string.find(appList, \"([^\"..separator..\"]+)\") then\r\n    -- Replace ; followed by whatever with nothing\r\n    -- Got to enclose the whole thing in () for reasons I mention in the other function\r\n    return (string.gsub(appList, \";.*\", ''))\r\n  else\r\n    return appList\r\n  end\r\nend\r\n\r\n-- Launch, Focus or Rotate application\r\n-- From https:\/\/apple.stackexchange.com\/a\/455010\r\n-- Modified by me\r\nlocal function launchOrFocusOrRotate(appList)\r\n  -- Get the first app from the list\r\n  local appFromList = getFirstAppFromList(appList)\r\n\r\n  -- thanks http:\/\/lua-users.org\/wiki\/PatternsTutorial\r\n  local app\r\n  if string.match(appFromList,'*$') then\r\n    -- check if an app is already running. the app name is the name we got from the list, with the * removed\r\n    app = string.gsub(appFromList,\"*\",\"\")\r\n    local appFind = hs.application.find(app)\r\n    if appFind == nil then\r\n      -- thanks http:\/\/lua-users.org\/wiki\/StringInterpolation for how to include variable in string\r\n      local message = string.format(\"\ud83d\udc7b %s is not open\", app)\r\n      hs.notify.new({\r\n        title = message, \r\n        informativeText = \"Manually launch \" ..app.. \" and then try if you want to switch to that\"}):send()\r\n      return\r\n    end\r\n  else\r\n    app = appFromList\r\n  end\r\n\r\n  local focusedWindow = hs.window.focusedWindow()\r\n  -- Output of the above is an hs.window object\r\n\r\n  -- I can get the application it belongs to via the :application() method\r\n  -- See https:\/\/www.hammerspoon.org\/docs\/hs.window.html#application \r\n  local focusedWindowApp = focusedWindow:application()\r\n  -- This returns an hs.application object\r\n\r\n  -- Get the name of this application; this isn't really useful for us as launchOrFocus needs the app name on disk\r\n  -- I do use it below, further on...\r\n  local focusedWindowAppName = focusedWindowApp:name()\r\n\r\n  -- This gives the path - \/Applications\/<application>.app\r\n  local focusedWindowPath = focusedWindowApp:path()\r\n\r\n  -- I need to extract <application> from that\r\n  local appNameOnDisk = string.gsub(focusedWindowPath,\"\/Applications\/\", \"\")\r\n  local appNameOnDisk = string.gsub(appNameOnDisk,\".app\", \"\")\r\n  -- Finder has this as its path\r\n  local appNameOnDisk = string.gsub(appNameOnDisk,\"\/System\/Library\/CoreServices\/\",\"\")\r\n\r\n  -- If already focused, try to find the next window\r\n  if focusedWindow and appNameOnDisk == app then\r\n    -- hs.application.get needs the name as per hs.application:name() and not the name on disk\r\n    -- It can also take pid or bundle, but that doesn't help here\r\n    -- Since I have the name already from above, I can use that though\r\n    local appWindows = hs.application.get(focusedWindowAppName):allWindows()\r\n\r\n    -- https:\/\/www.hammerspoon.org\/docs\/hs.application.html#allWindows\r\n    -- A table of zero or more hs.window objects owned by the application. From the current space. \r\n\r\n    -- Does the app have more than 1 window, if so switch between them\r\n    if #appWindows > 1 then\r\n        -- It seems that this list order changes after one window get focused, \r\n        -- Let's directly bring the last one to focus every time\r\n        -- https:\/\/www.hammerspoon.org\/docs\/hs.window.html#focus\r\n        if app == \"Finder\" then\r\n          -- If the app is Finder the window count returned is one more than the actual count, so I subtract\r\n          appWindows[#appWindows-1]:focus()\r\n        else\r\n          appWindows[#appWindows]:focus()\r\n        end\r\n    else\r\n    -- The app doesn't have more than one window, but we are focussed on it and still pressing the key\r\n    -- So let's switch to any other app in that list if present\r\n        appFromList = getAppToLaunchFromList(appList, app)\r\n\r\n        -- thanks http:\/\/lua-users.org\/wiki\/PatternsTutorial\r\n        local app\r\n        if string.match(appFromList,'*$') then\r\n          -- check if an app is already running. the app name is the name we got from the list, with the * removed\r\n          app = string.gsub(appFromList,\"*\",\"\")\r\n          local appFind = hs.application.find(app)\r\n          if appFind == nil then\r\n            -- thanks http:\/\/lua-users.org\/wiki\/StringInterpolation for how to include variable in string\r\n            local message = string.format(\"\ud83d\udc7b %s is not open\", app)\r\n            hs.notify.new({\r\n              title = message, \r\n              informativeText = \"Manually launch \" ..app.. \" and then try if you want to switch to that\"}):send()\r\n            return\r\n          end\r\n        else\r\n          app = appFromList\r\n        end\r\n\r\n        hs.application.launchOrFocus(app)\r\n        \r\n        -- Finder needs special treatment\r\n        -- From https:\/\/zhiye.li\/hammerspoon-use-the-keyboard-shortcuts-to-launch-apps-a7c59ab3d92\r\n        if app == 'Finder' then\r\n          hs.appfinder.appFromName(app):activate()\r\n        end\r\n    end\r\n  else -- if not focused\r\n    hs.application.launchOrFocus(app)\r\n    -- Finder needs special treatment\r\n    -- From https:\/\/zhiye.li\/hammerspoon-use-the-keyboard-shortcuts-to-launch-apps-a7c59ab3d92\r\n    if app == 'Finder' then\r\n      hs.appfinder.appFromName(app):activate()\r\n    end\r\n  end\r\nend<\/pre>\n

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<\/a>. Basically I added code to check for the existence of the asterisk and accordingly not do anything.<\/p>\n

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.<\/p>\n

 <\/p>\n","protected":false},"excerpt":{"rendered":"

A while ago I had posted how I use Hammerspoon to switch apps. Essentially I have a list of shortcuts defined like this: 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 … Continue reading Using Hammerspoon to switch apps (part 2)<\/span><\/a><\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_jetpack_memberships_contains_paid_content":false,"footnotes":"","jetpack_publicize_message":"","jetpack_publicize_feature_enabled":true,"jetpack_social_post_already_shared":true,"jetpack_social_options":{"image_generator_settings":{"template":"highway","enabled":false}}},"categories":[102,853],"tags":[1113,1114],"jetpack_publicize_connections":[],"jetpack_sharing_enabled":true,"jetpack_featured_media_url":"","_links":{"self":[{"href":"https:\/\/rakhesh.com\/wp-json\/wp\/v2\/posts\/7364"}],"collection":[{"href":"https:\/\/rakhesh.com\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rakhesh.com\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rakhesh.com\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/rakhesh.com\/wp-json\/wp\/v2\/comments?post=7364"}],"version-history":[{"count":1,"href":"https:\/\/rakhesh.com\/wp-json\/wp\/v2\/posts\/7364\/revisions"}],"predecessor-version":[{"id":7365,"href":"https:\/\/rakhesh.com\/wp-json\/wp\/v2\/posts\/7364\/revisions\/7365"}],"wp:attachment":[{"href":"https:\/\/rakhesh.com\/wp-json\/wp\/v2\/media?parent=7364"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rakhesh.com\/wp-json\/wp\/v2\/categories?post=7364"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rakhesh.com\/wp-json\/wp\/v2\/tags?post=7364"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}