summaryrefslogtreecommitdiff
path: root/config/hammerspoon/init.lua
diff options
context:
space:
mode:
Diffstat (limited to 'config/hammerspoon/init.lua')
-rw-r--r--config/hammerspoon/init.lua586
1 files changed, 586 insertions, 0 deletions
diff --git a/config/hammerspoon/init.lua b/config/hammerspoon/init.lua
new file mode 100644
index 0000000..45ce362
--- /dev/null
+++ b/config/hammerspoon/init.lua
@@ -0,0 +1,586 @@
+-- https://www.hammerspoon.org/
+--
+-- To place this file in ~/.config/hammerspoon/init.lua first run:
+-- defaults write org.hammerspoon.Hammerspoon MJConfigFile "$HOME/.config/hammerspoon/init.lua"
+
+-- hs.application.enableSpotlightForNameSearches(true)
+local function focus(...)
+ local appNames = { ... }
+ local selectedAppName = nil
+
+ -- If only one argument is provided, use it directly without searching
+ if #appNames == 1 then
+ selectedAppName = appNames[1]
+ else
+ -- For multiple arguments, find the first app that exists on disk
+ local appDirs = { "/Applications/", os.getenv("HOME") .. "/Applications/" }
+ for _, appName in ipairs(appNames) do
+ for _, dir in ipairs(appDirs) do
+ local path = dir .. appName .. ".app"
+ if hs.fs.attributes(path) then
+ selectedAppName = appName
+ break
+ end
+ end
+ if selectedAppName then break end
+ end
+ if not selectedAppName then
+ print("No apps found: " .. table.concat(appNames, ", "))
+ end
+ end
+
+ return function()
+ if selectedAppName then
+ print('Opening ' .. selectedAppName)
+ hs.application.launchOrFocus(selectedAppName)
+ else
+ print("No apps found: " .. table.concat(appNames, ", "))
+ end
+ end
+end
+
+local function toggle(appName)
+ return function()
+ local currentSpace = hs.spaces.focusedSpace() -- Get current space
+ local app = hs.application.get(appName) -- Get app
+ if app then -- If app already open:
+ if not app:mainWindow() then -- If no main window, then open a new window
+ app:selectMenuItem("New OS Window", true)
+ elseif app:isFrontmost() then -- If app is already in front, then hide it
+ app:hide()
+ else -- If there is a main window somewhere, bring it to current space and to front
+ hs.spaces.moveWindowToSpace(app:mainWindow(), currentSpace) -- First move the main window to the current space
+ app:activate() -- Activate the app
+ app:mainWindow():raise() -- Raise the main window and position correctly
+ end
+ else -- If app not open, open it
+ hs.application.launchOrFocus(appName)
+ app = hs.application.get(appName)
+ end
+ -- hs.spaces.gotoSpace(currentSpace)
+ end
+end
+
+
+local function execute(cmd)
+ return function()
+ hs.execute(cmd, true)
+ end
+end
+
+local default_browser_bin = '/Users/sz/.bin/default-browser'
+local function set_default_browser(browserName)
+ return execute(default_browser_bin .. ' "' .. browserName .. '"')
+end
+
+local function focusDefaultBrowser()
+ local output = hs.execute('/Users/sz/.bin/default-browser', true)
+ if not output then
+ print('ERROR: default-browser bin failed to run')
+ return
+ end
+
+ local marker = "%s%(default%)"
+ -- iterate over each line (\n) in the output
+ for line in string.gmatch(output, "[^\n]+") do
+ -- check if the line contains the marker
+ if string.find(line, marker) then
+ -- extract the name from the line
+ local name = line:gsub(marker, "")
+ print(name)
+ -- focus the browser with the extracted name
+ return focus(name)()
+ end
+ end
+end
+
+local function terminal_opacity(incr)
+ local tool = '/Users/sz/.bin/terminal-set-opacity'
+ local sign = '-'
+ local symbol = '🔽'
+ if incr then
+ symbol = '🔼'
+ sign = ''
+ end
+ local step = 0.02
+ local cmd = string.format('%s %s%s', tool, sign, step)
+ local out = hs.execute(cmd, true)
+ local alert = string.format('%s %s', symbol, out:gsub("\n", ""))
+ hs.alert.closeAll()
+ hs.alert.show(alert)
+end
+function terminal_increase_opacity()
+ terminal_opacity(true)
+end
+
+function terminal_decrease_opacity()
+ terminal_opacity(false)
+end
+
+local function show_battery()
+ local msg = 'Juice: ' .. hs.battery.percentage() .. '%'
+ hs.alert.show(msg)
+end
+local function hammerspoon_console()
+ hs.consoleOnTop(true)
+ hs.toggleConsole()
+end
+local function hammerspoon_reload()
+ hs.reload()
+end
+
+local function whisper_cpp()
+ -- --capture 1
+ local cmd =
+ "tmux neww -d -c '/Volumes/Sense/src/whisper.cpp' './stream --threads 8 --model models/ggml-base.en.bin --step 350 --length 10000 -f /tmp/whisper.txt'"
+ hs.execute(cmd, true)
+ hs.alert.show('Action!')
+end
+
+local function show_current_time()
+ local clock = os.date('%Y-%m-%d %H:%M')
+ hs.alert.show(clock)
+end
+
+-- Audio device preferences (in order of priority, case-insensitive substrings)
+local inputPrefs = { "yeti", "macbook" }
+local outputPrefs = { "soundcore Space One", "haidt", "yeti", "lg ultrafine", "macbook" }
+local audioWatcherEnabled = true
+
+-- Find the best available device from a preference list (case-insensitive substring match)
+local function find_preferred_device(devices, prefs)
+ for _, pref in ipairs(prefs) do
+ for _, device in ipairs(devices) do
+ if device and device:name():lower():find(pref:lower(), 1, true) then
+ return device
+ end
+ end
+ end
+ return nil
+end
+
+-- Set input to the best available preferred device
+local function set_preferred_input()
+ local preferred = find_preferred_device(hs.audiodevice.allInputDevices(), inputPrefs)
+ if not preferred then return false end
+
+ local current = hs.audiodevice.defaultInputDevice()
+ if current and current:uid() == preferred:uid() then return false end
+
+ preferred:setDefaultInputDevice()
+ print("Set input to " .. preferred:name())
+ return true
+end
+
+-- Set output to the best available preferred device
+local function set_preferred_output()
+ local preferred = find_preferred_device(hs.audiodevice.allOutputDevices(), outputPrefs)
+ if not preferred then return false end
+
+ local current = hs.audiodevice.defaultOutputDevice()
+ if current and current:uid() == preferred:uid() then return false end
+
+ preferred:setDefaultOutputDevice()
+ print("Set output to " .. preferred:name())
+ return true
+end
+
+-- Check and correct audio devices with retry logic for race conditions
+local function check_audio_devices()
+ local maxAttempts = 30
+ local delaySecs = 5 / maxAttempts
+ local attempt = 0
+
+ local function check()
+ attempt = attempt + 1
+ local inputChanged = set_preferred_input()
+ local outputChanged = set_preferred_output()
+
+ if not inputChanged and not outputChanged then
+ print("Audio devices correct (attempt " .. attempt .. ")")
+ return
+ end
+
+ print("Audio device correction (attempt " .. attempt .. "/" .. maxAttempts .. ")")
+ if attempt < maxAttempts then
+ hs.timer.doAfter(delaySecs, check)
+ end
+ end
+
+ check()
+end
+
+-- Keyed off the device, not the machine: only a Mac mini has these, and on
+-- any other machine the check simply never matches.
+local function mute_mac_mini_speakers()
+ local output = hs.audiodevice.defaultOutputDevice()
+ if output and output:name():lower():find("mac mini speakers", 1, true) then
+ output:setVolume(0)
+ print("Muted Mac mini Speakers")
+ end
+end
+
+hs.audiodevice.watcher.setCallback(function(event)
+ if not audioWatcherEnabled then return end
+
+ if event == "dev#" then -- device list changed
+ check_audio_devices()
+ elseif event == "dIn " then -- default input changed
+ set_preferred_input()
+ elseif event == "dOut" then -- default output changed
+ mute_mac_mini_speakers()
+ end
+end)
+hs.audiodevice.watcher.start()
+
+mute_mac_mini_speakers()
+local function show_audio_devices()
+ local function formatDeviceList(devices, currentDevice)
+ local list = {}
+ for _, device in ipairs(devices) do
+ local name = device:name()
+ local isCurrent = (currentDevice and device:uid() == currentDevice:uid())
+ if isCurrent then
+ name = "▶ " .. name
+ end
+ table.insert(list, name)
+ end
+ return table.concat(list, "\n")
+ end
+ local outputList = formatDeviceList(
+ hs.audiodevice.allOutputDevices(),
+ hs.audiodevice.defaultOutputDevice()
+ )
+ local inputList = formatDeviceList(
+ hs.audiodevice.allInputDevices(),
+ hs.audiodevice.defaultInputDevice()
+ )
+ local msg = "Output:\n" .. outputList .. "\n\nInput:\n" .. inputList
+ hs.alert.show(msg)
+end
+local function show_current_audio_devices()
+ check_audio_devices()
+ local input = hs.audiodevice.defaultInputDevice()
+ local output = hs.audiodevice.defaultOutputDevice()
+ local msg = "Input: " .. input:name() .. "\nOutput: " .. output:name()
+ hs.alert.show(msg)
+end
+local function toggle_audio_watcher()
+ audioWatcherEnabled = not audioWatcherEnabled
+ local status = audioWatcherEnabled and "enabled" or "disabled"
+ local emoji = audioWatcherEnabled and "✅" or "🚫"
+ hs.alert.show(emoji .. " Audio watcher " .. status)
+ print("Audio watcher " .. status)
+end
+
+
+local function type_current_date()
+ local clock = os.date('%Y-%m-%d')
+ hs.eventtap.keyStrokes(clock)
+end
+
+local function type_uuids(count)
+ local shell = '/bin/bash'
+ local script = '/Volumes/Sense/Work/kafka/kafka/.ignore/tools/gen-uuids.sh'
+ local arg = count
+ local cmd = string.format('%s %s %s', shell, script, arg)
+ return function()
+ local out = hs.execute(cmd, true)
+ hs.eventtap.keyStrokes(out)
+ end
+end
+local function type_random_ids(count)
+ -- use a login shell
+ local cmd = string.format('/bin/bash --login -c "random-id.ts %s"', count)
+ return function()
+ local out = hs.execute(cmd, true)
+ if count == 1 then
+ out = out:gsub("%s+$", "")
+ end
+ hs.eventtap.keyStrokes(out)
+ end
+end
+
+local function type_text(text, options)
+ return function()
+ if options and (options.min_delay or options.max_delay) then
+ -- Type with delays between characters
+ local min_delay = options.min_delay or 0
+ local max_delay = options.max_delay or min_delay
+
+ local chars = {}
+ for char in text:gmatch(".") do
+ table.insert(chars, char)
+ end
+
+ local function typeNextChar(index)
+ if index > #chars then return end
+
+ -- Handle newlines specially by sending return key
+ if chars[index] == "\n" then
+ hs.eventtap.keyStroke({}, "return")
+ else
+ hs.eventtap.keyStrokes(chars[index])
+ end
+
+ if index < #chars then
+ local delay = min_delay
+ if max_delay > min_delay then
+ -- Use squared random for skewed distribution
+ -- Most delays will be near min_delay, higher delays are rarer
+ local rand_squared = math.random() ^ 2
+ delay = min_delay + rand_squared * (max_delay - min_delay)
+ end
+ hs.timer.doAfter(delay, function()
+ typeNextChar(index + 1)
+ end)
+ end
+ end
+
+ typeNextChar(1)
+ else
+ -- Fast typing without delays
+ hs.eventtap.keyStrokes(text)
+ end
+ end
+end
+
+-- Useful to setup a hotkey to dump text for screencasting. e.g.
+-- hotkey('1', csa, demo_dump('/tmp/screencast1.txt'))
+-- hotkey('2', csa, demo_dump('/tmp/screencast2.txt'))
+---@diagnostic disable-next-line: unused-function
+local function demo_dump(filepath)
+ return function()
+ local file = io.open(filepath, 'r')
+ if not file then
+ print('ERROR: File not found: ' .. filepath)
+ hs.alert.show('File not found: ' .. filepath)
+ return
+ end
+ local text = file:read('*a')
+ file:close()
+
+ type_text(text, {min_delay = 0.005, max_delay = 0.15})()
+ end
+end
+
+local function short(name)
+ local file = io.open('/Users/sz/.cloud/shorts/' .. name, 'r')
+ local text = ''
+ if file then
+ text = file:read('*a')
+ file:close()
+ end
+ return function()
+ hs.eventtap.keyStrokes(text)
+ end
+end
+
+local function isZenBrowser(win)
+ if not win then return false end
+ local app = win:application()
+ if not app then return false end
+ local name = app:name() or ""
+ return name:lower():find("zen") ~= nil
+end
+
+local function window_cycle_screen()
+ local win = hs.window.focusedWindow()
+ if not win then
+ print("No focused window")
+ return
+ end
+ local screen = win:screen()
+ if not screen then
+ print("Window has no screen")
+ return
+ end
+ local nextScreen = screen:next()
+ if not nextScreen then
+ print("No next screen available")
+ return
+ end
+ local wasFullScreen = win:isFullScreen()
+ local isZen = isZenBrowser(win)
+
+ local function doMove()
+ if isZen then
+ local nextScreenFrame = nextScreen:frame()
+ local zenMinWidth = 1536
+ local currentFrame = win:frame()
+
+ if nextScreenFrame.w < zenMinWidth then
+ -- For narrow screens: shrink window first, then move
+ local smallerFrame = hs.geometry.rect(
+ currentFrame.x,
+ currentFrame.y,
+ zenMinWidth,
+ nextScreenFrame.h
+ )
+ win:setFrame(smallerFrame, 0)
+ hs.timer.doAfter(0.3, function()
+ win:setTopLeft(hs.geometry.point(nextScreenFrame.x, nextScreenFrame.y))
+ hs.timer.doAfter(0.3, function()
+ win:setTopLeft(hs.geometry.point(nextScreenFrame.x, nextScreenFrame.y))
+ end)
+ end)
+ else
+ -- For wide screens: move then maximize
+ win:setTopLeft(hs.geometry.point(nextScreenFrame.x, nextScreenFrame.y))
+ hs.timer.doAfter(0.3, function()
+ win:setTopLeft(hs.geometry.point(nextScreenFrame.x, nextScreenFrame.y))
+ hs.timer.doAfter(0.3, function()
+ win:setFrameWithWorkarounds(nextScreenFrame, 0)
+ end)
+ end)
+ end
+ else
+ win:moveToScreen(nextScreen)
+ end
+ end
+
+ if wasFullScreen then
+ win:setFullScreen(false)
+ hs.timer.doAfter(0.6, function()
+ doMove()
+ hs.timer.doAfter(0.6, function()
+ win:setFullScreen(true)
+ end)
+ end)
+ else
+ doMove()
+ end
+end
+
+local function window_resize(layout)
+ return function()
+ local win = hs.window.focusedWindow()
+ if not win then return end
+ if isZenBrowser(win) then
+ -- Use setFrameWithWorkarounds for Zen Browser
+ local screen = win:screen()
+ local screenFrame = screen:frame()
+ local newFrame = hs.geometry.new(layout):fromUnitRect(screenFrame):floor()
+ win:setFrameWithWorkarounds(newFrame, 0)
+ else
+ win:moveToUnit(layout, 0)
+ end
+ end
+end
+
+-- local function tprint(tbl, indent)
+-- if not indent then indent = 0 end
+-- local toprint = string.rep(" ", indent) .. "{\r\n"
+-- indent = indent + 2
+-- for k, v in pairs(tbl) do
+-- toprint = toprint .. string.rep(" ", indent)
+-- if (type(k) == "number") then
+-- toprint = toprint .. "[" .. k .. "] = "
+-- elseif (type(k) == "string") then
+-- toprint = toprint .. k .. "= "
+-- end
+-- if (type(v) == "number") then
+-- toprint = toprint .. v .. ",\r\n"
+-- elseif (type(v) == "string") then
+-- toprint = toprint .. "\"" .. v .. "\",\r\n"
+-- elseif (type(v) == "table") then
+-- toprint = toprint .. tprint(v, indent + 2) .. ",\r\n"
+-- else
+-- toprint = toprint .. "\"" .. tostring(v) .. "\",\r\n"
+-- end
+-- end
+-- toprint = toprint .. string.rep(" ", indent-2) .. "}"
+-- return toprint
+-- end
+
+local function hotkey(key, mods, fn)
+ hs.hotkey.bind(mods, key, fn)
+end
+
+local cs = { "ctrl", "shift" }
+local ca = { "ctrl", "alt" }
+local csa = { "ctrl", "shift", "alt" }
+local cag = { "ctrl", "alt", "cmd" }
+local hyp = { "ctrl", "alt", "cmd", "shift" }
+local spc = 'space'
+
+hotkey('0', cag, execute('/Users/sz/.bin/theme --set light'))
+hotkey('0', hyp, execute('/Users/sz/.bin/theme --set dark'))
+hotkey('1', cag, focus('Activity Monitor'))
+-- hotkey('8', cag, type_uuids(1))
+-- hotkey('8', hyp, type_uuids(10))
+hotkey('8', cag, type_random_ids(1))
+hotkey('8', hyp, type_random_ids(10))
+hotkey('9', csa, type_text('07871793085'))
+hotkey('9', cag, type_text('i@soarez.me'))
+hotkey('a', cag, focus('Alacritty'))
+hotkey('a', csa, toggle_audio_watcher)
+-- hotkey('a', hyp, show_audio_devices)
+hotkey('a', hyp, show_current_audio_devices)
+-- hotkey('b', cag, focus('Safari'))
+-- hotkey('b', hyp, set_default_browser('safari'))
+hotkey('b', cag, focus('Brave Browser'))
+hotkey('b', hyp, set_default_browser('brave browser'))
+hotkey('c', cag, focus('Calendar'))
+hotkey('c', hyp, focus('Claude'))
+hotkey('d', cag, show_current_time)
+hotkey('d', hyp, type_current_date)
+hotkey('f', cag, focusDefaultBrowser)
+hotkey('g', cag, focus('Preview'))
+hotkey('h', cag, show_battery)
+hotkey('h', csa, hammerspoon_reload)
+hotkey('h', hyp, hammerspoon_console)
+hotkey('i', cag, focus('Messages'))
+hotkey('j', cag, focus('IntelliJ IDEA', 'IntelliJ IDEA Ultimate', 'IntelliJ IDEA CE'))
+hotkey('k', cag, focus('Slack'))
+hotkey('k', hyp, toggle('Neovide'))
+hotkey('l', cag, focus('Simulator'))
+hotkey('m', cag, focus('Fastmail'))
+hotkey('n', cag, focus('Obsidian'))
+hotkey('n', hyp, focus('Notes'))
+hotkey('p', hyp, short('song'))
+-- hotkey('q', cag, whisper_cpp)
+-- hotkey('r', cag, focus('Radar'))
+hotkey('s', cag, focus('Spotify'))
+hotkey('s', hyp, focus('Signal'))
+hotkey('t', cag, terminal_increase_opacity)
+hotkey('t', hyp, terminal_decrease_opacity)
+-- hotkey('v', cag, execute('/Users/sz/.bin/vpn ikev2'))
+-- hotkey('v', hyp, execute('/Users/sz/.bin/vpn ssl-dc'))
+-- hotkey('w', csa, execute('/Users/sz/.bin/wifi on'))
+hotkey('w', cag, focus('WhatsApp'))
+-- hotkey('w', hyp, execute('/Users/sz/.bin/wifi off'))
+hotkey('y', cag, focus('YouTube Music'))
+hotkey('y', hyp, function()
+ local content = hs.pasteboard.getContents()
+ if content then
+ type_text(content)()
+ end
+end)
+hotkey('x', cag, type_text('𝕏'))
+hotkey('z', cag, toggle('Zen'))
+-- hotkey('z', hyp, focus('Zed'))
+hotkey('z', hyp, set_default_browser('zen'))
+hotkey(spc, cag, focus('Ghostty'))
+
+local geometry = require("hs.geometry")
+hotkey(spc, cs, window_cycle_screen)
+hotkey('1', ca, window_resize(hs.layout.maximized))
+hotkey('2', ca, window_resize(hs.layout.left50))
+hotkey('3', ca, window_resize(hs.layout.right50))
+hotkey('4', ca, window_resize(hs.layout.left30))
+hotkey('5', ca, window_resize(hs.layout.right70))
+hotkey('6', ca, window_resize(hs.layout.left25))
+hotkey('7', ca, window_resize(hs.layout.right75))
+hotkey('8', ca, window_resize(geometry.rect(0, 0, 0.20, 1)))
+hotkey('9', ca, window_resize(geometry.rect(0.20, 0, 0.80, 1)))
+
+-- hotkey('0', csa, demo_dump('/tmp/screencast0.txt'))
+-- hotkey('1', csa, demo_dump('/tmp/screencast1.txt'))
+-- hotkey('2', csa, demo_dump('/tmp/screencast2.txt'))
+-- hotkey('3', csa, demo_dump('/tmp/screencast3.txt'))
+-- hotkey('4', csa, demo_dump('/tmp/screencast4.txt'))
+-- hotkey('5', csa, demo_dump('/tmp/screencast5.txt'))
+-- hotkey('6', csa, demo_dump('/tmp/screencast6.txt'))
+-- hotkey('7', csa, demo_dump('/tmp/screencast7.txt'))
+-- hotkey('8', csa, demo_dump('/tmp/screencast8.txt'))