# Declarative Sway window manager, status bar, lock, idle and notifications. # Imported via ./desktop.nix, so only graphical hosts get it. # # The compositor binary, PAM and the polkit *daemon* come from the system-level # programs.sway (see ../modules/sway.nix); package = null below reuses it instead of # pulling a second Sway. The polkit authentication *agent* (the thing that draws # the GUI auth dialog) is a user service started here. home-manager owns the user # config (~/.config/sway) and wires the systemd user session (sway-session.target), # which is what lets the agent/swayidle/dunst/kanshi user services start with the # desktop. { pkgs, lib, # Threaded from mkHost (flake.nix). Desktop hosts set this false to drop # mobile components (battery block, screen-brightness keys). portable ? true, ... }: let # Catppuccin Mocha (shared with the ReGreet greeter). Raw hex; prefix "#" # where a consumer needs it -- Sway/i3status/dunst want "#", foot/swaylock do # not. ctp = import ../lib/catppuccin-mocha.nix; # Focused-window screenshot -> swappy editor (the dotfiles' grimshot.sh logic). # Full store paths so it needs nothing on PATH. screenshotWindow = pkgs.writeShellScript "screenshot-window" '' ${pkgs.grim}/bin/grim -g "$(${pkgs.sway}/bin/swaymsg -t get_tree \ | ${pkgs.jq}/bin/jq -r '.. | select(.focused?) | .rect | "\(.x),\(.y) \(.width)x\(.height)"')" \ - | ${pkgs.swappy}/bin/swappy -f - ''; # Binding-mode names. The string is both the `modes` attr key and what the # bar's mode indicator shows, so the keys are spelled out in the label. layoutMode = "layout: [s]tacking [w]tabbed [e]split"; systemMode = "system: [l]ock [e]xit [s]leep [r]eboot [Shift+s]shutdown"; in { wayland.windowManager.sway = { enable = true; package = null; # `sway --validate` needs a real package; skip it since package = null. checkConfig = false; config = rec { modifier = "Mod4"; terminal = "${pkgs.foot}/bin/foot"; # Launcher: sway-launcher-desktop running inside a floating foot window. menu = "${pkgs.foot}/bin/foot --app-id=launcher ${pkgs.sway-launcher-desktop}/bin/sway-launcher-desktop"; # Dvorak is a variant of the "us" layout, not a standalone layout -- # `xkb_layout = "dvorak"` fails to compile (no symbols/dvorak) and wlroots # silently falls back to QWERTY. Use the variant. input."type:keyboard" = { xkb_layout = "us"; xkb_variant = "dvorak"; }; # Touchpads (laptops): tap-to-click and natural scrolling. Inert on the # desktop hosts, which have no touchpad. input."type:touchpad" = { tap = "enabled"; natural_scroll = "enabled"; }; # Solid Catppuccin Mocha base as the wallpaper (no image dependency). output."*".bg = "#${ctp.base} solid_color"; # Window borders -- Catppuccin Mocha (blue accent on the focused window). colors = { focused = { border = "#${ctp.blue}"; background = "#${ctp.base}"; text = "#${ctp.text}"; indicator = "#${ctp.blue}"; childBorder = "#${ctp.blue}"; }; focusedInactive = { border = "#${ctp.surface0}"; background = "#${ctp.base}"; text = "#${ctp.subtext0}"; indicator = "#${ctp.surface0}"; childBorder = "#${ctp.surface0}"; }; unfocused = { border = "#${ctp.surface0}"; background = "#${ctp.base}"; text = "#${ctp.subtext0}"; indicator = "#${ctp.surface0}"; childBorder = "#${ctp.surface0}"; }; urgent = { border = "#${ctp.red}"; background = "#${ctp.base}"; text = "#${ctp.text}"; indicator = "#${ctp.red}"; childBorder = "#${ctp.red}"; }; }; window.commands = [ { criteria.app_id = "launcher"; command = "floating enable, resize set 800 500"; } # Don't let swayidle blank/lock during fullscreen video. Two rules cover # native Wayland (app_id) and XWayland (class) clients. { criteria.app_id = ".*"; command = "inhibit_idle fullscreen"; } { criteria.class = ".*"; command = "inhibit_idle fullscreen"; } ]; # Binding modes (submenus). Entered from keybindings below; each action # returns to the default mode. mkOptionDefault-merged with the module's # built-in "resize" mode. modes = { # Layout submenu (Mod+y). Mirrors Sway's default s/w/e layout keys -- # notably it restores split-toggle, which moved off Mod+e when that # became the nemo launcher. ${layoutMode} = { "s" = "layout stacking, mode default"; "w" = "layout tabbed, mode default"; "e" = "layout toggle split, mode default"; "Return" = "mode default"; "Escape" = "mode default"; }; # Power menu (Mod+Shift+x). Lock reuses the themed swaylock; the rest go # through systemd/logind (allowed for the active local session). ${systemMode} = { "l" = "exec ${pkgs.swaylock}/bin/swaylock -f, mode default"; "e" = "exec ${pkgs.sway}/bin/swaymsg exit, mode default"; "s" = "exec systemctl suspend, mode default"; "r" = "exec systemctl reboot, mode default"; "Shift+s" = "exec systemctl poweroff, mode default"; "Return" = "mode default"; "Escape" = "mode default"; }; }; bars = [ { position = "top"; statusCommand = "${pkgs.i3status-rust}/bin/i3status-rs ~/.config/i3status-rust/config-default.toml"; fonts = { names = [ "Noto Sans" "Font Awesome 6 Free" ]; size = 11.0; }; # Bar background + workspace buttons -- Catppuccin Mocha. The mantle # background matches i3status-rust's idle_bg below for a uniform strip. colors = { background = "#${ctp.mantle}"; statusline = "#${ctp.text}"; separator = "#${ctp.surface0}"; focusedWorkspace = { border = "#${ctp.blue}"; background = "#${ctp.blue}"; text = "#${ctp.base}"; }; activeWorkspace = { border = "#${ctp.surface0}"; background = "#${ctp.surface0}"; text = "#${ctp.text}"; }; inactiveWorkspace = { border = "#${ctp.mantle}"; background = "#${ctp.mantle}"; text = "#${ctp.subtext0}"; }; urgentWorkspace = { border = "#${ctp.red}"; background = "#${ctp.red}"; text = "#${ctp.base}"; }; }; } ]; # NB: this whole set is wrapped in mkOptionDefault so it MERGES with the # home-manager module's default keybindings (same priority) rather than # replacing them. Do not wrap it in mkMerge with a normal-priority attr -- # that makes the normal-priority def win and silently drops every default # bind (terminal, movement, workspaces, ...). keybindings = lib.mkOptionDefault ( { # Launcher on Mod+Space. mkForce overrides the module's own default # Mod+Space (focus mode_toggle); a plain value would conflict with it # at equal priority. Mod+d also still runs the launcher (module default). "${modifier}+space" = lib.mkForce "exec ${menu}"; # File manager. mkForce overrides the module default (layout toggle split). "${modifier}+e" = lib.mkForce "exec ${pkgs.nemo}/bin/nemo"; "${modifier}+l" = "exec ${pkgs.swaylock}/bin/swaylock -f"; # Cycle workspaces. "${modifier}+z" = "workspace prev"; "${modifier}+x" = "workspace next"; # focus mode_toggle (tiling <-> floating focus) -- re-homed off # Mod+Space, which is now the launcher. "${modifier}+Mod1+space" = "focus mode_toggle"; # Enter the binding-mode submenus defined above. "${modifier}+y" = "mode \"${layoutMode}\""; "${modifier}+Shift+x" = "mode \"${systemMode}\""; # Clipboard history: pick a past entry through fuzzel (clipman stores # it -- see services.clipman below). "${modifier}+c" = "exec ${pkgs.clipman}/bin/clipman pick -t CUSTOM --tool-args=\"${pkgs.fuzzel}/bin/fuzzel --dmenu\""; # Screenshots -> swappy editor: Print = drag a region, Shift+Print = # the focused window. "Print" = "exec ${pkgs.grim}/bin/grim -g \"$(${pkgs.slurp}/bin/slurp)\" - | ${pkgs.swappy}/bin/swappy -f -"; "Shift+Print" = "exec ${screenshotWindow}"; "XF86AudioRaiseVolume" = "exec ${pkgs.wireplumber}/bin/wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+"; "XF86AudioLowerVolume" = "exec ${pkgs.wireplumber}/bin/wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"; "XF86AudioMute" = "exec ${pkgs.wireplumber}/bin/wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"; "XF86AudioMicMute" = "exec ${pkgs.wireplumber}/bin/wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"; # Media keys (MPRIS via playerctl). "XF86AudioPlay" = "exec ${pkgs.playerctl}/bin/playerctl play-pause"; "XF86AudioNext" = "exec ${pkgs.playerctl}/bin/playerctl next"; "XF86AudioPrev" = "exec ${pkgs.playerctl}/bin/playerctl previous"; } # Screen backlight: laptops only (no internal backlight on a desktop). // lib.optionalAttrs portable { "XF86MonBrightnessUp" = "exec ${pkgs.brightnessctl}/bin/brightnessctl set 5%+"; "XF86MonBrightnessDown" = "exec ${pkgs.brightnessctl}/bin/brightnessctl set 5%-"; } ); }; }; # Terminal: Catppuccin Mocha. foot reads ~/.config/foot/foot.ini; the Sway # `terminal` above still launches the same binary, now themed. programs.foot = { enable = true; # foot 1.27: the bare [colors] section is deprecated in favour of # [colors-dark] (the default theme), and the cursor colour moved out of # [cursor] (where `color` is now rejected) into a `cursor` key here, written # " " (man foot.ini(5): "ff0000 00ff00" => green cursor, red # text). Only colors-dark is needed; we never set initial-color-theme=light. settings = { main = { # Nerd Font: monospace plus the powerline/Nerd glyphs the tmux # statusline uses (otherwise they render as blank/"?"). font = "JetBrainsMono Nerd Font:size=11"; # Advertise as xterm-256color so remote hosts without foot's terminfo # still behave (tmux re-adds foot's RGB/sync/etc. features -- see # shell.nix). The [main] section is the unheadered top of foot.ini. term = "xterm-256color"; }; scrollback.lines = 100000; "colors-dark" = { background = ctp.base; foreground = ctp.text; regular0 = ctp.surface1; regular1 = ctp.red; regular2 = ctp.green; regular3 = ctp.yellow; regular4 = ctp.blue; regular5 = ctp.pink; regular6 = ctp.teal; regular7 = ctp.subtext1; bright0 = ctp.surface2; bright1 = ctp.red; bright2 = ctp.green; bright3 = ctp.yellow; bright4 = ctp.blue; bright5 = ctp.pink; bright6 = ctp.teal; bright7 = ctp.subtext0; "selection-foreground" = ctp.base; "selection-background" = ctp.rosewater; cursor = "${ctp.base} ${ctp.rosewater}"; }; }; }; # Clipboard history: a user service runs `wl-paste --watch clipman store`, # bound to the Wayland session, so copies persist and Mod+c (above) can pick # an old entry through fuzzel. services.clipman.enable = true; # Polkit authentication agent. programs.sway (system) enables the polkit # daemon but no agent, so GUI privilege prompts (nemo mounting a disk, # NetworkManager/blueman editing a system resource) would otherwise fail # silently. lxqt-policykit is a small, toolkit-light agent; bind it to the # Sway session so it starts and stops with the desktop. systemd.user.services.polkit-lxqt = { Unit = { Description = "lxqt-policykit polkit authentication agent"; PartOf = [ "graphical-session.target" ]; After = [ "graphical-session.target" ]; }; Service = { ExecStart = "${pkgs.lxqt.lxqt-policykit}/bin/lxqt-policykit-agent"; Restart = "on-failure"; }; Install.WantedBy = [ "sway-session.target" ]; }; # Output/display management. Reacts to hotplug and applies per-display # mode/scale/position. Profiles are hardware-specific: the safe default below # just enables the internal laptop panel; add docked/desktop profiles with the # real identifiers from `swaymsg -t get_outputs` (e.g. the Mac Pro's Apple # Cinema Display with its scale, or a docked laptop + external monitor). services.kanshi = { enable = true; settings = [ { profile.name = "undocked"; profile.outputs = [ { criteria = "eDP-1"; status = "enable"; } ]; } # Example to copy per host (fill in real criteria/mode/scale/position): # { # profile.name = "desktop"; # profile.outputs = [ # { criteria = "Apple Computer Inc Cinema HD ..."; mode = "2560x1600"; scale = 1.0; position = "0,0"; status = "enable"; } # ]; # } ]; }; # Night light. Manual location (no geoclue dependency); warmer at night, # neutral by day. Coordinates come from the per-user module (e.g. # users/lyrathorpe/home.nix), not this shared module. services.gammastep = { enable = true; provider = "manual"; temperature = { day = 6500; night = 3700; }; }; # fuzzel: the dmenu picker used by clipman, themed Catppuccin Mocha to match # (fuzzel colours are RRGGBBAA -- 8 hex digits). programs.fuzzel = { enable = true; settings = { main = { font = "Noto Sans:size=12"; prompt = "\"clipboard \""; }; border = { width = 2; radius = 8; }; colors = { background = "${ctp.base}f0"; text = "${ctp.text}ff"; prompt = "${ctp.subtext0}ff"; input = "${ctp.text}ff"; match = "${ctp.blue}ff"; selection = "${ctp.surface1}ff"; selection-text = "${ctp.text}ff"; selection-match = "${ctp.blue}ff"; border = "${ctp.blue}ff"; }; }; }; programs.swaylock = { enable = true; # Catppuccin Mocha (swaylock colours are hex without "#"). settings = { color = ctp.base; indicator-radius = 100; indicator-thickness = 7; show-failed-attempts = true; font = "Noto Sans"; inside-color = ctp.base; inside-clear-color = ctp.base; inside-ver-color = ctp.base; inside-wrong-color = ctp.base; ring-color = ctp.surface1; ring-clear-color = ctp.yellow; ring-ver-color = ctp.blue; ring-wrong-color = ctp.red; key-hl-color = ctp.blue; bs-hl-color = ctp.red; line-color = ctp.base; line-clear-color = ctp.base; line-ver-color = ctp.base; line-wrong-color = ctp.base; separator-color = "00000000"; text-color = ctp.text; text-clear-color = ctp.text; text-ver-color = ctp.text; text-wrong-color = ctp.text; }; }; # Lock on idle, turn screens off shortly after, and lock before sleep. services.swayidle = { enable = true; timeouts = [ { timeout = 300; command = "${pkgs.swaylock}/bin/swaylock -f"; } { timeout = 600; command = "${pkgs.sway}/bin/swaymsg 'output * power off'"; resumeCommand = "${pkgs.sway}/bin/swaymsg 'output * power on'"; } ]; events = { before-sleep = "${pkgs.swaylock}/bin/swaylock -f"; lock = "${pkgs.swaylock}/bin/swaylock -f"; }; }; services.dunst = { enable = true; # Catppuccin Mocha notifications (dunst colours need a leading "#"). settings = { global = { font = "Noto Sans 11"; frame_color = "#${ctp.blue}"; frame_width = 2; separator_color = "frame"; offset = "10x10"; corner_radius = 5; }; urgency_low = { background = "#${ctp.base}"; foreground = "#${ctp.text}"; frame_color = "#${ctp.surface1}"; }; urgency_normal = { background = "#${ctp.base}"; foreground = "#${ctp.text}"; frame_color = "#${ctp.blue}"; }; urgency_critical = { background = "#${ctp.base}"; foreground = "#${ctp.text}"; frame_color = "#${ctp.peach}"; timeout = 0; }; }; }; programs.i3status-rust = { enable = true; bars.default = { # Catppuccin Mocha: a flat "plain" base recoloured via overrides. Idle # blocks sit on mantle (matching the Sway bar background) with light text; # only warning/critical states get a loud tinted background. The `theme` # bar option is shallow-merged away by `settings.theme`, so set the base # theme and its overrides together here. icons = "awesome6"; settings.theme = { theme = "plain"; overrides = { idle_bg = "#${ctp.mantle}"; idle_fg = "#${ctp.text}"; info_bg = "#${ctp.mantle}"; info_fg = "#${ctp.blue}"; good_bg = "#${ctp.mantle}"; good_fg = "#${ctp.green}"; warning_bg = "#${ctp.peach}"; warning_fg = "#${ctp.base}"; critical_bg = "#${ctp.red}"; critical_fg = "#${ctp.base}"; separator_bg = "#${ctp.mantle}"; separator_fg = "#${ctp.surface1}"; }; }; blocks = [ { block = "disk_space"; path = "/"; format = " $icon $available "; } { block = "memory"; format = " $icon $mem_used_percents "; } { block = "cpu"; interval = 2; } ] # Desktop-only: CPU temperature and wired network throughput, in place # of the laptop's battery readout. ++ lib.optionals (!portable) [ { block = "temperature"; interval = 5; format = " $icon $average avg, $max max "; } { block = "net"; } ] ++ [ { block = "sound"; } ] ++ lib.optional portable { block = "battery"; } ++ [ { block = "time"; interval = 5; format = " $timestamp.datetime(f:'%a %d/%m %R') "; } ]; }; }; }