Initial commit of Hammerspoon config
This commit is contained in:
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,82 @@
|
||||
--- AppBorders.lua
|
||||
|
||||
local logger = hs.logger.new("AppBorders")
|
||||
logger.i("Init")
|
||||
|
||||
-- 1. Variables must be global (no 'local') so they don't get garbage collected
|
||||
global_border = nil
|
||||
isBorderEnabled = false -- This tracks if the mode is ON or OFF
|
||||
|
||||
function initBorder()
|
||||
local win = hs.window.focusedWindow()
|
||||
local frame
|
||||
if win ~= nil then
|
||||
frame = win:frame()
|
||||
else
|
||||
frame = hs.geometry.new(0, 0, 0, 0)
|
||||
end
|
||||
|
||||
global_border = hs.drawing.rectangle(frame)
|
||||
global_border:setStrokeColor({ ["red"] = 1, ["blue"] = 0, ["green"] = 0, ["alpha"] = 0.8 })
|
||||
global_border:setFill(false)
|
||||
global_border:setStrokeWidth(8)
|
||||
|
||||
-- Only show if enabled
|
||||
if isBorderEnabled then
|
||||
global_border:show()
|
||||
end
|
||||
end
|
||||
|
||||
function redrawBorder(window, name, event)
|
||||
-- If the toggle is OFF, make sure border is hidden and stop
|
||||
if not isBorderEnabled then
|
||||
if global_border then global_border:hide() end
|
||||
return
|
||||
end
|
||||
|
||||
-- Skip specific apps
|
||||
if name == 'Kontrollzentrum' then return end
|
||||
|
||||
local win = hs.window.focusedWindow()
|
||||
if win ~= nil then
|
||||
-- Create the border object if it doesn't exist yet
|
||||
if not global_border then initBorder() end
|
||||
|
||||
local newFrame = win:frame()
|
||||
local currentFrame = global_border:frame()
|
||||
|
||||
if not newFrame:equals(currentFrame) then
|
||||
global_border:setFrame(newFrame)
|
||||
end
|
||||
global_border:show()
|
||||
else
|
||||
if global_border then global_border:hide() end
|
||||
end
|
||||
end
|
||||
|
||||
-- 2. Setup the window filter (but it won't do anything until isBorderEnabled is true)
|
||||
allwindows = hs.window.filter.new(nil)
|
||||
allwindows:subscribe(hs.window.filter.windowCreated, redrawBorder)
|
||||
allwindows:subscribe(hs.window.filter.windowDestroyed, redrawBorder)
|
||||
allwindows:subscribe(hs.window.filter.windowFocused, redrawBorder)
|
||||
allwindows:subscribe(hs.window.filter.windowMoved, redrawBorder)
|
||||
allwindows:subscribe(hs.window.filter.windowUnfocused, redrawBorder)
|
||||
|
||||
-- 3. THE TOGGLE FUNCTION
|
||||
function toggleAppBorders()
|
||||
isBorderEnabled = not isBorderEnabled
|
||||
|
||||
if isBorderEnabled then
|
||||
hs.alert.show("Window Borders: ON")
|
||||
if not global_border then initBorder() end
|
||||
redrawBorder() -- Trigger immediate draw
|
||||
else
|
||||
hs.alert.show("Window Borders: OFF")
|
||||
if global_border then global_border:hide() end
|
||||
end
|
||||
end
|
||||
|
||||
-- 4. THE HOTKEY (Change "B" or the modifiers to your liking)
|
||||
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "B", function()
|
||||
toggleAppBorders()
|
||||
end)
|
||||
@@ -0,0 +1,56 @@
|
||||
--- Caffeine.lua
|
||||
|
||||
local obj = {}
|
||||
|
||||
-- 1. POPUP ON LOAD
|
||||
hs.alert.show("Caffeine Loaded", 2)
|
||||
|
||||
-- 2. Create the menubar item
|
||||
-- We use a unique ID just to keep things stable
|
||||
obj.menu = hs.menubar.new(true, "CaffeineApp")
|
||||
|
||||
local on_message = 'Caffeine: ON'
|
||||
local off_message = 'Caffeine: OFF'
|
||||
local on_icon = "☕️"
|
||||
local off_icon = "😴"
|
||||
local hyper = {"cmd", "alt", "ctrl"}
|
||||
|
||||
function obj:init(mod, key, description)
|
||||
|
||||
local function setCaffeineDisplay(state)
|
||||
if state then
|
||||
hs.alert.show(on_message)
|
||||
if obj.menu then
|
||||
obj.menu:setTitle(on_icon)
|
||||
-- This ensures the hover text says "Caffeine: ON"
|
||||
obj.menu:setTooltip(on_message)
|
||||
end
|
||||
else
|
||||
hs.alert.show(off_message)
|
||||
if obj.menu then
|
||||
obj.menu:setTitle(off_icon)
|
||||
-- This ensures the hover text says "Caffeine: OFF"
|
||||
obj.menu:setTooltip(off_message)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function caffeineClicked()
|
||||
-- Toggles the system sleep prevention and updates the UI
|
||||
setCaffeineDisplay(hs.caffeinate.toggle("displayIdle"))
|
||||
end
|
||||
|
||||
if obj.menu then
|
||||
obj.menu:setClickCallback(caffeineClicked)
|
||||
-- Set initial state based on current system status
|
||||
setCaffeineDisplay(hs.caffeinate.get("displayIdle"))
|
||||
end
|
||||
|
||||
-- Bind the hotkey (Cmd + Alt + Ctrl + C)
|
||||
hs.hotkey.bind(mod, key, caffeineClicked)
|
||||
end
|
||||
|
||||
-- Initialize the object
|
||||
obj:init(hyper, 'c', "Toggle Caffeine")
|
||||
|
||||
return obj
|
||||
@@ -0,0 +1,219 @@
|
||||
local selector = {}
|
||||
local json = require("hs.json")
|
||||
local styledtext = require("hs.styledtext")
|
||||
|
||||
-- ==========================================
|
||||
-- CONFIGURATION
|
||||
-- ==========================================
|
||||
selector.storageDir = os.getenv("HOME") .. "/.hammerspoon/layouts/"
|
||||
selector.hotkeys = {}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
local ignoreListItems = {
|
||||
["TheBoringNotch"] = true,
|
||||
["theboringteam.boringnotch"] = true,
|
||||
["Control Center"] = true,
|
||||
["Notification Center"] = true,
|
||||
["Dock"] = true,
|
||||
["com.surteesstudios.Bartender"] = true,
|
||||
["pro.betterdisplay.BetterDisplay"] = true
|
||||
}
|
||||
|
||||
-- ==========================================
|
||||
-- INTERNAL LOGIC
|
||||
-- ==========================================
|
||||
|
||||
local function wrapText(text, limit)
|
||||
local lines = {}
|
||||
local currentLine = ""
|
||||
for word in text:gmatch("%S+") do
|
||||
local cleanWord = word:gsub(",$", "")
|
||||
if #currentLine + #word >= limit then
|
||||
table.insert(lines, currentLine .. (currentLine ~= "" and "," or ""))
|
||||
currentLine = " " .. word
|
||||
else
|
||||
currentLine = currentLine == "" and " ↳ " .. word or currentLine .. " " .. word
|
||||
end
|
||||
end
|
||||
table.insert(lines, currentLine)
|
||||
return lines
|
||||
end
|
||||
|
||||
local function captureCurrentLayout()
|
||||
local layout = { saveTime = os.date("%Y-%m-%d %H:%M:%S"), windows = {} }
|
||||
for _, win in ipairs(hs.window.allWindows()) do
|
||||
local app = win:application()
|
||||
local screen = win:screen()
|
||||
local frame = win:frame()
|
||||
if app and screen and win:isVisible() and frame.w > 0 and frame.h > 0 then
|
||||
local appName = app:name() or ""
|
||||
local bid = app:bundleID() or ""
|
||||
if not (ignoreListItems[appName] or ignoreListItems[bid] or appName:find("TheBoringNotch")) then
|
||||
table.insert(layout.windows, {
|
||||
appName = appName, bundleID = bid, winTitle = win:title(),
|
||||
screenName = screen:name(), x = math.floor(frame.x), y = math.floor(frame.y),
|
||||
w = math.floor(frame.w), h = math.floor(frame.h)
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
return layout
|
||||
end
|
||||
|
||||
local function executeRestore(filePath, layoutName)
|
||||
local data = hs.json.read(filePath)
|
||||
if not data then return end
|
||||
local windowList = data.windows or data
|
||||
local launchedAny = false
|
||||
|
||||
for _, winData in ipairs(windowList) do
|
||||
local app = hs.application.get(winData.bundleID) or hs.application.get(winData.appName)
|
||||
if not app then
|
||||
if winData.bundleID and winData.bundleID ~= "" then
|
||||
hs.application.launchOrFocusByBundleID(winData.bundleID)
|
||||
else
|
||||
hs.application.launchOrFocus(winData.appName)
|
||||
end
|
||||
launchedAny = true
|
||||
end
|
||||
end
|
||||
|
||||
local function moveWindows()
|
||||
for _, winData in ipairs(windowList) do
|
||||
local app = hs.application.get(winData.bundleID) or hs.application.get(winData.appName)
|
||||
if app then
|
||||
local pid = app:pid()
|
||||
local appPath = app:path() or ""
|
||||
local isElectron = appPath:find("Electron") or appPath:find("Frameworks")
|
||||
|
||||
for _, win in ipairs(app:allWindows()) do
|
||||
-- Check if window is minimized and restore it if so
|
||||
if win:isMinimized() then win:unminimize() end
|
||||
|
||||
local isStubborn = stubbornAppsList[app:name()] or isElectron
|
||||
local x, y, w, h = math.floor(winData.x), math.floor(winData.y), math.floor(winData.w), math.floor(winData.h)
|
||||
|
||||
if isStubborn then
|
||||
win:setFrame({x=x, y=y, w=w, h=h}, 0)
|
||||
local script = string.format([[
|
||||
tell application "System Events" to tell (first process whose unix id is %d)
|
||||
set position of window 1 to {%d, %d}
|
||||
set size of window 1 to {%d, %d}
|
||||
end tell
|
||||
]], pid, x, y, w, h)
|
||||
hs.applescript.applescript(script)
|
||||
hs.timer.doAfter(0.5, function() hs.applescript.applescript(script) end)
|
||||
else
|
||||
win:setFrame({x=x, y=y, w=w, h=h}, 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
hs.alert.show("Restored: " .. layoutName, 1.5)
|
||||
end
|
||||
|
||||
if launchedAny then
|
||||
hs.alert.show("Launching apps...", 3.5)
|
||||
hs.timer.doAfter(4.5, moveWindows)
|
||||
else
|
||||
moveWindows()
|
||||
end
|
||||
end
|
||||
|
||||
local function minimizeAll()
|
||||
local allWindows = hs.window.filter.new():getWindows()
|
||||
for _, win in ipairs(allWindows) do
|
||||
local app = win:application()
|
||||
if app and not ignoreListItems[app:name()] then
|
||||
win:minimize()
|
||||
end
|
||||
end
|
||||
hs.alert.show("All Windows Minimized", 1)
|
||||
end
|
||||
|
||||
-- ==========================================
|
||||
-- MENU BAR & HOTKEYS
|
||||
-- ==========================================
|
||||
selector.barItem = hs.menubar.new()
|
||||
|
||||
function selector.refreshMenu()
|
||||
for _, hk in pairs(selector.hotkeys) do hk:delete() end
|
||||
selector.hotkeys = {}
|
||||
|
||||
-- Global Hotkeys
|
||||
selector.hotkeys["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.refreshMenu()
|
||||
end
|
||||
end)
|
||||
selector.hotkeys["minimize"] = hs.hotkey.bind({"shift", "ctrl", "alt", "cmd"}, "M", minimizeAll)
|
||||
|
||||
local menuTable = {
|
||||
{ title = "📸 Save New Layout...", fn = function() hs.eventtap.keyStroke({"shift", "ctrl", "alt", "cmd"}, "S") end },
|
||||
{ title = "📉 Minimize All Windows", fn = minimizeAll },
|
||||
{ title = "-" }
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
if i <= 9 then selector.hotkeys[i] = hs.hotkey.bind({"ctrl", "alt", "cmd"}, tostring(i), function() executeRestore(path, label) end) end
|
||||
|
||||
table.insert(menuTable, {
|
||||
title = hs.styledtext.new("Layout: " .. label .. (i<=9 and " (⌃⌥⌘"..i..")" or ""), boldStyle),
|
||||
fn = function() executeRestore(path, label) end
|
||||
})
|
||||
|
||||
if data and data.saveTime then table.insert(menuTable, { title = " 📅 Saved: " .. data.saveTime, disabled = true }) end
|
||||
if data and data.windows then
|
||||
local seen, appNames = {}, {}
|
||||
for _, w in ipairs(data.windows) do if not seen[w.appName] then table.insert(appNames, w.appName .. ","); seen[w.appName] = true end end
|
||||
for _, line in ipairs(wrapText(table.concat(appNames, " "), 50)) do table.insert(menuTable, { title = line:gsub(",$", ""), disabled = true }) end
|
||||
end
|
||||
|
||||
table.insert(menuTable, {
|
||||
title = hs.styledtext.new(" 🔄 Update " .. label, boldStyle),
|
||||
fn = function()
|
||||
hs.json.write(captureCurrentLayout(), path, true, true)
|
||||
selector.refreshMenu()
|
||||
hs.alert.show("Updated: " .. label)
|
||||
end
|
||||
})
|
||||
|
||||
table.insert(menuTable, { title = " 🧹 Restore & Cleanup", fn = function() executeRestore(path, label) end })
|
||||
table.insert(menuTable, { title = " 📝 Rename", fn = function()
|
||||
local _, n = hs.dialog.textPrompt("Rename", "New name:", label, "Rename", "Cancel")
|
||||
if n and n ~= "" then os.rename(path, selector.storageDir .. n .. ".json"); selector.refreshMenu() end
|
||||
end })
|
||||
table.insert(menuTable, { title = " 🗑️ Delete", fn = function()
|
||||
if hs.dialog.blockAlert("Delete", "Delete "..label.."?", "Delete", "Cancel", "critical") == "Delete" then
|
||||
os.remove(path); selector.refreshMenu()
|
||||
end
|
||||
end })
|
||||
table.insert(menuTable, { title = "-" })
|
||||
end
|
||||
selector.barItem:setMenu(menuTable)
|
||||
end
|
||||
|
||||
selector.barItem:setTitle("🪟")
|
||||
selector.refreshMenu()
|
||||
hs.alert.show("Layout Selector Loaded", 1.5)
|
||||
return selector
|
||||
@@ -0,0 +1,229 @@
|
||||
-- SETTINGS: Customize your engines and display names here
|
||||
local engines = {
|
||||
-- primary = { name = "Google", url = "https://google.ca/search?q=" },
|
||||
primary = { name = "Google/StartPage", url = "https://www.startpage.com/do/search?query=" },
|
||||
secondary = { name = "Wikipedia", url = "https://wikipedia.org/wiki/Special:Search?search="},
|
||||
tertiary = { name = "YouTube", url = "https://youtube.com/results?search_query=" },
|
||||
chatgpt = { name = "ChatGPT", url = "https://chatgpt.com/?q=" },
|
||||
maps = { name = "Maps", url = "https://www.google.com/maps/search/" },
|
||||
spotify = { name = "Spotify", url = "https://open.spotify.com/search/" }
|
||||
}
|
||||
|
||||
local closeTimeout = 1.0
|
||||
local gracePeriod = 3.0
|
||||
|
||||
-- Global variables
|
||||
searchView = nil
|
||||
searchTimer = nil
|
||||
escWatcher = nil
|
||||
clickOutsideWatcher = nil
|
||||
|
||||
local timeSinceLeft = 0
|
||||
local hasEnteredWindow = false
|
||||
local autoKillTime = 0
|
||||
|
||||
------------------------------------------------------------
|
||||
-- CLOSE SEARCH
|
||||
------------------------------------------------------------
|
||||
local function closeSearch()
|
||||
if searchView then searchView:delete() searchView = nil end
|
||||
if searchTimer then searchTimer:stop() searchTimer = nil end
|
||||
if escWatcher then escWatcher:stop() escWatcher = nil end
|
||||
if clickOutsideWatcher then clickOutsideWatcher:stop() clickOutsideWatcher = nil end
|
||||
|
||||
timeSinceLeft = 0
|
||||
hasEnteredWindow = false
|
||||
autoKillTime = 0
|
||||
end
|
||||
|
||||
------------------------------------------------------------
|
||||
-- PERFORM SEARCH
|
||||
------------------------------------------------------------
|
||||
local function performSearch(engine)
|
||||
hs.alert.show("Searching " .. engine.name .. "...", 1)
|
||||
|
||||
hs.eventtap.keyStroke({"cmd"}, "c")
|
||||
|
||||
hs.timer.doAfter(0.2, function()
|
||||
|
||||
local query = hs.pasteboard.getContents()
|
||||
if not query or query == "" then return end
|
||||
|
||||
closeSearch()
|
||||
|
||||
-- RESET TRACKERS FOR NEW SEARCH
|
||||
timeSinceLeft = 0
|
||||
hasEnteredWindow = false
|
||||
autoKillTime = 0
|
||||
|
||||
local url = engine.url .. hs.http.encodeForQuery(query)
|
||||
|
||||
local screen = hs.mouse.getCurrentScreen()
|
||||
local screenFrame = screen:frame()
|
||||
|
||||
local width, height = 500, 600
|
||||
local x = screenFrame.x + (screenFrame.w - width) / 2
|
||||
local y = screenFrame.y + (screenFrame.h - height) / 2
|
||||
|
||||
searchView = hs.webview.new({
|
||||
x = x,
|
||||
y = y,
|
||||
w = width,
|
||||
h = height
|
||||
})
|
||||
|
||||
searchView:windowStyle({"titled", "utility", "closable", "resizable"})
|
||||
searchView:level(hs.drawing.windowLevels.floating)
|
||||
|
||||
searchView:userAgent(
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko)"
|
||||
)
|
||||
|
||||
searchView:url(url)
|
||||
searchView:show()
|
||||
searchView:bringToFront()
|
||||
|
||||
--------------------------------------------------------
|
||||
-- ESC CLOSE
|
||||
--------------------------------------------------------
|
||||
escWatcher = hs.eventtap.new(
|
||||
{hs.eventtap.event.types.keyDown},
|
||||
function(event)
|
||||
|
||||
if event:getKeyCode() == 53 then
|
||||
closeSearch()
|
||||
return true
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
):start()
|
||||
|
||||
--------------------------------------------------------
|
||||
-- CLICK OUTSIDE CLOSE
|
||||
--------------------------------------------------------
|
||||
clickOutsideWatcher = hs.eventtap.new(
|
||||
{hs.eventtap.event.types.leftMouseDown},
|
||||
function()
|
||||
|
||||
if not searchView then return false end
|
||||
|
||||
local mousePos = hs.mouse.absolutePosition()
|
||||
local winFrame = searchView:frame()
|
||||
|
||||
local isOutside =
|
||||
mousePos.x < winFrame.x or
|
||||
mousePos.x > (winFrame.x + winFrame.w) or
|
||||
mousePos.y < winFrame.y or
|
||||
mousePos.y > (winFrame.y + winFrame.h)
|
||||
|
||||
if isOutside then closeSearch() end
|
||||
|
||||
return false
|
||||
end
|
||||
):start()
|
||||
|
||||
--------------------------------------------------------
|
||||
-- AUTO CLOSE TIMER
|
||||
--------------------------------------------------------
|
||||
searchTimer = hs.timer.doEvery(0.1, function()
|
||||
|
||||
if not searchView then return end
|
||||
|
||||
local mousePos = hs.mouse.absolutePosition()
|
||||
local winFrame = searchView:frame()
|
||||
|
||||
local isOutside =
|
||||
mousePos.x < winFrame.x or
|
||||
mousePos.x > (winFrame.x + winFrame.w) or
|
||||
mousePos.y < winFrame.y or
|
||||
mousePos.y > (winFrame.y + winFrame.h)
|
||||
|
||||
if not isOutside then
|
||||
hasEnteredWindow = true
|
||||
timeSinceLeft = 0
|
||||
else
|
||||
if hasEnteredWindow then
|
||||
|
||||
timeSinceLeft = timeSinceLeft + 0.1
|
||||
|
||||
if timeSinceLeft >= closeTimeout then
|
||||
closeSearch()
|
||||
end
|
||||
|
||||
else
|
||||
|
||||
autoKillTime = autoKillTime + 0.1
|
||||
|
||||
if autoKillTime >= gracePeriod then
|
||||
closeSearch()
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
end)
|
||||
|
||||
end)
|
||||
end
|
||||
|
||||
------------------------------------------------------------
|
||||
-- RIGHT CLICK TRIGGERS
|
||||
------------------------------------------------------------
|
||||
if clickWatcher then clickWatcher:stop() end
|
||||
|
||||
clickWatcher = hs.eventtap.new(
|
||||
{hs.eventtap.event.types.rightMouseDown},
|
||||
function(event)
|
||||
|
||||
local flags = event:getFlags()
|
||||
|
||||
if flags.cmd and flags.shift then
|
||||
performSearch(engines.maps)
|
||||
return true
|
||||
|
||||
elseif flags.alt and flags.shift then
|
||||
performSearch(engines.spotify)
|
||||
return true
|
||||
|
||||
elseif flags.cmd then
|
||||
performSearch(engines.primary)
|
||||
return true
|
||||
|
||||
elseif flags.alt then
|
||||
performSearch(engines.secondary)
|
||||
return true
|
||||
|
||||
elseif flags.ctrl then
|
||||
performSearch(engines.tertiary)
|
||||
return true
|
||||
|
||||
elseif flags.shift then
|
||||
performSearch(engines.chatgpt)
|
||||
return true
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
):start()
|
||||
|
||||
------------------------------------------------------------
|
||||
-- CMD + ENTER → OPEN CURRENT PAGE
|
||||
------------------------------------------------------------
|
||||
hs.hotkey.bind({"cmd"}, "return", function()
|
||||
|
||||
if searchView then
|
||||
|
||||
local currentPage = searchView:url()
|
||||
|
||||
if currentPage then
|
||||
hs.urlevent.openURL(currentPage)
|
||||
else
|
||||
hs.alert.show("No page loaded")
|
||||
end
|
||||
|
||||
else
|
||||
hs.alert.show("No search window open")
|
||||
end
|
||||
|
||||
end)
|
||||
@@ -0,0 +1,16 @@
|
||||
-- System_Tweaks.lua
|
||||
-- Disable Time Machine throttling silently
|
||||
local function disableTMThrottling()
|
||||
-- Use hs.execute to run the command directly via sudo
|
||||
-- Since we added the NOPASSWD rule, this will be silent and instant
|
||||
local output, status, type, rc = hs.execute("sudo /usr/sbin/sysctl debug.lowpri_throttle_enabled=0", true)
|
||||
|
||||
if status then
|
||||
hs.notify.new({title="Hammerspoon", informativeText="Time Machine unthrottled (Silent)."}):send()
|
||||
else
|
||||
print("Sudo Error: " .. output)
|
||||
end
|
||||
end
|
||||
|
||||
-- Run on Hammerspoon load/reload
|
||||
disableTMThrottling()
|
||||
@@ -0,0 +1,263 @@
|
||||
local selector = {}
|
||||
local json = require("hs.json")
|
||||
local styledtext = require("hs.styledtext")
|
||||
|
||||
-- ==========================================
|
||||
-- CONFIGURATION
|
||||
-- ==========================================
|
||||
selector.storageDir = os.getenv("HOME") .. "/.hammerspoon/layouts/"
|
||||
selector.hotkeys = {}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
local ignoreListItems = {
|
||||
["TheBoringNotch"] = true,
|
||||
["theboringteam.boringnotch"] = true,
|
||||
["Control Center"] = true,
|
||||
["Notification Center"] = true,
|
||||
["Dock"] = true,
|
||||
["com.surteesstudios.Bartender"] = true,
|
||||
["pro.betterdisplay.BetterDisplay"] = true,
|
||||
["stats"] = true,
|
||||
["eu.exelban.Stats"] = true,
|
||||
["com.ethanbills.DockDoor"] = true,
|
||||
["DockDoor"] = true
|
||||
}
|
||||
|
||||
-- ==========================================
|
||||
-- INTERNAL LOGIC
|
||||
-- ==========================================
|
||||
|
||||
local function wrapText(text, limit)
|
||||
local lines = {}
|
||||
local currentLine = ""
|
||||
for word in text:gmatch("%S+") do
|
||||
local cleanWord = word:gsub(",$", "")
|
||||
if #currentLine + #word >= limit then
|
||||
table.insert(lines, currentLine .. (currentLine ~= "" and "," or ""))
|
||||
currentLine = " " .. word
|
||||
else
|
||||
currentLine = currentLine == "" and " ↳ " .. word or currentLine .. " " .. word
|
||||
end
|
||||
end
|
||||
table.insert(lines, currentLine)
|
||||
return lines
|
||||
end
|
||||
|
||||
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()
|
||||
local screen = win:screen()
|
||||
local frame = win:frame()
|
||||
if app and screen and win:isVisible() and frame.w > 0 and frame.h > 0 then
|
||||
local appName = app:name() or ""
|
||||
local bid = app:bundleID() or ""
|
||||
if not (ignoreListItems[appName] or ignoreListItems[bid] or appName:find("TheBoringNotch")) then
|
||||
table.insert(layout.windows, {
|
||||
appName = appName,
|
||||
bundleID = bid,
|
||||
winTitle = win:title(),
|
||||
winID = win:id(),
|
||||
screenName = screen:name(),
|
||||
x = math.floor(frame.x),
|
||||
y = math.floor(frame.y),
|
||||
w = math.floor(frame.w),
|
||||
h = math.floor(frame.h)
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
return layout
|
||||
end
|
||||
|
||||
local function executeRestore(filePath, layoutName)
|
||||
local data = hs.json.read(filePath)
|
||||
if not data then return end
|
||||
local windowList = data.windows or data
|
||||
local launchedAny = false
|
||||
|
||||
for _, winData in ipairs(windowList) do
|
||||
local app = hs.application.get(winData.bundleID) or hs.application.get(winData.appName)
|
||||
if not app then
|
||||
if winData.bundleID and winData.bundleID ~= "" then
|
||||
hs.application.launchOrFocusByBundleID(winData.bundleID)
|
||||
else
|
||||
hs.application.launchOrFocus(winData.appName)
|
||||
end
|
||||
launchedAny = true
|
||||
end
|
||||
end
|
||||
|
||||
local function moveWindows()
|
||||
for _, winData in ipairs(windowList) do
|
||||
local app = hs.application.get(winData.bundleID) or hs.application.get(winData.appName)
|
||||
if app then
|
||||
local pid = app:pid()
|
||||
local appPath = app:path() or ""
|
||||
local isElectron = appPath:find("Electron") or appPath:find("Frameworks")
|
||||
|
||||
for _, win in ipairs(app:allWindows()) do
|
||||
if win:isMinimized() then win:unminimize() end
|
||||
|
||||
local isExactMatch = (win:id() == winData.winID)
|
||||
local isTitleMatch = (win:title() == winData.winTitle)
|
||||
local isStubborn = stubbornAppsList[app:name()] or isElectron
|
||||
|
||||
local isFuzzy = (winData.bundleID == "com.apple.Terminal" or winData.appName == "Gemini")
|
||||
local shouldMove = (isExactMatch or (not winData.winID and isTitleMatch) or (#app:allWindows() == 1 and (isFuzzy or isStubborn)))
|
||||
|
||||
if shouldMove then
|
||||
local x, y, w, h = math.floor(winData.x), math.floor(winData.y), math.floor(winData.w), math.floor(winData.h)
|
||||
|
||||
if isStubborn then
|
||||
-- FIX: Target window by title to prevent stacking
|
||||
local safeTitle = win:title():gsub('"', '\\"')
|
||||
local script = string.format([[
|
||||
tell application "System Events" to tell (first process whose unix id is %d)
|
||||
try
|
||||
set targetWin to (first window whose name is "%s")
|
||||
set position of targetWin to {%d, %d}
|
||||
set size of targetWin to {%d, %d}
|
||||
end try
|
||||
end tell
|
||||
]], pid, safeTitle, x, y, w, h)
|
||||
|
||||
hs.applescript.applescript(script)
|
||||
hs.timer.doAfter(0.5, function() hs.applescript.applescript(script) end)
|
||||
else
|
||||
win:setFrame({x=x, y=y, w=w, h=h}, 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
hs.alert.show("Restored: " .. layoutName, 1.5)
|
||||
end
|
||||
|
||||
if launchedAny then
|
||||
hs.alert.show("Launching apps...", 3.5)
|
||||
hs.timer.doAfter(4.5, moveWindows)
|
||||
else
|
||||
moveWindows()
|
||||
end
|
||||
end
|
||||
|
||||
local function minimizeAll()
|
||||
local allWindows = hs.window.filter.new():getWindows()
|
||||
for _, win in ipairs(allWindows) do
|
||||
local app = win:application()
|
||||
if app and not ignoreListItems[app:name()] then
|
||||
win:minimize()
|
||||
end
|
||||
end
|
||||
hs.alert.show("All Windows Minimized", 1)
|
||||
end
|
||||
|
||||
-- ==========================================
|
||||
-- MENU BAR & HOTKEYS
|
||||
-- ==========================================
|
||||
selector.barItem = hs.menubar.new()
|
||||
|
||||
function selector.refreshMenu()
|
||||
for _, hk in pairs(selector.hotkeys) do hk:delete() end
|
||||
selector.hotkeys = {}
|
||||
|
||||
local screens = hs.screen.allScreens()
|
||||
local isDocked = #screens > 1
|
||||
|
||||
selector.barItem:setTitle(isDocked and "🖥️" or "💻")
|
||||
|
||||
selector.hotkeys["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.refreshMenu()
|
||||
end
|
||||
end)
|
||||
selector.hotkeys["minimize"] = hs.hotkey.bind({"shift", "ctrl", "alt", "cmd"}, "M", minimizeAll)
|
||||
|
||||
local menuTable = {
|
||||
{ title = "📸 Save New Layout... (⇧⌃⌥⌘S)", fn = function() hs.eventtap.keyStroke({"shift", "ctrl", "alt", "cmd"}, "S") end },
|
||||
{ title = "📉 Minimize All Windows (⇧⌃⌥⌘M)", fn = minimizeAll },
|
||||
{ title = "-" }
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
if i <= 9 then selector.hotkeys[i] = hs.hotkey.bind({"ctrl", "alt", "cmd"}, tostring(i), function() executeRestore(path, label) end) end
|
||||
|
||||
table.insert(menuTable, {
|
||||
title = hs.styledtext.new("Layout: " .. label .. (i<=9 and " (⌃⌥⌘"..i..")" or ""), boldStyle),
|
||||
fn = function() executeRestore(path, label) end
|
||||
})
|
||||
|
||||
if data and data.mode then
|
||||
local contextStr = (data.mode == "Docked")
|
||||
and string.format(" 🖥️ Docked (%d Screens)", data.screenCount or 0)
|
||||
or " 💻 Laptop Mode"
|
||||
table.insert(menuTable, { title = contextStr, disabled = true })
|
||||
end
|
||||
|
||||
if data and data.saveTime then table.insert(menuTable, { title = " 📅 Saved: " .. data.saveTime, disabled = true }) end
|
||||
|
||||
if data and data.windows then
|
||||
local seen, appNames = {}, {}
|
||||
for _, w in ipairs(data.windows) do if not seen[w.appName] then table.insert(appNames, w.appName .. ","); seen[w.appName] = true end end
|
||||
for _, line in ipairs(wrapText(table.concat(appNames, " "), 50)) do table.insert(menuTable, { title = line:gsub(",$", ""), disabled = true }) end
|
||||
end
|
||||
|
||||
table.insert(menuTable, {
|
||||
title = hs.styledtext.new(" 🔄 Update " .. label, boldStyle),
|
||||
fn = function()
|
||||
hs.json.write(captureCurrentLayout(), path, true, true)
|
||||
selector.refreshMenu()
|
||||
hs.alert.show("Updated: " .. label)
|
||||
end
|
||||
})
|
||||
|
||||
table.insert(menuTable, { title = " 🧹 Restore & Cleanup", fn = function() executeRestore(path, label) end })
|
||||
table.insert(menuTable, { title = " 📝 Rename", fn = function()
|
||||
local _, n = hs.dialog.textPrompt("Rename", "New name:", label, "Rename", "Cancel")
|
||||
if n and n ~= "" then os.rename(path, selector.storageDir .. n .. ".json"); selector.refreshMenu() end
|
||||
end })
|
||||
table.insert(menuTable, { title = " 🗑️ Delete", fn = function()
|
||||
if hs.dialog.blockAlert("Delete", "Delete "..label.."?", "Delete", "Cancel", "critical") == "Delete" then
|
||||
os.remove(path); selector.refreshMenu()
|
||||
end
|
||||
end })
|
||||
table.insert(menuTable, { title = "-" })
|
||||
end
|
||||
selector.barItem:setMenu(menuTable)
|
||||
end
|
||||
|
||||
selector.screenWatcher = hs.screen.watcher.new(function()
|
||||
selector.refreshMenu()
|
||||
end):start()
|
||||
|
||||
selector.refreshMenu()
|
||||
hs.alert.show("Layout Selector Loaded", 1.5)
|
||||
return selector
|
||||
@@ -0,0 +1,309 @@
|
||||
local obj = {}
|
||||
local json = require("hs.json")
|
||||
local styledtext = require("hs.styledtext")
|
||||
|
||||
-- ==========================================
|
||||
-- CONFIGURATION
|
||||
-- ==========================================
|
||||
obj.layoutFile = os.getenv("HOME") .. "/.hammerspoon/saved_layout.json"
|
||||
obj.saveInterval = 300
|
||||
obj.isRescued = false
|
||||
obj.isTransitioning = false
|
||||
obj.lastScreenCount = #hs.screen.allScreens()
|
||||
|
||||
local stubbornAppsList = {
|
||||
["Gemini"] = true,
|
||||
["AFFiNE"] = true,
|
||||
["Terminal"] = true,
|
||||
["System Settings"] = true,
|
||||
["Hammerspoon"] = true
|
||||
}
|
||||
|
||||
local ignoreListItems = {
|
||||
["TheBoringNotch"] = true,
|
||||
["theboringteam.boringnotch"] = true,
|
||||
["Control Center"] = true,
|
||||
["Notification Center"] = true,
|
||||
["Dock"] = true,
|
||||
["com.surteesstudios.Bartender"] = true,
|
||||
["pro.betterdisplay.BetterDisplay"] = true,
|
||||
["stats"] = true,
|
||||
["eu.exelban.Stats"] = true,
|
||||
["com.ethanbills.DockDoor"] = true,
|
||||
["DockDoor"] = true
|
||||
}
|
||||
|
||||
-- ==========================================
|
||||
-- POWER & SLEEP WATCHER
|
||||
-- ==========================================
|
||||
|
||||
obj.powerWatcher = hs.caffeinate.watcher.new(function(event)
|
||||
if event == hs.caffeinate.watcher.systemWillSleep or
|
||||
event == hs.caffeinate.watcher.systemWillPowerOff or
|
||||
event == hs.caffeinate.watcher.screensDidSleep then
|
||||
|
||||
-- Lock immediately to prevent saving the "scrambled" sleep layout
|
||||
obj.isTransitioning = true
|
||||
|
||||
elseif event == hs.caffeinate.watcher.systemDidWake or
|
||||
event == hs.caffeinate.watcher.screensDidWake then
|
||||
|
||||
-- Wait for monitors to handshake, then auto-restore
|
||||
hs.timer.doAfter(5, function()
|
||||
obj.isTransitioning = false
|
||||
obj.restoreLayout()
|
||||
end)
|
||||
end
|
||||
end):start()
|
||||
|
||||
-- ==========================================
|
||||
-- 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
|
||||
|
||||
-- ==========================================
|
||||
-- CORE LOGIC
|
||||
-- ==========================================
|
||||
|
||||
function obj.saveLayout(silent)
|
||||
if obj.isTransitioning or hs.caffeinate.get("displayIdle") or hs.caffeinate.get("systemIdle") then return end
|
||||
|
||||
local screens = hs.screen.allScreens()
|
||||
local layout = {
|
||||
saveTime = os.date("%H:%M:%S"),
|
||||
screenCount = #screens,
|
||||
mode = (#screens > 1) and "Docked" or "Laptop",
|
||||
windows = {}
|
||||
}
|
||||
|
||||
local allWindows = hs.window.allWindows()
|
||||
local consoleWin = hs.console.hswindow()
|
||||
if consoleWin then table.insert(allWindows, consoleWin) end
|
||||
|
||||
for _, win in ipairs(allWindows) do
|
||||
local app = win:application()
|
||||
local screen = win:screen()
|
||||
local frame = win:frame()
|
||||
|
||||
if app and screen and (win:isVisible() or win:title() == "Hammerspoon Console") and frame.w > 0 and frame.h > 0 then
|
||||
local appName = app:name() or ""
|
||||
local bid = app:bundleID() or ""
|
||||
if not (ignoreListItems[appName] or ignoreListItems[bid] or appName:find("TheBoringNotch")) then
|
||||
table.insert(layout.windows, {
|
||||
appName = appName,
|
||||
bundleID = bid,
|
||||
winTitle = win:title(),
|
||||
winID = win:id(),
|
||||
x = math.floor(frame.x),
|
||||
y = math.floor(frame.y),
|
||||
w = math.floor(frame.w),
|
||||
h = math.floor(frame.h)
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
hs.json.write(layout, obj.layoutFile, true, true)
|
||||
obj.lastSavedTime = layout.saveTime
|
||||
if not silent then hs.alert.show("Layout Saved", 1.5) end
|
||||
end
|
||||
|
||||
function obj.restoreLayout()
|
||||
local data = hs.json.read(obj.layoutFile)
|
||||
if not data then return end
|
||||
obj.isRescued = false
|
||||
local windowList = data.windows or data
|
||||
|
||||
for _, winData in ipairs(windowList) do
|
||||
if winData.winTitle == "Hammerspoon Console" then
|
||||
local cWin = hs.console.hswindow()
|
||||
if cWin then cWin:setFrame({x=winData.x, y=winData.y, w=winData.w, h=winData.h}, 0) end
|
||||
else
|
||||
local app = hs.application.get(winData.bundleID) or hs.application.get(winData.appName)
|
||||
if app then
|
||||
local pid = app:pid()
|
||||
local appPath = app:path() or ""
|
||||
local isElectron = appPath:find("Electron") or appPath:find("Frameworks")
|
||||
|
||||
for _, win in ipairs(app:allWindows()) do
|
||||
if win:isMinimized() then win:unminimize() end
|
||||
|
||||
local isExactMatch = (win:id() == winData.winID)
|
||||
local isTitleMatch = (win:title() == winData.winTitle)
|
||||
local isStubborn = stubbornAppsList[app:name()] or isElectron
|
||||
|
||||
local isFuzzy = (winData.bundleID == "com.apple.Terminal" or winData.appName == "Gemini")
|
||||
local shouldMove = (isExactMatch or (not winData.winID and isTitleMatch) or (#app:allWindows() == 1 and (isFuzzy or isStubborn)))
|
||||
|
||||
if shouldMove then
|
||||
local x, y, w, h = math.floor(winData.x), math.floor(winData.y), math.floor(winData.w), math.floor(winData.h)
|
||||
|
||||
if isStubborn then
|
||||
local safeTitle = win:title():gsub('"', '\\"')
|
||||
local winTarget = (app:name() == "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
|
||||
]], pid, winTarget, x, y, w, h)
|
||||
|
||||
hs.applescript.applescript(script)
|
||||
hs.timer.doAfter(0.5, function() hs.applescript.applescript(script) end)
|
||||
else
|
||||
win:setFrame({x=x, y=y, w=w, h=h}, 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
hs.alert.show("Layout Restored", 1.5)
|
||||
end
|
||||
|
||||
-- ==========================================
|
||||
-- RESCUE LOGIC
|
||||
-- ==========================================
|
||||
|
||||
function obj.rescueWindowsToLaptop()
|
||||
local screens = hs.screen.allScreens()
|
||||
if #screens == 1 and not obj.isRescued then
|
||||
local primary = hs.screen.primaryScreen()
|
||||
local primaryFrame = primary:frame()
|
||||
for _, win in ipairs(hs.window.allWindows()) do
|
||||
local app = win:application()
|
||||
if app then
|
||||
local name = app:name() or ""
|
||||
local bid = app:bundleID() or ""
|
||||
if not (ignoreListItems[name] or ignoreListItems[bid] or name:find("TheBoringNotch")) then
|
||||
win:unminimize()
|
||||
local appPath = app:path() or ""
|
||||
local isElectron = appPath:find("Electron") or appPath:find("Frameworks")
|
||||
if stubbornAppsList[name] or isElectron then
|
||||
local pid = app:pid()
|
||||
local safeTitle = win:title():gsub('"', '\\"')
|
||||
local winTarget = (name == "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 position of %s to {%d, %d}
|
||||
end try
|
||||
end tell
|
||||
]], pid, winTarget, math.floor(primaryFrame.x + 20), math.floor(primaryFrame.y + 40))
|
||||
hs.applescript.applescript(script)
|
||||
else
|
||||
win:moveToScreen(primary, false, true)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
obj.isRescued = true
|
||||
hs.alert.show("Forced to Laptop Screen", 1.5)
|
||||
end
|
||||
end
|
||||
|
||||
-- ==========================================
|
||||
-- MENUBAR UI
|
||||
-- ==========================================
|
||||
local saveCountdown = obj.saveInterval
|
||||
local timerMenu = hs.menubar.new()
|
||||
|
||||
function updateMenu()
|
||||
if timerMenu then
|
||||
local minutes = math.floor(saveCountdown / 60)
|
||||
local seconds = saveCountdown % 60
|
||||
timerMenu:setTitle(string.format("💠 %d:%02d", minutes, seconds))
|
||||
|
||||
local screens = hs.screen.allScreens()
|
||||
local currentCount = #screens
|
||||
local modeIcon = (currentCount > 1) and "🖥️" or "💻"
|
||||
local modeName = (currentCount > 1) and "Docked" or "Laptop"
|
||||
|
||||
local data = hs.json.read(obj.layoutFile)
|
||||
local lastTime = data and data.saveTime or "Never"
|
||||
local boldStyle = { font = { name = ".AppleSystemUIFontBold", size = 13 } }
|
||||
|
||||
local menuTable = {
|
||||
{ title = "📅 Last Saved: " .. lastTime, disabled = true },
|
||||
{ title = "-" },
|
||||
{ title = modeIcon .. " Status: " .. modeName .. " Mode", disabled = true },
|
||||
{ title = " ↳ 📺 Screens Detected: " .. currentCount, disabled = true },
|
||||
{ title = "-" },
|
||||
{ title = hs.styledtext.new("📸 Save Layout (⇧⌘W)", boldStyle), fn = function() obj.saveLayout(false); obj.resetCountdown() end },
|
||||
{ title = hs.styledtext.new("🔄 Restore Layout (⇧⌘R)", boldStyle), fn = function() obj.restoreLayout() end },
|
||||
{ title = hs.styledtext.new("🚀 Force Rescue: All Windows to Laptop (⇧⌘⌃L)", boldStyle), fn = function() obj.isRescued = false; obj.rescueWindowsToLaptop() end },
|
||||
{ title = "-" },
|
||||
{ title = "📦 Windows in Saved File:", disabled = true }
|
||||
}
|
||||
|
||||
if data and data.windows then
|
||||
local seen, appNames = {}, {}
|
||||
for _, w in ipairs(data.windows) do
|
||||
if not seen[w.appName] then
|
||||
table.insert(appNames, w.appName .. ", ")
|
||||
seen[w.appName] = true
|
||||
end
|
||||
end
|
||||
local appString = table.concat(appNames):gsub(", $", "")
|
||||
for _, line in ipairs(wrapText(appString, 45)) do
|
||||
table.insert(menuTable, { title = line, disabled = true })
|
||||
end
|
||||
end
|
||||
|
||||
timerMenu:setMenu(menuTable)
|
||||
end
|
||||
end
|
||||
|
||||
function obj.resetCountdown()
|
||||
saveCountdown = obj.saveInterval
|
||||
updateMenu()
|
||||
end
|
||||
|
||||
-- ==========================================
|
||||
-- WATCHERS & HOTKEYS
|
||||
-- ==========================================
|
||||
|
||||
hs.hotkey.bind({"shift", "cmd"}, "W", function() obj.saveLayout(false); obj.resetCountdown() end)
|
||||
hs.hotkey.bind({"shift", "cmd"}, "R", function() obj.restoreLayout() end)
|
||||
hs.hotkey.bind({"shift", "cmd", "ctrl"}, "L", function() obj.isRescued = false; obj.rescueWindowsToLaptop() end)
|
||||
|
||||
obj.screenWatcher = hs.screen.watcher.new(function()
|
||||
local currentCount = #hs.screen.allScreens()
|
||||
if currentCount ~= obj.lastScreenCount then
|
||||
obj.isTransitioning = true
|
||||
if currentCount > 1 then
|
||||
obj.isRescued = false
|
||||
hs.timer.doAfter(4, function() obj.restoreLayout(); obj.isTransitioning = false end)
|
||||
else
|
||||
hs.timer.doAfter(2, function() obj.rescueWindowsToLaptop(); obj.isTransitioning = false end)
|
||||
end
|
||||
obj.lastScreenCount = currentCount
|
||||
updateMenu()
|
||||
end
|
||||
end):start()
|
||||
|
||||
obj.autoSaveTimer = hs.timer.doEvery(obj.saveInterval, function() obj.saveLayout(true); obj.resetCountdown() end)
|
||||
obj.clockTimer = hs.timer.doEvery(1, function()
|
||||
saveCountdown = saveCountdown - 1
|
||||
if saveCountdown < 0 then obj.resetCountdown() end
|
||||
updateMenu()
|
||||
end)
|
||||
|
||||
updateMenu()
|
||||
return obj
|
||||
@@ -0,0 +1,157 @@
|
||||
local obj = {}
|
||||
local json = require("hs.json")
|
||||
|
||||
-- ==========================================
|
||||
-- CONFIGURATION
|
||||
-- ==========================================
|
||||
obj.layoutFile = os.getenv("HOME") .. "/.hammerspoon/saved_layout.json"
|
||||
obj.saveInterval = 300
|
||||
|
||||
-- Apps that require AppleScript authority
|
||||
local stubbornApps = {
|
||||
["Gemini"] = true,
|
||||
["AFFiNE"] = true,
|
||||
["Hammerspoon"] = true,
|
||||
["System Settings"] = true
|
||||
}
|
||||
|
||||
-- Apps or Bundle IDs that the window manager should NEVER touch
|
||||
local ignoreList = {
|
||||
["TheBoringNotch"] = true,
|
||||
["theboringteam.boringnotch"] = true,
|
||||
["boring.notch"] = true,
|
||||
["The Boring Notch"] = true,
|
||||
["Control Center"] = true,
|
||||
["Notification Center"] = true,
|
||||
["Dock"] = true,
|
||||
["com.surteesstudios.Bartender"] = true,
|
||||
["pro.betterdisplay.BetterDisplay"] = true
|
||||
}
|
||||
|
||||
-- ==========================================
|
||||
-- LOGIC
|
||||
-- ==========================================
|
||||
|
||||
-- Check if window actually needs to be moved
|
||||
local function needsMoving(win, savedFrame)
|
||||
local curr = win:frame()
|
||||
local threshold = 10
|
||||
return math.abs(curr.x - savedFrame.x) > threshold or
|
||||
math.abs(curr.y - savedFrame.y) > threshold
|
||||
end
|
||||
|
||||
function obj.saveLayout(silent)
|
||||
-- Safety: Don't save if the system is currently sleeping or screens are locked
|
||||
if hs.caffeinate.get("displayIdle") or hs.caffeinate.get("systemIdle") then return end
|
||||
|
||||
local allWindows = hs.window.allWindows()
|
||||
local consoleWin = hs.console.hswindow()
|
||||
if consoleWin then table.insert(allWindows, consoleWin) end
|
||||
|
||||
local currentLayout = {
|
||||
saveTime = os.date("%Y-%m-%d %H:%M:%S"),
|
||||
windows = {}
|
||||
}
|
||||
|
||||
for _, win in ipairs(allWindows) do
|
||||
local app = win:application()
|
||||
local screen = win:screen()
|
||||
local frame = win:frame()
|
||||
|
||||
-- GHOST WINDOW FIX: Only save windows that have actual size
|
||||
if app and screen and win:isVisible() and frame.w > 0 and frame.h > 0 then
|
||||
local name = app:name() or ""
|
||||
local bid = app:bundleID() or ""
|
||||
|
||||
-- IGNORE LOGIC: Match by name, Bundle ID, or fuzzy notch match
|
||||
local isIgnored = ignoreList[name] or ignoreList[bid] or name:find("TheBoringNotch")
|
||||
|
||||
if not isIgnored then
|
||||
table.insert(currentLayout.windows, {
|
||||
appName = name,
|
||||
bundleID = bid,
|
||||
winTitle = win:title(),
|
||||
screenName = screen:name(),
|
||||
x = frame.x, y = frame.y, w = frame.w, h = frame.h
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
hs.json.write(currentLayout, obj.layoutFile, true, true)
|
||||
if not silent then hs.alert.show("Layout Saved: " .. currentLayout.saveTime, 1.5) end
|
||||
end
|
||||
|
||||
function obj.restoreLayout()
|
||||
local data = hs.json.read(obj.layoutFile)
|
||||
if not data then return end
|
||||
local windowList = data.windows or data
|
||||
|
||||
local appleScriptParts = {"tell application \"System Events\""}
|
||||
local needsAppleScript = false
|
||||
|
||||
for _, winData in ipairs(windowList) do
|
||||
-- Handle Hammerspoon Console specifically
|
||||
if winData.appName == "Hammerspoon" and winData.winTitle == "Hammerspoon Console" then
|
||||
local cWin = hs.console.hswindow()
|
||||
if cWin and needsMoving(cWin, winData) then
|
||||
cWin:setFrame({x=winData.x, y=winData.y, w=winData.w, h=winData.h}, 0)
|
||||
end
|
||||
else
|
||||
local app = hs.application.get(winData.bundleID) or hs.application.get(winData.appName)
|
||||
if app then
|
||||
for _, win in ipairs(app:allWindows()) do
|
||||
local targetScreen = hs.screen.find(winData.screenName) or hs.screen.primaryScreen()
|
||||
|
||||
local isFuzzy = (winData.bundleID == "com.apple.Terminal" or winData.appName == "Gemini")
|
||||
local isStubborn = stubbornApps[winData.appName]
|
||||
local shouldMove = (win:title() == winData.winTitle or #app:allWindows() == 1 or isFuzzy or isStubborn)
|
||||
|
||||
if targetScreen and shouldMove and needsMoving(win, winData) then
|
||||
if isStubborn then
|
||||
needsAppleScript = true
|
||||
table.insert(appleScriptParts, string.format([[
|
||||
try
|
||||
tell process "%s" to set position of window 1 to {%d, %d}
|
||||
tell process "%s" to set size of window 1 to {%d, %d}
|
||||
end try
|
||||
]], winData.appName, winData.x, winData.y, winData.appName, winData.w, winData.h))
|
||||
else
|
||||
win:setFrame({x=winData.x, y=winData.y, w=winData.w, h=winData.h}, 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if needsAppleScript then
|
||||
table.insert(appleScriptParts, "end tell")
|
||||
hs.applescript.applescript(table.concat(appleScriptParts, "\n"))
|
||||
end
|
||||
end
|
||||
|
||||
-- Hotkeys
|
||||
hs.hotkey.bind({"shift", "cmd"}, "W", function() obj.saveLayout(false) end)
|
||||
hs.hotkey.bind({"shift", "cmd"}, "R", function() obj.restoreLayout() end)
|
||||
|
||||
-- Automation: Screen Watcher
|
||||
obj.screenWatcher = hs.screen.watcher.new(function()
|
||||
hs.timer.doAfter(2, function() obj.restoreLayout() end)
|
||||
end):start()
|
||||
|
||||
-- Automation: Sleep Watcher
|
||||
obj.sleepWatcher = hs.caffeinate.watcher.new(function(event)
|
||||
if event == hs.caffeinate.watcher.systemWillSleep or event == hs.caffeinate.watcher.screensDidLock then
|
||||
if obj.autoSaveTimer then obj.autoSaveTimer:stop() end
|
||||
elseif event == hs.caffeinate.watcher.systemDidWake or event == hs.caffeinate.watcher.screensDidUnlock then
|
||||
if obj.autoSaveTimer then obj.autoSaveTimer:start() end
|
||||
hs.timer.doAfter(3, function() obj.restoreLayout() end)
|
||||
end
|
||||
end):start()
|
||||
|
||||
obj.autoSaveTimer = hs.timer.doEvery(obj.saveInterval, function() obj.saveLayout(true) end)
|
||||
|
||||
hs.alert.show("Window Manager Loaded", 1.5)
|
||||
|
||||
return obj
|
||||
@@ -0,0 +1,78 @@
|
||||
-- ~/.hammerspoon/init.lua
|
||||
require('SearchWindows')
|
||||
require('Caffeine')
|
||||
require('AppBorders')
|
||||
-- require('System_Tweaks')
|
||||
|
||||
-- Load the window management module
|
||||
windowMgr = require("WindowManager")
|
||||
|
||||
|
||||
-- Load Spoon Files
|
||||
hs.loadSpoon('SpoonInstall')
|
||||
hs.loadSpoon('SpeedMenu')
|
||||
hs.loadSpoon('BrewInfo')
|
||||
-- hs.loadSpoon('Seal')
|
||||
|
||||
--- 3. Run/Configure Spoons
|
||||
|
||||
---- SpeedMenu Config
|
||||
if spoon.SpeedMenu then
|
||||
-- 1. 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"
|
||||
local details = hs.network.interfaceDetails(interface)
|
||||
|
||||
local ipv4 = (details and details.IPv4) and details.IPv4.Addresses[1] or "N/A"
|
||||
local ipv6 = (details and details.IPv6) and details.IPv6.Addresses[1] or "N/A"
|
||||
|
||||
-- Get MAC Address via shell
|
||||
local macaddr = hs.execute('ifconfig ' .. interface .. ' | grep ether | awk \'{print $2}\''):gsub("%s+", "")
|
||||
|
||||
local menuitems = {
|
||||
{ title = "SSID: " .. ssid, fn = function() hs.pasteboard.setContents(ssid) end },
|
||||
{ title = "IPv4: " .. ipv4, fn = function() hs.pasteboard.setContents(ipv4) end },
|
||||
{ title = "IPv6: " .. ipv6, fn = function() hs.pasteboard.setContents(ipv6) end },
|
||||
{ title = "MAC: " .. macaddr, fn = function() hs.pasteboard.setContents(macaddr) end },
|
||||
{ title = "-" },
|
||||
{ title = "Rescan Network Interfaces", fn = function() spoon.SpeedMenu:rescan() end }
|
||||
}
|
||||
spoon.SpeedMenu.menubar:setMenu(menuitems)
|
||||
end
|
||||
|
||||
-- 2. Hook the rescan method
|
||||
local oldRescan = spoon.SpeedMenu.rescan
|
||||
spoon.SpeedMenu.rescan = function(self)
|
||||
oldRescan(self)
|
||||
applyFullMenuFix()
|
||||
end
|
||||
|
||||
-- 3. Toggle Logic (Starts as OFF)
|
||||
local speedMenuRunning = false
|
||||
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "W", function()
|
||||
if speedMenuRunning then
|
||||
spoon.SpeedMenu:stop()
|
||||
hs.alert.show("SpeedMenu Stopped")
|
||||
else
|
||||
spoon.SpeedMenu:start() -- This puts it in the menu bar
|
||||
applyFullMenuFix() -- This populates the data
|
||||
hs.alert.show("SpeedMenu Started")
|
||||
end
|
||||
speedMenuRunning = not speedMenuRunning
|
||||
end)
|
||||
end
|
||||
---- SpeedMenu Config END
|
||||
|
||||
---- BrewInfo
|
||||
if spoon.BrewInfo then
|
||||
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "I", function()
|
||||
spoon.BrewInfo:showBrewInfo()
|
||||
end)
|
||||
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "J", function()
|
||||
spoon.BrewInfo:showBrewInfoCurSel()
|
||||
end)
|
||||
end
|
||||
---- BrewInfo END
|
||||
|
||||
hs.alert.show("Hammerspoon Config Reloaded")
|
||||
Reference in New Issue
Block a user