444 lines
18 KiB
Nix
444 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")
|
|
hl.exec_cmd("yogurt --tray")
|
|
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";
|
|
}
|
|
];
|
|
};
|
|
};
|
|
};
|
|
}
|