# The Hyprland desktop: compositor, lock screen, idle and wallpaper daemons, the
# bar and notifications. Hyprland and hyprlock themselves come from the NixOS
# modules (modules/nixos/desktop.nix); these write their configs.
#
# The services (hypridle, hyprpaper, waybar, dunst) run with
# graphical-session.target, which uwsm starts; services.nix puts them in uwsm's
# session slice.
{
config,
lib,
pkgs,
...
}:
let
inherit (config.lib.dot) link;
lua = lib.generators.mkLuaInline;
toLua = lib.generators.toLua { };
configHome = config.xdg.configHome;
wallpaper = ../../config/hypr/wallpaper.jpg;
# --- Hyprland -----------------------------------------------------------------
mod = "SUPER";
bind = keys: action: {
_args = [
"${mod} + ${keys}"
(lua action)
];
};
exec = command: "hl.dsp.exec_cmd(${toLua command})";
screenshotKeys = "${configHome}/keys/scrnis";
# hl.dsp.dpms for hypridle, which runs it through hyprctl.
dpms = action: "hyprctl dispatch 'hl.dsp.dpms({ action = \"${action}\" })'";
# --- Waybar -------------------------------------------------------------------
toggleFlyout = "${pkgs.bash}/bin/bash ${configHome}/waybar/eww/scripts/toggle.sh";
trayIcons = ../../config/waybar/icons;
in
{
wayland.windowManager.hyprland = {
enable = true;
# From the NixOS module (programs.hyprland), which uwsm starts. uwsm also
# brings up graphical-session.target, so Home Manager's own session target
# isn't needed.
package = null;
portalPackage = null;
systemd.enable = false;
configType = "lua";
settings = {
monitor = [
{
output = "HDMI-A-2";
mode = "2560x1440@280";
position = "0x0";
scale = 1;
vrr = 0;
}
{
output = "DP-1";
mode = "2560x1440@144";
position = "2560x-900";
scale = 1;
transform = 1;
vrr = 0;
}
];
# Workspaces 1-2 on HDMI-A-2, 3-5 on DP-1.
workspace_rule = map (workspace: {
workspace = toString workspace;
monitor = if workspace <= 2 then "HDMI-A-2" else "DP-1";
persistent = true;
}) (lib.range 1 5);
window_rule = [
{
name = "windowrule-1";
match.title = "^RuneLite.*";
float = true;
}
{
name = "windowrule-2";
match = {
class = "net-runelite-client-RuneLite";
title = "win.*";
};
no_focus = true;
}
{
name = "windowrule-3";
match.class = ".*";
immediate = true;
}
# Battle.net (Proton via Faugus). The class is shared by every umu game
# without its own ID, so match on the title too.
{
name = "battlenet-float";
match = {
class = "^steam_app_default$";
title = "^Battle\\.net.*";
};
float = true;
}
# xembedsniproxy (pkgs/xembedsniproxy) puts Wine/Proton tray icons such
# as Battle.net into Waybar's tray. It keeps a small helper window mapped
# for the icons it hosts, which Hyprland would otherwise show as a
# floating window.
{
name = "xembedsniproxy";
match.class = "^xembedsniproxy$";
opacity = "0.0 override 0.0 override";
border_size = 0;
no_blur = true;
no_shadow = true;
no_anim = true;
no_focus = true;
no_initial_focus = true;
}
# Dolphin's transparency comes from Kvantum's translucent_windows (see
# config/kvantum/RiceDark.kvconfig), not a forced window opacity: Kvantum
# puts alpha in the background only, so text and icons stay opaque, as
# in kitty. decoration.blur blurs through it. If Dolphin looks opaque
# after applying the Kvantum theme, this rule can come back:
# { name = "windowrule-4"; match.class = "^(org.kde.dolphin)$"; opacity = "0.90 0.80"; }
# Claude's desktop app creates its window opaque (transparent: false in
# the Electron bundle) and has no user stylesheet, so unlike kitty and
# Dolphin it can't put alpha in the background only. A forced window
# opacity is the closest it gets: decoration.blur frosts what shows
# through, at the cost of the text being translucent too.
{
name = "claude-frosted";
match.class = "^com\\.anthropic\\.Claude$";
opacity = "0.80 0.80";
}
];
# Blur behind the translucent layers: Waybar, fuzzel ("launcher"), dunst
# and the control centre.
layer_rule = [
{
name = "layerrule-1";
match.namespace = "waybar";
blur = true;
ignore_alpha = 0;
blur_popups = true;
}
{
name = "layerrule-2";
match.namespace = "launcher";
ignore_alpha = 0;
blur = true;
}
{
name = "layerrule-3";
match.namespace = "notifications";
ignore_alpha = 0;
blur = true;
}
{
name = "layerrule-4";
match.namespace = "eww-control-center";
ignore_alpha = 0;
blur = true;
}
];
bind = [
(bind "SHIFT + S" (exec "imsh-shot area -Cufa@${screenshotKeys} -s1"))
(bind "SHIFT + F" (exec "imsh-shot screen -Cua@${screenshotKeys}"))
(bind "SHIFT + R" (exec "imsh-cast -Cua@${screenshotKeys}"))
(bind "Return" (exec "${configHome}/fuzzel/launch"))
(bind "T" (exec (lib.getExe config.programs.kitty.package)))
(bind "W" (exec "firefox"))
(bind "Q" "hl.dsp.window.close()")
(bind "F" ''hl.dsp.window.float({ action = "toggle" })'')
(bind "G" ''hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" })'')
(bind "mouse_down" ''hl.dsp.focus({ workspace = "m+1" })'')
(bind "mouse_up" ''hl.dsp.focus({ workspace = "m-1" })'')
(bind "mouse:272" "hl.dsp.window.drag()")
(bind "mouse:273" "hl.dsp.window.resize()")
(bind "Delete" "hl.dsp.exit()")
];
env =
lib.mapAttrsToList
(name: value: {
_args = [
name
value
];
})
{
LIBVA_DRIVER_NAME = "nvidia";
# libva-nvidia-driver's backend, for Firefox's hardware video decoding.
NVD_BACKEND = "direct";
# Lets Firefox's video decoder process reach the NVIDIA driver.
MOZ_DISABLE_RDD_SANDBOX = "1";
XDG_SESSION_TYPE = "wayland";
GBM_BACKEND = "nvidia-drm";
__GLX_VENDOR_LIBRARY_NAME = "nvidia";
XCURSOR_THEME = config.home.pointerCursor.name;
XCURSOR_SIZE = toString config.home.pointerCursor.size;
QT_QPA_PLATFORMTHEME = config.qt.platformTheme.name;
};
config = {
general = {
border_size = 1;
gaps_in = 5;
gaps_out = 17;
layout = "dwindle";
col = {
active_border = "rgba(00000000)";
inactive_border = "rgba(00000000)";
};
allow_tearing = true;
};
decoration = {
rounding = 10;
# No visible borders; the focused window is the one that isn't dimmed.
dim_inactive = true;
dim_strength = 0.15;
blur = {
enabled = true;
size = 9;
passes = 3;
new_optimizations = true;
# Blur tooltips and context menus (Kvantum's popup_blurring is on for this).
popups = true;
};
shadow = {
enabled = false;
render_power = 3;
range = 4;
color = "rgba(1a1a1aee)";
};
};
input = {
kb_layout = "no";
follow_mouse = 1;
sensitivity = 0;
accel_profile = "flat";
};
misc = {
disable_hyprland_logo = true;
disable_splash_rendering = true;
# The wallpaper's average colour instead of black, so the moment
# between the login screen and hyprpaper drawing the wallpaper doesn't
# flash black.
background_color = "rgb(121d1a)";
};
ecosystem.no_donation_nag = true;
binds.scroll_event_delay = 10;
# Software cursor, for tearing. Hyprland (0.56) refuses to tear while a
# hardware cursor is visible, because the kernel can't update the cursor
# plane during a tearing flip. So with a hardware cursor a game only
# tears while it hides the pointer (e.g. mouselook) and is vsynced
# otherwise, and switching back and forth left WoW's cursor shape stale.
# With a software cursor the game tears all the time; the price is that
# frames are composited (one extra copy) while the pointer is visible.
cursor.no_hardware_cursors = true;
# Fullscreen games (e.g. WoW) scan out straight to the monitor instead
# of being copied by the compositor. With the software cursor above this
# only applies while the game hides the pointer. 1 = always; XWayland
# games don't set a content type, so 2 (auto) won't trigger.
render.direct_scanout = 1;
};
};
# What happens at login (the apps' placement rules), in Lua: linked to the
# checkout like the other hand-edited configs.
extraLuaFiles.startup = link "hypr/startup.lua";
};
# --- Lock screen, idle, wallpaper -----------------------------------------------
# Styled to match the greetd login screen (config/greetd/style.css): same
# bamboo wallpaper, translucent black boxes, #eee text, rounding 10.
programs.hyprlock = {
enable = true;
# From the NixOS module (programs.hyprlock), which also sets up its PAM.
package = null;
settings =
let
centered = {
monitor = "HDMI-A-2";
halign = "center";
valign = "center";
};
in
{
"$font" = "NotoSans Nerd Font";
"$text" = "rgb(238, 238, 238)";
"$box" = "rgba(0, 0, 0, 0.2)";
general = {
hide_cursor = false;
ignore_empty_input = true;
};
animations = {
enabled = true;
bezier = "ease, 0.25, 0.1, 0.25, 1";
animation = [
"fadeIn, 1, 4, ease"
"fadeOut, 1, 4, ease"
"inputFieldDots, 1, 2, ease"
];
};
# Light blur and dim so the text stays readable over the leaves, like
# the blurred panel behind the login box.
background = {
monitor = "";
path = "${wallpaper}";
blur_passes = 2;
blur_size = 5;
brightness = 0.75;
};
label = [
# Clock
(
centered
// {
text = ''$TIME'';
color = "$text";
font_family = "$font";
font_size = 110;
position = "0, 170";
}
)
# Date
(
centered
// {
text = ''cmd[update:60000] date +"%A %-d %B"'';
color = "rgba(238, 238, 238, 0.75)";
font_family = "$font";
font_size = 18;
position = "0, 70";
}
)
# User
(
centered
// {
text = "$USER";
color = "$text";
font_family = "$font";
font_size = 15;
position = "0, 0";
}
)
];
input-field = centered // {
size = "320, 50";
rounding = 10;
outline_thickness = 0;
inner_color = "$box";
outer_color = "rgba(0, 0, 0, 0)";
check_color = "rgba(170, 255, 255, 0.5)";
fail_color = "rgba(255, 107, 107, 0.6)";
capslock_color = "rgba(244, 214, 159, 0.5)";
font_color = "$text";
font_family = "$font";
fade_on_empty = false;
placeholder_text = ''Password'';
fail_text = ''$FAIL ($ATTEMPTS)'';
dots_size = 0.22;
dots_spacing = 0.4;
dots_center = true;
position = "0, -60";
};
};
};
services.hypridle = {
enable = true;
settings = {
general = {
lock_cmd = "pidof hyprlock || hyprlock";
before_sleep_cmd = "loginctl lock-session";
after_sleep_cmd = dpms "on";
};
listener = [
# Lock after 10 minutes idle (respects inhibitors, e.g. fullscreen video).
{
timeout = 600;
on-timeout = "loginctl lock-session";
}
{
timeout = 1;
on-resume = dpms "on";
on-timeout = "true";
ignore_inhibit = true;
}
{
timeout = 300;
on-timeout = dpms "off";
ignore_inhibit = true;
}
];
};
};
# A new picture from the folder every 30 seconds, on every monitor.
services.hyprpaper = {
enable = true;
settings = {
wallpaper = {
monitor = "";
path = "${config.xdg.userDirs.pictures}/Wallpapers";
timeout = 30;
};
splash = false;
};
};
# --- Waybar ---------------------------------------------------------------------
# The flyouts it opens (control centre, notifications) are Eww windows, in
# config/waybar/eww; its scripts are in config/waybar/scripts. Both stay linked
# to the checkout (files.nix).
programs.waybar = {
enable = true;
systemd.enable = true;
settings.main = {
layer = "top";
position = "top";
height = 36;
margin-left = 15;
margin-right = 15;
margin-top = 15;
modules-left = [
"hyprland/workspaces"
"hyprland/window"
];
modules-center = [
"custom/imsh-cast-monitor"
"clock"
];
modules-right = [
"tray"
"custom/notifications"
"custom/notifications-count"
"custom/control-center"
];
"hyprland/workspaces" = {
format = "{name}";
on-click = "activate";
sort-by = "number";
};
"hyprland/window".max-length = 60;
"custom/imsh-cast-monitor" = {
format = "{text}";
exec = "imsh-cast-monitor";
return-type = "json";
hide-empty-text = true;
};
clock = {
format = "{:%H:%M}";
format-alt = "{:%A, %B %d, %Y (%R)}";
tooltip-format = "{calendar}";
calendar = {
mode = "month";
iso8601 = true;
mode-mon-col = 3;
weeks-pos = "right";
on-scroll = 1;
format = {
months = "{}";
days = "{}";
weeks = "W{:%V}";
weekdays = "{}";
today = "{}";
};
};
actions = {
on-click-right = "mode";
on-scroll-up = "shift_up";
on-scroll-down = "shift_down";
};
};
# Own icons for the tray apps, by their StatusNotifierItem ID.
tray = {
icon-size = 16;
spacing = 10;
order = [ "discord_status_icon_1" ];
icons = {
Signal_status_icon_1 = "${trayIcons}/signal.svg";
Bitwarden_status_icon_1 = "${trayIcons}/bitwarden.svg";
WowUpCf_status_icon_1 = "${trayIcons}/wowup.svg";
Claude_status_icon_1 = "${trayIcons}/claude.svg";
steam_app_default = "${trayIcons}/battlenet.svg";
faugus-launcher = "${trayIcons}/faugus.svg";
steam = "${trayIcons}/steam.svg";
"tray-icon tray app opendeck" = "${trayIcons}/opendeck.svg";
};
};
"custom/notifications" = {
format = "";
tooltip = false;
on-click = "${toggleFlyout} notifications";
};
"custom/notifications-count" = {
exec = "${pkgs.python3-desktop}/bin/python3 ${configHome}/waybar/scripts/notification-count.py";
return-type = "json";
hide-empty-text = true;
tooltip = false;
on-click = "${toggleFlyout} notifications";
};
"custom/control-center" = {
format = " ";
tooltip = false;
on-click = toggleFlyout;
};
};
style = ''
* {
border: none;
border-radius: 17px;
font-family: NotoSans Nerd Font Regular;
font-size: 12px;
font-weight: bolder;
background: transparent;
color: white;
min-height: 0;
}
window#waybar {
background: rgba(0, 0, 0, 0.1);
}
window#waybar #tray menu {
background: rgba(0, 0, 0, 0.1);
}
#tray menu :hover {
background: rgba(0, 0, 0, 0.1);
}
#tray menu :hover label {
background: rgba(0, 0, 0, 0.1);
}
tooltip {
background: rgba(0, 0, 0, 0.1);
}
/* Workspace buttons are pills inset 4px from the bar's edge, so their 13px radius
is concentric with the bar's 17px one. Everything GTK's theme draws on a button
(border, shadows, hover gradient) is reset here, as it otherwise shows up as a
square box on hover. */
#workspaces button {
margin: 4px 0 4px 4px;
padding: 0 6px;
min-width: 16px;
border: none;
border-radius: 13px;
outline: none;
box-shadow: none;
text-shadow: none;
background-color: transparent;
/* Active-workspace underline: 2px line that grows from the centre (see .active). */
background-image: linear-gradient(white, white);
background-repeat: no-repeat;
background-position: 50% 92%;
background-size: 0 2px;
transition: background-size 200ms ease-out, background-color 150ms ease-out;
}
#workspaces button:hover {
background-color: rgba(255, 255, 255, 0.12);
}
#tray window decoration {
border-style: none;
box-shadow: none;
}
#workspaces button.active {
background-size: 10px 2px;
}
/* Workspaces with no windows are dimmed, unless it's the one you're on. The colour
goes on the label because the `*` rule above gives every label its own white. */
#workspaces button label {
transition: color 150ms ease-out;
}
#workspaces button.empty:not(.active) label {
color: rgba(255, 255, 255, 0.4);
}
#clock, #tray, #custom-notifications, #custom-control-center {
padding: 0 7px;
}
/* Notification count: a small white disc overlapping the bell's top right corner.
The margins shrink it from the bar's full height to 12px; the negative left margin
pulls it over the bell. Hidden by Waybar when there are no notifications. */
#custom-notifications-count {
min-width: 6px;
padding: 0 3px;
margin: 7px 3px 17px -11px;
border-radius: 6px;
background: white;
color: #18181c;
font-size: 8px;
}
.imsh-cast-monitor.recording {
color: #f00;
}
.imsh-cast-monitor.error {
color: #ff0;
}
/* Control-center button: your logo as a pre-rendered 18px image.
GTK stretches SVG backgrounds as pixels (which makes them blurry), so logo-18.png is
rendered at exactly 18px, and logo-36.png is used automatically on 2x-scaled monitors. */
#custom-control-center {
min-width: 18px;
min-height: 18px;
background-image: -gtk-scaled(url("file://${../../config/waybar/logo-18.png}"),
url("file://${../../config/waybar/logo-36.png}"));
background-repeat: no-repeat;
background-position: center;
background-size: 18px 18px;
margin-right: 6px; /* gap between the logo and the end of the bar */
}
'';
};
# --- Notifications --------------------------------------------------------------
services.dunst = {
enable = true;
settings =
let
# Near-transparent, over Hyprland's blur (the "notifications" layer rule).
urgency = timeout: {
inherit timeout;
background = "#25252501";
foreground = "#ffffff";
frame_color = "#25252501";
};
in
{
global = {
monitor = 0;
follow = "mouse";
alignment = "center";
width = 360;
offset = 15;
origin = "top-right";
padding = 12;
horizontal_padding = 12;
frame_width = 0;
markup = "full";
icon_position = "off";
font = "DejaVu Sans Mono 10";
stack_duplicates = true;
hide_duplicate_count = true;
separator_height = 0;
fullscreen = "pushback";
corner_radius = 15;
# Left click does what the notification is for (e.g. opens the chat),
# then closes it. Middle click only dismisses it, right click
# dismisses all.
mouse_left_click = "do_action, close_current";
mouse_middle_click = "close_current";
mouse_right_click = "close_all";
progress_bar = true;
};
urgency_low = urgency 4;
urgency_normal = urgency 8;
urgency_critical = urgency 16;
status = {
category = "status";
set_stack_tag = "status";
history_ignore = true;
timeout = 2;
};
};
};
}