diff --git a/Source/HocusFocus.spoon/docs.json b/Source/HocusFocus.spoon/docs.json new file mode 100644 index 00000000..e0ede567 --- /dev/null +++ b/Source/HocusFocus.spoon/docs.json @@ -0,0 +1,95 @@ +[ + { + "name": "HocusFocus", + "desc": "Prevents background apps from stealing focus.", + "doc": "Prevents background apps from stealing focus, while allowing expected user actions like Cmd+Tab, Cmd+Tab+Click, and clicking Dock icons. Useful for keeping your active window focused and blocking focus-stealing apps from interrupting your work.", + "stripped_doc": "", + "type": "Module", + "Constructor": [], + "Command": [], + "Constant": [], + "Deprecated": [], + "Field": [], + "Function": [], + "Variable": [], + "Method": [ + { + "name": "start", + "type": "Method", + "signature": "HocusFocus:start()", + "def": "HocusFocus:start()", + "desc": "Starts HocusFocus", + "doc": "Starts HocusFocus\n\nParameters:\n * None\n\nReturns:\n * The HocusFocus object\n\nNotes:\n * Begins monitoring for app focus changes.\n * Automatically restores focus to last user-selected window when unwanted focus steal occurs.", + "parameters": [ + " * None" + ], + "returns": [ + " * The HocusFocus object" + ], + "notes": [ + " * Begins monitoring for app focus changes.", + " * Automatically restores focus to last user-selected window when unwanted focus steal occurs." + ], + "stripped_doc": "" + }, + { + "name": "stop", + "type": "Method", + "signature": "HocusFocus:stop()", + "def": "HocusFocus:stop()", + "desc": "Stops HocusFocus", + "doc": "Stops HocusFocus\n\nParameters:\n * None\n\nReturns:\n * The HocusFocus object\n\nNotes:\n * Stops all focus monitoring and event interception.", + "parameters": [ + " * None" + ], + "returns": [ + " * The HocusFocus object" + ], + "notes": [ + " * Stops all focus monitoring and event interception." + ], + "stripped_doc": "" + } + ], + "items": [ + { + "name": "start", + "type": "Method", + "signature": "HocusFocus:start()", + "def": "HocusFocus:start()", + "desc": "Starts HocusFocus", + "doc": "Starts HocusFocus\n\nParameters:\n * None\n\nReturns:\n * The HocusFocus object\n\nNotes:\n * Begins monitoring for app focus changes.\n * Automatically restores focus to last user-selected window when unwanted focus steal occurs.", + "parameters": [ + " * None" + ], + "returns": [ + " * The HocusFocus object" + ], + "notes": [ + " * Begins monitoring for app focus changes.", + " * Automatically restores focus to last user-selected window when unwanted focus steal occurs." + ], + "stripped_doc": "" + }, + { + "name": "stop", + "type": "Method", + "signature": "HocusFocus:stop()", + "def": "HocusFocus:stop()", + "desc": "Stops HocusFocus", + "doc": "Stops HocusFocus\n\nParameters:\n * None\n\nReturns:\n * The HocusFocus object\n\nNotes:\n * Stops all focus monitoring and event interception.", + "parameters": [ + " * None" + ], + "returns": [ + " * The HocusFocus object" + ], + "notes": [ + " * Stops all focus monitoring and event interception." + ], + "stripped_doc": "" + } + ], + "submodules": [] + } +] \ No newline at end of file diff --git a/Source/HocusFocus.spoon/init.lua b/Source/HocusFocus.spoon/init.lua new file mode 100644 index 00000000..04457ae4 --- /dev/null +++ b/Source/HocusFocus.spoon/init.lua @@ -0,0 +1,180 @@ +--- === HocusFocus === +--- +--- Prevents background apps from stealing focus, while allowing user-triggered switches like Cmd+Tab, Dock clicks, and iTerm2 hotkey (e.g., Shift+Space). +--- Also prevents Dock-launched apps from stealing focus if the user switched elsewhere while the app was launching. + +local obj = {} +obj.__index = obj + +obj.name = "HocusFocus" +obj.version = "0.1" +obj.author = "Felipe Rohde " +obj.license = "MIT - https://opensource.org/licenses/MIT" +obj.homepage = "https://github.com/Hammerspoon/Spoons" + +local lastFocusedWindow = nil +local isUserSwitching = false +local blockInput = false +local allowNextActivation = false +local allowNextApp = nil + +-- Dock launch context +local dockLaunchedApp = nil +local dockLaunchTime = nil +local dockLaunchWindowCancelThreshold = 0.5 -- seconds + +-- Customize this for your own hotkey (e.g., iTerm2 trigger) +local hotkeyModifiers = { "shift" } +local hotkeyKey = "space" +local hotkeyAppName = "iTerm2" + +-- Apps that are always allowed to steal focus +local focusStealWhitelist = { + ["Finder"] = true, + ["SystemUIServer"] = true, + ["System Preferences"] = true, + ["System Settings"] = true, + ["SecurityAgent"] = true, + ["com.apple.authorizationhost"] = true, + ["com.apple.loginwindow"] = true, + ["Safari"] = true, + ["Google Chrome"] = true, + ["1Password"] = true +} + +local function isAllowedToStealFocus(app) + local appName = app:name() + if focusStealWhitelist[appName] then return true end + + if allowNextApp and appName == allowNextApp then + allowNextApp = nil + return true + end + + -- Dock-launched app logic + if dockLaunchedApp and appName == dockLaunchedApp then + local elapsed = hs.timer.secondsSinceEpoch() - (dockLaunchTime or 0) + if elapsed <= dockLaunchWindowCancelThreshold then + return false -- too soon, user may have canceled + end + -- else: too late to cancel + dockLaunchedApp = nil + end + + local win = app:focusedWindow() + if not win then return false end + + local role = win:role() + local subrole = win:subrole() + + return ( + subrole == "AXDialog" + or subrole == "AXSystemDialog" + or role == "AXFloatingWindow" + ) +end + +local function registerUserSwitch() + isUserSwitching = true + hs.timer.doAfter(0.1, function() + local win = hs.window.focusedWindow() + if win then lastFocusedWindow = win end + isUserSwitching = false + end) +end + +--- HocusFocus:start() +function obj:start() + -- Modifier keys: Cmd+Tab, etc. + self.modKeyWatcher = hs.eventtap.new({ hs.eventtap.event.types.flagsChanged }, function(evt) + local flags = evt:getFlags() + if flags["cmd"] or flags["ctrl"] or flags["alt"] or flags["shift"] then + allowNextActivation = true + dockLaunchedApp = nil -- user canceled dock app + end + return false + end) + self.modKeyWatcher:start() + + -- Mouse click (e.g. Dock click) + self.mouseClickWatcher = hs.eventtap.new({ hs.eventtap.event.types.leftMouseDown }, function(evt) + local pos = hs.mouse.getAbsolutePosition() + local appIconUnderCursor = hs.application.frontmostApplication() + if appIconUnderCursor then + dockLaunchedApp = appIconUnderCursor:name() + dockLaunchTime = hs.timer.secondsSinceEpoch() + end + allowNextActivation = true + return false + end) + self.mouseClickWatcher:start() + + -- Hotkey watcher for iTerm2 (or other apps) + self.customHotkeyWatcher = hs.eventtap.new({ hs.eventtap.event.types.keyDown }, function(evt) + local flags = evt:getFlags() + local key = evt:getCharacters(true) + local match = hs.fnutils.every(hotkeyModifiers, function(mod) + return flags[mod] + end) + if match and key:lower() == hotkeyKey then + allowNextApp = hotkeyAppName + end + return false + end) + self.customHotkeyWatcher:start() + + -- Input blocker to prevent interference during correction + self.blockInputTap = hs.eventtap.new({ + hs.eventtap.event.types.keyDown, + hs.eventtap.event.types.leftMouseDown, + hs.eventtap.event.types.rightMouseDown + }, function(evt) + if blockInput then return true end + return false + end) + self.blockInputTap:start() + + -- App watcher + self.appWatcher = hs.application.watcher.new(function(appName, eventType, app) + if eventType == hs.application.watcher.activated then + local current = hs.window.focusedWindow() + + if isUserSwitching or allowNextActivation then + registerUserSwitch() + allowNextActivation = false + return + end + + if isAllowedToStealFocus(app) then + lastFocusedWindow = current + return + end + + -- Restore previous window + if lastFocusedWindow and current and current:id() ~= lastFocusedWindow:id() then + blockInput = true + hs.timer.doAfter(0.05, function() + if lastFocusedWindow then + lastFocusedWindow:becomeMain() + lastFocusedWindow:focus() + end + hs.timer.doAfter(0.15, function() + blockInput = false + end) + end) + end + end + end) + self.appWatcher:start() +end + +--- HocusFocus:stop() +function obj:stop() + if self.modKeyWatcher then self.modKeyWatcher:stop() end + if self.mouseClickWatcher then self.mouseClickWatcher:stop() end + if self.customHotkeyWatcher then self.customHotkeyWatcher:stop() end + if self.blockInputTap then self.blockInputTap:stop() end + if self.appWatcher then self.appWatcher:stop() end +end + +return obj \ No newline at end of file