nixos/settings/hyprland.nix
rope 76bef27ac7 anyrun: restart daemon after standalone power menu use
The power menu runs anyrun in standalone stdin mode, which kills the
daemon. Explicitly quit the daemon before, then restart it after the
standalone invocation so the launcher stays fast.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-18 12:30:28 +01:00

613 lines
23 KiB
Nix

# settings/hyprland.nix
{ config, pkgs, lib, inputs, ... }:
let
hyprland-pkgs = inputs.hyprland.packages.${pkgs.stdenv.hostPlatform.system};
isMacbook = config.networking.hostName == "FredOS-Macbook";
isGaming = !isMacbook;
# hyprutils-0.13.1 requires GLIBCXX_3.4.34 (GCC 15) but Hyprland's RPATH
# was patched to gcc-14.3.0-lib which only provides up to GLIBCXX_3.4.33.
# Use our nixpkgs GCC 15 to supply the missing symbol via LD_PRELOAD.
gcc15-stdlib = pkgs.gcc15.cc.lib;
hyprland-wrapped = pkgs.symlinkJoin {
name = hyprland-pkgs.hyprland.name;
version = hyprland-pkgs.hyprland.version;
paths = [ hyprland-pkgs.hyprland ];
nativeBuildInputs = [ pkgs.makeWrapper ];
# passthru inside symlinkJoin flows through runCommand → mkDerivation,
# which correctly sets drv.passthru. The display manager requires
# providedSessions; HM's hyprland module requires override.
passthru = {
providedSessions = [ "hyprland" ];
override = _: hyprland-wrapped;
};
postBuild = ''
wrapProgram $out/bin/start-hyprland \
--set LD_PRELOAD "${gcc15-stdlib}/lib/libstdc++.so.6"
wrapProgram $out/bin/Hyprland \
--set LD_PRELOAD "${gcc15-stdlib}/lib/libstdc++.so.6"
# The session .desktop symlinks to the original package and contains
# its absolute store path. Rewrite Exec= to our wrapped binary so
# GDM actually launches the LD_PRELOAD wrapper, not the bare binary.
desktop=$out/share/wayland-sessions/hyprland.desktop
orig=$(readlink -f "$desktop")
rm "$desktop"
sed "s|^Exec=.*|Exec=$out/bin/start-hyprland|" "$orig" > "$desktop"
'';
};
in
{
config = lib.mkIf (lib.elem config.networking.hostName [ "FredOS-Gaming" "FredOS-Macbook" ]) {
programs.hyprland = {
enable = true;
xwayland.enable = true;
package = hyprland-wrapped;
portalPackage = hyprland-pkgs.xdg-desktop-portal-hyprland;
};
xdg.portal = {
enable = true;
# xdg-desktop-portal-hyprland is registered automatically by
# programs.hyprland.portalPackage; listing it here too produced a
# duplicate user-unit symlink during nixos-rebuild.
extraPortals = with pkgs; [
xdg-desktop-portal-gtk
];
config.hyprland.default = [ "hyprland" "gtk" ];
};
security.polkit.enable = true;
# Polkit GUI agent for GUI sudo prompts under Hyprland
systemd.user.services.polkit-gnome-authentication-agent-1 = {
description = "polkit-gnome-authentication-agent-1";
wantedBy = [ "graphical-session.target" ];
partOf = [ "graphical-session.target" ];
after = [ "graphical-session.target" ];
serviceConfig = {
Type = "simple";
ExecStart = "${pkgs.polkit_gnome}/libexec/polkit-gnome-authentication-agent-1";
Restart = "on-failure";
};
};
environment.systemPackages = with pkgs; [
ghostty
mako
grim
slurp
wl-clipboard
cliphist
brightnessctl
pamixer
swayosd
playerctl
hyprpaper
hyprlock
hypridle
hyprshot
networkmanagerapplet
pavucontrol
polkit_gnome
];
home-manager.users.fred = { config, lib, pkgs, inputs, ... }:
let
c = config.lib.stylix.colors;
rgb = hex: "rgb(${hex})";
rgba = hex: a: "rgba(${hex}${a})";
in {
# Stylix's Hyprland target injects settings.{general,decoration,group,misc}
# as top-level keys, which render as hl.general()/hl.decoration()/… in Lua
# mode — functions that don't exist. Disable it and absorb the colours
# into settings.config below.
stylix.targets.hyprland.enable = false;
# The disabled Hyprland target would normally enable this; do it
# manually. Stylix's hyprpaper target (auto-enabled) still handles
# preload/wallpaper settings.
services.hyprpaper.enable = true;
wayland.windowManager.hyprland = {
enable = true;
configType = "lua";
systemd.variables = [ "--all" ];
package = hyprland-wrapped;
settings = {
# hl.config({...}) — all static named-section configuration.
# monitor is set per-host in hosts/FredOS-{Gaming,Macbook}.nix.
config = {
general = {
gaps_in = 6;
gaps_out = 12;
border_size = 2;
layout = "dwindle";
resize_on_border = true;
"col.active_border" = rgb c.base0D;
"col.inactive_border" = rgb c.base03;
};
decoration = {
rounding = 8;
blur = {
enabled = true;
};
shadow.color = rgba c.base00 "99";
};
group = {
"col.border_active" = rgb c.base0D;
"col.border_inactive" = rgb c.base03;
"col.border_locked_active" = rgb c.base0C;
groupbar = {
text_color = rgb c.base05;
"col.active" = rgb c.base0D;
"col.inactive" = rgb c.base03;
};
};
render = {
direct_scanout = false;
};
animations = {
enabled = true;
};
input = {
kb_layout = "gb,no";
kb_options = "grp:alt_shift_toggle";
follow_mouse = 1;
accel_profile = "flat";
sensitivity = 0;
} // lib.optionalAttrs isMacbook {
touchpad = {
tap_to_click = true;
tap_button_map = "lrm";
natural_scroll = true;
};
};
cursor = {
no_warps = true;
};
dwindle = {
preserve_split = true;
};
misc = {
disable_hyprland_logo = true;
disable_splash_rendering = true;
# Apps demanding attention don't get to yank focus — they'll
# show as urgent in the bar instead.
focus_on_activate = false;
vrr = 2;
background_color = rgb c.base00;
};
# vfr moved from misc: to debug: in 0.55.0
debug = {
vfr = false; # keep compositor ticking, don't idle between frames
disable_logs = false;
};
};
};
extraConfig =
let
powerMenu = pkgs.writeShellScript "power-menu" ''
# Kill the daemon before using anyrun in standalone stdin mode;
# a concurrent standalone instance conflicts with the daemon.
${pkgs.anyrun}/bin/anyrun quit 2>/dev/null || true
choice=$(printf '%s\n' \
$'\uf023 Lock' \
$'\uf08b Logout' \
$'\uf01e Reboot' \
$'\uf011 Shutdown' \
| ${pkgs.anyrun}/bin/anyrun \
--plugins "${pkgs.anyrun}/lib/libstdin.so" \
--show-results-immediately true \
--hide-plugin-info true \
--close-on-click true)
# Restart the daemon for instant launcher opens.
${pkgs.anyrun}/bin/anyrun daemon &
case "$choice" in
*Lock) ${pkgs.hyprlock}/bin/hyprlock ;;
*Logout) hyprctl dispatch exit ;;
*Reboot) systemctl reboot ;;
*Shutdown) systemctl poweroff ;;
esac
'';
kbdBrightUp = pkgs.writeShellScript "kbd-bright-up" ''
${pkgs.brightnessctl}/bin/brightnessctl -d smc::kbd_backlight set +10%
brightness=$(${pkgs.brightnessctl}/bin/brightnessctl -d smc::kbd_backlight get)
max=$(${pkgs.brightnessctl}/bin/brightnessctl -d smc::kbd_backlight max)
echo $(( brightness * 100 / max )) > "$XDG_RUNTIME_DIR/wob.fifo"
'';
kbdBrightDown = pkgs.writeShellScript "kbd-bright-down" ''
${pkgs.brightnessctl}/bin/brightnessctl -d smc::kbd_backlight set 10%-
brightness=$(${pkgs.brightnessctl}/bin/brightnessctl -d smc::kbd_backlight get)
max=$(${pkgs.brightnessctl}/bin/brightnessctl -d smc::kbd_backlight max)
echo $(( brightness * 100 / max )) > "$XDG_RUNTIME_DIR/wob.fifo"
'';
in
''
-- Environment
hl.env("LD_PRELOAD", "")
hl.env("XCURSOR_THEME", "Bibata-Modern-Ice")
hl.env("XCURSOR_SIZE", "24")
hl.env("HYPRCURSOR_THEME", "Bibata-Modern-Ice")
hl.env("HYPRCURSOR_SIZE", "24")
hl.env("ELECTRON_OZONE_PLATFORM_HINT", "wayland")
hl.env("MOZ_ENABLE_WAYLAND", "1")
hl.env("QT_QPA_PLATFORM", "wayland;xcb")
hl.env("SDL_VIDEODRIVER", "wayland")
hl.env("_JAVA_AWT_WM_NONREPARENTING", "1")
${lib.optionalString isGaming ''
-- GPU pinning Navi 22 is card1 on the dual-GPU gaming box.
hl.env("AQ_DRM_DEVICES", "/dev/dri/card1")
hl.env("WLR_DRM_DEVICES", "/dev/dri/card1")
hl.env("DRI_PRIME", "pci-0000_03_00_0")
''}
-- Startup
hl.on("hyprland.start", function()
-- Ensure hyprland-session.target starts even if HM's
-- dbus-update-activation-environment chain fails upstream.
hl.exec_cmd("systemctl --user start hyprland-session.target")
hl.exec_cmd("mako")
hl.exec_cmd("nm-applet --indicator")
hl.exec_cmd("wl-paste --type text --watch cliphist store")
hl.exec_cmd("wl-paste --type image --watch cliphist store")
hl.exec_cmd("hyprctl setcursor Bibata-Modern-Ice 24")
hl.exec_cmd("swayosd-server")
hl.exec_cmd("anyrun daemon")
${lib.optionalString isMacbook ''hl.exec_cmd("hypridle")''}
end)
-- Animation curve and definitions
hl.curve("snap", { type = "bezier", points = { {0.05, 0.9}, {0.1, 1.0} } })
hl.animation({ leaf = "windows", enabled = true, speed = 1, bezier = "snap" })
hl.animation({ leaf = "windowsOut", enabled = true, speed = 1, bezier = "snap", style = "popin 80%" })
hl.animation({ leaf = "layers", enabled = true, speed = 1, bezier = "snap" })
hl.animation({ leaf = "border", enabled = true, speed = 2, bezier = "default" })
hl.animation({ leaf = "fade", enabled = true, speed = 1, bezier = "default" })
hl.animation({ leaf = "workspaces", enabled = true, speed = 1, bezier = "snap" })
-- Window rules
-- Battle.net tray icon leaks as a tiny floating XWayland window.
hl.window_rule({
match = { class = "steam_app_0", title = "^$", float = true },
workspace = "special silent",
})
-- Binds
local mod = "SUPER"
-- Apps
hl.bind(mod .. " + T", hl.dsp.exec_cmd("ghostty"))
hl.bind(mod .. " + E", hl.dsp.exec_cmd("nemo"))
hl.bind(mod .. " + R", hl.dsp.exec_cmd("anyrun"))
hl.bind(mod .. " + Q", hl.dsp.window.close())
hl.bind(mod .. " + SHIFT + E", hl.dsp.exit())
-- Floating / layout
hl.bind(mod .. " + V", hl.dsp.window.float({ action = "toggle" }))
hl.bind(mod .. " + F", hl.dsp.window.fullscreen())
hl.bind(mod .. " + P", hl.dsp.window.pseudo())
hl.bind(mod .. " + S", hl.dsp.layout("togglesplit"))
-- Focus
hl.bind(mod .. " + left", hl.dsp.focus({ direction = "left" }))
hl.bind(mod .. " + right", hl.dsp.focus({ direction = "right" }))
hl.bind(mod .. " + up", hl.dsp.focus({ direction = "up" }))
hl.bind(mod .. " + down", hl.dsp.focus({ direction = "down" }))
hl.bind(mod .. " + H", hl.dsp.focus({ direction = "left" }))
hl.bind(mod .. " + K", hl.dsp.focus({ direction = "up" }))
hl.bind(mod .. " + J", hl.dsp.focus({ direction = "down" }))
-- Power menu dismiss anyrun if open, then show menu
hl.bind(mod .. " + L", hl.dsp.exec_cmd("anyrun close; ${powerMenu}"))
-- Move windows
hl.bind(mod .. " + SHIFT + left", hl.dsp.window.move({ direction = "left" }))
hl.bind(mod .. " + SHIFT + right", hl.dsp.window.move({ direction = "right" }))
hl.bind(mod .. " + SHIFT + up", hl.dsp.window.move({ direction = "up" }))
hl.bind(mod .. " + SHIFT + down", hl.dsp.window.move({ direction = "down" }))
-- Workspaces
hl.bind(mod .. " + 1", hl.dsp.focus({ workspace = 1 }))
hl.bind(mod .. " + 2", hl.dsp.focus({ workspace = 2 }))
hl.bind(mod .. " + 3", hl.dsp.focus({ workspace = 3 }))
hl.bind(mod .. " + 4", hl.dsp.focus({ workspace = 4 }))
hl.bind(mod .. " + 5", hl.dsp.focus({ workspace = 5 }))
hl.bind(mod .. " + 6", hl.dsp.focus({ workspace = 6 }))
hl.bind(mod .. " + 7", hl.dsp.focus({ workspace = 7 }))
hl.bind(mod .. " + 8", hl.dsp.focus({ workspace = 8 }))
hl.bind(mod .. " + 9", hl.dsp.focus({ workspace = 9 }))
hl.bind(mod .. " + 0", hl.dsp.focus({ workspace = 10 }))
hl.bind(mod .. " + SHIFT + 1", hl.dsp.window.move({ workspace = 1, follow = false }))
hl.bind(mod .. " + SHIFT + 2", hl.dsp.window.move({ workspace = 2, follow = false }))
hl.bind(mod .. " + SHIFT + 3", hl.dsp.window.move({ workspace = 3, follow = false }))
hl.bind(mod .. " + SHIFT + 4", hl.dsp.window.move({ workspace = 4, follow = false }))
hl.bind(mod .. " + SHIFT + 5", hl.dsp.window.move({ workspace = 5, follow = false }))
hl.bind(mod .. " + SHIFT + 6", hl.dsp.window.move({ workspace = 6, follow = false }))
hl.bind(mod .. " + SHIFT + 7", hl.dsp.window.move({ workspace = 7, follow = false }))
hl.bind(mod .. " + SHIFT + 8", hl.dsp.window.move({ workspace = 8, follow = false }))
hl.bind(mod .. " + SHIFT + 9", hl.dsp.window.move({ workspace = 9, follow = false }))
hl.bind(mod .. " + SHIFT + 0", hl.dsp.window.move({ workspace = 10, follow = false }))
-- Screenshots Shift+Super+S matches GNOME binding
hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("hyprshot -m region --clipboard-only"))
hl.bind("Print", hl.dsp.exec_cmd("hyprshot -m output --clipboard-only"))
-- Settings shortcut Super+I matches GNOME binding
hl.bind(mod .. " + I", hl.dsp.exec_cmd("pavucontrol"))
-- Custom shortcuts
hl.bind(mod .. " + Z", hl.dsp.exec_cmd("zen-beta"))
-- Mouse window manipulation
hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true })
hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true })
-- Volume / brightness (repeating)
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("swayosd-client --output-volume raise"), { repeating = true })
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("swayosd-client --output-volume lower"), { repeating = true })
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("swayosd-client --output-volume mute-toggle"), { repeating = true })
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("swayosd-client --brightness raise"), { repeating = true })
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("swayosd-client --brightness lower"), { repeating = true })
${lib.optionalString isMacbook ''
hl.bind("XF86KbdBrightnessUp", hl.dsp.exec_cmd("${kbdBrightUp}"), { repeating = true })
hl.bind("XF86KbdBrightnessDown", hl.dsp.exec_cmd("${kbdBrightDown}"), { repeating = true })
''}
-- Media keys (locked work through lockscreen)
hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true })
hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true })
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true })
'';
};
programs.anyrun = {
enable = true;
config = {
plugins = [ "${pkgs.anyrun}/lib/libapplications.so" ];
x.fraction = 0.5;
y.fraction = 0.25;
width.absolute = 350;
height.absolute = 0;
margin = 16;
hideIcons = false;
ignoreExclusiveZones = false;
layer = "overlay";
hidePluginInfo = true;
closeOnClick = true;
maxEntries = 8;
};
extraCss =
let c = config.lib.stylix.colors; in
''
* { all: unset; font-family: "FiraMono Nerd Font", monospace; font-size: 13px; }
window { background: transparent; }
box.main {
background: #${c.base00};
border: 1px solid #${c.base03};
border-radius: 10px;
padding: 8px;
margin: 16px;
}
text {
background: #${c.base01};
color: #${c.base05};
caret-color: #${c.base0D};
padding: 8px 16px;
border-radius: 6px;
min-height: 0;
}
list.plugin { background: transparent; }
.matches { background: transparent; }
.match {
padding: 4px 16px;
border-radius: 6px;
color: #${c.base05};
background: transparent;
}
.match:selected {
background: #${c.base02};
border: none;
}
label.match.description { color: #${c.base04}; font-size: 11px; }
'';
extraConfigFiles."applications.ron".text = ''
Config(
desktop_actions: false,
max_entries: 8,
terminal: "ghostty",
)
'';
};
programs.hyprlock.enable = true;
services.hypridle = lib.mkIf isMacbook {
enable = true;
settings = {
general = {
lock_cmd = "pidof hyprlock || hyprlock";
before_sleep_cmd = "loginctl lock-session";
after_sleep_cmd = "hyprctl dispatch dpms on";
};
listener = [
{
timeout = 300; # 5 min — lock
on-timeout = "loginctl lock-session";
}
{
timeout = 420; # 7 min — display off
on-timeout = "hyprctl dispatch dpms off";
on-resume = "hyprctl dispatch dpms on";
}
{
timeout = 600; # 10 min — suspend
on-timeout = "systemctl suspend";
}
];
};
};
# Scope all HM Wayland services (hyprpaper, waybar, …) to the
# Hyprland session so they don't crash-loop in a GNOME session.
wayland.systemd.target = "hyprland-session.target";
programs.waybar = {
enable = true;
systemd.enable = true;
settings.mainBar = {
layer = "top";
position = "top";
height = 30;
spacing = 6;
modules-left = [ "hyprland/workspaces" ];
modules-center = [ "clock" ];
modules-right = lib.optionals isMacbook [ "battery" ] ++ [ "group/tray-drawer" ];
"hyprland/workspaces" = {
format = "{name}";
on-click = "activate";
sort-by-number = true;
};
clock = {
format = "{:%H:%M}";
tooltip-format = "<big>{:%A, %d %B %Y}</big>\n<tt><small>{calendar}</small></tt>";
};
"group/tray-drawer" = {
orientation = "horizontal";
drawer = {
transition-duration = 500;
transition-left-to-right = false;
};
modules = [ "custom/tray-handle" "pulseaudio" "tray" ];
};
"custom/tray-handle" = {
format = builtins.fromJSON ''"\ue0b2"''; # U+E0B2 Nerd Font powerline filled left-arrow
tooltip = false;
};
# Pulseaudio module, now conditionally visible
pulseaudio = {
format = "{icon} {volume}%";
format-muted = " muted";
format-icons = {
default = [ "" "" "" ];
headphone = "";
headset = "";
};
on-click = "pavucontrol";
scroll-step = 5;
};
# Tray module, now conditionally visible
tray = {
icon-size = 16;
spacing = 8;
};
} // lib.optionalAttrs isMacbook {
battery = {
format = "{capacity}% {icon}";
format-charging = "{capacity}% ";
format-icons = [ "" "" "" "" "" ];
states = { warning = 30; critical = 15; };
};
};
style = ''
* {
font-family: "FiraMono Nerd Font", monospace;
font-size: 13px;
min-height: 0;
border: none;
border-radius: 0;
}
window#waybar {
background: alpha(@base00, 0.82);
color: @base05;
}
#workspaces {
margin-left: 6px;
}
#workspaces button {
padding: 0 8px;
color: @base03;
background: transparent;
}
#workspaces button.active {
color: @base05;
}
#workspaces button:hover {
background: alpha(@base05, 0.08);
color: @base05;
box-shadow: none;
text-shadow: none;
}
#clock {
color: @base05;
font-weight: 500;
}
#pulseaudio,
#tray {
padding: 0 10px;
color: @base05;
}
#pulseaudio.muted,
#network.disconnected {
color: @base03;
}
#tray {
margin-right: 6px;
}
#custom-tray-toggle {
padding: 0 0px;
color: @base05;
}
#battery {
padding: 0 10px;
color: @base05;
}
#battery.warning { color: @base0A; }
#battery.critical { color: @base08; }
'';
};
# The old hyprlang hyprland.conf (with `# autogenerated = 1`) is not
# managed by HM after switching to configType = "lua", so Hyprland still
# finds it and shows the "autogenerated config" banner. Remove it once on
# every activation so it can never linger.
home.activation.removeOldHyprlandConf = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
rm -f "${config.xdg.configHome}/hypr/hyprland.conf"
'';
};
};
}