Configure macOS window tooling

This commit is contained in:
2026-04-26 14:24:40 -07:00
parent 1b8c54d722
commit 31fbcf73ac
5 changed files with 581 additions and 72 deletions

View File

@@ -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")

View File

@@ -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,17 +299,19 @@
# 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 = {
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;
};
users.users.kat = {
name = "kat";
home = "/Users/kat";
};
programs.zsh = {
@@ -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,9 +340,11 @@
{
nix-homebrew = {
enable = true;
user = "kat";
user = primaryUser;
autoMigrate = true;
package = inputs.brew-src // {
package =
inputs.brew-src
// {
name = "brew-5.1.7";
version = "5.1.7";
};

View File

@@ -107,10 +107,26 @@ in {
myModules.codexGeneratedSkills.enable = true;
home.packages = [
pkgs.gnupg
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";
}

View File

@@ -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";
}

View File

@@ -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: {