Initial commit of Hammerspoon config

This commit is contained in:
Franco Pellicciotti
2026-05-14 18:59:23 -04:00
commit 8a9f5c37ff
683 changed files with 180195 additions and 0 deletions
@@ -0,0 +1,263 @@
local selector = {}
local json = require("hs.json")
local styledtext = require("hs.styledtext")
-- ==========================================
-- CONFIGURATION
-- ==========================================
selector.storageDir = os.getenv("HOME") .. "/.hammerspoon/layouts/"
selector.hotkeys = {}
if not hs.fs.attributes(selector.storageDir) then hs.fs.mkdir(selector.storageDir) end
local stubbornAppsList = {
["Gemini"] = true,
["AFFiNE"] = true,
["Terminal"] = true,
["System Settings"] = true
}
local ignoreListItems = {
["TheBoringNotch"] = true,
["theboringteam.boringnotch"] = true,
["Control Center"] = true,
["Notification Center"] = true,
["Dock"] = true,
["com.surteesstudios.Bartender"] = true,
["pro.betterdisplay.BetterDisplay"] = true,
["stats"] = true,
["eu.exelban.Stats"] = true,
["com.ethanbills.DockDoor"] = true,
["DockDoor"] = true
}
-- ==========================================
-- INTERNAL LOGIC
-- ==========================================
local function wrapText(text, limit)
local lines = {}
local currentLine = ""
for word in text:gmatch("%S+") do
local cleanWord = word:gsub(",$", "")
if #currentLine + #word >= limit then
table.insert(lines, currentLine .. (currentLine ~= "" and "," or ""))
currentLine = " " .. word
else
currentLine = currentLine == "" and "" .. word or currentLine .. " " .. word
end
end
table.insert(lines, currentLine)
return lines
end
local function captureCurrentLayout()
local screens = hs.screen.allScreens()
local layout = {
saveTime = os.date("%Y-%m-%d %H:%M:%S"),
screenCount = #screens,
mode = (#screens > 1) and "Docked" or "Laptop",
windows = {}
}
for _, win in ipairs(hs.window.allWindows()) do
local app = win:application()
local screen = win:screen()
local frame = win:frame()
if app and screen and win:isVisible() and frame.w > 0 and frame.h > 0 then
local appName = app:name() or ""
local bid = app:bundleID() or ""
if not (ignoreListItems[appName] or ignoreListItems[bid] or appName:find("TheBoringNotch")) then
table.insert(layout.windows, {
appName = appName,
bundleID = bid,
winTitle = win:title(),
winID = win:id(),
screenName = screen:name(),
x = math.floor(frame.x),
y = math.floor(frame.y),
w = math.floor(frame.w),
h = math.floor(frame.h)
})
end
end
end
return layout
end
local function executeRestore(filePath, layoutName)
local data = hs.json.read(filePath)
if not data then return end
local windowList = data.windows or data
local launchedAny = false
for _, winData in ipairs(windowList) do
local app = hs.application.get(winData.bundleID) or hs.application.get(winData.appName)
if not app then
if winData.bundleID and winData.bundleID ~= "" then
hs.application.launchOrFocusByBundleID(winData.bundleID)
else
hs.application.launchOrFocus(winData.appName)
end
launchedAny = true
end
end
local function moveWindows()
for _, winData in ipairs(windowList) do
local app = hs.application.get(winData.bundleID) or hs.application.get(winData.appName)
if app then
local pid = app:pid()
local appPath = app:path() or ""
local isElectron = appPath:find("Electron") or appPath:find("Frameworks")
for _, win in ipairs(app:allWindows()) do
if win:isMinimized() then win:unminimize() end
local isExactMatch = (win:id() == winData.winID)
local isTitleMatch = (win:title() == winData.winTitle)
local isStubborn = stubbornAppsList[app:name()] or isElectron
local isFuzzy = (winData.bundleID == "com.apple.Terminal" or winData.appName == "Gemini")
local shouldMove = (isExactMatch or (not winData.winID and isTitleMatch) or (#app:allWindows() == 1 and (isFuzzy or isStubborn)))
if shouldMove then
local x, y, w, h = math.floor(winData.x), math.floor(winData.y), math.floor(winData.w), math.floor(winData.h)
if isStubborn then
-- FIX: Target window by title to prevent stacking
local safeTitle = win:title():gsub('"', '\\"')
local script = string.format([[
tell application "System Events" to tell (first process whose unix id is %d)
try
set targetWin to (first window whose name is "%s")
set position of targetWin to {%d, %d}
set size of targetWin to {%d, %d}
end try
end tell
]], pid, safeTitle, x, y, w, h)
hs.applescript.applescript(script)
hs.timer.doAfter(0.5, function() hs.applescript.applescript(script) end)
else
win:setFrame({x=x, y=y, w=w, h=h}, 0)
end
end
end
end
end
hs.alert.show("Restored: " .. layoutName, 1.5)
end
if launchedAny then
hs.alert.show("Launching apps...", 3.5)
hs.timer.doAfter(4.5, moveWindows)
else
moveWindows()
end
end
local function minimizeAll()
local allWindows = hs.window.filter.new():getWindows()
for _, win in ipairs(allWindows) do
local app = win:application()
if app and not ignoreListItems[app:name()] then
win:minimize()
end
end
hs.alert.show("All Windows Minimized", 1)
end
-- ==========================================
-- MENU BAR & HOTKEYS
-- ==========================================
selector.barItem = hs.menubar.new()
function selector.refreshMenu()
for _, hk in pairs(selector.hotkeys) do hk:delete() end
selector.hotkeys = {}
local screens = hs.screen.allScreens()
local isDocked = #screens > 1
selector.barItem:setTitle(isDocked and "🖥️" or "💻")
selector.hotkeys["save"] = hs.hotkey.bind({"shift", "ctrl", "alt", "cmd"}, "S", function()
local _, name = hs.dialog.textPrompt("New Layout", "Enter name:", "", "Save", "Cancel")
if name and name ~= "" then
hs.json.write(captureCurrentLayout(), selector.storageDir .. name .. ".json", true, true)
selector.refreshMenu()
end
end)
selector.hotkeys["minimize"] = hs.hotkey.bind({"shift", "ctrl", "alt", "cmd"}, "M", minimizeAll)
local menuTable = {
{ title = "📸 Save New Layout... (⇧⌃⌥⌘S)", fn = function() hs.eventtap.keyStroke({"shift", "ctrl", "alt", "cmd"}, "S") end },
{ title = "📉 Minimize All Windows (⇧⌃⌥⌘M)", fn = minimizeAll },
{ title = "-" }
}
local files = {}
local iter, dir_obj = hs.fs.dir(selector.storageDir)
if iter then for f in iter, dir_obj do if f:find("%.json$") then table.insert(files, f) end end end
table.sort(files)
local boldStyle = { font = { name = ".AppleSystemUIFontBold", size = 13 } }
for i, file in ipairs(files) do
local path, label = selector.storageDir .. file, file:gsub("%.json$", "")
local data = hs.json.read(path)
if i <= 9 then selector.hotkeys[i] = hs.hotkey.bind({"ctrl", "alt", "cmd"}, tostring(i), function() executeRestore(path, label) end) end
table.insert(menuTable, {
title = hs.styledtext.new("Layout: " .. label .. (i<=9 and " (⌃⌥⌘"..i..")" or ""), boldStyle),
fn = function() executeRestore(path, label) end
})
if data and data.mode then
local contextStr = (data.mode == "Docked")
and string.format(" 🖥️ Docked (%d Screens)", data.screenCount or 0)
or " 💻 Laptop Mode"
table.insert(menuTable, { title = contextStr, disabled = true })
end
if data and data.saveTime then table.insert(menuTable, { title = " 📅 Saved: " .. data.saveTime, disabled = true }) end
if data and data.windows then
local seen, appNames = {}, {}
for _, w in ipairs(data.windows) do if not seen[w.appName] then table.insert(appNames, w.appName .. ","); seen[w.appName] = true end end
for _, line in ipairs(wrapText(table.concat(appNames, " "), 50)) do table.insert(menuTable, { title = line:gsub(",$", ""), disabled = true }) end
end
table.insert(menuTable, {
title = hs.styledtext.new(" 🔄 Update " .. label, boldStyle),
fn = function()
hs.json.write(captureCurrentLayout(), path, true, true)
selector.refreshMenu()
hs.alert.show("Updated: " .. label)
end
})
table.insert(menuTable, { title = " 🧹 Restore & Cleanup", fn = function() executeRestore(path, label) end })
table.insert(menuTable, { title = " 📝 Rename", fn = function()
local _, n = hs.dialog.textPrompt("Rename", "New name:", label, "Rename", "Cancel")
if n and n ~= "" then os.rename(path, selector.storageDir .. n .. ".json"); selector.refreshMenu() end
end })
table.insert(menuTable, { title = " 🗑️ Delete", fn = function()
if hs.dialog.blockAlert("Delete", "Delete "..label.."?", "Delete", "Cancel", "critical") == "Delete" then
os.remove(path); selector.refreshMenu()
end
end })
table.insert(menuTable, { title = "-" })
end
selector.barItem:setMenu(menuTable)
end
selector.screenWatcher = hs.screen.watcher.new(function()
selector.refreshMenu()
end):start()
selector.refreshMenu()
hs.alert.show("Layout Selector Loaded", 1.5)
return selector
@@ -0,0 +1,309 @@
local obj = {}
local json = require("hs.json")
local styledtext = require("hs.styledtext")
-- ==========================================
-- CONFIGURATION
-- ==========================================
obj.layoutFile = os.getenv("HOME") .. "/.hammerspoon/saved_layout.json"
obj.saveInterval = 300
obj.isRescued = false
obj.isTransitioning = false
obj.lastScreenCount = #hs.screen.allScreens()
local stubbornAppsList = {
["Gemini"] = true,
["AFFiNE"] = true,
["Terminal"] = true,
["System Settings"] = true,
["Hammerspoon"] = true
}
local ignoreListItems = {
["TheBoringNotch"] = true,
["theboringteam.boringnotch"] = true,
["Control Center"] = true,
["Notification Center"] = true,
["Dock"] = true,
["com.surteesstudios.Bartender"] = true,
["pro.betterdisplay.BetterDisplay"] = true,
["stats"] = true,
["eu.exelban.Stats"] = true,
["com.ethanbills.DockDoor"] = true,
["DockDoor"] = true
}
-- ==========================================
-- POWER & SLEEP WATCHER
-- ==========================================
obj.powerWatcher = hs.caffeinate.watcher.new(function(event)
if event == hs.caffeinate.watcher.systemWillSleep or
event == hs.caffeinate.watcher.systemWillPowerOff or
event == hs.caffeinate.watcher.screensDidSleep then
-- Lock immediately to prevent saving the "scrambled" sleep layout
obj.isTransitioning = true
elseif event == hs.caffeinate.watcher.systemDidWake or
event == hs.caffeinate.watcher.screensDidWake then
-- Wait for monitors to handshake, then auto-restore
hs.timer.doAfter(5, function()
obj.isTransitioning = false
obj.restoreLayout()
end)
end
end):start()
-- ==========================================
-- INTERNAL UTILITIES
-- ==========================================
local function wrapText(text, limit)
local lines = {}
local currentLine = ""
for word in text:gmatch("%S+") do
if #currentLine + #word >= limit then
table.insert(lines, currentLine)
currentLine = " " .. word
else
currentLine = currentLine == "" and "" .. word or currentLine .. " " .. word
end
end
table.insert(lines, currentLine)
return lines
end
-- ==========================================
-- CORE LOGIC
-- ==========================================
function obj.saveLayout(silent)
if obj.isTransitioning or hs.caffeinate.get("displayIdle") or hs.caffeinate.get("systemIdle") then return end
local screens = hs.screen.allScreens()
local layout = {
saveTime = os.date("%H:%M:%S"),
screenCount = #screens,
mode = (#screens > 1) and "Docked" or "Laptop",
windows = {}
}
local allWindows = hs.window.allWindows()
local consoleWin = hs.console.hswindow()
if consoleWin then table.insert(allWindows, consoleWin) end
for _, win in ipairs(allWindows) do
local app = win:application()
local screen = win:screen()
local frame = win:frame()
if app and screen and (win:isVisible() or win:title() == "Hammerspoon Console") and frame.w > 0 and frame.h > 0 then
local appName = app:name() or ""
local bid = app:bundleID() or ""
if not (ignoreListItems[appName] or ignoreListItems[bid] or appName:find("TheBoringNotch")) then
table.insert(layout.windows, {
appName = appName,
bundleID = bid,
winTitle = win:title(),
winID = win:id(),
x = math.floor(frame.x),
y = math.floor(frame.y),
w = math.floor(frame.w),
h = math.floor(frame.h)
})
end
end
end
hs.json.write(layout, obj.layoutFile, true, true)
obj.lastSavedTime = layout.saveTime
if not silent then hs.alert.show("Layout Saved", 1.5) end
end
function obj.restoreLayout()
local data = hs.json.read(obj.layoutFile)
if not data then return end
obj.isRescued = false
local windowList = data.windows or data
for _, winData in ipairs(windowList) do
if winData.winTitle == "Hammerspoon Console" then
local cWin = hs.console.hswindow()
if cWin then cWin:setFrame({x=winData.x, y=winData.y, w=winData.w, h=winData.h}, 0) end
else
local app = hs.application.get(winData.bundleID) or hs.application.get(winData.appName)
if app then
local pid = app:pid()
local appPath = app:path() or ""
local isElectron = appPath:find("Electron") or appPath:find("Frameworks")
for _, win in ipairs(app:allWindows()) do
if win:isMinimized() then win:unminimize() end
local isExactMatch = (win:id() == winData.winID)
local isTitleMatch = (win:title() == winData.winTitle)
local isStubborn = stubbornAppsList[app:name()] or isElectron
local isFuzzy = (winData.bundleID == "com.apple.Terminal" or winData.appName == "Gemini")
local shouldMove = (isExactMatch or (not winData.winID and isTitleMatch) or (#app:allWindows() == 1 and (isFuzzy or isStubborn)))
if shouldMove then
local x, y, w, h = math.floor(winData.x), math.floor(winData.y), math.floor(winData.w), math.floor(winData.h)
if isStubborn then
local safeTitle = win:title():gsub('"', '\\"')
local winTarget = (app:name() == "Gemini") and "window 1" or string.format('first window whose name is "%s"', safeTitle)
local script = string.format([[
tell application "System Events" to tell (first process whose unix id is %d)
try
set targetWin to %s
set position of targetWin to {%d, %d}
set size of targetWin to {%d, %d}
end try
end tell
]], pid, winTarget, x, y, w, h)
hs.applescript.applescript(script)
hs.timer.doAfter(0.5, function() hs.applescript.applescript(script) end)
else
win:setFrame({x=x, y=y, w=w, h=h}, 0)
end
end
end
end
end
end
hs.alert.show("Layout Restored", 1.5)
end
-- ==========================================
-- RESCUE LOGIC
-- ==========================================
function obj.rescueWindowsToLaptop()
local screens = hs.screen.allScreens()
if #screens == 1 and not obj.isRescued then
local primary = hs.screen.primaryScreen()
local primaryFrame = primary:frame()
for _, win in ipairs(hs.window.allWindows()) do
local app = win:application()
if app then
local name = app:name() or ""
local bid = app:bundleID() or ""
if not (ignoreListItems[name] or ignoreListItems[bid] or name:find("TheBoringNotch")) then
win:unminimize()
local appPath = app:path() or ""
local isElectron = appPath:find("Electron") or appPath:find("Frameworks")
if stubbornAppsList[name] or isElectron then
local pid = app:pid()
local safeTitle = win:title():gsub('"', '\\"')
local winTarget = (name == "Gemini") and "window 1" or string.format('first window whose name is "%s"', safeTitle)
local script = string.format([[
tell application "System Events" to tell (first process whose unix id is %d)
try
set position of %s to {%d, %d}
end try
end tell
]], pid, winTarget, math.floor(primaryFrame.x + 20), math.floor(primaryFrame.y + 40))
hs.applescript.applescript(script)
else
win:moveToScreen(primary, false, true)
end
end
end
end
obj.isRescued = true
hs.alert.show("Forced to Laptop Screen", 1.5)
end
end
-- ==========================================
-- MENUBAR UI
-- ==========================================
local saveCountdown = obj.saveInterval
local timerMenu = hs.menubar.new()
function updateMenu()
if timerMenu then
local minutes = math.floor(saveCountdown / 60)
local seconds = saveCountdown % 60
timerMenu:setTitle(string.format("💠 %d:%02d", minutes, seconds))
local screens = hs.screen.allScreens()
local currentCount = #screens
local modeIcon = (currentCount > 1) and "🖥️" or "💻"
local modeName = (currentCount > 1) and "Docked" or "Laptop"
local data = hs.json.read(obj.layoutFile)
local lastTime = data and data.saveTime or "Never"
local boldStyle = { font = { name = ".AppleSystemUIFontBold", size = 13 } }
local menuTable = {
{ title = "📅 Last Saved: " .. lastTime, disabled = true },
{ title = "-" },
{ title = modeIcon .. " Status: " .. modeName .. " Mode", disabled = true },
{ title = " ↳ 📺 Screens Detected: " .. currentCount, disabled = true },
{ title = "-" },
{ title = hs.styledtext.new("📸 Save Layout (⇧⌘W)", boldStyle), fn = function() obj.saveLayout(false); obj.resetCountdown() end },
{ title = hs.styledtext.new("🔄 Restore Layout (⇧⌘R)", boldStyle), fn = function() obj.restoreLayout() end },
{ title = hs.styledtext.new("🚀 Force Rescue: All Windows to Laptop (⇧⌘⌃L)", boldStyle), fn = function() obj.isRescued = false; obj.rescueWindowsToLaptop() end },
{ title = "-" },
{ title = "📦 Windows in Saved File:", disabled = true }
}
if data and data.windows then
local seen, appNames = {}, {}
for _, w in ipairs(data.windows) do
if not seen[w.appName] then
table.insert(appNames, w.appName .. ", ")
seen[w.appName] = true
end
end
local appString = table.concat(appNames):gsub(", $", "")
for _, line in ipairs(wrapText(appString, 45)) do
table.insert(menuTable, { title = line, disabled = true })
end
end
timerMenu:setMenu(menuTable)
end
end
function obj.resetCountdown()
saveCountdown = obj.saveInterval
updateMenu()
end
-- ==========================================
-- WATCHERS & HOTKEYS
-- ==========================================
hs.hotkey.bind({"shift", "cmd"}, "W", function() obj.saveLayout(false); obj.resetCountdown() end)
hs.hotkey.bind({"shift", "cmd"}, "R", function() obj.restoreLayout() end)
hs.hotkey.bind({"shift", "cmd", "ctrl"}, "L", function() obj.isRescued = false; obj.rescueWindowsToLaptop() end)
obj.screenWatcher = hs.screen.watcher.new(function()
local currentCount = #hs.screen.allScreens()
if currentCount ~= obj.lastScreenCount then
obj.isTransitioning = true
if currentCount > 1 then
obj.isRescued = false
hs.timer.doAfter(4, function() obj.restoreLayout(); obj.isTransitioning = false end)
else
hs.timer.doAfter(2, function() obj.rescueWindowsToLaptop(); obj.isTransitioning = false end)
end
obj.lastScreenCount = currentCount
updateMenu()
end
end):start()
obj.autoSaveTimer = hs.timer.doEvery(obj.saveInterval, function() obj.saveLayout(true); obj.resetCountdown() end)
obj.clockTimer = hs.timer.doEvery(1, function()
saveCountdown = saveCountdown - 1
if saveCountdown < 0 then obj.resetCountdown() end
updateMenu()
end)
updateMenu()
return obj