Files
nix-config/modules/desktop/hyprland.org
phundrak a3df5e937f refactor: change to litterate config
Configuration is now held by the `.org` files. All `.nix` files are
tangled from the org-mode files.
2026-10-26 00:43:40 +01:00

14 KiB
Raw Permalink Blame History

Hyprland

Hyprland

Hyprland is my main Wayland compositor, dynamic tiling with nice animations out of the box. I used to keep the NixOS and home-manager halves of its config in separate files, but there was never a good reason for that, so they now both live here: one turns the program on system-wide, the other is where all of my actual config lives. Here’s the skeleton of both.

{
  flake.modules = {
    nixos.hyprland = {
      <<nixos-config>>
    };
    homeManager.hyprland = {
      config,
      lib,
      pkgs,
      ...
    }:
      with lib; let
        cfg = config.home.desktop.hyprland;
        laptops = ["gampo"];
        caelestiaEnabled = config.home.desktop.caelestia.enable;
      in {
        <<hm-options>>
        config = {
          <<desktop-defaults>>
          <<blueman-applet>>
          <<hyprland-settings>>
        };
      };
  };
}

Turning Hyprland On

On the NixOS side, there’s really nothing to it: just turn the program on. withUWSM hands session startup over to UWSM instead of Hyprland’s own half-baked systemd integration, which plays nicer with everything else expecting a proper systemd user session.

programs.hyprland = {
  enable = true;
  withUWSM = true;
};

Declaring the Options

emacsPkg exists so the rest of my config can reach whatever Emacs package I’m actually running, rather than assuming the default one. host is the more important one: it tells this module which machine it’s running on, which decides the monitor layout below and whether waybar bothers showing a battery indicator.

options.home.desktop.hyprland = {
  emacsPkg = mkOption {
    type = types.package;
    default = config.home.dev.editors.emacs.package or pkgs.emacs;
    example = pkgs.emacs;
  };
  host = mkOption {
    type = types.enum ["gampo" "marpa"];
    description = ''
      Which host is Hyprland running on.

      This helps determine the monitors layout and enable battery support in waybar.
    '';
  };
};

Letting Caelestia Take Over

These days I run caelestia on top of Hyprland, and it already covers the wallpaper, a bar, a notification centre, and night-light handling on its own. So whenever caelestia is enabled, I don’t want my own hyprpaper, waybar, swaync, and wlsunset fighting it for the same job. They’re all mkDefault, though, so a host still running bare Hyprland without caelestia can turn them back on — rofi and the battery indicator on waybar are the exceptions, since I always want rofi around, and only gampo (my laptop) needs a battery in the bar.

blueman-applet is a bit different: caelestia doesn’t have its own Bluetooth applet, so that one is just a straight negation rather than a default, with no way for a host to override it short of changing this module.

home.desktop = {
  hyprpaper.enable = mkDefault (! caelestiaEnabled);
  rofi.enable = mkDefault true;
  swaync.enable = mkDefault (! caelestiaEnabled);
  waybar = {
    enable = mkDefault (! caelestiaEnabled);
    battery = mkDefault (builtins.elem cfg.host laptops);
  };
  wlsunset.enable = mkDefault true;
};
services.blueman-applet.enable = ! caelestiaEnabled;

The Hyprland Config Itself

Everything from here on is the actual wayland.windowManager.hyprland config.

wayland.windowManager.hyprland = {
  enable = true;
  systemd.enable = false;
  importantPrefixes = ["$left" "$right" "$up" "$down" "$menu"];
  settings = {
    <<env>>
    <<input>>
    <<device>>
    <<monitor>>
    <<general>>
    <<master>>
    <<workspace>>
    <<decoration>>
    <<animations>>
    dwindle.preserve_split = true;
    exec-once =
      [
        <<exec-once-apps>>
      ]
      ++ lib.lists.optional (! caelestiaEnabled) "${pkgs.networkmanagerapplet}/bin/nm-applet";
  };
  extraConfig = ''
    <<keybind-device>>

    <<keybind-vars>>

    <<keybind-launch>>

    <<keybind-media>>

    <<keybind-movement>>

    <<keybind-mouse-actions>>

    <<keybind-workspaces>>
  '';
};

Environment and Input

XMODIFIERS points at fcitx so my input methods actually work inside Hyprland, and XCURSOR_SIZE just keeps the cursor from being comically tiny on my monitors. My keyboard uses the AFNOR standard for the bépo layout, same as everywhere else, with a US layout kept around as a fallback I can switch to on demand. I also keep numlock on by default and disable touchpad natural scrolling, since I never got used to it.

