From ab0de76e795d512f3a6fad6ba299d67c549269ed Mon Sep 17 00:00:00 2001 From: Jim Myhrberg Date: Sun, 23 Apr 2023 16:28:21 +0100 Subject: [PATCH] feat(hammerspoon/app_toggle): enable multi-app toggles A multi-app toggle is a keybinding which is configured to toggle 2 or more applications. This is intended as a context-ish-aware toggle, as it will only toggle the most recently focused application. This essentially enables you to bind a category/class of applications to a single hotkey, and whichever of the apps that's running and was most recently focused is the one that will be toggled. --- hammerspoon/app_toggle.lua | 156 ++++++++++++++++++++++++++++++------- hammerspoon/hosts/noct.lua | 40 ++++++---- 2 files changed, 156 insertions(+), 40 deletions(-) diff --git a/hammerspoon/app_toggle.lua b/hammerspoon/app_toggle.lua index d620172..66eebd1 100644 --- a/hammerspoon/app_toggle.lua +++ b/hammerspoon/app_toggle.lua @@ -1,32 +1,14 @@ --- luacheck: read_globals hs +--- === app_toggle === +--- +--- A Hammerspoon module for toggling between specified applications using +--- hotkeys. +--- +--- This module allows you to bind a hotkey to switch focus between specific +--- applications and show/hide them. local obj = {} -function obj:bind (mods, key, name, path) - hs.hotkey.bind(mods, key, self:toggleFn(name, path)) -end - -function obj:toggleFn (name, path) - return function () - self:toggle(name, path) - end -end - -function obj:toggle (name, path) - local app = self.findRunningApp(name, path) - - if app == nil then - return hs.application.open(path or name) - end - - if app == hs.application.frontmostApplication() then - return app:hide() - end - - return app:activate() -end - -function obj.findRunningApp (name, path) +local function findRunningApp(name, path) for _, app in ipairs(hs.application.runningApplications()) do if app:name() == name and (path == nil or path == app:path()) then return app @@ -34,5 +16,127 @@ function obj.findRunningApp (name, path) end end +local focusTimes = {} + +local function focusWatcher(_, eventType, appObject) + if eventType == hs.application.watcher.activated then + focusTimes[appObject:bundleID()] = hs.timer.secondsSinceEpoch() + end +end + +local appWatcher = hs.application.watcher.new(focusWatcher) +obj.started = false + +function obj:start() + if obj.started then + return + end + + appWatcher:start() + obj.started = true +end + +function obj:stop() + if not obj.started then + return + end + + appWatcher:stop() + obj.started = false +end + +--- app_toggle:bind(mods, key, ...) +--- Method +--- Binds a hotkey to toggle between the specified applications. +--- +--- Parameters: +--- * mods - A table with the modifiers for the hotkey +--- * key - A string with the key for the hotkey +--- * ... - A list of tables, each containing an application name and an +--- optional path +function obj:bind(mods, key, ...) + local apps = { ... } + if #apps > 1 then + self:start() + end + + hs.hotkey.bind(mods, key, self:toggleFn(apps)) +end + +--- app_toggle:toggleFn(apps) +--- Method +--- Creates and returns a function that toggles between the specified +--- applications via app_toggle:toggle() when called. +--- +--- Parameters: +--- * apps - A table containing application configurations. Each configuration +--- is a table with an application name at the first index and an +--- optional path at the second index. +--- +--- Returns: +--- * A function that, when called, toggles between the specified applications. +--- +--- Example: +--- local toggleApps = obj:toggleFn({{"Firefox"}, {"Safari"}}) +--- hs.hotkey.bind({"cmd", "ctrl"}, "b", toggleApps) +--- +--- Notes: +--- * The returned function can be used as a callback for hotkey bindings or +--- other event-driven scenarios. +function obj:toggleFn(apps) + return function() + self:toggle(apps) + end +end + +--- app_toggle:toggle(apps) +--- Method +--- Toggles focus/visibility specified applications. +--- +--- Parameters: +--- * apps - A table containing application configurations. Each configuration +--- is a table with an application name at the first index and an +--- optional path at the second index. +--- +--- Notes: +--- * If none of the specified applications are running, the function attempts +--- to launch the first application in the list. +--- * If the most recently focused application in the list is the current +--- frontmost application, it will be hidden. Otherwise, the most recently +--- focused application will be brought to the front. +function obj:toggle(apps) + local runningApps = {} + local mostRecentApp = nil + local mostRecentTime = -1 + + for _, appInfo in ipairs(apps) do + local name, path = appInfo[1], appInfo[2] + local app = findRunningApp(name, path) + if app then + table.insert(runningApps, app) + local focusTime = focusTimes[app:bundleID()] or 0 + if focusTime > mostRecentTime then + mostRecentTime = focusTime + mostRecentApp = app + end + end + end + + if #runningApps == 0 then + local app = apps[1] + local status, err = pcall(hs.application.open, app[2] or app[1]) + if not status then + hs.alert.show('Failed to open ' .. (app[2] or app[1]) .. ': ' .. err) + end + return + end + + if mostRecentApp == hs.application.frontmostApplication() then + return mostRecentApp:hide() + end + + return mostRecentApp:activate() +end + -- the end return obj diff --git a/hammerspoon/hosts/noct.lua b/hammerspoon/hosts/noct.lua index 1c0e36f..136cf53 100644 --- a/hammerspoon/hosts/noct.lua +++ b/hammerspoon/hosts/noct.lua @@ -3,20 +3,32 @@ local obj = {} function obj.init() local apptoggle = require('app_toggle') - apptoggle:bind({'cmd', 'alt', 'ctrl'}, 'A', 'Activity Monitor') - apptoggle:bind({'cmd', 'ctrl'}, '2', 'ChatGPT') - apptoggle:bind({'cmd', 'ctrl'}, '4', 'Microsoft Edge') - apptoggle:bind({'cmd', 'ctrl'}, 'A', 'Messages') - apptoggle:bind({'cmd', 'ctrl'}, 'B', 'TablePlus') - apptoggle:bind({'cmd', 'ctrl'}, 'C', 'Calendar') - apptoggle:bind({'cmd', 'ctrl'}, 'D', 'Mailplane') - apptoggle:bind({'cmd', 'ctrl'}, 'E', 'Emacs', '/Applications/Emacs.app') - apptoggle:bind({'cmd', 'ctrl'}, 'F', 'Element Nightly') - apptoggle:bind({'cmd', 'ctrl'}, 'S', 'Music') - apptoggle:bind({'cmd', 'ctrl'}, 'T', 'Discord PTB') - apptoggle:bind({'cmd', 'ctrl'}, 'W', 'WhatsApp') - apptoggle:bind({'cmd', 'ctrl'}, 'X', 'Notion') - apptoggle:bind({'cmd', 'ctrl'}, 'Z', 'Slack') + apptoggle:bind({ 'cmd', 'alt', 'ctrl' }, 'A', { 'Activity Monitor' }) + apptoggle:bind({ 'cmd', 'ctrl' }, '4', { 'Microsoft Edge' }) + apptoggle:bind({ 'cmd', 'ctrl' }, 'A', { 'Messages' }) + apptoggle:bind({ 'cmd', 'ctrl' }, 'C', { 'Calendar' }) + apptoggle:bind({ 'cmd', 'ctrl' }, 'D', { 'Mailplane' }) + apptoggle:bind({ 'cmd', 'ctrl' }, 'F', { 'Element Nightly' }) + apptoggle:bind({ 'cmd', 'ctrl' }, 'S', { 'Music' }) + apptoggle:bind({ 'cmd', 'ctrl' }, 'T', { 'Discord PTB' }) + apptoggle:bind({ 'cmd', 'ctrl' }, 'X', { 'Notion' }) + apptoggle:bind({ 'cmd', 'ctrl' }, 'Z', { 'Slack' }) + + apptoggle:bind({ 'cmd', 'ctrl' }, '2', + { 'ChatGPT X' }, + { 'ChatGPT' } + ) + apptoggle:bind({ 'cmd', 'ctrl' }, 'B', + { 'TablePlus' }, + { 'Lens' } + ) + + apptoggle:bind({ 'cmd', 'ctrl' }, 'E', + { 'Emacs', '/Applications/Emacs.app' } + ) + apptoggle:bind({ 'cmd', 'ctrl' }, 'W', + { 'Code', '/Applications/Visual Studio Code.app' } + ) end return obj