{ config, lib, pkgs, ... }: with lib; let cfg = config.home.roles.aerospace; in { options.home.roles.aerospace = { enable = mkEnableOption "AeroSpace tiling window manager for macOS"; leader = mkOption { type = types.str; default = "cmd"; description = "Leader key for aerospace shortcuts (e.g., 'cmd', 'ctrl', 'alt')"; example = "ctrl"; }; launchd.enable = mkOption { type = types.bool; default = true; description = "Whether to enable launchd agent for auto-starting aerospace"; }; userSettings = mkOption { type = types.attrs; default = {}; description = '' Additional aerospace configuration settings to merge with defaults. Use this to override or extend the default configuration on a per-machine basis. ''; example = literalExpression '' { mode.main.binding."''${leader}-custom" = "custom-command"; } ''; }; autoraise = { enable = mkOption { type = types.bool; default = true; description = "Whether to enable autoraise (auto-focus window on hover)"; }; pollMillis = mkOption { type = types.int; default = 50; description = "Polling interval in milliseconds"; }; delay = mkOption { type = types.int; default = 2; description = "Delay before raising window"; }; focusDelay = mkOption { type = types.int; default = 2; description = "Delay before focusing window"; }; }; enableSpansDisplays = mkOption { type = types.bool; default = true; description = '' Configure macOS Spaces to span displays (required for aerospace multi-monitor support). Sets com.apple.spaces.spans-displays to true. NOTE: This was previously set at the system level in modules/aerospace.nix, but has been moved to home-manager for better modularity. ''; }; ctrlShortcuts = { enable = mkOption { type = types.bool; default = false; description = '' Remap common macOS Cmd shortcuts to Ctrl equivalents for all operations. This makes macOS behave more like Linux. Shortcuts remapped globally: - Ctrl+N: New Window - Ctrl+T: New Tab - Ctrl+W: Close Tab - Ctrl+S: Save / Save As - Ctrl+O: Open - Ctrl+F: Find - Ctrl+H: Find and Replace - Ctrl+P: Print - Ctrl+C/V/X: Copy/Paste/Cut - Ctrl+Z: Undo NOTE: Terminal emulators like Ghostty require per-app overrides (configured separately) to preserve Ctrl+C as SIGINT instead of Copy. ''; }; }; sketchybar = { enable = mkOption { type = types.bool; default = false; description = "Whether to enable SketchyBar status bar"; }; }; }; config = mkIf cfg.enable { # Only apply on Darwin systems assertions = [ { assertion = pkgs.stdenv.isDarwin; message = "Aerospace role is only supported on macOS (Darwin) systems"; } ]; # Configure macOS preferences via targets.darwin.defaults targets.darwin.defaults = mkMerge [ # Spaces span displays (required for multi-monitor aerospace) (mkIf cfg.enableSpansDisplays { "com.apple.spaces" = { spans-displays = true; }; }) # Ctrl shortcuts to make macOS behave more like Linux (mkIf cfg.ctrlShortcuts.enable { NSGlobalDomain.NSUserKeyEquivalents = { # Window/Tab operations "New Window" = "^n"; "New Tab" = "^t"; "Close Tab" = "^w"; # File operations "Save" = "^s"; "Save As…" = "^$s"; # Ctrl+Shift+S "Open" = "^o"; "Open…" = "^o"; # Find operations "Find" = "^f"; "Find…" = "^f"; "Find and Replace" = "^h"; "Find and Replace…" = "^h"; # Print "Print" = "^p"; "Print…" = "^p"; # Clipboard operations "Copy" = "^c"; "Paste" = "^v"; "Cut" = "^x"; # Undo/Redo "Undo" = "^z"; "Redo" = "^$z"; # Ctrl+Shift+Z }; }) # Ghostty-specific overrides to preserve terminal behavior # Remap clipboard operations back to Cmd (macOS default) so Ctrl+C remains SIGINT (mkIf cfg.ctrlShortcuts.enable { "com.mitchellh.ghostty".NSUserKeyEquivalents = { # Remap back to Cmd for clipboard operations "Copy" = "@c"; # Cmd+C "Paste" = "@v"; # Cmd+V "Cut" = "@x"; # Cmd+X "Undo" = "@z"; # Cmd+Z "Redo" = "@$z"; # Cmd+Shift+Z }; }) ]; # Install aerospace package and optional tools if enabled home.packages = [ pkgs.aerospace ] ++ optionals cfg.autoraise.enable [ pkgs.autoraise ] ++ optionals cfg.sketchybar.enable [ pkgs.sketchybar pkgs.sketchybar-app-font ]; # Enable and configure aerospace programs.aerospace.enable = true; programs.aerospace.launchd.enable = cfg.launchd.enable; programs.aerospace.userSettings = mkMerge [ # Default configuration with leader key substitution { # Disable normalizations for i3-like behavior enable-normalization-flatten-containers = false; enable-normalization-opposite-orientation-for-nested-containers = false; mode.main.binding = { "${cfg.leader}-w" = "layout accordion horizontal"; # tabbed "${cfg.leader}-s" = "layout accordion vertical"; # stacking "${cfg.leader}-e" = "layout tiles horizontal vertical"; # tiles, toggles orientation "${cfg.leader}-shift-q" = "close"; "${cfg.leader}-shift-f" = "fullscreen"; "${cfg.leader}-h" = "focus left"; "${cfg.leader}-j" = "focus down"; "${cfg.leader}-k" = "focus up"; "${cfg.leader}-l" = "focus right"; "${cfg.leader}-shift-h" = "move left"; "${cfg.leader}-shift-j" = "move down"; "${cfg.leader}-shift-k" = "move up"; "${cfg.leader}-shift-l" = "move right"; "${cfg.leader}-r" = "mode resize"; "${cfg.leader}-1" = "workspace 1"; "${cfg.leader}-2" = "workspace 2"; "${cfg.leader}-3" = "workspace 3"; "${cfg.leader}-4" = "workspace 4"; "${cfg.leader}-5" = "workspace 5"; "${cfg.leader}-6" = "workspace 6"; "${cfg.leader}-7" = "workspace 7"; "${cfg.leader}-8" = "workspace 8"; "${cfg.leader}-9" = "workspace 9"; "${cfg.leader}-0" = "workspace 10"; "${cfg.leader}-shift-1" = "move-node-to-workspace 1"; "${cfg.leader}-shift-2" = "move-node-to-workspace 2"; "${cfg.leader}-shift-3" = "move-node-to-workspace 3"; "${cfg.leader}-shift-4" = "move-node-to-workspace 4"; "${cfg.leader}-shift-5" = "move-node-to-workspace 5"; "${cfg.leader}-shift-6" = "move-node-to-workspace 6"; "${cfg.leader}-shift-7" = "move-node-to-workspace 7"; "${cfg.leader}-shift-8" = "move-node-to-workspace 8"; "${cfg.leader}-shift-9" = "move-node-to-workspace 9"; "${cfg.leader}-shift-0" = "move-node-to-workspace 10"; "${cfg.leader}-tab" = "workspace-back-and-forth"; "${cfg.leader}-shift-tab" = "move-workspace-to-monitor --wrap-around next"; "${cfg.leader}-enter" = '' exec-and-forget osascript <<'APPLESCRIPT' tell application "Ghostty" activate tell application "System Events" keystroke "n" using {command down} end tell end tell APPLESCRIPT ''; "${cfg.leader}-shift-enter" = '' exec-and-forget osascript <<'APPLESCRIPT' tell application "Google Chrome" set newWindow to make new window activate tell newWindow to set index to 1 end tell APPLESCRIPT ''; "${cfg.leader}-shift-e" = "exec-and-forget zsh --login -c \"emacsclient -c -n\""; # Service mode: Deliberate aerospace window management "${cfg.leader}-i" = "mode service"; # Passthrough mode: Temporarily disable aerospace to use macOS shortcuts "${cfg.leader}-p" = "mode passthrough"; }; # Resize mode: For window resizing operations mode.resize.binding = { h = "resize width -50"; j = "resize height +50"; k = "resize height -50"; l = "resize width +50"; minus = "resize smart -50"; equal = "resize smart +50"; esc = "mode main"; enter = "mode main"; }; # Service mode: For deliberate aerospace window management operations mode.service.binding = { esc = ["reload-config" "mode main"]; r = ["flatten-workspace-tree" "mode main"]; # reset layout f = ["layout floating tiling" "mode main"]; # Toggle between floating and tiling layout backspace = ["close-all-windows-but-current" "mode main"]; "${cfg.leader}-shift-h" = ["join-with left" "mode main"]; "${cfg.leader}-shift-j" = ["join-with down" "mode main"]; "${cfg.leader}-shift-k" = ["join-with up" "mode main"]; "${cfg.leader}-shift-l" = ["join-with right" "mode main"]; }; # Passthrough mode: All shortcuts pass through to macOS mode.passthrough.binding = { esc = "mode main"; "${cfg.leader}-p" = "mode main"; }; # SketchyBar integration - notify bar of workspace changes exec-on-workspace-change = mkIf cfg.sketchybar.enable [ "/bin/bash" "-c" "${pkgs.sketchybar}/bin/sketchybar --trigger aerospace_workspace_change FOCUSED=$AEROSPACE_FOCUSED_WORKSPACE PREV=$AEROSPACE_PREV_WORKSPACE" ]; } # Gaps configuration - prevent windows from overlapping SketchyBar (mkIf cfg.sketchybar.enable { gaps = { outer = { top = 0; bottom = 40; left = 0; right = 0; }; }; }) cfg.userSettings ]; # Launchd agent for autoraise launchd.agents.autoraise = mkIf cfg.autoraise.enable { enable = true; config = { ProgramArguments = [ "${pkgs.autoraise}/bin/AutoRaise" "-pollMillis" (toString cfg.autoraise.pollMillis) "-delay" (toString cfg.autoraise.delay) "-focusDelay" (toString cfg.autoraise.focusDelay) ]; RunAtLoad = true; KeepAlive = true; }; }; # SketchyBar configuration home.file.".config/sketchybar/sketchybarrc" = mkIf cfg.sketchybar.enable { executable = true; onChange = "${pkgs.sketchybar}/bin/sketchybar --reload"; text = '' #!/bin/bash # Plugin directory PLUGIN_DIR="$HOME/.config/sketchybar/plugins" # Colors - i3/sway theme with exact color matching # Focused window/workspace color from i3/sway FOCUSED=0xff285577 # Background colors matching i3blocks bar BAR_BG=0xcc000000 # Semi-transparent black ITEM_BG=0xff333333 # Dark gray for inactive items # Text colors TEXT=0xffffffff # White text GRAY=0xff888888 # Muted text for inactive items # Accent colors for warnings WARNING=0xffff9900 CRITICAL=0xff900000 # Configure the bar appearance ${pkgs.sketchybar}/bin/sketchybar --bar \ position=bottom \ height=32 \ color=$BAR_BG \ border_width=0 \ corner_radius=0 \ padding_left=10 \ padding_right=10 # Set default properties for all items # Using monospace font to match waybar's Fira Code styling ${pkgs.sketchybar}/bin/sketchybar --default \ updates=when_shown \ icon.font="SF Mono:Regular:13.0" \ icon.color=$TEXT \ icon.padding_left=4 \ icon.padding_right=4 \ label.font="SF Mono:Regular:13.0" \ label.color=$TEXT \ label.padding_left=4 \ label.padding_right=4 \ padding_left=4 \ padding_right=4 \ background.corner_radius=5 \ background.height=24 # Register aerospace workspace change event ${pkgs.sketchybar}/bin/sketchybar --add event aerospace_workspace_change # Create workspace indicators for workspaces 1-10 for sid in 1 2 3 4 5 6 7 8 9 10; do # Display "0" for workspace 10 if [ "$sid" = "10" ]; then display="0" else display="$sid" fi ${pkgs.sketchybar}/bin/sketchybar --add item space.$sid left \ --subscribe space.$sid aerospace_workspace_change \ --set space.$sid \ update_freq=2 \ width=32 \ background.color=$ITEM_BG \ background.corner_radius=5 \ background.height=20 \ background.drawing=on \ icon="$display" \ icon.padding_left=13 \ icon.padding_right=11 \ icon.align=center \ label.drawing=off \ click_script="${pkgs.aerospace}/bin/aerospace workspace $sid" \ script="$PLUGIN_DIR/aerospace.sh $sid" done # Separator after workspaces ${pkgs.sketchybar}/bin/sketchybar --add item separator_left left \ --set separator_left \ icon="" \ label="" \ background.drawing=off \ padding_left=10 \ padding_right=10 # System monitoring modules (right side) # Note: Items added to 'right' appear in reverse order (last added = leftmost) # Adding in reverse to get: disk | cpu | memory | battery | volume | calendar ${pkgs.sketchybar}/bin/sketchybar --add item calendar right \ --set calendar \ icon="📅" \ update_freq=30 \ background.color=$ITEM_BG \ background.drawing=on \ script="$PLUGIN_DIR/calendar.sh" ${pkgs.sketchybar}/bin/sketchybar --add item separator_media right \ --set separator_media \ icon="|" \ label="" \ background.drawing=off \ padding_left=5 \ padding_right=5 ${pkgs.sketchybar}/bin/sketchybar --add item volume right \ --set volume \ background.color=$ITEM_BG \ background.drawing=on \ script="$PLUGIN_DIR/volume.sh" \ --subscribe volume volume_change ${pkgs.sketchybar}/bin/sketchybar --add item battery right \ --set battery \ update_freq=120 \ background.color=$ITEM_BG \ background.drawing=on \ script="$PLUGIN_DIR/battery.sh" \ --subscribe battery system_woke power_source_change ${pkgs.sketchybar}/bin/sketchybar --add item separator_sys right \ --set separator_sys \ icon="|" \ label="" \ background.drawing=off \ padding_left=5 \ padding_right=5 ${pkgs.sketchybar}/bin/sketchybar --add item memory right \ --set memory \ update_freq=5 \ icon="🐏" \ background.color=$ITEM_BG \ background.drawing=on \ script="$PLUGIN_DIR/memory.sh" ${pkgs.sketchybar}/bin/sketchybar --add item cpu right \ --set cpu \ update_freq=2 \ icon="🧠" \ background.color=$ITEM_BG \ background.drawing=on \ script="$PLUGIN_DIR/cpu.sh" ${pkgs.sketchybar}/bin/sketchybar --add item disk right \ --set disk \ update_freq=60 \ icon="💾" \ background.color=$ITEM_BG \ background.drawing=on \ script="$PLUGIN_DIR/disk.sh" # Menu bar extras / system tray items (rightmost) # Note: Requires Screen Recording permission for SketchyBar in System Settings # Use 'sketchybar --query default_menu_items' to discover available items # Bluetooth ${pkgs.sketchybar}/bin/sketchybar --add alias "Control Center,Bluetooth" right \ --set "Control Center,Bluetooth" \ alias.update_freq=1 \ padding_left=0 \ padding_right=0 # WiFi ${pkgs.sketchybar}/bin/sketchybar --add alias "Control Center,WiFi" right \ --set "Control Center,WiFi" \ alias.update_freq=1 \ padding_left=0 \ padding_right=0 # Add other menu bar apps as discovered # Common examples: # - Cloudflare WARP: --add alias "Cloudflare WARP,Item-0" right # - Notion Calendar: --add alias "Notion Calendar,Item-0" right # Run 'sketchybar --query default_menu_items' to find exact names # Update the bar ${pkgs.sketchybar}/bin/sketchybar --update ''; }; # SketchyBar aerospace workspace plugin home.file.".config/sketchybar/plugins/aerospace.sh" = mkIf cfg.sketchybar.enable { executable = true; text = '' #!/bin/bash # Colors FOCUSED_COLOR=0xff285577 ITEM_BG=0xff333333 TEXT=0xffffffff GRAY=0xff555555 # Get the currently focused workspace directly from aerospace # Trim whitespace to ensure clean comparison FOCUSED=$(${pkgs.aerospace}/bin/aerospace list-workspaces --focused | tr -d ' \n\r') # Get list of empty workspaces EMPTY_WORKSPACES=$(${pkgs.aerospace}/bin/aerospace list-workspaces --monitor all --empty) # Clean up the workspace number parameter WORKSPACE_NUM=$(echo "$1" | tr -d ' \n\r') # Check if workspace has windows (is NOT empty) IS_EMPTY=false if echo "$EMPTY_WORKSPACES" | grep -q "^$WORKSPACE_NUM$"; then IS_EMPTY=true fi # Check if this workspace is focused IS_FOCUSED=false if [ "$WORKSPACE_NUM" = "$FOCUSED" ]; then IS_FOCUSED=true fi # Determine visibility and styling # Always show focused workspace (even if empty) with fixed width # Hide non-focused empty workspaces by setting width to 0 (collapsed) # Show non-focused non-empty workspaces with fixed width and inactive styling if [ "$IS_FOCUSED" = "true" ]; then # Focused workspace - always show with focused styling ${pkgs.sketchybar}/bin/sketchybar --set space.$WORKSPACE_NUM \ drawing=on \ width=32 \ icon.padding_left=13 \ icon.padding_right=11 \ icon.align=center \ background.color=$FOCUSED_COLOR \ background.drawing=on \ icon.color=$TEXT elif [ "$IS_EMPTY" = "true" ]; then # Empty workspace (not focused) - hide by turning off drawing ${pkgs.sketchybar}/bin/sketchybar --set space.$WORKSPACE_NUM \ drawing=off else # Non-empty workspace (not focused) - show with inactive styling ${pkgs.sketchybar}/bin/sketchybar --set space.$WORKSPACE_NUM \ drawing=on \ width=32 \ icon.padding_left=13 \ icon.padding_right=11 \ icon.align=center \ background.color=$ITEM_BG \ background.drawing=on \ icon.color=$GRAY fi ''; }; # SketchyBar CPU monitoring plugin home.file.".config/sketchybar/plugins/cpu.sh" = mkIf cfg.sketchybar.enable { executable = true; text = '' #!/bin/bash CORE_COUNT=$(sysctl -n machdep.cpu.thread_count) CPU_INFO=$(ps -eo pcpu,user) CPU_SYS=$(echo "$CPU_INFO" | grep -v $(whoami) | sed "s/[^ 0-9\.]//g" | awk "{sum+=\$1} END {print sum/(100.0 * $CORE_COUNT)}") CPU_USER=$(echo "$CPU_INFO" | grep $(whoami) | sed "s/[^ 0-9\.]//g" | awk "{sum+=\$1} END {print sum/(100.0 * $CORE_COUNT)}") CPU_PERCENT="$(echo "$CPU_SYS $CPU_USER" | awk '{printf "%.0f\n", ($1 + $2)*100}')" ${pkgs.sketchybar}/bin/sketchybar --set $NAME label="$CPU_PERCENT%" ''; }; # SketchyBar memory monitoring plugin home.file.".config/sketchybar/plugins/memory.sh" = mkIf cfg.sketchybar.enable { executable = true; text = '' #!/bin/bash MEMORY_STATS=$(vm_stat) PAGES_FREE=$(echo "$MEMORY_STATS" | grep "Pages free" | awk '{print $3}' | tr -d '.') PAGES_ACTIVE=$(echo "$MEMORY_STATS" | grep "Pages active" | awk '{print $3}' | tr -d '.') PAGES_INACTIVE=$(echo "$MEMORY_STATS" | grep "Pages inactive" | awk '{print $3}' | tr -d '.') PAGES_WIRED=$(echo "$MEMORY_STATS" | grep "Pages wired down" | awk '{print $4}' | tr -d '.') PAGES_COMPRESSED=$(echo "$MEMORY_STATS" | grep "Pages stored in compressor" | awk '{print $5}' | tr -d '.') TOTAL_PAGES=$((PAGES_FREE + PAGES_ACTIVE + PAGES_INACTIVE + PAGES_WIRED + PAGES_COMPRESSED)) USED_PAGES=$((PAGES_ACTIVE + PAGES_INACTIVE + PAGES_WIRED + PAGES_COMPRESSED)) MEMORY_PERCENT=$((USED_PAGES * 100 / TOTAL_PAGES)) ${pkgs.sketchybar}/bin/sketchybar --set $NAME label="$MEMORY_PERCENT%" ''; }; # SketchyBar disk monitoring plugin home.file.".config/sketchybar/plugins/disk.sh" = mkIf cfg.sketchybar.enable { executable = true; text = '' #!/bin/bash DISK_USAGE=$(df -H / | grep -v Filesystem | awk '{print $5}') ${pkgs.sketchybar}/bin/sketchybar --set $NAME label="$DISK_USAGE" ''; }; # SketchyBar battery monitoring plugin home.file.".config/sketchybar/plugins/battery.sh" = mkIf cfg.sketchybar.enable { executable = true; text = '' #!/bin/bash PERCENTAGE=$(pmset -g batt | grep -Eo "\d+%" | cut -d% -f1) CHARGING=$(pmset -g batt | grep 'AC Power') if [ "$PERCENTAGE" = "" ]; then exit 0 fi # Select icon based on battery level case ''${PERCENTAGE} in 9[0-9]|100) ICON="🔋" ;; [6-8][0-9]) ICON="🔋" ;; [3-5][0-9]) ICON="🔋" ;; [1-2][0-9]) ICON="🔋" ;; *) ICON="🪫" esac # Show charging icon if connected to power if [[ $CHARGING != "" ]]; then ICON="⚡" fi ${pkgs.sketchybar}/bin/sketchybar --set $NAME icon="$ICON" label="''${PERCENTAGE}%" ''; }; # SketchyBar volume monitoring plugin home.file.".config/sketchybar/plugins/volume.sh" = mkIf cfg.sketchybar.enable { executable = true; text = '' #!/bin/bash if [ "$SENDER" = "volume_change" ]; then VOLUME=$(osascript -e "output volume of (get volume settings)") MUTED=$(osascript -e "output muted of (get volume settings)") if [ "$MUTED" = "true" ]; then ICON="🔇" LABEL="" else case $VOLUME in [6-9][0-9]|100) ICON="🔊" ;; [3-5][0-9]) ICON="🔉" ;; *) ICON="🔈" esac LABEL="$VOLUME%" fi ${pkgs.sketchybar}/bin/sketchybar --set $NAME icon="$ICON" label="$LABEL" fi ''; }; # SketchyBar calendar/clock plugin home.file.".config/sketchybar/plugins/calendar.sh" = mkIf cfg.sketchybar.enable { executable = true; text = '' #!/bin/bash ${pkgs.sketchybar}/bin/sketchybar --set $NAME label="$(date '+%Y-%m-%d %H:%M')" ''; }; # Launchd agent for auto-starting sketchybar launchd.agents.sketchybar = mkIf cfg.sketchybar.enable { enable = true; config = { ProgramArguments = [ "${pkgs.sketchybar}/bin/sketchybar" ]; RunAtLoad = true; KeepAlive = true; StandardOutPath = "/tmp/sketchybar.log"; StandardErrorPath = "/tmp/sketchybar.err.log"; }; }; }; }