├── .gitignore ├── LICENSE ├── README.md ├── docs ├── docs.md └── img │ ├── battery-stats.png │ └── c-hist.png ├── extras └── Hammerspoon Docs Search.alfredworkflow └── src ├── auto-quitter.lua ├── auto-tiling.lua ├── auto-toggle-vertical-horizontal-tabs.lua ├── battery-stats.lua ├── clipboard-history.lua ├── discord-open-server-on-launch.lua ├── discord-urls-without-preview.lua └── home-assistant-toggle.lua /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Lua sources 2 | luac.out 3 | 4 | # luarocks build files 5 | *.src.rock 6 | *.zip 7 | *.tar.gz 8 | 9 | # Object files 10 | *.o 11 | *.os 12 | *.ko 13 | *.obj 14 | *.elf 15 | 16 | # Precompiled Headers 17 | *.gch 18 | *.pch 19 | 20 | # Libraries 21 | *.lib 22 | *.a 23 | *.la 24 | *.lo 25 | *.def 26 | *.exp 27 | 28 | # Shared objects (inc. Windows DLLs) 29 | *.dll 30 | *.so 31 | *.so.* 32 | *.dylib 33 | 34 | # Executables 35 | *.exe 36 | *.out 37 | *.app 38 | *.i*86 39 | *.x86_64 40 | *.hex 41 | 42 | # Editor directories and files 43 | .vscode/* 44 | !.vscode/extensions.json 45 | .DS_Store 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 mystery-z 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # awesome-hammerspoon 4 | Awesome code snippets for the Hammerspoon Desktop Automation Utility 5 | 6 | - [Docs for the snippets](/docs/docs.md) 7 | - [Code Snippets](/src/) 8 | 9 | ## Documentation Search with Alfred 10 | Download the [Alfred Workflow](https://github.com/mystery-z/awesome-hammerspoon/blob/main/extras/Hammerspoon%20Docs%20Search.alfredworkflow), and use the keyword `hs` to search the Hammerspoon online Docs. 11 | 12 | ## LSP Support for Hammerspoon 13 | 1. Set up [lua-ls](https://github.com/LuaLS/lua-language-server) in the IDE of your choice. 14 | 2. Install the [EmmyLua Spoon](https://www.hammerspoon.org/Spoons/EmmyLua.html) and follow the instructions. 15 | 3. Restart your IDE, you should now have completions and hover documentation for Hammerspoon. 16 | 17 | ## Example Configs 18 | The Hammerspoon wiki also has several [example configs](https://github.com/Hammerspoon/hammerspoon/wiki/Sample-Configurations). 19 | 20 | 21 | -------------------------------------------------------------------------------- /docs/docs.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | - [**Clipboard History**](#clipboard-historylua) 4 | - [**Battery Status**](#battery-statslua) 5 | - [**Home Assistant Toggle**](#home-assistant-togglelua) 6 | 7 | ## clipboard-history.lua 8 | 9 | clipboard history menu 10 | 11 | - Use `ctrl + c` instead of `cmd + c` to copy 12 | 13 | - Use `ctrl + e` to see clipboard history menu 14 | 15 | - Use `ctrl + 1-9` to select from the menu 16 | 17 | ## battery-stats.lua 18 | 19 | battery stats popup 20 | 21 | - Use `ctrl + b` to see battery statistics 22 | 23 | ## home-assistant-toggle.lua 24 | 25 | Update `HASS_URL`, `HASS_TOKEN` & device entity id 26 | 27 | - Use `ctrl + cmd + 1` to toggle Home Assistant device 28 | -------------------------------------------------------------------------------- /docs/img/battery-stats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mystery-z/awesome-hammerspoon/bd0bf852ada09dac0b99e4869122255e6124089b/docs/img/battery-stats.png -------------------------------------------------------------------------------- /docs/img/c-hist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mystery-z/awesome-hammerspoon/bd0bf852ada09dac0b99e4869122255e6124089b/docs/img/c-hist.png -------------------------------------------------------------------------------- /extras/Hammerspoon Docs Search.alfredworkflow: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mystery-z/awesome-hammerspoon/bd0bf852ada09dac0b99e4869122255e6124089b/extras/Hammerspoon Docs Search.alfredworkflow -------------------------------------------------------------------------------- /src/auto-quitter.lua: -------------------------------------------------------------------------------- 1 | -- INFO This is essentially an implementation of the inspired by the macOS app 2 | -- [quitter](https://marco.org/apps), this module quits any app if long enough idle 3 | -------------------------------------------------------------------------------- 4 | 5 | -- CONFIG 6 | ---times after which apps should quit, in minutes 7 | Thresholds = { 8 | Slack = 15, 9 | Obsidian = 60, 10 | Mimestream = 10, 11 | BusyCal = 3, 12 | Finder = 20, -- requires `defaults write com.apple.Finder QuitMenuItem 1` 13 | } 14 | 15 | -------------------------------------------------------------------------------- 16 | 17 | IdleApps = {} ---table containing all apps with their last activation time 18 | 19 | --Initialize on load: fills `IdleApps` with all running apps and the current time 20 | for app, _ in pairs(Thresholds) do 21 | local now = os.time() 22 | IdleApps[app] = now 23 | end 24 | 25 | ---log times when an app has been deactivated 26 | local aw = hs.application.watcher 27 | DeactivationWatcher = aw.new(function(app, event) 28 | if not app or app == "" then return end -- safeguard for special apps 29 | 30 | if event == aw.deactivated then 31 | local now = os.time() 32 | IdleApps[app] = now 33 | elseif event == aw.activated or event == aw.terminated then 34 | IdleApps[app] = nil -- removes active or closed app from table 35 | end 36 | end):start() 37 | 38 | ---@param app string name of the app 39 | local function quitter(app) 40 | print("AutoQuitter: Quitting " .. app) 41 | IdleApps[app] = nil 42 | hs.application(app):kill() 43 | end 44 | 45 | ---check apps regularly and quit if idle for longer than their thresholds 46 | local checkIntervallSecs = 20 47 | AutoQuitterTimer = hs.timer 48 | .doEvery(checkIntervallSecs, function() 49 | local now = os.time() 50 | 51 | for app, lastActivation in pairs(IdleApps) do 52 | -- can't do this with guard clause, since lua has no `continue` 53 | local appHasThreshhold = Thresholds[app] ~= nil 54 | local appIsRunning = hs.application.get(app) 55 | 56 | if appHasThreshhold and appIsRunning then 57 | local idleTimeSecs = now - lastActivation 58 | local thresholdSecs = Thresholds[app] * 60 59 | if idleTimeSecs > thresholdSecs then quitter(app) end 60 | end 61 | end 62 | end) 63 | :start() 64 | -------------------------------------------------------------------------------- /src/auto-tiling.lua: -------------------------------------------------------------------------------- 1 | -- CONFIG 2 | local appsToAutoTile = {"Finder"} 3 | 4 | -------------------------------------------------------------------------------- 5 | 6 | ---autotile all windows that belong to the window filter 7 | ---@param windowSource hs.window.filter 8 | local function autoTile(windowSource) 9 | local wins = windowSource:getWindows() 10 | 11 | if #wins == 1 then 12 | wins[1]:moveToUnit(hs.layout.maximized) 13 | elseif #wins == 2 then 14 | wins[1]:moveToUnit(hs.layout.left50) 15 | wins[2]:moveToUnit(hs.layout.right50) 16 | elseif #wins == 3 then 17 | wins[1]:moveToUnit({ h = 1, w = 0.33, x = 0, y = 0 }) 18 | wins[2]:moveToUnit({ h = 1, w = 0.34, x = 0.33, y = 0 }) 19 | wins[3]:moveToUnit({ h = 1, w = 0.33, x = 0.67, y = 0 }) 20 | elseif #wins == 4 then 21 | wins[1]:moveToUnit({ h = 0.5, w = 0.5, x = 0, y = 0 }) 22 | wins[2]:moveToUnit({ h = 0.5, w = 0.5, x = 0, y = 0.5 }) 23 | wins[3]:moveToUnit({ h = 0.5, w = 0.5, x = 0.5, y = 0 }) 24 | wins[4]:moveToUnit({ h = 0.5, w = 0.5, x = 0.5, y = 0.5 }) 25 | end 26 | 27 | -- bring all windows to the front 28 | if #wins > 1 then 29 | local app = hs.application.frontmostApplication() 30 | app:selectMenuItem { "Window", "Bring All to Front" } 31 | end 32 | end 33 | 34 | ---triggers auto-tiling for all finder windows when a Finder window is 35 | ---created or closed 36 | Wf_finder = hs.window.filter.new(appsToAutoTile) 37 | :setOverrideFilter({ 38 | -- exclude various special windows for Finder.app 39 | rejectTitles = { "^Quick Look$", "^Move$", "^Copy$", "^Finder Settings$", " Info$", "^$" }, -- "^$" excludes the Desktop, which has no window title 40 | allowRoles = "AXStandardWindow", 41 | hasTitlebar = true, 42 | }) 43 | :subscribe(hs.window.filter.windowCreated, function() autoTile(Wf_finder) end) 44 | :subscribe(hs.window.filter.windowDestroyed, function() autoTile(Wf_finder) end) 45 | -------------------------------------------------------------------------------- /src/auto-toggle-vertical-horizontal-tabs.lua: -------------------------------------------------------------------------------- 1 | -- AUTOMATICALLY SWITCH BETWEEN VERTICAL AND HORIZONTAL TABS (IN BRAVE) 2 | -- Caveat: does not work when opening tabs in the background though, since the 3 | -- window title does not change then :/ 4 | 5 | PrevTabCount = 0 6 | Wf_braveWindowTitle = hs.window.filter.new({ "Brave Browser" }) 7 | :setOverrideFilter({ allowRoles = "AXStandardWindow" }) 8 | :subscribe(hs.window.filter.windowTitleChanged, function() 9 | local success, tabCount = 10 | hs.osascript.applescript('tell application "Brave Browser" to count tab in first window') 11 | if not success then return end 12 | local threshold = 9 13 | if 14 | (tabCount > threshold and PrevTabCount <= threshold) 15 | or (tabCount <= threshold and PrevTabCount > threshold) 16 | then 17 | -- ctrl-alt-shift-9 bound to Vertical Tab Toggling in Brave Settings 18 | -- brave://settings/system/shortcuts 19 | hs.eventtap.keyStroke({ "ctrl", "alt", "shift" }, "9", 0, "Brave Browser") 20 | end 21 | PrevTabCount = tabCount 22 | end) 23 | -------------------------------------------------------------------------------- /src/battery-stats.lua: -------------------------------------------------------------------------------- 1 | -- press control+b to show battery stats 2 | hs.hotkey.bind({'ctrl'}, 'B', function() 3 | local battery_report = "Percentage: "..hs.battery.percentage().."\n Cycles: "..hs.battery.cycles().."\n Status: ".. hs.battery.capacity().."mAh/"..hs.battery.designCapacity().."mAh" 4 | hs.dialog.alert(100, 100, testCallbackFn, "Custom Battery Alert", battery_report, "Close", None, "NSCriticalAlertStyle") 5 | end) -------------------------------------------------------------------------------- /src/clipboard-history.lua: -------------------------------------------------------------------------------- 1 | hs.hotkey.bind({"ctrl"}, "C", function() 2 | hs.eventtap.keyStroke({"cmd"}, "c") 3 | copy = hs.pasteboard.getContents() 4 | for index = 8, 0, -1 do 5 | paste = hs.pasteboard.readAllData(tostring(index)) 6 | if paste["public.utf8-plain-text"] == nil then 7 | hs.pasteboard.writeObjects("", tostring(index + 1)) 8 | else 9 | hs.pasteboard.writeObjects(paste["public.utf8-plain-text"], tostring(index + 1)) 10 | end 11 | end 12 | hs.pasteboard.writeObjects(copy, "0") 13 | hs.pasteboard.setContents(copy) 14 | end) 15 | 16 | 17 | local choices = {} 18 | local function focusLastFocused() 19 | local wf = hs.window.filter 20 | local lastFocused = wf.defaultCurrentSpace:getWindows(wf.sortByFocusedLast) 21 | if #lastFocused > 0 then lastFocused[1]:focus() end 22 | end 23 | local chooser = hs.chooser.new(function(choice) 24 | if not choice then focusLastFocused(); return end 25 | hs.pasteboard.setContents(choice["text"]) 26 | focusLastFocused() 27 | hs.eventtap.keyStrokes(hs.pasteboard.getContents()) 28 | end) 29 | function get_content(index) 30 | paste = hs.pasteboard.readAllData(tostring(index)); 31 | return paste["public.utf8-plain-text"]; 32 | end 33 | function update_choices() 34 | choices = {} 35 | for i = 1, 9, 1 do 36 | table.insert(choices,{["text"] = get_content(i)}) 37 | end 38 | chooser:choices(choices) 39 | end 40 | hs.hotkey.bind({"ctrl"}, "E", function() update_choices() chooser:show() end) 41 | -------------------------------------------------------------------------------- /src/discord-open-server-on-launch.lua: -------------------------------------------------------------------------------- 1 | -- on launch, automatically open a specific server instead of the friends tab. 2 | -- example URL is the discord Obsidian server (who needs friends if you have Obsidian?) 3 | discordAppWatcher = hs.application.watcher.new(function(appName, eventType) 4 | if eventType == hs.application.watcher.launched and appName == "Discord" then 5 | openLinkInBackground("discord://discord.com/channels/686053708261228577/700466324840775831") 6 | end 7 | end):start() 8 | -------------------------------------------------------------------------------- /src/discord-urls-without-preview.lua: -------------------------------------------------------------------------------- 1 | -- when Discord is focused, enclose URL in clipboard with <> 2 | -- when Discord unfocused, removes <> from URL in clipboard 3 | -- This is useful, since URLs enclosed in <> do not result in the annoying preview window you 4 | -- would have to manually remove 5 | discordAppWatcher = hs.application.watcher.new(function(appName, eventType) 6 | if appName ~= "Discord" then return end 7 | 8 | local clipb = hs.pasteboard.getContents() 9 | if not clipb then return end 10 | 11 | if eventType == hs.application.watcher.activated then 12 | local hasURL = clipb:match("^https?:%S+$") 13 | local hasObsidianURL = clipb:match("^obsidian:%S+$") 14 | local isTweet = clipb:match("^https?://twitter%.com") -- for tweets, the previews are actually useful 15 | if (hasURL or hasObsidianURL) and not isTweet then 16 | hs.pasteboard.setContents("<" .. clipb .. ">") 17 | end 18 | elseif eventType == hs.application.watcher.deactivated then 19 | local hasEnclosedURL = clipb:match("^$") 20 | local hasEnclosedObsidianURL = clipb:match("^$") 21 | if hasEnclosedURL or hasEnclosedObsidianURL then 22 | clipb = clipb:sub(2, -2) -- remove first & last character, i.e. the <> 23 | hs.pasteboard.setContents(clipb) 24 | end 25 | end 26 | end):start() 27 | -------------------------------------------------------------------------------- /src/home-assistant-toggle.lua: -------------------------------------------------------------------------------- 1 | local env = { 2 | ["HASS_URL"] = "http://localip:8123", 3 | ["HASS_TOKEN"] = "token" 4 | } 5 | 6 | -- device name 7 | function toggleDevice() 8 | local url = env.HASS_URL .. "/api/services/light/toggle" 9 | local headers = { 10 | ["Authorization"] = "Bearer " .. env.HASS_TOKEN, 11 | ["Content-Type"] = "application/json" 12 | } 13 | local body = '{"entity_id": "light.device_name"}' 14 | hs.http.post(url, body, headers) 15 | end 16 | 17 | -- keybinding(s) 18 | hs.hotkey.bind({"cmd", "ctrl"}, "1", toggleDevice) --------------------------------------------------------------------------------