detached a7c942d

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +123 -19
M Makefile +1 -1
@@ -45,7 +45,7 @@ arch:
4545 # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS
4646 # the packages holding them. Every named test must exist, so the guard cannot
4747 # 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
4949 PERF_PKGS := ./internal/ports ./internal/core ./internal/adapters/selection ./internal/adapters/indicator ./internal/adapters/ffmpeg ./internal/app ./internal/adapters/wayland
5050 perf_empty :=
5151 perf_space := $(perf_empty) $(perf_empty)
@@ -45,7 +45,7 @@ arch:
45 # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS45 # 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 cannot46 # 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 TestCaptureAllocations48+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/wayland49 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):
9494
9595 Flags:
9696 -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
9897 -debug enable debug logging (all commands)
9998 -output NAME output name (all except stop, status, outputs)
10099 -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
213212 func (e usageError) Error() string { return string(e) }
214213 func (e usageError) Is(target error) bool { return target == ErrUsage }
215214
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")
217216
218217 func usageErr(format string, a ...any) error {
219218 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 it99 -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) {
154154 } {
155155 got, err := cli.Parse(args, nil)
156156 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")
159159 assert.Equal(t, cli.Options{}, got)
160160 }
161161 }
@@ -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) {
131131 if value == "" || len(value) > maxPath || strings.IndexByte(value, 0) >= 0 {
132132 return "", fmt.Errorf("%s must be a non-empty path", key)
133133 }
134- if value == "~" || strings.HasPrefix(value, "~/") {
134+ if strings.HasPrefix(value, "~/") {
135135 home, err := os.UserHomeDir()
136136 if err != nil || !filepath.IsAbs(home) {
137137 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) {
133133 require.NoError(t, err)
134134 assert.Equal(t, Settings{Grid: false, ScreenshotDir: filepath.Join(home, "Pics", "Shots"), VideoDir: "/var/tmp/vids", Output: OutputFileClipboard}, got)
135135
136- saveConfig(t, path, "screenshot.dir = ~\nscreenshot.output = clipboard\n")
136+ saveConfig(t, path, "screenshot.dir = ~/\nscreenshot.output = clipboard\n")
137137 got, err = Load()
138138 require.NoError(t, err)
139139 assert.Equal(t, home, got.ScreenshotDir)
@@ -153,6 +153,7 @@ func TestLoadRejectsInvalidNewKeys(t *testing.T) {
153153 {"screenshot.dir = Pictures", "line 1: screenshot.dir must be an absolute path or start with ~/"},
154154 {"video.dir = ./v", "line 1: video.dir must be an absolute path"},
155155 {"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"},
156157 {"video.dir =", "line 1: video.dir must be a non-empty path"},
157158 {"screenshot.output = both", "line 1: screenshot.output must be file, file+clipboard or clipboard"},
158159 {"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) {
256256 // A digit picks that whole monitor from any overlay, without reopening.
257257 if i, ok := outputKey(name, len(m.outputs)); ok && !m.picker.Dragging() {
258258 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)
260260 }
261261 }
262262 }
@@ -287,7 +287,7 @@ func (m *model) settle() {
287287 case core.PickAccepted:
288288 r, _ := m.picker.Result()
289289 m.end = endAccepted
290- m.sess.accept(r)
290+ m.sess.accept(r, m.mode)
291291 return
292292 case core.PickCanceled:
293293 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 = endAccepted258 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 = endAccepted289 m.end = endAccepted
290- m.sess.accept(r)290+ m.sess.accept(r, m.mode)
291 return291 return
292 case core.PickCanceled:292 case core.PickCanceled:
293 m.end = endCanceled293 m.end = endCanceled
M internal/adapters/selection/selection_test.go +33 -5
@@ -315,21 +315,49 @@ func TestSelectRejectsBadInput(t *testing.T) {
315315 func TestSessionFirstDecisionWins(t *testing.T) {
316316 sess, ctx := testSession()
317317 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)
320320 sess.abort()
321321 assert.Error(t, ctx.Err())
322322 res, ok, err := sess.outcome(context.Background())
323323 require.NoError(t, err)
324324 assert.True(t, ok)
325325 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)
326354 }
327355
328356 func TestSessionRunEnded(t *testing.T) {
329357 boom := errors.New("boom")
330358
331359 sess, _ := testSession()
332- sess.accept(core.PickResult{OutputID: 7})
360+ sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot)
333361 sess.runEnded(context.Background(), "DP-1", context.Canceled) // our own cancel
334362 _, ok, err := sess.outcome(context.Background())
335363 assert.True(t, ok)
@@ -344,7 +372,7 @@ func TestSessionRunEnded(t *testing.T) {
344372 // GO012: a real failure joined with Canceled survives whole, including the
345373 // output name, after our own cancel.
346374 sess, _ = testSession()
347- sess.accept(core.PickResult{OutputID: 7})
375+ sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot)
348376 real := fmt.Errorf("surface close: %w", context.Canceled)
349377 sess.runEnded(context.Background(), "DP-2", errors.Join(context.Canceled, real, boom))
350378 _, ok, err = sess.outcome(context.Background())
@@ -360,7 +388,7 @@ func TestSessionRunEnded(t *testing.T) {
360388 assert.ErrorContains(t, err, "output DP-1")
361389
362390 sess, _ = testSession()
363- sess.accept(core.PickResult{OutputID: 7})
391+ sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot)
364392 sess.runEnded(context.Background(), "DP-1", boom) // a later overlay failing rejects the pick
365393 _, ok, err = sess.outcome(context.Background())
366394 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 cancel361 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 the372 // 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 pick392 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
128128 if err != nil || !ok {
129129 return ports.Selection{}, false, err
130130 }
131- return s.selectionFor(modeAfter(s.mode, sess.toggles.Load()), result), true, nil
131+ return s.selectionFor(sess.acceptedMode(), result), true, nil
132132 }
133133
134134 // 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, err129 return ports.Selection{}, false, err
130 }130 }
131- return s.selectionFor(modeAfter(s.mode, sess.toggles.Load()), result), true, nil131+ 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 (
99
1010 "github.com/bnema/nefercap/internal/adapters/uierrors"
1111 "github.com/bnema/nefercap/internal/core"
12+ "github.com/bnema/nefercap/internal/ports"
1213 )
1314
1415 var errOverlayClosed = errors.New("selection: overlay closed by the compositor")
@@ -29,14 +30,25 @@ type session struct {
2930 decided bool
3031 accepted bool
3132 result core.PickResult
33+ mode ports.Mode // the mode the confirming overlay showed
3234 errs []error
3335 }
3436
3537 func newSession(cancel context.CancelFunc) *session { return &session{cancel: cancel} }
3638
3739 // 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.
3842 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+ }
4052 for _, w := range s.wakes {
4153 select {
4254 case w <- struct{}{}:
@@ -45,11 +57,12 @@ func (s *session) toggle() {
4557 }
4658 }
4759
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) {
5063 s.mu.Lock()
5164 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
5366 }
5467 s.mu.Unlock()
5568 s.cancel()
@@ -104,3 +117,10 @@ func (s *session) outcome(parent context.Context) (core.PickResult, bool, error)
104117 }
105118 return s.result, s.accepted, nil
106119 }
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 bool30 decided bool
30 accepted bool31 accepted bool
31 result core.PickResult32 result core.PickResult
33+ mode ports.Mode // the mode the confirming overlay showed
32 errs []error34 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, r65+ 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, nil118 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 (
1414 "github.com/bnema/nefercap/internal/ports"
1515 )
1616
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+
1749 func TestAssignScreenshotDestination(t *testing.T) {
1850 dir := filepath.Join(t.TempDir(), "shots")
1951 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
3636 }
3737 return ports.Selection{}, false, fmt.Errorf("list workspace capture targets: %w", err)
3838 }
39+ if err := checkDirs(options, settings); err != nil {
40+ return ports.Selection{}, false, err
41+ }
3942 selector := selection.New(mode, options.Video, workspaces, settings.Grid)
4043 if options.Command == cli.AllInOne {
4144 selector.AllowToggle()
@@ -51,6 +54,27 @@ func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []po
5154 return sel, true, nil
5255 }
5356
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+
5478 // assignDestination resolves where an interactive capture goes. Flags win over
5579 // the config: -file alone is a file only, -clipboard alone is clipboard only,
5680 // 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, nil54 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 over78 // 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.

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