From 047329d1143063dd68283856569b63d089d57850 Mon Sep 17 00:00:00 2001 From: Franco Pellicciotti Date: Fri, 15 May 2026 23:27:36 -0400 Subject: [PATCH] Integration with Raycast --- LayoutSelector.lua | 247 +++++++++++++++++++++++++++------------------ WindowManager.lua | 48 ++++++++- init.lua | 31 ++---- layouts/test.json | 61 +++++++++++ 4 files changed, 263 insertions(+), 124 deletions(-) create mode 100644 layouts/test.json diff --git a/LayoutSelector.lua b/LayoutSelector.lua index a784782..f89008a 100644 --- a/LayoutSelector.lua +++ b/LayoutSelector.lua @@ -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 \ No newline at end of file diff --git a/WindowManager.lua b/WindowManager.lua index 8f300bb..68b220d 100644 --- a/WindowManager.lua +++ b/WindowManager.lua @@ -209,7 +209,6 @@ function obj.rescueWindowsToLaptop() if f.w > maxFrame.w then f.w = maxFrame.w - 100 end if f.h > maxFrame.h then f.h = maxFrame.h - 100 end - -- NEW LOGIC: Start at top-left corner (50px offset) instead of center f.x = maxFrame.x + 50 + staggerOffset f.y = maxFrame.y + 50 + staggerOffset @@ -302,7 +301,6 @@ obj.screenWatcher = hs.screen.watcher.new(function() local currentScreens = #hs.screen.allScreens() - -- NEW GUARD: If count hasn't changed, ignore ghost/handshake events if currentScreens == obj.lastScreenCount then log("DOCK EVENT: Ignored (Screen count unchanged).") return @@ -338,4 +336,50 @@ obj.clockTimer = hs.timer.doEvery(1, function() end) updateMenu() + +-- ========================================== +-- RAYCAST INTERACTIVE CHOOSER MENU +-- ========================================== +function obj.showMenu() + local choices = { + { + 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" + } + } + + 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) + saveCountdown = obj.saveInterval + elseif choice.action == "restore" then + obj.restoreLayout() + elseif choice.action == "rescue" then + obj.rescueWindowsToLaptop() + end + end + end) + + obj.instanceChooser:placeholderText("Workspace Manager Actions...") + obj.instanceChooser:choices(choices) + obj.instanceChooser:show() +end + +-- Export module instance globally for direct IPC command routing +WindowManager = obj + return obj \ No newline at end of file diff --git a/init.lua b/init.lua index 97b8e61..b275da9 100644 --- a/init.lua +++ b/init.lua @@ -6,14 +6,6 @@ hs.dockIcon(false) hs.ipc.cliInstall("/opt/homebrew") hs.alert.show("Hammerspoon Headless Daemon Active", 2) --- ======================================================================== --- HEADLESS WORKSPACE BACKGROUND ENGINE (FORCED AT BOOT) --- ======================================================================== -hs.menuIcon(false) -hs.dockIcon(false) -hs.ipc.cliInstall("/opt/homebrew") -hs.alert.show("Hammerspoon Headless Daemon Active", 2) - -- ~/.hammerspoon/init.lua -- Load Config First -- config = require("Config") @@ -24,7 +16,10 @@ require("HyperKey") require('SearchWindows') require('Caffeine') require('AppBorders') -require('LayoutSelector') + +-- CRITICAL FOR RAYCAST INTERACTION: Bind the return value to a global variable +LayoutSelector = require('LayoutSelector') + require('System_Tweaks') -- Used for Time Machine Throttle Disable -- require("Focus") -- Does not work with layout saver - Not needed if using Monocle Network = require("NetworkCenter") @@ -37,23 +32,19 @@ local productivity = require("productivity") local quickNote = require("affine_quick_note") quickNote.init() require("affine_clipper"):init() ---- --- Load Google API Monitor --- local googleMonitor = require("google_monitor") --- googleMonitor.init() --- -- Load Spoon Files hs.loadSpoon('SpoonInstall') hs.loadSpoon('SpeedMenu') hs.loadSpoon('BrewInfo') --- hs.loadSpoon('Seal') ---- 3. Run/Configure Spoons +-- ========================================== +-- SPOON CONFIGURATION +-- ========================================== ---- SpeedMenu Config if spoon.SpeedMenu then - -- 1. Define the Fix Function (includes MAC and IPv6) + -- Define the Fix Function (includes MAC and IPv6) local function applyFullMenuFix() local interface = spoon.SpeedMenu.interface or "en0" local ssid = hs.wifi.currentNetwork() or "Disconnected" @@ -76,14 +67,14 @@ if spoon.SpeedMenu then spoon.SpeedMenu.menubar:setMenu(menuitems) end - -- 2. Hook the rescan method + -- Hook the rescan method local oldRescan = spoon.SpeedMenu.rescan spoon.SpeedMenu.rescan = function(self) oldRescan(self) applyFullMenuFix() end - -- 3. Toggle Logic (Starts as OFF) + -- Toggle Logic (Starts as OFF) local speedMenuRunning = false hs.hotkey.bind({"cmd", "alt", "ctrl"}, "W", function() if speedMenuRunning then @@ -110,6 +101,4 @@ if spoon.BrewInfo then end ---- BrewInfo END --- Add this line to the module section of your init.lua - hs.alert.show("Hammerspoon Config Reloaded") \ No newline at end of file diff --git a/layouts/test.json b/layouts/test.json new file mode 100644 index 0000000..772f06f --- /dev/null +++ b/layouts/test.json @@ -0,0 +1,61 @@ +{ + "screenCount" : 3, + "windows" : [ + { + "x" : -952, + "bundleID" : "com.apple.finder", + "y" : 159, + "appName" : "Finder", + "h" : 492, + "w" : 920, + "winTitle" : "raycast-scripts" + }, + { + "x" : 2070, + "bundleID" : "com.raycast.macos", + "y" : 121, + "appName" : "Raycast", + "h" : 319, + "w" : 1000, + "winTitle" : "Settings" + }, + { + "x" : 1986, + "bundleID" : "com.google.GeminiMacOS", + "y" : 110, + "appName" : "Gemini", + "h" : 536, + "w" : 1002, + "winTitle" : "Gemini โ€” Hammerspoon Menu Bar Icon Troubleshooting" + }, + { + "x" : 0, + "bundleID" : "com.google.Chrome", + "y" : 39, + "appName" : "Google Chrome", + "h" : 957, + "w" : 1920, + "winTitle" : "francop - Dashboard - Gitea: Git with a cup of tea - Google Chrome" + }, + { + "x" : 2794, + "bundleID" : "com.apple.Terminal", + "y" : 131, + "appName" : "Terminal", + "h" : 499, + "w" : 860, + "winTitle" : "francop โ€” -zsh โ€” 120ร—30" + }, + { + "x" : 0, + "bundleID" : "com.microsoft.VSCode", + "y" : 30, + "appName" : "Code", + "h" : 957, + "w" : 1920, + "winTitle" : "init.lua โ€” .hammerspoon" + } + ], + "saveTime" : "2026-05-15 22:26:48", + "mode" : "Docked" +} \ No newline at end of file