detached 35799ea

feat(selector): freeze the screen while a screenshot is selected

A new selector.freeze setting, on by default, captures every output when
shot or the all-in-one selector opens. The selector draws that still copy
behind its overlay, and the screenshot is cut from it, so the result is
what was on screen when the selector opened. Recording selects and records
the live screen: rec never freezes and all-in-one thaws when it records.
Rotated outputs and outputs over 4096 pixels per side stay live. Freezing
drops the live capture session and its buffers, and only the chosen
output's copy is kept after the selection.

Also bumps nefergui to v0.7.2, which uploads images in frames without new
glyphs.
commit
35799eaaf8e7dfdcb7513615ae5c77b4741283e5
author
brice · · 2026-10-09 10:57 UTC
parent
0b39b0c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +526 -27
M README.md +5 -0
@@ -43,6 +43,7 @@ On NeferWL a workspace recording follows that workspace, even when you switch to
4343 ## Features
4444
4545 - **One shortcut.** Screenshot and recording share one selector; the same key stops a recording.
46+- **Frozen screenshots.** The screenshot selector shows a still copy of the screen, and the screenshot is exactly that copy.
4647 - **Clean video.** With NeferWL the compositor keeps the border and HUD out of the recording.
4748 - **Clipboard.** Screenshots can be saved, copied, or both.
4849 - **Small footprint.** One capture in flight, reused buffers, no redraw while idle. Allocation tests guard the hot paths.
@@ -54,6 +55,7 @@ On NeferWL a workspace recording follows that workspace, even when you switch to
5455
5556 ```ini
5657 selector.grid = on
58+selector.freeze = on
5759 screenshot.dir = ~/Pictures/Screenshots
5860 screenshot.output = file+clipboard
5961 video.dir = ~/Videos
@@ -62,10 +64,13 @@ video.dir = ~/Videos
6264 | Key | Values | Default |
6365 | --- | --- | --- |
6466 | `selector.grid` | `on`, `off` | `on` |
67+| `selector.freeze` | `on`, `off` | `on` |
6568 | `screenshot.dir` | absolute or `~/` path | `$XDG_PICTURES_DIR/Screenshots` |
6669 | `screenshot.output` | `file`, `file+clipboard`, `clipboard` | `file` |
6770 | `video.dir` | absolute or `~/` path | `$XDG_VIDEOS_DIR` |
6871
72+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.
73+
6974 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.
7075
7176 ## Commands
@@ -43,6 +43,7 @@ On NeferWL a workspace recording follows that workspace, even when you switch to
43 ## Features43 ## Features
44 44
45 - **One shortcut.** Screenshot and recording share one selector; the same key stops a recording.45 - **One shortcut.** Screenshot and recording share one selector; the same key stops a recording.
46+- **Frozen screenshots.** The screenshot selector shows a still copy of the screen, and the screenshot is exactly that copy.
46 - **Clean video.** With NeferWL the compositor keeps the border and HUD out of the recording.47 - **Clean video.** With NeferWL the compositor keeps the border and HUD out of the recording.
47 - **Clipboard.** Screenshots can be saved, copied, or both.48 - **Clipboard.** Screenshots can be saved, copied, or both.
48 - **Small footprint.** One capture in flight, reused buffers, no redraw while idle. Allocation tests guard the hot paths.49 - **Small footprint.** One capture in flight, reused buffers, no redraw while idle. Allocation tests guard the hot paths.
@@ -54,6 +55,7 @@ On NeferWL a workspace recording follows that workspace, even when you switch to
54 55
55 ```ini56 ```ini
56 selector.grid = on57 selector.grid = on
58+selector.freeze = on
57 screenshot.dir = ~/Pictures/Screenshots59 screenshot.dir = ~/Pictures/Screenshots
58 screenshot.output = file+clipboard60 screenshot.output = file+clipboard
59 video.dir = ~/Videos61 video.dir = ~/Videos
@@ -62,10 +64,13 @@ video.dir = ~/Videos
62 | Key | Values | Default |64 | Key | Values | Default |
63 | --- | --- | --- |65 | --- | --- | --- |
64 | `selector.grid` | `on`, `off` | `on` |66 | `selector.grid` | `on`, `off` | `on` |
67+| `selector.freeze` | `on`, `off` | `on` |
65 | `screenshot.dir` | absolute or `~/` path | `$XDG_PICTURES_DIR/Screenshots` |68 | `screenshot.dir` | absolute or `~/` path | `$XDG_PICTURES_DIR/Screenshots` |
66 | `screenshot.output` | `file`, `file+clipboard`, `clipboard` | `file` |69 | `screenshot.output` | `file`, `file+clipboard`, `clipboard` | `file` |
67 | `video.dir` | absolute or `~/` path | `$XDG_VIDEOS_DIR` |70 | `video.dir` | absolute or `~/` path | `$XDG_VIDEOS_DIR` |
68 71
72+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.
73+
69 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.74 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.
70 75
71 ## Commands76 ## Commands
M go.mod +1 -1
@@ -5,7 +5,7 @@ go 1.27
55 require (
66 github.com/bnema/go-wayland-bindings v0.1.0
77 github.com/bnema/neferclient v0.3.0
8- github.com/bnema/nefergui v0.7.1
8+ github.com/bnema/nefergui v0.7.2
99 github.com/bnema/purego-libwayland v0.8.0
1010 github.com/bnema/wlturbo v0.6.2
1111 github.com/bnema/zerowrap v1.4.1
@@ -5,7 +5,7 @@ go 1.27
5 require (5 require (
6 github.com/bnema/go-wayland-bindings v0.1.06 github.com/bnema/go-wayland-bindings v0.1.0
7 github.com/bnema/neferclient v0.3.07 github.com/bnema/neferclient v0.3.0
8- github.com/bnema/nefergui v0.7.18+ github.com/bnema/nefergui v0.7.2
9 github.com/bnema/purego-libwayland v0.8.09 github.com/bnema/purego-libwayland v0.8.0
10 github.com/bnema/wlturbo v0.6.210 github.com/bnema/wlturbo v0.6.2
11 github.com/bnema/zerowrap v1.4.111 github.com/bnema/zerowrap v1.4.1
M go.sum +2 -2
@@ -2,8 +2,8 @@ github.com/bnema/go-wayland-bindings v0.1.0 h1:mi2vNa8BB5ZCo/hq/9d6Af01yhqY6QXAZ
22 github.com/bnema/go-wayland-bindings v0.1.0/go.mod h1:eWOgjp9vzZEqxm059q1G9F5NXplmv9UAmrDJiivxVjA=
33 github.com/bnema/neferclient v0.3.0 h1:sEBphi11YpgMOPYHW1OcDtQYA8aD3WSaFILnb8fdp90=
44 github.com/bnema/neferclient v0.3.0/go.mod h1:CzZflJIvinLP8u0rYPjdmAnZjl4Ec2cvdnEGvqFO4Yw=
5-github.com/bnema/nefergui v0.7.1 h1:e9Ib7Z1mHRNaxd5cEaNEUcEEvvTWYR9VDCqiYmOdiBI=
6-github.com/bnema/nefergui v0.7.1/go.mod h1:G2iRRleA9bjSohU1RySwRqqxS0BbkPziK407HkuyEBc=
5+github.com/bnema/nefergui v0.7.2 h1:9gnYhVlMxpBp+cVE+9Uursc/HwPXe/Li16+m4HL/gkU=
6+github.com/bnema/nefergui v0.7.2/go.mod h1:G2iRRleA9bjSohU1RySwRqqxS0BbkPziK407HkuyEBc=
77 github.com/bnema/purego v0.13.0-bnema.1 h1:fKRdztnvTmRqAjANMoN+utEIH73dvPqVPJh2iZCCAO8=
88 github.com/bnema/purego v0.13.0-bnema.1/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM=
99 github.com/bnema/purego-libwayland v0.8.0 h1:e82G1nMre4HvnR1Q8TvdnVNITUStSc+hD3eMwkYjxQY=
@@ -2,8 +2,8 @@ github.com/bnema/go-wayland-bindings v0.1.0 h1:mi2vNa8BB5ZCo/hq/9d6Af01yhqY6QXAZ
2 github.com/bnema/go-wayland-bindings v0.1.0/go.mod h1:eWOgjp9vzZEqxm059q1G9F5NXplmv9UAmrDJiivxVjA=2 github.com/bnema/go-wayland-bindings v0.1.0/go.mod h1:eWOgjp9vzZEqxm059q1G9F5NXplmv9UAmrDJiivxVjA=
3 github.com/bnema/neferclient v0.3.0 h1:sEBphi11YpgMOPYHW1OcDtQYA8aD3WSaFILnb8fdp90=3 github.com/bnema/neferclient v0.3.0 h1:sEBphi11YpgMOPYHW1OcDtQYA8aD3WSaFILnb8fdp90=
4 github.com/bnema/neferclient v0.3.0/go.mod h1:CzZflJIvinLP8u0rYPjdmAnZjl4Ec2cvdnEGvqFO4Yw=4 github.com/bnema/neferclient v0.3.0/go.mod h1:CzZflJIvinLP8u0rYPjdmAnZjl4Ec2cvdnEGvqFO4Yw=
5-github.com/bnema/nefergui v0.7.1 h1:e9Ib7Z1mHRNaxd5cEaNEUcEEvvTWYR9VDCqiYmOdiBI=5+github.com/bnema/nefergui v0.7.2 h1:9gnYhVlMxpBp+cVE+9Uursc/HwPXe/Li16+m4HL/gkU=
6-github.com/bnema/nefergui v0.7.1/go.mod h1:G2iRRleA9bjSohU1RySwRqqxS0BbkPziK407HkuyEBc=6+github.com/bnema/nefergui v0.7.2/go.mod h1:G2iRRleA9bjSohU1RySwRqqxS0BbkPziK407HkuyEBc=
7 github.com/bnema/purego v0.13.0-bnema.1 h1:fKRdztnvTmRqAjANMoN+utEIH73dvPqVPJh2iZCCAO8=7 github.com/bnema/purego v0.13.0-bnema.1 h1:fKRdztnvTmRqAjANMoN+utEIH73dvPqVPJh2iZCCAO8=
8 github.com/bnema/purego v0.13.0-bnema.1/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM=8 github.com/bnema/purego v0.13.0-bnema.1/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM=
9 github.com/bnema/purego-libwayland v0.8.0 h1:e82G1nMre4HvnR1Q8TvdnVNITUStSc+hD3eMwkYjxQY=9 github.com/bnema/purego-libwayland v0.8.0 h1:e82G1nMre4HvnR1Q8TvdnVNITUStSc+hD3eMwkYjxQY=
M internal/adapters/cli/cli.go +1 -0
@@ -83,6 +83,7 @@ Selector keys and mouse:
8383
8484 Config: $XDG_CONFIG_HOME/nefercap/config (default ~/.config/nefercap/config)
8585 selector.grid = off hide guides on startup; G still toggles them
86+ selector.freeze = off screenshot selector shows the live screen (default on: still copy)
8687 screenshot.dir = ~/Pictures/Shots absolute or ~/ path; default XDG Pictures/Screenshots
8788 screenshot.output = file+clipboard file (default), file+clipboard or clipboard
8889 video.dir = ~/Videos absolute or ~/ path; default XDG Videos
@@ -83,6 +83,7 @@ Selector keys and mouse:
83 83
84 Config: $XDG_CONFIG_HOME/nefercap/config (default ~/.config/nefercap/config)84 Config: $XDG_CONFIG_HOME/nefercap/config (default ~/.config/nefercap/config)
85 selector.grid = off hide guides on startup; G still toggles them85 selector.grid = off hide guides on startup; G still toggles them
86+ selector.freeze = off screenshot selector shows the live screen (default on: still copy)
86 screenshot.dir = ~/Pictures/Shots absolute or ~/ path; default XDG Pictures/Screenshots87 screenshot.dir = ~/Pictures/Shots absolute or ~/ path; default XDG Pictures/Screenshots
87 screenshot.output = file+clipboard file (default), file+clipboard or clipboard88 screenshot.output = file+clipboard file (default), file+clipboard or clipboard
88 video.dir = ~/Videos absolute or ~/ path; default XDG Videos89 video.dir = ~/Videos absolute or ~/ path; default XDG Videos
M internal/adapters/config/config.go +24 -16
@@ -27,22 +27,25 @@ const (
2727 )
2828
2929 // Settings holds the persistent configuration. G toggles Grid for the current
30-// selection without changing the saved setting. The directories are absolute,
31-// or empty for the XDG default.
30+// selection without changing the saved setting. Freeze shows a still copy of
31+// the screen while a screenshot is selected, and captures that copy. The
32+// directories are absolute, or empty for the XDG default.
3233 type Settings struct {
3334 Grid bool
35+ Freeze bool
3436 ScreenshotDir string
3537 VideoDir string
3638 Output ScreenshotOutput
3739 }
3840
39-const knownKeys = "selector.grid, screenshot.dir, screenshot.output, video.dir"
41+const knownKeys = "selector.grid, selector.freeze, screenshot.dir, screenshot.output, video.dir"
4042
4143 // Load reads $XDG_CONFIG_HOME/nefercap/config (or ~/.config/nefercap/config).
42-// An absent file or setting enables the full monitor grid. Invalid settings
43-// fail explicitly rather than silently changing the requested appearance.
44+// An absent file or setting enables the full monitor grid and the frozen
45+// screenshot selector. Invalid settings fail explicitly rather than silently
46+// changing the requested appearance.
4447 func Load() (Settings, error) {
45- settings := Settings{Grid: true, Output: OutputFile}
48+ settings := Settings{Grid: true, Freeze: true, Output: OutputFile}
4649 root := os.Getenv("XDG_CONFIG_HOME")
4750 if !filepath.IsAbs(root) {
4851 home, err := os.UserHomeDir()
@@ -75,7 +78,7 @@ func Load() (Settings, error) {
7578 if len(data) > maxBytes {
7679 return Settings{}, fmt.Errorf("config %s exceeds %d bytes", path, maxBytes)
7780 }
78- var seen [4]bool
81+ var seen [len(keys)]bool
7982 for index, raw := range strings.Split(strings.TrimPrefix(string(data), "\ufeff"), "\n") {
8083 line := strings.TrimSpace(raw)
8184 if line == "" || strings.HasPrefix(line, "#") {
@@ -98,19 +101,14 @@ func Load() (Settings, error) {
98101 return settings, nil
99102 }
100103
101-var keys = [...]string{"selector.grid", "screenshot.dir", "screenshot.output", "video.dir"}
104+var keys = [...]string{"selector.grid", "selector.freeze", "screenshot.dir", "screenshot.output", "video.dir"}
102105
103106 func apply(s *Settings, key, value string) (err error) {
104107 switch key {
105108 case "selector.grid":
106- switch value {
107- case "on":
108- s.Grid = true
109- case "off":
110- s.Grid = false
111- default:
112- return errors.New("selector.grid must be on or off")
113- }
109+ s.Grid, err = onOff(key, value)
110+ case "selector.freeze":
111+ s.Freeze, err = onOff(key, value)
114112 case "screenshot.output":
115113 switch out := ScreenshotOutput(value); out {
116114 case OutputFile, OutputFileClipboard, OutputClipboard:
@@ -126,6 +124,16 @@ func apply(s *Settings, key, value string) (err error) {
126124 return err
127125 }
128126
127+func onOff(key, value string) (bool, error) {
128+ switch value {
129+ case "on":
130+ return true, nil
131+ case "off":
132+ return false, nil
133+ }
134+ return false, fmt.Errorf("%s must be on or off", key)
135+}
136+
129137 // dir accepts an absolute path or one starting with ~/ (the home directory).
130138 func dir(key, value string) (string, error) {
131139 if value == "" || len(value) > maxPath || strings.IndexByte(value, 0) >= 0 {
@@ -27,22 +27,25 @@ const (
27 )27 )
28 28
29 // Settings holds the persistent configuration. G toggles Grid for the current29 // Settings holds the persistent configuration. G toggles Grid for the current
30-// selection without changing the saved setting. The directories are absolute,30+// selection without changing the saved setting. Freeze shows a still copy of
31-// or empty for the XDG default.31+// the screen while a screenshot is selected, and captures that copy. The
32+// directories are absolute, or empty for the XDG default.
32 type Settings struct {33 type Settings struct {
33 Grid bool34 Grid bool
35+ Freeze bool
34 ScreenshotDir string36 ScreenshotDir string
35 VideoDir string37 VideoDir string
36 Output ScreenshotOutput38 Output ScreenshotOutput
37 }39 }
38 40
39-const knownKeys = "selector.grid, screenshot.dir, screenshot.output, video.dir"41+const knownKeys = "selector.grid, selector.freeze, screenshot.dir, screenshot.output, video.dir"
40 42
41 // Load reads $XDG_CONFIG_HOME/nefercap/config (or ~/.config/nefercap/config).43 // Load reads $XDG_CONFIG_HOME/nefercap/config (or ~/.config/nefercap/config).
42-// An absent file or setting enables the full monitor grid. Invalid settings44+// An absent file or setting enables the full monitor grid and the frozen
43-// fail explicitly rather than silently changing the requested appearance.45+// screenshot selector. Invalid settings fail explicitly rather than silently
46+// changing the requested appearance.
44 func Load() (Settings, error) {47 func Load() (Settings, error) {
45- settings := Settings{Grid: true, Output: OutputFile}48+ settings := Settings{Grid: true, Freeze: true, Output: OutputFile}
46 root := os.Getenv("XDG_CONFIG_HOME")49 root := os.Getenv("XDG_CONFIG_HOME")
47 if !filepath.IsAbs(root) {50 if !filepath.IsAbs(root) {
48 home, err := os.UserHomeDir()51 home, err := os.UserHomeDir()
@@ -75,7 +78,7 @@ func Load() (Settings, error) {
75 if len(data) > maxBytes {78 if len(data) > maxBytes {
76 return Settings{}, fmt.Errorf("config %s exceeds %d bytes", path, maxBytes)79 return Settings{}, fmt.Errorf("config %s exceeds %d bytes", path, maxBytes)
77 }80 }
78- var seen [4]bool81+ var seen [len(keys)]bool
79 for index, raw := range strings.Split(strings.TrimPrefix(string(data), "\ufeff"), "\n") {82 for index, raw := range strings.Split(strings.TrimPrefix(string(data), "\ufeff"), "\n") {
80 line := strings.TrimSpace(raw)83 line := strings.TrimSpace(raw)
81 if line == "" || strings.HasPrefix(line, "#") {84 if line == "" || strings.HasPrefix(line, "#") {
@@ -98,19 +101,14 @@ func Load() (Settings, error) {
98 return settings, nil101 return settings, nil
99 }102 }
100 103
101-var keys = [...]string{"selector.grid", "screenshot.dir", "screenshot.output", "video.dir"}104+var keys = [...]string{"selector.grid", "selector.freeze", "screenshot.dir", "screenshot.output", "video.dir"}
102 105
103 func apply(s *Settings, key, value string) (err error) {106 func apply(s *Settings, key, value string) (err error) {
104 switch key {107 switch key {
105 case "selector.grid":108 case "selector.grid":
106- switch value {109+ s.Grid, err = onOff(key, value)
107- case "on":110+ case "selector.freeze":
108- s.Grid = true111+ s.Freeze, err = onOff(key, value)
109- case "off":
110- s.Grid = false
111- default:
112- return errors.New("selector.grid must be on or off")
113- }
114 case "screenshot.output":112 case "screenshot.output":
115 switch out := ScreenshotOutput(value); out {113 switch out := ScreenshotOutput(value); out {
116 case OutputFile, OutputFileClipboard, OutputClipboard:114 case OutputFile, OutputFileClipboard, OutputClipboard:
@@ -126,6 +124,16 @@ func apply(s *Settings, key, value string) (err error) {
126 return err124 return err
127 }125 }
128 126
127+func onOff(key, value string) (bool, error) {
128+ switch value {
129+ case "on":
130+ return true, nil
131+ case "off":
132+ return false, nil
133+ }
134+ return false, fmt.Errorf("%s must be on or off", key)
135+}
136+
129 // dir accepts an absolute path or one starting with ~/ (the home directory).137 // dir accepts an absolute path or one starting with ~/ (the home directory).
130 func dir(key, value string) (string, error) {138 func dir(key, value string) (string, error) {
131 if value == "" || len(value) > maxPath || strings.IndexByte(value, 0) >= 0 {139 if value == "" || len(value) > maxPath || strings.IndexByte(value, 0) >= 0 {
M internal/adapters/config/config_test.go +23 -2
@@ -61,6 +61,8 @@ func TestLoadRejectsInvalid(t *testing.T) {
6161 {"selector.grid =", "line 1: selector.grid must be on or off"},
6262 {"selector.other = on", "line 1: expected each of"},
6363 {"selector.grid = on\nselector.grid = off", "line 2: expected each of"},
64+ {"selector.freeze = yes", "line 1: selector.freeze must be on or off"},
65+ {"selector.freeze = on\nselector.freeze = on", "line 2: expected each of"},
6466 {strings.Repeat("#", maxBytes+1), "exceeds 4096 bytes"},
6567 } {
6668 t.Run(tc.text[:min(len(tc.text), 40)], func(t *testing.T) {
@@ -131,7 +133,7 @@ func TestLoadPathsAndOutput(t *testing.T) {
131133 saveConfig(t, path, "# c\nscreenshot.dir = ~/Pics/Shots\nvideo.dir=/var/tmp/vids/\nscreenshot.output = file+clipboard\nselector.grid = off\n")
132134 got, err := Load()
133135 require.NoError(t, err)
134- assert.Equal(t, Settings{Grid: false, ScreenshotDir: filepath.Join(home, "Pics", "Shots"), VideoDir: "/var/tmp/vids", Output: OutputFileClipboard}, got)
136+ assert.Equal(t, Settings{Grid: false, Freeze: true, ScreenshotDir: filepath.Join(home, "Pics", "Shots"), VideoDir: "/var/tmp/vids", Output: OutputFileClipboard}, got)
135137
136138 saveConfig(t, path, "screenshot.dir = ~/\nscreenshot.output = clipboard\n")
137139 got, err = Load()
@@ -145,7 +147,26 @@ func TestLoadDefaultsOutputFile(t *testing.T) {
145147 configRoot(t)
146148 got, err := Load()
147149 require.NoError(t, err)
148- assert.Equal(t, Settings{Grid: true, Output: OutputFile}, got)
150+ assert.Equal(t, Settings{Grid: true, Freeze: true, Output: OutputFile}, got)
151+}
152+
153+func TestLoadFreeze(t *testing.T) {
154+ for _, tc := range []struct {
155+ text string
156+ freeze bool
157+ }{
158+ {"", true},
159+ {"selector.freeze = on\n", true},
160+ {"selector.freeze = off\n", false},
161+ } {
162+ t.Run(tc.text, func(t *testing.T) {
163+ saveConfig(t, configRoot(t), tc.text)
164+ got, err := Load()
165+ require.NoError(t, err)
166+ assert.Equal(t, tc.freeze, got.Freeze)
167+ assert.True(t, got.Grid, "independent of selector.grid")
168+ })
169+ }
149170 }
150171
151172 func TestLoadRejectsInvalidNewKeys(t *testing.T) {
@@ -61,6 +61,8 @@ func TestLoadRejectsInvalid(t *testing.T) {
61 {"selector.grid =", "line 1: selector.grid must be on or off"},61 {"selector.grid =", "line 1: selector.grid must be on or off"},
62 {"selector.other = on", "line 1: expected each of"},62 {"selector.other = on", "line 1: expected each of"},
63 {"selector.grid = on\nselector.grid = off", "line 2: expected each of"},63 {"selector.grid = on\nselector.grid = off", "line 2: expected each of"},
64+ {"selector.freeze = yes", "line 1: selector.freeze must be on or off"},
65+ {"selector.freeze = on\nselector.freeze = on", "line 2: expected each of"},
64 {strings.Repeat("#", maxBytes+1), "exceeds 4096 bytes"},66 {strings.Repeat("#", maxBytes+1), "exceeds 4096 bytes"},
65 } {67 } {
66 t.Run(tc.text[:min(len(tc.text), 40)], func(t *testing.T) {68 t.Run(tc.text[:min(len(tc.text), 40)], func(t *testing.T) {
@@ -131,7 +133,7 @@ func TestLoadPathsAndOutput(t *testing.T) {
131 saveConfig(t, path, "# c\nscreenshot.dir = ~/Pics/Shots\nvideo.dir=/var/tmp/vids/\nscreenshot.output = file+clipboard\nselector.grid = off\n")133 saveConfig(t, path, "# c\nscreenshot.dir = ~/Pics/Shots\nvideo.dir=/var/tmp/vids/\nscreenshot.output = file+clipboard\nselector.grid = off\n")
132 got, err := Load()134 got, err := Load()
133 require.NoError(t, err)135 require.NoError(t, err)
134- assert.Equal(t, Settings{Grid: false, ScreenshotDir: filepath.Join(home, "Pics", "Shots"), VideoDir: "/var/tmp/vids", Output: OutputFileClipboard}, got)136+ assert.Equal(t, Settings{Grid: false, Freeze: true, ScreenshotDir: filepath.Join(home, "Pics", "Shots"), VideoDir: "/var/tmp/vids", Output: OutputFileClipboard}, got)
135 137
136 saveConfig(t, path, "screenshot.dir = ~/\nscreenshot.output = clipboard\n")138 saveConfig(t, path, "screenshot.dir = ~/\nscreenshot.output = clipboard\n")
137 got, err = Load()139 got, err = Load()
@@ -145,7 +147,26 @@ func TestLoadDefaultsOutputFile(t *testing.T) {
145 configRoot(t)147 configRoot(t)
146 got, err := Load()148 got, err := Load()
147 require.NoError(t, err)149 require.NoError(t, err)
148- assert.Equal(t, Settings{Grid: true, Output: OutputFile}, got)150+ assert.Equal(t, Settings{Grid: true, Freeze: true, Output: OutputFile}, got)
151+}
152+
153+func TestLoadFreeze(t *testing.T) {
154+ for _, tc := range []struct {
155+ text string
156+ freeze bool
157+ }{
158+ {"", true},
159+ {"selector.freeze = on\n", true},
160+ {"selector.freeze = off\n", false},
161+ } {
162+ t.Run(tc.text, func(t *testing.T) {
163+ saveConfig(t, configRoot(t), tc.text)
164+ got, err := Load()
165+ require.NoError(t, err)
166+ assert.Equal(t, tc.freeze, got.Freeze)
167+ assert.True(t, got.Grid, "independent of selector.grid")
168+ })
169+ }
149 }170 }
150 171
151 func TestLoadRejectsInvalidNewKeys(t *testing.T) {172 func TestLoadRejectsInvalidNewKeys(t *testing.T) {
A internal/adapters/selection/backdrop_test.go +53 -0
@@ -0,0 +1,53 @@
1+package selection
2+
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/nefercap/internal/ports"
11+)
12+
13+func TestBackdropImageIsOpaqueRGBA(t *testing.T) {
14+ // One XRGB8888 pixel is B, G, R, X in memory; the stride has padding.
15+ f := ports.Frame{Pixels: []byte{
16+ 0x10, 0x20, 0x30, 0x00, 0x40, 0x50, 0x60, 0x00, 0xee, 0xee, 0xee, 0xee,
17+ 0x70, 0x80, 0x90, 0x00, 0xa0, 0xb0, 0xc0, 0x00,
18+ }, Width: 2, Height: 2, Stride: 12, Format: ports.XRGB8888}
19+ require.NoError(t, f.Validate())
20+ img := backdropImage(f)
21+ assert.Equal(t, image.Rect(0, 0, 2, 2), img.Bounds())
22+ assert.Equal(t, []byte{0x30, 0x20, 0x10, 0xff, 0x60, 0x50, 0x40, 0xff, 0x90, 0x80, 0x70, 0xff, 0xc0, 0xb0, 0xa0, 0xff}, img.Pix)
23+}
24+
25+func TestWithBackdrops(t *testing.T) {
26+ small := ports.Frame{Pixels: make([]byte, 4*4*4), Width: 4, Height: 4, Stride: 16, Format: ports.XRGB8888}
27+ s, err := New(ports.Screenshot, ports.VideoSettings{}, nil, false).WithBackdrops(map[uint32]ports.Frame{7: small})
28+ require.NoError(t, err)
29+ assert.Contains(t, s.backdrops, uint32(7))
30+
31+ huge := ports.Frame{Width: maxBackdrop + 1, Height: 1, Stride: (maxBackdrop + 1) * 4, Format: ports.XRGB8888}
32+ _, err = New(ports.Screenshot, ports.VideoSettings{}, nil, false).WithBackdrops(map[uint32]ports.Frame{9: huge})
33+ assert.ErrorContains(t, err, "at most 4096", "the caller freezes only outputs CanBackdrop accepts")
34+}
35+
36+func TestCanBackdrop(t *testing.T) {
37+ assert.True(t, CanBackdrop(ports.Output{Width: 3840, Height: 2160}))
38+ assert.True(t, CanBackdrop(ports.Output{Width: 2160, Height: 4096}))
39+ assert.False(t, CanBackdrop(ports.Output{Width: 5120, Height: 2880}))
40+ assert.False(t, CanBackdrop(ports.Output{}), "an unknown mode stays live")
41+}
42+
43+// The frozen screen is drawn under a screenshot selection only: a recording
44+// selects on the live screen.
45+func TestBackdropDrawnOnlyForScreenshots(t *testing.T) {
46+ m := newTestModel(t)
47+ m.backdrop = image.NewNRGBA(image.Rect(0, 0, 1920, 1080))
48+ assert.True(t, drawsBackdrop(m))
49+ m.mode = ports.Record
50+ assert.False(t, drawsBackdrop(m))
51+ m.mode, m.backdrop = ports.Screenshot, nil
52+ assert.False(t, drawsBackdrop(m))
53+}
@@ -0,0 +1,53 @@
1+package selection
2+
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/nefercap/internal/ports"
11+)
12+
13+func TestBackdropImageIsOpaqueRGBA(t *testing.T) {
14+ // One XRGB8888 pixel is B, G, R, X in memory; the stride has padding.
15+ f := ports.Frame{Pixels: []byte{
16+ 0x10, 0x20, 0x30, 0x00, 0x40, 0x50, 0x60, 0x00, 0xee, 0xee, 0xee, 0xee,
17+ 0x70, 0x80, 0x90, 0x00, 0xa0, 0xb0, 0xc0, 0x00,
18+ }, Width: 2, Height: 2, Stride: 12, Format: ports.XRGB8888}
19+ require.NoError(t, f.Validate())
20+ img := backdropImage(f)
21+ assert.Equal(t, image.Rect(0, 0, 2, 2), img.Bounds())
22+ assert.Equal(t, []byte{0x30, 0x20, 0x10, 0xff, 0x60, 0x50, 0x40, 0xff, 0x90, 0x80, 0x70, 0xff, 0xc0, 0xb0, 0xa0, 0xff}, img.Pix)
23+}
24+
25+func TestWithBackdrops(t *testing.T) {
26+ small := ports.Frame{Pixels: make([]byte, 4*4*4), Width: 4, Height: 4, Stride: 16, Format: ports.XRGB8888}
27+ s, err := New(ports.Screenshot, ports.VideoSettings{}, nil, false).WithBackdrops(map[uint32]ports.Frame{7: small})
28+ require.NoError(t, err)
29+ assert.Contains(t, s.backdrops, uint32(7))
30+
31+ huge := ports.Frame{Width: maxBackdrop + 1, Height: 1, Stride: (maxBackdrop + 1) * 4, Format: ports.XRGB8888}
32+ _, err = New(ports.Screenshot, ports.VideoSettings{}, nil, false).WithBackdrops(map[uint32]ports.Frame{9: huge})
33+ assert.ErrorContains(t, err, "at most 4096", "the caller freezes only outputs CanBackdrop accepts")
34+}
35+
36+func TestCanBackdrop(t *testing.T) {
37+ assert.True(t, CanBackdrop(ports.Output{Width: 3840, Height: 2160}))
38+ assert.True(t, CanBackdrop(ports.Output{Width: 2160, Height: 4096}))
39+ assert.False(t, CanBackdrop(ports.Output{Width: 5120, Height: 2880}))
40+ assert.False(t, CanBackdrop(ports.Output{}), "an unknown mode stays live")
41+}
42+
43+// The frozen screen is drawn under a screenshot selection only: a recording
44+// selects on the live screen.
45+func TestBackdropDrawnOnlyForScreenshots(t *testing.T) {
46+ m := newTestModel(t)
47+ m.backdrop = image.NewNRGBA(image.Rect(0, 0, 1920, 1080))
48+ assert.True(t, drawsBackdrop(m))
49+ m.mode = ports.Record
50+ assert.False(t, drawsBackdrop(m))
51+ m.mode, m.backdrop = ports.Screenshot, nil
52+ assert.False(t, drawsBackdrop(m))
53+}
M internal/adapters/selection/model.go +5 -0
@@ -2,6 +2,7 @@ package selection
22
33 import (
44 "fmt"
5+ "image"
56 "math"
67 "strconv"
78
@@ -51,6 +52,10 @@ type model struct {
5152 end endKind
5253 sess *session // shared first-decision record; the only cross-overlay state
5354
55+ // backdrop is the frozen screen drawn under everything, nil for a live
56+ // (transparent) overlay. It is never modified after the overlay starts.
57+ backdrop image.Image
58+
5459 workspace ports.Workspace // zero ID: none
5560 header string // static per surface
5661 footerText string
@@ -2,6 +2,7 @@ package selection
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5+ "image"
5 "math"6 "math"
6 "strconv"7 "strconv"
7 8
@@ -51,6 +52,10 @@ type model struct {
51 end endKind52 end endKind
52 sess *session // shared first-decision record; the only cross-overlay state53 sess *session // shared first-decision record; the only cross-overlay state
53 54
55+ // backdrop is the frozen screen drawn under everything, nil for a live
56+ // (transparent) overlay. It is never modified after the overlay starts.
57+ backdrop image.Image
58+
54 workspace ports.Workspace // zero ID: none59 workspace ports.Workspace // zero ID: none
55 header string // static per surface60 header string // static per surface
56 footerText string61 footerText string
M internal/adapters/selection/selector.go +41 -0
@@ -18,6 +18,7 @@ import (
1818 _ "embed"
1919 "errors"
2020 "fmt"
21+ "image"
2122 "sync"
2223
2324 "github.com/bnema/neferclient"
@@ -53,6 +54,7 @@ type Selector struct {
5354 grid bool
5455 toggle bool
5556 noIndicator bool
57+ backdrops map[uint32]image.Image // frozen screens by output ID
5658 }
5759
5860 var _ ports.Selector = (*Selector)(nil)
@@ -84,6 +86,43 @@ func (s *Selector) WithCapabilities(c ports.Capabilities) *Selector {
8486 return s
8587 }
8688
89+// maxBackdrop is the largest side NeferGUI accepts for one image.
90+const maxBackdrop = 4096
91+
92+// CanBackdrop reports whether an output of this physical mode can show a
93+// frozen frame. A larger output must stay live: freeze only the others.
94+func CanBackdrop(o ports.Output) bool {
95+ return o.Width > 0 && o.Height > 0 && max(o.Width, o.Height) <= maxBackdrop
96+}
97+
98+// WithBackdrops draws each output's frozen frame under its overlay instead of
99+// the live screen; outputs without a frame stay live. A frame larger than
100+// CanBackdrop allows is an error. The frames are copied, so the caller may
101+// release them once this returns.
102+func (s *Selector) WithBackdrops(frames map[uint32]ports.Frame) (*Selector, error) {
103+ s.backdrops = make(map[uint32]image.Image, len(frames))
104+ for id, f := range frames {
105+ if f.Width > maxBackdrop || f.Height > maxBackdrop {
106+ return s, fmt.Errorf("selection: frozen frame of output %d is %dx%d, at most %d per side", id, f.Width, f.Height, maxBackdrop)
107+ }
108+ s.backdrops[id] = backdropImage(f)
109+ }
110+ return s, nil
111+}
112+
113+// backdropImage converts a validated frame to an opaque image. NRGBA is the
114+// format NeferGUI uploads without another conversion.
115+func backdropImage(f ports.Frame) *image.NRGBA {
116+ img := image.NewNRGBA(image.Rect(0, 0, f.Width, f.Height))
117+ for y := range f.Height {
118+ src, dst := f.Row(y), img.Pix[y*img.Stride:]
119+ for x := 0; x+3 < len(src); x += 4 {
120+ dst[x], dst[x+1], dst[x+2], dst[x+3] = src[x+2], src[x+1], src[x], 0xff
121+ }
122+ }
123+ return img
124+}
125+
87126 // AllowToggle lets Tab switch between screenshot and record selection. The
88127 // starting mode is New's; the target selection is kept across a switch.
89128 func (s *Selector) AllowToggle() *Selector {
@@ -129,6 +168,7 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se
129168 m := newModel(s.mode, outputs, i, sess, s.grid)
130169 m.setWorkspaces(s.workspaces)
131170 m.noIndicator = s.noIndicator
171+ m.backdrop = s.backdrops[outputs[i].ID]
132172 if s.toggle {
133173 m.toggle, m.wake = true, make(chan struct{}, 1)
134174 sess.wakes = append(sess.wakes, m.wake)
@@ -141,6 +181,7 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se
141181 }()
142182 }
143183 wg.Wait()
184+ s.backdrops = nil // every overlay ended: nothing draws them any more
144185 result, ok, err := sess.outcome(ctx)
145186 if err != nil || !ok {
146187 return ports.Selection{}, false, err
@@ -18,6 +18,7 @@ import (
18 _ "embed"18 _ "embed"
19 "errors"19 "errors"
20 "fmt"20 "fmt"
21+ "image"
21 "sync"22 "sync"
22 23
23 "github.com/bnema/neferclient"24 "github.com/bnema/neferclient"
@@ -53,6 +54,7 @@ type Selector struct {
53 grid bool54 grid bool
54 toggle bool55 toggle bool
55 noIndicator bool56 noIndicator bool
57+ backdrops map[uint32]image.Image // frozen screens by output ID
56 }58 }
57 59
58 var _ ports.Selector = (*Selector)(nil)60 var _ ports.Selector = (*Selector)(nil)
@@ -84,6 +86,43 @@ func (s *Selector) WithCapabilities(c ports.Capabilities) *Selector {
84 return s86 return s
85 }87 }
86 88
89+// maxBackdrop is the largest side NeferGUI accepts for one image.
90+const maxBackdrop = 4096
91+
92+// CanBackdrop reports whether an output of this physical mode can show a
93+// frozen frame. A larger output must stay live: freeze only the others.
94+func CanBackdrop(o ports.Output) bool {
95+ return o.Width > 0 && o.Height > 0 && max(o.Width, o.Height) <= maxBackdrop
96+}
97+
98+// WithBackdrops draws each output's frozen frame under its overlay instead of
99+// the live screen; outputs without a frame stay live. A frame larger than
100+// CanBackdrop allows is an error. The frames are copied, so the caller may
101+// release them once this returns.
102+func (s *Selector) WithBackdrops(frames map[uint32]ports.Frame) (*Selector, error) {
103+ s.backdrops = make(map[uint32]image.Image, len(frames))
104+ for id, f := range frames {
105+ if f.Width > maxBackdrop || f.Height > maxBackdrop {
106+ return s, fmt.Errorf("selection: frozen frame of output %d is %dx%d, at most %d per side", id, f.Width, f.Height, maxBackdrop)
107+ }
108+ s.backdrops[id] = backdropImage(f)
109+ }
110+ return s, nil
111+}
112+
113+// backdropImage converts a validated frame to an opaque image. NRGBA is the
114+// format NeferGUI uploads without another conversion.
115+func backdropImage(f ports.Frame) *image.NRGBA {
116+ img := image.NewNRGBA(image.Rect(0, 0, f.Width, f.Height))
117+ for y := range f.Height {
118+ src, dst := f.Row(y), img.Pix[y*img.Stride:]
119+ for x := 0; x+3 < len(src); x += 4 {
120+ dst[x], dst[x+1], dst[x+2], dst[x+3] = src[x+2], src[x+1], src[x], 0xff
121+ }
122+ }
123+ return img
124+}
125+
87 // AllowToggle lets Tab switch between screenshot and record selection. The126 // AllowToggle lets Tab switch between screenshot and record selection. The
88 // starting mode is New's; the target selection is kept across a switch.127 // starting mode is New's; the target selection is kept across a switch.
89 func (s *Selector) AllowToggle() *Selector {128 func (s *Selector) AllowToggle() *Selector {
@@ -129,6 +168,7 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se
129 m := newModel(s.mode, outputs, i, sess, s.grid)168 m := newModel(s.mode, outputs, i, sess, s.grid)
130 m.setWorkspaces(s.workspaces)169 m.setWorkspaces(s.workspaces)
131 m.noIndicator = s.noIndicator170 m.noIndicator = s.noIndicator
171+ m.backdrop = s.backdrops[outputs[i].ID]
132 if s.toggle {172 if s.toggle {
133 m.toggle, m.wake = true, make(chan struct{}, 1)173 m.toggle, m.wake = true, make(chan struct{}, 1)
134 sess.wakes = append(sess.wakes, m.wake)174 sess.wakes = append(sess.wakes, m.wake)
@@ -141,6 +181,7 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se
141 }()181 }()
142 }182 }
143 wg.Wait()183 wg.Wait()
184+ s.backdrops = nil // every overlay ended: nothing draws them any more
144 result, ok, err := sess.outcome(ctx)185 result, ok, err := sess.outcome(ctx)
145 if err != nil || !ok {186 if err != nil || !ok {
146 return ports.Selection{}, false, err187 return ports.Selection{}, false, err
M internal/adapters/selection/style.css +4 -1
@@ -1,9 +1,12 @@
1-/* Transparent selector overlay: dimming, thin gray guides, monospace tags. */
1+/* Selector overlay: transparent (or a frozen screen), dimming, thin gray
2+ guides, monospace tags. */
23 app {
34 width: 100%; height: 100%; background-color: transparent;
45 color: #e6edf3; font-family: monospace; font-size: 1rem;
56 }
67 stack.scene { width: 100%; height: 100%; }
8+box.backdrop { overflow: hidden; }
9+box.backdrop image { width: 100%; height: 100%; }
710 box.dim { background-color: rgba(0, 0, 0, 0.5); }
811 box.edge { background-color: #4fd1a5; }
912 box.grid { background-color: #808080; }
@@ -1,9 +1,12 @@
1-/* Transparent selector overlay: dimming, thin gray guides, monospace tags. */1+/* Selector overlay: transparent (or a frozen screen), dimming, thin gray
2+ guides, monospace tags. */
2 app {3 app {
3 width: 100%; height: 100%; background-color: transparent;4 width: 100%; height: 100%; background-color: transparent;
4 color: #e6edf3; font-family: monospace; font-size: 1rem;5 color: #e6edf3; font-family: monospace; font-size: 1rem;
5 }6 }
6 stack.scene { width: 100%; height: 100%; }7 stack.scene { width: 100%; height: 100%; }
8+box.backdrop { overflow: hidden; }
9+box.backdrop image { width: 100%; height: 100%; }
7 box.dim { background-color: rgba(0, 0, 0, 0.5); }10 box.dim { background-color: rgba(0, 0, 0, 0.5); }
8 box.edge { background-color: #4fd1a5; }11 box.edge { background-color: #4fd1a5; }
9 box.grid { background-color: #808080; }12 box.grid { background-color: #808080; }
M internal/adapters/selection/view.go +16 -1
@@ -1,6 +1,10 @@
11 package selection
22
3-import "github.com/bnema/nefergui"
3+import (
4+ "github.com/bnema/nefergui"
5+
6+ "github.com/bnema/nefercap/internal/ports"
7+)
48
59 // view draws the scene. Geometry goes through Node.Rect, so no CSS strings
610 // are built per frame; classes come from the embedded stylesheet. Boxes with no
@@ -11,6 +15,11 @@ func view(f *nefergui.Frame, m *model) {
1115 m.refreshFooter()
1216 root := f.Root(nefergui.Class("root"))
1317 st := root.Stack(nefergui.Class("scene"))
18+ if drawsBackdrop(m) {
19+ // The frozen screen, scaled to fill the logical surface.
20+ box := st.Box(nefergui.Key("backdrop"), nefergui.Class("backdrop")).Rect(0, 0, float64(m.w), float64(m.h))
21+ box.Image(m.backdrop)
22+ }
1423 s := m.scene()
1524 for i, r := range s.dim {
1625 box(st, dimKeys[i], "dim", r)
@@ -35,6 +44,12 @@ func view(f *nefergui.Frame, m *model) {
3544 }
3645 }
3746
47+// drawsBackdrop: a screenshot selection shows the frozen screen; a recording
48+// selects on the live screen.
49+func drawsBackdrop(m *model) bool {
50+ return m.backdrop != nil && m.mode == ports.Screenshot && m.w > 0 && m.h > 0
51+}
52+
3853 type tagged struct {
3954 node nefergui.Node
4055 ok bool
@@ -1,6 +1,10 @@
1 package selection1 package selection
2 2
3-import "github.com/bnema/nefergui"3+import (
4+ "github.com/bnema/nefergui"
5+
6+ "github.com/bnema/nefercap/internal/ports"
7+)
4 8
5 // view draws the scene. Geometry goes through Node.Rect, so no CSS strings9 // view draws the scene. Geometry goes through Node.Rect, so no CSS strings
6 // are built per frame; classes come from the embedded stylesheet. Boxes with no10 // are built per frame; classes come from the embedded stylesheet. Boxes with no
@@ -11,6 +15,11 @@ func view(f *nefergui.Frame, m *model) {
11 m.refreshFooter()15 m.refreshFooter()
12 root := f.Root(nefergui.Class("root"))16 root := f.Root(nefergui.Class("root"))
13 st := root.Stack(nefergui.Class("scene"))17 st := root.Stack(nefergui.Class("scene"))
18+ if drawsBackdrop(m) {
19+ // The frozen screen, scaled to fill the logical surface.
20+ box := st.Box(nefergui.Key("backdrop"), nefergui.Class("backdrop")).Rect(0, 0, float64(m.w), float64(m.h))
21+ box.Image(m.backdrop)
22+ }
14 s := m.scene()23 s := m.scene()
15 for i, r := range s.dim {24 for i, r := range s.dim {
16 box(st, dimKeys[i], "dim", r)25 box(st, dimKeys[i], "dim", r)
@@ -35,6 +44,12 @@ func view(f *nefergui.Frame, m *model) {
35 }44 }
36 }45 }
37 46
47+// drawsBackdrop: a screenshot selection shows the frozen screen; a recording
48+// selects on the live screen.
49+func drawsBackdrop(m *model) bool {
50+ return m.backdrop != nil && m.mode == ports.Screenshot && m.w > 0 && m.h > 0
51+}
52+
38 type tagged struct {53 type tagged struct {
39 node nefergui.Node54 node nefergui.Node
40 ok bool55 ok bool
A internal/adapters/wayland/freeze.go +104 -0
@@ -0,0 +1,104 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "slices"
8+
9+ extworkspace "github.com/bnema/go-wayland-bindings/client/extworkspace"
10+
11+ "github.com/bnema/nefercap/internal/ports"
12+)
13+
14+// Freeze captures each output in ids once and keeps a private copy. Until
15+// Thaw or Close, Capture serves those copies instead of the live screen:
16+// whole outputs, regions and workspaces are cut from what the screen showed at
17+// Freeze. An output that cannot be captured as is (a rotated one) is left out
18+// and stays live. The returned frames, by output ID, own their storage and
19+// stay valid until Thaw or Close; the caller must not modify them.
20+func (s *Source) Freeze(ctx context.Context, ids []uint32) (map[uint32]ports.Frame, error) {
21+ frozen := make(map[uint32]ports.Frame, len(ids))
22+ for _, id := range ids {
23+ f, err := s.Capture(ctx, ports.Target{OutputID: id})
24+ if errors.Is(err, ports.ErrUnsupportedTransform) {
25+ s.log.Info().Uint32("output", id).Msg("rotated output stays live")
26+ continue
27+ }
28+ if err != nil {
29+ return nil, fmt.Errorf("freeze output %d: %w", id, err)
30+ }
31+ frozen[id] = ownedCopy(f)
32+ }
33+ s.opMu.Lock()
34+ defer s.opMu.Unlock()
35+ if s.dead.Load() {
36+ return nil, ports.ErrClosed
37+ }
38+ // Nothing is captured live until Thaw, unless an output stayed live: let
39+ // the compositor drop the session and free the shared buffers, which a
40+ // later live capture creates again.
41+ s.dropSession()
42+ if err := errors.Join(s.releaseBuffer(0, true), s.releaseBuffer(1, true)); err != nil {
43+ s.terminate()
44+ return nil, err
45+ }
46+ s.frozen = frozen
47+ return frozen, nil
48+}
49+
50+// Thaw makes Capture read the live screen again and drops the frozen copies,
51+// except the one of each output in keep.
52+func (s *Source) Thaw(keep ...uint32) {
53+ s.opMu.Lock()
54+ defer s.opMu.Unlock()
55+ for id := range s.frozen {
56+ if !slices.Contains(keep, id) {
57+ delete(s.frozen, id)
58+ }
59+ }
60+ if len(s.frozen) == 0 {
61+ s.frozen = nil
62+ }
63+}
64+
65+// ownedCopy packs f into new storage, without stride padding.
66+func ownedCopy(f ports.Frame) ports.Frame {
67+ row := f.Width * ports.BytesPerPixel
68+ pixels := make([]byte, row*f.Height)
69+ for y := range f.Height {
70+ copy(pixels[y*row:], f.Row(y))
71+ }
72+ return ports.Frame{Pixels: pixels, Width: f.Width, Height: f.Height, Stride: row, Format: f.Format}
73+}
74+
75+// frozenTarget cuts t from the frozen frame of its output; ok is false when
76+// that output is not frozen and is captured live. The caller holds opMu.
77+func (s *Source) frozenTarget(t ports.Target) (_ ports.Frame, ok bool, _ error) {
78+ f, ok := s.frozen[t.OutputID]
79+ out := s.outputs[t.OutputID]
80+ if !ok || out == nil {
81+ return ports.Frame{}, false, nil
82+ }
83+ region := t.Region
84+ if t.WorkspaceID != 0 {
85+ if t.Region != (ports.Region{}) {
86+ return ports.Frame{}, true, ports.ErrInvalidRegion
87+ }
88+ // As live without NeferWL's extension: only the workspace shown on
89+ // this output at Freeze is what the copy shows.
90+ w := s.ws.byID[t.WorkspaceID]
91+ if w == nil || w.removed || w.state&extworkspace.STATE_ACTIVE == 0 || w.group == nil || !w.group.outputs[out.proxy.ID()] {
92+ return ports.Frame{}, true, ports.ErrWorkspaceUnavailable
93+ }
94+ region = workspaceRegion(w, out)
95+ if lw, lh := out.logicalSize(); region == (ports.Region{Width: lw, Height: lh}) {
96+ region = ports.Region{}
97+ }
98+ }
99+ if region == (ports.Region{}) {
100+ return f, true, nil
101+ }
102+ f, err := cropFrame(f, region, out)
103+ return f, true, err
104+}
@@ -0,0 +1,104 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "slices"
8+
9+ extworkspace "github.com/bnema/go-wayland-bindings/client/extworkspace"
10+
11+ "github.com/bnema/nefercap/internal/ports"
12+)
13+
14+// Freeze captures each output in ids once and keeps a private copy. Until
15+// Thaw or Close, Capture serves those copies instead of the live screen:
16+// whole outputs, regions and workspaces are cut from what the screen showed at
17+// Freeze. An output that cannot be captured as is (a rotated one) is left out
18+// and stays live. The returned frames, by output ID, own their storage and
19+// stay valid until Thaw or Close; the caller must not modify them.
20+func (s *Source) Freeze(ctx context.Context, ids []uint32) (map[uint32]ports.Frame, error) {
21+ frozen := make(map[uint32]ports.Frame, len(ids))
22+ for _, id := range ids {
23+ f, err := s.Capture(ctx, ports.Target{OutputID: id})
24+ if errors.Is(err, ports.ErrUnsupportedTransform) {
25+ s.log.Info().Uint32("output", id).Msg("rotated output stays live")
26+ continue
27+ }
28+ if err != nil {
29+ return nil, fmt.Errorf("freeze output %d: %w", id, err)
30+ }
31+ frozen[id] = ownedCopy(f)
32+ }
33+ s.opMu.Lock()
34+ defer s.opMu.Unlock()
35+ if s.dead.Load() {
36+ return nil, ports.ErrClosed
37+ }
38+ // Nothing is captured live until Thaw, unless an output stayed live: let
39+ // the compositor drop the session and free the shared buffers, which a
40+ // later live capture creates again.
41+ s.dropSession()
42+ if err := errors.Join(s.releaseBuffer(0, true), s.releaseBuffer(1, true)); err != nil {
43+ s.terminate()
44+ return nil, err
45+ }
46+ s.frozen = frozen
47+ return frozen, nil
48+}
49+
50+// Thaw makes Capture read the live screen again and drops the frozen copies,
51+// except the one of each output in keep.
52+func (s *Source) Thaw(keep ...uint32) {
53+ s.opMu.Lock()
54+ defer s.opMu.Unlock()
55+ for id := range s.frozen {
56+ if !slices.Contains(keep, id) {
57+ delete(s.frozen, id)
58+ }
59+ }
60+ if len(s.frozen) == 0 {
61+ s.frozen = nil
62+ }
63+}
64+
65+// ownedCopy packs f into new storage, without stride padding.
66+func ownedCopy(f ports.Frame) ports.Frame {
67+ row := f.Width * ports.BytesPerPixel
68+ pixels := make([]byte, row*f.Height)
69+ for y := range f.Height {
70+ copy(pixels[y*row:], f.Row(y))
71+ }
72+ return ports.Frame{Pixels: pixels, Width: f.Width, Height: f.Height, Stride: row, Format: f.Format}
73+}
74+
75+// frozenTarget cuts t from the frozen frame of its output; ok is false when
76+// that output is not frozen and is captured live. The caller holds opMu.
77+func (s *Source) frozenTarget(t ports.Target) (_ ports.Frame, ok bool, _ error) {
78+ f, ok := s.frozen[t.OutputID]
79+ out := s.outputs[t.OutputID]
80+ if !ok || out == nil {
81+ return ports.Frame{}, false, nil
82+ }
83+ region := t.Region
84+ if t.WorkspaceID != 0 {
85+ if t.Region != (ports.Region{}) {
86+ return ports.Frame{}, true, ports.ErrInvalidRegion
87+ }
88+ // As live without NeferWL's extension: only the workspace shown on
89+ // this output at Freeze is what the copy shows.
90+ w := s.ws.byID[t.WorkspaceID]
91+ if w == nil || w.removed || w.state&extworkspace.STATE_ACTIVE == 0 || w.group == nil || !w.group.outputs[out.proxy.ID()] {
92+ return ports.Frame{}, true, ports.ErrWorkspaceUnavailable
93+ }
94+ region = workspaceRegion(w, out)
95+ if lw, lh := out.logicalSize(); region == (ports.Region{Width: lw, Height: lh}) {
96+ region = ports.Region{}
97+ }
98+ }
99+ if region == (ports.Region{}) {
100+ return f, true, nil
101+ }
102+ f, err := cropFrame(f, region, out)
103+ return f, true, err
104+}
A internal/adapters/wayland/freeze_test.go +168 -0
@@ -0,0 +1,168 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/nefercap/internal/adapters/wayland/testserver"
10+ "github.com/bnema/nefercap/internal/ports"
11+)
12+
13+// The test compositor paints the frame sequence into every pixel, so a
14+// frozen capture keeps the sequence of the Freeze while live ones advance.
15+func TestFreezeServesTheFrozenScreen(t *testing.T) {
16+ ctx := context.Background()
17+ s, srv := newSource(t, testserver.Config{Outputs: bigOutput()})
18+ out := firstOutput(t, s)
19+ frozen, err := s.Freeze(ctx, []uint32{out.ID})
20+ require.NoError(t, err)
21+ f := frozen[out.ID]
22+ require.NoError(t, f.Validate())
23+ require.Equal(t, [3]int{64, 48, 64 * 4}, [3]int{f.Width, f.Height, f.Stride})
24+ captures := srv.Stats().Captures
25+
26+ whole, err := s.Capture(ctx, ports.Target{OutputID: out.ID})
27+ require.NoError(t, err)
28+ want := testserver.Pixel(5, 3, 1)
29+ require.Equal(t, want[:], whole.Row(3)[5*4:6*4])
30+
31+ region, err := s.Capture(ctx, ports.Target{OutputID: out.ID, Region: ports.Region{X: 10, Y: 5, Width: 20, Height: 8}})
32+ require.NoError(t, err)
33+ require.Equal(t, [2]int{20, 8}, [2]int{region.Width, region.Height})
34+ want = testserver.Pixel(10, 5, 1)
35+ require.Equal(t, want[:], region.Row(0)[:4])
36+ require.Equal(t, captures, srv.Stats().Captures, "frozen captures read no new frame")
37+
38+ s.Thaw()
39+ live, err := s.Capture(ctx, ports.Target{OutputID: out.ID})
40+ require.NoError(t, err)
41+ want = testserver.Pixel(5, 3, 2)
42+ require.Equal(t, want[:], live.Row(3)[5*4:6*4], "Thaw captures the live screen again")
43+}
44+
45+// Freeze ends the live session and frees the shared buffers: the compositor
46+// stops streaming and only the frozen copy stays in memory.
47+func TestFreezeReleasesLiveResources(t *testing.T) {
48+ s, srv := newSource(t, testserver.Config{Outputs: bigOutput()})
49+ _, err := s.Freeze(context.Background(), []uint32{firstOutput(t, s).ID})
50+ require.NoError(t, err)
51+ require.NoError(t, s.roundtrip())
52+ st := srv.Stats()
53+ require.Equal(t, st.Sessions, st.DestroyedSessions)
54+ require.Equal(t, st.Buffers, st.DestroyedBuffers)
55+ require.Nil(t, s.bufs[0].data)
56+}
57+
58+// A rotated output cannot be frozen: it stays live instead of failing the
59+// selector, and its capture reports the transform as before.
60+func TestFreezeLeavesRotatedOutputsLive(t *testing.T) {
61+ s, _ := newSource(t, testserver.Config{Transform: 1})
62+ out := firstOutput(t, s)
63+ frozen, err := s.Freeze(context.Background(), []uint32{out.ID})
64+ require.NoError(t, err)
65+ require.Empty(t, frozen)
66+ _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID})
67+ require.ErrorIs(t, err, ports.ErrUnsupportedTransform)
68+}
69+
70+// Thaw keeps the copies of the listed outputs; the others capture live.
71+func TestThawKeepsListedOutputs(t *testing.T) {
72+ ctx := context.Background()
73+ s, _ := newSource(t, testserver.Config{Outputs: []testserver.OutputSpec{
74+ {Name: "A-1", Width: 8, Height: 4, Scale: 1}, {Name: "B-2", Width: 8, Height: 4, Scale: 1},
75+ }})
76+ outs, err := s.Outputs(ctx)
77+ require.NoError(t, err)
78+ _, err = s.Freeze(ctx, []uint32{outs[0].ID, outs[1].ID}) // frames 1 and 2
79+ require.NoError(t, err)
80+ s.Thaw(outs[0].ID)
81+ kept, err := s.Capture(ctx, ports.Target{OutputID: outs[0].ID})
82+ require.NoError(t, err)
83+ want := testserver.Pixel(0, 0, 1)
84+ require.Equal(t, want[:], kept.Row(0)[:4])
85+ live, err := s.Capture(ctx, ports.Target{OutputID: outs[1].ID})
86+ require.NoError(t, err)
87+ want = testserver.Pixel(0, 0, 3)
88+ require.Equal(t, want[:], live.Row(0)[:4])
89+}
90+
91+func TestCloseDropsFrozenFrames(t *testing.T) {
92+ s, _ := newSource(t, testserver.Config{})
93+ out := firstOutput(t, s)
94+ _, err := s.Freeze(context.Background(), []uint32{out.ID})
95+ require.NoError(t, err)
96+ require.NoError(t, s.Close())
97+ require.Nil(t, s.frozen)
98+ _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID})
99+ require.ErrorIs(t, err, ports.ErrClosed)
100+}
101+
102+func TestFreezeWorkspaceIsItsRegionOfTheFrozenOutput(t *testing.T) {
103+ cfg := wsConfig(testserver.WorkspaceSpec{ID: "7f3a-2", Name: "b", Output: "TEST-1", Active: true})
104+ cfg.NeferwlSource = true
105+ s, srv := newSource(t, cfg)
106+ srv.SetFrame("b", [4]int32{2, 1, 4, 2})
107+ ws := listWorkspaces(t, s)
108+ require.Len(t, ws, 1)
109+ out := firstOutput(t, s)
110+ _, err := s.Freeze(context.Background(), []uint32{out.ID})
111+ require.NoError(t, err)
112+ f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, WorkspaceID: ws[0].ID})
113+ require.NoError(t, err)
114+ require.Equal(t, [2]int{4, 2}, [2]int{f.Width, f.Height})
115+ want := testserver.Pixel(2, 1, 1)
116+ require.Equal(t, want[:], f.Row(0)[:4])
117+ require.Equal(t, "output", srv.Stats().LastSource, "a frozen workspace is cut from the output frame")
118+
119+ _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, WorkspaceID: 99})
120+ require.ErrorIs(t, err, ports.ErrWorkspaceUnavailable)
121+}
122+
123+// The copy shows the workspace that was displayed: a hidden one is
124+// unavailable, as in a live capture without NeferWL's extension.
125+func TestFreezeHiddenWorkspaceIsUnavailable(t *testing.T) {
126+ s, _ := newSource(t, wsConfig(
127+ testserver.WorkspaceSpec{ID: "1", Name: "a", Output: "TEST-1", Active: true},
128+ testserver.WorkspaceSpec{ID: "2", Name: "b", Output: "TEST-1"},
129+ ))
130+ ws := listWorkspaces(t, s)
131+ require.Len(t, ws, 2)
132+ out := firstOutput(t, s)
133+ _, err := s.Freeze(context.Background(), []uint32{out.ID})
134+ require.NoError(t, err)
135+ for _, w := range ws {
136+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, WorkspaceID: w.ID})
137+ if w.Active {
138+ require.NoError(t, err)
139+ } else {
140+ require.ErrorIs(t, err, ports.ErrWorkspaceUnavailable)
141+ }
142+ }
143+}
144+
145+func TestFreezeUnknownOutput(t *testing.T) {
146+ s, _ := newSource(t, testserver.Config{})
147+ _, err := s.Freeze(context.Background(), []uint32{12345})
148+ require.ErrorIs(t, err, ports.ErrOutputNotFound)
149+
150+ out := firstOutput(t, s)
151+ _, err = s.Freeze(context.Background(), []uint32{out.ID})
152+ require.NoError(t, err)
153+ _, err = s.Capture(context.Background(), ports.Target{OutputID: 12345})
154+ require.ErrorIs(t, err, ports.ErrOutputNotFound)
155+}
156+
157+func TestOwnedCopyDropsStridePadding(t *testing.T) {
158+ f := ports.Frame{Pixels: make([]byte, 2*16), Width: 3, Height: 2, Stride: 16, Format: ports.XRGB8888}
159+ for i := range f.Pixels {
160+ f.Pixels[i] = byte(i)
161+ }
162+ c := ownedCopy(f)
163+ require.NoError(t, c.Validate())
164+ require.Equal(t, 12, c.Stride)
165+ require.Equal(t, f.Row(1), c.Row(1))
166+ f.Pixels[16] = 0xee
167+ require.NotEqual(t, f.Row(1), c.Row(1), "the copy owns its storage")
168+}
@@ -0,0 +1,168 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/nefercap/internal/adapters/wayland/testserver"
10+ "github.com/bnema/nefercap/internal/ports"
11+)
12+
13+// The test compositor paints the frame sequence into every pixel, so a
14+// frozen capture keeps the sequence of the Freeze while live ones advance.
15+func TestFreezeServesTheFrozenScreen(t *testing.T) {
16+ ctx := context.Background()
17+ s, srv := newSource(t, testserver.Config{Outputs: bigOutput()})
18+ out := firstOutput(t, s)
19+ frozen, err := s.Freeze(ctx, []uint32{out.ID})
20+ require.NoError(t, err)
21+ f := frozen[out.ID]
22+ require.NoError(t, f.Validate())
23+ require.Equal(t, [3]int{64, 48, 64 * 4}, [3]int{f.Width, f.Height, f.Stride})
24+ captures := srv.Stats().Captures
25+
26+ whole, err := s.Capture(ctx, ports.Target{OutputID: out.ID})
27+ require.NoError(t, err)
28+ want := testserver.Pixel(5, 3, 1)
29+ require.Equal(t, want[:], whole.Row(3)[5*4:6*4])
30+
31+ region, err := s.Capture(ctx, ports.Target{OutputID: out.ID, Region: ports.Region{X: 10, Y: 5, Width: 20, Height: 8}})
32+ require.NoError(t, err)
33+ require.Equal(t, [2]int{20, 8}, [2]int{region.Width, region.Height})
34+ want = testserver.Pixel(10, 5, 1)
35+ require.Equal(t, want[:], region.Row(0)[:4])
36+ require.Equal(t, captures, srv.Stats().Captures, "frozen captures read no new frame")
37+
38+ s.Thaw()
39+ live, err := s.Capture(ctx, ports.Target{OutputID: out.ID})
40+ require.NoError(t, err)
41+ want = testserver.Pixel(5, 3, 2)
42+ require.Equal(t, want[:], live.Row(3)[5*4:6*4], "Thaw captures the live screen again")
43+}
44+
45+// Freeze ends the live session and frees the shared buffers: the compositor
46+// stops streaming and only the frozen copy stays in memory.
47+func TestFreezeReleasesLiveResources(t *testing.T) {
48+ s, srv := newSource(t, testserver.Config{Outputs: bigOutput()})
49+ _, err := s.Freeze(context.Background(), []uint32{firstOutput(t, s).ID})
50+ require.NoError(t, err)
51+ require.NoError(t, s.roundtrip())
52+ st := srv.Stats()
53+ require.Equal(t, st.Sessions, st.DestroyedSessions)
54+ require.Equal(t, st.Buffers, st.DestroyedBuffers)
55+ require.Nil(t, s.bufs[0].data)
56+}
57+
58+// A rotated output cannot be frozen: it stays live instead of failing the
59+// selector, and its capture reports the transform as before.
60+func TestFreezeLeavesRotatedOutputsLive(t *testing.T) {
61+ s, _ := newSource(t, testserver.Config{Transform: 1})
62+ out := firstOutput(t, s)
63+ frozen, err := s.Freeze(context.Background(), []uint32{out.ID})
64+ require.NoError(t, err)
65+ require.Empty(t, frozen)
66+ _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID})
67+ require.ErrorIs(t, err, ports.ErrUnsupportedTransform)
68+}
69+
70+// Thaw keeps the copies of the listed outputs; the others capture live.
71+func TestThawKeepsListedOutputs(t *testing.T) {
72+ ctx := context.Background()
73+ s, _ := newSource(t, testserver.Config{Outputs: []testserver.OutputSpec{
74+ {Name: "A-1", Width: 8, Height: 4, Scale: 1}, {Name: "B-2", Width: 8, Height: 4, Scale: 1},
75+ }})
76+ outs, err := s.Outputs(ctx)
77+ require.NoError(t, err)
78+ _, err = s.Freeze(ctx, []uint32{outs[0].ID, outs[1].ID}) // frames 1 and 2
79+ require.NoError(t, err)
80+ s.Thaw(outs[0].ID)
81+ kept, err := s.Capture(ctx, ports.Target{OutputID: outs[0].ID})
82+ require.NoError(t, err)
83+ want := testserver.Pixel(0, 0, 1)
84+ require.Equal(t, want[:], kept.Row(0)[:4])
85+ live, err := s.Capture(ctx, ports.Target{OutputID: outs[1].ID})
86+ require.NoError(t, err)
87+ want = testserver.Pixel(0, 0, 3)
88+ require.Equal(t, want[:], live.Row(0)[:4])
89+}
90+
91+func TestCloseDropsFrozenFrames(t *testing.T) {
92+ s, _ := newSource(t, testserver.Config{})
93+ out := firstOutput(t, s)
94+ _, err := s.Freeze(context.Background(), []uint32{out.ID})
95+ require.NoError(t, err)
96+ require.NoError(t, s.Close())
97+ require.Nil(t, s.frozen)
98+ _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID})
99+ require.ErrorIs(t, err, ports.ErrClosed)
100+}
101+
102+func TestFreezeWorkspaceIsItsRegionOfTheFrozenOutput(t *testing.T) {
103+ cfg := wsConfig(testserver.WorkspaceSpec{ID: "7f3a-2", Name: "b", Output: "TEST-1", Active: true})
104+ cfg.NeferwlSource = true
105+ s, srv := newSource(t, cfg)
106+ srv.SetFrame("b", [4]int32{2, 1, 4, 2})
107+ ws := listWorkspaces(t, s)
108+ require.Len(t, ws, 1)
109+ out := firstOutput(t, s)
110+ _, err := s.Freeze(context.Background(), []uint32{out.ID})
111+ require.NoError(t, err)
112+ f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, WorkspaceID: ws[0].ID})
113+ require.NoError(t, err)
114+ require.Equal(t, [2]int{4, 2}, [2]int{f.Width, f.Height})
115+ want := testserver.Pixel(2, 1, 1)
116+ require.Equal(t, want[:], f.Row(0)[:4])
117+ require.Equal(t, "output", srv.Stats().LastSource, "a frozen workspace is cut from the output frame")
118+
119+ _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, WorkspaceID: 99})
120+ require.ErrorIs(t, err, ports.ErrWorkspaceUnavailable)
121+}
122+
123+// The copy shows the workspace that was displayed: a hidden one is
124+// unavailable, as in a live capture without NeferWL's extension.
125+func TestFreezeHiddenWorkspaceIsUnavailable(t *testing.T) {
126+ s, _ := newSource(t, wsConfig(
127+ testserver.WorkspaceSpec{ID: "1", Name: "a", Output: "TEST-1", Active: true},
128+ testserver.WorkspaceSpec{ID: "2", Name: "b", Output: "TEST-1"},
129+ ))
130+ ws := listWorkspaces(t, s)
131+ require.Len(t, ws, 2)
132+ out := firstOutput(t, s)
133+ _, err := s.Freeze(context.Background(), []uint32{out.ID})
134+ require.NoError(t, err)
135+ for _, w := range ws {
136+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, WorkspaceID: w.ID})
137+ if w.Active {
138+ require.NoError(t, err)
139+ } else {
140+ require.ErrorIs(t, err, ports.ErrWorkspaceUnavailable)
141+ }
142+ }
143+}
144+
145+func TestFreezeUnknownOutput(t *testing.T) {
146+ s, _ := newSource(t, testserver.Config{})
147+ _, err := s.Freeze(context.Background(), []uint32{12345})
148+ require.ErrorIs(t, err, ports.ErrOutputNotFound)
149+
150+ out := firstOutput(t, s)
151+ _, err = s.Freeze(context.Background(), []uint32{out.ID})
152+ require.NoError(t, err)
153+ _, err = s.Capture(context.Background(), ports.Target{OutputID: 12345})
154+ require.ErrorIs(t, err, ports.ErrOutputNotFound)
155+}
156+
157+func TestOwnedCopyDropsStridePadding(t *testing.T) {
158+ f := ports.Frame{Pixels: make([]byte, 2*16), Width: 3, Height: 2, Stride: 16, Format: ports.XRGB8888}
159+ for i := range f.Pixels {
160+ f.Pixels[i] = byte(i)
161+ }
162+ c := ownedCopy(f)
163+ require.NoError(t, c.Validate())
164+ require.Equal(t, 12, c.Stride)
165+ require.Equal(t, f.Row(1), c.Row(1))
166+ f.Pixels[16] = 0xee
167+ require.NotEqual(t, f.Row(1), c.Row(1), "the copy owns its storage")
168+}
M internal/adapters/wayland/source.go +9 -4
@@ -91,9 +91,10 @@ type Source struct {
9191 conn net.Conn
9292 pollWait time.Duration // how long Capture waits for a pending frame
9393
94- sess *session // the one live capture session, nil before the first capture
95- ext extensions
96- ws workspaceState
94+ sess *session // the one live capture session, nil before the first capture
95+ frozen map[uint32]ports.Frame // by output ID, set by Freeze: Capture serves these
96+ ext extensions
97+ ws workspaceState
9798 }
9899
99100 var _ ports.Source = (*Source)(nil)
@@ -416,7 +417,10 @@ func (s *Source) Capture(ctx context.Context, t ports.Target) (ports.Frame, erro
416417 if err := s.begin(ctx); err != nil {
417418 return ports.Frame{}, err
418419 }
419- frame, err := s.captureTarget(t)
420+ frame, frozen, err := s.frozenTarget(t)
421+ if !frozen {
422+ frame, err = s.captureTarget(t)
423+ }
420424 if err = s.finish(ctx, err); err != nil {
421425 return ports.Frame{}, err
422426 }
@@ -438,5 +442,6 @@ func (s *Source) Close() error {
438442 s.opMu.Lock()
439443 defer s.opMu.Unlock()
440444 s.detachWatcher()
445+ s.frozen = nil
441446 return s.releaseBuffers()
442447 }
@@ -91,9 +91,10 @@ type Source struct {
91 conn net.Conn91 conn net.Conn
92 pollWait time.Duration // how long Capture waits for a pending frame92 pollWait time.Duration // how long Capture waits for a pending frame
93 93
94- sess *session // the one live capture session, nil before the first capture94+ sess *session // the one live capture session, nil before the first capture
95- ext extensions95+ frozen map[uint32]ports.Frame // by output ID, set by Freeze: Capture serves these
96- ws workspaceState96+ ext extensions
97+ ws workspaceState
97 }98 }
98 99
99 var _ ports.Source = (*Source)(nil)100 var _ ports.Source = (*Source)(nil)
@@ -416,7 +417,10 @@ func (s *Source) Capture(ctx context.Context, t ports.Target) (ports.Frame, erro
416 if err := s.begin(ctx); err != nil {417 if err := s.begin(ctx); err != nil {
417 return ports.Frame{}, err418 return ports.Frame{}, err
418 }419 }
419- frame, err := s.captureTarget(t)420+ frame, frozen, err := s.frozenTarget(t)
421+ if !frozen {
422+ frame, err = s.captureTarget(t)
423+ }
420 if err = s.finish(ctx, err); err != nil {424 if err = s.finish(ctx, err); err != nil {
421 return ports.Frame{}, err425 return ports.Frame{}, err
422 }426 }
@@ -438,5 +442,6 @@ func (s *Source) Close() error {
438 s.opMu.Lock()442 s.opMu.Lock()
439 defer s.opMu.Unlock()443 defer s.opMu.Unlock()
440 s.detachWatcher()444 s.detachWatcher()
445+ s.frozen = nil
441 return s.releaseBuffers()446 return s.releaseBuffers()
442 }447 }
A internal/app/freeze_test.go +37 -0
@@ -0,0 +1,37 @@
1+package app
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+
8+ "github.com/bnema/nefercap/internal/adapters/cli"
9+ "github.com/bnema/nefercap/internal/adapters/config"
10+ "github.com/bnema/nefercap/internal/ports"
11+)
12+
13+// Only selectors that can take a screenshot freeze the screen; rec records the
14+// live screen, and selector.freeze = off keeps every selector live.
15+func TestFreezes(t *testing.T) {
16+ on, off := config.Settings{Freeze: true}, config.Settings{}
17+ for _, tc := range []struct {
18+ command cli.Command
19+ settings config.Settings
20+ want bool
21+ }{
22+ {cli.Shot, on, true},
23+ {cli.AllInOne, on, true},
24+ {cli.Rec, on, false},
25+ {cli.Shot, off, false},
26+ {cli.AllInOne, off, false},
27+ } {
28+ assert.Equal(t, tc.want, freezes(cli.Options{Command: tc.command}, tc.settings), "%s freeze=%v", tc.command, tc.settings.Freeze)
29+ }
30+}
31+
32+// Outputs too large for a backdrop are not frozen, so their screenshot is
33+// live like their selector.
34+func TestFrozenOutputs(t *testing.T) {
35+ outputs := []ports.Output{{ID: 7, Width: 3840, Height: 2160}, {ID: 8, Width: 5120, Height: 2880}, {ID: 9, Width: 1080, Height: 1920}, {ID: 10}}
36+ assert.Equal(t, []uint32{7, 9}, frozenOutputs(outputs))
37+}
@@ -0,0 +1,37 @@
1+package app
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+
8+ "github.com/bnema/nefercap/internal/adapters/cli"
9+ "github.com/bnema/nefercap/internal/adapters/config"
10+ "github.com/bnema/nefercap/internal/ports"
11+)
12+
13+// Only selectors that can take a screenshot freeze the screen; rec records the
14+// live screen, and selector.freeze = off keeps every selector live.
15+func TestFreezes(t *testing.T) {
16+ on, off := config.Settings{Freeze: true}, config.Settings{}
17+ for _, tc := range []struct {
18+ command cli.Command
19+ settings config.Settings
20+ want bool
21+ }{
22+ {cli.Shot, on, true},
23+ {cli.AllInOne, on, true},
24+ {cli.Rec, on, false},
25+ {cli.Shot, off, false},
26+ {cli.AllInOne, off, false},
27+ } {
28+ assert.Equal(t, tc.want, freezes(cli.Options{Command: tc.command}, tc.settings), "%s freeze=%v", tc.command, tc.settings.Freeze)
29+ }
30+}
31+
32+// Outputs too large for a backdrop are not frozen, so their screenshot is
33+// live like their selector.
34+func TestFrozenOutputs(t *testing.T) {
35+ outputs := []ports.Output{{ID: 7, Width: 3840, Height: 2160}, {ID: 8, Width: 5120, Height: 2880}, {ID: 9, Width: 1080, Height: 1920}, {ID: 10}}
36+ assert.Equal(t, []uint32{7, 9}, frozenOutputs(outputs))
37+}
M internal/app/interactive.go +33 -0
@@ -43,10 +43,24 @@ func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []po
4343 if options.Command == cli.AllInOne {
4444 selector.AllowToggle()
4545 }
46+ if freezes(options, settings) {
47+ frames, err := source.Freeze(ctx, frozenOutputs(outputs))
48+ if err != nil {
49+ return ports.Selection{}, false, err
50+ }
51+ if _, err := selector.WithBackdrops(frames); err != nil {
52+ return ports.Selection{}, false, err
53+ }
54+ }
4655 sel, accepted, err := selector.Select(ctx, outputs)
4756 if err != nil || !accepted {
4857 return ports.Selection{}, false, err
4958 }
59+ if sel.Mode == ports.Record {
60+ source.Thaw() // a recording captures the live screen
61+ } else {
62+ source.Thaw(sel.Target.OutputID) // only the chosen output is captured
63+ }
5064 sel.Duration = options.Duration
5165 if err := assignDestination(&sel, options, settings); err != nil {
5266 return ports.Selection{}, false, err
@@ -54,6 +68,25 @@ func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []po
5468 return sel, true, nil
5569 }
5670
71+// freezes reports whether the selector shows, and a screenshot captures, a
72+// still copy of the screen. rec always records the live screen; all-in-one
73+// shows the copy only in screenshot mode and thaws for a recording.
74+func freezes(options cli.Options, settings config.Settings) bool {
75+ return settings.Freeze && (options.Command == cli.Shot || options.Command == cli.AllInOne)
76+}
77+
78+// frozenOutputs lists the outputs that can show a frozen screen; larger ones
79+// stay live, in the selector and in the screenshot.
80+func frozenOutputs(outputs []ports.Output) []uint32 {
81+ var ids []uint32
82+ for _, o := range outputs {
83+ if selection.CanBackdrop(o) {
84+ ids = append(ids, o.ID)
85+ }
86+ }
87+ return ids
88+}
89+
5790 // checkDirs creates and checks the default directories a confirmed capture
5891 // can use, so a bad screenshot.dir or video.dir fails before the selector
5992 // opens. All-in-one can reach both modes.
@@ -43,10 +43,24 @@ func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []po
43 if options.Command == cli.AllInOne {43 if options.Command == cli.AllInOne {
44 selector.AllowToggle()44 selector.AllowToggle()
45 }45 }
46+ if freezes(options, settings) {
47+ frames, err := source.Freeze(ctx, frozenOutputs(outputs))
48+ if err != nil {
49+ return ports.Selection{}, false, err
50+ }
51+ if _, err := selector.WithBackdrops(frames); err != nil {
52+ return ports.Selection{}, false, err
53+ }
54+ }
46 sel, accepted, err := selector.Select(ctx, outputs)55 sel, accepted, err := selector.Select(ctx, outputs)
47 if err != nil || !accepted {56 if err != nil || !accepted {
48 return ports.Selection{}, false, err57 return ports.Selection{}, false, err
49 }58 }
59+ if sel.Mode == ports.Record {
60+ source.Thaw() // a recording captures the live screen
61+ } else {
62+ source.Thaw(sel.Target.OutputID) // only the chosen output is captured
63+ }
50 sel.Duration = options.Duration64 sel.Duration = options.Duration
51 if err := assignDestination(&sel, options, settings); err != nil {65 if err := assignDestination(&sel, options, settings); err != nil {
52 return ports.Selection{}, false, err66 return ports.Selection{}, false, err
@@ -54,6 +68,25 @@ func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []po
54 return sel, true, nil68 return sel, true, nil
55 }69 }
56 70
71+// freezes reports whether the selector shows, and a screenshot captures, a
72+// still copy of the screen. rec always records the live screen; all-in-one
73+// shows the copy only in screenshot mode and thaws for a recording.
74+func freezes(options cli.Options, settings config.Settings) bool {
75+ return settings.Freeze && (options.Command == cli.Shot || options.Command == cli.AllInOne)
76+}
77+
78+// frozenOutputs lists the outputs that can show a frozen screen; larger ones
79+// stay live, in the selector and in the screenshot.
80+func frozenOutputs(outputs []ports.Output) []uint32 {
81+ var ids []uint32
82+ for _, o := range outputs {
83+ if selection.CanBackdrop(o) {
84+ ids = append(ids, o.ID)
85+ }
86+ }
87+ return ids
88+}
89+
57 // checkDirs creates and checks the default directories a confirmed capture90 // checkDirs creates and checks the default directories a confirmed capture
58 // can use, so a bad screenshot.dir or video.dir fails before the selector91 // can use, so a bad screenshot.dir or video.dir fails before the selector
59 // opens. All-in-one can reach both modes.92 // opens. All-in-one can reach both modes.

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