feat(desktop): add screen recording (task 0029) #21

Merged
alexion merged 2 commits from task-0029-screen-recording into main 2026-07-22 23:15:33 -04:00
6 changed files with 122 additions and 9 deletions

View File

@@ -13,8 +13,30 @@ No audio capture and no full-screen recording variant.
## Acceptance criteria ## Acceptance criteria
- [ ] A wf-recorder module exists in the desktop group and is enabled by the aggregator. - [x] A wf-recorder module exists in the desktop group and is enabled by the aggregator.
- [ ] The recorder selects a region first, then toggles video-only recording on `Super+Shift+R`. - [x] The recorder selects a region first, then toggles video-only recording on `Super+Shift+R`.
- [ ] A recording indicator appears in the bar, and notifications fire on start and stop. - [x] A recording indicator appears in the bar, and notifications fire on start and stop.
- [ ] No audio is captured and no full-screen variant is provided. - [x] No audio is captured and no full-screen variant is provided.
- [ ] neogaia builds green under `nix flake check`. - [x] neogaia builds green under `nix flake check`.
## Implementation Notes
- **Toggle design.**
One key both starts and stops.
A running capture is stopped with SIGINT so wf-recorder finalises the file; otherwise slurp picks a region and wf-recorder runs in the foreground for the whole recording, so the same invocation fires the "saved" notification once the file is written.
Region-first and video-only (no `-a`, so no audio) satisfy the spec directly, and no full-screen variant is offered.
- **Bar indicator polls rather than signals.**
The Waybar `custom/recording` widget samples the wf-recorder process with `pgrep` on a one-second interval, showing a video glyph while a capture runs and collapsing to nothing when idle.
An earlier draft signalled Waybar (`pkill -RTMIN+9`) from the toggle, but the start path raised the signal before wf-recorder had launched, so `pgrep` saw nothing and the indicator never lit during a recording — caught in review.
Polling is race-free, removes the signal number shared across two files, and is adequate for a status glyph.
- **No waybar `window-rewrite` icon.**
wf-recorder is headless and slurp is a transient selection overlay, so neither owns a workspace window and the per-application icon convention does not apply.
- **Output paths follow XDG user-dirs.**
A new `modules.desktop.userdirs` declares the XDG user directories (home-manager `xdg.userDirs`), and the recorder resolves its base with `xdg-user-dir VIDEOS`, writing timestamped `recording-<date>.mp4` under `<Videos>/Recordings` (created on first capture).
The screenshot module (task 0028) was aligned to the same convention (`xdg-user-dir PICTURES``<Pictures>/Screenshots`), so relocating a directory is a one-line change to `xdg.userDirs` rather than an edit in each tool.
- **Live capture is the irreducible manual step.**
The build is green, the config parses under `Hyprland --verify-config`, and the indicator's idle/recording transitions are verified against a stand-in process; exercising a real slurp selection and wf-recorder capture needs a running session.

View File

@@ -15,9 +15,11 @@ in
modules.desktop.hypridle.enable = lib.mkDefault true; modules.desktop.hypridle.enable = lib.mkDefault true;
modules.desktop.login.enable = lib.mkDefault true; modules.desktop.login.enable = lib.mkDefault true;
modules.desktop.mako.enable = lib.mkDefault true; modules.desktop.mako.enable = lib.mkDefault true;
modules.desktop.recording.enable = lib.mkDefault true;
modules.desktop.rofi.enable = lib.mkDefault true; modules.desktop.rofi.enable = lib.mkDefault true;
modules.desktop.screenshot.enable = lib.mkDefault true; modules.desktop.screenshot.enable = lib.mkDefault true;
modules.desktop.terminal.enable = lib.mkDefault true; modules.desktop.terminal.enable = lib.mkDefault true;
modules.desktop.userdirs.enable = lib.mkDefault true;
modules.desktop.theming.enable = lib.mkDefault true; modules.desktop.theming.enable = lib.mkDefault true;
modules.desktop.waybar.enable = lib.mkDefault true; modules.desktop.waybar.enable = lib.mkDefault true;
modules.desktop.audio.enable = lib.mkDefault true; modules.desktop.audio.enable = lib.mkDefault true;

