Files
nixos-config/modules/home.nix
T
daniil-berg 5c4045f4a6 feat(hyprland): add runtime monitor left/right swap bind
`$mod CTRL o` reflows the two connected monitors adjacent in reversed
order (gap-free regardless of differing widths) and re-inits the
layer-shell clients, which Hyprland doesn't reliably re-notify on a
runtime output move: waybar rebuilds on SIGUSR2, wallpaper is re-pushed.
`monitor` default becomes `mkDefault` so a host can pin its own layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 10:38:16 +02:00

821 lines
34 KiB
Nix

# Generic user-level (home-manager) config. Reusable across users — nothing
# user-specific here. Identity (home.username/homeDirectory/stateVersion, git
# user.name/email), host topology (SSH hosts, Syncthing peers) and personal app
# choices (grayjay, Claude Code, …) live in the inventory, not in this module.
{ config, pkgs, lib, ... }:
let
terminal = "foot";
browser = "librewolf";
editor = "nvim";
# SSH_ASKPASS helper that drives the GPG pinentry-qt: ssh passphrase/PIN
# prompts reuse the same Qt dialog as commit signing, adding no extra GUI
# toolkit (a packaged askpass would drag in KDE/GTK — hundreds of MB).
# ssh signals the prompt kind via $SSH_ASKPASS_PROMPT (OpenSSH ≥ 8.4):
# empty real passphrase/PIN entry — collect input, echo to stdout
# confirm/none informational, e.g. "Confirm user presence" (touch request)
# A FIDO touch can't be faked by software, so those messages need no dialog:
# succeed silently instead of popping a spurious PIN box (the token blinks).
# pinentry runs under `systemd-run --user`, not directly, so it is reparented
# onto the user service manager instead of this askpass shell. ssh spawns the
# askpass from inside the foot terminal, and Hyprland's window swallowing walks
# a new window's parent-PID chain for a `foot` ancestor (swallow_regex) — a
# direct pinentry would match and hide the terminal behind the PIN dialog.
# Detaching breaks that chain (parent becomes systemd), exactly like the
# commit-signing pinentry that gpg-agent already spawns off-tree. --pipe wires
# stdin/stdout through so the Assuan exchange still works; --wait propagates
# the exit status; --collect reaps the transient unit.
sshAskpass = pkgs.writeShellScript "ssh-askpass-pinentry" ''
[ -n "''${SSH_ASKPASS_PROMPT:-}" ] && exit 0
desc=$(printf '%s' "''${1:-Passphrase:}" | sed 's/%/%25/g')
printf 'SETTITLE ssh\nSETPROMPT PIN:\nSETDESC %s\nGETPIN\nBYE\n' "$desc" \
| ${pkgs.systemd}/bin/systemd-run --user --pipe --wait --collect --quiet \
${pkgs.pinentry-qt}/bin/pinentry-qt \
| sed -n 's/^D //p'
'';
# Screenshot helper for the Print-key binds below. Two axes, chosen by args:
# scope ($1 = full | region, the latter an interactive slurp selection) and
# destination ($2 = file | clip). Files are timestamped PNGs under
# ~/Pictures/Screenshots; clip copies to the Wayland clipboard. A mako toast
# confirms each grab. slurp exits non-zero on Escape, which aborts here.
screenshot = pkgs.writeShellScript "screenshot" ''
set -eu
if [ "$1" = region ]; then
geom=$(${pkgs.slurp}/bin/slurp) || exit 1
grab() { ${pkgs.grim}/bin/grim -g "$geom" "$@"; }
else
grab() { ${pkgs.grim}/bin/grim "$@"; }
fi
if [ "$2" = clip ]; then
grab - | ${pkgs.wl-clipboard}/bin/wl-copy
${pkgs.libnotify}/bin/notify-send "Screenshot" "Copied to clipboard"
else
dir="${config.xdg.userDirs.pictures}/Screenshots"
mkdir -p "$dir"
file="$dir/$(date +%Y-%m-%d-%H%M%S).png"
grab "$file"
${pkgs.libnotify}/bin/notify-send "Screenshot" "Saved $file"
fi
'';
# Session/power menu: a fuzzel --dmenu picker (not app-list entries), bound to
# $mod+Backspace. --index returns the chosen row number, so dispatch matches on
# position, not label text. Icons use the Papirus names via fuzzel's dmenu
# icon protocol (NUL + 0x1f after each label). Escape/no pick → fuzzel exits
# non-zero, command substitution is empty, case falls through → no-op.
#
# Icon names deliberately picked from Papirus's apps/ (colored) set that have
# NO actions/ variant: system-{reboot,shutdown,log-out} exist in both, and at
# dmenu line-height fuzzel resolves the monochrome actions/ copy. gnome-logout
# / system-restart / gnome-session-halt are apps-only, so they stay colored.
powerMenu = pkgs.writeShellScript "power-menu" ''
case $(printf 'Log Out\0icon\x1fgnome-logout\nReboot\0icon\x1fsystem-restart\nShut Down\0icon\x1fgnome-session-halt\n' \
| ${pkgs.fuzzel}/bin/fuzzel --dmenu --index --prompt 'power ') in
0) ${config.wayland.windowManager.hyprland.finalPackage}/bin/hyprctl dispatch exit ;;
1) ${pkgs.systemd}/bin/systemctl reboot ;;
2) ${pkgs.systemd}/bin/systemctl poweroff ;;
esac
'';
# Swap the physical left/right placement of a two-monitor setup at runtime.
# Reflows the pair adjacent from x=0 (left one at origin, right one butted
# against its logical width) after reversing their order — so it stays
# gap-free regardless of differing widths, unlike a naive x-origin exchange
# (equal widths only). Machine-agnostic: reads whichever two connectors are
# live from `hyprctl -j`, no connector names. Ephemeral (hyprctl keyword) —
# the per-host default layout is restored on the next reload/relogin. No-op
# unless exactly two monitors are connected.
#
# Hyprland doesn't reliably re-notify layer-shell clients when an output is
# reconfigured at runtime (a known limitation — same class as the mirror/unplug
# cases where waybar needs a restart). Observed here: after the move, waybar and
# the wallpaper keep drawing at the pre-swap offsets, so the moved monitor's
# bar/bg land off-screen. Re-init both against the new geometry: waybar rebuilds
# its bars on SIGUSR2, and awww only paints outputs present when `awww img` last
# ran, so re-push the wallpaper symlink. Both are idempotent — harmless when a
# surface happened not to lag (which surface stales seems to race).
swapMonitors = pkgs.writeShellScript "hypr-swap-monitors" ''
set -eu
hc=${config.wayland.windowManager.hyprland.finalPackage}/bin/hyprctl
jq=${pkgs.jq}/bin/jq
data=$("$hc" monitors -j)
n=$(printf '%s' "$data" | "$jq" length)
if [ "$n" -ne 2 ]; then
${pkgs.libnotify}/bin/notify-send "Monitor swap" "need exactly 2 monitors ($n connected)"
exit 0
fi
# sort by current x [left, right]; the new left is the old right.
cmd=$(printf '%s' "$data" | "$jq" -r '
sort_by(.x)
| .[1] as $l | .[0] as $r
| (($l.width / $l.scale) | floor) as $off
| "keyword monitor \($l.name),preferred,0x0,\($l.scale)"
+ " ; keyword monitor \($r.name),preferred,\($off)x0,\($r.scale)"')
"$hc" --batch "$cmd"
# re-anchor the layer-shell clients onto the new geometry (see comment above)
${pkgs.procps}/bin/pkill -USR2 -x .waybar-wrapped || true
bg="$HOME/.local/share/bg"
[ -e "$bg" ] && ${pkgs.awww}/bin/awww img "$bg" || true
'';
in
{
imports = [ ./gpg.nix ./media.nix ./texlive.nix ./librewolf.nix ./mc.nix ./mime.nix ./hyprland-workspaces.nix ];
# Put ~/.local/bin (the XDG user-bin dir) on PATH so hand-dropped scripts run
# by name without wiring each through nix.
home.sessionPath = [ "${config.home.homeDirectory}/.local/bin" ];
home.sessionVariables = {
TERMINAL = terminal;
BROWSER = browser;
EDITOR = editor;
# Keep $HOME clean: route node/npm out of ~ into XDG dirs.
NODE_REPL_HISTORY = "${config.xdg.stateHome}/node_repl_history";
NPM_CONFIG_CACHE = "${config.xdg.cacheHome}/npm";
NPM_CONFIG_USERCONFIG = "${config.xdg.configHome}/npm/npmrc";
# Python REPL: UTF-8 I/O, custom startup file, XDG-routed history.
PYTHONIOENCODING = "UTF-8";
PYTHONSTARTUP = "${config.xdg.configHome}/python/startup";
PYTHON_HISTFILE = "${config.xdg.cacheHome}/python/history";
# Default interpreter uv picks for `uv run`/`uv venv` without an explicit
# -p. Pins throwaway REPLs and new projects to one version across machines
# (further steered by uv.toml below). Fetched on demand by uv, not Nix.
UV_PYTHON = "3.14";
# psql / wget / readline configs, XDG-routed (the rc files are declared
# via xdg.configFile below).
PSQLRC = "${config.xdg.configHome}/psql/rc";
WGETRC = "${config.xdg.configHome}/wget/wgetrc";
INPUTRC = "${config.xdg.configHome}/readline/inputrc";
# Rust/cargo: keep registry cache, git checkouts and `cargo install` bins
# out of ~/.cargo (the toolchain itself comes from the flake's rust devShell).
CARGO_HOME = "${config.xdg.dataHome}/cargo";
# Mute the harmless "Wayland does not support QWindow::requestActivate()"
# spam Qt apps (keepassxc) print; scoped to that one log category only.
QT_LOGGING_RULES = "qt.qpa.wayland=false";
# Run Chromium/Electron apps (brave, signal, grayjay's webview) natively on
# Wayland instead of XWayland: nixpkgs' chromium/electron wrappers read this
# and inject the Ozone Wayland flags. This is a Wayland-only (Hyprland)
# session, so XWayland is pure downside (blurry fractional scaling, extra
# hop). Override per-app with --ozone-platform=x11 if one misbehaves.
NIXOS_OZONE_WL = "1";
# GUI helper for ssh passphrase/PIN prompts (see sshAskpass above). Not
# forced: with a tty ssh still prompts inline; the helper is used only when a
# caller sets SSH_ASKPASS_REQUIRE (e.g. a headless/agent context).
SSH_ASKPASS = "${sshAskpass}";
};
# Sets XDG_{CONFIG,DATA,CACHE}_HOME and enables xdg.configFile management.
xdg.enable = true;
home.packages = with pkgs; [
# librewolf is installed by ./librewolf.nix (via local.librewolf.package) so
# the wrapped package stays overridable — do NOT also list it here.
keepassxc # config in ~/.config/keepassxc
papirus-icon-theme # fuzzel icon-theme; ships non-symbolic power icons
# (Adwaita only has them as symbolic, which fuzzel skips)
# claude-code is installed by programs.claude-code below (its wrapped
# finalPackage) — do NOT also list it here, or buildEnv conflicts on the
# wrapper binary.
nodejs
uv # Python: versions, venvs, deps, lockfile. Runs uv-managed
# standalone CPython via nix-ld (see modules/nixos.nix).
highlight # `ccat` alias: syntax-highlighted cat
jq # JSON on the command line (scripts, ad-hoc inspection)
pv # pipe progress meter (throughput, ETA on streamed data)
libreoffice
grim # screenshot capture (also used by the Print-key binds)
slurp # interactive region selector for grim
brightnessctl # backlight control for the XF86MonBrightness binds; writes
# via the active logind session, so no udev rule needed
];
# Canonical XDG user dirs. Only Pictures is actually used (screenshot target,
# file-picker default). createDirectories stays off so the rest aren't
# littered into $HOME — they're merely declared in ~/.config/user-dirs.dirs,
# created lazily by whatever app wants them.
xdg.userDirs = {
enable = true;
createDirectories = false;
pictures = "${config.home.homeDirectory}/Pictures";
};
# ---- Per-tool config files, routed into XDG dirs ---------------------
xdg.configFile = {
"python/startup".source = ./assets/python-startup.py;
# uv global config. "managed" = always use uv's own CPython, never a
# system/Nix python — keeps behaviour reproducible regardless of what else
# is on PATH. Default version comes from UV_PYTHON above.
"uv/uv.toml".text = ''
python-preference = "managed"
'';
# psql: unlimited, per-database history file (kept out of $HOME).
"psql/rc".text = ''
\set HISTFILE ${config.xdg.cacheHome}/psql/history-:DBNAME
\set HISTSIZE -1
'';
# wget: keep its HSTS db in the cache dir, not ~/.
"wget/wgetrc".text = ''
hsts-file=${config.xdg.cacheHome}/wget/hsts
'';
# readline: affects any libreadline program (python, psql, bash...).
# zsh ignores it — it uses ZLE (see programs.zsh below). Binds Up/Down to
# prefix history search with the cursor jumped to end of line.
"readline/inputrc".text = ''
$include /etc/inputrc
"\C-x\C-p": history-search-backward
"\C-x\C-n": history-search-forward
"\e[A": "\C-x\C-p\C-e"
"\e[B": "\C-x\C-n\C-e"
"\eOA": "\C-x\C-p\C-e"
"\eOB": "\C-x\C-n\C-e"
'';
};
programs.home-manager.enable = true;
# Document converter (md/html/docx/…). PDF output shells out to a LaTeX engine
# (texlive.nix): default it to xelatex, which is Unicode-native — pdflatex
# (pandoc's own default) fails on combining marks, e.g. U+0308 in decomposed ä.
programs.pandoc = {
enable = true;
defaults.pdf-engine = "xelatex";
};
programs.git = {
enable = true;
settings = {
pull.rebase = true;
init.defaultBranch = "master";
# Detect whitespace errors; speed up untracked-file scans.
apply.whitespace = "fix";
core.whitespace = "space-before-tab,-indent-with-non-tab,trailing-space";
core.untrackedCache = true;
branch.sort = "-committerdate";
color.ui = "auto";
diff.renames = "copies";
help.autocorrect = 1;
merge.log = true;
push = { default = "simple"; followTags = true; };
submodule.recurse = true;
alias = {
l = "log --pretty=oneline -n 20 --graph --abbrev-commit";
logs = "log --show-signature";
logo = "log --pretty=format:\"%C(auto)%h%x09%ad%x09%<(16,trunc)%an%x09%s%x09%d\"";
s = "status -s";
d = "!\"git diff-index --quiet HEAD -- || clear; git --no-pager diff --patch-with-stat\"";
p = "pull --recurse-submodules";
c = "clone --recursive";
ca = "!git add -A && git commit -av";
go = "!f() { git checkout -b \"$1\" 2> /dev/null || git checkout \"$1\"; }; f";
amend = "commit --amend --reuse-message=HEAD";
reb = "!r() { git rebase -i HEAD~$1; }; r";
aliases = "config --get-regexp alias";
contributors = "shortlog --summary --numbered";
whoami = "config user.email";
# Delete local branches already merged into the current branch.
dm = "!git branch --merged | grep -v '\\*' | xargs -n 1 git branch -d";
};
};
};
programs.foot = {
enable = true;
settings = {
main = {
font = "JetBrainsMono Nerd Font:size=11";
pad = "8x8";
};
colors-dark = {
alpha = 0.9;
};
};
};
# App launcher ($mod+d in Hyprland). Wayland-native, launches apps detached
# so their stderr goes to the journal, not whatever terminal you're in.
programs.fuzzel = {
enable = true;
settings = {
main.font = "JetBrainsMono Nerd Font:size=11";
# Papirus for colored power icons in the $mod+Backspace dmenu (Adwaita
# ships those only as symbolic, and fuzzel skips symbolic dirs). It
# inherits hicolor, so app icons still resolve (recolored to Papirus).
main.icon-theme = "Papirus";
# Fuzzel ignores the portal color-scheme, and its built-in palette is
# light — dark has to be set here. Matches waybar (#222/#bbb + One Dark
# accents); flat, thin border, no rounding per the minimal look.
border = { width = 1; radius = 0; };
colors = {
background = "222222f2";
text = "bbbbbbff";
match = "e5c07bff";
selection = "333333ff";
selection-text = "ffffffff";
selection-match = "e5c07bff";
border = "444444ff";
};
};
};
# Curate what fuzzel's app-list mode shows. Fuzzel just enumerates XDG
# `.desktop` files, so we shape that set here. Shadow a package's entry by
# redefining its id with noDisplay = true: our ~/.local/share/applications
# copy wins over the profile's. noDisplay only hides from launchers/menus —
# the app stays wired up for xdg-open and "open with", so opening a file
# *with* mpv/zathura is unaffected. The attrset merges with anything a private
# host adds. noDisplay is mkDefault so a host can un-hide one (e.g. re-expose
# mpv) with a plain override, no conflict. (Power/session actions are not
# entries here — see the $mod+Backspace dmenu power menu above.)
xdg.desktopEntries = let
hide = exec: {
inherit exec;
name = "hidden";
noDisplay = lib.mkDefault true;
};
in {
# foot's three entries: launched via $mod+Return, never from a menu.
foot = hide "foot";
foot-server = hide "foot --server";
footclient = hide "footclient";
# mpv/zathura are file handlers, not things to launch bare from a menu.
mpv = hide "mpv %U";
umpv = hide "umpv %U";
"org.pwmt.zathura" = hide "zathura %U";
"org.pwmt.zathura-djvu" = hide "zathura %U";
"org.pwmt.zathura-cb" = hide "zathura %U";
# nvim is $EDITOR / opened from a terminal; no place in a GUI launcher.
nvim = hide "nvim %F";
};
programs.fzf.enable = true; # provides the ^f "cd into fzf dir" binding
# direnv + nix-direnv: a project's `.envrc` with `use flake` auto-loads any
# devShell on cd-in (and caches the eval) — general per-directory environments,
# not tied to one language. First consumer is the shared Rust dev shell (see
# the `devShells` comment in flake.nix). Zsh integration is on by default.
programs.direnv = {
enable = true;
nix-direnv.enable = true;
};
programs.zsh = {
enable = true;
autosuggestion.enable = true;
syntaxHighlighting.enable = true;
enableCompletion = true;
# Keep the completion dump in the cache dir, not ~/.zcompdump.
completionInit = ''
_zcd="${config.xdg.cacheHome}/zsh"; [ -d "$_zcd" ] || mkdir -p "$_zcd"
autoload -U compinit && compinit -d "$_zcd/zcompdump"
'';
defaultKeymap = "viins"; # vi keybindings
history = {
path = "${config.xdg.cacheHome}/zsh/history";
size = 1000000;
save = 1000000;
ignoreAllDups = true; # drop older duplicate of a re-run command
ignoreSpace = true; # don't record space-prefixed commands
share = true;
};
shellAliases = {
vim = "nvim";
vimdiff = "nvim -d";
cp = "cp -iv";
mv = "mv -iv";
rm = "rm -vI";
mkd = "mkdir -pv";
ls = "ls -hN --color=auto --group-directories-first";
grep = "grep --color=auto";
diff = "diff --color=auto";
ccat = "highlight --out-format=ansi";
ka = "killall";
g = "git";
e = editor;
v = editor;
# Throwaway REPL/scripts as if `python` were global; resolves to UV_PYTHON.
python = "uv run python";
};
# Interactive bits with no first-class HM option: prompt, autocd,
# completion-menu styling, vi cursor shaping, and a few keybinds.
initContent = ''
# Colored prompt: [user@host ~]$
autoload -U colors && colors
PS1="%B%{$fg[red]%}[%{$fg[yellow]%}%n%{$fg[green]%}@%{$fg[blue]%}%M %{$fg[magenta]%}%~%{$fg[red]%}]%{$reset_color%}$%b "
setopt autocd interactive_comments
stty stop undef # free up ctrl-s
export KEYTIMEOUT=1
# Completion menu: arrow-select, include dotfiles, vi keys in the menu.
zstyle ':completion:*' menu select
zmodload zsh/complist
_comp_options+=(globdots)
bindkey -M menuselect 'h' vi-backward-char
bindkey -M menuselect 'k' vi-up-line-or-history
bindkey -M menuselect 'l' vi-forward-char
bindkey -M menuselect 'j' vi-down-line-or-history
bindkey -v '^?' backward-delete-char
bindkey '^[[P' delete-char
# Beam cursor in insert mode, block in normal mode.
function zle-keymap-select () {
case $KEYMAP in
vicmd) echo -ne '\e[1 q';;
viins|main) echo -ne '\e[5 q';;
esac
}
zle -N zle-keymap-select
zle-line-init() { zle -K viins; echo -ne "\e[5 q"; }
zle -N zle-line-init
echo -ne '\e[5 q'
preexec() { echo -ne '\e[5 q'; }
# Edit current line in $EDITOR with ctrl-e; quick bc and fzf-cd binds.
autoload edit-command-line; zle -N edit-command-line
bindkey '^e' edit-command-line
bindkey -s '^a' 'bc -lq\n'
bindkey -s '^f' 'cd "$(dirname "$(fzf)")"\n'
# Up/Down: search history by the text already typed before the cursor,
# then place the cursor at the end of the matched line.
autoload -U history-search-end
zle -N history-beginning-search-backward-end history-search-end
zle -N history-beginning-search-forward-end history-search-end
bindkey "^[[A" history-beginning-search-backward-end
bindkey "^[[B" history-beginning-search-forward-end
'';
};
home.pointerCursor = {
name = "Adwaita";
package = pkgs.adwaita-icon-theme;
size = 24;
gtk.enable = true;
};
gtk = {
enable = true;
theme = {
name = "Adwaita-dark";
package = pkgs.gnome-themes-extra;
};
# Keep the GTK2 rc out of $HOME (default ~/.gtkrc-2.0). Setting this also
# makes home-manager export GTK2_RC_FILES so GTK2 apps still find it.
gtk2.configLocation = "${config.xdg.configHome}/gtk-2.0/gtkrc";
# The theme *name* alone isn't enough: the qgtk3 Qt bridge and apps' dark
# detection (e.g. keepassxc's "auto" theme) read this flag, not the name.
# Set it so both GTK apps and the Qt-via-gtk3 apps resolve to dark.
gtk3.extraConfig.gtk-application-prefer-dark-theme = true;
gtk4.extraConfig.gtk-application-prefer-dark-theme = true;
};
# The canonical dark/light signal on Wayland: apps query the xdg-desktop-portal
# Settings interface for org.freedesktop.appearance/color-scheme over D-Bus, and
# the gtk portal serves that from this dconf key. keepassxc's "auto" theme reads
# it (NixUtils::isDarkMode) and ignores the GTK theme name / Qt style entirely,
# so without this the portal reports 0 ("no preference") and keepass stays light.
# prefer-dark makes the portal report dark to every color-scheme-aware app.
dconf.settings."org/gnome/desktop/interface".color-scheme = "prefer-dark";
# Match the Qt apps (keepassxc, pinentry-qt, ssh askpass) to the GTK
# Adwaita-dark above, else they render the default light Fusion style. Use the
# gtk3 platform theme, not an adwaita *style* override: nixpkgs wraps Qt apps
# with a baked QT_PLUGIN_PATH pointing only at their build deps, so an external
# style plugin (adwaita-qt) is unreachable and QT_STYLE_OVERRIDE is silently
# ignored. The gtk3 plugin (libqgtk3) ships inside qtbase itself, so it's
# always on that path, and it reads the GTK theme (Adwaita-dark) directly.
qt = {
enable = true;
platformTheme.name = "gtk3";
};
# The home-manager Hyprland module below enables xdg.portal and pins the
# portal search dir (NIX_XDG_DESKTOP_PORTAL_DIR) to this user profile, adding
# only the Hyprland backend — which implements Screenshot/ScreenCast/
# GlobalShortcuts but not FileChooser. Add the GTK portal here (system-level
# extraPortals aren't in the pinned dir, so they're never seen) and route
# FileChooser to it, else file dialogs in Chromium/Electron apps (signal,
# brave) fail with a DBus "No such interface
# org.freedesktop.portal.FileChooser" error.
xdg.portal = {
extraPortals = [ pkgs.xdg-desktop-portal-gtk ];
config.common = {
default = [ "hyprland" "gtk" ];
"org.freedesktop.impl.portal.FileChooser" = [ "gtk" ];
};
};
wayland.windowManager.hyprland = {
enable = true;
configType = "hyprlang";
settings = {
# Fallback for hosts that don't pin a layout; a two-monitor host overrides
# this with explicit positions (see the private inventory). mkDefault so
# that override is a plain value, not a mkForce fight.
monitor = lib.mkDefault ",preferred,auto,1";
# The qt module sets QT_QPA_PLATFORMTHEME=gtk3 (→ Qt apps read the GTK
# Adwaita-dark theme), but only into hm-session-vars.sh + the systemd user
# env. gpg-agent (a user service) inherits it, so commit-signing pinentry
# is dark — but Hyprland doesn't source that file, so its children (foot →
# shell → ssh askpass → pinentry) launch without it and came up light.
# Verified the single var flips pinentry-qt dark; export it here so every
# session child gets it too.
env = [ "QT_QPA_PLATFORMTHEME,gtk3" ];
general = {
layout = "master";
gaps_in = 5;
gaps_out = 10;
border_size = 2;
};
decoration = {
rounding = 0;
shadow.enabled = false;
blur.enabled = false;
};
animations = {
enabled = false;
};
# Suppress Hyprland's built-in default wallpaper. The wallpaper here is
# driven by a userspace daemon (awww, see modules/media.nix) that only
# starts — and pushes the chosen image — once the compositor is up. Until
# then Hyprland paints its bundled splash, so on login it briefly shows
# before the real wallpaper replaces it. Disabling it leaves the plain
# background colour in that gap instead of a flash of unrelated art.
misc = {
force_default_wallpaper = 0;
disable_hyprland_logo = true;
# Window swallowing: a GUI spawned from a foot shell (e.g. zathura/imv
# from mc) hides the terminal and takes its place, restoring it on close.
# Matches on the *parent* terminal's class. Needs standalone foot: in
# server/client mode all windows share one process, so PID ancestry can't
# tell them apart and swallowing breaks — that's why $mod+Return runs
# `foot`, not `footclient`. Swallow is decided by getSwallower() walking
# the new window's parent-PID chain (up to 25 hops) for a window whose
# class matches swallow_regex; swallow_exception_regex, if set, matches the
# *terminal's title*, not the new window's class (a common misreading).
#
# Do NOT confuse swallowing with an app opening fullscreen/maximized — they
# look identical (the launching terminal vanishes) but are different things:
# - real swallow removes the terminal from the layout and restores it only
# when the swallower closes; you cannot un-swallow a live window.
# - a fullscreen/maximized app merely covers the workspace; the launcher
# is still a tile underneath and $mod+f / $mod SHIFT f toggles it back.
# `hyprctl clients` is decisive: a real swallow shows the terminal
# hidden=true and the new window swallowing!=0x0. LibreOffice trips this —
# it restores its own maximized state on open (from mc or a shell), so a
# document appears to "swallow" mc when it is only fullscreen. And being
# single-instance (one soffice.bin owns every window via IPC handoff), when
# it genuinely does swallow, that is fixed by whatever launched its daemon,
# not per-open — so it can't do the tile-from-mc / swallow-from-shell split
# that single-process handlers like zathura/imv get for free.
enable_swallow = true;
swallow_regex = "^(foot)$";
};
input = {
kb_layout = "de";
follow_mouse = 1;
};
"$mod" = "SUPER";
# Drag to move ($mod+LMB) / resize ($mod+RMB) any window. Needed to reach
# oversized floating dialogs (e.g. GTK save/upload pickers) whose confirm
# button lands off-screen on short displays.
bindm = [
"$mod, mouse:272, movewindow"
"$mod, mouse:273, resizewindow"
];
bind = [
"$mod, Return, exec, foot"
"$mod, q, killactive"
"$mod SHIFT, q, exit"
# fullscreen arg: 0 = true fullscreen, 1 = maximize (keeps bar/gaps).
"$mod, f, fullscreen, 0"
"$mod SHIFT, f, fullscreen, 1"
"$mod, w, exec, ${browser}"
"$mod, d, exec, fuzzel"
"$mod, Backspace, exec, ${powerMenu}"
"$mod, j, layoutmsg, cyclenext"
"$mod SHIFT, j, layoutmsg, swapnext"
"$mod, k, layoutmsg, cycleprev"
"$mod SHIFT, k, layoutmsg, swapprev"
# swap focused window with the master (dwm-style "zoom").
"$mod, space, layoutmsg, swapwithmaster"
# master area width. Master layout uses `mfact`, not dwindle's `splitratio`.
"$mod, h, layoutmsg, mfact -0.05"
"$mod, l, layoutmsg, mfact +0.05"
# Move focus / the current workspace to the other monitor ("o" = other).
# With two monitors the relative +1 just toggles between them.
"$mod, o, focusmonitor, +1"
"$mod SHIFT, o, movecurrentworkspacetomonitor, +1"
# Swap the two monitors' left/right placement (see swapMonitors above).
"$mod CTRL, o, exec, ${swapMonitors}"
] ++ (
# Dynamic definitions for viewing workspaces 1 to 9 and moving windows between them.
# References physical key codes for robustness with the SHIFT key, e.g. `code:10` instead of `1`.
builtins.concatLists (
builtins.genList (i:
let target = i + 1; in [
"$mod, code:1${toString i}, workspace, ${toString target}"
"$mod SHIFT, code:1${toString i}, movetoworkspace, ${toString target}"
]
) 9
)
) ++ [
", XF86MonBrightnessUp, exec, brightnessctl set 15%+"
", XF86MonBrightnessDown, exec, brightnessctl set 15%-"
", XF86AudioMute, exec, wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"
", XF86AudioRaiseVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 3%+"
", XF86AudioLowerVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 3%-"
# Screenshots. $mod = pick a region (slurp), SHIFT = to clipboard;
# bare Print grabs the whole screen to a file (the common case).
", Print, exec, ${screenshot} full file"
"$mod, Print, exec, ${screenshot} region file"
"SHIFT, Print, exec, ${screenshot} full clip"
"$mod SHIFT, Print, exec, ${screenshot} region clip"
];
exec-once = [
"waybar"
# wallpaper (see modules/media.nix): start the awww daemon, then restore
# the persisted choice. awww keeps no state across restarts, so re-push
# the ~/.local/share/bg symlink if it exists.
"awww-daemon"
"sh -c '[ -e \"$HOME/.local/share/bg\" ] && awww img \"$HOME/.local/share/bg\"'"
# re-push the wallpaper onto monitors connected after login (media.nix)
"wallpaper-hotplug"
];
};
};
# Notification daemon. notify-send clients (e.g. imv's info/delete toasts in
# modules/media.nix, gpg-agent errors) need a running server on the bus.
# Minimal to match the desktop: Nerd Font, no rounding, square border.
# Secret Service provider (freedesktop org.freedesktop.secrets). Bare Wayland
# session has none otherwise; apps using libsecret/qtkeychain need it.
services.gnome-keyring.enable = true;
# ssh-agent user service; sets SSH_AUTH_SOCK so `ssh-add` works in any shell.
# gnome-keyring above no longer ships an ssh component (dropped upstream), and
# gpg's ssh support is off, so this is the only agent. Keys are added on
# demand — nothing here presumes a particular ssh config.
services.ssh-agent.enable = true;
# The agent, not the ssh client, collects the FIDO PIN when signing with a
# verify-required sk-ed25519 key it holds — so the *agent's* environment needs
# the askpass helper. The systemd user service starts with a minimal env that
# lacks it, so such signatures fail with the misleading "incorrect passphrase
# supplied to decrypt private key". REQUIRE=force drives the prompt with no
# DISPLAY (a daemon has none); sshAskpass exits 0 for the touch step, so only
# the PIN pops a dialog.
systemd.user.services.ssh-agent.Service.Environment = [
"SSH_ASKPASS=${sshAskpass}"
"SSH_ASKPASS_REQUIRE=force"
];
services.mako = {
enable = true;
settings = {
font = "JetBrainsMono Nerd Font 10";
border-radius = 0;
default-timeout = 5000;
};
};
programs.waybar = {
enable = true;
settings = {
mainBar = {
layer = "top";
position = "top";
height = 28;
modules-left = [ "hyprland/workspaces" ];
modules-center = [ "hyprland/window" ];
modules-right = [ "pulseaudio" "network" "battery" "clock" ];
"hyprland/workspaces" = {
format = "{id}";
};
"hyprland/window" = {
format = "{title}";
max-length = 100;
separate-outputs = true;
};
clock = {
format = "{:%H:%M %a %d %b}";
tooltip-format = "<tt>{calendar}</tt>";
calendar = {
mode = "month";
mode-mon-col = 3;
weeks-pos = "left";
format = {
months = "<span color='#e5c07b'><b>{}</b></span>";
weekdays = "<span color='#c678dd'><b>{}</b></span>";
days = "<span color='#bbbbbb'>{}</span>";
today = "<span color='#61afef'><b><u>{}</u></b></span>";
weeks = "<span color='#5c6370'>W{:%V}</span>";
};
};
actions = {
on-click-right = "mode";
};
};
pulseaudio = {
format = "{volume}% {icon}";
format-muted = "󰝟";
format-icons = {
default = [ "󰕿" "󰖀" "󰕾" ];
headphone = "󰋋";
};
};
network = {
format-wifi = "{essid} 󰖩";
format-ethernet = "eth 󰈀";
format-disconnected = "󰖪";
};
battery = {
states = {
warning = 30;
critical = 15;
};
format = "{capacity}% {icon}";
format-charging = "{capacity}% 󰂄";
format-plugged = "{capacity}% 󰚥";
format-icons = [ "󰁺" "󰁻" "󰁼" "󰁽" "󰁾" "󰁿" "󰂀" "󰂁" "󰂂" "󰁹" ];
};
};
};
style = ''
* {
font-family: "JetBrainsMono Nerd Font";
font-size: 13px;
}
window#waybar {
background: #222222;
color: #bbbbbb;
}
#pulseaudio, #network, #battery, #clock {
padding: 0 12px;
}
#window {
color: #ffffff;
}
tooltip {
background: #222222;
border: 1px solid #333333;
}
tooltip label {
font-size: 15px;
padding: 4px;
}
window#waybar.empty #window {
background: transparent;
}
#battery.warning {
color: #e5c07b;
}
#battery.critical {
color: #e06c75;
}
#battery.charging {
color: #98c379;
}
#workspaces button {
padding: 0 8px;
color: #bbbbbb;
}
#workspaces button.active {
color: #ffffff;
background: #333333;
box-shadow: inset 0 -2px #61afef;
}
#workspaces button.urgent {
color: #e06c75;
}
'';
};
}