348 lines
13 KiB
Lua
348 lines
13 KiB
Lua
-- 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
|
|
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
|
|
|
|
-- ==========================================
|
|
-- 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"
|
|
}
|
|
}
|
|
|
|
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 |