-- 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