detached 935ba21

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
66
77 ```sh
88 make bin
9+./bin/nefercap
910 ./bin/nefercap shot
1011 ./bin/nefercap shot -clipboard
1112 ./bin/nefercap shot -clipboard -file capture.png
@@ -14,7 +15,9 @@ make bin
1415 ./bin/nefercap status
1516 ```
1617
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:
1821
1922 - **Drag and release:** capture a rectangle, clamped to its monitor.
2023 - **Click:** capture the monitor; small pointer jitter counts as a click.
@@ -25,23 +28,30 @@ make bin
2528
2629 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.
2730
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`):
2932
3033 ```ini
31-selector.grid = off
34+selector.grid = on
35+screenshot.dir = ~/Pictures/Screenshots
36+screenshot.output = file+clipboard
37+video.dir = ~/Videos
3238 ```
3339
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.
3545
3646 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.
3747
3848 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.
3949
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.
4151
4252 `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.
4353
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.
4555
4656 ## Requirements
4757
@@ -6,6 +6,7 @@ Fast Wayland screenshots and silent video recording with a NeferGUI layer-shell
6 6
7 ```sh7 ```sh
8 make bin8 make bin
9+./bin/nefercap
9 ./bin/nefercap shot10 ./bin/nefercap shot
10 ./bin/nefercap shot -clipboard11 ./bin/nefercap shot -clipboard
11 ./bin/nefercap shot -clipboard -file capture.png12 ./bin/nefercap shot -clipboard -file capture.png
@@ -14,7 +15,9 @@ make bin
14 ./bin/nefercap status15 ./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 ```ini33 ```ini
31-selector.grid = off34+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 ## Requirements56 ## Requirements
47 57
M internal/adapters/cli/cli.go +31 -7
@@ -18,9 +18,13 @@ import (
1818 type Command string
1919
2020 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"
2125 // 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.
2428 Shot Command = "shot"
2529 Rec Command = "rec"
2630 Stop Command = "stop"
@@ -40,7 +44,7 @@ var ErrUsage = errors.New("usage error")
4044
4145 // Options is the parsed command line. Video is set only for Record and Rec.
4246 // 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.
4448 type Options struct {
4549 Command Command
4650 Output string // output name; empty lets the caller choose
@@ -56,8 +60,12 @@ type Options struct {
5660 func Usage() string {
5761 return fmt.Sprintf(`Usage: nefercap [command] [flags]
5862
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+
5967 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
6169 rec select what to record; run again while recording to stop it
6270 stop stop the active recording
6371 status print the recording state
@@ -70,10 +78,14 @@ Selector keys and mouse:
7078 w choose the current workspace when known, click or Enter confirms
7179 1..9 pick that whole monitor from any overlay
7280 g toggle full-monitor center and thirds guides (on by default)
81+ Tab switch shot <-> rec (all-in-one only)
7382 Escape cancel
7483
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
7789
7890 Script commands (never open the selector):
7991 outputs list outputs
@@ -82,6 +94,7 @@ Script commands (never open the selector):
8294
8395 Flags:
8496 -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
8598 -debug enable debug logging (all commands)
8699 -output NAME output name (all except stop, status, outputs)
87100 -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) {
98111 if out == nil {
99112 out = io.Discard
100113 }
101- opts := Options{Command: Shot}
114+ opts := Options{Command: AllInOne}
102115 if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
103116 switch cmd := Command(args[0]); cmd {
104117 case Shot, Rec, Stop, Status, Outputs, Screenshot, Record:
@@ -139,6 +152,9 @@ func Parse(args []string, out io.Writer) (Options, error) {
139152 _, _ = io.WriteString(out, Usage())
140153 return Options{}, flag.ErrHelp
141154 }
155+ if opts.Command == AllInOne {
156+ return Options{}, errAllInOneFlags
157+ }
142158 return Options{}, fmt.Errorf("%w: %w", ErrUsage, err)
143159 }
144160 if fs.NArg() > 0 {
@@ -191,6 +207,14 @@ func Parse(args []string, out io.Writer) (Options, error) {
191207 return opts, nil
192208 }
193209
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+
194218 func usageErr(format string, a ...any) error {
195219 return fmt.Errorf("%w: %s", ErrUsage, fmt.Sprintf(format, a...))
196220 }
@@ -18,9 +18,13 @@ import (
18 type Command string18 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 is26+ // 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 caller46 // 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 Command49 Command Command
46 Output string // output name; empty lets the caller choose50 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 it69 rec select what to record; run again while recording to stop it
62 stop stop the active recording70 stop stop the active recording
63 status print the recording state71 status print the recording state
@@ -70,10 +78,14 @@ Selector keys and mouse:
70 w choose the current workspace when known, click or Enter confirms78 w choose the current workspace when known, click or Enter confirms
71 1..9 pick that whole monitor from any overlay79 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 cancel82 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 them85+ 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 outputs91 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 it100 -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.Discard112 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.ErrHelp153 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, nil207 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) {
2020 args []string
2121 want cli.Options
2222 }{
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"}},
2728 {"shot", []string{"shot"}, cli.Options{Command: cli.Shot}},
2829 {"shot all", []string{"shot", "-file", "a.png", "-output", "DP-1", "-debug"},
2930 cli.Options{Command: cli.Shot, Path: "a.png", Output: "DP-1", Debug: true}},
@@ -146,6 +147,19 @@ func TestParseUsageErrors(t *testing.T) {
146147 }
147148 }
148149
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+
149163 func TestParseErrorNamesProblem(t *testing.T) {
150164 _, err := cli.Parse([]string{"record"}, nil)
151165 assert.ErrorContains(t, err, "-file is required")
@@ -20,10 +20,11 @@ func TestParseValid(t *testing.T) {
20 args []string20 args []string
21 want cli.Options21 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.
22 package config
33
44 import (
@@ -7,23 +7,42 @@ import (
77 "io"
88 "os"
99 "path/filepath"
10+ "slices"
1011 "strings"
1112 "syscall"
1213 )
1314
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+)
1528
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.
1832 type Settings struct {
19- Grid bool
33+ Grid bool
34+ ScreenshotDir string
35+ VideoDir string
36+ Output ScreenshotOutput
2037 }
2138
39+const knownKeys = "selector.grid, screenshot.dir, screenshot.output, video.dir"
40+
2241 // Load reads $XDG_CONFIG_HOME/nefercap/config (or ~/.config/nefercap/config).
2342 // An absent file or setting enables the full monitor grid. Invalid settings
2443 // fail explicitly rather than silently changing the requested appearance.
2544 func Load() (Settings, error) {
26- settings := Settings{Grid: true}
45+ settings := Settings{Grid: true, Output: OutputFile}
2746 root := os.Getenv("XDG_CONFIG_HOME")
2847 if !filepath.IsAbs(root) {
2948 home, err := os.UserHomeDir()
@@ -39,42 +58,88 @@ func Load() (Settings, error) {
3958 return settings, nil
4059 }
4160 if err != nil {
42- return Settings{}, fmt.Errorf("open selector config: %w", err)
61+ return Settings{}, fmt.Errorf("open config: %w", err)
4362 }
4463 defer file.Close()
4564 info, err := file.Stat()
4665 if err != nil {
47- return Settings{}, fmt.Errorf("stat selector config: %w", err)
66+ return Settings{}, fmt.Errorf("stat config: %w", err)
4867 }
4968 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)
5170 }
5271 data, err := io.ReadAll(io.LimitReader(file, maxBytes+1))
5372 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)
5574 }
5675 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)
5877 }
59- seen := false
78+ var seen [4]bool
6079 for index, raw := range strings.Split(strings.TrimPrefix(string(data), "\ufeff"), "\n") {
6180 line := strings.TrimSpace(raw)
6281 if line == "" || strings.HasPrefix(line, "#") {
6382 continue
6483 }
6584 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)
6892 }
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 {
70107 case "on":
71- settings.Grid = true
108+ s.Grid = true
72109 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
74118 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")
76120 }
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)
78125 }
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
80145 }
@@ -1,4 +1,4 @@
1-// Package config reads the selector's persistent settings.1+// Package config reads nefercap's persistent settings.
2 package config2 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 = 409615+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 for29+// 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 bool33+ 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 settings42 // 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, nil58 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 := false78+ 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 continue82 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 = true108+ s.Grid = true
72 case "off":109 case "off":
73- settings.Grid = false110+ 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 = true121+ 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, nil126+ 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) {
5757 func TestLoadRejectsInvalid(t *testing.T) {
5858 for _, tc := range []struct{ text, want string }{
5959 {"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"},
6161 {"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"},
6464 {strings.Repeat("#", maxBytes+1), "exceeds 4096 bytes"},
6565 } {
6666 t.Run(tc.text[:min(len(tc.text), 40)], func(t *testing.T) {
@@ -123,3 +123,51 @@ func TestLoadDirectoryRejected(t *testing.T) {
123123 _, err := Load()
124124 require.ErrorContains(t, err, "expected a regular file")
125125 }
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")
3232
3333 // Screenshot returns a new .png path in the user's XDG pictures directory
3434 // (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+ }
3643 return propose(".png", func() (string, error) {
3744 return userDir("XDG_PICTURES_DIR", "Pictures", "Screenshots")
3845 })
@@ -40,7 +47,14 @@ func Screenshot() (string, error) {
4047
4148 // Video returns a new .mp4 path in the user's XDG videos directory, falling
4249 // 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+ }
4458 return propose(".mp4", func() (string, error) {
4559 return userDir("XDG_VIDEOS_DIR", "Videos")
4660 })
@@ -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 directory33 // 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, falling48 // 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 (
44 "os"
55 "path/filepath"
66 "regexp"
7+ "strings"
78 "testing"
89
910 "github.com/stretchr/testify/assert"
@@ -194,3 +195,18 @@ func TestNamesDoNotCollide(t *testing.T) {
194195 }
195196 assert.Len(t, seen, 200)
196197 }
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 (
1010
1111 "github.com/bnema/nefercap/internal/adapters/cli"
1212 "github.com/bnema/nefercap/internal/adapters/clipboard"
13+ "github.com/bnema/nefercap/internal/adapters/config"
1314 "github.com/bnema/nefercap/internal/adapters/png"
1415 "github.com/bnema/nefercap/internal/adapters/wayland"
1516 "github.com/bnema/nefercap/internal/core"
@@ -49,12 +50,17 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error)
4950 return nil
5051 }
5152 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+ }
5359 if err := rejectCompetingSelector(ctx); err != nil {
5460 return err
5561 }
5662 var accepted bool
57- selection, accepted, err = chooseInteractive(ctx, source, outputs, options)
63+ selection, accepted, err = chooseInteractive(ctx, source, outputs, options, settings)
5864 if err != nil || !accepted {
5965 return err
6066 }
@@ -76,7 +82,7 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error)
7682 log := logging.For(ctx, "app")
7783 log.Info().Str("mode", string(selection.Mode)).Msg("capture started")
7884 var saved bool
79- if options.Command == cli.Rec || options.Command == cli.Record {
85+ if selection.Mode == ports.Record {
8086 var outcome recordingOutcome
8187 outcome, err = recordInteractive(ctx, source, outputs, selection)
8288 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 nil50 return nil
50 }51 }
51 var selection ports.Selection52 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 err60 return err
55 }61 }
56 var accepted bool62 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 err65 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 bool84 var saved bool
79- if options.Command == cli.Rec || options.Command == cli.Record {85+ if selection.Mode == ports.Record {
80 var outcome recordingOutcome86 var outcome recordingOutcome
81 outcome, err = recordInteractive(ctx, source, outputs, selection)87 outcome, err = recordInteractive(ctx, source, outputs, selection)
82 saved = outcome.Saved88 saved = outcome.Saved
M internal/app/control.go +3 -2
@@ -11,10 +11,11 @@ import (
1111 )
1212
1313 // 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.
1516 func controlCommand(ctx context.Context, command cli.Command, output io.Writer) (handled bool, err error) {
1617 switch command {
17- case cli.Rec, cli.Stop:
18+ case cli.Rec, cli.AllInOne, cli.Stop:
1819 stopped, err := control.Stop(ctx)
1920 if err != nil {
2021 return true, err
@@ -11,10 +11,11 @@ import (
11 )11 )
12 12
13 // controlCommand resolves commands that must work without opening a GUI or13 // 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, err21 return true, err
M internal/app/control_test.go +29 -0
@@ -48,6 +48,35 @@ func TestControlCommands(t *testing.T) {
4848 }
4949 }
5050
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+
5180 func TestStopOnRequest(t *testing.T) {
5281 ctx, cancel := context.WithCancel(context.Background())
5382 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 (
1717
1818 // chooseInteractive uses native workspace metadata only when the compositor
1919 // 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) {
2121 mode := ports.Screenshot
2222 if options.Command == cli.Rec {
2323 mode = ports.Record
@@ -36,30 +36,43 @@ func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []po
3636 }
3737 return ports.Selection{}, false, fmt.Errorf("list workspace capture targets: %w", err)
3838 }
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()
4242 }
43- sel, accepted, err := selection.New(mode, options.Video, workspaces, settings.Grid).Select(ctx, outputs)
43+ sel, accepted, err := selector.Select(ctx, outputs)
4444 if err != nil || !accepted {
4545 return ports.Selection{}, false, err
4646 }
4747 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
5950 }
6051 return sel, true, nil
6152 }
6253
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+
6376 func rejectCompetingSelector(ctx context.Context) error {
6477 active, err := control.Query(ctx)
6578 if err != nil {
@@ -17,7 +17,7 @@ import (
17 17
18 // chooseInteractive uses native workspace metadata only when the compositor18 // 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.Screenshot21 mode := ports.Screenshot
22 if options.Command == cli.Rec {22 if options.Command == cli.Rec {
23 mode = ports.Record23 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, err45 return ports.Selection{}, false, err
46 }46 }
47 sel.Duration = options.Duration47 sel.Duration = options.Duration
48- sel.Path = options.Path48+ if err := assignDestination(&sel, options, settings); err != nil {
49- sel.Clipboard = options.Clipboard49+ 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, nil51 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 {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 9

↑↓ moveenter openesc close