Compare commits

...

12 Commits

25 changed files with 491 additions and 357 deletions

View File

@@ -2,10 +2,10 @@
## Desktop ## Desktop
- TODO: Verify this path after the NixOS configuration is migrated. - When adding a graphical application, register its workspace icon in `registry.window-icon`.
When adding a graphical application, add a `window-rewrite` icon mapping in `modules/desktop/session/waybar.nix`.
The workspace indicator uses this mapping to show an application icon instead of the generic default glyph. The workspace indicator uses this mapping to show an application icon instead of the generic default glyph.
Match the mapping on the window class. 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. 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. - 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`. Set Hyprland session environment variables in `modules/desktop/session/hyprland/default.nix` with `env = KEY,VALUE`.

View File

@@ -1,5 +1,6 @@
{ {
inputs, inputs,
util,
... ...
}: }:
# neogaia — Dell XPS 13 9380 laptop. # neogaia — Dell XPS 13 9380 laptop.
@@ -14,8 +15,7 @@
modules.host.cachyos.enable = true; modules.host.cachyos.enable = true;
# So wifi can be joined from the console. modules.network.enable = true;
networking.networkmanager.enable = true;
registry.address.neogaia.self = "10.23.50.146"; registry.address.neogaia.self = "10.23.50.146";
@@ -40,4 +40,23 @@
modules.desktop.apps.obsidian.enable = true; modules.desktop.apps.obsidian.enable = true;
modules.desktop.apps.steam.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; enable32Bit = true;
}; };
networking.networkmanager.enable = true; modules.network.enable = true;
services.logind.settings.Login = { services.logind.settings.Login = {
HandlePowerKey = "ignore"; HandlePowerKey = "ignore";

View File

@@ -3,6 +3,7 @@
inputs, inputs,
lib, lib,
pkgs, pkgs,
util,
... ...
}: }:
# Configures Firefox as the desktop browser. # Configures Firefox as the desktop browser.
@@ -26,6 +27,11 @@ in
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
registry.window-icon.${config.networking.hostName}.firefox = {
class = ".*[Ff]irefox.*";
icon = util.glyph "f269";
};
home-manager.users.${config.user.name} = hm: { home-manager.users.${config.user.name} = hm: {
programs.firefox = { programs.firefox = {
enable = true; enable = true;

View File

@@ -2,6 +2,7 @@
config, config,
lib, lib,
pkgs, pkgs,
util,
... ...
}: }:
# Ghostty as the desktop terminal. # Ghostty as the desktop terminal.
@@ -16,10 +17,15 @@ in
keys = "SUPER + Return"; keys = "SUPER + Return";
action = { action = {
kind = "exec"; kind = "exec";
command = "${pkgs.ghostty}/bin/ghostty"; 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 ]; home-manager.users.${user}.home.packages = [ pkgs.ghostty ];
}; };
} }

View File

@@ -2,6 +2,7 @@
config, config,
lib, lib,
pkgs, pkgs,
util,
... ...
}: }:
# Obsidian as the desktop note-taking vault. # Obsidian as the desktop note-taking vault.
@@ -14,5 +15,10 @@ in
config = lib.mkIf config.modules.desktop.apps.obsidian.enable { config = lib.mkIf config.modules.desktop.apps.obsidian.enable {
home-manager.users.${user}.home.packages = [ pkgs.obsidian ]; 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, config,
lib, lib,
pkgs, pkgs,
util,
... ...
}: }:
# Signal desktop messenger. # Signal desktop messenger.
@@ -13,5 +14,10 @@ in
config = lib.mkIf config.modules.desktop.apps.signal.enable { config = lib.mkIf config.modules.desktop.apps.signal.enable {
home-manager.users.${user}.home.packages = [ pkgs.signal-desktop ]; 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, config,
lib, lib,
pkgs, pkgs,
util,
... ...
}: }:
# Steam game launcher and runtime integration. # Steam game launcher and runtime integration.
@@ -23,5 +24,10 @@
environment.systemPackages = [ environment.systemPackages = [
pkgs.gamescope pkgs.gamescope
]; ];
registry.window-icon.${config.networking.hostName}.steam = {
class = "[Ss]team";
icon = util.glyph "f1b6";
};
}; };
} }

View File

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

View File

@@ -1,6 +1,7 @@
{ {
config, config,
lib, lib,
util,
... ...
}: }:
# The Hyprland compositor. # The Hyprland compositor.
@@ -227,5 +228,18 @@
}; };
} }
]; ];
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

@@ -21,10 +21,10 @@
general = { general = {
# The guard drops the call when a locker is already up, so no idle # The guard drops the call when a locker is already up, so no idle
# trigger stacks a second hyprlock over the first. # trigger stacks a second hyprlock over the first.
lock_cmd = "${pkgs.procps}/bin/pidof hyprlock || ${pkgs.hyprlock}/bin/hyprlock"; lock_cmd = "${lib.getExe' pkgs.procps "pidof"} hyprlock || ${lib.getExe pkgs.hyprlock}";
before_sleep_cmd = "loginctl lock-session"; before_sleep_cmd = "loginctl lock-session";
# Waking pokes the compositor in case firmware leaves displays dark. # Waking pokes the compositor in case firmware leaves displays dark.
after_sleep_cmd = "${config.programs.hyprland.package}/bin/hyprctl dispatch 'hl.dsp.dpms({ action = \"enable\" })'"; after_sleep_cmd = "${lib.getExe' config.programs.hyprland.package "hyprctl"} dispatch 'hl.dsp.dpms({ action = \"enable\" })'";
}; };
listener = [ listener = [

View File

@@ -2,44 +2,54 @@
config, config,
lib, lib,
pkgs, pkgs,
util,
... ...
}: }:
# The lock screen: a hyprlock session-lock surface the compositor owns. # The lock screen: a hyprlock session-lock surface the compositor owns.
let let
user = config.user.name; sleepIcon = util.glyph [
"db81"
# The hyprlock this module installs, used by the lock keybind below. "dcb2"
hyprlock = "${config.home-manager.users.${user}.programs.hyprlock.package}/bin/hyprlock"; ];
sleepOffIcon = util.glyph [
sleepIcon = builtins.fromJSON ''"\udb81\udcb2"''; "db81"
sleepOffIcon = builtins.fromJSON ''"\udb81\udcb3"''; "dcb3"
];
idleLockToggle = pkgs.writeShellScript "lockscreen-idle-inhibit-toggle" '' idleLockToggle = pkgs.writeShellScript "lockscreen-idle-inhibit-toggle" ''
unit=lockscreen-idle-inhibit.service unit=lockscreen-idle-inhibit.service
refresh_waybar() { 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 if ${lib.getExe' pkgs.systemd "systemctl"} --user is-active --quiet "$unit"; then
${pkgs.systemd}/bin/systemctl --user stop "$unit" ${lib.getExe' pkgs.systemd "systemctl"} --user stop "$unit"
refresh_waybar refresh_waybar
${pkgs.swayosd}/bin/swayosd-client --custom-message "${sleepIcon}" ${lib.getExe' pkgs.swayosd "swayosd-client"} --custom-message "${sleepIcon}"
exit 0 exit 0
fi fi
if ${pkgs.systemd}/bin/systemd-run \ if ${lib.getExe' pkgs.systemd "systemd-run"} \
--user \ --user \
--collect \ --collect \
--unit=lockscreen-idle-inhibit \ --unit=lockscreen-idle-inhibit \
--description="Disable idle-triggered screen locking" \ --description="Disable idle-triggered screen locking" \
${pkgs.systemd}/bin/systemd-inhibit \ ${lib.getExe' pkgs.systemd "systemd-inhibit"} \
--what=idle \ --what=idle \
--who=lockscreen-hotkey \ --who=lockscreen-hotkey \
--why="lock screen disabled by 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 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 fi
''; '';
in in
@@ -48,25 +58,7 @@ in
lib.mkEnableOption "the hyprlock lock screen"; lib.mkEnableOption "the hyprlock lock screen";
config = lib.mkIf config.modules.desktop.session.hyprland.hyprlock.enable { config = lib.mkIf config.modules.desktop.session.hyprland.hyprlock.enable {
registry.keybind.${config.networking.hostName} = { home-manager.users.${config.user.name} = {
hyprlock-lock = {
keys = "SUPER + X";
action = {
kind = "exec";
command = "${pkgs.procps}/bin/pidof hyprlock || ${hyprlock}";
};
};
hyprlock-toggle-idle-inhibit = {
keys = "SUPER + CTRL + X";
action = {
kind = "exec";
command = "${idleLockToggle}";
};
};
};
home-manager.users.${user} = {
# Colors and the lock-screen background come from Stylix's hyprlock target, # Colors and the lock-screen background come from Stylix's hyprlock target,
# so only geometry and behaviour are set here. # so only geometry and behaviour are set here.
programs.hyprlock = { programs.hyprlock = {
@@ -99,7 +91,36 @@ in
}; };
}; };
}; };
};
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 { config = lib.mkIf config.modules.desktop.session.login.enable {
services.greetd = { services.greetd = {
enable = true; enable = true;
settings.default_session.command = settings.default_session.command = lib.escapeShellArgs [
"${lib.getExe pkgs.tuigreet} --time --remember " (lib.getExe pkgs.tuigreet)
+ "--cmd 'uwsm start -e -D Hyprland hyprland.desktop'"; "--time"
"--remember"
"--cmd"
"${lib.getExe config.programs.uwsm.package} start -e -D Hyprland hyprland.desktop"
];
}; };
}; };
} }

View File

@@ -2,27 +2,29 @@
config, config,
lib, lib,
pkgs, pkgs,
util,
... ...
}: }:
# mako as the notification daemon: toasts, a do-not-disturb mode, and history recall. # mako as the notification daemon: toasts, a do-not-disturb mode, and history recall.
let 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 in
{ {
options.modules.desktop.session.mako.enable = lib.mkEnableOption "the mako notification daemon"; options.modules.desktop.session.mako.enable = lib.mkEnableOption "the mako notification daemon";
config = lib.mkIf config.modules.desktop.session.mako.enable { config = lib.mkIf config.modules.desktop.session.mako.enable {
registry.keybind.${config.networking.hostName}.mako-history = { home-manager.users.${config.user.name} = {
keys = "SUPER + N";
action = {
kind = "exec";
command = "${makoctl} restore";
};
};
home-manager.users.${user} = {
# Colors and the popup font come from Stylix's mako target, so only # Colors and the popup font come from Stylix's mako target, so only
# behaviour is set here. # behaviour is set here.
services.mako = { services.mako = {
@@ -39,7 +41,25 @@ in
"mode=dnd".invisible = true; "mode=dnd".invisible = true;
}; };
}; };
};
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. # rofi as the desktop launcher and dmenu-style menu frontend.
let let
user = config.user.name;
# A session menu built on the same themed rofi, so the power actions look # A session menu built on the same themed rofi, so the power actions look
# like every other menu the launcher drives. # like every other menu the launcher drives.
powerMenu = pkgs.writeShellScript "rofi-power-menu" '' powerMenu = pkgs.writeShellScript "rofi-power-menu" ''
chosen=$(printf '%s\n' Lock Suspend 'Log out' Reboot 'Shut down' \ 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 case "$chosen" in
Lock) ${pkgs.systemd}/bin/loginctl lock-session ;; Lock) ${lib.getExe' pkgs.systemd "loginctl"} lock-session ;;
Suspend) ${pkgs.systemd}/bin/systemctl suspend ;; Suspend) ${lib.getExe' pkgs.systemd "systemctl"} suspend ;;
'Log out') ${pkgs.systemd}/bin/loginctl terminate-session "$XDG_SESSION_ID" ;; 'Log out') ${lib.getExe' pkgs.systemd "loginctl"} terminate-session "$XDG_SESSION_ID" ;;
Reboot) ${pkgs.systemd}/bin/systemctl reboot ;; Reboot) ${lib.getExe' pkgs.systemd "systemctl"} reboot ;;
'Shut down') ${pkgs.systemd}/bin/systemctl poweroff ;; 'Shut down') ${lib.getExe' pkgs.systemd "systemctl"} poweroff ;;
esac esac
''; '';
in in
@@ -26,12 +24,34 @@ in
options.modules.desktop.session.rofi.enable = lib.mkEnableOption "the rofi launcher"; options.modules.desktop.session.rofi.enable = lib.mkEnableOption "the rofi launcher";
config = lib.mkIf config.modules.desktop.session.rofi.enable { config = lib.mkIf config.modules.desktop.session.rofi.enable {
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.
# Icons are off: resolving one per entry is the bulk of drun's startup.
extraConfig = {
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} = { registry.keybind.${config.networking.hostName} = {
rofi-launcher = { rofi-launcher = {
keys = "SUPER + R"; keys = "SUPER + R";
action = { action = {
kind = "exec"; kind = "exec";
command = "${pkgs.rofi}/bin/rofi -show drun"; command = "${lib.getExe pkgs.rofi} -show drun";
}; };
}; };
@@ -43,21 +63,5 @@ in
}; };
}; };
}; };
home-manager.users.${user} = {
# No theme is set here: Stylix's rofi target supplies the Nord one.
programs.rofi = {
enable = true;
# Application launch is the only mode, kept minimal so the prompt opens
# as fast as rofi can read the desktop entries.
# Icons are off: resolving one per entry is the bulk of drun's startup.
extraConfig = {
modi = "drun";
show-icons = false;
};
};
};
}; };
} }

View File

@@ -6,97 +6,19 @@
}: }:
# Waybar: a top status bar reading system state at a glance. # Waybar: a top status bar reading system state at a glance.
let 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 moduleEntries = lib.mapAttrsToList (name: module: {
# through JSON so the glyph survives as bytes rather than an editor paste. inherit name module;
g = code: builtins.fromJSON ''"\u${code}"''; }) modules;
user = config.user.name;
sleepOffIcon = builtins.fromJSON ''"\udb81\udcb3"''; modules = lib.attrByPath [ config.networking.hostName ] { } config.registry.waybar;
# 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 }'
'';
in in
{ {
options.modules.desktop.session.waybar.enable = lib.mkEnableOption "the Waybar status bar"; options.modules.desktop.session.waybar.enable = lib.mkEnableOption "the Waybar status bar";
@@ -106,64 +28,34 @@ in
# codepoints Stylix's monospace font does not carry. # codepoints Stylix's monospace font does not carry.
fonts.packages = [ pkgs.nerd-fonts.symbols-only ]; fonts.packages = [ pkgs.nerd-fonts.symbols-only ];
home-manager.users.${user}.programs.waybar = { home-manager.users.${config.user.name}.programs.waybar = {
enable = true; enable = true;
# A user service bound to graphical-session.target, which uwsm activates, # A user service bound to graphical-session.target, which uwsm activates,
# so the bar comes up with the session and no compositor exec-once. # so the bar comes up with the session and no compositor exec-once.
systemd.enable = true; systemd.enable = true;
style = '' settings.mainBar = lib.mkMerge [
.modules-right { {
margin-right: 8px; 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 { registry.waybar.${config.networking.hostName} = {
margin-right: 8px; clock = {
} position = "center";
''; order = 10;
settings = {
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<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 = {
format = "{:%a %d %b %H:%M}"; format = "{:%a %d %b %H:%M}";
tooltip-format = "<tt>{calendar}</tt>"; tooltip-format = "<tt>{calendar}</tt>";
calendar = { calendar = {
@@ -172,77 +64,6 @@ in
format.today = "<b><u>{}</u></b>"; 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

@@ -2,42 +2,24 @@
config, config,
lib, lib,
pkgs, pkgs,
util,
... ...
}: }:
# PipeWire as the desktop audio server. # 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"; options.modules.desktop.utilities.audio.enable = lib.mkEnableOption "the PipeWire audio server";
config = lib.mkIf config.modules.desktop.utilities.audio.enable { config = lib.mkIf config.modules.desktop.utilities.audio.enable {
registry.keybind.${config.networking.hostName} = {
media-play = {
keys = "XF86AudioPlay";
locked = true;
action = {
kind = "exec";
command = "${pkgs.playerctl}/bin/playerctl play-pause";
};
};
media-next = {
keys = "XF86AudioNext";
locked = true;
action = {
kind = "exec";
command = "${pkgs.playerctl}/bin/playerctl next";
};
};
media-previous = {
keys = "XF86AudioPrev";
locked = true;
action = {
kind = "exec";
command = "${pkgs.playerctl}/bin/playerctl previous";
};
};
};
# Realtime scheduling for the audio threads, so playback survives load. # Realtime scheduling for the audio threads, so playback survives load.
security.rtkit.enable = true; security.rtkit.enable = true;
@@ -46,5 +28,39 @@
alsa.enable = true; alsa.enable = true;
pulse.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. # Clipboard history: cliphist records every copy, picked back through rofi.
let 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 picker reuses the themed rofi, so history looks like every other menu
# the launcher drives. # the launcher drives.
# decode is needed because list emits id-prefixed lines rather than the copied # 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. # bytes, so the chosen id has to be resolved back before it can be re-copied.
picker = pkgs.writeShellScript "clipboard-picker" '' picker = pkgs.writeShellScript "clipboard-picker" ''
${cliphist} list \ ${lib.getExe pkgs.cliphist} list \
| ${rofi} -dmenu -i -p Clipboard \ | ${lib.getExe pkgs.rofi} -dmenu -i -p Clipboard \
| ${cliphist} decode \ | ${lib.getExe pkgs.cliphist} decode \
| ${wl-copy} | ${lib.getExe' pkgs.wl-clipboard "wl-copy"}
''; '';
in in
{ {
@@ -28,15 +22,7 @@ in
lib.mkEnableOption "cliphist clipboard history"; lib.mkEnableOption "cliphist clipboard history";
config = lib.mkIf config.modules.desktop.utilities.clipboard.enable { config = lib.mkIf config.modules.desktop.utilities.clipboard.enable {
registry.keybind.${config.networking.hostName}.clipboard-history = { home-manager.users.${config.user.name} = {
keys = "SUPER + SHIFT + V";
action = {
kind = "exec";
command = "${picker}";
};
};
home-manager.users.${user} = {
# wl-copy and wl-paste on PATH, so the shell can pipe into and out of the # wl-copy and wl-paste on PATH, so the shell can pipe into and out of the
# clipboard. # clipboard.
# The watchers and picker above reach wl-clipboard by store path, so this # The watchers and picker above reach wl-clipboard by store path, so this
@@ -47,5 +33,13 @@ in
# session so uwsm starts and stops them with it. # session so uwsm starts and stops them with it.
services.cliphist.enable = true; services.cliphist.enable = true;
}; };
registry.keybind.${config.networking.hostName}.clipboard-history = {
keys = "SUPER + SHIFT + V";
action = {
kind = "exec";
command = "${picker}";
};
};
}; };
} }

View File

@@ -2,19 +2,11 @@
config, config,
lib, lib,
pkgs, pkgs,
util,
... ...
}: }:
# Keybound screen recording: pick a region, then toggle a video-only capture. # Keybound screen recording: pick a region, then toggle a video-only capture.
let let
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 single key both starts and stops.
# A running wf-recorder is stopped with SIGINT so it finalises the file. # 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 # Otherwise slurp picks a region and wf-recorder runs in the foreground until
@@ -22,19 +14,29 @@ let
# reports it saved. # reports it saved.
# No -a means no audio is captured. # No -a means no audio is captured.
toggle = pkgs.writeShellScript "screen-record-toggle" '' toggle = pkgs.writeShellScript "screen-record-toggle" ''
if ${pgrep} -x wf-recorder >/dev/null; then if ${lib.getExe' pkgs.procps "pgrep"} -x wf-recorder >/dev/null; then
${pkill} -INT -x wf-recorder ${lib.getExe' pkgs.procps "pkill"} -INT -x wf-recorder
exit 0 exit 0
fi fi
region=$(${slurp}) || exit 0 region=$(${lib.getExe pkgs.slurp}) || exit 0
dir="$(${xdgUserDir} VIDEOS)/Recordings" dir="$(${lib.getExe' pkgs.xdg-user-dirs "xdg-user-dir"} VIDEOS)/Recordings"
${mkdir} -p "$dir" ${lib.getExe' pkgs.coreutils "mkdir"} -p "$dir"
file="$dir/recording-$(${date} +%Y%m%d-%H%M%S).mp4" 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." ${lib.getExe' pkgs.libnotify "notify-send"} -a "Screen recording" "Recording started" "Region capture, no audio."
${wf-recorder} -g "$region" -f "$file" ${lib.getExe pkgs.wf-recorder} -g "$region" -f "$file"
${notify-send} -a "Screen recording" "Recording saved" "$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 in
{ {
@@ -50,5 +52,13 @@ in
}; };
}; };
registry.waybar.${config.networking.hostName}."custom/recording" = {
order = 10;
settings = {
return-type = "json";
exec = "${recordingStatus}";
interval = 1;
};
};
}; };
} }

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, config,
lib, lib,
pkgs,
util,
... ...
}: }:
let let
cfg = config.modules.network.vpn-client; # NOLINT(let_order) 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 in
{ {
options.modules.network.vpn-client = { options.modules.network.vpn-client = {
@@ -57,6 +81,15 @@ in
vpn-off = "sudo systemctl stop 'wireguard-${cfg.interface}*.service' wireguard-${cfg.interface}.target"; 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 [ ]; systemd.targets."wireguard-${cfg.interface}".wantedBy = lib.mkForce [ ];
networking.wireguard.interfaces.${cfg.interface} = { networking.wireguard.interfaces.${cfg.interface} = {

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

@@ -25,6 +25,14 @@ in
removeNixSuffix = path: lib.removeSuffix ".nix" (builtins.baseNameOf path); removeNixSuffix = path: lib.removeSuffix ".nix" (builtins.baseNameOf path);
glyph =
code:
builtins.fromJSON (
"\""
+ (if builtins.isList code then lib.concatMapStrings (part: "\\u${part}") code else "\\u${code}")
+ "\""
);
registry.predicates.uniqueValue = uniqueValue; registry.predicates.uniqueValue = uniqueValue;
registry.predicates.uniquePerHost = registry.predicates.uniquePerHost =

View File

@@ -21,3 +21,32 @@ Do not call `lib.removeSuffix ".nix"` outside the utility definition.
### Rationale ### Rationale
The utility centralizes Nix file-name normalization. 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.