Compare commits

...

32 Commits

Author SHA1 Message Date
c053846adb fix(waybar): restore workspace clicks 2026-10-11 21:43:20 -04:00
2edad6c5df refactor(clipboard): simplify picker wiring 2026-10-11 21:36:54 -04:00
1d5acc09c6 refactor(audio): simplify media controls 2026-10-11 21:30:21 -04:00
ee29e53f7c refactor(waybar): register owned modules 2026-10-11 21:07:35 -04:00
3c9d0ec886 refactor(nix): share glyph decoding helper 2026-10-11 21:07:35 -04:00
f0c5544b10 refactor(waybar): register workspace window icons 2026-10-11 18:23:41 -04:00
4dd0a5a4f5 feat(rofi): increase launcher contrast 2026-10-11 17:49:05 -04:00
a4a795f7ee refactor(mako): use executable helper for history 2026-10-11 17:40:56 -04:00
ecdf877308 refactor(login): use configured uwsm executable 2026-10-11 17:39:37 -04:00
d1face10ec refactor(nix): apply executable helper rules 2026-10-11 17:38:15 -04:00
337d9a2014 docs(nix-style): add package command rules 2026-10-11 17:38:15 -04:00
f059068873 refactor(hyprlock): inline command paths 2026-10-11 17:25:18 -04:00
7662c04cda fix(hypridle): use Lua dpms dispatcher 2026-10-11 17:22:05 -04:00
33128fc3c7 feat(hyprland): migrate desktop utility bindings 2026-10-11 16:58:27 -04:00
68415ad47e fix(hyprland): place Zeus primary monitor at origin 2026-10-11 16:56:03 -04:00
6b7201e6fa feat(hyprland): migrate session feature bindings 2026-10-11 16:52:29 -04:00
4360f4863b feat(hyprland): migrate hardware key bindings 2026-10-11 16:40:07 -04:00
370cab8f83 feat(hyprland): render session env registry 2026-10-11 16:34:52 -04:00
e74a08b853 feat(dotfiles): add session env registry 2026-10-11 16:30:00 -04:00
250b0b71f9 feat(hyprland): migrate static Lua compositor settings 2026-10-11 16:24:48 -04:00
10299cec19 refactor(hyprland): place keybind definitions after config 2026-10-11 16:00:38 -04:00
91654b3068 feat(hyprland): add remaining window actions 2026-10-11 15:58:50 -04:00
be9c3c07a3 feat(hyprland): add window resize binds 2026-10-11 15:00:03 -04:00
a8bd4bf4cf feat(hyprland): add window movement binds 2026-10-11 14:52:39 -04:00
a2fc568704 feat(hyprland): add directional focus binds 2026-10-11 12:58:56 -04:00
0bf5ebae87 feat(hyprland): add workspace focus keybinds 2026-10-10 17:02:20 -04:00
fddec14128 refactor(hyprland): move terminal keybind into registry 2026-10-10 15:41:32 -04:00
85fadc5bfc feat(hyprland): start lua config migration 2026-10-10 13:57:33 -04:00
3992030949 docs: update notes 2026-10-10 11:06:05 -04:00
c8a596aa39 fix(hyprland): stop idle dpms poweroff 2026-10-10 08:29:01 -04:00
4908c4751f refactor(nixos): simplify Firefox configuration 2026-10-07 22:57:25 -04:00
79c56dfb1c refactor(nixos): remove Alacritty module 2026-10-07 22:53:09 -04:00
39 changed files with 2030 additions and 600 deletions

View File

@@ -2,10 +2,10 @@
## Desktop
- TODO: Verify this path after the NixOS configuration is migrated.
When adding a graphical application, add a `window-rewrite` icon mapping in `modules/desktop/session/waybar.nix`.
- When adding a graphical application, register its workspace icon in `registry.window-icon`.
The workspace indicator uses this mapping to show an application icon instead of the generic default glyph.
Match the mapping on the window class.
Store the icon as the rendered string.
Run `hyprctl clients -j | jq -r '.[].class' | sort -u` to list classes in the running session.
- TODO: Verify this path after the NixOS configuration is migrated.
Set Hyprland session environment variables in `modules/desktop/session/hyprland/default.nix` with `env = KEY,VALUE`.

View File

@@ -1,5 +1,6 @@
{
inputs,
util,
...
}:
# neogaia — Dell XPS 13 9380 laptop.
@@ -14,8 +15,7 @@
modules.host.cachyos.enable = true;
# So wifi can be joined from the console.
networking.networkmanager.enable = true;
modules.network.enable = true;
registry.address.neogaia.self = "10.23.50.146";
@@ -40,4 +40,23 @@
modules.desktop.apps.obsidian.enable = true;
modules.desktop.apps.steam.enable = true;
registry.waybar.neogaia.battery = {
order = 70;
settings = {
format = "{icon} {capacity}%";
format-charging = "${util.glyph "f0e7"} {capacity}%";
format-icons = [
(util.glyph "f244")
(util.glyph "f243")
(util.glyph "f242")
(util.glyph "f241")
(util.glyph "f240")
];
states = {
warning = 20;
critical = 10;
};
};
};
}

View File

@@ -21,7 +21,7 @@
enable32Bit = true;
};
networking.networkmanager.enable = true;
modules.network.enable = true;
services.logind.settings.Login = {
HandlePowerKey = "ignore";
@@ -49,8 +49,19 @@
modules.desktop.apps.vencord.enable = true;
home-manager.users.${config.user.name}.wayland.windowManager.hyprland.settings.monitor = [
"HDMI-A-1,1920x1080@120,0x0,1,transform,3"
"DP-3,2560x1440@239.96,1080x0,1"
{
output = "HDMI-A-1";
mode = "1920x1080@120";
position = "-1080x0";
scale = 1;
transform = 3;
}
{
output = "DP-3";
mode = "2560x1440@239.96";
position = "0x0";
scale = 1;
}
];
}

View File

@@ -1,18 +0,0 @@
{
config,
lib,
pkgs,
...
}:
# Alacritty as the desktop terminal.
let
user = config.user.name;
in
{
options.modules.desktop.apps.alacritty.enable =
lib.mkEnableOption "Alacritty as the desktop terminal";
config = lib.mkIf config.modules.desktop.apps.alacritty.enable {
home-manager.users.${user}.home.packages = [ pkgs.alacritty ];
};
}

View File

@@ -3,13 +3,12 @@
inputs,
lib,
pkgs,
util,
...
}:
# Firefox as the desktop browser: stock mainline, hardened and de-monetized by policy.
# Configures Firefox as the desktop browser.
let
cfg = config.modules.desktop.apps.firefox; # NOLINT(let_order)
firefoxAddons = inputs.firefox-addons.packages.${pkgs.stdenv.hostPlatform.system};
user = config.user.name;
in
{
options.modules.desktop.apps.firefox = {
@@ -19,19 +18,25 @@ in
type = lib.types.str;
default = "1.0";
description = ''
Firefox UI and page scale, passed to `layout.css.devPixelsPerPx`.
Override it on hosts that need a larger browser scale, such as small
high-DPI laptop panels.
Scales Firefox UI and page content.
Sets `layout.css.devPixelsPerPx`.
Use a larger value for high-DPI displays.
'';
};
};
config = lib.mkIf cfg.enable {
home-manager.users.${user} = hm: {
registry.window-icon.${config.networking.hostName}.firefox = {
class = ".*[Ff]irefox.*";
icon = util.glyph "f269";
};
home-manager.users.${config.user.name} = hm: {
programs.firefox = {
enable = true;
# Enterprise policies: enforced and unchangeable from the browser UI.
# Applies policies that users cannot change in Firefox.
policies = {
DisableTelemetry = true;
DisableFirefoxStudies = true;
@@ -40,7 +45,7 @@ in
DontCheckDefaultBrowser = true;
DisableFirefoxAccounts = true;
# Strip the monetized surfaces from the new-tab page.
# Removes sponsored content from the new-tab page.
FirefoxHome = {
SponsoredTopSites = false;
SponsoredPocket = false;
@@ -49,23 +54,19 @@ in
};
profiles.default = {
isDefault = true;
extensions = {
packages = with firefoxAddons; [
packages = with inputs.firefox-addons.packages.${pkgs.stdenv.hostPlatform.system}; [
ublock-origin
proton-pass
sponsorblock
];
# The Nord chrome theme is a declared extension setting, so home-manager
# owns the extension-settings store, overwriting runtime changes to it.
# Home Manager overwrites Nord theme changes made in Firefox.
force = true;
};
# Stylix's Nord mapping paints the selected address-bar result a
# near-white grey, leaving its light text unreadable. Darken that one
# highlight to the Nord selection grey, from the same scheme.
# Stylix uses a near-white selection background.
# Use the Nord selection color because its text stays readable.
extensions.settings."FirefoxColor@mozilla.com".settings.theme.colors.popup_highlight =
let
c = hm.config.lib.stylix.colors;
@@ -77,32 +78,26 @@ in
};
settings = {
# Scale the UI and page by a fixed factor.
# Left at auto (-1), Firefox reads the panel's 1.5x and inflates its
# whole chrome while point-sized apps stay put.
# A shade under that brings it into line without dropping to true
# 1:1, which reads too small at this DPI.
"layout.css.devPixelsPerPx" = cfg.uiScale;
# Auto-enable the sideloaded Firefox Color add-on carrying the Nord
# chrome theme, which Firefox otherwise leaves disabled.
# Enables Firefox Color after Firefox sideloads the add-on.
"extensions.autoDisableScopes" = 0;
# Sponsored surfaces the policies above do not reach.
# Removes sponsored content outside Firefox policies.
"browser.urlbar.suggest.quicksuggest.sponsored" = false;
"browser.newtabpage.activity-stream.showSponsored" = false;
"browser.newtabpage.activity-stream.showSponsoredTopSites" = false;
};
search = {
# Declaring search overwrites Firefox's own engine list wholesale,
# so engines added later in the UI do not survive a rebuild.
# Home Manager replaces the engine list when this setting is enabled.
# Engines added in Firefox do not survive a rebuild.
force = true;
default = "ddg";
# The general-purpose commercial engines, hidden to leave a lean
# DuckDuckGo-and-Wikipedia list. They stay reachable through bangs.
# An engine carrying only metaData is treated as a builtin.
# Hides commercial search engines.
# Bangs still reach these engines.
# Entries with only `metaData` configure Firefox built-in engines.
engines = {
google.metaData.hidden = true;
bing.metaData.hidden = true;
@@ -114,8 +109,7 @@ in
};
};
# Nord chrome for the one profile, applied through the Stylix-managed
# Firefox Color add-on that colorTheme enables.
# Applies the Nord theme through Stylix's Firefox Color add-on.
stylix.targets.firefox = {
enable = true;
profileNames = [ "default" ];
@@ -133,8 +127,8 @@ in
};
};
# Firefox writes profiles.ini itself on first launch, so home-manager is
# told to own the file rather than fail activation refusing to clobber it.
# Firefox creates `profiles.ini` on its first launch.
# Force replacement during Home Manager activation.
home.file."${hm.config.programs.firefox.configPath}/profiles.ini".force = true;
};
};

View File

@@ -2,6 +2,7 @@
config,
lib,
pkgs,
util,
...
}:
# Ghostty as the desktop terminal.
@@ -12,6 +13,19 @@ in
options.modules.desktop.apps.ghostty.enable = lib.mkEnableOption "Ghostty as the desktop terminal";
config = lib.mkIf config.modules.desktop.apps.ghostty.enable {
registry.keybind.${config.networking.hostName}.ghostty = {
keys = "SUPER + Return";
action = {
kind = "exec";
command = lib.getExe pkgs.ghostty;
};
};
registry.window-icon.${config.networking.hostName}.ghostty = {
class = "com.mitchellh.ghostty";
icon = util.glyph "f120";
};
home-manager.users.${user}.home.packages = [ pkgs.ghostty ];
};
}

View File

@@ -2,6 +2,7 @@
config,
lib,
pkgs,
util,
...
}:
# Obsidian as the desktop note-taking vault.
@@ -14,5 +15,10 @@ in
config = lib.mkIf config.modules.desktop.apps.obsidian.enable {
home-manager.users.${user}.home.packages = [ pkgs.obsidian ];
registry.window-icon.${config.networking.hostName}.obsidian = {
class = "obsidian";
icon = util.glyph "f02d";
};
};
}

