Files
nixos-config/home/common/programs/hyprland/default.nix
T
Miguel Palhas 88915d9cfd
CI / lint (push) Successful in 29s
CI / eval (push) Successful in 1m53s
feat(hyprland): restore media-key OSD with swayosd
The XF86 binds survived the noctalia -> eww switch, but the popup they
drew did not, so volume and brightness changed with no feedback. swayosd
replaces it: swayosd-client both applies the change and draws the OSD.

Also fixes two binds that pointed at binaries no host installs any more:
XF86AudioMicMute called volumectl (avizo, removed) and the brightness
keys called brightnessctl, which only arrakis has.

Commands are referenced by store path because Hyprland execs binds with
the PATH the session was started with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 18:35:27 +01:00

491 lines
20 KiB
Nix

{
config,
lib,
pkgs,
inputs,
...
}:
let
cfg = config.custom.hyprland;
hyprPkgs = inputs.hyprland.packages.${pkgs.stdenv.hostPlatform.system};
osd = "${pkgs.swayosd}/bin/swayosd-client";
# xdph runs this instead of hyprland-share-picker (screencopy:custom_picker_binary).
# The contract is just "print [SELECTION] to stdout"; a leading "r" grants a
# restore token. Printing it immediately means the dialog never renders.
#
# Why bypass the picker at all: Slack opens a burst of screencast sessions per
# share and only asks for persist_mode=1, so the leading sessions race ahead of
# their restore token and re-prompt. allow_token_by_default alone still left
# ~3 dialogs per share.
#
# Escape hatch: `touch ~/.config/hypr/share-picker-manual` to get the real
# picker back when you need a window or region instead of a whole screen.
#
# Deliberately NOT the focused monitor: at the moment you hit "share screen"
# the focus is on the Slack/Chrome window, which is generally not the screen
# you actually want to show. Auto-picking it would silently share the wrong
# display. shareOutput is pinned per-host instead.
sharePicker = pkgs.writeShellScriptBin "xdph-auto-picker" ''
realPicker="${hyprPkgs.xdg-desktop-portal-hyprland}/bin/hyprland-share-picker"
if [ -e "$HOME/.config/hypr/share-picker-manual" ]; then
exec "$realPicker" "$@"
fi
want=${lib.escapeShellArg (toString cfg.shareOutput)}
# Only auto-select if that output is actually connected right now; otherwise
# fall back to the picker rather than sharing something unexpected.
if ${hyprPkgs.hyprland}/bin/hyprctl monitors -j 2>/dev/null \
| ${pkgs.jq}/bin/jq -e --arg o "$want" 'any(.[]; .name == $o)' >/dev/null 2>&1; then
printf '[SELECTION]r/screen:%s\n' "$want"
else
exec "$realPicker" "$@"
fi
'';
# Floating webcam preview for the "corner-cam" recording trick: run `webcam`,
# then capture the whole screen with kooha/wf-recorder — the preview is in the
# recording. v4l2 only (won't work with the laptop's IPU6 cam). Optional device
# arg, e.g. `webcam /dev/video2`.
webcam = pkgs.writeShellScriptBin "webcam" ''
dev="''${1:-/dev/video0}"
exec ${pkgs.mpv}/bin/mpv \
--profile=low-latency --untimed \
--no-osc --no-input-default-bindings \
--title=webcam-overlay --border=no --ontop \
"av://v4l2:$dev"
'';
in
{
options.custom.hyprland = {
yaziSize = lib.mkOption {
type = lib.types.str;
default = "1400 1400";
description = "Size of the yazi special workspace window";
};
cursorSize = lib.mkOption {
type = lib.types.int;
default = 24;
description = "Cursor size for XCURSOR_SIZE and HYPRCURSOR_SIZE";
};
cursorPackage = lib.mkOption {
type = lib.types.nullOr lib.types.package;
default = null;
description = ''
Package providing cursorTheme.dark/light under share/icons. null uses
the nordzy-cursors package built in ./cursor.nix.
'';
};
cursorTheme = {
dark = lib.mkOption {
type = lib.types.str;
default = "Nordzy-cursors";
description = "Cursor theme darkman selects in dark mode.";
};
light = lib.mkOption {
type = lib.types.str;
default = "Nordzy-white";
description = "Cursor theme darkman selects in light mode.";
};
};
panelScale = lib.mkOption {
type = lib.types.float;
default = 1.0;
example = 1.5;
description = "Multiplier for every length in the eww dashboard stylesheet.";
};
verticalOutputs = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "DP-2" ];
description = ''
Outputs that get the portrait wallpaper set. Everything else gets the
landscape one.
'';
};
shareOutput = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "HDMI-A-1";
description = ''
Output that screen-sharing auto-selects, bypassing the xdph picker
entirely. null keeps the normal picker. Falls back to the picker if
the named output isn't currently connected.
'';
};
};
imports = [
./cursor.nix
./eww
./kbptr.nix
./shell.nix
./wallpapers.nix
];
config = {
home.packages = with pkgs; [
hyprcursor
pamixer
hyprshot
satty
playerctl
hyprsunset
libnotify
cliphist
wf-recorder
slurp
webcam
];
home.sessionVariables = {
ELECTRON_OZONE_PLATFORM_HINT = "auto";
# WebKitGTK (Tauri apps) renders blank/crashes on the DMABUF path across
# many GPU/driver combos; disable it on all hosts.
WEBKIT_DISABLE_DMABUF_RENDERER = "1";
};
# xdph's share-picker is stateless: the "Allow a restore token" checkbox
# starts unticked, so no app ever receives a token and every single share
# re-prompts (journal showed 0 "Sent restore token" in 30 days, always
# re-selecting the same output). This pre-ticks it, so OBS/Chrome/Slack get
# a token on first share and skip the dialog thereafter.
# Note: xdph.conf is still hyprlang — the 0.55 Lua switch only hit the
# compositor config, not this separate binary.
xdg.configFile."hypr/xdph.conf".text = ''
screencopy {
allow_token_by_default = true
${lib.optionalString (
cfg.shareOutput != null
) " custom_picker_binary = ${sharePicker}/bin/xdph-auto-picker"}
}
'';
wayland.windowManager.hyprland = {
enable = true;
package = null;
portalPackage = null;
# Hyprland 0.55+ removed the hyprlang parser entirely — the config format
# is now Lua only (see home/common/programs/hyprland — the whole config
# below is raw Lua using the hl.* API). home-manager still emits its own
# systemd-session start hook, so we don't duplicate that here.
configType = "lua";
plugins = [ ];
extraConfig = ''
local mod = "SUPER"
-- look & feel / behaviour
hl.config({
general = {
border_size = 1,
gaps_in = 0,
gaps_out = 0,
snap = {
enabled = true,
border_overlap = true,
},
col = {
inactive_border = "0x99999999",
active_border = "0x99999999",
},
},
input = {
kb_options = "ctrl:nocaps",
repeat_delay = 150,
touchpad = {
natural_scroll = true,
},
numlock_by_default = true,
},
cursor = {
no_hardware_cursors = true,
-- Auto-hide the pointer after 3s of no movement so a parked cursor
-- doesn't sit on top of a game (visible in the stream too). It
-- reappears the instant the mouse moves.
inactive_timeout = 3,
},
misc = {
disable_hyprland_logo = true,
disable_splash_rendering = true,
on_focus_under_fullscreen = 2,
},
ecosystem = {
no_update_news = true,
},
xwayland = {
force_zero_scaling = true,
},
decoration = {
blur = {
enabled = true,
popups = false,
},
shadow = {
enabled = false,
},
},
})
-- keep default animations, but speed them all up
hl.animation({ leaf = "global", enabled = true, speed = 2, bezier = "default" })
-- environment
hl.env("GDK_SCALE", "2.0")
hl.env("XCURSOR_SIZE", "${toString cfg.cursorSize}")
hl.env("HYPRCURSOR_THEME", "${cfg.cursorTheme.dark}")
hl.env("HYPRCURSOR_SIZE", "${toString cfg.cursorSize}")
-- Route Qt6 apps (incl. the xdph screen-share picker) through qt6ct so
-- the dark color scheme actually applies. Without this var the
-- qt6ct.conf is never read.
hl.env("QT_QPA_PLATFORMTHEME", "qt6ct")
-- autostart
hl.on("hyprland.start", function()
-- Import the Qt theme var too, so the dbus/systemd-activated
-- xdg-desktop-portal-hyprland (and its share-picker) inherit it.
hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP QT_QPA_PLATFORMTHEME")
hl.exec_cmd("hyprctl setcursor ${cfg.cursorTheme.dark} ${toString cfg.cursorSize}")
hl.exec_cmd("hyprsunset")
-- eww, wpaperd, mako and the polkit agent are systemd user services
-- bound to graphical-session.target; nothing to start here.
-- kdeconnectd is only DBus-activated; nothing starts it at login,
-- so clipboard sync stays dead until the indicator runs.
hl.exec_cmd("kdeconnect-indicator")
end)
-- layer rules
hl.layer_rule({ match = { namespace = "eww-panel" }, blur = true, ignore_alpha = 0.5 })
-- workspace rules
hl.workspace_rule({ workspace = "w[t1]", gaps_out = 0, gaps_in = 0 }) -- no gaps when only window
hl.workspace_rule({ workspace = "w[tg1]", gaps_out = 0, gaps_in = 0 })
hl.workspace_rule({ workspace = "f[1]", gaps_out = 0, gaps_in = 0 })
hl.workspace_rule({ workspace = "special:terminal", on_created_empty = "[float; size 1400 1000; center 1] kitty", persistent = false })
hl.workspace_rule({ workspace = "special:yazi", on_created_empty = "[float; size ${cfg.yaziSize}; center 1] kitty --session sessions/yazi", persistent = false })
-- window rules
hl.window_rule({ match = { class = "com.gabm.satty" }, float = true })
hl.window_rule({ match = { class = "imv" }, float = true, size = "1920 1080", move = "(cursor_x-(window_w*0.5)) (cursor_y-(window_h*0.5))" })
hl.window_rule({ match = { class = "mpv" }, float = true, size = "1920 1080", move = "(cursor_x-(window_w*0.5)) (cursor_y-(window_h*0.5))" })
-- webcam overlay (`webcam` command) small, pinned, bottom-right.
-- Declared after the mpv class rule so its title match wins.
hl.window_rule({
match = { title = "webcam-overlay" },
float = true,
size = "360 240",
move = "100%-380 100%-260",
pin = true,
no_initial_focus = true,
border_size = 0,
})
hl.window_rule({ match = { class = "thunar" }, float = true, size = "1800 1200", move = "(cursor_x-(window_w*0.5)) (cursor_y-(window_h*0.5))" })
-- nixpkgs wraps blueman with --inherit-argv0, so the class is
-- blueman-manager, not the .blueman-manager-wrapped the old rule expected.
hl.window_rule({ match = { class = [[.*[Bb]lueman.*]] }, float = true, size = "1200 800", move = "(cursor_x-(window_w*0.5)) (cursor_y-(window_h*0.5))" })
hl.window_rule({ match = { class = "nm-connection-editor" }, float = true, size = "1200 800", move = "(cursor_x-(window_w*0.5)) (cursor_y-(window_h*0.5))" })
hl.window_rule({ match = { class = "waypaper" }, float = true, size = "1600 1000", move = "(cursor_x-(window_w*0.5)) (cursor_y-(window_h*0.5))" })
hl.window_rule({ match = { class = [[org\.pulseaudio\.pavucontrol]] }, float = true, size = "1200 1200", move = "(cursor_x-(window_w*0.5)) (cursor_y-(window_h*0.5))" })
-- wine / game installers (Inno Setup temp windows, e.g. Heroic/GOG).
-- Their class is the random "setup_*.tmp" filename, so match by suffix.
hl.window_rule({ match = { class = [[.*\.tmp]] }, float = true, center = true })
-- metamask
hl.window_rule({ match = { class = [[chrome-nkbihfbeogaeaoehlefnkodbefgpgknn-.*]] }, float = true })
-- bitwarden, chrome
hl.window_rule({ match = { class = [[chrome-nngceckbapebfimnlniiiahkandclblb-.*]] }, float = true })
-- claude for chrome
hl.window_rule({ match = { class = [[chrome-fcoeoabgfenejglbffodgkkbkcdhcgfn-.*]] }, float = true, move = "(cursor_x-(window_w*0.5)) (cursor_y-(window_h*0.5))" })
-- no gaps when only window
hl.window_rule({ match = { float = false, workspace = "w[t1]" }, border_size = 0, rounding = 0 })
hl.window_rule({ match = { float = false, workspace = "w[tg1]" }, border_size = 0, rounding = 0 })
hl.window_rule({ match = { float = false, workspace = "f[1]" }, border_size = 0, rounding = 0 })
hl.window_rule({ match = { workspace = "special:terminal" }, center = true })
hl.window_rule({ match = { workspace = "special:yazi" }, center = true })
-- keybinds
hl.bind(mod .. " + N", hl.dsp.exec_cmd("eww-panel"))
hl.bind(mod .. " + T", hl.dsp.exec_cmd("kitty"))
hl.bind(mod .. " + V", hl.dsp.window.float({ action = "toggle" }))
hl.bind(mod .. " + Q", hl.dsp.window.close())
hl.bind(mod .. " + F", hl.dsp.window.fullscreen({ mode = "fullscreen" }))
hl.bind(mod .. " + SHIFT + F", hl.dsp.window.fullscreen({ mode = "maximized" }))
-- lock (routes through logind -> hypridle lock_cmd -> hyprlock)
hl.bind(mod .. " + CTRL + L", hl.dsp.exec_cmd("loginctl lock-session"))
hl.bind(mod .. " + space", hl.dsp.exec_cmd("fuzzel"))
-- printscreen
hl.bind("Print", hl.dsp.exec_cmd("hyprshot -m region --raw | satty --filename - --output-filename ~/downloads/screenshots/$(date +%Y-%m-%d_%H-%M-%S).png"))
hl.bind("SHIFT + Print", hl.dsp.exec_cmd("hyprshot -m window --raw | satty --filename - --output-filename ~/downloads/screenshots/$(date +%Y-%m-%d_%H-%M-%S).png"))
-- move focus with mod + hjkl
hl.bind(mod .. " + H", hl.dsp.focus({ direction = "left" }))
hl.bind(mod .. " + J", hl.dsp.focus({ direction = "down" }))
hl.bind(mod .. " + K", hl.dsp.focus({ direction = "up" }))
hl.bind(mod .. " + L", hl.dsp.focus({ direction = "right" }))
-- switch / move-to workspaces 1-6
for i = 1, 6 do
hl.bind(mod .. " + " .. i, hl.dsp.focus({ workspace = i }))
hl.bind(mod .. " + SHIFT + " .. i, hl.dsp.window.move({ workspace = i }))
end
-- move active window inside workspace
hl.bind(mod .. " + SHIFT + H", hl.dsp.window.move({ direction = "left" }))
hl.bind(mod .. " + SHIFT + J", hl.dsp.window.move({ direction = "down" }))
hl.bind(mod .. " + SHIFT + K", hl.dsp.window.move({ direction = "up" }))
hl.bind(mod .. " + SHIFT + L", hl.dsp.window.move({ direction = "right" }))
-- scroll through workspaces with mod + scroll
hl.bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "e+1" }))
hl.bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "e-1" }))
-- special workspaces (toggle + recenter in one handler)
hl.bind(mod .. " + X", function()
hl.dispatch(hl.dsp.workspace.toggle_special("terminal"))
hl.dispatch(hl.dsp.window.center())
end)
hl.bind(mod .. " + E", function()
hl.dispatch(hl.dsp.workspace.toggle_special("yazi"))
hl.dispatch(hl.dsp.window.center())
end)
-- volume / brightness / media (locked so they work on the lock screen;
-- volume and brightness repeat on hold). Everything goes through
-- swayosd-client, which applies the change and draws the OSD popup;
-- store paths because Hyprland execs these with the login PATH, which
-- doesn't pick up profile changes made after the session started.
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("${osd} --output-volume raise"), { locked = true, repeating = true })
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("${osd} --output-volume lower"), { locked = true, repeating = true })
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("${osd} --brightness raise"), { locked = true, repeating = true })
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("${osd} --brightness lower"), { locked = true, repeating = true })
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("${osd} --output-volume mute-toggle"), { locked = true })
hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("${osd} --input-volume mute-toggle"), { locked = true })
hl.bind("XF86AudioNext", hl.dsp.exec_cmd("${osd} --playerctl next"), { locked = true })
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("${osd} --playerctl prev"), { locked = true })
hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("${osd} --playerctl play-pause"), { locked = true })
-- move/resize windows with mod + LMB/RMB drag
hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true })
hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true })
'';
};
services.hyprpaper.enable = false;
# hyprlock: the lock screen. Clean minimal dark look; tweak colours/clock/
# font to taste. hypridle below drives it (idle + before-sleep), and
# $mod CTRL+L locks manually.
programs.hyprlock = {
enable = true;
settings = {
general = {
hide_cursor = true;
grace = 0;
ignore_empty_input = true;
};
background = [
{
monitor = "";
color = "rgba(16, 16, 20, 1.0)";
}
];
label = [
{
monitor = "";
text = "$TIME";
color = "rgba(230, 230, 240, 1.0)";
font_size = 92;
font_family = "monospace";
position = "0, 110";
halign = "center";
valign = "center";
}
{
monitor = "";
text = ''cmd[update:60000] date +"%A, %d %B"'';
color = "rgba(170, 170, 185, 1.0)";
font_size = 20;
font_family = "monospace";
position = "0, 30";
halign = "center";
valign = "center";
}
];
input-field = [
{
monitor = "";
size = "320, 56";
outline_thickness = 2;
dots_size = 0.26;
dots_spacing = 0.3;
dots_center = true;
outer_color = "rgba(80, 80, 95, 1.0)";
inner_color = "rgba(30, 30, 38, 1.0)";
font_color = "rgba(220, 220, 230, 1.0)";
check_color = "rgba(120, 170, 240, 1.0)";
fail_color = "rgba(220, 90, 90, 1.0)";
fail_text = "<i>wrong</i>";
placeholder_text = "<i>password</i>";
fade_on_empty = false;
position = "0, -50";
halign = "center";
valign = "center";
}
];
};
};
services.hypridle = {
enable = true;
settings = {
general = {
after_sleep_cmd = "hyprctl dispatch dpms on";
before_sleep_cmd = "loginctl lock-session";
# Must be true on this streaming box. GameMode (via Sunshine/Moonlight)
# raises a dbus ScreenSaver inhibitor; if one arrives *after* the dpms-off
# timeout has fired, hypridle's onResumed() sees inhibit_locks > 0 and
# silently skips the on-resume `dpms on` — leaving every display (and the
# Moonlight stream) stuck black until a hard power-cycle. Ignoring dbus
# inhibits keeps the lock count at 0 so resume always turns displays back on.
ignore_dbus_inhibit = true;
# guard against launching a second hyprlock over an existing one
lock_cmd = "pidof hyprlock || hyprlock";
};
listener = [
{
timeout = 900;
on-timeout = "loginctl lock-session";
}
{
timeout = 1200;
on-timeout = "hyprctl dispatch dpms off";
on-resume = "hyprctl dispatch dpms on";
}
];
};
};
};
}