fix(selection): keep the confirmed mode and check capture directories before the selector opens
- commit
- a7c942d78ebed2f26b4fa30a238be44c3fc6b9d9
- author
- brice · · 2026-09-30 15:58 UTC
- parent
- 935ba21
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +123 -19
M Makefile +1 -1
| @@ -45,7 +45,7 @@ arch: | |||
| 45 | 45 | # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS | |
| 46 | 46 | # the packages holding them. Every named test must exist, so the guard cannot | |
| 47 | 47 | # pass vacuously when a frame-path test is removed or renamed. | |
| 48 | - | PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestMonitorGridAllocations TestMonitorGridPhysicalAllocations TestGridAllocations TestGridModeAllocations TestSelectorAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations | |
| 48 | + | PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestMonitorGridAllocations TestMonitorGridPhysicalAllocations TestGridAllocations TestGridModeAllocations TestSelectorAllocations TestModeToggleAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations | |
| 49 | 49 | PERF_PKGS := ./internal/ports ./internal/core ./internal/adapters/selection ./internal/adapters/indicator ./internal/adapters/ffmpeg ./internal/app ./internal/adapters/wayland | |
| 50 | 50 | perf_empty := | |
| 51 | 51 | perf_space := $(perf_empty) $(perf_empty) | |
| @@ -45,7 +45,7 @@ arch: | |||||
| 45 | # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS | 45 | # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS | ||
| 46 | # the packages holding them. Every named test must exist, so the guard cannot | 46 | # the packages holding them. Every named test must exist, so the guard cannot | ||
| 47 | # pass vacuously when a frame-path test is removed or renamed. | 47 | # pass vacuously when a frame-path test is removed or renamed. | ||
| 48 | - | PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestMonitorGridAllocations TestMonitorGridPhysicalAllocations TestGridAllocations TestGridModeAllocations TestSelectorAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations | 48 | + | PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestMonitorGridAllocations TestMonitorGridPhysicalAllocations TestGridAllocations TestGridModeAllocations TestSelectorAllocations TestModeToggleAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations |
| 49 | PERF_PKGS := ./internal/ports ./internal/core ./internal/adapters/selection ./internal/adapters/indicator ./internal/adapters/ffmpeg ./internal/app ./internal/adapters/wayland | 49 | PERF_PKGS := ./internal/ports ./internal/core ./internal/adapters/selection ./internal/adapters/indicator ./internal/adapters/ffmpeg ./internal/app ./internal/adapters/wayland | ||
| 50 | perf_empty := | 50 | perf_empty := | ||
| 51 | perf_space := $(perf_empty) $(perf_empty) | 51 | perf_space := $(perf_empty) $(perf_empty) | ||
M internal/adapters/cli/cli.go +1 -2
| @@ -94,7 +94,6 @@ Script commands (never open the selector): | |||
| 94 | 94 | ||
| 95 | 95 | Flags: | |
| 96 | 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 | |
| 98 | 97 | -debug enable debug logging (all commands) | |
| 99 | 98 | -output NAME output name (all except stop, status, outputs) | |
| 100 | 99 | -file PATH new file to write; optional for shot, rec; screenshot requires it unless -clipboard; record requires it | |
| @@ -213,7 +212,7 @@ type usageError string | |||
| 213 | 212 | func (e usageError) Error() string { return string(e) } | |
| 214 | 213 | func (e usageError) Is(target error) bool { return target == ErrUsage } | |
| 215 | 214 | ||
| 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.") | |
| 215 | + | const errAllInOneFlags = usageError("nefercap: all-in-one mode takes no flags except -debug; it reads $XDG_CONFIG_HOME/nefercap/config (default ~/.config/nefercap/config); use shot, rec, screenshot or record for per-run overrides") | |
| 217 | 216 | ||
| 218 | 217 | func usageErr(format string, a ...any) error { | |
| 219 | 218 | return fmt.Errorf("%w: %s", ErrUsage, fmt.Sprintf(format, a...)) | |
| @@ -94,7 +94,6 @@ Script commands (never open the selector): | |||||
| 94 | 94 | ||||
| 95 | Flags: | 95 | Flags: | ||
| 96 | -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 | |||
| 98 | -debug enable debug logging (all commands) | 97 | -debug enable debug logging (all commands) | ||
| 99 | -output NAME output name (all except stop, status, outputs) | 98 | -output NAME output name (all except stop, status, outputs) | ||
| 100 | -file PATH new file to write; optional for shot, rec; screenshot requires it unless -clipboard; record requires it | 99 | -file PATH new file to write; optional for shot, rec; screenshot requires it unless -clipboard; record requires it | ||
| @@ -213,7 +212,7 @@ type usageError string | |||||
| 213 | func (e usageError) Error() string { return string(e) } | 212 | func (e usageError) Error() string { return string(e) } | ||
| 214 | func (e usageError) Is(target error) bool { return target == ErrUsage } | 213 | func (e usageError) Is(target error) bool { return target == ErrUsage } | ||
| 215 | 214 | ||||
| 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.") | 215 | + | const errAllInOneFlags = usageError("nefercap: all-in-one mode takes no flags except -debug; it reads $XDG_CONFIG_HOME/nefercap/config (default ~/.config/nefercap/config); use shot, rec, screenshot or record for per-run overrides") |
| 217 | 216 | ||||
| 218 | func usageErr(format string, a ...any) error { | 217 | func usageErr(format string, a ...any) error { | ||
| 219 | return fmt.Errorf("%w: %s", ErrUsage, fmt.Sprintf(format, a...)) | 218 | return fmt.Errorf("%w: %s", ErrUsage, fmt.Sprintf(format, a...)) | ||
M internal/adapters/cli/cli_test.go +2 -2
| @@ -154,8 +154,8 @@ func TestAllInOneRejectsFlags(t *testing.T) { | |||
| 154 | 154 | } { | |
| 155 | 155 | got, err := cli.Parse(args, nil) | |
| 156 | 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.") | |
| 157 | + | assert.ErrorContains(t, err, "all-in-one mode takes no flags except -debug") | |
| 158 | + | assert.ErrorContains(t, err, "use shot, rec, screenshot or record for per-run overrides") | |
| 159 | 159 | assert.Equal(t, cli.Options{}, got) | |
| 160 | 160 | } | |
| 161 | 161 | } | |
| @@ -154,8 +154,8 @@ func TestAllInOneRejectsFlags(t *testing.T) { | |||||
| 154 | } { | 154 | } { | ||
| 155 | got, err := cli.Parse(args, nil) | 155 | got, err := cli.Parse(args, nil) | ||
| 156 | require.ErrorIs(t, err, cli.ErrUsage, args) | 156 | require.ErrorIs(t, err, cli.ErrUsage, args) | ||
| 157 | - | assert.ErrorContains(t, err, "all-in-one mode takes no flags") | 157 | + | assert.ErrorContains(t, err, "all-in-one mode takes no flags except -debug") |
| 158 | - | assert.ErrorContains(t, err, "Use shot, rec, screenshot or record for per-run overrides.") | 158 | + | assert.ErrorContains(t, err, "use shot, rec, screenshot or record for per-run overrides") |
| 159 | assert.Equal(t, cli.Options{}, got) | 159 | assert.Equal(t, cli.Options{}, got) | ||
| 160 | } | 160 | } | ||
| 161 | } | 161 | } | ||
M internal/adapters/config/config.go +1 -1
| @@ -131,7 +131,7 @@ func dir(key, value string) (string, error) { | |||
| 131 | 131 | if value == "" || len(value) > maxPath || strings.IndexByte(value, 0) >= 0 { | |
| 132 | 132 | return "", fmt.Errorf("%s must be a non-empty path", key) | |
| 133 | 133 | } | |
| 134 | - | if value == "~" || strings.HasPrefix(value, "~/") { | |
| 134 | + | if strings.HasPrefix(value, "~/") { | |
| 135 | 135 | home, err := os.UserHomeDir() | |
| 136 | 136 | if err != nil || !filepath.IsAbs(home) { | |
| 137 | 137 | return "", fmt.Errorf("%s: no absolute home directory for ~", key) | |
| @@ -131,7 +131,7 @@ func dir(key, value string) (string, error) { | |||||
| 131 | if value == "" || len(value) > maxPath || strings.IndexByte(value, 0) >= 0 { | 131 | if value == "" || len(value) > maxPath || strings.IndexByte(value, 0) >= 0 { | ||
| 132 | return "", fmt.Errorf("%s must be a non-empty path", key) | 132 | return "", fmt.Errorf("%s must be a non-empty path", key) | ||
| 133 | } | 133 | } | ||
| 134 | - | if value == "~" || strings.HasPrefix(value, "~/") { | 134 | + | if strings.HasPrefix(value, "~/") { |
| 135 | home, err := os.UserHomeDir() | 135 | home, err := os.UserHomeDir() | ||
| 136 | if err != nil || !filepath.IsAbs(home) { | 136 | if err != nil || !filepath.IsAbs(home) { | ||
| 137 | return "", fmt.Errorf("%s: no absolute home directory for ~", key) | 137 | return "", fmt.Errorf("%s: no absolute home directory for ~", key) | ||
M internal/adapters/config/config_test.go +2 -1
| @@ -133,7 +133,7 @@ func TestLoadPathsAndOutput(t *testing.T) { | |||
| 133 | 133 | require.NoError(t, err) | |
| 134 | 134 | assert.Equal(t, Settings{Grid: false, ScreenshotDir: filepath.Join(home, "Pics", "Shots"), VideoDir: "/var/tmp/vids", Output: OutputFileClipboard}, got) | |
| 135 | 135 | ||
| 136 | - | saveConfig(t, path, "screenshot.dir = ~\nscreenshot.output = clipboard\n") | |
| 136 | + | saveConfig(t, path, "screenshot.dir = ~/\nscreenshot.output = clipboard\n") | |
| 137 | 137 | got, err = Load() | |
| 138 | 138 | require.NoError(t, err) | |
| 139 | 139 | assert.Equal(t, home, got.ScreenshotDir) | |
| @@ -153,6 +153,7 @@ func TestLoadRejectsInvalidNewKeys(t *testing.T) { | |||
| 153 | 153 | {"screenshot.dir = Pictures", "line 1: screenshot.dir must be an absolute path or start with ~/"}, | |
| 154 | 154 | {"video.dir = ./v", "line 1: video.dir must be an absolute path"}, | |
| 155 | 155 | {"video.dir = ~user/v", "line 1: video.dir must be an absolute path"}, | |
| 156 | + | {"video.dir = ~", "line 1: video.dir must be an absolute path"}, | |
| 156 | 157 | {"video.dir =", "line 1: video.dir must be a non-empty path"}, | |
| 157 | 158 | {"screenshot.output = both", "line 1: screenshot.output must be file, file+clipboard or clipboard"}, | |
| 158 | 159 | {"screenshot.output = File", "screenshot.output must be"}, | |
| @@ -133,7 +133,7 @@ func TestLoadPathsAndOutput(t *testing.T) { | |||||
| 133 | require.NoError(t, err) | 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) | 134 | assert.Equal(t, Settings{Grid: false, ScreenshotDir: filepath.Join(home, "Pics", "Shots"), VideoDir: "/var/tmp/vids", Output: OutputFileClipboard}, got) | ||
| 135 | 135 | ||||
| 136 | - | saveConfig(t, path, "screenshot.dir = ~\nscreenshot.output = clipboard\n") | 136 | + | saveConfig(t, path, "screenshot.dir = ~/\nscreenshot.output = clipboard\n") |
| 137 | got, err = Load() | 137 | got, err = Load() | ||
| 138 | require.NoError(t, err) | 138 | require.NoError(t, err) | ||
| 139 | assert.Equal(t, home, got.ScreenshotDir) | 139 | assert.Equal(t, home, got.ScreenshotDir) | ||
| @@ -153,6 +153,7 @@ func TestLoadRejectsInvalidNewKeys(t *testing.T) { | |||||
| 153 | {"screenshot.dir = Pictures", "line 1: screenshot.dir must be an absolute path or start with ~/"}, | 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"}, | 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"}, | 155 | {"video.dir = ~user/v", "line 1: video.dir must be an absolute path"}, | ||
| 156 | + | {"video.dir = ~", "line 1: video.dir must be an absolute path"}, | |||
| 156 | {"video.dir =", "line 1: video.dir must be a non-empty path"}, | 157 | {"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 = both", "line 1: screenshot.output must be file, file+clipboard or clipboard"}, | ||
| 158 | {"screenshot.output = File", "screenshot.output must be"}, | 159 | {"screenshot.output = File", "screenshot.output must be"}, | ||
M internal/adapters/selection/model.go +2 -2
| @@ -256,7 +256,7 @@ func (m *model) key(name string) { | |||
| 256 | 256 | // A digit picks that whole monitor from any overlay, without reopening. | |
| 257 | 257 | if i, ok := outputKey(name, len(m.outputs)); ok && !m.picker.Dragging() { | |
| 258 | 258 | m.end = endAccepted | |
| 259 | - | m.sess.accept(core.PickResult{OutputID: m.outputs[i].ID, Kind: core.PickMonitor}) | |
| 259 | + | m.sess.accept(core.PickResult{OutputID: m.outputs[i].ID, Kind: core.PickMonitor}, m.mode) | |
| 260 | 260 | } | |
| 261 | 261 | } | |
| 262 | 262 | } | |
| @@ -287,7 +287,7 @@ func (m *model) settle() { | |||
| 287 | 287 | case core.PickAccepted: | |
| 288 | 288 | r, _ := m.picker.Result() | |
| 289 | 289 | m.end = endAccepted | |
| 290 | - | m.sess.accept(r) | |
| 290 | + | m.sess.accept(r, m.mode) | |
| 291 | 291 | return | |
| 292 | 292 | case core.PickCanceled: | |
| 293 | 293 | m.end = endCanceled | |
| @@ -256,7 +256,7 @@ func (m *model) key(name string) { | |||||
| 256 | // A digit picks that whole monitor from any overlay, without reopening. | 256 | // A digit picks that whole monitor from any overlay, without reopening. | ||
| 257 | if i, ok := outputKey(name, len(m.outputs)); ok && !m.picker.Dragging() { | 257 | if i, ok := outputKey(name, len(m.outputs)); ok && !m.picker.Dragging() { | ||
| 258 | m.end = endAccepted | 258 | m.end = endAccepted | ||
| 259 | - | m.sess.accept(core.PickResult{OutputID: m.outputs[i].ID, Kind: core.PickMonitor}) | 259 | + | m.sess.accept(core.PickResult{OutputID: m.outputs[i].ID, Kind: core.PickMonitor}, m.mode) |
| 260 | } | 260 | } | ||
| 261 | } | 261 | } | ||
| 262 | } | 262 | } | ||
| @@ -287,7 +287,7 @@ func (m *model) settle() { | |||||
| 287 | case core.PickAccepted: | 287 | case core.PickAccepted: | ||
| 288 | r, _ := m.picker.Result() | 288 | r, _ := m.picker.Result() | ||
| 289 | m.end = endAccepted | 289 | m.end = endAccepted | ||
| 290 | - | m.sess.accept(r) | 290 | + | m.sess.accept(r, m.mode) |
| 291 | return | 291 | return | ||
| 292 | case core.PickCanceled: | 292 | case core.PickCanceled: | ||
| 293 | m.end = endCanceled | 293 | m.end = endCanceled | ||
M internal/adapters/selection/selection_test.go +33 -5
| @@ -315,21 +315,49 @@ func TestSelectRejectsBadInput(t *testing.T) { | |||
| 315 | 315 | func TestSessionFirstDecisionWins(t *testing.T) { | |
| 316 | 316 | sess, ctx := testSession() | |
| 317 | 317 | first := core.PickResult{OutputID: 7, Kind: core.PickMonitor} | |
| 318 | - | sess.accept(first) | |
| 319 | - | sess.accept(core.PickResult{OutputID: 9, Kind: core.PickMonitor}) | |
| 318 | + | sess.accept(first, ports.Screenshot) | |
| 319 | + | sess.accept(core.PickResult{OutputID: 9, Kind: core.PickMonitor}, ports.Record) | |
| 320 | 320 | sess.abort() | |
| 321 | 321 | assert.Error(t, ctx.Err()) | |
| 322 | 322 | res, ok, err := sess.outcome(context.Background()) | |
| 323 | 323 | require.NoError(t, err) | |
| 324 | 324 | assert.True(t, ok) | |
| 325 | 325 | assert.Equal(t, first, res) | |
| 326 | + | assert.Equal(t, ports.Screenshot, sess.acceptedMode()) | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | // The recorded mode is the one the confirming overlay showed: a Tab on | |
| 330 | + | // another overlay after the decision changes nothing. | |
| 331 | + | func TestLateTabKeepsConfirmedMode(t *testing.T) { | |
| 332 | + | sess, _ := testSession() | |
| 333 | + | a := newModel(ports.Screenshot, testOutputs, 0, sess, false) | |
| 334 | + | b := newModel(ports.Screenshot, testOutputs, 1, sess, false) | |
| 335 | + | a.toggle, b.toggle = true, true | |
| 336 | + | a.wake, b.wake = make(chan struct{}, 1), make(chan struct{}, 1) | |
| 337 | + | sess.wakes = []chan struct{}{a.wake, b.wake} | |
| 338 | + | a.resize(1920, 1080, 1) | |
| 339 | + | b.resize(1280, 720, 1) | |
| 340 | + | b.input(key("Tab")) | |
| 341 | + | <-a.wake | |
| 342 | + | <-b.wake | |
| 343 | + | require.Equal(t, ports.Record, b.mode) | |
| 344 | + | b.input(key("m")) | |
| 345 | + | b.input(key("Return")) | |
| 346 | + | res, ok, err := sess.outcome(context.Background()) | |
| 347 | + | require.NoError(t, err) | |
| 348 | + | require.True(t, ok) | |
| 349 | + | a.input(key("Tab")) // arrives before a sees the cancel | |
| 350 | + | assert.Empty(t, a.wake, "no redraw after the decision") | |
| 351 | + | assert.Equal(t, ports.Record, sess.acceptedMode()) | |
| 352 | + | sel := New(ports.Screenshot, ports.VideoSettings{}, nil, false).AllowToggle().selectionFor(sess.acceptedMode(), res) | |
| 353 | + | assert.Equal(t, ports.Record, sel.Mode) | |
| 326 | 354 | } | |
| 327 | 355 | ||
| 328 | 356 | func TestSessionRunEnded(t *testing.T) { | |
| 329 | 357 | boom := errors.New("boom") | |
| 330 | 358 | ||
| 331 | 359 | sess, _ := testSession() | |
| 332 | - | sess.accept(core.PickResult{OutputID: 7}) | |
| 360 | + | sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot) | |
| 333 | 361 | sess.runEnded(context.Background(), "DP-1", context.Canceled) // our own cancel | |
| 334 | 362 | _, ok, err := sess.outcome(context.Background()) | |
| 335 | 363 | assert.True(t, ok) | |
| @@ -344,7 +372,7 @@ func TestSessionRunEnded(t *testing.T) { | |||
| 344 | 372 | // GO012: a real failure joined with Canceled survives whole, including the | |
| 345 | 373 | // output name, after our own cancel. | |
| 346 | 374 | sess, _ = testSession() | |
| 347 | - | sess.accept(core.PickResult{OutputID: 7}) | |
| 375 | + | sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot) | |
| 348 | 376 | real := fmt.Errorf("surface close: %w", context.Canceled) | |
| 349 | 377 | sess.runEnded(context.Background(), "DP-2", errors.Join(context.Canceled, real, boom)) | |
| 350 | 378 | _, ok, err = sess.outcome(context.Background()) | |
| @@ -360,7 +388,7 @@ func TestSessionRunEnded(t *testing.T) { | |||
| 360 | 388 | assert.ErrorContains(t, err, "output DP-1") | |
| 361 | 389 | ||
| 362 | 390 | sess, _ = testSession() | |
| 363 | - | sess.accept(core.PickResult{OutputID: 7}) | |
| 391 | + | sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot) | |
| 364 | 392 | sess.runEnded(context.Background(), "DP-1", boom) // a later overlay failing rejects the pick | |
| 365 | 393 | _, ok, err = sess.outcome(context.Background()) | |
| 366 | 394 | assert.False(t, ok || err == nil) | |
| @@ -315,21 +315,49 @@ func TestSelectRejectsBadInput(t *testing.T) { | |||||
| 315 | func TestSessionFirstDecisionWins(t *testing.T) { | 315 | func TestSessionFirstDecisionWins(t *testing.T) { | ||
| 316 | sess, ctx := testSession() | 316 | sess, ctx := testSession() | ||
| 317 | first := core.PickResult{OutputID: 7, Kind: core.PickMonitor} | 317 | first := core.PickResult{OutputID: 7, Kind: core.PickMonitor} | ||
| 318 | - | sess.accept(first) | 318 | + | sess.accept(first, ports.Screenshot) |
| 319 | - | sess.accept(core.PickResult{OutputID: 9, Kind: core.PickMonitor}) | 319 | + | sess.accept(core.PickResult{OutputID: 9, Kind: core.PickMonitor}, ports.Record) |
| 320 | sess.abort() | 320 | sess.abort() | ||
| 321 | assert.Error(t, ctx.Err()) | 321 | assert.Error(t, ctx.Err()) | ||
| 322 | res, ok, err := sess.outcome(context.Background()) | 322 | res, ok, err := sess.outcome(context.Background()) | ||
| 323 | require.NoError(t, err) | 323 | require.NoError(t, err) | ||
| 324 | assert.True(t, ok) | 324 | assert.True(t, ok) | ||
| 325 | assert.Equal(t, first, res) | 325 | assert.Equal(t, first, res) | ||
| 326 | + | assert.Equal(t, ports.Screenshot, sess.acceptedMode()) | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | // The recorded mode is the one the confirming overlay showed: a Tab on | |||
| 330 | + | // another overlay after the decision changes nothing. | |||
| 331 | + | func TestLateTabKeepsConfirmedMode(t *testing.T) { | |||
| 332 | + | sess, _ := testSession() | |||
| 333 | + | a := newModel(ports.Screenshot, testOutputs, 0, sess, false) | |||
| 334 | + | b := newModel(ports.Screenshot, testOutputs, 1, sess, false) | |||
| 335 | + | a.toggle, b.toggle = true, true | |||
| 336 | + | a.wake, b.wake = make(chan struct{}, 1), make(chan struct{}, 1) | |||
| 337 | + | sess.wakes = []chan struct{}{a.wake, b.wake} | |||
| 338 | + | a.resize(1920, 1080, 1) | |||
| 339 | + | b.resize(1280, 720, 1) | |||
| 340 | + | b.input(key("Tab")) | |||
| 341 | + | <-a.wake | |||
| 342 | + | <-b.wake | |||
| 343 | + | require.Equal(t, ports.Record, b.mode) | |||
| 344 | + | b.input(key("m")) | |||
| 345 | + | b.input(key("Return")) | |||
| 346 | + | res, ok, err := sess.outcome(context.Background()) | |||
| 347 | + | require.NoError(t, err) | |||
| 348 | + | require.True(t, ok) | |||
| 349 | + | a.input(key("Tab")) // arrives before a sees the cancel | |||
| 350 | + | assert.Empty(t, a.wake, "no redraw after the decision") | |||
| 351 | + | assert.Equal(t, ports.Record, sess.acceptedMode()) | |||
| 352 | + | sel := New(ports.Screenshot, ports.VideoSettings{}, nil, false).AllowToggle().selectionFor(sess.acceptedMode(), res) | |||
| 353 | + | assert.Equal(t, ports.Record, sel.Mode) | |||
| 326 | } | 354 | } | ||
| 327 | 355 | ||||
| 328 | func TestSessionRunEnded(t *testing.T) { | 356 | func TestSessionRunEnded(t *testing.T) { | ||
| 329 | boom := errors.New("boom") | 357 | boom := errors.New("boom") | ||
| 330 | 358 | ||||
| 331 | sess, _ := testSession() | 359 | sess, _ := testSession() | ||
| 332 | - | sess.accept(core.PickResult{OutputID: 7}) | 360 | + | sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot) |
| 333 | sess.runEnded(context.Background(), "DP-1", context.Canceled) // our own cancel | 361 | sess.runEnded(context.Background(), "DP-1", context.Canceled) // our own cancel | ||
| 334 | _, ok, err := sess.outcome(context.Background()) | 362 | _, ok, err := sess.outcome(context.Background()) | ||
| 335 | assert.True(t, ok) | 363 | assert.True(t, ok) | ||
| @@ -344,7 +372,7 @@ func TestSessionRunEnded(t *testing.T) { | |||||
| 344 | // GO012: a real failure joined with Canceled survives whole, including the | 372 | // GO012: a real failure joined with Canceled survives whole, including the | ||
| 345 | // output name, after our own cancel. | 373 | // output name, after our own cancel. | ||
| 346 | sess, _ = testSession() | 374 | sess, _ = testSession() | ||
| 347 | - | sess.accept(core.PickResult{OutputID: 7}) | 375 | + | sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot) |
| 348 | real := fmt.Errorf("surface close: %w", context.Canceled) | 376 | real := fmt.Errorf("surface close: %w", context.Canceled) | ||
| 349 | sess.runEnded(context.Background(), "DP-2", errors.Join(context.Canceled, real, boom)) | 377 | sess.runEnded(context.Background(), "DP-2", errors.Join(context.Canceled, real, boom)) | ||
| 350 | _, ok, err = sess.outcome(context.Background()) | 378 | _, ok, err = sess.outcome(context.Background()) | ||
| @@ -360,7 +388,7 @@ func TestSessionRunEnded(t *testing.T) { | |||||
| 360 | assert.ErrorContains(t, err, "output DP-1") | 388 | assert.ErrorContains(t, err, "output DP-1") | ||
| 361 | 389 | ||||
| 362 | sess, _ = testSession() | 390 | sess, _ = testSession() | ||
| 363 | - | sess.accept(core.PickResult{OutputID: 7}) | 391 | + | sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot) |
| 364 | sess.runEnded(context.Background(), "DP-1", boom) // a later overlay failing rejects the pick | 392 | sess.runEnded(context.Background(), "DP-1", boom) // a later overlay failing rejects the pick | ||
| 365 | _, ok, err = sess.outcome(context.Background()) | 393 | _, ok, err = sess.outcome(context.Background()) | ||
| 366 | assert.False(t, ok || err == nil) | 394 | assert.False(t, ok || err == nil) | ||
M internal/adapters/selection/selector.go +1 -1
| @@ -128,7 +128,7 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se | |||
| 128 | 128 | if err != nil || !ok { | |
| 129 | 129 | return ports.Selection{}, false, err | |
| 130 | 130 | } | |
| 131 | - | return s.selectionFor(modeAfter(s.mode, sess.toggles.Load()), result), true, nil | |
| 131 | + | return s.selectionFor(sess.acceptedMode(), result), true, nil | |
| 132 | 132 | } | |
| 133 | 133 | ||
| 134 | 134 | // run shows one overlay on its own output until the context ends. | |
| @@ -128,7 +128,7 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se | |||||
| 128 | if err != nil || !ok { | 128 | if err != nil || !ok { | ||
| 129 | return ports.Selection{}, false, err | 129 | return ports.Selection{}, false, err | ||
| 130 | } | 130 | } | ||
| 131 | - | return s.selectionFor(modeAfter(s.mode, sess.toggles.Load()), result), true, nil | 131 | + | return s.selectionFor(sess.acceptedMode(), result), true, nil |
| 132 | } | 132 | } | ||
| 133 | 133 | ||||
| 134 | // run shows one overlay on its own output until the context ends. | 134 | // run shows one overlay on its own output until the context ends. | ||
M internal/adapters/selection/session.go +24 -4
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/nefercap/internal/adapters/uierrors" | |
| 11 | 11 | "github.com/bnema/nefercap/internal/core" | |
| 12 | + | "github.com/bnema/nefercap/internal/ports" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 14 | 15 | var errOverlayClosed = errors.New("selection: overlay closed by the compositor") | |
| @@ -29,14 +30,25 @@ type session struct { | |||
| 29 | 30 | decided bool | |
| 30 | 31 | accepted bool | |
| 31 | 32 | result core.PickResult | |
| 33 | + | mode ports.Mode // the mode the confirming overlay showed | |
| 32 | 34 | errs []error | |
| 33 | 35 | } | |
| 34 | 36 | ||
| 35 | 37 | func newSession(cancel context.CancelFunc) *session { return &session{cancel: cancel} } | |
| 36 | 38 | ||
| 37 | 39 | // toggle flips the capture mode for every overlay and asks each to redraw. | |
| 40 | + | // After the first decision it does nothing, so a late Tab on another overlay | |
| 41 | + | // cannot change what was confirmed. | |
| 38 | 42 | func (s *session) toggle() { | |
| 39 | - | s.toggles.Add(1) | |
| 43 | + | s.mu.Lock() | |
| 44 | + | decided := s.decided | |
| 45 | + | if !decided { | |
| 46 | + | s.toggles.Add(1) | |
| 47 | + | } | |
| 48 | + | s.mu.Unlock() | |
| 49 | + | if decided { | |
| 50 | + | return | |
| 51 | + | } | |
| 40 | 52 | for _, w := range s.wakes { | |
| 41 | 53 | select { | |
| 42 | 54 | case w <- struct{}{}: | |
| @@ -45,11 +57,12 @@ func (s *session) toggle() { | |||
| 45 | 57 | } | |
| 46 | 58 | } | |
| 47 | 59 | ||
| 48 | - | // accept records the first decision and ends every overlay. | |
| 49 | - | func (s *session) accept(r core.PickResult) { | |
| 60 | + | // accept records the first decision, with the mode its overlay showed, and | |
| 61 | + | // ends every overlay. | |
| 62 | + | func (s *session) accept(r core.PickResult, mode ports.Mode) { | |
| 50 | 63 | s.mu.Lock() | |
| 51 | 64 | if !s.decided { | |
| 52 | - | s.decided, s.accepted, s.result = true, true, r | |
| 65 | + | s.decided, s.accepted, s.result, s.mode = true, true, r, mode | |
| 53 | 66 | } | |
| 54 | 67 | s.mu.Unlock() | |
| 55 | 68 | s.cancel() | |
| @@ -104,3 +117,10 @@ func (s *session) outcome(parent context.Context) (core.PickResult, bool, error) | |||
| 104 | 117 | } | |
| 105 | 118 | return s.result, s.accepted, nil | |
| 106 | 119 | } | |
| 120 | + | ||
| 121 | + | // acceptedMode is the mode recorded by accept. | |
| 122 | + | func (s *session) acceptedMode() ports.Mode { | |
| 123 | + | s.mu.Lock() | |
| 124 | + | defer s.mu.Unlock() | |
| 125 | + | return s.mode | |
| 126 | + | } | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | "github.com/bnema/nefercap/internal/adapters/uierrors" | 10 | "github.com/bnema/nefercap/internal/adapters/uierrors" | ||
| 11 | "github.com/bnema/nefercap/internal/core" | 11 | "github.com/bnema/nefercap/internal/core" | ||
| 12 | + | "github.com/bnema/nefercap/internal/ports" | |||
| 12 | ) | 13 | ) | ||
| 13 | 14 | ||||
| 14 | var errOverlayClosed = errors.New("selection: overlay closed by the compositor") | 15 | var errOverlayClosed = errors.New("selection: overlay closed by the compositor") | ||
| @@ -29,14 +30,25 @@ type session struct { | |||||
| 29 | decided bool | 30 | decided bool | ||
| 30 | accepted bool | 31 | accepted bool | ||
| 31 | result core.PickResult | 32 | result core.PickResult | ||
| 33 | + | mode ports.Mode // the mode the confirming overlay showed | |||
| 32 | errs []error | 34 | errs []error | ||
| 33 | } | 35 | } | ||
| 34 | 36 | ||||
| 35 | func newSession(cancel context.CancelFunc) *session { return &session{cancel: cancel} } | 37 | func newSession(cancel context.CancelFunc) *session { return &session{cancel: cancel} } | ||
| 36 | 38 | ||||
| 37 | // toggle flips the capture mode for every overlay and asks each to redraw. | 39 | // toggle flips the capture mode for every overlay and asks each to redraw. | ||
| 40 | + | // After the first decision it does nothing, so a late Tab on another overlay | |||
| 41 | + | // cannot change what was confirmed. | |||
| 38 | func (s *session) toggle() { | 42 | func (s *session) toggle() { | ||
| 39 | - | s.toggles.Add(1) | 43 | + | s.mu.Lock() |
| 44 | + | decided := s.decided | |||
| 45 | + | if !decided { | |||
| 46 | + | s.toggles.Add(1) | |||
| 47 | + | } | |||
| 48 | + | s.mu.Unlock() | |||
| 49 | + | if decided { | |||
| 50 | + | return | |||
| 51 | + | } | |||
| 40 | for _, w := range s.wakes { | 52 | for _, w := range s.wakes { | ||
| 41 | select { | 53 | select { | ||
| 42 | case w <- struct{}{}: | 54 | case w <- struct{}{}: | ||
| @@ -45,11 +57,12 @@ func (s *session) toggle() { | |||||
| 45 | } | 57 | } | ||
| 46 | } | 58 | } | ||
| 47 | 59 | ||||
| 48 | - | // accept records the first decision and ends every overlay. | 60 | + | // accept records the first decision, with the mode its overlay showed, and |
| 49 | - | func (s *session) accept(r core.PickResult) { | 61 | + | // ends every overlay. |
| 62 | + | func (s *session) accept(r core.PickResult, mode ports.Mode) { | |||
| 50 | s.mu.Lock() | 63 | s.mu.Lock() | ||
| 51 | if !s.decided { | 64 | if !s.decided { | ||
| 52 | - | s.decided, s.accepted, s.result = true, true, r | 65 | + | s.decided, s.accepted, s.result, s.mode = true, true, r, mode |
| 53 | } | 66 | } | ||
| 54 | s.mu.Unlock() | 67 | s.mu.Unlock() | ||
| 55 | s.cancel() | 68 | s.cancel() | ||
| @@ -104,3 +117,10 @@ func (s *session) outcome(parent context.Context) (core.PickResult, bool, error) | |||||
| 104 | } | 117 | } | ||
| 105 | return s.result, s.accepted, nil | 118 | return s.result, s.accepted, nil | ||
| 106 | } | 119 | } | ||
| 120 | + | ||||
| 121 | + | // acceptedMode is the mode recorded by accept. | |||
| 122 | + | func (s *session) acceptedMode() ports.Mode { | |||
| 123 | + | s.mu.Lock() | |||
| 124 | + | defer s.mu.Unlock() | |||
| 125 | + | return s.mode | |||
| 126 | + | } | |||
M internal/app/destination_test.go +32 -0
| @@ -14,6 +14,38 @@ import ( | |||
| 14 | 14 | "github.com/bnema/nefercap/internal/ports" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | + | // A bad configured directory fails before the selector opens, for every | |
| 18 | + | // directory the command can reach. | |
| 19 | + | func TestCheckDirsBeforeSelector(t *testing.T) { | |
| 20 | + | root := t.TempDir() | |
| 21 | + | file := filepath.Join(root, "file") | |
| 22 | + | require.NoError(t, os.WriteFile(file, nil, 0o600)) | |
| 23 | + | good := filepath.Join(root, "good") | |
| 24 | + | for _, tc := range []struct { | |
| 25 | + | name string | |
| 26 | + | options cli.Options | |
| 27 | + | settings config.Settings | |
| 28 | + | wantErr bool | |
| 29 | + | }{ | |
| 30 | + | {"all-in-one bad video dir", cli.Options{Command: cli.AllInOne}, config.Settings{VideoDir: file, ScreenshotDir: good}, true}, | |
| 31 | + | {"all-in-one bad screenshot dir", cli.Options{Command: cli.AllInOne}, config.Settings{VideoDir: good, ScreenshotDir: file}, true}, | |
| 32 | + | {"all-in-one clipboard skips screenshot dir", cli.Options{Command: cli.AllInOne}, config.Settings{VideoDir: good, ScreenshotDir: file, Output: config.OutputClipboard}, false}, | |
| 33 | + | {"shot ignores video dir", cli.Options{Command: cli.Shot}, config.Settings{VideoDir: file, ScreenshotDir: good}, false}, | |
| 34 | + | {"rec ignores screenshot dir", cli.Options{Command: cli.Rec}, config.Settings{VideoDir: good, ScreenshotDir: file}, false}, | |
| 35 | + | {"rec bad video dir", cli.Options{Command: cli.Rec}, config.Settings{VideoDir: file}, true}, | |
| 36 | + | {"explicit -file skips dirs", cli.Options{Command: cli.Shot, Path: "x.png"}, config.Settings{ScreenshotDir: file}, false}, | |
| 37 | + | } { | |
| 38 | + | t.Run(tc.name, func(t *testing.T) { | |
| 39 | + | err := checkDirs(tc.options, tc.settings) | |
| 40 | + | if tc.wantErr { | |
| 41 | + | assert.Error(t, err) | |
| 42 | + | } else { | |
| 43 | + | assert.NoError(t, err) | |
| 44 | + | } | |
| 45 | + | }) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 17 | 49 | func TestAssignScreenshotDestination(t *testing.T) { | |
| 18 | 50 | dir := filepath.Join(t.TempDir(), "shots") | |
| 19 | 51 | for _, tc := range []struct { | |
| @@ -14,6 +14,38 @@ import ( | |||||
| 14 | "github.com/bnema/nefercap/internal/ports" | 14 | "github.com/bnema/nefercap/internal/ports" | ||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | + | // A bad configured directory fails before the selector opens, for every | |||
| 18 | + | // directory the command can reach. | |||
| 19 | + | func TestCheckDirsBeforeSelector(t *testing.T) { | |||
| 20 | + | root := t.TempDir() | |||
| 21 | + | file := filepath.Join(root, "file") | |||
| 22 | + | require.NoError(t, os.WriteFile(file, nil, 0o600)) | |||
| 23 | + | good := filepath.Join(root, "good") | |||
| 24 | + | for _, tc := range []struct { | |||
| 25 | + | name string | |||
| 26 | + | options cli.Options | |||
| 27 | + | settings config.Settings | |||
| 28 | + | wantErr bool | |||
| 29 | + | }{ | |||
| 30 | + | {"all-in-one bad video dir", cli.Options{Command: cli.AllInOne}, config.Settings{VideoDir: file, ScreenshotDir: good}, true}, | |||
| 31 | + | {"all-in-one bad screenshot dir", cli.Options{Command: cli.AllInOne}, config.Settings{VideoDir: good, ScreenshotDir: file}, true}, | |||
| 32 | + | {"all-in-one clipboard skips screenshot dir", cli.Options{Command: cli.AllInOne}, config.Settings{VideoDir: good, ScreenshotDir: file, Output: config.OutputClipboard}, false}, | |||
| 33 | + | {"shot ignores video dir", cli.Options{Command: cli.Shot}, config.Settings{VideoDir: file, ScreenshotDir: good}, false}, | |||
| 34 | + | {"rec ignores screenshot dir", cli.Options{Command: cli.Rec}, config.Settings{VideoDir: good, ScreenshotDir: file}, false}, | |||
| 35 | + | {"rec bad video dir", cli.Options{Command: cli.Rec}, config.Settings{VideoDir: file}, true}, | |||
| 36 | + | {"explicit -file skips dirs", cli.Options{Command: cli.Shot, Path: "x.png"}, config.Settings{ScreenshotDir: file}, false}, | |||
| 37 | + | } { | |||
| 38 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 39 | + | err := checkDirs(tc.options, tc.settings) | |||
| 40 | + | if tc.wantErr { | |||
| 41 | + | assert.Error(t, err) | |||
| 42 | + | } else { | |||
| 43 | + | assert.NoError(t, err) | |||
| 44 | + | } | |||
| 45 | + | }) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 17 | func TestAssignScreenshotDestination(t *testing.T) { | 49 | func TestAssignScreenshotDestination(t *testing.T) { | ||
| 18 | dir := filepath.Join(t.TempDir(), "shots") | 50 | dir := filepath.Join(t.TempDir(), "shots") | ||
| 19 | for _, tc := range []struct { | 51 | for _, tc := range []struct { | ||
M internal/app/interactive.go +24 -0
| @@ -36,6 +36,9 @@ 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 | + | if err := checkDirs(options, settings); err != nil { | |
| 40 | + | return ports.Selection{}, false, err | |
| 41 | + | } | |
| 39 | 42 | selector := selection.New(mode, options.Video, workspaces, settings.Grid) | |
| 40 | 43 | if options.Command == cli.AllInOne { | |
| 41 | 44 | selector.AllowToggle() | |
| @@ -51,6 +54,27 @@ func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []po | |||
| 51 | 54 | return sel, true, nil | |
| 52 | 55 | } | |
| 53 | 56 | ||
| 57 | + | // checkDirs creates and checks the default directories a confirmed capture | |
| 58 | + | // can use, so a bad screenshot.dir or video.dir fails before the selector | |
| 59 | + | // opens. All-in-one can reach both modes. | |
| 60 | + | func checkDirs(options cli.Options, settings config.Settings) error { | |
| 61 | + | if options.Path != "" { | |
| 62 | + | return nil | |
| 63 | + | } | |
| 64 | + | all := options.Command == cli.AllInOne | |
| 65 | + | if all || options.Command == cli.Rec { | |
| 66 | + | if _, err := destination.VideoIn(settings.VideoDir); err != nil { | |
| 67 | + | return err | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | if (all || options.Command == cli.Shot) && !options.Clipboard && settings.Output != config.OutputClipboard { | |
| 71 | + | if _, err := destination.ScreenshotIn(settings.ScreenshotDir); err != nil { | |
| 72 | + | return err | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | return nil | |
| 76 | + | } | |
| 77 | + | ||
| 54 | 78 | // assignDestination resolves where an interactive capture goes. Flags win over | |
| 55 | 79 | // the config: -file alone is a file only, -clipboard alone is clipboard only, | |
| 56 | 80 | // both save and copy. Without flags a screenshot follows screenshot.output. | |
| @@ -36,6 +36,9 @@ 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 | + | if err := checkDirs(options, settings); err != nil { | |||
| 40 | + | return ports.Selection{}, false, err | |||
| 41 | + | } | |||
| 39 | selector := selection.New(mode, options.Video, workspaces, settings.Grid) | 42 | selector := selection.New(mode, options.Video, workspaces, settings.Grid) | ||
| 40 | if options.Command == cli.AllInOne { | 43 | if options.Command == cli.AllInOne { | ||
| 41 | selector.AllowToggle() | 44 | selector.AllowToggle() | ||
| @@ -51,6 +54,27 @@ func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []po | |||||
| 51 | return sel, true, nil | 54 | return sel, true, nil | ||
| 52 | } | 55 | } | ||
| 53 | 56 | ||||
| 57 | + | // checkDirs creates and checks the default directories a confirmed capture | |||
| 58 | + | // can use, so a bad screenshot.dir or video.dir fails before the selector | |||
| 59 | + | // opens. All-in-one can reach both modes. | |||
| 60 | + | func checkDirs(options cli.Options, settings config.Settings) error { | |||
| 61 | + | if options.Path != "" { | |||
| 62 | + | return nil | |||
| 63 | + | } | |||
| 64 | + | all := options.Command == cli.AllInOne | |||
| 65 | + | if all || options.Command == cli.Rec { | |||
| 66 | + | if _, err := destination.VideoIn(settings.VideoDir); err != nil { | |||
| 67 | + | return err | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | if (all || options.Command == cli.Shot) && !options.Clipboard && settings.Output != config.OutputClipboard { | |||
| 71 | + | if _, err := destination.ScreenshotIn(settings.ScreenshotDir); err != nil { | |||
| 72 | + | return err | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | return nil | |||
| 76 | + | } | |||
| 77 | + | ||||
| 54 | // assignDestination resolves where an interactive capture goes. Flags win over | 78 | // assignDestination resolves where an interactive capture goes. Flags win over | ||
| 55 | // the config: -file alone is a file only, -clipboard alone is clipboard only, | 79 | // 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. | 80 | // both save and copy. Without flags a screenshot follows screenshot.output. | ||