From 005928ef88cc6de71e88356ca451d2aee2d55a82 Mon Sep 17 00:00:00 2001 From: alexion Date: Wed, 22 Jul 2026 18:37:32 -0400 Subject: [PATCH] feat(desktop): add hyprlock and hypridle (task 0026) Add a session-lock screen and idle management to the Hyprland-native subdirectory, enabled through the desktop aggregator. hyprlock draws through the compositor session-lock protocol, themed by Stylix, with a centered field and clock. Super+X locks directly through a guarded launch so the key works without the idle daemon. hypridle locks on idle and powers the displays off, with tunable timeouts, and locks before every suspend. Lid-close routes through logind to suspend, which the pre-sleep lock covers, so the lid lands at a locked screen. --- .claude/tasks/0026-hyprlock-and-hypridle.md | 35 +++++++++-- modules/desktop/desktop.nix | 2 + modules/desktop/hyprland/hypridle.nix | 69 +++++++++++++++++++++ modules/desktop/hyprland/hyprlock.nix | 61 ++++++++++++++++++ 4 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 modules/desktop/hyprland/hypridle.nix create mode 100644 modules/desktop/hyprland/hyprlock.nix diff --git a/.claude/tasks/0026-hyprlock-and-hypridle.md b/.claude/tasks/0026-hyprlock-and-hypridle.md index e6edd21..5adb2eb 100644 --- a/.claude/tasks/0026-hyprlock-and-hypridle.md +++ b/.claude/tasks/0026-hyprlock-and-hypridle.md @@ -14,8 +14,33 @@ Bind lock on `Super+X`. ## Acceptance criteria -- [ ] hyprlock and hypridle modules exist in the Hyprland-native subdirectory and are enabled by the aggregator. -- [ ] hyprlock uses the compositor session-lock protocol. -- [ ] hypridle triggers lock-on-idle, screen-off, lock-before-suspend, and lid-close, with tunable timeouts. -- [ ] Lock is bound on `Super+X`. -- [ ] neogaia builds green under `nix flake check`. +- [x] hyprlock and hypridle modules exist in the Hyprland-native subdirectory and are enabled by the aggregator. +- [x] hyprlock uses the compositor session-lock protocol. +- [x] hypridle triggers lock-on-idle, screen-off, lock-before-suspend, and lid-close, with tunable timeouts. +- [x] Lock is bound on `Super+X`. +- [x] neogaia builds green under `nix flake check`. + +## Implementation Notes + +- **hyprlock is inherently the session-lock client.** + Criterion 2 needs no option: hyprlock draws its surface through the ext-session-lock protocol, so the compositor owns the surface and it survives a crash of the locker. + The module therefore carries only geometry and behaviour. + +- **Stylix themes the lock screen.** + Colors and the lock background come from Stylix's hyprlock target, which merges into the same `background` and `input-field` blocks, so the module sets only field geometry and a `$TIME` label. + +- **lid-close is wired through logind, not a hypridle listener.** + hypridle cannot observe lid events, so the module sets `services.logind.settings.Login.HandleLidSwitch = "suspend"`, and the shared `before_sleep_cmd` locks ahead of the suspend. + The lid therefore lands at a locked screen, satisfying the criterion by outcome even though the trigger is logind's. + +- **`Super+X` is self-contained.** + The keybind execs a guarded hyprlock launch directly (`pidof hyprlock || hyprlock`) rather than `loginctl lock-session`, so the lock key works whenever hyprlock is enabled, without depending on hypridle being the running lock handler. + hypridle's own idle and suspend paths still funnel through `loginctl lock-session` so logind tracks the locked state on those paths. + +- **Idle-suspend was left out.** + The spec enumerates lock-on-idle, screen-off, lock-before-suspend, and lid-close, so hypridle does not itself suspend on idle. + `before_sleep_cmd` handles lock-before-suspend for the lid and any manual or externally configured suspend. + Adding an idle-suspend stage is a reasonable future knob but was not requested here. + +- **One hyprlock package.** + Both the keybind and hypridle's `lock_cmd` reference `programs.hyprlock.package`, so the locker never splits versions between the two call sites. diff --git a/modules/desktop/desktop.nix b/modules/desktop/desktop.nix index 6e945b8..e2dd2b3 100644 --- a/modules/desktop/desktop.nix +++ b/modules/desktop/desktop.nix @@ -10,6 +10,8 @@ in # any one of them while the single flag above enables the whole desktop. config = lib.mkIf cfg.enable { modules.desktop.hyprland.enable = lib.mkDefault true; + modules.desktop.hyprlock.enable = lib.mkDefault true; + modules.desktop.hypridle.enable = lib.mkDefault true; modules.desktop.login.enable = lib.mkDefault true; modules.desktop.mako.enable = lib.mkDefault true; modules.desktop.rofi.enable = lib.mkDefault true; diff --git a/modules/desktop/hyprland/hypridle.nix b/modules/desktop/hyprland/hypridle.nix new file mode 100644 index 0000000..914bfa6 --- /dev/null +++ b/modules/desktop/hyprland/hypridle.nix @@ -0,0 +1,69 @@ +{ + config, + lib, + pkgs, + ... +}: +# Idle management: hypridle locks on idle, powers the displays off, and locks +# before every suspend, so an unattended session always lands at hyprlock. +let + cfg = config.modules.desktop.hypridle; + user = config.user.name; + + hyprctl = "${config.programs.hyprland.package}/bin/hyprctl"; + hyprlock = "${config.home-manager.users.${user}.programs.hyprlock.package}/bin/hyprlock"; + + # The guard drops the call when a locker is already up, so no idle trigger + # stacks a second hyprlock over the first. + lockCmd = "${pkgs.procps}/bin/pidof hyprlock || ${hyprlock}"; +in +{ + options.modules.desktop.hypridle = { + enable = lib.mkEnableOption "hypridle idle management"; + + lockTimeout = lib.mkOption { + type = lib.types.ints.positive; + default = 300; + description = "Seconds of inactivity before the screen locks."; + }; + + screenOffTimeout = lib.mkOption { + type = lib.types.ints.positive; + default = 360; + description = "Seconds of inactivity before the displays are powered off."; + }; + }; + + config = lib.mkIf cfg.enable { + # Closing the lid suspends, and every suspend locks first through the + # before_sleep_cmd below, so the lid always lands at a locked screen. + services.logind.settings.Login.HandleLidSwitch = "suspend"; + + home-manager.users.${user}.services.hypridle = { + enable = true; + + settings = { + general = { + lock_cmd = lockCmd; + before_sleep_cmd = "loginctl lock-session"; + # Waking restores the displays the screen-off listener may have cut. + after_sleep_cmd = "${hyprctl} dispatch dpms on"; + }; + + listener = [ + # Lock on idle. + { + timeout = cfg.lockTimeout; + on-timeout = "loginctl lock-session"; + } + # Power the displays off a little later, restoring them on any activity. + { + timeout = cfg.screenOffTimeout; + on-timeout = "${hyprctl} dispatch dpms off"; + on-resume = "${hyprctl} dispatch dpms on"; + } + ]; + }; + }; + }; +} diff --git a/modules/desktop/hyprland/hyprlock.nix b/modules/desktop/hyprland/hyprlock.nix new file mode 100644 index 0000000..a76830c --- /dev/null +++ b/modules/desktop/hyprland/hyprlock.nix @@ -0,0 +1,61 @@ +{ + config, + lib, + pkgs, + ... +}: +# The lock screen: hyprlock, a session-lock client whose surface the compositor owns, so it survives a crash of the locker rather than exposing the session. +let + cfg = config.modules.desktop.hyprlock; + user = config.user.name; + + # The hyprlock this module installs, so the keybind and the idle daemon lock + # with one package and never split versions. + hyprlock = "${config.home-manager.users.${user}.programs.hyprlock.package}/bin/hyprlock"; +in +{ + options.modules.desktop.hyprlock.enable = lib.mkEnableOption "the hyprlock lock screen"; + + config = lib.mkIf cfg.enable { + home-manager.users.${user} = { + # Colors and the lock-screen background come from Stylix's hyprlock target, + # so only geometry and behaviour are set here. + programs.hyprlock = { + enable = true; + + settings = { + general = { + hide_cursor = true; + # No progress bar flashes before the field is ready to take input. + disable_loading_bar = true; + }; + + # A centered password field; its colors are the Stylix target's. + input-field = { + size = "260, 52"; + rounding = 8; + position = "0, -100"; + halign = "center"; + valign = "center"; + }; + + # The current time, above the field. + label = { + text = "$TIME"; + font_size = 48; + position = "0, 120"; + halign = "center"; + valign = "center"; + }; + }; + }; + + # Lock on Super+X. + # The guard drops the keypress when a locker is already up, so a second + # hyprlock never stacks over the first. + wayland.windowManager.hyprland.settings.bind = [ + "$mod, X, exec, ${pkgs.procps}/bin/pidof hyprlock || ${hyprlock}" + ]; + }; + }; +}