feat(desktop): add the desktop group and Hyprland session (task 0021) #13
@@ -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 (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.
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
16
modules/desktop/desktop.nix
Normal file
16
modules/desktop/desktop.nix
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
132
modules/desktop/hyprland/hyprland.nix
Normal file
132
modules/desktop/hyprland/hyprland.nix
Normal 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
23
modules/desktop/login.nix
Normal 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'";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
18
modules/desktop/terminal.nix
Normal file
18
modules/desktop/terminal.nix
Normal 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 ];
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user