157 lines
6.0 KiB
Lua
157 lines
6.0 KiB
Lua
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 |