├── .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 |
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 |
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)
--------------------------------------------------------------------------------