{ config, lib, pkgs, ... }: # The Hyprland compositor. let # NOLINT(let_order) cfg = config.modules.desktop.session.hyprland; # NOLINT(let_order) cursor = config.stylix.cursor; bind = key: dispatcher: { _args = [ key dispatcher ]; }; luaCall = function: args: mkLuaInline "${function}(${lib.concatMapStringsSep ", " toLua args})"; terminal = "ghostty"; execBindWith = key: command: options: bindWith key (luaCall "hl.dsp.exec_cmd" [ command ]) options; bindWith = key: dispatcher: options: { _args = [ key dispatcher options ]; }; # Numbered-workspace switch and move for 1..9, the operator's i3 muscle memory. workspaceBinds = lib.concatMap (n: [ (bind "${mod} + ${toString n}" (luaCall "hl.dsp.focus" [ { workspace = n; } ])) (bind "${mod} + SHIFT + ${toString n}" (luaCall "hl.dsp.window.move" [ { workspace = n; } ])) ]) (lib.range 1 9); execBind = key: command: bind key (luaCall "hl.dsp.exec_cmd" [ command ]); inherit (lib.generators) mkLuaInline; mod = "SUPER"; toLua = lib.generators.toLua { }; user = config.user.name; # Flip the tiling strategy between the two built-in layouts, since neither a # dispatcher nor a keyword toggles it on its own. toggleLayout = pkgs.writeShellScript "hypr-toggle-layout" '' if [ "$(hyprctl getoption -j general:layout | ${pkgs.jq}/bin/jq -r .str)" = dwindle ]; then hyprctl keyword general:layout master else hyprctl keyword general:layout dwindle fi ''; in { options.modules.desktop.session.hyprland = { enable = lib.mkEnableOption "the Hyprland compositor"; blur = lib.mkEnableOption '' window blur. Off by default as the single biggest battery cost on a laptop, left on for a host with the headroom to spend it''; }; config = lib.mkIf cfg.enable { # This program integration owns the session, portals, and polkit, launched # through the universal Wayland session manager. programs.hyprland = { enable = true; withUWSM = true; }; home-manager.users.${user}.wayland.windowManager.hyprland = { enable = true; # One package drives the whole session, so there is never a version split. # The program integration above installs it and the portal, leaving home- # manager to write only the config. package = null; portalPackage = null; # uwsm owns the systemd graphical-session targets. systemd.enable = false; # Write the native Lua Hyprland config. Home Manager renders the Nix # settings below as hl.* calls. configType = "lua"; importantPrefixes = [ "config" "curve" "animation" ]; settings = { # Session variables handed to the compositor directly. # UWSM launches the session without the shell profile that would carry # them, so a variable the compositor or its children must see is set # here rather than through home-manager's sessionVariables. env = [ # Chromium and Electron apps read this to select native Wayland; # nixpkgs wrappers (Obsidian's included) gate their Wayland flags on # it, so without it they run under XWayland and blur at this DPI. { _args = [ "NIXOS_OZONE_WL" "1" ]; } ] # Bibata ships XCursor only. # The hyprcursor variables name the same theme, which Hyprland # resolves through its XCursor fallback. ++ lib.optionals (cursor != null) [ { _args = [ "XCURSOR_THEME" cursor.name ]; } { _args = [ "XCURSOR_SIZE" (toString cursor.size) ]; } { _args = [ "HYPRCURSOR_THEME" cursor.name ]; } { _args = [ "HYPRCURSOR_SIZE" (toString cursor.size) ]; } ]; config = { input = { kb_layout = "us"; # Caps is a second Escape. # Shift+Caps still toggles a real CapsLock. kb_options = "caps:escape_shifted_capslock"; # Snappy key repeat. repeat_delay = 250; repeat_rate = 45; accel_profile = "flat"; touchpad = { natural_scroll = true; tap_to_click = true; disable_while_typing = true; }; }; general = { gaps_in = 4; gaps_out = 8; border_size = 2; layout = "dwindle"; }; decoration = { rounding = 6; blur.enabled = cfg.blur; }; # XWayland clients render at the panel's native resolution instead of # being raster-scaled by the compositor at the fractional monitor scale. xwayland.force_zero_scaling = true; animations.enabled = true; dwindle = { preserve_split = true; }; }; curve = { _args = [ "ease" { type = "bezier"; points = [ [ 0.25 0.1 ] [ 0.25 1.0 ] ]; } ]; }; # Durations are in centiseconds. # Short values keep the motion subtle. animation = [ { leaf = "windows"; enabled = true; speed = 3; bezier = "ease"; } { leaf = "fade"; enabled = true; speed = 3; bezier = "ease"; } # Layer surfaces like the launcher fade in a touch quicker than windows. { leaf = "layersIn"; enabled = true; speed = 2; bezier = "ease"; } { leaf = "fadeLayersIn"; enabled = true; speed = 2; bezier = "ease"; } { leaf = "workspaces"; enabled = true; speed = 3; bezier = "ease"; } { leaf = "border"; enabled = true; speed = 3; bezier = "ease"; } ]; bind = [ (execBind "${mod} + Return" terminal) # Move focus. (bind "${mod} + H" (luaCall "hl.dsp.focus" [ { direction = "left"; } ])) (bind "${mod} + J" (luaCall "hl.dsp.focus" [ { direction = "down"; } ])) (bind "${mod} + K" (luaCall "hl.dsp.focus" [ { direction = "up"; } ])) (bind "${mod} + L" (luaCall "hl.dsp.focus" [ { direction = "right"; } ])) # Move the window within the layout. (bind "${mod} + SHIFT + H" (luaCall "hl.dsp.window.move" [ { direction = "left"; } ])) (bind "${mod} + SHIFT + J" (luaCall "hl.dsp.window.move" [ { direction = "down"; } ])) (bind "${mod} + SHIFT + K" (luaCall "hl.dsp.window.move" [ { direction = "up"; } ])) (bind "${mod} + SHIFT + L" (luaCall "hl.dsp.window.move" [ { direction = "right"; } ])) # Resize the active window. (bind "${mod} + ALT + H" ( luaCall "hl.dsp.window.resize" [ { x = -40; y = 0; relative = true; } ] )) (bind "${mod} + ALT + J" ( luaCall "hl.dsp.window.resize" [ { x = 0; y = 40; relative = true; } ] )) (bind "${mod} + ALT + K" ( luaCall "hl.dsp.window.resize" [ { x = 0; y = -40; relative = true; } ] )) (bind "${mod} + ALT + L" ( luaCall "hl.dsp.window.resize" [ { x = 40; y = 0; relative = true; } ] )) (bind "${mod} + Space" (luaCall "hl.dsp.window.float" [ { action = "toggle"; } ])) (bind "${mod} + F" (luaCall "hl.dsp.window.fullscreen" [ ])) # togglesplit is a dwindle layout message, reached through layout. (bind "${mod} + T" (luaCall "hl.dsp.layout" [ "togglesplit" ])) (execBind "${mod} + SHIFT + T" "${toggleLayout}") (bind "${mod} + SHIFT + Q" (luaCall "hl.dsp.window.close" [ ])) (bind "${mod} + CTRL + Q" (luaCall "hl.dsp.window.kill" [ ])) # Volume and brightness keys repeat while held, each raising a popup # through the OSD client. # Volume is capped at 100 percent. # The client floors brightness so a full hold cannot black the screen out. (execBindWith "XF86AudioRaiseVolume" "${pkgs.swayosd}/bin/swayosd-client --output-volume raise --max-volume 100" { repeating = true; } ) (execBindWith "XF86AudioLowerVolume" "${pkgs.swayosd}/bin/swayosd-client --output-volume lower" { repeating = true; }) (execBindWith "XF86MonBrightnessUp" "${pkgs.swayosd}/bin/swayosd-client --brightness raise" { repeating = true; }) (execBindWith "XF86MonBrightnessDown" "${pkgs.swayosd}/bin/swayosd-client --brightness lower" { repeating = true; }) # Mute and media transport still fire while the session is locked. (execBindWith "XF86AudioMute" "${pkgs.swayosd}/bin/swayosd-client --output-volume mute-toggle" { locked = true; }) (execBindWith "XF86AudioPlay" "${pkgs.playerctl}/bin/playerctl play-pause" { locked = true; }) (execBindWith "XF86AudioNext" "${pkgs.playerctl}/bin/playerctl next" { locked = true; }) (execBindWith "XF86AudioPrev" "${pkgs.playerctl}/bin/playerctl previous" { locked = true; }) ] ++ workspaceBinds; }; }; }; }