From 4b6fbfe73229ae52ca5c36e2b6c75a2329f995ba Mon Sep 17 00:00:00 2001 From: alexion Date: Wed, 22 Jul 2026 07:47:14 -0400 Subject: [PATCH] feat(desktop): add the desktop group and Hyprland session (task 0021) Introduce the modules/desktop/ group behind a single modules.desktop.enable aggregator that hand-lists and turns on each piece at default priority, so a host enables the whole desktop with one flag yet can override any single piece. The Hyprland compositor lives in a subdirectory of the group, sourced from nixpkgs with the program integration owning the session, portals, and polkit and home-manager writing the config against that one package. greetd with the tuigreet greeter provides a mouse-free text login that launches the session through uwsm, and Ghostty is the terminal on Super+Return. Port the operator's KDE/i3 bindings expressed entirely in hjkl and letters, tune input (US layout, Caps->Escape, snappy repeat, touchpad taps and natural scroll, flat mouse accel), and set a subtle feel with blur left as a host knob. Enable the desktop on neogaia. --- ...0021-desktop-group-and-hyprland-session.md | 41 ++++-- CLAUDE.md | 4 + hosts/neogaia/default.nix | 2 + modules/desktop/desktop.nix | 16 +++ modules/desktop/hyprland/hyprland.nix | 132 ++++++++++++++++++ modules/desktop/login.nix | 23 +++ modules/desktop/terminal.nix | 18 +++ 7 files changed, 227 insertions(+), 9 deletions(-) create mode 100644 modules/desktop/desktop.nix create mode 100644 modules/desktop/hyprland/hyprland.nix create mode 100644 modules/desktop/login.nix create mode 100644 modules/desktop/terminal.nix diff --git a/.claude/tasks/0021-desktop-group-and-hyprland-session.md b/.claude/tasks/0021-desktop-group-and-hyprland-session.md index 248b6e3..55c1b59 100644 --- a/.claude/tasks/0021-desktop-group-and-hyprland-session.md +++ b/.claude/tasks/0021-desktop-group-and-hyprland-session.md @@ -23,12 +23,35 @@ Enable the desktop on neogaia. ## Acceptance criteria -- [ ] `modules/desktop/` exists with an aggregator exposing `modules.desktop.enable` that hand-lists and enables its pieces at default priority, each piece independently overridable. -- [ ] Desktop enable options are namespaced under a single desktop group; the Hyprland-native compositor lives in a subdirectory of the group. -- [ ] Hyprland is sourced from nixpkgs; the NixOS integration and the home-manager user config share one Hyprland package. -- [ ] The session launches through the universal Wayland session manager from a greetd/tuigreet text login. -- [ ] Keybinds match the spec's table, using only `hjkl`, letters, and number rows — no arrow or navigation-cluster keys. -- [ ] Input is tuned: US-only layout, Caps→Escape (Shift+Caps = CapsLock), snappy key-repeat, touchpad tap-to-click + natural scroll + disable-while-typing, flat mouse acceleration. -- [ ] Animations, rounding, and small gaps are on; blur is off and remains host-overridable. -- [ ] Ghostty opens on `Super+Return`. -- [ ] neogaia enables `modules.desktop` and builds green under `nix flake check`. +- [x] `modules/desktop/` exists with an aggregator exposing `modules.desktop.enable` that hand-lists and enables its pieces at default priority, each piece independently overridable. +- [x] Desktop enable options are namespaced under a single desktop group; the Hyprland-native compositor lives in a subdirectory of the group. +- [x] Hyprland is sourced from nixpkgs; the NixOS integration and the home-manager user config share one Hyprland package. +- [x] The session launches through the universal Wayland session manager from a greetd/tuigreet text login. +- [x] Keybinds match the spec's table, using only `hjkl`, letters, and number rows — no arrow or navigation-cluster keys. +- [x] Input is tuned: US-only layout, Caps→Escape (Shift+Caps = CapsLock), snappy key-repeat, touchpad tap-to-click + natural scroll + disable-while-typing, flat mouse acceleration. +- [x] Animations, rounding, and small gaps are on; blur is off and remains host-overridable. +- [x] Ghostty opens on `Super+Return`. +- [x] neogaia enables `modules.desktop` and builds green under `nix flake check`. + +## Implementation Notes + +- **Keybind scope.** + This task ports only the enumerated compositor-native bindings (workspace switch/move, focus, window move, resize, terminal, floating, fullscreen, split, close, force-kill). + The spec table's launcher, lock, screenshot, clipboard, and record bindings depend on tools installed by later tasks (0024–0029), so each of those tasks adds its own binding rather than this one binding to a missing binary. + Force-kill uses Hyprland's `forcekillactive` dispatcher, keeping it keyboard-only. + +- **Shared Hyprland package.** + The NixOS `programs.hyprland` installs the single package and the portal system-wide, and the home-manager module sets `package = null` and `portalPackage = null` so it writes only the config against that one package. + This is the "never a version split" guarantee, read as one package total rather than two identical derivations. + +- **hyprlang, not Lua.** + The home-manager `wayland.windowManager.hyprland` module now defaults `configType` to `"lua"` at `home.stateVersion` ≥ 26.05, which serialises `$mod`-style variables and INI `bind=` strings into invalid Lua without failing the build. + The module pins `configType = "hyprlang"` to emit the native `hyprland.conf`. + Recorded as a gotcha in `CLAUDE.md`. + +- **Greeter session command.** + greetd's `default_session` runs `uwsm start -e -D Hyprland hyprland.desktop`, mirroring the Exec line of the uwsm session the Hyprland package itself ships, so the session goes through the universal Wayland session manager deterministically. + +- **Dropped from the plan.** + Mouse drag-to-move and drag-to-resize (`bindm`) were removed: they fall outside the task's enumerated keyboard bindings, and `resizeactive`/`movewindow` already cover floating windows from the keyboard. + Hardware media/brightness keys (the spec table's `XF86` row) are likewise deferred, since they depend on audio and backlight tooling not yet in scope. diff --git a/CLAUDE.md b/CLAUDE.md index 7b5d2da..c41fde5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,3 +98,7 @@ The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overla `raichu`, a server with no desktop, is the only Nvidia machine. `laptop-mvi.md`'s out-of-scope line calls zeus Nvidia, but that is stale and the document is kept historical and unchanged, so do not infer any host's GPU from it. The corrected fact lives in ADR 0003 and the `hyprland-desktop` spec. +- The home-manager `wayland.windowManager.hyprland` module defaults `configType` to `"lua"` at `home.stateVersion` >= 26.05, writing `hyprland.lua` through an `hl.*` Lua API instead of the native `hyprland.conf`. + The Lua backend mangles `$mod`-style variables and INI `bind=` strings into invalid Lua (`hl.$mod("SUPER")`), and does not fail the build, since the config is only text. + Set `configType = "hyprlang"` to get the native `hyprland.conf` whose variable and bind syntax the usual settings are written in. + Render the file to check which format is in effect: `nix build --print-out-paths .#nixosConfigurations..config.home-manager.users..xdg.configFile.\"hypr/hyprland.conf\".source` (only the enabled `configType`'s key exists). diff --git a/hosts/neogaia/default.nix b/hosts/neogaia/default.nix index 085df4f..a24268f 100644 --- a/hosts/neogaia/default.nix +++ b/hosts/neogaia/default.nix @@ -52,6 +52,8 @@ modules.gitea-axi.enable = true; modules.pi.enable = true; + modules.desktop.enable = true; + time.timeZone = "America/New_York"; i18n.defaultLocale = "en_GB.UTF-8"; } diff --git a/modules/desktop/desktop.nix b/modules/desktop/desktop.nix new file mode 100644 index 0000000..d1b823e --- /dev/null +++ b/modules/desktop/desktop.nix @@ -0,0 +1,16 @@ +{ config, lib, ... }: +# The desktop aggregator: one flag brings up the whole graphical session. +let + cfg = config.modules.desktop; +in +{ + options.modules.desktop.enable = lib.mkEnableOption "the keyboard-driven Hyprland desktop"; + + # Each piece is turned on at default priority, so a host can still override + # 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.login.enable = lib.mkDefault true; + modules.desktop.terminal.enable = lib.mkDefault true; + }; +} diff --git a/modules/desktop/hyprland/hyprland.nix b/modules/desktop/hyprland/hyprland.nix new file mode 100644 index 0000000..91f0048 --- /dev/null +++ b/modules/desktop/hyprland/hyprland.nix @@ -0,0 +1,132 @@ +{ + config, + lib, + pkgs, + ... +}: +# The Hyprland compositor, sourced from nixpkgs. +let + cfg = config.modules.desktop.hyprland; + user = config.user.name; + + # Numbered-workspace switch and move for 1..9, the operator's i3 muscle memory. + workspaceBinds = lib.concatMap (n: [ + "$mod, ${toString n}, workspace, ${toString n}" + "$mod SHIFT, ${toString n}, movetoworkspace, ${toString n}" + ]) (lib.range 1 9); +in +{ + options.modules.desktop.hyprland = { + enable = lib.mkEnableOption "the Hyprland compositor"; + + blur = lib.mkEnableOption '' + window blur. Off by default as the single biggest battery cost on a + laptop, left on for a host with the headroom to spend it''; + }; + + config = lib.mkIf cfg.enable { + # This program integration owns the session, portals, and polkit, launched + # through the universal Wayland session manager. + programs.hyprland = { + enable = true; + withUWSM = true; + }; + + home-manager.users.${user}.wayland.windowManager.hyprland = { + enable = true; + + # One package drives the whole session, so there is never a version split. + # The program integration above installs it and the portal, leaving home- + # manager to write only the config. + package = null; + portalPackage = null; + + # uwsm owns the systemd graphical-session targets. + systemd.enable = false; + + # Write the native hyprlang hyprland.conf, whose variable and bind syntax + # the settings below are expressed in. + configType = "hyprlang"; + + settings = { + "$mod" = "SUPER"; + "$terminal" = "ghostty"; + + input = { + kb_layout = "us"; + # Caps is a second Escape. + # Shift+Caps still toggles a real CapsLock. + kb_options = "caps:escape_shifted_capslock"; + # Snappy: a short delay before repeat begins, then a fast repeat rate. + repeat_delay = 250; + repeat_rate = 45; + accel_profile = "flat"; + touchpad = { + natural_scroll = true; + tap-to-click = true; + disable_while_typing = true; + }; + }; + + general = { + gaps_in = 4; + gaps_out = 8; + border_size = 2; + layout = "dwindle"; + }; + + decoration = { + rounding = 6; + blur.enabled = cfg.blur; + }; + + animations = { + enabled = true; + bezier = [ "ease, 0.25, 0.1, 0.25, 1.0" ]; + # Durations are in centiseconds. + # Short values keep the motion subtle. + animation = [ + "windows, 1, 3, ease" + "fade, 1, 3, ease" + "workspaces, 1, 3, ease" + "border, 1, 3, ease" + ]; + }; + + dwindle = { + pseudotile = true; + preserve_split = true; + }; + + bind = [ + "$mod, Return, exec, $terminal" + + # Move focus. + "$mod, H, movefocus, l" + "$mod, J, movefocus, d" + "$mod, K, movefocus, u" + "$mod, L, movefocus, r" + + # Move the window within the layout. + "$mod SHIFT, H, movewindow, l" + "$mod SHIFT, J, movewindow, d" + "$mod SHIFT, K, movewindow, u" + "$mod SHIFT, L, movewindow, r" + + # Resize the active window. + "$mod ALT, H, resizeactive, -40 0" + "$mod ALT, J, resizeactive, 0 40" + "$mod ALT, K, resizeactive, 0 -40" + "$mod ALT, L, resizeactive, 40 0" + + "$mod, Space, togglefloating," + "$mod, F, fullscreen," + "$mod, T, togglesplit," + "$mod SHIFT, Q, killactive," + "$mod CTRL, Q, forcekillactive," + ] + ++ workspaceBinds; + }; + }; + }; +} diff --git a/modules/desktop/login.nix b/modules/desktop/login.nix new file mode 100644 index 0000000..a5551c0 --- /dev/null +++ b/modules/desktop/login.nix @@ -0,0 +1,23 @@ +{ + config, + lib, + pkgs, + ... +}: +# Text login: greetd running the tuigreet greeter, which starts the session +# through the universal Wayland session manager. +let + cfg = config.modules.desktop.login; +in +{ + options.modules.desktop.login.enable = lib.mkEnableOption "greetd with the tuigreet text greeter"; + + config = lib.mkIf cfg.enable { + services.greetd = { + enable = true; + settings.default_session.command = + "${lib.getExe pkgs.tuigreet} --time --remember " + + "--cmd 'uwsm start -e -D Hyprland hyprland.desktop'"; + }; + }; +} diff --git a/modules/desktop/terminal.nix b/modules/desktop/terminal.nix new file mode 100644 index 0000000..876eeda --- /dev/null +++ b/modules/desktop/terminal.nix @@ -0,0 +1,18 @@ +{ + config, + lib, + pkgs, + ... +}: +# Ghostty as the desktop terminal. +let + cfg = config.modules.desktop.terminal; + user = config.user.name; +in +{ + options.modules.desktop.terminal.enable = lib.mkEnableOption "Ghostty as the desktop terminal"; + + config = lib.mkIf cfg.enable { + home-manager.users.${user}.home.packages = [ pkgs.ghostty ]; + }; +}