feat(selection): add configurable full-monitor center and thirds guides
- commit
- 14f1bb9a74e04f46def65b119085c1d3b225016f
- author
- brice · · 2026-09-30 12:36 UTC
- committer
- brice · · 2026-09-30 14:48 UTC
- parent
- f25b0c0
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
19 files · +1003 -71
M Makefile +1 -1
| @@ -45,7 +45,7 @@ arch: | |||
| 45 | 45 | # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS | |
| 46 | 46 | # the packages holding them. Every named test must exist, so the guard cannot | |
| 47 | 47 | # pass vacuously when a frame-path test is removed or renamed. | |
| 48 | - | PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestSelectorAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations | |
| 48 | + | PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestMonitorGridAllocations TestMonitorGridPhysicalAllocations TestGridAllocations TestGridModeAllocations TestSelectorAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations | |
| 49 | 49 | PERF_PKGS := ./internal/ports ./internal/core ./internal/adapters/selection ./internal/adapters/indicator ./internal/adapters/ffmpeg ./internal/app ./internal/adapters/wayland | |
| 50 | 50 | perf_empty := | |
| 51 | 51 | perf_space := $(perf_empty) $(perf_empty) | |
| @@ -45,7 +45,7 @@ arch: | |||||
| 45 | # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS | 45 | # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS | ||
| 46 | # the packages holding them. Every named test must exist, so the guard cannot | 46 | # the packages holding them. Every named test must exist, so the guard cannot | ||
| 47 | # pass vacuously when a frame-path test is removed or renamed. | 47 | # pass vacuously when a frame-path test is removed or renamed. | ||
| 48 | - | PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestSelectorAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations | 48 | + | PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestMonitorGridAllocations TestMonitorGridPhysicalAllocations TestGridAllocations TestGridModeAllocations TestSelectorAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations |
| 49 | PERF_PKGS := ./internal/ports ./internal/core ./internal/adapters/selection ./internal/adapters/indicator ./internal/adapters/ffmpeg ./internal/app ./internal/adapters/wayland | 49 | PERF_PKGS := ./internal/ports ./internal/core ./internal/adapters/selection ./internal/adapters/indicator ./internal/adapters/ffmpeg ./internal/app ./internal/adapters/wayland | ||
| 50 | perf_empty := | 50 | perf_empty := | ||
| 51 | perf_space := $(perf_empty) $(perf_empty) | 51 | perf_space := $(perf_empty) $(perf_empty) | ||
M README.md +9 -1
| @@ -18,9 +18,17 @@ make bin | |||
| 18 | 18 | - **Click:** capture the monitor; small pointer jitter counts as a click. | |
| 19 | 19 | - **M, then Enter:** select the monitor. | |
| 20 | 20 | - **W, then Enter:** select the current workspace when native workspace metadata is available. | |
| 21 | - | - **R:** return to rectangle selection. **G:** toggle thirds guides. | |
| 21 | + | - **R:** return to rectangle selection. **G:** toggle full-monitor center and thirds guides. | |
| 22 | 22 | - **1–9:** select that monitor. **Esc:** cancel without creating a capture. | |
| 23 | 23 | ||
| 24 | + | The guides are visible by default: three vertical and three horizontal black lines, one physical pixel wide, aligned to each monitor's scaling. They stay fixed while dragging. To hide them on startup, set this in `$XDG_CONFIG_HOME/nefercap/config` (default `~/.config/nefercap/config`): | |
| 25 | + | ||
| 26 | + | ```ini | |
| 27 | + | selector.grid = off | |
| 28 | + | ``` | |
| 29 | + | ||
| 30 | + | Use `on` to enable them. The config accepts this setting once, blank lines and `#` comment lines; unknown keys, duplicates and invalid values are errors. **G** changes visibility for the current monitor's overlay without rewriting the config. Selection dimensions are logical pixels; the captured file's dimensions depend on the monitor scaling. | |
| 31 | + | ||
| 24 | 32 | A screenshot saves immediately. Recording displays a red border around the visible target and a small **REC / Stop** HUD. Both are excluded from nefercap's video. The HUD takes no keyboard focus; clicks outside Stop pass through. Press the recording shortcut again, click Stop, or run `nefercap stop` to finish the file. | |
| 25 | 33 | ||
| 26 | 34 | Monitor recording follows workspace changes. A workspace recording follows its stable identity and continues when another workspace is displayed. A fixed region stays attached to its monitor. A hidden workspace has no border on the unrelated visible workspace; the recording HUD remains available. | |
| @@ -18,9 +18,17 @@ make bin | |||||
| 18 | - **Click:** capture the monitor; small pointer jitter counts as a click. | 18 | - **Click:** capture the monitor; small pointer jitter counts as a click. | ||
| 19 | - **M, then Enter:** select the monitor. | 19 | - **M, then Enter:** select the monitor. | ||
| 20 | - **W, then Enter:** select the current workspace when native workspace metadata is available. | 20 | - **W, then Enter:** select the current workspace when native workspace metadata is available. | ||
| 21 | - | - **R:** return to rectangle selection. **G:** toggle thirds guides. | 21 | + | - **R:** return to rectangle selection. **G:** toggle full-monitor center and thirds guides. |
| 22 | - **1–9:** select that monitor. **Esc:** cancel without creating a capture. | 22 | - **1–9:** select that monitor. **Esc:** cancel without creating a capture. | ||
| 23 | 23 | ||||
| 24 | + | The guides are visible by default: three vertical and three horizontal black lines, one physical pixel wide, aligned to each monitor's scaling. They stay fixed while dragging. To hide them on startup, set this in `$XDG_CONFIG_HOME/nefercap/config` (default `~/.config/nefercap/config`): | |||
| 25 | + | ||||
| 26 | + | ```ini | |||
| 27 | + | selector.grid = off | |||
| 28 | + | ``` | |||
| 29 | + | ||||
| 30 | + | Use `on` to enable them. The config accepts this setting once, blank lines and `#` comment lines; unknown keys, duplicates and invalid values are errors. **G** changes visibility for the current monitor's overlay without rewriting the config. Selection dimensions are logical pixels; the captured file's dimensions depend on the monitor scaling. | |||
| 31 | + | ||||
| 24 | A screenshot saves immediately. Recording displays a red border around the visible target and a small **REC / Stop** HUD. Both are excluded from nefercap's video. The HUD takes no keyboard focus; clicks outside Stop pass through. Press the recording shortcut again, click Stop, or run `nefercap stop` to finish the file. | 32 | A screenshot saves immediately. Recording displays a red border around the visible target and a small **REC / Stop** HUD. Both are excluded from nefercap's video. The HUD takes no keyboard focus; clicks outside Stop pass through. Press the recording shortcut again, click Stop, or run `nefercap stop` to finish the file. | ||
| 25 | 33 | ||||
| 26 | Monitor recording follows workspace changes. A workspace recording follows its stable identity and continues when another workspace is displayed. A fixed region stays attached to its monitor. A hidden workspace has no border on the unrelated visible workspace; the recording HUD remains available. | 34 | Monitor recording follows workspace changes. A workspace recording follows its stable identity and continues when another workspace is displayed. A fixed region stays attached to its monitor. A hidden workspace has no border on the unrelated visible workspace; the recording HUD remains available. | ||
M internal/adapters/cli/cli.go +5 -2
| @@ -55,7 +55,7 @@ type Options struct { | |||
| 55 | 55 | func Usage() string { | |
| 56 | 56 | return fmt.Sprintf(`Usage: nefercap [command] [flags] | |
| 57 | 57 | ||
| 58 | - | Interactive commands (layer-shell selector over the current output): | |
| 58 | + | Interactive commands (layer-shell selector over the connected outputs): | |
| 59 | 59 | shot select what to capture and save a PNG (default) | |
| 60 | 60 | rec select what to record; run again while recording to stop it | |
| 61 | 61 | stop stop the active recording | |
| @@ -68,9 +68,12 @@ Selector keys and mouse: | |||
| 68 | 68 | m choose the whole monitor, Enter confirms | |
| 69 | 69 | w choose the current workspace when known, click or Enter confirms | |
| 70 | 70 | 1..9 pick that whole monitor from any overlay | |
| 71 | - | g toggle rule-of-thirds guides | |
| 71 | + | g toggle full-monitor center and thirds guides (on by default) | |
| 72 | 72 | Escape cancel | |
| 73 | 73 | ||
| 74 | + | Selector config: $XDG_CONFIG_HOME/nefercap/config (default ~/.config/nefercap/config) | |
| 75 | + | selector.grid = off hide guides on startup; G still toggles them | |
| 76 | + | ||
| 74 | 77 | Script commands (never open the selector): | |
| 75 | 78 | outputs list outputs | |
| 76 | 79 | screenshot save a PNG screenshot | |
| @@ -55,7 +55,7 @@ type Options struct { | |||||
| 55 | func Usage() string { | 55 | func Usage() string { | ||
| 56 | return fmt.Sprintf(`Usage: nefercap [command] [flags] | 56 | return fmt.Sprintf(`Usage: nefercap [command] [flags] | ||
| 57 | 57 | ||||
| 58 | - | Interactive commands (layer-shell selector over the current output): | 58 | + | Interactive commands (layer-shell selector over the connected outputs): |
| 59 | shot select what to capture and save a PNG (default) | 59 | shot select what to capture and save a PNG (default) | ||
| 60 | rec select what to record; run again while recording to stop it | 60 | rec select what to record; run again while recording to stop it | ||
| 61 | stop stop the active recording | 61 | stop stop the active recording | ||
| @@ -68,9 +68,12 @@ Selector keys and mouse: | |||||
| 68 | m choose the whole monitor, Enter confirms | 68 | m choose the whole monitor, Enter confirms | ||
| 69 | w choose the current workspace when known, click or Enter confirms | 69 | w choose the current workspace when known, click or Enter confirms | ||
| 70 | 1..9 pick that whole monitor from any overlay | 70 | 1..9 pick that whole monitor from any overlay | ||
| 71 | - | g toggle rule-of-thirds guides | 71 | + | g toggle full-monitor center and thirds guides (on by default) |
| 72 | Escape cancel | 72 | Escape cancel | ||
| 73 | 73 | ||||
| 74 | + | Selector config: $XDG_CONFIG_HOME/nefercap/config (default ~/.config/nefercap/config) | |||
| 75 | + | selector.grid = off hide guides on startup; G still toggles them | |||
| 76 | + | ||||
| 74 | Script commands (never open the selector): | 77 | Script commands (never open the selector): | ||
| 75 | outputs list outputs | 78 | outputs list outputs | ||
| 76 | screenshot save a PNG screenshot | 79 | screenshot save a PNG screenshot | ||
A internal/adapters/config/config.go +80 -0
| @@ -0,0 +1,80 @@ | |||
| 1 | + | // Package config reads the selector's persistent settings. | |
| 2 | + | package config | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "strings" | |
| 11 | + | "syscall" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | const maxBytes = 4096 | |
| 15 | + | ||
| 16 | + | // Settings controls the selector's initial appearance. G toggles Grid for | |
| 17 | + | // the current selection without changing the saved setting. | |
| 18 | + | type Settings struct { | |
| 19 | + | Grid bool | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // Load reads $XDG_CONFIG_HOME/nefercap/config (or ~/.config/nefercap/config). | |
| 23 | + | // An absent file or setting enables the full monitor grid. Invalid settings | |
| 24 | + | // fail explicitly rather than silently changing the requested appearance. | |
| 25 | + | func Load() (Settings, error) { | |
| 26 | + | settings := Settings{Grid: true} | |
| 27 | + | root := os.Getenv("XDG_CONFIG_HOME") | |
| 28 | + | if !filepath.IsAbs(root) { | |
| 29 | + | home, err := os.UserHomeDir() | |
| 30 | + | if err != nil { | |
| 31 | + | return settings, nil | |
| 32 | + | } | |
| 33 | + | root = filepath.Join(home, ".config") | |
| 34 | + | } | |
| 35 | + | path := filepath.Join(root, "nefercap", "config") | |
| 36 | + | // Nonblocking open also protects against a FIFO substituted before Stat. | |
| 37 | + | file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) | |
| 38 | + | if errors.Is(err, os.ErrNotExist) { | |
| 39 | + | return settings, nil | |
| 40 | + | } | |
| 41 | + | if err != nil { | |
| 42 | + | return Settings{}, fmt.Errorf("open selector config: %w", err) | |
| 43 | + | } | |
| 44 | + | defer file.Close() | |
| 45 | + | info, err := file.Stat() | |
| 46 | + | if err != nil { | |
| 47 | + | return Settings{}, fmt.Errorf("stat selector config: %w", err) | |
| 48 | + | } | |
| 49 | + | if !info.Mode().IsRegular() { | |
| 50 | + | return Settings{}, fmt.Errorf("selector config %s: expected a regular file", path) | |
| 51 | + | } | |
| 52 | + | data, err := io.ReadAll(io.LimitReader(file, maxBytes+1)) | |
| 53 | + | if err != nil { | |
| 54 | + | return Settings{}, fmt.Errorf("read selector config %s: %w", path, err) | |
| 55 | + | } | |
| 56 | + | if len(data) > maxBytes { | |
| 57 | + | return Settings{}, fmt.Errorf("selector config %s exceeds %d bytes", path, maxBytes) | |
| 58 | + | } | |
| 59 | + | seen := false | |
| 60 | + | for index, raw := range strings.Split(strings.TrimPrefix(string(data), "\ufeff"), "\n") { | |
| 61 | + | line := strings.TrimSpace(raw) | |
| 62 | + | if line == "" || strings.HasPrefix(line, "#") { | |
| 63 | + | continue | |
| 64 | + | } | |
| 65 | + | key, value, ok := strings.Cut(line, "=") | |
| 66 | + | if !ok || strings.TrimSpace(key) != "selector.grid" || seen { | |
| 67 | + | return Settings{}, fmt.Errorf("selector config %s line %d: expected one selector.grid = on/off setting", path, index+1) | |
| 68 | + | } | |
| 69 | + | switch strings.TrimSpace(value) { | |
| 70 | + | case "on": | |
| 71 | + | settings.Grid = true | |
| 72 | + | case "off": | |
| 73 | + | settings.Grid = false | |
| 74 | + | default: | |
| 75 | + | return Settings{}, fmt.Errorf("selector config %s line %d: selector.grid must be on or off", path, index+1) | |
| 76 | + | } | |
| 77 | + | seen = true | |
| 78 | + | } | |
| 79 | + | return settings, nil | |
| 80 | + | } | |
| @@ -0,0 +1,80 @@ | |||||
| 1 | + | // Package config reads the selector's persistent settings. | |||
| 2 | + | package config | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "io" | |||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "strings" | |||
| 11 | + | "syscall" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | const maxBytes = 4096 | |||
| 15 | + | ||||
| 16 | + | // Settings controls the selector's initial appearance. G toggles Grid for | |||
| 17 | + | // the current selection without changing the saved setting. | |||
| 18 | + | type Settings struct { | |||
| 19 | + | Grid bool | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // Load reads $XDG_CONFIG_HOME/nefercap/config (or ~/.config/nefercap/config). | |||
| 23 | + | // An absent file or setting enables the full monitor grid. Invalid settings | |||
| 24 | + | // fail explicitly rather than silently changing the requested appearance. | |||
| 25 | + | func Load() (Settings, error) { | |||
| 26 | + | settings := Settings{Grid: true} | |||
| 27 | + | root := os.Getenv("XDG_CONFIG_HOME") | |||
| 28 | + | if !filepath.IsAbs(root) { | |||
| 29 | + | home, err := os.UserHomeDir() | |||
| 30 | + | if err != nil { | |||
| 31 | + | return settings, nil | |||
| 32 | + | } | |||
| 33 | + | root = filepath.Join(home, ".config") | |||
| 34 | + | } | |||
| 35 | + | path := filepath.Join(root, "nefercap", "config") | |||
| 36 | + | // Nonblocking open also protects against a FIFO substituted before Stat. | |||
| 37 | + | file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) | |||
| 38 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 39 | + | return settings, nil | |||
| 40 | + | } | |||
| 41 | + | if err != nil { | |||
| 42 | + | return Settings{}, fmt.Errorf("open selector config: %w", err) | |||
| 43 | + | } | |||
| 44 | + | defer file.Close() | |||
| 45 | + | info, err := file.Stat() | |||
| 46 | + | if err != nil { | |||
| 47 | + | return Settings{}, fmt.Errorf("stat selector config: %w", err) | |||
| 48 | + | } | |||
| 49 | + | if !info.Mode().IsRegular() { | |||
| 50 | + | return Settings{}, fmt.Errorf("selector config %s: expected a regular file", path) | |||
| 51 | + | } | |||
| 52 | + | data, err := io.ReadAll(io.LimitReader(file, maxBytes+1)) | |||
| 53 | + | if err != nil { | |||
| 54 | + | return Settings{}, fmt.Errorf("read selector config %s: %w", path, err) | |||
| 55 | + | } | |||
| 56 | + | if len(data) > maxBytes { | |||
| 57 | + | return Settings{}, fmt.Errorf("selector config %s exceeds %d bytes", path, maxBytes) | |||
| 58 | + | } | |||
| 59 | + | seen := false | |||
| 60 | + | for index, raw := range strings.Split(strings.TrimPrefix(string(data), "\ufeff"), "\n") { | |||
| 61 | + | line := strings.TrimSpace(raw) | |||
| 62 | + | if line == "" || strings.HasPrefix(line, "#") { | |||
| 63 | + | continue | |||
| 64 | + | } | |||
| 65 | + | key, value, ok := strings.Cut(line, "=") | |||
| 66 | + | if !ok || strings.TrimSpace(key) != "selector.grid" || seen { | |||
| 67 | + | return Settings{}, fmt.Errorf("selector config %s line %d: expected one selector.grid = on/off setting", path, index+1) | |||
| 68 | + | } | |||
| 69 | + | switch strings.TrimSpace(value) { | |||
| 70 | + | case "on": | |||
| 71 | + | settings.Grid = true | |||
| 72 | + | case "off": | |||
| 73 | + | settings.Grid = false | |||
| 74 | + | default: | |||
| 75 | + | return Settings{}, fmt.Errorf("selector config %s line %d: selector.grid must be on or off", path, index+1) | |||
| 76 | + | } | |||
| 77 | + | seen = true | |||
| 78 | + | } | |||
| 79 | + | return settings, nil | |||
| 80 | + | } | |||
A internal/adapters/config/config_test.go +125 -0
| @@ -0,0 +1,125 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "strings" | |
| 7 | + | "syscall" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func configRoot(t *testing.T) string { | |
| 15 | + | t.Helper() | |
| 16 | + | root := t.TempDir() | |
| 17 | + | t.Setenv("XDG_CONFIG_HOME", root) | |
| 18 | + | return filepath.Join(root, "nefercap", "config") | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func saveConfig(t *testing.T, path, text string) { | |
| 22 | + | t.Helper() | |
| 23 | + | require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) | |
| 24 | + | require.NoError(t, os.WriteFile(path, []byte(text), 0o600)) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestLoadDefault(t *testing.T) { | |
| 28 | + | configRoot(t) | |
| 29 | + | got, err := Load() | |
| 30 | + | require.NoError(t, err) | |
| 31 | + | assert.True(t, got.Grid) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestLoadGrid(t *testing.T) { | |
| 35 | + | for _, tc := range []struct { | |
| 36 | + | name, text string | |
| 37 | + | grid bool | |
| 38 | + | }{ | |
| 39 | + | {"empty", "", true}, | |
| 40 | + | {"comments", "# selector settings\n\n", true}, | |
| 41 | + | {"on", "selector.grid = on\n", true}, | |
| 42 | + | {"off", "selector.grid = off\n", false}, | |
| 43 | + | {"whitespace", " selector.grid\t= off \n", false}, | |
| 44 | + | {"CRLF", "# config\r\nselector.grid = off\r\n", false}, | |
| 45 | + | {"BOM", "\ufeffselector.grid = off\n", false}, | |
| 46 | + | } { | |
| 47 | + | t.Run(tc.name, func(t *testing.T) { | |
| 48 | + | path := configRoot(t) | |
| 49 | + | saveConfig(t, path, tc.text) | |
| 50 | + | got, err := Load() | |
| 51 | + | require.NoError(t, err) | |
| 52 | + | assert.Equal(t, tc.grid, got.Grid) | |
| 53 | + | }) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestLoadRejectsInvalid(t *testing.T) { | |
| 58 | + | for _, tc := range []struct{ text, want string }{ | |
| 59 | + | {"selector.grid = maybe", "line 1: selector.grid must be on or off"}, | |
| 60 | + | {"selector.grid", "line 1: expected one"}, | |
| 61 | + | {"selector.grid =", "line 1: selector.grid must be on or off"}, | |
| 62 | + | {"selector.other = on", "line 1: expected one"}, | |
| 63 | + | {"selector.grid = on\nselector.grid = off", "line 2: expected one"}, | |
| 64 | + | {strings.Repeat("#", maxBytes+1), "exceeds 4096 bytes"}, | |
| 65 | + | } { | |
| 66 | + | t.Run(tc.text[:min(len(tc.text), 40)], func(t *testing.T) { | |
| 67 | + | path := configRoot(t) | |
| 68 | + | saveConfig(t, path, tc.text) | |
| 69 | + | _, err := Load() | |
| 70 | + | require.ErrorContains(t, err, tc.want) | |
| 71 | + | assert.Contains(t, err.Error(), path) | |
| 72 | + | }) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestLoadHomeFallback(t *testing.T) { | |
| 77 | + | home := t.TempDir() | |
| 78 | + | t.Setenv("HOME", home) | |
| 79 | + | t.Setenv("XDG_CONFIG_HOME", "") | |
| 80 | + | saveConfig(t, filepath.Join(home, ".config", "nefercap", "config"), "selector.grid = off\n") | |
| 81 | + | got, err := Load() | |
| 82 | + | require.NoError(t, err) | |
| 83 | + | assert.False(t, got.Grid) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestLoadRelativeXDGUsesHome(t *testing.T) { | |
| 87 | + | home := t.TempDir() | |
| 88 | + | t.Setenv("HOME", home) | |
| 89 | + | t.Setenv("XDG_CONFIG_HOME", "relative-config") | |
| 90 | + | saveConfig(t, filepath.Join(home, ".config", "nefercap", "config"), "selector.grid = off\n") | |
| 91 | + | got, err := Load() | |
| 92 | + | require.NoError(t, err) | |
| 93 | + | assert.False(t, got.Grid) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestLoadNoConfigDirectoryUsesDefaults(t *testing.T) { | |
| 97 | + | t.Setenv("HOME", "") | |
| 98 | + | t.Setenv("XDG_CONFIG_HOME", "") | |
| 99 | + | got, err := Load() | |
| 100 | + | require.NoError(t, err) | |
| 101 | + | assert.True(t, got.Grid) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func TestLoadFIFORejectedWithoutBlocking(t *testing.T) { | |
| 105 | + | path := configRoot(t) | |
| 106 | + | require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) | |
| 107 | + | require.NoError(t, syscall.Mkfifo(path, 0o600)) | |
| 108 | + | _, err := Load() | |
| 109 | + | require.ErrorContains(t, err, "expected a regular file") | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func TestLoadSizeBoundary(t *testing.T) { | |
| 113 | + | path := configRoot(t) | |
| 114 | + | saveConfig(t, path, strings.Repeat("#", maxBytes)) | |
| 115 | + | got, err := Load() | |
| 116 | + | require.NoError(t, err) | |
| 117 | + | assert.True(t, got.Grid) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestLoadDirectoryRejected(t *testing.T) { | |
| 121 | + | path := configRoot(t) | |
| 122 | + | require.NoError(t, os.MkdirAll(path, 0o700)) | |
| 123 | + | _, err := Load() | |
| 124 | + | require.ErrorContains(t, err, "expected a regular file") | |
| 125 | + | } | |
| @@ -0,0 +1,125 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "strings" | |||
| 7 | + | "syscall" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func configRoot(t *testing.T) string { | |||
| 15 | + | t.Helper() | |||
| 16 | + | root := t.TempDir() | |||
| 17 | + | t.Setenv("XDG_CONFIG_HOME", root) | |||
| 18 | + | return filepath.Join(root, "nefercap", "config") | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func saveConfig(t *testing.T, path, text string) { | |||
| 22 | + | t.Helper() | |||
| 23 | + | require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) | |||
| 24 | + | require.NoError(t, os.WriteFile(path, []byte(text), 0o600)) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestLoadDefault(t *testing.T) { | |||
| 28 | + | configRoot(t) | |||
| 29 | + | got, err := Load() | |||
| 30 | + | require.NoError(t, err) | |||
| 31 | + | assert.True(t, got.Grid) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestLoadGrid(t *testing.T) { | |||
| 35 | + | for _, tc := range []struct { | |||
| 36 | + | name, text string | |||
| 37 | + | grid bool | |||
| 38 | + | }{ | |||
| 39 | + | {"empty", "", true}, | |||
| 40 | + | {"comments", "# selector settings\n\n", true}, | |||
| 41 | + | {"on", "selector.grid = on\n", true}, | |||
| 42 | + | {"off", "selector.grid = off\n", false}, | |||
| 43 | + | {"whitespace", " selector.grid\t= off \n", false}, | |||
| 44 | + | {"CRLF", "# config\r\nselector.grid = off\r\n", false}, | |||
| 45 | + | {"BOM", "\ufeffselector.grid = off\n", false}, | |||
| 46 | + | } { | |||
| 47 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 48 | + | path := configRoot(t) | |||
| 49 | + | saveConfig(t, path, tc.text) | |||
| 50 | + | got, err := Load() | |||
| 51 | + | require.NoError(t, err) | |||
| 52 | + | assert.Equal(t, tc.grid, got.Grid) | |||
| 53 | + | }) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestLoadRejectsInvalid(t *testing.T) { | |||
| 58 | + | for _, tc := range []struct{ text, want string }{ | |||
| 59 | + | {"selector.grid = maybe", "line 1: selector.grid must be on or off"}, | |||
| 60 | + | {"selector.grid", "line 1: expected one"}, | |||
| 61 | + | {"selector.grid =", "line 1: selector.grid must be on or off"}, | |||
| 62 | + | {"selector.other = on", "line 1: expected one"}, | |||
| 63 | + | {"selector.grid = on\nselector.grid = off", "line 2: expected one"}, | |||
| 64 | + | {strings.Repeat("#", maxBytes+1), "exceeds 4096 bytes"}, | |||
| 65 | + | } { | |||
| 66 | + | t.Run(tc.text[:min(len(tc.text), 40)], func(t *testing.T) { | |||
| 67 | + | path := configRoot(t) | |||
| 68 | + | saveConfig(t, path, tc.text) | |||
| 69 | + | _, err := Load() | |||
| 70 | + | require.ErrorContains(t, err, tc.want) | |||
| 71 | + | assert.Contains(t, err.Error(), path) | |||
| 72 | + | }) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestLoadHomeFallback(t *testing.T) { | |||
| 77 | + | home := t.TempDir() | |||
| 78 | + | t.Setenv("HOME", home) | |||
| 79 | + | t.Setenv("XDG_CONFIG_HOME", "") | |||
| 80 | + | saveConfig(t, filepath.Join(home, ".config", "nefercap", "config"), "selector.grid = off\n") | |||
| 81 | + | got, err := Load() | |||
| 82 | + | require.NoError(t, err) | |||
| 83 | + | assert.False(t, got.Grid) | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func TestLoadRelativeXDGUsesHome(t *testing.T) { | |||
| 87 | + | home := t.TempDir() | |||
| 88 | + | t.Setenv("HOME", home) | |||
| 89 | + | t.Setenv("XDG_CONFIG_HOME", "relative-config") | |||
| 90 | + | saveConfig(t, filepath.Join(home, ".config", "nefercap", "config"), "selector.grid = off\n") | |||
| 91 | + | got, err := Load() | |||
| 92 | + | require.NoError(t, err) | |||
| 93 | + | assert.False(t, got.Grid) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func TestLoadNoConfigDirectoryUsesDefaults(t *testing.T) { | |||
| 97 | + | t.Setenv("HOME", "") | |||
| 98 | + | t.Setenv("XDG_CONFIG_HOME", "") | |||
| 99 | + | got, err := Load() | |||
| 100 | + | require.NoError(t, err) | |||
| 101 | + | assert.True(t, got.Grid) | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func TestLoadFIFORejectedWithoutBlocking(t *testing.T) { | |||
| 105 | + | path := configRoot(t) | |||
| 106 | + | require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) | |||
| 107 | + | require.NoError(t, syscall.Mkfifo(path, 0o600)) | |||
| 108 | + | _, err := Load() | |||
| 109 | + | require.ErrorContains(t, err, "expected a regular file") | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func TestLoadSizeBoundary(t *testing.T) { | |||
| 113 | + | path := configRoot(t) | |||
| 114 | + | saveConfig(t, path, strings.Repeat("#", maxBytes)) | |||
| 115 | + | got, err := Load() | |||
| 116 | + | require.NoError(t, err) | |||
| 117 | + | assert.True(t, got.Grid) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestLoadDirectoryRejected(t *testing.T) { | |||
| 121 | + | path := configRoot(t) | |||
| 122 | + | require.NoError(t, os.MkdirAll(path, 0o700)) | |||
| 123 | + | _, err := Load() | |||
| 124 | + | require.ErrorContains(t, err, "expected a regular file") | |||
| 125 | + | } | |||
A internal/adapters/selection/grid_mode_test.go +152 -0
| @@ -0,0 +1,152 @@ | |||
| 1 | + | package selection | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "image" | |
| 5 | + | "math" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/nefercap/internal/core" | |
| 12 | + | "github.com/bnema/nefercap/internal/ports" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // modeModel is a one-output overlay whose output mode is mw x mh and whose | |
| 16 | + | // surface is w x h logical at scale. | |
| 17 | + | func modeModel(t *testing.T, mw, mh, w, h int, scale float64) *model { | |
| 18 | + | t.Helper() | |
| 19 | + | sess, _ := testSession() | |
| 20 | + | outs := []ports.Output{{ID: 7, Name: "DP-1", Width: mw, Height: mh, Scale: 1}} | |
| 21 | + | m := newModel(ports.Screenshot, outs, 0, sess, true) | |
| 22 | + | m.resize(w, h, scale) | |
| 23 | + | require.Equal(t, core.PickActive, m.picker.Status()) | |
| 24 | + | return m | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // renderedGrid returns the scene's lines as the renderer places them. | |
| 28 | + | func renderedGrid(m *model) (g core.GridLines) { | |
| 29 | + | for i, r := range m.scene().grid { | |
| 30 | + | g[i] = physicalEdges(r, m.scale) | |
| 31 | + | } | |
| 32 | + | return g | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestGridFollowsTheOutputModeNotTheBuffer(t *testing.T) { | |
| 36 | + | // The logical surface is round(mode/scale); its buffer is ceil(logical*scale) | |
| 37 | + | // and can be one pixel larger than the mode, which the compositor crops. | |
| 38 | + | // The guides must sit on the visible mode's thirds. | |
| 39 | + | want, err := core.MonitorGridPhysical(901, 601) | |
| 40 | + | require.NoError(t, err) | |
| 41 | + | for _, scale := range []float64{1.2, 1.25, 1.5, 2} { | |
| 42 | + | w, h := int(math.Round(901/scale)), int(math.Round(601/scale)) | |
| 43 | + | m := modeModel(t, 901, 601, w, h, scale) | |
| 44 | + | got := renderedGrid(m) | |
| 45 | + | assert.Equal(t, want, got, "scale %g (%dx%d logical)", scale, w, h) | |
| 46 | + | for i, x := range []int{300, 450, 600} { | |
| 47 | + | assert.Equal(t, image.Rect(x, 0, x+1, 601), got[i], "scale %g vertical %d", scale, i) | |
| 48 | + | } | |
| 49 | + | for i, y := range []int{200, 300, 400} { | |
| 50 | + | assert.Equal(t, image.Rect(0, y, 901, y+1), got[3+i], "scale %g horizontal %d", scale, i) | |
| 51 | + | } | |
| 52 | + | bw, _ := core.PhysicalExtent(w, scale) | |
| 53 | + | bh, _ := core.PhysicalExtent(h, scale) | |
| 54 | + | for i, r := range got { | |
| 55 | + | assert.True(t, r.In(image.Rect(0, 0, bw, bh)), "scale %g line %d stays inside the %dx%d buffer", scale, i, bw, bh) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestGridFallsBackToTheBufferWithoutAMatchingMode(t *testing.T) { | |
| 61 | + | buffer, err := core.MonitorGrid(751, 501, 1.2) | |
| 62 | + | require.NoError(t, err) | |
| 63 | + | for name, c := range map[string]struct{ mw, mh int }{ | |
| 64 | + | "unknown mode": {0, 0}, | |
| 65 | + | "negative mode": {-901, -601}, | |
| 66 | + | "width mismatches": {1920, 601}, | |
| 67 | + | "height mismatches": {901, 1080}, | |
| 68 | + | "stale config": {1920, 1080}, | |
| 69 | + | "one pixel off": {903, 601}, // 903/1.2 rounds to 753 | |
| 70 | + | } { | |
| 71 | + | m := modeModel(t, c.mw, c.mh, 751, 501, 1.2) | |
| 72 | + | assert.Equal(t, buffer, renderedGrid(m), name) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestGridModeIsCheckedAgainstEveryResize(t *testing.T) { | |
| 77 | + | m := modeModel(t, 901, 601, 751, 501, 1.2) | |
| 78 | + | mode, err := core.MonitorGridPhysical(901, 601) | |
| 79 | + | require.NoError(t, err) | |
| 80 | + | assert.Equal(t, mode, renderedGrid(m)) | |
| 81 | + | ||
| 82 | + | m.resize(751, 501, 1.25) // scale only: 901/1.25 is no longer 751 | |
| 83 | + | buffer, err := core.MonitorGrid(751, 501, 1.25) | |
| 84 | + | require.NoError(t, err) | |
| 85 | + | assert.Equal(t, buffer, renderedGrid(m), "the mode no longer explains the surface") | |
| 86 | + | ||
| 87 | + | m.resize(721, 481, 1.25) // round(901/1.25) x round(601/1.25) | |
| 88 | + | mode, err = core.MonitorGridPhysical(901, 601) | |
| 89 | + | require.NoError(t, err) | |
| 90 | + | assert.Equal(t, mode, renderedGrid(m), "and matches again when it does") | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestGridUsesRotatedModeWhenItMatchesSwapped(t *testing.T) { | |
| 94 | + | // A mode reported as 601x901 with a surface of 751x501 logical. | |
| 95 | + | m := modeModel(t, 601, 901, 751, 501, 1.2) | |
| 96 | + | want, err := core.MonitorGridPhysical(901, 601) | |
| 97 | + | require.NoError(t, err) | |
| 98 | + | assert.Equal(t, want, renderedGrid(m)) | |
| 99 | + | ||
| 100 | + | // An unrelated swapped mode is ignored. | |
| 101 | + | m = modeModel(t, 1080, 1920, 751, 501, 1.2) | |
| 102 | + | buffer, err := core.MonitorGrid(751, 501, 1.2) | |
| 103 | + | require.NoError(t, err) | |
| 104 | + | assert.Equal(t, buffer, renderedGrid(m)) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestGridBufferMoreThanOnePixelPastTheModeFallsBack(t *testing.T) { | |
| 108 | + | // At scale 3 the surface is round(901/3) = 300 wide, a 900 buffer: the | |
| 109 | + | // mode is larger than the buffer, so the compositor resamples and the | |
| 110 | + | // mode is not a safe position source. | |
| 111 | + | m := modeModel(t, 901, 601, 300, 200, 3) | |
| 112 | + | buffer, err := core.MonitorGrid(300, 200, 3) | |
| 113 | + | require.NoError(t, err) | |
| 114 | + | assert.Equal(t, buffer, renderedGrid(m)) | |
| 115 | + | ||
| 116 | + | // A surface size the mode does not round to: 1000/3 is 333, not 334. | |
| 117 | + | m = modeModel(t, 1000, 1000, 334, 334, 3) | |
| 118 | + | buffer, err = core.MonitorGrid(334, 334, 3) | |
| 119 | + | require.NoError(t, err) | |
| 120 | + | assert.Equal(t, buffer, renderedGrid(m)) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestGridModePerOutputIsIndependent(t *testing.T) { | |
| 124 | + | sess, _ := testSession() | |
| 125 | + | outs := []ports.Output{ | |
| 126 | + | {ID: 7, Name: "DP-1", Width: 901, Height: 601, Scale: 1}, | |
| 127 | + | {ID: 9, Name: "HDMI-A-1", Width: 1280, Height: 720, Scale: 1}, | |
| 128 | + | } | |
| 129 | + | a := newModel(ports.Screenshot, outs, 0, sess, true) | |
| 130 | + | b := newModel(ports.Screenshot, outs, 1, sess, true) | |
| 131 | + | a.resize(751, 501, 1.2) | |
| 132 | + | b.resize(1280, 720, 1) | |
| 133 | + | wantA, err := core.MonitorGridPhysical(901, 601) | |
| 134 | + | require.NoError(t, err) | |
| 135 | + | wantB, err := core.MonitorGridPhysical(1280, 720) | |
| 136 | + | require.NoError(t, err) | |
| 137 | + | assert.Equal(t, wantA, renderedGrid(a)) | |
| 138 | + | assert.Equal(t, wantB, renderedGrid(b)) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func TestGridModeAllocations(t *testing.T) { | |
| 142 | + | if raceEnabled { | |
| 143 | + | t.Skip("the race detector adds allocations") | |
| 144 | + | } | |
| 145 | + | m := modeModel(t, 901, 601, 751, 501, 1.2) | |
| 146 | + | n := testing.AllocsPerRun(200, func() { | |
| 147 | + | m.resize(751, 501, 1.2) | |
| 148 | + | m.resize(751, 501, 1.25) | |
| 149 | + | _ = m.scene() | |
| 150 | + | }) | |
| 151 | + | assert.Zero(t, n) | |
| 152 | + | } | |
| @@ -0,0 +1,152 @@ | |||||
| 1 | + | package selection | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "image" | |||
| 5 | + | "math" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/nefercap/internal/core" | |||
| 12 | + | "github.com/bnema/nefercap/internal/ports" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // modeModel is a one-output overlay whose output mode is mw x mh and whose | |||
| 16 | + | // surface is w x h logical at scale. | |||
| 17 | + | func modeModel(t *testing.T, mw, mh, w, h int, scale float64) *model { | |||
| 18 | + | t.Helper() | |||
| 19 | + | sess, _ := testSession() | |||
| 20 | + | outs := []ports.Output{{ID: 7, Name: "DP-1", Width: mw, Height: mh, Scale: 1}} | |||
| 21 | + | m := newModel(ports.Screenshot, outs, 0, sess, true) | |||
| 22 | + | m.resize(w, h, scale) | |||
| 23 | + | require.Equal(t, core.PickActive, m.picker.Status()) | |||
| 24 | + | return m | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // renderedGrid returns the scene's lines as the renderer places them. | |||
| 28 | + | func renderedGrid(m *model) (g core.GridLines) { | |||
| 29 | + | for i, r := range m.scene().grid { | |||
| 30 | + | g[i] = physicalEdges(r, m.scale) | |||
| 31 | + | } | |||
| 32 | + | return g | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestGridFollowsTheOutputModeNotTheBuffer(t *testing.T) { | |||
| 36 | + | // The logical surface is round(mode/scale); its buffer is ceil(logical*scale) | |||
| 37 | + | // and can be one pixel larger than the mode, which the compositor crops. | |||
| 38 | + | // The guides must sit on the visible mode's thirds. | |||
| 39 | + | want, err := core.MonitorGridPhysical(901, 601) | |||
| 40 | + | require.NoError(t, err) | |||
| 41 | + | for _, scale := range []float64{1.2, 1.25, 1.5, 2} { | |||
| 42 | + | w, h := int(math.Round(901/scale)), int(math.Round(601/scale)) | |||
| 43 | + | m := modeModel(t, 901, 601, w, h, scale) | |||
| 44 | + | got := renderedGrid(m) | |||
| 45 | + | assert.Equal(t, want, got, "scale %g (%dx%d logical)", scale, w, h) | |||
| 46 | + | for i, x := range []int{300, 450, 600} { | |||
| 47 | + | assert.Equal(t, image.Rect(x, 0, x+1, 601), got[i], "scale %g vertical %d", scale, i) | |||
| 48 | + | } | |||
| 49 | + | for i, y := range []int{200, 300, 400} { | |||
| 50 | + | assert.Equal(t, image.Rect(0, y, 901, y+1), got[3+i], "scale %g horizontal %d", scale, i) | |||
| 51 | + | } | |||
| 52 | + | bw, _ := core.PhysicalExtent(w, scale) | |||
| 53 | + | bh, _ := core.PhysicalExtent(h, scale) | |||
| 54 | + | for i, r := range got { | |||
| 55 | + | assert.True(t, r.In(image.Rect(0, 0, bw, bh)), "scale %g line %d stays inside the %dx%d buffer", scale, i, bw, bh) | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestGridFallsBackToTheBufferWithoutAMatchingMode(t *testing.T) { | |||
| 61 | + | buffer, err := core.MonitorGrid(751, 501, 1.2) | |||
| 62 | + | require.NoError(t, err) | |||
| 63 | + | for name, c := range map[string]struct{ mw, mh int }{ | |||
| 64 | + | "unknown mode": {0, 0}, | |||
| 65 | + | "negative mode": {-901, -601}, | |||
| 66 | + | "width mismatches": {1920, 601}, | |||
| 67 | + | "height mismatches": {901, 1080}, | |||
| 68 | + | "stale config": {1920, 1080}, | |||
| 69 | + | "one pixel off": {903, 601}, // 903/1.2 rounds to 753 | |||
| 70 | + | } { | |||
| 71 | + | m := modeModel(t, c.mw, c.mh, 751, 501, 1.2) | |||
| 72 | + | assert.Equal(t, buffer, renderedGrid(m), name) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestGridModeIsCheckedAgainstEveryResize(t *testing.T) { | |||
| 77 | + | m := modeModel(t, 901, 601, 751, 501, 1.2) | |||
| 78 | + | mode, err := core.MonitorGridPhysical(901, 601) | |||
| 79 | + | require.NoError(t, err) | |||
| 80 | + | assert.Equal(t, mode, renderedGrid(m)) | |||
| 81 | + | ||||
| 82 | + | m.resize(751, 501, 1.25) // scale only: 901/1.25 is no longer 751 | |||
| 83 | + | buffer, err := core.MonitorGrid(751, 501, 1.25) | |||
| 84 | + | require.NoError(t, err) | |||
| 85 | + | assert.Equal(t, buffer, renderedGrid(m), "the mode no longer explains the surface") | |||
| 86 | + | ||||
| 87 | + | m.resize(721, 481, 1.25) // round(901/1.25) x round(601/1.25) | |||
| 88 | + | mode, err = core.MonitorGridPhysical(901, 601) | |||
| 89 | + | require.NoError(t, err) | |||
| 90 | + | assert.Equal(t, mode, renderedGrid(m), "and matches again when it does") | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestGridUsesRotatedModeWhenItMatchesSwapped(t *testing.T) { | |||
| 94 | + | // A mode reported as 601x901 with a surface of 751x501 logical. | |||
| 95 | + | m := modeModel(t, 601, 901, 751, 501, 1.2) | |||
| 96 | + | want, err := core.MonitorGridPhysical(901, 601) | |||
| 97 | + | require.NoError(t, err) | |||
| 98 | + | assert.Equal(t, want, renderedGrid(m)) | |||
| 99 | + | ||||
| 100 | + | // An unrelated swapped mode is ignored. | |||
| 101 | + | m = modeModel(t, 1080, 1920, 751, 501, 1.2) | |||
| 102 | + | buffer, err := core.MonitorGrid(751, 501, 1.2) | |||
| 103 | + | require.NoError(t, err) | |||
| 104 | + | assert.Equal(t, buffer, renderedGrid(m)) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func TestGridBufferMoreThanOnePixelPastTheModeFallsBack(t *testing.T) { | |||
| 108 | + | // At scale 3 the surface is round(901/3) = 300 wide, a 900 buffer: the | |||
| 109 | + | // mode is larger than the buffer, so the compositor resamples and the | |||
| 110 | + | // mode is not a safe position source. | |||
| 111 | + | m := modeModel(t, 901, 601, 300, 200, 3) | |||
| 112 | + | buffer, err := core.MonitorGrid(300, 200, 3) | |||
| 113 | + | require.NoError(t, err) | |||
| 114 | + | assert.Equal(t, buffer, renderedGrid(m)) | |||
| 115 | + | ||||
| 116 | + | // A surface size the mode does not round to: 1000/3 is 333, not 334. | |||
| 117 | + | m = modeModel(t, 1000, 1000, 334, 334, 3) | |||
| 118 | + | buffer, err = core.MonitorGrid(334, 334, 3) | |||
| 119 | + | require.NoError(t, err) | |||
| 120 | + | assert.Equal(t, buffer, renderedGrid(m)) | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestGridModePerOutputIsIndependent(t *testing.T) { | |||
| 124 | + | sess, _ := testSession() | |||
| 125 | + | outs := []ports.Output{ | |||
| 126 | + | {ID: 7, Name: "DP-1", Width: 901, Height: 601, Scale: 1}, | |||
| 127 | + | {ID: 9, Name: "HDMI-A-1", Width: 1280, Height: 720, Scale: 1}, | |||
| 128 | + | } | |||
| 129 | + | a := newModel(ports.Screenshot, outs, 0, sess, true) | |||
| 130 | + | b := newModel(ports.Screenshot, outs, 1, sess, true) | |||
| 131 | + | a.resize(751, 501, 1.2) | |||
| 132 | + | b.resize(1280, 720, 1) | |||
| 133 | + | wantA, err := core.MonitorGridPhysical(901, 601) | |||
| 134 | + | require.NoError(t, err) | |||
| 135 | + | wantB, err := core.MonitorGridPhysical(1280, 720) | |||
| 136 | + | require.NoError(t, err) | |||
| 137 | + | assert.Equal(t, wantA, renderedGrid(a)) | |||
| 138 | + | assert.Equal(t, wantB, renderedGrid(b)) | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func TestGridModeAllocations(t *testing.T) { | |||
| 142 | + | if raceEnabled { | |||
| 143 | + | t.Skip("the race detector adds allocations") | |||
| 144 | + | } | |||
| 145 | + | m := modeModel(t, 901, 601, 751, 501, 1.2) | |||
| 146 | + | n := testing.AllocsPerRun(200, func() { | |||
| 147 | + | m.resize(751, 501, 1.2) | |||
| 148 | + | m.resize(751, 501, 1.25) | |||
| 149 | + | _ = m.scene() | |||
| 150 | + | }) | |||
| 151 | + | assert.Zero(t, n) | |||
| 152 | + | } | |||
M internal/adapters/selection/model.go +72 -8
| @@ -2,6 +2,7 @@ package selection | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "math" | |
| 5 | 6 | "strconv" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/nefergui" | |
| @@ -33,6 +34,12 @@ type model struct { | |||
| 33 | 34 | picker core.Picker | |
| 34 | 35 | sized bool | |
| 35 | 36 | w, h int | |
| 37 | + | scale float64 // fractional output scale from OnResize; 0 before the first resize | |
| 38 | + | ||
| 39 | + | // The monitor grid belongs to the overlay, not the picker: Begin resets | |
| 40 | + | // the picker on a size change but the user's toggle must survive it. | |
| 41 | + | grid bool | |
| 42 | + | gridLines [core.GridLineCount]frect // whole-monitor guides, logical units | |
| 36 | 43 | ||
| 37 | 44 | end endKind | |
| 38 | 45 | sess *session // shared first-decision record; the only cross-overlay state | |
| @@ -43,23 +50,80 @@ type model struct { | |||
| 43 | 50 | label label | |
| 44 | 51 | } | |
| 45 | 52 | ||
| 46 | - | func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session) *model { | |
| 47 | - | return &model{mode: mode, outputs: outputs, index: index, sess: sess, header: headerText(outputs, index), footerText: footerText(mode)} | |
| 53 | + | func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session, grid bool) *model { | |
| 54 | + | return &model{mode: mode, outputs: outputs, index: index, sess: sess, grid: grid, header: headerText(outputs, index), footerText: footerText(mode)} | |
| 48 | 55 | } | |
| 49 | 56 | ||
| 50 | - | // resize (re)starts the picker when the logical output size changes. It is | |
| 51 | - | // the OnResize callback; scale-only changes keep the current pick. | |
| 52 | - | func (m *model) resize(w, h int, _ float64) { | |
| 57 | + | // resize is the OnResize callback. A logical size change (re)starts the | |
| 58 | + | // picker; a scale-only change keeps the current pick. Either way the grid is | |
| 59 | + | // laid out again on this output's physical pixels. | |
| 60 | + | func (m *model) resize(w, h int, scale float64) { | |
| 53 | 61 | if m.sized && w == m.w && h == m.h { | |
| 62 | + | m.scale = scale | |
| 63 | + | m.layoutGrid() | |
| 54 | 64 | return | |
| 55 | 65 | } | |
| 56 | 66 | if err := m.picker.Begin(m.outputs[m.index].ID, w, h); err != nil { | |
| 57 | 67 | m.fail(fmt.Errorf("resize to %dx%d: %w", w, h, err)) | |
| 58 | 68 | return | |
| 59 | 69 | } | |
| 60 | - | m.sized, m.w, m.h = true, w, h | |
| 70 | + | m.sized, m.w, m.h, m.scale = true, w, h, scale | |
| 61 | 71 | m.label.clear() | |
| 62 | 72 | m.picker.SetWorkspace(m.workspace.ID, m.workspace.Region) | |
| 73 | + | m.layoutGrid() | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // layoutGrid derives the logical guide rectangles from exact physical lines: | |
| 77 | + | // snapping happens in physical pixels, and only then is each edge divided by | |
| 78 | + | // the scale, so the renderer's edge rounding returns the same whole pixels. An | |
| 79 | + | // unusable scale draws no grid instead of ending the selection. | |
| 80 | + | // | |
| 81 | + | // The lines follow the output mode when it explains the surface, because that | |
| 82 | + | // is the monitor the user sees: a fractional scale rounds the logical size, so | |
| 83 | + | // the surface buffer can be a pixel larger than the mode and the compositor | |
| 84 | + | // crops that pixel. Otherwise (unknown, stale or unrelated mode) they follow | |
| 85 | + | // the surface buffer, core.MonitorGrid. | |
| 86 | + | func (m *model) layoutGrid() { | |
| 87 | + | m.gridLines = [core.GridLineCount]frect{} | |
| 88 | + | lines, err := m.physicalGrid() | |
| 89 | + | if err != nil { | |
| 90 | + | return | |
| 91 | + | } | |
| 92 | + | for i, r := range lines { | |
| 93 | + | m.gridLines[i] = frect{ | |
| 94 | + | float64(r.Min.X) / m.scale, float64(r.Min.Y) / m.scale, | |
| 95 | + | float64(r.Dx()) / m.scale, float64(r.Dy()) / m.scale, | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // physicalGrid is the grid in physical pixels; see layoutGrid. | |
| 101 | + | func (m *model) physicalGrid() (core.GridLines, error) { | |
| 102 | + | buffer, err := core.MonitorGrid(m.w, m.h, m.scale) | |
| 103 | + | if err != nil { | |
| 104 | + | return buffer, err | |
| 105 | + | } | |
| 106 | + | bw, _ := core.PhysicalExtent(m.w, m.scale) | |
| 107 | + | bh, _ := core.PhysicalExtent(m.h, m.scale) | |
| 108 | + | out := m.outputs[m.index] | |
| 109 | + | // A rotated output reports its mode unrotated, so try both orientations. | |
| 110 | + | for _, mode := range [2][2]int{{out.Width, out.Height}, {out.Height, out.Width}} { | |
| 111 | + | if modeFits(mode[0], m.w, bw, m.scale) && modeFits(mode[1], m.h, bh, m.scale) { | |
| 112 | + | if lines, err := core.MonitorGridPhysical(mode[0], mode[1]); err == nil { | |
| 113 | + | return lines, nil | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | return buffer, nil | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // modeFits reports whether a physical mode extent is what the compositor shows | |
| 121 | + | // of a surface axis: the logical size is the mode divided by the scale and | |
| 122 | + | // rounded, and the buffer is the same size or one pixel larger, which the | |
| 123 | + | // compositor crops instead of resampling. Anything else (an unknown or stale | |
| 124 | + | // mode, a rounding gap, a buffer smaller than the mode) is not trusted. | |
| 125 | + | func modeFits(mode, logical, buffer int, scale float64) bool { | |
| 126 | + | return mode > 0 && int(math.Round(float64(mode)/scale)) == logical && buffer-mode >= 0 && buffer-mode <= 1 | |
| 63 | 127 | } | |
| 64 | 128 | ||
| 65 | 129 | // setWorkspaces keeps the workspace that is currently displayed on this | |
| @@ -99,7 +163,7 @@ type visible struct { | |||
| 99 | 163 | ||
| 100 | 164 | func (m *model) snapshot() visible { | |
| 101 | 165 | r, _ := m.picker.Rect() | |
| 102 | - | return visible{r, m.picker.Dragging(), m.picker.Kind(), m.picker.Grid(), m.picker.Status()} | |
| 166 | + | return visible{r, m.picker.Dragging(), m.picker.Kind(), m.grid, m.picker.Status()} | |
| 103 | 167 | } | |
| 104 | 168 | ||
| 105 | 169 | // input is the OnInput callback. It reports whether the drawn state changed, | |
| @@ -147,7 +211,7 @@ func (m *model) key(name string) { | |||
| 147 | 211 | case "r", "R": | |
| 148 | 212 | m.picker.SelectRegion() | |
| 149 | 213 | case "g", "G": | |
| 150 | - | m.picker.ToggleGrid() | |
| 214 | + | m.grid = !m.grid | |
| 151 | 215 | case "Return", "KP_Enter": | |
| 152 | 216 | m.picker.Confirm() | |
| 153 | 217 | default: | |
| @@ -2,6 +2,7 @@ package selection | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | + | "math" | |||
| 5 | "strconv" | 6 | "strconv" | ||
| 6 | 7 | ||||
| 7 | "github.com/bnema/nefergui" | 8 | "github.com/bnema/nefergui" | ||
| @@ -33,6 +34,12 @@ type model struct { | |||||
| 33 | picker core.Picker | 34 | picker core.Picker | ||
| 34 | sized bool | 35 | sized bool | ||
| 35 | w, h int | 36 | w, h int | ||
| 37 | + | scale float64 // fractional output scale from OnResize; 0 before the first resize | |||
| 38 | + | ||||
| 39 | + | // The monitor grid belongs to the overlay, not the picker: Begin resets | |||
| 40 | + | // the picker on a size change but the user's toggle must survive it. | |||
| 41 | + | grid bool | |||
| 42 | + | gridLines [core.GridLineCount]frect // whole-monitor guides, logical units | |||
| 36 | 43 | ||||
| 37 | end endKind | 44 | end endKind | ||
| 38 | sess *session // shared first-decision record; the only cross-overlay state | 45 | sess *session // shared first-decision record; the only cross-overlay state | ||
| @@ -43,23 +50,80 @@ type model struct { | |||||
| 43 | label label | 50 | label label | ||
| 44 | } | 51 | } | ||
| 45 | 52 | ||||
| 46 | - | func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session) *model { | 53 | + | func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session, grid bool) *model { |
| 47 | - | return &model{mode: mode, outputs: outputs, index: index, sess: sess, header: headerText(outputs, index), footerText: footerText(mode)} | 54 | + | return &model{mode: mode, outputs: outputs, index: index, sess: sess, grid: grid, header: headerText(outputs, index), footerText: footerText(mode)} |
| 48 | } | 55 | } | ||
| 49 | 56 | ||||
| 50 | - | // resize (re)starts the picker when the logical output size changes. It is | 57 | + | // resize is the OnResize callback. A logical size change (re)starts the |
| 51 | - | // the OnResize callback; scale-only changes keep the current pick. | 58 | + | // picker; a scale-only change keeps the current pick. Either way the grid is |
| 52 | - | func (m *model) resize(w, h int, _ float64) { | 59 | + | // laid out again on this output's physical pixels. |
| 60 | + | func (m *model) resize(w, h int, scale float64) { | |||
| 53 | if m.sized && w == m.w && h == m.h { | 61 | if m.sized && w == m.w && h == m.h { | ||
| 62 | + | m.scale = scale | |||
| 63 | + | m.layoutGrid() | |||
| 54 | return | 64 | return | ||
| 55 | } | 65 | } | ||
| 56 | if err := m.picker.Begin(m.outputs[m.index].ID, w, h); err != nil { | 66 | if err := m.picker.Begin(m.outputs[m.index].ID, w, h); err != nil { | ||
| 57 | m.fail(fmt.Errorf("resize to %dx%d: %w", w, h, err)) | 67 | m.fail(fmt.Errorf("resize to %dx%d: %w", w, h, err)) | ||
| 58 | return | 68 | return | ||
| 59 | } | 69 | } | ||
| 60 | - | m.sized, m.w, m.h = true, w, h | 70 | + | m.sized, m.w, m.h, m.scale = true, w, h, scale |
| 61 | m.label.clear() | 71 | m.label.clear() | ||
| 62 | m.picker.SetWorkspace(m.workspace.ID, m.workspace.Region) | 72 | m.picker.SetWorkspace(m.workspace.ID, m.workspace.Region) | ||
| 73 | + | m.layoutGrid() | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // layoutGrid derives the logical guide rectangles from exact physical lines: | |||
| 77 | + | // snapping happens in physical pixels, and only then is each edge divided by | |||
| 78 | + | // the scale, so the renderer's edge rounding returns the same whole pixels. An | |||
| 79 | + | // unusable scale draws no grid instead of ending the selection. | |||
| 80 | + | // | |||
| 81 | + | // The lines follow the output mode when it explains the surface, because that | |||
| 82 | + | // is the monitor the user sees: a fractional scale rounds the logical size, so | |||
| 83 | + | // the surface buffer can be a pixel larger than the mode and the compositor | |||
| 84 | + | // crops that pixel. Otherwise (unknown, stale or unrelated mode) they follow | |||
| 85 | + | // the surface buffer, core.MonitorGrid. | |||
| 86 | + | func (m *model) layoutGrid() { | |||
| 87 | + | m.gridLines = [core.GridLineCount]frect{} | |||
| 88 | + | lines, err := m.physicalGrid() | |||
| 89 | + | if err != nil { | |||
| 90 | + | return | |||
| 91 | + | } | |||
| 92 | + | for i, r := range lines { | |||
| 93 | + | m.gridLines[i] = frect{ | |||
| 94 | + | float64(r.Min.X) / m.scale, float64(r.Min.Y) / m.scale, | |||
| 95 | + | float64(r.Dx()) / m.scale, float64(r.Dy()) / m.scale, | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // physicalGrid is the grid in physical pixels; see layoutGrid. | |||
| 101 | + | func (m *model) physicalGrid() (core.GridLines, error) { | |||
| 102 | + | buffer, err := core.MonitorGrid(m.w, m.h, m.scale) | |||
| 103 | + | if err != nil { | |||
| 104 | + | return buffer, err | |||
| 105 | + | } | |||
| 106 | + | bw, _ := core.PhysicalExtent(m.w, m.scale) | |||
| 107 | + | bh, _ := core.PhysicalExtent(m.h, m.scale) | |||
| 108 | + | out := m.outputs[m.index] | |||
| 109 | + | // A rotated output reports its mode unrotated, so try both orientations. | |||
| 110 | + | for _, mode := range [2][2]int{{out.Width, out.Height}, {out.Height, out.Width}} { | |||
| 111 | + | if modeFits(mode[0], m.w, bw, m.scale) && modeFits(mode[1], m.h, bh, m.scale) { | |||
| 112 | + | if lines, err := core.MonitorGridPhysical(mode[0], mode[1]); err == nil { | |||
| 113 | + | return lines, nil | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | return buffer, nil | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // modeFits reports whether a physical mode extent is what the compositor shows | |||
| 121 | + | // of a surface axis: the logical size is the mode divided by the scale and | |||
| 122 | + | // rounded, and the buffer is the same size or one pixel larger, which the | |||
| 123 | + | // compositor crops instead of resampling. Anything else (an unknown or stale | |||
| 124 | + | // mode, a rounding gap, a buffer smaller than the mode) is not trusted. | |||
| 125 | + | func modeFits(mode, logical, buffer int, scale float64) bool { | |||
| 126 | + | return mode > 0 && int(math.Round(float64(mode)/scale)) == logical && buffer-mode >= 0 && buffer-mode <= 1 | |||
| 63 | } | 127 | } | ||
| 64 | 128 | ||||
| 65 | // setWorkspaces keeps the workspace that is currently displayed on this | 129 | // setWorkspaces keeps the workspace that is currently displayed on this | ||
| @@ -99,7 +163,7 @@ type visible struct { | |||||
| 99 | 163 | ||||
| 100 | func (m *model) snapshot() visible { | 164 | func (m *model) snapshot() visible { | ||
| 101 | r, _ := m.picker.Rect() | 165 | r, _ := m.picker.Rect() | ||
| 102 | - | return visible{r, m.picker.Dragging(), m.picker.Kind(), m.picker.Grid(), m.picker.Status()} | 166 | + | return visible{r, m.picker.Dragging(), m.picker.Kind(), m.grid, m.picker.Status()} |
| 103 | } | 167 | } | ||
| 104 | 168 | ||||
| 105 | // input is the OnInput callback. It reports whether the drawn state changed, | 169 | // input is the OnInput callback. It reports whether the drawn state changed, | ||
| @@ -147,7 +211,7 @@ func (m *model) key(name string) { | |||||
| 147 | case "r", "R": | 211 | case "r", "R": | ||
| 148 | m.picker.SelectRegion() | 212 | m.picker.SelectRegion() | ||
| 149 | case "g", "G": | 213 | case "g", "G": | ||
| 150 | - | m.picker.ToggleGrid() | 214 | + | m.grid = !m.grid |
| 151 | case "Return", "KP_Enter": | 215 | case "Return", "KP_Enter": | ||
| 152 | m.picker.Confirm() | 216 | m.picker.Confirm() | ||
| 153 | default: | 217 | default: | ||
M internal/adapters/selection/scene.go +5 -14
| @@ -35,9 +35,9 @@ type frect struct{ x, y, w, h float64 } | |||
| 35 | 35 | // scene is the complete geometry of one frame, derived from the picker. It is | |
| 36 | 36 | // a plain value so computing it never allocates. | |
| 37 | 37 | type scene struct { | |
| 38 | - | dim [4]frect // top, bottom, left, right of the selection; dim[0] is all when none | |
| 39 | - | edges [4]frect // top, bottom, left, right lines | |
| 40 | - | grid [4]frect // two vertical then two horizontal thirds lines | |
| 38 | + | dim [4]frect // top, bottom, left, right of the selection; dim[0] is all when none | |
| 39 | + | edges [4]frect // top, bottom, left, right lines | |
| 40 | + | grid [core.GridLineCount]frect // whole-monitor guides: three vertical then three horizontal | |
| 41 | 41 | label frect | |
| 42 | 42 | header frect | |
| 43 | 43 | footer frect | |
| @@ -86,17 +86,8 @@ func (m *model) scene() scene { | |||
| 86 | 86 | {fr.x + fr.w - edge, fr.y, edge, fr.h}, | |
| 87 | 87 | } | |
| 88 | 88 | } | |
| 89 | - | if m.picker.Grid() { | |
| 90 | - | g := frect{0, 0, w, h} | |
| 91 | - | if outline { | |
| 92 | - | g = fr | |
| 93 | - | } | |
| 94 | - | s.grid = [4]frect{ | |
| 95 | - | {g.x + g.w/3, g.y, edge, g.h}, | |
| 96 | - | {g.x + 2*g.w/3, g.y, edge, g.h}, | |
| 97 | - | {g.x, g.y + g.h/3, g.w, edge}, | |
| 98 | - | {g.x, g.y + 2*g.h/3, g.w, edge}, | |
| 99 | - | } | |
| 89 | + | if m.grid { | |
| 90 | + | s.grid = m.gridLines // the whole monitor, whatever is being dragged | |
| 100 | 91 | } | |
| 101 | 92 | if m.label.text != "" && outline { | |
| 102 | 93 | s.label = labelRect(fr, w, h) | |
| @@ -35,9 +35,9 @@ type frect struct{ x, y, w, h float64 } | |||||
| 35 | // scene is the complete geometry of one frame, derived from the picker. It is | 35 | // scene is the complete geometry of one frame, derived from the picker. It is | ||
| 36 | // a plain value so computing it never allocates. | 36 | // a plain value so computing it never allocates. | ||
| 37 | type scene struct { | 37 | type scene struct { | ||
| 38 | - | dim [4]frect // top, bottom, left, right of the selection; dim[0] is all when none | 38 | + | dim [4]frect // top, bottom, left, right of the selection; dim[0] is all when none |
| 39 | - | edges [4]frect // top, bottom, left, right lines | 39 | + | edges [4]frect // top, bottom, left, right lines |
| 40 | - | grid [4]frect // two vertical then two horizontal thirds lines | 40 | + | grid [core.GridLineCount]frect // whole-monitor guides: three vertical then three horizontal |
| 41 | label frect | 41 | label frect | ||
| 42 | header frect | 42 | header frect | ||
| 43 | footer frect | 43 | footer frect | ||
| @@ -86,17 +86,8 @@ func (m *model) scene() scene { | |||||
| 86 | {fr.x + fr.w - edge, fr.y, edge, fr.h}, | 86 | {fr.x + fr.w - edge, fr.y, edge, fr.h}, | ||
| 87 | } | 87 | } | ||
| 88 | } | 88 | } | ||
| 89 | - | if m.picker.Grid() { | 89 | + | if m.grid { |
| 90 | - | g := frect{0, 0, w, h} | 90 | + | s.grid = m.gridLines // the whole monitor, whatever is being dragged |
| 91 | - | if outline { | |||
| 92 | - | g = fr | |||
| 93 | - | } | |||
| 94 | - | s.grid = [4]frect{ | |||
| 95 | - | {g.x + g.w/3, g.y, edge, g.h}, | |||
| 96 | - | {g.x + 2*g.w/3, g.y, edge, g.h}, | |||
| 97 | - | {g.x, g.y + g.h/3, g.w, edge}, | |||
| 98 | - | {g.x, g.y + 2*g.h/3, g.w, edge}, | |||
| 99 | - | } | |||
| 100 | } | 91 | } | ||
| 101 | if m.label.text != "" && outline { | 92 | if m.label.text != "" && outline { | ||
| 102 | s.label = labelRect(fr, w, h) | 93 | s.label = labelRect(fr, w, h) | ||
M internal/adapters/selection/selection_test.go +181 -15
| @@ -4,6 +4,8 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | + | "image" | |
| 8 | + | "math" | |
| 7 | 9 | "strings" | |
| 8 | 10 | "testing" | |
| 9 | 11 | ||
| @@ -29,7 +31,7 @@ func testSession() (*session, context.Context) { | |||
| 29 | 31 | func newTestModel(t *testing.T) *model { | |
| 30 | 32 | t.Helper() | |
| 31 | 33 | sess, _ := testSession() | |
| 32 | - | m := newModel(ports.Screenshot, testOutputs, 0, sess) | |
| 34 | + | m := newModel(ports.Screenshot, testOutputs, 0, sess, false) | |
| 33 | 35 | m.resize(1920, 1080, 1) | |
| 34 | 36 | require.Equal(t, core.PickActive, m.picker.Status()) | |
| 35 | 37 | return m | |
| @@ -38,7 +40,7 @@ func newTestModel(t *testing.T) *model { | |||
| 38 | 40 | // newBareModel has no size yet, like an overlay before its first configure. | |
| 39 | 41 | func newBareModel() (*model, context.Context) { | |
| 40 | 42 | sess, ctx := testSession() | |
| 41 | - | return newModel(ports.Screenshot, testOutputs, 0, sess), ctx | |
| 43 | + | return newModel(ports.Screenshot, testOutputs, 0, sess, false), ctx | |
| 42 | 44 | } | |
| 43 | 45 | ||
| 44 | 46 | func motion(x, y float64) nefergui.InputEvent { | |
| @@ -80,7 +82,7 @@ func TestClickAcceptsMonitor(t *testing.T) { | |||
| 80 | 82 | require.Equal(t, endAccepted, m.end) | |
| 81 | 83 | res, _, _ := m.sess.outcome(context.Background()) | |
| 82 | 84 | assert.Equal(t, core.PickMonitor, res.Kind) | |
| 83 | - | sel := New(ports.Screenshot, ports.VideoSettings{}, nil).selection(res) | |
| 85 | + | sel := New(ports.Screenshot, ports.VideoSettings{}, nil, false).selection(res) | |
| 84 | 86 | assert.Equal(t, ports.Target{OutputID: 7}, sel.Target) | |
| 85 | 87 | } | |
| 86 | 88 | ||
| @@ -97,7 +99,7 @@ func TestMonitorKeyThenEnter(t *testing.T) { | |||
| 97 | 99 | ||
| 98 | 100 | func TestEscapeAbortsEveryOverlay(t *testing.T) { | |
| 99 | 101 | sess, ctx := testSession() | |
| 100 | - | m := newModel(ports.Screenshot, testOutputs, 1, sess) | |
| 102 | + | m := newModel(ports.Screenshot, testOutputs, 1, sess, false) | |
| 101 | 103 | m.resize(1280, 720, 1) | |
| 102 | 104 | m.input(key("Escape")) | |
| 103 | 105 | assert.Equal(t, endCanceled, m.end) | |
| @@ -117,7 +119,7 @@ func TestDigitPicksMonitorFromAnyOverlay(t *testing.T) { | |||
| 117 | 119 | require.NoError(t, err) | |
| 118 | 120 | require.True(t, ok) | |
| 119 | 121 | assert.Equal(t, core.PickResult{OutputID: 9, Kind: core.PickMonitor}, res) | |
| 120 | - | sel := New(ports.Screenshot, ports.VideoSettings{}, nil).selection(res) | |
| 122 | + | sel := New(ports.Screenshot, ports.VideoSettings{}, nil, false).selection(res) | |
| 121 | 123 | assert.Equal(t, ports.Target{OutputID: 9}, sel.Target) | |
| 122 | 124 | ||
| 123 | 125 | own := newTestModel(t) | |
| @@ -179,7 +181,7 @@ func TestInputResetBeforeThresholdAndKeepsChoices(t *testing.T) { | |||
| 179 | 181 | assert.Equal(t, endNone, m.end) | |
| 180 | 182 | ||
| 181 | 183 | assert.Equal(t, core.PickWorkspace, m.picker.Kind(), "kind survives a reset") | |
| 182 | - | assert.True(t, m.picker.Grid(), "grid survives a reset") | |
| 184 | + | assert.True(t, m.grid, "grid survives a reset") | |
| 183 | 185 | id, _, ok := m.picker.Workspace() | |
| 184 | 186 | assert.True(t, ok) | |
| 185 | 187 | assert.Equal(t, uint64(11), id, "workspace survives a reset") | |
| @@ -189,7 +191,7 @@ func TestInputResetBeforeThresholdAndKeepsChoices(t *testing.T) { | |||
| 189 | 191 | res, ok, err := m.sess.outcome(context.Background()) | |
| 190 | 192 | require.NoError(t, err) | |
| 191 | 193 | require.True(t, ok) | |
| 192 | - | sel := New(ports.Screenshot, ports.VideoSettings{}, ws).selection(res) | |
| 194 | + | sel := New(ports.Screenshot, ports.VideoSettings{}, ws, false).selection(res) | |
| 193 | 195 | assert.Equal(t, ports.Target{OutputID: 7, WorkspaceID: 11}, sel.Target, "a workspace has a stable ID and a zero Region") | |
| 194 | 196 | } | |
| 195 | 197 | ||
| @@ -239,7 +241,7 @@ func TestWorkspaceOnlyWhenRealAndOnThisOutput(t *testing.T) { | |||
| 239 | 241 | res, _, _ := m.sess.outcome(context.Background()) | |
| 240 | 242 | assert.Equal(t, core.PickWorkspace, res.Kind) | |
| 241 | 243 | assert.Equal(t, uint64(11), res.WorkspaceID) | |
| 242 | - | sel := New(ports.Screenshot, ports.VideoSettings{}, ws).selection(res) | |
| 244 | + | sel := New(ports.Screenshot, ports.VideoSettings{}, ws, false).selection(res) | |
| 243 | 245 | assert.Equal(t, ports.Target{OutputID: 7, WorkspaceID: 11}, sel.Target, "workspace geometry is live from the compositor, not a cached crop") | |
| 244 | 246 | } | |
| 245 | 247 | ||
| @@ -266,7 +268,7 @@ func TestOnlyTheActiveWorkspaceIsOffered(t *testing.T) { | |||
| 266 | 268 | } | |
| 267 | 269 | ||
| 268 | 270 | func TestSelectionConversionUsesCoreTarget(t *testing.T) { | |
| 269 | - | s := New(ports.Screenshot, ports.VideoSettings{}, nil) | |
| 271 | + | s := New(ports.Screenshot, ports.VideoSettings{}, nil, false) | |
| 270 | 272 | region := ports.Region{X: 10, Y: 20, Width: 300, Height: 200} | |
| 271 | 273 | assert.Equal(t, ports.Target{OutputID: 7, Region: region}, | |
| 272 | 274 | s.selection(core.PickResult{OutputID: 7, Kind: core.PickRegion, Region: region}).Target, "a dragged region keeps its geometry") | |
| @@ -277,14 +279,14 @@ func TestSelectionConversionUsesCoreTarget(t *testing.T) { | |||
| 277 | 279 | } | |
| 278 | 280 | ||
| 279 | 281 | func TestNewSelectionDefaults(t *testing.T) { | |
| 280 | - | sel := New(ports.Record, ports.VideoSettings{}, nil).selection(core.PickResult{OutputID: 7, Kind: core.PickMonitor}) | |
| 282 | + | sel := New(ports.Record, ports.VideoSettings{}, nil, false).selection(core.PickResult{OutputID: 7, Kind: core.PickMonitor}) | |
| 281 | 283 | assert.Equal(t, ports.Record, sel.Mode) | |
| 282 | 284 | assert.Equal(t, DefaultFPS, sel.Video.FPS) | |
| 283 | 285 | assert.Empty(t, sel.Path) | |
| 284 | 286 | ||
| 285 | 287 | custom := ports.VideoSettings{FPS: 60, Width: 640, Height: 360} | |
| 286 | - | assert.Equal(t, custom, New(ports.Record, custom, nil).selection(core.PickResult{}).Video) | |
| 287 | - | assert.Equal(t, ports.VideoSettings{}, New(ports.Screenshot, custom, nil).selection(core.PickResult{}).Video) | |
| 288 | + | assert.Equal(t, custom, New(ports.Record, custom, nil, false).selection(core.PickResult{}).Video) | |
| 289 | + | assert.Equal(t, ports.VideoSettings{}, New(ports.Screenshot, custom, nil, false).selection(core.PickResult{}).Video) | |
| 288 | 290 | } | |
| 289 | 291 | ||
| 290 | 292 | func TestSelectRejectsTooManyOutputsWithoutOpeningAnything(t *testing.T) { | |
| @@ -292,13 +294,13 @@ func TestSelectRejectsTooManyOutputsWithoutOpeningAnything(t *testing.T) { | |||
| 292 | 294 | for i := range outs { | |
| 293 | 295 | outs[i] = ports.Output{ID: uint32(i + 1), Name: "O"} | |
| 294 | 296 | } | |
| 295 | - | _, ok, err := New(ports.Screenshot, ports.VideoSettings{}, nil).Select(context.Background(), outs) | |
| 297 | + | _, ok, err := New(ports.Screenshot, ports.VideoSettings{}, nil, false).Select(context.Background(), outs) | |
| 296 | 298 | assert.False(t, ok) | |
| 297 | 299 | assert.ErrorIs(t, err, ErrTooManyOutputs) | |
| 298 | 300 | } | |
| 299 | 301 | ||
| 300 | 302 | func TestSelectRejectsBadInput(t *testing.T) { | |
| 301 | - | s := New(ports.Screenshot, ports.VideoSettings{}, nil) | |
| 303 | + | s := New(ports.Screenshot, ports.VideoSettings{}, nil, false) | |
| 302 | 304 | _, ok, err := s.Select(context.Background(), nil) | |
| 303 | 305 | assert.False(t, ok) | |
| 304 | 306 | assert.ErrorIs(t, err, ports.ErrOutputNotFound) | |
| @@ -422,7 +424,171 @@ func TestSceneMonitorOutlineAndGrid(t *testing.T) { | |||
| 422 | 424 | assert.Equal(t, [4]frect{}, s.dim, "monitor choice is not dimmed") | |
| 423 | 425 | assert.Equal(t, frect{0, 0, 1920, 1}, s.edges[0]) | |
| 424 | 426 | assert.Equal(t, frect{640, 0, 1, 1080}, s.grid[0]) | |
| 425 | - | assert.Equal(t, frect{0, 720, 1920, 1}, s.grid[3]) | |
| 427 | + | assert.Equal(t, frect{0, 720, 1920, 1}, s.grid[5]) | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | // physicalEdges converts a logical rectangle the way the renderer does: | |
| 431 | + | // each edge is rounded to a physical pixel independently. | |
| 432 | + | func physicalEdges(r frect, scale float64) image.Rectangle { | |
| 433 | + | return image.Rect( | |
| 434 | + | int(math.Round(r.x*scale)), int(math.Round(r.y*scale)), | |
| 435 | + | int(math.Round((r.x+r.w)*scale)), int(math.Round((r.y+r.h)*scale))) | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | func gridModel(t *testing.T, grid bool, w, h int, scale float64) *model { | |
| 439 | + | t.Helper() | |
| 440 | + | sess, _ := testSession() | |
| 441 | + | m := newModel(ports.Screenshot, testOutputs, 0, sess, grid) | |
| 442 | + | m.resize(w, h, scale) | |
| 443 | + | require.Equal(t, core.PickActive, m.picker.Status()) | |
| 444 | + | return m | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | func TestGridDefaultAndToggle(t *testing.T) { | |
| 448 | + | on := gridModel(t, true, 1920, 1080, 1) | |
| 449 | + | assert.NotEqual(t, [core.GridLineCount]frect{}, on.scene().grid, "configured on: drawn at once") | |
| 450 | + | assert.True(t, on.input(key("g"))) | |
| 451 | + | assert.Equal(t, [core.GridLineCount]frect{}, on.scene().grid, "G turns it off") | |
| 452 | + | assert.True(t, on.input(key("G"))) | |
| 453 | + | assert.NotEqual(t, [core.GridLineCount]frect{}, on.scene().grid, "and on again") | |
| 454 | + | ||
| 455 | + | off := gridModel(t, false, 1920, 1080, 1) | |
| 456 | + | assert.Equal(t, [core.GridLineCount]frect{}, off.scene().grid, "configured off: nothing drawn") | |
| 457 | + | assert.True(t, off.input(key("g"))) | |
| 458 | + | assert.NotEqual(t, [core.GridLineCount]frect{}, off.scene().grid) | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | func TestGridRendersExactPhysicalPixels(t *testing.T) { | |
| 462 | + | for _, c := range []struct { | |
| 463 | + | w, h int | |
| 464 | + | scale float64 | |
| 465 | + | }{ | |
| 466 | + | {1920, 1080, 1}, {1600, 900, 1.2}, {1536, 864, 1.25}, {1280, 720, 1.5}, {960, 540, 2}, | |
| 467 | + | {1366, 768, 1}, {1367, 769, 1.25}, {1001, 601, 1.2}, {1279, 719, 1.5}, {853, 481, 2.25}, | |
| 468 | + | } { | |
| 469 | + | m := gridModel(t, true, c.w, c.h, c.scale) | |
| 470 | + | want, err := core.MonitorGrid(c.w, c.h, c.scale) | |
| 471 | + | require.NoError(t, err) | |
| 472 | + | for i, r := range m.scene().grid { | |
| 473 | + | got := physicalEdges(r, c.scale) | |
| 474 | + | assert.Equal(t, want[i], got, "%dx%d@%g line %d", c.w, c.h, c.scale, i) | |
| 475 | + | if i < 3 { | |
| 476 | + | assert.Equal(t, 1, got.Dx(), "%dx%d@%g vertical %d is one physical pixel", c.w, c.h, c.scale, i) | |
| 477 | + | } else { | |
| 478 | + | assert.Equal(t, 1, got.Dy(), "%dx%d@%g horizontal %d is one physical pixel", c.w, c.h, c.scale, i) | |
| 479 | + | } | |
| 480 | + | } | |
| 481 | + | } | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | func TestGridSweepRendersOnePhysicalPixel(t *testing.T) { | |
| 485 | + | // Every compositor fractional scale (k/120) over many logical sizes. | |
| 486 | + | for k := 120; k <= 360; k++ { | |
| 487 | + | scale := float64(k) / 120 | |
| 488 | + | for w := 640; w <= 3000; w += 37 { | |
| 489 | + | h := w*9/16 + 1 | |
| 490 | + | m := gridModel(t, true, w, h, scale) | |
| 491 | + | want, err := core.MonitorGrid(w, h, scale) | |
| 492 | + | require.NoError(t, err) | |
| 493 | + | for i, r := range m.scene().grid { | |
| 494 | + | if got := physicalEdges(r, scale); got != want[i] { | |
| 495 | + | t.Fatalf("%dx%d@%d/120 line %d renders %v, want %v", w, h, k, i, got, want[i]) | |
| 496 | + | } | |
| 497 | + | } | |
| 498 | + | } | |
| 499 | + | } | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | func TestGridIsPerOutputScale(t *testing.T) { | |
| 503 | + | sess, _ := testSession() | |
| 504 | + | a := newModel(ports.Screenshot, testOutputs, 0, sess, true) | |
| 505 | + | b := newModel(ports.Screenshot, testOutputs, 1, sess, true) | |
| 506 | + | a.resize(1920, 1080, 1) | |
| 507 | + | b.resize(1280, 720, 1.5) | |
| 508 | + | assert.Equal(t, frect{640, 0, 1, 1080}, a.scene().grid[0]) | |
| 509 | + | assert.Equal(t, frect{640 / 1.5, 0, 1 / 1.5, 1080 / 1.5}, b.scene().grid[0], "physical 640 of 1920 on the scaled output") | |
| 510 | + | assert.Equal(t, image.Rect(640, 0, 641, 1080), physicalEdges(b.scene().grid[0], 1.5)) | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | func TestGridStaysOnWholeMonitorWhileDragging(t *testing.T) { | |
| 514 | + | m := gridModel(t, true, 1920, 1080, 1) | |
| 515 | + | idle := m.scene().grid | |
| 516 | + | m.input(button(100, 50, true)) | |
| 517 | + | m.input(motion(300, 250)) | |
| 518 | + | require.True(t, m.picker.Dragging()) | |
| 519 | + | assert.Equal(t, idle, m.scene().grid, "the drag does not move the grid") | |
| 520 | + | m.input(motion(1000, 700)) | |
| 521 | + | assert.Equal(t, idle, m.scene().grid) | |
| 522 | + | m.input(key("m")) | |
| 523 | + | m.input(key("r")) | |
| 524 | + | assert.Equal(t, idle, m.scene().grid, "nor does a kind change") | |
| 525 | + | m.input(button(1000, 700, false)) | |
| 526 | + | assert.Equal(t, core.PickAccepted, m.picker.Status()) | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | func TestGridToggleSurvivesResizeAndScaleChange(t *testing.T) { | |
| 530 | + | m := gridModel(t, true, 1920, 1080, 1) | |
| 531 | + | m.input(key("g")) | |
| 532 | + | require.False(t, m.grid) | |
| 533 | + | m.resize(1280, 720, 1.5) // size change resets the picker, not the toggle | |
| 534 | + | assert.False(t, m.grid) | |
| 535 | + | assert.Equal(t, [core.GridLineCount]frect{}, m.scene().grid) | |
| 536 | + | m.input(key("g")) | |
| 537 | + | m.resize(1280, 720, 2) // scale only | |
| 538 | + | assert.True(t, m.grid) | |
| 539 | + | m.resize(1600, 900, 1.2) | |
| 540 | + | assert.True(t, m.grid) | |
| 541 | + | } | |
| 542 | + | ||
| 543 | + | func TestGridScaleOnlyChangeKeepsPickAndRelays(t *testing.T) { | |
| 544 | + | m := gridModel(t, true, 1920, 1080, 1) | |
| 545 | + | m.input(button(10, 10, true)) | |
| 546 | + | m.input(motion(200, 200)) | |
| 547 | + | before := m.scene().grid | |
| 548 | + | m.resize(1920, 1080, 1.25) | |
| 549 | + | assert.True(t, m.picker.Dragging(), "a scale-only change keeps the drag") | |
| 550 | + | after := m.scene().grid | |
| 551 | + | assert.NotEqual(t, before, after, "the grid follows the new scale") | |
| 552 | + | want, err := core.MonitorGrid(1920, 1080, 1.25) | |
| 553 | + | require.NoError(t, err) | |
| 554 | + | for i, r := range after { | |
| 555 | + | assert.Equal(t, want[i], physicalEdges(r, 1.25), "line %d", i) | |
| 556 | + | } | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | func TestGridBadScaleDrawsNothingAndKeepsSelecting(t *testing.T) { | |
| 560 | + | for _, scale := range []float64{0, -1, math.NaN(), math.Inf(1), 100} { | |
| 561 | + | m := gridModel(t, true, 1920, 1080, scale) | |
| 562 | + | assert.Equal(t, endNone, m.end, "scale %v", scale) | |
| 563 | + | assert.Equal(t, [core.GridLineCount]frect{}, m.scene().grid, "scale %v", scale) | |
| 564 | + | } | |
| 565 | + | } | |
| 566 | + | ||
| 567 | + | func TestGridAllocations(t *testing.T) { | |
| 568 | + | if raceEnabled { | |
| 569 | + | t.Skip("the race detector adds allocations") | |
| 570 | + | } | |
| 571 | + | m := gridModel(t, true, 1600, 900, 1.2) | |
| 572 | + | var s scene | |
| 573 | + | n := testing.AllocsPerRun(200, func() { | |
| 574 | + | s = m.scene() | |
| 575 | + | m.input(key("g")) | |
| 576 | + | m.input(key("g")) | |
| 577 | + | m.resize(1600, 900, 1.2) | |
| 578 | + | }) | |
| 579 | + | assert.Zero(t, n, "grid toggle, relayout and scene do not allocate") | |
| 580 | + | _ = s | |
| 581 | + | } | |
| 582 | + | ||
| 583 | + | func BenchmarkGridResizeScene(b *testing.B) { | |
| 584 | + | sess, _ := testSession() | |
| 585 | + | m := newModel(ports.Screenshot, testOutputs, 0, sess, true) | |
| 586 | + | m.resize(1600, 900, 1.2) | |
| 587 | + | b.ReportAllocs() | |
| 588 | + | for i := 0; b.Loop(); i++ { | |
| 589 | + | m.resize(1600, 900, 1.2+float64(i%2)*0.05) | |
| 590 | + | _ = m.scene() | |
| 591 | + | } | |
| 426 | 592 | } | |
| 427 | 593 | ||
| 428 | 594 | func TestLabelPlacementStaysOnSurface(t *testing.T) { | |
| @@ -4,6 +4,8 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | + | "image" | |||
| 8 | + | "math" | |||
| 7 | "strings" | 9 | "strings" | ||
| 8 | "testing" | 10 | "testing" | ||
| 9 | 11 | ||||
| @@ -29,7 +31,7 @@ func testSession() (*session, context.Context) { | |||||
| 29 | func newTestModel(t *testing.T) *model { | 31 | func newTestModel(t *testing.T) *model { | ||
| 30 | t.Helper() | 32 | t.Helper() | ||
| 31 | sess, _ := testSession() | 33 | sess, _ := testSession() | ||
| 32 | - | m := newModel(ports.Screenshot, testOutputs, 0, sess) | 34 | + | m := newModel(ports.Screenshot, testOutputs, 0, sess, false) |
| 33 | m.resize(1920, 1080, 1) | 35 | m.resize(1920, 1080, 1) | ||
| 34 | require.Equal(t, core.PickActive, m.picker.Status()) | 36 | require.Equal(t, core.PickActive, m.picker.Status()) | ||
| 35 | return m | 37 | return m | ||
| @@ -38,7 +40,7 @@ func newTestModel(t *testing.T) *model { | |||||
| 38 | // newBareModel has no size yet, like an overlay before its first configure. | 40 | // newBareModel has no size yet, like an overlay before its first configure. | ||
| 39 | func newBareModel() (*model, context.Context) { | 41 | func newBareModel() (*model, context.Context) { | ||
| 40 | sess, ctx := testSession() | 42 | sess, ctx := testSession() | ||
| 41 | - | return newModel(ports.Screenshot, testOutputs, 0, sess), ctx | 43 | + | return newModel(ports.Screenshot, testOutputs, 0, sess, false), ctx |
| 42 | } | 44 | } | ||
| 43 | 45 | ||||
| 44 | func motion(x, y float64) nefergui.InputEvent { | 46 | func motion(x, y float64) nefergui.InputEvent { | ||
| @@ -80,7 +82,7 @@ func TestClickAcceptsMonitor(t *testing.T) { | |||||
| 80 | require.Equal(t, endAccepted, m.end) | 82 | require.Equal(t, endAccepted, m.end) | ||
| 81 | res, _, _ := m.sess.outcome(context.Background()) | 83 | res, _, _ := m.sess.outcome(context.Background()) | ||
| 82 | assert.Equal(t, core.PickMonitor, res.Kind) | 84 | assert.Equal(t, core.PickMonitor, res.Kind) | ||
| 83 | - | sel := New(ports.Screenshot, ports.VideoSettings{}, nil).selection(res) | 85 | + | sel := New(ports.Screenshot, ports.VideoSettings{}, nil, false).selection(res) |
| 84 | assert.Equal(t, ports.Target{OutputID: 7}, sel.Target) | 86 | assert.Equal(t, ports.Target{OutputID: 7}, sel.Target) | ||
| 85 | } | 87 | } | ||
| 86 | 88 | ||||
| @@ -97,7 +99,7 @@ func TestMonitorKeyThenEnter(t *testing.T) { | |||||
| 97 | 99 | ||||
| 98 | func TestEscapeAbortsEveryOverlay(t *testing.T) { | 100 | func TestEscapeAbortsEveryOverlay(t *testing.T) { | ||
| 99 | sess, ctx := testSession() | 101 | sess, ctx := testSession() | ||
| 100 | - | m := newModel(ports.Screenshot, testOutputs, 1, sess) | 102 | + | m := newModel(ports.Screenshot, testOutputs, 1, sess, false) |
| 101 | m.resize(1280, 720, 1) | 103 | m.resize(1280, 720, 1) | ||
| 102 | m.input(key("Escape")) | 104 | m.input(key("Escape")) | ||
| 103 | assert.Equal(t, endCanceled, m.end) | 105 | assert.Equal(t, endCanceled, m.end) | ||
| @@ -117,7 +119,7 @@ func TestDigitPicksMonitorFromAnyOverlay(t *testing.T) { | |||||
| 117 | require.NoError(t, err) | 119 | require.NoError(t, err) | ||
| 118 | require.True(t, ok) | 120 | require.True(t, ok) | ||
| 119 | assert.Equal(t, core.PickResult{OutputID: 9, Kind: core.PickMonitor}, res) | 121 | assert.Equal(t, core.PickResult{OutputID: 9, Kind: core.PickMonitor}, res) | ||
| 120 | - | sel := New(ports.Screenshot, ports.VideoSettings{}, nil).selection(res) | 122 | + | sel := New(ports.Screenshot, ports.VideoSettings{}, nil, false).selection(res) |
| 121 | assert.Equal(t, ports.Target{OutputID: 9}, sel.Target) | 123 | assert.Equal(t, ports.Target{OutputID: 9}, sel.Target) | ||
| 122 | 124 | ||||
| 123 | own := newTestModel(t) | 125 | own := newTestModel(t) | ||
| @@ -179,7 +181,7 @@ func TestInputResetBeforeThresholdAndKeepsChoices(t *testing.T) { | |||||
| 179 | assert.Equal(t, endNone, m.end) | 181 | assert.Equal(t, endNone, m.end) | ||
| 180 | 182 | ||||
| 181 | assert.Equal(t, core.PickWorkspace, m.picker.Kind(), "kind survives a reset") | 183 | assert.Equal(t, core.PickWorkspace, m.picker.Kind(), "kind survives a reset") | ||
| 182 | - | assert.True(t, m.picker.Grid(), "grid survives a reset") | 184 | + | assert.True(t, m.grid, "grid survives a reset") |
| 183 | id, _, ok := m.picker.Workspace() | 185 | id, _, ok := m.picker.Workspace() | ||
| 184 | assert.True(t, ok) | 186 | assert.True(t, ok) | ||
| 185 | assert.Equal(t, uint64(11), id, "workspace survives a reset") | 187 | assert.Equal(t, uint64(11), id, "workspace survives a reset") | ||
| @@ -189,7 +191,7 @@ func TestInputResetBeforeThresholdAndKeepsChoices(t *testing.T) { | |||||
| 189 | res, ok, err := m.sess.outcome(context.Background()) | 191 | res, ok, err := m.sess.outcome(context.Background()) | ||
| 190 | require.NoError(t, err) | 192 | require.NoError(t, err) | ||
| 191 | require.True(t, ok) | 193 | require.True(t, ok) | ||
| 192 | - | sel := New(ports.Screenshot, ports.VideoSettings{}, ws).selection(res) | 194 | + | sel := New(ports.Screenshot, ports.VideoSettings{}, ws, false).selection(res) |
| 193 | assert.Equal(t, ports.Target{OutputID: 7, WorkspaceID: 11}, sel.Target, "a workspace has a stable ID and a zero Region") | 195 | assert.Equal(t, ports.Target{OutputID: 7, WorkspaceID: 11}, sel.Target, "a workspace has a stable ID and a zero Region") | ||
| 194 | } | 196 | } | ||
| 195 | 197 | ||||
| @@ -239,7 +241,7 @@ func TestWorkspaceOnlyWhenRealAndOnThisOutput(t *testing.T) { | |||||
| 239 | res, _, _ := m.sess.outcome(context.Background()) | 241 | res, _, _ := m.sess.outcome(context.Background()) | ||
| 240 | assert.Equal(t, core.PickWorkspace, res.Kind) | 242 | assert.Equal(t, core.PickWorkspace, res.Kind) | ||
| 241 | assert.Equal(t, uint64(11), res.WorkspaceID) | 243 | assert.Equal(t, uint64(11), res.WorkspaceID) | ||
| 242 | - | sel := New(ports.Screenshot, ports.VideoSettings{}, ws).selection(res) | 244 | + | sel := New(ports.Screenshot, ports.VideoSettings{}, ws, false).selection(res) |
| 243 | assert.Equal(t, ports.Target{OutputID: 7, WorkspaceID: 11}, sel.Target, "workspace geometry is live from the compositor, not a cached crop") | 245 | assert.Equal(t, ports.Target{OutputID: 7, WorkspaceID: 11}, sel.Target, "workspace geometry is live from the compositor, not a cached crop") | ||
| 244 | } | 246 | } | ||
| 245 | 247 | ||||
| @@ -266,7 +268,7 @@ func TestOnlyTheActiveWorkspaceIsOffered(t *testing.T) { | |||||
| 266 | } | 268 | } | ||
| 267 | 269 | ||||
| 268 | func TestSelectionConversionUsesCoreTarget(t *testing.T) { | 270 | func TestSelectionConversionUsesCoreTarget(t *testing.T) { | ||
| 269 | - | s := New(ports.Screenshot, ports.VideoSettings{}, nil) | 271 | + | s := New(ports.Screenshot, ports.VideoSettings{}, nil, false) |
| 270 | region := ports.Region{X: 10, Y: 20, Width: 300, Height: 200} | 272 | region := ports.Region{X: 10, Y: 20, Width: 300, Height: 200} | ||
| 271 | assert.Equal(t, ports.Target{OutputID: 7, Region: region}, | 273 | assert.Equal(t, ports.Target{OutputID: 7, Region: region}, | ||
| 272 | s.selection(core.PickResult{OutputID: 7, Kind: core.PickRegion, Region: region}).Target, "a dragged region keeps its geometry") | 274 | s.selection(core.PickResult{OutputID: 7, Kind: core.PickRegion, Region: region}).Target, "a dragged region keeps its geometry") | ||
| @@ -277,14 +279,14 @@ func TestSelectionConversionUsesCoreTarget(t *testing.T) { | |||||
| 277 | } | 279 | } | ||
| 278 | 280 | ||||
| 279 | func TestNewSelectionDefaults(t *testing.T) { | 281 | func TestNewSelectionDefaults(t *testing.T) { | ||
| 280 | - | sel := New(ports.Record, ports.VideoSettings{}, nil).selection(core.PickResult{OutputID: 7, Kind: core.PickMonitor}) | 282 | + | sel := New(ports.Record, ports.VideoSettings{}, nil, false).selection(core.PickResult{OutputID: 7, Kind: core.PickMonitor}) |
| 281 | assert.Equal(t, ports.Record, sel.Mode) | 283 | assert.Equal(t, ports.Record, sel.Mode) | ||
| 282 | assert.Equal(t, DefaultFPS, sel.Video.FPS) | 284 | assert.Equal(t, DefaultFPS, sel.Video.FPS) | ||
| 283 | assert.Empty(t, sel.Path) | 285 | assert.Empty(t, sel.Path) | ||
| 284 | 286 | ||||
| 285 | custom := ports.VideoSettings{FPS: 60, Width: 640, Height: 360} | 287 | custom := ports.VideoSettings{FPS: 60, Width: 640, Height: 360} | ||
| 286 | - | assert.Equal(t, custom, New(ports.Record, custom, nil).selection(core.PickResult{}).Video) | 288 | + | assert.Equal(t, custom, New(ports.Record, custom, nil, false).selection(core.PickResult{}).Video) |
| 287 | - | assert.Equal(t, ports.VideoSettings{}, New(ports.Screenshot, custom, nil).selection(core.PickResult{}).Video) | 289 | + | assert.Equal(t, ports.VideoSettings{}, New(ports.Screenshot, custom, nil, false).selection(core.PickResult{}).Video) |
| 288 | } | 290 | } | ||
| 289 | 291 | ||||
| 290 | func TestSelectRejectsTooManyOutputsWithoutOpeningAnything(t *testing.T) { | 292 | func TestSelectRejectsTooManyOutputsWithoutOpeningAnything(t *testing.T) { | ||
| @@ -292,13 +294,13 @@ func TestSelectRejectsTooManyOutputsWithoutOpeningAnything(t *testing.T) { | |||||
| 292 | for i := range outs { | 294 | for i := range outs { | ||
| 293 | outs[i] = ports.Output{ID: uint32(i + 1), Name: "O"} | 295 | outs[i] = ports.Output{ID: uint32(i + 1), Name: "O"} | ||
| 294 | } | 296 | } | ||
| 295 | - | _, ok, err := New(ports.Screenshot, ports.VideoSettings{}, nil).Select(context.Background(), outs) | 297 | + | _, ok, err := New(ports.Screenshot, ports.VideoSettings{}, nil, false).Select(context.Background(), outs) |
| 296 | assert.False(t, ok) | 298 | assert.False(t, ok) | ||
| 297 | assert.ErrorIs(t, err, ErrTooManyOutputs) | 299 | assert.ErrorIs(t, err, ErrTooManyOutputs) | ||
| 298 | } | 300 | } | ||
| 299 | 301 | ||||
| 300 | func TestSelectRejectsBadInput(t *testing.T) { | 302 | func TestSelectRejectsBadInput(t *testing.T) { | ||
| 301 | - | s := New(ports.Screenshot, ports.VideoSettings{}, nil) | 303 | + | s := New(ports.Screenshot, ports.VideoSettings{}, nil, false) |
| 302 | _, ok, err := s.Select(context.Background(), nil) | 304 | _, ok, err := s.Select(context.Background(), nil) | ||
| 303 | assert.False(t, ok) | 305 | assert.False(t, ok) | ||
| 304 | assert.ErrorIs(t, err, ports.ErrOutputNotFound) | 306 | assert.ErrorIs(t, err, ports.ErrOutputNotFound) | ||
| @@ -422,7 +424,171 @@ func TestSceneMonitorOutlineAndGrid(t *testing.T) { | |||||
| 422 | assert.Equal(t, [4]frect{}, s.dim, "monitor choice is not dimmed") | 424 | assert.Equal(t, [4]frect{}, s.dim, "monitor choice is not dimmed") | ||
| 423 | assert.Equal(t, frect{0, 0, 1920, 1}, s.edges[0]) | 425 | assert.Equal(t, frect{0, 0, 1920, 1}, s.edges[0]) | ||
| 424 | assert.Equal(t, frect{640, 0, 1, 1080}, s.grid[0]) | 426 | assert.Equal(t, frect{640, 0, 1, 1080}, s.grid[0]) | ||
| 425 | - | assert.Equal(t, frect{0, 720, 1920, 1}, s.grid[3]) | 427 | + | assert.Equal(t, frect{0, 720, 1920, 1}, s.grid[5]) |
| 428 | + | } | |||
| 429 | + | ||||
| 430 | + | // physicalEdges converts a logical rectangle the way the renderer does: | |||
| 431 | + | // each edge is rounded to a physical pixel independently. | |||
| 432 | + | func physicalEdges(r frect, scale float64) image.Rectangle { | |||
| 433 | + | return image.Rect( | |||
| 434 | + | int(math.Round(r.x*scale)), int(math.Round(r.y*scale)), | |||
| 435 | + | int(math.Round((r.x+r.w)*scale)), int(math.Round((r.y+r.h)*scale))) | |||
| 436 | + | } | |||
| 437 | + | ||||
| 438 | + | func gridModel(t *testing.T, grid bool, w, h int, scale float64) *model { | |||
| 439 | + | t.Helper() | |||
| 440 | + | sess, _ := testSession() | |||
| 441 | + | m := newModel(ports.Screenshot, testOutputs, 0, sess, grid) | |||
| 442 | + | m.resize(w, h, scale) | |||
| 443 | + | require.Equal(t, core.PickActive, m.picker.Status()) | |||
| 444 | + | return m | |||
| 445 | + | } | |||
| 446 | + | ||||
| 447 | + | func TestGridDefaultAndToggle(t *testing.T) { | |||
| 448 | + | on := gridModel(t, true, 1920, 1080, 1) | |||
| 449 | + | assert.NotEqual(t, [core.GridLineCount]frect{}, on.scene().grid, "configured on: drawn at once") | |||
| 450 | + | assert.True(t, on.input(key("g"))) | |||
| 451 | + | assert.Equal(t, [core.GridLineCount]frect{}, on.scene().grid, "G turns it off") | |||
| 452 | + | assert.True(t, on.input(key("G"))) | |||
| 453 | + | assert.NotEqual(t, [core.GridLineCount]frect{}, on.scene().grid, "and on again") | |||
| 454 | + | ||||
| 455 | + | off := gridModel(t, false, 1920, 1080, 1) | |||
| 456 | + | assert.Equal(t, [core.GridLineCount]frect{}, off.scene().grid, "configured off: nothing drawn") | |||
| 457 | + | assert.True(t, off.input(key("g"))) | |||
| 458 | + | assert.NotEqual(t, [core.GridLineCount]frect{}, off.scene().grid) | |||
| 459 | + | } | |||
| 460 | + | ||||
| 461 | + | func TestGridRendersExactPhysicalPixels(t *testing.T) { | |||
| 462 | + | for _, c := range []struct { | |||
| 463 | + | w, h int | |||
| 464 | + | scale float64 | |||
| 465 | + | }{ | |||
| 466 | + | {1920, 1080, 1}, {1600, 900, 1.2}, {1536, 864, 1.25}, {1280, 720, 1.5}, {960, 540, 2}, | |||
| 467 | + | {1366, 768, 1}, {1367, 769, 1.25}, {1001, 601, 1.2}, {1279, 719, 1.5}, {853, 481, 2.25}, | |||
| 468 | + | } { | |||
| 469 | + | m := gridModel(t, true, c.w, c.h, c.scale) | |||
| 470 | + | want, err := core.MonitorGrid(c.w, c.h, c.scale) | |||
| 471 | + | require.NoError(t, err) | |||
| 472 | + | for i, r := range m.scene().grid { | |||
| 473 | + | got := physicalEdges(r, c.scale) | |||
| 474 | + | assert.Equal(t, want[i], got, "%dx%d@%g line %d", c.w, c.h, c.scale, i) | |||
| 475 | + | if i < 3 { | |||
| 476 | + | assert.Equal(t, 1, got.Dx(), "%dx%d@%g vertical %d is one physical pixel", c.w, c.h, c.scale, i) | |||
| 477 | + | } else { | |||
| 478 | + | assert.Equal(t, 1, got.Dy(), "%dx%d@%g horizontal %d is one physical pixel", c.w, c.h, c.scale, i) | |||
| 479 | + | } | |||
| 480 | + | } | |||
| 481 | + | } | |||
| 482 | + | } | |||
| 483 | + | ||||
| 484 | + | func TestGridSweepRendersOnePhysicalPixel(t *testing.T) { | |||
| 485 | + | // Every compositor fractional scale (k/120) over many logical sizes. | |||
| 486 | + | for k := 120; k <= 360; k++ { | |||
| 487 | + | scale := float64(k) / 120 | |||
| 488 | + | for w := 640; w <= 3000; w += 37 { | |||
| 489 | + | h := w*9/16 + 1 | |||
| 490 | + | m := gridModel(t, true, w, h, scale) | |||
| 491 | + | want, err := core.MonitorGrid(w, h, scale) | |||
| 492 | + | require.NoError(t, err) | |||
| 493 | + | for i, r := range m.scene().grid { | |||
| 494 | + | if got := physicalEdges(r, scale); got != want[i] { | |||
| 495 | + | t.Fatalf("%dx%d@%d/120 line %d renders %v, want %v", w, h, k, i, got, want[i]) | |||
| 496 | + | } | |||
| 497 | + | } | |||
| 498 | + | } | |||
| 499 | + | } | |||
| 500 | + | } | |||
| 501 | + | ||||
| 502 | + | func TestGridIsPerOutputScale(t *testing.T) { | |||
| 503 | + | sess, _ := testSession() | |||
| 504 | + | a := newModel(ports.Screenshot, testOutputs, 0, sess, true) | |||
| 505 | + | b := newModel(ports.Screenshot, testOutputs, 1, sess, true) | |||
| 506 | + | a.resize(1920, 1080, 1) | |||
| 507 | + | b.resize(1280, 720, 1.5) | |||
| 508 | + | assert.Equal(t, frect{640, 0, 1, 1080}, a.scene().grid[0]) | |||
| 509 | + | assert.Equal(t, frect{640 / 1.5, 0, 1 / 1.5, 1080 / 1.5}, b.scene().grid[0], "physical 640 of 1920 on the scaled output") | |||
| 510 | + | assert.Equal(t, image.Rect(640, 0, 641, 1080), physicalEdges(b.scene().grid[0], 1.5)) | |||
| 511 | + | } | |||
| 512 | + | ||||
| 513 | + | func TestGridStaysOnWholeMonitorWhileDragging(t *testing.T) { | |||
| 514 | + | m := gridModel(t, true, 1920, 1080, 1) | |||
| 515 | + | idle := m.scene().grid | |||
| 516 | + | m.input(button(100, 50, true)) | |||
| 517 | + | m.input(motion(300, 250)) | |||
| 518 | + | require.True(t, m.picker.Dragging()) | |||
| 519 | + | assert.Equal(t, idle, m.scene().grid, "the drag does not move the grid") | |||
| 520 | + | m.input(motion(1000, 700)) | |||
| 521 | + | assert.Equal(t, idle, m.scene().grid) | |||
| 522 | + | m.input(key("m")) | |||
| 523 | + | m.input(key("r")) | |||
| 524 | + | assert.Equal(t, idle, m.scene().grid, "nor does a kind change") | |||
| 525 | + | m.input(button(1000, 700, false)) | |||
| 526 | + | assert.Equal(t, core.PickAccepted, m.picker.Status()) | |||
| 527 | + | } | |||
| 528 | + | ||||
| 529 | + | func TestGridToggleSurvivesResizeAndScaleChange(t *testing.T) { | |||
| 530 | + | m := gridModel(t, true, 1920, 1080, 1) | |||
| 531 | + | m.input(key("g")) | |||
| 532 | + | require.False(t, m.grid) | |||
| 533 | + | m.resize(1280, 720, 1.5) // size change resets the picker, not the toggle | |||
| 534 | + | assert.False(t, m.grid) | |||
| 535 | + | assert.Equal(t, [core.GridLineCount]frect{}, m.scene().grid) | |||
| 536 | + | m.input(key("g")) | |||
| 537 | + | m.resize(1280, 720, 2) // scale only | |||
| 538 | + | assert.True(t, m.grid) | |||
| 539 | + | m.resize(1600, 900, 1.2) | |||
| 540 | + | assert.True(t, m.grid) | |||
| 541 | + | } | |||
| 542 | + | ||||
| 543 | + | func TestGridScaleOnlyChangeKeepsPickAndRelays(t *testing.T) { | |||
| 544 | + | m := gridModel(t, true, 1920, 1080, 1) | |||
| 545 | + | m.input(button(10, 10, true)) | |||
| 546 | + | m.input(motion(200, 200)) | |||
| 547 | + | before := m.scene().grid | |||
| 548 | + | m.resize(1920, 1080, 1.25) | |||
| 549 | + | assert.True(t, m.picker.Dragging(), "a scale-only change keeps the drag") | |||
| 550 | + | after := m.scene().grid | |||
| 551 | + | assert.NotEqual(t, before, after, "the grid follows the new scale") | |||
| 552 | + | want, err := core.MonitorGrid(1920, 1080, 1.25) | |||
| 553 | + | require.NoError(t, err) | |||
| 554 | + | for i, r := range after { | |||
| 555 | + | assert.Equal(t, want[i], physicalEdges(r, 1.25), "line %d", i) | |||
| 556 | + | } | |||
| 557 | + | } | |||
| 558 | + | ||||
| 559 | + | func TestGridBadScaleDrawsNothingAndKeepsSelecting(t *testing.T) { | |||
| 560 | + | for _, scale := range []float64{0, -1, math.NaN(), math.Inf(1), 100} { | |||
| 561 | + | m := gridModel(t, true, 1920, 1080, scale) | |||
| 562 | + | assert.Equal(t, endNone, m.end, "scale %v", scale) | |||
| 563 | + | assert.Equal(t, [core.GridLineCount]frect{}, m.scene().grid, "scale %v", scale) | |||
| 564 | + | } | |||
| 565 | + | } | |||
| 566 | + | ||||
| 567 | + | func TestGridAllocations(t *testing.T) { | |||
| 568 | + | if raceEnabled { | |||
| 569 | + | t.Skip("the race detector adds allocations") | |||
| 570 | + | } | |||
| 571 | + | m := gridModel(t, true, 1600, 900, 1.2) | |||
| 572 | + | var s scene | |||
| 573 | + | n := testing.AllocsPerRun(200, func() { | |||
| 574 | + | s = m.scene() | |||
| 575 | + | m.input(key("g")) | |||
| 576 | + | m.input(key("g")) | |||
| 577 | + | m.resize(1600, 900, 1.2) | |||
| 578 | + | }) | |||
| 579 | + | assert.Zero(t, n, "grid toggle, relayout and scene do not allocate") | |||
| 580 | + | _ = s | |||
| 581 | + | } | |||
| 582 | + | ||||
| 583 | + | func BenchmarkGridResizeScene(b *testing.B) { | |||
| 584 | + | sess, _ := testSession() | |||
| 585 | + | m := newModel(ports.Screenshot, testOutputs, 0, sess, true) | |||
| 586 | + | m.resize(1600, 900, 1.2) | |||
| 587 | + | b.ReportAllocs() | |||
| 588 | + | for i := 0; b.Loop(); i++ { | |||
| 589 | + | m.resize(1600, 900, 1.2+float64(i%2)*0.05) | |||
| 590 | + | _ = m.scene() | |||
| 591 | + | } | |||
| 426 | } | 592 | } | ||
| 427 | 593 | ||||
| 428 | func TestLabelPlacementStaysOnSurface(t *testing.T) { | 594 | func TestLabelPlacementStaysOnSurface(t *testing.T) { | ||
M internal/adapters/selection/selector.go +6 -4
| @@ -48,6 +48,7 @@ type Selector struct { | |||
| 48 | 48 | mode ports.Mode | |
| 49 | 49 | video ports.VideoSettings | |
| 50 | 50 | workspaces []ports.Workspace | |
| 51 | + | grid bool | |
| 51 | 52 | } | |
| 52 | 53 | ||
| 53 | 54 | var _ ports.Selector = (*Selector)(nil) | |
| @@ -56,17 +57,18 @@ var _ ports.Selector = (*Selector)(nil) | |||
| 56 | 57 | // becomes DefaultFPS. workspaces is native compositor metadata: the W key is | |
| 57 | 58 | // offered only for the Active workspace with a valid (nonzero) ID on the output | |
| 58 | 59 | // an overlay covers. Hidden workspaces are never offered and nothing is | |
| 59 | - | // invented when no workspace qualifies. The caller assigns | |
| 60 | + | // invented when no workspace qualifies. grid is the initial state of the | |
| 61 | + | // whole-monitor guides; the G key toggles them per overlay. The caller assigns | |
| 60 | 62 | // Selection.Path. The first output passed to Select opens first, so the caller | |
| 61 | 63 | // orders outputs to put the preferred one first. | |
| 62 | - | func New(mode ports.Mode, video ports.VideoSettings, workspaces []ports.Workspace) *Selector { | |
| 64 | + | func New(mode ports.Mode, video ports.VideoSettings, workspaces []ports.Workspace, grid bool) *Selector { | |
| 63 | 65 | if mode == ports.Record && video.FPS == 0 { | |
| 64 | 66 | video.FPS = DefaultFPS | |
| 65 | 67 | } | |
| 66 | 68 | if mode != ports.Record { | |
| 67 | 69 | video = ports.VideoSettings{} | |
| 68 | 70 | } | |
| 69 | - | return &Selector{mode: mode, video: video, workspaces: append([]ports.Workspace(nil), workspaces...)} | |
| 71 | + | return &Selector{mode: mode, video: video, workspaces: append([]ports.Workspace(nil), workspaces...), grid: grid} | |
| 70 | 72 | } | |
| 71 | 73 | ||
| 72 | 74 | // Select blocks until the user accepts a selection (true), aborts with Escape | |
| @@ -104,7 +106,7 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se | |||
| 104 | 106 | sess := newSession(cancel) | |
| 105 | 107 | var wg sync.WaitGroup | |
| 106 | 108 | for i := range outputs { | |
| 107 | - | m := newModel(s.mode, outputs, i, sess) | |
| 109 | + | m := newModel(s.mode, outputs, i, sess, s.grid) | |
| 108 | 110 | m.setWorkspaces(s.workspaces) | |
| 109 | 111 | wg.Add(1) | |
| 110 | 112 | go func() { | |
| @@ -48,6 +48,7 @@ type Selector struct { | |||||
| 48 | mode ports.Mode | 48 | mode ports.Mode | ||
| 49 | video ports.VideoSettings | 49 | video ports.VideoSettings | ||
| 50 | workspaces []ports.Workspace | 50 | workspaces []ports.Workspace | ||
| 51 | + | grid bool | |||
| 51 | } | 52 | } | ||
| 52 | 53 | ||||
| 53 | var _ ports.Selector = (*Selector)(nil) | 54 | var _ ports.Selector = (*Selector)(nil) | ||
| @@ -56,17 +57,18 @@ var _ ports.Selector = (*Selector)(nil) | |||||
| 56 | // becomes DefaultFPS. workspaces is native compositor metadata: the W key is | 57 | // becomes DefaultFPS. workspaces is native compositor metadata: the W key is | ||
| 57 | // offered only for the Active workspace with a valid (nonzero) ID on the output | 58 | // offered only for the Active workspace with a valid (nonzero) ID on the output | ||
| 58 | // an overlay covers. Hidden workspaces are never offered and nothing is | 59 | // an overlay covers. Hidden workspaces are never offered and nothing is | ||
| 59 | - | // invented when no workspace qualifies. The caller assigns | 60 | + | // invented when no workspace qualifies. grid is the initial state of the |
| 61 | + | // whole-monitor guides; the G key toggles them per overlay. The caller assigns | |||
| 60 | // Selection.Path. The first output passed to Select opens first, so the caller | 62 | // Selection.Path. The first output passed to Select opens first, so the caller | ||
| 61 | // orders outputs to put the preferred one first. | 63 | // orders outputs to put the preferred one first. | ||
| 62 | - | func New(mode ports.Mode, video ports.VideoSettings, workspaces []ports.Workspace) *Selector { | 64 | + | func New(mode ports.Mode, video ports.VideoSettings, workspaces []ports.Workspace, grid bool) *Selector { |
| 63 | if mode == ports.Record && video.FPS == 0 { | 65 | if mode == ports.Record && video.FPS == 0 { | ||
| 64 | video.FPS = DefaultFPS | 66 | video.FPS = DefaultFPS | ||
| 65 | } | 67 | } | ||
| 66 | if mode != ports.Record { | 68 | if mode != ports.Record { | ||
| 67 | video = ports.VideoSettings{} | 69 | video = ports.VideoSettings{} | ||
| 68 | } | 70 | } | ||
| 69 | - | return &Selector{mode: mode, video: video, workspaces: append([]ports.Workspace(nil), workspaces...)} | 71 | + | return &Selector{mode: mode, video: video, workspaces: append([]ports.Workspace(nil), workspaces...), grid: grid} |
| 70 | } | 72 | } | ||
| 71 | 73 | ||||
| 72 | // Select blocks until the user accepts a selection (true), aborts with Escape | 74 | // Select blocks until the user accepts a selection (true), aborts with Escape | ||
| @@ -104,7 +106,7 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se | |||||
| 104 | sess := newSession(cancel) | 106 | sess := newSession(cancel) | ||
| 105 | var wg sync.WaitGroup | 107 | var wg sync.WaitGroup | ||
| 106 | for i := range outputs { | 108 | for i := range outputs { | ||
| 107 | - | m := newModel(s.mode, outputs, i, sess) | 109 | + | m := newModel(s.mode, outputs, i, sess, s.grid) |
| 108 | m.setWorkspaces(s.workspaces) | 110 | m.setWorkspaces(s.workspaces) | ||
| 109 | wg.Add(1) | 111 | wg.Add(1) | ||
| 110 | go func() { | 112 | go func() { | ||
M internal/adapters/selection/smoke_test.go +1 -1
| @@ -34,7 +34,7 @@ func TestSmokeSelect(t *testing.T) { | |||
| 34 | 34 | ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second) | |
| 35 | 35 | defer cancel() | |
| 36 | 36 | peak := samplePeakRSS(ctx) | |
| 37 | - | sel, ok, err := New(ports.Record, ports.VideoSettings{}, nil).Select(ctx, outs) | |
| 37 | + | sel, ok, err := New(ports.Record, ports.VideoSettings{}, nil, false).Select(ctx, outs) | |
| 38 | 38 | cancel() | |
| 39 | 39 | fmt.Printf("SMOKE outputs=%d peak_rss_kib=%d\n", len(outs), <-peak) | |
| 40 | 40 | require.NoError(t, err) | |
| @@ -34,7 +34,7 @@ func TestSmokeSelect(t *testing.T) { | |||||
| 34 | ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second) | 34 | ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second) | ||
| 35 | defer cancel() | 35 | defer cancel() | ||
| 36 | peak := samplePeakRSS(ctx) | 36 | peak := samplePeakRSS(ctx) | ||
| 37 | - | sel, ok, err := New(ports.Record, ports.VideoSettings{}, nil).Select(ctx, outs) | 37 | + | sel, ok, err := New(ports.Record, ports.VideoSettings{}, nil, false).Select(ctx, outs) |
| 38 | cancel() | 38 | cancel() | ||
| 39 | fmt.Printf("SMOKE outputs=%d peak_rss_kib=%d\n", len(outs), <-peak) | 39 | fmt.Printf("SMOKE outputs=%d peak_rss_kib=%d\n", len(outs), <-peak) | ||
| 40 | require.NoError(t, err) | 40 | require.NoError(t, err) | ||
M internal/adapters/selection/style.css +2 -2
| @@ -1,4 +1,4 @@ | |||
| 1 | - | /* Transparent selector overlay: dimming, thin accent lines, monospace tags. */ | |
| 1 | + | /* Transparent selector overlay: dimming, thin accent lines, opaque black grid, monospace tags. */ | |
| 2 | 2 | app { | |
| 3 | 3 | width: 100%; height: 100%; background-color: transparent; | |
| 4 | 4 | color: #e6edf3; font-family: monospace; font-size: 13px; | |
| @@ -6,7 +6,7 @@ app { | |||
| 6 | 6 | stack.scene { width: 100%; height: 100%; } | |
| 7 | 7 | box.dim { background-color: rgba(0, 0, 0, 0.5); } | |
| 8 | 8 | box.edge { background-color: #4fd1a5; } | |
| 9 | - | box.grid { background-color: rgba(79, 209, 165, 0.45); } | |
| 9 | + | box.grid { background-color: #000000; } | |
| 10 | 10 | box.tag { | |
| 11 | 11 | box-sizing: border-box; padding: 4px 10px; overflow: hidden; | |
| 12 | 12 | background-color: rgba(13, 16, 20, 0.92); color: #e6edf3; | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | /* Transparent selector overlay: dimming, thin accent lines, monospace tags. */ | 1 | + | /* Transparent selector overlay: dimming, thin accent lines, opaque black grid, monospace tags. */ |
| 2 | app { | 2 | app { | ||
| 3 | width: 100%; height: 100%; background-color: transparent; | 3 | width: 100%; height: 100%; background-color: transparent; | ||
| 4 | color: #e6edf3; font-family: monospace; font-size: 13px; | 4 | color: #e6edf3; font-family: monospace; font-size: 13px; | ||
| @@ -6,7 +6,7 @@ app { | |||||
| 6 | stack.scene { width: 100%; height: 100%; } | 6 | stack.scene { width: 100%; height: 100%; } | ||
| 7 | box.dim { background-color: rgba(0, 0, 0, 0.5); } | 7 | box.dim { background-color: rgba(0, 0, 0, 0.5); } | ||
| 8 | box.edge { background-color: #4fd1a5; } | 8 | box.edge { background-color: #4fd1a5; } | ||
| 9 | - | box.grid { background-color: rgba(79, 209, 165, 0.45); } | 9 | + | box.grid { background-color: #000000; } |
| 10 | box.tag { | 10 | box.tag { | ||
| 11 | box-sizing: border-box; padding: 4px 10px; overflow: hidden; | 11 | box-sizing: border-box; padding: 4px 10px; overflow: hidden; | ||
| 12 | background-color: rgba(13, 16, 20, 0.92); color: #e6edf3; | 12 | background-color: rgba(13, 16, 20, 0.92); color: #e6edf3; | ||
M internal/adapters/selection/view.go +1 -1
| @@ -53,6 +53,6 @@ func tag(st nefergui.Node, key string, r frect) tagged { | |||
| 53 | 53 | // Static keys avoid building identity strings per frame. | |
| 54 | 54 | var ( | |
| 55 | 55 | dimKeys = [4]string{"dim0", "dim1", "dim2", "dim3"} | |
| 56 | - | gridKeys = [4]string{"grid0", "grid1", "grid2", "grid3"} | |
| 56 | + | gridKeys = [6]string{"grid0", "grid1", "grid2", "grid3", "grid4", "grid5"} | |
| 57 | 57 | edgeKeys = [4]string{"edge0", "edge1", "edge2", "edge3"} | |
| 58 | 58 | ) | |
| @@ -53,6 +53,6 @@ func tag(st nefergui.Node, key string, r frect) tagged { | |||||
| 53 | // Static keys avoid building identity strings per frame. | 53 | // Static keys avoid building identity strings per frame. | ||
| 54 | var ( | 54 | var ( | ||
| 55 | dimKeys = [4]string{"dim0", "dim1", "dim2", "dim3"} | 55 | dimKeys = [4]string{"dim0", "dim1", "dim2", "dim3"} | ||
| 56 | - | gridKeys = [4]string{"grid0", "grid1", "grid2", "grid3"} | 56 | + | gridKeys = [6]string{"grid0", "grid1", "grid2", "grid3", "grid4", "grid5"} |
| 57 | edgeKeys = [4]string{"edge0", "edge1", "edge2", "edge3"} | 57 | edgeKeys = [4]string{"edge0", "edge1", "edge2", "edge3"} | ||
| 58 | ) | 58 | ) | ||
M internal/app/interactive.go +6 -1
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/nefercap/internal/adapters/cli" | |
| 9 | + | "github.com/bnema/nefercap/internal/adapters/config" | |
| 9 | 10 | "github.com/bnema/nefercap/internal/adapters/control" | |
| 10 | 11 | "github.com/bnema/nefercap/internal/adapters/destination" | |
| 11 | 12 | "github.com/bnema/nefercap/internal/adapters/selection" | |
| @@ -35,7 +36,11 @@ func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []po | |||
| 35 | 36 | } | |
| 36 | 37 | return ports.Selection{}, false, fmt.Errorf("list workspace capture targets: %w", err) | |
| 37 | 38 | } | |
| 38 | - | sel, accepted, err := selection.New(mode, options.Video, workspaces).Select(ctx, outputs) | |
| 39 | + | settings, err := config.Load() | |
| 40 | + | if err != nil { | |
| 41 | + | return ports.Selection{}, false, fmt.Errorf("load selector config: %w", err) | |
| 42 | + | } | |
| 43 | + | sel, accepted, err := selection.New(mode, options.Video, workspaces, settings.Grid).Select(ctx, outputs) | |
| 39 | 44 | if err != nil || !accepted { | |
| 40 | 45 | return ports.Selection{}, false, err | |
| 41 | 46 | } | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | 7 | ||||
| 8 | "github.com/bnema/nefercap/internal/adapters/cli" | 8 | "github.com/bnema/nefercap/internal/adapters/cli" | ||
| 9 | + | "github.com/bnema/nefercap/internal/adapters/config" | |||
| 9 | "github.com/bnema/nefercap/internal/adapters/control" | 10 | "github.com/bnema/nefercap/internal/adapters/control" | ||
| 10 | "github.com/bnema/nefercap/internal/adapters/destination" | 11 | "github.com/bnema/nefercap/internal/adapters/destination" | ||
| 11 | "github.com/bnema/nefercap/internal/adapters/selection" | 12 | "github.com/bnema/nefercap/internal/adapters/selection" | ||
| @@ -35,7 +36,11 @@ func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []po | |||||
| 35 | } | 36 | } | ||
| 36 | return ports.Selection{}, false, fmt.Errorf("list workspace capture targets: %w", err) | 37 | return ports.Selection{}, false, fmt.Errorf("list workspace capture targets: %w", err) | ||
| 37 | } | 38 | } | ||
| 38 | - | sel, accepted, err := selection.New(mode, options.Video, workspaces).Select(ctx, outputs) | 39 | + | settings, err := config.Load() |
| 40 | + | if err != nil { | |||
| 41 | + | return ports.Selection{}, false, fmt.Errorf("load selector config: %w", err) | |||
| 42 | + | } | |||
| 43 | + | sel, accepted, err := selection.New(mode, options.Video, workspaces, settings.Grid).Select(ctx, outputs) | |||
| 39 | if err != nil || !accepted { | 44 | if err != nil || !accepted { | ||
| 40 | return ports.Selection{}, false, err | 45 | return ports.Selection{}, false, err | ||
| 41 | } | 46 | } | ||
A internal/core/grid.go +95 -0
| @@ -0,0 +1,95 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "image" | |
| 6 | + | "math" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/nefercap/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // GridLineCount is the number of guide lines of a monitor grid. | |
| 12 | + | const GridLineCount = 6 | |
| 13 | + | ||
| 14 | + | // MaxGridScale is the largest output scale a grid accepts. It bounds the | |
| 15 | + | // physical size to MaxDimension * MaxGridScale, far inside int32. | |
| 16 | + | const MaxGridScale = 8 | |
| 17 | + | ||
| 18 | + | // scaleEpsilon absorbs binary floating-point noise at exact integer | |
| 19 | + | // boundaries (1600 * 1.2 is 1920.0000000000002), like the surface buffer | |
| 20 | + | // size the window system derives from the same logical size and scale. | |
| 21 | + | const scaleEpsilon = 1e-9 | |
| 22 | + | ||
| 23 | + | // ErrInvalidGrid reports a grid requested for an unusable size or scale. | |
| 24 | + | var ErrInvalidGrid = errors.New("invalid grid size or scale") | |
| 25 | + | ||
| 26 | + | // GridLines are the guide lines of a monitor in physical pixels, origin at the | |
| 27 | + | // monitor's top-left: three vertical lines (1/3, 1/2, 2/3 of the width) then | |
| 28 | + | // three horizontal lines (1/3, 1/2, 2/3 of the height). Each line is one | |
| 29 | + | // physical pixel thick and spans the whole monitor. It is a plain value. | |
| 30 | + | type GridLines [GridLineCount]image.Rectangle | |
| 31 | + | ||
| 32 | + | // PhysicalExtent returns the physical pixel count of a logical extent at a | |
| 33 | + | // possibly fractional scale: ceil(logical * scale), computed like the surface | |
| 34 | + | // buffer size with a small epsilon so float noise never adds a pixel. It | |
| 35 | + | // fails with ErrInvalidGrid for a logical extent outside 1..MaxDimension, a | |
| 36 | + | // scale that is not finite or above MaxGridScale, or a result below one pixel. | |
| 37 | + | func PhysicalExtent(logical int, scale float64) (int, error) { | |
| 38 | + | if logical < 1 || logical > ports.MaxDimension || | |
| 39 | + | math.IsNaN(scale) || scale <= 0 || scale > MaxGridScale { | |
| 40 | + | return 0, ErrInvalidGrid | |
| 41 | + | } | |
| 42 | + | n := int(math.Ceil(float64(logical)*scale - scaleEpsilon)) | |
| 43 | + | if n < 1 { | |
| 44 | + | return 0, ErrInvalidGrid | |
| 45 | + | } | |
| 46 | + | return n, nil | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // MonitorGrid returns the fixed guide lines of a whole monitor of the given | |
| 50 | + | // logical size at the given scale, on the surface buffer PhysicalExtent gives | |
| 51 | + | // for each axis. Every line coordinate is snapped to a whole physical pixel | |
| 52 | + | // first (floor of the exact 1/3, 1/2 and 2/3 position) and is exactly one | |
| 53 | + | // pixel thick, so callers convert to logical units only afterwards. It does | |
| 54 | + | // not allocate and returns the zero value with ErrInvalidGrid on bad input. | |
| 55 | + | // | |
| 56 | + | // The buffer can be a pixel larger than the output mode when the logical size | |
| 57 | + | // was rounded; use MonitorGridPhysical with the mode when it is known. | |
| 58 | + | func MonitorGrid(width, height int, scale float64) (GridLines, error) { | |
| 59 | + | pw, err := PhysicalExtent(width, scale) | |
| 60 | + | if err != nil { | |
| 61 | + | return GridLines{}, err | |
| 62 | + | } | |
| 63 | + | ph, err := PhysicalExtent(height, scale) | |
| 64 | + | if err != nil { | |
| 65 | + | return GridLines{}, err | |
| 66 | + | } | |
| 67 | + | return physicalGrid(pw, ph), nil | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // MonitorGridPhysical returns the guide lines of a monitor whose physical | |
| 71 | + | // size is exactly width x height pixels, for example the output mode. The | |
| 72 | + | // lines are snapped and one pixel thick as in MonitorGrid and span the given | |
| 73 | + | // size. Each size must be 1..MaxDimension*MaxGridScale, since a physical mode | |
| 74 | + | // can exceed MaxDimension when the scale is above 1. It does not allocate and | |
| 75 | + | // returns the zero value with ErrInvalidGrid on bad input. | |
| 76 | + | func MonitorGridPhysical(width, height int) (GridLines, error) { | |
| 77 | + | const limit = ports.MaxDimension * MaxGridScale | |
| 78 | + | if width < 1 || width > limit || height < 1 || height > limit { | |
| 79 | + | return GridLines{}, ErrInvalidGrid | |
| 80 | + | } | |
| 81 | + | return physicalGrid(width, height), nil | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // physicalGrid lays the lines out on an exact physical size of at least 1x1. | |
| 85 | + | func physicalGrid(pw, ph int) GridLines { | |
| 86 | + | var g GridLines | |
| 87 | + | // Integer floor division keeps the snap exact for every size. | |
| 88 | + | xs := [3]int{pw / 3, pw / 2, pw * 2 / 3} | |
| 89 | + | ys := [3]int{ph / 3, ph / 2, ph * 2 / 3} | |
| 90 | + | for i := range xs { | |
| 91 | + | g[i] = image.Rect(xs[i], 0, xs[i]+1, ph) | |
| 92 | + | g[3+i] = image.Rect(0, ys[i], pw, ys[i]+1) | |
| 93 | + | } | |
| 94 | + | return g | |
| 95 | + | } | |
| @@ -0,0 +1,95 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "image" | |||
| 6 | + | "math" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/nefercap/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // GridLineCount is the number of guide lines of a monitor grid. | |||
| 12 | + | const GridLineCount = 6 | |||
| 13 | + | ||||
| 14 | + | // MaxGridScale is the largest output scale a grid accepts. It bounds the | |||
| 15 | + | // physical size to MaxDimension * MaxGridScale, far inside int32. | |||
| 16 | + | const MaxGridScale = 8 | |||
| 17 | + | ||||
| 18 | + | // scaleEpsilon absorbs binary floating-point noise at exact integer | |||
| 19 | + | // boundaries (1600 * 1.2 is 1920.0000000000002), like the surface buffer | |||
| 20 | + | // size the window system derives from the same logical size and scale. | |||
| 21 | + | const scaleEpsilon = 1e-9 | |||
| 22 | + | ||||
| 23 | + | // ErrInvalidGrid reports a grid requested for an unusable size or scale. | |||
| 24 | + | var ErrInvalidGrid = errors.New("invalid grid size or scale") | |||
| 25 | + | ||||
| 26 | + | // GridLines are the guide lines of a monitor in physical pixels, origin at the | |||
| 27 | + | // monitor's top-left: three vertical lines (1/3, 1/2, 2/3 of the width) then | |||
| 28 | + | // three horizontal lines (1/3, 1/2, 2/3 of the height). Each line is one | |||
| 29 | + | // physical pixel thick and spans the whole monitor. It is a plain value. | |||
| 30 | + | type GridLines [GridLineCount]image.Rectangle | |||
| 31 | + | ||||
| 32 | + | // PhysicalExtent returns the physical pixel count of a logical extent at a | |||
| 33 | + | // possibly fractional scale: ceil(logical * scale), computed like the surface | |||
| 34 | + | // buffer size with a small epsilon so float noise never adds a pixel. It | |||
| 35 | + | // fails with ErrInvalidGrid for a logical extent outside 1..MaxDimension, a | |||
| 36 | + | // scale that is not finite or above MaxGridScale, or a result below one pixel. | |||
| 37 | + | func PhysicalExtent(logical int, scale float64) (int, error) { | |||
| 38 | + | if logical < 1 || logical > ports.MaxDimension || | |||
| 39 | + | math.IsNaN(scale) || scale <= 0 || scale > MaxGridScale { | |||
| 40 | + | return 0, ErrInvalidGrid | |||
| 41 | + | } | |||
| 42 | + | n := int(math.Ceil(float64(logical)*scale - scaleEpsilon)) | |||
| 43 | + | if n < 1 { | |||
| 44 | + | return 0, ErrInvalidGrid | |||
| 45 | + | } | |||
| 46 | + | return n, nil | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // MonitorGrid returns the fixed guide lines of a whole monitor of the given | |||
| 50 | + | // logical size at the given scale, on the surface buffer PhysicalExtent gives | |||
| 51 | + | // for each axis. Every line coordinate is snapped to a whole physical pixel | |||
| 52 | + | // first (floor of the exact 1/3, 1/2 and 2/3 position) and is exactly one | |||
| 53 | + | // pixel thick, so callers convert to logical units only afterwards. It does | |||
| 54 | + | // not allocate and returns the zero value with ErrInvalidGrid on bad input. | |||
| 55 | + | // | |||
| 56 | + | // The buffer can be a pixel larger than the output mode when the logical size | |||
| 57 | + | // was rounded; use MonitorGridPhysical with the mode when it is known. | |||
| 58 | + | func MonitorGrid(width, height int, scale float64) (GridLines, error) { | |||
| 59 | + | pw, err := PhysicalExtent(width, scale) | |||
| 60 | + | if err != nil { | |||
| 61 | + | return GridLines{}, err | |||
| 62 | + | } | |||
| 63 | + | ph, err := PhysicalExtent(height, scale) | |||
| 64 | + | if err != nil { | |||
| 65 | + | return GridLines{}, err | |||
| 66 | + | } | |||
| 67 | + | return physicalGrid(pw, ph), nil | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // MonitorGridPhysical returns the guide lines of a monitor whose physical | |||
| 71 | + | // size is exactly width x height pixels, for example the output mode. The | |||
| 72 | + | // lines are snapped and one pixel thick as in MonitorGrid and span the given | |||
| 73 | + | // size. Each size must be 1..MaxDimension*MaxGridScale, since a physical mode | |||
| 74 | + | // can exceed MaxDimension when the scale is above 1. It does not allocate and | |||
| 75 | + | // returns the zero value with ErrInvalidGrid on bad input. | |||
| 76 | + | func MonitorGridPhysical(width, height int) (GridLines, error) { | |||
| 77 | + | const limit = ports.MaxDimension * MaxGridScale | |||
| 78 | + | if width < 1 || width > limit || height < 1 || height > limit { | |||
| 79 | + | return GridLines{}, ErrInvalidGrid | |||
| 80 | + | } | |||
| 81 | + | return physicalGrid(width, height), nil | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // physicalGrid lays the lines out on an exact physical size of at least 1x1. | |||
| 85 | + | func physicalGrid(pw, ph int) GridLines { | |||
| 86 | + | var g GridLines | |||
| 87 | + | // Integer floor division keeps the snap exact for every size. | |||
| 88 | + | xs := [3]int{pw / 3, pw / 2, pw * 2 / 3} | |||
| 89 | + | ys := [3]int{ph / 3, ph / 2, ph * 2 / 3} | |||
| 90 | + | for i := range xs { | |||
| 91 | + | g[i] = image.Rect(xs[i], 0, xs[i]+1, ph) | |||
| 92 | + | g[3+i] = image.Rect(0, ys[i], pw, ys[i]+1) | |||
| 93 | + | } | |||
| 94 | + | return g | |||
| 95 | + | } | |||
A internal/core/grid_physical_test.go +81 -0
| @@ -0,0 +1,81 @@ | |||
| 1 | + | package core_test | |
| 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/core" | |
| 11 | + | "github.com/bnema/nefercap/internal/ports" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestMonitorGridPhysicalUsesTheGivenPixels(t *testing.T) { | |
| 15 | + | // An odd 901x601 output mode: the lines are its own thirds, one pixel each. | |
| 16 | + | g, err := core.MonitorGridPhysical(901, 601) | |
| 17 | + | require.NoError(t, err) | |
| 18 | + | assert.Equal(t, gridWant(901, 601, [3]int{300, 450, 600}, [3]int{200, 300, 400}), g) | |
| 19 | + | ||
| 20 | + | same, err := core.MonitorGridPhysical(1920, 1080) | |
| 21 | + | require.NoError(t, err) | |
| 22 | + | fromScale, err := core.MonitorGrid(1600, 900, 1.2) | |
| 23 | + | require.NoError(t, err) | |
| 24 | + | assert.Equal(t, fromScale, same, "an exact-fit buffer and mode agree") | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestMonitorGridPhysicalSpansItsOwnSize(t *testing.T) { | |
| 28 | + | g, err := core.MonitorGridPhysical(901, 601) | |
| 29 | + | require.NoError(t, err) | |
| 30 | + | for i, r := range g { | |
| 31 | + | assert.True(t, r.In(image.Rect(0, 0, 901, 601)), "line %d %v", i, r) | |
| 32 | + | if i < 3 { | |
| 33 | + | assert.Equal(t, image.Rect(r.Min.X, 0, r.Min.X+1, 601), r) | |
| 34 | + | } else { | |
| 35 | + | assert.Equal(t, image.Rect(0, r.Min.Y, 901, r.Min.Y+1), r) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestMonitorGridPhysicalValidation(t *testing.T) { | |
| 41 | + | const limit = ports.MaxDimension * core.MaxGridScale | |
| 42 | + | for name, c := range map[string]struct{ w, h int }{ | |
| 43 | + | "zero width": {0, 600}, | |
| 44 | + | "zero height": {900, 0}, | |
| 45 | + | "negative width": {-1, 600}, | |
| 46 | + | "negative height": {900, -5}, | |
| 47 | + | "width too large": {limit + 1, 600}, | |
| 48 | + | "height too high": {900, limit + 1}, | |
| 49 | + | } { | |
| 50 | + | t.Run(name, func(t *testing.T) { | |
| 51 | + | g, err := core.MonitorGridPhysical(c.w, c.h) | |
| 52 | + | assert.ErrorIs(t, err, core.ErrInvalidGrid) | |
| 53 | + | assert.Equal(t, core.GridLines{}, g) | |
| 54 | + | }) | |
| 55 | + | } | |
| 56 | + | // A physical mode may exceed MaxDimension when the scale is above 1. | |
| 57 | + | g, err := core.MonitorGridPhysical(limit, limit) | |
| 58 | + | require.NoError(t, err) | |
| 59 | + | assert.Equal(t, limit, g[3].Dx()) | |
| 60 | + | _, err = core.MonitorGridPhysical(1, 1) | |
| 61 | + | assert.NoError(t, err) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestMonitorGridPhysicalAllocations(t *testing.T) { | |
| 65 | + | var g core.GridLines | |
| 66 | + | n := testing.AllocsPerRun(200, func() { | |
| 67 | + | g, _ = core.MonitorGridPhysical(901, 601) | |
| 68 | + | _, _ = core.MonitorGridPhysical(0, 601) | |
| 69 | + | }) | |
| 70 | + | assert.Zero(t, n) | |
| 71 | + | _ = g | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func BenchmarkMonitorGridPhysical(b *testing.B) { | |
| 75 | + | b.ReportAllocs() | |
| 76 | + | var g core.GridLines | |
| 77 | + | for i := 0; b.Loop(); i++ { | |
| 78 | + | g, _ = core.MonitorGridPhysical(901+i%3, 601) | |
| 79 | + | } | |
| 80 | + | _ = g | |
| 81 | + | } | |
| @@ -0,0 +1,81 @@ | |||||
| 1 | + | package core_test | |||
| 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/core" | |||
| 11 | + | "github.com/bnema/nefercap/internal/ports" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestMonitorGridPhysicalUsesTheGivenPixels(t *testing.T) { | |||
| 15 | + | // An odd 901x601 output mode: the lines are its own thirds, one pixel each. | |||
| 16 | + | g, err := core.MonitorGridPhysical(901, 601) | |||
| 17 | + | require.NoError(t, err) | |||
| 18 | + | assert.Equal(t, gridWant(901, 601, [3]int{300, 450, 600}, [3]int{200, 300, 400}), g) | |||
| 19 | + | ||||
| 20 | + | same, err := core.MonitorGridPhysical(1920, 1080) | |||
| 21 | + | require.NoError(t, err) | |||
| 22 | + | fromScale, err := core.MonitorGrid(1600, 900, 1.2) | |||
| 23 | + | require.NoError(t, err) | |||
| 24 | + | assert.Equal(t, fromScale, same, "an exact-fit buffer and mode agree") | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestMonitorGridPhysicalSpansItsOwnSize(t *testing.T) { | |||
| 28 | + | g, err := core.MonitorGridPhysical(901, 601) | |||
| 29 | + | require.NoError(t, err) | |||
| 30 | + | for i, r := range g { | |||
| 31 | + | assert.True(t, r.In(image.Rect(0, 0, 901, 601)), "line %d %v", i, r) | |||
| 32 | + | if i < 3 { | |||
| 33 | + | assert.Equal(t, image.Rect(r.Min.X, 0, r.Min.X+1, 601), r) | |||
| 34 | + | } else { | |||
| 35 | + | assert.Equal(t, image.Rect(0, r.Min.Y, 901, r.Min.Y+1), r) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestMonitorGridPhysicalValidation(t *testing.T) { | |||
| 41 | + | const limit = ports.MaxDimension * core.MaxGridScale | |||
| 42 | + | for name, c := range map[string]struct{ w, h int }{ | |||
| 43 | + | "zero width": {0, 600}, | |||
| 44 | + | "zero height": {900, 0}, | |||
| 45 | + | "negative width": {-1, 600}, | |||
| 46 | + | "negative height": {900, -5}, | |||
| 47 | + | "width too large": {limit + 1, 600}, | |||
| 48 | + | "height too high": {900, limit + 1}, | |||
| 49 | + | } { | |||
| 50 | + | t.Run(name, func(t *testing.T) { | |||
| 51 | + | g, err := core.MonitorGridPhysical(c.w, c.h) | |||
| 52 | + | assert.ErrorIs(t, err, core.ErrInvalidGrid) | |||
| 53 | + | assert.Equal(t, core.GridLines{}, g) | |||
| 54 | + | }) | |||
| 55 | + | } | |||
| 56 | + | // A physical mode may exceed MaxDimension when the scale is above 1. | |||
| 57 | + | g, err := core.MonitorGridPhysical(limit, limit) | |||
| 58 | + | require.NoError(t, err) | |||
| 59 | + | assert.Equal(t, limit, g[3].Dx()) | |||
| 60 | + | _, err = core.MonitorGridPhysical(1, 1) | |||
| 61 | + | assert.NoError(t, err) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestMonitorGridPhysicalAllocations(t *testing.T) { | |||
| 65 | + | var g core.GridLines | |||
| 66 | + | n := testing.AllocsPerRun(200, func() { | |||
| 67 | + | g, _ = core.MonitorGridPhysical(901, 601) | |||
| 68 | + | _, _ = core.MonitorGridPhysical(0, 601) | |||
| 69 | + | }) | |||
| 70 | + | assert.Zero(t, n) | |||
| 71 | + | _ = g | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func BenchmarkMonitorGridPhysical(b *testing.B) { | |||
| 75 | + | b.ReportAllocs() | |||
| 76 | + | var g core.GridLines | |||
| 77 | + | for i := 0; b.Loop(); i++ { | |||
| 78 | + | g, _ = core.MonitorGridPhysical(901+i%3, 601) | |||
| 79 | + | } | |||
| 80 | + | _ = g | |||
| 81 | + | } | |||
A internal/core/grid_test.go +180 -0
| @@ -0,0 +1,180 @@ | |||
| 1 | + | package core_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "image" | |
| 5 | + | "math" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/nefercap/internal/core" | |
| 12 | + | "github.com/bnema/nefercap/internal/ports" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // gridWant builds the expected lines for a physical size and the pixel | |
| 16 | + | // coordinates of the three vertical and three horizontal lines. | |
| 17 | + | func gridWant(w, h int, xs, ys [3]int) core.GridLines { | |
| 18 | + | var g core.GridLines | |
| 19 | + | for i := range 3 { | |
| 20 | + | g[i] = image.Rect(xs[i], 0, xs[i]+1, h) | |
| 21 | + | g[3+i] = image.Rect(0, ys[i], w, ys[i]+1) | |
| 22 | + | } | |
| 23 | + | return g | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestMonitorGridExactPhysicalLines(t *testing.T) { | |
| 27 | + | for name, c := range map[string]struct { | |
| 28 | + | w, h int | |
| 29 | + | scale float64 | |
| 30 | + | want core.GridLines | |
| 31 | + | }{ | |
| 32 | + | "1920x1080 at 1": {1920, 1080, 1, gridWant(1920, 1080, [3]int{640, 960, 1280}, [3]int{360, 540, 720})}, | |
| 33 | + | "1600x900 at 1.2": {1600, 900, 1.2, gridWant(1920, 1080, [3]int{640, 960, 1280}, [3]int{360, 540, 720})}, | |
| 34 | + | "1536x864 at 1.25": {1536, 864, 1.25, gridWant(1920, 1080, [3]int{640, 960, 1280}, [3]int{360, 540, 720})}, | |
| 35 | + | "1280x720 at 1.5": {1280, 720, 1.5, gridWant(1920, 1080, [3]int{640, 960, 1280}, [3]int{360, 540, 720})}, | |
| 36 | + | "960x540 at 2": {960, 540, 2, gridWant(1920, 1080, [3]int{640, 960, 1280}, [3]int{360, 540, 720})}, | |
| 37 | + | "odd 1366x768 at 1": {1366, 768, 1, gridWant(1366, 768, [3]int{455, 683, 910}, [3]int{256, 384, 512})}, | |
| 38 | + | // 1367*1.25 = 1708.75 and 769*1.25 = 961.25 round up to whole buffer pixels. | |
| 39 | + | "odd fractional 1367x769 at 1.25": {1367, 769, 1.25, gridWant(1709, 962, [3]int{569, 854, 1139}, [3]int{320, 481, 641})}, | |
| 40 | + | // 1001*1.2 = 1201.2 and 601*1.2 = 721.2. | |
| 41 | + | "odd fractional 1001x601 at 1.2": {1001, 601, 1.2, gridWant(1202, 722, [3]int{400, 601, 801}, [3]int{240, 361, 481})}, | |
| 42 | + | "1x1 at 1": {1, 1, 1, gridWant(1, 1, [3]int{}, [3]int{})}, | |
| 43 | + | "2x2 at 1": {2, 2, 1, gridWant(2, 2, [3]int{0, 1, 1}, [3]int{0, 1, 1})}, | |
| 44 | + | "3x3 at 1": {3, 3, 1, gridWant(3, 3, [3]int{1, 1, 2}, [3]int{1, 1, 2})}, | |
| 45 | + | } { | |
| 46 | + | t.Run(name, func(t *testing.T) { | |
| 47 | + | got, err := core.MonitorGrid(c.w, c.h, c.scale) | |
| 48 | + | require.NoError(t, err) | |
| 49 | + | assert.Equal(t, c.want, got) | |
| 50 | + | for i, r := range got { | |
| 51 | + | if i < 3 { | |
| 52 | + | assert.Equal(t, 1, r.Dx(), "vertical %d is one physical pixel wide", i) | |
| 53 | + | } else { | |
| 54 | + | assert.Equal(t, 1, r.Dy(), "horizontal %d is one physical pixel tall", i) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | }) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestMonitorGridLineOrderAndBounds(t *testing.T) { | |
| 62 | + | g, err := core.MonitorGrid(1920, 1080, 1) | |
| 63 | + | require.NoError(t, err) | |
| 64 | + | assert.Equal(t, core.GridLineCount, len(g)) | |
| 65 | + | assert.Less(t, g[0].Min.X, g[1].Min.X) | |
| 66 | + | assert.Less(t, g[1].Min.X, g[2].Min.X) | |
| 67 | + | assert.Less(t, g[3].Min.Y, g[4].Min.Y) | |
| 68 | + | assert.Less(t, g[4].Min.Y, g[5].Min.Y) | |
| 69 | + | whole := image.Rect(0, 0, 1920, 1080) | |
| 70 | + | for _, r := range g { | |
| 71 | + | assert.True(t, r.In(whole), "%v stays inside the physical monitor", r) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestPhysicalExtentMatchesExactFractionalScales(t *testing.T) { | |
| 76 | + | // Compositor fractional scales are k/120. The buffer edge is | |
| 77 | + | // ceil(logical*k/120) computed exactly in integers; float noise at exact | |
| 78 | + | // boundaries (for example 1600*1.2) must not add a pixel. | |
| 79 | + | for k := 120; k <= 480; k++ { | |
| 80 | + | scale := float64(k) / 120 | |
| 81 | + | for n := 1; n <= ports.MaxDimension; n += 1 + n/97 { | |
| 82 | + | want := (n*k + 119) / 120 | |
| 83 | + | got, err := core.PhysicalExtent(n, scale) | |
| 84 | + | require.NoError(t, err) | |
| 85 | + | if got != want { | |
| 86 | + | t.Fatalf("PhysicalExtent(%d, %d/120) = %d, want %d", n, k, got, want) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | for n := 1; n <= 4096; n++ { // every small size | |
| 90 | + | want := (n*k + 119) / 120 | |
| 91 | + | got, err := core.PhysicalExtent(n, scale) | |
| 92 | + | require.NoError(t, err) | |
| 93 | + | if got != want { | |
| 94 | + | t.Fatalf("PhysicalExtent(%d, %d/120) = %d, want %d", n, k, got, want) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestMonitorGridSweepInvariants(t *testing.T) { | |
| 101 | + | for k := 120; k <= 360; k += 7 { | |
| 102 | + | scale := float64(k) / 120 | |
| 103 | + | for w := 1; w <= 3000; w += 13 { | |
| 104 | + | h := w/2 + 1 | |
| 105 | + | g, err := core.MonitorGrid(w, h, scale) | |
| 106 | + | require.NoError(t, err) | |
| 107 | + | pw, _ := core.PhysicalExtent(w, scale) | |
| 108 | + | ph, _ := core.PhysicalExtent(h, scale) | |
| 109 | + | for i, r := range g { | |
| 110 | + | if !r.In(image.Rect(0, 0, pw, ph)) || r.Empty() { | |
| 111 | + | t.Fatalf("%dx%d@%d/120 line %d = %v outside %dx%d", w, h, k, i, r, pw, ph) | |
| 112 | + | } | |
| 113 | + | if i < 3 && (r.Dx() != 1 || r.Dy() != ph) || i >= 3 && (r.Dy() != 1 || r.Dx() != pw) { | |
| 114 | + | t.Fatalf("%dx%d@%d/120 line %d = %v is not one pixel wide over the full monitor", w, h, k, i, r) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | if g[0].Min.X > g[1].Min.X || g[1].Min.X > g[2].Min.X || g[3].Min.Y > g[4].Min.Y || g[4].Min.Y > g[5].Min.Y { | |
| 118 | + | t.Fatalf("%dx%d@%d/120 lines out of order: %v", w, h, k, g) | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestMonitorGridRejectsInvalidInput(t *testing.T) { | |
| 125 | + | nan, inf := math.NaN(), math.Inf(1) | |
| 126 | + | for name, c := range map[string]struct { | |
| 127 | + | w, h int | |
| 128 | + | scale float64 | |
| 129 | + | }{ | |
| 130 | + | "zero width": {0, 1080, 1}, | |
| 131 | + | "zero height": {1920, 0, 1}, | |
| 132 | + | "negative width": {-1, 1080, 1}, | |
| 133 | + | "negative height": {1920, -1, 1}, | |
| 134 | + | "width too large": {ports.MaxDimension + 1, 1080, 1}, | |
| 135 | + | "height too large": {1920, ports.MaxDimension + 1, 1}, | |
| 136 | + | "zero scale": {1920, 1080, 0}, | |
| 137 | + | "negative scale": {1920, 1080, -1}, | |
| 138 | + | "NaN scale": {1920, 1080, nan}, | |
| 139 | + | "infinite scale": {1920, 1080, inf}, | |
| 140 | + | "negative infinity": {1920, 1080, -inf}, | |
| 141 | + | "scale too large": {1920, 1080, core.MaxGridScale * 2}, | |
| 142 | + | "vanishing scale": {1, 1, 1e-12}, | |
| 143 | + | } { | |
| 144 | + | t.Run(name, func(t *testing.T) { | |
| 145 | + | g, err := core.MonitorGrid(c.w, c.h, c.scale) | |
| 146 | + | assert.ErrorIs(t, err, core.ErrInvalidGrid) | |
| 147 | + | assert.Equal(t, core.GridLines{}, g, "no partial result") | |
| 148 | + | }) | |
| 149 | + | } | |
| 150 | + | _, err := core.PhysicalExtent(0, 1) | |
| 151 | + | assert.ErrorIs(t, err, core.ErrInvalidGrid) | |
| 152 | + | _, err = core.PhysicalExtent(10, nan) | |
| 153 | + | assert.ErrorIs(t, err, core.ErrInvalidGrid) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func TestMonitorGridAcceptsLimits(t *testing.T) { | |
| 157 | + | g, err := core.MonitorGrid(ports.MaxDimension, ports.MaxDimension, core.MaxGridScale) | |
| 158 | + | require.NoError(t, err) | |
| 159 | + | pw := ports.MaxDimension * core.MaxGridScale | |
| 160 | + | assert.Equal(t, pw, g[3].Dx()) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func TestMonitorGridAllocations(t *testing.T) { | |
| 164 | + | var g core.GridLines | |
| 165 | + | n := testing.AllocsPerRun(200, func() { | |
| 166 | + | g, _ = core.MonitorGrid(1600, 900, 1.2) | |
| 167 | + | _, _ = core.MonitorGrid(0, 900, 1.2) // the error path too | |
| 168 | + | }) | |
| 169 | + | assert.Zero(t, n) | |
| 170 | + | _ = g | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func BenchmarkMonitorGrid(b *testing.B) { | |
| 174 | + | b.ReportAllocs() | |
| 175 | + | var g core.GridLines | |
| 176 | + | for i := 0; b.Loop(); i++ { | |
| 177 | + | g, _ = core.MonitorGrid(1600+i%3, 900, 1.2) | |
| 178 | + | } | |
| 179 | + | _ = g | |
| 180 | + | } | |
| @@ -0,0 +1,180 @@ | |||||
| 1 | + | package core_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "image" | |||
| 5 | + | "math" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/nefercap/internal/core" | |||
| 12 | + | "github.com/bnema/nefercap/internal/ports" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // gridWant builds the expected lines for a physical size and the pixel | |||
| 16 | + | // coordinates of the three vertical and three horizontal lines. | |||
| 17 | + | func gridWant(w, h int, xs, ys [3]int) core.GridLines { | |||
| 18 | + | var g core.GridLines | |||
| 19 | + | for i := range 3 { | |||
| 20 | + | g[i] = image.Rect(xs[i], 0, xs[i]+1, h) | |||
| 21 | + | g[3+i] = image.Rect(0, ys[i], w, ys[i]+1) | |||
| 22 | + | } | |||
| 23 | + | return g | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func TestMonitorGridExactPhysicalLines(t *testing.T) { | |||
| 27 | + | for name, c := range map[string]struct { | |||
| 28 | + | w, h int | |||
| 29 | + | scale float64 | |||
| 30 | + | want core.GridLines | |||
| 31 | + | }{ | |||
| 32 | + | "1920x1080 at 1": {1920, 1080, 1, gridWant(1920, 1080, [3]int{640, 960, 1280}, [3]int{360, 540, 720})}, | |||
| 33 | + | "1600x900 at 1.2": {1600, 900, 1.2, gridWant(1920, 1080, [3]int{640, 960, 1280}, [3]int{360, 540, 720})}, | |||
| 34 | + | "1536x864 at 1.25": {1536, 864, 1.25, gridWant(1920, 1080, [3]int{640, 960, 1280}, [3]int{360, 540, 720})}, | |||
| 35 | + | "1280x720 at 1.5": {1280, 720, 1.5, gridWant(1920, 1080, [3]int{640, 960, 1280}, [3]int{360, 540, 720})}, | |||
| 36 | + | "960x540 at 2": {960, 540, 2, gridWant(1920, 1080, [3]int{640, 960, 1280}, [3]int{360, 540, 720})}, | |||
| 37 | + | "odd 1366x768 at 1": {1366, 768, 1, gridWant(1366, 768, [3]int{455, 683, 910}, [3]int{256, 384, 512})}, | |||
| 38 | + | // 1367*1.25 = 1708.75 and 769*1.25 = 961.25 round up to whole buffer pixels. | |||
| 39 | + | "odd fractional 1367x769 at 1.25": {1367, 769, 1.25, gridWant(1709, 962, [3]int{569, 854, 1139}, [3]int{320, 481, 641})}, | |||
| 40 | + | // 1001*1.2 = 1201.2 and 601*1.2 = 721.2. | |||
| 41 | + | "odd fractional 1001x601 at 1.2": {1001, 601, 1.2, gridWant(1202, 722, [3]int{400, 601, 801}, [3]int{240, 361, 481})}, | |||
| 42 | + | "1x1 at 1": {1, 1, 1, gridWant(1, 1, [3]int{}, [3]int{})}, | |||
| 43 | + | "2x2 at 1": {2, 2, 1, gridWant(2, 2, [3]int{0, 1, 1}, [3]int{0, 1, 1})}, | |||
| 44 | + | "3x3 at 1": {3, 3, 1, gridWant(3, 3, [3]int{1, 1, 2}, [3]int{1, 1, 2})}, | |||
| 45 | + | } { | |||
| 46 | + | t.Run(name, func(t *testing.T) { | |||
| 47 | + | got, err := core.MonitorGrid(c.w, c.h, c.scale) | |||
| 48 | + | require.NoError(t, err) | |||
| 49 | + | assert.Equal(t, c.want, got) | |||
| 50 | + | for i, r := range got { | |||
| 51 | + | if i < 3 { | |||
| 52 | + | assert.Equal(t, 1, r.Dx(), "vertical %d is one physical pixel wide", i) | |||
| 53 | + | } else { | |||
| 54 | + | assert.Equal(t, 1, r.Dy(), "horizontal %d is one physical pixel tall", i) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | }) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestMonitorGridLineOrderAndBounds(t *testing.T) { | |||
| 62 | + | g, err := core.MonitorGrid(1920, 1080, 1) | |||
| 63 | + | require.NoError(t, err) | |||
| 64 | + | assert.Equal(t, core.GridLineCount, len(g)) | |||
| 65 | + | assert.Less(t, g[0].Min.X, g[1].Min.X) | |||
| 66 | + | assert.Less(t, g[1].Min.X, g[2].Min.X) | |||
| 67 | + | assert.Less(t, g[3].Min.Y, g[4].Min.Y) | |||
| 68 | + | assert.Less(t, g[4].Min.Y, g[5].Min.Y) | |||
| 69 | + | whole := image.Rect(0, 0, 1920, 1080) | |||
| 70 | + | for _, r := range g { | |||
| 71 | + | assert.True(t, r.In(whole), "%v stays inside the physical monitor", r) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestPhysicalExtentMatchesExactFractionalScales(t *testing.T) { | |||
| 76 | + | // Compositor fractional scales are k/120. The buffer edge is | |||
| 77 | + | // ceil(logical*k/120) computed exactly in integers; float noise at exact | |||
| 78 | + | // boundaries (for example 1600*1.2) must not add a pixel. | |||
| 79 | + | for k := 120; k <= 480; k++ { | |||
| 80 | + | scale := float64(k) / 120 | |||
| 81 | + | for n := 1; n <= ports.MaxDimension; n += 1 + n/97 { | |||
| 82 | + | want := (n*k + 119) / 120 | |||
| 83 | + | got, err := core.PhysicalExtent(n, scale) | |||
| 84 | + | require.NoError(t, err) | |||
| 85 | + | if got != want { | |||
| 86 | + | t.Fatalf("PhysicalExtent(%d, %d/120) = %d, want %d", n, k, got, want) | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | for n := 1; n <= 4096; n++ { // every small size | |||
| 90 | + | want := (n*k + 119) / 120 | |||
| 91 | + | got, err := core.PhysicalExtent(n, scale) | |||
| 92 | + | require.NoError(t, err) | |||
| 93 | + | if got != want { | |||
| 94 | + | t.Fatalf("PhysicalExtent(%d, %d/120) = %d, want %d", n, k, got, want) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func TestMonitorGridSweepInvariants(t *testing.T) { | |||
| 101 | + | for k := 120; k <= 360; k += 7 { | |||
| 102 | + | scale := float64(k) / 120 | |||
| 103 | + | for w := 1; w <= 3000; w += 13 { | |||
| 104 | + | h := w/2 + 1 | |||
| 105 | + | g, err := core.MonitorGrid(w, h, scale) | |||
| 106 | + | require.NoError(t, err) | |||
| 107 | + | pw, _ := core.PhysicalExtent(w, scale) | |||
| 108 | + | ph, _ := core.PhysicalExtent(h, scale) | |||
| 109 | + | for i, r := range g { | |||
| 110 | + | if !r.In(image.Rect(0, 0, pw, ph)) || r.Empty() { | |||
| 111 | + | t.Fatalf("%dx%d@%d/120 line %d = %v outside %dx%d", w, h, k, i, r, pw, ph) | |||
| 112 | + | } | |||
| 113 | + | if i < 3 && (r.Dx() != 1 || r.Dy() != ph) || i >= 3 && (r.Dy() != 1 || r.Dx() != pw) { | |||
| 114 | + | t.Fatalf("%dx%d@%d/120 line %d = %v is not one pixel wide over the full monitor", w, h, k, i, r) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | if g[0].Min.X > g[1].Min.X || g[1].Min.X > g[2].Min.X || g[3].Min.Y > g[4].Min.Y || g[4].Min.Y > g[5].Min.Y { | |||
| 118 | + | t.Fatalf("%dx%d@%d/120 lines out of order: %v", w, h, k, g) | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func TestMonitorGridRejectsInvalidInput(t *testing.T) { | |||
| 125 | + | nan, inf := math.NaN(), math.Inf(1) | |||
| 126 | + | for name, c := range map[string]struct { | |||
| 127 | + | w, h int | |||
| 128 | + | scale float64 | |||
| 129 | + | }{ | |||
| 130 | + | "zero width": {0, 1080, 1}, | |||
| 131 | + | "zero height": {1920, 0, 1}, | |||
| 132 | + | "negative width": {-1, 1080, 1}, | |||
| 133 | + | "negative height": {1920, -1, 1}, | |||
| 134 | + | "width too large": {ports.MaxDimension + 1, 1080, 1}, | |||
| 135 | + | "height too large": {1920, ports.MaxDimension + 1, 1}, | |||
| 136 | + | "zero scale": {1920, 1080, 0}, | |||
| 137 | + | "negative scale": {1920, 1080, -1}, | |||
| 138 | + | "NaN scale": {1920, 1080, nan}, | |||
| 139 | + | "infinite scale": {1920, 1080, inf}, | |||
| 140 | + | "negative infinity": {1920, 1080, -inf}, | |||
| 141 | + | "scale too large": {1920, 1080, core.MaxGridScale * 2}, | |||
| 142 | + | "vanishing scale": {1, 1, 1e-12}, | |||
| 143 | + | } { | |||
| 144 | + | t.Run(name, func(t *testing.T) { | |||
| 145 | + | g, err := core.MonitorGrid(c.w, c.h, c.scale) | |||
| 146 | + | assert.ErrorIs(t, err, core.ErrInvalidGrid) | |||
| 147 | + | assert.Equal(t, core.GridLines{}, g, "no partial result") | |||
| 148 | + | }) | |||
| 149 | + | } | |||
| 150 | + | _, err := core.PhysicalExtent(0, 1) | |||
| 151 | + | assert.ErrorIs(t, err, core.ErrInvalidGrid) | |||
| 152 | + | _, err = core.PhysicalExtent(10, nan) | |||
| 153 | + | assert.ErrorIs(t, err, core.ErrInvalidGrid) | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func TestMonitorGridAcceptsLimits(t *testing.T) { | |||
| 157 | + | g, err := core.MonitorGrid(ports.MaxDimension, ports.MaxDimension, core.MaxGridScale) | |||
| 158 | + | require.NoError(t, err) | |||
| 159 | + | pw := ports.MaxDimension * core.MaxGridScale | |||
| 160 | + | assert.Equal(t, pw, g[3].Dx()) | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func TestMonitorGridAllocations(t *testing.T) { | |||
| 164 | + | var g core.GridLines | |||
| 165 | + | n := testing.AllocsPerRun(200, func() { | |||
| 166 | + | g, _ = core.MonitorGrid(1600, 900, 1.2) | |||
| 167 | + | _, _ = core.MonitorGrid(0, 900, 1.2) // the error path too | |||
| 168 | + | }) | |||
| 169 | + | assert.Zero(t, n) | |||
| 170 | + | _ = g | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func BenchmarkMonitorGrid(b *testing.B) { | |||
| 174 | + | b.ReportAllocs() | |||
| 175 | + | var g core.GridLines | |||
| 176 | + | for i := 0; b.Loop(); i++ { | |||
| 177 | + | g, _ = core.MonitorGrid(1600+i%3, 900, 1.2) | |||
| 178 | + | } | |||
| 179 | + | _ = g | |||
| 180 | + | } | |||
M internal/core/selection.go +1 -14
| @@ -94,7 +94,6 @@ type Picker struct { | |||
| 94 | 94 | rect ports.Region | |
| 95 | 95 | pressed, | |
| 96 | 96 | dragging bool | |
| 97 | - | grid bool | |
| 98 | 97 | ||
| 99 | 98 | wsID uint64 | |
| 100 | 99 | wsRect ports.Region | |
| @@ -129,9 +128,6 @@ func (p *Picker) Rect() (r ports.Region, ok bool) { return p.rect, p.dragging } | |||
| 129 | 128 | // Bounds returns the output logical size. | |
| 130 | 129 | func (p *Picker) Bounds() (width, height int) { return p.width, p.height } | |
| 131 | 130 | ||
| 132 | - | // Grid reports whether the thirds guides are on. | |
| 133 | - | func (p *Picker) Grid() bool { return p.grid } | |
| 134 | - | ||
| 135 | 131 | // Workspace returns the supplied workspace identity and its rectangle | |
| 136 | 132 | // clipped to the output; ok is false when none is available. | |
| 137 | 133 | func (p *Picker) Workspace() (id uint64, r ports.Region, ok bool) { | |
| @@ -185,15 +181,6 @@ func (p *Picker) setKind(k PickKind) bool { | |||
| 185 | 181 | return true | |
| 186 | 182 | } | |
| 187 | 183 | ||
| 188 | - | // ToggleGrid flips the thirds guides. | |
| 189 | - | func (p *Picker) ToggleGrid() bool { | |
| 190 | - | if p.status != PickActive { | |
| 191 | - | return false | |
| 192 | - | } | |
| 193 | - | p.grid = !p.grid | |
| 194 | - | return true | |
| 195 | - | } | |
| 196 | - | ||
| 197 | 184 | // MouseDown starts a press at (x, y). | |
| 198 | 185 | func (p *Picker) MouseDown(x, y float64) bool { | |
| 199 | 186 | if p.status != PickActive || p.pressed { | |
| @@ -261,7 +248,7 @@ func (p *Picker) MouseUp(x, y float64) bool { | |||
| 261 | 248 | // AbandonPress drops an unfinished press and any drag preview, for example | |
| 262 | 249 | // when the pointer leaves the surface or input focus is lost, so a button | |
| 263 | 250 | // release that is never delivered cannot leave the picker stuck. The chosen | |
| 264 | - | // kind, grid and workspace are kept and the status stays PickActive. It does | |
| 251 | + | // kind and workspace are kept and the status stays PickActive. It does | |
| 265 | 252 | // nothing after acceptance or cancellation. It reports whether state changed. | |
| 266 | 253 | func (p *Picker) AbandonPress() bool { | |
| 267 | 254 | if p.status != PickActive || !(p.pressed || p.dragging || p.rect != (ports.Region{})) { | |
| @@ -94,7 +94,6 @@ type Picker struct { | |||||
| 94 | rect ports.Region | 94 | rect ports.Region | ||
| 95 | pressed, | 95 | pressed, | ||
| 96 | dragging bool | 96 | dragging bool | ||
| 97 | - | grid bool | |||
| 98 | 97 | ||||
| 99 | wsID uint64 | 98 | wsID uint64 | ||
| 100 | wsRect ports.Region | 99 | wsRect ports.Region | ||
| @@ -129,9 +128,6 @@ func (p *Picker) Rect() (r ports.Region, ok bool) { return p.rect, p.dragging } | |||||
| 129 | // Bounds returns the output logical size. | 128 | // Bounds returns the output logical size. | ||
| 130 | func (p *Picker) Bounds() (width, height int) { return p.width, p.height } | 129 | func (p *Picker) Bounds() (width, height int) { return p.width, p.height } | ||
| 131 | 130 | ||||
| 132 | - | // Grid reports whether the thirds guides are on. | |||
| 133 | - | func (p *Picker) Grid() bool { return p.grid } | |||
| 134 | - | ||||
| 135 | // Workspace returns the supplied workspace identity and its rectangle | 131 | // Workspace returns the supplied workspace identity and its rectangle | ||
| 136 | // clipped to the output; ok is false when none is available. | 132 | // clipped to the output; ok is false when none is available. | ||
| 137 | func (p *Picker) Workspace() (id uint64, r ports.Region, ok bool) { | 133 | func (p *Picker) Workspace() (id uint64, r ports.Region, ok bool) { | ||
| @@ -185,15 +181,6 @@ func (p *Picker) setKind(k PickKind) bool { | |||||
| 185 | return true | 181 | return true | ||
| 186 | } | 182 | } | ||
| 187 | 183 | ||||
| 188 | - | // ToggleGrid flips the thirds guides. | |||
| 189 | - | func (p *Picker) ToggleGrid() bool { | |||
| 190 | - | if p.status != PickActive { | |||
| 191 | - | return false | |||
| 192 | - | } | |||
| 193 | - | p.grid = !p.grid | |||
| 194 | - | return true | |||
| 195 | - | } | |||
| 196 | - | ||||
| 197 | // MouseDown starts a press at (x, y). | 184 | // MouseDown starts a press at (x, y). | ||
| 198 | func (p *Picker) MouseDown(x, y float64) bool { | 185 | func (p *Picker) MouseDown(x, y float64) bool { | ||
| 199 | if p.status != PickActive || p.pressed { | 186 | if p.status != PickActive || p.pressed { | ||
| @@ -261,7 +248,7 @@ func (p *Picker) MouseUp(x, y float64) bool { | |||||
| 261 | // AbandonPress drops an unfinished press and any drag preview, for example | 248 | // AbandonPress drops an unfinished press and any drag preview, for example | ||
| 262 | // when the pointer leaves the surface or input focus is lost, so a button | 249 | // when the pointer leaves the surface or input focus is lost, so a button | ||
| 263 | // release that is never delivered cannot leave the picker stuck. The chosen | 250 | // release that is never delivered cannot leave the picker stuck. The chosen | ||
| 264 | - | // kind, grid and workspace are kept and the status stays PickActive. It does | 251 | + | // kind and workspace are kept and the status stays PickActive. It does |
| 265 | // nothing after acceptance or cancellation. It reports whether state changed. | 252 | // nothing after acceptance or cancellation. It reports whether state changed. | ||
| 266 | func (p *Picker) AbandonPress() bool { | 253 | func (p *Picker) AbandonPress() bool { | ||
| 267 | if p.status != PickActive || !(p.pressed || p.dragging || p.rect != (ports.Region{})) { | 254 | if p.status != PickActive || !(p.pressed || p.dragging || p.rect != (ports.Region{})) { | ||
M internal/core/selection_test.go +0 -7
| @@ -297,10 +297,6 @@ func TestPickerChangedFlags(t *testing.T) { | |||
| 297 | 297 | assert.True(t, p.MouseMove(10, 10)) | |
| 298 | 298 | assert.False(t, p.MouseMove(10, 10), "same position") | |
| 299 | 299 | assert.False(t, p.MouseUp(10, 10), "no press") | |
| 300 | - | assert.True(t, p.ToggleGrid()) | |
| 301 | - | assert.True(t, p.Grid()) | |
| 302 | - | assert.True(t, p.ToggleGrid()) | |
| 303 | - | assert.False(t, p.Grid()) | |
| 304 | 300 | assert.True(t, p.MouseDown(10, 10)) | |
| 305 | 301 | assert.False(t, p.MouseDown(20, 20), "already pressed") | |
| 306 | 302 | assert.False(t, p.SelectMonitor(), "mode cannot change mid-press") | |
| @@ -331,7 +327,6 @@ func TestPickerAbandonDragKeepsModes(t *testing.T) { | |||
| 331 | 327 | ws := ports.Region{X: 10, Y: 10, Width: 500, Height: 400} | |
| 332 | 328 | p.SetWorkspace(21, ws) | |
| 333 | 329 | p.SelectWorkspace() | |
| 334 | - | p.ToggleGrid() | |
| 335 | 330 | p.MouseDown(100, 100) | |
| 336 | 331 | p.MouseMove(300, 300) | |
| 337 | 332 | _, dragging := p.Rect() | |
| @@ -341,7 +336,6 @@ func TestPickerAbandonDragKeepsModes(t *testing.T) { | |||
| 341 | 336 | _, dragging = p.Rect() | |
| 342 | 337 | assert.False(t, dragging) | |
| 343 | 338 | assert.False(t, p.Dragging()) | |
| 344 | - | assert.True(t, p.Grid()) | |
| 345 | 339 | assert.Equal(t, core.PickWorkspace, p.Kind()) | |
| 346 | 340 | id, r, ok := p.Workspace() | |
| 347 | 341 | assert.True(t, ok) | |
| @@ -394,7 +388,6 @@ func TestPickerAllocations(t *testing.T) { | |||
| 394 | 388 | p.AbandonPress() | |
| 395 | 389 | p.MouseDown(5, 5) | |
| 396 | 390 | p.AbandonPress() | |
| 397 | - | p.ToggleGrid() | |
| 398 | 391 | p.SelectMonitor() | |
| 399 | 392 | p.SelectRegion() | |
| 400 | 393 | p.Cancel() | |
| @@ -297,10 +297,6 @@ func TestPickerChangedFlags(t *testing.T) { | |||||
| 297 | assert.True(t, p.MouseMove(10, 10)) | 297 | assert.True(t, p.MouseMove(10, 10)) | ||
| 298 | assert.False(t, p.MouseMove(10, 10), "same position") | 298 | assert.False(t, p.MouseMove(10, 10), "same position") | ||
| 299 | assert.False(t, p.MouseUp(10, 10), "no press") | 299 | assert.False(t, p.MouseUp(10, 10), "no press") | ||
| 300 | - | assert.True(t, p.ToggleGrid()) | |||
| 301 | - | assert.True(t, p.Grid()) | |||
| 302 | - | assert.True(t, p.ToggleGrid()) | |||
| 303 | - | assert.False(t, p.Grid()) | |||
| 304 | assert.True(t, p.MouseDown(10, 10)) | 300 | assert.True(t, p.MouseDown(10, 10)) | ||
| 305 | assert.False(t, p.MouseDown(20, 20), "already pressed") | 301 | assert.False(t, p.MouseDown(20, 20), "already pressed") | ||
| 306 | assert.False(t, p.SelectMonitor(), "mode cannot change mid-press") | 302 | assert.False(t, p.SelectMonitor(), "mode cannot change mid-press") | ||
| @@ -331,7 +327,6 @@ func TestPickerAbandonDragKeepsModes(t *testing.T) { | |||||
| 331 | ws := ports.Region{X: 10, Y: 10, Width: 500, Height: 400} | 327 | ws := ports.Region{X: 10, Y: 10, Width: 500, Height: 400} | ||
| 332 | p.SetWorkspace(21, ws) | 328 | p.SetWorkspace(21, ws) | ||
| 333 | p.SelectWorkspace() | 329 | p.SelectWorkspace() | ||
| 334 | - | p.ToggleGrid() | |||
| 335 | p.MouseDown(100, 100) | 330 | p.MouseDown(100, 100) | ||
| 336 | p.MouseMove(300, 300) | 331 | p.MouseMove(300, 300) | ||
| 337 | _, dragging := p.Rect() | 332 | _, dragging := p.Rect() | ||
| @@ -341,7 +336,6 @@ func TestPickerAbandonDragKeepsModes(t *testing.T) { | |||||
| 341 | _, dragging = p.Rect() | 336 | _, dragging = p.Rect() | ||
| 342 | assert.False(t, dragging) | 337 | assert.False(t, dragging) | ||
| 343 | assert.False(t, p.Dragging()) | 338 | assert.False(t, p.Dragging()) | ||
| 344 | - | assert.True(t, p.Grid()) | |||
| 345 | assert.Equal(t, core.PickWorkspace, p.Kind()) | 339 | assert.Equal(t, core.PickWorkspace, p.Kind()) | ||
| 346 | id, r, ok := p.Workspace() | 340 | id, r, ok := p.Workspace() | ||
| 347 | assert.True(t, ok) | 341 | assert.True(t, ok) | ||
| @@ -394,7 +388,6 @@ func TestPickerAllocations(t *testing.T) { | |||||
| 394 | p.AbandonPress() | 388 | p.AbandonPress() | ||
| 395 | p.MouseDown(5, 5) | 389 | p.MouseDown(5, 5) | ||
| 396 | p.AbandonPress() | 390 | p.AbandonPress() | ||
| 397 | - | p.ToggleGrid() | |||
| 398 | p.SelectMonitor() | 391 | p.SelectMonitor() | ||
| 399 | p.SelectRegion() | 392 | p.SelectRegion() | ||
| 400 | p.Cancel() | 393 | p.Cancel() | ||