diff --git a/hammerspoon/.hammerspoon/config.lua b/hammerspoon/.hammerspoon/config.lua new file mode 100644 index 0000000..350b54a --- /dev/null +++ b/hammerspoon/.hammerspoon/config.lua @@ -0,0 +1,41 @@ +local accentColor = { + red = 0.25, + green = 0.65, + blue = 1.0, + alpha = 0.95, +} + +return { + windowHighlight = { + borderWidth = 5, + padding = 4, + radius = 10, + color = accentColor, + + animation = true, + animationDuration = 0.10, + animationSteps = 8, + + hideOnFullscreen = false, + framePollingFps = 30, + }, + + appNameToast = { + enabled = true, + duration = 0.7, + showOnFocus = true, + showOnCommand = true, + + style = { + fillColor = { white = 0.08, alpha = 0.88 }, + strokeColor = accentColor, + strokeWidth = 2, + textColor = { white = 1, alpha = 1 }, + textSize = 20, + padding = 10, + radius = 10, + fadeInDuration = 0.08, + fadeOutDuration = 0.12, + }, + }, +} diff --git a/hammerspoon/.hammerspoon/init.lua b/hammerspoon/.hammerspoon/init.lua index ea0715f..0396923 100644 --- a/hammerspoon/.hammerspoon/init.lua +++ b/hammerspoon/.hammerspoon/init.lua @@ -1,311 +1,29 @@ --- Active Window Highlight for Hammerspoon -- Reload: Hammerspoon menu -> Reload Config -local config = { - borderWidth = 5, - padding = 4, - radius = 10, +local config = require("config") +local AppNameToast = require("modules.app_name_toast") +local WindowHighlight = require("modules.window_highlight") - -- blue border - color = { red = 0.25, green = 0.65, blue = 1.0, alpha = 0.95 }, - - -- animation - animation = true, - animationDuration = 0.10, - animationSteps = 8, - - hideOnFullscreen = false, -} - -local border = hs.drawing.rectangle(hs.geometry.rect(0, 0, 1, 1)) -border:setFill(false) -border:setStroke(true) -border:setStrokeWidth(config.borderWidth) -border:setStrokeColor(config.color) -border:setRoundedRectRadii(config.radius, config.radius) -border:setLevel(hs.drawing.windowLevels.screenSaver) -border:setBehaviorByLabels({ - "canJoinAllSpaces", - "stationary", - "ignoresCycle", - "fullScreenAuxiliary", +local appNameToast = AppNameToast.new(config.appNameToast) +local windowHighlight = WindowHighlight.new(config.windowHighlight, { + onWindowFocused = function() + if config.appNameToast.showOnFocus then + appNameToast:show() + end + end, }) -local currentFrame = nil -local animationTimer = nil -local missionControlActive = false -local dockElement = nil -local liveWindowWatcher = nil -local watchedWindowId = nil -local lastPolledWindowId = nil -local lastPolledFrame = nil - -local function rectForWindow(win) - local f = win:frame() - local screen = win:screen() - local screenFrame = screen:fullFrame() - local visibleFrame = screen:frame() - local pad = config.padding - - -- Padding normally expands the highlight outside the window. When a window - -- touches a screen edge, align left/right/bottom with the physical display. - -- Align the center of the top stroke with the bottom edge of the menu bar so - -- there is no visible gap between them. - local left = math.max(f.x - pad, screenFrame.x) - local top = math.max(f.y - pad, visibleFrame.y) - local right = math.min( - f.x + f.w + pad, - screenFrame.x + screenFrame.w - ) - local bottom = math.min( - f.y + f.h + pad, - screenFrame.y + screenFrame.h - ) - - return { - x = left, - y = top, - w = right - left, - h = bottom - top, - } -end - -local function isValidWindow(win) - if not win then return false end - if not win:isStandard() then return false end - if config.hideOnFullscreen and win:isFullScreen() then return false end - - local app = win:application() - if not app then return false end - - return true -end - -local function hideHighlight() - if animationTimer then - animationTimer:stop() - animationTimer = nil - end - - border:hide() -end - -local function isMissionControlOpen() - if not (dockElement and dockElement:isValid()) then - local dockApps = hs.application.applicationsForBundleID("com.apple.dock") - local dockApp = dockApps and dockApps[1] - - if not dockApp then return false end - dockElement = hs.axuielement.applicationElement(dockApp) - end - - for _, element in ipairs(dockElement) do - if element.AXIdentifier == "mc" then return true end - end - - return false -end - -local function setFrame(frame) - border:setFrame(frame) - border:show() -end - -local function lerp(a, b, t) - return a + (b - a) * t -end - -local function easeOutCubic(t) - return 1 - math.pow(1 - t, 3) -end - -local function animateTo(targetFrame) - if animationTimer then - animationTimer:stop() - animationTimer = nil - end - - if not config.animation or not currentFrame then - currentFrame = targetFrame - setFrame(targetFrame) - return - end - - local startFrame = currentFrame - local step = 0 - local steps = config.animationSteps - local interval = config.animationDuration / steps - - animationTimer = hs.timer.doEvery(interval, function() - step = step + 1 - local t = easeOutCubic(step / steps) - - local frame = { - x = lerp(startFrame.x, targetFrame.x, t), - y = lerp(startFrame.y, targetFrame.y, t), - w = lerp(startFrame.w, targetFrame.w, t), - h = lerp(startFrame.h, targetFrame.h, t), - } - - currentFrame = frame - setFrame(frame) - - if step >= steps then - animationTimer:stop() - animationTimer = nil - currentFrame = targetFrame - setFrame(targetFrame) - end - end) -end - -local function updateHighlight(immediate) - if missionControlActive then - hideHighlight() - currentFrame = nil - return - end - - local win = hs.window.focusedWindow() - - if not isValidWindow(win) then - hideHighlight() - currentFrame = nil - return - end - - local targetFrame = rectForWindow(win) - - if immediate then - if animationTimer then - animationTimer:stop() - animationTimer = nil - end - - currentFrame = targetFrame - setFrame(targetFrame) - else - animateTo(targetFrame) - end -end - -local function watchFocusedWindowLive() - local win = hs.window.focusedWindow() - local windowId = win and win:id() or nil - - if liveWindowWatcher and watchedWindowId == windowId then return end - - if liveWindowWatcher then - liveWindowWatcher:stop() - liveWindowWatcher = nil - end - - watchedWindowId = windowId - if not win then return end - - liveWindowWatcher = win:newWatcher(function(_, event) - if event == hs.uielement.watcher.windowMoved - or event == hs.uielement.watcher.windowResized then - updateHighlight(true) - end - end) - - liveWindowWatcher:start({ - hs.uielement.watcher.windowMoved, - hs.uielement.watcher.windowResized, - hs.uielement.watcher.elementDestroyed, - }) -end - -local wf = hs.window.filter.new(nil) - --- window.filter reports both moves and resizes as windowMoved. The separate --- windowResized event only exists on hs.uielement.watcher, so subscribing to --- hs.window.filter.windowResized would pass nil and make subscribe() fail. -wf:subscribe({ - hs.window.filter.windowFocused, - hs.window.filter.windowMoved, - hs.window.filter.windowTitleChanged, - hs.window.filter.windowUnfocused, -}, updateHighlight) - -wf:subscribe(hs.window.filter.windowFocused, function() - watchFocusedWindowLive() +appNameToast:setShouldShow(function() + return not windowHighlight:isMissionControlActive() end) --- Keep watcher references alive for as long as the config is loaded. -local applicationWatcher = hs.application.watcher.new(function() - hs.timer.doAfter(0.05, function() - watchFocusedWindowLive() - updateHighlight() - end) -end) -applicationWatcher:start() +appNameToast:start() +windowHighlight:start() -local spacesWatcher = hs.spaces.watcher.new(function() - hs.timer.doAfter(0.15, updateHighlight) -end) -spacesWatcher:start() - --- macOS exposes Mission Control as the "mc" element in Dock's accessibility --- tree. Polling this state also catches trackpad gestures and hot corners. -local missionControlWatcher = hs.timer.doEvery(0.1, function() - local ok, isOpen = pcall(isMissionControlOpen) - if not ok or isOpen == missionControlActive then return end - - missionControlActive = isOpen - currentFrame = nil - - if missionControlActive then - hideHighlight() - else - hs.timer.doAfter(0.15, updateHighlight) - end -end) - -local function sameFrame(a, b) - if not a or not b then return false end - - return math.abs(a.x - b.x) < 0.1 - and math.abs(a.y - b.y) < 0.1 - and math.abs(a.w - b.w) < 0.1 - and math.abs(a.h - b.h) < 0.1 -end - --- Some apps and window managers (including Rectangle on some macOS versions) --- coalesce accessibility resize events. Polling the focused frame provides a --- reliable realtime fallback with at most ~33 ms latency. -local framePollingWatcher = hs.timer.doEvery(1 / 30, function() - if missionControlActive then return end - - local win = hs.window.focusedWindow() - local windowId = win and win:id() or nil - - if windowId ~= lastPolledWindowId then - lastPolledWindowId = windowId - lastPolledFrame = win and win:frame() or nil - return - end - - if not win then return end - - local frame = win:frame() - if sameFrame(frame, lastPolledFrame) then return end - - lastPolledFrame = frame - updateHighlight(true) -end) - --- Running Hammerspoon objects must remain reachable after this file finishes. -_G.activeWindowHighlightWatchers = { - windowFilter = wf, - application = applicationWatcher, - spaces = spacesWatcher, - missionControl = missionControlWatcher, - framePolling = framePollingWatcher, +-- Keep module state and watchers alive until the next reload. +_G.hammerspoonModules = { + appNameToast = appNameToast, + windowHighlight = windowHighlight, } -hs.timer.doAfter(0.5, updateHighlight) -hs.timer.doAfter(0.5, watchFocusedWindowLive) - -hs.alert.show("Active window highlight loaded") +hs.alert.show("Hammerspoon config loaded") diff --git a/hammerspoon/.hammerspoon/modules/app_name_toast.lua b/hammerspoon/.hammerspoon/modules/app_name_toast.lua new file mode 100644 index 0000000..23bd033 --- /dev/null +++ b/hammerspoon/.hammerspoon/modules/app_name_toast.lua @@ -0,0 +1,58 @@ +local AppNameToast = {} +AppNameToast.__index = AppNameToast + +function AppNameToast.new(config) + return setmetatable({ + config = config, + alertId = nil, + commandKeyDown = false, + commandKeyWatcher = nil, + shouldShow = nil, + }, AppNameToast) +end + +function AppNameToast:setShouldShow(predicate) + self.shouldShow = predicate +end + +function AppNameToast:show() + if not self.config.enabled then return end + if self.shouldShow and not self.shouldShow() then return end + + local win = hs.window.focusedWindow() + local app = win and win:application() or nil + if not app then return end + + if self.alertId then + hs.alert.closeSpecific(self.alertId, 0) + end + + self.alertId = hs.alert.show( + app:name(), + self.config.style, + win:screen(), + self.config.duration + ) +end + +function AppNameToast:start() + if not self.config.showOnCommand then return self end + + self.commandKeyWatcher = hs.eventtap.new({ + hs.eventtap.event.types.flagsChanged, + }, function(event) + local isCommandDown = event:getFlags().cmd == true + + if isCommandDown and not self.commandKeyDown then + self:show() + end + + self.commandKeyDown = isCommandDown + return false + end) + self.commandKeyWatcher:start() + + return self +end + +return AppNameToast diff --git a/hammerspoon/.hammerspoon/modules/window_highlight.lua b/hammerspoon/.hammerspoon/modules/window_highlight.lua new file mode 100644 index 0000000..f8762f8 --- /dev/null +++ b/hammerspoon/.hammerspoon/modules/window_highlight.lua @@ -0,0 +1,307 @@ +local WindowHighlight = {} +WindowHighlight.__index = WindowHighlight + +local function sameFrame(a, b) + if not a or not b then return false end + + return math.abs(a.x - b.x) < 0.1 + and math.abs(a.y - b.y) < 0.1 + and math.abs(a.w - b.w) < 0.1 + and math.abs(a.h - b.h) < 0.1 +end + +local function lerp(a, b, t) + return a + (b - a) * t +end + +local function easeOutCubic(t) + return 1 - math.pow(1 - t, 3) +end + +function WindowHighlight.new(config, callbacks) + local border = hs.drawing.rectangle(hs.geometry.rect(0, 0, 1, 1)) + border:setFill(false) + border:setStroke(true) + border:setStrokeWidth(config.borderWidth) + border:setStrokeColor(config.color) + border:setRoundedRectRadii(config.radius, config.radius) + border:setLevel(hs.drawing.windowLevels.screenSaver) + border:setBehaviorByLabels({ + "canJoinAllSpaces", + "stationary", + "ignoresCycle", + "fullScreenAuxiliary", + }) + + return setmetatable({ + config = config, + callbacks = callbacks or {}, + border = border, + currentFrame = nil, + animationTimer = nil, + missionControlActive = false, + dockElement = nil, + liveWindowWatcher = nil, + watchedWindowId = nil, + lastPolledWindowId = nil, + lastPolledFrame = nil, + watchers = {}, + }, WindowHighlight) +end + +function WindowHighlight:isMissionControlActive() + return self.missionControlActive +end + +function WindowHighlight:rectForWindow(win) + local frame = win:frame() + local screen = win:screen() + local screenFrame = screen:fullFrame() + local visibleFrame = screen:frame() + local pad = self.config.padding + + local left = math.max(frame.x - pad, screenFrame.x) + local top = math.max(frame.y - pad, visibleFrame.y) + local right = math.min( + frame.x + frame.w + pad, + screenFrame.x + screenFrame.w + ) + local bottom = math.min( + frame.y + frame.h + pad, + screenFrame.y + screenFrame.h + ) + + return { + x = left, + y = top, + w = right - left, + h = bottom - top, + } +end + +function WindowHighlight:isValidWindow(win) + if not win then return false end + if not win:isStandard() then return false end + if self.config.hideOnFullscreen and win:isFullScreen() then return false end + + return win:application() ~= nil +end + +function WindowHighlight:hide() + if self.animationTimer then + self.animationTimer:stop() + self.animationTimer = nil + end + + self.border:hide() +end + +function WindowHighlight:isMissionControlOpen() + if not (self.dockElement and self.dockElement:isValid()) then + local dockApps = hs.application.applicationsForBundleID("com.apple.dock") + local dockApp = dockApps and dockApps[1] + + if not dockApp then return false end + self.dockElement = hs.axuielement.applicationElement(dockApp) + end + + for _, element in ipairs(self.dockElement) do + if element.AXIdentifier == "mc" then return true end + end + + return false +end + +function WindowHighlight:setFrame(frame) + self.border:setFrame(frame) + self.border:show() +end + +function WindowHighlight:animateTo(targetFrame) + if self.animationTimer then + self.animationTimer:stop() + self.animationTimer = nil + end + + if not self.config.animation or not self.currentFrame then + self.currentFrame = targetFrame + self:setFrame(targetFrame) + return + end + + local startFrame = self.currentFrame + local step = 0 + local steps = self.config.animationSteps + local interval = self.config.animationDuration / steps + + self.animationTimer = hs.timer.doEvery(interval, function() + step = step + 1 + local t = easeOutCubic(step / steps) + local frame = { + x = lerp(startFrame.x, targetFrame.x, t), + y = lerp(startFrame.y, targetFrame.y, t), + w = lerp(startFrame.w, targetFrame.w, t), + h = lerp(startFrame.h, targetFrame.h, t), + } + + self.currentFrame = frame + self:setFrame(frame) + + if step >= steps then + self.animationTimer:stop() + self.animationTimer = nil + self.currentFrame = targetFrame + self:setFrame(targetFrame) + end + end) +end + +function WindowHighlight:update(immediate) + if self.missionControlActive then + self:hide() + self.currentFrame = nil + return + end + + local win = hs.window.focusedWindow() + if not self:isValidWindow(win) then + self:hide() + self.currentFrame = nil + return + end + + local targetFrame = self:rectForWindow(win) + if not immediate then + self:animateTo(targetFrame) + return + end + + if self.animationTimer then + self.animationTimer:stop() + self.animationTimer = nil + end + + self.currentFrame = targetFrame + self:setFrame(targetFrame) +end + +function WindowHighlight:watchFocusedWindowLive() + local win = hs.window.focusedWindow() + local windowId = win and win:id() or nil + + if self.liveWindowWatcher and self.watchedWindowId == windowId then return end + + if self.liveWindowWatcher then + self.liveWindowWatcher:stop() + self.liveWindowWatcher = nil + end + + self.watchedWindowId = windowId + if not win then return end + + self.liveWindowWatcher = win:newWatcher(function(_, event) + if event == hs.uielement.watcher.windowMoved + or event == hs.uielement.watcher.windowResized then + self:update(true) + end + end) + + self.liveWindowWatcher:start({ + hs.uielement.watcher.windowMoved, + hs.uielement.watcher.windowResized, + hs.uielement.watcher.elementDestroyed, + }) +end + +function WindowHighlight:start() + local windowFilter = hs.window.filter.new(nil) + windowFilter:subscribe({ + hs.window.filter.windowFocused, + hs.window.filter.windowMoved, + hs.window.filter.windowTitleChanged, + hs.window.filter.windowUnfocused, + }, function() + self:update(false) + end) + + windowFilter:subscribe(hs.window.filter.windowFocused, function() + self:watchFocusedWindowLive() + if self.callbacks.onWindowFocused then + self.callbacks.onWindowFocused() + end + end) + + local applicationWatcher = hs.application.watcher.new(function() + hs.timer.doAfter(0.05, function() + self:watchFocusedWindowLive() + self:update(false) + end) + end) + applicationWatcher:start() + + local spacesWatcher = hs.spaces.watcher.new(function() + hs.timer.doAfter(0.15, function() + self:update(false) + end) + end) + spacesWatcher:start() + + local missionControlWatcher = hs.timer.doEvery(0.1, function() + local ok, isOpen = pcall(function() + return self:isMissionControlOpen() + end) + if not ok or isOpen == self.missionControlActive then return end + + self.missionControlActive = isOpen + self.currentFrame = nil + + if self.missionControlActive then + self:hide() + else + hs.timer.doAfter(0.15, function() + self:update(false) + end) + end + end) + + local framePollingWatcher = hs.timer.doEvery( + 1 / self.config.framePollingFps, + function() + if self.missionControlActive then return end + + local win = hs.window.focusedWindow() + local windowId = win and win:id() or nil + + if windowId ~= self.lastPolledWindowId then + self.lastPolledWindowId = windowId + self.lastPolledFrame = win and win:frame() or nil + return + end + + if not win then return end + + local frame = win:frame() + if sameFrame(frame, self.lastPolledFrame) then return end + + self.lastPolledFrame = frame + self:update(true) + end + ) + + self.watchers = { + windowFilter = windowFilter, + application = applicationWatcher, + spaces = spacesWatcher, + missionControl = missionControlWatcher, + framePolling = framePollingWatcher, + } + + hs.timer.doAfter(0.5, function() + self:update(false) + self:watchFocusedWindowLive() + end) + + return self +end + +return WindowHighlight