Merge pull request 'feat(selection): toggle the frozen screen with F in the screenshot selector' (#12) from feat/freeze-key into main
13 files · +231 -38
M Makefile +1 -1
| @@ -48,7 +48,7 @@ arch: | |||
| 48 | 48 | # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS | |
| 49 | 49 | # the packages holding them. Every named test must exist, so the guard cannot | |
| 50 | 50 | # pass vacuously when a frame-path test is removed or renamed. | |
| 51 | - | PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestMonitorGridAllocations TestMonitorGridPhysicalAllocations TestGridAllocations TestGridModeAllocations TestSelectorAllocations TestModeToggleAllocations TestEffectsAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations TestCaptureAllocationsCancellable TestCropAllocations | |
| 51 | + | PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestMonitorGridAllocations TestMonitorGridPhysicalAllocations TestGridAllocations TestGridModeAllocations TestSelectorAllocations TestModeToggleAllocations TestFreezeKeyAllocations TestEffectsAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations TestCaptureAllocationsCancellable TestCropAllocations | |
| 52 | 52 | PERF_PKGS := ./internal/ports ./internal/core ./internal/adapters/selection ./internal/adapters/indicator ./internal/adapters/ffmpeg ./internal/app ./internal/adapters/wayland | |
| 53 | 53 | perf_empty := | |
| 54 | 54 | perf_space := $(perf_empty) $(perf_empty) | |
| @@ -48,7 +48,7 @@ arch: | |||||
| 48 | # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS | 48 | # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS | ||
| 49 | # the packages holding them. Every named test must exist, so the guard cannot | 49 | # the packages holding them. Every named test must exist, so the guard cannot | ||
| 50 | # pass vacuously when a frame-path test is removed or renamed. | 50 | # pass vacuously when a frame-path test is removed or renamed. | ||
| 51 | - | PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestMonitorGridAllocations TestMonitorGridPhysicalAllocations TestGridAllocations TestGridModeAllocations TestSelectorAllocations TestModeToggleAllocations TestEffectsAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations TestCaptureAllocationsCancellable TestCropAllocations | 51 | + | PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestMonitorGridAllocations TestMonitorGridPhysicalAllocations TestGridAllocations TestGridModeAllocations TestSelectorAllocations TestModeToggleAllocations TestFreezeKeyAllocations TestEffectsAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations TestCaptureAllocationsCancellable TestCropAllocations |
| 52 | PERF_PKGS := ./internal/ports ./internal/core ./internal/adapters/selection ./internal/adapters/indicator ./internal/adapters/ffmpeg ./internal/app ./internal/adapters/wayland | 52 | PERF_PKGS := ./internal/ports ./internal/core ./internal/adapters/selection ./internal/adapters/indicator ./internal/adapters/ffmpeg ./internal/app ./internal/adapters/wayland | ||
| 53 | perf_empty := | 53 | perf_empty := | ||
| 54 | perf_space := $(perf_empty) $(perf_empty) | 54 | perf_space := $(perf_empty) $(perf_empty) | ||
M README.md +1 -1
| @@ -73,7 +73,7 @@ video.dir = ~/Videos | |||
| 73 | 73 | | `screenshot.output` | `file`, `file+clipboard`, `clipboard` | `file` | | |
| 74 | 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 | 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 | ||
| @@ -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: | |||
| 79 | 79 | none is known; click or Enter confirms | |
| 80 | 80 | 1..9 pick that whole monitor from any overlay | |
| 81 | 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 | 84 | Tab switch shot <-> rec (all-in-one only) | |
| 83 | 85 | Escape cancel | |
| 84 | 86 | ||
| @@ -79,6 +79,8 @@ Selector keys and mouse: | |||||
| 79 | none is known; click or Enter confirms | 79 | none is known; click or Enter confirms | ||
| 80 | 1..9 pick that whole monitor from any overlay | 80 | 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 cancel | 85 | Escape cancel | ||
| 84 | 86 | ||||
M internal/adapters/selection/backdrop_test.go +91 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package selection | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "image" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| @@ -51,3 +52,93 @@ func TestBackdropDrawnOnlyForScreenshots(t *testing.T) { | |||
| 51 | 52 | m.mode, m.backdrop = ports.Screenshot, nil | |
| 52 | 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 | + | // The live state survives a round trip through record mode, where F is | |
| 98 | + | // ignored. | |
| 99 | + | func TestFreezeKeyWithTab(t *testing.T) { | |
| 100 | + | sess, _ := testSession() | |
| 101 | + | sess.canFreeze = true | |
| 102 | + | m := frozenModel(t, 0, sess) | |
| 103 | + | m.toggle = true | |
| 104 | + | m.input(key("f")) | |
| 105 | + | m.input(key("Tab")) | |
| 106 | + | require.Equal(t, ports.Record, m.mode) | |
| 107 | + | assert.False(t, m.input(key("f")), "F is ignored in record mode") | |
| 108 | + | m.input(key("Tab")) | |
| 109 | + | assert.False(t, drawsBackdrop(m), "still live after the round trip") | |
| 110 | + | assert.Contains(t, m.footerText, "F freeze") | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // Like Tab, F allocates only the rebuilt footer text. | |
| 114 | + | func TestFreezeKeyAllocations(t *testing.T) { | |
| 115 | + | if raceEnabled { | |
| 116 | + | t.Skip("the race detector adds allocations") | |
| 117 | + | } | |
| 118 | + | sess, _ := testSession() | |
| 119 | + | sess.canFreeze = true | |
| 120 | + | m := frozenModel(t, 0, sess) | |
| 121 | + | n := testing.AllocsPerRun(100, func() { | |
| 122 | + | m.input(key("f")) | |
| 123 | + | _ = m.scene() | |
| 124 | + | }) | |
| 125 | + | assert.LessOrEqual(t, n, 1.0, "an F press allocates only the rebuilt legend text") | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // Without a frozen screen, or in record mode, F does nothing and is not shown. | |
| 129 | + | func TestFreezeKeyHiddenWithoutBackdrop(t *testing.T) { | |
| 130 | + | m := newTestModel(t) | |
| 131 | + | assert.NotContains(t, m.footerText, "F ") | |
| 132 | + | assert.False(t, m.input(key("f"))) | |
| 133 | + | ||
| 134 | + | sess, _ := testSession() | |
| 135 | + | sess.canFreeze = true | |
| 136 | + | m = frozenModel(t, 0, sess) | |
| 137 | + | m.mode = ports.Record | |
| 138 | + | m.refreshFooter() | |
| 139 | + | assert.NotContains(t, m.footerText, "F ") | |
| 140 | + | assert.False(t, m.input(key("f"))) | |
| 141 | + | m.input(key("m")) | |
| 142 | + | m.input(key("Return")) | |
| 143 | + | assert.True(t, sess.acceptedFrozen(), "untouched, the frozen state is kept for the app to thaw") | |
| 144 | + | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package selection | 1 | package selection | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "image" | 5 | "image" | ||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| @@ -51,3 +52,93 @@ func TestBackdropDrawnOnlyForScreenshots(t *testing.T) { | |||||
| 51 | m.mode, m.backdrop = ports.Screenshot, nil | 52 | 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 | + | // The live state survives a round trip through record mode, where F is | |||
| 98 | + | // ignored. | |||
| 99 | + | func TestFreezeKeyWithTab(t *testing.T) { | |||
| 100 | + | sess, _ := testSession() | |||
| 101 | + | sess.canFreeze = true | |||
| 102 | + | m := frozenModel(t, 0, sess) | |||
| 103 | + | m.toggle = true | |||
| 104 | + | m.input(key("f")) | |||
| 105 | + | m.input(key("Tab")) | |||
| 106 | + | require.Equal(t, ports.Record, m.mode) | |||
| 107 | + | assert.False(t, m.input(key("f")), "F is ignored in record mode") | |||
| 108 | + | m.input(key("Tab")) | |||
| 109 | + | assert.False(t, drawsBackdrop(m), "still live after the round trip") | |||
| 110 | + | assert.Contains(t, m.footerText, "F freeze") | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | // Like Tab, F allocates only the rebuilt footer text. | |||
| 114 | + | func TestFreezeKeyAllocations(t *testing.T) { | |||
| 115 | + | if raceEnabled { | |||
| 116 | + | t.Skip("the race detector adds allocations") | |||
| 117 | + | } | |||
| 118 | + | sess, _ := testSession() | |||
| 119 | + | sess.canFreeze = true | |||
| 120 | + | m := frozenModel(t, 0, sess) | |||
| 121 | + | n := testing.AllocsPerRun(100, func() { | |||
| 122 | + | m.input(key("f")) | |||
| 123 | + | _ = m.scene() | |||
| 124 | + | }) | |||
| 125 | + | assert.LessOrEqual(t, n, 1.0, "an F press allocates only the rebuilt legend text") | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // Without a frozen screen, or in record mode, F does nothing and is not shown. | |||
| 129 | + | func TestFreezeKeyHiddenWithoutBackdrop(t *testing.T) { | |||
| 130 | + | m := newTestModel(t) | |||
| 131 | + | assert.NotContains(t, m.footerText, "F ") | |||
| 132 | + | assert.False(t, m.input(key("f"))) | |||
| 133 | + | ||||
| 134 | + | sess, _ := testSession() | |||
| 135 | + | sess.canFreeze = true | |||
| 136 | + | m = frozenModel(t, 0, sess) | |||
| 137 | + | m.mode = ports.Record | |||
| 138 | + | m.refreshFooter() | |||
| 139 | + | assert.NotContains(t, m.footerText, "F ") | |||
| 140 | + | assert.False(t, m.input(key("f"))) | |||
| 141 | + | m.input(key("m")) | |||
| 142 | + | m.input(key("Return")) | |||
| 143 | + | assert.True(t, sess.acceptedFrozen(), "untouched, the frozen state is kept for the app to thaw") | |||
| 144 | + | } | |||
M internal/adapters/selection/fx.go +10 -0
| @@ -38,6 +38,8 @@ const ( | |||
| 38 | 38 | toastMonitor = "Monitor" | |
| 39 | 39 | toastWorkspace = "Workspace" | |
| 40 | 40 | toastNoWS = "No workspace found · using monitor" | |
| 41 | + | toastFrozen = "Frozen screen" | |
| 42 | + | toastLive = "Live screen" | |
| 41 | 43 | ) | |
| 42 | 44 | ||
| 43 | 45 | // toast is one centered message. start is zero until the first frame draws | |
| @@ -142,6 +144,14 @@ func targetToast(kind core.PickKind) string { | |||
| 142 | 144 | 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 | 155 | func modeToast(mode ports.Mode) string { | |
| 146 | 156 | if mode == ports.Record { | |
| 147 | 157 | 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 draws | 45 | // 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 toastRegion | 144 | 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 toastRec | 157 | 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 { | |||
| 15 | 15 | m := newTestModel(t) | |
| 16 | 16 | m.base, m.mode, m.toggle = mode, mode, toggle | |
| 17 | 17 | 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 | 19 | return m | |
| 20 | 20 | } | |
| 21 | 21 | ||
| @@ -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, toggle | 16 | m.base, m.mode, m.toggle = mode, mode, toggle | ||
| 17 | m.footerMode = mode | 17 | 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 m | 19 | return m | ||
| 20 | } | 20 | } | ||
| 21 | 21 | ||||
M internal/adapters/selection/model.go +43 -8
| @@ -35,6 +35,10 @@ type model struct { | |||
| 35 | 35 | // record footer says there is none. | |
| 36 | 36 | noIndicator bool | |
| 37 | 37 | 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 | 42 | wake chan struct{} | |
| 39 | 43 | outputs []ports.Output | |
| 40 | 44 | index int // the output this overlay covers; every output has its own model | |
| @@ -62,6 +66,7 @@ type model struct { | |||
| 62 | 66 | footerKind core.PickKind | |
| 63 | 67 | footerMode ports.Mode | |
| 64 | 68 | footerWorkspace bool | |
| 69 | + | footerLive bool | |
| 65 | 70 | label label | |
| 66 | 71 | ||
| 67 | 72 | // 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 | 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 | 88 | // otherMode is the mode a Tab switches to. | |
| @@ -101,6 +108,27 @@ func (m *model) syncMode() { | |||
| 101 | 108 | m.showToast(modeToast(m.mode), modeHold, false) | |
| 102 | 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 | 134 | // resize is the OnResize callback. A logical size change (re)starts the | |
| @@ -209,13 +237,14 @@ type visible struct { | |||
| 209 | 237 | dragging bool | |
| 210 | 238 | kind core.PickKind | |
| 211 | 239 | grid bool | |
| 240 | + | live bool | |
| 212 | 241 | status core.PickStatus | |
| 213 | 242 | fx uint32 | |
| 214 | 243 | } | |
| 215 | 244 | ||
| 216 | 245 | func (m *model) snapshot() visible { | |
| 217 | 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 | 250 | // input is the OnInput callback. It reports whether the drawn state changed, | |
| @@ -261,6 +290,11 @@ func (m *model) key(name string) { | |||
| 261 | 290 | m.selectTarget(core.PickRegion) | |
| 262 | 291 | case "g", "G": | |
| 263 | 292 | m.grid = !m.grid | |
| 293 | + | case "f", "F": | |
| 294 | + | if m.freezeHint() != freezeOff { | |
| 295 | + | m.sess.toggleFreeze() | |
| 296 | + | m.syncMode() | |
| 297 | + | } | |
| 264 | 298 | case "Tab": | |
| 265 | 299 | if m.toggle { | |
| 266 | 300 | m.sess.toggle() | |
| @@ -272,7 +306,7 @@ func (m *model) key(name string) { | |||
| 272 | 306 | // A digit picks that whole monitor from any overlay, without reopening. | |
| 273 | 307 | if i, ok := outputKey(name, len(m.outputs)); ok && !m.picker.Dragging() { | |
| 274 | 308 | 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 | 371 | 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 | 376 | func (m *model) refreshFooter() { | |
| 342 | 377 | kind := m.picker.Kind() | |
| 343 | 378 | _, _, 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()) | |
| 347 | 382 | } | |
| 348 | 383 | } | |
| 349 | 384 | ||
| @@ -354,7 +389,7 @@ func (m *model) settle() { | |||
| 354 | 389 | case core.PickAccepted: | |
| 355 | 390 | r, _ := m.picker.Result() | |
| 356 | 391 | m.end = endAccepted | |
| 357 | - | m.sess.accept(r, m.mode) | |
| 392 | + | m.sess.accept(r, m.mode, m.frozen()) | |
| 358 | 393 | return | |
| 359 | 394 | case core.PickCanceled: | |
| 360 | 395 | 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 bool | 36 | noIndicator bool | ||
| 37 | seen uint32 // session toggles already applied to mode | 37 | 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.Output | 43 | outputs []ports.Output | ||
| 40 | index int // the output this overlay covers; every output has its own model | 44 | index int // the output this overlay covers; every output has its own model | ||
| @@ -62,6 +66,7 @@ type model struct { | |||||
| 62 | footerKind core.PickKind | 66 | footerKind core.PickKind | ||
| 63 | footerMode ports.Mode | 67 | footerMode ports.Mode | ||
| 64 | footerWorkspace bool | 68 | footerWorkspace bool | ||
| 69 | + | footerLive bool | |||
| 65 | label label | 70 | label label | ||
| 66 | 71 | ||||
| 67 | // Key feedback: a toast and a border pulse, advanced by animate. fx counts | 72 | // 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 the | 134 | // resize is the OnResize callback. A logical size change (re)starts the | ||
| @@ -209,13 +237,14 @@ type visible struct { | |||||
| 209 | dragging bool | 237 | dragging bool | ||
| 210 | kind core.PickKind | 238 | kind core.PickKind | ||
| 211 | grid bool | 239 | grid bool | ||
| 240 | + | live bool | |||
| 212 | status core.PickStatus | 241 | status core.PickStatus | ||
| 213 | fx uint32 | 242 | 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.grid | 292 | 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 = endAccepted | 308 | 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 < n | 371 | 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.mode | 380 | + | 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 = endAccepted | 391 | m.end = endAccepted | ||
| 357 | - | m.sess.accept(r, m.mode) | 392 | + | m.sess.accept(r, m.mode, m.frozen()) |
| 358 | return | 393 | return | ||
| 359 | case core.PickCanceled: | 394 | case core.PickCanceled: | ||
| 360 | m.end = endCanceled | 395 | m.end = endCanceled | ||
M internal/adapters/selection/scene.go +20 -4
| @@ -29,9 +29,18 @@ const ( | |||
| 29 | 29 | // recording indicator out of the video, so none is drawn. | |
| 30 | 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 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 { | |
| 35 | 44 | action, name, other := "capture", "shot", "rec" | |
| 36 | 45 | if mode == ports.Record { | |
| 37 | 46 | action, name, other = "record", "rec", "shot" | |
| @@ -47,7 +56,14 @@ func footerText(mode ports.Mode, kind core.PickKind, workspace, toggle, noIndica | |||
| 47 | 56 | if workspace { | |
| 48 | 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 | 67 | switch kind { | |
| 52 | 68 | case core.PickMonitor, core.PickWorkspace: | |
| 53 | 69 | 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 workspace | 32 | + | // 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) { | |||
| 323 | 323 | func TestSessionFirstDecisionWins(t *testing.T) { | |
| 324 | 324 | sess, ctx := testSession() | |
| 325 | 325 | 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) | |
| 328 | 328 | sess.abort() | |
| 329 | 329 | assert.Error(t, ctx.Err()) | |
| 330 | 330 | res, ok, err := sess.outcome(context.Background()) | |
| @@ -365,7 +365,7 @@ func TestSessionRunEnded(t *testing.T) { | |||
| 365 | 365 | boom := errors.New("boom") | |
| 366 | 366 | ||
| 367 | 367 | sess, _ := testSession() | |
| 368 | - | sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot) | |
| 368 | + | sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot, false) | |
| 369 | 369 | sess.runEnded(context.Background(), "DP-1", context.Canceled) // our own cancel | |
| 370 | 370 | _, ok, err := sess.outcome(context.Background()) | |
| 371 | 371 | assert.True(t, ok) | |
| @@ -380,7 +380,7 @@ func TestSessionRunEnded(t *testing.T) { | |||
| 380 | 380 | // GO012: a real failure joined with Canceled survives whole, including the | |
| 381 | 381 | // output name, after our own cancel. | |
| 382 | 382 | sess, _ = testSession() | |
| 383 | - | sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot) | |
| 383 | + | sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot, false) | |
| 384 | 384 | real := fmt.Errorf("surface close: %w", context.Canceled) | |
| 385 | 385 | sess.runEnded(context.Background(), "DP-2", errors.Join(context.Canceled, real, boom)) | |
| 386 | 386 | _, ok, err = sess.outcome(context.Background()) | |
| @@ -396,7 +396,7 @@ func TestSessionRunEnded(t *testing.T) { | |||
| 396 | 396 | assert.ErrorContains(t, err, "output DP-1") | |
| 397 | 397 | ||
| 398 | 398 | sess, _ = testSession() | |
| 399 | - | sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot) | |
| 399 | + | sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot, false) | |
| 400 | 400 | sess.runEnded(context.Background(), "DP-1", boom) // a later overlay failing rejects the pick | |
| 401 | 401 | _, ok, err = sess.outcome(context.Background()) | |
| 402 | 402 | assert.False(t, ok || err == nil) | |
| @@ -669,11 +669,11 @@ func TestLabelContentHoldsMonospaceLine(t *testing.T) { | |||
| 669 | 669 | } | |
| 670 | 670 | ||
| 671 | 671 | 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") | |
| 677 | 677 | assert.Equal(t, "DP-1 · 1/2 · keys 1-2 pick monitor", headerText(testOutputs, 0)) | |
| 678 | 678 | assert.Equal(t, "DP-1", headerText(testOutputs[:1], 0)) | |
| 679 | 679 | 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 cancel | 369 | 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 the | 380 | // 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 pick | 400 | 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 +19 -2
| @@ -55,6 +55,7 @@ type Selector struct { | |||
| 55 | 55 | toggle bool | |
| 56 | 56 | noIndicator bool | |
| 57 | 57 | 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 | 61 | var _ ports.Selector = (*Selector)(nil) | |
| @@ -123,6 +124,11 @@ func backdropImage(f ports.Frame) *image.NRGBA { | |||
| 123 | 124 | 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 | 132 | // AllowToggle lets Tab switch between screenshot and record selection. The | |
| 127 | 133 | // starting mode is New's; the target selection is kept across a switch. | |
| 128 | 134 | func (s *Selector) AllowToggle() *Selector { | |
| @@ -163,7 +169,11 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se | |||
| 163 | 169 | child, cancel := context.WithCancel(ctx) | |
| 164 | 170 | defer cancel() | |
| 165 | 171 | sess := newSession(cancel) | |
| 166 | - | var wg sync.WaitGroup | |
| 172 | + | sess.canFreeze = len(s.backdrops) > 0 | |
| 173 | + | s.frozen = false | |
| 174 | + | // Build every model and the complete wakes slice before any overlay | |
| 175 | + | // starts: a running overlay reads sess.wakes on Tab or F. | |
| 176 | + | models := make([]*model, len(outputs)) | |
| 167 | 177 | for i := range outputs { | |
| 168 | 178 | m := newModel(s.mode, outputs, i, sess, s.grid) | |
| 169 | 179 | m.setWorkspaces(s.workspaces) | |
| @@ -174,9 +184,15 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se | |||
| 174 | 184 | m.wake, m.anim = make(chan struct{}, 1), newAnimator() | |
| 175 | 185 | if s.toggle { | |
| 176 | 186 | m.toggle = true | |
| 187 | + | } | |
| 188 | + | if s.toggle || sess.canFreeze { | |
| 177 | 189 | sess.wakes = append(sess.wakes, m.wake) | |
| 178 | 190 | } | |
| 179 | - | m.footerText = footerText(m.mode, m.footerKind, m.footerWorkspace, m.toggle, m.noIndicator) | |
| 191 | + | m.footerText = footerText(m.mode, m.footerKind, m.footerWorkspace, m.toggle, m.noIndicator, m.freezeHint()) | |
| 192 | + | models[i] = m | |
| 193 | + | } | |
| 194 | + | var wg sync.WaitGroup | |
| 195 | + | for i, m := range models { | |
| 180 | 196 | wg.Add(1) | |
| 181 | 197 | go func() { | |
| 182 | 198 | defer wg.Done() | |
| @@ -195,6 +211,7 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se | |||
| 195 | 211 | if err != nil || !ok { | |
| 196 | 212 | return ports.Selection{}, false, err | |
| 197 | 213 | } | |
| 214 | + | s.frozen = sess.acceptedFrozen() | |
| 198 | 215 | return s.selectionFor(sess.acceptedMode(), result), true, nil | |
| 199 | 216 | } | |
| 200 | 217 | ||
| @@ -55,6 +55,7 @@ type Selector struct { | |||||
| 55 | toggle bool | 55 | toggle bool | ||
| 56 | noIndicator bool | 56 | noIndicator bool | ||
| 57 | backdrops map[uint32]image.Image // frozen screens by output ID | 57 | 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 img | 124 | 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. The | 132 | // 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,7 +169,11 @@ 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) | ||
| 166 | - | var wg sync.WaitGroup | 172 | + | sess.canFreeze = len(s.backdrops) > 0 |
| 173 | + | s.frozen = false | |||
| 174 | + | // Build every model and the complete wakes slice before any overlay | |||
| 175 | + | // starts: a running overlay reads sess.wakes on Tab or F. | |||
| 176 | + | models := make([]*model, len(outputs)) | |||
| 167 | for i := range outputs { | 177 | for i := range outputs { | ||
| 168 | m := newModel(s.mode, outputs, i, sess, s.grid) | 178 | m := newModel(s.mode, outputs, i, sess, s.grid) | ||
| 169 | m.setWorkspaces(s.workspaces) | 179 | m.setWorkspaces(s.workspaces) | ||
| @@ -174,9 +184,15 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se | |||||
| 174 | m.wake, m.anim = make(chan struct{}, 1), newAnimator() | 184 | m.wake, m.anim = make(chan struct{}, 1), newAnimator() | ||
| 175 | if s.toggle { | 185 | if s.toggle { | ||
| 176 | m.toggle = true | 186 | m.toggle = true | ||
| 187 | + | } | |||
| 188 | + | if s.toggle || sess.canFreeze { | |||
| 177 | sess.wakes = append(sess.wakes, m.wake) | 189 | sess.wakes = append(sess.wakes, m.wake) | ||
| 178 | } | 190 | } | ||
| 179 | - | m.footerText = footerText(m.mode, m.footerKind, m.footerWorkspace, m.toggle, m.noIndicator) | 191 | + | m.footerText = footerText(m.mode, m.footerKind, m.footerWorkspace, m.toggle, m.noIndicator, m.freezeHint()) |
| 192 | + | models[i] = m | |||
| 193 | + | } | |||
| 194 | + | var wg sync.WaitGroup | |||
| 195 | + | for i, m := range models { | |||
| 180 | wg.Add(1) | 196 | wg.Add(1) | ||
| 181 | go func() { | 197 | go func() { | ||
| 182 | defer wg.Done() | 198 | defer wg.Done() | ||
| @@ -195,6 +211,7 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se | |||||
| 195 | if err != nil || !ok { | 211 | if err != nil || !ok { | ||
| 196 | return ports.Selection{}, false, err | 212 | return ports.Selection{}, false, err | ||
| 197 | } | 213 | } | ||
| 214 | + | s.frozen = sess.acceptedFrozen() | |||
| 198 | return s.selectionFor(sess.acceptedMode(), result), true, nil | 215 | return s.selectionFor(sess.acceptedMode(), result), true, nil | ||
| 199 | } | 216 | } | ||
| 200 | 217 | ||||
M internal/adapters/selection/session.go +28 -6
| @@ -25,12 +25,17 @@ type session struct { | |||
| 25 | 25 | // after one. wakes is set before the overlays start and never changes. | |
| 26 | 26 | toggles atomic.Uint32 | |
| 27 | 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 | 33 | mu sync.Mutex | |
| 30 | 34 | decided bool | |
| 31 | 35 | accepted bool | |
| 32 | 36 | result core.PickResult | |
| 33 | 37 | mode ports.Mode // the mode the confirming overlay showed | |
| 38 | + | frozen bool // whether the confirming overlay showed the frozen screen | |
| 34 | 39 | errs []error | |
| 35 | 40 | } | |
| 36 | 41 | ||
| @@ -39,11 +44,21 @@ func newSession(cancel context.CancelFunc) *session { return &session{cancel: ca | |||
| 39 | 44 | // toggle flips the capture mode for every overlay and asks each to redraw. | |
| 40 | 45 | // After the first decision it does nothing, so a late Tab on another overlay | |
| 41 | 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 | 58 | s.mu.Lock() | |
| 44 | 59 | decided := s.decided | |
| 45 | 60 | if !decided { | |
| 46 | - | s.toggles.Add(1) | |
| 61 | + | n.Add(1) | |
| 47 | 62 | } | |
| 48 | 63 | s.mu.Unlock() | |
| 49 | 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, 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) { | |
| 63 | 78 | s.mu.Lock() | |
| 64 | 79 | 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 | |
| 66 | 81 | } | |
| 67 | 82 | s.mu.Unlock() | |
| 68 | 83 | s.cancel() | |
| @@ -124,3 +139,10 @@ func (s *session) acceptedMode() ports.Mode { | |||
| 124 | 139 | defer s.mu.Unlock() | |
| 125 | 140 | 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 | + | } | |
| @@ -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.Uint32 | 26 | 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.Mutex | 33 | mu sync.Mutex | ||
| 30 | decided bool | 34 | decided bool | ||
| 31 | accepted bool | 35 | accepted bool | ||
| 32 | result core.PickResult | 36 | result core.PickResult | ||
| 33 | mode ports.Mode // the mode the confirming overlay showed | 37 | mode ports.Mode // the mode the confirming overlay showed | ||
| 38 | + | frozen bool // whether the confirming overlay showed the frozen screen | |||
| 34 | errs []error | 39 | 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 overlay | 45 | // 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.decided | 59 | 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, and | 75 | + | // 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, mode | 80 | + | 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.mode | 140 | 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) { | |||
| 70 | 70 | } | |
| 71 | 71 | } | |
| 72 | 72 | ||
| 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. | |
| 75 | 75 | 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 | |
| 77 | 77 | } | |
| 78 | 78 | ||
| 79 | 79 | 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 recording | 73 | + | // 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 > 0 | 76 | + | 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 | |||
| 56 | 56 | if err != nil || !accepted { | |
| 57 | 57 | return ports.Selection{}, false, err | |
| 58 | 58 | } | |
| 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 | |
| 61 | 61 | } else { | |
| 62 | 62 | source.Thaw(sel.Target.OutputID) // only the chosen output is captured | |
| 63 | 63 | } | |
| @@ -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, err | 57 | 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 screen | 60 | + | 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 captured | 62 | source.Thaw(sel.Target.OutputID) // only the chosen output is captured | ||
| 63 | } | 63 | } | ||