View File

@@ -2,6 +2,7 @@
config,
lib,
pkgs,
util,
...
}:
# Signal desktop messenger.
@@ -13,5 +14,10 @@ in
config = lib.mkIf config.modules.desktop.apps.signal.enable {
home-manager.users.${user}.home.packages = [ pkgs.signal-desktop ];
registry.window-icon.${config.networking.hostName}.signal = {
class = "[Ss]ignal";
icon = util.glyph "f4ad";
};
};
}

View File

@@ -2,6 +2,7 @@
config,
lib,
pkgs,
util,
...
}:
# Steam game launcher and runtime integration.
@@ -23,5 +24,10 @@
environment.systemPackages = [
pkgs.gamescope
];
registry.window-icon.${config.networking.hostName}.steam = {
class = "[Ss]team";
icon = util.glyph "f1b6";
};
};
}

View File

@@ -2,6 +2,7 @@
config,
lib,
pkgs,
util,
...
}:
# Vesktop, a Discord client with Vencord built in.
@@ -14,5 +15,10 @@ in
config = lib.mkIf config.modules.desktop.apps.vencord.enable {
home-manager.users.${user}.home.packages = [ pkgs.vesktop ];
registry.window-icon.${config.networking.hostName}.vesktop = {
class = "[Vv]esktop";
icon = util.glyph "f1ff";
};
};
}

View File

@@ -23,5 +23,6 @@
modules.desktop.utilities.screenshot.enable = lib.mkDefault true;
modules.desktop.utilities.theming.enable = lib.mkDefault true;
modules.desktop.utilities.userdirs.enable = lib.mkDefault true;
registry.env.${config.networking.hostName}.NIXOS_OZONE_WL = "1";
};
}

View File

@@ -1,42 +1,15 @@
{
config,
lib,
pkgs,
util,
...
}:
# The Hyprland compositor.
let
cfg = config.modules.desktop.session.hyprland; # NOLINT(let_order)
cursor = config.stylix.cursor;
# 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);
user = config.user.name;
# Flip the tiling strategy between the two built-in layouts, since neither a
# dispatcher nor a keyword toggles it on its own.
toggleLayout = pkgs.writeShellScript "hypr-toggle-layout" ''
if [ "$(hyprctl getoption -j general:layout | ${pkgs.jq}/bin/jq -r .str)" = dwindle ]; then
hyprctl keyword general:layout master
else
hyprctl keyword general:layout dwindle
fi
'';
in
{
options.modules.desktop.session.hyprland = {
enable = lib.mkEnableOption "the Hyprland compositor";
options.modules.desktop.session.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 config.modules.desktop.session.hyprland.enable {
config = lib.mkIf cfg.enable {
# This program integration owns the session, portals, and polkit, launched
# through the universal Wayland session manager.
programs.hyprland = {
@@ -44,7 +17,7 @@ in
withUWSM = true;
};
home-manager.users.${user}.wayland.windowManager.hyprland = {
home-manager.users.${config.user.name}.wayland.windowManager.hyprland = {
enable = true;
# One package drives the whole session, so there is never a version split.
@@ -56,135 +29,216 @@ in
# 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";
# Write the native Lua Hyprland config. Home Manager renders the Nix
# settings below as hl.* calls.
configType = "lua";
settings = {
"$mod" = "SUPER";
"$terminal" = "ghostty";
# Session variables handed to the compositor directly.
# UWSM launches the session without the shell profile that would carry
# them, so a variable the compositor or its children must see is set
# here rather than through home-manager's sessionVariables.
env = [
# Chromium and Electron apps read this to select native Wayland;
# nixpkgs wrappers (Obsidian's included) gate their Wayland flags on
# it, so without it they run under XWayland and blur at this DPI.
"NIXOS_OZONE_WL,1"
]
# Bibata ships XCursor only.
# The hyprcursor variables name the same theme, which Hyprland
# resolves through its XCursor fallback.
++ lib.optionals (cursor != null) [
"XCURSOR_THEME,${cursor.name}"
"XCURSOR_SIZE,${toString cursor.size}"
"HYPRCURSOR_THEME,${cursor.name}"
"HYPRCURSOR_SIZE,${toString cursor.size}"
];
input = {
kb_layout = "us";
# Caps is a second Escape.
# Shift+Caps still toggles a real CapsLock.
kb_options = "caps:escape_shifted_capslock";
# Snappy key repeat.
repeat_delay = 250;
repeat_rate = 45;
accel_profile = "flat";
touchpad = {
natural_scroll = true;
tap-to-click = true;
disable_while_typing = true;
config = {
input = {
kb_layout = config.services.xserver.xkb.layout;
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 = false;
};
xwayland.force_zero_scaling = true;
dwindle.preserve_split = true;
};
general = {
gaps_in = 4;
gaps_out = 8;
border_size = 2;
layout = "dwindle";
};
decoration = {
rounding = 6;
blur.enabled = cfg.blur;
};
# XWayland clients render at the panel's native resolution instead of
# being raster-scaled by the compositor at the fractional monitor scale.
xwayland.force_zero_scaling = true;
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"
# Layer surfaces like the launcher fade in a touch quicker than windows.
"layersIn, 1, 2, ease"
"fadeLayersIn, 1, 2, ease"
"workspaces, 1, 3, ease"
"border, 1, 3, ease"
env = lib.mapAttrsToList (name: value: {
_args = [
name
value
];
}) (lib.attrByPath [ config.networking.hostName ] { } config.registry.env);
bind = lib.mapAttrsToList (_name: keybind: {
_args = [
keybind.keys
(lib.generators.mkLuaInline (
{
exec = "hl.dsp.exec_cmd(${lib.generators.toLua { } keybind.action.command})";
focus = "hl.dsp.focus(${
lib.generators.toLua { } { inherit (keybind.action) direction workspace; }
})";
"move-window" = "hl.dsp.window.move(${
lib.generators.toLua { } { inherit (keybind.action) direction workspace; }
})";
"resize-window" = "hl.dsp.window.resize(${
lib.generators.toLua { } (
{
down = {
x = 0;
y = 10;
relative = true;
};
left = {
x = -10;
y = 0;
relative = true;
};
right = {
x = 10;
y = 0;
relative = true;
};
up = {
x = 0;
y = -10;
relative = true;
};
}
.${keybind.action.direction}
)
})";
"toggle-floating" = ''hl.dsp.window.float({ action = "toggle" })'';
"toggle-fullscreen" = "hl.dsp.window.fullscreen()";
"close-window" = "hl.dsp.window.close()";
}
.${keybind.action.kind}
))
]
++ lib.optional keybind.repeating {
repeating = true;
}
++ lib.optional keybind.locked {
locked = true;
};
}) (lib.attrByPath [ config.networking.hostName ] { } config.registry.keybind);
};
};
registry.keybind.${config.networking.hostName} = lib.mkMerge [
# Generate numeric workspace keybinds.
(lib.listToAttrs (
lib.concatMap (workspace: [
{
name = "hyprland-workspace-${toString workspace}";
value = {
keys = "SUPER + ${toString workspace}";
action = {
kind = "focus";
inherit workspace;
};
};
}
{
name = "hyprland-move-to-workspace-${toString workspace}";
value = {
keys = "SUPER + SHIFT + ${toString workspace}";
action = {
kind = "move-window";
inherit workspace;
};
};
}
]) (lib.range 1 9)
))
# Generate directional keybinds.
(lib.listToAttrs (
lib.concatMap
(
{ direction, key }:
[
{
name = "hyprland-focus-${direction}";
value = {
keys = "SUPER + ${key}";
action = {
kind = "focus";
inherit direction;
};
};
}
{
name = "hyprland-move-${direction}";
value = {
keys = "SUPER + SHIFT + ${key}";
action = {
kind = "move-window";
inherit direction;
};
};
}
{
name = "hyprland-resize-${direction}";
value = {
keys = "SUPER + ALT + ${key}";
repeating = true;
action = {
kind = "resize-window";
inherit direction;
};
};
}
]
)
[
{
key = "H";
direction = "left";
}
{
key = "J";
direction = "down";
}
{
key = "K";
direction = "up";
}
{
key = "L";
direction = "right";
}
]
))
{
hyprland-toggle-floating = {
keys = "SUPER + Space";
action.kind = "toggle-floating";
};
dwindle = {
preserve_split = true;
hyprland-toggle-fullscreen = {
keys = "SUPER + F";
action.kind = "toggle-fullscreen";
};
bind = [
"$mod, Return, exec, $terminal"
hyprland-close-window = {
keys = "SUPER + SHIFT + Q";
action.kind = "close-window";
};
}
];
# 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,"
# togglesplit is a dwindle layout message, reached through layoutmsg.
"$mod, T, layoutmsg, togglesplit"
"$mod SHIFT, T, exec, ${toggleLayout}"
"$mod SHIFT, Q, killactive,"
"$mod CTRL, Q, forcekillactive,"
]
++ workspaceBinds;
# Volume and brightness keys repeat while held, each raising a popup
# through the OSD client.
# Volume is capped at 100 percent.
# The client floors brightness so a full hold cannot black the screen out.
binde = [
", XF86AudioRaiseVolume, exec, ${pkgs.swayosd}/bin/swayosd-client --output-volume raise --max-volume 100"
", XF86AudioLowerVolume, exec, ${pkgs.swayosd}/bin/swayosd-client --output-volume lower"
", XF86MonBrightnessUp, exec, ${pkgs.swayosd}/bin/swayosd-client --brightness raise"
", XF86MonBrightnessDown, exec, ${pkgs.swayosd}/bin/swayosd-client --brightness lower"
];
# Mute and media transport still fire while the session is locked.
bindl = [
", XF86AudioMute, exec, ${pkgs.swayosd}/bin/swayosd-client --output-volume mute-toggle"
", XF86AudioPlay, exec, ${pkgs.playerctl}/bin/playerctl play-pause"
", XF86AudioNext, exec, ${pkgs.playerctl}/bin/playerctl next"
", XF86AudioPrev, exec, ${pkgs.playerctl}/bin/playerctl previous"
];
registry.waybar.${config.networking.hostName}."hyprland/workspaces" = {
position = "left";
order = 10;
settings = {
format = "{id} {windows}";
format-window-separator = " ";
window-rewrite-default = util.glyph "f2d0";
window-rewrite = lib.mapAttrs' (
_name: windowIcon: lib.nameValuePair "class<${windowIcon.class}>" windowIcon.icon
) (lib.attrByPath [ config.networking.hostName ] { } config.registry.window-icon);
};
};
};

View File

