niri.nix (11477B)
1 # modules/features/desktop/niri.nix — Niri (scrolling-column Wayland 2 # compositor) with Noctalia Shell, as a wrapped, portable package plus the 3 # NixOS module that installs it as a login session. 4 # 5 # nix run ~/NixDaemon#niri try it nested inside the running desktop (Alt is the modifier) 6 # daemon.desktop.niri.enable the switch (modules/features/desktop/options.nix) 7 # 8 # The settings below become niri's config.kdl at build time (the wrapper runs 9 # `niri validate` on it, so a bad bind fails the build, not the login) and the 10 # package carries every program its binds spawn. Keys mirror the Hyprland set 11 # (modules/home/hypr/defaults.lua) where a Niri or Noctalia counterpart exists. 12 # Rosé Pine Main: the focus ring is rose #ebbcba on overlay #26233a. 13 { self, inputs, ... }: 14 { 15 perSystem = 16 { pkgs, lib, self', ... }: 17 let 18 kitty = lib.getExe pkgs.kitty; 19 zen = lib.getExe' inputs.zen-browser.packages.${pkgs.stdenv.hostPlatform.system}.default "zen-beta"; # default browser (modules/home/zen.nix) 20 nautilus = lib.getExe pkgs.nautilus; 21 grim = lib.getExe pkgs.grim; 22 slurp = lib.getExe pkgs.slurp; 23 gradia = lib.getExe self'.packages.gradia; 24 wl-copy = "${pkgs.wl-clipboard}/bin/wl-copy"; 25 wpctl = "${pkgs.wireplumber}/bin/wpctl"; 26 brightnessctl = lib.getExe pkgs.brightnessctl; 27 playerctl = lib.getExe pkgs.playerctl; 28 29 # a bind that also works on the lock screen (volume, brightness, media) 30 locked = cmd: _: { 31 props.allow-when-locked = true; 32 content.spawn-sh = cmd; 33 }; 34 # a bind listed in the Mod+Shift+/ hotkey overlay under its own title 35 titled = title: action: _: { 36 props.hotkey-overlay-title = title; 37 content = action; 38 }; 39 workspaceBinds = lib.foldl' (acc: n: acc // { 40 "Mod+${toString n}".focus-workspace = n; 41 "Mod+Shift+${toString n}".move-column-to-workspace = n; 42 }) { } (lib.range 1 9); 43 44 # One niri, parameterised by the Noctalia it starts and the keyboard. 45 mkNiri = 46 { 47 noctaliaPkg, 48 xkb ? { layout = "us"; }, 49 }: 50 let 51 noctalia = lib.getExe noctaliaPkg; 52 ipc = target: "${noctalia} ipc call ${target}"; 53 in 54 inputs.wrapper-modules.wrappers.niri.wrap { 55 inherit pkgs; 56 57 settings = { 58 input = { 59 keyboard = { 60 inherit xkb; 61 }; 62 touchpad = { 63 tap = _: { }; 64 natural-scroll = _: { }; 65 }; 66 focus-follows-mouse = _: { }; 67 }; 68 69 layout = { 70 gaps = 8; 71 focus-ring = { 72 width = 2; 73 active-color = "#ebbcba"; 74 inactive-color = "#26233a"; 75 }; 76 border.off = _: { }; 77 preset-column-widths = [ 78 { proportion = 0.33333; } 79 { proportion = 0.5; } 80 { proportion = 0.66667; } 81 ]; 82 }; 83 84 prefer-no-csd = _: { }; 85 hotkey-overlay.skip-at-startup = _: { }; 86 xwayland-satellite.path = lib.getExe pkgs.xwayland-satellite; 87 88 # The same session variables the Hyprland side sets (hosts/laptop configuration.nix). 89 environment = { 90 ELECTRON_OZONE_PLATFORM_HINT = "auto"; 91 QT_QPA_PLATFORM = "wayland;xcb"; 92 }; 93 94 spawn-at-startup = [ noctalia ]; 95 96 binds = { 97 # apps 98 "Mod+Return".spawn = [ kitty ]; 99 "Mod+Shift+Return".spawn = [ zen ]; 100 "Mod+Shift+B".spawn = [ zen ]; 101 "Mod+Shift+Alt+B".spawn = [ zen "--private-window" ]; 102 "Mod+Shift+F".spawn = [ nautilus "--new-window" ]; 103 "Mod+Shift+O".spawn-sh = "obsidian"; # unfree, from the system profile (modules/features/workstation.nix) 104 "Mod+Shift+N".spawn-sh = "${kitty} -e \${EDITOR:-nano}"; 105 106 # Noctalia 107 "Mod+Space".spawn-sh = ipc "launcher toggle"; 108 "Mod+Alt+Space".spawn-sh = ipc "launcher toggle"; 109 "Mod+Escape".spawn-sh = ipc "sessionMenu toggle"; 110 "Mod+Ctrl+P".spawn-sh = ipc "sessionMenu toggle"; 111 "Mod+A".spawn-sh = ipc "controlCenter toggle"; 112 "Mod+Comma".spawn-sh = ipc "notifications clear"; 113 "Mod+Ctrl+V".spawn-sh = ipc "launcher clipboard"; 114 "Mod+Ctrl+Space".spawn-sh = ipc "wallpaper toggle"; 115 "Mod+Shift+Escape".spawn-sh = ipc "lockScreen lock"; 116 117 # windows and columns 118 "Mod+Q".close-window = _: { }; 119 "Mod+F".maximize-column = _: { }; 120 "Mod+G".fullscreen-window = _: { }; 121 "Mod+Shift+V".toggle-window-floating = _: { }; 122 "Mod+H".focus-column-left = _: { }; 123 "Mod+J".focus-window-down = _: { }; 124 "Mod+K".focus-window-up = _: { }; 125 "Mod+L".focus-column-right = _: { }; 126 "Mod+Left".focus-column-left = _: { }; 127 "Mod+Down".focus-window-down = _: { }; 128 "Mod+Up".focus-window-up = _: { }; 129 "Mod+Right".focus-column-right = _: { }; 130 "Mod+Shift+H".move-column-left = _: { }; 131 "Mod+Shift+J".move-window-down = _: { }; 132 "Mod+Shift+K".move-window-up = _: { }; 133 "Mod+Shift+L".move-column-right = _: { }; 134 "Mod+Shift+Left".move-column-left = _: { }; 135 "Mod+Shift+Down".move-window-down = _: { }; 136 "Mod+Shift+Up".move-window-up = _: { }; 137 "Mod+Shift+Right".move-column-right = _: { }; 138 "Mod+Ctrl+H".set-column-width = "-5%"; 139 "Mod+Ctrl+L".set-column-width = "+5%"; 140 "Mod+Ctrl+J".set-window-height = "-5%"; 141 "Mod+Ctrl+K".set-window-height = "+5%"; 142 # stacking: pull the focused window into the column beside it 143 # (under the window there), or push it back out into its own column 144 "Mod+BracketLeft" = titled "Stack window under the column on the left" { consume-or-expel-window-left = _: { }; }; 145 "Mod+BracketRight" = titled "Stack window under the column on the right" { consume-or-expel-window-right = _: { }; }; 146 "Mod+Period" = titled "Pop bottom window out of the column" { expel-window-from-column = _: { }; }; 147 "Mod+W" = titled "Toggle column as tabs" { toggle-column-tabbed-display = _: { }; }; 148 # evening out: preset widths/heights, back to an equal split 149 "Mod+R" = titled "Cycle column width ⅓ ½ ⅔" { switch-preset-column-width = _: { }; }; 150 "Mod+Shift+R" = titled "Cycle window height ⅓ ½ ⅔" { switch-preset-window-height = _: { }; }; 151 "Mod+Ctrl+R" = titled "Reset window height (even split)" { reset-window-height = _: { }; }; 152 "Mod+Equal" = titled "Column width ½ (even columns)" { set-column-width = "50%"; }; 153 "Mod+Ctrl+F" = titled "Expand column into free space" { expand-column-to-available-width = _: { }; }; 154 "Mod+C" = titled "Centre column" { center-column = _: { }; }; 155 "Mod+WheelScrollDown" = _: { 156 props.cooldown-ms = 150; 157 content.focus-workspace-down = _: { }; 158 }; 159 "Mod+WheelScrollUp" = _: { 160 props.cooldown-ms = 150; 161 content.focus-workspace-up = _: { }; 162 }; 163 "Mod+O".toggle-overview = _: { }; 164 "Mod+Shift+Slash".show-hotkey-overlay = _: { }; 165 166 # screenshots: Print and Shift+Print open the shot in Gradia to 167 # annotate (arrows, text, blur, background) then copy or save; 168 # Ctrl+Print is the quick region-to-clipboard grab 169 "Print" = titled "Screenshot region → editor" { 170 spawn-sh = "region=$(${slurp}) && ${grim} -g \"$region\" - | ${gradia}"; 171 }; 172 "Shift+Print" = titled "Screenshot screen → editor" { spawn-sh = "${grim} - | ${gradia}"; }; 173 "Ctrl+Print" = titled "Screenshot region → clipboard" { 174 spawn-sh = "region=$(${slurp}) && ${grim} -g \"$region\" - | ${wl-copy}"; 175 }; 176 177 # media and hardware keys, also on the lock screen 178 "XF86AudioRaiseVolume" = locked "${wpctl} set-volume -l 1.4 @DEFAULT_AUDIO_SINK@ 5%+"; 179 "XF86AudioLowerVolume" = locked "${wpctl} set-volume -l 1.4 @DEFAULT_AUDIO_SINK@ 5%-"; 180 "XF86AudioMute" = locked "${wpctl} set-mute @DEFAULT_AUDIO_SINK@ toggle"; 181 "XF86AudioMicMute" = locked "${wpctl} set-mute @DEFAULT_AUDIO_SOURCE@ toggle"; 182 "XF86MonBrightnessUp" = locked "${brightnessctl} set 5%+"; 183 "XF86MonBrightnessDown" = locked "${brightnessctl} set 5%-"; 184 "XF86AudioPlay" = locked "${playerctl} play-pause"; 185 "XF86AudioPause" = locked "${playerctl} play-pause"; 186 "XF86AudioNext" = locked "${playerctl} next"; 187 "XF86AudioPrev" = locked "${playerctl} previous"; 188 189 "Mod+Shift+E".quit = _: { }; # niri asks for confirmation 190 } // workspaceBinds; 191 }; 192 }; 193 in 194 { 195 # Gradia, the screenshot editor the Print binds open, with its Censor tool 196 # replaced by a secure blur (_gradia-secure-blur.patch). Upstream 197 # pixelates in fixed 8 px blocks with the size slider greyed out, which 198 # leaves large text readable. Patched, the slider sets the strength: the 199 # area is averaged into cells 4× the slider value (12–100 px, 56 by 200 # default), each cell gets random brightness noise so depixelation tools 201 # can't match the true averages, and it is scaled back up smoothly. 202 # Still not zero-leak: for secrets, a filled rectangle is the safe choice. 203 # Shared with home-manager (modules/home/session.nix) so the launcher 204 # entry runs the same build. 205 packages.gradia = pkgs.gradia.overrideAttrs (old: { 206 patches = (old.patches or [ ]) ++ [ ./_gradia-secure-blur.patch ]; 207 }); 208 209 # packages.niri is what anyone running the flake gets (US layout, the 210 # neutral Noctalia); packages.niri-daemon is the author's (his keyboard 211 # options, packages.noctalia-daemon). The generic host builds its own 212 # with its keyboard layout through legacyPackages.mkNiri. 213 packages.niri = mkNiri { noctaliaPkg = self'.packages.noctalia; }; 214 packages.niri-daemon = mkNiri { 215 noctaliaPkg = self'.packages.noctalia-daemon; 216 xkb = { 217 layout = "us"; 218 options = "compose:caps,shift:both_capslock_cancel"; # as services.xserver.xkb 219 }; 220 }; 221 legacyPackages.mkNiri = mkNiri; 222 }; 223 224 flake.nixosModules.desktop-niri = 225 { config, lib, pkgs, ... }: 226 { 227 config = lib.mkIf config.daemon.desktop.niri.enable { 228 # nixpkgs' module registers the session for tuigreet and adds the 229 # GNOME portal Niri documents; the package is the wrapped one above. 230 programs.niri = { 231 enable = true; 232 package = config.daemon.desktop.niri.package; 233 }; 234 # Noctalia's battery widget reads UPower (nixpkgs' module leaves it off). 235 services.upower.enable = true; 236 # nixpkgs' portal config for niri names the gtk implementations for 237 # Access, FileChooser and Notification; keep that portal installed even 238 # when the Hyprland side (which also adds it) is switched off. 239 xdg.portal.extraPortals = [ pkgs.xdg-desktop-portal-gtk ]; 240 }; 241 }; 242 }