detached 4c2a7d6

fix(gui): align panel limits with ports and harden submit checks

commit
4c2a7d6c8b50ef94a9adb1b0f30755844f4bfee3
author
brice · · 2026-09-30 08:30 UTC
committer
brice · · 2026-09-30 14:48 UTC
parent
b11e89c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +284 -88
M internal/adapters/gui/form.go +55 -17
@@ -2,28 +2,31 @@ package gui
22
33 import (
44 "errors"
5+ "fmt"
6+ "strconv"
57 "strings"
68 "time"
79
810 "github.com/bnema/nefercap/internal/ports"
911 )
1012
11-// Form limits. Bounds are generous but keep every value far from int overflow.
12-const (
13- maxDim = 16384
14- maxFPS = 240
15- maxDuration = 24 * time.Hour
16-)
13+// Form limits. Dimension, frame-size and fps bounds come from ports so the
14+// panel cannot accept what the capture pipeline rejects.
15+const maxDuration = 24 * time.Hour
1716
18-// Validation messages are static so status rendering never allocates.
17+// Validation messages are built once, so status rendering never allocates.
1918 var (
2019 errOutput = errors.New("! select an output")
2120 errMode = errors.New("! select screenshot or record")
2221 errPath = errors.New("! file must name a path (not empty, not a directory)")
23- errRegion = errors.New("! region must be X,Y,WxH with X,Y >= 0 and W,H > 0, or empty")
24- errFPS = errors.New("! fps must be a whole number from 1 to 240")
25- errSize = errors.New("! size must be WxH with both > 0, or empty for source size")
22+ errRegion = fmt.Errorf("! region must be X,Y,WxH with X,Y >= 0, W,H > 0 and X+W, Y+H <= %d, or empty", ports.MaxDimension)
23+ errFPS = fmt.Errorf("! fps must be a whole number from 1 to %d", ports.MaxFPS)
24+ errSize = fmt.Errorf("! resolution must be WxH with both from 1 to %d, or empty for source size", ports.MaxDimension)
25+ errSizeOdd = errors.New("! resolution width and height must be even (video encoding)")
26+ errSizeSize = fmt.Errorf("! resolution is too large: at most %d MiB per frame", ports.MaxFrameBytes>>20)
2627 errDuration = errors.New("! duration must be seconds or e.g. 1m30s (max 24h); 0 = until Ctrl+C")
28+
29+ fpsPlaceholder = "1-" + strconv.Itoa(ports.MaxFPS)
2730 )
2831
2932 // parse validates the form and, on success, stores the result in m.sel. It
@@ -50,7 +53,7 @@ func (m *model) parse() error {
5053 sel.Mode = ports.Screenshot
5154 case modeRecord:
5255 sel.Mode = ports.Record
53- fps, ok := parseUint(m.fps, maxFPS)
56+ fps, ok := parseUint(m.fps, ports.MaxFPS)
5457 if !ok || fps < 1 {
5558 return errFPS
5659 }
@@ -58,7 +61,10 @@ func (m *model) parse() error {
5861 if !ok {
5962 return errSize
6063 }
61- dur, ok := parseDuration(m.duration)
64+ if err := checkVideoSize(w, h); err != nil {
65+ return err
66+ }
67+ dur, ok := m.parsedDuration()
6268 if !ok {
6369 return errDuration
6470 }
@@ -71,6 +77,35 @@ func (m *model) parse() error {
7177 return nil
7278 }
7379
80+// checkVideoSize applies encoder rules to an explicit video resolution (zero
81+// means source size). Region sizes are not checked: captured physical
82+// dimensions can differ from the logical region by the output scale, so an odd
83+// region does not imply odd video. If the source itself is odd, the user must
84+// set an even resolution; the record note says so.
85+func checkVideoSize(w, h int) error {
86+ if w == 0 {
87+ return nil
88+ }
89+ if w%2 != 0 || h%2 != 0 {
90+ return errSizeOdd
91+ }
92+ if w > ports.MaxFrameBytes/ports.BytesPerPixel/h {
93+ return errSizeSize
94+ }
95+ return nil
96+}
97+
98+// parsedDuration parses m.duration, remembering the last input and result so
99+// an unchanged (even invalid) field costs no allocation per frame.
100+func (m *model) parsedDuration() (time.Duration, bool) {
101+ if !m.durCached || m.durSrc != m.duration {
102+ m.durSrc = m.duration
103+ m.durVal, m.durOK = parseDuration(m.duration)
104+ m.durCached = true
105+ }
106+ return m.durVal, m.durOK
107+}
108+
74109 // parseUint parses plain decimal digits no greater than limit.
75110 func parseUint(s string, limit int) (int, bool) {
76111 s = strings.TrimSpace(s)
@@ -92,6 +127,9 @@ func parseUint(s string, limit int) (int, bool) {
92127 }
93128
94129 // parseRegion parses "X,Y,WxH". Empty means the full output (zero Region).
130+// It is deliberately stricter than the coordinate bound alone: the far edges
131+// X+W and Y+H must also stay within ports.MaxDimension. Whether the region fits
132+// the chosen output is only known to the capture adapter.
95133 func parseRegion(s string) (ports.Region, bool) {
96134 s = strings.TrimSpace(s)
97135 if s == "" {
@@ -105,10 +143,10 @@ func parseRegion(s string) (ports.Region, bool) {
105143 if !ok {
106144 return ports.Region{}, false
107145 }
108- x, ok1 := parseUint(xs, maxDim)
109- y, ok2 := parseUint(ys, maxDim)
146+ x, ok1 := parseUint(xs, ports.MaxDimension)
147+ y, ok2 := parseUint(ys, ports.MaxDimension)
110148 w, h, ok3 := parseSize(size)
111- if !ok1 || !ok2 || !ok3 || w == 0 {
149+ if !ok1 || !ok2 || !ok3 || w == 0 || x+w > ports.MaxDimension || y+h > ports.MaxDimension {
112150 return ports.Region{}, false
113151 }
114152 return ports.Region{X: x, Y: y, Width: w, Height: h}, true
@@ -124,8 +162,8 @@ func parseSize(s string) (w, h int, ok bool) {
124162 if i < 0 {
125163 return 0, 0, false
126164 }
127- w, ok1 := parseUint(s[:i], maxDim)
128- h, ok2 := parseUint(s[i+1:], maxDim)
165+ w, ok1 := parseUint(s[:i], ports.MaxDimension)
166+ h, ok2 := parseUint(s[i+1:], ports.MaxDimension)
129167 if !ok1 || !ok2 || w < 1 || h < 1 {
130168 return 0, 0, false
131169 }
@@ -2,28 +2,31 @@ package gui
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "fmt"
6+ "strconv"
5 "strings"7 "strings"
6 "time"8 "time"
7 9
8 "github.com/bnema/nefercap/internal/ports"10 "github.com/bnema/nefercap/internal/ports"
9 )11 )
10 12
11-// Form limits. Bounds are generous but keep every value far from int overflow.13+// Form limits. Dimension, frame-size and fps bounds come from ports so the
12-const (14+// panel cannot accept what the capture pipeline rejects.
13- maxDim = 1638415+const maxDuration = 24 * time.Hour
14- maxFPS = 240
15- maxDuration = 24 * time.Hour
16-)
17 16
18-// Validation messages are static so status rendering never allocates.17+// Validation messages are built once, so status rendering never allocates.
19 var (18 var (
20 errOutput = errors.New("! select an output")19 errOutput = errors.New("! select an output")
21 errMode = errors.New("! select screenshot or record")20 errMode = errors.New("! select screenshot or record")
22 errPath = errors.New("! file must name a path (not empty, not a directory)")21 errPath = errors.New("! file must name a path (not empty, not a directory)")
23- errRegion = errors.New("! region must be X,Y,WxH with X,Y >= 0 and W,H > 0, or empty")22+ errRegion = fmt.Errorf("! region must be X,Y,WxH with X,Y >= 0, W,H > 0 and X+W, Y+H <= %d, or empty", ports.MaxDimension)
24- errFPS = errors.New("! fps must be a whole number from 1 to 240")23+ errFPS = fmt.Errorf("! fps must be a whole number from 1 to %d", ports.MaxFPS)
25- errSize = errors.New("! size must be WxH with both > 0, or empty for source size")24+ errSize = fmt.Errorf("! resolution must be WxH with both from 1 to %d, or empty for source size", ports.MaxDimension)
25+ errSizeOdd = errors.New("! resolution width and height must be even (video encoding)")
26+ errSizeSize = fmt.Errorf("! resolution is too large: at most %d MiB per frame", ports.MaxFrameBytes>>20)
26 errDuration = errors.New("! duration must be seconds or e.g. 1m30s (max 24h); 0 = until Ctrl+C")27 errDuration = errors.New("! duration must be seconds or e.g. 1m30s (max 24h); 0 = until Ctrl+C")
28+
29+ fpsPlaceholder = "1-" + strconv.Itoa(ports.MaxFPS)
27 )30 )
28 31
29 // parse validates the form and, on success, stores the result in m.sel. It32 // parse validates the form and, on success, stores the result in m.sel. It
@@ -50,7 +53,7 @@ func (m *model) parse() error {
50 sel.Mode = ports.Screenshot53 sel.Mode = ports.Screenshot
51 case modeRecord:54 case modeRecord:
52 sel.Mode = ports.Record55 sel.Mode = ports.Record
53- fps, ok := parseUint(m.fps, maxFPS)56+ fps, ok := parseUint(m.fps, ports.MaxFPS)
54 if !ok || fps < 1 {57 if !ok || fps < 1 {
55 return errFPS58 return errFPS
56 }59 }
@@ -58,7 +61,10 @@ func (m *model) parse() error {
58 if !ok {61 if !ok {
59 return errSize62 return errSize
60 }63 }
61- dur, ok := parseDuration(m.duration)64+ if err := checkVideoSize(w, h); err != nil {
65+ return err
66+ }
67+ dur, ok := m.parsedDuration()
62 if !ok {68 if !ok {
63 return errDuration69 return errDuration
64 }70 }
@@ -71,6 +77,35 @@ func (m *model) parse() error {
71 return nil77 return nil
72 }78 }
73 79
80+// checkVideoSize applies encoder rules to an explicit video resolution (zero
81+// means source size). Region sizes are not checked: captured physical
82+// dimensions can differ from the logical region by the output scale, so an odd
83+// region does not imply odd video. If the source itself is odd, the user must
84+// set an even resolution; the record note says so.
85+func checkVideoSize(w, h int) error {
86+ if w == 0 {
87+ return nil
88+ }
89+ if w%2 != 0 || h%2 != 0 {
90+ return errSizeOdd
91+ }
92+ if w > ports.MaxFrameBytes/ports.BytesPerPixel/h {
93+ return errSizeSize
94+ }
95+ return nil
96+}
97+
98+// parsedDuration parses m.duration, remembering the last input and result so
99+// an unchanged (even invalid) field costs no allocation per frame.
100+func (m *model) parsedDuration() (time.Duration, bool) {
101+ if !m.durCached || m.durSrc != m.duration {
102+ m.durSrc = m.duration
103+ m.durVal, m.durOK = parseDuration(m.duration)
104+ m.durCached = true
105+ }
106+ return m.durVal, m.durOK
107+}
108+
74 // parseUint parses plain decimal digits no greater than limit.109 // parseUint parses plain decimal digits no greater than limit.
75 func parseUint(s string, limit int) (int, bool) {110 func parseUint(s string, limit int) (int, bool) {
76 s = strings.TrimSpace(s)111 s = strings.TrimSpace(s)
@@ -92,6 +127,9 @@ func parseUint(s string, limit int) (int, bool) {
92 }127 }
93 128
94 // parseRegion parses "X,Y,WxH". Empty means the full output (zero Region).129 // parseRegion parses "X,Y,WxH". Empty means the full output (zero Region).
130+// It is deliberately stricter than the coordinate bound alone: the far edges
131+// X+W and Y+H must also stay within ports.MaxDimension. Whether the region fits
132+// the chosen output is only known to the capture adapter.
95 func parseRegion(s string) (ports.Region, bool) {133 func parseRegion(s string) (ports.Region, bool) {
96 s = strings.TrimSpace(s)134 s = strings.TrimSpace(s)
97 if s == "" {135 if s == "" {
@@ -105,10 +143,10 @@ func parseRegion(s string) (ports.Region, bool) {
105 if !ok {143 if !ok {
106 return ports.Region{}, false144 return ports.Region{}, false
107 }145 }
108- x, ok1 := parseUint(xs, maxDim)146+ x, ok1 := parseUint(xs, ports.MaxDimension)
109- y, ok2 := parseUint(ys, maxDim)147+ y, ok2 := parseUint(ys, ports.MaxDimension)
110 w, h, ok3 := parseSize(size)148 w, h, ok3 := parseSize(size)
111- if !ok1 || !ok2 || !ok3 || w == 0 {149+ if !ok1 || !ok2 || !ok3 || w == 0 || x+w > ports.MaxDimension || y+h > ports.MaxDimension {
112 return ports.Region{}, false150 return ports.Region{}, false
113 }151 }
114 return ports.Region{X: x, Y: y, Width: w, Height: h}, true152 return ports.Region{X: x, Y: y, Width: w, Height: h}, true
@@ -124,8 +162,8 @@ func parseSize(s string) (w, h int, ok bool) {
124 if i < 0 {162 if i < 0 {
125 return 0, 0, false163 return 0, 0, false
126 }164 }
127- w, ok1 := parseUint(s[:i], maxDim)165+ w, ok1 := parseUint(s[:i], ports.MaxDimension)
128- h, ok2 := parseUint(s[i+1:], maxDim)166+ h, ok2 := parseUint(s[i+1:], ports.MaxDimension)
129 if !ok1 || !ok2 || w < 1 || h < 1 {167 if !ok1 || !ok2 || w < 1 || h < 1 {
130 return 0, 0, false168 return 0, 0, false
131 }169 }
M internal/adapters/gui/form_test.go +67 -8
@@ -1,6 +1,7 @@
11 package gui
22
33 import (
4+ "strconv"
45 "testing"
56 "time"
67
@@ -38,10 +39,11 @@ func TestParseRecordDefaults(t *testing.T) {
3839 m.mode = modeRecord
3940 require.NoError(t, m.parse())
4041 assert.Equal(t, ports.Selection{
41- Mode: ports.Record,
42- Target: ports.Target{OutputID: 7},
43- Path: "nefercap-20260102-030405.png",
44- Video: ports.VideoSettings{FPS: 30},
42+ Mode: ports.Record,
43+ Target: ports.Target{OutputID: 7},
44+ Path: "nefercap-20260102-030405.png",
45+ Video: ports.VideoSettings{FPS: 30},
46+ Duration: 10 * time.Second,
4547 }, m.sel)
4648 }
4749
@@ -81,15 +83,20 @@ func TestParseInvalid(t *testing.T) {
8183 {"region negative", func(m *model) { m.region = "-1,0,5x5" }, errRegion},
8284 {"region junk", func(m *model) { m.region = "a,b,cxd" }, errRegion},
8385 {"region huge", func(m *model) { m.region = "0,0,99999999999999999999x5" }, errRegion},
86+ {"region far edge x", func(m *model) { m.region = "16000,0,385x5" }, errRegion},
87+ {"region far edge y", func(m *model) { m.region = "0,16000,5x385" }, errRegion},
8488 {"region empty size", func(m *model) { m.region = "0,0," }, errRegion},
8589 {"fps empty", func(m *model) { m.mode = modeRecord; m.fps = "" }, errFPS},
8690 {"fps zero", func(m *model) { m.mode = modeRecord; m.fps = "0" }, errFPS},
87- {"fps high", func(m *model) { m.mode = modeRecord; m.fps = "241" }, errFPS},
91+ {"fps high", func(m *model) { m.mode = modeRecord; m.fps = "121" }, errFPS},
8892 {"fps fraction", func(m *model) { m.mode = modeRecord; m.fps = "29.97" }, errFPS},
8993 {"fps sign", func(m *model) { m.mode = modeRecord; m.fps = "+30" }, errFPS},
9094 {"size one dim", func(m *model) { m.mode = modeRecord; m.size = "1280" }, errSize},
9195 {"size zero", func(m *model) { m.mode = modeRecord; m.size = "0x720" }, errSize},
9296 {"size missing height", func(m *model) { m.mode = modeRecord; m.size = "1280x" }, errSize},
97+ {"size odd width", func(m *model) { m.mode = modeRecord; m.size = "1281x720" }, errSizeOdd},
98+ {"size odd height", func(m *model) { m.mode = modeRecord; m.size = "1280x721" }, errSizeOdd},
99+ {"size over frame bytes", func(m *model) { m.mode = modeRecord; m.size = "16384x16384" }, errSizeSize},
93100 {"duration junk", func(m *model) { m.mode = modeRecord; m.duration = "soon" }, errDuration},
94101 {"duration negative", func(m *model) { m.mode = modeRecord; m.duration = "-5s" }, errDuration},
95102 {"duration too long", func(m *model) { m.mode = modeRecord; m.duration = "25h" }, errDuration},
@@ -104,6 +111,46 @@ func TestParseInvalid(t *testing.T) {
104111 }
105112 }
106113
114+func TestFPSBoundIsPortsMax(t *testing.T) {
115+ m := newTestModel(t)
116+ m.mode = modeRecord
117+ m.fps = strconv.Itoa(ports.MaxFPS)
118+ require.NoError(t, m.parse())
119+ assert.Equal(t, ports.MaxFPS, m.sel.Video.FPS)
120+ m.fps = strconv.Itoa(ports.MaxFPS + 1)
121+ assert.ErrorIs(t, m.parse(), errFPS)
122+ assert.Contains(t, errFPS.Error(), strconv.Itoa(ports.MaxFPS))
123+ assert.Equal(t, "1-"+strconv.Itoa(ports.MaxFPS), fpsPlaceholder)
124+}
125+
126+func TestOddRegionAllowedEvenSizeChecked(t *testing.T) {
127+ m := newTestModel(t)
128+ m.mode = modeRecord
129+ m.region = "1,3,641x481"
130+ require.NoError(t, m.parse())
131+ assert.Equal(t, ports.Region{X: 1, Y: 3, Width: 641, Height: 481}, m.sel.Target.Region)
132+ m.mode = modeScreenshot
133+ m.size = "1281x721"
134+ require.NoError(t, m.parse(), "screenshot ignores video size")
135+}
136+
137+func TestVideoSizeAtFrameLimit(t *testing.T) {
138+ m := newTestModel(t)
139+ m.mode = modeRecord
140+ m.size = "16384x4096"
141+ require.NoError(t, m.parse())
142+ m.size = "16384x4098"
143+ assert.ErrorIs(t, m.parse(), errSizeSize)
144+}
145+
146+func TestRegionAtMaxDimension(t *testing.T) {
147+ m := newTestModel(t)
148+ m.region = "0,0,16384x16384"
149+ require.NoError(t, m.parse())
150+ m.region = "1,0,16384x1"
151+ assert.ErrorIs(t, m.parse(), errRegion)
152+}
153+
107154 func TestScreenshotIgnoresRecordFields(t *testing.T) {
108155 m := newTestModel(t)
109156 m.fps, m.size, m.duration = "bad", "bad", "bad"
@@ -133,12 +180,12 @@ func TestParseDuration(t *testing.T) {
133180 }
134181
135182 func TestParseUintBounds(t *testing.T) {
136- n, ok := parseUint("16384", maxDim)
183+ n, ok := parseUint("16384", ports.MaxDimension)
137184 assert.True(t, ok)
138185 assert.Equal(t, 16384, n)
139- _, ok = parseUint("16385", maxDim)
186+ _, ok = parseUint("16385", ports.MaxDimension)
140187 assert.False(t, ok)
141- _, ok = parseUint("", maxDim)
188+ _, ok = parseUint("", ports.MaxDimension)
142189 assert.False(t, ok)
143190 }
144191
@@ -152,12 +199,24 @@ func TestFormAllocations(t *testing.T) {
152199 m.duration = "1m30s"
153200 require.NoError(t, m.parse())
154201 assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "valid form")
202+ m.size = "1281x720"
203+ assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "odd size")
204+ m.size = "1280x720"
155205 assert.Zero(t, testing.AllocsPerRun(100, func() { _, _, _ = m.status() }), "status")
156206 m.fps = "abc"
157207 assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "invalid form")
158208 m.fps = "30"
159209 m.duration = "0"
160210 assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "seconds duration")
211+ m.duration = "soon"
212+ require.ErrorIs(t, m.parse(), errDuration)
213+ assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "invalid duration")
214+ assert.Zero(t, testing.AllocsPerRun(100, func() { _, _, _ = m.status() }), "invalid duration status")
215+ m.duration = "-5s"
216+ require.ErrorIs(t, m.parse(), errDuration)
217+ m.duration = "1m"
218+ require.NoError(t, m.parse(), "cache follows edits")
219+ assert.Equal(t, time.Minute, m.sel.Duration)
161220 }
162221
163222 func BenchmarkFormParse(b *testing.B) {
@@ -1,6 +1,7 @@
1 package gui1 package gui
2 2
3 import (3 import (
4+ "strconv"
4 "testing"5 "testing"
5 "time"6 "time"
6 7
@@ -38,10 +39,11 @@ func TestParseRecordDefaults(t *testing.T) {
38 m.mode = modeRecord39 m.mode = modeRecord
39 require.NoError(t, m.parse())40 require.NoError(t, m.parse())
40 assert.Equal(t, ports.Selection{41 assert.Equal(t, ports.Selection{
41- Mode: ports.Record,42+ Mode: ports.Record,
42- Target: ports.Target{OutputID: 7},43+ Target: ports.Target{OutputID: 7},
43- Path: "nefercap-20260102-030405.png",44+ Path: "nefercap-20260102-030405.png",
44- Video: ports.VideoSettings{FPS: 30},45+ Video: ports.VideoSettings{FPS: 30},
46+ Duration: 10 * time.Second,
45 }, m.sel)47 }, m.sel)
46 }48 }
47 49
@@ -81,15 +83,20 @@ func TestParseInvalid(t *testing.T) {
81 {"region negative", func(m *model) { m.region = "-1,0,5x5" }, errRegion},83 {"region negative", func(m *model) { m.region = "-1,0,5x5" }, errRegion},
82 {"region junk", func(m *model) { m.region = "a,b,cxd" }, errRegion},84 {"region junk", func(m *model) { m.region = "a,b,cxd" }, errRegion},
83 {"region huge", func(m *model) { m.region = "0,0,99999999999999999999x5" }, errRegion},85 {"region huge", func(m *model) { m.region = "0,0,99999999999999999999x5" }, errRegion},
86+ {"region far edge x", func(m *model) { m.region = "16000,0,385x5" }, errRegion},
87+ {"region far edge y", func(m *model) { m.region = "0,16000,5x385" }, errRegion},
84 {"region empty size", func(m *model) { m.region = "0,0," }, errRegion},88 {"region empty size", func(m *model) { m.region = "0,0," }, errRegion},
85 {"fps empty", func(m *model) { m.mode = modeRecord; m.fps = "" }, errFPS},89 {"fps empty", func(m *model) { m.mode = modeRecord; m.fps = "" }, errFPS},
86 {"fps zero", func(m *model) { m.mode = modeRecord; m.fps = "0" }, errFPS},90 {"fps zero", func(m *model) { m.mode = modeRecord; m.fps = "0" }, errFPS},
87- {"fps high", func(m *model) { m.mode = modeRecord; m.fps = "241" }, errFPS},91+ {"fps high", func(m *model) { m.mode = modeRecord; m.fps = "121" }, errFPS},
88 {"fps fraction", func(m *model) { m.mode = modeRecord; m.fps = "29.97" }, errFPS},92 {"fps fraction", func(m *model) { m.mode = modeRecord; m.fps = "29.97" }, errFPS},
89 {"fps sign", func(m *model) { m.mode = modeRecord; m.fps = "+30" }, errFPS},93 {"fps sign", func(m *model) { m.mode = modeRecord; m.fps = "+30" }, errFPS},
90 {"size one dim", func(m *model) { m.mode = modeRecord; m.size = "1280" }, errSize},94 {"size one dim", func(m *model) { m.mode = modeRecord; m.size = "1280" }, errSize},
91 {"size zero", func(m *model) { m.mode = modeRecord; m.size = "0x720" }, errSize},95 {"size zero", func(m *model) { m.mode = modeRecord; m.size = "0x720" }, errSize},
92 {"size missing height", func(m *model) { m.mode = modeRecord; m.size = "1280x" }, errSize},96 {"size missing height", func(m *model) { m.mode = modeRecord; m.size = "1280x" }, errSize},
97+ {"size odd width", func(m *model) { m.mode = modeRecord; m.size = "1281x720" }, errSizeOdd},
98+ {"size odd height", func(m *model) { m.mode = modeRecord; m.size = "1280x721" }, errSizeOdd},
99+ {"size over frame bytes", func(m *model) { m.mode = modeRecord; m.size = "16384x16384" }, errSizeSize},
93 {"duration junk", func(m *model) { m.mode = modeRecord; m.duration = "soon" }, errDuration},100 {"duration junk", func(m *model) { m.mode = modeRecord; m.duration = "soon" }, errDuration},
94 {"duration negative", func(m *model) { m.mode = modeRecord; m.duration = "-5s" }, errDuration},101 {"duration negative", func(m *model) { m.mode = modeRecord; m.duration = "-5s" }, errDuration},
95 {"duration too long", func(m *model) { m.mode = modeRecord; m.duration = "25h" }, errDuration},102 {"duration too long", func(m *model) { m.mode = modeRecord; m.duration = "25h" }, errDuration},
@@ -104,6 +111,46 @@ func TestParseInvalid(t *testing.T) {
104 }111 }
105 }112 }
106 113
114+func TestFPSBoundIsPortsMax(t *testing.T) {
115+ m := newTestModel(t)
116+ m.mode = modeRecord
117+ m.fps = strconv.Itoa(ports.MaxFPS)
118+ require.NoError(t, m.parse())
119+ assert.Equal(t, ports.MaxFPS, m.sel.Video.FPS)
120+ m.fps = strconv.Itoa(ports.MaxFPS + 1)
121+ assert.ErrorIs(t, m.parse(), errFPS)
122+ assert.Contains(t, errFPS.Error(), strconv.Itoa(ports.MaxFPS))
123+ assert.Equal(t, "1-"+strconv.Itoa(ports.MaxFPS), fpsPlaceholder)
124+}
125+
126+func TestOddRegionAllowedEvenSizeChecked(t *testing.T) {
127+ m := newTestModel(t)
128+ m.mode = modeRecord
129+ m.region = "1,3,641x481"
130+ require.NoError(t, m.parse())
131+ assert.Equal(t, ports.Region{X: 1, Y: 3, Width: 641, Height: 481}, m.sel.Target.Region)
132+ m.mode = modeScreenshot
133+ m.size = "1281x721"
134+ require.NoError(t, m.parse(), "screenshot ignores video size")
135+}
136+
137+func TestVideoSizeAtFrameLimit(t *testing.T) {
138+ m := newTestModel(t)
139+ m.mode = modeRecord
140+ m.size = "16384x4096"
141+ require.NoError(t, m.parse())
142+ m.size = "16384x4098"
143+ assert.ErrorIs(t, m.parse(), errSizeSize)
144+}
145+
146+func TestRegionAtMaxDimension(t *testing.T) {
147+ m := newTestModel(t)
148+ m.region = "0,0,16384x16384"
149+ require.NoError(t, m.parse())
150+ m.region = "1,0,16384x1"
151+ assert.ErrorIs(t, m.parse(), errRegion)
152+}
153+
107 func TestScreenshotIgnoresRecordFields(t *testing.T) {154 func TestScreenshotIgnoresRecordFields(t *testing.T) {
108 m := newTestModel(t)155 m := newTestModel(t)
109 m.fps, m.size, m.duration = "bad", "bad", "bad"156 m.fps, m.size, m.duration = "bad", "bad", "bad"
@@ -133,12 +180,12 @@ func TestParseDuration(t *testing.T) {
133 }180 }
134 181
135 func TestParseUintBounds(t *testing.T) {182 func TestParseUintBounds(t *testing.T) {
136- n, ok := parseUint("16384", maxDim)183+ n, ok := parseUint("16384", ports.MaxDimension)
137 assert.True(t, ok)184 assert.True(t, ok)
138 assert.Equal(t, 16384, n)185 assert.Equal(t, 16384, n)
139- _, ok = parseUint("16385", maxDim)186+ _, ok = parseUint("16385", ports.MaxDimension)
140 assert.False(t, ok)187 assert.False(t, ok)
141- _, ok = parseUint("", maxDim)188+ _, ok = parseUint("", ports.MaxDimension)
142 assert.False(t, ok)189 assert.False(t, ok)
143 }190 }
144 191
@@ -152,12 +199,24 @@ func TestFormAllocations(t *testing.T) {
152 m.duration = "1m30s"199 m.duration = "1m30s"
153 require.NoError(t, m.parse())200 require.NoError(t, m.parse())
154 assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "valid form")201 assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "valid form")
202+ m.size = "1281x720"
203+ assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "odd size")
204+ m.size = "1280x720"
155 assert.Zero(t, testing.AllocsPerRun(100, func() { _, _, _ = m.status() }), "status")205 assert.Zero(t, testing.AllocsPerRun(100, func() { _, _, _ = m.status() }), "status")
156 m.fps = "abc"206 m.fps = "abc"
157 assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "invalid form")207 assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "invalid form")
158 m.fps = "30"208 m.fps = "30"
159 m.duration = "0"209 m.duration = "0"
160 assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "seconds duration")210 assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "seconds duration")
211+ m.duration = "soon"
212+ require.ErrorIs(t, m.parse(), errDuration)
213+ assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "invalid duration")
214+ assert.Zero(t, testing.AllocsPerRun(100, func() { _, _, _ = m.status() }), "invalid duration status")
215+ m.duration = "-5s"
216+ require.ErrorIs(t, m.parse(), errDuration)
217+ m.duration = "1m"
218+ require.NoError(t, m.parse(), "cache follows edits")
219+ assert.Equal(t, time.Minute, m.sel.Duration)
161 }220 }
162 221
163 func BenchmarkFormParse(b *testing.B) {222 func BenchmarkFormParse(b *testing.B) {
M internal/adapters/gui/gui.go +42 -30
@@ -30,8 +30,11 @@ func New() *Selector { return &Selector{} }
3030
3131 // Select blocks until the user accepts a selection (true), closes the window
3232 // (false, nil) or ctx ends or the window system fails (error). The window is
33-// gone before an accepted selection is returned, so it cannot appear in the
34-// capture. Select must be called from one goroutine at a time.
33+// destroyed by the panel's own Wayland session before an accepted selection is
34+// returned. The compositor may still process that teardown after Select
35+// returns, so a caller that must not capture the panel should confirm through
36+// a fresh capture connection before capturing. Select must be called from one
37+// goroutine at a time.
3538 func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Selection, bool, error) {
3639 if err := ctx.Err(); err != nil {
3740 return ports.Selection{}, false, err
@@ -59,22 +62,23 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se
5962 return m.result(ctx, runErr)
6063 }
6164
62-// result maps Run's outcome. A Canceled error caused by our own accept or
63-// dismiss is not a failure, but any other error (including one joined with it,
64-// such as a surface Close failure) is reported and rejects the selection. When
65-// the parent context ended, its error is always reported and nothing is
66-// accepted.
65+// result maps Run's outcome. The parent context is checked first: if it has
66+// ended, nothing is accepted and its error is returned, joined with any real
67+// Run error. Otherwise a Canceled error caused by our own accept or dismiss is
68+// not a failure, but any other error (including one joined with it, such as a
69+// surface Close failure) is reported and rejects the selection.
6770 func (m *model) result(parent context.Context, runErr error) (ports.Selection, bool, error) {
68- if runErr != nil {
69- if perr := parent.Err(); perr != nil {
70- return ports.Selection{}, false, errors.Join(perr, dropCanceled(runErr))
71- }
72- if m.accepted || m.closed {
73- runErr = dropCanceled(runErr)
74- }
75- if runErr != nil {
76- return ports.Selection{}, false, runErr
71+ if perr := parent.Err(); perr != nil {
72+ if rest := dropErrors(runErr, context.Canceled, perr); rest != nil {
73+ return ports.Selection{}, false, errors.Join(perr, rest)
7774 }
75+ return ports.Selection{}, false, perr
76+ }
77+ if m.accepted || m.closed {
78+ runErr = dropErrors(runErr, context.Canceled)
79+ }
80+ if runErr != nil {
81+ return ports.Selection{}, false, runErr
7882 }
7983 if m.accepted {
8084 return m.sel, true, nil
@@ -82,25 +86,33 @@ func (m *model) result(parent context.Context, runErr error) (ports.Selection, b
8286 return ports.Selection{}, false, nil
8387 }
8488
85-// dropCanceled removes bare context.Canceled leaves from err and joined errors.
86-func dropCanceled(err error) error {
87- if err == nil || err == context.Canceled {
89+// dropErrors removes leaves equal to any target from err and from joined
90+// errors, keeping everything else. It returns nil if nothing real remains.
91+func dropErrors(err error, targets ...error) error {
92+ if err == nil {
8893 return nil
8994 }
90- j, ok := err.(interface{ Unwrap() []error })
91- if !ok {
92- return err
93- }
94- var keep []error
95- for _, e := range j.Unwrap() {
96- if e = dropCanceled(e); e != nil {
97- keep = append(keep, e)
95+ if j, ok := err.(interface{ Unwrap() []error }); ok {
96+ var keep []error
97+ for _, e := range j.Unwrap() {
98+ if e = dropErrors(e, targets...); e != nil {
99+ keep = append(keep, e)
100+ }
98101 }
102+ switch len(keep) {
103+ case 0:
104+ return nil
105+ case 1:
106+ return keep[0]
107+ }
108+ return errors.Join(keep...)
99109 }
100- if len(keep) == 1 {
101- return keep[0]
110+ for _, t := range targets {
111+ if err == t {
112+ return nil
113+ }
102114 }
103- return errors.Join(keep...)
115+ return err
104116 }
105117
106118 // writeStyleSheet stores the embedded stylesheet in a private temporary file
@@ -30,8 +30,11 @@ func New() *Selector { return &Selector{} }
30 30
31 // Select blocks until the user accepts a selection (true), closes the window31 // Select blocks until the user accepts a selection (true), closes the window
32 // (false, nil) or ctx ends or the window system fails (error). The window is32 // (false, nil) or ctx ends or the window system fails (error). The window is
33-// gone before an accepted selection is returned, so it cannot appear in the33+// destroyed by the panel's own Wayland session before an accepted selection is
34-// capture. Select must be called from one goroutine at a time.34+// returned. The compositor may still process that teardown after Select
35+// returns, so a caller that must not capture the panel should confirm through
36+// a fresh capture connection before capturing. Select must be called from one
37+// goroutine at a time.
35 func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Selection, bool, error) {38 func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Selection, bool, error) {
36 if err := ctx.Err(); err != nil {39 if err := ctx.Err(); err != nil {
37 return ports.Selection{}, false, err40 return ports.Selection{}, false, err
@@ -59,22 +62,23 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se
59 return m.result(ctx, runErr)62 return m.result(ctx, runErr)
60 }63 }
61 64
62-// result maps Run's outcome. A Canceled error caused by our own accept or65+// result maps Run's outcome. The parent context is checked first: if it has
63-// dismiss is not a failure, but any other error (including one joined with it,66+// ended, nothing is accepted and its error is returned, joined with any real
64-// such as a surface Close failure) is reported and rejects the selection. When67+// Run error. Otherwise a Canceled error caused by our own accept or dismiss is
65-// the parent context ended, its error is always reported and nothing is68+// not a failure, but any other error (including one joined with it, such as a
66-// accepted.69+// surface Close failure) is reported and rejects the selection.
67 func (m *model) result(parent context.Context, runErr error) (ports.Selection, bool, error) {70 func (m *model) result(parent context.Context, runErr error) (ports.Selection, bool, error) {
68- if runErr != nil {71+ if perr := parent.Err(); perr != nil {
69- if perr := parent.Err(); perr != nil {72+ if rest := dropErrors(runErr, context.Canceled, perr); rest != nil {
70- return ports.Selection{}, false, errors.Join(perr, dropCanceled(runErr))73+ return ports.Selection{}, false, errors.Join(perr, rest)
71- }
72- if m.accepted || m.closed {
73- runErr = dropCanceled(runErr)
74- }
75- if runErr != nil {
76- return ports.Selection{}, false, runErr
77 }74 }
75+ return ports.Selection{}, false, perr
76+ }
77+ if m.accepted || m.closed {
78+ runErr = dropErrors(runErr, context.Canceled)
79+ }
80+ if runErr != nil {
81+ return ports.Selection{}, false, runErr
78 }82 }
79 if m.accepted {83 if m.accepted {
80 return m.sel, true, nil84 return m.sel, true, nil
@@ -82,25 +86,33 @@ func (m *model) result(parent context.Context, runErr error) (ports.Selection, b
82 return ports.Selection{}, false, nil86 return ports.Selection{}, false, nil
83 }87 }
84 88
85-// dropCanceled removes bare context.Canceled leaves from err and joined errors.89+// dropErrors removes leaves equal to any target from err and from joined
86-func dropCanceled(err error) error {90+// errors, keeping everything else. It returns nil if nothing real remains.
87- if err == nil || err == context.Canceled {91+func dropErrors(err error, targets ...error) error {
92+ if err == nil {
88 return nil93 return nil
89 }94 }
90- j, ok := err.(interface{ Unwrap() []error })95+ if j, ok := err.(interface{ Unwrap() []error }); ok {
91- if !ok {96+ var keep []error
92- return err97+ for _, e := range j.Unwrap() {
93- }98+ if e = dropErrors(e, targets...); e != nil {
94- var keep []error99+ keep = append(keep, e)
95- for _, e := range j.Unwrap() {100+ }
96- if e = dropCanceled(e); e != nil {
97- keep = append(keep, e)
98 }101 }
102+ switch len(keep) {
103+ case 0:
104+ return nil
105+ case 1:
106+ return keep[0]
107+ }
108+ return errors.Join(keep...)
99 }109 }
100- if len(keep) == 1 {110+ for _, t := range targets {
101- return keep[0]111+ if err == t {
112+ return nil
113+ }
102 }114 }
103- return errors.Join(keep...)115+ return err
104 }116 }
105 117
106 // writeStyleSheet stores the embedded stylesheet in a private temporary file118 // writeStyleSheet stores the embedded stylesheet in a private temporary file
M internal/adapters/gui/gui_test.go +57 -8
@@ -25,7 +25,7 @@ func TestNewModelLabelsAndDefaults(t *testing.T) {
2525 assert.Equal(t, "nefercap-20260102-030405.png", m.defaultShot)
2626 assert.Equal(t, "nefercap-20260102-030405.mp4", m.defaultRec)
2727 assert.Equal(t, "30", m.fps)
28- assert.Equal(t, "0", m.duration)
28+ assert.Equal(t, "10", m.duration)
2929 keys := map[string]bool{}
3030 for _, o := range m.outputs {
3131 assert.False(t, keys[o.key])
@@ -161,6 +161,7 @@ func TestResult(t *testing.T) {
161161 {name: "setup failure", parent: context.Background(), runErr: closeErr, wantErr: closeErr},
162162 {name: "unexplained cancel", parent: context.Background(), runErr: context.Canceled, wantErr: context.Canceled},
163163 {name: "parent canceled", parent: canceledParent, runErr: context.Canceled, wantErr: context.Canceled},
164+ {name: "parent canceled nil run", parent: canceledParent, wantErr: context.Canceled},
164165 {name: "parent canceled after accept", parent: canceledParent, accepted: true, runErr: context.Canceled, wantErr: context.Canceled},
165166 {name: "parent canceled with close failure", parent: canceledParent, accepted: true, runErr: errors.Join(context.Canceled, closeErr), wantErr: closeErr},
166167 }
@@ -189,16 +190,59 @@ func TestResult(t *testing.T) {
189190 }
190191 }
191192
192-func TestDropCanceled(t *testing.T) {
193+// A parent deadline or cancel with only cancellation noise from Run reports
194+// the parent's own error bare, so callers can exit cleanly on it.
195+func TestResultReturnsBareParentError(t *testing.T) {
196+ m := newTestModel(t)
197+ parent, cancel := context.WithCancel(context.Background())
198+ cancel()
199+ _, _, err := m.result(parent, errors.Join(context.Canceled, context.Canceled))
200+ assert.True(t, err == context.Canceled, "bare canceled error")
201+
202+ deadline, stop := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
203+ defer stop()
204+ _, _, err = m.result(deadline, context.DeadlineExceeded)
205+ assert.True(t, err == context.DeadlineExceeded, "bare deadline error")
206+}
207+
208+func TestDropErrors(t *testing.T) {
193209 real := errors.New("real")
194- assert.NoError(t, dropCanceled(nil))
195- assert.NoError(t, dropCanceled(context.Canceled))
196- assert.NoError(t, dropCanceled(errors.Join(context.Canceled, context.Canceled)))
197- assert.Same(t, real, dropCanceled(errors.Join(context.Canceled, real)))
210+ assert.NoError(t, dropErrors(nil, context.Canceled))
211+ assert.NoError(t, dropErrors(context.Canceled, context.Canceled))
212+ assert.NoError(t, dropErrors(errors.Join(context.Canceled, context.Canceled), context.Canceled))
213+ assert.Same(t, real, dropErrors(errors.Join(context.Canceled, real), context.Canceled))
198214 wrapped := fmt.Errorf("wrapped: %w", context.Canceled)
199- assert.Equal(t, wrapped, dropCanceled(wrapped), "wrapped cancellation is not ours")
200- both := dropCanceled(errors.Join(real, errors.New("second")))
215+ assert.Equal(t, wrapped, dropErrors(wrapped, context.Canceled), "wrapped cancellation is not ours")
216+ both := dropErrors(errors.Join(real, errors.New("second")), context.Canceled)
201217 assert.ErrorIs(t, both, real)
218+ assert.NoError(t, dropErrors(errors.Join(context.Canceled, context.DeadlineExceeded), context.Canceled, context.DeadlineExceeded))
219+}
220+
221+func TestCheckPath(t *testing.T) {
222+ dir := t.TempDir()
223+ file := filepath.Join(dir, "taken.png")
224+ require.NoError(t, os.WriteFile(file, nil, 0o600))
225+ assert.Empty(t, checkPath(filepath.Join(dir, "new.png")))
226+ assert.Empty(t, checkPath(filepath.Join(dir, "new.mp4")), "extension does not dictate format")
227+ assert.Equal(t, noticeExists, checkPath(file))
228+ assert.Equal(t, noticeDir, checkPath(filepath.Join(dir, "missing", "new.png")))
229+ assert.Equal(t, noticeDir, checkPath(filepath.Join(file, "new.png")), "parent is a file")
230+ assert.Empty(t, checkPath("relative-new-name.png"))
231+
232+ link := filepath.Join(dir, "dangling.png")
233+ require.NoError(t, os.Symlink(filepath.Join(dir, "nowhere"), link))
234+ assert.Equal(t, noticeExists, checkPath(link), "dangling symlink is not overwritten")
235+
236+ // A path component that is a regular file yields ENOTDIR, not ErrNotExist.
237+ assert.Equal(t, noticeDir, checkPath(filepath.Join(file, "x", "y.png")))
238+}
239+
240+func TestRecordWithImageExtensionAccepted(t *testing.T) {
241+ m := newTestModel(t)
242+ m.mode = modeRecord
243+ m.path = filepath.Join(t.TempDir(), "clip.png")
244+ require.NoError(t, m.parse())
245+ assert.Equal(t, ports.Record, m.sel.Mode)
202246 }
203247
204248 func TestWriteStyleSheet(t *testing.T) {
@@ -220,6 +264,11 @@ func TestStyleSheetUsesMonospace(t *testing.T) {
220264 assert.Contains(t, styleSheet, "monospace")
221265 }
222266
267+func TestStyleSheetScrollsRootAndOmitsDeadRules(t *testing.T) {
268+ assert.Contains(t, styleSheet, "overflow-y: auto")
269+ assert.NotContains(t, styleSheet, "separator")
270+}
271+
223272 func TestWriteStyleSheetFailure(t *testing.T) {
224273 t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing"))
225274 _, _, err := writeStyleSheet()
@@ -25,7 +25,7 @@ func TestNewModelLabelsAndDefaults(t *testing.T) {
25 assert.Equal(t, "nefercap-20260102-030405.png", m.defaultShot)25 assert.Equal(t, "nefercap-20260102-030405.png", m.defaultShot)
26 assert.Equal(t, "nefercap-20260102-030405.mp4", m.defaultRec)26 assert.Equal(t, "nefercap-20260102-030405.mp4", m.defaultRec)
27 assert.Equal(t, "30", m.fps)27 assert.Equal(t, "30", m.fps)
28- assert.Equal(t, "0", m.duration)28+ assert.Equal(t, "10", m.duration)
29 keys := map[string]bool{}29 keys := map[string]bool{}
30 for _, o := range m.outputs {30 for _, o := range m.outputs {
31 assert.False(t, keys[o.key])31 assert.False(t, keys[o.key])
@@ -161,6 +161,7 @@ func TestResult(t *testing.T) {
161 {name: "setup failure", parent: context.Background(), runErr: closeErr, wantErr: closeErr},161 {name: "setup failure", parent: context.Background(), runErr: closeErr, wantErr: closeErr},
162 {name: "unexplained cancel", parent: context.Background(), runErr: context.Canceled, wantErr: context.Canceled},162 {name: "unexplained cancel", parent: context.Background(), runErr: context.Canceled, wantErr: context.Canceled},
163 {name: "parent canceled", parent: canceledParent, runErr: context.Canceled, wantErr: context.Canceled},163 {name: "parent canceled", parent: canceledParent, runErr: context.Canceled, wantErr: context.Canceled},
164+ {name: "parent canceled nil run", parent: canceledParent, wantErr: context.Canceled},
164 {name: "parent canceled after accept", parent: canceledParent, accepted: true, runErr: context.Canceled, wantErr: context.Canceled},165 {name: "parent canceled after accept", parent: canceledParent, accepted: true, runErr: context.Canceled, wantErr: context.Canceled},
165 {name: "parent canceled with close failure", parent: canceledParent, accepted: true, runErr: errors.Join(context.Canceled, closeErr), wantErr: closeErr},166 {name: "parent canceled with close failure", parent: canceledParent, accepted: true, runErr: errors.Join(context.Canceled, closeErr), wantErr: closeErr},
166 }167 }
@@ -189,16 +190,59 @@ func TestResult(t *testing.T) {
189 }190 }
190 }191 }
191 192
192-func TestDropCanceled(t *testing.T) {193+// A parent deadline or cancel with only cancellation noise from Run reports
194+// the parent's own error bare, so callers can exit cleanly on it.
195+func TestResultReturnsBareParentError(t *testing.T) {
196+ m := newTestModel(t)
197+ parent, cancel := context.WithCancel(context.Background())
198+ cancel()
199+ _, _, err := m.result(parent, errors.Join(context.Canceled, context.Canceled))
200+ assert.True(t, err == context.Canceled, "bare canceled error")
201+
202+ deadline, stop := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
203+ defer stop()
204+ _, _, err = m.result(deadline, context.DeadlineExceeded)
205+ assert.True(t, err == context.DeadlineExceeded, "bare deadline error")
206+}
207+
208+func TestDropErrors(t *testing.T) {
193 real := errors.New("real")209 real := errors.New("real")
194- assert.NoError(t, dropCanceled(nil))210+ assert.NoError(t, dropErrors(nil, context.Canceled))
195- assert.NoError(t, dropCanceled(context.Canceled))211+ assert.NoError(t, dropErrors(context.Canceled, context.Canceled))
196- assert.NoError(t, dropCanceled(errors.Join(context.Canceled, context.Canceled)))212+ assert.NoError(t, dropErrors(errors.Join(context.Canceled, context.Canceled), context.Canceled))
197- assert.Same(t, real, dropCanceled(errors.Join(context.Canceled, real)))213+ assert.Same(t, real, dropErrors(errors.Join(context.Canceled, real), context.Canceled))
198 wrapped := fmt.Errorf("wrapped: %w", context.Canceled)214 wrapped := fmt.Errorf("wrapped: %w", context.Canceled)
199- assert.Equal(t, wrapped, dropCanceled(wrapped), "wrapped cancellation is not ours")215+ assert.Equal(t, wrapped, dropErrors(wrapped, context.Canceled), "wrapped cancellation is not ours")
200- both := dropCanceled(errors.Join(real, errors.New("second")))216+ both := dropErrors(errors.Join(real, errors.New("second")), context.Canceled)
201 assert.ErrorIs(t, both, real)217 assert.ErrorIs(t, both, real)
218+ assert.NoError(t, dropErrors(errors.Join(context.Canceled, context.DeadlineExceeded), context.Canceled, context.DeadlineExceeded))
219+}
220+
221+func TestCheckPath(t *testing.T) {
222+ dir := t.TempDir()
223+ file := filepath.Join(dir, "taken.png")
224+ require.NoError(t, os.WriteFile(file, nil, 0o600))
225+ assert.Empty(t, checkPath(filepath.Join(dir, "new.png")))
226+ assert.Empty(t, checkPath(filepath.Join(dir, "new.mp4")), "extension does not dictate format")
227+ assert.Equal(t, noticeExists, checkPath(file))
228+ assert.Equal(t, noticeDir, checkPath(filepath.Join(dir, "missing", "new.png")))
229+ assert.Equal(t, noticeDir, checkPath(filepath.Join(file, "new.png")), "parent is a file")
230+ assert.Empty(t, checkPath("relative-new-name.png"))
231+
232+ link := filepath.Join(dir, "dangling.png")
233+ require.NoError(t, os.Symlink(filepath.Join(dir, "nowhere"), link))
234+ assert.Equal(t, noticeExists, checkPath(link), "dangling symlink is not overwritten")
235+
236+ // A path component that is a regular file yields ENOTDIR, not ErrNotExist.
237+ assert.Equal(t, noticeDir, checkPath(filepath.Join(file, "x", "y.png")))
238+}
239+
240+func TestRecordWithImageExtensionAccepted(t *testing.T) {
241+ m := newTestModel(t)
242+ m.mode = modeRecord
243+ m.path = filepath.Join(t.TempDir(), "clip.png")
244+ require.NoError(t, m.parse())
245+ assert.Equal(t, ports.Record, m.sel.Mode)
202 }246 }
203 247
204 func TestWriteStyleSheet(t *testing.T) {248 func TestWriteStyleSheet(t *testing.T) {
@@ -220,6 +264,11 @@ func TestStyleSheetUsesMonospace(t *testing.T) {
220 assert.Contains(t, styleSheet, "monospace")264 assert.Contains(t, styleSheet, "monospace")
221 }265 }
222 266
267+func TestStyleSheetScrollsRootAndOmitsDeadRules(t *testing.T) {
268+ assert.Contains(t, styleSheet, "overflow-y: auto")
269+ assert.NotContains(t, styleSheet, "separator")
270+}
271+
223 func TestWriteStyleSheetFailure(t *testing.T) {272 func TestWriteStyleSheetFailure(t *testing.T) {
224 t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing"))273 t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing"))
225 _, _, err := writeStyleSheet()274 _, _, err := writeStyleSheet()
M internal/adapters/gui/model.go +38 -4
@@ -2,7 +2,10 @@ package gui
22
33 import (
44 "context"
5+ "errors"
6+ "io/fs"
57 "os"
8+ "path/filepath"
69 "strconv"
710 "strings"
811 "time"
@@ -23,8 +26,16 @@ const (
2326 // support is limited to what the capture adapter offers.
2427 warning = "note: regular window, not a layer-shell overlay; it opens on the monitor " +
2528 "of the current workspace. Off-screen sources and the cursor cannot be captured."
26- recordNote = "duration 0 records until Ctrl+C in the terminal."
29+ recordNote = "duration 0 records until Ctrl+C in the terminal that started nefercap. " +
30+ "Video width and height must be even: set an even resolution if the source is odd."
31+
32+ noticeStat = "! cannot check the file path"
33+ noticeDir = "! folder does not exist"
2734 stampLayout = "20060102-150405"
35+
36+ // A finite default lets a desktop launch stop without a terminal; enter 0
37+ // to record until interrupted.
38+ defaultDuration = "10"
2839 )
2940
3041 // outputChoice is one precomputed radio entry.
@@ -49,6 +60,12 @@ type model struct {
4960 // notice is a submit-time problem shown until the path or mode changes.
5061 notice string
5162
63+ // Last duration parse, so an unchanged field is not re-parsed per frame.
64+ durSrc string
65+ durVal time.Duration
66+ durOK bool
67+ durCached bool
68+
5269 sel ports.Selection
5370 accepted bool
5471 closed bool
@@ -68,7 +85,7 @@ func newModel(outputs []ports.Output, now time.Time) (*model, error) {
6885 defaultShot: "nefercap-" + stamp + ".png",
6986 defaultRec: "nefercap-" + stamp + ".mp4",
7087 fps: "30",
71- duration: "0",
88+ duration: defaultDuration,
7289 }
7390 m.path = m.defaultShot
7491 var b strings.Builder
@@ -161,8 +178,8 @@ func (m *model) submit() {
161178 if err := m.parse(); err != nil {
162179 return
163180 }
164- if _, err := os.Lstat(m.sel.Path); err == nil {
165- m.notice = noticeExists
181+ if n := checkPath(m.sel.Path); n != "" {
182+ m.notice = n
166183 return
167184 }
168185 m.notice = ""
@@ -170,6 +187,23 @@ func (m *model) submit() {
170187 m.stop()
171188 }
172189
190+// checkPath returns a static notice when path cannot be created, or "". The
191+// folder must exist; write permission is left to the capture adapter.
192+func checkPath(path string) string {
193+ if info, err := os.Stat(filepath.Dir(path)); err != nil || !info.IsDir() {
194+ return noticeDir
195+ }
196+ _, err := os.Lstat(path)
197+ switch {
198+ case err == nil:
199+ return noticeExists
200+ case errors.Is(err, fs.ErrNotExist):
201+ return ""
202+ default:
203+ return noticeStat
204+ }
205+}
206+
173207 // dismiss closes the window without a selection.
174208 func (m *model) dismiss() {
175209 if m.accepted || m.closed {
@@ -2,7 +2,10 @@ package gui
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
6+ "io/fs"
5 "os"7 "os"
8+ "path/filepath"
6 "strconv"9 "strconv"
7 "strings"10 "strings"
8 "time"11 "time"
@@ -23,8 +26,16 @@ const (
23 // support is limited to what the capture adapter offers.26 // support is limited to what the capture adapter offers.
24 warning = "note: regular window, not a layer-shell overlay; it opens on the monitor " +27 warning = "note: regular window, not a layer-shell overlay; it opens on the monitor " +
25 "of the current workspace. Off-screen sources and the cursor cannot be captured."28 "of the current workspace. Off-screen sources and the cursor cannot be captured."
26- recordNote = "duration 0 records until Ctrl+C in the terminal."29+ recordNote = "duration 0 records until Ctrl+C in the terminal that started nefercap. " +
30+ "Video width and height must be even: set an even resolution if the source is odd."
31+
32+ noticeStat = "! cannot check the file path"
33+ noticeDir = "! folder does not exist"
27 stampLayout = "20060102-150405"34 stampLayout = "20060102-150405"
35+
36+ // A finite default lets a desktop launch stop without a terminal; enter 0
37+ // to record until interrupted.
38+ defaultDuration = "10"
28 )39 )
29 40
30 // outputChoice is one precomputed radio entry.41 // outputChoice is one precomputed radio entry.
@@ -49,6 +60,12 @@ type model struct {
49 // notice is a submit-time problem shown until the path or mode changes.60 // notice is a submit-time problem shown until the path or mode changes.
50 notice string61 notice string
51 62
63+ // Last duration parse, so an unchanged field is not re-parsed per frame.
64+ durSrc string
65+ durVal time.Duration
66+ durOK bool
67+ durCached bool
68+
52 sel ports.Selection69 sel ports.Selection
53 accepted bool70 accepted bool
54 closed bool71 closed bool
@@ -68,7 +85,7 @@ func newModel(outputs []ports.Output, now time.Time) (*model, error) {
68 defaultShot: "nefercap-" + stamp + ".png",85 defaultShot: "nefercap-" + stamp + ".png",
69 defaultRec: "nefercap-" + stamp + ".mp4",86 defaultRec: "nefercap-" + stamp + ".mp4",
70 fps: "30",87 fps: "30",
71- duration: "0",88+ duration: defaultDuration,
72 }89 }
73 m.path = m.defaultShot90 m.path = m.defaultShot
74 var b strings.Builder91 var b strings.Builder
@@ -161,8 +178,8 @@ func (m *model) submit() {
161 if err := m.parse(); err != nil {178 if err := m.parse(); err != nil {
162 return179 return
163 }180 }
164- if _, err := os.Lstat(m.sel.Path); err == nil {181+ if n := checkPath(m.sel.Path); n != "" {
165- m.notice = noticeExists182+ m.notice = n
166 return183 return
167 }184 }
168 m.notice = ""185 m.notice = ""
@@ -170,6 +187,23 @@ func (m *model) submit() {
170 m.stop()187 m.stop()
171 }188 }
172 189
190+// checkPath returns a static notice when path cannot be created, or "". The
191+// folder must exist; write permission is left to the capture adapter.
192+func checkPath(path string) string {
193+ if info, err := os.Stat(filepath.Dir(path)); err != nil || !info.IsDir() {
194+ return noticeDir
195+ }
196+ _, err := os.Lstat(path)
197+ switch {
198+ case err == nil:
199+ return noticeExists
200+ case errors.Is(err, fs.ErrNotExist):
201+ return ""
202+ default:
203+ return noticeStat
204+ }
205+}
206+
173 // dismiss closes the window without a selection.207 // dismiss closes the window without a selection.
174 func (m *model) dismiss() {208 func (m *model) dismiss() {
175 if m.accepted || m.closed {209 if m.accepted || m.closed {
M internal/adapters/gui/style.css +4 -1
@@ -5,7 +5,11 @@ app {
55 background: #0d1014; color: #c9d1d9;
66 font-family: monospace; font-size: 14px; line-height: 1.4;
77 accent-color: #4fd1a5;
8+ /* Short windows scroll with the wheel; NeferGUI 0.1.0 does not scroll focus into view. */
9+ overflow-y: auto;
810 }
11+/* Children keep their natural height so overflow scrolls instead of squashing. */
12+heading, text, radio, row, column, button { flex-shrink: 0; }
913 heading { margin: 0 0 4px 0; font-size: 16px; font-weight: 700; color: #4fd1a5; }
1014 text.section { margin-top: 8px; color: #8b949e; }
1115 text.note { color: #8b949e; font-size: 12px; }
@@ -18,7 +22,6 @@ column.group {
1822 }
1923 row.field { display: flex; flex-direction: row; align-items: center; column-gap: 8px; }
2024 row.actions { display: flex; flex-direction: row; column-gap: 8px; margin-top: 8px; }
21-separator { background: #2a313b; }
2225 radio { padding-top: 2px; padding-bottom: 2px; color: #c9d1d9; }
2326 radio:hover { color: #e6edf3; }
2427 radio:focus-visible, button:focus-visible { outline: 1px solid #4fd1a5; outline-offset: 1px; }
@@ -5,7 +5,11 @@ app {
5 background: #0d1014; color: #c9d1d9;5 background: #0d1014; color: #c9d1d9;
6 font-family: monospace; font-size: 14px; line-height: 1.4;6 font-family: monospace; font-size: 14px; line-height: 1.4;
7 accent-color: #4fd1a5;7 accent-color: #4fd1a5;
8+ /* Short windows scroll with the wheel; NeferGUI 0.1.0 does not scroll focus into view. */
9+ overflow-y: auto;
8 }10 }
11+/* Children keep their natural height so overflow scrolls instead of squashing. */
12+heading, text, radio, row, column, button { flex-shrink: 0; }
9 heading { margin: 0 0 4px 0; font-size: 16px; font-weight: 700; color: #4fd1a5; }13 heading { margin: 0 0 4px 0; font-size: 16px; font-weight: 700; color: #4fd1a5; }
10 text.section { margin-top: 8px; color: #8b949e; }14 text.section { margin-top: 8px; color: #8b949e; }
11 text.note { color: #8b949e; font-size: 12px; }15 text.note { color: #8b949e; font-size: 12px; }
@@ -18,7 +22,6 @@ column.group {
18 }22 }
19 row.field { display: flex; flex-direction: row; align-items: center; column-gap: 8px; }23 row.field { display: flex; flex-direction: row; align-items: center; column-gap: 8px; }
20 row.actions { display: flex; flex-direction: row; column-gap: 8px; margin-top: 8px; }24 row.actions { display: flex; flex-direction: row; column-gap: 8px; margin-top: 8px; }
21-separator { background: #2a313b; }
22 radio { padding-top: 2px; padding-bottom: 2px; color: #c9d1d9; }25 radio { padding-top: 2px; padding-bottom: 2px; color: #c9d1d9; }
23 radio:hover { color: #e6edf3; }26 radio:hover { color: #e6edf3; }
24 radio:focus-visible, button:focus-visible { outline: 1px solid #4fd1a5; outline-offset: 1px; }27 radio:focus-visible, button:focus-visible { outline: 1px solid #4fd1a5; outline-offset: 1px; }
M internal/adapters/gui/view.go +21 -20
@@ -26,7 +26,7 @@ var (
2626
2727 fileField = newField("file", "file", "path of the new capture file")
2828 regionField = newField("region", "region", "X,Y,WxH (empty = full output)")
29- fpsField = newField("fps", "fps", "1-240")
29+ fpsField = newField("fps", "fps", fpsPlaceholder)
3030 sizeField = newField("size", "resolution", "WxH (empty = source size)")
3131 durationField = newField("duration", "duration", "seconds or 1m30s; 0 = until Ctrl+C")
3232 )
@@ -72,8 +72,28 @@ func (f *field) show(parent nefergui.Node, value *string) (changed, submitted bo
7272 // alone; events mutate it while the frame is built.
7373 func view(f *nefergui.Frame, m *model) {
7474 root := f.Root(rootOpts...)
75+ submit := false
7576 root.Heading("NeferCap", titleOpts...)
7677
78+ text, submittable, ok := m.status()
79+ if ok {
80+ root.Text(text, statusGood...)
81+ } else {
82+ root.Text(text, statusBad...)
83+ }
84+
85+ actions := root.Row(actionsOpts...)
86+ capture := captureOff
87+ if submittable {
88+ capture = captureOn
89+ }
90+ if actions.Button("Capture", capture...).Activated() && submittable {
91+ submit = true
92+ }
93+ if actions.Button("Close", closeOpts...).Activated() {
94+ m.dismiss()
95+ }
96+
7797 root.Text("output", outputTitle...)
7898 outs := root.Column(outputsGroup...)
7999 for i := range m.outputs {
@@ -87,7 +107,6 @@ func view(f *nefergui.Frame, m *model) {
87107 modes.Radio("record", modeRecord, &m.mode, recOpts...)
88108 m.modeChanged()
89109
90- submit := false
91110 root.Text("target", targetTitle...)
92111 target := root.Column(targetGroup...)
93112 changed, entered := fileField.show(target, &m.path)
@@ -112,24 +131,6 @@ func view(f *nefergui.Frame, m *model) {
112131
113132 root.Text(warning, warningOpts...)
114133
115- text, submittable, ok := m.status()
116- if ok {
117- root.Text(text, statusGood...)
118- } else {
119- root.Text(text, statusBad...)
120- }
121-
122- actions := root.Row(actionsOpts...)
123- capture := captureOff
124- if submittable {
125- capture = captureOn
126- }
127- if actions.Button("Capture", capture...).Activated() && submittable {
128- submit = true
129- }
130- if actions.Button("Close", closeOpts...).Activated() {
131- m.dismiss()
132- }
133134 if submit {
134135 m.submit()
135136 }
@@ -26,7 +26,7 @@ var (
26 26
27 fileField = newField("file", "file", "path of the new capture file")27 fileField = newField("file", "file", "path of the new capture file")
28 regionField = newField("region", "region", "X,Y,WxH (empty = full output)")28 regionField = newField("region", "region", "X,Y,WxH (empty = full output)")
29- fpsField = newField("fps", "fps", "1-240")29+ fpsField = newField("fps", "fps", fpsPlaceholder)
30 sizeField = newField("size", "resolution", "WxH (empty = source size)")30 sizeField = newField("size", "resolution", "WxH (empty = source size)")
31 durationField = newField("duration", "duration", "seconds or 1m30s; 0 = until Ctrl+C")31 durationField = newField("duration", "duration", "seconds or 1m30s; 0 = until Ctrl+C")
32 )32 )
@@ -72,8 +72,28 @@ func (f *field) show(parent nefergui.Node, value *string) (changed, submitted bo
72 // alone; events mutate it while the frame is built.72 // alone; events mutate it while the frame is built.
73 func view(f *nefergui.Frame, m *model) {73 func view(f *nefergui.Frame, m *model) {
74 root := f.Root(rootOpts...)74 root := f.Root(rootOpts...)
75+ submit := false
75 root.Heading("NeferCap", titleOpts...)76 root.Heading("NeferCap", titleOpts...)
76 77
78+ text, submittable, ok := m.status()
79+ if ok {
80+ root.Text(text, statusGood...)
81+ } else {
82+ root.Text(text, statusBad...)
83+ }
84+
85+ actions := root.Row(actionsOpts...)
86+ capture := captureOff
87+ if submittable {
88+ capture = captureOn
89+ }
90+ if actions.Button("Capture", capture...).Activated() && submittable {
91+ submit = true
92+ }
93+ if actions.Button("Close", closeOpts...).Activated() {
94+ m.dismiss()
95+ }
96+
77 root.Text("output", outputTitle...)97 root.Text("output", outputTitle...)
78 outs := root.Column(outputsGroup...)98 outs := root.Column(outputsGroup...)
79 for i := range m.outputs {99 for i := range m.outputs {
@@ -87,7 +107,6 @@ func view(f *nefergui.Frame, m *model) {
87 modes.Radio("record", modeRecord, &m.mode, recOpts...)107 modes.Radio("record", modeRecord, &m.mode, recOpts...)
88 m.modeChanged()108 m.modeChanged()
89 109
90- submit := false
91 root.Text("target", targetTitle...)110 root.Text("target", targetTitle...)
92 target := root.Column(targetGroup...)111 target := root.Column(targetGroup...)
93 changed, entered := fileField.show(target, &m.path)112 changed, entered := fileField.show(target, &m.path)
@@ -112,24 +131,6 @@ func view(f *nefergui.Frame, m *model) {
112 131
113 root.Text(warning, warningOpts...)132 root.Text(warning, warningOpts...)
114 133
115- text, submittable, ok := m.status()
116- if ok {
117- root.Text(text, statusGood...)
118- } else {
119- root.Text(text, statusBad...)
120- }
121-
122- actions := root.Row(actionsOpts...)
123- capture := captureOff
124- if submittable {
125- capture = captureOn
126- }
127- if actions.Button("Capture", capture...).Activated() && submittable {
128- submit = true
129- }
130- if actions.Button("Close", closeOpts...).Activated() {
131- m.dismiss()
132- }
133 if submit {134 if submit {
134 m.submit()135 m.submit()
135 }136 }

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