-- LayoutSelector.lua local selector = {} local json = require("hs.json") local styledtext = require("hs.styledtext") -- ========================================== -- CONFIGURATION & CENTRAL JSON CONFIG LOADING -- ========================================== local homeDir = os.getenv("HOME") or "/Users/francop" selector.storageDir = homeDir .. "/.hammerspoon/layouts/" local configFile = homeDir .. "/.hammerspoon/config.json" selector.layoutHotkeys = {} selector.staticHotkeys = {} selector.instanceChooser = nil selector.actionChooser = nil -- Secondary chooser cache for profile actions if not hs.fs.attributes(selector.storageDir) then hs.fs.mkdir(selector.storageDir) end -- Baseline fallbacks 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 JSON 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 print("LayoutSelector: Successfully loaded rules from central config.json") else print("LayoutSelector: Central config.json not found, utilizing default fallbacks") 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 "" local winTitle = win:title() or "Untitled Window" -- Hybrid check for Capture if not ignoreListItems[appName] and not ignoreListItems[bundleID] then table.insert(layout.windows, { appName = appName, bundleID = bundleID, winTitle = winTitle, 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 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 -- Establish sequential window targeting intervals -- Shifts execution past the window manager canvas updates moveAction() local intervals = isStubborn and {0.2, 0.6, 1.2, 2.2} or {0.3, 1.0} for _, delay in ipairs(intervals) do hs.timer.doAfter(delay, moveAction) end end end end end hs.alert.show("Restored: " .. layoutName, 1.5) end if launchedAny then hs.alert.show("Syncing Apps...", 3) hs.timer.doAfter(5.0, moveWindows) -- Bumped to 5s to let heavy frameworks build window handles completely else moveWindows() end end -- ========================================== -- RAYCAST EXTERNAL IPC HOOKS & MANAGEMENT -- ========================================== function selector.save(name) if not name or name == "" then hs.alert.show("Error: Invalid Layout Name", 2) return false end local path = selector.storageDir .. name .. ".json" local layoutData = captureCurrentLayout() hs.json.write(layoutData, path, true, true) selector.rebindLayoutKeys() hs.alert.show("Saved Layout: " .. name, 2) return true end function selector.load(name) if not name or name == "" then hs.alert.show("Error: Specify Layout Name", 2) return false end local path = selector.storageDir .. name .. ".json" if hs.fs.attributes(path) then executeRestore(path, name) return true else hs.alert.show("Layout Not Found: " .. name, 2) return false end end -- Secondary Selector Options Window (Handles single profile rules) function selector.showLayoutActions(layoutName) local path = selector.storageDir .. layoutName .. ".json" local actionChoices = { { text = "🚀 Restore Layout Profile", subText = "Snap open apps back into position for: " .. layoutName, action = "restore" }, { text = "🔄 Update / Overwrite Profile", subText = "Replace layout coordinates with your current window setup", action = "update" }, { text = "🗑️ Delete Layout Profile", subText = "Permanently remove the profile config file from disk", action = "delete" }, { text = "───────────────────────────────────────────────────────", subText = "📦 Captured Windows Inside Profile State File:", action = "info" } } -- Dynamically read layout profile file and append saved windows local data = hs.json.read(path) 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(actionChoices, { 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(actionChoices, { text = "⚠️ No Saved Window Metrics Found", subText = "This layout profile does not contain any captured windows.", action = "info" }) end if selector.actionChooser then selector.actionChooser:hide() end selector.actionChooser = hs.chooser.new(function(choice) if choice then if choice.action == "restore" then selector.load(layoutName) elseif choice.action == "update" then hs.json.write(captureCurrentLayout(), path, true, true) hs.alert.show("Updated: " .. layoutName, 2) elseif choice.action == "delete" then os.remove(path) selector.rebindLayoutKeys() hs.alert.show("Deleted Profile: " .. layoutName, 2) end end end) selector.actionChooser:placeholderText("Manage Layout Context [" .. layoutName .. "]...") selector.actionChooser:choices(actionChoices) selector.actionChooser:show() end -- Primary Selection Interface Window function selector.showMenu() local choices = { { text = "📸 Create / Save New Layout...", subText = "Take a snapshot snapshot of your active desktop workspace configuration", action = "create" } } local files = getLayoutFiles() for _, file in ipairs(files) do local label = file:gsub("%.json$", "") local path = selector.storageDir .. file local data = hs.json.read(path) local subTextStr = "Layout Profile" if data then local modeStr = (data.mode == "Docked") and "🖥️ Docked" or "💻 Laptop" subTextStr = string.format("%s Mode | Screens: %d | Saved: %s", modeStr, data.screenCount or 1, data.saveTime or "Unknown") end table.insert(choices, { text = "📁 " .. label, subText = subTextStr, action = "manage", layoutName = label }) end if selector.instanceChooser then selector.instanceChooser:hide() end selector.instanceChooser = hs.chooser.new(function(choice) if choice then if choice.action == "create" then -- Fallback input trigger to type profile key local _, name = hs.dialog.textPrompt("Create Window Layout", "Enter a unique profile label name:", "", "Save", "Cancel") if name and name ~= "" then selector.save(name) end elseif choice.action == "manage" then -- Hand context frame mapping loop to the action menu selector block selector.showLayoutActions(choice.layoutName) end end end) selector.instanceChooser:placeholderText("Select a layout workspace profile or register a new one...") selector.instanceChooser:choices(choices) selector.instanceChooser:show() end -- ========================================== -- HOTKEY MANAGEMENT -- ========================================== function selector.rebindLayoutKeys() 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 -- ========================================== -- INITIALIZATION & GLOBAL EXPORT -- ========================================== 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 selector.save(name) end end) selector.rebindLayoutKeys() LayoutSelector = selector return selector