View File

@@ -0,0 +1,54 @@
{
config,
lib,
pkgs,
...
}:
# Keybound screen recording: pick a region, then toggle a video-only capture.
let
cfg = config.modules.desktop.recording;
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
# that stop arrives, so this instance lives for the whole recording and then
# 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
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"
${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"
'';
in
{
options.modules.desktop.recording.enable = lib.mkEnableOption "wf-recorder screen recording";
config = lib.mkIf cfg.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}"
];
};
};
}

View File

@@ -13,8 +13,7 @@ let
grimblast = "${pkgs.grimblast}/bin/grimblast"; grimblast = "${pkgs.grimblast}/bin/grimblast";
satty = "${pkgs.satty}/bin/satty"; satty = "${pkgs.satty}/bin/satty";
wl-copy = "${pkgs.wl-clipboard}/bin/wl-copy"; wl-copy = "${pkgs.wl-clipboard}/bin/wl-copy";
xdgUserDir = "${pkgs.xdg-user-dirs}/bin/xdg-user-dir";
shotDir = "$HOME/Pictures/Screenshots";
# satty is the annotation step, and its copy action is set to save as well, # satty is the annotation step, and its copy action is set to save as well,
# so a single keystroke through it lands the shot in both the clipboard and a # so a single keystroke through it lands the shot in both the clipboard and a
@@ -22,10 +21,11 @@ let
capture = capture =
target: target:
pkgs.writeShellScript "screenshot-${target}" '' pkgs.writeShellScript "screenshot-${target}" ''
mkdir -p ${shotDir} dir="$(${xdgUserDir} PICTURES)/Screenshots"
mkdir -p "$dir"
${grimblast} save ${target} - \ ${grimblast} save ${target} - \
| ${satty} --filename - \ | ${satty} --filename - \
--output-filename "${shotDir}/screenshot-%Y%m%d-%H%M%S.png" \ --output-filename "$dir/screenshot-%Y%m%d-%H%M%S.png" \
--copy-command ${wl-copy} \ --copy-command ${wl-copy} \
--save-after-copy \ --save-after-copy \
--early-exit \ --early-exit \

View File

@@ -0,0 +1,16 @@
{ config, lib, ... }:
# Declares the XDG user directories, so a tool that queries them lands in a
# well-known folder a host can relocate from one place.
let
cfg = config.modules.desktop.userdirs;
user = config.user.name;
in
{
options.modules.desktop.userdirs.enable = lib.mkEnableOption "XDG user directories";
config = lib.mkIf cfg.enable {
# enable writes ~/.config/user-dirs.dirs from the option defaults, which
# xdg-user-dir then reads.
home-manager.users.${user}.xdg.userDirs.enable = true;
};
}

View File

@@ -28,6 +28,16 @@ let
${pkgs.mako}/bin/makoctl mode -t dnd >/dev/null 2>&1 ${pkgs.mako}/bin/makoctl mode -t dnd >/dev/null 2>&1
${pkgs.procps}/bin/pkill -RTMIN+8 waybar ${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
'';
in in
{ {
options.modules.desktop.waybar.enable = lib.mkEnableOption "the Waybar status bar"; options.modules.desktop.waybar.enable = lib.mkEnableOption "the Waybar status bar";
@@ -52,6 +62,7 @@ in
modules-left = [ "hyprland/workspaces" ]; modules-left = [ "hyprland/workspaces" ];
modules-center = [ "clock" ]; modules-center = [ "clock" ];
modules-right = [ modules-right = [
"custom/recording"
"mpris" "mpris"
"wireplumber" "wireplumber"
"network" "network"
@@ -134,6 +145,14 @@ in
interval = "once"; interval = "once";
signal = 8; 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;
};
}; };
}; };
}; };