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
+303
View File
@@ -0,0 +1,303 @@
-- LayoutSelector.lua
local selector = {}
local json = require("hs.json")
local styledtext = require("hs.styledtext")
-- ==========================================
-- CONFIGURATION & CENTRAL CONFIG LOADING
-- ==========================================
selector.storageDir = os.getenv("HOME") .. "/.hammerspoon/layouts/"
local configFile = os.getenv("HOME") .. "/.hammerspoon/config.json"
selector.layoutHotkeys = {}
selector.staticHotkeys = {}
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, ["Hammerspoon"] = true
}
local ignoreListItems = {
["TheBoringNotch"] = true, ["Control Center"] = true, ["Notification Center"] = true,
["Dock"] = true, ["BetterDisplay"] = true, ["Stats"] = true, ["DockDoor"] = true,
["Bartender 6"] = true, ["Bartender"] = true, ["Arq"] = true, ["Arq Agent"] = true, ["TypeWhisper"] = true,
["com.typewhisper.mac"] = true
}
-- Load Central Config if available
local cfgData = hs.json.read(configFile)
if cfgData then
if cfgData.stubbornAppsList then stubbornAppsList = cfgData.stubbornAppsList end
if cfgData.ignoreListItems then ignoreListItems = cfgData.ignoreListItems end
end
-- ==========================================
-- 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
local function getLayoutFiles()
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)
return files
end
-- ==========================================
-- CORE LOGIC
-- ==========================================
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()
if app and win:isVisible() and win:frame().w > 0 then
local appName = app:name() or ""
local bundleID = app:bundleID() or ""
-- Hybrid check for Capture
if not ignoreListItems[appName] and not ignoreListItems[bundleID] then
table.insert(layout.windows, {
appName = appName, bundleID = bundleID, winTitle = win:title(),
x = math.floor(win:frame().x), y = math.floor(win:frame().y),
w = math.floor(win:frame().w), h = math.floor(win:frame().h)
})
end
end
end
return layout
end
local function executeRestore(filePath, layoutName)
local data = hs.json.read(filePath)
if not data or not data.windows then return end
local launchedAny = false
for _, winData in ipairs(data.windows) do
local app = hs.application.get(winData.bundleID) or hs.application.get(winData.appName)
if not app then
hs.application.launchOrFocusByBundleID(winData.bundleID)
launchedAny = true
end
end
local function moveWindows()
local savedByApp = {}
for _, winData in ipairs(data.windows) do
-- Hybrid check for Restore processing
if not ignoreListItems[winData.appName] and not ignoreListItems[winData.bundleID] then
savedByApp[winData.appName] = savedByApp[winData.appName] or {}
table.insert(savedByApp[winData.appName], winData)
end
end
for appName, entries in pairs(savedByApp) do
table.sort(entries, function(a, b)
if a.y == b.y then return a.x < b.x end
return a.y < b.y
end)
end
for appName, savedEntries in pairs(savedByApp) do
local app = hs.application.get(savedEntries[1].bundleID) or hs.application.get(appName)
if app then
local physicalWins = {}
for _, w in ipairs(app:allWindows()) do
if w:isVisible() and w:frame().w > 0 then table.insert(physicalWins, w) end
end
table.sort(physicalWins, function(a, b)
local af, bf = a:frame(), b:frame()
if af.y == bf.y then return af.x < bf.x end
return af.y < bf.y
end)
for i, winData in ipairs(savedEntries) do
local win = physicalWins[i]
if win then
if win:isMinimized() then win:unminimize() end
local x, y, w, h = winData.x, winData.y, winData.w, winData.h
local isStubborn = stubbornAppsList[appName] or (app:path() or ""):find("Electron")
local function moveAction()
if isStubborn then
local safeTitle = win:title():gsub('"', '\\"')
local winTarget = (appName == "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
]], app:pid(), winTarget, x, y, w, h)
hs.applescript.applescript(script)
else
win:setFrame({x=x, y=y, w=w, h=h}, 0)
end
end
moveAction()
hs.timer.doAfter(0.5, moveAction)
hs.timer.doAfter(1.5, moveAction)
end
end
end
end
hs.alert.show("Restored: " .. layoutName, 1.5)
end
if launchedAny then
hs.alert.show("Syncing Apps...", 3)
hs.timer.doAfter(4.5, moveWindows)
else
moveWindows()
end
end
-- ==========================================
-- HOTKEY & MENU MANAGEMENT
-- ==========================================
function selector.rebindLayoutKeys()
-- Only rebind when called explicitly (on file changes)
for _, hk in pairs(selector.layoutHotkeys) do hk:delete() end
selector.layoutHotkeys = {}
local files = getLayoutFiles()
for i, file in ipairs(files) do
if i > 9 then break end
local path = selector.storageDir .. file
local label = file:gsub("%.json$", "")
selector.layoutHotkeys[i] = hs.hotkey.bind({"ctrl", "alt", "cmd"}, tostring(i), function() executeRestore(path, label) end)
end
print("LayoutSelector: Dynamic Hotkeys Rebuilt")
end
selector.barItem = hs.menubar.new()
function selector.refreshMenu()
-- Guard against title-looping
local icon = #hs.screen.allScreens() > 1 and "🖥️" or "💻"
if selector.barItem:title() ~= icon then selector.barItem:setTitle(icon) end
local menuTable = {
{ title = "📸 Save New Layout... (⇧⌃⌥⌘S)", fn = function() hs.eventtap.keyStroke({"shift", "ctrl", "alt", "cmd"}, "S") end },
{ title = "-" }
}
local files = getLayoutFiles()
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)
local layoutSubmenu = {}
-- Logic to determine visual cue icon
local mainIcon = ""
if data and data.mode == "Docked" then mainIcon = "🖥️ " elseif data and data.mode == "Laptop" then mainIcon = "💻 " end
table.insert(layoutSubmenu, {
title = hs.styledtext.new("🚀 Restore Layout", boldStyle),
fn = function() executeRestore(path, label) end
})
table.insert(layoutSubmenu, { title = "-" })
if data then
local modeStr = (data.mode == "Docked") and "🖥️ Docked (".. (data.screenCount or "?") .." Screens)" or "💻 Laptop Mode"
table.insert(layoutSubmenu, { title = modeStr, disabled = true })
table.insert(layoutSubmenu, { title = "📅 Saved: " .. (data.saveTime or "Unknown"), disabled = true })
if data.windows then
local seen, names = {}, {}
for _, w in ipairs(data.windows) do
if not seen[w.appName] then
table.insert(names, w.appName .. ", ")
seen[w.appName] = true
end
end
local appString = table.concat(names):gsub(", $", "")
for _, line in ipairs(wrapText(appString, 45)) do table.insert(layoutSubmenu, { title = line, disabled = true }) end
end
end
table.insert(layoutSubmenu, { title = "-" })
table.insert(layoutSubmenu, {
title = "🔄 Update " .. label,
fn = function()
hs.json.write(captureCurrentLayout(), path, true, true)
selector.refreshMenu()
hs.alert.show("Updated: " .. label)
end
})
table.insert(layoutSubmenu, { title = "✏️ Rename", fn = function()
local _, newName = hs.dialog.textPrompt("Rename Layout", "Enter new name for " .. label .. ":", label, "Rename", "Cancel")
if newName and newName ~= "" and newName ~= label then
local newPath = selector.storageDir .. newName .. ".json"
os.rename(path, newPath)
selector.rebindLayoutKeys()
selector.refreshMenu()
end
end })
table.insert(layoutSubmenu, { title = "🗑️ Delete", fn = function()
if hs.dialog.blockAlert("Delete", "Delete "..label.."?", "Delete", "Cancel", "critical") == "Delete" then
os.remove(path)
selector.rebindLayoutKeys()
selector.refreshMenu()
end
end })
table.insert(menuTable, {
title = hs.styledtext.new(mainIcon .. "Layout: " .. label .. (i<=9 and " (⌃⌥⌘"..i..")" or ""), boldStyle),
menu = layoutSubmenu
})
end
selector.barItem:setMenu(menuTable)
end
-- ==========================================
-- INITIALIZATION
-- ==========================================
-- Static Save Hotkey (Defined once)
selector.staticHotkeys["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.rebindLayoutKeys() -- Rebuild keys only when file added
selector.refreshMenu()
end
end)
-- Screen watcher only refreshes the VISUAL menu, not the keys
selector.screenWatcher = hs.screen.watcher.new(function()
hs.timer.doAfter(2, selector.refreshMenu)
end):start()
selector.rebindLayoutKeys() -- Run once at load
selector.refreshMenu() -- Run once at load
return selector