Integration with Raycast

This commit is contained in:
2026-05-15 23:27:36 -04:00
parent d5903784ae
commit 047329d114
4 changed files with 263 additions and 124 deletions
+146 -101
View File
@@ -5,15 +5,19 @@ local json = require("hs.json")
local styledtext = require("hs.styledtext")
-- ==========================================
-- CONFIGURATION & CENTRAL CONFIG LOADING
-- CONFIGURATION & CENTRAL JSON CONFIG LOADING
-- ==========================================
selector.storageDir = os.getenv("HOME") .. "/.hammerspoon/layouts/"
local configFile = os.getenv("HOME") .. "/.hammerspoon/config.json"
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
@@ -26,11 +30,14 @@ local ignoreListItems = {
["com.typewhisper.mac"] = true
}
-- Load Central Config if available
-- 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
-- ==========================================
@@ -77,10 +84,11 @@ local function captureCurrentLayout()
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 = win:title(),
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)
})
@@ -106,7 +114,6 @@ local function executeRestore(filePath, layoutName)
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)
@@ -178,11 +185,138 @@ local function executeRestore(filePath, layoutName)
end
-- ==========================================
-- HOTKEY & MENU MANAGEMENT
-- 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()
-- Only rebind when called explicitly (on file changes)
for _, hk in pairs(selector.layoutHotkeys) do hk:delete() end
selector.layoutHotkeys = {}
@@ -196,108 +330,19 @@ function selector.rebindLayoutKeys()
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
-- INITIALIZATION & GLOBAL EXPORT
-- ==========================================
-- 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()
selector.save(name)
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()
selector.rebindLayoutKeys() -- Run once at load
selector.refreshMenu() -- Run once at load
LayoutSelector = selector
return selector