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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 43 | 43 | ## Features | |
| 44 | 44 | ||
| 45 | 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 | 47 | - **Clean video.** With NeferWL the compositor keeps the border and HUD out of the recording. | |
| 47 | 48 | - **Clipboard.** Screenshots can be saved, copied, or both. | |
| 48 | 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 | 56 | ```ini | |
| 56 | 57 | selector.grid = on | |
| 58 | + | selector.freeze = on | |
| 57 | 59 | screenshot.dir = ~/Pictures/Screenshots | |
| 58 | 60 | screenshot.output = file+clipboard | |
| 59 | 61 | video.dir = ~/Videos | |
| @@ -62,10 +64,13 @@ video.dir = ~/Videos | |||
| 62 | 64 | | Key | Values | Default | | |
| 63 | 65 | | --- | --- | --- | | |
| 64 | 66 | | `selector.grid` | `on`, `off` | `on` | | |
| 67 | + | | `selector.freeze` | `on`, `off` | `on` | | |
| 65 | 68 | | `screenshot.dir` | absolute or `~/` path | `$XDG_PICTURES_DIR/Screenshots` | | |
| 66 | 69 | | `screenshot.output` | `file`, `file+clipboard`, `clipboard` | `file` | | |
| 67 | 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 | 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 | 76 | ## Commands | |
| @@ -43,6 +43,7 @@ On NeferWL a workspace recording follows that workspace, even when you switch to | |||||
| 43 | ## Features | 43 | ## 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 | ```ini | 56 | ```ini | ||
| 56 | selector.grid = on | 57 | selector.grid = on | ||
| 58 | + | selector.freeze = on | |||
| 57 | screenshot.dir = ~/Pictures/Screenshots | 59 | screenshot.dir = ~/Pictures/Screenshots | ||
| 58 | screenshot.output = file+clipboard | 60 | screenshot.output = file+clipboard | ||
| 59 | video.dir = ~/Videos | 61 | 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 | ## Commands | 76 | ## Commands | ||
M go.mod +1 -1
| @@ -5,7 +5,7 @@ go 1.27 | |||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/bnema/go-wayland-bindings v0.1.0 | |
| 7 | 7 | github.com/bnema/neferclient v0.3.0 | |
| 8 | - | github.com/bnema/nefergui v0.7.1 | |
| 8 | + | github.com/bnema/nefergui v0.7.2 | |
| 9 | 9 | github.com/bnema/purego-libwayland v0.8.0 | |
| 10 | 10 | github.com/bnema/wlturbo v0.6.2 | |
| 11 | 11 | 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.0 | 6 | github.com/bnema/go-wayland-bindings v0.1.0 | ||
| 7 | github.com/bnema/neferclient v0.3.0 | 7 | github.com/bnema/neferclient v0.3.0 | ||
| 8 | - | github.com/bnema/nefergui v0.7.1 | 8 | + | github.com/bnema/nefergui v0.7.2 |
| 9 | github.com/bnema/purego-libwayland v0.8.0 | 9 | github.com/bnema/purego-libwayland v0.8.0 | ||
| 10 | github.com/bnema/wlturbo v0.6.2 | 10 | github.com/bnema/wlturbo v0.6.2 | ||
| 11 | github.com/bnema/zerowrap v1.4.1 | 11 | github.com/bnema/zerowrap v1.4.1 | ||
M go.sum +2 -2 generated file: collapsed
M internal/adapters/cli/cli.go +1 -0
| @@ -83,6 +83,7 @@ Selector keys and mouse: | |||
| 83 | 83 | ||
| 84 | 84 | Config: $XDG_CONFIG_HOME/nefercap/config (default ~/.config/nefercap/config) | |
| 85 | 85 | 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 | 87 | screenshot.dir = ~/Pictures/Shots absolute or ~/ path; default XDG Pictures/Screenshots | |
| 87 | 88 | screenshot.output = file+clipboard file (default), file+clipboard or clipboard | |
| 88 | 89 | 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 them | 85 | 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/Screenshots | 87 | screenshot.dir = ~/Pictures/Shots absolute or ~/ path; default XDG Pictures/Screenshots | ||
| 87 | screenshot.output = file+clipboard file (default), file+clipboard or clipboard | 88 | screenshot.output = file+clipboard file (default), file+clipboard or clipboard | ||
| 88 | video.dir = ~/Videos absolute or ~/ path; default XDG Videos | 89 | video.dir = ~/Videos absolute or ~/ path; default XDG Videos | ||
M internal/adapters/config/config.go +24 -16
| @@ -27,22 +27,25 @@ const ( | |||
| 27 | 27 | ) | |
| 28 | 28 | ||
| 29 | 29 | // 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. | |
| 32 | 33 | type Settings struct { | |
| 33 | 34 | Grid bool | |
| 35 | + | Freeze bool | |
| 34 | 36 | ScreenshotDir string | |
| 35 | 37 | VideoDir string | |
| 36 | 38 | 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 | 43 | // 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. | |
| 44 | 47 | func Load() (Settings, error) { | |
| 45 | - | settings := Settings{Grid: true, Output: OutputFile} | |
| 48 | + | settings := Settings{Grid: true, Freeze: true, Output: OutputFile} | |
| 46 | 49 | root := os.Getenv("XDG_CONFIG_HOME") | |
| 47 | 50 | if !filepath.IsAbs(root) { | |
| 48 | 51 | home, err := os.UserHomeDir() | |
| @@ -75,7 +78,7 @@ func Load() (Settings, error) { | |||
| 75 | 78 | if len(data) > maxBytes { | |
| 76 | 79 | return Settings{}, fmt.Errorf("config %s exceeds %d bytes", path, maxBytes) | |
| 77 | 80 | } | |
| 78 | - | var seen [4]bool | |
| 81 | + | var seen [len(keys)]bool | |
| 79 | 82 | for index, raw := range strings.Split(strings.TrimPrefix(string(data), "\ufeff"), "\n") { | |
| 80 | 83 | line := strings.TrimSpace(raw) | |
| 81 | 84 | if line == "" || strings.HasPrefix(line, "#") { | |
| @@ -98,19 +101,14 @@ func Load() (Settings, error) { | |||
| 98 | 101 | 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 | 106 | func apply(s *Settings, key, value string) (err error) { | |
| 104 | 107 | switch key { | |
| 105 | 108 | 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) | |
| 114 | 112 | case "screenshot.output": | |
| 115 | 113 | switch out := ScreenshotOutput(value); out { | |
| 116 | 114 | case OutputFile, OutputFileClipboard, OutputClipboard: | |
| @@ -126,6 +124,16 @@ func apply(s *Settings, key, value string) (err error) { | |||
| 126 | 124 | 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 | 137 | // dir accepts an absolute path or one starting with ~/ (the home directory). | |
| 130 | 138 | func dir(key, value string) (string, error) { | |
| 131 | 139 | 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 current | 29 | // 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 bool | 34 | Grid bool | ||
| 35 | + | Freeze bool | |||
| 34 | ScreenshotDir string | 36 | ScreenshotDir string | ||
| 35 | VideoDir string | 37 | VideoDir string | ||
| 36 | Output ScreenshotOutput | 38 | 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 settings | 44 | + | // 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]bool | 81 | + | 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, nil | 101 | 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 = true | 111 | + | 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 err | 124 | 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) { | |||
| 61 | 61 | {"selector.grid =", "line 1: selector.grid must be on or off"}, | |
| 62 | 62 | {"selector.other = on", "line 1: expected each of"}, | |
| 63 | 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 | 66 | {strings.Repeat("#", maxBytes+1), "exceeds 4096 bytes"}, | |
| 65 | 67 | } { | |
| 66 | 68 | t.Run(tc.text[:min(len(tc.text), 40)], func(t *testing.T) { | |
| @@ -131,7 +133,7 @@ func TestLoadPathsAndOutput(t *testing.T) { | |||
| 131 | 133 | saveConfig(t, path, "# c\nscreenshot.dir = ~/Pics/Shots\nvideo.dir=/var/tmp/vids/\nscreenshot.output = file+clipboard\nselector.grid = off\n") | |
| 132 | 134 | got, err := Load() | |
| 133 | 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 | 138 | saveConfig(t, path, "screenshot.dir = ~/\nscreenshot.output = clipboard\n") | |
| 137 | 139 | got, err = Load() | |
| @@ -145,7 +147,26 @@ func TestLoadDefaultsOutputFile(t *testing.T) { | |||
| 145 | 147 | configRoot(t) | |
| 146 | 148 | got, err := Load() | |
| 147 | 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 | 172 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "image" | |
| 5 | 6 | "math" | |
| 6 | 7 | "strconv" | |
| 7 | 8 | ||
| @@ -51,6 +52,10 @@ type model struct { | |||
| 51 | 52 | end endKind | |
| 52 | 53 | 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 | 59 | workspace ports.Workspace // zero ID: none | |
| 55 | 60 | header string // static per surface | |
| 56 | 61 | 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 endKind | 52 | end endKind | ||
| 52 | sess *session // shared first-decision record; the only cross-overlay state | 53 | 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: none | 59 | workspace ports.Workspace // zero ID: none | ||
| 55 | header string // static per surface | 60 | header string // static per surface | ||
| 56 | footerText string | 61 | footerText string | ||
M internal/adapters/selection/selector.go +41 -0
| @@ -18,6 +18,7 @@ import ( | |||
| 18 | 18 | _ "embed" | |
| 19 | 19 | "errors" | |
| 20 | 20 | "fmt" | |
| 21 | + | "image" | |
| 21 | 22 | "sync" | |
| 22 | 23 | ||
| 23 | 24 | "github.com/bnema/neferclient" | |
| @@ -53,6 +54,7 @@ type Selector struct { | |||
| 53 | 54 | grid bool | |
| 54 | 55 | toggle bool | |
| 55 | 56 | noIndicator bool | |
| 57 | + | backdrops map[uint32]image.Image // frozen screens by output ID | |
| 56 | 58 | } | |
| 57 | 59 | ||
| 58 | 60 | var _ ports.Selector = (*Selector)(nil) | |
| @@ -84,6 +86,43 @@ func (s *Selector) WithCapabilities(c ports.Capabilities) *Selector { | |||
| 84 | 86 | 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 | 126 | // AllowToggle lets Tab switch between screenshot and record selection. The | |
| 88 | 127 | // starting mode is New's; the target selection is kept across a switch. | |
| 89 | 128 | func (s *Selector) AllowToggle() *Selector { | |
| @@ -129,6 +168,7 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se | |||
| 129 | 168 | m := newModel(s.mode, outputs, i, sess, s.grid) | |
| 130 | 169 | m.setWorkspaces(s.workspaces) | |
| 131 | 170 | m.noIndicator = s.noIndicator | |
| 171 | + | m.backdrop = s.backdrops[outputs[i].ID] | |
| 132 | 172 | if s.toggle { | |
| 133 | 173 | m.toggle, m.wake = true, make(chan struct{}, 1) | |
| 134 | 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 | 183 | wg.Wait() | |
| 184 | + | s.backdrops = nil // every overlay ended: nothing draws them any more | |
| 144 | 185 | result, ok, err := sess.outcome(ctx) | |
| 145 | 186 | if err != nil || !ok { | |
| 146 | 187 | 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 bool | 54 | grid bool | ||
| 54 | toggle bool | 55 | toggle bool | ||
| 55 | noIndicator bool | 56 | 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 s | 86 | 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. The | 126 | // 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.noIndicator | 170 | 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, err | 187 | 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. */ | |
| 2 | 3 | app { | |
| 3 | 4 | width: 100%; height: 100%; background-color: transparent; | |
| 4 | 5 | color: #e6edf3; font-family: monospace; font-size: 1rem; | |
| 5 | 6 | } | |
| 6 | 7 | stack.scene { width: 100%; height: 100%; } | |
| 8 | + | box.backdrop { overflow: hidden; } | |
| 9 | + | box.backdrop image { width: 100%; height: 100%; } | |
| 7 | 10 | box.dim { background-color: rgba(0, 0, 0, 0.5); } | |
| 8 | 11 | box.edge { background-color: #4fd1a5; } | |
| 9 | 12 | 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 @@ | |||
| 1 | 1 | 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 | 9 | // view draws the scene. Geometry goes through Node.Rect, so no CSS strings | |
| 6 | 10 | // 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 | 15 | m.refreshFooter() | |
| 12 | 16 | root := f.Root(nefergui.Class("root")) | |
| 13 | 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 | 23 | s := m.scene() | |
| 15 | 24 | for i, r := range s.dim { | |
| 16 | 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 | 53 | type tagged struct { | |
| 39 | 54 | node nefergui.Node | |
| 40 | 55 | ok bool | |
| @@ -1,6 +1,10 @@ | |||||
| 1 | package selection | 1 | 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 strings | 9 | // 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 no | 10 | // 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.Node | 54 | node nefergui.Node | ||
| 40 | ok bool | 55 | 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 { | |||
| 91 | 91 | conn net.Conn | |
| 92 | 92 | 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 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 | |
| 97 | 98 | } | |
| 98 | 99 | ||
| 99 | 100 | var _ ports.Source = (*Source)(nil) | |
| @@ -416,7 +417,10 @@ func (s *Source) Capture(ctx context.Context, t ports.Target) (ports.Frame, erro | |||
| 416 | 417 | if err := s.begin(ctx); err != nil { | |
| 417 | 418 | 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 | 424 | if err = s.finish(ctx, err); err != nil { | |
| 421 | 425 | return ports.Frame{}, err | |
| 422 | 426 | } | |
| @@ -438,5 +442,6 @@ func (s *Source) Close() error { | |||
| 438 | 442 | s.opMu.Lock() | |
| 439 | 443 | defer s.opMu.Unlock() | |
| 440 | 444 | s.detachWatcher() | |
| 445 | + | s.frozen = nil | |
| 441 | 446 | return s.releaseBuffers() | |
| 442 | 447 | } | |
| @@ -91,9 +91,10 @@ type Source struct { | |||||
| 91 | conn net.Conn | 91 | conn net.Conn | ||
| 92 | pollWait time.Duration // how long Capture waits for a pending frame | 92 | 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 capture | 94 | + | sess *session // the one live capture session, nil before the first capture |
| 95 | - | ext extensions | 95 | + | frozen map[uint32]ports.Frame // by output ID, set by Freeze: Capture serves these |
| 96 | - | ws workspaceState | 96 | + | 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{}, err | 418 | 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{}, err | 425 | 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 | |||
| 43 | 43 | if options.Command == cli.AllInOne { | |
| 44 | 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 | 55 | sel, accepted, err := selector.Select(ctx, outputs) | |
| 47 | 56 | if err != nil || !accepted { | |
| 48 | 57 | 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 | 64 | sel.Duration = options.Duration | |
| 51 | 65 | if err := assignDestination(&sel, options, settings); err != nil { | |
| 52 | 66 | return ports.Selection{}, false, err | |
| @@ -54,6 +68,25 @@ func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []po | |||
| 54 | 68 | 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 | 90 | // checkDirs creates and checks the default directories a confirmed capture | |
| 58 | 91 | // can use, so a bad screenshot.dir or video.dir fails before the selector | |
| 59 | 92 | // 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, err | 57 | 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.Duration | 64 | 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, err | 66 | return ports.Selection{}, false, err | ||
| @@ -54,6 +68,25 @@ func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []po | |||||
| 54 | return sel, true, nil | 68 | 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 capture | 90 | // checkDirs creates and checks the default directories a confirmed capture | ||
| 58 | // can use, so a bad screenshot.dir or video.dir fails before the selector | 91 | // 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. | ||