feat(desktop): add the desktop group and Hyprland session (task 0021) #13

Merged
alexion merged 5 commits from task-0021-desktop-group-and-hyprland-session into main 2026-07-22 10:09:37 -04:00
7 changed files with 227 additions and 9 deletions
Showing only changes of commit 4b6fbfe732 - Show all commits

View File

@@ -23,12 +23,35 @@ Enable the desktop on neogaia.
## Acceptance criteria ## 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. - [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.
- [ ] Desktop enable options are namespaced under a single desktop group; the Hyprland-native compositor lives in a subdirectory of the group. - [x] 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. - [x] 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. - [x] 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. - [x] 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. - [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.
- [ ] Animations, rounding, and small gaps are on; blur is off and remains host-overridable. - [x] Animations, rounding, and small gaps are on; blur is off and remains host-overridable.
- [ ] Ghostty opens on `Super+Return`. - [x] Ghostty opens on `Super+Return`.
- [ ] neogaia enables `modules.desktop` and builds green under `nix flake check`. - [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 (00240029), 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.

View File

@@ -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. `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. `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 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.<host>.config.home-manager.users.<user>.xdg.configFile.\"hypr/hyprland.conf\".source` (only the enabled `configType`'s key exists).

View File

@@ -52,6 +52,8 @@
modules.gitea-axi.enable = true; modules.gitea-axi.enable = true;
modules.pi.enable = true; modules.pi.enable = true;
modules.desktop.enable = true;
time.timeZone = "America/New_York"; time.timeZone = "America/New_York";
i18n.defaultLocale = "en_GB.UTF-8"; i18n.defaultLocale = "en_GB.UTF-8";
} }

View File

@@ -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;
};
}

View File

@@ -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;
};
};
};
}

23
modules/desktop/login.nix Normal file
View File

@@ -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'";
};
};
}

View File

@@ -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 ];
};
}