438 lines
16 KiB
Lua
438 lines
16 KiB
Lua
-- WindowManager.lua
|
|
|
|
local obj = {}
|
|
local json = require("hs.json")
|
|
local styledtext = require("hs.styledtext")
|
|
|
|
-- ==========================================
|
|
-- CONFIGURATION & CENTRAL CONFIG LOADING
|
|
-- ==========================================
|
|
obj.layoutFile = os.getenv("HOME") .. "/.hammerspoon/saved_layout.json"
|
|
local configFile = os.getenv("HOME") .. "/.hammerspoon/config.json"
|
|
|
|
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
|
|
|
|
obj.saveInterval = 300
|
|
obj.saveCountdown = obj.saveInterval
|
|
obj.isRescued = false
|
|
obj.isTransitioning = false
|
|
obj.isRestoring = false
|
|
obj.isMenuUpdating = false -- Safety guard to prevent concurrent IPC menu rendering loops
|
|
obj.wakeTimer = nil
|
|
obj.lastScreenCount = #hs.screen.allScreens()
|
|
obj.lastSavedTime = "Never"
|
|
|
|
-- ==========================================
|
|
-- INTERNAL UTILITIES
|
|
-- ==========================================
|
|
|
|
local function log(msg)
|
|
print(string.format("WindowManager [%s]: %s", os.date("%H:%M:%S"), msg))
|
|
end
|
|
|
|
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)
|
|
local currentScreens = #hs.screen.allScreens()
|
|
|
|
if currentScreens ~= obj.lastScreenCount then
|
|
log(string.format("Screen mismatch (%d vs %d). Syncing count.", currentScreens, obj.lastScreenCount))
|
|
obj.lastScreenCount = currentScreens
|
|
end
|
|
|
|
local layout = {
|
|
saveTime = os.date("%H:%M:%S"),
|
|
screenCount = currentScreens,
|
|
windows = {}
|
|
}
|
|
|
|
for _, win in ipairs(hs.window.allWindows()) do
|
|
pcall(function()
|
|
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 ""
|
|
|
|
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)
|
|
end
|
|
|
|
hs.json.write(layout, obj.layoutFile, true, true)
|
|
obj.lastSavedTime = layout.saveTime
|
|
|
|
if silent then
|
|
log("Autosave successful.")
|
|
else
|
|
log("Manual save successful.")
|
|
hs.alert.show("Layout Saved", 1.5)
|
|
end
|
|
end
|
|
|
|
function obj.restoreLayout()
|
|
obj.isRestoring = true
|
|
obj.isTransitioning = false
|
|
obj.isRescued = false
|
|
|
|
local data = hs.json.read(obj.layoutFile)
|
|
if not data or not data.windows then
|
|
log("Restore FAILED: No data found in JSON.")
|
|
obj.isRestoring = false
|
|
return
|
|
end
|
|
|
|
hs.screen.restoreGamma()
|
|
|
|
local savedByApp = {}
|
|
for _, winData in ipairs(data.windows) do
|
|
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
|
|
|
|
log("RESTORE STARTING...")
|
|
|
|
for appName, savedEntries in pairs(savedByApp) do
|
|
local firstEntry = savedEntries[1]
|
|
local app = hs.application.get(firstEntry.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
|
|
log(string.format("Moving %s (%d/%d)", appName, i, #savedEntries))
|
|
if win:isMinimized() then win:unminimize() end
|
|
|
|
pcall(function()
|
|
win:setFrame({x=winData.x, y=winData.y, w=winData.w, h=winData.h}, 0)
|
|
|
|
if stubbornAppsList[appName] then
|
|
hs.timer.doAfter(0.5, function()
|
|
pcall(function() win:setFrame({x=winData.x, y=winData.y, w=winData.w, h=winData.h}, 0) end)
|
|
end)
|
|
end
|
|
end)
|
|
end
|
|
end
|
|
else
|
|
log(string.format("Skip: %s is not running.", appName))
|
|
end
|
|
end
|
|
|
|
hs.alert.show("Layout Restored", 1.5)
|
|
hs.timer.doAfter(5, function()
|
|
obj.isRestoring = false
|
|
obj.isTransitioning = false
|
|
log("RESTORE CYCLE COMPLETE.")
|
|
end)
|
|
end
|
|
|
|
function obj.rescueWindowsToLaptop()
|
|
local primary = hs.screen.primaryScreen()
|
|
if not primary then return end
|
|
local maxFrame = primary:frame()
|
|
log("RESCUE: Cascading windows on laptop.")
|
|
|
|
local allWindows = hs.window.allWindows()
|
|
local staggerOffset = 0
|
|
|
|
for _, win in ipairs(allWindows) do
|
|
pcall(function()
|
|
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 ""
|
|
|
|
if not ignoreListItems[appName] and not ignoreListItems[bundleID] then
|
|
local f = win:frame()
|
|
if f.w > maxFrame.w then f.w = maxFrame.w - 100 end
|
|
if f.h > maxFrame.h then f.h = maxFrame.h - 100 end
|
|
|
|
f.x = maxFrame.x + 50 + staggerOffset
|
|
f.y = maxFrame.y + 50 + staggerOffset
|
|
|
|
win:setFrame(f, 0)
|
|
staggerOffset = staggerOffset + 30
|
|
if staggerOffset > 150 then staggerOffset = 0 end
|
|
end
|
|
end
|
|
end)
|
|
end
|
|
obj.isRescued = true
|
|
obj.isTransitioning = false
|
|
hs.alert.show("Windows Cascaded", 1.5)
|
|
end
|
|
|
|
-- ==========================================
|
|
-- MENUBAR & WATCHERS
|
|
-- ==========================================
|
|
local timerMenu = hs.menubar.new()
|
|
|
|
function updateMenu()
|
|
-- Safety Guard: Bail if update is already running from a previous thread block
|
|
if obj.isMenuUpdating then return end
|
|
obj.isMenuUpdating = true
|
|
|
|
if timerMenu then
|
|
local screens = hs.screen.allScreens()
|
|
timerMenu:setTitle(string.format("💠 %d:%02d", math.floor(obj.saveCountdown / 60), obj.saveCountdown % 60))
|
|
local menuTable = {
|
|
{ title = "📅 Last Saved: " .. (obj.lastSavedTime or "Never"), disabled = true },
|
|
{ title = ((#screens > 1) and "🖥️ Docked" or "💻 Laptop") .. " (" .. #screens .. " Screens)", disabled = true },
|
|
{ title = "-" },
|
|
{ title = "📸 Save Layout (⇧⌘W)", fn = function() obj.saveLayout(false); obj.saveCountdown = obj.saveInterval end },
|
|
{ title = "🔄 Restore Layout (⇧⌘R)", fn = function() obj.restoreLayout() end },
|
|
{ title = "🚀 Rescue Windows (Cascade on Laptop) (⇧⌘⌃L)", fn = obj.rescueWindowsToLaptop },
|
|
{ title = "-" },
|
|
{ title = "📦 Saved Apps:", disabled = true }
|
|
}
|
|
|
|
local data = hs.json.read(obj.layoutFile)
|
|
if data and 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
|
|
for _, line in ipairs(wrapText(table.concat(names):gsub(", $", ""), 45)) do
|
|
table.insert(menuTable, { title = line, disabled = true })
|
|
end
|
|
end
|
|
timerMenu:setMenu(menuTable)
|
|
end
|
|
|
|
obj.isMenuUpdating = false
|
|
end
|
|
|
|
obj.powerWatcher = hs.caffeinate.watcher.new(function(event)
|
|
if event == hs.caffeinate.watcher.systemWillSleep or event == hs.caffeinate.watcher.screensDidSleep or event == hs.caffeinate.watcher.screensDidLock then
|
|
log("POWER: Sleep event.")
|
|
obj.isTransitioning = true
|
|
-- Stop both timers entirely so they don't fire or stack requests while suspended
|
|
if obj.autoSaveTimer then obj.autoSaveTimer:stop() end
|
|
if obj.clockTimer then obj.clockTimer:stop() end
|
|
elseif event == hs.caffeinate.watcher.systemDidWake or event == hs.caffeinate.watcher.screensDidWake or event == hs.caffeinate.watcher.screensDidUnlock then
|
|
log("POWER: Wake event.")
|
|
obj.saveCountdown = obj.saveInterval
|
|
obj.isMenuUpdating = false -- Explicitly clean guard flag upon waking up
|
|
|
|
-- Safely start timers up only after wakeup initialization
|
|
if obj.autoSaveTimer then obj.autoSaveTimer:start() end
|
|
if obj.clockTimer then obj.clockTimer:start() end
|
|
if obj.wakeTimer then obj.wakeTimer:stop() end
|
|
|
|
local currentScreens = #hs.screen.allScreens()
|
|
|
|
if currentScreens > 1 then
|
|
obj.wakeTimer = hs.timer.doAfter(12, function()
|
|
obj.isTransitioning = false
|
|
obj.isRestoring = false
|
|
obj.lastScreenCount = currentScreens
|
|
obj.restoreLayout()
|
|
obj.wakeTimer = nil
|
|
end)
|
|
else
|
|
log("WAKE SKIP: Single screen detected. Syncing count only.")
|
|
obj.isTransitioning = false
|
|
obj.isRestoring = false
|
|
obj.lastScreenCount = currentScreens
|
|
updateMenu()
|
|
end
|
|
end
|
|
end):start()
|
|
|
|
hs.hotkey.bind({"shift", "cmd"}, "W", function() obj.saveLayout(false); obj.saveCountdown = obj.saveInterval end)
|
|
hs.hotkey.bind({"shift", "cmd"}, "R", function() obj.restoreLayout() end)
|
|
hs.hotkey.bind({"shift", "cmd", "ctrl"}, "L", obj.rescueWindowsToLaptop)
|
|
|
|
obj.screenWatcher = hs.screen.watcher.new(function()
|
|
if obj.isTransitioning or obj.isRestoring or obj.wakeTimer then
|
|
log("DOCK EVENT: Ignored.")
|
|
return
|
|
end
|
|
|
|
local currentScreens = #hs.screen.allScreens()
|
|
|
|
if currentScreens == obj.lastScreenCount then
|
|
log("DOCK EVENT: Ignored (Screen count unchanged).")
|
|
return
|
|
end
|
|
|
|
log("DOCK EVENT: Detected.")
|
|
obj.isTransitioning = true
|
|
|
|
hs.timer.doAfter(15, function()
|
|
if obj.isTransitioning and not obj.isRestoring then
|
|
obj.isTransitioning = false
|
|
log("STATE GUARD: Emergency clear of busy flag.")
|
|
end
|
|
end)
|
|
|
|
hs.timer.doAfter(7, function()
|
|
obj.lastScreenCount = #hs.screen.allScreens()
|
|
obj.isTransitioning = false
|
|
if obj.lastScreenCount > 1 then obj.restoreLayout() else obj.rescueWindowsToLaptop() end
|
|
updateMenu()
|
|
end)
|
|
end):start()
|
|
|
|
obj.autoSaveTimer = hs.timer.doEvery(obj.saveInterval, function()
|
|
obj.saveLayout(true)
|
|
obj.saveCountdown = obj.saveInterval
|
|
end)
|
|
|
|
obj.clockTimer = hs.timer.doEvery(1, function()
|
|
obj.saveCountdown = obj.saveCountdown - 1
|
|
if obj.saveCountdown < 0 then obj.saveCountdown = obj.saveInterval end
|
|
updateMenu()
|
|
end)
|
|
|
|
updateMenu()
|
|
|
|
-- ==========================================
|
|
-- RAYCAST INTERACTIVE CHOOSER MENU
|
|
-- ==========================================
|
|
function obj.showMenu()
|
|
local min = math.floor(obj.saveCountdown / 60)
|
|
local sec = obj.saveCountdown % 60
|
|
|
|
-- 1. Base Core Operational Rows
|
|
local choices = {
|
|
{
|
|
text = string.format("⏱️ Next Autosave: %d:%02d", min, sec),
|
|
subText = "📅 Last Saved Profile State: " .. obj.lastSavedTime,
|
|
action = "info"
|
|
},
|
|
{
|
|
text = "📸 Save State Layout",
|
|
subText = "Snapshot active positions to saved_layout.json (Resets auto-timer)",
|
|
action = "save"
|
|
},
|
|
{
|
|
text = "🔄 Restore State Layout",
|
|
subText = "Force apps and window sizes back to your saved profile state",
|
|
action = "restore"
|
|
},
|
|
{
|
|
text = "🚀 Rescue Windows",
|
|
subText = "Cascade active window threads onto primary laptop screen space",
|
|
action = "rescue"
|
|
},
|
|
{
|
|
text = "───────────────────────────────────────────────────────",
|
|
subText = "📦 Captured Windows Inside Profile State File:",
|
|
action = "info"
|
|
}
|
|
}
|
|
|
|
-- 2. Dynamically read layout file and append saved windows
|
|
local data = hs.json.read(obj.layoutFile)
|
|
if data and data.windows and #data.windows > 0 then
|
|
for _, win in ipairs(data.windows) do
|
|
-- Verify if the application is currently running on the system server
|
|
local isRunning = hs.application.get(win.bundleID) or hs.application.get(win.appName)
|
|
local statusIndicator = isRunning and "🟢" or "🔴"
|
|
|
|
local cleanTitle = (win.winTitle and win.winTitle ~= "") and win.winTitle or "Untitled Window"
|
|
if #cleanTitle > 60 then cleanTitle = string.sub(cleanTitle, 1, 57) .. "..." end
|
|
|
|
table.insert(choices, {
|
|
text = string.format("%s %s", statusIndicator, win.appName),
|
|
subText = string.format("↳ Title: \"%s\" | Bounds: %dx%d at (%d,%d)", cleanTitle, win.w, win.h, win.x, win.y),
|
|
action = "info"
|
|
})
|
|
end
|
|
else
|
|
table.insert(choices, {
|
|
text = "⚠️ No Saved Window Metrics Found",
|
|
subText = "Run a Save operation to record your desktop layout profile context.",
|
|
action = "info"
|
|
})
|
|
end
|
|
|
|
if obj.instanceChooser then obj.instanceChooser:hide() end
|
|
|
|
obj.instanceChooser = hs.chooser.new(function(choice)
|
|
if choice then
|
|
if choice.action == "save" then
|
|
obj.saveLayout(false)
|
|
obj.saveCountdown = obj.saveInterval
|
|
elseif choice.action == "restore" then
|
|
obj.restoreLayout()
|
|
elseif choice.action == "rescue" then
|
|
obj.rescueWindowsToLaptop()
|
|
end
|
|
end
|
|
end)
|
|
|
|
obj.instanceChooser:placeholderText("Select a background workspace operation...")
|
|
obj.instanceChooser:choices(choices)
|
|
obj.instanceChooser:show()
|
|
end
|
|
|
|
-- Export module instance globally for direct IPC command routing
|
|
WindowManager = obj
|
|
|
|
return obj |