Compare commits

...

9 Commits

23 changed files with 431 additions and 322 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";

View File

@@ -3,6 +3,7 @@
inputs,
lib,
pkgs,
util,
...
}:
# Configures Firefox as the desktop browser.
@@ -26,6 +27,11 @@ in
};
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: {
programs.firefox = {
enable = true;

View File

@@ -2,6 +2,7 @@
config,
lib,
pkgs,
util,
...
}:
# Ghostty as the desktop terminal.
@@ -20,6 +21,11 @@ in
};
};
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

@@ -1,6 +1,7 @@
{
config,
lib,
util,
...
}:
# 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

@@ -2,12 +2,19 @@
config,
lib,
pkgs,
util,
...
}:
# The lock screen: a hyprlock session-lock surface the compositor owns.
let
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
@@ -37,6 +44,14 @@ let
${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
{
options.modules.desktop.session.hyprland.hyprlock.enable =
@@ -97,5 +112,15 @@ in
};
};
};
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,27 +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 {
registry.keybind.${config.networking.hostName}.mako-history = {
keys = "SUPER + N";
action = {
kind = "exec";
command = "${makoctl} restore";
};
};
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 = {
@@ -39,7 +41,25 @@ in
"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.
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,12 +24,34 @@ in
options.modules.desktop.session.rofi.enable = lib.mkEnableOption "the rofi launcher";
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} = {
rofi-launcher = {
keys = "SUPER + R";
action = {
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.
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,64 +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<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 = {
@@ -172,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

@@ -2,42 +2,24 @@
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";
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.
security.rtkit.enable = true;
@@ -46,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,15 +22,7 @@ in
lib.mkEnableOption "cliphist clipboard history";
config = lib.mkIf config.modules.desktop.utilities.clipboard.enable {
registry.keybind.${config.networking.hostName}.clipboard-history = {
keys = "SUPER + SHIFT + V";
action = {
kind = "exec";
command = "${picker}";
};
};
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
@@ -47,5 +33,13 @@ in
# session so uwsm starts and stops them with it.
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,
lib,
pkgs,
util,
...
}:
# Keybound screen recording: pick a region, then toggle a video-only capture.
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 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
@@ -22,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
{
@@ -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,
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

@@ -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);
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 =