feat(app): add all-in-one command and screenshot/video config
- commit
- 935ba214355136e7191689e607bbc943b9ac5fc2
- author
- brice · · 2026-09-30 15:52 UTC
- committer
- brice · · 2026-09-30 15:52 UTC
- parent
- 11965a5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +366 -63
M README.md +16 -6
| @@ -6,6 +6,7 @@ Fast Wayland screenshots and silent video recording with a NeferGUI layer-shell | |||
| 6 | 6 | ||
| 7 | 7 | ```sh | |
| 8 | 8 | make bin | |
| 9 | + | ./bin/nefercap | |
| 9 | 10 | ./bin/nefercap shot | |
| 10 | 11 | ./bin/nefercap shot -clipboard | |
| 11 | 12 | ./bin/nefercap shot -clipboard -file capture.png | |
| @@ -14,7 +15,9 @@ make bin | |||
| 14 | 15 | ./bin/nefercap status | |
| 15 | 16 | ``` | |
| 16 | 17 | ||
| 17 | - | `nefercap` without a command starts screenshot selection. The selector covers the connected outputs, up to nine: | |
| 18 | + | `nefercap` without a command is all-in-one: it stops an active recording, otherwise opens the selector in shot mode. **Tab** switches shot ⇄ rec and keeps the chosen region, monitor or workspace; Enter or a click confirms in the current mode. It takes no flags except `-debug` and reads its settings from the config file. `shot` and `rec` keep a fixed mode (no Tab) and accept `-file`/`-clipboard` (shot) to override the config per run. A badge at the top left of every overlay always shows the mode: `SHOT`, or a red `● REC`. | |
| 19 | + | ||
| 20 | + | The selector covers the connected outputs, up to nine: | |
| 18 | 21 | ||
| 19 | 22 | - **Drag and release:** capture a rectangle, clamped to its monitor. | |
| 20 | 23 | - **Click:** capture the monitor; small pointer jitter counts as a click. | |
| @@ -25,23 +28,30 @@ make bin | |||
| 25 | 28 | ||
| 26 | 29 | The selector shows mode keys and the current confirmation action in a two-line legend. Workspace hints appear only when a current workspace is available. Text uses a 1rem base (16 logical pixels), with proportional spacing and controls; the output's fractional scaling determines physical size. | |
| 27 | 30 | ||
| 28 | - | The guides are visible by default: three vertical and three horizontal medium-gray lines, one physical pixel wide, aligned to each monitor's scaling. They stay fixed while dragging. To hide them on startup, set this in `$XDG_CONFIG_HOME/nefercap/config` (default `~/.config/nefercap/config`): | |
| 31 | + | The guides are visible by default: three vertical and three horizontal medium-gray lines, one physical pixel wide, aligned to each monitor's scaling. They stay fixed while dragging. Settings live in `$XDG_CONFIG_HOME/nefercap/config` (default `~/.config/nefercap/config`): | |
| 29 | 32 | ||
| 30 | 33 | ```ini | |
| 31 | - | selector.grid = off | |
| 34 | + | selector.grid = on | |
| 35 | + | screenshot.dir = ~/Pictures/Screenshots | |
| 36 | + | screenshot.output = file+clipboard | |
| 37 | + | video.dir = ~/Videos | |
| 32 | 38 | ``` | |
| 33 | 39 | ||
| 34 | - | Use `on` to enable them. The config accepts this setting once, blank lines and `#` comment lines; unknown keys, duplicates and invalid values are errors. **G** changes visibility for the current monitor's overlay without rewriting the config. Selection dimensions are logical pixels; the captured file's dimensions depend on the monitor scaling. | |
| 40 | + | - `selector.grid`: `on` (default) or `off` to hide the guides on startup. | |
| 41 | + | - `screenshot.dir`, `video.dir`: an absolute path or `~/…`; relative paths are errors. The directory is created (private) when missing. Absent, the XDG defaults below apply. | |
| 42 | + | - `screenshot.output`: `file` (default), `file+clipboard` (save, then copy the same PNG) or `clipboard` (copy only; no file, no path printed). | |
| 43 | + | ||
| 44 | + | Each key may appear once; blank lines and `#` comment lines are allowed; unknown keys, duplicates and invalid values are errors reported before the selector opens. `screenshot.output` and the directories apply to all-in-one, `shot` and `rec`; the scripted `screenshot` and `record` never read them. On `shot`, `-clipboard` alone copies only, `-clipboard -file PATH` saves and copies, and `-file PATH` alone saves only, whatever `screenshot.output` says. **G** changes visibility for the current monitor's overlay without rewriting the config. Selection dimensions are logical pixels; the captured file's dimensions depend on the monitor scaling. | |
| 35 | 45 | ||
| 36 | 46 | A screenshot saves immediately. Recording displays a red border around the visible target and a small **REC / Stop** HUD. Both are excluded from nefercap's video. The HUD takes no keyboard focus; clicks outside Stop pass through. Press the recording shortcut again, click Stop, or run `nefercap stop` to finish the file. | |
| 37 | 47 | ||
| 38 | 48 | Monitor recording follows workspace changes. A workspace recording follows its stable identity and continues when another workspace is displayed. A fixed region stays attached to its monitor. A hidden workspace has no border on the unrelated visible workspace; the recording HUD remains available. | |
| 39 | 49 | ||
| 40 | - | Screenshots go to the configured XDG Pictures directory's `Screenshots` subdirectory, and videos to the XDG Videos directory. Names include a nanosecond timestamp. `-file` chooses an explicit destination. Files are private to their owner and are never overwritten. The saved path is printed to stdout, including for scripted commands. Cancelling before any frame is written creates no file and prints no path. | |
| 50 | + | Without `screenshot.dir`/`video.dir`, screenshots go to the configured XDG Pictures directory's `Screenshots` subdirectory, and videos to the XDG Videos directory. Names include a nanosecond timestamp. `-file` chooses an explicit destination. Files are private to their owner and are never overwritten. The saved path is printed to stdout, including for scripted commands. Cancelling before any frame is written creates no file and prints no path. | |
| 41 | 51 | ||
| 42 | 52 | `shot -clipboard` copies an `image/png` screenshot using `wl-copy` without creating a capture file or printing a path. Add `-file PATH` to save and copy the same PNG. A successfully saved file is retained and its path printed even if clipboard copying later fails. The clipboard owner intentionally survives nefercap's exit. This CLI-only option also works with scripted `screenshot`; other commands reject it. Install `wl-clipboard` to use it. | |
| 43 | 53 | ||
| 44 | - | Opening screenshot selection while a recording is active is refused: its overlay is not part of the existing session's authorized controls. A scripted `screenshot` still uses standard capture and includes the active HUD and border. | |
| 54 | + | `shot` while a recording is active is refused: its overlay is not part of the existing session's authorized controls. A scripted `screenshot` still uses standard capture and includes the active HUD and border. | |
| 45 | 55 | ||
| 46 | 56 | ## Requirements | |
| 47 | 57 | ||
| @@ -6,6 +6,7 @@ Fast Wayland screenshots and silent video recording with a NeferGUI layer-shell | |||||
| 6 | 6 | ||||
| 7 | ```sh | 7 | ```sh | ||
| 8 | make bin | 8 | make bin | ||
| 9 | + | ./bin/nefercap | |||
| 9 | ./bin/nefercap shot | 10 | ./bin/nefercap shot | ||
| 10 | ./bin/nefercap shot -clipboard | 11 | ./bin/nefercap shot -clipboard | ||
| 11 | ./bin/nefercap shot -clipboard -file capture.png | 12 | ./bin/nefercap shot -clipboard -file capture.png | ||
| @@ -14,7 +15,9 @@ make bin | |||||
| 14 | ./bin/nefercap status | 15 | ./bin/nefercap status | ||
| 15 | ``` | 16 | ``` | ||
| 16 | 17 | ||||
| 17 | - | `nefercap` without a command starts screenshot selection. The selector covers the connected outputs, up to nine: | 18 | + | `nefercap` without a command is all-in-one: it stops an active recording, otherwise opens the selector in shot mode. **Tab** switches shot ⇄ rec and keeps the chosen region, monitor or workspace; Enter or a click confirms in the current mode. It takes no flags except `-debug` and reads its settings from the config file. `shot` and `rec` keep a fixed mode (no Tab) and accept `-file`/`-clipboard` (shot) to override the config per run. A badge at the top left of every overlay always shows the mode: `SHOT`, or a red `● REC`. |
| 19 | + | ||||
| 20 | + | The selector covers the connected outputs, up to nine: | |||
| 18 | 21 | ||||
| 19 | - **Drag and release:** capture a rectangle, clamped to its monitor. | 22 | - **Drag and release:** capture a rectangle, clamped to its monitor. | ||
| 20 | - **Click:** capture the monitor; small pointer jitter counts as a click. | 23 | - **Click:** capture the monitor; small pointer jitter counts as a click. | ||
| @@ -25,23 +28,30 @@ make bin | |||||
| 25 | 28 | ||||
| 26 | The selector shows mode keys and the current confirmation action in a two-line legend. Workspace hints appear only when a current workspace is available. Text uses a 1rem base (16 logical pixels), with proportional spacing and controls; the output's fractional scaling determines physical size. | 29 | The selector shows mode keys and the current confirmation action in a two-line legend. Workspace hints appear only when a current workspace is available. Text uses a 1rem base (16 logical pixels), with proportional spacing and controls; the output's fractional scaling determines physical size. | ||
| 27 | 30 | ||||
| 28 | - | The guides are visible by default: three vertical and three horizontal medium-gray lines, one physical pixel wide, aligned to each monitor's scaling. They stay fixed while dragging. To hide them on startup, set this in `$XDG_CONFIG_HOME/nefercap/config` (default `~/.config/nefercap/config`): | 31 | + | The guides are visible by default: three vertical and three horizontal medium-gray lines, one physical pixel wide, aligned to each monitor's scaling. They stay fixed while dragging. Settings live in `$XDG_CONFIG_HOME/nefercap/config` (default `~/.config/nefercap/config`): |
| 29 | 32 | ||||
| 30 | ```ini | 33 | ```ini | ||
| 31 | - | selector.grid = off | 34 | + | selector.grid = on |
| 35 | + | screenshot.dir = ~/Pictures/Screenshots | |||
| 36 | + | screenshot.output = file+clipboard | |||
| 37 | + | video.dir = ~/Videos | |||
| 32 | ``` | 38 | ``` | ||
| 33 | 39 | ||||
| 34 | - | Use `on` to enable them. The config accepts this setting once, blank lines and `#` comment lines; unknown keys, duplicates and invalid values are errors. **G** changes visibility for the current monitor's overlay without rewriting the config. Selection dimensions are logical pixels; the captured file's dimensions depend on the monitor scaling. | 40 | + | - `selector.grid`: `on` (default) or `off` to hide the guides on startup. |
| 41 | + | - `screenshot.dir`, `video.dir`: an absolute path or `~/…`; relative paths are errors. The directory is created (private) when missing. Absent, the XDG defaults below apply. | |||
| 42 | + | - `screenshot.output`: `file` (default), `file+clipboard` (save, then copy the same PNG) or `clipboard` (copy only; no file, no path printed). | |||
| 43 | + | ||||
| 44 | + | Each key may appear once; blank lines and `#` comment lines are allowed; unknown keys, duplicates and invalid values are errors reported before the selector opens. `screenshot.output` and the directories apply to all-in-one, `shot` and `rec`; the scripted `screenshot` and `record` never read them. On `shot`, `-clipboard` alone copies only, `-clipboard -file PATH` saves and copies, and `-file PATH` alone saves only, whatever `screenshot.output` says. **G** changes visibility for the current monitor's overlay without rewriting the config. Selection dimensions are logical pixels; the captured file's dimensions depend on the monitor scaling. | |||
| 35 | 45 | ||||
| 36 | A screenshot saves immediately. Recording displays a red border around the visible target and a small **REC / Stop** HUD. Both are excluded from nefercap's video. The HUD takes no keyboard focus; clicks outside Stop pass through. Press the recording shortcut again, click Stop, or run `nefercap stop` to finish the file. | 46 | A screenshot saves immediately. Recording displays a red border around the visible target and a small **REC / Stop** HUD. Both are excluded from nefercap's video. The HUD takes no keyboard focus; clicks outside Stop pass through. Press the recording shortcut again, click Stop, or run `nefercap stop` to finish the file. | ||
| 37 | 47 | ||||
| 38 | Monitor recording follows workspace changes. A workspace recording follows its stable identity and continues when another workspace is displayed. A fixed region stays attached to its monitor. A hidden workspace has no border on the unrelated visible workspace; the recording HUD remains available. | 48 | Monitor recording follows workspace changes. A workspace recording follows its stable identity and continues when another workspace is displayed. A fixed region stays attached to its monitor. A hidden workspace has no border on the unrelated visible workspace; the recording HUD remains available. | ||
| 39 | 49 | ||||
| 40 | - | Screenshots go to the configured XDG Pictures directory's `Screenshots` subdirectory, and videos to the XDG Videos directory. Names include a nanosecond timestamp. `-file` chooses an explicit destination. Files are private to their owner and are never overwritten. The saved path is printed to stdout, including for scripted commands. Cancelling before any frame is written creates no file and prints no path. | 50 | + | Without `screenshot.dir`/`video.dir`, screenshots go to the configured XDG Pictures directory's `Screenshots` subdirectory, and videos to the XDG Videos directory. Names include a nanosecond timestamp. `-file` chooses an explicit destination. Files are private to their owner and are never overwritten. The saved path is printed to stdout, including for scripted commands. Cancelling before any frame is written creates no file and prints no path. |
| 41 | 51 | ||||
| 42 | `shot -clipboard` copies an `image/png` screenshot using `wl-copy` without creating a capture file or printing a path. Add `-file PATH` to save and copy the same PNG. A successfully saved file is retained and its path printed even if clipboard copying later fails. The clipboard owner intentionally survives nefercap's exit. This CLI-only option also works with scripted `screenshot`; other commands reject it. Install `wl-clipboard` to use it. | 52 | `shot -clipboard` copies an `image/png` screenshot using `wl-copy` without creating a capture file or printing a path. Add `-file PATH` to save and copy the same PNG. A successfully saved file is retained and its path printed even if clipboard copying later fails. The clipboard owner intentionally survives nefercap's exit. This CLI-only option also works with scripted `screenshot`; other commands reject it. Install `wl-clipboard` to use it. | ||
| 43 | 53 | ||||
| 44 | - | Opening screenshot selection while a recording is active is refused: its overlay is not part of the existing session's authorized controls. A scripted `screenshot` still uses standard capture and includes the active HUD and border. | 54 | + | `shot` while a recording is active is refused: its overlay is not part of the existing session's authorized controls. A scripted `screenshot` still uses standard capture and includes the active HUD and border. |
| 45 | 55 | ||||
| 46 | ## Requirements | 56 | ## Requirements | ||
| 47 | 57 | ||||
M internal/adapters/cli/cli.go +31 -7
| @@ -18,9 +18,13 @@ import ( | |||
| 18 | 18 | type Command string | |
| 19 | 19 | ||
| 20 | 20 | const ( | |
| 21 | + | // AllInOne is the command-less invocation: it stops an active recording, | |
| 22 | + | // otherwise opens the selector where Tab switches between shot and rec. | |
| 23 | + | // It is never named on the command line and takes no capture flags. | |
| 24 | + | AllInOne Command = "all-in-one" | |
| 21 | 25 | // Shot and Rec pick a target with the layer-shell selector; Rec toggles: | |
| 22 | - | // while a recording is active it stops that recording instead. Shot is | |
| 23 | - | // the default command. | |
| 26 | + | // while a recording is active it stops that recording instead. Their mode | |
| 27 | + | // is fixed: no Tab toggle. | |
| 24 | 28 | Shot Command = "shot" | |
| 25 | 29 | Rec Command = "rec" | |
| 26 | 30 | Stop Command = "stop" | |
| @@ -40,7 +44,7 @@ var ErrUsage = errors.New("usage error") | |||
| 40 | 44 | ||
| 41 | 45 | // Options is the parsed command line. Video is set only for Record and Rec. | |
| 42 | 46 | // Path is optional for Shot and Rec, where an empty Path lets the caller | |
| 43 | - | // choose one. | |
| 47 | + | // choose one. Clipboard with an empty Path means clipboard only. | |
| 44 | 48 | type Options struct { | |
| 45 | 49 | Command Command | |
| 46 | 50 | Output string // output name; empty lets the caller choose | |
| @@ -56,8 +60,12 @@ type Options struct { | |||
| 56 | 60 | func Usage() string { | |
| 57 | 61 | return fmt.Sprintf(`Usage: nefercap [command] [flags] | |
| 58 | 62 | ||
| 63 | + | (no command) all-in-one: stop an active recording, else open the selector | |
| 64 | + | in shot mode; Tab switches shot <-> rec. Takes no flags but | |
| 65 | + | -debug; settings come from the config file. | |
| 66 | + | ||
| 59 | 67 | Interactive commands (layer-shell selector over the connected outputs): | |
| 60 | - | shot select what to capture and save a PNG (default) | |
| 68 | + | shot select what to capture and save a PNG; fixed mode | |
| 61 | 69 | rec select what to record; run again while recording to stop it | |
| 62 | 70 | stop stop the active recording | |
| 63 | 71 | status print the recording state | |
| @@ -70,10 +78,14 @@ Selector keys and mouse: | |||
| 70 | 78 | w choose the current workspace when known, click or Enter confirms | |
| 71 | 79 | 1..9 pick that whole monitor from any overlay | |
| 72 | 80 | g toggle full-monitor center and thirds guides (on by default) | |
| 81 | + | Tab switch shot <-> rec (all-in-one only) | |
| 73 | 82 | Escape cancel | |
| 74 | 83 | ||
| 75 | - | Selector config: $XDG_CONFIG_HOME/nefercap/config (default ~/.config/nefercap/config) | |
| 76 | - | selector.grid = off hide guides on startup; G still toggles them | |
| 84 | + | Config: $XDG_CONFIG_HOME/nefercap/config (default ~/.config/nefercap/config) | |
| 85 | + | selector.grid = off hide guides on startup; G still toggles them | |
| 86 | + | screenshot.dir = ~/Pictures/Shots absolute or ~/ path; default XDG Pictures/Screenshots | |
| 87 | + | screenshot.output = file+clipboard file (default), file+clipboard or clipboard | |
| 88 | + | video.dir = ~/Videos absolute or ~/ path; default XDG Videos | |
| 77 | 89 | ||
| 78 | 90 | Script commands (never open the selector): | |
| 79 | 91 | outputs list outputs | |
| @@ -82,6 +94,7 @@ Script commands (never open the selector): | |||
| 82 | 94 | ||
| 83 | 95 | Flags: | |
| 84 | 96 | -clipboard copy PNG with wl-copy; no file unless -file is given (shot, screenshot) | |
| 97 | + | shot and rec flags override the config for that run | |
| 85 | 98 | -debug enable debug logging (all commands) | |
| 86 | 99 | -output NAME output name (all except stop, status, outputs) | |
| 87 | 100 | -file PATH new file to write; optional for shot, rec; screenshot requires it unless -clipboard; record requires it | |
| @@ -98,7 +111,7 @@ func Parse(args []string, out io.Writer) (Options, error) { | |||
| 98 | 111 | if out == nil { | |
| 99 | 112 | out = io.Discard | |
| 100 | 113 | } | |
| 101 | - | opts := Options{Command: Shot} | |
| 114 | + | opts := Options{Command: AllInOne} | |
| 102 | 115 | if len(args) > 0 && !strings.HasPrefix(args[0], "-") { | |
| 103 | 116 | switch cmd := Command(args[0]); cmd { | |
| 104 | 117 | case Shot, Rec, Stop, Status, Outputs, Screenshot, Record: | |
| @@ -139,6 +152,9 @@ func Parse(args []string, out io.Writer) (Options, error) { | |||
| 139 | 152 | _, _ = io.WriteString(out, Usage()) | |
| 140 | 153 | return Options{}, flag.ErrHelp | |
| 141 | 154 | } | |
| 155 | + | if opts.Command == AllInOne { | |
| 156 | + | return Options{}, errAllInOneFlags | |
| 157 | + | } | |
| 142 | 158 | return Options{}, fmt.Errorf("%w: %w", ErrUsage, err) | |
| 143 | 159 | } | |
| 144 | 160 | if fs.NArg() > 0 { | |
| @@ -191,6 +207,14 @@ func Parse(args []string, out io.Writer) (Options, error) { | |||
| 191 | 207 | return opts, nil | |
| 192 | 208 | } | |
| 193 | 209 | ||
| 210 | + | // usageError is a usage error that prints without the generic prefix. | |
| 211 | + | type usageError string | |
| 212 | + | ||
| 213 | + | func (e usageError) Error() string { return string(e) } | |
| 214 | + | func (e usageError) Is(target error) bool { return target == ErrUsage } | |
| 215 | + | ||
| 216 | + | const errAllInOneFlags = usageError("nefercap: all-in-one mode takes no flags; it reads $XDG_CONFIG_HOME/nefercap/config (default ~/.config/nefercap/config). Use shot, rec, screenshot or record for per-run overrides.") | |
| 217 | + | ||
| 194 | 218 | func usageErr(format string, a ...any) error { | |
| 195 | 219 | return fmt.Errorf("%w: %s", ErrUsage, fmt.Sprintf(format, a...)) | |
| 196 | 220 | } | |
| @@ -18,9 +18,13 @@ import ( | |||||
| 18 | type Command string | 18 | type Command string | ||
| 19 | 19 | ||||
| 20 | const ( | 20 | const ( | ||
| 21 | + | // AllInOne is the command-less invocation: it stops an active recording, | |||
| 22 | + | // otherwise opens the selector where Tab switches between shot and rec. | |||
| 23 | + | // It is never named on the command line and takes no capture flags. | |||
| 24 | + | AllInOne Command = "all-in-one" | |||
| 21 | // Shot and Rec pick a target with the layer-shell selector; Rec toggles: | 25 | // Shot and Rec pick a target with the layer-shell selector; Rec toggles: | ||
| 22 | - | // while a recording is active it stops that recording instead. Shot is | 26 | + | // while a recording is active it stops that recording instead. Their mode |
| 23 | - | // the default command. | 27 | + | // is fixed: no Tab toggle. |
| 24 | Shot Command = "shot" | 28 | Shot Command = "shot" | ||
| 25 | Rec Command = "rec" | 29 | Rec Command = "rec" | ||
| 26 | Stop Command = "stop" | 30 | Stop Command = "stop" | ||
| @@ -40,7 +44,7 @@ var ErrUsage = errors.New("usage error") | |||||
| 40 | 44 | ||||
| 41 | // Options is the parsed command line. Video is set only for Record and Rec. | 45 | // Options is the parsed command line. Video is set only for Record and Rec. | ||
| 42 | // Path is optional for Shot and Rec, where an empty Path lets the caller | 46 | // Path is optional for Shot and Rec, where an empty Path lets the caller | ||
| 43 | - | // choose one. | 47 | + | // choose one. Clipboard with an empty Path means clipboard only. |
| 44 | type Options struct { | 48 | type Options struct { | ||
| 45 | Command Command | 49 | Command Command | ||
| 46 | Output string // output name; empty lets the caller choose | 50 | Output string // output name; empty lets the caller choose | ||
| @@ -56,8 +60,12 @@ type Options struct { | |||||
| 56 | func Usage() string { | 60 | func Usage() string { | ||
| 57 | return fmt.Sprintf(`Usage: nefercap [command] [flags] | 61 | return fmt.Sprintf(`Usage: nefercap [command] [flags] | ||
| 58 | 62 | ||||
| 63 | + | (no command) all-in-one: stop an active recording, else open the selector | |||
| 64 | + | in shot mode; Tab switches shot <-> rec. Takes no flags but | |||
| 65 | + | -debug; settings come from the config file. | |||
| 66 | + | ||||
| 59 | Interactive commands (layer-shell selector over the connected outputs): | 67 | Interactive commands (layer-shell selector over the connected outputs): | ||
| 60 | - | shot select what to capture and save a PNG (default) | 68 | + | shot select what to capture and save a PNG; fixed mode |
| 61 | rec select what to record; run again while recording to stop it | 69 | rec select what to record; run again while recording to stop it | ||
| 62 | stop stop the active recording | 70 | stop stop the active recording | ||
| 63 | status print the recording state | 71 | status print the recording state | ||
| @@ -70,10 +78,14 @@ Selector keys and mouse: | |||||
| 70 | w choose the current workspace when known, click or Enter confirms | 78 | w choose the current workspace when known, click or Enter confirms | ||
| 71 | 1..9 pick that whole monitor from any overlay | 79 | 1..9 pick that whole monitor from any overlay | ||
| 72 | g toggle full-monitor center and thirds guides (on by default) | 80 | g toggle full-monitor center and thirds guides (on by default) | ||
| 81 | + | Tab switch shot <-> rec (all-in-one only) | |||
| 73 | Escape cancel | 82 | Escape cancel | ||
| 74 | 83 | ||||
| 75 | - | Selector config: $XDG_CONFIG_HOME/nefercap/config (default ~/.config/nefercap/config) | 84 | + | Config: $XDG_CONFIG_HOME/nefercap/config (default ~/.config/nefercap/config) |
| 76 | - | selector.grid = off hide guides on startup; G still toggles them | 85 | + | selector.grid = off hide guides on startup; G still toggles them |
| 86 | + | screenshot.dir = ~/Pictures/Shots absolute or ~/ path; default XDG Pictures/Screenshots | |||
| 87 | + | screenshot.output = file+clipboard file (default), file+clipboard or clipboard | |||
| 88 | + | video.dir = ~/Videos absolute or ~/ path; default XDG Videos | |||
| 77 | 89 | ||||
| 78 | Script commands (never open the selector): | 90 | Script commands (never open the selector): | ||
| 79 | outputs list outputs | 91 | outputs list outputs | ||
| @@ -82,6 +94,7 @@ Script commands (never open the selector): | |||||
| 82 | 94 | ||||
| 83 | Flags: | 95 | Flags: | ||
| 84 | -clipboard copy PNG with wl-copy; no file unless -file is given (shot, screenshot) | 96 | -clipboard copy PNG with wl-copy; no file unless -file is given (shot, screenshot) | ||
| 97 | + | shot and rec flags override the config for that run | |||
| 85 | -debug enable debug logging (all commands) | 98 | -debug enable debug logging (all commands) | ||
| 86 | -output NAME output name (all except stop, status, outputs) | 99 | -output NAME output name (all except stop, status, outputs) | ||
| 87 | -file PATH new file to write; optional for shot, rec; screenshot requires it unless -clipboard; record requires it | 100 | -file PATH new file to write; optional for shot, rec; screenshot requires it unless -clipboard; record requires it | ||
| @@ -98,7 +111,7 @@ func Parse(args []string, out io.Writer) (Options, error) { | |||||
| 98 | if out == nil { | 111 | if out == nil { | ||
| 99 | out = io.Discard | 112 | out = io.Discard | ||
| 100 | } | 113 | } | ||
| 101 | - | opts := Options{Command: Shot} | 114 | + | opts := Options{Command: AllInOne} |
| 102 | if len(args) > 0 && !strings.HasPrefix(args[0], "-") { | 115 | if len(args) > 0 && !strings.HasPrefix(args[0], "-") { | ||
| 103 | switch cmd := Command(args[0]); cmd { | 116 | switch cmd := Command(args[0]); cmd { | ||
| 104 | case Shot, Rec, Stop, Status, Outputs, Screenshot, Record: | 117 | case Shot, Rec, Stop, Status, Outputs, Screenshot, Record: | ||
| @@ -139,6 +152,9 @@ func Parse(args []string, out io.Writer) (Options, error) { | |||||
| 139 | _, _ = io.WriteString(out, Usage()) | 152 | _, _ = io.WriteString(out, Usage()) | ||
| 140 | return Options{}, flag.ErrHelp | 153 | return Options{}, flag.ErrHelp | ||
| 141 | } | 154 | } | ||
| 155 | + | if opts.Command == AllInOne { | |||
| 156 | + | return Options{}, errAllInOneFlags | |||
| 157 | + | } | |||
| 142 | return Options{}, fmt.Errorf("%w: %w", ErrUsage, err) | 158 | return Options{}, fmt.Errorf("%w: %w", ErrUsage, err) | ||
| 143 | } | 159 | } | ||
| 144 | if fs.NArg() > 0 { | 160 | if fs.NArg() > 0 { | ||
| @@ -191,6 +207,14 @@ func Parse(args []string, out io.Writer) (Options, error) { | |||||
| 191 | return opts, nil | 207 | return opts, nil | ||
| 192 | } | 208 | } | ||
| 193 | 209 | ||||
| 210 | + | // usageError is a usage error that prints without the generic prefix. | |||
| 211 | + | type usageError string | |||
| 212 | + | ||||
| 213 | + | func (e usageError) Error() string { return string(e) } | |||
| 214 | + | func (e usageError) Is(target error) bool { return target == ErrUsage } | |||
| 215 | + | ||||
| 216 | + | const errAllInOneFlags = usageError("nefercap: all-in-one mode takes no flags; it reads $XDG_CONFIG_HOME/nefercap/config (default ~/.config/nefercap/config). Use shot, rec, screenshot or record for per-run overrides.") | |||
| 217 | + | ||||
| 194 | func usageErr(format string, a ...any) error { | 218 | func usageErr(format string, a ...any) error { | ||
| 195 | return fmt.Errorf("%w: %s", ErrUsage, fmt.Sprintf(format, a...)) | 219 | return fmt.Errorf("%w: %s", ErrUsage, fmt.Sprintf(format, a...)) | ||
| 196 | } | 220 | } | ||
M internal/adapters/cli/cli_test.go +18 -4
| @@ -20,10 +20,11 @@ func TestParseValid(t *testing.T) { | |||
| 20 | 20 | args []string | |
| 21 | 21 | want cli.Options | |
| 22 | 22 | }{ | |
| 23 | - | {"default shot", nil, cli.Options{Command: cli.Shot}}, | |
| 24 | - | {"default shot debug", []string{"-debug"}, cli.Options{Command: cli.Shot, Debug: true}}, | |
| 25 | - | {"default shot flags", []string{"-file", "a.png", "-output", "DP-1"}, | |
| 26 | - | cli.Options{Command: cli.Shot, Path: "a.png", Output: "DP-1"}}, | |
| 23 | + | {"all-in-one", nil, cli.Options{Command: cli.AllInOne}}, | |
| 24 | + | {"all-in-one debug", []string{"-debug"}, cli.Options{Command: cli.AllInOne, Debug: true}}, | |
| 25 | + | {"shot clipboard", []string{"shot", "-clipboard"}, cli.Options{Command: cli.Shot, Clipboard: true}}, | |
| 26 | + | {"shot clipboard file", []string{"shot", "-clipboard", "-file", "a.png"}, | |
| 27 | + | cli.Options{Command: cli.Shot, Clipboard: true, Path: "a.png"}}, | |
| 27 | 28 | {"shot", []string{"shot"}, cli.Options{Command: cli.Shot}}, | |
| 28 | 29 | {"shot all", []string{"shot", "-file", "a.png", "-output", "DP-1", "-debug"}, | |
| 29 | 30 | cli.Options{Command: cli.Shot, Path: "a.png", Output: "DP-1", Debug: true}}, | |
| @@ -146,6 +147,19 @@ func TestParseUsageErrors(t *testing.T) { | |||
| 146 | 147 | } | |
| 147 | 148 | } | |
| 148 | 149 | ||
| 150 | + | func TestAllInOneRejectsFlags(t *testing.T) { | |
| 151 | + | for _, args := range [][]string{ | |
| 152 | + | {"-file", "a.png"}, {"-output", "DP-1"}, {"-clipboard"}, {"-region", "0,0,1x1"}, {"-fps", "30"}, | |
| 153 | + | {"-size", "2x2"}, {"-duration", "1s"}, {"-nope"}, {"-debug", "-file", "a.png"}, {"-file", ""}, | |
| 154 | + | } { | |
| 155 | + | got, err := cli.Parse(args, nil) | |
| 156 | + | require.ErrorIs(t, err, cli.ErrUsage, args) | |
| 157 | + | assert.ErrorContains(t, err, "all-in-one mode takes no flags") | |
| 158 | + | assert.ErrorContains(t, err, "Use shot, rec, screenshot or record for per-run overrides.") | |
| 159 | + | assert.Equal(t, cli.Options{}, got) | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 149 | 163 | func TestParseErrorNamesProblem(t *testing.T) { | |
| 150 | 164 | _, err := cli.Parse([]string{"record"}, nil) | |
| 151 | 165 | assert.ErrorContains(t, err, "-file is required") | |
| @@ -20,10 +20,11 @@ func TestParseValid(t *testing.T) { | |||||
| 20 | args []string | 20 | args []string | ||
| 21 | want cli.Options | 21 | want cli.Options | ||
| 22 | }{ | 22 | }{ | ||
| 23 | - | {"default shot", nil, cli.Options{Command: cli.Shot}}, | 23 | + | {"all-in-one", nil, cli.Options{Command: cli.AllInOne}}, |
| 24 | - | {"default shot debug", []string{"-debug"}, cli.Options{Command: cli.Shot, Debug: true}}, | 24 | + | {"all-in-one debug", []string{"-debug"}, cli.Options{Command: cli.AllInOne, Debug: true}}, |
| 25 | - | {"default shot flags", []string{"-file", "a.png", "-output", "DP-1"}, | 25 | + | {"shot clipboard", []string{"shot", "-clipboard"}, cli.Options{Command: cli.Shot, Clipboard: true}}, |
| 26 | - | cli.Options{Command: cli.Shot, Path: "a.png", Output: "DP-1"}}, | 26 | + | {"shot clipboard file", []string{"shot", "-clipboard", "-file", "a.png"}, |
| 27 | + | cli.Options{Command: cli.Shot, Clipboard: true, Path: "a.png"}}, | |||
| 27 | {"shot", []string{"shot"}, cli.Options{Command: cli.Shot}}, | 28 | {"shot", []string{"shot"}, cli.Options{Command: cli.Shot}}, | ||
| 28 | {"shot all", []string{"shot", "-file", "a.png", "-output", "DP-1", "-debug"}, | 29 | {"shot all", []string{"shot", "-file", "a.png", "-output", "DP-1", "-debug"}, | ||
| 29 | cli.Options{Command: cli.Shot, Path: "a.png", Output: "DP-1", Debug: true}}, | 30 | cli.Options{Command: cli.Shot, Path: "a.png", Output: "DP-1", Debug: true}}, | ||
| @@ -146,6 +147,19 @@ func TestParseUsageErrors(t *testing.T) { | |||||
| 146 | } | 147 | } | ||
| 147 | } | 148 | } | ||
| 148 | 149 | ||||
| 150 | + | func TestAllInOneRejectsFlags(t *testing.T) { | |||
| 151 | + | for _, args := range [][]string{ | |||
| 152 | + | {"-file", "a.png"}, {"-output", "DP-1"}, {"-clipboard"}, {"-region", "0,0,1x1"}, {"-fps", "30"}, | |||
| 153 | + | {"-size", "2x2"}, {"-duration", "1s"}, {"-nope"}, {"-debug", "-file", "a.png"}, {"-file", ""}, | |||
| 154 | + | } { | |||
| 155 | + | got, err := cli.Parse(args, nil) | |||
| 156 | + | require.ErrorIs(t, err, cli.ErrUsage, args) | |||
| 157 | + | assert.ErrorContains(t, err, "all-in-one mode takes no flags") | |||
| 158 | + | assert.ErrorContains(t, err, "Use shot, rec, screenshot or record for per-run overrides.") | |||
| 159 | + | assert.Equal(t, cli.Options{}, got) | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | ||||
| 149 | func TestParseErrorNamesProblem(t *testing.T) { | 163 | func TestParseErrorNamesProblem(t *testing.T) { | ||
| 150 | _, err := cli.Parse([]string{"record"}, nil) | 164 | _, err := cli.Parse([]string{"record"}, nil) | ||
| 151 | assert.ErrorContains(t, err, "-file is required") | 165 | assert.ErrorContains(t, err, "-file is required") | ||
M internal/adapters/config/config.go +85 -20
| @@ -1,4 +1,4 @@ | |||
| 1 | - | // Package config reads the selector's persistent settings. | |
| 1 | + | // Package config reads nefercap's persistent settings. | |
| 2 | 2 | package config | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| @@ -7,23 +7,42 @@ import ( | |||
| 7 | 7 | "io" | |
| 8 | 8 | "os" | |
| 9 | 9 | "path/filepath" | |
| 10 | + | "slices" | |
| 10 | 11 | "strings" | |
| 11 | 12 | "syscall" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 14 | - | const maxBytes = 4096 | |
| 15 | + | const ( | |
| 16 | + | maxBytes = 4096 | |
| 17 | + | maxPath = 4096 | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // ScreenshotOutput says where an interactive screenshot goes. | |
| 21 | + | type ScreenshotOutput string | |
| 22 | + | ||
| 23 | + | const ( | |
| 24 | + | OutputFile ScreenshotOutput = "file" | |
| 25 | + | OutputFileClipboard ScreenshotOutput = "file+clipboard" | |
| 26 | + | OutputClipboard ScreenshotOutput = "clipboard" | |
| 27 | + | ) | |
| 15 | 28 | ||
| 16 | - | // Settings controls the selector's initial appearance. G toggles Grid for | |
| 17 | - | // the current selection without changing the saved setting. | |
| 29 | + | // Settings holds the persistent configuration. G toggles Grid for the current | |
| 30 | + | // selection without changing the saved setting. The directories are absolute, | |
| 31 | + | // or empty for the XDG default. | |
| 18 | 32 | type Settings struct { | |
| 19 | - | Grid bool | |
| 33 | + | Grid bool | |
| 34 | + | ScreenshotDir string | |
| 35 | + | VideoDir string | |
| 36 | + | Output ScreenshotOutput | |
| 20 | 37 | } | |
| 21 | 38 | ||
| 39 | + | const knownKeys = "selector.grid, screenshot.dir, screenshot.output, video.dir" | |
| 40 | + | ||
| 22 | 41 | // Load reads $XDG_CONFIG_HOME/nefercap/config (or ~/.config/nefercap/config). | |
| 23 | 42 | // An absent file or setting enables the full monitor grid. Invalid settings | |
| 24 | 43 | // fail explicitly rather than silently changing the requested appearance. | |
| 25 | 44 | func Load() (Settings, error) { | |
| 26 | - | settings := Settings{Grid: true} | |
| 45 | + | settings := Settings{Grid: true, Output: OutputFile} | |
| 27 | 46 | root := os.Getenv("XDG_CONFIG_HOME") | |
| 28 | 47 | if !filepath.IsAbs(root) { | |
| 29 | 48 | home, err := os.UserHomeDir() | |
| @@ -39,42 +58,88 @@ func Load() (Settings, error) { | |||
| 39 | 58 | return settings, nil | |
| 40 | 59 | } | |
| 41 | 60 | if err != nil { | |
| 42 | - | return Settings{}, fmt.Errorf("open selector config: %w", err) | |
| 61 | + | return Settings{}, fmt.Errorf("open config: %w", err) | |
| 43 | 62 | } | |
| 44 | 63 | defer file.Close() | |
| 45 | 64 | info, err := file.Stat() | |
| 46 | 65 | if err != nil { | |
| 47 | - | return Settings{}, fmt.Errorf("stat selector config: %w", err) | |
| 66 | + | return Settings{}, fmt.Errorf("stat config: %w", err) | |
| 48 | 67 | } | |
| 49 | 68 | if !info.Mode().IsRegular() { | |
| 50 | - | return Settings{}, fmt.Errorf("selector config %s: expected a regular file", path) | |
| 69 | + | return Settings{}, fmt.Errorf("config %s: expected a regular file", path) | |
| 51 | 70 | } | |
| 52 | 71 | data, err := io.ReadAll(io.LimitReader(file, maxBytes+1)) | |
| 53 | 72 | if err != nil { | |
| 54 | - | return Settings{}, fmt.Errorf("read selector config %s: %w", path, err) | |
| 73 | + | return Settings{}, fmt.Errorf("read config %s: %w", path, err) | |
| 55 | 74 | } | |
| 56 | 75 | if len(data) > maxBytes { | |
| 57 | - | return Settings{}, fmt.Errorf("selector config %s exceeds %d bytes", path, maxBytes) | |
| 76 | + | return Settings{}, fmt.Errorf("config %s exceeds %d bytes", path, maxBytes) | |
| 58 | 77 | } | |
| 59 | - | seen := false | |
| 78 | + | var seen [4]bool | |
| 60 | 79 | for index, raw := range strings.Split(strings.TrimPrefix(string(data), "\ufeff"), "\n") { | |
| 61 | 80 | line := strings.TrimSpace(raw) | |
| 62 | 81 | if line == "" || strings.HasPrefix(line, "#") { | |
| 63 | 82 | continue | |
| 64 | 83 | } | |
| 65 | 84 | key, value, ok := strings.Cut(line, "=") | |
| 66 | - | if !ok || strings.TrimSpace(key) != "selector.grid" || seen { | |
| 67 | - | return Settings{}, fmt.Errorf("selector config %s line %d: expected one selector.grid = on/off setting", path, index+1) | |
| 85 | + | key, value = strings.TrimSpace(key), strings.TrimSpace(value) | |
| 86 | + | slot := -1 | |
| 87 | + | if ok { | |
| 88 | + | slot = slices.Index(keys[:], key) | |
| 89 | + | } | |
| 90 | + | if slot < 0 || seen[slot] { | |
| 91 | + | return Settings{}, fmt.Errorf("config %s line %d: expected each of %s at most once as key = value", path, index+1, knownKeys) | |
| 68 | 92 | } | |
| 69 | - | switch strings.TrimSpace(value) { | |
| 93 | + | seen[slot] = true | |
| 94 | + | if err := apply(&settings, key, value); err != nil { | |
| 95 | + | return Settings{}, fmt.Errorf("config %s line %d: %w", path, index+1, err) | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | return settings, nil | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | var keys = [...]string{"selector.grid", "screenshot.dir", "screenshot.output", "video.dir"} | |
| 102 | + | ||
| 103 | + | func apply(s *Settings, key, value string) (err error) { | |
| 104 | + | switch key { | |
| 105 | + | case "selector.grid": | |
| 106 | + | switch value { | |
| 70 | 107 | case "on": | |
| 71 | - | settings.Grid = true | |
| 108 | + | s.Grid = true | |
| 72 | 109 | case "off": | |
| 73 | - | settings.Grid = false | |
| 110 | + | s.Grid = false | |
| 111 | + | default: | |
| 112 | + | return errors.New("selector.grid must be on or off") | |
| 113 | + | } | |
| 114 | + | case "screenshot.output": | |
| 115 | + | switch out := ScreenshotOutput(value); out { | |
| 116 | + | case OutputFile, OutputFileClipboard, OutputClipboard: | |
| 117 | + | s.Output = out | |
| 74 | 118 | default: | |
| 75 | - | return Settings{}, fmt.Errorf("selector config %s line %d: selector.grid must be on or off", path, index+1) | |
| 119 | + | return errors.New("screenshot.output must be file, file+clipboard or clipboard") | |
| 76 | 120 | } | |
| 77 | - | seen = true | |
| 121 | + | case "screenshot.dir": | |
| 122 | + | s.ScreenshotDir, err = dir(key, value) | |
| 123 | + | case "video.dir": | |
| 124 | + | s.VideoDir, err = dir(key, value) | |
| 78 | 125 | } | |
| 79 | - | return settings, nil | |
| 126 | + | return err | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // dir accepts an absolute path or one starting with ~/ (the home directory). | |
| 130 | + | func dir(key, value string) (string, error) { | |
| 131 | + | if value == "" || len(value) > maxPath || strings.IndexByte(value, 0) >= 0 { | |
| 132 | + | return "", fmt.Errorf("%s must be a non-empty path", key) | |
| 133 | + | } | |
| 134 | + | if value == "~" || strings.HasPrefix(value, "~/") { | |
| 135 | + | home, err := os.UserHomeDir() | |
| 136 | + | if err != nil || !filepath.IsAbs(home) { | |
| 137 | + | return "", fmt.Errorf("%s: no absolute home directory for ~", key) | |
| 138 | + | } | |
| 139 | + | value = filepath.Join(home, value[1:]) | |
| 140 | + | } | |
| 141 | + | if !filepath.IsAbs(value) { | |
| 142 | + | return "", fmt.Errorf("%s must be an absolute path or start with ~/", key) | |
| 143 | + | } | |
| 144 | + | return filepath.Clean(value), nil | |
| 80 | 145 | } | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | // Package config reads the selector's persistent settings. | 1 | + | // Package config reads nefercap's persistent settings. |
| 2 | package config | 2 | package config | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| @@ -7,23 +7,42 @@ import ( | |||||
| 7 | "io" | 7 | "io" | ||
| 8 | "os" | 8 | "os" | ||
| 9 | "path/filepath" | 9 | "path/filepath" | ||
| 10 | + | "slices" | |||
| 10 | "strings" | 11 | "strings" | ||
| 11 | "syscall" | 12 | "syscall" | ||
| 12 | ) | 13 | ) | ||
| 13 | 14 | ||||
| 14 | - | const maxBytes = 4096 | 15 | + | const ( |
| 16 | + | maxBytes = 4096 | |||
| 17 | + | maxPath = 4096 | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // ScreenshotOutput says where an interactive screenshot goes. | |||
| 21 | + | type ScreenshotOutput string | |||
| 22 | + | ||||
| 23 | + | const ( | |||
| 24 | + | OutputFile ScreenshotOutput = "file" | |||
| 25 | + | OutputFileClipboard ScreenshotOutput = "file+clipboard" | |||
| 26 | + | OutputClipboard ScreenshotOutput = "clipboard" | |||
| 27 | + | ) | |||
| 15 | 28 | ||||
| 16 | - | // Settings controls the selector's initial appearance. G toggles Grid for | 29 | + | // Settings holds the persistent configuration. G toggles Grid for the current |
| 17 | - | // the current selection without changing the saved setting. | 30 | + | // selection without changing the saved setting. The directories are absolute, |
| 31 | + | // or empty for the XDG default. | |||
| 18 | type Settings struct { | 32 | type Settings struct { | ||
| 19 | - | Grid bool | 33 | + | Grid bool |
| 34 | + | ScreenshotDir string | |||
| 35 | + | VideoDir string | |||
| 36 | + | Output ScreenshotOutput | |||
| 20 | } | 37 | } | ||
| 21 | 38 | ||||
| 39 | + | const knownKeys = "selector.grid, screenshot.dir, screenshot.output, video.dir" | |||
| 40 | + | ||||
| 22 | // Load reads $XDG_CONFIG_HOME/nefercap/config (or ~/.config/nefercap/config). | 41 | // Load reads $XDG_CONFIG_HOME/nefercap/config (or ~/.config/nefercap/config). | ||
| 23 | // An absent file or setting enables the full monitor grid. Invalid settings | 42 | // An absent file or setting enables the full monitor grid. Invalid settings | ||
| 24 | // fail explicitly rather than silently changing the requested appearance. | 43 | // fail explicitly rather than silently changing the requested appearance. | ||
| 25 | func Load() (Settings, error) { | 44 | func Load() (Settings, error) { | ||
| 26 | - | settings := Settings{Grid: true} | 45 | + | settings := Settings{Grid: true, Output: OutputFile} |
| 27 | root := os.Getenv("XDG_CONFIG_HOME") | 46 | root := os.Getenv("XDG_CONFIG_HOME") | ||
| 28 | if !filepath.IsAbs(root) { | 47 | if !filepath.IsAbs(root) { | ||
| 29 | home, err := os.UserHomeDir() | 48 | home, err := os.UserHomeDir() | ||
| @@ -39,42 +58,88 @@ func Load() (Settings, error) { | |||||
| 39 | return settings, nil | 58 | return settings, nil | ||
| 40 | } | 59 | } | ||
| 41 | if err != nil { | 60 | if err != nil { | ||
| 42 | - | return Settings{}, fmt.Errorf("open selector config: %w", err) | 61 | + | return Settings{}, fmt.Errorf("open config: %w", err) |
| 43 | } | 62 | } | ||
| 44 | defer file.Close() | 63 | defer file.Close() | ||
| 45 | info, err := file.Stat() | 64 | info, err := file.Stat() | ||
| 46 | if err != nil { | 65 | if err != nil { | ||
| 47 | - | return Settings{}, fmt.Errorf("stat selector config: %w", err) | 66 | + | return Settings{}, fmt.Errorf("stat config: %w", err) |
| 48 | } | 67 | } | ||
| 49 | if !info.Mode().IsRegular() { | 68 | if !info.Mode().IsRegular() { | ||
| 50 | - | return Settings{}, fmt.Errorf("selector config %s: expected a regular file", path) | 69 | + | return Settings{}, fmt.Errorf("config %s: expected a regular file", path) |
| 51 | } | 70 | } | ||
| 52 | data, err := io.ReadAll(io.LimitReader(file, maxBytes+1)) | 71 | data, err := io.ReadAll(io.LimitReader(file, maxBytes+1)) | ||
| 53 | if err != nil { | 72 | if err != nil { | ||
| 54 | - | return Settings{}, fmt.Errorf("read selector config %s: %w", path, err) | 73 | + | return Settings{}, fmt.Errorf("read config %s: %w", path, err) |
| 55 | } | 74 | } | ||
| 56 | if len(data) > maxBytes { | 75 | if len(data) > maxBytes { | ||
| 57 | - | return Settings{}, fmt.Errorf("selector config %s exceeds %d bytes", path, maxBytes) | 76 | + | return Settings{}, fmt.Errorf("config %s exceeds %d bytes", path, maxBytes) |
| 58 | } | 77 | } | ||
| 59 | - | seen := false | 78 | + | var seen [4]bool |
| 60 | for index, raw := range strings.Split(strings.TrimPrefix(string(data), "\ufeff"), "\n") { | 79 | for index, raw := range strings.Split(strings.TrimPrefix(string(data), "\ufeff"), "\n") { | ||
| 61 | line := strings.TrimSpace(raw) | 80 | line := strings.TrimSpace(raw) | ||
| 62 | if line == "" || strings.HasPrefix(line, "#") { | 81 | if line == "" || strings.HasPrefix(line, "#") { | ||
| 63 | continue | 82 | continue | ||
| 64 | } | 83 | } | ||
| 65 | key, value, ok := strings.Cut(line, "=") | 84 | key, value, ok := strings.Cut(line, "=") | ||
| 66 | - | if !ok || strings.TrimSpace(key) != "selector.grid" || seen { | 85 | + | key, value = strings.TrimSpace(key), strings.TrimSpace(value) |
| 67 | - | return Settings{}, fmt.Errorf("selector config %s line %d: expected one selector.grid = on/off setting", path, index+1) | 86 | + | slot := -1 |
| 87 | + | if ok { | |||
| 88 | + | slot = slices.Index(keys[:], key) | |||
| 89 | + | } | |||
| 90 | + | if slot < 0 || seen[slot] { | |||
| 91 | + | return Settings{}, fmt.Errorf("config %s line %d: expected each of %s at most once as key = value", path, index+1, knownKeys) | |||
| 68 | } | 92 | } | ||
| 69 | - | switch strings.TrimSpace(value) { | 93 | + | seen[slot] = true |
| 94 | + | if err := apply(&settings, key, value); err != nil { | |||
| 95 | + | return Settings{}, fmt.Errorf("config %s line %d: %w", path, index+1, err) | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | return settings, nil | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | var keys = [...]string{"selector.grid", "screenshot.dir", "screenshot.output", "video.dir"} | |||
| 102 | + | ||||
| 103 | + | func apply(s *Settings, key, value string) (err error) { | |||
| 104 | + | switch key { | |||
| 105 | + | case "selector.grid": | |||
| 106 | + | switch value { | |||
| 70 | case "on": | 107 | case "on": | ||
| 71 | - | settings.Grid = true | 108 | + | s.Grid = true |
| 72 | case "off": | 109 | case "off": | ||
| 73 | - | settings.Grid = false | 110 | + | s.Grid = false |
| 111 | + | default: | |||
| 112 | + | return errors.New("selector.grid must be on or off") | |||
| 113 | + | } | |||
| 114 | + | case "screenshot.output": | |||
| 115 | + | switch out := ScreenshotOutput(value); out { | |||
| 116 | + | case OutputFile, OutputFileClipboard, OutputClipboard: | |||
| 117 | + | s.Output = out | |||
| 74 | default: | 118 | default: | ||
| 75 | - | return Settings{}, fmt.Errorf("selector config %s line %d: selector.grid must be on or off", path, index+1) | 119 | + | return errors.New("screenshot.output must be file, file+clipboard or clipboard") |
| 76 | } | 120 | } | ||
| 77 | - | seen = true | 121 | + | case "screenshot.dir": |
| 122 | + | s.ScreenshotDir, err = dir(key, value) | |||
| 123 | + | case "video.dir": | |||
| 124 | + | s.VideoDir, err = dir(key, value) | |||
| 78 | } | 125 | } | ||
| 79 | - | return settings, nil | 126 | + | return err |
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | // dir accepts an absolute path or one starting with ~/ (the home directory). | |||
| 130 | + | func dir(key, value string) (string, error) { | |||
| 131 | + | if value == "" || len(value) > maxPath || strings.IndexByte(value, 0) >= 0 { | |||
| 132 | + | return "", fmt.Errorf("%s must be a non-empty path", key) | |||
| 133 | + | } | |||
| 134 | + | if value == "~" || strings.HasPrefix(value, "~/") { | |||
| 135 | + | home, err := os.UserHomeDir() | |||
| 136 | + | if err != nil || !filepath.IsAbs(home) { | |||
| 137 | + | return "", fmt.Errorf("%s: no absolute home directory for ~", key) | |||
| 138 | + | } | |||
| 139 | + | value = filepath.Join(home, value[1:]) | |||
| 140 | + | } | |||
| 141 | + | if !filepath.IsAbs(value) { | |||
| 142 | + | return "", fmt.Errorf("%s must be an absolute path or start with ~/", key) | |||
| 143 | + | } | |||
| 144 | + | return filepath.Clean(value), nil | |||
| 80 | } | 145 | } | ||
M internal/adapters/config/config_test.go +51 -3
| @@ -57,10 +57,10 @@ func TestLoadGrid(t *testing.T) { | |||
| 57 | 57 | func TestLoadRejectsInvalid(t *testing.T) { | |
| 58 | 58 | for _, tc := range []struct{ text, want string }{ | |
| 59 | 59 | {"selector.grid = maybe", "line 1: selector.grid must be on or off"}, | |
| 60 | - | {"selector.grid", "line 1: expected one"}, | |
| 60 | + | {"selector.grid", "line 1: expected each of"}, | |
| 61 | 61 | {"selector.grid =", "line 1: selector.grid must be on or off"}, | |
| 62 | - | {"selector.other = on", "line 1: expected one"}, | |
| 63 | - | {"selector.grid = on\nselector.grid = off", "line 2: expected one"}, | |
| 62 | + | {"selector.other = on", "line 1: expected each of"}, | |
| 63 | + | {"selector.grid = on\nselector.grid = off", "line 2: expected each of"}, | |
| 64 | 64 | {strings.Repeat("#", maxBytes+1), "exceeds 4096 bytes"}, | |
| 65 | 65 | } { | |
| 66 | 66 | t.Run(tc.text[:min(len(tc.text), 40)], func(t *testing.T) { | |
| @@ -123,3 +123,51 @@ func TestLoadDirectoryRejected(t *testing.T) { | |||
| 123 | 123 | _, err := Load() | |
| 124 | 124 | require.ErrorContains(t, err, "expected a regular file") | |
| 125 | 125 | } | |
| 126 | + | ||
| 127 | + | func TestLoadPathsAndOutput(t *testing.T) { | |
| 128 | + | home := t.TempDir() | |
| 129 | + | t.Setenv("HOME", home) | |
| 130 | + | path := configRoot(t) | |
| 131 | + | saveConfig(t, path, "# c\nscreenshot.dir = ~/Pics/Shots\nvideo.dir=/var/tmp/vids/\nscreenshot.output = file+clipboard\nselector.grid = off\n") | |
| 132 | + | got, err := Load() | |
| 133 | + | require.NoError(t, err) | |
| 134 | + | assert.Equal(t, Settings{Grid: false, ScreenshotDir: filepath.Join(home, "Pics", "Shots"), VideoDir: "/var/tmp/vids", Output: OutputFileClipboard}, got) | |
| 135 | + | ||
| 136 | + | saveConfig(t, path, "screenshot.dir = ~\nscreenshot.output = clipboard\n") | |
| 137 | + | got, err = Load() | |
| 138 | + | require.NoError(t, err) | |
| 139 | + | assert.Equal(t, home, got.ScreenshotDir) | |
| 140 | + | assert.Equal(t, OutputClipboard, got.Output) | |
| 141 | + | assert.Empty(t, got.VideoDir) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func TestLoadDefaultsOutputFile(t *testing.T) { | |
| 145 | + | configRoot(t) | |
| 146 | + | got, err := Load() | |
| 147 | + | require.NoError(t, err) | |
| 148 | + | assert.Equal(t, Settings{Grid: true, Output: OutputFile}, got) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestLoadRejectsInvalidNewKeys(t *testing.T) { | |
| 152 | + | for _, tc := range []struct{ text, want string }{ | |
| 153 | + | {"screenshot.dir = Pictures", "line 1: screenshot.dir must be an absolute path or start with ~/"}, | |
| 154 | + | {"video.dir = ./v", "line 1: video.dir must be an absolute path"}, | |
| 155 | + | {"video.dir = ~user/v", "line 1: video.dir must be an absolute path"}, | |
| 156 | + | {"video.dir =", "line 1: video.dir must be a non-empty path"}, | |
| 157 | + | {"screenshot.output = both", "line 1: screenshot.output must be file, file+clipboard or clipboard"}, | |
| 158 | + | {"screenshot.output = File", "screenshot.output must be"}, | |
| 159 | + | {"screenshot.dir = /a\nscreenshot.dir = /b", "line 2: expected each of"}, | |
| 160 | + | {"screenshot.output = file\n\nscreenshot.output = file", "line 3: expected each of"}, | |
| 161 | + | {"video.dir = /a\nvideo.dir = /a", "line 2: expected each of"}, | |
| 162 | + | {"video.output = file", "line 1: expected each of"}, | |
| 163 | + | } { | |
| 164 | + | t.Run(tc.text, func(t *testing.T) { | |
| 165 | + | t.Setenv("HOME", t.TempDir()) | |
| 166 | + | path := configRoot(t) | |
| 167 | + | saveConfig(t, path, tc.text) | |
| 168 | + | _, err := Load() | |
| 169 | + | require.ErrorContains(t, err, tc.want) | |
| 170 | + | assert.Contains(t, err.Error(), path) | |
| 171 | + | }) | |
| 172 | + | } | |
| 173 | + | } | |
| @@ -57,10 +57,10 @@ func TestLoadGrid(t *testing.T) { | |||||
| 57 | func TestLoadRejectsInvalid(t *testing.T) { | 57 | func TestLoadRejectsInvalid(t *testing.T) { | ||
| 58 | for _, tc := range []struct{ text, want string }{ | 58 | for _, tc := range []struct{ text, want string }{ | ||
| 59 | {"selector.grid = maybe", "line 1: selector.grid must be on or off"}, | 59 | {"selector.grid = maybe", "line 1: selector.grid must be on or off"}, | ||
| 60 | - | {"selector.grid", "line 1: expected one"}, | 60 | + | {"selector.grid", "line 1: expected each of"}, |
| 61 | {"selector.grid =", "line 1: selector.grid must be on or off"}, | 61 | {"selector.grid =", "line 1: selector.grid must be on or off"}, | ||
| 62 | - | {"selector.other = on", "line 1: expected one"}, | 62 | + | {"selector.other = on", "line 1: expected each of"}, |
| 63 | - | {"selector.grid = on\nselector.grid = off", "line 2: expected one"}, | 63 | + | {"selector.grid = on\nselector.grid = off", "line 2: expected each of"}, |
| 64 | {strings.Repeat("#", maxBytes+1), "exceeds 4096 bytes"}, | 64 | {strings.Repeat("#", maxBytes+1), "exceeds 4096 bytes"}, | ||
| 65 | } { | 65 | } { | ||
| 66 | t.Run(tc.text[:min(len(tc.text), 40)], func(t *testing.T) { | 66 | t.Run(tc.text[:min(len(tc.text), 40)], func(t *testing.T) { | ||
| @@ -123,3 +123,51 @@ func TestLoadDirectoryRejected(t *testing.T) { | |||||
| 123 | _, err := Load() | 123 | _, err := Load() | ||
| 124 | require.ErrorContains(t, err, "expected a regular file") | 124 | require.ErrorContains(t, err, "expected a regular file") | ||
| 125 | } | 125 | } | ||
| 126 | + | ||||
| 127 | + | func TestLoadPathsAndOutput(t *testing.T) { | |||
| 128 | + | home := t.TempDir() | |||
| 129 | + | t.Setenv("HOME", home) | |||
| 130 | + | path := configRoot(t) | |||
| 131 | + | saveConfig(t, path, "# c\nscreenshot.dir = ~/Pics/Shots\nvideo.dir=/var/tmp/vids/\nscreenshot.output = file+clipboard\nselector.grid = off\n") | |||
| 132 | + | got, err := Load() | |||
| 133 | + | require.NoError(t, err) | |||
| 134 | + | assert.Equal(t, Settings{Grid: false, ScreenshotDir: filepath.Join(home, "Pics", "Shots"), VideoDir: "/var/tmp/vids", Output: OutputFileClipboard}, got) | |||
| 135 | + | ||||
| 136 | + | saveConfig(t, path, "screenshot.dir = ~\nscreenshot.output = clipboard\n") | |||
| 137 | + | got, err = Load() | |||
| 138 | + | require.NoError(t, err) | |||
| 139 | + | assert.Equal(t, home, got.ScreenshotDir) | |||
| 140 | + | assert.Equal(t, OutputClipboard, got.Output) | |||
| 141 | + | assert.Empty(t, got.VideoDir) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func TestLoadDefaultsOutputFile(t *testing.T) { | |||
| 145 | + | configRoot(t) | |||
| 146 | + | got, err := Load() | |||
| 147 | + | require.NoError(t, err) | |||
| 148 | + | assert.Equal(t, Settings{Grid: true, Output: OutputFile}, got) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func TestLoadRejectsInvalidNewKeys(t *testing.T) { | |||
| 152 | + | for _, tc := range []struct{ text, want string }{ | |||
| 153 | + | {"screenshot.dir = Pictures", "line 1: screenshot.dir must be an absolute path or start with ~/"}, | |||
| 154 | + | {"video.dir = ./v", "line 1: video.dir must be an absolute path"}, | |||
| 155 | + | {"video.dir = ~user/v", "line 1: video.dir must be an absolute path"}, | |||
| 156 | + | {"video.dir =", "line 1: video.dir must be a non-empty path"}, | |||
| 157 | + | {"screenshot.output = both", "line 1: screenshot.output must be file, file+clipboard or clipboard"}, | |||
| 158 | + | {"screenshot.output = File", "screenshot.output must be"}, | |||
| 159 | + | {"screenshot.dir = /a\nscreenshot.dir = /b", "line 2: expected each of"}, | |||
| 160 | + | {"screenshot.output = file\n\nscreenshot.output = file", "line 3: expected each of"}, | |||
| 161 | + | {"video.dir = /a\nvideo.dir = /a", "line 2: expected each of"}, | |||
| 162 | + | {"video.output = file", "line 1: expected each of"}, | |||
| 163 | + | } { | |||
| 164 | + | t.Run(tc.text, func(t *testing.T) { | |||
| 165 | + | t.Setenv("HOME", t.TempDir()) | |||
| 166 | + | path := configRoot(t) | |||
| 167 | + | saveConfig(t, path, tc.text) | |||
| 168 | + | _, err := Load() | |||
| 169 | + | require.ErrorContains(t, err, tc.want) | |||
| 170 | + | assert.Contains(t, err.Error(), path) | |||
| 171 | + | }) | |||
| 172 | + | } | |||
| 173 | + | } | |||
M internal/adapters/destination/destination.go +16 -2
| @@ -32,7 +32,14 @@ var ErrNoHome = errors.New("destination: no absolute home directory") | |||
| 32 | 32 | ||
| 33 | 33 | // Screenshot returns a new .png path in the user's XDG pictures directory | |
| 34 | 34 | // (Screenshots subdirectory), falling back to $HOME/Pictures/Screenshots. | |
| 35 | - | func Screenshot() (string, error) { | |
| 35 | + | func Screenshot() (string, error) { return ScreenshotIn("") } | |
| 36 | + | ||
| 37 | + | // ScreenshotIn is Screenshot inside dir, an absolute directory that is created | |
| 38 | + | // when missing. An empty dir selects the XDG default. | |
| 39 | + | func ScreenshotIn(dir string) (string, error) { | |
| 40 | + | if dir != "" { | |
| 41 | + | return propose(".png", func() (string, error) { return dir, nil }) | |
| 42 | + | } | |
| 36 | 43 | return propose(".png", func() (string, error) { | |
| 37 | 44 | return userDir("XDG_PICTURES_DIR", "Pictures", "Screenshots") | |
| 38 | 45 | }) | |
| @@ -40,7 +47,14 @@ func Screenshot() (string, error) { | |||
| 40 | 47 | ||
| 41 | 48 | // Video returns a new .mp4 path in the user's XDG videos directory, falling | |
| 42 | 49 | // back to $HOME/Videos. | |
| 43 | - | func Video() (string, error) { | |
| 50 | + | func Video() (string, error) { return VideoIn("") } | |
| 51 | + | ||
| 52 | + | // VideoIn is Video inside dir, an absolute directory that is created when | |
| 53 | + | // missing. An empty dir selects the XDG default. | |
| 54 | + | func VideoIn(dir string) (string, error) { | |
| 55 | + | if dir != "" { | |
| 56 | + | return propose(".mp4", func() (string, error) { return dir, nil }) | |
| 57 | + | } | |
| 44 | 58 | return propose(".mp4", func() (string, error) { | |
| 45 | 59 | return userDir("XDG_VIDEOS_DIR", "Videos") | |
| 46 | 60 | }) | |
| @@ -32,7 +32,14 @@ var ErrNoHome = errors.New("destination: no absolute home directory") | |||||
| 32 | 32 | ||||
| 33 | // Screenshot returns a new .png path in the user's XDG pictures directory | 33 | // Screenshot returns a new .png path in the user's XDG pictures directory | ||
| 34 | // (Screenshots subdirectory), falling back to $HOME/Pictures/Screenshots. | 34 | // (Screenshots subdirectory), falling back to $HOME/Pictures/Screenshots. | ||
| 35 | - | func Screenshot() (string, error) { | 35 | + | func Screenshot() (string, error) { return ScreenshotIn("") } |
| 36 | + | ||||
| 37 | + | // ScreenshotIn is Screenshot inside dir, an absolute directory that is created | |||
| 38 | + | // when missing. An empty dir selects the XDG default. | |||
| 39 | + | func ScreenshotIn(dir string) (string, error) { | |||
| 40 | + | if dir != "" { | |||
| 41 | + | return propose(".png", func() (string, error) { return dir, nil }) | |||
| 42 | + | } | |||
| 36 | return propose(".png", func() (string, error) { | 43 | return propose(".png", func() (string, error) { | ||
| 37 | return userDir("XDG_PICTURES_DIR", "Pictures", "Screenshots") | 44 | return userDir("XDG_PICTURES_DIR", "Pictures", "Screenshots") | ||
| 38 | }) | 45 | }) | ||
| @@ -40,7 +47,14 @@ func Screenshot() (string, error) { | |||||
| 40 | 47 | ||||
| 41 | // Video returns a new .mp4 path in the user's XDG videos directory, falling | 48 | // Video returns a new .mp4 path in the user's XDG videos directory, falling | ||
| 42 | // back to $HOME/Videos. | 49 | // back to $HOME/Videos. | ||
| 43 | - | func Video() (string, error) { | 50 | + | func Video() (string, error) { return VideoIn("") } |
| 51 | + | ||||
| 52 | + | // VideoIn is Video inside dir, an absolute directory that is created when | |||
| 53 | + | // missing. An empty dir selects the XDG default. | |||
| 54 | + | func VideoIn(dir string) (string, error) { | |||
| 55 | + | if dir != "" { | |||
| 56 | + | return propose(".mp4", func() (string, error) { return dir, nil }) | |||
| 57 | + | } | |||
| 44 | return propose(".mp4", func() (string, error) { | 58 | return propose(".mp4", func() (string, error) { | ||
| 45 | return userDir("XDG_VIDEOS_DIR", "Videos") | 59 | return userDir("XDG_VIDEOS_DIR", "Videos") | ||
| 46 | }) | 60 | }) | ||
M internal/adapters/destination/destination_test.go +16 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "os" | |
| 5 | 5 | "path/filepath" | |
| 6 | 6 | "regexp" | |
| 7 | + | "strings" | |
| 7 | 8 | "testing" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/stretchr/testify/assert" | |
| @@ -194,3 +195,18 @@ func TestNamesDoNotCollide(t *testing.T) { | |||
| 194 | 195 | } | |
| 195 | 196 | assert.Len(t, seen, 200) | |
| 196 | 197 | } | |
| 198 | + | ||
| 199 | + | func TestInExplicitDirectory(t *testing.T) { | |
| 200 | + | root := filepath.Join(t.TempDir(), "a", "b") | |
| 201 | + | shot, err := ScreenshotIn(root) | |
| 202 | + | require.NoError(t, err) | |
| 203 | + | assert.Equal(t, root, filepath.Dir(shot)) | |
| 204 | + | assert.True(t, strings.HasSuffix(shot, ".png")) | |
| 205 | + | fi, err := os.Stat(root) | |
| 206 | + | require.NoError(t, err) | |
| 207 | + | assert.Equal(t, os.FileMode(0o700), fi.Mode().Perm()) | |
| 208 | + | vid, err := VideoIn(root) | |
| 209 | + | require.NoError(t, err) | |
| 210 | + | assert.Equal(t, root, filepath.Dir(vid)) | |
| 211 | + | assert.True(t, strings.HasSuffix(vid, ".mp4")) | |
| 212 | + | } | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "os" | 4 | "os" | ||
| 5 | "path/filepath" | 5 | "path/filepath" | ||
| 6 | "regexp" | 6 | "regexp" | ||
| 7 | + | "strings" | |||
| 7 | "testing" | 8 | "testing" | ||
| 8 | 9 | ||||
| 9 | "github.com/stretchr/testify/assert" | 10 | "github.com/stretchr/testify/assert" | ||
| @@ -194,3 +195,18 @@ func TestNamesDoNotCollide(t *testing.T) { | |||||
| 194 | } | 195 | } | ||
| 195 | assert.Len(t, seen, 200) | 196 | assert.Len(t, seen, 200) | ||
| 196 | } | 197 | } | ||
| 198 | + | ||||
| 199 | + | func TestInExplicitDirectory(t *testing.T) { | |||
| 200 | + | root := filepath.Join(t.TempDir(), "a", "b") | |||
| 201 | + | shot, err := ScreenshotIn(root) | |||
| 202 | + | require.NoError(t, err) | |||
| 203 | + | assert.Equal(t, root, filepath.Dir(shot)) | |||
| 204 | + | assert.True(t, strings.HasSuffix(shot, ".png")) | |||
| 205 | + | fi, err := os.Stat(root) | |||
| 206 | + | require.NoError(t, err) | |||
| 207 | + | assert.Equal(t, os.FileMode(0o700), fi.Mode().Perm()) | |||
| 208 | + | vid, err := VideoIn(root) | |||
| 209 | + | require.NoError(t, err) | |||
| 210 | + | assert.Equal(t, root, filepath.Dir(vid)) | |||
| 211 | + | assert.True(t, strings.HasSuffix(vid, ".mp4")) | |||
| 212 | + | } | |||
M internal/app/app.go +9 -3
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/nefercap/internal/adapters/cli" | |
| 12 | 12 | "github.com/bnema/nefercap/internal/adapters/clipboard" | |
| 13 | + | "github.com/bnema/nefercap/internal/adapters/config" | |
| 13 | 14 | "github.com/bnema/nefercap/internal/adapters/png" | |
| 14 | 15 | "github.com/bnema/nefercap/internal/adapters/wayland" | |
| 15 | 16 | "github.com/bnema/nefercap/internal/core" | |
| @@ -49,12 +50,17 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error) | |||
| 49 | 50 | return nil | |
| 50 | 51 | } | |
| 51 | 52 | var selection ports.Selection | |
| 52 | - | if options.Command == cli.Shot || options.Command == cli.Rec { | |
| 53 | + | if options.Command == cli.Shot || options.Command == cli.Rec || options.Command == cli.AllInOne { | |
| 54 | + | // Fail on a bad config before anything is shown. | |
| 55 | + | settings, loadErr := config.Load() | |
| 56 | + | if loadErr != nil { | |
| 57 | + | return fmt.Errorf("load config: %w", loadErr) | |
| 58 | + | } | |
| 53 | 59 | if err := rejectCompetingSelector(ctx); err != nil { | |
| 54 | 60 | return err | |
| 55 | 61 | } | |
| 56 | 62 | var accepted bool | |
| 57 | - | selection, accepted, err = chooseInteractive(ctx, source, outputs, options) | |
| 63 | + | selection, accepted, err = chooseInteractive(ctx, source, outputs, options, settings) | |
| 58 | 64 | if err != nil || !accepted { | |
| 59 | 65 | return err | |
| 60 | 66 | } | |
| @@ -76,7 +82,7 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error) | |||
| 76 | 82 | log := logging.For(ctx, "app") | |
| 77 | 83 | log.Info().Str("mode", string(selection.Mode)).Msg("capture started") | |
| 78 | 84 | var saved bool | |
| 79 | - | if options.Command == cli.Rec || options.Command == cli.Record { | |
| 85 | + | if selection.Mode == ports.Record { | |
| 80 | 86 | var outcome recordingOutcome | |
| 81 | 87 | outcome, err = recordInteractive(ctx, source, outputs, selection) | |
| 82 | 88 | saved = outcome.Saved | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | "github.com/bnema/nefercap/internal/adapters/cli" | 11 | "github.com/bnema/nefercap/internal/adapters/cli" | ||
| 12 | "github.com/bnema/nefercap/internal/adapters/clipboard" | 12 | "github.com/bnema/nefercap/internal/adapters/clipboard" | ||
| 13 | + | "github.com/bnema/nefercap/internal/adapters/config" | |||
| 13 | "github.com/bnema/nefercap/internal/adapters/png" | 14 | "github.com/bnema/nefercap/internal/adapters/png" | ||
| 14 | "github.com/bnema/nefercap/internal/adapters/wayland" | 15 | "github.com/bnema/nefercap/internal/adapters/wayland" | ||
| 15 | "github.com/bnema/nefercap/internal/core" | 16 | "github.com/bnema/nefercap/internal/core" | ||
| @@ -49,12 +50,17 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error) | |||||
| 49 | return nil | 50 | return nil | ||
| 50 | } | 51 | } | ||
| 51 | var selection ports.Selection | 52 | var selection ports.Selection | ||
| 52 | - | if options.Command == cli.Shot || options.Command == cli.Rec { | 53 | + | if options.Command == cli.Shot || options.Command == cli.Rec || options.Command == cli.AllInOne { |
| 54 | + | // Fail on a bad config before anything is shown. | |||
| 55 | + | settings, loadErr := config.Load() | |||
| 56 | + | if loadErr != nil { | |||
| 57 | + | return fmt.Errorf("load config: %w", loadErr) | |||
| 58 | + | } | |||
| 53 | if err := rejectCompetingSelector(ctx); err != nil { | 59 | if err := rejectCompetingSelector(ctx); err != nil { | ||
| 54 | return err | 60 | return err | ||
| 55 | } | 61 | } | ||
| 56 | var accepted bool | 62 | var accepted bool | ||
| 57 | - | selection, accepted, err = chooseInteractive(ctx, source, outputs, options) | 63 | + | selection, accepted, err = chooseInteractive(ctx, source, outputs, options, settings) |
| 58 | if err != nil || !accepted { | 64 | if err != nil || !accepted { | ||
| 59 | return err | 65 | return err | ||
| 60 | } | 66 | } | ||
| @@ -76,7 +82,7 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error) | |||||
| 76 | log := logging.For(ctx, "app") | 82 | log := logging.For(ctx, "app") | ||
| 77 | log.Info().Str("mode", string(selection.Mode)).Msg("capture started") | 83 | log.Info().Str("mode", string(selection.Mode)).Msg("capture started") | ||
| 78 | var saved bool | 84 | var saved bool | ||
| 79 | - | if options.Command == cli.Rec || options.Command == cli.Record { | 85 | + | if selection.Mode == ports.Record { |
| 80 | var outcome recordingOutcome | 86 | var outcome recordingOutcome | ||
| 81 | outcome, err = recordInteractive(ctx, source, outputs, selection) | 87 | outcome, err = recordInteractive(ctx, source, outputs, selection) | ||
| 82 | saved = outcome.Saved | 88 | saved = outcome.Saved | ||
M internal/app/control.go +3 -2
| @@ -11,10 +11,11 @@ import ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | // controlCommand resolves commands that must work without opening a GUI or | |
| 14 | - | // connecting to Wayland. A rec invocation is a stop toggle when already active. | |
| 14 | + | // connecting to Wayland. A rec or all-in-one invocation is a stop toggle when | |
| 15 | + | // already active. | |
| 15 | 16 | func controlCommand(ctx context.Context, command cli.Command, output io.Writer) (handled bool, err error) { | |
| 16 | 17 | switch command { | |
| 17 | - | case cli.Rec, cli.Stop: | |
| 18 | + | case cli.Rec, cli.AllInOne, cli.Stop: | |
| 18 | 19 | stopped, err := control.Stop(ctx) | |
| 19 | 20 | if err != nil { | |
| 20 | 21 | return true, err | |
| @@ -11,10 +11,11 @@ import ( | |||||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | // controlCommand resolves commands that must work without opening a GUI or | 13 | // controlCommand resolves commands that must work without opening a GUI or | ||
| 14 | - | // connecting to Wayland. A rec invocation is a stop toggle when already active. | 14 | + | // connecting to Wayland. A rec or all-in-one invocation is a stop toggle when |
| 15 | + | // already active. | |||
| 15 | func controlCommand(ctx context.Context, command cli.Command, output io.Writer) (handled bool, err error) { | 16 | func controlCommand(ctx context.Context, command cli.Command, output io.Writer) (handled bool, err error) { | ||
| 16 | switch command { | 17 | switch command { | ||
| 17 | - | case cli.Rec, cli.Stop: | 18 | + | case cli.Rec, cli.AllInOne, cli.Stop: |
| 18 | stopped, err := control.Stop(ctx) | 19 | stopped, err := control.Stop(ctx) | ||
| 19 | if err != nil { | 20 | if err != nil { | ||
| 20 | return true, err | 21 | return true, err | ||
M internal/app/control_test.go +29 -0
| @@ -48,6 +48,35 @@ func TestControlCommands(t *testing.T) { | |||
| 48 | 48 | } | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | + | func TestAllInOneStopsActiveRecording(t *testing.T) { | |
| 52 | + | runtime := filepath.Join(t.TempDir(), "runtime") | |
| 53 | + | if err := os.Mkdir(runtime, 0o700); err != nil { | |
| 54 | + | t.Fatal(err) | |
| 55 | + | } | |
| 56 | + | t.Setenv("XDG_RUNTIME_DIR", runtime) | |
| 57 | + | var out bytes.Buffer | |
| 58 | + | ctx := context.Background() | |
| 59 | + | if handled, err := controlCommand(ctx, cli.AllInOne, &out); handled || err != nil { | |
| 60 | + | t.Fatalf("idle all-in-one must open the selector: %t %v", handled, err) | |
| 61 | + | } | |
| 62 | + | server, err := control.Listen() | |
| 63 | + | if err != nil { | |
| 64 | + | t.Fatal(err) | |
| 65 | + | } | |
| 66 | + | defer server.Close() | |
| 67 | + | if handled, err := controlCommand(ctx, cli.AllInOne, &out); !handled || err != nil || out.Len() != 0 { | |
| 68 | + | t.Fatalf("active all-in-one: %t %v %q", handled, err, out.String()) | |
| 69 | + | } | |
| 70 | + | select { | |
| 71 | + | case got := <-server.Requests(): | |
| 72 | + | if got != control.RequestStop { | |
| 73 | + | t.Fatalf("request: %v", got) | |
| 74 | + | } | |
| 75 | + | case <-time.After(time.Second): | |
| 76 | + | t.Fatal("stop not delivered") | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 51 | 80 | func TestStopOnRequest(t *testing.T) { | |
| 52 | 81 | ctx, cancel := context.WithCancel(context.Background()) | |
| 53 | 82 | defer cancel() | |
| @@ -48,6 +48,35 @@ func TestControlCommands(t *testing.T) { | |||||
| 48 | } | 48 | } | ||
| 49 | } | 49 | } | ||
| 50 | 50 | ||||
| 51 | + | func TestAllInOneStopsActiveRecording(t *testing.T) { | |||
| 52 | + | runtime := filepath.Join(t.TempDir(), "runtime") | |||
| 53 | + | if err := os.Mkdir(runtime, 0o700); err != nil { | |||
| 54 | + | t.Fatal(err) | |||
| 55 | + | } | |||
| 56 | + | t.Setenv("XDG_RUNTIME_DIR", runtime) | |||
| 57 | + | var out bytes.Buffer | |||
| 58 | + | ctx := context.Background() | |||
| 59 | + | if handled, err := controlCommand(ctx, cli.AllInOne, &out); handled || err != nil { | |||
| 60 | + | t.Fatalf("idle all-in-one must open the selector: %t %v", handled, err) | |||
| 61 | + | } | |||
| 62 | + | server, err := control.Listen() | |||
| 63 | + | if err != nil { | |||
| 64 | + | t.Fatal(err) | |||
| 65 | + | } | |||
| 66 | + | defer server.Close() | |||
| 67 | + | if handled, err := controlCommand(ctx, cli.AllInOne, &out); !handled || err != nil || out.Len() != 0 { | |||
| 68 | + | t.Fatalf("active all-in-one: %t %v %q", handled, err, out.String()) | |||
| 69 | + | } | |||
| 70 | + | select { | |||
| 71 | + | case got := <-server.Requests(): | |||
| 72 | + | if got != control.RequestStop { | |||
| 73 | + | t.Fatalf("request: %v", got) | |||
| 74 | + | } | |||
| 75 | + | case <-time.After(time.Second): | |||
| 76 | + | t.Fatal("stop not delivered") | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 51 | func TestStopOnRequest(t *testing.T) { | 80 | func TestStopOnRequest(t *testing.T) { | ||
| 52 | ctx, cancel := context.WithCancel(context.Background()) | 81 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 53 | defer cancel() | 82 | defer cancel() | ||
A internal/app/destination_test.go +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/nefercap/internal/adapters/cli" | |
| 13 | + | "github.com/bnema/nefercap/internal/adapters/config" | |
| 14 | + | "github.com/bnema/nefercap/internal/ports" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestAssignScreenshotDestination(t *testing.T) { | |
| 18 | + | dir := filepath.Join(t.TempDir(), "shots") | |
| 19 | + | for _, tc := range []struct { | |
| 20 | + | name string | |
| 21 | + | output config.ScreenshotOutput | |
| 22 | + | options cli.Options | |
| 23 | + | wantFile bool // a file in dir | |
| 24 | + | wantPath string | |
| 25 | + | clipboard bool | |
| 26 | + | }{ | |
| 27 | + | {"file default", config.OutputFile, cli.Options{}, true, "", false}, | |
| 28 | + | {"file+clipboard", config.OutputFileClipboard, cli.Options{}, true, "", true}, | |
| 29 | + | {"clipboard only", config.OutputClipboard, cli.Options{}, false, "", true}, | |
| 30 | + | {"-file overrides clipboard config", config.OutputClipboard, cli.Options{Path: "x.png"}, false, "x.png", false}, | |
| 31 | + | {"-file overrides file+clipboard", config.OutputFileClipboard, cli.Options{Path: "x.png"}, false, "x.png", false}, | |
| 32 | + | {"-clipboard overrides file config", config.OutputFile, cli.Options{Clipboard: true}, false, "", true}, | |
| 33 | + | {"-clipboard -file", config.OutputFile, cli.Options{Clipboard: true, Path: "x.png"}, false, "x.png", true}, | |
| 34 | + | } { | |
| 35 | + | t.Run(tc.name, func(t *testing.T) { | |
| 36 | + | sel := ports.Selection{Mode: ports.Screenshot} | |
| 37 | + | require.NoError(t, assignDestination(&sel, tc.options, config.Settings{Output: tc.output, ScreenshotDir: dir})) | |
| 38 | + | assert.Equal(t, tc.clipboard, sel.Clipboard) | |
| 39 | + | if tc.wantFile { | |
| 40 | + | assert.Equal(t, dir, filepath.Dir(sel.Path)) | |
| 41 | + | assert.True(t, strings.HasSuffix(sel.Path, ".png")) | |
| 42 | + | } else { | |
| 43 | + | assert.Equal(t, tc.wantPath, sel.Path) | |
| 44 | + | } | |
| 45 | + | }) | |
| 46 | + | } | |
| 47 | + | info, err := os.Stat(dir) | |
| 48 | + | require.NoError(t, err) | |
| 49 | + | assert.Equal(t, os.FileMode(0o700), info.Mode().Perm()) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestAssignVideoDestination(t *testing.T) { | |
| 53 | + | dir := filepath.Join(t.TempDir(), "vids") | |
| 54 | + | settings := config.Settings{Output: config.OutputClipboard, VideoDir: dir} | |
| 55 | + | sel := ports.Selection{Mode: ports.Record} | |
| 56 | + | require.NoError(t, assignDestination(&sel, cli.Options{}, settings)) | |
| 57 | + | assert.Equal(t, dir, filepath.Dir(sel.Path), "video.dir applies; screenshot.output does not") | |
| 58 | + | assert.True(t, strings.HasSuffix(sel.Path, ".mp4")) | |
| 59 | + | assert.False(t, sel.Clipboard) | |
| 60 | + | sel = ports.Selection{Mode: ports.Record} | |
| 61 | + | require.NoError(t, assignDestination(&sel, cli.Options{Path: "v.mp4"}, settings)) | |
| 62 | + | assert.Equal(t, "v.mp4", sel.Path) | |
| 63 | + | } | |
| @@ -0,0 +1,63 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/nefercap/internal/adapters/cli" | |||
| 13 | + | "github.com/bnema/nefercap/internal/adapters/config" | |||
| 14 | + | "github.com/bnema/nefercap/internal/ports" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func TestAssignScreenshotDestination(t *testing.T) { | |||
| 18 | + | dir := filepath.Join(t.TempDir(), "shots") | |||
| 19 | + | for _, tc := range []struct { | |||
| 20 | + | name string | |||
| 21 | + | output config.ScreenshotOutput | |||
| 22 | + | options cli.Options | |||
| 23 | + | wantFile bool // a file in dir | |||
| 24 | + | wantPath string | |||
| 25 | + | clipboard bool | |||
| 26 | + | }{ | |||
| 27 | + | {"file default", config.OutputFile, cli.Options{}, true, "", false}, | |||
| 28 | + | {"file+clipboard", config.OutputFileClipboard, cli.Options{}, true, "", true}, | |||
| 29 | + | {"clipboard only", config.OutputClipboard, cli.Options{}, false, "", true}, | |||
| 30 | + | {"-file overrides clipboard config", config.OutputClipboard, cli.Options{Path: "x.png"}, false, "x.png", false}, | |||
| 31 | + | {"-file overrides file+clipboard", config.OutputFileClipboard, cli.Options{Path: "x.png"}, false, "x.png", false}, | |||
| 32 | + | {"-clipboard overrides file config", config.OutputFile, cli.Options{Clipboard: true}, false, "", true}, | |||
| 33 | + | {"-clipboard -file", config.OutputFile, cli.Options{Clipboard: true, Path: "x.png"}, false, "x.png", true}, | |||
| 34 | + | } { | |||
| 35 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 36 | + | sel := ports.Selection{Mode: ports.Screenshot} | |||
| 37 | + | require.NoError(t, assignDestination(&sel, tc.options, config.Settings{Output: tc.output, ScreenshotDir: dir})) | |||
| 38 | + | assert.Equal(t, tc.clipboard, sel.Clipboard) | |||
| 39 | + | if tc.wantFile { | |||
| 40 | + | assert.Equal(t, dir, filepath.Dir(sel.Path)) | |||
| 41 | + | assert.True(t, strings.HasSuffix(sel.Path, ".png")) | |||
| 42 | + | } else { | |||
| 43 | + | assert.Equal(t, tc.wantPath, sel.Path) | |||
| 44 | + | } | |||
| 45 | + | }) | |||
| 46 | + | } | |||
| 47 | + | info, err := os.Stat(dir) | |||
| 48 | + | require.NoError(t, err) | |||
| 49 | + | assert.Equal(t, os.FileMode(0o700), info.Mode().Perm()) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestAssignVideoDestination(t *testing.T) { | |||
| 53 | + | dir := filepath.Join(t.TempDir(), "vids") | |||
| 54 | + | settings := config.Settings{Output: config.OutputClipboard, VideoDir: dir} | |||
| 55 | + | sel := ports.Selection{Mode: ports.Record} | |||
| 56 | + | require.NoError(t, assignDestination(&sel, cli.Options{}, settings)) | |||
| 57 | + | assert.Equal(t, dir, filepath.Dir(sel.Path), "video.dir applies; screenshot.output does not") | |||
| 58 | + | assert.True(t, strings.HasSuffix(sel.Path, ".mp4")) | |||
| 59 | + | assert.False(t, sel.Clipboard) | |||
| 60 | + | sel = ports.Selection{Mode: ports.Record} | |||
| 61 | + | require.NoError(t, assignDestination(&sel, cli.Options{Path: "v.mp4"}, settings)) | |||
| 62 | + | assert.Equal(t, "v.mp4", sel.Path) | |||
| 63 | + | } | |||
M internal/app/interactive.go +29 -16
| @@ -17,7 +17,7 @@ import ( | |||
| 17 | 17 | ||
| 18 | 18 | // chooseInteractive uses native workspace metadata only when the compositor | |
| 19 | 19 | // exposes it. Monitor and region screenshots still work with standard capture. | |
| 20 | - | func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []ports.Output, options cli.Options) (ports.Selection, bool, error) { | |
| 20 | + | func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []ports.Output, options cli.Options, settings config.Settings) (ports.Selection, bool, error) { | |
| 21 | 21 | mode := ports.Screenshot | |
| 22 | 22 | if options.Command == cli.Rec { | |
| 23 | 23 | mode = ports.Record | |
| @@ -36,30 +36,43 @@ func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []po | |||
| 36 | 36 | } | |
| 37 | 37 | return ports.Selection{}, false, fmt.Errorf("list workspace capture targets: %w", err) | |
| 38 | 38 | } | |
| 39 | - | settings, err := config.Load() | |
| 40 | - | if err != nil { | |
| 41 | - | return ports.Selection{}, false, fmt.Errorf("load selector config: %w", err) | |
| 39 | + | selector := selection.New(mode, options.Video, workspaces, settings.Grid) | |
| 40 | + | if options.Command == cli.AllInOne { | |
| 41 | + | selector.AllowToggle() | |
| 42 | 42 | } | |
| 43 | - | sel, accepted, err := selection.New(mode, options.Video, workspaces, settings.Grid).Select(ctx, outputs) | |
| 43 | + | sel, accepted, err := selector.Select(ctx, outputs) | |
| 44 | 44 | if err != nil || !accepted { | |
| 45 | 45 | return ports.Selection{}, false, err | |
| 46 | 46 | } | |
| 47 | 47 | sel.Duration = options.Duration | |
| 48 | - | sel.Path = options.Path | |
| 49 | - | sel.Clipboard = options.Clipboard | |
| 50 | - | if sel.Path == "" && !sel.Clipboard { | |
| 51 | - | if mode == ports.Record { | |
| 52 | - | sel.Path, err = destination.Video() | |
| 53 | - | } else { | |
| 54 | - | sel.Path, err = destination.Screenshot() | |
| 55 | - | } | |
| 56 | - | if err != nil { | |
| 57 | - | return ports.Selection{}, false, err | |
| 58 | - | } | |
| 48 | + | if err := assignDestination(&sel, options, settings); err != nil { | |
| 49 | + | return ports.Selection{}, false, err | |
| 59 | 50 | } | |
| 60 | 51 | return sel, true, nil | |
| 61 | 52 | } | |
| 62 | 53 | ||
| 54 | + | // assignDestination resolves where an interactive capture goes. Flags win over | |
| 55 | + | // the config: -file alone is a file only, -clipboard alone is clipboard only, | |
| 56 | + | // both save and copy. Without flags a screenshot follows screenshot.output. | |
| 57 | + | // Recordings always write a file; only screenshots are copied. | |
| 58 | + | func assignDestination(sel *ports.Selection, options cli.Options, settings config.Settings) (err error) { | |
| 59 | + | if sel.Mode == ports.Record { | |
| 60 | + | if sel.Path = options.Path; sel.Path == "" { | |
| 61 | + | sel.Path, err = destination.VideoIn(settings.VideoDir) | |
| 62 | + | } | |
| 63 | + | return err | |
| 64 | + | } | |
| 65 | + | if options.Path != "" || options.Clipboard { | |
| 66 | + | sel.Path, sel.Clipboard = options.Path, options.Clipboard | |
| 67 | + | return nil | |
| 68 | + | } | |
| 69 | + | sel.Clipboard = settings.Output != config.OutputFile | |
| 70 | + | if settings.Output != config.OutputClipboard { | |
| 71 | + | sel.Path, err = destination.ScreenshotIn(settings.ScreenshotDir) | |
| 72 | + | } | |
| 73 | + | return err | |
| 74 | + | } | |
| 75 | + | ||
| 63 | 76 | func rejectCompetingSelector(ctx context.Context) error { | |
| 64 | 77 | active, err := control.Query(ctx) | |
| 65 | 78 | if err != nil { | |
| @@ -17,7 +17,7 @@ import ( | |||||
| 17 | 17 | ||||
| 18 | // chooseInteractive uses native workspace metadata only when the compositor | 18 | // chooseInteractive uses native workspace metadata only when the compositor | ||
| 19 | // exposes it. Monitor and region screenshots still work with standard capture. | 19 | // exposes it. Monitor and region screenshots still work with standard capture. | ||
| 20 | - | func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []ports.Output, options cli.Options) (ports.Selection, bool, error) { | 20 | + | func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []ports.Output, options cli.Options, settings config.Settings) (ports.Selection, bool, error) { |
| 21 | mode := ports.Screenshot | 21 | mode := ports.Screenshot | ||
| 22 | if options.Command == cli.Rec { | 22 | if options.Command == cli.Rec { | ||
| 23 | mode = ports.Record | 23 | mode = ports.Record | ||
| @@ -36,30 +36,43 @@ func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []po | |||||
| 36 | } | 36 | } | ||
| 37 | return ports.Selection{}, false, fmt.Errorf("list workspace capture targets: %w", err) | 37 | return ports.Selection{}, false, fmt.Errorf("list workspace capture targets: %w", err) | ||
| 38 | } | 38 | } | ||
| 39 | - | settings, err := config.Load() | 39 | + | selector := selection.New(mode, options.Video, workspaces, settings.Grid) |
| 40 | - | if err != nil { | 40 | + | if options.Command == cli.AllInOne { |
| 41 | - | return ports.Selection{}, false, fmt.Errorf("load selector config: %w", err) | 41 | + | selector.AllowToggle() |
| 42 | } | 42 | } | ||
| 43 | - | sel, accepted, err := selection.New(mode, options.Video, workspaces, settings.Grid).Select(ctx, outputs) | 43 | + | sel, accepted, err := selector.Select(ctx, outputs) |
| 44 | if err != nil || !accepted { | 44 | if err != nil || !accepted { | ||
| 45 | return ports.Selection{}, false, err | 45 | return ports.Selection{}, false, err | ||
| 46 | } | 46 | } | ||
| 47 | sel.Duration = options.Duration | 47 | sel.Duration = options.Duration | ||
| 48 | - | sel.Path = options.Path | 48 | + | if err := assignDestination(&sel, options, settings); err != nil { |
| 49 | - | sel.Clipboard = options.Clipboard | 49 | + | return ports.Selection{}, false, err |
| 50 | - | if sel.Path == "" && !sel.Clipboard { | |||
| 51 | - | if mode == ports.Record { | |||
| 52 | - | sel.Path, err = destination.Video() | |||
| 53 | - | } else { | |||
| 54 | - | sel.Path, err = destination.Screenshot() | |||
| 55 | - | } | |||
| 56 | - | if err != nil { | |||
| 57 | - | return ports.Selection{}, false, err | |||
| 58 | - | } | |||
| 59 | } | 50 | } | ||
| 60 | return sel, true, nil | 51 | return sel, true, nil | ||
| 61 | } | 52 | } | ||
| 62 | 53 | ||||
| 54 | + | // assignDestination resolves where an interactive capture goes. Flags win over | |||
| 55 | + | // the config: -file alone is a file only, -clipboard alone is clipboard only, | |||
| 56 | + | // both save and copy. Without flags a screenshot follows screenshot.output. | |||
| 57 | + | // Recordings always write a file; only screenshots are copied. | |||
| 58 | + | func assignDestination(sel *ports.Selection, options cli.Options, settings config.Settings) (err error) { | |||
| 59 | + | if sel.Mode == ports.Record { | |||
| 60 | + | if sel.Path = options.Path; sel.Path == "" { | |||
| 61 | + | sel.Path, err = destination.VideoIn(settings.VideoDir) | |||
| 62 | + | } | |||
| 63 | + | return err | |||
| 64 | + | } | |||
| 65 | + | if options.Path != "" || options.Clipboard { | |||
| 66 | + | sel.Path, sel.Clipboard = options.Path, options.Clipboard | |||
| 67 | + | return nil | |||
| 68 | + | } | |||
| 69 | + | sel.Clipboard = settings.Output != config.OutputFile | |||
| 70 | + | if settings.Output != config.OutputClipboard { | |||
| 71 | + | sel.Path, err = destination.ScreenshotIn(settings.ScreenshotDir) | |||
| 72 | + | } | |||
| 73 | + | return err | |||
| 74 | + | } | |||
| 75 | + | ||||
| 63 | func rejectCompetingSelector(ctx context.Context) error { | 76 | func rejectCompetingSelector(ctx context.Context) error { | ||
| 64 | active, err := control.Query(ctx) | 77 | active, err := control.Query(ctx) | ||
| 65 | if err != nil { | 78 | if err != nil { | ||