@@ -5,63 +5,34 @@
...
}:
# hypridle: idle-triggered locking and display power management.
let
cfg = config.modules.desktop.session.hyprland.hypridle; # NOLINT(let_order)
# 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}";
user = config.user.name;
hyprctl = "${config.programs.hyprland.package}/bin/hyprctl";
hyprlock = "${config.home-manager.users.${user}.programs.hyprlock.package}/bin/hyprlock";
in
{
options.modules.desktop.session.hyprland.hypridle = {
enable = lib.mkEnableOption "hypridle idle management";
options.modules.desktop.session.hyprland.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 {
config = lib.mkIf config.modules.desktop.session.hyprland.hypridle.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 = {
home-manager.users.${config.user.name}.services.hypridle = {
enable = true;
settings = {
general = {
lock_cmd = lockCmd;
# The guard drops the call when a locker is already up, so no idle
# trigger stacks a second hyprlock over the first.
lock_cmd = "${lib.getExe' pkgs.procps "pidof"} hyprlock || ${lib.getExe pkgs.hyprlock}";
before_sleep_cmd = "loginctl lock-session";
# Waking restores the displays the screen-off listener may have cut.
after_sleep_cmd = "${hyprctl} dispatch dpms on";
# Waking pokes the compositor in case firmware leaves displays dark.
after_sleep_cmd = "${lib.getExe' config.programs.hyprland.package "hyprctl"} dispatch 'hl.dsp.dpms({ action = \"enable\" })'";
};
listener = [
# Lock on idle.
{
timeout = cfg.lockTimeout;
timeout = 300;
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";
}
];
};
};

View File

@@ -2,44 +2,54 @@
config,
lib,
pkgs,
util,
...
}:
# The lock screen: a hyprlock session-lock surface the compositor owns.
let
user = config.user.name;
# The hyprlock this module installs, used by the lock keybind below.
hyprlock = "${config.home-manager.users.${user}.programs.hyprlock.package}/bin/hyprlock";
sleepIcon = builtins.fromJSON ''"\udb81\udcb2"'';
sleepOffIcon = builtins.fromJSON ''"\udb81\udcb3"'';
sleepIcon = util.glyph [
"db81"
"dcb2"
];
sleepOffIcon = util.glyph [
"db81"
"dcb3"
];
idleLockToggle = pkgs.writeShellScript "lockscreen-idle-inhibit-toggle" ''
unit=lockscreen-idle-inhibit.service
refresh_waybar() {
${pkgs.procps}/bin/pkill -RTMIN+9 waybar >/dev/null 2>&1 || true
${lib.getExe' pkgs.procps "pkill"} -RTMIN+9 waybar >/dev/null 2>&1 || true
}
if ${pkgs.systemd}/bin/systemctl --user is-active --quiet "$unit"; then
${pkgs.systemd}/bin/systemctl --user stop "$unit"
if ${lib.getExe' pkgs.systemd "systemctl"} --user is-active --quiet "$unit"; then
${lib.getExe' pkgs.systemd "systemctl"} --user stop "$unit"
refresh_waybar
${pkgs.swayosd}/bin/swayosd-client --custom-message "${sleepIcon}"
${lib.getExe' pkgs.swayosd "swayosd-client"} --custom-message "${sleepIcon}"
exit 0
fi
if ${pkgs.systemd}/bin/systemd-run \
if ${lib.getExe' pkgs.systemd "systemd-run"} \
--user \
--collect \
--unit=lockscreen-idle-inhibit \
--description="Disable idle-triggered screen locking" \
${pkgs.systemd}/bin/systemd-inhibit \
${lib.getExe' pkgs.systemd "systemd-inhibit"} \
--what=idle \
--who=lockscreen-hotkey \
--why="lock screen disabled by hotkey" \
${pkgs.coreutils}/bin/sleep infinity >/dev/null; then
${lib.getExe' pkgs.coreutils "sleep"} infinity >/dev/null; then
refresh_waybar
${pkgs.swayosd}/bin/swayosd-client --custom-message "${sleepOffIcon}"
${lib.getExe' pkgs.swayosd "swayosd-client"} --custom-message "${sleepOffIcon}"
fi
'';
idleLockStatus = pkgs.writeShellScript "waybar-idle-lock-status" ''
if ${lib.getExe' pkgs.systemd "systemctl"} --user is-active --quiet lockscreen-idle-inhibit.service; then
printf '{"text":"${sleepOffIcon}","tooltip":"Idle locking inhibited","class":"inhibited"}\n'
else
printf '{"text":"","class":"idle"}\n'
fi
'';
in
@@ -48,7 +58,7 @@ in
lib.mkEnableOption "the hyprlock lock screen";
config = lib.mkIf config.modules.desktop.session.hyprland.hyprlock.enable {
home-manager.users.${user} = {
home-manager.users.${config.user.name} = {
# Colors and the lock-screen background come from Stylix's hyprlock target,
# so only geometry and behaviour are set here.
programs.hyprlock = {
@@ -81,14 +91,36 @@ in
};
};
};
};
# 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}"
"$mod CTRL, X, exec, ${idleLockToggle}"
];
registry.keybind.${config.networking.hostName} = {
hyprlock-lock = {
keys = "SUPER + X";
action = {
kind = "exec";
# The guard drops the call when a locker is already up, so no manual
# trigger stacks a second hyprlock over the first.
command = "${lib.getExe' pkgs.procps "pidof"} hyprlock || ${lib.getExe pkgs.hyprlock}";
};
};
hyprlock-toggle-idle-inhibit = {
keys = "SUPER + CTRL + X";
action = {
kind = "exec";
command = "${idleLockToggle}";
};
};
};
registry.waybar.${config.networking.hostName}."custom/idle-lock" = {
order = 20;
settings = {
return-type = "json";
exec = "${idleLockStatus}";
interval = "once";
signal = 9;
};
};
};
}

View File

@@ -13,9 +13,13 @@
config = lib.mkIf config.modules.desktop.session.login.enable {
services.greetd = {
enable = true;
settings.default_session.command =
"${lib.getExe pkgs.tuigreet} --time --remember "
+ "--cmd 'uwsm start -e -D Hyprland hyprland.desktop'";
settings.default_session.command = lib.escapeShellArgs [
(lib.getExe pkgs.tuigreet)
"--time"
"--remember"
"--cmd"
"${lib.getExe config.programs.uwsm.package} start -e -D Hyprland hyprland.desktop"
];
};
};
}

View File

@@ -2,19 +2,29 @@
config,
lib,
pkgs,
util,
...
}:
# mako as the notification daemon: toasts, a do-not-disturb mode, and history recall.
let
user = config.user.name;
dndStatus = pkgs.writeShellScript "waybar-dnd-status" ''
if ${lib.getExe' pkgs.mako "makoctl"} mode 2>/dev/null | ${lib.getExe pkgs.gnugrep} -qx dnd; then
printf '{"text":"${util.glyph "f1f6"}","tooltip":"Do not disturb: on","class":"dnd"}\n'
else
printf '{"text":"${util.glyph "f0f3"}","tooltip":"Notifications on","class":"active"}\n'
fi
'';
makoctl = "${pkgs.mako}/bin/makoctl";
dndToggle = pkgs.writeShellScript "waybar-dnd-toggle" ''
${lib.getExe' pkgs.mako "makoctl"} mode -t dnd >/dev/null 2>&1
${lib.getExe' pkgs.procps "pkill"} -RTMIN+8 waybar
'';
in
{
options.modules.desktop.session.mako.enable = lib.mkEnableOption "the mako notification daemon";
config = lib.mkIf config.modules.desktop.session.mako.enable {
home-manager.users.${user} = {
home-manager.users.${config.user.name} = {
# Colors and the popup font come from Stylix's mako target, so only
# behaviour is set here.
services.mako = {
@@ -31,13 +41,25 @@ in
"mode=dnd".invisible = true;
};
};
};
# Recall the last notification from history, keyboard-only like the rest
# of the session.
# $mod is defined by the compositor config these binds share.
wayland.windowManager.hyprland.settings.bind = [
"$mod, N, exec, ${makoctl} restore"
];
registry.keybind.${config.networking.hostName}.mako-history = {
keys = "SUPER + N";
action = {
kind = "exec";
command = "${lib.getExe' pkgs.mako "makoctl"} restore";
};
};
registry.waybar.${config.networking.hostName}."custom/dnd" = {
order = 80;
settings = {
return-type = "json";
exec = "${dndStatus}";
on-click = "${dndToggle}";
interval = "once";
signal = 8;
};
};
};
}

View File

@@ -6,19 +6,17 @@
}:
# rofi as the desktop launcher and dmenu-style menu frontend.
let
user = config.user.name;
# A session menu built on the same themed rofi, so the power actions look
# like every other menu the launcher drives.
powerMenu = pkgs.writeShellScript "rofi-power-menu" ''
chosen=$(printf '%s\n' Lock Suspend 'Log out' Reboot 'Shut down' \
| ${pkgs.rofi}/bin/rofi -dmenu -i -p Power)
| ${lib.getExe pkgs.rofi} -dmenu -i -p Power)
case "$chosen" in
Lock) ${pkgs.systemd}/bin/loginctl lock-session ;;
Suspend) ${pkgs.systemd}/bin/systemctl suspend ;;
'Log out') ${pkgs.systemd}/bin/loginctl terminate-session "$XDG_SESSION_ID" ;;
Reboot) ${pkgs.systemd}/bin/systemctl reboot ;;
'Shut down') ${pkgs.systemd}/bin/systemctl poweroff ;;
Lock) ${lib.getExe' pkgs.systemd "loginctl"} lock-session ;;
Suspend) ${lib.getExe' pkgs.systemd "systemctl"} suspend ;;
'Log out') ${lib.getExe' pkgs.systemd "loginctl"} terminate-session "$XDG_SESSION_ID" ;;
Reboot) ${lib.getExe' pkgs.systemd "systemctl"} reboot ;;
'Shut down') ${lib.getExe' pkgs.systemd "systemctl"} poweroff ;;
esac
'';
in
@@ -26,10 +24,11 @@ in
options.modules.desktop.session.rofi.enable = lib.mkEnableOption "the rofi launcher";
config = lib.mkIf config.modules.desktop.session.rofi.enable {
home-manager.users.${user} = {
# No theme is set here: Stylix's rofi target supplies the Nord one.
home-manager.users.${config.user.name} = hm: {
# Stylix supplies the Nord theme.
programs.rofi = {
enable = true;
font = lib.mkForce "JetBrains Mono 12";
# Application launch is the only mode, kept minimal so the prompt opens
# as fast as rofi can read the desktop entries.
@@ -38,14 +37,31 @@ in
modi = "drun";
show-icons = false;
};
theme.window = {
border = hm.config.lib.formats.rasi.mkLiteral "2px";
border-color = hm.config.lib.formats.rasi.mkLiteral "#FFFFFF";
border-radius = hm.config.lib.formats.rasi.mkLiteral "8px";
};
};
};
registry.keybind.${config.networking.hostName} = {
rofi-launcher = {
keys = "SUPER + R";
action = {
kind = "exec";
command = "${lib.getExe pkgs.rofi} -show drun";
};
};
# The launcher and the session menu, kept next to the tool they drive.
# $mod is defined by the compositor config these binds share.
wayland.windowManager.hyprland.settings.bind = [
"$mod, R, exec, ${pkgs.rofi}/bin/rofi -show drun"
"$mod SHIFT, X, exec, ${powerMenu}"
];
rofi-power-menu = {
keys = "SUPER + SHIFT + X";
action = {
kind = "exec";
command = "${powerMenu}";
};
};
};
};
}

View File

