From c88edc5b8eaed938732a9719f6e674f767c451f2 Mon Sep 17 00:00:00 2001 From: Christian Colglazier Date: Fri, 30 Oct 2026 06:44:26 -0400 Subject: [PATCH] feat(macos): configure hammerspoon window placement --- home/.chezmoiignore | 1 + home/dot_Brewfile | 3 + home/dot_hammerspoon/init.lua | 130 ++++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 home/dot_hammerspoon/init.lua diff --git a/home/.chezmoiignore b/home/.chezmoiignore index bf61035..d2e3447 100644 --- a/home/.chezmoiignore +++ b/home/.chezmoiignore @@ -58,5 +58,6 @@ Library/Application Support/OrcaSlicer .config/aerospace .config/karabiner .config/AutoRaise +.hammerspoon bin/setup-macos.sh {{ end -}} diff --git a/home/dot_Brewfile b/home/dot_Brewfile index 43e2872..10c1f37 100644 --- a/home/dot_Brewfile +++ b/home/dot_Brewfile @@ -44,6 +44,9 @@ cask "font-inconsolata" tap "dimentium/autoraise" brew "dimentium/autoraise/autoraise" +# Save and automatically restore window layouts +cask "hammerspoon" + # Optional tools for audio conversion and local AI # brew "ffmpeg" # brew "ollama" diff --git a/home/dot_hammerspoon/init.lua b/home/dot_hammerspoon/init.lua new file mode 100644 index 0000000..074d33c --- /dev/null +++ b/home/dot_hammerspoon/init.lua @@ -0,0 +1,130 @@ +-- Placement rules inferred from the running windows on this Mac. +require("hs.ipc") + +local spaces = require("hs.spaces") +local log = hs.logger.new("placement", "info") +local monitors = { + main = "CBBC3C70-2EE1-4D28-8B7D-DC03A186F6EB", -- DELL P2715Q (2) + portrait = "22CE41A6-C177-4B4D-85D3-43337F9566E8", -- DELL P2715Q (1) + right = "3C0512D4-DAFF-4EE2-8CC8-8EA148E1ECB0", -- T24i-2L +} + +-- Desktop numbers are per monitor, excluding native fullscreen Spaces. +-- Frames are fractions of the entire display, including the menu bar area. +local rules = { + {app = "com.mitchellh.ghostty", monitor = "main", desktop = 1, + frame = {0, 30 / 1890, 1, 1860 / 1890}}, + {app = "io.robbie.HomeAssistant", monitor = "portrait", desktop = 1, + frame = {0, 30 / 3360, 1, 1665 / 3360}}, + {app = "org.whispersystems.signal-desktop", monitor = "right", desktop = 2, + frame = {0, 30 / 1080, 1, 1050 / 1080}}, + -- Match specialized browser windows; general browsing uses the fallback below. + {app = "com.brave.Browser", title = "YouTube", monitor = "portrait", desktop = 1, + frame = {0, 1695 / 3360, 1, 1665 / 3360}}, + {app = "com.brave.Browser", title = "Proxmox Console", monitor = "right", desktop = 1, + frame = {0, 30 / 1080, 800 / 1920, 484 / 1080}}, + -- Other browser windows start on the right; a later YouTube title moves them. + {app = "com.brave.Browser", monitor = "right", desktop = 1, + frame = {0, 30 / 1080, 1, 1050 / 1080}}, + -- Steam's library is owned by Steam Helper, not the launcher process. + {app = "com.valvesoftware.steam", monitor = "right", desktop = 1, + frame = {267 / 1920, 163 / 1080, 1010 / 1920, 1050 / 1080}}, + {name = "Steam Helper", monitor = "right", desktop = 1, + frame = {267 / 1920, 163 / 1080, 1010 / 1920, 1050 / 1080}}, +} + +local applied, pending = {}, {} + +local function ruleFor(window) + local app = window:application() + if not app or not window:isStandard() or window:isFullScreen() + or window:isMinimized() then return end + -- Ignore Steam's small helper/tooltip windows even if exposed as standard. + if app:name() == "Steam Helper" and window:frame().w < 600 then return end + for index, rule in ipairs(rules) do + if ((rule.app and app:bundleID() == rule.app) + or (rule.name and app:name() == rule.name)) + and (not rule.title or window:title():find(rule.title, 1, true)) then + return rule, index + end + end +end + +local function place(window, force) + local rule, index = ruleFor(window) + local id = window:id() + if not rule or not id or (not force and applied[id] == index) then return end + local screen + for _, candidate in ipairs(hs.screen.allScreens()) do + if candidate:getUUID() == monitors[rule.monitor] then screen = candidate; break end + end + if not screen then return end -- Leave windows alone when a monitor is unplugged. + local desktops = {} + for _, space in ipairs(spaces.spacesForScreen(screen) or {}) do + if spaces.spaceType(space) == "user" then table.insert(desktops, space) end + end + local target = desktops[rule.desktop] + local current = spaces.windowSpaces(window) or {} + if not target or #current ~= 1 or spaces.spaceType(current[1]) ~= "user" then return end + + window:moveToScreen(screen, false, true, 0) + if current[1] ~= target then + local ok, err = spaces.moveWindowToSpace(window, target) + if not ok then + log.w("Desktop placement failed: " .. tostring(err)) + return + end + end + local bounds, frame = screen:fullFrame(), rule.frame + window:setFrame(hs.geometry.rect( + bounds.x + frame[1] * bounds.w, bounds.y + frame[2] * bounds.h, + frame[3] * bounds.w, frame[4] * bounds.h + ), 0) + applied[id] = index +end + +local filter = hs.window.filter.new(true):setOverrideFilter({ + allowRoles = "AXStandardWindow", fullscreen = false, +}) + +local function restore() + for _, window in ipairs(filter:getWindows()) do place(window, true) end +end + +filter:subscribe({hs.window.filter.windowCreated, hs.window.filter.windowTitleChanged}, function(window) + local id = window:id() + if not id then return end + if pending[id] then pending[id]:stop() end + -- Wait for apps to finish their own initial resize and browser page loading. + pending[id] = hs.timer.doAfter(1, function() + pending[id] = nil + if window:id() then place(window, false) end + end) +end) +filter:subscribe(hs.window.filter.windowDestroyed, function(window) + local id = window:id() + if id then + applied[id] = nil + if pending[id] then pending[id]:stop(); pending[id] = nil end + end +end) + +-- Retain the filter, watchers and menu for the lifetime of the configuration. +windowPlacement = { + rules = rules, + restore = restore, + filter = filter, + menu = hs.menubar.new(), + screenWatcher = hs.screen.watcher.new(function() + hs.timer.doAfter(3, restore) + end):start(), +} +windowPlacement.menu:setTitle("Windows") +windowPlacement.menu:setMenu({ + {title = "Reapply placement rules", fn = restore}, + {title = "Reload configuration", fn = hs.reload}, + {title = "Open Console", fn = hs.openConsole}, +}) + +hs.autoLaunch(true) +if hs.accessibilityState(true) then hs.timer.doAfter(3, restore) end