From 31fbcf73acc04539352fd258f4f77ceab2bb5890 Mon Sep 17 00:00:00 2001 From: Kat Huang Date: Sun, 26 Apr 2026 14:24:40 -0700 Subject: [PATCH] Configure macOS window tooling --- dotfiles/hammerspoon/init.lua | 438 ++++++++++++++++++++++++++ nix-darwin/flake.nix | 121 ++++--- nix-darwin/home/common.nix | 66 +++- nix-darwin/home/{kat.nix => ivan.nix} | 26 +- nix-shared/overlays/default.nix | 2 +- 5 files changed, 581 insertions(+), 72 deletions(-) create mode 100644 dotfiles/hammerspoon/init.lua rename nix-darwin/home/{kat.nix => ivan.nix} (56%) diff --git a/dotfiles/hammerspoon/init.lua b/dotfiles/hammerspoon/init.lua new file mode 100644 index 00000000..b1e91e63 --- /dev/null +++ b/dotfiles/hammerspoon/init.lua @@ -0,0 +1,438 @@ +local hyper = { "cmd", "ctrl", "alt" } +local hyperShift = { "cmd", "ctrl", "alt", "shift" } +local wf = hs.window.filter.defaultCurrentSpace + +hs.window.animationDuration = 0 +pcall(function() + hs.ipc.cliInstall() +end) + +local config = { + gap = 8, + autoColumns = false, +} + +local retileTimer = nil +local arranging = false +local rightCommandDown = false +local rightCommandUsed = false +local rightCommandKeyCode = 54 + +local function notify(message) + hs.alert.show(message, 0.7) +end + +local function screenFrame(screen) + return screen:frame() +end + +local function sameScreen(a, b) + return a and b and a:id() == b:id() +end + +local function centerX(win) + local f = win:frame() + return f.x + (f.w / 2) +end + +local function centerY(win) + local f = win:frame() + return f.y + (f.h / 2) +end + +local function distance(a, b) + local dx = a.x - b.x + local dy = a.y - b.y + return math.sqrt((dx * dx) + (dy * dy)) +end + +local function directionScore(focused, candidate, direction) + local focusedCenter = { + x = centerX(focused), + y = centerY(focused), + } + local candidateCenter = { + x = centerX(candidate), + y = centerY(candidate), + } + + if direction == "left" and candidateCenter.x >= focusedCenter.x then + return nil + elseif direction == "right" and candidateCenter.x <= focusedCenter.x then + return nil + elseif direction == "up" and candidateCenter.y >= focusedCenter.y then + return nil + elseif direction == "down" and candidateCenter.y <= focusedCenter.y then + return nil + end + + return distance(focusedCenter, candidateCenter) +end + +local function columnWindows(screen) + local windows = {} + + for _, win in ipairs(wf:getWindows()) do + if win:isStandard() + and not win:isMinimized() + and sameScreen(win:screen(), screen) + then + table.insert(windows, win) + end + end + + table.sort(windows, function(a, b) + local af = a:frame() + local bf = b:frame() + + if math.abs(centerX(a) - centerX(b)) > 24 then + return centerX(a) < centerX(b) + end + + return af.y < bf.y + end) + + return windows +end + +local function neighborWindow(direction) + local focused = hs.window.focusedWindow() + if not focused then + return nil + end + + local focusedId = focused:id() + local best = nil + local bestScore = nil + + for _, win in ipairs(wf:getWindows()) do + if win:isStandard() + and not win:isMinimized() + and sameScreen(win:screen(), focused:screen()) + and win:id() ~= focusedId + then + local score = directionScore(focused, win, direction) + if score and (not bestScore or score < bestScore) then + best = win + bestScore = score + end + end + end + + return best +end + +local function setFrame(win, frame) + win:setFrame(frame, 0) +end + +local function tileWindows(windows) + if #windows == 0 then + return + end + + arranging = true + + local screen = windows[1]:screen() + local frame = screenFrame(screen) + local gap = config.gap + local count = #windows + local width = (frame.w - (gap * (count - 1))) / count + + for index, win in ipairs(windows) do + setFrame(win, { + x = frame.x + ((index - 1) * (width + gap)), + y = frame.y, + w = width, + h = frame.h, + }) + end + + arranging = false +end + +local function tileFocusedScreen() + local focused = hs.window.focusedWindow() + if not focused then + notify("No focused window") + return + end + + local windows = columnWindows(focused:screen()) + tileWindows(windows) +end + +local function focusWindow(direction) + local target = neighborWindow(direction) + if target then + target:focus() + end +end + +local function swapWindow(direction) + local focused = hs.window.focusedWindow() + local target = neighborWindow(direction) + if not focused or not target then + return + end + + arranging = true + + local focusedFrame = focused:frame() + local targetFrame = target:frame() + + setFrame(focused, targetFrame) + setFrame(target, focusedFrame) + focused:focus() + + arranging = false +end + +local function placeFocused(startColumn, spanColumns, totalColumns) + local focused = hs.window.focusedWindow() + if not focused then + return + end + + local frame = screenFrame(focused:screen()) + local gap = config.gap + local unit = (frame.w - (gap * (totalColumns - 1))) / totalColumns + local x = frame.x + ((startColumn - 1) * (unit + gap)) + local width = (unit * spanColumns) + (gap * (spanColumns - 1)) + + setFrame(focused, { + x = x, + y = frame.y, + w = width, + h = frame.h, + }) +end + +local function moveFocusedToScreen(direction) + local focused = hs.window.focusedWindow() + if not focused then + return + end + + local target = direction < 0 and focused:screen():previous() or focused:screen():next() + focused:moveToScreen(target, false, true) + tileWindows(columnWindows(target)) +end + +local function scheduleRetile() + if arranging or not config.autoColumns then + return + end + + if retileTimer then + retileTimer:stop() + end + + retileTimer = hs.timer.doAfter(0.25, tileFocusedScreen) +end + +local function toggleAutoColumns() + config.autoColumns = not config.autoColumns + notify(config.autoColumns and "Auto columns on" or "Auto columns off") + + if config.autoColumns then + tileFocusedScreen() + end +end + +wf:subscribe({ + hs.window.filter.windowCreated, + hs.window.filter.windowDestroyed, + hs.window.filter.windowMoved, + hs.window.filter.windowInCurrentSpace, + hs.window.filter.windowNotInCurrentSpace, + hs.window.filter.windowUnminimized, + hs.window.filter.windowNotVisible, +}, scheduleRetile) + +hs.hotkey.bind(hyper, "c", tileFocusedScreen) +hs.hotkey.bind(hyper, "v", toggleAutoColumns) + +hs.hotkey.bind(hyper, "a", function() + focusWindow("left") +end) + +hs.hotkey.bind(hyper, "d", function() + focusWindow("right") +end) + +hs.hotkey.bind(hyper, "w", function() + focusWindow("up") +end) + +hs.hotkey.bind(hyper, "s", function() + focusWindow("down") +end) + +hs.hotkey.bind(hyperShift, "a", function() + swapWindow("left") +end) + +hs.hotkey.bind(hyperShift, "d", function() + swapWindow("right") +end) + +hs.hotkey.bind(hyperShift, "w", function() + swapWindow("up") +end) + +hs.hotkey.bind(hyperShift, "s", function() + swapWindow("down") +end) + +hs.hotkey.bind(hyper, "m", function() + placeFocused(1, 1, 1) +end) + +hs.hotkey.bind(hyper, "f", function() + placeFocused(1, 1, 1) +end) + +hs.hotkey.bind(hyper, "1", function() + placeFocused(1, 1, 3) +end) + +hs.hotkey.bind(hyper, "2", function() + placeFocused(2, 1, 3) +end) + +hs.hotkey.bind(hyper, "3", function() + placeFocused(3, 1, 3) +end) + +hs.hotkey.bind(hyper, "4", function() + placeFocused(1, 2, 3) +end) + +hs.hotkey.bind(hyper, "5", function() + placeFocused(2, 2, 3) +end) + +hs.hotkey.bind(hyper, "q", function() + moveFocusedToScreen(-1) +end) + +hs.hotkey.bind(hyper, "e", function() + moveFocusedToScreen(1) +end) + +hs.hotkey.bind(hyper, "r", hs.reload) + +local rguiBindings = {} + +local function bindRgui(key, handler, shiftedHandler) + rguiBindings[hs.keycodes.map[key]] = { + normal = handler, + shifted = shiftedHandler, + } +end + +bindRgui("a", function() + focusWindow("left") +end, function() + swapWindow("left") +end) + +bindRgui("d", function() + focusWindow("right") +end, function() + swapWindow("right") +end) + +bindRgui("w", function() + focusWindow("up") +end, function() + swapWindow("up") +end) + +bindRgui("s", function() + focusWindow("down") +end, function() + swapWindow("down") +end) + +bindRgui("c", tileFocusedScreen) +bindRgui("v", toggleAutoColumns) + +bindRgui("m", function() + placeFocused(1, 1, 1) +end) + +bindRgui("f", function() + placeFocused(1, 1, 1) +end) + +bindRgui("1", function() + placeFocused(1, 1, 3) +end) + +bindRgui("2", function() + placeFocused(2, 1, 3) +end) + +bindRgui("3", function() + placeFocused(3, 1, 3) +end) + +bindRgui("4", function() + placeFocused(1, 2, 3) +end) + +bindRgui("5", function() + placeFocused(2, 2, 3) +end) + +bindRgui("q", function() + moveFocusedToScreen(-1) +end) + +bindRgui("e", function() + moveFocusedToScreen(1) +end) + +bindRgui("r", hs.reload) + +local rguiTap = hs.eventtap.new({ + hs.eventtap.event.types.flagsChanged, + hs.eventtap.event.types.keyDown, +}, function(event) + local keyCode = event:getKeyCode() + local eventType = event:getType() + + if eventType == hs.eventtap.event.types.flagsChanged and keyCode == rightCommandKeyCode then + rightCommandDown = event:getFlags().cmd + if rightCommandDown then + rightCommandUsed = false + elseif not rightCommandUsed then + hs.eventtap.keyStroke({}, "escape", 0) + end + return false + end + + if eventType ~= hs.eventtap.event.types.keyDown or not rightCommandDown then + return false + end + + local binding = rguiBindings[keyCode] + if not binding then + return false + end + + rightCommandUsed = true + local flags = event:getFlags() + local handler = flags.shift and binding.shifted or binding.normal + if handler then + handler() + end + + return true +end) + +rguiTap:start() + +notify("Hammerspoon loaded") diff --git a/nix-darwin/flake.nix b/nix-darwin/flake.nix index fd4fd8d0..e1b60866 100644 --- a/nix-darwin/flake.nix +++ b/nix-darwin/flake.nix @@ -71,6 +71,23 @@ ... }: let libDir = ../dotfiles/lib; + # Keep this on the currently-existing macOS account until the target user + # exists locally and its home directory has been migrated. + activePrimaryUser = "kat"; + targetPrimaryUser = "imalison"; + primaryUser = activePrimaryUser; + personalUsers = [ + activePrimaryUser + targetPrimaryUser + ]; + # Home Manager activation should only target accounts that exist today. + # Add targetPrimaryUser here when the macOS account is ready. + enabledHomeUsers = [ + activePrimaryUser + ]; + sharedHomeModules = [./home/common.nix]; + ivanHomeModules = [./home/ivan.nix]; + homeForUser = user: "/Users/${user}"; configuration = { pkgs, lib, @@ -78,12 +95,21 @@ ... }: let essentialPkgs = (import ../nix-shared/system/essential.nix {inherit pkgs lib inputs;}).environment.systemPackages; + disabledAppleSymbolicHotKey = parameters: { + enabled = false; + value = { + inherit parameters; + type = "standard"; + }; + }; in { networking.hostName = "mac-demarco-mini"; - imports = [(import ./gitea-actions-runner.nix)]; + imports = [ + (import ./gitea-actions-runner.nix) + ]; age = { identityPaths = [ - "${config.users.users.kat.home}/.ssh/id_ed25519" + "${config.users.users.${primaryUser}.home}/.ssh/id_ed25519" "/etc/ssh/ssh_host_ed25519_key" "/etc/ssh/ssh_host_rsa_key" ]; @@ -135,37 +161,31 @@ XDG_RUNTIME_DIR = "/var/lib/gitea-runner/tmp"; }; - system.primaryUser = "kat"; + system.primaryUser = primaryUser; + + security.sudo.extraConfig = '' + ${primaryUser} ALL=(ALL) NOPASSWD: ALL + ''; system.defaults.NSGlobalDomain."com.apple.swipescrolldirection" = false; system.defaults.CustomUserPreferences."com.apple.screensaver".idleTime = 300; system.defaults.CustomUserPreferences."com.apple.symbolichotkeys".AppleSymbolicHotKeys = { - "60" = { - enabled = false; - value = { - parameters = [ - 32 - 49 - 262144 - ]; - type = "standard"; - }; - }; - "61" = { - enabled = false; - value = { - parameters = [ - 32 - 49 - 786432 - ]; - type = "standard"; - }; - }; + # Disable input source shortcuts that conflict with launcher usage. + "60" = disabledAppleSymbolicHotKey [32 49 262144]; + "61" = disabledAppleSymbolicHotKey [32 49 786432]; + # Disable Spotlight's Command-Space and Finder search window shortcuts. + "64" = disabledAppleSymbolicHotKey [32 49 1048576]; + "65" = disabledAppleSymbolicHotKey [32 49 1572864]; }; system.defaults.screensaver.askForPassword = false; system.defaults.screensaver.askForPasswordDelay = 0; + system.activationScripts.postActivation.text = '' + echo >&2 "current-host screensaver defaults..." + launchctl asuser "$(id -u -- ${primaryUser})" sudo --user=${primaryUser} -- defaults -currentHost write com.apple.screensaver askForPassword -bool false + launchctl asuser "$(id -u -- ${primaryUser})" sudo --user=${primaryUser} -- defaults -currentHost write com.apple.screensaver askForPasswordDelay -int 0 + ''; + power.sleep = { computer = "never"; display = "never"; @@ -237,6 +257,7 @@ casks = [ "codex-app" "ghostty" + "hammerspoon" "raycast" "vlc" ]; @@ -248,6 +269,10 @@ # Auto upgrade nix package and the daemon service. launchd.user.envVariables.PATH = config.environment.systemPath; + launchd.user.agents.hammerspoon.serviceConfig = { + ProgramArguments = ["/usr/bin/open" "-gja" "Hammerspoon"]; + RunAtLoad = true; + }; programs.direnv.enable = true; @@ -274,18 +299,20 @@ # The platform the configuration will be used on. nixpkgs.hostPlatform = "aarch64-darwin"; - users.users.kat.openssh.authorizedKeys.keys = inputs.railbird-secrets.keys.kanivanKeys; - users.users.gitea-runner = { - name = "gitea-runner"; - isHidden = false; - home = "/Users/gitea-runner"; - createHome = false; - }; - - users.users.kat = { - name = "kat"; - home = "/Users/kat"; - }; + users.users = + lib.genAttrs personalUsers (user: { + name = user; + home = homeForUser user; + openssh.authorizedKeys.keys = inputs.railbird-secrets.keys.kanivanKeys; + }) + // { + gitea-runner = { + name = "gitea-runner"; + isHidden = false; + home = "/Users/gitea-runner"; + createHome = false; + }; + }; programs.zsh = { enable = true; @@ -298,10 +325,10 @@ extraSpecialArgs = { inherit inputs libDir; }; - sharedModules = [./home/common.nix]; - users.kat = { - imports = [./home/kat.nix]; - }; + sharedModules = sharedHomeModules; + users = lib.genAttrs enabledHomeUsers (_: { + imports = ivanHomeModules; + }); }; }; in { @@ -313,12 +340,14 @@ { nix-homebrew = { enable = true; - user = "kat"; + user = primaryUser; autoMigrate = true; - package = inputs.brew-src // { - name = "brew-5.1.7"; - version = "5.1.7"; - }; + package = + inputs.brew-src + // { + name = "brew-5.1.7"; + version = "5.1.7"; + }; taps = { "homebrew/homebrew-core" = inputs.homebrew-core; "homebrew/homebrew-cask" = inputs.homebrew-cask; diff --git a/nix-darwin/home/common.nix b/nix-darwin/home/common.nix index 87e002f6..da39b679 100644 --- a/nix-darwin/home/common.nix +++ b/nix-darwin/home/common.nix @@ -107,10 +107,26 @@ in { myModules.codexGeneratedSkills.enable = true; - home.packages = [ - pkgs.gnupg - (pkgs.pass.withExtensions (ext: [ext.pass-otp])) - ]; + home.packages = + [ + (pkgs.pass.withExtensions (ext: [ext.pass-otp])) + ] + ++ (with pkgs; [ + alejandra + alt-tab-macos + claude-code + cocoapods + codex + gnupg + nodejs + playwright-cli + prettier + slack + tea + typescript + vim + yarn + ]); home.activation.repairGpgHomeAndImportKey = lib.hm.dag.entryAfter ["writeBoundary"] '' gnupg_dir="$HOME/.gnupg" @@ -141,6 +157,27 @@ in { fi ''; + home.activation.configureRaycastHotkey = lib.hm.dag.entryAfter ["writeBoundary"] '' + raycast_domain="com.raycast.macos" + desired_hotkey="Command-49" + current_hotkey="$(/usr/bin/defaults read "$raycast_domain" raycastGlobalHotkey 2>/dev/null || true)" + + if [ -d /Applications/Raycast.app ]; then + /usr/bin/xattr -dr com.apple.quarantine /Applications/Raycast.app 2>/dev/null || true + fi + + if [ "$current_hotkey" != "$desired_hotkey" ]; then + /usr/bin/defaults write "$raycast_domain" raycastGlobalHotkey -string "$desired_hotkey" + /usr/bin/defaults write "$raycast_domain" mainWindow_isMonitoringGlobalHotkeys -bool true + + if /usr/bin/pgrep -x Raycast >/dev/null 2>&1; then + /usr/bin/killall Raycast || true + /bin/sleep 1 + fi + /usr/bin/open /Applications/Raycast.app || true + fi + ''; + home.sessionPath = [ "$HOME/.cargo/bin" "${libDir}/bin" @@ -205,8 +242,7 @@ in { }; ProgramArguments = [ "/usr/bin/open" - "-a" - "Raycast" + "/Applications/Raycast.app" ]; KeepAlive = false; ProcessType = "Interactive"; @@ -216,6 +252,22 @@ in { }; }; + launchd.agents.alt-tab = lib.mkIf pkgs.stdenv.isDarwin { + enable = true; + config = { + ProgramArguments = [ + "/usr/bin/open" + "-gj" + "${pkgs.alt-tab-macos}/Applications/AltTab.app" + ]; + KeepAlive = false; + ProcessType = "Interactive"; + RunAtLoad = true; + StandardOutPath = "${config.home.homeDirectory}/Library/Logs/alt-tab-launchd.log"; + StandardErrorPath = "${config.home.homeDirectory}/Library/Logs/alt-tab-launchd.err.log"; + }; + }; + programs.starship = { enable = true; }; @@ -247,4 +299,6 @@ in { }; xdg.configFile = xdgConfigLinks; + + home.stateVersion = "24.05"; } diff --git a/nix-darwin/home/kat.nix b/nix-darwin/home/ivan.nix similarity index 56% rename from nix-darwin/home/kat.nix rename to nix-darwin/home/ivan.nix index 21ba1beb..ef6805e4 100644 --- a/nix-darwin/home/kat.nix +++ b/nix-darwin/home/ivan.nix @@ -1,18 +1,8 @@ -{pkgs, ...}: { - home.packages = with pkgs; [ - alejandra - claude-code - cocoapods - codex - nodejs - prettier - slack - tea - typescript - vim - yarn - ]; - +{ + config, + pkgs, + ... +}: { services.git-sync = { enable = true; package = @@ -21,16 +11,14 @@ else pkgs.git-sync; repositories = { org = { - path = "/Users/kat/org"; + path = "${config.home.homeDirectory}/org"; uri = "git@github.com:colonelpanic8/org.git"; interval = 180; }; password-store = { - path = "/Users/kat/.password-store"; + path = "${config.home.homeDirectory}/.password-store"; uri = "git@github.com:IvanMalison/.password-store.git"; }; }; }; - - home.stateVersion = "24.05"; } diff --git a/nix-shared/overlays/default.nix b/nix-shared/overlays/default.nix index 1c90d1df..a4669f28 100644 --- a/nix-shared/overlays/default.nix +++ b/nix-shared/overlays/default.nix @@ -312,7 +312,7 @@ from transformers import (/' \ }); happy-coder = final.callPackage ./packages/happy-coder { }; - playwright-cli = final.callPackage ./packages/playwright-cli { }; + playwright-cli = final.callPackage ../../nixos/packages/playwright-cli { }; t3code = final.callPackage ./packages/t3code { }; # Custom Waybar fork for workspace taskbar support + external SNI watcher option. waybar = prev.waybar.overrideAttrs (old: {