@@ -6,97 +6,19 @@
}:
# Waybar: a top status bar reading system state at a glance.
let
vpnClient = config.modules.network.vpn-client;
moduleNames =
position:
map (entry: entry.name) (
lib.sort (left: right: left.module.order < right.module.order) (
lib.filter (entry: entry.module.position == position) moduleEntries
)
);
# A Nerd Font Private-Use-Area codepoint as a literal character, decoded
# through JSON so the glyph survives as bytes rather than an editor paste.
g = code: builtins.fromJSON ''"\u${code}"'';
user = config.user.name;
moduleEntries = lib.mapAttrsToList (name: module: {
inherit name module;
}) modules;
sleepOffIcon = builtins.fromJSON ''"\udb81\udcb3"'';
# Renders and toggles mako's do-not-disturb mode.
# With no mako daemon answering, the status reads "notifications on", keeping
# the widget inert rather than broken.
dndStatus = pkgs.writeShellScript "waybar-dnd-status" ''
if ${pkgs.mako}/bin/makoctl mode 2>/dev/null | ${pkgs.gnugrep}/bin/grep -qx dnd; then
printf '{"text":"${g "f1f6"}","tooltip":"Do not disturb: on","class":"dnd"}\n'
else
printf '{"text":"${g "f0f3"}","tooltip":"Notifications on","class":"active"}\n'
fi
'';
dndToggle = pkgs.writeShellScript "waybar-dnd-toggle" ''
${pkgs.mako}/bin/makoctl mode -t dnd >/dev/null 2>&1
${pkgs.procps}/bin/pkill -RTMIN+8 waybar
'';
# Lit while a wf-recorder capture is running.
# Empty text collapses the widget when idle, so it shows nothing until then.
recStatus = pkgs.writeShellScript "waybar-recording-status" ''
if ${pkgs.procps}/bin/pgrep -x wf-recorder >/dev/null; then
printf '{"text":"${g "f03d"}","tooltip":"Recording","class":"recording"}\n'
else
printf '{"text":"","class":"idle"}\n'
fi
'';
idleLockStatus = pkgs.writeShellScript "waybar-idle-lock-status" ''
if ${pkgs.systemd}/bin/systemctl --user is-active --quiet lockscreen-idle-inhibit.service; then
printf '{"text":"${sleepOffIcon}","tooltip":"Idle locking inhibited","class":"inhibited"}\n'
else
printf '{"text":"","class":"idle"}\n'
fi
'';
vpnStatus = pkgs.writeShellScript "waybar-vpn-status" ''
interface=${lib.escapeShellArg vpnClient.interface}
if ! ${pkgs.iproute2}/bin/ip link show dev "$interface" >/dev/null 2>&1; then
printf '{"text":"","class":"idle"}\n'
exit 0
fi
ipaddr=$(${pkgs.iproute2}/bin/ip -4 -o addr show dev "$interface" | ${pkgs.gawk}/bin/awk '{ print $4; exit }')
${pkgs.jq}/bin/jq -cn \
--arg text "󰲝" \
--arg tooltip "VPN $interface: ''${ipaddr:-no IPv4}" \
--arg class "active" \
'{ text: $text, tooltip: $tooltip, class: $class }'
'';
networkStatus = pkgs.writeShellScript "waybar-network-status" ''
active=$(
${pkgs.networkmanager}/bin/nmcli -t -f DEVICE,TYPE,STATE,CONNECTION device status \
| ${pkgs.gawk}/bin/awk -F: '$3 == "connected" && $2 != "loopback" { print; exit }'
)
if [ -z "$active" ]; then
${pkgs.jq}/bin/jq -cn \
--arg text "${g "f127"} off" \
--arg tooltip "Network disconnected" \
--arg class "disconnected" \
'{ text: $text, tooltip: $tooltip, class: $class }'
exit 0
fi
IFS=: read -r device type _state connection <<< "$active"
case "$type" in
wifi) icon="${g "f1eb"}" ;;
ethernet) icon="LAN" ;;
*) icon="NET" ;;
esac
ipaddr=$(${pkgs.iproute2}/bin/ip -4 -o addr show dev "$device" | ${pkgs.gawk}/bin/awk '{ print $4; exit }')
${pkgs.jq}/bin/jq -cn \
--arg text "$icon $connection" \
--arg tooltip "$device: ''${ipaddr:-no IPv4}" \
--arg class "$type" \
'{ text: $text, tooltip: $tooltip, class: $class }'
'';
modules = lib.attrByPath [ config.networking.hostName ] { } config.registry.waybar;
in
{
options.modules.desktop.session.waybar.enable = lib.mkEnableOption "the Waybar status bar";
@@ -106,65 +28,34 @@ in
# codepoints Stylix's monospace font does not carry.
fonts.packages = [ pkgs.nerd-fonts.symbols-only ];
home-manager.users.${user}.programs.waybar = {
home-manager.users.${config.user.name}.programs.waybar = {
enable = true;
# A user service bound to graphical-session.target, which uwsm activates,
# so the bar comes up with the session and no compositor exec-once.
systemd.enable = true;
style = ''
.modules-right {
margin-right: 8px;
settings.mainBar = lib.mkMerge [
{
layer = "top";
position = "top";
height = 30;
spacing = 8;
margin-right = 8;
modules-left = moduleNames "left";
modules-center = moduleNames "center";
modules-right = moduleNames "right";
}
(lib.mapAttrs (_name: module: module.settings) modules)
];
};
#custom-dnd {
margin-right: 8px;
}
'';
settings.mainBar = {
layer = "top";
position = "top";
height = 30;
spacing = 8;
modules-left = [ "hyprland/workspaces" ];
modules-center = [ "clock" ];
modules-right = [
"custom/recording"
"custom/idle-lock"
"mpris"
"wireplumber"
]
++ lib.optional vpnClient.enable "custom/vpn"
++ [
"custom/network"
"battery"
"custom/dnd"
];
# Per-application icons for the windows on each workspace, mapped from
# window class with a neutral glyph for anything unlisted.
"hyprland/workspaces" = {
on-click = "activate";
format = "{id} {windows}";
format-window-separator = " ";
window-rewrite-default = g "f2d0";
window-rewrite = {
"class<Alacritty>" = g "f120";
"class<com.mitchellh.ghostty>" = g "f120";
"class<.*[Ff]irefox.*>" = g "f269";
"class<chromium.*>" = g "f268";
"class<[Cc]ode>" = g "f121";
"class<obsidian>" = g "f02d";
"class<[Ss]ignal>" = g "f4ad";
"class<[Ss]team>" = g "f1b6";
"class<[Vv]esktop>" = g "f1ff";
};
};
clock = {
registry.waybar.${config.networking.hostName} = {
clock = {
position = "center";
order = 10;
settings = {
format = "{:%a %d %b %H:%M}";
tooltip-format = "<tt>{calendar}</tt>";
calendar = {
@@ -173,77 +64,6 @@ in
format.today = "<b><u>{}</u></b>";
};
};
mpris = {
format = "${g "f001"} {title}";
format-paused = "${g "f04c"} {title}";
max-length = 40;
on-click = "play-pause";
on-scroll-up = "next";
on-scroll-down = "previous";
};
wireplumber = {
format = "{icon} {volume}%";
format-muted = "${g "f026"} muted";
format-icons = [
(g "f026")
(g "f027")
(g "f028")
];
on-click = "${pkgs.wireplumber}/bin/wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle";
};
"custom/vpn" = {
return-type = "json";
exec = "${vpnStatus}";
interval = 5;
};
"custom/network" = {
return-type = "json";
exec = "${networkStatus}";
interval = 5;
};
battery = {
format = "{icon} {capacity}%";
format-charging = "${g "f0e7"} {capacity}%";
format-icons = [
(g "f244")
(g "f243")
(g "f242")
(g "f241")
(g "f240")
];
states = {
warning = 20;
critical = 10;
};
};
"custom/dnd" = {
return-type = "json";
exec = "${dndStatus}";
on-click = "${dndToggle}";
interval = "once";
signal = 8;
};
# Recording state has no event to hook, so the widget samples the
# wf-recorder process once a second.
"custom/recording" = {
return-type = "json";
exec = "${recStatus}";
interval = 1;
};
"custom/idle-lock" = {
return-type = "json";
exec = "${idleLockStatus}";
interval = "once";
signal = 9;
};
};
};
};

View File

@@ -1,5 +1,21 @@
{ config, lib, ... }:
{
config,
lib,
pkgs,
util,
...
}:
# PipeWire as the desktop audio server.
let
mediaKey = keys: command: {
inherit keys;
locked = true;
action = {
kind = "exec";
command = "${lib.getExe pkgs.playerctl} ${command}";
};
};
in
{
options.modules.desktop.utilities.audio.enable = lib.mkEnableOption "the PipeWire audio server";
@@ -12,5 +28,39 @@
alsa.enable = true;
pulse.enable = true;
};
registry.keybind.${config.networking.hostName} = {
media-play = mediaKey "XF86AudioPlay" "play-pause";
media-next = mediaKey "XF86AudioNext" "next";
media-previous = mediaKey "XF86AudioPrev" "previous";
};
registry.waybar.${config.networking.hostName} = {
mpris = {
order = 30;
settings = {
format = "${util.glyph "f001"} {title}";
format-paused = "${util.glyph "f04c"} {title}";
max-length = 40;
on-click = "play-pause";
on-scroll-up = "next";
on-scroll-down = "previous";
};
};
wireplumber = {
order = 40;
settings = {
format = "{icon} {volume}%";
format-muted = "${util.glyph "f026"} muted";
format-icons = [
(util.glyph "f026")
(util.glyph "f027")
(util.glyph "f028")
];
on-click = "${lib.getExe' pkgs.wireplumber "wpctl"} set-mute @DEFAULT_AUDIO_SINK@ toggle";
};
};
};
};
}

View File

@@ -6,21 +6,15 @@
}:
# Clipboard history: cliphist records every copy, picked back through rofi.
let
user = config.user.name;
cliphist = "${pkgs.cliphist}/bin/cliphist";
rofi = "${pkgs.rofi}/bin/rofi";
wl-copy = "${pkgs.wl-clipboard}/bin/wl-copy";
# The picker reuses the themed rofi, so history looks like every other menu
# the launcher drives.
# decode is needed because list emits id-prefixed lines rather than the copied
# bytes, so the chosen id has to be resolved back before it can be re-copied.
picker = pkgs.writeShellScript "clipboard-picker" ''
${cliphist} list \
| ${rofi} -dmenu -i -p Clipboard \
| ${cliphist} decode \
| ${wl-copy}
${lib.getExe pkgs.cliphist} list \
| ${lib.getExe pkgs.rofi} -dmenu -i -p Clipboard \
| ${lib.getExe pkgs.cliphist} decode \
| ${lib.getExe' pkgs.wl-clipboard "wl-copy"}
'';
in
{
@@ -28,7 +22,7 @@ in
lib.mkEnableOption "cliphist clipboard history";
config = lib.mkIf config.modules.desktop.utilities.clipboard.enable {
home-manager.users.${user} = {
home-manager.users.${config.user.name} = {
# wl-copy and wl-paste on PATH, so the shell can pipe into and out of the
# clipboard.
# The watchers and picker above reach wl-clipboard by store path, so this
@@ -38,11 +32,14 @@ in
# Two watchers record text and images to history, bound to the graphical
# session so uwsm starts and stops them with it.
services.cliphist.enable = true;
};
# $mod is defined by the compositor config these binds share.
wayland.windowManager.hyprland.settings.bind = [
"$mod SHIFT, V, exec, ${picker}"
];
registry.keybind.${config.networking.hostName}.clipboard-history = {
keys = "SUPER + SHIFT + V";
action = {
kind = "exec";
command = "${picker}";
};
};
};
}

View File

@@ -13,6 +13,53 @@ in
lib.mkEnableOption "the SwayOSD volume and brightness popup";
config = lib.mkIf config.modules.desktop.utilities.osd.enable {
registry.keybind.${config.networking.hostName} = {
volume-up = {
keys = "XF86AudioRaiseVolume";
repeating = true;
action = {
kind = "exec";
command = "${pkgs.swayosd}/bin/swayosd-client --output-volume raise --max-volume 100";
};
};
volume-down = {
keys = "XF86AudioLowerVolume";
repeating = true;
action = {
kind = "exec";
command = "${pkgs.swayosd}/bin/swayosd-client --output-volume lower";
};
};
brightness-up = {
keys = "XF86MonBrightnessUp";
repeating = true;
action = {
kind = "exec";
command = "${pkgs.swayosd}/bin/swayosd-client --brightness raise";
};
};
brightness-down = {
keys = "XF86MonBrightnessDown";
repeating = true;
action = {
kind = "exec";
command = "${pkgs.swayosd}/bin/swayosd-client --brightness lower";
};
};
mute = {
keys = "XF86AudioMute";
locked = true;
action = {
kind = "exec";
command = "${pkgs.swayosd}/bin/swayosd-client --output-volume mute-toggle";
};
};
};
environment.systemPackages = [ pkgs.swayosd ];
# The udev rule chgrps each backlight's brightness node to `video` and adds

View File

@@ -2,21 +2,11 @@
config,
lib,
pkgs,
util,
...
}:
# Keybound screen recording: pick a region, then toggle a video-only capture.
let
user = config.user.name;
slurp = "${pkgs.slurp}/bin/slurp";
wf-recorder = "${pkgs.wf-recorder}/bin/wf-recorder";
notify-send = "${pkgs.libnotify}/bin/notify-send";
pgrep = "${pkgs.procps}/bin/pgrep";
pkill = "${pkgs.procps}/bin/pkill";
date = "${pkgs.coreutils}/bin/date";
mkdir = "${pkgs.coreutils}/bin/mkdir";
xdgUserDir = "${pkgs.xdg-user-dirs}/bin/xdg-user-dir";
# A single key both starts and stops.
# A running wf-recorder is stopped with SIGINT so it finalises the file.
# Otherwise slurp picks a region and wf-recorder runs in the foreground until
@@ -24,19 +14,29 @@ let
# reports it saved.
# No -a means no audio is captured.
toggle = pkgs.writeShellScript "screen-record-toggle" ''
if ${pgrep} -x wf-recorder >/dev/null; then
${pkill} -INT -x wf-recorder
if ${lib.getExe' pkgs.procps "pgrep"} -x wf-recorder >/dev/null; then
${lib.getExe' pkgs.procps "pkill"} -INT -x wf-recorder
exit 0
fi
region=$(${slurp}) || exit 0
dir="$(${xdgUserDir} VIDEOS)/Recordings"
${mkdir} -p "$dir"
file="$dir/recording-$(${date} +%Y%m%d-%H%M%S).mp4"
region=$(${lib.getExe pkgs.slurp}) || exit 0
dir="$(${lib.getExe' pkgs.xdg-user-dirs "xdg-user-dir"} VIDEOS)/Recordings"
${lib.getExe' pkgs.coreutils "mkdir"} -p "$dir"
file="$dir/recording-$(${lib.getExe' pkgs.coreutils "date"} +%Y%m%d-%H%M%S).mp4"
${notify-send} -a "Screen recording" "Recording started" "Region capture, no audio."
${wf-recorder} -g "$region" -f "$file"
${notify-send} -a "Screen recording" "Recording saved" "$file"
${lib.getExe' pkgs.libnotify "notify-send"} -a "Screen recording" "Recording started" "Region capture, no audio."
${lib.getExe pkgs.wf-recorder} -g "$region" -f "$file"
${lib.getExe' pkgs.libnotify "notify-send"} -a "Screen recording" "Recording saved" "$file"
'';
# Lit while a wf-recorder capture is running.
# Empty text collapses the widget when idle, so it shows nothing until then.
recordingStatus = pkgs.writeShellScript "waybar-recording-status" ''
if ${lib.getExe' pkgs.procps "pgrep"} -x wf-recorder >/dev/null; then
printf '{"text":"${util.glyph "f03d"}","tooltip":"Recording","class":"recording"}\n'
else
printf '{"text":"","class":"idle"}\n'
fi
'';
in
{
@@ -44,11 +44,21 @@ in
lib.mkEnableOption "wf-recorder screen recording";
config = lib.mkIf config.modules.desktop.utilities.recording.enable {
home-manager.users.${user} = {
# $mod is defined by the compositor config these binds share.
wayland.windowManager.hyprland.settings.bind = [
"$mod SHIFT, R, exec, ${toggle}"
];
registry.keybind.${config.networking.hostName}.recording-toggle = {
keys = "SUPER + SHIFT + R";
action = {
kind = "exec";
command = "${toggle}";
};
};
registry.waybar.${config.networking.hostName}."custom/recording" = {
order = 10;
settings = {
return-type = "json";
exec = "${recordingStatus}";
interval = 1;
};
};
};
}

View File

@@ -37,23 +37,42 @@ in
lib.mkEnableOption "screenshots via grimblast and satty";
config = lib.mkIf config.modules.desktop.utilities.screenshot.enable {
registry.keybind.${config.networking.hostName} = {
screenshot-area = {
keys = "Print";
action = {
kind = "exec";
command = "${capture "area"}";
};
};
screenshot-active = {
keys = "SHIFT + Print";
action = {
kind = "exec";
command = "${capture "active"}";
};
};
screenshot-screen = {
keys = "CTRL + Print";
action = {
kind = "exec";
command = "${capture "screen"}";
};
};
};
home-manager.users.${user} = {
# satty is a one-shot annotation surface, so float it rather than letting
# it claim a tile in the layout.
# windowrule v3 syntax: space-separated field/value tokens, comma
# separated, with matchers under a match: prefix and effects bare.
wayland.windowManager.hyprland.settings.windowrule = [
"float 1, match:class ^(com\\.gabm\\.satty)$"
wayland.windowManager.hyprland.settings.window_rule = [
{
match.class = "^(com\\.gabm\\.satty)$";
float = true;
}
];
# Print with plain/Shift/Ctrl for region/window/full.
# Super+L is already the hjkl focus and movement bind, so screenshots take
# the Print key instead.
wayland.windowManager.hyprland.settings.bind = [
", Print, exec, ${capture "area"}"
"SHIFT, Print, exec, ${capture "active"}"
"CTRL, Print, exec, ${capture "screen"}"
];
};
};
}

View File

@@ -58,6 +58,10 @@ in
};
};
};
registry.env.${config.networking.hostName} = {
HYPRCURSOR_THEME = config.stylix.cursor.name;
HYPRCURSOR_SIZE = toString config.stylix.cursor.size;
};
home-manager.users.${user} = {
# Stylix drives the cursor through home-manager's pointer-cursor config,

View File

@@ -30,7 +30,6 @@ in
modules.network.ssh.userKey.sopsFile = hostSecrets;
services.xserver.xkb.layout = "us";
services.xserver.xkb.options = "caps:escape_shifted_capslock";
console.useXkbConfig = true;
sops.defaultSopsFile = ../../secrets/shared.yaml;
@@ -54,6 +53,7 @@ in
home.username = config.user.name;
home.homeDirectory = "/home/${config.user.name}";
home.stateVersion = config.system.stateVersion;
home.sessionVariables = lib.attrByPath [ config.networking.hostName ] { } config.registry.env;
home.packages = with pkgs; [
eza
unzip

View File

@@ -0,0 +1,57 @@
{
config,
lib,
pkgs,
util,
...
}:
# NetworkManager-managed networking.
let
networkStatus = pkgs.writeShellScript "waybar-network-status" ''
active=$(
${lib.getExe' pkgs.networkmanager "nmcli"} -t -f DEVICE,TYPE,STATE,CONNECTION device status \
| ${lib.getExe' pkgs.gawk "awk"} -F: '$3 == "connected" && $2 != "loopback" { print; exit }'
)
if [ -z "$active" ]; then
${lib.getExe pkgs.jq} -cn \
--arg text "${util.glyph "f127"} off" \
--arg tooltip "Network disconnected" \
--arg class "disconnected" \
'{ text: $text, tooltip: $tooltip, class: $class }'
exit 0
fi
IFS=: read -r device type _state connection <<< "$active"
case "$type" in
wifi) icon="${util.glyph "f1eb"}" ;;
ethernet) icon="LAN" ;;
*) icon="NET" ;;
esac
ipaddr=$(${lib.getExe' pkgs.iproute2 "ip"} -4 -o addr show dev "$device" | ${lib.getExe' pkgs.gawk "awk"} '{ print $4; exit }')
${lib.getExe pkgs.jq} -cn \
--arg text "$icon $connection" \
--arg tooltip "$device: ''${ipaddr:-no IPv4}" \
--arg class "$type" \
'{ text: $text, tooltip: $tooltip, class: $class }'
'';
in
{
options.modules.network.enable = lib.mkEnableOption "NetworkManager-managed networking";
config = lib.mkIf config.modules.network.enable {
networking.networkmanager.enable = true;
registry.waybar.${config.networking.hostName}."custom/network" = {
order = 60;
settings = {
return-type = "json";
exec = "${networkStatus}";
interval = 5;
};
};
};
}

View File

@@ -1,10 +1,34 @@
{
config,
lib,
pkgs,
util,
...
}:
let
cfg = config.modules.network.vpn-client; # NOLINT(let_order)
vpnIcon = util.glyph [
"db83"
"dc9d"
];
vpnStatus = pkgs.writeShellScript "waybar-vpn-status" ''
interface=${lib.escapeShellArg cfg.interface}
if ! ${lib.getExe' pkgs.iproute2 "ip"} link show dev "$interface" >/dev/null 2>&1; then
printf '{"text":"","class":"idle"}\n'
exit 0
fi
ipaddr=$(${lib.getExe' pkgs.iproute2 "ip"} -4 -o addr show dev "$interface" | ${lib.getExe' pkgs.gawk "awk"} '{ print $4; exit }')
${lib.getExe pkgs.jq} -cn \
--arg text "${vpnIcon}" \
--arg tooltip "VPN $interface: ''${ipaddr:-no IPv4}" \
--arg class "active" \
'{ text: $text, tooltip: $tooltip, class: $class }'
'';
in
{
options.modules.network.vpn-client = {
@@ -57,6 +81,15 @@ in
vpn-off = "sudo systemctl stop 'wireguard-${cfg.interface}*.service' wireguard-${cfg.interface}.target";
};
registry.waybar.${config.networking.hostName}."custom/vpn" = {
order = 50;
settings = {
return-type = "json";
exec = "${vpnStatus}";
interval = 5;
};
};
systemd.targets."wireguard-${cfg.interface}".wantedBy = lib.mkForce [ ];
networking.wireguard.interfaces.${cfg.interface} = {

View File

@@ -13,9 +13,8 @@ in
lib.mkEnableOption "Python tools for the primary user's shell environment";
config = lib.mkIf config.modules.shell.python.enable {
home-manager.users.${user}.home = {
sessionVariables.PYTHON_TOOLCHAIN_ROOT = "${pkgs.python3}";
packages = [ pkgs.python3 ];
};
registry.env.${config.networking.hostName}.PYTHON_TOOLCHAIN_ROOT = "${pkgs.python3}";
home-manager.users.${user}.home.packages = [ pkgs.python3 ];
};
}

View File

@@ -23,9 +23,9 @@ in
lib.mkEnableOption "Rust tools for the primary user's shell environment";
config = lib.mkIf config.modules.shell.rust.enable {
home-manager.users.${user} = {
home.sessionVariables.RUST_TOOLCHAIN_ROOT = "${toolchain}";
registry.env.${config.networking.hostName}.RUST_TOOLCHAIN_ROOT = "${toolchain}";
home-manager.users.${user} = {
home.packages = [
toolchain
pkgs.rust-analyzer

View File

@@ -0,0 +1,4 @@
{ lib, ... }:
{
type = lib.types.str;
}

View File

@@ -0,0 +1,112 @@
{ lib, util, ... }:
{
checks = [
{
assertion = util.registry.predicates.uniquePerHost (entry: entry.value.keys);
message = "Desktop keybinds must use unique key chords per host.";
}
{
assertion =
entries:
lib.all (
entry:
(entry.value.action.kind == "exec" && entry.value.action.command != null)
|| (
builtins.elem entry.value.action.kind [
"focus"
"move-window"
]
&& lib.xor (entry.value.action.direction != null) (entry.value.action.workspace != null)
)
|| (
entry.value.action.kind == "resize-window"
&& entry.value.action.direction != null
&& entry.value.action.workspace == null
)
|| (
builtins.elem entry.value.action.kind [
"toggle-floating"
"toggle-fullscreen"
"close-window"
]
&& entry.value.action.command == null
&& entry.value.action.direction == null
&& entry.value.action.workspace == null
)
) entries;
message = "Desktop keybind actions must set the fields their kind requires.";
}
];
type = lib.types.submodule {
options = {
keys = lib.mkOption {
description = "Sets the key chord for this desktop keybind.";
type = lib.types.str;
example = "SUPER + Return";
};
repeating = lib.mkOption {
description = "Repeats the keybind action while the key chord is held.";
type = lib.types.bool;
default = false;
example = true;
};
locked = lib.mkOption {
description = "Allows the keybind action while the session is locked.";
type = lib.types.bool;
default = false;
example = true;
};
action = lib.mkOption {
description = "Declares the action this keybind runs.";
type = lib.types.submodule {
options = {
kind = lib.mkOption {
description = "Selects the keybind action kind.";
type = lib.types.enum [
"exec"
"focus"
"move-window"
"resize-window"
"toggle-floating"
"toggle-fullscreen"
"close-window"
];
};
command = lib.mkOption {
description = "Command executed by an exec keybind.";
type = lib.types.nullOr lib.types.str;
default = null;
example = "ghostty";
};
direction = lib.mkOption {
description = "Direction targeted by a keybind action.";
type = lib.types.nullOr (
lib.types.enum [
"down"
"left"
"right"
"up"
]
);
default = null;
example = "left";
};
workspace = lib.mkOption {
description = "Workspace targeted by a keybind action.";
type = lib.types.nullOr lib.types.int;
default = null;
example = 1;
};
};
};
};
};
};
}

View File

@@ -0,0 +1,29 @@
{ lib, ... }:
{
type = lib.types.submodule {
options = {
position = lib.mkOption {
type = lib.types.enum [
"left"
"center"
"right"
];
default = "right";
description = "Selects the Waybar section that shows the module.";
};
order = lib.mkOption {
type = lib.types.int;
default = 100;
example = 50;
description = "Sorts the module within its Waybar section.";
};
settings = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
default = { };
description = "Sets the Waybar module configuration.";
};
};
};
}

View File

@@ -0,0 +1,19 @@
{ lib, ... }:
{
type = lib.types.submodule {
options = {
class = lib.mkOption {
description = "Matches the window class reported by the compositor.";
type = lib.types.str;
example = ".*[Ff]irefox.*";
};
icon = lib.mkOption {
description = "Sets the icon shown for matching windows.";
type = lib.types.str;
example = "";
};
};
};
}

View File

@@ -13,12 +13,12 @@
"state": {
"type": "markdown",
"state": {
"file": "TODO.md",
"mode": "source",
"file": "00 INBOX/ai/valheim-items.md",
"mode": "preview",
"source": true
},
"icon": "lucide-file",
"title": "TODO"
"title": "valheim-items"
}
}
]
@@ -91,7 +91,7 @@
"state": {
"type": "localgraph",
"state": {
"file": "TODO.md",
"file": "00 INBOX/ai/valheim-items.md",
"options": {
"collapse-filter": true,
"search": "",
@@ -233,8 +233,12 @@
},
"active": "22a4c1f201a1b00e",
"lastOpenFiles": [
"00 INBOX/valheim progression.md",
"00 INBOX/ai/hyprland-lua-migration-plan.md",
"00 INBOX/ai/deadnix-architecture.md",
"00 INBOX/ai/valheim-items.md",
"TODO.md",
"AGENTS.md",
"00 INBOX/valheim progression.md",
"technical-writing.md",
"rust-module-prototype.md",
"00 INBOX/ai/statix-architecture.md",
@@ -243,7 +247,6 @@
"00 INBOX/nix name resolution.md",
"00 INBOX/let order name resolution.md",
"00 INBOX/nix name resolution.md~",
"00 INBOX/ai/deadnix-architecture.md",
"TODO.md~",
"00 INBOX/ai/xatu-rule-implementation-order.md",
"00 INBOX/ai/xatu-architecture-design.md",
@@ -259,9 +262,6 @@
"00 INBOX/ai/monorepo-work-report.md",
"00 INBOX/ai/clew-ast-exploration.md",
"00 INBOX/ai/steamcmd servers.md",
"00 INBOX/ai/ariadne-pipeline-stage-boundary.md",
"00 INBOX/ai/ariadne-project-goals.md",
"02 notes/steamcmd server setup.md",
"00 INBOX/ai/clew-ast-exploration.md~",
"03 wrk/machoke.md~",
"03 wrk/falinks.md~",

View File

@@ -0,0 +1,102 @@
# Hyprland Lua Migration Plan
## Problem Statement
The Hyprland compositor config uses Home Manager's hyprlang output.
Hyprland deprecates hyprlang for the main compositor config.
The repository needs to generate a Lua config without changing desktop behavior.
## Solution
Keep the config owned by Nix and Home Manager.
Switch the Hyprland Home Manager config output to Lua.
Convert only the settings whose shape changes under Lua.
This is not a desktop redesign.
The change preserves the current monitors, binds, window rules, environment variables, and UWSM startup.
## User Stories
1. As the operator, I want Hyprland to load `hyprland.lua`, so that startup no longer warns about hyprlang deprecation.
2. As the operator, I want existing keyboard binds to keep working, so that the desktop behavior stays unchanged.
3. As the operator, I want host monitor layout to stay unchanged, so that the physical display setup does not move.
4. As the operator, I want session environment variables to stay in the compositor config, so that Wayland and cursor behavior stays unchanged.
5. As the operator, I want auxiliary module binds to keep working, so that launcher, lock, screenshot, clipboard, notifications, and recording flows stay usable.
## Implementation Decisions
1. Use Home Manager's Lua config generator.
Set the Hyprland config type to Lua and keep package ownership unchanged.
2. Keep ordinary compositor options declarative in Nix.
Move input, general, decoration, xwayland, animation, and layout settings into the Lua config structure.
3. Remove hyprlang-only variables from generated config.
Keep modifier and terminal values as Nix locals or helper inputs.
4. Add minimal local helpers for Lua calls.
Use helpers only for repeated bind, dispatcher, command, environment, and rule shapes.
5. Convert all bind strings to Lua bind call data.
Preserve key combinations, dispatchers, commands, repeat behavior, and locked-session behavior.
6. Convert contributed binds in their owning modules.
Keep each bind near the module that owns the tool it launches.
7. Convert monitor strings to Lua monitor records.
Preserve output names, modes, positions, scale values, and rotation.
8. Convert the screenshot window rule to the Lua rule shape.
Preserve the Satty class matcher and floating behavior.
9. Convert environment strings to Lua environment calls.
Preserve the Wayland flag and cursor variables.
10. Delete or update comments that describe hyprlang syntax.
Keep comments that explain behavior or non-obvious invariants.
## Testing Decisions
1. Run the repository Nix formatting and style checks for modified Nix files.
2. Evaluate or build the affected host configuration.
This proves Home Manager accepts the Lua settings structure.
3. Confirm the generated config file uses Lua.
The expected output is `hyprland.lua`, not `hyprland.conf`.
4. Verify Hyprland accepts the generated Lua config.
Use Hyprland config verification when the installed build supports it.
Otherwise reload the compositor and inspect errors.
5. Smoke test the changed desktop surface.
Test one bind from each source area after reload.
6. Check the explicit monitor layout on the host that declares it.
Confirm resolution, position, scale, and rotation stayed unchanged.
## Out of Scope
- Do not split the config into Lua modules.
- Do not hand-write a full Lua config.
- Do not add new Hyprland behavior.
- Do not change UWSM startup.
- Do not redesign binds, monitor layout, or visual settings.
- Do not change hyprlock, hypridle, waybar, mako, or rofi settings beyond their Hyprland binds.
## Further Notes
Home Manager documents `configType = "lua"` for Hyprland.
It maps settings to `hl.*` calls.
It supports `_args` for multi-argument calls.
It supports raw Lua expressions with `lib.generators.mkLuaInline`.
The riskiest parts are binds, monitor declarations, and window rules.
Those parts changed shape more than scalar config sections.
Assumption: the pinned Home Manager input contains the documented Lua Hyprland generator.
Validate this during the configuration build.

View File

@@ -0,0 +1,939 @@
# Valheim craft and build checklist
This checklist groups craftable inventory items and non-structural build pieces by biome progression.
It excludes floors, walls, roofs, beams, poles, stairs, doors, gates, windows, and generic structure pieces.
Biome placement uses the earliest ordinary unlock from recipe materials or required stations.
## Meadows
### Buildables
- [x] Workbench
- [x] Chopping Block
- [x] Tanning Rack
- [x] Cooking Station
- [x] Beehive
- [x] Bed
- [x] Chest
- [x] Sign
- [x] Campfire
- [x] Standing Wood Torch
- [x] Deer Rug
- [x] Mysterious Rock
- [x] Wood Stack
- [x] Stone Pile
- [x] Flint Pile
### Equipment and inventory
#### Weapons and tools
- [x] Stone Axe
- [x] Flint Axe
- [x] Early Axes
- [x] Club
- [x] Torch
- [x] Flint Spear
- [x] Flint Knife
- [x] Crude Bow
- [x] Hoe
- [x] Antler Pickaxe
#### Armor
- [x] Rag Tunic
- [x] Rag Pants
- [x] Leather Helmet
- [x] Leather Tunic
- [x] Leather Pants
- [x] Deer Hide Cape
#### Shields
- [x] Wood Shield
- [x] Wood Tower Shield
- [x] Bone Tower Shield
#### Ammunition
- [x] Wood Arrow
- [x] Fire Arrow
- [x] Flinthead Arrow
#### Utility
- [x] Tankard
#### Fireworks
- [ ] Red fireworks
- [ ] Yellow fireworks
### Food, mead, and bait
- [x] Grilled neck tail
- [ ] Cooked boar meat
- [x] Honey
- [x] Cooked deer meat
- [x] Whole roasted Meadow boar
## Black Forest
### Buildables
- [x] Adze
- [x] Cauldron
- [x] Mead Ketill
- [x] Fermenter
- [x] Forge
- [x] Forge Cooler
- [x] Anvils
- [x] Smelter
- [x] Charcoal Kiln
- [ ] Barrel
- [ ] Stool
- [ ] Wood Chair
- [ ] Sitting Log
- [ ] Wood Bench
- [x] Table
- [x] Item Stand — vertical
- [x] Item Stand — horizontal
- [ ] Cartography Table
- [ ] Barber Station
- [ ] Archery Target
- [ ] T.W.I.G.
- [ ] Wood Dragon Adornment
- [ ] Sconce
- [ ] Fey Lights
- [ ] Flower Garland
- [x] Bearskin Rug
- [ ] Black Banner
- [ ] Blue Banner
- [ ] White and Red Striped Banner
- [ ] Yellow Banner
- [ ] Purple Banner
- [ ] Finewood Stack
- [ ] Corewood Stack
- [x] Coal Pile
- [x] Bone Stack
- [x] Coin Stack
- [x] Coin Pile
- [x] Tin Stack
- [x] Copper Stack
- [x] Bronze Stack
- [x] Cart
- [x] Portal
- [ ] Sharp Stakes
- [ ] Ward
### Equipment and inventory
#### Weapons and tools
- [x] Bronze Axe
- [x] Bronze Pickaxe
- [x] Cultivator
- [x] Butcher Knife
- [x] Wooden Sword
- [x] Stagbreaker
- [x] Bronze Mace
- [x] Bronze Sword
- [x] Bronze Spear
- [x] Bronze Atgeir
- [x] Copper Knife
- [x] Paws of the Bear
- [x] Finewood Bow
#### Armor
- [x] Troll Leather Helmet
- [x] Troll Leather Tunic
- [x] Troll Leather Pants
- [x] Troll Hide Cape
- [x] Headdress of the Bear
- [x] Patterns of the Bear
- [x] Loincloth of the Bear
- [x] Bronze Helmet
- [x] Bronze Plate Cuirass
- [x] Bronze Plate Leggings
- [x] Celebratory Cap
#### Shield
- [x] Bronze Buckler
#### Ammunition
- [x] Bronzehead Arrow
#### Trinkets
- [x] Heart of the Forest
- [x] Bronze Pendant
#### Utility
- [x] Mead Horn of Odin
#### Fireworks
- [ ] Blue fireworks
- [ ] Cyan fireworks
### Food, mead, and bait
- [ ] Carrot
- [ ] Boar jerky
- [ ] Cooked bear meat
- [ ] Minced meat sauce
- [ ] Queen's jam
- [ ] Carrot soup
- [ ] Deer stew
- [ ] Pulled Bear
- [ ] Black Forest buffet platter
- [ ] Mead base: Minor healing `[INFERENCE]`
- [ ] Minor healing mead `[INFERENCE]`
- [ ] Mead base: Minor stamina `[INFERENCE]`
- [ ] Minor stamina mead `[INFERENCE]`
- [ ] Mead base: Poison resistance `[INFERENCE]`
- [ ] Poison resistance mead `[INFERENCE]`
- [ ] Mead base: Tasty `[INFERENCE]`
- [ ] Tasty mead `[INFERENCE]`
- [ ] Mead base: Vananidir `[INFERENCE]`
- [ ] Draught of Vananidir `[INFERENCE]`
- [ ] Mead base: Ratatosk `[INFERENCE]`
- [ ] Tonic of Ratatosk `[INFERENCE]`
- [ ] Mead base: Troll endurance `[INFERENCE]`
- [ ] Mead of Troll endurance `[INFERENCE]`
- [ ] Mossy fishing bait
## Ocean
### Buildables
- [ ] Raft
- [ ] Karve
- [ ] Longship
### Equipment and inventory
#### Weapons and tools
- [ ] Abyssal Razor
- [ ] Abyssal Harpoon
#### Trinket
- [ ] Fins of Destiny
### Food, mead, and bait
- [ ] Cooked serpent meat `[INFERENCE]`
- [ ] Serpent stew `[INFERENCE]`
- [ ] Sailor's bounty
- [ ] Heavy fishing bait
## Swamp
### Buildables
- [ ] Birds' Nest
- [ ] Spice Rack
- [ ] Stonecutter
- [ ] Food Preparation Table
- [ ] Iron Cooking Station
- [ ] Forge Bellows
- [ ] Grinding Wheel
- [ ] Smith's Anvil
- [ ] Forge Tool Rack
- [ ] Obliterator
- [ ] Bonfire
- [ ] Hearth
- [ ] Hanging Brazier
- [ ] Standing Iron Torch
- [ ] Standing Blue-burning Iron Torch
- [ ] Standing Green-burning Iron Torch
- [ ] Resin Candle
- [ ] Reinforced Chest
- [ ] Personal Chest
- [ ] Armour Stand
- [ ] Raven Throne
- [ ] Red Banner
- [ ] Green Banner
- [ ] Iron Stack
- [ ] Iron Fire Pit
### Equipment and inventory
#### Weapons and tools
- [ ] Iron Axe
- [ ] Iron Pickaxe
- [ ] Battleaxe
- [ ] Iron Mace
- [ ] Iron Sledge
- [ ] Iron Sword
- [ ] Ancient Bark Spear
- [ ] Iron Atgeir
- [ ] Huntsman Bow
#### Armor
- [ ] Root Mask
- [ ] Root Harnesk
- [ ] Root Leggings
- [ ] Iron Helmet
- [ ] Iron Scale Mail
- [ ] Iron Greaves
#### Shields
- [ ] Banded Shield
- [ ] Shield of Roots
- [ ] Iron Buckler
- [ ] Iron Tower Shield
- [ ] Serpent Scale Shield
#### Ammunition
- [ ] Ironhead Arrow
#### Bombs
- [ ] Ooze Bomb
- [ ] Blob Bomb: Poison
- [ ] Blob Bomb: Elite poison
#### Fishing bait
- [ ] Mossy Fishing Bait
- [ ] Sticky Fishing Bait
- [ ] Heavy Fishing Bait
#### Trinkets
- [ ] Iron Brooch
- [ ] Nimble Anklet
#### Utility
- [ ] Horn of Celebration
#### Fireworks
- [ ] Green fireworks
- [ ] Purple fireworks
### Food, mead, and bait
- [ ] Cooked fish
- [ ] Muckshake
- [ ] Turnip stew
- [ ] Sausages
- [ ] Black soup
- [ ] Swamp dweller's delight
- [ ] Mead base: Frost resistance `[INFERENCE]`
- [ ] Frost resistance mead `[INFERENCE]`
- [ ] Mead base: Medium healing `[INFERENCE]`
- [ ] Medium healing mead `[INFERENCE]`
- [ ] Sticky fishing bait
## Mountain
### Buildables
- [ ] Tool Shelf
- [ ] Butcher's Table
- [ ] Dragon Bed
- [ ] Stone Throne
- [ ] Standing Brazier
- [ ] Blue Standing Brazier
- [ ] Wolf Rug
- [ ] Treasure Chest
- [ ] Silver Stack
### Equipment and inventory
#### Weapons and tools
- [ ] Crystal Battleaxe
- [ ] Frostner
- [ ] Silver Sword
- [ ] Fang Spear
- [ ] Silver Knife
- [ ] Flesh Rippers
- [ ] Draugr Fang
- [ ] Scythe
#### Armor
- [ ] Fenris Hood
- [ ] Fenris Coat
- [ ] Fenris Leggings
- [ ] Drake Helmet
- [ ] Wolf Hide Chestpiece
- [ ] Wolf Hide Trousers
- [ ] Wolf Fur Cape
#### Shield
- [ ] Silver Shield
#### Ammunition
- [ ] Silver Arrow
- [ ] Obsidian Arrow
- [ ] Poison Arrow
- [ ] Frost Arrow
#### Bomb
- [ ] Blob Bomb: Frost
#### Fishing bait
- [ ] Cold Fishing Bait
- [ ] Frosty Fishing Bait
#### Trinkets
- [ ] Wolf Sight
- [ ] Crystal Heart
### Food, mead, and bait
- [ ] Onion
- [ ] Cooked wolf meat
- [ ] Wolf jerky
- [ ] Onion soup
- [ ] Wolf skewer
- [ ] Eyescream
- [ ] Hearty Mountain logger's stew
- [ ] Mead base: Animal whispers `[INFERENCE]`
- [ ] Brew of animal whispers `[INFERENCE]`
- [ ] Cold fishing bait
- [ ] Frosty fishing bait `[INFERENCE]`
## Plains
### Buildables
- [ ] Artisan Table
- [ ] Blast Furnace
- [ ] Spinning Wheel
- [ ] Windmill
- [ ] Stone Oven
- [ ] Pots and Pans
- [ ] Darkwood Chair
- [ ] Long Heavy Table
- [ ] Round Table
- [ ] Hot Tub
- [ ] Black Metal Chest
- [ ] Lox Rug
- [ ] Straw
- [ ] Red Jute Carpet
- [ ] Red Jute Curtain
- [ ] Raven Adornment
- [ ] Wolf Adornment
- [ ] Blue, Red and White Banner
- [ ] White and Blue Striped Banner
- [ ] Orange Banner
- [ ] White Banner
- [ ] Black Metal Stack
### Equipment and inventory
#### Weapons and tools
- [ ] Black Metal Axe
- [ ] Black Metal Battleaxe
- [ ] Porcupine
- [ ] Black Metal Sword
- [ ] Black Metal Atgeir
- [ ] Black Metal Knife
- [ ] Vilebone Maulclaws
#### Armor
- [ ] Padded Helmet
- [ ] Padded Cuirass
- [ ] Padded Greaves
- [ ] Linen Cape
- [ ] Lox Cape
- [ ] Lox Fur Hood
- [ ] Lox Fur Jacket
- [ ] Lox Fur Trousers
- [ ] Vilebone Visage
- [ ] Vilebone Cage
- [ ] Vilebone Drapes
#### Shields
- [ ] Black Metal Shield
- [ ] Black Metal Tower Shield
#### Ammunition
- [ ] Needle Arrow
- [ ] Wooden Missile
- [ ] Black Metal Missile
#### Bomb
- [ ] Blob Bomb: Tar
#### Fishing bait
- [ ] Stingy Fishing Bait
- [ ] Misty Fishing Bait
#### Trinkets
- [ ] Bracelets of the Brave
- [ ] Evasion Mantle
#### Accessories and utility
- [ ] Wisplight
- [ ] Lox Saddle
### Food, mead, and bait
- [ ] Cooked egg
- [ ] Cooked chicken meat
- [ ] Cooked lox meat
- [ ] Fish wraps
- [ ] Lox meat pie
- [ ] Blood pudding
- [ ] Bread
- [ ] Frosted sweetbread
- [ ] Plains pie picnic
- [ ] Barley wine base: Fire resistance `[INFERENCE]`
- [ ] Fire resistance barley wine `[INFERENCE]`
- [ ] Mead base: Medium stamina `[INFERENCE]`
- [ ] Medium stamina mead `[INFERENCE]`
- [ ] Mead base: Berserkir `[INFERENCE]`
- [ ] Berserkir mead `[INFERENCE]`
- [ ] Mead base: Anti-sting `[INFERENCE]`
- [ ] Anti-sting concoction `[INFERENCE]`
- [ ] Stingy fishing bait
- [ ] Misty fishing bait `[INFERENCE]`
## Mistlands
### Buildables
- [ ] Wisp Fountain
- [ ] Artisan Press
- [ ] Mortar and Pestle
- [ ] Black Forge
- [ ] Black Forge Cooler
- [ ] Vice
- [ ] Galdr Table
- [ ] Rune Table
- [ ] Unfading Candles
- [ ] Sap Extractor
- [ ] Eitr Refinery
- [ ] Dvergr Wall Lantern
- [ ] Dvergr Pole Lantern
- [ ] Wisp Torch
- [ ] Black Marble Bench
- [ ] Black Marble Table
- [ ] Black Marble Throne
- [ ] Hare Rug
- [ ] Blue Jute Carpet
- [ ] Blue Jute Curtain
- [ ] Blue Jute Drapes
- [ ] Yggdrasil Wood Stack
- [ ] Black Marble Pile
- [ ] Dvergr Sharp Stakes
- [ ] Trap
- [ ] Ballista
### Equipment and inventory
#### Weapons and tools
- [ ] Jotun Bane
- [ ] Skull Splittur
- [ ] Demolisher
- [ ] Mistwalker
- [ ] Krom
- [ ] Carapace Spear
- [ ] Himminafl
- [ ] Skoll and Hati
- [ ] Spinesnap
- [ ] Arbalest
- [ ] Staff of Frost
- [ ] Staff of Embers
- [ ] Staff of Protection
- [ ] Dead Raiser
- [ ] Black Metal Pickaxe
- [ ] Dvergr Lantern
#### Armor
- [ ] Carapace Helmet
- [ ] Carapace Breastplate
- [ ] Carapace Greaves
- [ ] Eitr-weave Hood
- [ ] Eitr-weave Robe
- [ ] Eitr-weave Trousers
- [ ] Feather Cape
#### Shields
- [ ] Carapace Shield
- [ ] Carapace Buckler
#### Ammunition
- [ ] Carapace Arrow
- [ ] Bone Bolt
- [ ] Iron Bolt
- [ ] Black Metal Bolt
- [ ] Carapace Bolt
#### Bomb
- [ ] Bile Bomb
#### Trinkets
- [ ] Pulsating Earrings
- [ ] Resounding Shackle
#### Utility
- [ ] Sealbreaker
### Food, mead, and bait
- [ ] Magecap
- [ ] Jotun puffs
- [ ] Cooked seeker meat
- [ ] Cooked hare meat
- [ ] Meat platter
- [ ] Honey glazed chicken
- [ ] Misthare supreme
- [ ] Salad
- [ ] Mushroom omelette
- [ ] Fish 'n' bread
- [ ] Stuffed mushroom
- [ ] Yggdrasil porridge
- [ ] Seeker aspic
- [ ] Mushrooms galore á la Mistlands
- [ ] Mead base: Major healing `[INFERENCE]`
- [ ] Major healing mead `[INFERENCE]`
- [ ] Mead base: Lingering stamina `[INFERENCE]`
- [ ] Lingering stamina mead `[INFERENCE]`
- [ ] Mead base: Minor eitr `[INFERENCE]`
- [ ] Minor eitr mead `[INFERENCE]`
- [ ] Mead base: Lightfoot `[INFERENCE]`
- [ ] Lightfoot mead `[INFERENCE]`
## Ashlands
### Buildables
- [ ] Rolling Pins and Cutting Boards
- [ ] Metal Cutter
- [ ] Gem Cutter
- [ ] Feathery Wreath
- [ ] Eternal Pyre
- [ ] Shield Generator
- [ ] Ashwood Bed
- [ ] Ashwood Bench
- [ ] Bone Throne
- [ ] Grausten Chest
- [ ] Small Green Pot
- [ ] Medium Green Pot
- [ ] Large Green Pot
- [ ] Lava Lantern
- [ ] Asksvin Rug
- [ ] Asksvin Skeleton
- [ ] Ashwood Stack
- [ ] Grausten Pile
- [ ] Flametal Stack
- [ ] Pile of Skulls
- [ ] Portal Stone
- [ ] Drakkar
- [ ] Battering Ram
- [ ] Catapult
### Equipment and inventory
#### Weapons
- [ ] Berserkir Axes
- [ ] Bleeding Berserkir Axes
- [ ] Thundering Berserkir Axes
- [ ] Primal Berserkir Axes
- [ ] Flametal Mace
- [ ] Bloodgeon
- [ ] Storm Star
- [ ] Klossen
- [ ] Nidhögg
- [ ] Nidhögg the Bleeding
- [ ] Nidhögg the Thundering
- [ ] Nidhögg the Primal
- [ ] Dyrnwyn
- [ ] Slayer
- [ ] Brutal Slayer
- [ ] Scourging Slayer
- [ ] Primal Slayer
- [ ] Splitnir
- [ ] Splitnir the Bleeding
- [ ] Splitnir the Storming
- [ ] Splitnir the Primal
- [ ] Ash Fang
- [ ] Blood Fang
- [ ] Storm Fang
- [ ] Root Fang
- [ ] Ripper
- [ ] Wound Ripper
- [ ] Storm Ripper
- [ ] Root Ripper
- [ ] Staff of Fracturing
- [ ] Dundr
- [ ] Staff of the Wild
- [ ] Trollstav
#### Armor
- [ ] Hood of Embla
- [ ] Robes of Embla
- [ ] Trousers of Embla
- [ ] Hood of Ask
- [ ] Breastplate of Ask
- [ ] Trousers of Ask
- [ ] Flametal Helmet
- [ ] Flametal Breastplate
- [ ] Flametal Greaves
- [ ] Asksvin Cloak
- [ ] Ashen Cape
#### Shields
- [ ] Flametal Shield
- [ ] Flametal Tower Shield
#### Ammunition
- [ ] Charred Arrow
- [ ] Charred Bolt
- [ ] Flametal Missile
- [ ] Grausten Payload
- [ ] Explosive Payload
#### Bombs
- [ ] Smoke Bomb
- [ ] Basalt Bomb
- [ ] Blob Bomb: Lava
#### Fishing bait
- [ ] Hot Fishing Bait
#### Trinkets
- [ ] Jörmundling
- [ ] Brimstone
#### Utility
- [ ] Asksvin Saddle
- [ ] Bell
### Food, mead, and bait
- [ ] Vineberry cluster
- [ ] Cooked asksvin tail
- [ ] Cooked volture meat
- [ ] Cooked bonemaw meat
- [ ] Fiery svinstew
- [ ] Mashed meat
- [ ] Piquant pie
- [ ] Spicy marmalade
- [ ] Scorching medley
- [ ] Roasted crust pie
- [ ] Sizzling berry broth
- [ ] Sparkling shroomshake
- [ ] Marinated greens
- [ ] Ashlands gourmet bowl
- [ ] Mead base: Lingering health `[INFERENCE]`
- [ ] Lingering healing mead `[INFERENCE]`
- [ ] Mead base: Lingering eitr `[INFERENCE]`
- [ ] Lingering eitr mead `[INFERENCE]`
- [ ] Hot fishing bait
## Deep North
### Buildables
- [ ] Eternal Pyre
- [ ] Frigid Kiln
- [ ] Frost Foundry
- [ ] Smith's Aprons
- [ ] Smoker
- [ ] Standing Loom
- [ ] Timberwood Drawbridge
- [ ] Antler Throne
- [ ] Carved Bench
- [ ] Carved Chair
- [ ] Hooded Lantern
- [ ] Long Carved Table
- [ ] Moose Hide Carpet
- [ ] Sealskin Rug
- [ ] Snow Lantern
- [ ] Square Carved Table
- [ ] Wardrobe
- [ ] Bloodgold Stack
- [ ] Timberwood Stack
- [ ] Oat Straw `[INFERENCE]`
- [ ] Seed Kale
- [ ] Timberwood Sapling
### Equipment and inventory
#### Equipment and inventory
- [ ] Nord Atgeir
- [ ] Nord Axe
- [ ] Nord Bow
- [ ] Nord Crossbow
- [ ] Nord Dagger
- [ ] Nord Greataxe
- [ ] Nord Greatsword
- [ ] Nord Knucklechains
- [ ] Nord Mace
- [ ] Nord Sledge
- [ ] Nord Spear
- [ ] Nord Sword
- [ ] Frostfire Atgeir
- [ ] Frostfire Axe
- [ ] Frostfire Bow
- [ ] Frostfire Crossbow
- [ ] Frostfire Dagger
- [ ] Frostfire Greataxe
- [ ] Frostfire Greatsword
- [ ] Frostfire Knucklechains
- [ ] Frostfire Mace
- [ ] Frostfire Sledge
- [ ] Frostfire Spear
- [ ] Frostfire Sword
- [ ] Thunderblood Atgeir
- [ ] Thunderblood Axe
- [ ] Thunderblood Bow
- [ ] Thunderblood Crossbow
- [ ] Thunderblood Dagger
- [ ] Thunderblood Greataxe
- [ ] Thunderblood Greatsword
- [ ] Thunderblood Knucklechains
- [ ] Thunderblood Mace
- [ ] Thunderblood Sledge
- [ ] Thunderblood Spear
- [ ] Thunderblood Sword
- [ ] Echo Spike
- [ ] Lightning Strike
- [ ] Northern Vengeance
- [ ] Spirit Caller
- [ ] Nord Buckler
- [ ] Nord Greatshield
- [ ] Nord Shield
- [ ] Helmet of the Protector
- [ ] Breastplate of the Protector
- [ ] Trousers of the Protector
- [ ] Hood of the Vanguard
- [ ] Chestpiece of the Vanguard
- [ ] Trousers of the Vanguard
- [ ] Headdress of the Caller
- [ ] Robes of the Caller
- [ ] Trousers of the Caller
- [ ] Cape of the Caller
- [ ] Moose Hide Cape
- [ ] Crown of Valheim `[INFERENCE]`
- [ ] Fishing Hat
- [ ] Neckstabber `[INFERENCE]`
- [ ] Witch Crown
- [ ] Snow Shovel
- [ ] Intricate Key
- [ ] Moose Saddle `[INFERENCE]`
- [ ] Bloodgold Arrow
- [ ] Bloodgold Bolt
- [ ] Bloodgold Missile
- [ ] Bloodgold Payload
- [ ] Blob Bomb: Pulp `[INFERENCE]`
- [ ] Ember Charge
- [ ] Snowball
### Food, mead, and bait
- [ ] Baked Poteitr
- [ ] Cooked Moose Meat
- [ ] Cooked Seal Blubber `[INFERENCE]`
- [ ] Fish Soup `[INFERENCE]`
- [ ] Kale *(also the cultivator crop piece; listed once)*
- [ ] Kale Chips `[INFERENCE]`
- [ ] Lingonberries
- [ ] Lingonberry Juice
- [ ] Luminous Larva
- [ ] Meat In Bread
- [ ] Meatballs and Poteitr
- [ ] Oat Milk `[INFERENCE]`
- [ ] Oatmeal
- [ ] Oats `[INFERENCE]`
- [ ] Oven Pancake
- [ ] Pancakes
- [ ] Poteitr `[INFERENCE]`
- [ ] Seal Meat Soup
- [ ] Smoked Fish
- [ ] Smoked Moose Meat
- [ ] Northern Morning Fare
- [ ] Oat Flour `[INFERENCE]`
- [ ] Oven Pancake Batter
- [ ] Raw Kale Chips `[INFERENCE]`
- [ ] Unbaked Poteitr
- [ ] Frosty fishing bait
## Seasonal
### Buildables
- [ ] Maypole
- [ ] Jack-o-turnip
- [ ] Mistletoe
- [ ] Yule Tree
- [ ] Yule Wreath
- [ ] Yule Garland
- [ ] Yuleklapp — small
- [ ] Yuleklapp — medium
- [ ] Yuleklapp — large
### Equipment and inventory
#### Armor
- [ ] Midsummer Crown
- [ ] Pointy Hat
## Sources
The Valheim Wiki gives most current lists and progression tables.
Deep North names use the 1.0 notes and game-derived catalogs when Wiki pages lag release data.
- https://valheim.wiki/w/Weapons
- https://valheim.wiki/w/Armor
- https://valheim.wiki/w/Tools
- https://valheim.wiki/w/Accessories
- https://valheim.wiki/w/Trinkets
- https://valheim.wiki/w/Food
- https://valheim.wiki/w/Mead
- https://valheim.wiki/w/Bait
- https://valheim.wiki/w/Building
- https://valheim.wiki/w/Deep_North
- https://www.valheimgame.com/news/valheim-1-0-has-arrived-/
- https://www.valheimplanner.com/recipes/
- https://www.valheimplanner.com/build/
- https://www.valheimplanner.com/food/

View File

@@ -134,26 +134,25 @@ Review Nix files in entrypoint order:
- [x] `configs/nixos/registry/address.nix`
- [>] `configs/nixos/registry/homepage.nix`
- [x] `configs/nixos/registry/route.nix`
- [ ] `configs/nixos/registry/shell-shortcut.nix`
- [ ] `configs/nixos/registry/stable-uid.nix`
- [ ] `src/dotfiles/modules.nix`
- [ ] `configs/nixos/modules/default.nix`
- [ ] `configs/nixos/modules/host.nix`
- [ ] `configs/nixos/modules/guest.nix`
- [ ] `configs/nixos/modules/monorepo.nix`
- [ ] `configs/nixos/modules/zfs.nix`
- [ ] `configs/nixos/modules/network/ssh.nix`
- [ ] `configs/nixos/modules/network/vlans.nix`
- [ ] `configs/nixos/modules/network/vpn-client.nix`
- [ ] `configs/nixos/modules/desktop/default.nix`
- [ ] `configs/nixos/modules/desktop/apps/alacritty.nix`
- [ ] `configs/nixos/modules/desktop/apps/firefox.nix`
- [ ] `configs/nixos/modules/desktop/apps/ghostty.nix`
- [ ] `configs/nixos/modules/desktop/apps/obsidian.nix`
- [ ] `configs/nixos/modules/desktop/apps/r2modman.nix`
- [ ] `configs/nixos/modules/desktop/apps/signal.nix`
- [ ] `configs/nixos/modules/desktop/apps/steam.nix`
- [ ] `configs/nixos/modules/desktop/apps/vencord.nix`
- [x] `configs/nixos/registry/shell-shortcut.nix`
- [x] `configs/nixos/registry/stable-uid.nix`
- [x] `configs/nixos/modules/default.nix`
- [x] `configs/nixos/modules/host.nix`
- [x] `configs/nixos/modules/guest.nix`
- [x] `configs/nixos/modules/monorepo.nix`
- [x] `configs/nixos/modules/zfs.nix`
- [x] `configs/nixos/modules/network/ssh.nix`
- [x] `configs/nixos/modules/network/vlans.nix`
- [x] `configs/nixos/modules/network/vpn-client.nix`
- [x] `configs/nixos/modules/desktop/default.nix`
- [x] `configs/nixos/modules/desktop/apps/alacritty.nix`
- [x] `configs/nixos/modules/desktop/apps/firefox.nix`
- [x] `configs/nixos/modules/desktop/apps/ghostty.nix`
- [x] `configs/nixos/modules/desktop/apps/obsidian.nix`
- [x] `configs/nixos/modules/desktop/apps/r2modman.nix`
- [x] `configs/nixos/modules/desktop/apps/signal.nix`
- [x] `configs/nixos/modules/desktop/apps/steam.nix`
- [x] `configs/nixos/modules/desktop/apps/vencord.nix`
- [ ] `configs/nixos/modules/desktop/session/hyprland/default.nix`
- [ ] `configs/nixos/modules/desktop/session/hyprland/hypridle.nix`
- [ ] `configs/nixos/modules/desktop/session/hyprland/hyprlock.nix`

View File

@@ -16,14 +16,31 @@ let
[ ]
) (builtins.readDir dir)
);
uniqueValue =
value: entries: builtins.length (lib.unique (map value entries)) == builtins.length entries;
in
{
inherit collectNixFiles;
removeNixSuffix = path: lib.removeSuffix ".nix" (builtins.baseNameOf path);
registry.predicates.uniqueValue =
value: entries: builtins.length (lib.unique (map value entries)) == builtins.length entries;
glyph =
code:
builtins.fromJSON (
"\""
+ (if builtins.isList code then lib.concatMapStrings (part: "\\u${part}") code else "\\u${code}")
+ "\""
);
registry.predicates.uniqueValue = uniqueValue;
registry.predicates.uniquePerHost =
value:
uniqueValue (entry: {
inherit (entry) host;
value = value entry;
});
bridgeName = id: "br-vlan${toString id}";

View File

@@ -21,3 +21,32 @@ Do not call `lib.removeSuffix ".nix"` outside the utility definition.
### Rationale
The utility centralizes Nix file-name normalization.
## Use executable helpers for package commands
### Rule
Use `lib.getExe` for a package's main executable.
Use `lib.getExe'` for a package's non-main executable.
Do not build executable paths with `"${pkg}/bin/name"` when an executable helper names the target.
Use `lib.getExe' config.programs.<name>.package "tool"` when a command follows a configured package option.
### Rationale
Executable helpers select the package output that contains binaries.
They make package ownership explicit.
`lib.getExe'` prevents calls to a package's main executable when code needs another tool.
## Prefer NixOS package ownership for commands
### Rule
Use `pkgs.<name>` for Home Manager tools.
Use `config.programs.<name>.package` when a NixOS module owns the package selection.
Do not read `config.home-manager.users.<user>.programs.<name>.package` for command paths.
### Rationale
Home Manager uses the NixOS package set in this repository.
`pkgs.<name>` avoids dependency paths through the user tree under Home Manager.
Configured NixOS package options preserve package selection when a module owns the tool.