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
BIN
View File
Binary file not shown.
+82
View File
@@ -0,0 +1,82 @@
--- AppBorders.lua
local logger = hs.logger.new("AppBorders")
logger.i("Init")
-- 1. Variables must be global (no 'local') so they don't get garbage collected
global_border = nil
isBorderEnabled = false -- This tracks if the mode is ON or OFF
function initBorder()
local win = hs.window.focusedWindow()
local frame
if win ~= nil then
frame = win:frame()
else
frame = hs.geometry.new(0, 0, 0, 0)
end
global_border = hs.drawing.rectangle(frame)
global_border:setStrokeColor({ ["red"] = 1, ["blue"] = 0, ["green"] = 0, ["alpha"] = 0.8 })
global_border:setFill(false)
global_border:setStrokeWidth(8)
-- Only show if enabled
if isBorderEnabled then
global_border:show()
end
end
function redrawBorder(window, name, event)
-- If the toggle is OFF, make sure border is hidden and stop
if not isBorderEnabled then
if global_border then global_border:hide() end
return
end
-- Skip specific apps
if name == 'Kontrollzentrum' then return end
local win = hs.window.focusedWindow()
if win ~= nil then
-- Create the border object if it doesn't exist yet
if not global_border then initBorder() end
local newFrame = win:frame()
local currentFrame = global_border:frame()
if not newFrame:equals(currentFrame) then
global_border:setFrame(newFrame)
end
global_border:show()
else
if global_border then global_border:hide() end
end
end
-- 2. Setup the window filter (but it won't do anything until isBorderEnabled is true)
allwindows = hs.window.filter.new(nil)
allwindows:subscribe(hs.window.filter.windowCreated, redrawBorder)
allwindows:subscribe(hs.window.filter.windowDestroyed, redrawBorder)
allwindows:subscribe(hs.window.filter.windowFocused, redrawBorder)
allwindows:subscribe(hs.window.filter.windowMoved, redrawBorder)
allwindows:subscribe(hs.window.filter.windowUnfocused, redrawBorder)
-- 3. THE TOGGLE FUNCTION
function toggleAppBorders()
isBorderEnabled = not isBorderEnabled
if isBorderEnabled then
hs.alert.show("Window Borders: ON")
if not global_border then initBorder() end
redrawBorder() -- Trigger immediate draw
else
hs.alert.show("Window Borders: OFF")
if global_border then global_border:hide() end
end
end
-- 4. THE HOTKEY (Change "B" or the modifiers to your liking)
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "B", function()
toggleAppBorders()
end)
+56
View File
@@ -0,0 +1,56 @@
--- Caffeine.lua
local obj = {}
-- 1. POPUP ON LOAD
hs.alert.show("Caffeine Loaded", 2)
-- 2. Create the menubar item
-- We use a unique ID just to keep things stable
obj.menu = hs.menubar.new(true, "CaffeineApp")
local on_message = 'Caffeine: ON'
local off_message = 'Caffeine: OFF'
local on_icon = "☕️"
local off_icon = "😴"
local hyper = {"cmd", "alt", "ctrl"}
function obj:init(mod, key, description)
local function setCaffeineDisplay(state)
if state then
hs.alert.show(on_message)
if obj.menu then
obj.menu:setTitle(on_icon)
-- This ensures the hover text says "Caffeine: ON"
obj.menu:setTooltip(on_message)
end
else
hs.alert.show(off_message)
if obj.menu then
obj.menu:setTitle(off_icon)
-- This ensures the hover text says "Caffeine: OFF"
obj.menu:setTooltip(off_message)
end
end
end
local function caffeineClicked()
-- Toggles the system sleep prevention and updates the UI
setCaffeineDisplay(hs.caffeinate.toggle("displayIdle"))
end
if obj.menu then
obj.menu:setClickCallback(caffeineClicked)
-- Set initial state based on current system status
setCaffeineDisplay(hs.caffeinate.get("displayIdle"))
end
-- Bind the hotkey (Cmd + Alt + Ctrl + C)
hs.hotkey.bind(mod, key, caffeineClicked)
end
-- Initialize the object
obj:init(hyper, 'c', "Toggle Caffeine")
return obj
+219
View File
@@ -0,0 +1,219 @@
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
}
-- ==========================================
-- 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 layout = { saveTime = os.date("%Y-%m-%d %H:%M:%S"), 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(),
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
-- Check if window is minimized and restore it if so
if win:isMinimized() then win:unminimize() end
local isStubborn = stubbornAppsList[app:name()] or isElectron
local x, y, w, h = math.floor(winData.x), math.floor(winData.y), math.floor(winData.w), math.floor(winData.h)
if isStubborn then
win:setFrame({x=x, y=y, w=w, h=h}, 0)
local script = string.format([[
tell application "System Events" to tell (first process whose unix id is %d)
set position of window 1 to {%d, %d}
set size of window 1 to {%d, %d}
end tell
]], pid, 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
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 = {}
-- Global Hotkeys
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...", fn = function() hs.eventtap.keyStroke({"shift", "ctrl", "alt", "cmd"}, "S") end },
{ title = "📉 Minimize All Windows", 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.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.barItem:setTitle("🪟")
selector.refreshMenu()
hs.alert.show("Layout Selector Loaded", 1.5)
return selector
+229
View File
@@ -0,0 +1,229 @@
-- SETTINGS: Customize your engines and display names here
local engines = {
-- primary = { name = "Google", url = "https://google.ca/search?q=" },
primary = { name = "Google/StartPage", url = "https://www.startpage.com/do/search?query=" },
secondary = { name = "Wikipedia", url = "https://wikipedia.org/wiki/Special:Search?search="},
tertiary = { name = "YouTube", url = "https://youtube.com/results?search_query=" },
chatgpt = { name = "ChatGPT", url = "https://chatgpt.com/?q=" },
maps = { name = "Maps", url = "https://www.google.com/maps/search/" },
spotify = { name = "Spotify", url = "https://open.spotify.com/search/" }
}
local closeTimeout = 1.0
local gracePeriod = 3.0
-- Global variables
searchView = nil
searchTimer = nil
escWatcher = nil
clickOutsideWatcher = nil
local timeSinceLeft = 0
local hasEnteredWindow = false
local autoKillTime = 0
------------------------------------------------------------
-- CLOSE SEARCH
------------------------------------------------------------
local function closeSearch()
if searchView then searchView:delete() searchView = nil end
if searchTimer then searchTimer:stop() searchTimer = nil end
if escWatcher then escWatcher:stop() escWatcher = nil end
if clickOutsideWatcher then clickOutsideWatcher:stop() clickOutsideWatcher = nil end
timeSinceLeft = 0
hasEnteredWindow = false
autoKillTime = 0
end
------------------------------------------------------------
-- PERFORM SEARCH
------------------------------------------------------------
local function performSearch(engine)
hs.alert.show("Searching " .. engine.name .. "...", 1)
hs.eventtap.keyStroke({"cmd"}, "c")
hs.timer.doAfter(0.2, function()
local query = hs.pasteboard.getContents()
if not query or query == "" then return end
closeSearch()
-- RESET TRACKERS FOR NEW SEARCH
timeSinceLeft = 0
hasEnteredWindow = false
autoKillTime = 0
local url = engine.url .. hs.http.encodeForQuery(query)
local screen = hs.mouse.getCurrentScreen()
local screenFrame = screen:frame()
local width, height = 500, 600
local x = screenFrame.x + (screenFrame.w - width) / 2
local y = screenFrame.y + (screenFrame.h - height) / 2
searchView = hs.webview.new({
x = x,
y = y,
w = width,
h = height
})
searchView:windowStyle({"titled", "utility", "closable", "resizable"})
searchView:level(hs.drawing.windowLevels.floating)
searchView:userAgent(
"Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko)"
)
searchView:url(url)
searchView:show()
searchView:bringToFront()
--------------------------------------------------------
-- ESC CLOSE
--------------------------------------------------------
escWatcher = hs.eventtap.new(
{hs.eventtap.event.types.keyDown},
function(event)
if event:getKeyCode() == 53 then
closeSearch()
return true
end
return false
end
):start()
--------------------------------------------------------
-- CLICK OUTSIDE CLOSE
--------------------------------------------------------
clickOutsideWatcher = hs.eventtap.new(
{hs.eventtap.event.types.leftMouseDown},
function()
if not searchView then return false end
local mousePos = hs.mouse.absolutePosition()
local winFrame = searchView:frame()
local isOutside =
mousePos.x < winFrame.x or
mousePos.x > (winFrame.x + winFrame.w) or
mousePos.y < winFrame.y or
mousePos.y > (winFrame.y + winFrame.h)
if isOutside then closeSearch() end
return false
end
):start()
--------------------------------------------------------
-- AUTO CLOSE TIMER
--------------------------------------------------------
searchTimer = hs.timer.doEvery(0.1, function()
if not searchView then return end
local mousePos = hs.mouse.absolutePosition()
local winFrame = searchView:frame()
local isOutside =
mousePos.x < winFrame.x or
mousePos.x > (winFrame.x + winFrame.w) or
mousePos.y < winFrame.y or
mousePos.y > (winFrame.y + winFrame.h)
if not isOutside then
hasEnteredWindow = true
timeSinceLeft = 0
else
if hasEnteredWindow then
timeSinceLeft = timeSinceLeft + 0.1
if timeSinceLeft >= closeTimeout then
closeSearch()
end
else
autoKillTime = autoKillTime + 0.1
if autoKillTime >= gracePeriod then
closeSearch()
end
end
end
end)
end)
end
------------------------------------------------------------
-- RIGHT CLICK TRIGGERS
------------------------------------------------------------
if clickWatcher then clickWatcher:stop() end
clickWatcher = hs.eventtap.new(
{hs.eventtap.event.types.rightMouseDown},
function(event)
local flags = event:getFlags()
if flags.cmd and flags.shift then
performSearch(engines.maps)
return true
elseif flags.alt and flags.shift then
performSearch(engines.spotify)
return true
elseif flags.cmd then
performSearch(engines.primary)
return true
elseif flags.alt then
performSearch(engines.secondary)
return true
elseif flags.ctrl then
performSearch(engines.tertiary)
return true
elseif flags.shift then
performSearch(engines.chatgpt)
return true
end
return false
end
):start()
------------------------------------------------------------
-- CMD + ENTER → OPEN CURRENT PAGE
------------------------------------------------------------
hs.hotkey.bind({"cmd"}, "return", function()
if searchView then
local currentPage = searchView:url()
if currentPage then
hs.urlevent.openURL(currentPage)
else
hs.alert.show("No page loaded")
end
else
hs.alert.show("No search window open")
end
end)
+16
View File
@@ -0,0 +1,16 @@
-- System_Tweaks.lua
-- Disable Time Machine throttling silently
local function disableTMThrottling()
-- Use hs.execute to run the command directly via sudo
-- Since we added the NOPASSWD rule, this will be silent and instant
local output, status, type, rc = hs.execute("sudo /usr/sbin/sysctl debug.lowpri_throttle_enabled=0", true)
if status then
hs.notify.new({title="Hammerspoon", informativeText="Time Machine unthrottled (Silent)."}):send()
else
print("Sudo Error: " .. output)
end
end
-- Run on Hammerspoon load/reload
disableTMThrottling()
@@ -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
+157
View File
@@ -0,0 +1,157 @@
local obj = {}
local json = require("hs.json")
-- ==========================================
-- CONFIGURATION
-- ==========================================
obj.layoutFile = os.getenv("HOME") .. "/.hammerspoon/saved_layout.json"
obj.saveInterval = 300
-- Apps that require AppleScript authority
local stubbornApps = {
["Gemini"] = true,
["AFFiNE"] = true,
["Hammerspoon"] = true,
["System Settings"] = true
}
-- Apps or Bundle IDs that the window manager should NEVER touch
local ignoreList = {
["TheBoringNotch"] = true,
["theboringteam.boringnotch"] = true,
["boring.notch"] = true,
["The Boring Notch"] = true,
["Control Center"] = true,
["Notification Center"] = true,
["Dock"] = true,
["com.surteesstudios.Bartender"] = true,
["pro.betterdisplay.BetterDisplay"] = true
}
-- ==========================================
-- LOGIC
-- ==========================================
-- Check if window actually needs to be moved
local function needsMoving(win, savedFrame)
local curr = win:frame()
local threshold = 10
return math.abs(curr.x - savedFrame.x) > threshold or
math.abs(curr.y - savedFrame.y) > threshold
end
function obj.saveLayout(silent)
-- Safety: Don't save if the system is currently sleeping or screens are locked
if hs.caffeinate.get("displayIdle") or hs.caffeinate.get("systemIdle") then return end
local allWindows = hs.window.allWindows()
local consoleWin = hs.console.hswindow()
if consoleWin then table.insert(allWindows, consoleWin) end
local currentLayout = {
saveTime = os.date("%Y-%m-%d %H:%M:%S"),
windows = {}
}
for _, win in ipairs(allWindows) do
local app = win:application()
local screen = win:screen()
local frame = win:frame()
-- GHOST WINDOW FIX: Only save windows that have actual size
if app and screen and win:isVisible() and frame.w > 0 and frame.h > 0 then
local name = app:name() or ""
local bid = app:bundleID() or ""
-- IGNORE LOGIC: Match by name, Bundle ID, or fuzzy notch match
local isIgnored = ignoreList[name] or ignoreList[bid] or name:find("TheBoringNotch")
if not isIgnored then
table.insert(currentLayout.windows, {
appName = name,
bundleID = bid,
winTitle = win:title(),
screenName = screen:name(),
x = frame.x, y = frame.y, w = frame.w, h = frame.h
})
end
end
end
hs.json.write(currentLayout, obj.layoutFile, true, true)
if not silent then hs.alert.show("Layout Saved: " .. currentLayout.saveTime, 1.5) end
end
function obj.restoreLayout()
local data = hs.json.read(obj.layoutFile)
if not data then return end
local windowList = data.windows or data
local appleScriptParts = {"tell application \"System Events\""}
local needsAppleScript = false
for _, winData in ipairs(windowList) do
-- Handle Hammerspoon Console specifically
if winData.appName == "Hammerspoon" and winData.winTitle == "Hammerspoon Console" then
local cWin = hs.console.hswindow()
if cWin and needsMoving(cWin, winData) 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
for _, win in ipairs(app:allWindows()) do
local targetScreen = hs.screen.find(winData.screenName) or hs.screen.primaryScreen()
local isFuzzy = (winData.bundleID == "com.apple.Terminal" or winData.appName == "Gemini")
local isStubborn = stubbornApps[winData.appName]
local shouldMove = (win:title() == winData.winTitle or #app:allWindows() == 1 or isFuzzy or isStubborn)
if targetScreen and shouldMove and needsMoving(win, winData) then
if isStubborn then
needsAppleScript = true
table.insert(appleScriptParts, string.format([[
try
tell process "%s" to set position of window 1 to {%d, %d}
tell process "%s" to set size of window 1 to {%d, %d}
end try
]], winData.appName, winData.x, winData.y, winData.appName, winData.w, winData.h))
else
win:setFrame({x=winData.x, y=winData.y, w=winData.w, h=winData.h}, 0)
end
end
end
end
end
end
if needsAppleScript then
table.insert(appleScriptParts, "end tell")
hs.applescript.applescript(table.concat(appleScriptParts, "\n"))
end
end
-- Hotkeys
hs.hotkey.bind({"shift", "cmd"}, "W", function() obj.saveLayout(false) end)
hs.hotkey.bind({"shift", "cmd"}, "R", function() obj.restoreLayout() end)
-- Automation: Screen Watcher
obj.screenWatcher = hs.screen.watcher.new(function()
hs.timer.doAfter(2, function() obj.restoreLayout() end)
end):start()
-- Automation: Sleep Watcher
obj.sleepWatcher = hs.caffeinate.watcher.new(function(event)
if event == hs.caffeinate.watcher.systemWillSleep or event == hs.caffeinate.watcher.screensDidLock then
if obj.autoSaveTimer then obj.autoSaveTimer:stop() end
elseif event == hs.caffeinate.watcher.systemDidWake or event == hs.caffeinate.watcher.screensDidUnlock then
if obj.autoSaveTimer then obj.autoSaveTimer:start() end
hs.timer.doAfter(3, function() obj.restoreLayout() end)
end
end):start()
obj.autoSaveTimer = hs.timer.doEvery(obj.saveInterval, function() obj.saveLayout(true) end)
hs.alert.show("Window Manager Loaded", 1.5)
return obj
+78
View File
@@ -0,0 +1,78 @@
-- ~/.hammerspoon/init.lua
require('SearchWindows')
require('Caffeine')
require('AppBorders')
-- require('System_Tweaks')
-- Load the window management module
windowMgr = require("WindowManager")
-- Load Spoon Files
hs.loadSpoon('SpoonInstall')
hs.loadSpoon('SpeedMenu')
hs.loadSpoon('BrewInfo')
-- hs.loadSpoon('Seal')
--- 3. Run/Configure Spoons
---- SpeedMenu Config
if spoon.SpeedMenu then
-- 1. Define the Fix Function (includes MAC and IPv6)
local function applyFullMenuFix()
local interface = spoon.SpeedMenu.interface or "en0"
local ssid = hs.wifi.currentNetwork() or "Disconnected"
local details = hs.network.interfaceDetails(interface)
local ipv4 = (details and details.IPv4) and details.IPv4.Addresses[1] or "N/A"
local ipv6 = (details and details.IPv6) and details.IPv6.Addresses[1] or "N/A"
-- Get MAC Address via shell
local macaddr = hs.execute('ifconfig ' .. interface .. ' | grep ether | awk \'{print $2}\''):gsub("%s+", "")
local menuitems = {
{ title = "SSID: " .. ssid, fn = function() hs.pasteboard.setContents(ssid) end },
{ title = "IPv4: " .. ipv4, fn = function() hs.pasteboard.setContents(ipv4) end },
{ title = "IPv6: " .. ipv6, fn = function() hs.pasteboard.setContents(ipv6) end },
{ title = "MAC: " .. macaddr, fn = function() hs.pasteboard.setContents(macaddr) end },
{ title = "-" },
{ title = "Rescan Network Interfaces", fn = function() spoon.SpeedMenu:rescan() end }
}
spoon.SpeedMenu.menubar:setMenu(menuitems)
end
-- 2. Hook the rescan method
local oldRescan = spoon.SpeedMenu.rescan
spoon.SpeedMenu.rescan = function(self)
oldRescan(self)
applyFullMenuFix()
end
-- 3. Toggle Logic (Starts as OFF)
local speedMenuRunning = false
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "W", function()
if speedMenuRunning then
spoon.SpeedMenu:stop()
hs.alert.show("SpeedMenu Stopped")
else
spoon.SpeedMenu:start() -- This puts it in the menu bar
applyFullMenuFix() -- This populates the data
hs.alert.show("SpeedMenu Started")
end
speedMenuRunning = not speedMenuRunning
end)
end
---- SpeedMenu Config END
---- BrewInfo
if spoon.BrewInfo then
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "I", function()
spoon.BrewInfo:showBrewInfo()
end)
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "J", function()
spoon.BrewInfo:showBrewInfoCurSel()
end)
end
---- BrewInfo END
hs.alert.show("Hammerspoon Config Reloaded")