detached 14f1bb9

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
19 files · +1003 -71
M Makefile +1 -1
@@ -45,7 +45,7 @@ arch:
4545 # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS
4646 # the packages holding them. Every named test must exist, so the guard cannot
4747 # 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
4949 PERF_PKGS := ./internal/ports ./internal/core ./internal/adapters/selection ./internal/adapters/indicator ./internal/adapters/ffmpeg ./internal/app ./internal/adapters/wayland
5050 perf_empty :=
5151 perf_space := $(perf_empty) $(perf_empty)
@@ -45,7 +45,7 @@ arch:
45 # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS45 # 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 cannot46 # 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 TestCaptureAllocations48+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/wayland49 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
1818 - **Click:** capture the monitor; small pointer jitter counts as a click.
1919 - **M, then Enter:** select the monitor.
2020 - **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.
2222 - **1–9:** select that monitor. **Esc:** cancel without creating a capture.
2323
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+
2432 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.
2533
2634 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 {
5555 func Usage() string {
5656 return fmt.Sprintf(`Usage: nefercap [command] [flags]
5757
58-Interactive commands (layer-shell selector over the current output):
58+Interactive commands (layer-shell selector over the connected outputs):
5959 shot select what to capture and save a PNG (default)
6060 rec select what to record; run again while recording to stop it
6161 stop stop the active recording
@@ -68,9 +68,12 @@ Selector keys and mouse:
6868 m choose the whole monitor, Enter confirms
6969 w choose the current workspace when known, click or Enter confirms
7070 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)
7272 Escape cancel
7373
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+
7477 Script commands (never open the selector):
7578 outputs list outputs
7679 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 it60 rec select what to record; run again while recording to stop it
61 stop stop the active recording61 stop stop the active recording
@@ -68,9 +68,12 @@ Selector keys and mouse:
68 m choose the whole monitor, Enter confirms68 m choose the whole monitor, Enter confirms
69 w choose the current workspace when known, click or Enter confirms69 w choose the current workspace when known, click or Enter confirms
70 1..9 pick that whole monitor from any overlay70 1..9 pick that whole monitor from any overlay
71- g toggle rule-of-thirds guides71+ g toggle full-monitor center and thirds guides (on by default)
72 Escape cancel72 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 outputs78 outputs list outputs
76 screenshot save a PNG screenshot79 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
22
33 import (
44 "fmt"
5+ "math"
56 "strconv"
67
78 "github.com/bnema/nefergui"
@@ -33,6 +34,12 @@ type model struct {
3334 picker core.Picker
3435 sized bool
3536 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
3643
3744 end endKind
3845 sess *session // shared first-decision record; the only cross-overlay state
@@ -43,23 +50,80 @@ type model struct {
4350 label label
4451 }
4552
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)}
4855 }
4956
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) {
5361 if m.sized && w == m.w && h == m.h {
62+ m.scale = scale
63+ m.layoutGrid()
5464 return
5565 }
5666 if err := m.picker.Begin(m.outputs[m.index].ID, w, h); err != nil {
5767 m.fail(fmt.Errorf("resize to %dx%d: %w", w, h, err))
5868 return
5969 }
60- m.sized, m.w, m.h = true, w, h
70+ m.sized, m.w, m.h, m.scale = true, w, h, scale
6171 m.label.clear()
6272 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
63127 }
64128
65129 // setWorkspaces keeps the workspace that is currently displayed on this
@@ -99,7 +163,7 @@ type visible struct {
99163
100164 func (m *model) snapshot() visible {
101165 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()}
103167 }
104168
105169 // input is the OnInput callback. It reports whether the drawn state changed,
@@ -147,7 +211,7 @@ func (m *model) key(name string) {
147211 case "r", "R":
148212 m.picker.SelectRegion()
149213 case "g", "G":
150- m.picker.ToggleGrid()
214+ m.grid = !m.grid
151215 case "Return", "KP_Enter":
152216 m.picker.Confirm()
153217 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.Picker34 picker core.Picker
34 sized bool35 sized bool
35 w, h int36 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 endKind44 end endKind
38 sess *session // shared first-decision record; the only cross-overlay state45 sess *session // shared first-decision record; the only cross-overlay state
@@ -43,23 +50,80 @@ type model struct {
43 label label50 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 is57+// 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 return64 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 return68 return
59 }69 }
60- m.sized, m.w, m.h = true, w, h70+ 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 this129 // 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 }
3535 // scene is the complete geometry of one frame, derived from the picker. It is
3636 // a plain value so computing it never allocates.
3737 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
4141 label frect
4242 header frect
4343 footer frect
@@ -86,17 +86,8 @@ func (m *model) scene() scene {
8686 {fr.x + fr.w - edge, fr.y, edge, fr.h},
8787 }
8888 }
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
10091 }
10192 if m.label.text != "" && outline {
10293 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 is35 // 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 none38+ dim [4]frect // top, bottom, left, right of the selection; dim[0] is all when none
39- edges [4]frect // top, bottom, left, right lines39+ edges [4]frect // top, bottom, left, right lines
40- grid [4]frect // two vertical then two horizontal thirds lines40+ grid [core.GridLineCount]frect // whole-monitor guides: three vertical then three horizontal
41 label frect41 label frect
42 header frect42 header frect
43 footer frect43 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 (
44 "context"
55 "errors"
66 "fmt"
7+ "image"
8+ "math"
79 "strings"
810 "testing"
911
@@ -29,7 +31,7 @@ func testSession() (*session, context.Context) {
2931 func newTestModel(t *testing.T) *model {
3032 t.Helper()
3133 sess, _ := testSession()
32- m := newModel(ports.Screenshot, testOutputs, 0, sess)
34+ m := newModel(ports.Screenshot, testOutputs, 0, sess, false)
3335 m.resize(1920, 1080, 1)
3436 require.Equal(t, core.PickActive, m.picker.Status())
3537 return m
@@ -38,7 +40,7 @@ func newTestModel(t *testing.T) *model {
3840 // newBareModel has no size yet, like an overlay before its first configure.
3941 func newBareModel() (*model, context.Context) {
4042 sess, ctx := testSession()
41- return newModel(ports.Screenshot, testOutputs, 0, sess), ctx
43+ return newModel(ports.Screenshot, testOutputs, 0, sess, false), ctx
4244 }
4345
4446 func motion(x, y float64) nefergui.InputEvent {
@@ -80,7 +82,7 @@ func TestClickAcceptsMonitor(t *testing.T) {
8082 require.Equal(t, endAccepted, m.end)
8183 res, _, _ := m.sess.outcome(context.Background())
8284 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)
8486 assert.Equal(t, ports.Target{OutputID: 7}, sel.Target)
8587 }
8688
@@ -97,7 +99,7 @@ func TestMonitorKeyThenEnter(t *testing.T) {
9799
98100 func TestEscapeAbortsEveryOverlay(t *testing.T) {
99101 sess, ctx := testSession()
100- m := newModel(ports.Screenshot, testOutputs, 1, sess)
102+ m := newModel(ports.Screenshot, testOutputs, 1, sess, false)
101103 m.resize(1280, 720, 1)
102104 m.input(key("Escape"))
103105 assert.Equal(t, endCanceled, m.end)
@@ -117,7 +119,7 @@ func TestDigitPicksMonitorFromAnyOverlay(t *testing.T) {
117119 require.NoError(t, err)
118120 require.True(t, ok)
119121 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)
121123 assert.Equal(t, ports.Target{OutputID: 9}, sel.Target)
122124
123125 own := newTestModel(t)
@@ -179,7 +181,7 @@ func TestInputResetBeforeThresholdAndKeepsChoices(t *testing.T) {
179181 assert.Equal(t, endNone, m.end)
180182
181183 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")
183185 id, _, ok := m.picker.Workspace()
184186 assert.True(t, ok)
185187 assert.Equal(t, uint64(11), id, "workspace survives a reset")
@@ -189,7 +191,7 @@ func TestInputResetBeforeThresholdAndKeepsChoices(t *testing.T) {
189191 res, ok, err := m.sess.outcome(context.Background())
190192 require.NoError(t, err)
191193 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)
193195 assert.Equal(t, ports.Target{OutputID: 7, WorkspaceID: 11}, sel.Target, "a workspace has a stable ID and a zero Region")
194196 }
195197
@@ -239,7 +241,7 @@ func TestWorkspaceOnlyWhenRealAndOnThisOutput(t *testing.T) {
239241 res, _, _ := m.sess.outcome(context.Background())
240242 assert.Equal(t, core.PickWorkspace, res.Kind)
241243 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)
243245 assert.Equal(t, ports.Target{OutputID: 7, WorkspaceID: 11}, sel.Target, "workspace geometry is live from the compositor, not a cached crop")
244246 }
245247
@@ -266,7 +268,7 @@ func TestOnlyTheActiveWorkspaceIsOffered(t *testing.T) {
266268 }
267269
268270 func TestSelectionConversionUsesCoreTarget(t *testing.T) {
269- s := New(ports.Screenshot, ports.VideoSettings{}, nil)
271+ s := New(ports.Screenshot, ports.VideoSettings{}, nil, false)
270272 region := ports.Region{X: 10, Y: 20, Width: 300, Height: 200}
271273 assert.Equal(t, ports.Target{OutputID: 7, Region: region},
272274 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) {
277279 }
278280
279281 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})
281283 assert.Equal(t, ports.Record, sel.Mode)
282284 assert.Equal(t, DefaultFPS, sel.Video.FPS)
283285 assert.Empty(t, sel.Path)
284286
285287 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)
288290 }
289291
290292 func TestSelectRejectsTooManyOutputsWithoutOpeningAnything(t *testing.T) {
@@ -292,13 +294,13 @@ func TestSelectRejectsTooManyOutputsWithoutOpeningAnything(t *testing.T) {
292294 for i := range outs {
293295 outs[i] = ports.Output{ID: uint32(i + 1), Name: "O"}
294296 }
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)
296298 assert.False(t, ok)
297299 assert.ErrorIs(t, err, ErrTooManyOutputs)
298300 }
299301
300302 func TestSelectRejectsBadInput(t *testing.T) {
301- s := New(ports.Screenshot, ports.VideoSettings{}, nil)
303+ s := New(ports.Screenshot, ports.VideoSettings{}, nil, false)
302304 _, ok, err := s.Select(context.Background(), nil)
303305 assert.False(t, ok)
304306 assert.ErrorIs(t, err, ports.ErrOutputNotFound)
@@ -422,7 +424,171 @@ func TestSceneMonitorOutlineAndGrid(t *testing.T) {
422424 assert.Equal(t, [4]frect{}, s.dim, "monitor choice is not dimmed")
423425 assert.Equal(t, frect{0, 0, 1920, 1}, s.edges[0])
424426 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+ }
426592 }
427593
428594 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 m37 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), ctx43+ 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 {
4848 mode ports.Mode
4949 video ports.VideoSettings
5050 workspaces []ports.Workspace
51+ grid bool
5152 }
5253
5354 var _ ports.Selector = (*Selector)(nil)
@@ -56,17 +57,18 @@ var _ ports.Selector = (*Selector)(nil)
5657 // becomes DefaultFPS. workspaces is native compositor metadata: the W key is
5758 // offered only for the Active workspace with a valid (nonzero) ID on the output
5859 // 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
6062 // Selection.Path. The first output passed to Select opens first, so the caller
6163 // 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 {
6365 if mode == ports.Record && video.FPS == 0 {
6466 video.FPS = DefaultFPS
6567 }
6668 if mode != ports.Record {
6769 video = ports.VideoSettings{}
6870 }
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}
7072 }
7173
7274 // 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
104106 sess := newSession(cancel)
105107 var wg sync.WaitGroup
106108 for i := range outputs {
107- m := newModel(s.mode, outputs, i, sess)
109+ m := newModel(s.mode, outputs, i, sess, s.grid)
108110 m.setWorkspaces(s.workspaces)
109111 wg.Add(1)
110112 go func() {
@@ -48,6 +48,7 @@ type Selector struct {
48 mode ports.Mode48 mode ports.Mode
49 video ports.VideoSettings49 video ports.VideoSettings
50 workspaces []ports.Workspace50 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 is57 // 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 output58 // 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 is59 // an overlay covers. Hidden workspaces are never offered and nothing is
59-// invented when no workspace qualifies. The caller assigns60+// 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 caller62 // 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 = DefaultFPS66 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 Escape74 // 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.WaitGroup107 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) {
3434 ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
3535 defer cancel()
3636 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)
3838 cancel()
3939 fmt.Printf("SMOKE outputs=%d peak_rss_kib=%d\n", len(outs), <-peak)
4040 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. */
22 app {
33 width: 100%; height: 100%; background-color: transparent;
44 color: #e6edf3; font-family: monospace; font-size: 13px;
@@ -6,7 +6,7 @@ app {
66 stack.scene { width: 100%; height: 100%; }
77 box.dim { background-color: rgba(0, 0, 0, 0.5); }
88 box.edge { background-color: #4fd1a5; }
9-box.grid { background-color: rgba(79, 209, 165, 0.45); }
9+box.grid { background-color: #000000; }
1010 box.tag {
1111 box-sizing: border-box; padding: 4px 10px; overflow: hidden;
1212 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 {
5353 // Static keys avoid building identity strings per frame.
5454 var (
5555 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"}
5757 edgeKeys = [4]string{"edge0", "edge1", "edge2", "edge3"}
5858 )
@@ -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 (
66 "fmt"
77
88 "github.com/bnema/nefercap/internal/adapters/cli"
9+ "github.com/bnema/nefercap/internal/adapters/config"
910 "github.com/bnema/nefercap/internal/adapters/control"
1011 "github.com/bnema/nefercap/internal/adapters/destination"
1112 "github.com/bnema/nefercap/internal/adapters/selection"
@@ -35,7 +36,11 @@ func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []po
3536 }
3637 return ports.Selection{}, false, fmt.Errorf("list workspace capture targets: %w", err)
3738 }
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)
3944 if err != nil || !accepted {
4045 return ports.Selection{}, false, err
4146 }
@@ -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, err45 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 {
9494 rect ports.Region
9595 pressed,
9696 dragging bool
97- grid bool
9897
9998 wsID uint64
10099 wsRect ports.Region
@@ -129,9 +128,6 @@ func (p *Picker) Rect() (r ports.Region, ok bool) { return p.rect, p.dragging }
129128 // Bounds returns the output logical size.
130129 func (p *Picker) Bounds() (width, height int) { return p.width, p.height }
131130
132-// Grid reports whether the thirds guides are on.
133-func (p *Picker) Grid() bool { return p.grid }
134-
135131 // Workspace returns the supplied workspace identity and its rectangle
136132 // clipped to the output; ok is false when none is available.
137133 func (p *Picker) Workspace() (id uint64, r ports.Region, ok bool) {
@@ -185,15 +181,6 @@ func (p *Picker) setKind(k PickKind) bool {
185181 return true
186182 }
187183
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-
197184 // MouseDown starts a press at (x, y).
198185 func (p *Picker) MouseDown(x, y float64) bool {
199186 if p.status != PickActive || p.pressed {
@@ -261,7 +248,7 @@ func (p *Picker) MouseUp(x, y float64) bool {
261248 // AbandonPress drops an unfinished press and any drag preview, for example
262249 // when the pointer leaves the surface or input focus is lost, so a button
263250 // 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
265252 // nothing after acceptance or cancellation. It reports whether state changed.
266253 func (p *Picker) AbandonPress() bool {
267254 if p.status != PickActive || !(p.pressed || p.dragging || p.rect != (ports.Region{})) {
@@ -94,7 +94,6 @@ type Picker struct {
94 rect ports.Region94 rect ports.Region
95 pressed,95 pressed,
96 dragging bool96 dragging bool
97- grid bool
98 97
99 wsID uint6498 wsID uint64
100 wsRect ports.Region99 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 rectangle131 // 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 true181 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 example248 // 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 button249 // 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 chosen250 // 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 does251+// 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) {
297297 assert.True(t, p.MouseMove(10, 10))
298298 assert.False(t, p.MouseMove(10, 10), "same position")
299299 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())
304300 assert.True(t, p.MouseDown(10, 10))
305301 assert.False(t, p.MouseDown(20, 20), "already pressed")
306302 assert.False(t, p.SelectMonitor(), "mode cannot change mid-press")
@@ -331,7 +327,6 @@ func TestPickerAbandonDragKeepsModes(t *testing.T) {
331327 ws := ports.Region{X: 10, Y: 10, Width: 500, Height: 400}
332328 p.SetWorkspace(21, ws)
333329 p.SelectWorkspace()
334- p.ToggleGrid()
335330 p.MouseDown(100, 100)
336331 p.MouseMove(300, 300)
337332 _, dragging := p.Rect()
@@ -341,7 +336,6 @@ func TestPickerAbandonDragKeepsModes(t *testing.T) {
341336 _, dragging = p.Rect()
342337 assert.False(t, dragging)
343338 assert.False(t, p.Dragging())
344- assert.True(t, p.Grid())
345339 assert.Equal(t, core.PickWorkspace, p.Kind())
346340 id, r, ok := p.Workspace()
347341 assert.True(t, ok)
@@ -394,7 +388,6 @@ func TestPickerAllocations(t *testing.T) {
394388 p.AbandonPress()
395389 p.MouseDown(5, 5)
396390 p.AbandonPress()
397- p.ToggleGrid()
398391 p.SelectMonitor()
399392 p.SelectRegion()
400393 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()

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 9

↑↓ moveenter openesc close