Files
nixos-config/home/common/programs/hyprland/default.nix
T
Miguel Palhas d417a27f2d
CI / lint (push) Successful in 31s
CI / eval (push) Successful in 2m1s
feat: make the clipboard work across tmux, ssh and hosts
tmux copy bindings piped to xclip, which is X11-only and never worked
under Hyprland or over SSH, and set-clipboard was never enabled, so the
vi-mode `y` binding was dead too. Route both through OSC 52 instead.
terminal-features has to name the outer terminal explicitly: tmux skips
OSC 52 silently when the terminfo entry does not claim the capability,
which is the common case for an SSH session on xterm-256color.

Neovim only falls back to OSC 52 when it finds no clipboard tool, but
wl-clipboard is installed on every host, so over SSH it wrote the remote
clipboard. Pin the OSC 52 provider for SSH sessions only.

Moonlight and Sunshine have no clipboard channel in the protocol, so
nothing at the terminal layer can help there. Add kdeconnect on all three
hosts for that, and start its indicator from Hyprland — kdeconnectd is
DBus-activated and nothing else brings it up at login.

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

446 lines
18 KiB
Nix

{
config,
lib,
pkgs,
inputs,
...
}:
let
cfg = config.custom.hyprland;
hyprPkgs = inputs.hyprland.packages.${pkgs.stdenv.hostPlatform.system};
# 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";
};
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
./kbptr.nix
./noctalia
./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", "rose-pine-hyprcursor")
hl.env("HYPRCURSOR_SIZE", "${toString cfg.cursorSize}")
-- Route Qt6 apps (incl. the xdph screen-share picker) through qt6ct so
-- the noctalia-generated 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 Nordzy-cursors ${toString cfg.cursorSize}")
hl.exec_cmd("hyprsunset")
hl.exec_cmd("noctalia")
-- 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 = "noctalia-wallpaper" }, 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))" })
hl.window_rule({ match = { class = [[\.blueman-manager-wrapped]] }, float = true, size = "1200 800", 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("noctalia msg panel-toggle control-center"))
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("noctalia msg panel-toggle launcher"))
-- 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 (locked so they work on the lock screen; repeat on hold)
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+"), { locked = true, repeating = true })
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"), { locked = true, repeating = true })
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl set 5%+"), { locked = true, repeating = true })
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl set 5%-"), { locked = true, repeating = true })
-- media keys (locked)
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("pactl set-sink-mute @DEFAULT_SINK@ toggle"), { locked = true })
hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("volumectl -m toggle-mute"), { locked = true })
hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true })
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true })
hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("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, replacing noctalia's built-in locker (which is
# ugly and — unlike hyprlock — opaque to scripts/lock-state detection).
# 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";
}
];
};
};
};
}