env = [
  "XMODIFIERS,@im=fcitx"
  "XCURSOR_SIZE,12"
];
input = {
  kb_layout = "fr,us";
  kb_variant = "bepo_afnor,";
  numlock_by_default = true;
  follow_mouse = 1;
  touchpad.natural_scroll = false;
  sensitivity = "0";
};

On top of that, my graphics tablet gets its own sensitivity tuned down a bit, since the default is far too twitchy for actual drawing.

device = {
  name = "wacom-usb-bamboo-pad-finger";
  sensitivity = 0.5;
};

Monitors

Hyprland just ignores a monitor it can’t find, but between marpa and gampo I want genuinely different layouts rather than one config that happens to degrade gracefully, so this picks the right list by host name instead. On marpa, DP-1 is my main ultrawide and DP-2 sits to its left as a secondary vertical-ish screen; the commented-out line underneath is a lower resolution I switch to by hand when streaming, since the full resolution is more than my upload can comfortably push through. gampo is just its own laptop panel, so there’s nothing to list — Hyprland figures that one out on its own.

monitor =
  {
    "marpa" = [
      "DP-1, 3440x1440@144, 1080x550, 1"
      # "DP-1, 2560x1440@144, 1080x550, 1" # streaming
      "DP-2, 2560x1080@60, 0x0, 1, transform, 1"
    ];
    "gampo" = [];
  }."${cfg.host}";

Look and Feel

I like my gaps, and keeping windows relatively tight together but with a generous gap around the whole screen looks nice to me. The border colours come from the Nord palette again, nord9 fading into nord14 for the active window and a plain nord3 for inactive ones.

general = {
  gaps_in = 5;
  gaps_out = 20;
  border_size = 2;
  "col.active_border" = "rgb(81a1c1) rgb(a3be8c) 45deg";
  "col.inactive_border" = "rgb(4c566a)";
  layout = "master";
};

I switched over to the master layout, centred, with new windows inheriting whatever status the window they split off from had.

master = {
  orientation = "center";
  new_status = "inherit";
};

Workspaces 1, 9 and 10 get their master area pinned to the bottom instead of the side, which just suits how I actually use them better.

workspace = [
  "1, layoutopt:orientation:bottom"
  "9, layoutopt:orientation:bottom"
  "10, layoutopt:orientation:bottom"
];

Decoration-wise I like a fairly generous rounding on my windows, no shadow or blur needed.

decoration = {
  rounding = 20;
};

Animations are fun, and I’ve mostly kept close to Hyprland’s own example config here, minus the window-open animation, which I never liked as much as everything else.

animations = {
  enabled = true;
  animation = [
    # "windows, 1, 7, myBezier"
    "windowsOut, 1, 7, default, popin 80%"
    "border, 1, 10, default"
    "borderangle, 1, 8, default"
    "fade, 1, 7, default"
    "workspaces, 1, 6, default"
  ];
};

Autolaunch of Programs

module-switch-on-connect means Pipewire hops to a new audio device the moment it’s plugged in, and I stop mpc right away just in case something was already playing. The Polkit agent needs to be running for a lot of GUI programs to be able to ask for elevated privileges at all, and mozc_server=/=fcitx5 are the input method daemons behind my input method setup. nm-applet only joins the party when caelestia isn’t around to show network status itself.

"pactl load-module module-switch-on-connect"
"${pkgs.mpc}/bin/mpc stop"
"${pkgs.polkit_gnome}/libexec/polkit-gnome-authentication-agent-1"
"${pkgs.mozc}/lib/mozc/mozc_server"
"${pkgs.fcitx5}/bin/fcitx5 -d"

Keybindings

The rest of the config is plain Hyprland syntax, so it lives in extraConfig rather than the settings attrset. I don’t bother with submaps here, unlike my old standalone config — everything’s a direct SUPER-something bind.

First, the tablet gets a second, more specific declaration here with an adaptive acceleration profile, since that bit isn’t exposed through the settings attrset the way the rest of device is. Then come my usual $left=/=$right=/=$up=/=$down variables — remember that hjkl becomes ctsr on a bépo keyboard — and $menu for my combi launcher.

device {
  name = wacom-usb-bamboo-pad-finger
  sensitivity = 0.5
  accel_profile = adaptive
}
$left = c
$right = r
$up = s
$down = t
$menu = rofi -combi-modi drun,calc -show combi

