detached 07981f5

feat(selection): toggle the frozen screen with F in the screenshot selector

commit
07981f568d2a5e6b9a222b73f170b56a1280af9f
author
brice · · 2026-10-18 09:26 UTC
parent
1218e29
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +192 -36
M README.md +1 -1
@@ -73,7 +73,7 @@ video.dir = ~/Videos
7373 | `screenshot.output` | `file`, `file+clipboard`, `clipboard` | `file` |
7474 | `video.dir` | absolute or `~/` path | `$XDG_VIDEOS_DIR` |
7575
76-With `selector.freeze = on`, `nefercap shot` and the all-in-one selector in screenshot mode show the screen as it was when the selector opened, and capture that image. Recording always selects and records the live screen. Rotated outputs, and outputs wider or taller than 4096 pixels, stay live.
76+With `selector.freeze = on`, `nefercap shot` and the all-in-one selector in screenshot mode show the screen as it was when the selector opened, and capture that image. Press `F` in the selector to switch between the frozen and the live screen; the screenshot follows what is shown. Recording always selects and records the live screen. Rotated outputs, and outputs wider or taller than 4096 pixels, stay live.
7777
7878 Unknown keys, duplicates and invalid values are reported before the selector opens. Files are private, named with a timestamp, never overwritten, and their path is printed on stdout.
7979
@@ -73,7 +73,7 @@ video.dir = ~/Videos
73 | `screenshot.output` | `file`, `file+clipboard`, `clipboard` | `file` |73 | `screenshot.output` | `file`, `file+clipboard`, `clipboard` | `file` |
74 | `video.dir` | absolute or `~/` path | `$XDG_VIDEOS_DIR` |74 | `video.dir` | absolute or `~/` path | `$XDG_VIDEOS_DIR` |
75 75
76-With `selector.freeze = on`, `nefercap shot` and the all-in-one selector in screenshot mode show the screen as it was when the selector opened, and capture that image. Recording always selects and records the live screen. Rotated outputs, and outputs wider or taller than 4096 pixels, stay live.76+With `selector.freeze = on`, `nefercap shot` and the all-in-one selector in screenshot mode show the screen as it was when the selector opened, and capture that image. Press `F` in the selector to switch between the frozen and the live screen; the screenshot follows what is shown. Recording always selects and records the live screen. Rotated outputs, and outputs wider or taller than 4096 pixels, stay live.
77 77
78 Unknown keys, duplicates and invalid values are reported before the selector opens. Files are private, named with a timestamp, never overwritten, and their path is printed on stdout.78 Unknown keys, duplicates and invalid values are reported before the selector opens. Files are private, named with a timestamp, never overwritten, and their path is printed on stdout.
79 79
M internal/adapters/cli/cli.go +2 -0
@@ -79,6 +79,8 @@ Selector keys and mouse:
7979 none is known; click or Enter confirms
8080 1..9 pick that whole monitor from any overlay
8181 g toggle full-monitor center and thirds guides (on by default)
82+ f switch between the frozen and the live screen (screenshot mode,
83+ selector.freeze = on)
8284 Tab switch shot <-> rec (all-in-one only)
8385 Escape cancel
8486
@@ -79,6 +79,8 @@ Selector keys and mouse:
79 none is known; click or Enter confirms79 none is known; click or Enter confirms
80 1..9 pick that whole monitor from any overlay80 1..9 pick that whole monitor from any overlay
81 g toggle full-monitor center and thirds guides (on by default)81 g toggle full-monitor center and thirds guides (on by default)
82+ f switch between the frozen and the live screen (screenshot mode,
83+ selector.freeze = on)
82 Tab switch shot <-> rec (all-in-one only)84 Tab switch shot <-> rec (all-in-one only)
83 Escape cancel85 Escape cancel
84 86
M internal/adapters/selection/backdrop_test.go +60 -0
@@ -1,6 +1,7 @@
11 package selection
22
33 import (
4+ "context"
45 "image"
56 "testing"
67
@@ -51,3 +52,62 @@ func TestBackdropDrawnOnlyForScreenshots(t *testing.T) {
5152 m.mode, m.backdrop = ports.Screenshot, nil
5253 assert.False(t, drawsBackdrop(m))
5354 }
55+
56+func frozenModel(t *testing.T, index int, sess *session) *model {
57+ t.Helper()
58+ m := newModel(ports.Screenshot, testOutputs, index, sess, false)
59+ m.backdrop = image.NewNRGBA(image.Rect(0, 0, 1, 1))
60+ m.wake = make(chan struct{}, 1)
61+ m.resize(1920, 1080, 1)
62+ return m
63+}
64+
65+// F switches every overlay between the frozen and the live screen, the
66+// footer says what F does next, and the accepted pick records the state.
67+func TestFreezeKeyTogglesEveryOverlay(t *testing.T) {
68+ sess, _ := testSession()
69+ sess.canFreeze = true
70+ a, b := frozenModel(t, 0, sess), frozenModel(t, 1, sess)
71+ sess.wakes = []chan struct{}{a.wake, b.wake}
72+ require.True(t, drawsBackdrop(a))
73+ assert.Contains(t, a.footerText, "G grid · F live · Esc cancel")
74+
75+ assert.True(t, a.input(key("f")), "F redraws")
76+ assert.False(t, drawsBackdrop(a))
77+ assert.Equal(t, toastLive, a.toast.text)
78+ assert.Contains(t, a.footerText, "F freeze")
79+ require.Len(t, b.wake, 1, "the other overlay is woken")
80+ b.input(motion(1, 1))
81+ assert.False(t, drawsBackdrop(b), "the other overlay follows")
82+
83+ b.input(key("F"))
84+ assert.True(t, drawsBackdrop(b))
85+ a.input(motion(2, 2))
86+ assert.True(t, drawsBackdrop(a))
87+
88+ a.input(key("f"))
89+ a.input(key("m"))
90+ a.input(key("Return"))
91+ _, ok, err := sess.outcome(context.Background())
92+ require.NoError(t, err)
93+ require.True(t, ok)
94+ assert.False(t, sess.acceptedFrozen(), "a pick on the live screen is captured live")
95+}
96+
97+// Without a frozen screen, or in record mode, F does nothing and is not shown.
98+func TestFreezeKeyHiddenWithoutBackdrop(t *testing.T) {
99+ m := newTestModel(t)
100+ assert.NotContains(t, m.footerText, "F ")
101+ assert.False(t, m.input(key("f")))
102+
103+ sess, _ := testSession()
104+ sess.canFreeze = true
105+ m = frozenModel(t, 0, sess)
106+ m.mode = ports.Record
107+ m.refreshFooter()
108+ assert.NotContains(t, m.footerText, "F ")
109+ assert.False(t, m.input(key("f")))
110+ m.input(key("m"))
111+ m.input(key("Return"))
112+ assert.True(t, sess.acceptedFrozen(), "untouched, the frozen state is kept for the app to thaw")
113+}
@@ -1,6 +1,7 @@
1 package selection1 package selection
2 2
3 import (3 import (
4+ "context"
4 "image"5 "image"
5 "testing"6 "testing"
6 7
@@ -51,3 +52,62 @@ func TestBackdropDrawnOnlyForScreenshots(t *testing.T) {
51 m.mode, m.backdrop = ports.Screenshot, nil52 m.mode, m.backdrop = ports.Screenshot, nil
52 assert.False(t, drawsBackdrop(m))53 assert.False(t, drawsBackdrop(m))
53 }54 }
55+
56+func frozenModel(t *testing.T, index int, sess *session) *model {
57+ t.Helper()
58+ m := newModel(ports.Screenshot, testOutputs, index, sess, false)
59+ m.backdrop = image.NewNRGBA(image.Rect(0, 0, 1, 1))
60+ m.wake = make(chan struct{}, 1)
61+ m.resize(1920, 1080, 1)
62+ return m
63+}
64+
65+// F switches every overlay between the frozen and the live screen, the
66+// footer says what F does next, and the accepted pick records the state.
67+func TestFreezeKeyTogglesEveryOverlay(t *testing.T) {
68+ sess, _ := testSession()
69+ sess.canFreeze = true
70+ a, b := frozenModel(t, 0, sess), frozenModel(t, 1, sess)
71+ sess.wakes = []chan struct{}{a.wake, b.wake}
72+ require.True(t, drawsBackdrop(a))
73+ assert.Contains(t, a.footerText, "G grid · F live · Esc cancel")
74+
75+ assert.True(t, a.input(key("f")), "F redraws")
76+ assert.False(t, drawsBackdrop(a))
77+ assert.Equal(t, toastLive, a.toast.text)
78+ assert.Contains(t, a.footerText, "F freeze")
79+ require.Len(t, b.wake, 1, "the other overlay is woken")
80+ b.input(motion(1, 1))
81+ assert.False(t, drawsBackdrop(b), "the other overlay follows")
82+
83+ b.input(key("F"))
84+ assert.True(t, drawsBackdrop(b))
85+ a.input(motion(2, 2))
86+ assert.True(t, drawsBackdrop(a))
87+
88+ a.input(key("f"))
89+ a.input(key("m"))
90+ a.input(key("Return"))
91+ _, ok, err := sess.outcome(context.Background())
92+ require.NoError(t, err)
93+ require.True(t, ok)
94+ assert.False(t, sess.acceptedFrozen(), "a pick on the live screen is captured live")
95+}
96+
97+// Without a frozen screen, or in record mode, F does nothing and is not shown.
98+func TestFreezeKeyHiddenWithoutBackdrop(t *testing.T) {
99+ m := newTestModel(t)
100+ assert.NotContains(t, m.footerText, "F ")
101+ assert.False(t, m.input(key("f")))
102+
103+ sess, _ := testSession()
104+ sess.canFreeze = true
105+ m = frozenModel(t, 0, sess)
106+ m.mode = ports.Record
107+ m.refreshFooter()
108+ assert.NotContains(t, m.footerText, "F ")
109+ assert.False(t, m.input(key("f")))
110+ m.input(key("m"))
111+ m.input(key("Return"))
112+ assert.True(t, sess.acceptedFrozen(), "untouched, the frozen state is kept for the app to thaw")
113+}
M internal/adapters/selection/fx.go +10 -0
@@ -38,6 +38,8 @@ const (
3838 toastMonitor = "Monitor"
3939 toastWorkspace = "Workspace"
4040 toastNoWS = "No workspace found · using monitor"
41+ toastFrozen = "Frozen screen"
42+ toastLive = "Live screen"
4143 )
4244
4345 // toast is one centered message. start is zero until the first frame draws
@@ -142,6 +144,14 @@ func targetToast(kind core.PickKind) string {
142144 return toastRegion
143145 }
144146
147+// freezeToast confirms an F press.
148+func freezeToast(live bool) string {
149+ if live {
150+ return toastLive
151+ }
152+ return toastFrozen
153+}
154+
145155 func modeToast(mode ports.Mode) string {
146156 if mode == ports.Record {
147157 return toastRec
@@ -38,6 +38,8 @@ const (
38 toastMonitor = "Monitor"38 toastMonitor = "Monitor"
39 toastWorkspace = "Workspace"39 toastWorkspace = "Workspace"
40 toastNoWS = "No workspace found · using monitor"40 toastNoWS = "No workspace found · using monitor"
41+ toastFrozen = "Frozen screen"
42+ toastLive = "Live screen"
41 )43 )
42 44
43 // toast is one centered message. start is zero until the first frame draws45 // toast is one centered message. start is zero until the first frame draws
@@ -142,6 +144,14 @@ func targetToast(kind core.PickKind) string {
142 return toastRegion144 return toastRegion
143 }145 }
144 146
147+// freezeToast confirms an F press.
148+func freezeToast(live bool) string {
149+ if live {
150+ return toastLive
151+ }
152+ return toastFrozen
153+}
154+
145 func modeToast(mode ports.Mode) string {155 func modeToast(mode ports.Mode) string {
146 if mode == ports.Record {156 if mode == ports.Record {
147 return toastRec157 return toastRec
M internal/adapters/selection/mode_test.go +1 -1
@@ -15,7 +15,7 @@ func toggleModel(t *testing.T, mode ports.Mode, toggle bool) *model {
1515 m := newTestModel(t)
1616 m.base, m.mode, m.toggle = mode, mode, toggle
1717 m.footerMode = mode
18- m.footerText = footerText(mode, core.PickRegion, false, toggle, false)
18+ m.footerText = footerText(mode, core.PickRegion, false, toggle, false, freezeOff)
1919 return m
2020 }
2121
@@ -15,7 +15,7 @@ func toggleModel(t *testing.T, mode ports.Mode, toggle bool) *model {
15 m := newTestModel(t)15 m := newTestModel(t)
16 m.base, m.mode, m.toggle = mode, mode, toggle16 m.base, m.mode, m.toggle = mode, mode, toggle
17 m.footerMode = mode17 m.footerMode = mode
18- m.footerText = footerText(mode, core.PickRegion, false, toggle, false)18+ m.footerText = footerText(mode, core.PickRegion, false, toggle, false, freezeOff)
19 return m19 return m
20 }20 }
21 21
M internal/adapters/selection/model.go +43 -8
@@ -35,6 +35,10 @@ type model struct {
3535 // record footer says there is none.
3636 noIndicator bool
3737 seen uint32 // session toggles already applied to mode
38+ // live: F turned the frozen screen off; seenFreezes is the session F
39+ // count already applied to it.
40+ live bool
41+ seenFreezes uint32
3842 wake chan struct{}
3943 outputs []ports.Output
4044 index int // the output this overlay covers; every output has its own model
@@ -62,6 +66,7 @@ type model struct {
6266 footerKind core.PickKind
6367 footerMode ports.Mode
6468 footerWorkspace bool
69+ footerLive bool
6570 label label
6671
6772 // Key feedback: a toast and a border pulse, advanced by animate. fx counts
@@ -75,7 +80,9 @@ type model struct {
7580 }
7681
7782 func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session, grid bool) *model {
78- return &model{base: mode, mode: mode, footerMode: mode, outputs: outputs, index: index, sess: sess, grid: grid, header: headerText(outputs, index), footerText: footerText(mode, core.PickRegion, false, false, false)}
83+ m := &model{base: mode, mode: mode, footerMode: mode, outputs: outputs, index: index, sess: sess, grid: grid, header: headerText(outputs, index)}
84+ m.footerText = footerText(mode, core.PickRegion, false, false, false, m.freezeHint())
85+ return m
7986 }
8087
8188 // otherMode is the mode a Tab switches to.
@@ -101,6 +108,27 @@ func (m *model) syncMode() {
101108 m.showToast(modeToast(m.mode), modeHold, false)
102109 m.pulseBorder()
103110 }
111+ if n := m.sess.freezes.Load(); n != m.seenFreezes {
112+ m.seenFreezes, m.live = n, n%2 == 1
113+ m.showToast(freezeToast(m.live), modeHold, false)
114+ m.pulseBorder()
115+ }
116+}
117+
118+// frozen reports whether this overlay shows, and its screenshot captures, the
119+// frozen screen.
120+func (m *model) frozen() bool { return m.sess.canFreeze && !m.live }
121+
122+// freezeHint is the F state the footer shows: none without a frozen screen
123+// or in record mode, which always captures the live screen.
124+func (m *model) freezeHint() freezeState {
125+ switch {
126+ case !m.sess.canFreeze || m.mode == ports.Record:
127+ return freezeOff
128+ case m.live:
129+ return freezeLive
130+ }
131+ return freezeOn
104132 }
105133
106134 // resize is the OnResize callback. A logical size change (re)starts the
@@ -209,13 +237,14 @@ type visible struct {
209237 dragging bool
210238 kind core.PickKind
211239 grid bool
240+ live bool
212241 status core.PickStatus
213242 fx uint32
214243 }
215244
216245 func (m *model) snapshot() visible {
217246 r, _ := m.picker.Rect()
218- return visible{r, m.mode == ports.Record, m.picker.Dragging(), m.picker.Kind(), m.grid, m.picker.Status(), m.fx}
247+ return visible{r, m.mode == ports.Record, m.picker.Dragging(), m.picker.Kind(), m.grid, m.live, m.picker.Status(), m.fx}
219248 }
220249
221250 // input is the OnInput callback. It reports whether the drawn state changed,
@@ -261,6 +290,11 @@ func (m *model) key(name string) {
261290 m.selectTarget(core.PickRegion)
262291 case "g", "G":
263292 m.grid = !m.grid
293+ case "f", "F":
294+ if m.freezeHint() != freezeOff {
295+ m.sess.toggleFreeze()
296+ m.syncMode()
297+ }
264298 case "Tab":
265299 if m.toggle {
266300 m.sess.toggle()
@@ -272,7 +306,7 @@ func (m *model) key(name string) {
272306 // A digit picks that whole monitor from any overlay, without reopening.
273307 if i, ok := outputKey(name, len(m.outputs)); ok && !m.picker.Dragging() {
274308 m.end = endAccepted
275- m.sess.accept(core.PickResult{OutputID: m.outputs[i].ID, Kind: core.PickMonitor}, m.mode)
309+ m.sess.accept(core.PickResult{OutputID: m.outputs[i].ID, Kind: core.PickMonitor}, m.mode, m.frozen())
276310 }
277311 }
278312 }
@@ -337,13 +371,14 @@ func outputKey(name string, n int) (int, bool) {
337371 return i, i < n
338372 }
339373
340-// refreshFooter rebuilds the footer only when the mode, target kind or workspace changes.
374+// refreshFooter rebuilds the footer only when the mode, target kind,
375+// workspace or frozen state changes.
341376 func (m *model) refreshFooter() {
342377 kind := m.picker.Kind()
343378 _, _, workspace := m.picker.Workspace()
344- if kind != m.footerKind || workspace != m.footerWorkspace || m.mode != m.footerMode {
345- m.footerKind, m.footerWorkspace, m.footerMode = kind, workspace, m.mode
346- m.footerText = footerText(m.mode, kind, workspace, m.toggle, m.noIndicator)
379+ if kind != m.footerKind || workspace != m.footerWorkspace || m.mode != m.footerMode || m.live != m.footerLive {
380+ m.footerKind, m.footerWorkspace, m.footerMode, m.footerLive = kind, workspace, m.mode, m.live
381+ m.footerText = footerText(m.mode, kind, workspace, m.toggle, m.noIndicator, m.freezeHint())
347382 }
348383 }
349384
@@ -354,7 +389,7 @@ func (m *model) settle() {
354389 case core.PickAccepted:
355390 r, _ := m.picker.Result()
356391 m.end = endAccepted
357- m.sess.accept(r, m.mode)
392+ m.sess.accept(r, m.mode, m.frozen())
358393 return
359394 case core.PickCanceled:
360395 m.end = endCanceled
@@ -35,6 +35,10 @@ type model struct {
35 // record footer says there is none.35 // record footer says there is none.
36 noIndicator bool36 noIndicator bool
37 seen uint32 // session toggles already applied to mode37 seen uint32 // session toggles already applied to mode
38+ // live: F turned the frozen screen off; seenFreezes is the session F
39+ // count already applied to it.
40+ live bool
41+ seenFreezes uint32
38 wake chan struct{}42 wake chan struct{}
39 outputs []ports.Output43 outputs []ports.Output
40 index int // the output this overlay covers; every output has its own model44 index int // the output this overlay covers; every output has its own model
@@ -62,6 +66,7 @@ type model struct {
62 footerKind core.PickKind66 footerKind core.PickKind
63 footerMode ports.Mode67 footerMode ports.Mode
64 footerWorkspace bool68 footerWorkspace bool
69+ footerLive bool
65 label label70 label label
66 71
67 // Key feedback: a toast and a border pulse, advanced by animate. fx counts72 // Key feedback: a toast and a border pulse, advanced by animate. fx counts
@@ -75,7 +80,9 @@ type model struct {
75 }80 }
76 81
77 func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session, grid bool) *model {82 func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session, grid bool) *model {
78- return &model{base: mode, mode: mode, footerMode: mode, outputs: outputs, index: index, sess: sess, grid: grid, header: headerText(outputs, index), footerText: footerText(mode, core.PickRegion, false, false, false)}83+ m := &model{base: mode, mode: mode, footerMode: mode, outputs: outputs, index: index, sess: sess, grid: grid, header: headerText(outputs, index)}
84+ m.footerText = footerText(mode, core.PickRegion, false, false, false, m.freezeHint())
85+ return m
79 }86 }
80 87
81 // otherMode is the mode a Tab switches to.88 // otherMode is the mode a Tab switches to.
@@ -101,6 +108,27 @@ func (m *model) syncMode() {
101 m.showToast(modeToast(m.mode), modeHold, false)108 m.showToast(modeToast(m.mode), modeHold, false)
102 m.pulseBorder()109 m.pulseBorder()
103 }110 }
111+ if n := m.sess.freezes.Load(); n != m.seenFreezes {
112+ m.seenFreezes, m.live = n, n%2 == 1
113+ m.showToast(freezeToast(m.live), modeHold, false)
114+ m.pulseBorder()
115+ }
116+}
117+
118+// frozen reports whether this overlay shows, and its screenshot captures, the
119+// frozen screen.
120+func (m *model) frozen() bool { return m.sess.canFreeze && !m.live }
121+
122+// freezeHint is the F state the footer shows: none without a frozen screen
123+// or in record mode, which always captures the live screen.
124+func (m *model) freezeHint() freezeState {
125+ switch {
126+ case !m.sess.canFreeze || m.mode == ports.Record:
127+ return freezeOff
128+ case m.live:
129+ return freezeLive
130+ }
131+ return freezeOn
104 }132 }
105 133
106 // resize is the OnResize callback. A logical size change (re)starts the134 // resize is the OnResize callback. A logical size change (re)starts the
@@ -209,13 +237,14 @@ type visible struct {
209 dragging bool237 dragging bool
210 kind core.PickKind238 kind core.PickKind
211 grid bool239 grid bool
240+ live bool
212 status core.PickStatus241 status core.PickStatus
213 fx uint32242 fx uint32
214 }243 }
215 244
216 func (m *model) snapshot() visible {245 func (m *model) snapshot() visible {
217 r, _ := m.picker.Rect()246 r, _ := m.picker.Rect()
218- return visible{r, m.mode == ports.Record, m.picker.Dragging(), m.picker.Kind(), m.grid, m.picker.Status(), m.fx}247+ return visible{r, m.mode == ports.Record, m.picker.Dragging(), m.picker.Kind(), m.grid, m.live, m.picker.Status(), m.fx}
219 }248 }
220 249
221 // input is the OnInput callback. It reports whether the drawn state changed,250 // input is the OnInput callback. It reports whether the drawn state changed,
@@ -261,6 +290,11 @@ func (m *model) key(name string) {
261 m.selectTarget(core.PickRegion)290 m.selectTarget(core.PickRegion)
262 case "g", "G":291 case "g", "G":
263 m.grid = !m.grid292 m.grid = !m.grid
293+ case "f", "F":
294+ if m.freezeHint() != freezeOff {
295+ m.sess.toggleFreeze()
296+ m.syncMode()
297+ }
264 case "Tab":298 case "Tab":
265 if m.toggle {299 if m.toggle {
266 m.sess.toggle()300 m.sess.toggle()
@@ -272,7 +306,7 @@ func (m *model) key(name string) {
272 // A digit picks that whole monitor from any overlay, without reopening.306 // A digit picks that whole monitor from any overlay, without reopening.
273 if i, ok := outputKey(name, len(m.outputs)); ok && !m.picker.Dragging() {307 if i, ok := outputKey(name, len(m.outputs)); ok && !m.picker.Dragging() {
274 m.end = endAccepted308 m.end = endAccepted
275- m.sess.accept(core.PickResult{OutputID: m.outputs[i].ID, Kind: core.PickMonitor}, m.mode)309+ m.sess.accept(core.PickResult{OutputID: m.outputs[i].ID, Kind: core.PickMonitor}, m.mode, m.frozen())
276 }310 }
277 }311 }
278 }312 }
@@ -337,13 +371,14 @@ func outputKey(name string, n int) (int, bool) {
337 return i, i < n371 return i, i < n
338 }372 }
339 373
340-// refreshFooter rebuilds the footer only when the mode, target kind or workspace changes.374+// refreshFooter rebuilds the footer only when the mode, target kind,
375+// workspace or frozen state changes.
341 func (m *model) refreshFooter() {376 func (m *model) refreshFooter() {
342 kind := m.picker.Kind()377 kind := m.picker.Kind()
343 _, _, workspace := m.picker.Workspace()378 _, _, workspace := m.picker.Workspace()
344- if kind != m.footerKind || workspace != m.footerWorkspace || m.mode != m.footerMode {379+ if kind != m.footerKind || workspace != m.footerWorkspace || m.mode != m.footerMode || m.live != m.footerLive {
345- m.footerKind, m.footerWorkspace, m.footerMode = kind, workspace, m.mode380+ m.footerKind, m.footerWorkspace, m.footerMode, m.footerLive = kind, workspace, m.mode, m.live
346- m.footerText = footerText(m.mode, kind, workspace, m.toggle, m.noIndicator)381+ m.footerText = footerText(m.mode, kind, workspace, m.toggle, m.noIndicator, m.freezeHint())
347 }382 }
348 }383 }
349 384
@@ -354,7 +389,7 @@ func (m *model) settle() {
354 case core.PickAccepted:389 case core.PickAccepted:
355 r, _ := m.picker.Result()390 r, _ := m.picker.Result()
356 m.end = endAccepted391 m.end = endAccepted
357- m.sess.accept(r, m.mode)392+ m.sess.accept(r, m.mode, m.frozen())
358 return393 return
359 case core.PickCanceled:394 case core.PickCanceled:
360 m.end = endCanceled395 m.end = endCanceled
M internal/adapters/selection/scene.go +20 -4
@@ -29,9 +29,18 @@ const (
2929 // recording indicator out of the video, so none is drawn.
3030 const noIndicatorHint = "No recording indicator on this compositor · stop with the same shortcut or nefercap stop"
3131
32-// footerText is rebuilt only when the capture mode, picker kind or workspace
33-// availability changes; pointer motion never formats hints.
34-func footerText(mode ports.Mode, kind core.PickKind, workspace, toggle, noIndicator bool) string {
32+// freezeState is the F hint of the footer.
33+type freezeState uint8
34+
35+const (
36+ freezeOff freezeState = iota // no frozen screen in this mode: F is hidden
37+ freezeOn // the frozen screen is shown: F goes live
38+ freezeLive // F turned it off: F freezes again
39+)
40+
41+// footerText is rebuilt only when the capture mode, picker kind, workspace
42+// availability or frozen state changes; pointer motion never formats hints.
43+func footerText(mode ports.Mode, kind core.PickKind, workspace, toggle, noIndicator bool, freeze freezeState) string {
3544 action, name, other := "capture", "shot", "rec"
3645 if mode == ports.Record {
3746 action, name, other = "record", "rec", "shot"
@@ -47,7 +56,14 @@ func footerText(mode ports.Mode, kind core.PickKind, workspace, toggle, noIndica
4756 if workspace {
4857 b.WriteString(" · W workspace")
4958 }
50- b.WriteString(" · G grid · Esc cancel\n")
59+ b.WriteString(" · G grid")
60+ switch freeze {
61+ case freezeOn:
62+ b.WriteString(" · F live")
63+ case freezeLive:
64+ b.WriteString(" · F freeze")
65+ }
66+ b.WriteString(" · Esc cancel\n")
5167 switch kind {
5268 case core.PickMonitor, core.PickWorkspace:
5369 target := " monitor"
@@ -29,9 +29,18 @@ const (
29 // recording indicator out of the video, so none is drawn.29 // recording indicator out of the video, so none is drawn.
30 const noIndicatorHint = "No recording indicator on this compositor · stop with the same shortcut or nefercap stop"30 const noIndicatorHint = "No recording indicator on this compositor · stop with the same shortcut or nefercap stop"
31 31
32-// footerText is rebuilt only when the capture mode, picker kind or workspace32+// freezeState is the F hint of the footer.
33-// availability changes; pointer motion never formats hints.33+type freezeState uint8
34-func footerText(mode ports.Mode, kind core.PickKind, workspace, toggle, noIndicator bool) string {34+
35+const (
36+ freezeOff freezeState = iota // no frozen screen in this mode: F is hidden
37+ freezeOn // the frozen screen is shown: F goes live
38+ freezeLive // F turned it off: F freezes again
39+)
40+
41+// footerText is rebuilt only when the capture mode, picker kind, workspace
42+// availability or frozen state changes; pointer motion never formats hints.
43+func footerText(mode ports.Mode, kind core.PickKind, workspace, toggle, noIndicator bool, freeze freezeState) string {
35 action, name, other := "capture", "shot", "rec"44 action, name, other := "capture", "shot", "rec"
36 if mode == ports.Record {45 if mode == ports.Record {
37 action, name, other = "record", "rec", "shot"46 action, name, other = "record", "rec", "shot"
@@ -47,7 +56,14 @@ func footerText(mode ports.Mode, kind core.PickKind, workspace, toggle, noIndica
47 if workspace {56 if workspace {
48 b.WriteString(" · W workspace")57 b.WriteString(" · W workspace")
49 }58 }
50- b.WriteString(" · G grid · Esc cancel\n")59+ b.WriteString(" · G grid")
60+ switch freeze {
61+ case freezeOn:
62+ b.WriteString(" · F live")
63+ case freezeLive:
64+ b.WriteString(" · F freeze")
65+ }
66+ b.WriteString(" · Esc cancel\n")
51 switch kind {67 switch kind {
52 case core.PickMonitor, core.PickWorkspace:68 case core.PickMonitor, core.PickWorkspace:
53 target := " monitor"69 target := " monitor"
M internal/adapters/selection/selection_test.go +10 -10
@@ -323,8 +323,8 @@ func TestSelectRejectsBadInput(t *testing.T) {
323323 func TestSessionFirstDecisionWins(t *testing.T) {
324324 sess, ctx := testSession()
325325 first := core.PickResult{OutputID: 7, Kind: core.PickMonitor}
326- sess.accept(first, ports.Screenshot)
327- sess.accept(core.PickResult{OutputID: 9, Kind: core.PickMonitor}, ports.Record)
326+ sess.accept(first, ports.Screenshot, false)
327+ sess.accept(core.PickResult{OutputID: 9, Kind: core.PickMonitor}, ports.Record, false)
328328 sess.abort()
329329 assert.Error(t, ctx.Err())
330330 res, ok, err := sess.outcome(context.Background())
@@ -365,7 +365,7 @@ func TestSessionRunEnded(t *testing.T) {
365365 boom := errors.New("boom")
366366
367367 sess, _ := testSession()
368- sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot)
368+ sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot, false)
369369 sess.runEnded(context.Background(), "DP-1", context.Canceled) // our own cancel
370370 _, ok, err := sess.outcome(context.Background())
371371 assert.True(t, ok)
@@ -380,7 +380,7 @@ func TestSessionRunEnded(t *testing.T) {
380380 // GO012: a real failure joined with Canceled survives whole, including the
381381 // output name, after our own cancel.
382382 sess, _ = testSession()
383- sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot)
383+ sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot, false)
384384 real := fmt.Errorf("surface close: %w", context.Canceled)
385385 sess.runEnded(context.Background(), "DP-2", errors.Join(context.Canceled, real, boom))
386386 _, ok, err = sess.outcome(context.Background())
@@ -396,7 +396,7 @@ func TestSessionRunEnded(t *testing.T) {
396396 assert.ErrorContains(t, err, "output DP-1")
397397
398398 sess, _ = testSession()
399- sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot)
399+ sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot, false)
400400 sess.runEnded(context.Background(), "DP-1", boom) // a later overlay failing rejects the pick
401401 _, ok, err = sess.outcome(context.Background())
402402 assert.False(t, ok || err == nil)
@@ -669,11 +669,11 @@ func TestLabelContentHoldsMonospaceLine(t *testing.T) {
669669 }
670670
671671 func TestFooterAndHeaderText(t *testing.T) {
672- assert.Contains(t, footerText(ports.Screenshot, core.PickRegion, false, false, false), "shot · R region")
673- assert.Contains(t, footerText(ports.Record, core.PickRegion, false, false, false), "rec · R region")
674- assert.NotContains(t, footerText(ports.Record, core.PickRegion, false, false, false), "No recording indicator")
675- assert.Contains(t, footerText(ports.Record, core.PickRegion, false, false, true), "No recording indicator on this compositor")
676- assert.NotContains(t, footerText(ports.Screenshot, core.PickRegion, false, false, true), "No recording indicator", "only record mode needs the hint")
672+ assert.Contains(t, footerText(ports.Screenshot, core.PickRegion, false, false, false, freezeOff), "shot · R region")
673+ assert.Contains(t, footerText(ports.Record, core.PickRegion, false, false, false, freezeOff), "rec · R region")
674+ assert.NotContains(t, footerText(ports.Record, core.PickRegion, false, false, false, freezeOff), "No recording indicator")
675+ assert.Contains(t, footerText(ports.Record, core.PickRegion, false, false, true, freezeOff), "No recording indicator on this compositor")
676+ assert.NotContains(t, footerText(ports.Screenshot, core.PickRegion, false, false, true, freezeOff), "No recording indicator", "only record mode needs the hint")
677677 assert.Equal(t, "DP-1 · 1/2 · keys 1-2 pick monitor", headerText(testOutputs, 0))
678678 assert.Equal(t, "DP-1", headerText(testOutputs[:1], 0))
679679 i, ok := outputKey("2", 2)
@@ -323,8 +323,8 @@ func TestSelectRejectsBadInput(t *testing.T) {
323 func TestSessionFirstDecisionWins(t *testing.T) {323 func TestSessionFirstDecisionWins(t *testing.T) {
324 sess, ctx := testSession()324 sess, ctx := testSession()
325 first := core.PickResult{OutputID: 7, Kind: core.PickMonitor}325 first := core.PickResult{OutputID: 7, Kind: core.PickMonitor}
326- sess.accept(first, ports.Screenshot)326+ sess.accept(first, ports.Screenshot, false)
327- sess.accept(core.PickResult{OutputID: 9, Kind: core.PickMonitor}, ports.Record)327+ sess.accept(core.PickResult{OutputID: 9, Kind: core.PickMonitor}, ports.Record, false)
328 sess.abort()328 sess.abort()
329 assert.Error(t, ctx.Err())329 assert.Error(t, ctx.Err())
330 res, ok, err := sess.outcome(context.Background())330 res, ok, err := sess.outcome(context.Background())
@@ -365,7 +365,7 @@ func TestSessionRunEnded(t *testing.T) {
365 boom := errors.New("boom")365 boom := errors.New("boom")
366 366
367 sess, _ := testSession()367 sess, _ := testSession()
368- sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot)368+ sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot, false)
369 sess.runEnded(context.Background(), "DP-1", context.Canceled) // our own cancel369 sess.runEnded(context.Background(), "DP-1", context.Canceled) // our own cancel
370 _, ok, err := sess.outcome(context.Background())370 _, ok, err := sess.outcome(context.Background())
371 assert.True(t, ok)371 assert.True(t, ok)
@@ -380,7 +380,7 @@ func TestSessionRunEnded(t *testing.T) {
380 // GO012: a real failure joined with Canceled survives whole, including the380 // GO012: a real failure joined with Canceled survives whole, including the
381 // output name, after our own cancel.381 // output name, after our own cancel.
382 sess, _ = testSession()382 sess, _ = testSession()
383- sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot)383+ sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot, false)
384 real := fmt.Errorf("surface close: %w", context.Canceled)384 real := fmt.Errorf("surface close: %w", context.Canceled)
385 sess.runEnded(context.Background(), "DP-2", errors.Join(context.Canceled, real, boom))385 sess.runEnded(context.Background(), "DP-2", errors.Join(context.Canceled, real, boom))
386 _, ok, err = sess.outcome(context.Background())386 _, ok, err = sess.outcome(context.Background())
@@ -396,7 +396,7 @@ func TestSessionRunEnded(t *testing.T) {
396 assert.ErrorContains(t, err, "output DP-1")396 assert.ErrorContains(t, err, "output DP-1")
397 397
398 sess, _ = testSession()398 sess, _ = testSession()
399- sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot)399+ sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot, false)
400 sess.runEnded(context.Background(), "DP-1", boom) // a later overlay failing rejects the pick400 sess.runEnded(context.Background(), "DP-1", boom) // a later overlay failing rejects the pick
401 _, ok, err = sess.outcome(context.Background())401 _, ok, err = sess.outcome(context.Background())
402 assert.False(t, ok || err == nil)402 assert.False(t, ok || err == nil)
@@ -669,11 +669,11 @@ func TestLabelContentHoldsMonospaceLine(t *testing.T) {
669 }669 }
670 670
671 func TestFooterAndHeaderText(t *testing.T) {671 func TestFooterAndHeaderText(t *testing.T) {
672- assert.Contains(t, footerText(ports.Screenshot, core.PickRegion, false, false, false), "shot · R region")672+ assert.Contains(t, footerText(ports.Screenshot, core.PickRegion, false, false, false, freezeOff), "shot · R region")
673- assert.Contains(t, footerText(ports.Record, core.PickRegion, false, false, false), "rec · R region")673+ assert.Contains(t, footerText(ports.Record, core.PickRegion, false, false, false, freezeOff), "rec · R region")
674- assert.NotContains(t, footerText(ports.Record, core.PickRegion, false, false, false), "No recording indicator")674+ assert.NotContains(t, footerText(ports.Record, core.PickRegion, false, false, false, freezeOff), "No recording indicator")
675- assert.Contains(t, footerText(ports.Record, core.PickRegion, false, false, true), "No recording indicator on this compositor")675+ assert.Contains(t, footerText(ports.Record, core.PickRegion, false, false, true, freezeOff), "No recording indicator on this compositor")
676- assert.NotContains(t, footerText(ports.Screenshot, core.PickRegion, false, false, true), "No recording indicator", "only record mode needs the hint")676+ assert.NotContains(t, footerText(ports.Screenshot, core.PickRegion, false, false, true, freezeOff), "No recording indicator", "only record mode needs the hint")
677 assert.Equal(t, "DP-1 · 1/2 · keys 1-2 pick monitor", headerText(testOutputs, 0))677 assert.Equal(t, "DP-1 · 1/2 · keys 1-2 pick monitor", headerText(testOutputs, 0))
678 assert.Equal(t, "DP-1", headerText(testOutputs[:1], 0))678 assert.Equal(t, "DP-1", headerText(testOutputs[:1], 0))
679 i, ok := outputKey("2", 2)679 i, ok := outputKey("2", 2)
M internal/adapters/selection/selector.go +12 -1
@@ -55,6 +55,7 @@ type Selector struct {
5555 toggle bool
5656 noIndicator bool
5757 backdrops map[uint32]image.Image // frozen screens by output ID
58+ frozen bool // the accepted selection showed the frozen screen
5859 }
5960
6061 var _ ports.Selector = (*Selector)(nil)
@@ -123,6 +124,11 @@ func backdropImage(f ports.Frame) *image.NRGBA {
123124 return img
124125 }
125126
127+// Frozen reports whether the last accepted selection still showed the frozen
128+// screen, so its screenshot must come from the frozen copy. It is false after
129+// F switched to the live screen, and when there were no backdrops.
130+func (s *Selector) Frozen() bool { return s.frozen }
131+
126132 // AllowToggle lets Tab switch between screenshot and record selection. The
127133 // starting mode is New's; the target selection is kept across a switch.
128134 func (s *Selector) AllowToggle() *Selector {
@@ -163,6 +169,8 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se
163169 child, cancel := context.WithCancel(ctx)
164170 defer cancel()
165171 sess := newSession(cancel)
172+ sess.canFreeze = len(s.backdrops) > 0
173+ s.frozen = false
166174 var wg sync.WaitGroup
167175 for i := range outputs {
168176 m := newModel(s.mode, outputs, i, sess, s.grid)
@@ -174,9 +182,11 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se
174182 m.wake, m.anim = make(chan struct{}, 1), newAnimator()
175183 if s.toggle {
176184 m.toggle = true
185+ }
186+ if s.toggle || sess.canFreeze {
177187 sess.wakes = append(sess.wakes, m.wake)
178188 }
179- m.footerText = footerText(m.mode, m.footerKind, m.footerWorkspace, m.toggle, m.noIndicator)
189+ m.footerText = footerText(m.mode, m.footerKind, m.footerWorkspace, m.toggle, m.noIndicator, m.freezeHint())
180190 wg.Add(1)
181191 go func() {
182192 defer wg.Done()
@@ -195,6 +205,7 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se
195205 if err != nil || !ok {
196206 return ports.Selection{}, false, err
197207 }
208+ s.frozen = sess.acceptedFrozen()
198209 return s.selectionFor(sess.acceptedMode(), result), true, nil
199210 }
200211
@@ -55,6 +55,7 @@ type Selector struct {
55 toggle bool55 toggle bool
56 noIndicator bool56 noIndicator bool
57 backdrops map[uint32]image.Image // frozen screens by output ID57 backdrops map[uint32]image.Image // frozen screens by output ID
58+ frozen bool // the accepted selection showed the frozen screen
58 }59 }
59 60
60 var _ ports.Selector = (*Selector)(nil)61 var _ ports.Selector = (*Selector)(nil)
@@ -123,6 +124,11 @@ func backdropImage(f ports.Frame) *image.NRGBA {
123 return img124 return img
124 }125 }
125 126
127+// Frozen reports whether the last accepted selection still showed the frozen
128+// screen, so its screenshot must come from the frozen copy. It is false after
129+// F switched to the live screen, and when there were no backdrops.
130+func (s *Selector) Frozen() bool { return s.frozen }
131+
126 // AllowToggle lets Tab switch between screenshot and record selection. The132 // AllowToggle lets Tab switch between screenshot and record selection. The
127 // starting mode is New's; the target selection is kept across a switch.133 // starting mode is New's; the target selection is kept across a switch.
128 func (s *Selector) AllowToggle() *Selector {134 func (s *Selector) AllowToggle() *Selector {
@@ -163,6 +169,8 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se
163 child, cancel := context.WithCancel(ctx)169 child, cancel := context.WithCancel(ctx)
164 defer cancel()170 defer cancel()
165 sess := newSession(cancel)171 sess := newSession(cancel)
172+ sess.canFreeze = len(s.backdrops) > 0
173+ s.frozen = false
166 var wg sync.WaitGroup174 var wg sync.WaitGroup
167 for i := range outputs {175 for i := range outputs {
168 m := newModel(s.mode, outputs, i, sess, s.grid)176 m := newModel(s.mode, outputs, i, sess, s.grid)
@@ -174,9 +182,11 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se
174 m.wake, m.anim = make(chan struct{}, 1), newAnimator()182 m.wake, m.anim = make(chan struct{}, 1), newAnimator()
175 if s.toggle {183 if s.toggle {
176 m.toggle = true184 m.toggle = true
185+ }
186+ if s.toggle || sess.canFreeze {
177 sess.wakes = append(sess.wakes, m.wake)187 sess.wakes = append(sess.wakes, m.wake)
178 }188 }
179- m.footerText = footerText(m.mode, m.footerKind, m.footerWorkspace, m.toggle, m.noIndicator)189+ m.footerText = footerText(m.mode, m.footerKind, m.footerWorkspace, m.toggle, m.noIndicator, m.freezeHint())
180 wg.Add(1)190 wg.Add(1)
181 go func() {191 go func() {
182 defer wg.Done()192 defer wg.Done()
@@ -195,6 +205,7 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se
195 if err != nil || !ok {205 if err != nil || !ok {
196 return ports.Selection{}, false, err206 return ports.Selection{}, false, err
197 }207 }
208+ s.frozen = sess.acceptedFrozen()
198 return s.selectionFor(sess.acceptedMode(), result), true, nil209 return s.selectionFor(sess.acceptedMode(), result), true, nil
199 }210 }
200 211
M internal/adapters/selection/session.go +28 -6
@@ -25,12 +25,17 @@ type session struct {
2525 // after one. wakes is set before the overlays start and never changes.
2626 toggles atomic.Uint32
2727 wakes []chan struct{}
28+ // freezes counts F presses on any overlay: each flips the frozen screen
29+ // on or off. canFreeze is set before the overlays start.
30+ freezes atomic.Uint32
31+ canFreeze bool
2832
2933 mu sync.Mutex
3034 decided bool
3135 accepted bool
3236 result core.PickResult
3337 mode ports.Mode // the mode the confirming overlay showed
38+ frozen bool // whether the confirming overlay showed the frozen screen
3439 errs []error
3540 }
3641
@@ -39,11 +44,21 @@ func newSession(cancel context.CancelFunc) *session { return &session{cancel: ca
3944 // toggle flips the capture mode for every overlay and asks each to redraw.
4045 // After the first decision it does nothing, so a late Tab on another overlay
4146 // cannot change what was confirmed.
42-func (s *session) toggle() {
47+func (s *session) toggle() { s.flip(&s.toggles) }
48+
49+// toggleFreeze flips the frozen screen for every overlay, like toggle.
50+func (s *session) toggleFreeze() {
51+ if s.canFreeze {
52+ s.flip(&s.freezes)
53+ }
54+}
55+
56+// flip counts one press before the first decision and wakes every overlay.
57+func (s *session) flip(n *atomic.Uint32) {
4358 s.mu.Lock()
4459 decided := s.decided
4560 if !decided {
46- s.toggles.Add(1)
61+ n.Add(1)
4762 }
4863 s.mu.Unlock()
4964 if decided {
@@ -57,12 +72,12 @@ func (s *session) toggle() {
5772 }
5873 }
5974
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) {
75+// accept records the first decision, with the mode and frozen state its
76+// overlay showed, and ends every overlay.
77+func (s *session) accept(r core.PickResult, mode ports.Mode, frozen bool) {
6378 s.mu.Lock()
6479 if !s.decided {
65- s.decided, s.accepted, s.result, s.mode = true, true, r, mode
80+ s.decided, s.accepted, s.result, s.mode, s.frozen = true, true, r, mode, frozen
6681 }
6782 s.mu.Unlock()
6883 s.cancel()
@@ -124,3 +139,10 @@ func (s *session) acceptedMode() ports.Mode {
124139 defer s.mu.Unlock()
125140 return s.mode
126141 }
142+
143+// acceptedFrozen is the frozen state recorded by accept.
144+func (s *session) acceptedFrozen() bool {
145+ s.mu.Lock()
146+ defer s.mu.Unlock()
147+ return s.frozen
148+}
@@ -25,12 +25,17 @@ type session struct {
25 // after one. wakes is set before the overlays start and never changes.25 // after one. wakes is set before the overlays start and never changes.
26 toggles atomic.Uint3226 toggles atomic.Uint32
27 wakes []chan struct{}27 wakes []chan struct{}
28+ // freezes counts F presses on any overlay: each flips the frozen screen
29+ // on or off. canFreeze is set before the overlays start.
30+ freezes atomic.Uint32
31+ canFreeze bool
28 32
29 mu sync.Mutex33 mu sync.Mutex
30 decided bool34 decided bool
31 accepted bool35 accepted bool
32 result core.PickResult36 result core.PickResult
33 mode ports.Mode // the mode the confirming overlay showed37 mode ports.Mode // the mode the confirming overlay showed
38+ frozen bool // whether the confirming overlay showed the frozen screen
34 errs []error39 errs []error
35 }40 }
36 41
@@ -39,11 +44,21 @@ func newSession(cancel context.CancelFunc) *session { return &session{cancel: ca
39 // toggle flips the capture mode for every overlay and asks each to redraw.44 // 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 overlay45 // After the first decision it does nothing, so a late Tab on another overlay
41 // cannot change what was confirmed.46 // cannot change what was confirmed.
42-func (s *session) toggle() {47+func (s *session) toggle() { s.flip(&s.toggles) }
48+
49+// toggleFreeze flips the frozen screen for every overlay, like toggle.
50+func (s *session) toggleFreeze() {
51+ if s.canFreeze {
52+ s.flip(&s.freezes)
53+ }
54+}
55+
56+// flip counts one press before the first decision and wakes every overlay.
57+func (s *session) flip(n *atomic.Uint32) {
43 s.mu.Lock()58 s.mu.Lock()
44 decided := s.decided59 decided := s.decided
45 if !decided {60 if !decided {
46- s.toggles.Add(1)61+ n.Add(1)
47 }62 }
48 s.mu.Unlock()63 s.mu.Unlock()
49 if decided {64 if decided {
@@ -57,12 +72,12 @@ func (s *session) toggle() {
57 }72 }
58 }73 }
59 74
60-// accept records the first decision, with the mode its overlay showed, and75+// accept records the first decision, with the mode and frozen state its
61-// ends every overlay.76+// overlay showed, and ends every overlay.
62-func (s *session) accept(r core.PickResult, mode ports.Mode) {77+func (s *session) accept(r core.PickResult, mode ports.Mode, frozen bool) {
63 s.mu.Lock()78 s.mu.Lock()
64 if !s.decided {79 if !s.decided {
65- s.decided, s.accepted, s.result, s.mode = true, true, r, mode80+ s.decided, s.accepted, s.result, s.mode, s.frozen = true, true, r, mode, frozen
66 }81 }
67 s.mu.Unlock()82 s.mu.Unlock()
68 s.cancel()83 s.cancel()
@@ -124,3 +139,10 @@ func (s *session) acceptedMode() ports.Mode {
124 defer s.mu.Unlock()139 defer s.mu.Unlock()
125 return s.mode140 return s.mode
126 }141 }
142+
143+// acceptedFrozen is the frozen state recorded by accept.
144+func (s *session) acceptedFrozen() bool {
145+ s.mu.Lock()
146+ defer s.mu.Unlock()
147+ return s.frozen
148+}
M internal/adapters/selection/view.go +3 -3
@@ -70,10 +70,10 @@ func chip(st nefergui.Node, key string, r frect, state, fade, text string) {
7070 }
7171 }
7272
73-// drawsBackdrop: a screenshot selection shows the frozen screen; a recording
74-// selects on the live screen.
73+// drawsBackdrop: a screenshot selection shows the frozen screen until F turns
74+// it off; a recording selects on the live screen.
7575 func drawsBackdrop(m *model) bool {
76- return m.backdrop != nil && m.mode == ports.Screenshot && m.w > 0 && m.h > 0
76+ return m.backdrop != nil && !m.live && m.mode == ports.Screenshot && m.w > 0 && m.h > 0
7777 }
7878
7979 type tagged struct {
@@ -70,10 +70,10 @@ func chip(st nefergui.Node, key string, r frect, state, fade, text string) {
70 }70 }
71 }71 }
72 72
73-// drawsBackdrop: a screenshot selection shows the frozen screen; a recording73+// drawsBackdrop: a screenshot selection shows the frozen screen until F turns
74-// selects on the live screen.74+// it off; a recording selects on the live screen.
75 func drawsBackdrop(m *model) bool {75 func drawsBackdrop(m *model) bool {
76- return m.backdrop != nil && m.mode == ports.Screenshot && m.w > 0 && m.h > 076+ return m.backdrop != nil && !m.live && m.mode == ports.Screenshot && m.w > 0 && m.h > 0
77 }77 }
78 78
79 type tagged struct {79 type tagged struct {
M internal/app/interactive.go +2 -2
@@ -56,8 +56,8 @@ func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []po
5656 if err != nil || !accepted {
5757 return ports.Selection{}, false, err
5858 }
59- if sel.Mode == ports.Record {
60- source.Thaw() // a recording captures the live screen
59+ if sel.Mode == ports.Record || !selector.Frozen() {
60+ source.Thaw() // a recording, or F in the selector, captures the live screen
6161 } else {
6262 source.Thaw(sel.Target.OutputID) // only the chosen output is captured
6363 }
@@ -56,8 +56,8 @@ func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []po
56 if err != nil || !accepted {56 if err != nil || !accepted {
57 return ports.Selection{}, false, err57 return ports.Selection{}, false, err
58 }58 }
59- if sel.Mode == ports.Record {59+ if sel.Mode == ports.Record || !selector.Frozen() {
60- source.Thaw() // a recording captures the live screen60+ source.Thaw() // a recording, or F in the selector, captures the live screen
61 } else {61 } else {
62 source.Thaw(sel.Target.OutputID) // only the chosen output is captured62 source.Thaw(sel.Target.OutputID) // only the chosen output is captured
63 }63 }

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