diff options
Diffstat (limited to 'config/hammerspoon/init.lua')
| -rw-r--r-- | config/hammerspoon/init.lua | 586 |
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')) |