A terminal and my wlr-which-key menu (bound to both SUPER+Space and Print, so I can reach it either way) are the only two things I actually launch by hand.

bind = SUPER, Return, exec, ${pkgs.kitty}/bin/kitty ${pkgs.tmux}/bin/tmux
bind = SUPER, Space,  exec, ${pkgs.wlr-which-key}/bin/wlr-which-key
bind =      , Print,  exec, ${pkgs.wlr-which-key}/bin/wlr-which-key -k s

The media keys are the same as always: playback and volume through playerctl=/=pamixer, and brightness through xbacklight — including the keyboard backlight keys, which just reuse the same command since my keyboard doesn’t expose a separate control for it.

bindl = , XF86AudioPlay, exec, playerctl play-pause
bindl = , XF86AudioPause, exec, playerctl pause
bindl = , XF86AudioStop, exec, playerctl stop
bindl = , XF86AudioPrev, exec, playerctl previous
bindl = , XF86AudioNext, exec, playerctl next
bindl = , XF86AudioForward, exec, playerctl position +1
bindl = , XF86AudioRewind, exec, playerctl position -1
bindl = , XF86AudioRaiseVolume, exec, pamixer -i 2
bindl = , XF86AudioLowerVolume, exec, pamixer -d 2
bindl = , XF86MonBrightnessUp, exec, xbacklight -perceived -inc 2
bindl = , XF86MonBrightnessDown, exec, xbacklight -perceived -dec 2
bindl = , XF86KbdBrightnessUp, exec, xbacklight -perceived -inc 2
bindl = , XF86KbdBrightnessDown, exec, xbacklight -perceived -dec 2
bind = SUPER, a, exec, hyprctl switchxkblayout glove80-keyboard next

Window focus and movement all go through the $left=/=$right=/=$up=/=$down variables above, plus the usual Tab cycling and mouse-driven move/resize.

bind = SUPER, $left, movefocus, l
bind = SUPER, $right, movefocus, r
bind = SUPER, $up, movefocus, u
bind = SUPER, $down, movefocus, d
bind = SUPER_SHIFT, $left, movewindow, l
bind = SUPER_SHIFT, $right, movewindow, r
bind = SUPER_SHIFT, $up, movewindow, u
bind = SUPER_SHIFT, $down, movewindow, d
bind = SUPER_CTRL_SHIFT, $left, moveworkspacetomonitor, e+0 +1
bind = SUPER_CTRL_SHIFT, $right, moveworkspacetomonitor, e+0 -1
bind = SUPER, Tab, cyclenext,
bind = SUPER_SHIFT, Tab, cyclenext, prev
bindm = SUPER, mouse:272, movewindow
bindm = SUPER, mouse:273, resizewindow

Workspace switching is where the bépo layout bites back: the number row gives me punctuation, not digits, so the binds below go by key name instead. As a reminder, here’s what each one actually is and which digit sits on it under Shift:

Character name Character Number
quotedbl " 1
guillemotleft « 2
guillemotright » 3
parenleft ( 4
parenright ) 5
at @ 6
plus + 7
minus - 8
slash / 9
asterisk * 0
bind = SUPER, quotedbl, workspace, 1
bind = SUPER, guillemotleft, workspace, 2
bind = SUPER, guillemotright, workspace, 3
bind = SUPER, parenleft, workspace, 4
bind = SUPER, parenright, workspace, 5
bind = SUPER, at, workspace, 6
bind = SUPER, plus, workspace, 7
bind = SUPER, minus, workspace, 8
bind = SUPER, slash, workspace, 9
bind = SUPER, asterisk, workspace, 10
bind = SUPER, mouse_down, workspace, e+1
bind = SUPER, mouse_up, workspace, e-1
bind = SUPER_SHIFT, quotedbl, movetoworkspace, 1
bind = SUPER_SHIFT, guillemotleft, movetoworkspace, 2
bind = SUPER_SHIFT, guillemotright, movetoworkspace, 3
bind = SUPER_SHIFT, parenleft, movetoworkspace, 4
bind = SUPER_SHIFT, parenright, movetoworkspace, 5
bind = SUPER_SHIFT, at, movetoworkspace, 6
bind = SUPER_SHIFT, plus, movetoworkspace, 7
bind = SUPER_SHIFT, minus, movetoworkspace, 8
bind = SUPER_SHIFT, slash, movetoworkspace, 9
bind = SUPER_SHIFT, asterisk, movetoworkspace, 10